diff --git a/scripts/studio-save.py b/scripts/studio-save.py index cd52c7e..81bc7fd 100644 --- a/scripts/studio-save.py +++ b/scripts/studio-save.py @@ -345,13 +345,37 @@ def _bullets(chunk: str) -> list[str]: return ["\n".join(x).strip() for x in items if "\n".join(x).strip()] +# 제목이 붙은 항목의 모양이 둘이다. `### N. 제목` 도 있고 **굵은 한 줄**도 있다. +# 둘 다 `{title, description}` 에 그대로 맞는다 — 굵은 줄이 제목이고 다음 문단이 몸통이다. +BOLD_TITLE = re.compile(r"^\*\*(.+?)\*\*\s*$", re.M) +HASH_TITLE = re.compile(r"^###\s+(.+)$", re.M) + + def _question_options(chunk: str) -> list[dict]: - """`### N. 제목` 과 그 아래 문단을 `QuestionOption` 으로.""" - out: list[dict] = [] - parts = re.split(r"^###\s+(.+)$", chunk, flags=re.M) - for i in range(1, len(parts), 2): - title = re.sub(r"^\d+[.)]\s*", "", parts[i].strip()) - out.append({"title": title[:120], "description": parts[i + 1].strip()}) + """제목이 붙은 항목을 `{title, description}` 으로. + + **모양 하나만 읽으면 다른 모양으로 쓴 절이 조용히 `[]` 가 된다.** `_ordered` 에서 + 문단형을 못 읽던 것과 같은 자리인데, 이쪽은 `_ordered` 를 안 거쳐서 **거절도 안 걸렸다** — + 읽지도 않고 막지도 않으니 그게 제일 나쁜 상태다. + """ + for pattern in (HASH_TITLE, BOLD_TITLE): + parts = pattern.split(chunk) + if len(parts) < 3: # 그 모양이 아니다 — 다음 모양을 본다 + continue + out: list[dict] = [] + for i in range(1, len(parts), 2): + title = re.sub(r"^\d+[.)]\s*", "", parts[i].strip()) + out.append({"title": title[:120], "description": parts[i + 1].strip()}) + return out + # 세 번째 모양 — 표시 없이 **첫 줄이 제목이고 다음 줄들이 몸통**이다. 빈 줄로 나뉜다. + # 이 저장소의 `openquestion-*` 여섯이 이 모양이라 앞 둘만 읽으면 그 여섯이 막힌다 + out = [] + for block in re.split(r"\n\s*\n", chunk): + lines = [x for x in block.strip().split("\n") if x.strip()] + if not lines: + continue + title = re.sub(r"^\d+[.)]\s*", "", lines[0].strip()) + out.append({"title": title[:120], "description": "\n".join(lines[1:]).strip()}) return out @@ -445,6 +469,16 @@ def _question_shape(doc: dict, fm: dict) -> dict: doc[field] = _ordered(slug, field, doc.get(field)) raw = doc.get("options") opts = _question_options(raw) if isinstance(raw, str) else [] + if isinstance(raw, str) and raw.strip() and not opts: + # **`_ordered` 의 거절이 여기서는 안 걸린다.** 이 칸은 그 함수를 안 거친다 — + # 같은 규칙을 여기에도 둔다. 비어 있는 것과 못 읽은 것은 다른 일이다. + # + # **지금은 여기 안 온다.** 세 번째 모양(빈 줄로 나눈 문단)이 글이 있으면 언제나 + # 항목을 하나는 만든다. `MAX_KEY_LENGTH` 와 같은 자리다 — 죽은 코드가 아니라 + # **모양을 더하거나 고칠 때 살아나는 방어선**이다. 지우지 않는다 + raise Refused("선택지 절에 글이 있는데 항목을 하나도 못 읽었다. " + "빈 칸으로 보내지 않는다 — `### N. 제목` 이나 `**굵은 한 줄**` 로 " + "제목을 붙이거나, 무엇이 사라졌는지 보고 고친다") doc["options"] = [{"id": _ordered_id(slug, "options", i), "title": o["title"], "description": o["description"], "order": i} for i, o in enumerate(opts)] diff --git a/scripts/tests/test_studio_save.py b/scripts/tests/test_studio_save.py index ac52e84..28a1fc5 100644 --- a/scripts/tests/test_studio_save.py +++ b/scripts/tests/test_studio_save.py @@ -839,6 +839,33 @@ class HarnessTestPlanTest(unittest.TestCase): 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_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)