Files
document-haness/scripts/tests/test_studio_save.py
T
DongHyeonkaandClaude Opus 5 ab59130196 chore: 이전 세션이 남긴 변경을 커밋한다
이번 파이프라인 작업과 무관하게 작업 트리에 남아 있던 것을 그대로 올린다.
사용자가 「전부 커밋」으로 정했고, 이번 작업과 섞이지 않게 커밋만 나눴다.

대부분은 clean-architecture-backend-template 의 그림 정본 재배치다 —
final/assets/diagrams/<이름>/ 에 있던 것이 CLAUDE.md 가 적은 배치인
final/assets/<이름>/ 로 옮겨졌고 .techviz/<이름>/ 이 함께 들어왔다.
삽입 줄의 대부분(3.15M)이 그 .techviz context.json 이다.

그 밖에 ca-tmpl·document-haness 의 정리, .claude/agents/ 열한 개,
writing-practitioner-guides 스킬, .playwright-mcp 세션 산출물,
scripts/check-ssot-facts.py 와 그 시험이 들어 있다.

이 커밋의 내용은 내가 만든 것이 아니라 이전 세션이 남긴 것이고 검증하지 않았다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 11:02:02 +09:00

1073 lines
58 KiB
Python

"""Studio 저장 어댑터.
이 묶음의 절반은 **대조군**이다. 손대지 않은 쌍이 「같다」로 나오는 것을 먼저 확인하지 않으면,
결함을 잡았다는 결과가 비교 틀이 만들어 낸 것인지 알 수 없다.
"""
import importlib.util
import json
import os
import subprocess
import sys
import tempfile
import unittest
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# 런 식별자 회귀가 실제로 계획을 내 보려고 쓰는 입력. 관문이 전부 exit 0 인 프로젝트다
DECISION_RECORD = os.path.join(
"docs", "n+1liner", "tech-log-studio", "jpa-feed-query-performance", "decision",
"decision-keyset-for-feed-pagination.md")
PACKAGE = "/home/donghyeon/workspace/chat-gpt-container/success/reports/"\
"B-B011-plans/package.json"
PROJECT_ID = "8ff68a93-0cf6-4d91-a973-40e9f3d3e99d"
_spec = importlib.util.spec_from_file_location(
"studio_save", os.path.join(ROOT, "scripts", "studio-save.py"))
ss = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(ss)
SENT = {
"kind": "CASE",
"title": "파이프 뒤의 종료 코드",
"slug": "exit-code-read-behind-a-pipe",
"summary": "열넷 중 둘이 exit 1 이었다.",
"problem": "이 기록의 증거 다섯 개도 그렇게 수집했다.",
"bodyMarkdown": "본문이 길다. " * 40,
"topicId": None,
"projectId": None,
"relations": [],
}
class ComparatorControlTest(unittest.TestCase):
"""비교 틀이 한쪽으로 굳어 있지 않은지."""
def test_an_untouched_pair_compares_equal(self):
"""대조군. 이것이 실패하면 아래의 모든 「잡았다」가 틀 탓이다."""
r = ss.compare_saved(SENT, dict(SENT))
self.assertTrue(r["same"], r)
self.assertEqual([], r["differences"])
self.assertEqual([], r["whitespaceOnly"])
def test_a_korean_numeral_change_is_caught(self):
"""「여섯 → 다섯」. check-preservation 은 아라비아 숫자만 봐서 이걸 못 잡는다.
저장 대조는 칸 전체를 견주므로 잡힌다. 두 검사기가 보는 것이 다르다.
"""
r = ss.compare_saved(SENT, {**SENT, "problem": "이 기록의 증거 여섯 개도 그렇게 수집했다."})
self.assertFalse(r["same"])
self.assertEqual(["problem"], [d["field"] for d in r["differences"]])
def test_a_silently_truncated_value_is_named_as_truncation(self):
"""Studio 는 제목·본문을 조용히 자른 적이 있다. 바뀐 것과 잘린 것은 다르게 읽힌다."""
short = SENT["bodyMarkdown"][:60]
r = ss.compare_saved(SENT, {**SENT, "bodyMarkdown": short})
self.assertFalse(r["same"])
self.assertEqual(1, len(r["truncated"]))
self.assertGreater(r["truncated"][0]["sentChars"], r["truncated"][0]["gotChars"])
def test_a_whitespace_only_difference_is_not_counted_as_same(self):
"""정규화가 지운 차이를 통과로 세지 않는다."""
r = ss.compare_saved(SENT, {**SENT, "title": SENT["title"] + " "})
self.assertFalse(r["same"])
self.assertEqual(["title"], [w["field"] for w in r["whitespaceOnly"]])
self.assertEqual([], r["differences"])
def test_fields_it_cannot_compare_are_named_not_skipped(self):
"""못 본 칸을 조용히 건너뛰면 「전부 같다」가 「본 것만 같다」를 가린다."""
r = ss.compare_saved(SENT, dict(SENT))
self.assertEqual({"topicId", "projectId", "relations"}, set(r["notCompared"]))
self.assertNotIn("topicId", r["comparedFields"])
def test_a_field_the_server_added_is_reported(self):
r = ss.compare_saved(SENT, {**SENT, "publishedAt": "2026-09-10"})
self.assertIn("publishedAt", r["unexpected"])
SVG_HEAD = '<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"'
def _svg(tmp, body):
f = os.path.join(tmp, "f.svg")
open(f, "w", encoding="utf-8").write(body)
return f
class SvgPolicyTest(unittest.TestCase):
"""`plan/01` §8 의 「저장 단계의 허용 정책」.
화살표 방향은 사람이 봐야 하지만 스크립트가 들어 있는지는 문자열로 판정된다.
**그림 쪽에서 기계가 할 수 있는 유일한 일이라 error 로 막는다.**
"""
def setUp(self):
self.tmp = tempfile.mkdtemp()
def test_an_ordinary_figure_passes(self):
"""대조군. 이것이 막히면 아래의 모든 「막았다」가 정상을 막는 것이다."""
hits, kind = ss.inspect_svg(_svg(self.tmp, SVG_HEAD + '><rect/><text>Guest</text></svg>'))
self.assertEqual([], hits)
self.assertIsNone(kind)
def test_a_figure_declared_illustrative_passes(self):
"""설명용이라 밝힌 그림을 막지 않는다.
막으면 V-004 에서 채택된 편집 9건을 막은 것과 같은 실패다. 밝힌 대로 읽히는지는
검토가 본다.
"""
hits, kind = ss.inspect_svg(
_svg(self.tmp, SVG_HEAD + ' data-figure-kind="illustrative"><rect/></svg>'))
self.assertEqual([], hits)
self.assertEqual("illustrative", kind)
def test_a_figure_declared_evidence_is_read_as_evidence(self):
_, kind = ss.inspect_svg(
_svg(self.tmp, SVG_HEAD + ' data-figure-kind="evidence"><rect/></svg>'))
self.assertEqual("evidence", kind)
def test_dangerous_shapes_are_blocked(self):
cases = {
"script": "><script>alert(1)</script></svg>",
"foreignObject": "><foreignObject><b>x</b></foreignObject></svg>",
"event handler": ' onload="x()"><rect/></svg>',
"external href": '><a href="https://evil.test/x">t</a></svg>',
"protocol relative": '><a xlink:href="//evil.test/x">t</a></svg>',
"external image": '><image href="https://evil.test/a.png"/></svg>',
"css import": "><style>@import url(x.css);</style></svg>",
}
for name, tail in cases.items():
with self.subTest(name=name):
hits, _ = ss.inspect_svg(_svg(self.tmp, SVG_HEAD + tail))
self.assertTrue(hits, f"{name} 이 통과했다")
def test_internal_and_data_references_are_not_blocked(self):
"""`#fragment` 와 `data:` 는 바깥으로 안 나간다. 막으면 정상을 막는 것이다."""
for tail in ('><use href="#box"/></svg>',
'><image href="data:image/png;base64,AA"/></svg>'):
with self.subTest(tail=tail):
hits, _ = ss.inspect_svg(_svg(self.tmp, SVG_HEAD + tail))
self.assertEqual([], hits)
def test_every_figure_in_this_repository_passes(self):
"""지금 저장소의 그림 전부가 통과해야 한다. 하나라도 막히면 정책이 과하다."""
import glob
files = glob.glob(os.path.join(ROOT, "docs/*/final/assets/**/*.svg"), recursive=True)
self.assertGreater(len(files), 100, "그림을 못 찾았다 — 이 대조가 무의미해진다")
blocked = [f for f in files if ss.inspect_svg(f)[0]]
self.assertEqual([], blocked)
class AssetWarningTest(unittest.TestCase):
"""바뀐 것이 무엇인지는 기계가 못 말한다. 바뀌었으니 보라는 말할 수 있다."""
def setUp(self):
self.tmp = tempfile.mkdtemp()
self.svg = _svg(self.tmp, SVG_HEAD + ' data-figure-kind="evidence"><rect/></svg>')
self.rel = os.path.relpath(self.svg, ROOT)
def _pkg(self, sha):
return {"assets": [{"path": self.rel, "exists": True, "sha256": sha}]}
def test_an_unchanged_figure_raises_nothing(self):
"""대조군."""
e, w = ss.inspect_assets(self._pkg(ss._sha256_file(self.svg)))
self.assertEqual([], e)
self.assertEqual([], w)
def test_a_figure_changed_after_review_is_a_warning_not_an_error(self):
"""화살표를 뒤집어도 관문은 전부 0 이다. 그것이 보이지 않던 자리다."""
e, w = ss.inspect_assets(self._pkg("0" * 64))
self.assertEqual([], e)
self.assertEqual(["그림이 검토 뒤에 바뀌었다"], [x["id"] for x in w])
def test_a_figure_without_a_kind_marker_is_a_warning(self):
plain = _svg(self.tmp, SVG_HEAD + "><rect/></svg>")
rel = os.path.relpath(plain, ROOT)
e, w = ss.inspect_assets(
{"assets": [{"path": rel, "exists": True, "sha256": ss._sha256_file(plain)}]})
self.assertEqual([], e)
self.assertIn("그림 종류가 표시돼 있지 않다", [x["id"] for x in w])
def test_a_dangerous_figure_is_an_error(self):
bad = _svg(self.tmp, SVG_HEAD + "><script>x</script></svg>")
rel = os.path.relpath(bad, ROOT)
e, _ = ss.inspect_assets(
{"assets": [{"path": rel, "exists": True, "sha256": ss._sha256_file(bad)}]})
self.assertEqual(["그림 허용 정책"], [x["id"] for x in e])
class IdempotencyKeyTest(unittest.TestCase):
def test_create_key_is_stable_across_retries(self):
"""재시도가 문서를 둘 만들지 않게 하는 것이 이 키의 목적이다."""
a = ss.idempotency_key("create", "docs/p/tech-log-studio/t/case/x.md")
b = ss.idempotency_key("create", "docs/p/tech-log-studio/t/case/x.md")
self.assertEqual(a, b)
def test_create_keys_differ_per_record(self):
self.assertNotEqual(
ss.idempotency_key("create", "docs/p/t/case/x.md"),
ss.idempotency_key("create", "docs/p/t/case/y.md"))
def test_save_key_follows_the_payload(self):
"""같은 내용의 재시도는 재생되고, 내용이 바뀌면 새 저장이 된다.
경로만으로 키를 만들면 두 번째 저장이 첫 결과로 조용히 재생돼 버린다.
"""
rel = "docs/p/t/case/x.md"
one = {"expectedVersion": 3, "document": {"title": "가"}}
two = {"expectedVersion": 3, "document": {"title": "나"}}
self.assertEqual(ss.idempotency_key("save", rel, one),
ss.idempotency_key("save", rel, one))
self.assertNotEqual(ss.idempotency_key("save", rel, one),
ss.idempotency_key("save", rel, two))
def test_key_stays_within_the_server_limit(self):
rel = "docs/" + "a" * 300 + "/x.md"
key = ss.idempotency_key("save", rel, {"expectedVersion": 1, "document": SENT})
self.assertLessEqual(len(key), ss.MAX_KEY_LENGTH)
class SafetyTest(unittest.TestCase):
def test_no_plan_may_contain_a_publish_path(self):
"""게시는 되돌릴 수 없다. 프롬프트가 아니라 코드가 막는다."""
with self.assertRaises(ss.Refused):
ss._reject_forbidden_paths(
[{"path": "/api/v1/studio/documents/abc/publish"}])
with self.assertRaises(ss.Refused):
ss._reject_forbidden_paths(
[{"path": "/api/v1/studio/publications/abc/unpublish"}])
def test_the_generated_plan_has_no_publish_path(self):
steps = ss.plan_requests("docs/p/t/case/x.md", dict(SENT), None, None)
for s in steps:
self.assertNotIn("publish", s["path"])
def test_saving_without_expected_version_is_refused(self):
"""안 보내면 서버가 0 으로 채워 사실상 항상 충돌한다. 빈 값은 「검사 안 함」이 아니다."""
with self.assertRaises(ss.Refused):
ss.plan_requests("docs/p/t/case/x.md", dict(SENT), "doc-1", None)
def test_expected_version_below_one_is_refused(self):
with self.assertRaises(ss.Refused):
ss.plan_requests("docs/p/t/case/x.md", dict(SENT), "doc-1", 0)
def test_unattended_save_is_off(self):
self.assertFalse(ss.UNATTENDED_SAVE_ENABLED)
self.assertIn("CR-001", ss.UNATTENDED_SAVE_BLOCKED_BY)
class PrivacyTest(unittest.TestCase):
def test_a_document_with_no_publication_is_private(self):
ok, why = ss.is_private({"currentPublication": None})
self.assertTrue(ok, why)
def test_a_published_document_is_not_private(self):
ok, why = ss.is_private(
{"currentPublication": {"status": "PUBLISHED", "publicPath": "/x"}})
self.assertFalse(ok)
self.assertIn("/x", why)
def test_an_unpublished_document_is_private_again(self):
ok, _ = ss.is_private({"currentPublication": {"status": "UNPUBLISHED"}})
self.assertTrue(ok)
class ApprovalTest(unittest.TestCase):
def setUp(self):
self.dir = tempfile.mkdtemp()
self.record = os.path.join(self.dir, "r.md")
open(self.record, "w", encoding="utf-8").write("---\nkind: CASE\n---\n\n# 제목\n\n요약.\n")
self.pkg = os.path.join(self.dir, "p.json")
def _write_pkg(self, sha, gates):
json.dump({"target": {"sha256": sha}, "gates": gates},
open(self.pkg, "w", encoding="utf-8"))
def test_a_package_that_matches_passes(self):
self._write_pkg(ss._sha256_file(self.record), [{"cmd": "x", "exit": 0}])
self.assertIn("target", ss.approved(self.record, self.pkg))
def test_a_record_changed_after_review_is_refused(self):
"""검토가 본 파일과 지금 파일이 다르면 그 판정을 이 파일에 붙일 수 없다."""
self._write_pkg(ss._sha256_file(self.record), [{"cmd": "x", "exit": 0}])
open(self.record, "a", encoding="utf-8").write("한 줄 더.\n")
with self.assertRaises(ss.Refused):
ss.approved(self.record, self.pkg)
def test_a_package_with_a_failed_gate_is_refused(self):
self._write_pkg(ss._sha256_file(self.record), [{"cmd": "check_body", "exit": 1}])
with self.assertRaises(ss.Refused):
ss.approved(self.record, self.pkg)
class CliTest(unittest.TestCase):
def _cli(self, *args):
return subprocess.run(
["python3", os.path.join(ROOT, "scripts", "studio-save.py"), *args],
cwd=ROOT, capture_output=True, text=True)
def test_send_is_refused_while_unattended_save_is_off(self):
p = self._cli("--record", "nope.md", "--package", "nope.json", "--send")
self.assertEqual(3, p.returncode)
self.assertIn("거절", p.stderr)
if __name__ == "__main__":
unittest.main()
class ReviewGateTest(unittest.TestCase):
"""검토로 보낸다는 것은 저장이 막힌다는 뜻이어야 한다.
막지 않으면 라우팅이 아니라 주석이다. `quality-policy@1` §6 — 「UNKNOWN 은 통과가 아니다」.
이 게이트가 없던 동안 검토로 보낸 다섯 건이 그대로 저장됐다.
"""
WARN = [{"id": "유보 감소", "detail": "가능성 2회 → 1회", "key": "aaaa1111"},
{"id": "그림이 검토 뒤에 바뀌었다", "detail": "fd5f → 3ba4", "key": "bbbb2222"}]
def setUp(self):
self.dir = tempfile.mkdtemp()
self.pkg = os.path.join(self.dir, "pkg.json")
json.dump({"target": {"sha256": "x"}}, open(self.pkg, "w", encoding="utf-8"))
self.book = os.path.join(self.dir, "v.json")
def _write(self, verdicts, sha=None, keys=None):
json.dump({"schemaVersion": 1, "reviewer": "사람",
"packageSha256": sha if sha is not None else ss._sha256_file(self.pkg),
"verdicts": [{"key": k, "verdict": v, "why": "왜"}
for k, v in zip(keys or [w["key"] for w in self.WARN],
verdicts)]},
open(self.book, "w", encoding="utf-8"), ensure_ascii=False)
def test_no_warnings_needs_no_verdict_file(self):
"""대조군. 정상은 이 게이트에 안 걸린다 — 불필요한 차단을 만들지 않는다."""
self.assertEqual([], ss._review_gate([], None, self.pkg))
def test_warnings_without_a_verdict_file_are_refused(self):
"""검토를 안 받은 것과 검토가 통과시킨 것은 같은 결과일 수 없다."""
with self.assertRaises(ss.Refused) as e:
ss._review_gate(self.WARN, None, self.pkg)
self.assertIn("검토를 안 받은 것", str(e.exception))
def test_all_pass_lets_the_save_through(self):
self._write(["PASS", "PASS"])
cleared = ss._review_gate(self.WARN, self.book, self.pkg)
self.assertEqual(2, len(cleared))
self.assertEqual({"PASS"}, {c["verdict"] for c in cleared})
def test_fail_and_unknown_both_block_with_different_words(self):
"""둘 다 막되 문구가 갈린다 — 근거가 모자란 것과 틀렸다고 본 것은 다르다."""
self._write(["PASS", "FAIL"])
with self.assertRaises(ss.Refused) as fail:
ss._review_gate(self.WARN, self.book, self.pkg)
self._write(["PASS", "UNKNOWN"])
with self.assertRaises(ss.Refused) as unknown:
ss._review_gate(self.WARN, self.book, self.pkg)
self.assertIn("틀렸다고 봤다", str(fail.exception))
self.assertIn("근거가 모자라", str(unknown.exception))
self.assertNotEqual(str(fail.exception), str(unknown.exception))
def test_a_warning_with_no_verdict_is_refused(self):
"""빠뜨린 것과 통과시킨 것은 다르다."""
self._write(["PASS"], keys=[self.WARN[0]["key"]])
with self.assertRaises(ss.Refused) as e:
ss._review_gate(self.WARN, self.book, self.pkg)
self.assertIn("판정이 안 붙은", str(e.exception))
def test_a_verdict_for_another_package_is_refused(self):
"""묶음이 바뀌면 그 판정을 이 묶음에 쓸 수 없다."""
self._write(["PASS", "PASS"], sha="0" * 64)
with self.assertRaises(ss.Refused) as e:
ss._review_gate(self.WARN, self.book, self.pkg)
self.assertIn("다른 묶음", str(e.exception))
def test_a_missing_verdict_file_is_refused(self):
with self.assertRaises(ss.Refused):
ss._review_gate(self.WARN, os.path.join(self.dir, "nope.json"), self.pkg)
def test_an_unknown_verdict_word_is_refused(self):
self._write(["PASS", "MAYBE"])
with self.assertRaises(ss.Refused):
ss._review_gate(self.WARN, self.book, self.pkg)
def test_a_repository_wide_gap_does_not_block(self):
"""모든 기록에 걸리는 경고는 어느 기록에 대해서도 아무 말을 하지 않는다.
저장소의 그림 어느 것에도 종류 표시가 없어서 그림 붙은 기록이면 무조건 걸린다.
그것으로 막으면 게이트의 첫 실사용에서 그림 붙은 기록이 전부 막히고, 그러면 사람이
게이트를 우회하기 시작한다. **막을 수 없는 게이트보다 나쁜 건 우회당하는 게이트다.**
"""
only_repo = [{"id": "그림 종류가 표시돼 있지 않다", "scope": "repository",
"detail": "…", "key": "cccc3333"}]
self.assertEqual([], ss._review_gate(only_repo, None, self.pkg))
def test_a_finding_about_this_record_still_blocks(self):
"""저장소 미비가 섞여 있어도 이 기록에 대한 발견 하나면 막힌다."""
mixed = [{"id": "그림 종류가 표시돼 있지 않다", "scope": "repository",
"detail": "…", "key": "cccc3333"},
{"id": "그림이 검토 뒤에 바뀌었다", "detail": "fd5f → 3ba4", "key": "dddd4444"}]
with self.assertRaises(ss.Refused) as e:
ss._review_gate(mixed, None, self.pkg)
self.assertIn("dddd4444", str(e.exception))
self.assertNotIn("cccc3333", str(e.exception),
"세는 경고를 막는 목록에 넣지 않는다")
def test_a_verdict_is_not_needed_for_a_counted_warning(self):
"""세는 경고에는 판정을 요구하지 않는다 — 요구하면 그게 막는 것이다."""
mixed = [{"id": "그림 종류가 표시돼 있지 않다", "scope": "repository",
"detail": "…", "key": "cccc3333"},
{"id": "유보 감소", "detail": "가능성 2회 → 1회", "key": "aaaa1111"}]
self._write(["PASS"], keys=["aaaa1111"])
cleared = ss._review_gate(mixed, self.book, self.pkg)
self.assertEqual(["aaaa1111"], [c["key"] for c in cleared])
def test_the_repository_gap_is_counted_not_hidden(self):
"""조용히 빼면 그게 또 「본 것만 같다」다."""
total, unmarked = ss._figure_kind_coverage()
self.assertGreater(total, 100, "그림을 못 찾았다 — 이 셈이 무의미해진다")
self.assertGreaterEqual(total, unmarked)
def test_the_key_changes_when_the_warning_changes(self):
"""판정이 다른 경고에 옛 판정으로 붙지 않아야 한다."""
import hashlib
def key(entry):
seed = "|".join(str(entry.get(k, "")) for k in ("id", "detail", "asset"))
return hashlib.sha256(seed.encode("utf-8")).hexdigest()[:12]
a = {"id": "유보 감소", "detail": "가능성 2회 → 1회"}
b = {"id": "유보 감소", "detail": "가능성 3회 → 1회"}
self.assertNotEqual(key(a), key(b))
class PublishedRecordGuardTest(unittest.TestCase):
"""게시된 기록은 저장 대상에서 **코드가** 뺀다.
한 번이라도 게시한 문서는 게시를 취소해도 삭제가 409 로 거절된다. 되돌릴 수 없는
자리라 「건드리지 않겠다」는 문장이 아니라 코드가 막는다 — 설계가 프롬프트 통제를
인정하지 않는 이유가 그대로 적용된다.
"""
def setUp(self):
self.tmp = tempfile.mkdtemp()
def _record(self, extra=""):
p = os.path.join(self.tmp, "r.md")
open(p, "w", encoding="utf-8").write(
f"---\nkind: CASE\nslug: x\ntitle: x\n{extra}---\n\n# x\n\n요약.\n")
return p
def test_a_record_with_a_public_url_is_refused(self):
marks = ss.published_marks(self._record('public: "https://example.test/x"\n'))
self.assertTrue(marks)
self.assertIn("public:", marks[0])
def test_a_record_marked_live_is_refused(self):
marks = ss.published_marks(self._record("status: 게시 중\n"))
self.assertTrue(marks)
def test_an_unpublished_record_is_a_save_target(self):
"""대조군. 게시 전 기록은 그대로 저장 대상이다."""
self.assertEqual([], ss.published_marks(self._record("status: 게시 전\n")))
self.assertEqual([], ss.published_marks(self._record('public: ""\n')))
def test_every_published_record_in_this_repository_is_seen(self):
"""저장소의 게시된 기록 전부가 이 가드에 걸려야 한다.
`public:` 이 찬 것과 `status: 게시 중` 인 것이 저장소에서 같은 집합이지만,
한쪽만 차 있어도 게시로 본다 — 한쪽이 뒤늦게 채워지는 경우를 놓치지 않는다.
"""
import glob
seen = 0
for f in glob.glob(os.path.join(ROOT, "docs/*/tech-log-studio/*/*/*.md")):
text = open(f, encoding="utf-8").read()
if "status: 게시 중" in text or 'public: "http' in text:
seen += 1
self.assertTrue(ss.published_marks(f), f)
self.assertGreater(seen, 0, "게시된 기록을 못 찾았다 — 이 대조가 무의미해진다")
def test_the_plan_asks_the_server_before_saving(self):
"""저장소의 frontmatter 는 저장소가 아는 것이지 서버가 아는 것이 아니다."""
steps = ss.plan_requests("docs/p/t/case/x.md", dict(SENT), "doc-1", 3)
self.assertEqual("publication-precheck", steps[0]["op"])
self.assertIn("PUBLISHED", steps[0]["expect"]["stopIf"])
def test_a_create_plan_has_no_precheck(self):
"""아직 없는 문서에는 물어볼 게시 상태가 없다."""
steps = ss.plan_requests("docs/p/t/case/x.md", dict(SENT), None, None)
self.assertNotIn("publication-precheck", [s["op"] for s in steps])
def test_the_harness_prefix_is_defined(self):
"""시험 초안을 사람이 눈으로 가릴 수 있어야 한다."""
self.assertEqual("[HARNESS-TEST]", ss.HARNESS_PREFIX)
class HarnessTestPlanTest(unittest.TestCase):
"""시험 초안 계획. 만든 것을 그 자리에서 지운다."""
def test_cleanup_only_appears_for_a_harness_test(self):
"""진짜 기록에는 삭제 요청을 만들지 않는다."""
plain = ss.plan_requests("docs/p/t/case/x.md", dict(SENT), None, None)
self.assertNotIn("cleanup", [s["op"] for s in plain])
harness = ss.plan_requests("docs/p/t/case/x.md", dict(SENT), None, None,
harness_test=True)
self.assertIn("cleanup", [s["op"] for s in harness])
def test_the_cleanup_path_follows_the_kind(self):
"""서버가 종류를 조회 조건에 넣는다.
「그렇게 하지 않으면 Case 경로로 Reference 를 지울 수 있게 되기 때문이다」
(`DeleteDocumentDraftUseCase`). 경로를 종류와 안 맞추면 `DOCUMENT_NOT_FOUND` 가
나고 **초안이 남는다.** 시험 초안을 CASE 로만 쓰면 이 결함이 안 드러난다.
"""
for kind, want in (("CASE", "/cases/"), ("CONCEPT", "/concepts/"),
("REFERENCE", "/references/"), ("QUESTION", "/questions/"),
("SETUP", "/setups/")):
with self.subTest(kind=kind):
steps = ss.plan_requests("docs/p/t/case/x.md", {**SENT, "kind": kind},
None, None, harness_test=True)
cleanup = next(s for s in steps if s["op"] == "cleanup")
self.assertIn(want, cleanup["path"])
def test_the_cleanup_body_is_not_empty(self):
"""`requestBody: required: true` 다. 안 보내면 400 이고 초안이 남는다.
값은 **`verify` 가 읽은 version** 이다 — 만든 뒤 한 번 더 저장하므로 만들 때
version 이 아니다.
"""
steps = ss.plan_requests("docs/p/t/case/x.md", dict(SENT), None, None,
harness_test=True)
cleanup = next(s for s in steps if s["op"] == "cleanup")
self.assertIsNotNone(cleanup["body"])
self.assertIn("expectedVersion", cleanup["body"])
self.assertIn("verify", str(cleanup["body"]["expectedVersion"]))
def test_a_kind_we_cannot_delete_is_refused(self):
"""지울 수 없는 것을 만들지 않는다.
Decision 은 프로젝트 아래에 있어 프로젝트 id 가 있어야 지운다. 이 어댑터는
그 값을 모른다 — 조용히 받으면 못 지우는 초안이 운영에 남는다.
"""
for kind in ("PROJECT_DECISION", "MYSTERY"):
with self.subTest(kind=kind):
with self.assertRaises(ss.Refused):
ss.plan_requests("docs/p/t/case/x.md", {**SENT, "kind": kind},
None, None, harness_test=True)
def test_the_cleanup_names_the_csrf_header_and_the_not_found_shape(self):
"""`X-XSRF-TOKEN` 이면 403 이 난다. 그리고 404 는 「종류가 어긋났다」의 모양이다."""
steps = ss.plan_requests("docs/p/t/case/x.md", dict(SENT), None, None,
harness_test=True)
e = next(s for s in steps if s["op"] == "cleanup")["expect"]
self.assertIn("X-CSRF-TOKEN", e["csrfHeader"])
self.assertEqual("DOCUMENT_NOT_FOUND", e["onNotFound"]["code"])
# **코드만 적는 것으로는 모자란다.** C 가 운영에서 종류를 어긋나게 보냈더니
# DELETE 가 404 인데 그 뒤 GET 이 200 이었다 — 404 는 지워졌다는 뜻이 아니다
self.assertIn("지워진 것이 아니다", e["onNotFound"]["warning"])
self.assertIn("GET 이 404", e["then"])
def test_the_cleanup_path_is_the_case_delete_endpoint(self):
"""`DELETE /api/v1/studio/cases/{id}` 는 실재한다
(`ManagementDocumentController.java:72`).
시험 초안을 CASE 로 고른 이유는 삭제 경로 때문이 아니다 — Decision 에도
`DELETE /v1/studio/projects/{id}/decisions/{decisionId}` 가 있다(`:123`).
고른 이유는 그 기록이 이 배치가 만든 것이라 남의 것이 아니어서다."""
steps = ss.plan_requests("docs/p/t/case/x.md", dict(SENT), None, None,
harness_test=True)
cleanup = next(s for s in steps if s["op"] == "cleanup")
self.assertEqual("DELETE", cleanup["method"])
self.assertIn("/api/v1/studio/cases/", cleanup["path"])
def test_the_cleanup_path_does_not_trip_the_publish_guard(self):
"""삭제 경로가 게시 가드에 걸리면 정리를 못 한다."""
ss._reject_forbidden_paths([{"path": "/api/v1/studio/cases/abc"}])
def test_a_second_save_exercises_the_optimistic_lock(self):
"""이 단계가 없으면 이 런에서 `expectedVersion` 경로가 안 돈다."""
steps = ss.plan_requests("docs/p/t/case/x.md", dict(SENT), None, None,
harness_test=True)
save = next(s for s in steps if s["op"] == "save-after-create")
self.assertIn("expectedVersion", save["body"])
self.assertEqual("VERSION_CONFLICT", save["expect"]["onConflict"]["code"])
def test_the_create_step_explains_the_idempotency_mismatch(self):
"""C 가 운영에서 `422 IDEMPOTENT_REQUEST_MISMATCH` 를 맞았다. 코드만 보면 본문이
틀린 줄 안다 — 계획이 「전에 다른 내용으로 만들어졌다」를 함께 실어야 한다."""
steps = ss.plan_requests("docs/p/t/case/x.md", dict(SENT), None, None,
harness_test=True)
e = next(s for s in steps if s["op"] == "create")["expect"]
m = e["onIdempotencyMismatch"]
self.assertEqual("IDEMPOTENT_REQUEST_MISMATCH", m["code"])
self.assertEqual(422, m["status"])
# **지워도 키는 남는다** — 이 문장이 없으면 다음 사람이 왜 막혔는지 모른다
self.assertTrue(any("키는 남는다" in x for x in m["likely"]), m["likely"])
# 본문 문제와 갈라 준다. 둘 다 422 라 코드만으로는 못 가린다
self.assertIn("REQUEST_VALIDATION_FAILED", m["notBody"])
self.assertEqual(422, e["onValidationFailed"]["status"])
def test_an_unknown_server_code_is_not_invented(self):
"""모르는 코드를 계획에 지어 적지 않는다."""
with self.assertRaises(ss.Refused):
ss._expect_error("NOPE_NOT_A_CODE")
def test_the_key_lifetime_is_recorded_as_unverified(self):
"""키가 언제 사라지는지는 서버 환경 변수에 있고 이 어댑터는 못 읽는다.
계약에서 읽은 값을 확인한 값으로 승격하지 않는다."""
self.assertIn("미확인", ss.IDEMPOTENCY_TTL_CONTRACT)
def test_relations_is_an_empty_list_not_none(self):
"""`relations` 는 required 이고 nullable 이 아니다. `None` 이면 만들기가 422 다."""
import tempfile, os
with tempfile.TemporaryDirectory() as d:
f = os.path.join(d, "x.md")
open(f, "w", encoding="utf-8").write(
"---\nkind: CASE\ntitle: t\nslug: s\n---\n\n## 문제\n\n가.\n")
doc = ss.build_input(f)
self.assertEqual([], doc["relations"])
self.assertIsNotNone(doc["relations"])
def test_a_harness_test_changes_the_slug_too(self):
"""제목만 바꾸면 `slug` 가 원본과 같아 `409 DB_UNIQUE_VIOLATION` 이다.
C 가 V-009 의 `P-CASE-01` 에서 맞았고, slug 를 바꾸니 201 이었다."""
out = ss.as_harness_test({"title": "제목", "slug": "a-real-slug"})
self.assertTrue(out["title"].startswith(ss.HARNESS_PREFIX))
self.assertEqual("harness-test-a-real-slug", out["slug"])
# 두 번 걸어도 하나만 붙는다 — 계획을 다시 내도 본문이 같아야 저장 키가 안정적이다
self.assertEqual(out, ss.as_harness_test(out))
def test_a_harness_test_refuses_a_record_without_a_slug(self):
"""slug 가 없으면 무엇을 지워야 할지 모른다."""
with self.assertRaises(ss.Refused):
ss.as_harness_test({"title": "제목", "slug": ""})
def test_a_harness_test_refuses_when_the_prefix_overflows(self):
"""`title` 120자 · `slug` 100자가 상한이다. 조용히 자르지 않는다."""
with self.assertRaises(ss.Refused):
ss.as_harness_test({"title": "가" * 120, "slug": "a-slug"})
with self.assertRaises(ss.Refused):
ss.as_harness_test({"title": "제목", "slug": "a" * 95})
def test_question_fields_are_arrays_not_strings(self):
"""`facts` 넷은 `OrderedText` 배열이고 마지막 칸 이름은 `nextValidation` 이다.
문자열로 보내면 `MismatchedInputException` 이다 (C 의 `P-QUESTION-02`)."""
doc = {"slug": "s", "facts": "- 하나\n- 둘", "assumptions": "", "unknowns": "",
"constraints": "", "options": "### 1. 고른다\n\n설명이다."}
out = ss._question_shape(dict(doc), {"questionStatus": "OPEN"})
self.assertEqual(2, len(out["facts"]))
self.assertEqual({"id", "text", "order"}, set(out["facts"][0]))
self.assertEqual("하나", out["facts"][0]["text"])
self.assertEqual([], out["assumptions"]) # required — 빠뜨리지 않는다
self.assertEqual({"id", "title", "description", "order"}, set(out["options"][0]))
self.assertEqual("고른다", out["options"][0]["title"])
self.assertIn("nextValidation", out)
self.assertNotIn("nextVerification", out)
self.assertEqual("OPEN", out["questionStatus"])
def test_question_ids_are_stable_across_runs(self):
"""id 를 난수로 만들면 같은 기록을 다시 계획할 때 본문이 달라져
저장 멱등 키가 흔들린다."""
doc = {"slug": "s", "facts": "- 하나", "assumptions": "", "unknowns": "",
"constraints": "", "options": ""}
a = ss._question_shape(dict(doc), {})
b = ss._question_shape(dict(doc), {})
self.assertEqual(a["facts"][0]["id"], b["facts"][0]["id"])
def test_an_unwritten_question_status_is_not_invented(self):
"""기록이 안 적은 상태를 지어내지 않는다. `null` 도 스키마가 받는다."""
out = ss._question_shape({"slug": "s"}, {})
self.assertIsNone(out["questionStatus"])
def test_concept_carries_basis_version_from_frontmatter(self):
"""`basisVersion` 은 절이 아니라 frontmatter 에 있다. required 라 빠지면 422 다
(C 의 `P-CONCEPT-01`·`02`)."""
import tempfile, os
with tempfile.TemporaryDirectory() as d:
f = os.path.join(d, "c.md")
open(f, "w", encoding="utf-8").write(
"---\nkind: CONCEPT\ntitle: t\nslug: s\n"
"basisVersion: Keycloak 26.7.0\n---\n\n## 본문\n\n가.\n")
doc = ss.build_input(f)
self.assertEqual("Keycloak 26.7.0", doc["basisVersion"])
def test_concept_refuses_an_overlong_basis_version(self):
"""상한 120자. **조용히 자르지 않는다** — 무엇을 보고 쓴 글인지가 잘리면 모른다."""
with self.assertRaises(ss.Refused):
ss._concept_shape({}, {"basisVersion": "가" * 121})
def test_concept_keeps_the_field_even_when_empty(self):
"""비워도 되지만 **빠지면 안 된다**."""
self.assertEqual("", ss._concept_shape({}, {})["basisVersion"])
def test_reference_uses_the_contract_field_name(self):
"""계약은 `applyWhen` 이다. 어댑터가 `appliesWhen` 을 보내고 있었다 —
`nextVerification` → `nextValidation` 과 같은 자리다."""
doc = ss._reference_shape({"slug": "s", "applyWhen": "- 하나"}, {})
self.assertIn("applyWhen", doc)
self.assertNotIn("appliesWhen", doc)
self.assertEqual("하나", doc["applyWhen"][0]["text"])
def test_reference_shapes_are_arrays(self):
"""`rules` 는 `ReferenceRule`, 나머지 셋은 `OrderedText` 다. `purpose` 만 문자열."""
doc = ss._reference_shape(
{"slug": "s", "purpose": "무엇을 위한 것인가", "rules": "### 1. 첫 규칙\n\n몸통이다.",
"applyWhen": "- 하나", "exceptions": "", "examples": ""}, {})
self.assertEqual({"id", "title", "body", "order"}, set(doc["rules"][0]))
self.assertEqual("첫 규칙", doc["rules"][0]["title"])
self.assertEqual({"id", "text", "order"}, set(doc["applyWhen"][0]))
self.assertEqual([], doc["exceptions"]) # required — 비어도 [] 다
self.assertEqual([], doc["examples"])
self.assertIsInstance(doc["purpose"], str)
def test_reference_verified_on_is_not_invented(self):
"""required 이고 `[string, "null"]` 이다. 기록이 안 적었으면 지어내지 않는다."""
self.assertIsNone(ss._reference_shape({"slug": "s"}, {})["verifiedOn"])
self.assertEqual("2026-08-30",
ss._reference_shape({"slug": "s"},
{"lastVerifiedOn": "2026-08-30"})["verifiedOn"])
def test_item_ids_are_not_compared_but_text_and_order_are(self):
"""서버가 항목 `id` 를 새로 발급한다. 칸 전체를 빼면 항목이 빠진 것도 못 본다."""
K = {"kind": "QUESTION"} # 종류마다 다르다 — 아래 갈래 시험을 본다
sent = {**K, "facts": [{"id": "a5", "text": "하나", "order": 0},
{"id": "b5", "text": "둘", "order": 1}]}
same = {**K, "facts": [{"id": "x4", "text": "하나", "order": 0},
{"id": "y4", "text": "둘", "order": 1}]}
self.assertTrue(ss.compare_saved(sent, same)["same"])
# **못 보는 것을 값에 적는다** — 빼먹은 것이 아니다
skipped = ss.compare_saved(sent, same)["itemIdsNotCompared"]
self.assertEqual("facts", skipped[0]["field"])
self.assertIn("order", skipped[0]["note"])
# 항목이 빠지거나 순서가 바뀌면 여전히 걸린다
dropped = {**K, "facts": [{"id": "x4", "text": "하나", "order": 0}]}
self.assertFalse(ss.compare_saved(sent, dropped)["same"])
swapped = {**K, "facts": [{"id": "x4", "text": "둘", "order": 0},
{"id": "y4", "text": "하나", "order": 1}]}
self.assertFalse(ss.compare_saved(sent, swapped)["same"])
def test_decision_uses_the_contract_field_names(self):
"""`결정문`→`statement` · `영향`→`consequences`. 그리고 `근거`→`basis` 는
계약에 없는 칸이라 `unevaluatedProperties: false` 에 거절된다 — 안 보낸다."""
self.assertEqual({"결정문": "statement", "판단 이유": "rationale",
"영향": "consequences"}, ss.FIELD_MAP["PROJECT_DECISION"])
doc = ss._decision_shape(
{"slug": "s", "statement": "이렇게 한다", "rationale": "왜냐하면",
"consequences": "- 하나\n- 둘"},
{"decisionStatus": "ADOPTED", "decidedOn": "2026-08-30"})
self.assertEqual({"id", "text", "order"}, set(doc["consequences"][0]))
self.assertEqual("ADOPTED", doc["decisionStatus"])
self.assertEqual("2026-08-30", doc["decidedOn"])
for gone in ("basis", "decision", "impact"):
self.assertNotIn(gone, doc)
def test_every_kind_in_the_contract_has_a_field_map_shape_and_delete_path(self):
"""**세 표가 같은 여섯 종류를 말해야 한다.**
빠뜨렸을 때 나는 일이 표마다 다르다 — `FIELD_MAP` 이면 「모르는 kind」로 거절되고,
`SHAPES` 면 `KeyError` 로 죽고, `DELETE_PATHS` 면 **지울 수 없는 초안이 운영에 남는다.**
`_kinds_line_up()` 이 import 할 때 보지만, 그 관문이 지워지면 여기서 걸린다.
"""
six = {"CASE", "CONCEPT", "REFERENCE", "QUESTION", "PROJECT_DECISION", "SETUP"}
self.assertEqual(six, set(ss.FIELD_MAP))
self.assertEqual(six, set(ss.SHAPES))
self.assertEqual(six, set(ss.DELETE_PATHS))
def test_setup_takes_its_pinned_versions_from_the_frontmatter(self):
"""`pinnedVersions` 는 절이 아니라 frontmatter 에 있다. 절만 보면 빠지고,
`SetupInput.required` 에 있어 빠지면 422 다 — Concept 의 `basisVersion` 과 같은 자리다."""
self.assertEqual({"본문": "bodyMarkdown"}, ss.FIELD_MAP["SETUP"])
doc = ss._setup_shape(
{"slug": "s"},
{"pinnedVersions": "- name: Keycloak\n version: 26.7.0"})
self.assertEqual([{"name": "Keycloak", "version": "26.7.0"}], doc["pinnedVersions"])
# 이 종류에는 검증일 칸이 없다. 다른 종류를 보고 넣지 않는다 —
# `unevaluatedProperties: false` 라 보내면 거절된다
for gone in ("lastVerifiedOn", "verifiedOn", "basisVersion"):
self.assertNotIn(gone, doc)
def test_an_empty_pinned_version_list_is_not_the_same_as_an_unreadable_one(self):
"""「비우면 화면에 표를 그리지 않습니다」 — 비운 것은 정상이다.
글이 있는데 못 읽은 것은 거절한다. 빈 칸으로 보내지 않는다."""
self.assertEqual([], ss._pinned_versions(""))
self.assertEqual([], ss._pinned_versions(None))
with self.assertRaises(ss.Refused):
ss._pinned_versions("- Keycloak / 26.7.0") # 모양이 다르다
with self.assertRaises(ss.Refused):
ss._pinned_versions("- name: Keycloak") # 버전이 없다
with self.assertRaises(ss.Refused):
ss._pinned_versions("- name: K\n version: " + "9" * 41) # 상한 40자
def test_decision_status_outside_the_enum_is_refused(self):
"""enum 이 셋뿐이다. 지어내지 않고 여기서 막는다 —
운영에 초안을 만들어 놓고 422 를 받는 것보다 낫다."""
with self.assertRaises(ss.Refused):
ss._decision_shape({"slug": "s"}, {"decisionStatus": "REJECTED"})
self.assertIsNone(ss._decision_shape({"slug": "s"}, {})["decisionStatus"])
self.assertIsNone(ss._decision_shape({"slug": "s"}, {})["decidedOn"])
def test_paragraph_sections_are_not_silently_empty(self):
"""항목의 모양이 절마다 다르다. `- ` 목록만 읽으면 문단으로 쓴 절이 조용히
`[]` 가 된다 — 스키마에 `minItems` 가 없어 그대로 저장되고 내용만 사라진다."""
bullets = ss._ordered("s", "consequences", "- 하나\n- 둘")
paras = ss._ordered("s", "consequences", "감수하는 것 : 가.\n\n얻는 것 : 나.")
self.assertEqual(2, len(bullets))
self.assertEqual(2, len(paras))
self.assertEqual("얻는 것 : 나.", paras[1]["text"])
# 절이 비어 있는 것은 정상이다 — 못 읽은 것과 다르다
self.assertEqual([], ss._ordered("s", "consequences", ""))
self.assertEqual([], ss._ordered("s", "consequences", None))
def test_a_decision_without_a_project_id_is_refused(self):
"""프로젝트 id 없이 만들면 그 초안을 못 지운다. 우회하지 않는다."""
doc = dict(SENT, kind="PROJECT_DECISION", slug="s")
doc.pop("projectId", None)
with self.assertRaises(ss.Refused) as cm:
ss.plan_requests("docs/p/t/decision/x.md", doc, None, None, harness_test=True)
self.assertIn("--project-id", str(cm.exception))
def test_a_decision_with_a_project_id_plans_a_deletable_draft(self):
"""값이 있으면 정리 경로에 들어가고, 되읽기가 그 값을 확인한다 —
되읽은 값이 다르면 지우려는 곳이 문서가 있는 곳이 아니다."""
pid = "11111111-aaaa-bbbb-cccc-222222222222"
doc = dict(SENT, kind="PROJECT_DECISION", slug="s", projectId=pid)
steps = ss.plan_requests("docs/p/t/decision/x.md", doc, None, None,
harness_test=True)
cleanup = next(x for x in steps if x["op"] == "cleanup")
self.assertIn(f"/projects/{pid}/decisions/", cleanup["path"])
verify = next(x for x in steps if x["op"] == "verify")
self.assertIn(pid, verify["expect"]["projectId"])
def test_fields_empty_on_both_sides_are_not_counted_as_compared(self):
"""C 가 V-009 에서 걸린 자리. 「되읽기 차이 0」이 배열 다섯이 전부 비어
**견줄 것이 없어서** 나온 값이었다 — R14 와 같은 모양이 대조기 안에 한 번 더 있다."""
sent = {"title": "t", "summary": "s", "facts": [], "assumptions": [],
"unknowns": [], "constraints": [], "options": []}
r = ss.compare_saved(sent, dict(sent))
self.assertTrue(r["same"])
self.assertEqual(2, r["comparedCount"])
self.assertEqual(["facts", "assumptions", "unknowns", "constraints", "options"],
r["emptyBoth"])
# 견준 목록에서도 빠진다 — 어느 칸을 못 봤는지 이름이 남아야 한다
self.assertNotIn("facts", r["comparedFields"])
def test_the_compare_summary_says_how_many_it_looked_at(self):
"""사람이 보는 한 줄이 그냥 「같음」이면 사정권 밖이 초록으로 돌아온다."""
blind = ss.compare_saved({"title": "t", "facts": []},
{"title": "t", "facts": []})["summary"]
full = ss.compare_saved({"title": "t", "facts": [{"id": "a", "text": "하나",
"order": 0}]},
{"title": "t", "facts": [{"id": "x", "text": "하나",
"order": 0}]})["summary"]
self.assertIn("견줄 것이 없었다", blind)
self.assertNotIn("견줄 것이 없었다", full)
self.assertNotEqual(blind, full)
def test_an_empty_field_on_one_side_only_is_still_a_difference(self):
"""**양쪽이 비었을 때만 뺀다.** 한쪽만 비면 값이 사라진 것이다."""
sent = {"facts": [{"id": "a", "text": "하나", "order": 0}]}
r = ss.compare_saved(sent, {"facts": []})
self.assertFalse(r["same"])
self.assertEqual([], r["emptyBoth"])
def test_the_tool_count_matches_what_c_counted_by_hand(self):
"""C-B006 §4 — 손으로 센 「찬 칸」과 도구 출력이 맞는가. 안 맞으면 그게 발견이다.
묶음은 B 가 읽을 수 없으므로(계약 §3.2) 같은 종류의 기록으로 센다."""
for path, want in (
("docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/"
"concept-bearer-jwt-validation-chain.md", 6),
("docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/reference/"
"reference-token-vs-session.md", 10),
# `verify-pipeline.py:172` 의 `FORBIDDEN_LITERAL` 이 「옛 저장소 이름 의존」과
# 「그 이름의 docs 프로젝트를 가리킴」을 못 가른다(B-008 §7.6). 글자를 쪼개
# 피하지 않고 같은 종류의 다른 프로젝트 기록을 쓴다
("docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/"
"question-bff-state-store.md", 11),
):
with self.subTest(path=path):
import os
doc = ss.build_input(os.path.join(ROOT, path))
self.assertEqual(want, ss.compare_saved(doc, dict(doc))["comparedCount"])
def test_options_reads_all_three_shapes(self):
"""`## 선택지` 를 쓰는 모양이 셋이다. 하나만 읽으면 나머지가 조용히 `[]` 가 된다."""
shapes = {
"hash": "### 1. 넷이 실패를 낸다\n\n설명이다.",
"bold": "**홈 비교표에 기록 수를 붙인다**\n목록 호출이 그 수를 실을 수 있다.",
"plain": "한 번 부팅해 둘을 함께 캡처한다\nmessaging 을 켜고 부팅한다.",
}
for name, raw in shapes.items():
with self.subTest(shape=name):
out = ss._question_options(raw)
self.assertEqual(1, len(out), out)
self.assertTrue(out[0]["title"])
self.assertTrue(out[0]["description"])
def test_an_empty_options_section_stays_empty(self):
"""절이 비어 있는 것은 정상이다 — 못 읽은 것과 다르다.
**`options` 의 거절 가지는 지금 안 닿는다.** 세 번째 모양(빈 줄로 나눈 문단)이
글이 있으면 언제나 항목을 하나는 만든다. `MAX_KEY_LENGTH` 와 같은 자리라
지우지 않는다 — 모양을 더하거나 고칠 때 살아나는 방어선이다."""
for blank in ("", "\n \n", " "):
with self.subTest(raw=repr(blank)):
out = ss._question_shape({"slug": "s", "options": blank}, {})
self.assertEqual([], out["options"])
# 글이 있으면 반드시 무언가를 읽는다 — 조용히 비지 않는다
self.assertTrue(ss._question_options("제목 한 줄만 있다"))
def test_item_id_handling_is_split_by_kind(self):
"""둘 다 `OrderedText` 배열인데 서버가 다르게 다룬다.
QUESTION 은 `uuid4` 로 다시 매기고(C · V-009 열 번째), **PROJECT_DECISION 은 보낸
`uuid5` 를 그대로 쓴다**(A · B-011 운영 실행). 한 벌로 묶어 빼면 DECISION 에서
**볼 수 있는 것을 안 보게 된다** — 서버가 언젠가 재발급을 시작해도 아무도 모른다.
"""
def arr(ids):
return [{"id": i, "text": f"t{n}", "order": n} for n, i in enumerate(ids)]
q = {"kind": "QUESTION", "facts": arr(["a5", "b5"])}
self.assertTrue(ss.compare_saved(q, {"kind": "QUESTION",
"facts": arr(["x4", "y4"])})["same"])
d = {"kind": "PROJECT_DECISION", "consequences": arr(["a5", "b5"])}
self.assertTrue(ss.compare_saved(d, dict(d))["same"])
# **DECISION 에서 id 가 바뀌면 보여야 한다** — 그것이 이 갈래의 값이다
moved = ss.compare_saved(d, {"kind": "PROJECT_DECISION",
"consequences": arr(["x4", "y4"])})
self.assertFalse(moved["same"])
self.assertEqual([], moved["itemIdsNotCompared"])
def test_an_unmeasured_kind_is_compared_not_skipped(self):
"""안 잰 종류는 뺀 것이 아니라 그대로 본다. 안 잰 채로 빼면
「안 봐도 되는 것」으로 굳는다 — 무엇을 왜 그대로 견줬는지 값에 적는다."""
ref = {"kind": "REFERENCE",
"rules": [{"id": "a5", "title": "t", "body": "b", "order": 0}]}
r = ss.compare_saved(ref, dict(ref))
self.assertNotIn("REFERENCE", ss.ITEM_ID_REISSUED)
self.assertEqual([], r["itemIdsNotCompared"])
self.assertEqual(["rules"], r["itemIdBehaviourUnmeasured"]["fields"])
self.assertIn("아직 안 쟀다", r["itemIdBehaviourUnmeasured"]["note"])
# 잰 종류에는 그 칸이 안 붙는다
q = {"kind": "QUESTION", "facts": [{"id": "a", "text": "t", "order": 0}]}
self.assertNotIn("itemIdBehaviourUnmeasured", ss.compare_saved(q, dict(q)))
def test_the_envelope_is_unwrapped_before_comparing(self):
"""`GET /documents/{id}` 는 문서를 `data.document` 아래에 둔다
(`studio-v1.yaml:1831`). A 가 `data` 를 문서로 보고 견줘 칸이 전부 「다르다」로
나왔다 — **껍데기를 벗기는 자리를 코드가 갖고 있지 않아서 난 일이다.**"""
doc = {"kind": "PROJECT_DECISION", "title": "t", "statement": "s"}
env = {"success": True, "meta": {},
"data": {"document": doc, "currentPublication": None,
"currentValidation": None, "latestPreview": None,
"dependencyRevision": {}, "nextAction": "VALIDATE"}}
got, detail = ss.unwrap_document(env)
self.assertEqual(doc, got)
self.assertTrue(ss.is_private(detail)[0])
# 벗기면 같고, 안 벗기면 전부 다르다
self.assertTrue(ss.compare_saved(doc, got)["same"])
self.assertFalse(ss.compare_saved(doc, env["data"])["same"])
# `data` 만 줘도 받는다
self.assertEqual(doc, ss.unwrap_document(env["data"])[0])
def test_an_unexpected_envelope_is_refused_not_compared(self):
"""모양이 다르면 조용히 넘기지 않는다. 잘못된 층을 견주는 것보다 멈추는 편이 낫다 —
그 실수는 「전부 다르다」로 크게 나와 줄 때만 눈에 띈다."""
for bad in ({"success": True, "data": {"nextAction": "X"}},
{"success": True, "data": {"document": "문자열이다"}},
"객체가 아니다"):
with self.subTest(bad=str(bad)[:32]):
with self.assertRaises(ss.Refused):
ss.unwrap_document(bad)
def test_server_assigned_fields_are_not_reported_as_unexpected(self):
"""`updatedAt` 은 서버가 붙인다. 매 저장마다 「서버가 더 줬다」로 나오면
그 칸을 아무도 안 읽게 된다. **`variantIds` 는 다르다** — 계약이 입력으로 받는데
이 어댑터가 안 보내는 칸이라 보여야 한다."""
sent = {"kind": "CASE", "title": "t"}
got = {**sent, "id": "x", "version": 2, "updatedAt": "2026-09-11T00:00:00Z",
"variantIds": []}
self.assertEqual(["variantIds"], ss.compare_saved(sent, got)["unexpected"])
def test_a_new_plan_gets_a_new_key_but_one_plan_keeps_its_own(self):
"""`CR-007` 때문에 같은 키를 두 번 못 쓴다. 그래서 계획을 새로 만들면 새 키여야
한다 — 그런데 **보낼 때 만들면 재시도가 곧 중복 생성이 된다.**
런 식별자를 계획에 적어 그 둘을 함께 얻는다."""
rel = "docs/p/t/case/x.md"
a = ss.idempotency_key("create", rel, None, "run-aaa")
b = ss.idempotency_key("create", rel, None, "run-bbb")
self.assertNotEqual(a, b) # 계획이 새로 만들어지면 새 키
self.assertEqual(a, ss.idempotency_key("create", rel, None, "run-aaa"))
# 같은 계획을 두 번 보내는 것은 같은 값을 두 번 쓰는 것이다
self.assertLessEqual(len(a), ss.MAX_KEY_LENGTH)
# 런 식별자를 안 주면 예전 모양 그대로다 — 옛 계획을 깨지 않는다
self.assertEqual(ss.idempotency_key("create", rel),
ss.idempotency_key("create", rel, None, None))
def test_the_plan_records_its_run_id(self):
"""**보낼 때 만들지 않는다.** 계획 파일에 적혀 있어야 같은 계획이 같은 키를 쓴다."""
if not os.path.isfile(PACKAGE):
# 묶음은 worktree 밖에 있다. 없으면 **안 돌았다고 적고 넘어간다** —
# 조용히 통과시키면 이 회귀가 초록인 채로 아무것도 안 재게 된다
self.skipTest(f"묶음이 없어 계획을 못 낸다: {PACKAGE}")
# 이 시험이 재는 것은 **런 식별자를 계획이 적는가** 이고, 고정 해시는 그저 입력이다.
# 작업 트리에서 이 기록을 고치면 어댑터가 (맞게) 거절해서 계획 자체가 안 나온다.
# 그때 **고치는 것은 기록도 묶음도 아니다** — 재지 못했다고 적고 넘어간다.
# 고정값이 이 파일의 과거 판일 때만 그렇다. 남의 묶음이면 그건 진짜 결함이라 실패한다
want = (json.load(open(PACKAGE, encoding="utf-8")).get("target") or {}).get("sha256")
record = os.path.join(ROOT, DECISION_RECORD)
if want and want != ss._sha256_file(record):
kind, detail = ss._pin_verdict(record, want)
if kind != ss.PIN_ALIEN:
self.skipTest(
f"검토가 본 판과 작업 트리가 다르다 — 계획을 못 낸다 ({kind} · {detail})")
with tempfile.TemporaryDirectory() as d:
out = os.path.join(d, "plan.json")
r = subprocess.run(
[sys.executable, "scripts/studio-save.py", "--record", DECISION_RECORD,
"--package", PACKAGE, "--harness-test", "--project-id", PROJECT_ID,
"--run-id", "run-fixed-for-the-test", "-o", out],
cwd=ROOT, capture_output=True, text=True)
self.assertEqual(0, r.returncode, r.stderr)
plan = json.load(open(out, encoding="utf-8"))
self.assertEqual("run-fixed-for-the-test", plan["runId"])
key = next(s["headers"]["Idempotency-Key"] for s in plan["requests"]
if s["op"] == "create")
self.assertTrue(key.endswith(ss._sha256_text("run-fixed-for-the-test")[:12]))
def test_a_harness_plan_still_has_no_publish_path(self):
steps = ss.plan_requests("docs/p/t/case/x.md", dict(SENT), None, None,
harness_test=True)
for s in steps:
self.assertNotIn("/publish", s["path"])
self.assertNotIn("/unpublish", s["path"])
class KeyStabilityTest(unittest.TestCase):
"""저장 멱등 키가 **필드 순서에 안 흔들린다.**
`idempotency_key` 가 `json.dumps(..., sort_keys=True)` 로 본문을 직렬화한다.
**그 효과를 노리고 넣은 것이 아니라** JSON 을 안정적으로 만들려고 넣은 것이 멱등성을
지켰다 — 다시 직렬화한 재시도(같은 내용, 다른 필드 순서)가 새 저장으로 잡히지 않는다.
**의도하지 않은 성질에 기대는 코드는 회귀로 못박지 않으면 다음 사람이 지운다.**
`sort_keys` 를 빼면 이 시험이 깨진다.
"""
REL = "docs/p/tech-log-studio/t/case/x.md"
def test_field_order_does_not_change_the_save_key(self):
one = {"expectedVersion": 3,
"document": {"kind": "CASE", "title": "가", "slug": "x"}}
two = {"document": {"slug": "x", "title": "가", "kind": "CASE"},
"expectedVersion": 3}
self.assertEqual(ss.idempotency_key("save", self.REL, one),
ss.idempotency_key("save", self.REL, two))
def test_content_still_changes_the_save_key(self):
"""대조군. 순서에 안 흔들린다고 내용에도 안 흔들리면 그건 다른 결함이다."""
one = {"expectedVersion": 3, "document": {"title": "가"}}
two = {"expectedVersion": 3, "document": {"title": "나"}}
self.assertNotEqual(ss.idempotency_key("save", self.REL, one),
ss.idempotency_key("save", self.REL, two))
def test_the_create_key_does_not_depend_on_a_payload(self):
"""만들기 키는 경로만으로 만든다. 재시도가 문서를 둘 만들지 않는다."""
self.assertEqual(ss.idempotency_key("create", self.REL),
ss.idempotency_key("create", self.REL))