A 가 B-011 운영 실행에서 봤다. PROJECT_DECISION 은 보낸 uuid5 여섯이 글자 그대로 돌아온다. QUESTION 은 서버가 uuid4 로 다시 발급한다(C · V-009 열 번째). 둘 다 OrderedText 배열인데 서버가 다르게 다룬다. 한 벌로 묶어 빼면 DECISION 에서 볼 수 있는 것을 안 보게 된다 — 서버가 언젠가 DECISION 도 재발급하기 시작해도 아무도 모른다. 그래서 종류로 갈랐다. QUESTION 다시 매긴다 → id 를 뺀다 (쟀다) PROJECT_DECISION 다시 안 매긴다 → 그대로 본다 (쟀다) 그 밖 안 쟀다 → 빼지 않고 그대로 보고, 안 쟀다는 것을 값에 적는다 안 잰 채로 빼면 「안 봐도 되는 것」으로 굳는다. 「못 보는 것」과 「안 봐도 되는 것」은 다르다 — itemIdBehaviourUnmeasured 가 어느 칸을 왜 그대로 견줬는지 적는다. python3 -m unittest discover -s scripts/tests — Ran 286 · OK (skipped=13) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4vKjQo9KKBBokzxqXLCfk
945 lines
50 KiB
Python
945 lines
50 KiB
Python
"""Studio 저장 어댑터.
|
|
|
|
이 묶음의 절반은 **대조군**이다. 손대지 않은 쌍이 「같다」로 나오는 것을 먼저 확인하지 않으면,
|
|
결함을 잡았다는 결과가 비교 틀이 만들어 낸 것인지 알 수 없다.
|
|
"""
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import tempfile
|
|
import unittest
|
|
|
|
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
_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_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_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))
|