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)
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:
kmlee1andClaude Opus 4.7 committed 2026-05-25 08:27:09 +09:00
1 parent 9062931863
commit 4e281a20d8
13 files changed
+834 -52

No files matched your search

+100 -27
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 };