feat(#73): IMP-44 u1~u5 layout override unknown-key guard + frontend zone_geometries validation
Some checks failed
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 23s
Some checks failed
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 23s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,7 @@ import {
|
|||||||
deriveUserOverridesKey,
|
deriveUserOverridesKey,
|
||||||
applyPersistedNonFrameOverrides,
|
applyPersistedNonFrameOverrides,
|
||||||
remapPersistedFramesToZoneFrames,
|
remapPersistedFramesToZoneFrames,
|
||||||
|
validateZoneGeometriesAgainstLayout,
|
||||||
} from "../utils/slidePlanUtils";
|
} from "../utils/slidePlanUtils";
|
||||||
import {
|
import {
|
||||||
parseMdxFile,
|
parseMdxFile,
|
||||||
@@ -154,6 +155,21 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
carriedZoneSections[targetPos].push(...zone.section_ids);
|
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 {
|
return {
|
||||||
...p,
|
...p,
|
||||||
userSelection: {
|
userSelection: {
|
||||||
@@ -162,6 +178,7 @@ export default function Home() {
|
|||||||
...p.userSelection.overrides,
|
...p.userSelection.overrides,
|
||||||
layout_preset: layoutId,
|
layout_preset: layoutId,
|
||||||
zone_sections: carriedZoneSections,
|
zone_sections: carriedZoneSections,
|
||||||
|
zone_geometries: {},
|
||||||
},
|
},
|
||||||
selectedZoneId: null,
|
selectedZoneId: null,
|
||||||
selectedRegionId: null,
|
selectedRegionId: null,
|
||||||
@@ -329,9 +346,28 @@ export default function Home() {
|
|||||||
|
|
||||||
// zone-geometry override — backend 의 build_layout_css 에 전달 (horizontal-2 /
|
// zone-geometry override — backend 의 build_layout_css 에 전달 (horizontal-2 /
|
||||||
// vertical-2 만 적용). zone_id (top/bottom/...) → slide-body 내부 0~1 비율.
|
// 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;
|
const zoneGeometries = state.userSelection.overrides.zone_geometries;
|
||||||
if (zoneGeometries && Object.keys(zoneGeometries).length > 0) {
|
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) 복귀.
|
// 2026-05-22 — IMP-08 B-3 원래 동작 (sameAsDefault with effectiveSlidePlan) 복귀.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { UserSelection, SlidePlan, Zone, InternalRegion, LayoutPresetId } from "../types/designAgent";
|
import type { UserSelection, SlidePlan, Zone, InternalRegion, LayoutPresetId } from "../types/designAgent";
|
||||||
import type { UserOverrides } from "../services/userOverridesApi";
|
import type { UserOverrides } from "../services/userOverridesApi";
|
||||||
|
import { computeZonePositions } from "../services/designAgentApi";
|
||||||
|
|
||||||
// ─── IMP-52 u6 — restore-on-reopen helpers (pure, exported for testing) ────
|
// ─── IMP-52 u6 — restore-on-reopen helpers (pure, exported for testing) ────
|
||||||
// These helpers compose persisted `user_overrides.json` payloads (typed by
|
// 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;
|
if (selection.overrides.layout_preset) return selection.overrides.layout_preset;
|
||||||
return slidePlan?.layout_preset || 'single';
|
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<string, ZoneGeometryValue>;
|
||||||
|
dropped: Record<string, ZoneGeometryValue>;
|
||||||
|
expectedPositions: string[];
|
||||||
|
valid: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateZoneGeometriesAgainstLayout(
|
||||||
|
geoms: Record<string, ZoneGeometryValue> | null | undefined,
|
||||||
|
layoutPreset: LayoutPresetId | string | null | undefined,
|
||||||
|
): ZoneGeometriesValidationResult {
|
||||||
|
const kept: Record<string, ZoneGeometryValue> = {};
|
||||||
|
const dropped: Record<string, ZoneGeometryValue> = {};
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
222
Front/client/tests/zone_geometries_validation.test.ts
Normal file
222
Front/client/tests/zone_geometries_validation.test.ts
Normal file
@@ -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<string, { x: number; y: number; w: number; h: number }>,
|
||||||
|
"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));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1923,83 +1923,139 @@ def build_layout_css(layout_preset: str, zones_data: list[dict],
|
|||||||
# ── Step D-ext : user override 처리 ──
|
# ── Step D-ext : user override 처리 ──
|
||||||
if override_zone_geometries:
|
if override_zone_geometries:
|
||||||
if layout_preset == "horizontal-2":
|
if layout_preset == "horizontal-2":
|
||||||
# heights_px override — zone 의 h 비율로 SLIDE_BODY_HEIGHT 분배.
|
# IMP-44 u1 — unknown-key guard: drop foreign-preset keys
|
||||||
# Hot-fix (2026-05-22): partial override = 나머지 공간을 비-override zone 들에
|
# (예: vertical-2 keys {left,right} sent to horizontal-2), emit
|
||||||
# 균등 분배 (drag boundary intent). 이전엔 0.0 fallback → 100/0 깨짐.
|
# structured warning, keep matching keys. All-unknown → fall
|
||||||
overridden_h = sum(
|
# through to default dynamic dispatch (no false override_applied).
|
||||||
float(override_zone_geometries[p]["h"])
|
unknown_keys = sorted(
|
||||||
for p in positions if p in override_zone_geometries
|
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]
|
if unknown_keys:
|
||||||
per_non = max(0.0, 1.0 - overridden_h) / max(len(non_overridden), 1)
|
print(
|
||||||
ratios = []
|
f" [override-warning] layout_preset={layout_preset} "
|
||||||
for pos in positions:
|
f"expected_positions={list(positions)} unknown_keys={unknown_keys} "
|
||||||
geom = override_zone_geometries.get(pos)
|
f"(dropped foreign-preset keys; default split for non-overridden).",
|
||||||
ratios.append(float(geom["h"]) if geom else per_non)
|
file=sys.stderr,
|
||||||
total = sum(ratios)
|
)
|
||||||
if total > 0:
|
filtered_overrides = {
|
||||||
heights_px = [int(round(r / total * SLIDE_BODY_HEIGHT)) for r in ratios]
|
k: v for k, v in override_zone_geometries.items() if k in positions
|
||||||
rows = " ".join(f"{h}px" for h in heights_px)
|
}
|
||||||
return {
|
if filtered_overrides:
|
||||||
"areas": preset["css_areas"],
|
# heights_px override — zone 의 h 비율로 SLIDE_BODY_HEIGHT 분배.
|
||||||
"cols": preset["css_cols"],
|
# Hot-fix (2026-05-22): partial override = 나머지 공간을 비-override zone 들에
|
||||||
"rows": rows,
|
# 균등 분배 (drag boundary intent). 이전엔 0.0 fallback → 100/0 깨짐.
|
||||||
"heights_px": heights_px,
|
overridden_h = sum(
|
||||||
"widths_px": [SLIDE_BODY_WIDTH],
|
float(filtered_overrides[p]["h"])
|
||||||
"ratios": [round(r / total, 3) for r in ratios],
|
for p in positions if p in filtered_overrides
|
||||||
"width_ratios": [1.0],
|
)
|
||||||
"computation": "user_override_geometry",
|
non_overridden = [p for p in positions if p not in filtered_overrides]
|
||||||
"dynamic_rows": True,
|
per_non = max(0.0, 1.0 - overridden_h) / max(len(non_overridden), 1)
|
||||||
"dynamic_cols": False,
|
ratios = []
|
||||||
"raw_zone_layout": {"override_applied": True, "source": override_zone_geometries},
|
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":
|
elif layout_preset == "vertical-2":
|
||||||
# cols override — zone 의 w 비율로 fr 분배 (legacy: fr-string cols).
|
# IMP-44 u1 — unknown-key guard: drop foreign-preset keys
|
||||||
# PR 1 keeps fr-string cols for legacy preserve; widths_px is
|
# (예: horizontal-2 keys {top,bottom} sent to vertical-2), emit
|
||||||
# populated in pixels for _compute_per_zone_geometry length contract.
|
# structured warning, keep matching keys. All-unknown → fall
|
||||||
# Hot-fix (2026-05-22): partial override = 나머지 공간을 비-override zone 들에
|
# through to default dynamic dispatch (no false override_applied).
|
||||||
# 균등 분배 (drag boundary intent). 이전엔 0.0 fallback → 100/0 깨짐.
|
unknown_keys = sorted(
|
||||||
overridden_w = sum(
|
k for k in override_zone_geometries if k not in positions
|
||||||
float(override_zone_geometries[p]["w"])
|
|
||||||
for p in positions if p in override_zone_geometries
|
|
||||||
)
|
)
|
||||||
non_overridden = [p for p in positions if p not in override_zone_geometries]
|
if unknown_keys:
|
||||||
per_non = max(0.0, 1.0 - overridden_w) / max(len(non_overridden), 1)
|
print(
|
||||||
ratios = []
|
f" [override-warning] layout_preset={layout_preset} "
|
||||||
for pos in positions:
|
f"expected_positions={list(positions)} unknown_keys={unknown_keys} "
|
||||||
geom = override_zone_geometries.get(pos)
|
f"(dropped foreign-preset keys; default split for non-overridden).",
|
||||||
ratios.append(float(geom["w"]) if geom else per_non)
|
file=sys.stderr,
|
||||||
total = sum(ratios)
|
)
|
||||||
if total > 0:
|
filtered_overrides = {
|
||||||
cols = " ".join(f"{round(r / total * 100, 2)}fr" for r in ratios)
|
k: v for k, v in override_zone_geometries.items() if k in positions
|
||||||
normalized = [r / total for r in ratios]
|
}
|
||||||
widths_px = [
|
if filtered_overrides:
|
||||||
int(round(rr * (SLIDE_BODY_WIDTH - gap * (len(ratios) - 1))))
|
# cols override — zone 의 w 비율로 fr 분배 (legacy: fr-string cols).
|
||||||
for rr in normalized
|
# PR 1 keeps fr-string cols for legacy preserve; widths_px is
|
||||||
]
|
# populated in pixels for _compute_per_zone_geometry length contract.
|
||||||
diff = (SLIDE_BODY_WIDTH - gap * (len(ratios) - 1)) - sum(widths_px)
|
# Hot-fix (2026-05-22): partial override = 나머지 공간을 비-override zone 들에
|
||||||
if diff != 0 and widths_px:
|
# 균등 분배 (drag boundary intent). 이전엔 0.0 fallback → 100/0 깨짐.
|
||||||
widths_px[-1] += diff
|
overridden_w = sum(
|
||||||
return {
|
float(filtered_overrides[p]["w"])
|
||||||
"areas": preset["css_areas"],
|
for p in positions if p in filtered_overrides
|
||||||
"cols": cols,
|
)
|
||||||
"rows": preset["css_rows"],
|
non_overridden = [p for p in positions if p not in filtered_overrides]
|
||||||
"heights_px": [SLIDE_BODY_HEIGHT],
|
per_non = max(0.0, 1.0 - overridden_w) / max(len(non_overridden), 1)
|
||||||
"widths_px": widths_px,
|
ratios = []
|
||||||
"ratios": [1.0],
|
for pos in positions:
|
||||||
"width_ratios": [round(rr, 3) for rr in normalized],
|
geom = filtered_overrides.get(pos)
|
||||||
"computation": "user_override_geometry",
|
ratios.append(float(geom["w"]) if geom else per_non)
|
||||||
"dynamic_rows": False,
|
total = sum(ratios)
|
||||||
"dynamic_cols": True,
|
if total > 0:
|
||||||
"raw_zone_layout": {"override_applied": True, "source": override_zone_geometries},
|
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"):
|
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).
|
# 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
|
# Degenerate inputs (total_h == 0 or total_w == 0) fall back to
|
||||||
# _build_grid_dynamic_2d inside the helper.
|
# _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:
|
else:
|
||||||
# warn-and-fallthrough preserved for remaining presets (single).
|
# warn-and-fallthrough preserved for remaining presets (single).
|
||||||
# PR 3 territory.
|
# PR 3 territory.
|
||||||
|
|||||||
@@ -156,3 +156,109 @@ def test_top_1_bottom_2_dynamic_2d_populates_geometry():
|
|||||||
assert result["dynamic_cols"] is True
|
assert result["dynamic_cols"] is True
|
||||||
assert len(result["heights_px"]) == 2 # R rows
|
assert len(result["heights_px"]) == 2 # R rows
|
||||||
assert len(result["widths_px"]) == 2 # C cols
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user