diff --git a/Front/client/src/components/SlideCanvas.tsx b/Front/client/src/components/SlideCanvas.tsx index 87783d4..5e003c1 100644 --- a/Front/client/src/components/SlideCanvas.tsx +++ b/Front/client/src/components/SlideCanvas.tsx @@ -612,6 +612,28 @@ export default function SlideCanvas({ : null; const previewUrl = previewCandidate?.thumbnailUrl ?? null; + // IMP-11 u4: active frame lookup — distinct axis from preview. + // preview is shown only when override differs from default; active is + // always defined as override-if-present-else-default. Used by u5 to + // compare the active frame's catalog min_height_px against zone height. + const activeFrameId = overrideFrameId ?? defaultFrameId; + const activeCandidate = activeFrameId + ? region?.frame_candidates?.find((c) => c.id === activeFrameId) + : undefined; + + // IMP-11 u5: catalog min_height_px violation hint. height is already + // a fraction of SLIDE_H (1280x720 logical px coordinate space), so + // logical px = height * SLIDE_H. measuredSlideBody.h is intentionally + // not re-multiplied (double-apply would shrink the comparison value). + // Hint is pendingLayout-only; resize clamp (minSize=0.05) is unchanged. + const zoneHeightPx = isPendingLayout ? height * SLIDE_H : null; + const minHeightPx = activeCandidate?.minHeightPx ?? null; + const belowMinHeight = + isPendingLayout && + minHeightPx != null && + zoneHeightPx != null && + zoneHeightPx < minHeightPx; + return (
)} + {/* IMP-11 u5: red border + 'min H Npx' badge when zone height + is below the active frame's catalog min_height_px. Visual + hint only, no clamp/resize behavior change. */} + {belowMinHeight && minHeightPx != null && ( + <> +
+ + min H {minHeightPx}px + + + )} + {/* zone 라벨 — 좌상단. 주 라벨 = section ids (S1, S1+S2), 부 라벨 = backend zone position (top, bottom, primary). */}
diff --git a/Front/client/src/services/designAgentApi.ts b/Front/client/src/services/designAgentApi.ts index d7f4a61..b2ec5d0 100644 --- a/Front/client/src/services/designAgentApi.ts +++ b/Front/client/src/services/designAgentApi.ts @@ -527,6 +527,10 @@ export async function loadRun(runId: string): Promise { // backend step09 의 catalog_registered (frame_contracts.yaml 등록 여부). // v4_all_judgments 에만 있음. v4_candidates fallback 시 undefined. catalogRegistered: c.catalog_registered, + // backend step09 의 min_height_px (frame_contracts.yaml visual_hints.min_height_px). + // logical 1280x720 px 좌표계. contract 미등록 또는 visual_hints 부재 시 undefined. + // v4_all_judgments 에만 있음. v4_candidates fallback 시 undefined (graceful). + minHeightPx: c.min_height_px ?? undefined, })); const displayStrategy = ( diff --git a/Front/client/src/types/designAgent.ts b/Front/client/src/types/designAgent.ts index a6ff01b..5ca099f 100644 --- a/Front/client/src/types/designAgent.ts +++ b/Front/client/src/types/designAgent.ts @@ -127,6 +127,10 @@ export interface FrameCandidate { /** backend frame_contracts.yaml 에 catalog 등록 여부. false 면 사용자가 override * 시도해도 Step 7-A 가 skip (render path 미연결). UI 회색 + "render path 미적용" 표시. */ catalogRegistered?: boolean; + /** IMP-11 D-2 — frame contract visual_hints.min_height_px (logical 1280x720 px). + * Source = templates/phase_z2/catalog/frame_contracts.yaml visual_hints.min_height_px. + * Undefined when contract unregistered or visual_hints absent (frontend tolerates undefined). */ + minHeightPx?: number; } // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/phase_z2_pipeline.py b/src/phase_z2_pipeline.py index d0b2205..56983ee 100644 --- a/src/phase_z2_pipeline.py +++ b/src/phase_z2_pipeline.py @@ -3969,6 +3969,24 @@ def run_phase_z2_mvp1( "delegated_to": delegated, }) + # IMP-11 D-2 (u1) — per-candidate min_height_px source = catalog + # frame_contracts[template_id].visual_hints.min_height_px (logical 1280×720 px). + # None when contract unregistered (frontend tolerates undefined). + # Single get_contract lookup binds both catalog_registered and min_height_px. + v4_all_judgments_list = [] + for c in v4_all_for_unit: + _contract = get_contract(c.template_id) + v4_all_judgments_list.append({ + "template_id": c.template_id, + "frame_id": c.frame_id, + "frame_number": c.frame_number, + "v4_rank": c.v4_rank, + "confidence": c.confidence, + "label": c.label, + "catalog_registered": _contract is not None, + "min_height_px": (_contract or {}).get("visual_hints", {}).get("min_height_px"), + }) + application_plan_units.append({ "unit_id": unit_id, "layout_preset": layout_preset, @@ -4004,18 +4022,8 @@ def run_phase_z2_mvp1( # v4_all_judgments 는 reject 포함. # catalog_registered = frame_contracts.yaml 에 contract 있는지 여부. # false 면 사용자가 override 시도해도 Step 7-A 가 skip (render path 미연결). - "v4_all_judgments": [ - { - "template_id": c.template_id, - "frame_id": c.frame_id, - "frame_number": c.frame_number, - "v4_rank": c.v4_rank, - "confidence": c.confidence, - "label": c.label, - "catalog_registered": get_contract(c.template_id) is not None, - } - for c in v4_all_for_unit - ], + # IMP-11 D-2 (u1) : per-candidate min_height_px added (None when unregistered). + "v4_all_judgments": v4_all_judgments_list, "application_candidates": app_candidates, # IMP-06 blocker-fix (Codex #13 Blocker 3 / #16) — plan-aware # additive fields. None / False / [] when no override CLI used. diff --git a/tests/test_phase_z2_step9_v4_all_judgments_min_height.py b/tests/test_phase_z2_step9_v4_all_judgments_min_height.py new file mode 100644 index 0000000..7aa1139 --- /dev/null +++ b/tests/test_phase_z2_step9_v4_all_judgments_min_height.py @@ -0,0 +1,169 @@ +"""IMP-11 D-2 (u1) — Step 9 v4_all_judgments[] min_height_px field tests. + +u1 contract: + Each v4_all_judgments[] entry MUST expose `min_height_px` sourced from + catalog `frame_contracts[template_id].visual_hints.min_height_px` + (logical 1280×720 px), with `None` fallback when contract is unregistered. + A single `get_contract(c.template_id)` lookup binds both + `catalog_registered` and `min_height_px` (no double-lookup cost). + +Production code = inline list builder in `run_phase_z2_mvp1` +(`src/phase_z2_pipeline.py`, near v4_all_for_unit loop). These tests follow +the same source-string + catalog-shape guard pattern as the existing +`test_step9_production_emits_candidate_evidence_and_alias` in +`tests/test_phase_z2_v4_fallback.py`, kept until a helper is extracted. +""" +from __future__ import annotations + +import inspect + +from src import phase_z2_pipeline +from phase_z2_mapper import get_contract, load_frame_contracts + + +# ─── Case 1 : u1 production-source guard ──────────────────────────────────── + + +def test_v4_all_judgments_emits_min_height_px_with_none_fallback(): + """Source guard — single get_contract bound to `_contract`, then both + `catalog_registered` and `min_height_px` derived from that binding. + `min_height_px` uses the `(_contract or {})` chain so unregistered + contracts propagate `None` (frontend tolerates undefined). + """ + source = inspect.getsource(phase_z2_pipeline) + + # u1 marker present (locates the builder) + assert "IMP-11 D-2 (u1)" in source + + # Single get_contract lookup bound to local var + assert "_contract = get_contract(c.template_id)" in source + + # catalog_registered reuses the local binding (no second lookup) + assert '"catalog_registered": _contract is not None' in source + + # min_height_px source = visual_hints chain; None when contract is None + assert ( + '"min_height_px": (_contract or {})' + '.get("visual_hints", {})' + '.get("min_height_px")' + ) in source + + # v4_all_judgments wires the new builder list + assert '"v4_all_judgments": v4_all_judgments_list' in source + + +# ─── Case 2 : additive guarantee — existing 7 fields preserved ────────────── + + +def test_v4_all_judgments_preserves_existing_fields(): + """u1 is additive only — the existing 7 keys must remain in the per-entry + dict alongside the new `min_height_px`. + """ + source = inspect.getsource(phase_z2_pipeline) + + builder_start = source.find("IMP-11 D-2 (u1)") + assert builder_start != -1 + builder_end = source.find("application_plan_units.append", builder_start) + assert builder_end != -1 + builder = source[builder_start:builder_end] + + for field in ( + '"template_id": c.template_id', + '"frame_id": c.frame_id', + '"frame_number": c.frame_number', + '"v4_rank": c.v4_rank', + '"confidence": c.confidence', + '"label": c.label', + '"catalog_registered": _contract is not None', + '"min_height_px":', + ): + assert field in builder, f"missing field in u1 builder: {field!r}" + + +# ─── Case 3 : catalog reality — visual_hints.min_height_px shape is real ──── + + +def test_catalog_visual_hints_min_height_px_path_is_real(): + """The source-string guard depends on the actual catalog shape having + `visual_hints.min_height_px` as a positive int on registered contracts + whose `visual_hints` block declares it. Verify against the real + `frame_contracts.yaml` so a future catalog schema change cannot silently + invalidate the `.get("visual_hints", {}).get("min_height_px")` chain. + """ + load_frame_contracts() + + # Real registered template_ids that ship with visual_hints.min_height_px + # (verified via load_frame_contracts() — see frame_contracts.yaml). + sample_template_ids = ( + "three_parallel_requirements", + "process_product_two_way", + "construction_goals_three_circle_intersection", + "bim_dx_comparison_table", + ) + + found = 0 + for tid in sample_template_ids: + contract = get_contract(tid) + if contract is None: + continue # tolerate catalog rename — at least one must remain + # The exact .get chain used by the u1 builder + min_h = (contract or {}).get("visual_hints", {}).get("min_height_px") + assert isinstance(min_h, int), ( + f"{tid}: visual_hints.min_height_px must be int, " + f"got {type(min_h).__name__}={min_h!r}" + ) + assert min_h > 0, f"{tid}: min_height_px must be positive, got {min_h}" + found += 1 + + assert found > 0, ( + "no sample registered contract present — catalog audit drift; " + "update sample_template_ids to match current frame_contracts.yaml" + ) + + +def test_registered_contract_without_min_height_px_propagates_none(): + """Registered contract whose `visual_hints` block omits `min_height_px` + (or sets it to `null`) must also propagate `None` through the u1 chain. + Real example in current catalog: `bim_issues_quadrant_four`. + """ + load_frame_contracts() + + tid = "bim_issues_quadrant_four" + contract = get_contract(tid) + if contract is None: + import pytest # noqa: PLC0415 — runtime skip only when catalog drifts + pytest.skip(f"sample template {tid!r} no longer registered") + + # Exact chain used by u1 builder + min_h = (contract or {}).get("visual_hints", {}).get("min_height_px") + assert min_h is None, ( + f"{tid}: expected None when visual_hints.min_height_px is absent/null, " + f"got {min_h!r} — chain semantics changed" + ) + # catalog_registered must still be True (additive, independent of value) + assert (contract is not None) is True + + +# ─── Case 4 : None propagation for unregistered template_id ───────────────── + + +def test_unregistered_template_id_propagates_none(): + """When `get_contract(template_id)` returns `None`, the u1 chain + `(_contract or {}).get("visual_hints", {}).get("min_height_px")` must + yield `None` (frontend tolerates undefined; no KeyError). + """ + load_frame_contracts() + + # Synthetic template_id guaranteed not to be in the catalog + unregistered = "MOCK_template_unregistered_for_u1_test" + assert get_contract(unregistered) is None, ( + "test precondition broken — synthetic template_id leaked into catalog" + ) + + # Replicate the u1 chain exactly + _contract = get_contract(unregistered) + min_height = (_contract or {}).get("visual_hints", {}).get("min_height_px") + catalog_registered = _contract is not None + + assert min_height is None + assert catalog_registered is False