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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-24 12:12:24 +09:00
parent 5deeb97cf6
commit e0c39f1bc1
5 changed files with 565 additions and 70 deletions

View File

@@ -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) 복귀.

View File

@@ -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<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,
};
}

View 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));
});
});