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,
|
||||
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) 복귀.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
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 처리 ──
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user