diff --git a/Front/client/src/pages/Home.tsx b/Front/client/src/pages/Home.tsx index 0fab4b7..8854e93 100644 --- a/Front/client/src/pages/Home.tsx +++ b/Front/client/src/pages/Home.tsx @@ -19,6 +19,7 @@ import { deriveUserOverridesKey, applyPersistedNonFrameOverrides, remapPersistedFramesToZoneFrames, + validateZoneGeometriesAgainstLayout, } from "../utils/slidePlanUtils"; import { parseMdxFile, @@ -154,6 +155,21 @@ export default function Home() { } carriedZoneSections[targetPos].push(...zone.section_ids); }); + // IMP-44 (#73) u4 — clear in-memory zone_geometries on layout flip. + // The persisted keys were valid for the *prior* preset; carrying them + // forward into the new preset would either trip the u1/u2 backend + // [override-warning] guards (foreign keys dropped, override_applied + // forced back to None) or partially apply on shared keys. Drop them + // up-front so the new layout starts from a clean even-split baseline, + // and persist a clear sentinel (null) so a subsequent reopen does not + // resurrect the stale snapshot from user_overrides.json. + const priorGeoms = p.userSelection.overrides.zone_geometries; + const hadPriorGeoms = + priorGeoms && typeof priorGeoms === "object" && Object.keys(priorGeoms).length > 0; + if (p.uploadedFile && hadPriorGeoms) { + const key = deriveUserOverridesKey(p.uploadedFile.name); + void saveUserOverrides(key, { zone_geometries: null }); + } return { ...p, userSelection: { @@ -162,6 +178,7 @@ export default function Home() { ...p.userSelection.overrides, layout_preset: layoutId, zone_sections: carriedZoneSections, + zone_geometries: {}, }, selectedZoneId: null, selectedRegionId: null, @@ -329,9 +346,28 @@ export default function Home() { // zone-geometry override — backend 의 build_layout_css 에 전달 (horizontal-2 / // vertical-2 만 적용). zone_id (top/bottom/...) → slide-body 내부 0~1 비율. + // IMP-44 (#73) u4 — validate against the active layout *before* the + // round-trip so foreign-preset keys never reach the backend. Mirrors + // the u1/u2 WARN+DROP guards on the frontend side: dropped keys surface + // as a toast (so the user knows why their resize "vanished"), and only + // the `kept` subset is forwarded. The active layout = the layout the + // backend will use, which is `overrides.layout` when the user has set + // one, else the default slidePlan preset (mirrors backend resolution). const zoneGeometries = state.userSelection.overrides.zone_geometries; if (zoneGeometries && Object.keys(zoneGeometries).length > 0) { - overrides.zoneGeometries = zoneGeometries; + const activeLayout = overrides.layout ?? sourcePlan.layout_preset; + const validation = validateZoneGeometriesAgainstLayout( + zoneGeometries, + activeLayout, + ); + if (Object.keys(validation.dropped).length > 0) { + toast.error( + `zone_geometries layout-mismatch: dropped ${Object.keys(validation.dropped).join(", ")} (expected ${validation.expectedPositions.join(", ") || "—"}; layout=${activeLayout}).`, + ); + } + if (Object.keys(validation.kept).length > 0) { + overrides.zoneGeometries = validation.kept; + } } // 2026-05-22 — IMP-08 B-3 원래 동작 (sameAsDefault with effectiveSlidePlan) 복귀. diff --git a/Front/client/src/utils/slidePlanUtils.ts b/Front/client/src/utils/slidePlanUtils.ts index 79ec225..f2aa653 100644 --- a/Front/client/src/utils/slidePlanUtils.ts +++ b/Front/client/src/utils/slidePlanUtils.ts @@ -1,5 +1,6 @@ import type { UserSelection, SlidePlan, Zone, InternalRegion, LayoutPresetId } from "../types/designAgent"; import type { UserOverrides } from "../services/userOverridesApi"; +import { computeZonePositions } from "../services/designAgentApi"; // ─── IMP-52 u6 — restore-on-reopen helpers (pure, exported for testing) ──── // These helpers compose persisted `user_overrides.json` payloads (typed by @@ -320,3 +321,77 @@ export function getEffectiveLayoutId(slidePlan: SlidePlan | null, selection: Use if (selection.overrides.layout_preset) return selection.overrides.layout_preset; return slidePlan?.layout_preset || 'single'; } + +// ─── IMP-44 (#73) u3 — zone_geometries layout-mismatch validation ─────────── +// Pure helper paired with the backend [override-warning] guards added in u1 +// (1-D horizontal-2 / vertical-2 branches of `build_layout_css`) and u2 (2-D +// `_override_to_grid_tracks` call site). Same WARN+DROP / KEEP-known contract, +// but expressed on the frontend so handleGenerate (u4) can validate against +// the active layout *before* forwarding and surface a toast on dropped keys. +// +// Source of truth for expected positions = `computeZonePositions(layoutPreset)` +// (designAgentApi.ts), which mirrors backend `layouts.yaml` (positions field). +// Unknown layout (null / undefined / not in LAYOUT_PRESET_IDS) ⇒ fail-safe +// drop-all: caller has no contract for projecting geometries onto an unknown +// preset, so we keep zero keys rather than passing them through verbatim. + +export interface ZoneGeometryValue { + x: number; + y: number; + w: number; + h: number; +} + +export interface ZoneGeometriesValidationResult { + kept: Record; + dropped: Record; + expectedPositions: string[]; + valid: boolean; +} + +export function validateZoneGeometriesAgainstLayout( + geoms: Record | null | undefined, + layoutPreset: LayoutPresetId | string | null | undefined, +): ZoneGeometriesValidationResult { + const kept: Record = {}; + const dropped: Record = {}; + const safeGeoms = + geoms && typeof geoms === "object" && !Array.isArray(geoms) ? geoms : null; + + // Unknown-layout fail-safe — drop everything; no expected positions known. + if (typeof layoutPreset !== "string" || !LAYOUT_PRESET_IDS.has(layoutPreset)) { + if (safeGeoms) { + for (const [k, v] of Object.entries(safeGeoms)) { + dropped[k] = v; + } + } + return { + kept, + dropped, + expectedPositions: [], + valid: Object.keys(dropped).length === 0, + }; + } + + const expectedPositions = computeZonePositions( + layoutPreset as LayoutPresetId, + ).map((p) => p.name); + const expectedSet = new Set(expectedPositions); + + if (safeGeoms) { + for (const [k, v] of Object.entries(safeGeoms)) { + if (expectedSet.has(k)) { + kept[k] = v; + } else { + dropped[k] = v; + } + } + } + + return { + kept, + dropped, + expectedPositions, + valid: Object.keys(dropped).length === 0, + }; +} diff --git a/Front/client/tests/zone_geometries_validation.test.ts b/Front/client/tests/zone_geometries_validation.test.ts new file mode 100644 index 0000000..c0ece48 --- /dev/null +++ b/Front/client/tests/zone_geometries_validation.test.ts @@ -0,0 +1,222 @@ +// IMP-44 (#73) u3 — vitest coverage for `validateZoneGeometriesAgainstLayout`. +// +// Pairs with the backend [override-warning] guards added in u1 (1-D +// horizontal-2 / vertical-2 branches of `build_layout_css`) and u2 (2-D +// `_override_to_grid_tracks` call site). Same WARN+DROP unknown / KEEP known +// contract; this helper lets handleGenerate (u4) validate against the active +// layout before forwarding so the user sees a toast on dropped keys rather +// than the backend silently even-splitting non-overridden zones with a false +// `computation=user_override_geometry` signal. +// +// Cases (Stage 2 scope-lock): +// 1) horizontal-2 → vertical-2 mismatch (all keys dropped) +// 2) passthrough (all keys recognized) +// 3) partial mix (some kept, some dropped) +// 4) empty input ({} on a known layout) +// 5) unknown-layout fail-safe (preset null / undefined / unknown string) + +import { describe, it, expect } from "vitest"; +import { validateZoneGeometriesAgainstLayout } from "../src/utils/slidePlanUtils"; + +const g = (x: number, y: number, w: number, h: number) => ({ x, y, w, h }); + +describe("validateZoneGeometriesAgainstLayout (IMP-44 u3)", () => { + // ── 1. mismatch ────────────────────────────────────────────────────────── + it("drops horizontal-2 keys when the active layout is vertical-2", () => { + const result = validateZoneGeometriesAgainstLayout( + { top: g(0, 0, 1, 0.4), bottom: g(0, 0.4, 1, 0.6) }, + "vertical-2", + ); + expect(result.kept).toEqual({}); + expect(result.dropped).toEqual({ + top: g(0, 0, 1, 0.4), + bottom: g(0, 0.4, 1, 0.6), + }); + expect(result.expectedPositions).toEqual(["left", "right"]); + expect(result.valid).toBe(false); + }); + + it("drops vertical-2 keys when the active layout is horizontal-2", () => { + const result = validateZoneGeometriesAgainstLayout( + { left: g(0, 0, 0.5, 1), right: g(0.5, 0, 0.5, 1) }, + "horizontal-2", + ); + expect(result.kept).toEqual({}); + expect(Object.keys(result.dropped).sort()).toEqual(["left", "right"]); + expect(result.expectedPositions).toEqual(["top", "bottom"]); + expect(result.valid).toBe(false); + }); + + // ── 2. passthrough ─────────────────────────────────────────────────────── + it("keeps all keys when every input key is in the active layout positions", () => { + const input = { + top: g(0, 0, 1, 0.4), + bottom: g(0, 0.4, 1, 0.6), + }; + const result = validateZoneGeometriesAgainstLayout(input, "horizontal-2"); + expect(result.kept).toEqual(input); + expect(result.dropped).toEqual({}); + expect(result.expectedPositions).toEqual(["top", "bottom"]); + expect(result.valid).toBe(true); + }); + + it("passes a single 'primary' key through on the 'single' preset", () => { + const result = validateZoneGeometriesAgainstLayout( + { primary: g(0, 0, 1, 1) }, + "single", + ); + expect(result.kept).toEqual({ primary: g(0, 0, 1, 1) }); + expect(result.dropped).toEqual({}); + expect(result.expectedPositions).toEqual(["primary"]); + expect(result.valid).toBe(true); + }); + + it("recognizes the 2-D preset positions reported by computeZonePositions (top-1-bottom-2)", () => { + const input = { + top: g(0, 0, 1, 0.5), + "bottom-left": g(0, 0.5, 0.5, 0.5), + "bottom-right": g(0.5, 0.5, 0.5, 0.5), + }; + const result = validateZoneGeometriesAgainstLayout(input, "top-1-bottom-2"); + expect(result.kept).toEqual(input); + expect(result.dropped).toEqual({}); + expect(result.expectedPositions).toEqual([ + "top", + "bottom-left", + "bottom-right", + ]); + expect(result.valid).toBe(true); + }); + + // ── 3. partial mix ─────────────────────────────────────────────────────── + it("keeps known keys and drops unknown keys on a partial-mix input", () => { + const result = validateZoneGeometriesAgainstLayout( + { top: g(0, 0, 1, 0.4), foo: g(0, 0, 1, 1) }, + "horizontal-2", + ); + expect(result.kept).toEqual({ top: g(0, 0, 1, 0.4) }); + expect(result.dropped).toEqual({ foo: g(0, 0, 1, 1) }); + expect(result.expectedPositions).toEqual(["top", "bottom"]); + expect(result.valid).toBe(false); + }); + + it("on a 2-D preset, keeps known 2-D track keys and drops legacy 1-D keys", () => { + // Simulates the user resizing under top-1-bottom-2, then flipping to + // grid-2x2 — legacy `bottom-left` stays valid; `top` (no longer a 2x2 + // position) gets dropped. + const result = validateZoneGeometriesAgainstLayout( + { + top: g(0, 0, 1, 0.5), + "bottom-left": g(0, 0.5, 0.5, 0.5), + "top-left": g(0, 0, 0.5, 0.5), + }, + "grid-2x2", + ); + expect(result.kept).toEqual({ + "bottom-left": g(0, 0.5, 0.5, 0.5), + "top-left": g(0, 0, 0.5, 0.5), + }); + expect(result.dropped).toEqual({ top: g(0, 0, 1, 0.5) }); + expect(result.expectedPositions).toEqual([ + "top-left", + "top-right", + "bottom-left", + "bottom-right", + ]); + expect(result.valid).toBe(false); + }); + + // ── 4. empty input ─────────────────────────────────────────────────────── + it("returns empty kept/dropped and valid=true on an empty {} input", () => { + const result = validateZoneGeometriesAgainstLayout({}, "horizontal-2"); + expect(result.kept).toEqual({}); + expect(result.dropped).toEqual({}); + expect(result.expectedPositions).toEqual(["top", "bottom"]); + expect(result.valid).toBe(true); + }); + + it("treats null / undefined geoms as empty input (no throw, valid=true on a known layout)", () => { + const nullResult = validateZoneGeometriesAgainstLayout(null, "vertical-2"); + expect(nullResult.kept).toEqual({}); + expect(nullResult.dropped).toEqual({}); + expect(nullResult.expectedPositions).toEqual(["left", "right"]); + expect(nullResult.valid).toBe(true); + + const undefResult = validateZoneGeometriesAgainstLayout( + undefined, + "vertical-2", + ); + expect(undefResult.kept).toEqual({}); + expect(undefResult.dropped).toEqual({}); + expect(undefResult.expectedPositions).toEqual(["left", "right"]); + expect(undefResult.valid).toBe(true); + }); + + it("ignores array payloads (defensive against hand-edited persisted files)", () => { + const result = validateZoneGeometriesAgainstLayout( + [] as unknown as Record, + "horizontal-2", + ); + expect(result.kept).toEqual({}); + expect(result.dropped).toEqual({}); + expect(result.expectedPositions).toEqual(["top", "bottom"]); + expect(result.valid).toBe(true); + }); + + // ── 5. unknown-layout fail-safe ────────────────────────────────────────── + it("drops every input key when layout is null (fail-safe)", () => { + const result = validateZoneGeometriesAgainstLayout( + { top: g(0, 0, 1, 0.4), bottom: g(0, 0.4, 1, 0.6) }, + null, + ); + expect(result.kept).toEqual({}); + expect(result.dropped).toEqual({ + top: g(0, 0, 1, 0.4), + bottom: g(0, 0.4, 1, 0.6), + }); + expect(result.expectedPositions).toEqual([]); + expect(result.valid).toBe(false); + }); + + it("drops every input key when layout is undefined (fail-safe)", () => { + const result = validateZoneGeometriesAgainstLayout( + { primary: g(0, 0, 1, 1) }, + undefined, + ); + expect(result.kept).toEqual({}); + expect(result.dropped).toEqual({ primary: g(0, 0, 1, 1) }); + expect(result.expectedPositions).toEqual([]); + expect(result.valid).toBe(false); + }); + + it("drops every input key when layout is an unknown preset string (fail-safe)", () => { + const result = validateZoneGeometriesAgainstLayout( + { top: g(0, 0, 1, 0.4) }, + "rogue-preset" as unknown as string, + ); + expect(result.kept).toEqual({}); + expect(result.dropped).toEqual({ top: g(0, 0, 1, 0.4) }); + expect(result.expectedPositions).toEqual([]); + expect(result.valid).toBe(false); + }); + + it("returns empty kept/dropped/expectedPositions when layout is unknown AND geoms is empty", () => { + const result = validateZoneGeometriesAgainstLayout({}, null); + expect(result.kept).toEqual({}); + expect(result.dropped).toEqual({}); + expect(result.expectedPositions).toEqual([]); + // No keys to drop ⇒ vacuously valid; handleGenerate (u4) gates the toast + // on `Object.keys(dropped).length > 0`, not `valid`, so this is safe. + expect(result.valid).toBe(true); + }); + + // ── purity / mutation safety ───────────────────────────────────────────── + it("does not mutate the input geometries object", () => { + const input = { top: g(0, 0, 1, 0.4), foo: g(0, 0, 1, 1) }; + const inputKeysBefore = Object.keys(input).sort(); + validateZoneGeometriesAgainstLayout(input, "horizontal-2"); + expect(Object.keys(input).sort()).toEqual(inputKeysBefore); + // Sample value still pristine. + expect(input.top).toEqual(g(0, 0, 1, 0.4)); + }); +}); diff --git a/src/phase_z2_pipeline.py b/src/phase_z2_pipeline.py index fd798b1..daa2f1e 100644 --- a/src/phase_z2_pipeline.py +++ b/src/phase_z2_pipeline.py @@ -1923,83 +1923,139 @@ def build_layout_css(layout_preset: str, zones_data: list[dict], # ── Step D-ext : user override 처리 ── if override_zone_geometries: if layout_preset == "horizontal-2": - # heights_px override — zone 의 h 비율로 SLIDE_BODY_HEIGHT 분배. - # Hot-fix (2026-05-22): partial override = 나머지 공간을 비-override zone 들에 - # 균등 분배 (drag boundary intent). 이전엔 0.0 fallback → 100/0 깨짐. - overridden_h = sum( - float(override_zone_geometries[p]["h"]) - for p in positions if p in override_zone_geometries + # IMP-44 u1 — unknown-key guard: drop foreign-preset keys + # (예: vertical-2 keys {left,right} sent to horizontal-2), emit + # structured warning, keep matching keys. All-unknown → fall + # through to default dynamic dispatch (no false override_applied). + unknown_keys = sorted( + k for k in override_zone_geometries if k not in positions ) - non_overridden = [p for p in positions if p not in override_zone_geometries] - per_non = max(0.0, 1.0 - overridden_h) / max(len(non_overridden), 1) - ratios = [] - for pos in positions: - geom = override_zone_geometries.get(pos) - ratios.append(float(geom["h"]) if geom else per_non) - total = sum(ratios) - if total > 0: - heights_px = [int(round(r / total * SLIDE_BODY_HEIGHT)) for r in ratios] - rows = " ".join(f"{h}px" for h in heights_px) - return { - "areas": preset["css_areas"], - "cols": preset["css_cols"], - "rows": rows, - "heights_px": heights_px, - "widths_px": [SLIDE_BODY_WIDTH], - "ratios": [round(r / total, 3) for r in ratios], - "width_ratios": [1.0], - "computation": "user_override_geometry", - "dynamic_rows": True, - "dynamic_cols": False, - "raw_zone_layout": {"override_applied": True, "source": override_zone_geometries}, - } + if unknown_keys: + print( + f" [override-warning] layout_preset={layout_preset} " + f"expected_positions={list(positions)} unknown_keys={unknown_keys} " + f"(dropped foreign-preset keys; default split for non-overridden).", + file=sys.stderr, + ) + filtered_overrides = { + k: v for k, v in override_zone_geometries.items() if k in positions + } + if filtered_overrides: + # heights_px override — zone 의 h 비율로 SLIDE_BODY_HEIGHT 분배. + # Hot-fix (2026-05-22): partial override = 나머지 공간을 비-override zone 들에 + # 균등 분배 (drag boundary intent). 이전엔 0.0 fallback → 100/0 깨짐. + overridden_h = sum( + float(filtered_overrides[p]["h"]) + for p in positions if p in filtered_overrides + ) + non_overridden = [p for p in positions if p not in filtered_overrides] + per_non = max(0.0, 1.0 - overridden_h) / max(len(non_overridden), 1) + ratios = [] + for pos in positions: + geom = filtered_overrides.get(pos) + ratios.append(float(geom["h"]) if geom else per_non) + total = sum(ratios) + if total > 0: + heights_px = [int(round(r / total * SLIDE_BODY_HEIGHT)) for r in ratios] + rows = " ".join(f"{h}px" for h in heights_px) + return { + "areas": preset["css_areas"], + "cols": preset["css_cols"], + "rows": rows, + "heights_px": heights_px, + "widths_px": [SLIDE_BODY_WIDTH], + "ratios": [round(r / total, 3) for r in ratios], + "width_ratios": [1.0], + "computation": "user_override_geometry", + "dynamic_rows": True, + "dynamic_cols": False, + "raw_zone_layout": {"override_applied": True, "source": filtered_overrides}, + } elif layout_preset == "vertical-2": - # cols override — zone 의 w 비율로 fr 분배 (legacy: fr-string cols). - # PR 1 keeps fr-string cols for legacy preserve; widths_px is - # populated in pixels for _compute_per_zone_geometry length contract. - # Hot-fix (2026-05-22): partial override = 나머지 공간을 비-override zone 들에 - # 균등 분배 (drag boundary intent). 이전엔 0.0 fallback → 100/0 깨짐. - overridden_w = sum( - float(override_zone_geometries[p]["w"]) - for p in positions if p in override_zone_geometries + # IMP-44 u1 — unknown-key guard: drop foreign-preset keys + # (예: horizontal-2 keys {top,bottom} sent to vertical-2), emit + # structured warning, keep matching keys. All-unknown → fall + # through to default dynamic dispatch (no false override_applied). + unknown_keys = sorted( + k for k in override_zone_geometries if k not in positions ) - non_overridden = [p for p in positions if p not in override_zone_geometries] - per_non = max(0.0, 1.0 - overridden_w) / max(len(non_overridden), 1) - ratios = [] - for pos in positions: - geom = override_zone_geometries.get(pos) - ratios.append(float(geom["w"]) if geom else per_non) - total = sum(ratios) - if total > 0: - cols = " ".join(f"{round(r / total * 100, 2)}fr" for r in ratios) - normalized = [r / total for r in ratios] - widths_px = [ - int(round(rr * (SLIDE_BODY_WIDTH - gap * (len(ratios) - 1)))) - for rr in normalized - ] - diff = (SLIDE_BODY_WIDTH - gap * (len(ratios) - 1)) - sum(widths_px) - if diff != 0 and widths_px: - widths_px[-1] += diff - return { - "areas": preset["css_areas"], - "cols": cols, - "rows": preset["css_rows"], - "heights_px": [SLIDE_BODY_HEIGHT], - "widths_px": widths_px, - "ratios": [1.0], - "width_ratios": [round(rr, 3) for rr in normalized], - "computation": "user_override_geometry", - "dynamic_rows": False, - "dynamic_cols": True, - "raw_zone_layout": {"override_applied": True, "source": override_zone_geometries}, - } + if unknown_keys: + print( + f" [override-warning] layout_preset={layout_preset} " + f"expected_positions={list(positions)} unknown_keys={unknown_keys} " + f"(dropped foreign-preset keys; default split for non-overridden).", + file=sys.stderr, + ) + filtered_overrides = { + k: v for k, v in override_zone_geometries.items() if k in positions + } + if filtered_overrides: + # cols override — zone 의 w 비율로 fr 분배 (legacy: fr-string cols). + # PR 1 keeps fr-string cols for legacy preserve; widths_px is + # populated in pixels for _compute_per_zone_geometry length contract. + # Hot-fix (2026-05-22): partial override = 나머지 공간을 비-override zone 들에 + # 균등 분배 (drag boundary intent). 이전엔 0.0 fallback → 100/0 깨짐. + overridden_w = sum( + float(filtered_overrides[p]["w"]) + for p in positions if p in filtered_overrides + ) + non_overridden = [p for p in positions if p not in filtered_overrides] + per_non = max(0.0, 1.0 - overridden_w) / max(len(non_overridden), 1) + ratios = [] + for pos in positions: + geom = filtered_overrides.get(pos) + ratios.append(float(geom["w"]) if geom else per_non) + total = sum(ratios) + if total > 0: + cols = " ".join(f"{round(r / total * 100, 2)}fr" for r in ratios) + normalized = [r / total for r in ratios] + widths_px = [ + int(round(rr * (SLIDE_BODY_WIDTH - gap * (len(ratios) - 1)))) + for rr in normalized + ] + diff = (SLIDE_BODY_WIDTH - gap * (len(ratios) - 1)) - sum(widths_px) + if diff != 0 and widths_px: + widths_px[-1] += diff + return { + "areas": preset["css_areas"], + "cols": cols, + "rows": preset["css_rows"], + "heights_px": [SLIDE_BODY_HEIGHT], + "widths_px": widths_px, + "ratios": [1.0], + "width_ratios": [round(rr, 3) for rr in normalized], + "computation": "user_override_geometry", + "dynamic_rows": False, + "dynamic_cols": True, + "raw_zone_layout": {"override_applied": True, "source": filtered_overrides}, + } elif topology in ("T", "inverted-T", "side-T-left", "side-T-right", "2x2"): # IMP-09 PR 2 — 2-D override path (T / inverted-T / side-T / 2x2). # Degenerate inputs (total_h == 0 or total_w == 0) fall back to # _build_grid_dynamic_2d inside the helper. - return _override_to_grid_tracks( - preset, zones_data, override_zone_geometries, gap=gap + # + # IMP-44 u2 — unknown-key guard mirrors u1 (1-D): drop foreign- + # preset keys (예: vertical-2 keys {left,right} sent to T-preset), + # emit structured warning, keep matching keys. All-unknown → fall + # through to _build_grid_dynamic_2d default (no false override_applied). + unknown_keys = sorted( + k for k in override_zone_geometries if k not in positions ) + if unknown_keys: + print( + f" [override-warning] layout_preset={layout_preset} " + f"expected_positions={list(positions)} unknown_keys={unknown_keys} " + f"(dropped foreign-preset keys; default split for non-overridden).", + file=sys.stderr, + ) + filtered_overrides = { + k: v for k, v in override_zone_geometries.items() if k in positions + } + if filtered_overrides: + return _override_to_grid_tracks( + preset, zones_data, filtered_overrides, gap=gap + ) + return _build_grid_dynamic_2d(preset, zones_data, gap=gap) else: # warn-and-fallthrough preserved for remaining presets (single). # PR 3 territory. diff --git a/tests/phase_z2/test_build_layout_css_pr1.py b/tests/phase_z2/test_build_layout_css_pr1.py index 486865e..a799db7 100644 --- a/tests/phase_z2/test_build_layout_css_pr1.py +++ b/tests/phase_z2/test_build_layout_css_pr1.py @@ -156,3 +156,109 @@ def test_top_1_bottom_2_dynamic_2d_populates_geometry(): assert result["dynamic_cols"] is True assert len(result["heights_px"]) == 2 # R rows assert len(result["widths_px"]) == 2 # C cols + + +# ────────────────────── IMP-44 u5 regression ────────────────────── +# Regression coverage for the layout-override unknown-key guard +# (Stage 1 root-cause #73). Asserts that foreign-preset keys are +# dropped, structured [override-warning] is emitted, and +# computation=user_override_geometry is NEVER reported when the +# kept-key set is empty (no false override_applied=true). + + +def test_imp44_h2_with_v2_keys_emits_warning_and_falls_through(capsys): + """horizontal-2 receiving vertical-2 keys (left/right) → all-unknown: + drop both, emit warning, fall through to dynamic dispatch. + computation must NOT be user_override_geometry.""" + zones = [_zone("top", 0.6), _zone("bottom", 0.4)] + override = { + "left": {"x": 0, "y": 0, "w": 0.5, "h": 1.0}, + "right": {"x": 0.5, "y": 0, "w": 0.5, "h": 1.0}, + } + result = build_layout_css( + "horizontal-2", zones, override_zone_geometries=override + ) + captured = capsys.readouterr() + assert "[override-warning]" in captured.err + assert "layout_preset=horizontal-2" in captured.err + assert "unknown_keys=['left', 'right']" in captured.err + assert "expected_positions=['top', 'bottom']" in captured.err + # All-unknown → no override applied (no silent fallback). + assert result["computation"] != "user_override_geometry" + raw = result.get("raw_zone_layout") or {} + if isinstance(raw, dict): + assert raw.get("override_applied") is not True + + +def test_imp44_v2_with_h2_keys_emits_warning_and_falls_through(capsys): + """vertical-2 receiving horizontal-2 keys (top/bottom) → all-unknown: + drop both, emit warning, fall through to dynamic dispatch. + computation must NOT be user_override_geometry.""" + zones = [_zone("left", 0.5), _zone("right", 0.5)] + override = { + "top": {"x": 0, "y": 0, "w": 1.0, "h": 0.3}, + "bottom": {"x": 0, "y": 0.3, "w": 1.0, "h": 0.7}, + } + result = build_layout_css( + "vertical-2", zones, override_zone_geometries=override + ) + captured = capsys.readouterr() + assert "[override-warning]" in captured.err + assert "layout_preset=vertical-2" in captured.err + assert "unknown_keys=['bottom', 'top']" in captured.err + assert "expected_positions=['left', 'right']" in captured.err + assert result["computation"] != "user_override_geometry" + raw = result.get("raw_zone_layout") or {} + if isinstance(raw, dict): + assert raw.get("override_applied") is not True + + +def test_imp44_partial_mix_keeps_known_drops_unknown(capsys): + """horizontal-2 receiving {top (known), left (unknown)}: keep top, + drop left, emit warning naming only 'left'. override_applied=True + must hold and the source must contain only the kept key.""" + zones = [_zone("top", 0.6), _zone("bottom", 0.4)] + override = { + "top": {"x": 0, "y": 0, "w": 1.0, "h": 0.3}, + "left": {"x": 0, "y": 0, "w": 0.5, "h": 1.0}, + } + result = build_layout_css( + "horizontal-2", zones, override_zone_geometries=override + ) + captured = capsys.readouterr() + assert "[override-warning]" in captured.err + assert "unknown_keys=['left']" in captured.err + # Known key applied → user_override_geometry computation. + assert result["computation"] == "user_override_geometry" + raw = result["raw_zone_layout"] + assert raw["override_applied"] is True + assert set(raw["source"].keys()) == {"top"} + # Sanity: top ratio (0.3) drives heights_px[0] < heights_px[1]. + assert result["heights_px"][0] < result["heights_px"][1] + + +def test_imp44_2d_preset_with_h2_keys_emits_warning_and_falls_through(capsys): + """2-D preset (top-1-bottom-2) receiving horizontal-2 keys + (top/bottom): all-unknown vs T positions + {top, bottom-left, bottom-right} → drop all, emit warning, + fall through to 2-D dynamic dispatch.""" + zones = [ + _zone("top", 0.5), + _zone("bottom-left", 0.25), + _zone("bottom-right", 0.25), + ] + override = { + "bottom": {"x": 0, "y": 0.3, "w": 1.0, "h": 0.7}, + } + result = build_layout_css( + "top-1-bottom-2", zones, override_zone_geometries=override + ) + captured = capsys.readouterr() + assert "[override-warning]" in captured.err + assert "layout_preset=top-1-bottom-2" in captured.err + assert "unknown_keys=['bottom']" in captured.err + # All-unknown → 2-D dynamic fallback (not user_override_geometry). + assert result["computation"] == "2d_dynamic_aggregated" + raw = result.get("raw_zone_layout") or {} + if isinstance(raw, dict): + assert raw.get("override_applied") is not True