feat(#93): IMP-55 u1~u12 frontend manual section swap detection (manual_section_assignment bool axis + drag-only marker gate + dual-axis persistence + backend manual-true gate)
Some checks failed
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 9s

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 08:27:09 +09:00
parent 9062931863
commit 4e281a20d8
13 changed files with 834 additions and 52 deletions

View File

@@ -170,6 +170,16 @@ export default function Home() {
const key = deriveUserOverridesKey(p.uploadedFile.name);
void saveUserOverrides(key, { zone_geometries: null });
}
// IMP-55 (#93) u12 — persist the marker reset to disk so a stale
// `manual_section_assignment: true` from a prior drag (written via
// u6's co-PUT) cannot survive the layout apply. The in-memory reset
// on line 192 protects the current session, but a page reload would
// re-seed from disk via u3's restore branch and re-arm the u7 gate.
// Unconditional — apply always resets, independent of hadPriorGeoms.
if (p.uploadedFile) {
const key = deriveUserOverridesKey(p.uploadedFile.name);
void saveUserOverrides(key, { manual_section_assignment: false });
}
return {
...p,
userSelection: {
@@ -179,6 +189,17 @@ export default function Home() {
layout_preset: layoutId,
zone_sections: carriedZoneSections,
zone_geometries: {},
// IMP-55 (#93) u5 — reset the bool intent marker to `false` on
// layout apply. `carriedZoneSections` above is auto-carry (old
// zone.section_ids → new layout positions), NOT user drag-drop
// intent. Without this explicit reset the spread of
// `...p.userSelection.overrides` would carry a prior-drag `true`
// into the new layout, causing handleGenerate (u7) to forward
// auto-carried assignments as user overrides and re-trigger the
// PARTIAL_COVERAGE regression. The marker flips back to `true`
// only when the user actually drag-drops a section in the new
// layout (u6 handleSectionDrop).
manual_section_assignment: false,
},
selectedZoneId: null,
selectedRegionId: null,
@@ -193,10 +214,27 @@ export default function Home() {
// pending 모드 취소 → 평소 (final.html iframe) 모드 복귀.
const handleCancelPendingLayout = useCallback(() => {
setPendingLayout(null);
setState((p) => ({
...p,
userSelection: createInitialUserSelection(p.slidePlan),
}));
setState((p) => {
// IMP-55 (#93) u12 — persist marker=false to disk on cancel. In-memory
// the u3 seed via createInitialUserSelection already pins false (u5
// contract), but if a prior drag-drop wrote `true` to disk via u6's
// co-PUT, that value would survive a reopen and re-arm the u7
// forwarding gate on the next page load. Symmetric with the apply
// path's disk PUT above.
if (p.uploadedFile) {
const key = deriveUserOverridesKey(p.uploadedFile.name);
void saveUserOverrides(key, { manual_section_assignment: false });
}
return {
...p,
// IMP-55 (#93) u5 — cancel discards all pending overrides via
// `createInitialUserSelection`, whose u3 seed pins
// `manual_section_assignment: false`. In-memory reset is implicit
// via the seed; u12 adds the disk-side PUT above to keep persisted
// state consistent so a reopen does not re-arm the marker.
userSelection: createInitialUserSelection(p.slidePlan),
};
});
setHasPendingChanges(false);
}, []);
@@ -354,29 +392,45 @@ export default function Home() {
}
}
// 2026-05-22 — IMP-08 B-3 원래 동작 (sameAsDefault with effectiveSlidePlan) 복귀.
// 시연 안정성 우선. section swap 은 별 path (수동 drag detection) 로 풀어야 함.
// 임시 over-aggressive fix 가 default flow 깨뜨려 PARTIAL_COVERAGE 발생했음.
const userZoneSections = state.userSelection.overrides.zone_sections;
if (userZoneSections) {
const defaultByZone = new Map<string, string[]>();
sourcePlan.zones.forEach((z) => {
defaultByZone.set(z.zone_id, z.section_ids);
});
const zoneSectionsDiff: Record<string, string[]> = {};
for (const [zoneId, sids] of Object.entries(userZoneSections)) {
if (!Array.isArray(sids)) continue;
const cleaned = sids.filter((s) => typeof s === "string" && s.trim());
const defaults = defaultByZone.get(zoneId) ?? [];
const sameAsDefault =
cleaned.length === defaults.length &&
cleaned.every((sid, i) => sid === defaults[i]);
if (!sameAsDefault) {
zoneSectionsDiff[zoneId] = cleaned;
// IMP-55 (#93) u7 — Replace the IMP-08 B-3 self-compare with the bool
// `manual_section_assignment` intent marker gate. The prior code built
// `defaultByZone` from `sourcePlan.zones` and compared against the
// user's `overrides.zone_sections`, but `sourcePlan === effectiveSlidePlan`
// (Home.tsx:305) and `effectiveSlidePlan.zones === pendingZones`
// (Home.tsx:649), which is itself derived from
// `state.userSelection.overrides.zone_sections` via slidePlanUtils.ts.
// The comparison was degenerate (user input vs itself), so real drag-drop
// swaps were classified `sameAsDefault` and silently dropped from
// `overrides.zoneSections` — the exact regression IMP-55 fixes.
// - true → forward `zone_sections` filtered to zone_ids that exist in
// `sourcePlan.zones` (cross-layout safety so foreign zone keys from a
// stale persisted layout never reach backend `--override-section-
// assignment`). u6 is the SOLE setter of true (real drag-drop).
// - false → skip. Backend determines assignment from its own default
// policy. u3 seeds false on first load, u5 resets false on layout
// apply auto-carry, u12 persists false so a stale disk `true` cannot
// survive a reopen-after-apply window.
// No `sameAsDefault` heuristic — the marker is the source of intent.
const manualMarker =
state.userSelection.overrides.manual_section_assignment;
if (manualMarker === true) {
const userZoneSections = state.userSelection.overrides.zone_sections;
if (userZoneSections) {
const validZoneIds = new Set(
sourcePlan.zones.map((z) => z.zone_id),
);
const zoneSectionsForward: Record<string, string[]> = {};
for (const [zoneId, sids] of Object.entries(userZoneSections)) {
if (!validZoneIds.has(zoneId)) continue;
if (!Array.isArray(sids)) continue;
const cleaned = sids.filter(
(s) => typeof s === "string" && s.trim(),
);
zoneSectionsForward[zoneId] = cleaned;
}
if (Object.keys(zoneSectionsForward).length > 0) {
overrides.zoneSections = zoneSectionsForward;
}
}
if (Object.keys(zoneSectionsDiff).length > 0) {
overrides.zoneSections = zoneSectionsDiff;
}
}
}
@@ -478,7 +532,21 @@ export default function Home() {
const handleSectionDrop = useCallback((sectionId: string, zoneId: string) => {
setState((p) => {
const newSelection = moveSectionToZone(p.userSelection, sectionId, zoneId);
const finalSelection = selectZone(newSelection, zoneId); // 이동된 존 자동 선택
const zoneSelected = selectZone(newSelection, zoneId); // 이동된 존 자동 선택
// IMP-55 (#93) u6 — flip the bool intent marker to `true` on real
// user drag-drop. Inverse of the u5 reset (layout apply/cancel
// auto-carry → false). handleGenerate (u7) gates `overrides.zoneSections`
// forwarding on this marker, so an unflipped drop would never reach
// the backend (the IMP-55 self-compare regression). The marker is
// flipped BEFORE persistence so the in-memory selection and the
// co-PUT body stay in sync atomically.
const finalSelection = {
...zoneSelected,
overrides: {
...zoneSelected.overrides,
manual_section_assignment: true,
},
};
// IMP-52 u7 — persist the post-drop zone_sections snapshot. The on-disk
// schema axis (`zone_sections`) shares the in-memory shape (zone_id →
// section_ids), so we forward the full mutated value; the u4 PUT path
@@ -486,10 +554,15 @@ export default function Home() {
// p.uploadedFile gate skips persistence before any MDX is loaded —
// the demo-mode initial render path would otherwise PUT to the empty
// key. saveUserOverrides is debounced (300ms) and per-key coalesced.
// IMP-55 (#93) u6 — co-PUT `manual_section_assignment: true` in the
// SAME body so the disk file never has the post-drop zone_sections
// without the marker (would otherwise look like an unmotivated
// IMP-52 zone_sections write to the u9 backend fallback).
if (p.uploadedFile) {
const key = deriveUserOverridesKey(p.uploadedFile.name);
void saveUserOverrides(key, {
zone_sections: finalSelection.overrides.zone_sections,
manual_section_assignment: true,
});
}
return { ...p, userSelection: finalSelection };

View File

@@ -65,6 +65,16 @@ export type ImageOverride = {
};
export type ImageOverridesOverride = Record<string, ImageOverride>;
/**
* IMP-55 #93 u1 — bool intent marker that gates whether persisted
* `zone_sections` are consumed by the backend pipeline. Frontend sets
* `true` only on a real user drag-drop (Home.tsx handleSectionDrop, u6)
* and `false` on layout apply/cancel auto-carry (u5/u12). Mirrors the
* Python KNOWN_AXES (`manual_section_assignment`) added in u1 and the
* Vite KNOWN_USER_OVERRIDES_AXES allowlist entry added in u1.
*/
export type ManualSectionAssignmentOverride = boolean;
/** Full on-disk schema. All axes optional — file may carry any subset. */
export interface UserOverrides {
layout: string;
@@ -72,6 +82,7 @@ export interface UserOverrides {
zone_geometries: ZoneGeometriesOverride;
zone_sections: ZoneSectionsOverride;
image_overrides: ImageOverridesOverride;
manual_section_assignment: ManualSectionAssignmentOverride;
}
/** Partial-mutation payload. `null` is the explicit clear sentinel (mirrors u4). */

View File

@@ -213,6 +213,20 @@ export interface UserSelection {
// `image_overrides` axis (KNOWN_AXES, src/user_overrides_io.py u1) and
// the typed-client `ImageOverridesOverride` (services/userOverridesApi.ts u3).
image_overrides: Record<string, { x: number; y: number; w: number; h: number }>;
// IMP-55 (#93) u3 — bool intent marker gating whether the backend
// consumes persisted `zone_sections` as a user override. Set to `true`
// only by the real drag-drop path (Home.tsx handleSectionDrop, u6); set
// back to `false` by the layout apply/cancel auto-carry path (u5/u12).
// handleGenerate (u7) reads this flag to decide whether to forward
// `overrides.zoneSections` to the backend, replacing the pre-IMP-55
// self-compare against `effectiveSlidePlan`. Seeded `false` in
// `createInitialUserSelection` and only restored on reopen when the
// persisted value is a real boolean (slidePlanUtils.ts u3 layering).
// Mirrors the on-disk axis added in u1 — Python KNOWN_AXES
// (src/user_overrides_io.py), Vite KNOWN_USER_OVERRIDES_AXES
// (Front/vite.config.ts), and `ManualSectionAssignmentOverride`
// (services/userOverridesApi.ts).
manual_section_assignment: boolean;
};
}

View File

@@ -85,6 +85,20 @@ export function applyPersistedNonFrameOverrides(
) {
next.image_overrides = { ...persisted.image_overrides };
}
// IMP-55 (#93) u3 — restore the bool intent marker only when the persisted
// value is a real `boolean`. A missing axis, `null` (the u4 clear sentinel
// observed post-flush), or any non-boolean shape (string "true", 1, {})
// intentionally falls through to the `createInitialUserSelection` seed of
// `false`. This is the fail-closed half of the marker contract: the
// backend pipeline (u9) consumes persisted `zone_sections` only when
// `manual_section_assignment is True`, so anything other than a real
// `true` MUST end up as `false` in memory to avoid resurrecting stale
// auto-carry assignments as user intent. Both `true` and `false` are
// restored verbatim (the explicit `false` from u12's apply/cancel write
// is meaningful — it pins the marker off across reopens).
if (typeof persisted.manual_section_assignment === "boolean") {
next.manual_section_assignment = persisted.manual_section_assignment;
}
return { ...selection, overrides: next };
}
@@ -160,6 +174,14 @@ export function createInitialUserSelection(slidePlan?: SlidePlan | null): UserSe
// here via `saveImageOverride` (SlideCanvas drag/resize handler) and
// are seeded on reopen via `applyPersistedNonFrameOverrides`.
image_overrides: {},
// IMP-55 (#93) u3 — bool intent marker seeded `false` so a fresh
// MDX open (no persisted file, or persisted file with axis absent)
// never forwards `overrides.zoneSections` to the backend. The marker
// flips to `true` only via the real drag-drop path (Home.tsx u6) and
// is reset to `false` by layout apply/cancel auto-carry (u5/u12).
// `applyPersistedNonFrameOverrides` may restore a persisted boolean
// verbatim on reopen — see the bool-only guard there.
manual_section_assignment: false,
},
};
}

View File

@@ -310,16 +310,44 @@ describe("KNOWN_USER_OVERRIDES_AXES (IMP-52 u4)", () => {
// The on-disk schema is shared with backend pipeline fallback (u2).
// Any drift here means a PUT could write an axis that the Python
// load() ignores, or vice-versa, silently losing user overrides.
// Mirror is Python-minus-`slide_css` (known IMP-45 #74 gap — the
// frontend never writes slide_css). IMP-55 #93 u1 adds the bool
// `manual_section_assignment` axis as a first-class allowlist entry.
expect(KNOWN_USER_OVERRIDES_AXES).toEqual([
"layout",
"zone_geometries",
"zone_sections",
"frames",
"image_overrides",
"manual_section_assignment",
]);
});
});
describe("mergeUserOverrides (IMP-55 #93 u1) — manual_section_assignment bool axis", () => {
it("merges bool true / false literally and clears on null", () => {
// The PUT handler must treat the bool axis like any other allowlisted
// axis: replace on write, preserve when absent, delete on null. Tests
// both true→false flip and explicit null-clear so the backend (u9)
// sees the exact frontend intent.
let merged = mergeUserOverrides({}, { manual_section_assignment: true });
expect(merged.manual_section_assignment).toBe(true);
merged = mergeUserOverrides(merged, { manual_section_assignment: false });
expect(merged.manual_section_assignment).toBe(false);
merged = mergeUserOverrides(merged, { manual_section_assignment: null });
expect("manual_section_assignment" in merged).toBe(false);
});
it("preserves bool axis when partial touches only a sibling axis", () => {
const existing = { manual_section_assignment: true, layout: "old" };
const merged = mergeUserOverrides(existing, { layout: "new" });
expect(merged.manual_section_assignment).toBe(true);
expect(merged.layout).toBe("new");
});
});
describe("mergeUserOverrides (IMP-52 u4)", () => {
it("only mutates KNOWN_AXES present in partial", () => {
const existing = {

View File

@@ -54,6 +54,11 @@ function makeSelection(overrides?: Partial<UserSelection["overrides"]>): UserSel
// axis declared on `UserSelection.overrides`. Empty by default so the
// existing IMP-52 cases remain unchanged in shape.
image_overrides: {},
// IMP-55 (#93) u3 — bool intent marker is REQUIRED on
// `UserSelection.overrides` (not optional). Default to `false` so every
// pre-existing fixture matches the `createInitialUserSelection` seed
// and stays compile-clean after u3 widened the type.
manual_section_assignment: false,
...overrides,
},
};
@@ -460,3 +465,88 @@ describe("image_overrides axis — saveImageOverride (IMP-51 u11)", () => {
expect(sel.overrides.image_overrides).toEqual(before);
});
});
// ─── IMP-55 (#93) u3 — manual_section_assignment bool axis ──────────────────
// Restore-on-reopen / seed coverage for the bool intent marker. Production
// branch lives at `slidePlanUtils.ts` — `applyPersistedNonFrameOverrides`
// guards with `typeof persisted.manual_section_assignment === "boolean"`,
// and `createInitialUserSelection` seeds the axis to `false`. The marker
// gates whether `handleGenerate` (u7) forwards `overrides.zoneSections`
// to the backend; the pipeline (u9) consumes persisted `zone_sections`
// only when the marker is exactly `true`, so any non-boolean payload MUST
// end up `false` in memory (fail-closed).
describe("manual_section_assignment axis — applyPersistedNonFrameOverrides (IMP-55 #93 u3)", () => {
it("restores literal true verbatim", () => {
const sel = makeSelection();
const next = applyPersistedNonFrameOverrides(sel, {
manual_section_assignment: true,
});
expect(next.overrides.manual_section_assignment).toBe(true);
});
it("restores literal false verbatim (u12 apply/cancel write must survive reopen)", () => {
// Seed `true` so the assertion proves `false` overwrites; a truthiness
// check instead of `typeof === \"boolean\"` would silently keep `true`
// and resurrect stale auto-carry assignments as user intent.
const sel = makeSelection({ manual_section_assignment: true });
const next = applyPersistedNonFrameOverrides(sel, {
manual_section_assignment: false,
});
expect(next.overrides.manual_section_assignment).toBe(false);
});
it("leaves the in-memory marker unchanged when the persisted axis is absent", () => {
const sel = makeSelection({ manual_section_assignment: true });
const next = applyPersistedNonFrameOverrides(sel, { layout: "horizontal-2" });
expect(next.overrides.manual_section_assignment).toBe(true);
expect(next.overrides.layout_preset).toBe("horizontal-2");
});
it.each([
["null clear sentinel", null],
['string "true"', "true"],
['string "false"', "false"],
["number 1", 1],
["number 0", 0],
["object {}", {}],
["array []", []],
])("ignores non-boolean payload (%s) — keeps prior in-memory value", (_label, payload) => {
const sel = makeSelection({ manual_section_assignment: true });
const next = applyPersistedNonFrameOverrides(sel, {
manual_section_assignment: payload as unknown as boolean,
});
expect(next.overrides.manual_section_assignment).toBe(true);
});
it("seeds an empty selection with manual_section_assignment=false (createInitialUserSelection)", () => {
const sel = createInitialUserSelection();
expect(sel.overrides.manual_section_assignment).toBe(false);
});
it("returns a NEW selection object (no input mutation) when restoring the marker", () => {
const sel = makeSelection({ manual_section_assignment: false });
const next = applyPersistedNonFrameOverrides(sel, {
manual_section_assignment: true,
});
expect(next).not.toBe(sel);
expect(next.overrides).not.toBe(sel.overrides);
// Input still pristine — proves the helper does not flip the fixture.
expect(sel.overrides.manual_section_assignment).toBe(false);
});
it("layers the bool axis alongside other persisted axes in a single call", () => {
const sel = makeSelection();
const next = applyPersistedNonFrameOverrides(sel, {
layout: "vertical-2",
zone_sections: { top: ["03-1"], bottom: ["03-2"] },
manual_section_assignment: true,
});
expect(next.overrides.layout_preset).toBe("vertical-2");
expect(next.overrides.zone_sections).toEqual({
top: ["03-1"],
bottom: ["03-2"],
});
expect(next.overrides.manual_section_assignment).toBe(true);
});
});

View File

@@ -559,3 +559,67 @@ describe("saveUserOverrides (IMP-51 #79 u3) — image_overrides axis", () => {
});
});
});
// ============================================================================
// IMP-55 #93 u1 — manual_section_assignment axis (7th axis) parity coverage
//
// The bool intent marker rides on the same per-axis coalescing rails as the
// 6 sibling axes. These tests lock the typed client behavior so a regression
// in the boolean serialization (e.g., coercion to "true" string, dropped
// `false` due to truthy filtering) fails here instead of in Home.tsx (u6/u7)
// or the backend gate (u9~u11).
// ============================================================================
describe("saveUserOverrides (IMP-55 #93 u1) — manual_section_assignment axis", () => {
it("PUT body carries only manual_section_assignment when it is the sole mutated axis", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03", { manual_section_assignment: true });
vi.advanceTimersByTime(300);
await drainMicrotasks();
const body = lastPutBody() as Record<string, unknown>;
expect(Object.keys(body)).toEqual(["manual_section_assignment"]);
expect(body.manual_section_assignment).toBe(true);
});
it("later-wins coalesces true → false within a single debounce window", async () => {
// Drag-then-cancel inside 300 ms — server must see only the final
// `false`, not a transient `true` that would re-enable backend
// consumption of stale zone_sections.
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03", { manual_section_assignment: true });
void saveUserOverrides("03", { manual_section_assignment: false });
vi.advanceTimersByTime(300);
await drainMicrotasks();
expect(putCallsCount()).toBe(1);
expect(lastPutBody()).toEqual({ manual_section_assignment: false });
});
it("forwards null sentinel verbatim (explicit clear)", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03", { manual_section_assignment: null });
vi.advanceTimersByTime(300);
await drainMicrotasks();
expect(lastPutBody()).toEqual({ manual_section_assignment: null });
});
it("coalesces with zone_sections sibling into a single PUT (drag-drop pair)", async () => {
// Real-world drag flow (u6): one save() sets the bool + zone_sections
// together. Asserts both axes survive coalescing as a single PUT body.
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03", {
zone_sections: { left: ["03-2"], right: ["03-1"] },
manual_section_assignment: true,
});
vi.advanceTimersByTime(300);
await drainMicrotasks();
expect(putCallsCount()).toBe(1);
expect(lastPutBody()).toEqual({
zone_sections: { left: ["03-2"], right: ["03-1"] },
manual_section_assignment: true,
});
});
});

View File

@@ -85,6 +85,22 @@ function sliceHandler(source: string, name: string): string {
return source.slice(start, end);
}
/**
* IMP-55 #93 u8 — strip JS/TS line + block comments so source-pattern
* regex checks assert against LIVE code only. The u5 / u7 docblocks in
* Home.tsx intentionally reference removed identifiers (e.g. `defaultByZone`,
* `sameAsDefault`, `zoneSectionsDiff`) and the marker axis name in prose to
* document the Stage 1 root cause for future readers — those references are
* documentation, not behavior, and must not trigger negative-match guards.
* Strips `// ...` to EOL and `/* ... */` (incl. multi-line) — keeps string
* literals intact because we only consume the result for regex-match tests.
*/
function stripComments(source: string): string {
return source
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/\/\/.*$/gm, "");
}
describe("Home.tsx write-side wiring (IMP-52 u10) — source pattern", () => {
it("handleSectionDrop persists zone_sections behind uploadedFile gate", () => {
const block = sliceHandler(HOME_TSX, "handleSectionDrop");
@@ -567,3 +583,220 @@ describe("restore-on-reopen end-to-end (IMP-52 u10)", () => {
expect(remapPersistedFramesToZoneFrames(plan, persisted.frames)).toEqual({});
});
});
// ─── IMP-55 #93 u8 — manual_section_assignment intent marker contract ─────
// Verifies four axes of the marker contract introduced in u3 (type) / u5
// (apply reset) / u6 (drag flip + co-PUT) / u7 (generate gate):
// 1) Drag dual-axis persistence — handleSectionDrop persists BOTH
// `zone_sections` AND `manual_section_assignment: true` in the SAME
// PUT body (co-PUT atomicity — disk never sees post-drop zone_sections
// without the marker).
// 2) Apply / cancel reset — handleApplyPendingLayout writes explicit
// `manual_section_assignment: false` after the `...overrides` spread,
// and handleCancelPendingLayout relies on createInitialUserSelection
// (which u3 seeds to `false`) to drop a prior `true`.
// 3) Marker-gated forwarding — handleGenerate gates `overrides.zoneSections`
// forwarding strictly on `manualMarker === true` (NOT truthiness, NOT
// `!= null`, NOT presence). u3-seeded `false` and absent values both
// skip forwarding.
// 4) sameAsDefault NOT required — the Stage 1 anti-pattern (defaultByZone
// / sameAsDefault / zoneSectionsDiff self-compare loop) is gone from
// `handleGenerate` entirely; the marker is the source of intent.
describe("IMP-55 #93 u8 — manual_section_assignment marker contract", () => {
it("handleSectionDrop sets marker true in-memory before persistence", () => {
const block = sliceHandler(HOME_TSX, "handleSectionDrop");
// finalSelection literal (built from zoneSelected, then marker = true)
// must occur BEFORE the saveUserOverrides call so the in-memory state
// and the PUT body source from the same overrides shape.
const markerIdx = block.search(/manual_section_assignment:\s*true/);
const saveIdx = block.search(/saveUserOverrides\(/);
expect(markerIdx).toBeGreaterThan(-1);
expect(saveIdx).toBeGreaterThan(-1);
expect(markerIdx).toBeLessThan(saveIdx);
});
it("handleSectionDrop co-PUTs zone_sections + manual_section_assignment:true (single body)", () => {
const block = sliceHandler(HOME_TSX, "handleSectionDrop");
// Single saveUserOverrides call carrying BOTH axes. The regex spans the
// call body to prove the two keys live in the same object literal — a
// future split into two PUTs would race the 300ms debounce and re-open
// the IMP-55 stale-disk window.
expect(block).toMatch(
/saveUserOverrides\([\s\S]*?zone_sections:[\s\S]*?manual_section_assignment:\s*true[\s\S]*?\)/,
);
// Exactly ONE saveUserOverrides call in the handler.
const calls = block.match(/saveUserOverrides\(/g) ?? [];
expect(calls.length).toBe(1);
});
it("handleApplyPendingLayout resets the marker to false in overrides literal", () => {
const block = sliceHandler(HOME_TSX, "handleApplyPendingLayout");
// After spreading `...p.userSelection.overrides`, the explicit
// `manual_section_assignment: false` overrides any prior-drag `true`.
// Without this the layout flip would carry the marker through, and u7
// would forward auto-carried assignments as user overrides → the
// PARTIAL_COVERAGE regression that motivated IMP-55.
expect(block).toMatch(/\.\.\.p\.userSelection\.overrides[\s\S]*?manual_section_assignment:\s*false/);
});
it("handleCancelPendingLayout uses createInitialUserSelection (u3 seeds false)", () => {
const block = sliceHandler(HOME_TSX, "handleCancelPendingLayout");
// Cancel discards all pending in-memory edits via the fresh-selection
// helper — the seed (u3) is the single source of truth for the
// in-memory marker on this path. u12 adds a separate disk-side
// saveUserOverrides PUT (covered by the u12 describe block below);
// the in-memory userSelection literal still has no explicit marker
// field — the seed handles it.
expect(block).toMatch(/createInitialUserSelection\(p\.slidePlan\)/);
// In-memory contract: no `manual_section_assignment` property appears
// inside the userSelection assignment. The only marker reference in
// live code lives inside the u12 saveUserOverrides(...) call body.
const codeOnly = stripComments(block);
expect(codeOnly).not.toMatch(
/userSelection:[\s\S]*?manual_section_assignment/,
);
});
it("handleGenerate gates overrides.zoneSections on manualMarker === true (strict bool)", () => {
const block = sliceHandler(HOME_TSX, "handleGenerate");
// Marker read AND strict-equality gate. `===` not `==`, not truthiness,
// not presence — so `false` / absent both skip forwarding (fail-closed).
expect(block).toMatch(/state\.userSelection\.overrides\.manual_section_assignment/);
expect(block).toMatch(/manualMarker\s*===\s*true/);
// The assignment to `overrides.zoneSections` must live INSIDE the
// marker-true branch.
const gateIdx = block.search(/if\s*\(\s*manualMarker\s*===\s*true\s*\)/);
const assignIdx = block.search(/overrides\.zoneSections\s*=/);
expect(gateIdx).toBeGreaterThan(-1);
expect(assignIdx).toBeGreaterThan(gateIdx);
});
it("handleGenerate filters forwarded zone_sections to valid zone_ids only (cross-layout safety)", () => {
const block = sliceHandler(HOME_TSX, "handleGenerate");
// A stale persisted layout could carry zone_ids that do not exist in
// the current sourcePlan (e.g. horizontal-2 `top`/`bottom` while the
// current layout is vertical-2 `left`/`right`). Those foreign keys
// must be dropped before reaching the backend `--override-section-
// assignment` so they cannot trigger PARTIAL_COVERAGE.
expect(block).toMatch(/validZoneIds\s*=\s*new Set\(\s*sourcePlan\.zones\.map\(\(z\)\s*=>\s*z\.zone_id\)/);
expect(block).toMatch(/if\s*\(!validZoneIds\.has\(zoneId\)\)\s*continue/);
});
it("handleGenerate no longer contains the IMP-08 B-3 self-compare anti-pattern", () => {
// Strip comments — the u7 docblock intentionally references the removed
// identifiers (`defaultByZone` / `sameAsDefault` / `zoneSectionsDiff`)
// in prose to explain the Stage 1 root cause for future readers; the
// regression we guard against is the LIVE code re-emerging.
const block = stripComments(sliceHandler(HOME_TSX, "handleGenerate"));
// The Stage 1 root cause: these identifiers compared user input against
// itself (sourcePlan === effectiveSlidePlan → zones === pendingZones,
// both derived from the same overrides.zone_sections). u7 deleted the
// entire block.
expect(block).not.toMatch(/\bdefaultByZone\b/);
expect(block).not.toMatch(/\bsameAsDefault\b/);
expect(block).not.toMatch(/\bzoneSectionsDiff\b/);
});
it("co-PUT payload contract: marker=true + zone_sections land in a single PUT body", async () => {
fetchMock.mockResolvedValue(mockResponse({}));
// Shape produced by handleSectionDrop after the u6 marker flip.
void saveUserOverrides("03_demo", {
zone_sections: { left: ["03-2"], right: ["03-1"] },
manual_section_assignment: true,
});
vi.advanceTimersByTime(300);
await drainMicrotasks();
expect(fetchMock).toHaveBeenCalledTimes(1);
const body = lastPutBody() as Record<string, unknown>;
// Both axes in the same PUT body — exact equality, not arrayContaining,
// because any extra axis would mean a foreign mutation leaked through.
expect(Object.keys(body).sort()).toEqual(
["manual_section_assignment", "zone_sections"].sort(),
);
expect(body.manual_section_assignment).toBe(true);
expect(body.zone_sections).toEqual({ left: ["03-2"], right: ["03-1"] });
});
it("co-PUT payload contract: marker=false carries explicitly through saveUserOverrides", async () => {
// u12 will add the apply/cancel explicit `false` PUT; the typed client
// must already propagate the literal `false` through the debounce
// bucket. A truthiness-based coalesce in the bucket merge would drop
// the value and re-open the stale-disk window. This locks the wire
// contract independently of the u12 caller-site write.
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03_demo", { manual_section_assignment: false });
vi.advanceTimersByTime(300);
await drainMicrotasks();
const body = lastPutBody() as Record<string, unknown>;
expect(Object.keys(body)).toEqual(["manual_section_assignment"]);
expect(body.manual_section_assignment).toBe(false);
});
});
// ─── IMP-55 #93 u12 — stale-disk marker reset on apply / cancel ───────────
// u5 resets the in-memory marker on layout apply, and u3's seed via
// `createInitialUserSelection` resets it on cancel. But the disk persists
// independently — a prior drag wrote `true` via u6's co-PUT, so after a
// page reload the u3 restore branch would re-seed `true` and the u7 gate
// would forward auto-carried section assignments → PARTIAL_COVERAGE
// regression. u12 closes that window by writing `manual_section_assignment:
// false` to disk via saveUserOverrides on both apply and cancel paths.
describe("IMP-55 #93 u12 — stale-disk marker reset on layout apply/cancel", () => {
it("handleApplyPendingLayout source contains a marker=false saveUserOverrides PUT", () => {
const block = sliceHandler(HOME_TSX, "handleApplyPendingLayout");
// Stripped-comment source so the u5 docblock prose doesn't satisfy the
// assertion — must be a real call expression.
const code = stripComments(block);
// Uploaded-file gate (mirrors the u6 / other handler pattern — the
// demo-mode initial render path must not PUT to an empty key).
expect(code).toMatch(
/if\s*\(\s*p\.uploadedFile\s*\)[\s\S]*?saveUserOverrides\([\s\S]*?manual_section_assignment:\s*false[\s\S]*?\)/,
);
expect(code).toMatch(/deriveUserOverridesKey\(p\.uploadedFile\.name\)/);
});
it("handleCancelPendingLayout source contains a marker=false saveUserOverrides PUT", () => {
const block = sliceHandler(HOME_TSX, "handleCancelPendingLayout");
const code = stripComments(block);
// Cancel handler converts from arrow-body to function-body for the
// disk PUT; the in-memory reset still comes from createInitialUserSelection.
expect(code).toMatch(
/if\s*\(\s*p\.uploadedFile\s*\)[\s\S]*?saveUserOverrides\([\s\S]*?manual_section_assignment:\s*false[\s\S]*?\)/,
);
expect(code).toMatch(/createInitialUserSelection\(p\.slidePlan\)/);
});
it("apply path PUT payload: marker=false carries alone (no auto-carry leakage)", async () => {
// The apply handler issues a dedicated PUT for the marker reset that is
// independent of the (conditional) zone_geometries PUT and of the
// in-memory zone_sections rewrite. The wire contract for this PUT must
// contain only the marker — if zone_sections leaked into the same body
// it would re-arm the u9 backend fallback gate against u12's intent.
fetchMock.mockResolvedValue(mockResponse({}));
void saveUserOverrides("03_demo", { manual_section_assignment: false });
vi.advanceTimersByTime(300);
await drainMicrotasks();
expect(fetchMock).toHaveBeenCalledTimes(1);
const body = lastPutBody() as Record<string, unknown>;
expect(Object.keys(body)).toEqual(["manual_section_assignment"]);
expect(body.manual_section_assignment).toBe(false);
});
it("apply path PUT is unconditional (does NOT gate on hadPriorGeoms)", () => {
// The u4 zone_geometries PUT inside handleApplyPendingLayout is
// conditional (`p.uploadedFile && hadPriorGeoms`). The u12 marker PUT
// must NOT inherit that gate — a stale disk `true` can exist without
// any prior zone_geometries, so the reset must always fire.
const code = stripComments(sliceHandler(HOME_TSX, "handleApplyPendingLayout"));
// Locate the marker PUT and verify its enclosing `if` clause is just
// `p.uploadedFile`, not the compound `... && hadPriorGeoms` guard.
const markerCallMatch = code.match(
/if\s*\(([^)]*)\)\s*\{[^}]*saveUserOverrides\([^)]*manual_section_assignment:\s*false[^)]*\)/,
);
expect(markerCallMatch).not.toBeNull();
if (markerCallMatch) {
expect(markerCallMatch[1].trim()).toBe("p.uploadedFile");
}
});
});