diff --git a/scripts/studio-save.py b/scripts/studio-save.py index f2c1b35..cd52c7e 100644 --- a/scripts/studio-save.py +++ b/scripts/studio-save.py @@ -898,6 +898,11 @@ ITEM_ID_NOTE = ("항목 `id` 만 못 본다 — 보낸 것은 uuid5 이고 서 "순서가 바뀌는 것은 여전히 걸린다") +def _is_empty(v) -> bool: + """값이 「없음」인가. 서버가 빈 배열로, 어댑터가 `None` 으로 두는 자리를 같게 본다.""" + return v is None or v == "" or v == [] or v == {} + + def _without_item_ids(value): """항목 배열에서 `id` 만 뺀다. 나머지 칸은 그대로 둔다.""" if not isinstance(value, list): @@ -919,6 +924,21 @@ def normalize(value) -> str: return "\n".join(line.rstrip() for line in text.split("\n")).strip() +def _compare_summary(sent: dict, empty_both: list, differences: list, + whitespace_only: list) -> str: + """사람이 보는 한 줄. **그냥 「같음」이면 사정권 밖이 초록으로 돌아온다** — + `NO_BEFORE` 를 요약 줄에 적은 것과 같은 이유다.""" + total = len([f for f in sent if f not in NOT_COMPARED]) + seen = total - len(empty_both) + head = "같음" if not differences and not whitespace_only else ( + f"다름 {len(differences)}칸" + (f" · 공백만 {len(whitespace_only)}칸" + if whitespace_only else "")) + tail = (f" — {total}칸 중 {seen}칸을 견줬다. " + f"{len(empty_both)}칸은 양쪽이 비어 견줄 것이 없었다" + if empty_both else f" — {total}칸을 전부 견줬다") + return head + tail + + def compare_saved(sent: dict, fetched: dict) -> dict: """보낸 것과 되읽은 것을 견준다. @@ -932,11 +952,18 @@ def compare_saved(sent: dict, fetched: dict) -> dict: 적이 있다. 앞이 같고 뒤가 없는 모양은 바뀐 것과 다르게 읽힌다. """ differences, whitespace_only, truncated = [], [], [] - item_ids_skipped = [] + item_ids_skipped, empty_both = [], [] for field, want in sent.items(): if field in NOT_COMPARED: continue got = fetched.get(field) + if _is_empty(want) and _is_empty(got): + # **견준 것이 아니라 견줄 것이 없었던 칸이다.** 이것을 「같다」에 섞으면 + # 「칸이 다 맞았다」가 「찬 칸만 맞았다」를 가린다 — R14 와 같은 모양이다. + # C 가 V-009 에서 걸렸다: `P-QUESTION-01` 의 「되읽기 차이 0」이 배열 다섯이 + # 전부 비어 **견줄 것이 없어서** 나온 값이었다 + empty_both.append(field) + continue if field in ITEM_ID_REISSUED and isinstance(want, list): # **빼먹은 것이 아니라 못 보는 것**이므로 무엇을 왜 안 보는지 값에 적는다 item_ids_skipped.append({"field": field, "items": len(want), @@ -964,7 +991,15 @@ def compare_saved(sent: dict, fetched: dict) -> dict: "notCompared": NOT_COMPARED, "itemIdsNotCompared": item_ids_skipped, "unexpected": unexpected, - "comparedFields": [f for f in sent if f not in NOT_COMPARED], + # **실제로 견준 칸만 남는다.** 양쪽이 빈 칸은 `emptyBoth` 로 갈라 나간다. + # C 의 수정안은 이 칸을 개수로 바꿨는데 여기서는 목록을 유지하고 개수를 따로 둔다 — + # 이미 목록으로 읽는 회귀가 있고, 어느 칸을 못 봤는지는 이름이 있어야 안다 + "comparedFields": [f for f in sent + if f not in NOT_COMPARED and f not in empty_both], + "comparedCount": len([f for f in sent + if f not in NOT_COMPARED and f not in empty_both]), + "emptyBoth": empty_both, + "summary": _compare_summary(sent, empty_both, differences, whitespace_only), } diff --git a/scripts/tests/test_studio_save.py b/scripts/tests/test_studio_save.py index 39e0b86..ac52e84 100644 --- a/scripts/tests/test_studio_save.py +++ b/scripts/tests/test_studio_save.py @@ -788,6 +788,57 @@ class HarnessTestPlanTest(unittest.TestCase): 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_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)