「Decision 은 계약에 삭제 경로가 없다」고 적었는데 틀렸다. ManagementDocumentController
:123 에 DELETE /v1/studio/projects/{id}/decisions/{decisionId} 가 있다. 스킬의 문장을
확인 없이 옮겼다. 시험 초안을 CASE 로 고른 이유는 삭제 경로 때문이 아니라 그 기록이 이
배치가 만든 것이라 남의 것이 아니어서다.
멱등 키 길이 검사는 지금 정책에서 안 닿는다. 키가 studio-{op}-{32자}(+{32자})라 길이가
사실상 고정이고 저장 키가 77자다. 죽은 코드가 아니라 키 정책이 바뀌면 살아나는 방어선이라
지우지 않고, 언제 살아나는지를 주석에 적었다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4vKjQo9KKBBokzxqXLCfk
536 lines
26 KiB
Python
536 lines
26 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_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.assertIn("VERSION_CONFLICT", save["expect"]["onConflict"])
|
|
|
|
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"])
|