fix(studio-save): 항목 id 를 다시 매기는지는 종류마다 다르다

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
This commit is contained in:
DongHyeonka
2026-09-11 11:19:55 +09:00
co-authored by Claude Opus 5
parent 325b6008ab
commit 1be1c8ea6d
2 changed files with 72 additions and 14 deletions
+29 -7
View File
@@ -919,17 +919,31 @@ NOT_COMPARED = {
"relations": "서버가 순서를 다시 매긴다. 목록 비교는 따로 만들어야 한다", "relations": "서버가 순서를 다시 매긴다. 목록 비교는 따로 만들어야 한다",
} }
# 항목 배열의 **`id` 만** 못 본다. 보내는 것은 `uuid5(slug|칸|순번)` 이고 서버는 저장하면서 # 항목 배열의 `id` 를 서버가 다시 매기는지는 **종류마다 다르고, 잰 종류만 안다.**
# 새 uuid4 를 발급한다 — C 가 V-009 의 `P-QUESTION-02` 에서 갈랐다(`text`·`order` 는 같고 #
# `id` 만 달랐다). 칸 전체를 빼면 **모든 QUESTION·REFERENCE 저장이 「차이 있음」으로 보고되거나 # QUESTION 다시 매긴다 — C 가 V-009 열 번째에서 봤다. 보낸 uuid5 와 받은 uuid4 가
# 반대로 항목이 빠진 것을 못 보게 된다.** 그래서 `id` 만 빼고 나머지는 계속 대조한다. # 다르고 `text`·`order` 는 같았다
# PROJECT_DECISION **다시 안 매긴다** — A 가 B-011 운영 실행에서 봤다. 보낸 uuid5 여섯이
# 글자 그대로 돌아왔다
#
# 둘 다 `OrderedText` 배열인데 서버가 다르게 다룬다. **그러니 한 벌로 묶어 빼면 안 된다.**
# QUESTION 에서는 뺄 수밖에 없는 칸이지만 **DECISION 에서는 볼 수 있는 칸이고, 보면
# 「안 매겨졌다」는 사실이 드러난다.** 빼 버리면 서버가 언젠가 DECISION 도 재발급하기
# 시작해도 아무도 모른다.
#
# **안 잰 종류는 뺀 것이 아니라 그대로 본다.** 「못 보는 것」과 「안 봐도 되는 것」은 다르다 —
# 안 잰 채로 빼면 「안 봐도 되는 것」으로 굳는다. 차이가 나면 그때 재서 이 표에 적는다.
ITEM_ID_REISSUED = { ITEM_ID_REISSUED = {
"facts", "assumptions", "unknowns", "constraints", "options", "QUESTION": {"facts", "assumptions", "unknowns", "constraints", "options"},
"rules", "applyWhen", "exceptions", "examples", "consequences", "PROJECT_DECISION": set(), # 쟀다 — 서버가 보낸 id 를 그대로 쓴다
} }
ITEM_ID_NOTE = ("항목 `id` 만 못 본다 — 보낸 것은 uuid5 이고 서버가 새 uuid4 를 발급한다. " ITEM_ID_NOTE = ("항목 `id` 만 못 본다 — 보낸 것은 uuid5 이고 서버가 새 uuid4 를 발급한다. "
"`text`·`title`·`body`·`order` 는 그대로 대조한다 — 항목이 빠지거나 " "`text`·`title`·`body`·`order` 는 그대로 대조한다 — 항목이 빠지거나 "
"순서가 바뀌는 것은 여전히 걸린다") "순서가 바뀌는 것은 여전히 걸린다")
ITEM_ID_UNMEASURED = ("이 종류는 서버가 항목 `id` 를 다시 매기는지 **아직 안 쟀다.** "
"그래서 빼지 않고 그대로 견준다 — `id` 만 다른 차이가 나오면 그것은 "
"값이 사라진 것이 아니라 **재발급을 처음 본 것**일 수 있다. "
"재서 ITEM_ID_REISSUED 에 적는다")
def _is_empty(v) -> bool: def _is_empty(v) -> bool:
@@ -987,6 +1001,10 @@ def compare_saved(sent: dict, fetched: dict) -> dict:
""" """
differences, whitespace_only, truncated = [], [], [] differences, whitespace_only, truncated = [], [], []
item_ids_skipped, empty_both = [], [] item_ids_skipped, empty_both = [], []
kind = (sent.get("kind") or "").upper()
reissued = ITEM_ID_REISSUED.get(kind)
array_fields = [f for f, v in sent.items()
if isinstance(v, list) and any(isinstance(x, dict) for x in v)]
for field, want in sent.items(): for field, want in sent.items():
if field in NOT_COMPARED: if field in NOT_COMPARED:
continue continue
@@ -998,7 +1016,7 @@ def compare_saved(sent: dict, fetched: dict) -> dict:
# 전부 비어 **견줄 것이 없어서** 나온 값이었다 # 전부 비어 **견줄 것이 없어서** 나온 값이었다
empty_both.append(field) empty_both.append(field)
continue continue
if field in ITEM_ID_REISSUED and isinstance(want, list): if reissued and field in reissued and isinstance(want, list):
# **빼먹은 것이 아니라 못 보는 것**이므로 무엇을 왜 안 보는지 값에 적는다 # **빼먹은 것이 아니라 못 보는 것**이므로 무엇을 왜 안 보는지 값에 적는다
item_ids_skipped.append({"field": field, "items": len(want), item_ids_skipped.append({"field": field, "items": len(want),
"note": ITEM_ID_NOTE}) "note": ITEM_ID_NOTE})
@@ -1024,6 +1042,10 @@ def compare_saved(sent: dict, fetched: dict) -> dict:
"whitespaceOnly": whitespace_only, "whitespaceOnly": whitespace_only,
"notCompared": NOT_COMPARED, "notCompared": NOT_COMPARED,
"itemIdsNotCompared": item_ids_skipped, "itemIdsNotCompared": item_ids_skipped,
# 안 잰 종류를 조용히 넘기지 않는다. 무엇을 왜 그대로 견줬는지 값에 적는다
**({"itemIdBehaviourUnmeasured": {"kind": kind, "fields": array_fields,
"note": ITEM_ID_UNMEASURED}}
if reissued is None and array_fields else {}),
"unexpected": unexpected, "unexpected": unexpected,
# **실제로 견준 칸만 남는다.** 양쪽이 빈 칸은 `emptyBoth` 로 갈라 나간다. # **실제로 견준 칸만 남는다.** 양쪽이 빈 칸은 `emptyBoth` 로 갈라 나간다.
# C 의 수정안은 이 칸을 개수로 바꿨는데 여기서는 목록을 유지하고 개수를 따로 둔다 — # C 의 수정안은 이 칸을 개수로 바꿨는데 여기서는 목록을 유지하고 개수를 따로 둔다 —
+43 -7
View File
@@ -717,20 +717,21 @@ class HarnessTestPlanTest(unittest.TestCase):
def test_item_ids_are_not_compared_but_text_and_order_are(self): def test_item_ids_are_not_compared_but_text_and_order_are(self):
"""서버가 항목 `id` 를 새로 발급한다. 칸 전체를 빼면 항목이 빠진 것도 못 본다.""" """서버가 항목 `id` 를 새로 발급한다. 칸 전체를 빼면 항목이 빠진 것도 못 본다."""
sent = {"facts": [{"id": "a5", "text": "하나", "order": 0}, K = {"kind": "QUESTION"} # 종류마다 다르다 — 아래 갈래 시험을 본다
{"id": "b5", "text": "", "order": 1}]} sent = {**K, "facts": [{"id": "a5", "text": "하나", "order": 0},
same = {"facts": [{"id": "x4", "text": "하나", "order": 0}, {"id": "b5", "text": "", "order": 1}]}
{"id": "y4", "text": "", "order": 1}]} same = {**K, "facts": [{"id": "x4", "text": "하나", "order": 0},
{"id": "y4", "text": "", "order": 1}]}
self.assertTrue(ss.compare_saved(sent, same)["same"]) self.assertTrue(ss.compare_saved(sent, same)["same"])
# **못 보는 것을 값에 적는다** — 빼먹은 것이 아니다 # **못 보는 것을 값에 적는다** — 빼먹은 것이 아니다
skipped = ss.compare_saved(sent, same)["itemIdsNotCompared"] skipped = ss.compare_saved(sent, same)["itemIdsNotCompared"]
self.assertEqual("facts", skipped[0]["field"]) self.assertEqual("facts", skipped[0]["field"])
self.assertIn("order", skipped[0]["note"]) self.assertIn("order", skipped[0]["note"])
# 항목이 빠지거나 순서가 바뀌면 여전히 걸린다 # 항목이 빠지거나 순서가 바뀌면 여전히 걸린다
dropped = {"facts": [{"id": "x4", "text": "하나", "order": 0}]} dropped = {**K, "facts": [{"id": "x4", "text": "하나", "order": 0}]}
self.assertFalse(ss.compare_saved(sent, dropped)["same"]) self.assertFalse(ss.compare_saved(sent, dropped)["same"])
swapped = {"facts": [{"id": "x4", "text": "", "order": 0}, swapped = {**K, "facts": [{"id": "x4", "text": "", "order": 0},
{"id": "y4", "text": "하나", "order": 1}]} {"id": "y4", "text": "하나", "order": 1}]}
self.assertFalse(ss.compare_saved(sent, swapped)["same"]) self.assertFalse(ss.compare_saved(sent, swapped)["same"])
def test_decision_uses_the_contract_field_names(self): def test_decision_uses_the_contract_field_names(self):
@@ -866,6 +867,41 @@ class HarnessTestPlanTest(unittest.TestCase):
# 글이 있으면 반드시 무언가를 읽는다 — 조용히 비지 않는다 # 글이 있으면 반드시 무언가를 읽는다 — 조용히 비지 않는다
self.assertTrue(ss._question_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): 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, steps = ss.plan_requests("docs/p/t/case/x.md", dict(SENT), None, None,
harness_test=True) harness_test=True)