diff --git a/src/phase_z2_mapper.py b/src/phase_z2_mapper.py index fd990f8..0776ba9 100644 --- a/src/phase_z2_mapper.py +++ b/src/phase_z2_mapper.py @@ -579,12 +579,23 @@ def _build_compare_table_2col(section, units, contract) -> dict: builder_options : item_parser : ITEM_PARSERS key (예: `compare_row_2col_item`) - col_a_label_default : col_a header (MDX 미명시 시 fallback. F1-a fix) - col_b_label_default : col_b header (MDX 미명시 시 fallback) + col_a_label_default : col_a header literal in catalog. + Semantics depend on col_a_label_default_role. + col_a_label_default_role : "placeholder" | "fallback" (IMP-40 #69). + placeholder = Figma visual placeholder; suppressed + at runtime → col_a_label emitted as "". + fallback = MDX 미명시 시 catalog literal 사용. + absent = legacy contracts default to fallback. + col_b_label_default : col_b header literal (same policy as col_a). + col_b_label_default_role : same role discriminator for col_b (IMP-40 #69). strip_col_prefix_aliases : list[str] — col_a/col_b 값의 prefix `:` 를 strip (Codex round 43 §F1-b — narrow alias). 예 : ["BIM", "DX"]. default [] (no stripping). max_rows : N (default 999 — practical 한계). + + NOTE: MDX 측 col_a_label / col_b_label inflow 경로 없음 + (compare_row_2col_item parser → {label,col_a,col_b}, _resolve_title → title only). + placeholder role 은 col_*_label 을 빈 문자열로 확정 — 정책 결정점은 catalog 한 곳뿐. """ options = contract["payload"]["builder_options"] parser_name = options["item_parser"] @@ -595,8 +606,21 @@ def _build_compare_table_2col(section, units, contract) -> dict: f"but ITEM_PARSERS has no such entry." ) - col_a_label = options.get("col_a_label_default", "") - col_b_label = options.get("col_b_label_default", "") + def _resolve_label_default(col_key: str) -> str: + default_key = f"{col_key}_label_default" + role_key = f"{col_key}_label_default_role" + role = options.get(role_key, "fallback") + if role == "placeholder": + return "" + if role == "fallback": + return options.get(default_key, "") + raise ValueError( + f"Contract '{contract['template_id']}' builder_options.{role_key}='{role}' " + f"is invalid; expected 'placeholder' or 'fallback' (IMP-40 #69)." + ) + + col_a_label = _resolve_label_default("col_a") + col_b_label = _resolve_label_default("col_b") strip_aliases = options.get("strip_col_prefix_aliases", []) or [] max_rows = options.get("max_rows", 999) diff --git a/templates/phase_z2/catalog/frame_contracts.yaml b/templates/phase_z2/catalog/frame_contracts.yaml index 47fe471..02045eb 100644 --- a/templates/phase_z2/catalog/frame_contracts.yaml +++ b/templates/phase_z2/catalog/frame_contracts.yaml @@ -474,7 +474,9 @@ bim_dx_comparison_table: builder_options: item_parser: compare_row_2col_item # NEW parser — top_bullet → {label, col_a, col_b} col_a_label_default: "BIM" # F1-a (Codex round 43) — explicit default + col_a_label_default_role: placeholder # IMP-40 (#69) — Figma visual placeholder; suppressed at runtime, NOT a fallback col_b_label_default: "DX" # F1-a — explicit default + col_b_label_default_role: placeholder # IMP-40 (#69) — Figma visual placeholder; suppressed at runtime, NOT a fallback strip_col_prefix_aliases: # F1-b (Codex round 43) — narrow alias 만 strip - "BIM" - "DX" @@ -1785,8 +1787,11 @@ industry_current_status_three_col: builder_options: item_parser: compare_row_3col_item # NEW parser placeholder — top_bullet → {label, col_a, col_b, col_c}. Peer parity with compare_row_2col_item. col_a_label_default: "제조업" # F30 source column 1 label (analysis.md three_industries anchor set). + col_a_label_default_role: placeholder # IMP-40 (#69) — Figma visual placeholder; suppressed at runtime, NOT a fallback col_b_label_default: "건축" # F30 source column 2 label. + col_b_label_default_role: placeholder # IMP-40 (#69) — Figma visual placeholder; suppressed at runtime, NOT a fallback col_c_label_default: "토목" # F30 source column 3 label (강조 테두리 빨간색 cue — visual styling). + col_c_label_default_role: placeholder # IMP-40 (#69) — Figma visual placeholder; suppressed at runtime, NOT a fallback max_rows: 12 # typical 4-6, overflow 보호 (peer parity with compare_table_2col max_rows 12). @@ -1847,6 +1852,9 @@ industry_characteristics_three_col: builder_options: item_parser: compare_row_3col_item # Shared parser with industry_current_status_three_col (F30) — top_bullet → {label, col_a, col_b, col_c}. Peer parity with compare_row_2col_item. col_a_label_default: "제조업" # F31 source column 1 label (analysis.md three_industries anchor set; shared with F30). + col_a_label_default_role: placeholder # IMP-40 (#69) — Figma visual placeholder; suppressed at runtime, NOT a fallback col_b_label_default: "건축" # F31 source column 2 label (shared with F30). + col_b_label_default_role: placeholder # IMP-40 (#69) — Figma visual placeholder; suppressed at runtime, NOT a fallback col_c_label_default: "토목" # F31 source column 3 label (강조 테두리 빨간색 cue — visual styling; shared with F30). + col_c_label_default_role: placeholder # IMP-40 (#69) — Figma visual placeholder; suppressed at runtime, NOT a fallback max_rows: 12 # typical 3-4 (F31 compressed view), overflow 보호 (peer parity with compare_table_2col + F30 compare_table_3col max_rows 12). diff --git a/tests/integration/__snapshots__/slot_payload.json b/tests/integration/__snapshots__/slot_payload.json index 4d7d048..2f0eeb2 100644 --- a/tests/integration/__snapshots__/slot_payload.json +++ b/tests/integration/__snapshots__/slot_payload.json @@ -8,7 +8,7 @@ "slot_names": ["col_a_label", "col_b_label", "rows", "title"], "list_slot_counts": {"rows": 2}, "dict_slot_sub_counts": {}, - "string_slot_nonempty": {"col_a_label": true, "col_b_label": true, "title": true} + "string_slot_nonempty": {"col_a_label": false, "col_b_label": false, "title": true} }, { "position": "bottom", diff --git a/tests/phase_z2/test_imp40_label_default_role.py b/tests/phase_z2/test_imp40_label_default_role.py new file mode 100644 index 0000000..7fc4d54 --- /dev/null +++ b/tests/phase_z2/test_imp40_label_default_role.py @@ -0,0 +1,323 @@ +"""IMP-40 u4 (issue #69) — synthetic role-policy matrix for +``_build_compare_table_2col`` label-default discriminator. + +Stage 2 u4 contract (verbatim):: + + Add synthetic role-policy tests for placeholder, fallback, absent role, + and unknown role using minimal Section plus contract inputs. + +Why this is load-bearing +======================== + +u1 (frame_contracts.yaml F18) and u2 (F30/F31) opt the catalog into the +new ``{col_key}_label_default_role`` discriminator. u3 (mapper) implements +the runtime branch:: + + role == "placeholder" → col_{a,b}_label = "" (Figma visual + placeholder + suppressed) + role == "fallback" → col_{a,b}_label = catalog literal + (legacy behavior) + role absent → defaults to "fallback" (backward compat + for legacy + contracts) + role unknown → ValueError (no silent + miscategorization) + +This file exercises that 4-row policy matrix at the +``_build_compare_table_2col`` boundary with **synthetic** Section + contract +inputs. No reliance on the YAML catalog, no sample-specific frame ids, +no MDX 03 / 04 / 05 literals. Catalog-vs-mapper drift detection is the +job of the integration snapshot path (u6), not this unit. + +Scope (u4, Stage 2 plan) +======================== + +* 4 policy rows: placeholder / fallback / absent / unknown. +* ``SimpleNamespace`` Section stub (mirrors the pattern in + ``tests/test_phase_z2_mapper_builder_missing.py``). +* Inline contract dicts — no catalog import. +* ``title`` slot omitted (``_resolve_title`` returns ``{}`` when + ``payload.title.source`` is absent — verified at + ``src/phase_z2_mapper.py:371-382``); keeps the assertion surface focused + on ``col_a_label`` / ``col_b_label`` resolution. +* Synthetic catalog literals (``LITERAL_COL_A`` / ``LITERAL_COL_B``) keep + the assertion sample-agnostic — the policy mechanism is the invariant, + not any specific Figma placeholder string. + +Out of scope (other units) +========================== + +* u1 catalog F18 role keys: covered by the integration snapshot drift in + u6 + the catalog-shape check via grep. +* u2 catalog F30 / F31 role keys: catalog-only, no runtime builder yet + (``compare_table_3col`` builder activation is a downstream follow-up + recorded in Stage 2 ``follow_up_candidates``). +* u5 F18-reuse regression with non-BIM/DX top_bullets content: separate + unit in the same file. +* u6 mdx 01 F18 ``slot_payload`` snapshot refresh. +""" +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from src.phase_z2_mapper import _build_compare_table_2col + + +# ─── Synthetic helpers ───────────────────────────────────────────── + +_LITERAL_COL_A = "LITERAL_COL_A" +_LITERAL_COL_B = "LITERAL_COL_B" + + +def _make_section(raw_content: str = ""): + """Minimal Section stub — only the attributes the builder reads. + + ``_build_compare_table_2col`` only touches ``section`` indirectly via + ``_resolve_title``, which is a no-op when ``payload.title.source`` is + absent. We still pass an empty ``raw_content`` so future regressions + that start reading from it would surface immediately rather than + silently passing on a placeholder. + """ + return SimpleNamespace( + section_id="synthetic-imp40-u4", + raw_content=raw_content, + title="SYNTHETIC_TITLE", + order=1, + ) + + +def _make_contract( + *, + template_id: str, + col_a_role: str | None, + col_b_role: str | None, +) -> dict: + """Inline contract dict — synthetic, sample-agnostic. + + role=None → key omitted entirely (legacy / absent-role axis). + """ + builder_options: dict = { + "item_parser": "compare_row_2col_item", + "col_a_label_default": _LITERAL_COL_A, + "col_b_label_default": _LITERAL_COL_B, + } + if col_a_role is not None: + builder_options["col_a_label_default_role"] = col_a_role + if col_b_role is not None: + builder_options["col_b_label_default_role"] = col_b_role + + return { + "template_id": template_id, + "source_shape": "top_bullets", + "cardinality": {}, + "payload": { + "builder": "compare_table_2col", + "builder_options": builder_options, + }, + } + + +# ─── Policy row 1: placeholder → "" (Figma placeholder suppressed) ─ + +def test_placeholder_role_emits_empty_label_for_both_columns(): + """role=placeholder MUST suppress the catalog literal at runtime. + + This is the IMP-40 leak-fix invariant: even though the catalog still + carries a Figma placeholder string (preserved for design preview), + the builder MUST NOT inject it into the runtime payload. + """ + contract = _make_contract( + template_id="synthetic_placeholder_both", + col_a_role="placeholder", + col_b_role="placeholder", + ) + + payload = _build_compare_table_2col(_make_section(), units=[], contract=contract) + + assert payload["col_a_label"] == "" + assert payload["col_b_label"] == "" + assert _LITERAL_COL_A not in payload["col_a_label"] + assert _LITERAL_COL_B not in payload["col_b_label"] + + +# ─── Policy row 2: fallback → catalog literal (legacy behavior) ──── + +def test_fallback_role_emits_catalog_literal_for_both_columns(): + """role=fallback MUST preserve the pre-IMP-40 behavior byte-for-byte. + + Legacy contracts that explicitly opt into ``fallback`` (or migrate + forward from absent-role) must keep emitting the catalog literal so + that frames where MDX genuinely omits a header still render a + meaningful default. + """ + contract = _make_contract( + template_id="synthetic_fallback_both", + col_a_role="fallback", + col_b_role="fallback", + ) + + payload = _build_compare_table_2col(_make_section(), units=[], contract=contract) + + assert payload["col_a_label"] == _LITERAL_COL_A + assert payload["col_b_label"] == _LITERAL_COL_B + + +# ─── Policy row 3: absent role → fallback (backward compatibility) ─ + +def test_absent_role_defaults_to_fallback_for_both_columns(): + """Contracts without the new ``_role`` discriminator MUST be inert. + + Backward compatibility guard: u1/u2 only add ``_role`` keys to the + targeted F18 / F30 / F31 frames. Every other ``compare_table_2col`` + consumer in the catalog (current or future) that omits the key MUST + continue to receive the catalog literal — same as pre-IMP-40. + """ + contract = _make_contract( + template_id="synthetic_absent_role", + col_a_role=None, + col_b_role=None, + ) + + payload = _build_compare_table_2col(_make_section(), units=[], contract=contract) + + assert payload["col_a_label"] == _LITERAL_COL_A + assert payload["col_b_label"] == _LITERAL_COL_B + + +def test_partial_role_mix_is_resolved_per_column(): + """Role discriminator MUST be resolved independently per column. + + Synthetic edge case: col_a=placeholder, col_b absent. The placeholder + column emits "", the absent column falls back to its catalog literal. + Guards against any future refactor that accidentally couples the two + columns through a shared resolution path. + """ + contract = _make_contract( + template_id="synthetic_partial_mix", + col_a_role="placeholder", + col_b_role=None, + ) + + payload = _build_compare_table_2col(_make_section(), units=[], contract=contract) + + assert payload["col_a_label"] == "" + assert payload["col_b_label"] == _LITERAL_COL_B + + +# ─── Policy row 4: unknown role → ValueError (fail-fast) ─────────── + +def test_unknown_role_raises_value_error_with_contract_context(): + """role= MUST raise ValueError, not silently fall back. + + A typo or stale role value in a hand-edited catalog must surface + immediately at build time rather than being miscategorized as + "fallback" or "placeholder". Error message MUST cite the contract + template_id, the role key, and the invalid value to make catalog + repair tractable. + """ + contract = _make_contract( + template_id="synthetic_unknown_role", + col_a_role="not_a_real_role", + col_b_role="placeholder", + ) + + with pytest.raises(ValueError) as exc: + _build_compare_table_2col(_make_section(), units=[], contract=contract) + + msg = str(exc.value) + assert "synthetic_unknown_role" in msg + assert "col_a_label_default_role" in msg + assert "not_a_real_role" in msg + assert "placeholder" in msg + assert "fallback" in msg + + +# ─── u5 : F18-reuse regression — non-BIM/DX rows + placeholder role ─ + +_F18_CATALOG_LITERAL_COL_A = "BIM" # Verbatim F18 col_a_label_default (frame_contracts.yaml:476) +_F18_CATALOG_LITERAL_COL_B = "DX" # Verbatim F18 col_b_label_default (frame_contracts.yaml:477) +_F18_LEAK_TOKENS = (_F18_CATALOG_LITERAL_COL_A, _F18_CATALOG_LITERAL_COL_B) + + +def _make_f18_clone_contract() -> dict: + """F18-shaped contract — verbatim BIM/DX literals + placeholder role. + + Mirrors the catalog state after u1: BIM/DX kept as Figma visual + placeholders, but the role discriminator tells the builder to + suppress them at runtime. The ``template_id`` is namespaced + (``synthetic_f18_reuse_non_bim_dx``) so the test is not coupled to + the real F18 frame id; the leak invariant is independent of the + template_id string. + """ + return { + "template_id": "synthetic_f18_reuse_non_bim_dx", + "source_shape": "top_bullets", + "cardinality": {}, + "payload": { + "builder": "compare_table_2col", + "builder_options": { + "item_parser": "compare_row_2col_item", + "col_a_label_default": _F18_CATALOG_LITERAL_COL_A, + "col_a_label_default_role": "placeholder", + "col_b_label_default": _F18_CATALOG_LITERAL_COL_B, + "col_b_label_default_role": "placeholder", + }, + }, + } + + +def test_f18_reuse_with_non_bim_dx_rows_suppresses_catalog_placeholder(): + """F18-reuse axis (mdx 04-2 scenario, synthetic): BIM/DX MUST NOT leak. + + Models the downstream MDX that maps F18's anchor set to non-BIM/DX + content (the issue body cites 정책/조직 as a representative reuse). + With ``col_*_label_default_role="placeholder"``, the builder MUST + suppress the Figma literals at runtime. Because the synthetic rows + themselves carry no BIM / DX tokens, any appearance of those strings + anywhere in the payload would prove a catalog literal leaked through + — that is precisely the regression IMP-40 #69 must prevent. + """ + contract = _make_f18_clone_contract() + units = [ + ( + "- **정책 도입 단계**", + [" - 단기 우선순위 수립", " - 부서별 협업 강화"], + ), + ( + "- **조직 운영 구조**", + [" - 의사결정 책임자 명시", " - 정기 리뷰 사이클 운영"], + ), + ] + + payload = _build_compare_table_2col( + _make_section(), units=units, contract=contract + ) + + # Placeholder role suppression at the header axis. + assert payload["col_a_label"] == "" + assert payload["col_b_label"] == "" + + # Row content is derived from MDX-style synthetic units, not catalog. + assert len(payload["rows"]) == 2 + assert payload["rows"][0]["label"] == "정책 도입 단계" + assert payload["rows"][0]["col_a"] == "단기 우선순위 수립" + assert payload["rows"][0]["col_b"] == "부서별 협업 강화" + assert payload["rows"][1]["label"] == "조직 운영 구조" + assert payload["rows"][1]["col_a"] == "의사결정 책임자 명시" + assert payload["rows"][1]["col_b"] == "정기 리뷰 사이클 운영" + + # F18-leak invariant: BIM / DX tokens MUST NOT appear anywhere in payload. + for leak in _F18_LEAK_TOKENS: + assert leak not in payload["col_a_label"], ( + f"placeholder role failed to suppress catalog literal '{leak}' in col_a_label" + ) + assert leak not in payload["col_b_label"], ( + f"placeholder role failed to suppress catalog literal '{leak}' in col_b_label" + ) + for row in payload["rows"]: + assert leak not in row["label"] + assert leak not in row["col_a"] + assert leak not in row["col_b"]