From 4da22adb4300d974c6d9f5c6937293ad3d797166 Mon Sep 17 00:00:00 2001 From: kyeongmin Date: Tue, 26 May 2026 06:12:13 +0900 Subject: [PATCH] feat(#90): IMP-56 u1-u19 catch-up before final close (post-u20 push fix) u1: text_overrides axis in user_overrides_io u2: structure_overrides axis in user_overrides_io u3: vite allowlist for new endpoints u4: text_override_resolver u5: Step 12 text_overrides apply in phase_z2_pipeline u6: structure_override_resolver u7: text_path_stamper u8: SlideCanvas text-edit capture u9: SlideCanvas structure-edit overlay u10: userOverridesApi service extension u11: designAgent types extension u12: slidePlanUtils restore u13: user_overrides endpoint tests u14: user_overrides restore tests u15: pipeline fallback tests u16: edit-mode state + gating tests u17: slide_base print mode CSS u18: /api/connect endpoint (vite) u19: /api/export endpoint (vite) Recovery scope: 29 files (12 modified + 17 new). u20 already pushed in 9439575; this commit lands u1-u19 that were authored but not committed before #90 was externally closed. Co-Authored-By: Claude Opus 4.7 --- Front/client/src/components/SlideCanvas.tsx | 348 +++++++++++++--- .../src/components/StructureEditOverlay.tsx | 165 ++++++++ Front/client/src/services/userOverridesApi.ts | 33 ++ Front/client/src/types/designAgent.ts | 9 + Front/client/src/utils/slidePlanUtils.ts | 133 +++++- .../tests/imp90_connect_endpoint.test.ts | 282 +++++++++++++ .../tests/imp90_edit_mode_gating.test.tsx | 219 ++++++++++ .../tests/imp90_edit_mode_state.test.tsx | 133 ++++++ .../tests/imp90_export_endpoint.test.ts | 255 ++++++++++++ .../tests/imp90_structure_overlay.test.tsx | 150 +++++++ .../tests/imp90_text_edit_capture.test.tsx | 259 ++++++++++++ .../tests/user_overrides_endpoint.test.ts | 22 +- .../tests/user_overrides_restore.test.ts | 154 +++++++ Front/vite.config.ts | 258 +++++++++++- src/phase_z2_pipeline.py | 268 +++++++++++- src/structure_override_resolver.py | 189 +++++++++ src/text_override_resolver.py | 143 +++++++ src/text_path_stamper.py | 155 +++++++ src/user_overrides_io.py | 36 +- templates/phase_z2/slide_base.html | 49 +++ tests/phase_z2/test_slide_base_print_mode.py | 151 +++++++ tests/test_phase_z2_structure_overrides.py | 216 ++++++++++ tests/test_phase_z2_text_overrides.py | 158 +++++++ tests/test_phase_z2_text_path_stamper.py | 212 ++++++++++ tests/test_structure_override_resolver.py | 392 ++++++++++++++++++ tests/test_text_override_resolver.py | 188 +++++++++ tests/test_text_path_stamper.py | 307 ++++++++++++++ tests/test_user_overrides_io.py | 56 ++- .../test_user_overrides_pipeline_fallback.py | 75 ++++ 29 files changed, 4937 insertions(+), 78 deletions(-) create mode 100644 Front/client/src/components/StructureEditOverlay.tsx create mode 100644 Front/client/tests/imp90_connect_endpoint.test.ts create mode 100644 Front/client/tests/imp90_edit_mode_gating.test.tsx create mode 100644 Front/client/tests/imp90_edit_mode_state.test.tsx create mode 100644 Front/client/tests/imp90_export_endpoint.test.ts create mode 100644 Front/client/tests/imp90_structure_overlay.test.tsx create mode 100644 Front/client/tests/imp90_text_edit_capture.test.tsx create mode 100644 src/structure_override_resolver.py create mode 100644 src/text_override_resolver.py create mode 100644 src/text_path_stamper.py create mode 100644 tests/phase_z2/test_slide_base_print_mode.py create mode 100644 tests/test_phase_z2_structure_overrides.py create mode 100644 tests/test_phase_z2_text_overrides.py create mode 100644 tests/test_phase_z2_text_path_stamper.py create mode 100644 tests/test_structure_override_resolver.py create mode 100644 tests/test_text_override_resolver.py create mode 100644 tests/test_text_path_stamper.py diff --git a/Front/client/src/components/SlideCanvas.tsx b/Front/client/src/components/SlideCanvas.tsx index 2f3b1d8..2250890 100644 --- a/Front/client/src/components/SlideCanvas.tsx +++ b/Front/client/src/components/SlideCanvas.tsx @@ -28,7 +28,12 @@ import { crossedDragThreshold, type ImageDragDirection, } from "./slideCanvasDragMath"; -import type { ImageOverridesOverride } from "../services/userOverridesApi"; +import type { + ImageOverridesOverride, + StructureOverridesOverride, + StructureOverridePerZone, +} from "../services/userOverridesApi"; +import StructureEditOverlay from "./StructureEditOverlay"; interface SlideCanvasProps { slidePlan: SlidePlan | null; @@ -73,11 +78,112 @@ interface SlideCanvasProps { imageId: string, geometry: { x: number; y: number; w: number; h: number } ) => void; + /** IMP-90 (#90) u13 — focusout-emitted capture; u15 debounces + PUTs. */ + onTextEdit?: (capture: TextEditCapture) => void; + /** IMP-90 (#90) u14 — persisted structure overrides per zone + * (slot_order + hidden_slots). When `editMode === "structure"` the + * StructureEditOverlay reads from this to render the current state. */ + structureOverrides?: StructureOverridesOverride; + /** IMP-90 (#90) u14 — emitted whenever the user reorders or hides a + * slot in structure-mode. u15 will debounce + PUT to /api/user- + * overrides; u14 only exposes the capture. SCOPE LOCK: inner shape is + * `{slot_order, hidden_slots}` only (frame swap stays on `frames` axis). */ + onStructureEdit?: (zoneId: string, capture: StructureOverridePerZone) => void; } const SLIDE_W = 1280; const SLIDE_H = 720; +// IMP-90 (#90) u11 — discriminated edit mode. Replaces the prior single +// `isEditMode` boolean. u11 introduces the enum + the toolbar UI surface; +// gesture gating (text contentEditable vs structure reorder vs image-zone +// drag/resize) stays unified behind `isEditMode = editMode !== 'off'` so +// existing behavior is preserved byte-identical. u12 will discriminate the +// gestures per mode (mutually exclusive). The 'off' state is the no-edit +// baseline; 'image-zone' bundles image edit (#79) + zone resize (#81) +// because both are pointer-driven canvas gestures on slide geometry. +export type EditMode = "off" | "text" | "structure" | "image-zone"; +export const EDIT_MODES: ReadonlyArray = ["text", "structure", "image-zone"]; +/** Pure helper — given the current edit mode and the user's requested mode, + * return the next mode. Clicking the active mode toggles back to 'off'; + * clicking a different mode switches; explicit 'off' always exits. */ +export function nextEditMode(current: EditMode, requested: EditMode): EditMode { + if (requested === "off") return "off"; + return current === requested ? "off" : requested; +} + +// IMP-90 (#90) u12 — per-mode gesture gating. Pure helper deriving the +// boolean gates that drive SlideCanvas's useEffect branches (designMode +// + iframe-side image click listener) and JSX conditionals (iframe +// pointer-events, zone resize/drag affordances, image overlay). The +// mapping enforces the mutually-exclusive contract from the issue body: +// text -> contentEditable + iframe pointer-events:auto only. +// structure -> nothing here; u14 will plant the structure overlay. +// image-zone -> zone resize/drag + image overlay; iframe pe:auto so +// in-iframe user-content images can be click-selected. +// off -> every gate false (baseline). +// pendingLayout fully suppresses every gate — mirrors the existing +// useEffect (line ~248) that forces editMode='off' on pendingLayout +// entry. The helper still defensively returns all-false so a stray +// pendingLayout=true with a non-'off' editMode never leaks gestures. +export interface EditModeGates { + textEditing: boolean; + imageSelection: boolean; + iframePointerAuto: boolean; + zoneGestures: boolean; + imageOverlay: boolean; +} +export function computeEditModeGates( + editMode: EditMode, + isPendingLayout: boolean +): EditModeGates { + if (isPendingLayout) { + return { + textEditing: false, + imageSelection: false, + iframePointerAuto: false, + zoneGestures: false, + imageOverlay: false, + }; + } + return { + textEditing: editMode === "text", + imageSelection: editMode === "image-zone", + iframePointerAuto: editMode === "text" || editMode === "image-zone", + zoneGestures: editMode === "image-zone", + imageOverlay: editMode === "image-zone", + }; +} + +// IMP-90 (#90) u13 — pure helper resolving a contentEditable focusout +// target into (zoneId, textPath, value). data-text-path stamped by u8 at +// Step 13; .zone[data-zone-position] from Phase Z slide-base. Non-stamped +// targets return null so capture silently skips. u15 will debounce + PUT. +export interface TextEditCaptureTarget { + closest(selector: string): TextEditCaptureTarget | null; + getAttribute(name: string): string | null; + textContent: string | null; +} +export interface TextEditCapture { + zoneId: string; + textPath: string; + value: string; +} +export function deriveTextEditCapture( + target: TextEditCaptureTarget | null +): TextEditCapture | null { + if (!target) return null; + const lineEl = target.closest("[data-text-path]"); + if (!lineEl) return null; + const textPath = lineEl.getAttribute("data-text-path"); + if (!textPath) return null; + const zoneEl = lineEl.closest(".zone[data-zone-position]"); + if (!zoneEl) return null; + const zoneId = zoneEl.getAttribute("data-zone-position"); + if (!zoneId) return null; + return { zoneId, textPath, value: (lineEl.textContent ?? "").trim() }; +} + export default function SlideCanvas({ slidePlan, userSelection, @@ -93,6 +199,9 @@ export default function SlideCanvas({ onZoneResize, imageOverrides, onImageResize, + onTextEdit, + structureOverrides, + onStructureEdit, }: SlideCanvasProps) { const containerRef = useRef(null); const [scale, setScale] = useState(1); @@ -135,7 +244,15 @@ export default function SlideCanvas({ // HTML 편집 모드 — 글벗 패턴 (designMode + contentEditable + outline CSS) 차용. // 활성 시 iframe 안 텍스트 element 직접 클릭하여 수정 가능. backend 반영은 별 작업. // pendingLayout 과 배타적 (충돌 방지). - const [isEditMode, setIsEditMode] = useState(false); + // IMP-90 (#90) u11 — `editMode` enum replaces the prior boolean. The + // `isEditMode` shim is kept ONLY for the pendingLayout coupling + + // zone-wrapper visual cues (border / hover / selected styling) that + // fire whenever any edit mode is active. u12 routes gesture-activating + // gates through `editGates` so text / structure / image-zone gestures + // are mutually exclusive. + const [editMode, setEditMode] = useState("off"); + const isEditMode = editMode !== "off"; + const editGates = computeEditModeGates(editMode, !!isPendingLayout); const iframeRef = useRef(null); // 편집 모드 toggle 시 iframe contentDocument 에 글벗 패턴 적용 / 해제. @@ -159,11 +276,22 @@ export default function SlideCanvas({ const editableTags = ["DIV", "P", "H1", "H2", "H3", "H4", "SPAN", "LI", "TD", "TH", "FIGCAPTION"]; let inputHandler: ((e: Event) => void) | null = null; + // IMP-90 (#90) u13 — focusout (= bubbling blur) emits one capture per + // finished line edit; u15 will debounce + PUT. + let textEditCaptureHandler: ((e: Event) => void) | null = null; // IMP-51 (#79) u8 — user-content image click listeners installed // inside the iframe contentDocument. Tracked here so the cleanup // callback can remove them when edit mode exits (or iframe reloads). const imageClickBindings: Array<{ el: HTMLImageElement; handler: (e: Event) => void; prevCursor: string; prevOutline: string }> = []; - if (isEditMode) { + + // IMP-90 (#90) u12 — text-editing gate: only the 'text' editMode + // turns designMode on + makes the editable tags contentEditable. + // The else branch tears the prior state down so leaving text mode + // (to structure / image-zone / off) immediately disables in-place + // text editing — required for mutual exclusivity vs the image-zone + // overlay's drag/resize gestures (a contentEditable cursor would + // otherwise be placed by every image click). + if (editGates.textEditing) { doc.designMode = "on"; doc.querySelectorAll(".slide *").forEach((el) => { if (editableTags.includes((el as HTMLElement).tagName)) { @@ -176,11 +304,28 @@ export default function SlideCanvas({ }; doc.addEventListener("input", inputHandler); - // IMP-51 (#79) u8 — wire click → selectedImageId on every stamped - // user-content image. Selector mirrors USER_CONTENT_IMAGE_SELECTOR - // in src/image_id_stamper.py (+ requires data-image-id which the - // stamper always emits). Decorative / frame imgs lacking the role - // attribute are intentionally NOT clickable here. + textEditCaptureHandler = (ev: Event) => { + const cap = deriveTextEditCapture( + ev.target as unknown as TextEditCaptureTarget | null + ); + if (cap) onTextEdit?.(cap); + }; + doc.addEventListener("focusout", textEditCaptureHandler); + } else { + doc.designMode = "off"; + doc.querySelectorAll("[contenteditable]").forEach((el) => { + (el as HTMLElement).removeAttribute("contenteditable"); + }); + } + + // IMP-90 (#90) u12 — image-selection gate: only the 'image-zone' + // editMode wires the in-iframe user-content image click → selection. + // Selector mirrors USER_CONTENT_IMAGE_SELECTOR in image_id_stamper.py + // (requires data-image-id which the stamper always emits). Decorative + // / frame imgs lacking the role attribute are NOT clickable. The + // else branch clears `selectedImageId` so the React-side overlay + // never lingers on a non-image-zone edit mode. + if (editGates.imageSelection) { const imgEls = doc.querySelectorAll( '.slide img[data-image-role="user-content"][data-image-id]' ); @@ -200,12 +345,6 @@ export default function SlideCanvas({ imageClickBindings.push({ el: imgEl, handler, prevCursor, prevOutline }); }); } else { - doc.designMode = "off"; - doc.querySelectorAll("[contenteditable]").forEach((el) => { - (el as HTMLElement).removeAttribute("contenteditable"); - }); - // edit-mode exit also clears stale image selection so the handle - // overlay never lingers on a non-editable iframe. setSelectedImageId(null); } @@ -213,19 +352,60 @@ export default function SlideCanvas({ if (inputHandler && doc) { doc.removeEventListener("input", inputHandler); } + if (textEditCaptureHandler && doc) { + doc.removeEventListener("focusout", textEditCaptureHandler); + } imageClickBindings.forEach(({ el, handler, prevCursor, prevOutline }) => { el.removeEventListener("click", handler); el.style.cursor = prevCursor; el.style.outline = prevOutline; }); }; - }, [isEditMode, finalHtmlUrl, onContentEdit]); + }, [editGates.textEditing, editGates.imageSelection, finalHtmlUrl, onContentEdit, onTextEdit]); // pendingLayout 진입 시 편집 모드 자동 OFF (충돌 방지). useEffect(() => { - if (isPendingLayout && isEditMode) setIsEditMode(false); + if (isPendingLayout && isEditMode) setEditMode("off"); }, [isPendingLayout, isEditMode]); + // IMP-90 (#90) u14 — discover slot keys per zone for the structure + // overlay. Source = iframe DOM `data-text-path="{slot_key}.{line_index}"` + // attributes stamped by u8 (`src/text_path_stamper.py`). Unique slot_key + // prefixes per `.zone[data-zone-position]` form the overlay's slot list. + // Discovery runs only when entering structure mode (and resets on exit + // or iframe reload) so off / text / image-zone modes never pay this + // traversal cost. + const [slotKeysByZone, setSlotKeysByZone] = useState< + Record + >({}); + useEffect(() => { + if (editMode !== "structure" || isPendingLayout) { + setSlotKeysByZone({}); + return; + } + const doc = iframeRef.current?.contentDocument; + if (!doc) return; + const next: Record = {}; + doc.querySelectorAll(".zone[data-zone-position]").forEach((zEl) => { + const zoneId = (zEl as HTMLElement).getAttribute("data-zone-position"); + if (!zoneId) return; + const seen = new Set(); + const keys: string[] = []; + zEl.querySelectorAll("[data-text-path]").forEach((lineEl) => { + const path = (lineEl as HTMLElement).getAttribute("data-text-path"); + if (!path) return; + const lastDot = path.lastIndexOf("."); + const slotKey = lastDot > 0 ? path.slice(0, lastDot) : path; + if (slotKey && !seen.has(slotKey)) { + seen.add(slotKey); + keys.push(slotKey); + } + }); + next[zoneId] = keys; + }); + setSlotKeysByZone(next); + }, [editMode, isPendingLayout, finalHtmlUrl]); + // finalHtmlUrl 이 바뀌면 (= 다른 run / 재실행) stale 측정값 reset. // 새 iframe 의 onLoad 가 발화하면서 measuredZones 다시 채움. useEffect(() => { @@ -332,28 +512,50 @@ export default function SlideCanvas({ )} - {/* 편집 모드 toggle 버튼 — normal mode + final.html 있을 때만. - 글벗 패턴 차용 — designMode + contentEditable. backend 반영은 별 작업. */} + {/* IMP-90 (#90) u11 — discriminated edit-mode toolbar. + Replaces the prior single ✏ toggle. Three modes (text / + structure / image-zone) are mutually exclusive; clicking the + active mode toggles back to 'off'. Gesture gating per mode is + u12 — u11 only plants the state + UI surface, so all three + modes currently share the same `isEditMode` shim behavior. */} {!isPendingLayout && finalHtmlUrl && ( - + {EDIT_MODES.map((mode) => { + const active = editMode === mode; + const label = + mode === "text" ? "✏ 텍스트" : mode === "structure" ? "▦ 구조" : "🖼 이미지/존"; + const title = + mode === "text" + ? "텍스트 편집 — 텍스트 클릭하여 직접 수정" + : mode === "structure" + ? "구조 편집 — slot 순서 / 숨김 변경 (u14 펜딩)" + : "이미지/존 편집 — 이미지 드래그·리사이즈 + 존 리사이즈"; + return ( + + ); + })} + )}
{ // IMP-14 (Step 13 A-4) — embedded vs standalone CSS reset 은 backend // slide_base.html 가 `?embedded=1` query 로 소유. frontend 가 더 이상 @@ -564,9 +772,11 @@ export default function SlideCanvas({ const makeResizeHandler = ( direction: ResizeDir ) => (ev: React.MouseEvent) => { - // resize 는 pendingLayout OR 편집 모드 활성. 2026-05-22 demo hot-fix — - // frame partial 에 @container aspect-ratio 회전이 들어가서 fixed px 제약 사라짐. - if ((!isPendingLayout && !isEditMode) || !onZoneResize) return; + // resize 는 pendingLayout OR image-zone 편집 모드 활성. 2026-05-22 + // demo hot-fix — frame partial 에 @container aspect-ratio 회전이 + // 들어가서 fixed px 제약 사라짐. IMP-90 u12: text/structure 모드 + // 에서는 zone resize 비활성 (mutually exclusive per editGates). + if ((!isPendingLayout && !editGates.zoneGestures) || !onZoneResize) return; if (!measuredSlideBody) return; ev.preventDefault(); ev.stopPropagation(); @@ -637,7 +847,10 @@ export default function SlideCanvas({ ev: React.MouseEvent ) => { ev.stopPropagation(); - const canDrag = !!((isPendingLayout || isEditMode) && measuredSlideBody && onZoneResize); + // IMP-90 u12: zone drag is image-zone-mode-only (text / + // structure suppress canDrag; non-zoneGestures click still + // triggers onZoneClick via the !dragged branch on mouse-up). + const canDrag = !!((isPendingLayout || editGates.zoneGestures) && measuredSlideBody && onZoneResize); const startMouseX = ev.clientX; const startMouseY = ev.clientY; const startGeom = { ...localGeom }; @@ -856,12 +1069,14 @@ export default function SlideCanvas({
)} - {/* Step C : zone resize handles — 8 방향. pendingLayout OR 편집 모드 활성. - 2026-05-22 demo hot-fix — frame partial 에 @container aspect-ratio 회전 - 들어간 후 fixed px 제약 사라져 편집 모드 resize 도 의미 있음. + {/* Step C : zone resize handles — 8 방향. pendingLayout OR image-zone + 편집 모드 활성. 2026-05-22 demo hot-fix — frame partial 에 @container + aspect-ratio 회전 들어간 후 fixed px 제약 사라져 image-zone 모드 resize + 도 의미 있음. IMP-90 u12: text / structure 모드에서는 zone resize + affordance 미노출 (editGates.zoneGestures = image-zone only). edge handle (top/bottom/left/right) : 한 boundary 이동 corner handle (nw/ne/sw/se) : 두 boundary 동시. */} - {(isPendingLayout || isEditMode) && onZoneResize && ( + {(isPendingLayout || editGates.zoneGestures) && onZoneResize && ( <> {/* top edge */}
{ + const m = measuredZones[zone.zone_id]; + if (!m) return null; + const slotKeys = slotKeysByZone[zone.zone_id] ?? []; + const current = structureOverrides?.[zone.zone_id]; + return ( +
+ +
+ ); + })} + {/* ── IMP-51 (#79) u8 — user-content image edit overlay ── Activates only in edit mode when an image_id appears in either `imageOverrides` (u11-fed persisted axis) or `measuredImages` @@ -1014,7 +1264,11 @@ export default function SlideCanvas({ `slideCanvasDragMath.ts` so the boundary contract Codex #16 verified is exercised directly by vitest (mirror of how IMP-54 u3 split the zone math out of SlideCanvas). */} - {!isPendingLayout && isEditMode && finalHtmlUrl && onImageResize && + {/* IMP-90 u12: image overlay is image-zone-mode-only. text / + structure 모드에서는 image drag/resize affordance 미노출 + (editGates.imageOverlay = false). pendingLayout 도 동일하게 + suppress (computeEditModeGates 가 모두 false 반환). */} + {!isPendingLayout && editGates.imageOverlay && finalHtmlUrl && onImageResize && Object.entries({ ...measuredImages, ...(imageOverrides ?? {}) }).map( ([imageId]) => { const persisted = imageOverrides?.[imageId]; diff --git a/Front/client/src/components/StructureEditOverlay.tsx b/Front/client/src/components/StructureEditOverlay.tsx new file mode 100644 index 0000000..f1f986d --- /dev/null +++ b/Front/client/src/components/StructureEditOverlay.tsx @@ -0,0 +1,165 @@ +/** + * IMP-90 (#90) u14 — Structure edit overlay. + * + * React component + pure helpers that present a per-zone slot list with + * reorder (↑ / ↓) and hide (👁 / 🚫) affordances. Mounted by SlideCanvas + * when `editMode === "structure"`. Emits a `StructureOverridePerZone` + * tuple `{slot_order, hidden_slots}` through `onChange`; u15 will debounce + * + PUT this to `/api/user-overrides` (NOT u14 scope), and u16 reads the + * persisted axis at the next CLI generate run. + * + * SCOPE LOCK (binding contract): + * - inner shape = `{slot_order, hidden_slots}` ONLY. + * - frame swap stays on the existing `frames` axis (u6 backend resolver + * rejects frame-swap-shaped inner keys). + * - per-slot text content NEVER mutated here — `text_overrides` axis + * (u4/u5/u13) handles that exclusively. + * + * The exported pure helpers (`resolveEffectiveSlotOrder`, `moveItem`) are + * the unit's vitest surface; React rendering is NOT tested because the + * Front package devDependencies do not include jsdom / @testing-library + * (verified by u11/u12/u13 test pattern). + */ +import type { + StructureOverridePerZone, +} from "../services/userOverridesApi"; + +export interface StructureEditOverlayProps { + zoneId: string; + /** Discovered slot keys for this zone (e.g. from iframe DOM + * `data-text-path` prefixes). Order = backend default. */ + slotKeys: ReadonlyArray; + /** Current persisted override (or undefined). `slot_order` reorders the + * discovered keys; missing keys keep backend order at the tail. */ + current?: StructureOverridePerZone; + /** Emitted on every user mutation. u15 wires this to autosave. */ + onChange?: (zoneId: string, next: StructureOverridePerZone) => void; +} + +/** Apply `slot_order` override to the discovered slot list. Unknown + * override entries are dropped; missing discovered keys are appended in + * backend order so the user never loses a slot by partial-override. */ +export function resolveEffectiveSlotOrder( + slotKeys: ReadonlyArray, + slotOrder?: ReadonlyArray | null, +): string[] { + if (!slotOrder || slotOrder.length === 0) return [...slotKeys]; + const allowed = new Set(slotKeys); + const seen = new Set(); + const ordered: string[] = []; + for (const k of slotOrder) { + if (typeof k === "string" && allowed.has(k) && !seen.has(k)) { + ordered.push(k); + seen.add(k); + } + } + for (const k of slotKeys) { + if (!seen.has(k)) ordered.push(k); + } + return ordered; +} + +/** Move `arr[index]` by `delta` positions. Out-of-range returns a fresh + * copy of the input (defensive: caller can always treat the result as a + * new reference). */ +export function moveItem( + arr: ReadonlyArray, + index: number, + delta: number, +): T[] { + const next = arr.slice(); + const target = index + delta; + if ( + index < 0 || + index >= next.length || + target < 0 || + target >= next.length + ) { + return next; + } + const tmp = next[index]; + next[index] = next[target]; + next[target] = tmp; + return next; +} + +export default function StructureEditOverlay({ + zoneId, + slotKeys, + current, + onChange, +}: StructureEditOverlayProps) { + const effective = resolveEffectiveSlotOrder(slotKeys, current?.slot_order); + const hidden = new Set(current?.hidden_slots ?? []); + const emit = (nextOrder: string[], nextHidden: Set) => { + onChange?.(zoneId, { + slot_order: nextOrder, + hidden_slots: Array.from(nextHidden), + }); + }; + return ( +
+
+ ▦ {zoneId} +
+ {effective.length === 0 ? ( +
slot 없음
+ ) : ( + effective.map((key, i) => ( +
+ + {key} + + + + +
+ )) + )} +
+ ); +} diff --git a/Front/client/src/services/userOverridesApi.ts b/Front/client/src/services/userOverridesApi.ts index 0e7b668..a09cb47 100644 --- a/Front/client/src/services/userOverridesApi.ts +++ b/Front/client/src/services/userOverridesApi.ts @@ -75,6 +75,37 @@ export type ImageOverridesOverride = Record; */ export type ManualSectionAssignmentOverride = boolean; +/** + * IMP-56 #90 u10 — Step-22 text-edit persist axis. Keyed by `zone_id`; the + * inner mapping is `text_path` (= `{slot_key}.{line_index}`) → line value. + * The `text_path` stamp is emitted by `src/text_path_stamper.py` (u8) and + * applied at Step 13 (u9); the value is consumed by `text_override_resolver` + * (u4) and applied at Step 12 (u5). Stale paths (frame swap / layout + * regression between sessions) are tolerated by the backend resolver as + * `skipped`, NOT raised — so the on-disk axis is forward-compat with layout + * and frame churn. Mirrors Python `KNOWN_AXES` entry (u1) and Vite + * `KNOWN_USER_OVERRIDES_AXES` allowlist entry (u3). + */ +export type TextOverridesPerZone = Record; +export type TextOverridesOverride = Record; + +/** + * IMP-56 #90 u10 — Step-22 structure-edit persist axis. Keyed by `zone_id`; + * the inner mapping is SCOPE-LOCKED to `{slot_order, hidden_slots}` — slot + * reorder + slot hide only. Frame swap stays on the existing `frames` axis; + * the `structure_override_resolver` (u6) rejects frame-swap-shaped inner + * keys at the validate gate so Phase Z's no-AI-HTML-structure invariant + * holds across this persisted axis too. Per-slot `list[str]` line content + * is NEVER mutated by the u7 Step-12 apply — that is the `text_overrides` + * axis above. Mirrors Python `KNOWN_AXES` entry (u2) and Vite + * `KNOWN_USER_OVERRIDES_AXES` allowlist entry (u3). + */ +export type StructureOverridePerZone = { + slot_order?: string[]; + hidden_slots?: string[]; +}; +export type StructureOverridesOverride = Record; + /** Full on-disk schema. All axes optional — file may carry any subset. */ export interface UserOverrides { layout: string; @@ -83,6 +114,8 @@ export interface UserOverrides { zone_sections: ZoneSectionsOverride; image_overrides: ImageOverridesOverride; manual_section_assignment: ManualSectionAssignmentOverride; + text_overrides: TextOverridesOverride; + structure_overrides: StructureOverridesOverride; } /** Partial-mutation payload. `null` is the explicit clear sentinel (mirrors u4). */ diff --git a/Front/client/src/types/designAgent.ts b/Front/client/src/types/designAgent.ts index 178197b..99537d8 100644 --- a/Front/client/src/types/designAgent.ts +++ b/Front/client/src/types/designAgent.ts @@ -227,6 +227,15 @@ export interface UserSelection { // (Front/vite.config.ts), and `ManualSectionAssignmentOverride` // (services/userOverridesApi.ts). manual_section_assignment: boolean; + // IMP-56 #90 u10/u15 — Step-22 text + structure persist axes. Mirrors + // services/userOverridesApi.ts (`TextOverridesOverride` / + // `StructureOverridesOverride`). `text_overrides[zoneId][textPath] = value` + // is fed by SlideCanvas u13 focusout capture + Home u15 autosave; + // `structure_overrides[zoneId] = {slot_order, hidden_slots}` is fed by + // u14 overlay + u15 autosave. Both seeded `{}` in createInitialUserSelection + // and restored on reopen via applyPersistedNonFrameOverrides. + text_overrides: Record>; + structure_overrides: Record; }; } diff --git a/Front/client/src/utils/slidePlanUtils.ts b/Front/client/src/utils/slidePlanUtils.ts index 8b7d08b..a142df3 100644 --- a/Front/client/src/utils/slidePlanUtils.ts +++ b/Front/client/src/utils/slidePlanUtils.ts @@ -1,5 +1,11 @@ import type { UserSelection, SlidePlan, Zone, InternalRegion, LayoutPresetId } from "../types/designAgent"; -import type { UserOverrides } from "../services/userOverridesApi"; +import type { + StructureOverridePerZone, + StructureOverridesOverride, + TextOverridesOverride, + TextOverridesPerZone, + UserOverrides, +} from "../services/userOverridesApi"; import { computeZonePositions } from "../services/designAgentApi"; // ─── IMP-52 u6 — restore-on-reopen helpers (pure, exported for testing) ──── @@ -99,9 +105,74 @@ export function applyPersistedNonFrameOverrides( if (typeof persisted.manual_section_assignment === "boolean") { next.manual_section_assignment = persisted.manual_section_assignment; } + // IMP-56 (#90) u15 — layer the two Step-22 persist axes through the + // u10 extract helpers; their `_isPlainObject` + dedupe gates already + // sanitize foreign / hand-edited payloads, so reopen never poisons + // memory with non-string values or non-list slot_order entries. + next.text_overrides = extractPersistedTextOverrides(persisted); + next.structure_overrides = extractPersistedStructureOverrides(persisted); return { ...selection, overrides: next }; } +// ─── IMP-56 #90 u10 — typed extract helpers for the two new persist axes ─── +// Pure helpers that defensively sanitize Step-22 text_overrides and +// structure_overrides payloads off a `Partial` (typed by u10's +// userOverridesApi extension). They mirror the backend validation gates +// (`text_override_resolver` u4 / `structure_override_resolver` u6) on the +// frontend so a hand-edited or schema-drift payload cannot poison memory. +// Layering onto `UserSelection.overrides` arrives in u14~u16; until then +// capture / autosave / restore wiring units consume these as typed. + +function _isPlainObject(x: unknown): x is Record { + return !!x && typeof x === "object" && !Array.isArray(x); +} + +function _dedupeStringList(arr: unknown): string[] { + if (!Array.isArray(arr)) return []; + const seen = new Set(); + const out: string[] = []; + for (const k of arr) { + if (typeof k === "string" && k.length > 0 && !seen.has(k)) { + seen.add(k); + out.push(k); + } + } + return out; +} + +export function extractPersistedTextOverrides( + persisted: Partial | null | undefined, +): TextOverridesOverride { + const raw = persisted?.text_overrides; + if (!_isPlainObject(raw)) return {}; + const out: TextOverridesOverride = {}; + for (const [zoneId, perZone] of Object.entries(raw)) { + if (!zoneId || !_isPlainObject(perZone)) continue; + const safe: TextOverridesPerZone = {}; + for (const [textPath, value] of Object.entries(perZone)) { + if (textPath && typeof value === "string") safe[textPath] = value; + } + out[zoneId] = safe; + } + return out; +} + +export function extractPersistedStructureOverrides( + persisted: Partial | null | undefined, +): StructureOverridesOverride { + const raw = persisted?.structure_overrides; + if (!_isPlainObject(raw)) return {}; + const out: StructureOverridesOverride = {}; + for (const [zoneId, perZone] of Object.entries(raw)) { + if (!zoneId || !_isPlainObject(perZone)) continue; + const safe: StructureOverridePerZone = {}; + if (Array.isArray(perZone.slot_order)) safe.slot_order = _dedupeStringList(perZone.slot_order); + if (Array.isArray(perZone.hidden_slots)) safe.hidden_slots = _dedupeStringList(perZone.hidden_slots); + out[zoneId] = safe; + } + return out; +} + /** * Remap persisted frames (`unit_id` → template_id) to the in-memory * `zone_frames` (region.id → template_id) using the freshly built @@ -174,6 +245,12 @@ export function createInitialUserSelection(slidePlan?: SlidePlan | null): UserSe // here via `saveImageOverride` (SlideCanvas drag/resize handler) and // are seeded on reopen via `applyPersistedNonFrameOverrides`. image_overrides: {}, + // IMP-56 (#90) u15 — Step-22 axes seeded empty. Entries land here + // via `saveTextOverride` (u13 focusout capture) and + // `saveStructureOverride` (u14 overlay) and are restored on reopen + // via `applyPersistedNonFrameOverrides`. + text_overrides: {}, + structure_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 @@ -229,6 +306,60 @@ export function saveImageOverride( }; } +/** + * IMP-56 (#90) u15 — record a single text-line capture (zone_id, text_path, + * value) onto the in-memory selection's `text_overrides` axis. Mirrors + * `saveImageOverride` (pure / immutable). u13's focusout capture emits one + * entry per finished edit; Home u15's handler funnels each emit through this + * helper before scheduling the debounced PUT (`saveUserOverrides` 300ms). + */ +export function saveTextOverride( + selection: UserSelection, + zoneId: string, + textPath: string, + value: string, +): UserSelection { + const prevZone = selection.overrides.text_overrides[zoneId] ?? {}; + return { + ...selection, + overrides: { + ...selection.overrides, + text_overrides: { + ...selection.overrides.text_overrides, + [zoneId]: { ...prevZone, [textPath]: value }, + }, + }, + }; +} + +/** + * IMP-56 (#90) u15 — record a single structure capture (zone_id ↦ + * {slot_order, hidden_slots}) onto the in-memory selection's + * `structure_overrides` axis. Scope-locked to slot reorder + hide (frame + * swap stays on the `frames` axis). u14's overlay emits one entry per + * user mutation; Home u15's handler funnels each emit through this + * helper before scheduling the debounced PUT. + */ +export function saveStructureOverride( + selection: UserSelection, + zoneId: string, + perZone: StructureOverridePerZone, +): UserSelection { + return { + ...selection, + overrides: { + ...selection.overrides, + structure_overrides: { + ...selection.overrides.structure_overrides, + [zoneId]: { + ...(perZone.slot_order !== undefined && { slot_order: [...perZone.slot_order] }), + ...(perZone.hidden_slots !== undefined && { hidden_slots: [...perZone.hidden_slots] }), + }, + }, + }, + }; +} + export function saveZoneSizes(selection: UserSelection, groupId: string, sizes: number[]): UserSelection { return { ...selection, diff --git a/Front/client/tests/imp90_connect_endpoint.test.ts b/Front/client/tests/imp90_connect_endpoint.test.ts new file mode 100644 index 0000000..faedd93 --- /dev/null +++ b/Front/client/tests/imp90_connect_endpoint.test.ts @@ -0,0 +1,282 @@ +// IMP-56 (#90) u18 — vitest coverage for the vite POST /api/connect +// middleware and its supporting mirrorDirRecursive helper. +// +// Scope: +// 1) mirrorDirRecursive (pure helper): +// - absent src → returns 0 (no-throw, no dst creation). +// - file-only src → flat copy + count. +// - nested src → recursive copy + count. +// - overwrites pre-existing dst files (cel mirror semantics). +// 2) handleConnectMirror (POST): +// - method != POST → false (chain continues; next middleware may handle). +// - invalid JSON / non-object body → 400. +// - missing run_id or slug → 400. +// - invalid run_id or slug (key gate / path traversal) → 400. +// - final.html missing → 404. +// - success without run-assets dir → 200, assets_copied: 0, html copy ok. +// - success with run-assets dir → 200, assets_copied = file count, dst dir +// populated. +// - dstSlidesDir auto-created when celRoot/public/slides missing. +// +// Tests exercise the pure handler with mock req/res — no real vite server. + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { EventEmitter } from "node:events"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { + handleConnectMirror, + mirrorDirRecursive, +} from "../../vite.config"; + +function makeMockRes() { + const state = { + statusCode: 0, + headers: {} as Record, + body: "", + ended: false, + }; + return { + state, + res: { + writeHead(status: number, headers?: Record) { + state.statusCode = status; + if (headers) state.headers = headers; + }, + end(body?: string) { + state.body = body ?? ""; + state.ended = true; + }, + }, + }; +} + +function makeMockReq(opts: { + method?: string; +}): EventEmitter & { method?: string; send: (body: string) => void } { + const ee = new EventEmitter() as EventEmitter & { + method?: string; + send: (body: string) => void; + }; + ee.method = opts.method; + ee.send = (body: string) => { + if (body.length > 0) ee.emit("data", Buffer.from(body, "utf-8")); + ee.emit("end"); + }; + return ee; +} + +function seedRun(daRoot: string, runId: string, htmlBody: string): string { + const runDir = path.join(daRoot, "data", "runs", runId, "phase_z2"); + fs.mkdirSync(runDir, { recursive: true }); + const html = path.join(runDir, "final.html"); + fs.writeFileSync(html, htmlBody, "utf-8"); + return runDir; +} + +describe("mirrorDirRecursive (IMP-56 #90 u18)", () => { + let tmp: string; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "imp90-u18-mirror-")); + }); + + afterEach(() => { + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it("returns 0 and does not throw when src absent", () => { + const dst = path.join(tmp, "dst"); + const n = mirrorDirRecursive(path.join(tmp, "missing"), dst); + expect(n).toBe(0); + expect(fs.existsSync(dst)).toBe(false); + }); + + it("returns 0 when src exists but is a file (not a directory)", () => { + const srcFile = path.join(tmp, "src.txt"); + fs.writeFileSync(srcFile, "x", "utf-8"); + const dst = path.join(tmp, "dst"); + const n = mirrorDirRecursive(srcFile, dst); + expect(n).toBe(0); + expect(fs.existsSync(dst)).toBe(false); + }); + + it("flat-copies file entries and returns the file count", () => { + const src = path.join(tmp, "src"); + fs.mkdirSync(src); + fs.writeFileSync(path.join(src, "a.css"), "/*a*/", "utf-8"); + fs.writeFileSync(path.join(src, "b.png"), "PNG", "utf-8"); + const dst = path.join(tmp, "dst"); + const n = mirrorDirRecursive(src, dst); + expect(n).toBe(2); + expect(fs.readFileSync(path.join(dst, "a.css"), "utf-8")).toBe("/*a*/"); + expect(fs.readFileSync(path.join(dst, "b.png"), "utf-8")).toBe("PNG"); + }); + + it("recurses into nested directories and counts only files", () => { + const src = path.join(tmp, "src"); + fs.mkdirSync(path.join(src, "nested", "deep"), { recursive: true }); + fs.writeFileSync(path.join(src, "root.txt"), "r", "utf-8"); + fs.writeFileSync(path.join(src, "nested", "n.txt"), "n", "utf-8"); + fs.writeFileSync(path.join(src, "nested", "deep", "d.txt"), "d", "utf-8"); + const dst = path.join(tmp, "dst"); + const n = mirrorDirRecursive(src, dst); + expect(n).toBe(3); + expect(fs.readFileSync(path.join(dst, "nested", "deep", "d.txt"), "utf-8")) + .toBe("d"); + }); + + it("overwrites pre-existing files in dst (cel mirror semantics)", () => { + const src = path.join(tmp, "src"); + fs.mkdirSync(src); + fs.writeFileSync(path.join(src, "a.css"), "NEW", "utf-8"); + const dst = path.join(tmp, "dst"); + fs.mkdirSync(dst); + fs.writeFileSync(path.join(dst, "a.css"), "OLD", "utf-8"); + mirrorDirRecursive(src, dst); + expect(fs.readFileSync(path.join(dst, "a.css"), "utf-8")).toBe("NEW"); + }); +}); + +describe("handleConnectMirror (IMP-56 #90 u18)", () => { + let daRoot: string; + let celRoot: string; + + beforeEach(() => { + daRoot = fs.mkdtempSync(path.join(os.tmpdir(), "imp90-u18-da-")); + celRoot = fs.mkdtempSync(path.join(os.tmpdir(), "imp90-u18-cel-")); + }); + + afterEach(() => { + fs.rmSync(daRoot, { recursive: true, force: true }); + fs.rmSync(celRoot, { recursive: true, force: true }); + }); + + it("returns false (next chained) when method != POST", () => { + const req = makeMockReq({ method: "GET" }); + const { res, state } = makeMockRes(); + const handled = handleConnectMirror(req, res, daRoot, celRoot); + expect(handled).toBe(false); + expect(state.ended).toBe(false); + }); + + it("returns 400 on invalid JSON body", () => { + const req = makeMockReq({ method: "POST" }); + const { res, state } = makeMockRes(); + const handled = handleConnectMirror(req, res, daRoot, celRoot); + expect(handled).toBe(true); + req.send("{not-json}"); + expect(state.statusCode).toBe(400); + expect(JSON.parse(state.body).error).toBe("invalid JSON"); + }); + + it("returns 400 when body is not a JSON object (array root)", () => { + const req = makeMockReq({ method: "POST" }); + const { res, state } = makeMockRes(); + handleConnectMirror(req, res, daRoot, celRoot); + req.send(JSON.stringify(["not", "an", "object"])); + expect(state.statusCode).toBe(400); + expect(JSON.parse(state.body).error).toBe("body must be a JSON object"); + }); + + it("returns 400 when run_id or slug is missing", () => { + const req = makeMockReq({ method: "POST" }); + const { res, state } = makeMockRes(); + handleConnectMirror(req, res, daRoot, celRoot); + req.send(JSON.stringify({ run_id: "abc" })); // slug missing + expect(state.statusCode).toBe(400); + expect(JSON.parse(state.body).error).toBe("missing run_id or slug"); + }); + + it("returns 400 when run_id contains path traversal", () => { + const req = makeMockReq({ method: "POST" }); + const { res, state } = makeMockRes(); + handleConnectMirror(req, res, daRoot, celRoot); + req.send(JSON.stringify({ run_id: "../escape", slug: "03" })); + expect(state.statusCode).toBe(400); + expect(JSON.parse(state.body).error).toBe("invalid run_id or slug"); + }); + + it("returns 400 when slug contains a forward slash", () => { + const req = makeMockReq({ method: "POST" }); + const { res, state } = makeMockRes(); + handleConnectMirror(req, res, daRoot, celRoot); + req.send(JSON.stringify({ run_id: "valid_id", slug: "03/etc" })); + expect(state.statusCode).toBe(400); + expect(JSON.parse(state.body).error).toBe("invalid run_id or slug"); + }); + + it("returns 404 when final.html does not exist for run_id", () => { + const req = makeMockReq({ method: "POST" }); + const { res, state } = makeMockRes(); + handleConnectMirror(req, res, daRoot, celRoot); + req.send(JSON.stringify({ run_id: "ghost_run", slug: "03" })); + expect(state.statusCode).toBe(404); + expect(JSON.parse(state.body).error).toBe("final.html not found"); + }); + + it("copies final.html to cel/public/slides/.html on success", () => { + seedRun(daRoot, "mdx03_run", "03"); + const req = makeMockReq({ method: "POST" }); + const { res, state } = makeMockRes(); + handleConnectMirror(req, res, daRoot, celRoot); + req.send(JSON.stringify({ run_id: "mdx03_run", slug: "03" })); + + expect(state.statusCode).toBe(200); + const dstHtml = path.join(celRoot, "public", "slides", "03.html"); + expect(fs.existsSync(dstHtml)).toBe(true); + expect(fs.readFileSync(dstHtml, "utf-8")).toBe("03"); + const body = JSON.parse(state.body); + expect(body.success).toBe(true); + expect(body.run_id).toBe("mdx03_run"); + expect(body.slug).toBe("03"); + expect(body.assets_copied).toBe(0); + expect(body.html_target).toBe(dstHtml); + }); + + it("auto-creates cel/public/slides when missing", () => { + seedRun(daRoot, "mdx04_run", "04"); + expect(fs.existsSync(path.join(celRoot, "public", "slides"))).toBe(false); + const req = makeMockReq({ method: "POST" }); + const { res, state } = makeMockRes(); + handleConnectMirror(req, res, daRoot, celRoot); + req.send(JSON.stringify({ run_id: "mdx04_run", slug: "04" })); + expect(state.statusCode).toBe(200); + expect(fs.existsSync(path.join(celRoot, "public", "slides", "04.html"))).toBe(true); + }); + + it("mirrors assets/ recursively when present in the run dir", () => { + const runDir = seedRun(daRoot, "mdx05_run", "05"); + fs.mkdirSync(path.join(runDir, "assets", "css"), { recursive: true }); + fs.writeFileSync(path.join(runDir, "assets", "main.css"), "*{}", "utf-8"); + fs.writeFileSync(path.join(runDir, "assets", "css", "extra.css"), "p{}", "utf-8"); + + const req = makeMockReq({ method: "POST" }); + const { res, state } = makeMockRes(); + handleConnectMirror(req, res, daRoot, celRoot); + req.send(JSON.stringify({ run_id: "mdx05_run", slug: "05" })); + + expect(state.statusCode).toBe(200); + expect(JSON.parse(state.body).assets_copied).toBe(2); + expect(fs.readFileSync(path.join(celRoot, "public", "slides", "assets", "main.css"), "utf-8")) + .toBe("*{}"); + expect(fs.readFileSync(path.join(celRoot, "public", "slides", "assets", "css", "extra.css"), "utf-8")) + .toBe("p{}"); + }); + + it("overwrites pre-existing cel slide html (re-Connect semantics)", () => { + seedRun(daRoot, "mdx03_run", "NEW"); + const dstSlidesDir = path.join(celRoot, "public", "slides"); + fs.mkdirSync(dstSlidesDir, { recursive: true }); + fs.writeFileSync(path.join(dstSlidesDir, "03.html"), "OLD", "utf-8"); + + const req = makeMockReq({ method: "POST" }); + const { res, state } = makeMockRes(); + handleConnectMirror(req, res, daRoot, celRoot); + req.send(JSON.stringify({ run_id: "mdx03_run", slug: "03" })); + + expect(state.statusCode).toBe(200); + expect(fs.readFileSync(path.join(dstSlidesDir, "03.html"), "utf-8")).toBe("NEW"); + }); +}); diff --git a/Front/client/tests/imp90_edit_mode_gating.test.tsx b/Front/client/tests/imp90_edit_mode_gating.test.tsx new file mode 100644 index 0000000..ad176d0 --- /dev/null +++ b/Front/client/tests/imp90_edit_mode_gating.test.tsx @@ -0,0 +1,219 @@ +// IMP-90 (#90) u12 — vitest coverage for `computeEditModeGates`, the pure +// helper that drives SlideCanvas's mutually-exclusive gesture gating. +// u11 introduced the `EditMode` enum + toolbar; u12 splits the prior +// `isEditMode` shim (which fired ALL gates whenever any edit mode was +// active) into 5 per-gate booleans: +// textEditing — designMode + contentEditable (text mode only). +// imageSelection — in-iframe user-content image click listener +// (image-zone mode only). +// iframePointerAuto — iframe pointer-events:auto so in-iframe gestures +// (text caret OR image click) can reach the doc. +// text mode + image-zone mode; structure stays +// pe:none because u14 will overlay React controls. +// zoneGestures — zone resize 8-handle ring + drag perimeter strips +// + canDrag in handleZoneMouseDown +// (image-zone mode only). +// imageOverlay — React-side image edit overlay (image-zone only). +// +// Mutually-exclusive contract (from the issue body's "discriminated edit +// mode"): no editMode value enables both `textEditing` and either +// `imageSelection` or `zoneGestures` simultaneously. structure mode is +// the no-op placeholder — u14 will plant the structure overlay there. +// pendingLayout fully suppresses every gate (mirrors the existing +// useEffect that forces editMode='off' on pendingLayout entry). +// +// Scope guard: this test exercises the pure helper only — no React +// rendering, no DOM. testing-library/react is NOT in devDependencies +// (verified in Front/package.json); helper-level coverage is the +// established u11 pattern. + +import { describe, it, expect } from "vitest"; +import { + computeEditModeGates, + type EditMode, + type EditModeGates, +} from "../src/components/SlideCanvas"; + +const ALL_MODES: EditMode[] = ["off", "text", "structure", "image-zone"]; + +describe("computeEditModeGates (IMP-90 u12) — pendingLayout suppression", () => { + it.each(ALL_MODES)( + "pendingLayout=true forces every gate false (editMode=%s)", + (mode) => { + const g = computeEditModeGates(mode, true); + expect(g).toEqual({ + textEditing: false, + imageSelection: false, + iframePointerAuto: false, + zoneGestures: false, + imageOverlay: false, + }); + } + ); +}); + +describe("computeEditModeGates (IMP-90 u12) — off baseline", () => { + it("editMode=off pendingLayout=false: every gate false", () => { + expect(computeEditModeGates("off", false)).toEqual({ + textEditing: false, + imageSelection: false, + iframePointerAuto: false, + zoneGestures: false, + imageOverlay: false, + }); + }); +}); + +describe("computeEditModeGates (IMP-90 u12) — text mode", () => { + const g = computeEditModeGates("text", false); + + it("textEditing = true (designMode + contentEditable activate)", () => { + expect(g.textEditing).toBe(true); + }); + it("iframePointerAuto = true (caret needs to reach the doc)", () => { + expect(g.iframePointerAuto).toBe(true); + }); + it("imageSelection = false (no in-iframe image click listener)", () => { + expect(g.imageSelection).toBe(false); + }); + it("zoneGestures = false (no zone resize / drag affordances)", () => { + expect(g.zoneGestures).toBe(false); + }); + it("imageOverlay = false (no React-side image overlay)", () => { + expect(g.imageOverlay).toBe(false); + }); +}); + +describe("computeEditModeGates (IMP-90 u12) — structure mode", () => { + const g = computeEditModeGates("structure", false); + + // structure mode is the u14 placeholder — no gestures here yet. All five + // gates stay false so the iframe and React overlays remain quiescent + // until u14 plants the structure overlay on the React layer. + it("every gate false (u14 will plant the structure overlay later)", () => { + expect(g).toEqual({ + textEditing: false, + imageSelection: false, + iframePointerAuto: false, + zoneGestures: false, + imageOverlay: false, + }); + }); +}); + +describe("computeEditModeGates (IMP-90 u12) — image-zone mode", () => { + const g = computeEditModeGates("image-zone", false); + + it("textEditing = false (contentEditable would steal image clicks)", () => { + expect(g.textEditing).toBe(false); + }); + it("imageSelection = true (in-iframe img click → selectedImageId)", () => { + expect(g.imageSelection).toBe(true); + }); + it("iframePointerAuto = true (so image clicks reach the doc)", () => { + expect(g.iframePointerAuto).toBe(true); + }); + it("zoneGestures = true (zone resize + drag affordances visible)", () => { + expect(g.zoneGestures).toBe(true); + }); + it("imageOverlay = true (React-side overlay renders the drag handles)", () => { + expect(g.imageOverlay).toBe(true); + }); +}); + +describe("computeEditModeGates (IMP-90 u12) — mutually exclusive contract", () => { + it("text mode never co-activates image-zone gates (imageSelection / zoneGestures / imageOverlay)", () => { + const g = computeEditModeGates("text", false); + expect(g.textEditing).toBe(true); + expect(g.imageSelection).toBe(false); + expect(g.zoneGestures).toBe(false); + expect(g.imageOverlay).toBe(false); + }); + + it("image-zone mode never co-activates text gates (textEditing)", () => { + const g = computeEditModeGates("image-zone", false); + expect(g.imageSelection).toBe(true); + expect(g.textEditing).toBe(false); + }); + + it.each(ALL_MODES)( + "for every editMode (%s), textEditing AND zoneGestures are NEVER both true", + (mode) => { + const g = computeEditModeGates(mode, false); + expect(g.textEditing && g.zoneGestures).toBe(false); + } + ); + + it.each(ALL_MODES)( + "for every editMode (%s), textEditing AND imageOverlay are NEVER both true", + (mode) => { + const g = computeEditModeGates(mode, false); + expect(g.textEditing && g.imageOverlay).toBe(false); + } + ); + + it.each(ALL_MODES)( + "for every editMode (%s), textEditing AND imageSelection are NEVER both true", + (mode) => { + const g = computeEditModeGates(mode, false); + expect(g.textEditing && g.imageSelection).toBe(false); + } + ); +}); + +describe("computeEditModeGates (IMP-90 u12) — iframePointerAuto coupling", () => { + // pe:auto is the iframe-side prerequisite for ANY in-iframe gesture + // (text caret OR image click). The helper must NOT advertise an + // in-iframe gate as active while pe is none, or those gestures would + // be silently swallowed by the wrapper. + it.each(ALL_MODES)( + "textEditing → iframePointerAuto (editMode=%s)", + (mode) => { + const g = computeEditModeGates(mode, false); + if (g.textEditing) expect(g.iframePointerAuto).toBe(true); + } + ); + it.each(ALL_MODES)( + "imageSelection → iframePointerAuto (editMode=%s)", + (mode) => { + const g = computeEditModeGates(mode, false); + if (g.imageSelection) expect(g.iframePointerAuto).toBe(true); + } + ); +}); + +describe("computeEditModeGates (IMP-90 u12) — referential transparency", () => { + it("multiple calls with the same inputs return equal output", () => { + const a = computeEditModeGates("image-zone", false); + const b = computeEditModeGates("image-zone", false); + const c = computeEditModeGates("image-zone", false); + expect(a).toEqual(b); + expect(b).toEqual(c); + }); + + it("does not mutate captured state across calls (independent invocations)", () => { + const a = computeEditModeGates("text", false); + const _b = computeEditModeGates("image-zone", false); + // a must still reflect text mode after b's call. + expect(a.textEditing).toBe(true); + expect(a.imageSelection).toBe(false); + }); +}); + +describe("computeEditModeGates (IMP-90 u12) — gate truthtable snapshot", () => { + // Snapshot for human-readable inspection — the per-mode flag layout + // is the contract u13 (text capture) and u14 (structure overlay) + // will build against. Any change requires updating both this test + // AND the consuming gates in SlideCanvas.tsx. + it("non-pendingLayout truthtable matches the u12 contract", () => { + const rows = (["off", "text", "structure", "image-zone"] as EditMode[]).map( + (m) => ({ mode: m, ...computeEditModeGates(m, false) }) + ); + expect(rows).toEqual([ + { mode: "off", textEditing: false, imageSelection: false, iframePointerAuto: false, zoneGestures: false, imageOverlay: false }, + { mode: "text", textEditing: true, imageSelection: false, iframePointerAuto: true, zoneGestures: false, imageOverlay: false }, + { mode: "structure", textEditing: false, imageSelection: false, iframePointerAuto: false, zoneGestures: false, imageOverlay: false }, + { mode: "image-zone", textEditing: false, imageSelection: true, iframePointerAuto: true, zoneGestures: true, imageOverlay: true }, + ]); + }); +}); diff --git a/Front/client/tests/imp90_edit_mode_state.test.tsx b/Front/client/tests/imp90_edit_mode_state.test.tsx new file mode 100644 index 0000000..f25111e --- /dev/null +++ b/Front/client/tests/imp90_edit_mode_state.test.tsx @@ -0,0 +1,133 @@ +// IMP-90 (#90) u11 — vitest coverage for the discriminated EditMode enum +// and its pure transition helper `nextEditMode`. Replaces the prior single +// `isEditMode` boolean state. u11 introduces ONLY the state surface + the +// toolbar UI; gesture gating per mode is u12 (mutually exclusive) and must +// not regress this contract. +// +// Scope (Stage 2 unit u11 contract): +// 1) EDIT_MODES is the canonical ['text','structure','image-zone'] list +// in toolbar render order. 'off' is intentionally excluded from the +// iterable because it is the implicit baseline (no button); the +// toolbar only renders the three active modes per the u11 design. +// 2) nextEditMode is a pure (current, requested) -> EditMode mapping +// with three rules: +// - requested === 'off' -> 'off' (explicit exit) +// - requested === current -> 'off' (toggle exit) +// - requested !== current && != 'off'-> requested (mode switch) +// 3) The helper is referentially transparent — no side effects, no +// React, no useState, no DOM. SlideCanvas wires it as the useState +// updater callback (`setEditMode((prev) => nextEditMode(prev, m))`), +// so covering the helper here covers every toolbar click outcome +// directly without DOM rendering. (@testing-library/react is NOT in +// devDependencies; this mirrors the imp47b_human_review_toast pattern.) +// 4) The exported EditMode type union must contain exactly the four +// members 'off' | 'text' | 'structure' | 'image-zone'. The runtime +// EDIT_MODES list intentionally excludes 'off' (see (1) above). +// +// Forward-compat note: u12 will discriminate per-mode gating but MUST NOT +// alter the (current, requested) -> next contract verified here. Any +// change to the toggle/switch/exit semantics is a scope-violation against +// the u11 binding contract. + +import { describe, it, expect } from "vitest"; +import { + EDIT_MODES, + nextEditMode, + type EditMode, +} from "../src/components/SlideCanvas"; + +describe("EDIT_MODES (IMP-90 u11 — toolbar render order)", () => { + it("contains exactly the three active modes in toolbar order", () => { + expect(EDIT_MODES).toEqual(["text", "structure", "image-zone"]); + }); + + it("excludes 'off' — baseline is implicit, no toolbar button", () => { + expect(EDIT_MODES).not.toContain("off" as EditMode); + }); + + it("has length 3", () => { + expect(EDIT_MODES.length).toBe(3); + }); +}); + +describe("nextEditMode (IMP-90 u11 — pure transition helper)", () => { + describe("explicit 'off' request always exits", () => { + it.each(["off", "text", "structure", "image-zone"])( + "current=%s, requested=off -> off", + (current) => { + expect(nextEditMode(current, "off")).toBe("off"); + } + ); + }); + + describe("clicking the active mode toggles back to 'off'", () => { + it.each(["text", "structure", "image-zone"])( + "current=%s, requested=%s -> off", + (mode) => { + expect(nextEditMode(mode, mode)).toBe("off"); + } + ); + }); + + describe("clicking a different mode switches", () => { + const cases: Array<[EditMode, EditMode]> = [ + ["off", "text"], + ["off", "structure"], + ["off", "image-zone"], + ["text", "structure"], + ["text", "image-zone"], + ["structure", "text"], + ["structure", "image-zone"], + ["image-zone", "text"], + ["image-zone", "structure"], + ]; + it.each(cases)("current=%s, requested=%s -> requested", (current, requested) => { + expect(nextEditMode(current, requested)).toBe(requested); + }); + }); + + it("is referentially transparent — multiple calls with same inputs return same output", () => { + const a = nextEditMode("text", "structure"); + const b = nextEditMode("text", "structure"); + const c = nextEditMode("text", "structure"); + expect(a).toBe("structure"); + expect(b).toBe("structure"); + expect(c).toBe("structure"); + }); + + it("never returns a value outside the EditMode union", () => { + const all: EditMode[] = ["off", "text", "structure", "image-zone"]; + for (const current of all) { + for (const requested of all) { + const result = nextEditMode(current, requested); + expect(all).toContain(result); + } + } + }); + + it("preserves toggle semantics under repeated identical clicks", () => { + // off -> text -> off -> text -> off (toggle behavior) + let m: EditMode = "off"; + m = nextEditMode(m, "text"); + expect(m).toBe("text"); + m = nextEditMode(m, "text"); + expect(m).toBe("off"); + m = nextEditMode(m, "text"); + expect(m).toBe("text"); + m = nextEditMode(m, "text"); + expect(m).toBe("off"); + }); + + it("preserves switch semantics across distinct mode clicks", () => { + // off -> text -> structure -> image-zone -> off (via toggle) + let m: EditMode = "off"; + m = nextEditMode(m, "text"); + expect(m).toBe("text"); + m = nextEditMode(m, "structure"); + expect(m).toBe("structure"); + m = nextEditMode(m, "image-zone"); + expect(m).toBe("image-zone"); + m = nextEditMode(m, "image-zone"); + expect(m).toBe("off"); + }); +}); diff --git a/Front/client/tests/imp90_export_endpoint.test.ts b/Front/client/tests/imp90_export_endpoint.test.ts new file mode 100644 index 0000000..eb73436 --- /dev/null +++ b/Front/client/tests/imp90_export_endpoint.test.ts @@ -0,0 +1,255 @@ +// IMP-56 (#90) u19 — vitest coverage for the vite POST /api/export +// middleware and its supporting inlineAssetsAsDataUrls helper. +// +// Scope: +// 1) inlineAssetsAsDataUrls (pure helper): +// - no url(assets/...) refs → passthrough. +// - single PNG ref → inlined as base64 data: URL with image/png mime. +// - multiple refs → all inlined. +// - SVG ref → image/svg+xml mime. +// - missing asset file → left as-is (no throw, no rewrite). +// - data:/http:/ URLs (non-asset) → untouched. +// 2) handleExportStandalone (POST): +// - method != POST → false (chain continues; next middleware may handle). +// - invalid JSON / non-object body → 400. +// - missing run_id → 400. +// - invalid run_id (key gate / path traversal) → 400. +// - final.html missing → 404. +// - success → 200 with Content-Disposition: attachment; filename=..., +// Content-Type: text/html; charset=utf-8, body = inlined HTML. +// +// Tests exercise the pure handler with mock req/res — no real vite server. + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { EventEmitter } from "node:events"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { + handleExportStandalone, + inlineAssetsAsDataUrls, +} from "../../vite.config"; + +function makeMockRes() { + const state = { + statusCode: 0, + headers: {} as Record, + body: "", + ended: false, + }; + return { + state, + res: { + writeHead(status: number, headers?: Record) { + state.statusCode = status; + if (headers) state.headers = headers; + }, + end(body?: string) { + state.body = body ?? ""; + state.ended = true; + }, + }, + }; +} + +function makeMockReq(opts: { + method?: string; +}): EventEmitter & { method?: string; send: (body: string) => void } { + const ee = new EventEmitter() as EventEmitter & { + method?: string; + send: (body: string) => void; + }; + ee.method = opts.method; + ee.send = (body: string) => { + if (body.length > 0) ee.emit("data", Buffer.from(body, "utf-8")); + ee.emit("end"); + }; + return ee; +} + +function seedRun( + daRoot: string, + runId: string, + htmlBody: string, + assets?: Record, +): string { + const runDir = path.join(daRoot, "data", "runs", runId, "phase_z2"); + fs.mkdirSync(runDir, { recursive: true }); + const html = path.join(runDir, "final.html"); + fs.writeFileSync(html, htmlBody, "utf-8"); + if (assets) { + for (const [rel, buf] of Object.entries(assets)) { + const dst = path.join(runDir, "assets", rel); + fs.mkdirSync(path.dirname(dst), { recursive: true }); + fs.writeFileSync(dst, buf); + } + } + return runDir; +} + +describe("inlineAssetsAsDataUrls (IMP-56 #90 u19)", () => { + let tmp: string; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "imp90-u19-inline-")); + }); + + afterEach(() => { + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it("returns html unchanged when no url(assets/...) refs are present", () => { + const html = "hi"; + expect(inlineAssetsAsDataUrls(html, tmp)).toBe(html); + }); + + it("inlines a single PNG asset as a base64 data: URL with image/png mime", () => { + fs.mkdirSync(path.join(tmp, "frame_x"), { recursive: true }); + const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + fs.writeFileSync(path.join(tmp, "frame_x", "a.png"), pngBytes); + const html = "background: url(assets/frame_x/a.png);"; + const out = inlineAssetsAsDataUrls(html, tmp); + expect(out).toContain(`url("data:image/png;base64,${pngBytes.toString("base64")}")`); + expect(out).not.toContain("url(assets/frame_x/a.png)"); + }); + + it("inlines multiple refs across the same HTML body", () => { + fs.mkdirSync(path.join(tmp, "f"), { recursive: true }); + fs.writeFileSync(path.join(tmp, "f", "one.png"), Buffer.from("ONE")); + fs.writeFileSync(path.join(tmp, "f", "two.png"), Buffer.from("TWO")); + const html = "a{background:url(assets/f/one.png)} b{background:url(assets/f/two.png)}"; + const out = inlineAssetsAsDataUrls(html, tmp); + expect(out).toContain(`data:image/png;base64,${Buffer.from("ONE").toString("base64")}`); + expect(out).toContain(`data:image/png;base64,${Buffer.from("TWO").toString("base64")}`); + }); + + it("uses image/svg+xml mime for .svg refs", () => { + fs.mkdirSync(path.join(tmp, "f"), { recursive: true }); + fs.writeFileSync(path.join(tmp, "f", "icon.svg"), "", "utf-8"); + const html = "url(assets/f/icon.svg)"; + const out = inlineAssetsAsDataUrls(html, tmp); + expect(out).toContain("data:image/svg+xml;base64,"); + }); + + it("leaves the ref untouched when the asset file is missing", () => { + const html = "url(assets/missing/file.png)"; + const out = inlineAssetsAsDataUrls(html, tmp); + expect(out).toBe(html); + }); + + it("does not touch data: or http(s): url() values (only matches assets/...)", () => { + const html = + "x{background:url(data:image/png;base64,AAA)} " + + "y{background:url(https://cdn.x/a.png)}"; + expect(inlineAssetsAsDataUrls(html, tmp)).toBe(html); + }); + + it("handles quoted url(...) refs (single and double quotes)", () => { + fs.mkdirSync(path.join(tmp, "q"), { recursive: true }); + fs.writeFileSync(path.join(tmp, "q", "k.png"), Buffer.from("K")); + const html = + "a{background:url('assets/q/k.png')} b{background:url(\"assets/q/k.png\")}"; + const out = inlineAssetsAsDataUrls(html, tmp); + const data = `data:image/png;base64,${Buffer.from("K").toString("base64")}`; + expect(out.split(data).length - 1).toBe(2); + }); +}); + +describe("handleExportStandalone (IMP-56 #90 u19)", () => { + let daRoot: string; + + beforeEach(() => { + daRoot = fs.mkdtempSync(path.join(os.tmpdir(), "imp90-u19-da-")); + }); + + afterEach(() => { + fs.rmSync(daRoot, { recursive: true, force: true }); + }); + + it("returns false (next chained) when method != POST", () => { + const req = makeMockReq({ method: "GET" }); + const { res, state } = makeMockRes(); + const handled = handleExportStandalone(req, res, daRoot); + expect(handled).toBe(false); + expect(state.ended).toBe(false); + }); + + it("returns 400 on invalid JSON body", () => { + const req = makeMockReq({ method: "POST" }); + const { res, state } = makeMockRes(); + const handled = handleExportStandalone(req, res, daRoot); + expect(handled).toBe(true); + req.send("{nope"); + expect(state.statusCode).toBe(400); + expect(JSON.parse(state.body).error).toBe("invalid JSON"); + }); + + it("returns 400 when body is not a JSON object (array root)", () => { + const req = makeMockReq({ method: "POST" }); + const { res, state } = makeMockRes(); + handleExportStandalone(req, res, daRoot); + req.send(JSON.stringify(["x"])); + expect(state.statusCode).toBe(400); + expect(JSON.parse(state.body).error).toBe("body must be a JSON object"); + }); + + it("returns 400 when run_id is missing", () => { + const req = makeMockReq({ method: "POST" }); + const { res, state } = makeMockRes(); + handleExportStandalone(req, res, daRoot); + req.send(JSON.stringify({})); + expect(state.statusCode).toBe(400); + expect(JSON.parse(state.body).error).toBe("missing run_id"); + }); + + it("returns 400 when run_id contains path traversal", () => { + const req = makeMockReq({ method: "POST" }); + const { res, state } = makeMockRes(); + handleExportStandalone(req, res, daRoot); + req.send(JSON.stringify({ run_id: "../escape" })); + expect(state.statusCode).toBe(400); + expect(JSON.parse(state.body).error).toBe("invalid run_id"); + }); + + it("returns 404 when final.html does not exist for run_id", () => { + const req = makeMockReq({ method: "POST" }); + const { res, state } = makeMockRes(); + handleExportStandalone(req, res, daRoot); + req.send(JSON.stringify({ run_id: "ghost_run" })); + expect(state.statusCode).toBe(404); + expect(JSON.parse(state.body).error).toBe("final.html not found"); + }); + + it("returns 200 with text/html body + Content-Disposition on success", () => { + seedRun(daRoot, "mdx03_run", "03"); + const req = makeMockReq({ method: "POST" }); + const { res, state } = makeMockRes(); + handleExportStandalone(req, res, daRoot); + req.send(JSON.stringify({ run_id: "mdx03_run" })); + expect(state.statusCode).toBe(200); + expect(state.headers["Content-Type"]).toBe("text/html; charset=utf-8"); + expect(state.headers["Content-Disposition"]).toBe( + 'attachment; filename="mdx03_run.html"', + ); + expect(state.body).toBe("03"); + }); + + it("inlines assets in final.html when run dir has assets/", () => { + const pngBytes = Buffer.from("PNGDATA"); + seedRun( + daRoot, + "mdx05_run", + "
", + { "f/x.png": pngBytes }, + ); + const req = makeMockReq({ method: "POST" }); + const { res, state } = makeMockRes(); + handleExportStandalone(req, res, daRoot); + req.send(JSON.stringify({ run_id: "mdx05_run" })); + expect(state.statusCode).toBe(200); + expect(state.body).toContain( + `data:image/png;base64,${pngBytes.toString("base64")}`, + ); + expect(state.body).not.toContain("url(assets/f/x.png)"); + }); +}); diff --git a/Front/client/tests/imp90_structure_overlay.test.tsx b/Front/client/tests/imp90_structure_overlay.test.tsx new file mode 100644 index 0000000..1d3a8c5 --- /dev/null +++ b/Front/client/tests/imp90_structure_overlay.test.tsx @@ -0,0 +1,150 @@ +// IMP-90 (#90) u14 — vitest coverage for the pure helpers exported by +// `StructureEditOverlay`. The React component itself is not rendered +// (jsdom / @testing-library NOT in Front devDependencies — verified in +// `Front/package.json`); we test the deterministic pieces that drive its +// JSX: `resolveEffectiveSlotOrder` (effective-order resolution under +// override) and `moveItem` (immutable reorder primitive). +// +// Upstream / downstream contracts (verified by prior units): +// - u2 KNOWN_AXES += structure_overrides (Python backend). +// - u3 vite allowlist += structure_overrides. +// - u6 structure_override_resolver — inner shape locked to +// {slot_order, hidden_slots}; frame swap REJECTED to existing +// frames axis. +// - u10 typed-client `StructureOverridePerZone` + extract helper. +// - u15 (next) will debounce + PUT the emitted capture. +// +// u14 scope: pure helpers only. React render path is verified by Codex +// auditor via static read of the JSX (no runtime test possible without +// jsdom). Tests below are intentionally side-effect-free. + +import { describe, it, expect } from "vitest"; +import { + resolveEffectiveSlotOrder, + moveItem, +} from "../src/components/StructureEditOverlay"; + +// ───────────────────────────────────────────────────────────────────── +// resolveEffectiveSlotOrder +// ───────────────────────────────────────────────────────────────────── + +describe("resolveEffectiveSlotOrder — no override", () => { + it("returns a fresh copy of the discovered keys when slotOrder is undefined", () => { + const discovered = ["a", "b", "c"]; + const out = resolveEffectiveSlotOrder(discovered, undefined); + expect(out).toEqual(["a", "b", "c"]); + expect(out).not.toBe(discovered); + }); + it("returns a fresh copy when slotOrder is null", () => { + const out = resolveEffectiveSlotOrder(["a", "b"], null); + expect(out).toEqual(["a", "b"]); + }); + it("returns a fresh copy when slotOrder is empty []", () => { + const out = resolveEffectiveSlotOrder(["a", "b"], []); + expect(out).toEqual(["a", "b"]); + }); + it("handles empty discovered list (no slots in zone)", () => { + expect(resolveEffectiveSlotOrder([], undefined)).toEqual([]); + expect(resolveEffectiveSlotOrder([], ["x"])).toEqual([]); + }); +}); + +describe("resolveEffectiveSlotOrder — full override", () => { + it("reorders all discovered keys per slotOrder", () => { + expect( + resolveEffectiveSlotOrder(["a", "b", "c"], ["c", "a", "b"]), + ).toEqual(["c", "a", "b"]); + }); + it("is idempotent when slotOrder matches discovered order", () => { + expect( + resolveEffectiveSlotOrder(["a", "b", "c"], ["a", "b", "c"]), + ).toEqual(["a", "b", "c"]); + }); +}); + +describe("resolveEffectiveSlotOrder — partial / drift override", () => { + it("appends missing discovered keys in backend order at the tail", () => { + // user reordered b -> first, but c was added later by backend. + expect( + resolveEffectiveSlotOrder(["a", "b", "c"], ["b", "a"]), + ).toEqual(["b", "a", "c"]); + }); + it("drops override entries that no longer exist in discovered keys", () => { + // user had slot 'x' before; backend dropped it. + expect( + resolveEffectiveSlotOrder(["a", "b"], ["x", "a", "b"]), + ).toEqual(["a", "b"]); + }); + it("dedupes duplicate entries within slotOrder", () => { + expect( + resolveEffectiveSlotOrder(["a", "b", "c"], ["a", "a", "b"]), + ).toEqual(["a", "b", "c"]); + }); + it("dedupe + drop + append all together (stress)", () => { + expect( + resolveEffectiveSlotOrder( + ["a", "b", "c", "d"], + ["d", "x", "d", "a", "ghost"], + ), + ).toEqual(["d", "a", "b", "c"]); + }); + it("ignores non-string entries in slotOrder", () => { + const bogus = ["a", null as unknown as string, undefined as unknown as string, "b"]; + expect(resolveEffectiveSlotOrder(["a", "b"], bogus)).toEqual(["a", "b"]); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// moveItem +// ───────────────────────────────────────────────────────────────────── + +describe("moveItem — happy paths", () => { + it("moves index 0 down by 1 (swap with index 1)", () => { + expect(moveItem(["a", "b", "c"], 0, 1)).toEqual(["b", "a", "c"]); + }); + it("moves index 2 up by 1 (swap with index 1)", () => { + expect(moveItem(["a", "b", "c"], 2, -1)).toEqual(["a", "c", "b"]); + }); + it("moves across larger delta (swap with target)", () => { + expect(moveItem(["a", "b", "c", "d"], 0, 2)).toEqual(["c", "b", "a", "d"]); + }); +}); + +describe("moveItem — bounds", () => { + it("no-op (fresh copy) when moving first up", () => { + const src = ["a", "b", "c"]; + const out = moveItem(src, 0, -1); + expect(out).toEqual(["a", "b", "c"]); + expect(out).not.toBe(src); + }); + it("no-op when moving last down", () => { + expect(moveItem(["a", "b", "c"], 2, 1)).toEqual(["a", "b", "c"]); + }); + it("no-op when index negative", () => { + expect(moveItem(["a", "b"], -1, 1)).toEqual(["a", "b"]); + }); + it("no-op when index past end", () => { + expect(moveItem(["a", "b"], 5, -1)).toEqual(["a", "b"]); + }); + it("no-op when target falls out of range from large delta", () => { + expect(moveItem(["a", "b", "c"], 1, 99)).toEqual(["a", "b", "c"]); + }); + it("no-op on empty array (any index)", () => { + expect(moveItem([], 0, 1)).toEqual([]); + }); +}); + +describe("moveItem — immutability", () => { + it("never mutates the input array", () => { + const src = ["a", "b", "c"]; + moveItem(src, 0, 1); + expect(src).toEqual(["a", "b", "c"]); + }); + it("returns a new reference even when no-op", () => { + const src = ["a", "b"]; + expect(moveItem(src, 0, -1)).not.toBe(src); + }); + it("preserves T-typed values (number array)", () => { + expect(moveItem([1, 2, 3], 0, 1)).toEqual([2, 1, 3]); + }); +}); diff --git a/Front/client/tests/imp90_text_edit_capture.test.tsx b/Front/client/tests/imp90_text_edit_capture.test.tsx new file mode 100644 index 0000000..da58ec3 --- /dev/null +++ b/Front/client/tests/imp90_text_edit_capture.test.tsx @@ -0,0 +1,259 @@ +// IMP-90 (#90) u13 — vitest coverage for `deriveTextEditCapture`, the pure +// helper that resolves a contentEditable focusout target into the +// (zone_id, text_path, value) capture tuple emitted by SlideCanvas. +// +// Upstream contract (verified by prior units): +// - u8 `src/text_path_stamper.py` stamps `data-text-path="{slot_key}.{ +// line_index}"` on every rendered text-line opening tag at Step 13. +// - u9 wires the stamper into `render_slide` so the final.html consumed +// by SlideCanvas's iframe carries those attributes. +// - Phase Z slide-base wraps every zone in `.zone[data-zone-position]` +// (verified at SlideCanvas.tsx onLoad measure block). +// +// u13 scope: derive the capture tuple from any descendant of a stamped +// line, OR the stamped line itself. Non-stamped targets (slide-base +// title/footer, decorative spans outside the zone tree) return null so +// the focusout handler silently skips them — never crashes. +// +// Forward-compat note: u15 will debounce + PUT the capture; u15 MUST NOT +// alter the (target) -> {zoneId, textPath, value} | null contract verified +// here. Any change to the resolution semantics is a scope-violation +// against the u13 binding contract. +// +// jsdom is NOT in devDependencies (verified in Front/package.json); this +// test mocks `TextEditCaptureTarget` with structurally-typed objects per +// the established u11/u12 pure-helper pattern. + +import { describe, it, expect } from "vitest"; +import { + deriveTextEditCapture, + type TextEditCapture, + type TextEditCaptureTarget, +} from "../src/components/SlideCanvas"; + +// --- minimal closest-aware mock builders ----------------------------- +// Each node only needs to know which selectors it matches and its +// parent chain — `closest` is implemented by walking parent pointers. + +interface MockNodeSpec { + matches: string[]; + attrs?: Record; + text?: string | null; + parent?: MockNode | null; +} +interface MockNode extends TextEditCaptureTarget { + matches(sel: string): boolean; + parent: MockNode | null; +} +function makeNode(spec: MockNodeSpec): MockNode { + const node: MockNode = { + parent: spec.parent ?? null, + matches(sel: string) { + return spec.matches.includes(sel); + }, + closest(sel: string): TextEditCaptureTarget | null { + let cur: MockNode | null = node; + while (cur) { + if (cur.matches(sel)) return cur; + cur = cur.parent; + } + return null; + }, + getAttribute(name: string): string | null { + return spec.attrs?.[name] ?? null; + }, + textContent: spec.text === undefined ? null : spec.text, + }; + return node; +} + +// Canonical zone + line scaffold used across happy-path tests. +// `null` for any field is preserved verbatim so edge cases (missing attr / +// null textContent) can exercise the helper's defensive branches. +function makeZoneLineScaffold(opts: { + zoneId?: string | null; + textPath?: string | null; + lineText?: string | null; +}) { + const zone = makeNode({ + matches: [".zone[data-zone-position]"], + attrs: opts.zoneId === null ? {} : { "data-zone-position": opts.zoneId ?? "top" }, + }); + const line = makeNode({ + matches: ["[data-text-path]"], + attrs: + opts.textPath === null + ? {} + : { "data-text-path": opts.textPath ?? "row_1_left_body.0" }, + text: opts.lineText === undefined ? "hello world" : opts.lineText, + parent: zone, + }); + return { zone, line }; +} + +describe("deriveTextEditCapture (IMP-90 u13) — null inputs / non-stamped", () => { + it("returns null when target is null", () => { + expect(deriveTextEditCapture(null)).toBeNull(); + }); + + it("returns null when no ancestor has data-text-path (e.g., slide title)", () => { + const title = makeNode({ + matches: [".slide-title"], + text: "Phase Z 슬라이드", + }); + expect(deriveTextEditCapture(title)).toBeNull(); + }); + + it("returns null when the stamped line has no enclosing zone", () => { + // Decorative line stamped by the future u8 but rendered outside a + // zone (e.g., footer pill). u13 silently skips — caller never sees + // a half-resolved capture. + const orphanLine = makeNode({ + matches: ["[data-text-path]"], + attrs: { "data-text-path": "footer.0" }, + text: "결론", + }); + expect(deriveTextEditCapture(orphanLine)).toBeNull(); + }); +}); + +describe("deriveTextEditCapture (IMP-90 u13) — happy path", () => { + it("resolves (zoneId, textPath, value) when target IS the stamped line", () => { + const { line } = makeZoneLineScaffold({ + zoneId: "top", + textPath: "row_1_left_body.0", + lineText: "분석 결과", + }); + expect(deriveTextEditCapture(line)).toEqual({ + zoneId: "top", + textPath: "row_1_left_body.0", + value: "분석 결과", + }); + }); + + it("walks up to the stamped line when target is a nested descendant", () => { + const { zone, line } = makeZoneLineScaffold({ + zoneId: "bottom_l", + textPath: "left_body.2", + lineText: "wrapped", + }); + // emulate a SPAN inside the stamped line (e.g., bold inline span) + const innerSpan = makeNode({ + matches: ["span.highlight"], + text: "ignored — closest walks to the line", + parent: line, + }); + void zone; + expect(deriveTextEditCapture(innerSpan)).toEqual({ + zoneId: "bottom_l", + textPath: "left_body.2", + value: "wrapped", + }); + }); + + it("preserves the line's textContent without HTML normalization", () => { + const { line } = makeZoneLineScaffold({ + zoneId: "primary", + textPath: "headline.0", + lineText: " spaced inner words ", + }); + // u13 trims outer whitespace but does NOT collapse interior whitespace + // — value mirrors what user typed, modulo blur-edge trim. + expect(deriveTextEditCapture(line)?.value).toBe("spaced inner words"); + }); + + it("returns empty string when textContent is null (edge: empty line)", () => { + const { line } = makeZoneLineScaffold({ + zoneId: "top", + textPath: "row_1_left_body.0", + lineText: null, + }); + expect(deriveTextEditCapture(line)?.value).toBe(""); + }); + + it("returns empty string when textContent is whitespace-only", () => { + const { line } = makeZoneLineScaffold({ + zoneId: "top", + textPath: "row_1_left_body.0", + lineText: " \n \t ", + }); + expect(deriveTextEditCapture(line)?.value).toBe(""); + }); +}); + +describe("deriveTextEditCapture (IMP-90 u13) — missing attribute defensiveness", () => { + it("returns null when data-text-path attribute is absent on the matched line", () => { + // Should not happen with the u8 stamper, but a downstream mutation + // (e.g., user pasting a fresh element) could create a stamped-class + // node without the actual attribute. u13 stays defensive. + const zone = makeNode({ + matches: [".zone[data-zone-position]"], + attrs: { "data-zone-position": "top" }, + }); + const lineNoPath = makeNode({ + matches: ["[data-text-path]"], + attrs: {}, + text: "hello", + parent: zone, + }); + expect(deriveTextEditCapture(lineNoPath)).toBeNull(); + }); + + it("returns null when data-zone-position attribute is absent on the matched zone", () => { + const zoneNoId = makeNode({ + matches: [".zone[data-zone-position]"], + attrs: {}, + }); + const line = makeNode({ + matches: ["[data-text-path]"], + attrs: { "data-text-path": "row_1_left_body.0" }, + text: "hello", + parent: zoneNoId, + }); + expect(deriveTextEditCapture(line)).toBeNull(); + }); +}); + +describe("deriveTextEditCapture (IMP-90 u13) — referential transparency", () => { + it("multiple calls with the same target return equal captures", () => { + const { line } = makeZoneLineScaffold({ + zoneId: "top", + textPath: "row_1_left_body.0", + lineText: "stable", + }); + const a = deriveTextEditCapture(line); + const b = deriveTextEditCapture(line); + expect(a).toEqual(b); + expect(a).not.toBe(b); // fresh objects each call (caller-friendly) + }); + + it("does not mutate the target element (attrs / parent / textContent unchanged)", () => { + const { line, zone } = makeZoneLineScaffold({ + zoneId: "top", + textPath: "row_1_left_body.0", + lineText: "immutable", + }); + deriveTextEditCapture(line); + expect(line.getAttribute("data-text-path")).toBe("row_1_left_body.0"); + expect(line.textContent).toBe("immutable"); + expect(zone.getAttribute("data-zone-position")).toBe("top"); + }); +}); + +describe("deriveTextEditCapture (IMP-90 u13) — zone id pass-through", () => { + // u13 does not validate the zone id shape — Phase Z slide-base owns the + // canonical zone position vocabulary, and u15 / pipeline-side resolver + // (u4) re-validate downstream. u13 just forwards whatever the stamped + // DOM declared. + const ZONE_IDS = ["top", "bottom_l", "bottom_r", "primary", "secondary"]; + it.each(ZONE_IDS)("preserves zone id '%s' verbatim", (zid) => { + const { line } = makeZoneLineScaffold({ + zoneId: zid, + textPath: `${zid}.0`, + lineText: "x", + }); + const cap = deriveTextEditCapture(line); + expect(cap?.zoneId).toBe(zid); + expect(cap?.textPath).toBe(`${zid}.0`); + }); +}); diff --git a/Front/client/tests/user_overrides_endpoint.test.ts b/Front/client/tests/user_overrides_endpoint.test.ts index 954b4fe..8458990 100644 --- a/Front/client/tests/user_overrides_endpoint.test.ts +++ b/Front/client/tests/user_overrides_endpoint.test.ts @@ -305,23 +305,37 @@ describe("handleGetUserOverrides (IMP-52 u3)", () => { // IMP-52 u4 — PUT endpoint coverage // --------------------------------------------------------------------------- -describe("KNOWN_USER_OVERRIDES_AXES (IMP-52 u4)", () => { +describe("KNOWN_USER_OVERRIDES_AXES (IMP-52 u4 + IMP-56 #90 u3 allowlist sync)", () => { it("matches the Python KNOWN_AXES tuple in src/user_overrides_io.py", () => { // 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. + // IMP-56 #90 u3 closes the prior `slide_css` gap (IMP-45 #74) and + // pre-wires `text_overrides` (IMP-56 #90 u1) + + // `structure_overrides` (IMP-56 #90 u2) — full 9-axis mirror of the + // Python tuple, same order. expect(KNOWN_USER_OVERRIDES_AXES).toEqual([ "layout", "zone_geometries", "zone_sections", "frames", "image_overrides", + "slide_css", "manual_section_assignment", + "text_overrides", + "structure_overrides", ]); }); + + it("includes the 3 axes added by IMP-56 #90 u3 (allowlist sync)", () => { + // Spot-check the diff in addition to the full-equality assertion so a + // future edit that drops one of the new axes fails with a localized + // error rather than a 9-vs-N tuple-diff that obscures intent. + expect(KNOWN_USER_OVERRIDES_AXES).toContain("slide_css"); + expect(KNOWN_USER_OVERRIDES_AXES).toContain("text_overrides"); + expect(KNOWN_USER_OVERRIDES_AXES).toContain("structure_overrides"); + expect(KNOWN_USER_OVERRIDES_AXES.length).toBe(9); + }); }); describe("mergeUserOverrides (IMP-55 #93 u1) — manual_section_assignment bool axis", () => { diff --git a/Front/client/tests/user_overrides_restore.test.ts b/Front/client/tests/user_overrides_restore.test.ts index 021d307..d052b0e 100644 --- a/Front/client/tests/user_overrides_restore.test.ts +++ b/Front/client/tests/user_overrides_restore.test.ts @@ -35,6 +35,8 @@ import { deriveUserOverridesKey, remapPersistedFramesToZoneFrames, saveImageOverride, + saveTextOverride, + saveStructureOverride, } from "../src/utils/slidePlanUtils"; // ─── Fixtures ─────────────────────────────────────────────────────────────── @@ -59,6 +61,11 @@ function makeSelection(overrides?: Partial): UserSel // pre-existing fixture matches the `createInitialUserSelection` seed // and stays compile-clean after u3 widened the type. manual_section_assignment: false, + // IMP-56 (#90) u15 — keep the fixture in sync with the two Step-22 + // persist axes declared on `UserSelection.overrides`. Empty by + // default so pre-existing cases retain their shape. + text_overrides: {}, + structure_overrides: {}, ...overrides, }, }; @@ -550,3 +557,150 @@ describe("manual_section_assignment axis — applyPersistedNonFrameOverrides (IM expect(next.overrides.manual_section_assignment).toBe(true); }); }); + +// ─── IMP-56 (#90) u15 — text_overrides + structure_overrides axes ─────────── +// Pure helpers wired by Home.tsx into the SlideCanvas u13 focusout capture +// (text) and u14 structure overlay emit (structure). Tests cover: +// • saveTextOverride / saveStructureOverride immutability + merge semantics +// • createInitialUserSelection seeding the two new axes empty +// • applyPersistedNonFrameOverrides layering via the u10 extract helpers + +describe("text_overrides axis — saveTextOverride (IMP-56 u15)", () => { + it("records a fresh (zoneId, textPath, value) tuple", () => { + const sel = makeSelection(); + const next = saveTextOverride(sel, "top", "row_1_left_body.0", "분석 결과"); + expect(next.overrides.text_overrides).toEqual({ + top: { "row_1_left_body.0": "분석 결과" }, + }); + }); + + it("merges within the same zone without erasing prior text_paths", () => { + const sel = makeSelection({ + text_overrides: { top: { "row_1_left_body.0": "기존" } }, + }); + const next = saveTextOverride(sel, "top", "row_1_left_body.1", "신규"); + expect(next.overrides.text_overrides.top).toEqual({ + "row_1_left_body.0": "기존", + "row_1_left_body.1": "신규", + }); + }); + + it("overwrites the same textPath value within a zone", () => { + const sel = makeSelection({ + text_overrides: { top: { "headline.0": "v1" } }, + }); + const next = saveTextOverride(sel, "top", "headline.0", "v2"); + expect(next.overrides.text_overrides.top).toEqual({ "headline.0": "v2" }); + }); + + it("does not mutate the input selection (immutable contract)", () => { + const sel = makeSelection({ + text_overrides: { top: { "headline.0": "before" } }, + }); + saveTextOverride(sel, "top", "headline.0", "after"); + expect(sel.overrides.text_overrides).toEqual({ + top: { "headline.0": "before" }, + }); + }); + + it("seeds an empty text_overrides on a fresh selection", () => { + const sel = createInitialUserSelection(); + expect(sel.overrides.text_overrides).toEqual({}); + }); +}); + +describe("structure_overrides axis — saveStructureOverride (IMP-56 u15)", () => { + it("records a fresh (zoneId → {slot_order, hidden_slots}) tuple", () => { + const sel = makeSelection(); + const next = saveStructureOverride(sel, "top", { + slot_order: ["b", "a"], + hidden_slots: ["c"], + }); + expect(next.overrides.structure_overrides).toEqual({ + top: { slot_order: ["b", "a"], hidden_slots: ["c"] }, + }); + }); + + it("replaces an existing zone entry verbatim (no merge within zone)", () => { + const sel = makeSelection({ + structure_overrides: { top: { slot_order: ["a", "b"], hidden_slots: [] } }, + }); + const next = saveStructureOverride(sel, "top", { + slot_order: ["b", "a"], + hidden_slots: ["a"], + }); + expect(next.overrides.structure_overrides.top).toEqual({ + slot_order: ["b", "a"], + hidden_slots: ["a"], + }); + }); + + it("keeps unrelated zones intact when updating one zone", () => { + const sel = makeSelection({ + structure_overrides: { + top: { slot_order: ["x"], hidden_slots: [] }, + bottom_l: { slot_order: ["y"], hidden_slots: ["z"] }, + }, + }); + const next = saveStructureOverride(sel, "top", { + slot_order: ["x", "x2"], + hidden_slots: [], + }); + expect(next.overrides.structure_overrides.bottom_l).toEqual({ + slot_order: ["y"], + hidden_slots: ["z"], + }); + }); + + it("does not mutate the input perZone object after save", () => { + const sel = makeSelection(); + const perZone = { slot_order: ["a"], hidden_slots: ["b"] }; + const next = saveStructureOverride(sel, "top", perZone); + perZone.slot_order.push("MUTATED"); + expect(next.overrides.structure_overrides.top.slot_order).toEqual(["a"]); + }); + + it("seeds an empty structure_overrides on a fresh selection", () => { + const sel = createInitialUserSelection(); + expect(sel.overrides.structure_overrides).toEqual({}); + }); +}); + +describe("Step-22 axes — applyPersistedNonFrameOverrides restore (IMP-56 u15)", () => { + it("layers persisted text_overrides through the u10 extract helper", () => { + const sel = makeSelection(); + const next = applyPersistedNonFrameOverrides(sel, { + text_overrides: { + top: { "row_1_left_body.0": "복원" }, + }, + }); + expect(next.overrides.text_overrides).toEqual({ + top: { "row_1_left_body.0": "복원" }, + }); + }); + + it("layers persisted structure_overrides through the u10 extract helper", () => { + const sel = makeSelection(); + const next = applyPersistedNonFrameOverrides(sel, { + structure_overrides: { + top: { slot_order: ["b", "a"], hidden_slots: ["c"] }, + }, + }); + expect(next.overrides.structure_overrides).toEqual({ + top: { slot_order: ["b", "a"], hidden_slots: ["c"] }, + }); + }); + + it("drops non-object payloads silently (no throw, axis stays empty)", () => { + const sel = makeSelection(); + const next = applyPersistedNonFrameOverrides(sel, { + text_overrides: "garbage" as unknown as Record>, + structure_overrides: ["bad"] as unknown as Record< + string, + { slot_order?: string[]; hidden_slots?: string[] } + >, + }); + expect(next.overrides.text_overrides).toEqual({}); + expect(next.overrides.structure_overrides).toEqual({}); + }); +}); diff --git a/Front/vite.config.ts b/Front/vite.config.ts index 82b1d68..97c9316 100644 --- a/Front/vite.config.ts +++ b/Front/vite.config.ts @@ -219,25 +219,36 @@ function vitePluginStorageProxy(): Plugin { export const USER_OVERRIDES_KEY_RE = /^[A-Za-z0-9_][A-Za-z0-9_.\-]*$/; -// The six in-scope axes — mirror of KNOWN_AXES in -// src/user_overrides_io.py minus `slide_css` (known gap, IMP-45 #74 — -// the Python side persists it for backend consumption; the Vite PUT does -// not write it because the frontend never mutates the slide-level CSS -// override). Any payload key outside this allowlist is silently dropped -// by the PUT handler (u4) so the on-disk schema cannot drift from the -// backend pipeline (u2) contract. Foreign top-level keys already on disk -// are preserved verbatim (see mergeUserOverrides). +// The nine in-scope axes — full mirror of KNOWN_AXES in +// src/user_overrides_io.py. Order matches the Python tuple verbatim so +// a side-by-side audit reads as a no-op. Any payload key outside this +// allowlist is silently dropped by the PUT handler (u4) so the on-disk +// schema cannot drift from the backend pipeline (u2) contract. Foreign +// top-level keys already on disk are preserved verbatim (see +// mergeUserOverrides). // IMP-51 (#79) u2: added `image_overrides` (image_id → {x,y,w,h} // percent-of-slide coordinates). // IMP-55 (#93) u1: added `manual_section_assignment` (bool intent marker // — drag-drop sets true, layout apply/cancel sets false). +// IMP-56 (#90) u3: allowlist sync — closes the prior `slide_css` gap +// (IMP-45 #74; the Step-22 slide CSS edit path will write it from the +// frontend) and pre-wires `text_overrides` (IMP-56 #90 u1, keyed by +// {zone_id: {text_path: value}}) + `structure_overrides` (IMP-56 #90 u2, +// keyed by {zone_id: {slot_order, hidden_slots}} — scope LOCKED to slot +// reorder + hide; frame swap stays on the existing `frames` axis to +// preserve Phase Z's no-AI-HTML-structure invariant) so the Step-22 +// capture path (u10~u17) can PUT either axis without a follow-on +// allowlist edit. export const KNOWN_USER_OVERRIDES_AXES = [ "layout", "zone_geometries", "zone_sections", "frames", "image_overrides", + "slide_css", "manual_section_assignment", + "text_overrides", + "structure_overrides", ] as const; export type KnownUserOverridesAxis = (typeof KNOWN_USER_OVERRIDES_AXES)[number]; @@ -506,6 +517,211 @@ export function handlePutUserOverrides( return true; } +// ============================================================================= +// IMP-56 (#90) u18 — POST /api/connect : cel astro dev mirror copy. +// +// Body: {"run_id": "", "slug": ""}. +// • Copies /data/runs//phase_z2/final.html → +// /public/slides/.html (overwrite). +// • If /phase_z2/assets/ exists, mirrors its contents into +// /public/slides/assets/ (overwrite copy, recursive). +// • run_id and slug are validated through the existing +// isValidUserOverridesKey gate so path-traversal payloads are rejected. +// ============================================================================= + +export function mirrorDirRecursive(srcDir: string, dstDir: string): number { + if (!fs.existsSync(srcDir) || !fs.statSync(srcDir).isDirectory()) return 0; + if (!fs.existsSync(dstDir)) fs.mkdirSync(dstDir, { recursive: true }); + let count = 0; + for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) { + const srcPath = path.join(srcDir, entry.name); + const dstPath = path.join(dstDir, entry.name); + if (entry.isDirectory()) { + count += mirrorDirRecursive(srcPath, dstPath); + } else if (entry.isFile()) { + fs.copyFileSync(srcPath, dstPath); + count += 1; + } + } + return count; +} + +export function handleConnectMirror( + req: PutReqLike, + res: ResLike, + designAgentRoot: string, + celRoot: string, +): boolean { + if (req.method !== "POST") return false; + let body = ""; + req.on("data", (chunk: Buffer | string) => { + body += typeof chunk === "string" ? chunk : chunk.toString(); + }); + req.on("end", () => { + let parsed: unknown; + try { + parsed = body.length > 0 ? JSON.parse(body) : {}; + } catch { + res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify({ error: "invalid JSON" })); + return; + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify({ error: "body must be a JSON object" })); + return; + } + const { run_id, slug } = parsed as { run_id?: unknown; slug?: unknown }; + if (typeof run_id !== "string" || typeof slug !== "string") { + res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify({ error: "missing run_id or slug" })); + return; + } + if (!isValidUserOverridesKey(run_id) || !isValidUserOverridesKey(slug)) { + res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify({ error: "invalid run_id or slug" })); + return; + } + const runDir = path.join(designAgentRoot, "data", "runs", run_id, "phase_z2"); + const srcHtml = path.join(runDir, "final.html"); + if (!fs.existsSync(srcHtml)) { + res.writeHead(404, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify({ error: "final.html not found" })); + return; + } + const dstSlidesDir = path.join(celRoot, "public", "slides"); + if (!fs.existsSync(dstSlidesDir)) fs.mkdirSync(dstSlidesDir, { recursive: true }); + const dstHtml = path.join(dstSlidesDir, `${slug}.html`); + try { + fs.copyFileSync(srcHtml, dstHtml); + } catch (err) { + res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify({ error: `copy failed: ${String(err)}` })); + return; + } + const assetsCopied = mirrorDirRecursive( + path.join(runDir, "assets"), + path.join(dstSlidesDir, "assets"), + ); + res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify({ success: true, run_id, slug, html_target: dstHtml, assets_copied: assetsCopied })); + }); + req.on("error", () => { + res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify({ error: "request error" })); + }); + return true; +} + +// ============================================================================= +// IMP-56 (#90) u19 — POST /api/export : standalone HTML download. +// +// Body: {"run_id": ""}. +// • Reads /data/runs//phase_z2/final.html. +// • Inlines every `url(assets//)` reference (the only +// external dep emitted by the Phase Z2 render path — verified by grep +// against templates/phase_z2/slide_base.html and a representative run) +// as a base64 data URL so the emitted HTML is portable (file:// open +// or any external host, no co-located assets/ dir required). Mirrors +// u18 validation: isValidUserOverridesKey gate for path-traversal +// rejection; final.html missing → 404. +// • Response: 200 text/html with Content-Disposition: attachment so the +// browser triggers a download with `.html` filename. Raw HTML +// body (NOT JSON-wrapped) — the BottomActions wiring (u20) will pipe +// the response body straight into a Blob → a[download] click chain +// mirroring the existing serializeSlidePlan JSON download flow. +// ============================================================================= + +export function inlineAssetsAsDataUrls(html: string, assetsRoot: string): string { + // Match `url(assets/)` (with optional single/double quotes, + // optional surrounding whitespace). The Phase Z2 render path emits + // `url(assets//.png)` verbatim into inline `style="..."` + // custom-property declarations (see slide_base.html `--card-frame-bg` + // etc.) — there is no `` or `` external + // ref to handle. Keeping the matcher narrow avoids accidentally + // rewriting `data:` / `http(s):` / sibling-path URLs that the render + // path does not produce. + const URL_RE = /url\(\s*(['"]?)assets\/([^)'"]+)\1\s*\)/g; + return html.replace(URL_RE, (match, _quote: string, rel: string) => { + const filePath = path.join(assetsRoot, rel); + if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) return match; + const ext = path.extname(filePath).toLowerCase().slice(1); + const mime = + ext === "png" ? "image/png" : + ext === "jpg" || ext === "jpeg" ? "image/jpeg" : + ext === "svg" ? "image/svg+xml" : + ext === "webp" ? "image/webp" : + ext === "gif" ? "image/gif" : + "application/octet-stream"; + const buf = fs.readFileSync(filePath); + return `url("data:${mime};base64,${buf.toString("base64")}")`; + }); +} + +export function handleExportStandalone( + req: PutReqLike, + res: ResLike, + designAgentRoot: string, +): boolean { + if (req.method !== "POST") return false; + let body = ""; + req.on("data", (chunk: Buffer | string) => { + body += typeof chunk === "string" ? chunk : chunk.toString(); + }); + req.on("end", () => { + let parsed: unknown; + try { + parsed = body.length > 0 ? JSON.parse(body) : {}; + } catch { + res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify({ error: "invalid JSON" })); + return; + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify({ error: "body must be a JSON object" })); + return; + } + const { run_id } = parsed as { run_id?: unknown }; + if (typeof run_id !== "string") { + res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify({ error: "missing run_id" })); + return; + } + if (!isValidUserOverridesKey(run_id)) { + res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify({ error: "invalid run_id" })); + return; + } + const runDir = path.join(designAgentRoot, "data", "runs", run_id, "phase_z2"); + const srcHtml = path.join(runDir, "final.html"); + if (!fs.existsSync(srcHtml)) { + res.writeHead(404, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify({ error: "final.html not found" })); + return; + } + let html: string; + try { + html = fs.readFileSync(srcHtml, "utf-8"); + } catch (err) { + res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify({ error: `read failed: ${String(err)}` })); + return; + } + const inlined = inlineAssetsAsDataUrls(html, path.join(runDir, "assets")); + res.writeHead(200, { + "Content-Type": "text/html; charset=utf-8", + "Content-Disposition": `attachment; filename="${run_id}.html"`, + }); + res.end(inlined); + }); + req.on("error", () => { + res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify({ error: "request error" })); + }); + return true; +} + // ============================================================================= // Phase Z API Plugin — MDX 업로드 → 파이프라인 실행 → 결과 노출 // @@ -514,14 +730,19 @@ export function handlePutUserOverrides( // GET /data/runs/{run_id}/{path} → {DESIGN_AGENT_ROOT}/data/runs/{run_id}/phase_z2/{path} // GET /api/user-overrides/{key} → data/user_overrides/{key}.json (IMP-52 u3) // PUT /api/user-overrides/{key} → partial-merge save (IMP-52 u4) +// POST /api/connect → cel mirror (IMP-56 #90 u18) +// POST /api/export → standalone HTML download (IMP-56 #90 u19) // // 환경 변수 (선택) : // DESIGN_AGENT_ROOT python pipeline 실행 cwd. default = D:/ad-hoc/kei/design_agent +// CEL_PROJECT_ROOT cel astro dev repo root. default = D:/ad-hoc/cel // ============================================================================= function vitePluginPhaseZApi(): Plugin { const DESIGN_AGENT_ROOT = process.env.DESIGN_AGENT_ROOT || "D:\\ad-hoc\\kei\\design_agent"; + const CEL_PROJECT_ROOT = + process.env.CEL_PROJECT_ROOT || "D:\\ad-hoc\\cel"; const UPLOADS_DIR = path.join(DESIGN_AGENT_ROOT, "samples", "uploads"); const RUNS_DIR = path.join(DESIGN_AGENT_ROOT, "data", "runs"); @@ -801,6 +1022,27 @@ function vitePluginPhaseZApi(): Plugin { next(); }); + // ── POST /api/connect → cel astro public/slides mirror ── + // IMP-56 (#90) u18 — see handleConnectMirror docblock for body shape + + // copy semantics. Logic lives in the pure helper so vitest can drive + // it without booting vite. + server.middlewares.use("/api/connect", (req, res, next) => { + if (handleConnectMirror(req, res, DESIGN_AGENT_ROOT, CEL_PROJECT_ROOT)) return; + next(); + }); + + // ── POST /api/export → standalone HTML download ── + // IMP-56 (#90) u19 — see handleExportStandalone docblock for body + // shape + inline-asset semantics. Logic lives in the pure helper + // (handleExportStandalone + inlineAssetsAsDataUrls) so vitest can + // drive it without booting vite. The response is raw text/html + // (Content-Disposition: attachment); the u20 BottomActions wiring + // will turn the response body into a Blob → a[download] click. + server.middlewares.use("/api/export", (req, res, next) => { + if (handleExportStandalone(req, res, DESIGN_AGENT_ROOT)) return; + next(); + }); + // ── GET /data/runs/{run_id}/{path} → {RUNS_DIR}/{run_id}/phase_z2/{path} ── server.middlewares.use("/data/runs", (req, res, next) => { if (req.method !== "GET") return next(); diff --git a/src/phase_z2_pipeline.py b/src/phase_z2_pipeline.py index e9f46db..a37450c 100644 --- a/src/phase_z2_pipeline.py +++ b/src/phase_z2_pipeline.py @@ -109,6 +109,32 @@ from src.phase_z2_ai_fallback.step17 import run_step17_popup_gate # WRITE the snapshot — restore wiring lands in u4. from src.phase_z2_reuse_snapshot import build_snapshot, SNAPSHOT_FILENAME +# IMP-56 (#90) u5 — Step 12 text_overrides apply. Pure deterministic; no AI. +# u4 resolver imports are aliased with leading-underscore so the helper at +# `_apply_text_overrides_to_zones` reads as a pipeline-private wrapper +# (mirrors the AI-repair u5 / image-overrides u7 wrapper naming pattern). +from src.text_override_resolver import ( + apply_text_override as _apply_text_override, + validate_text_overrides as _validate_text_overrides, + InvalidTextOverride as _InvalidTextOverride, +) +# IMP-56 (#90) u7 — Step 12 structure_overrides apply. Pure deterministic; no AI. +# u6 resolver imports are aliased with leading-underscore so the helper at +# `_apply_structure_overrides_to_zones` reads as a pipeline-private wrapper +# (mirrors the u5 text_overrides wrapper naming pattern). SCOPE LOCKED to +# slot_order + hidden_slots — frame swap stays on the existing `frames` +# axis to preserve Phase Z's no-AI-HTML-structure invariant. +from src.structure_override_resolver import ( + apply_structure_override as _apply_structure_override, + validate_structure_overrides as _validate_structure_overrides, +) +# IMP-56 (#90) u9 — Step 13 text_path stamping wired into render_slide. +# u8 stamper injects ``data-text-path="{slot_key}.{line_index}"`` onto each +# ``text-line`` opening tag so the frontend SlideCanvas (u10+) can attribute +# per-line edits back to the ``text_overrides`` axis (u1 schema, u4 resolver, +# u5 Step-12 apply). Pure deterministic; no AI / HTTP / subprocess. +from src.text_path_stamper import stamp_zone_html as _stamp_zone_html + # ─── Constants ────────────────────────────────────────────────── @@ -882,6 +908,102 @@ def _apply_ai_repair_proposals_to_zones( record["apply_status"] = "applied:partial_overrides" +def _apply_text_overrides_to_zones( + text_overrides: dict, + zones_data: list[dict], +) -> dict: + """IMP-56 (#90) u5 — Apply persisted ``text_overrides`` to Step 12 zones. + + Captures the audit shape the Step 12 ``text_overrides`` artifact emits + so reviewers can see which user text edits re-applied to the next render + and which silently skipped (stale ``text_path`` after a frame swap / + layout regression). Per-entry tolerant by design — mirrors the + ``image_overrides`` u7 contract so a single malformed row never blocks + a batch (Phase Z PZ-4 "no silent shrink" — surface, don't drop). + + raw_content preservation : the function mutates ``zone["slot_payload"]`` + in place only — ``debug_zones[i].source_section_ids`` and the + ``MdxSection`` graph live elsewhere and stay byte-identical. This honours + the Stage 1 binding contract ("raw_content preserved at Step 12") and + the global no-silent-shrink rule (PZ-4). + """ + sanitized = _validate_text_overrides(text_overrides or {}) + applied = 0 + skipped = 0 + per_zone: list[dict] = [] + for zone in zones_data: + zone_id = zone.get("position") + if not isinstance(zone_id, str) or zone_id not in sanitized: + continue + slot_payload = zone.get("slot_payload") + if not isinstance(slot_payload, dict): + continue + z_applied = 0 + z_skipped = 0 + for text_path, value in sanitized[zone_id].items(): + try: + ok = _apply_text_override(slot_payload, text_path, value) + except _InvalidTextOverride: + ok = False + if ok: + z_applied += 1 + else: + z_skipped += 1 + applied += z_applied + skipped += z_skipped + per_zone.append({ + "position": zone_id, + "applied": z_applied, + "skipped": z_skipped, + }) + return {"applied": applied, "skipped": skipped, "per_zone": per_zone} + + +def _apply_structure_overrides_to_zones( + structure_overrides: dict, + zones_data: list[dict], +) -> dict: + """IMP-56 (#90) u7 — Apply persisted ``structure_overrides`` at Step 12. + + SCOPE LOCK : only top-level ``slot_payload`` key membership + ordering + is mutated (reorder + hide). NO DOM rebuild. NO frame swap — the u6 + validate gate drops any non-``slot_order`` / non-``hidden_slots`` inner + key silently. Frame swap stays on the existing ``frames`` axis. + + Per-zone tolerant — stale slot_keys (frame swap / layout regression + between renders) silently no-op via ``apply_structure_override``'s + ``False`` return (counted as ``skipped_zones``). + + raw_content preservation : per-slot ``list[str]`` line content is + NEVER inspected or modified here — the resolver only reorders / + removes top-level slot_payload keys. ``debug_zones[i].source_section_ids`` + + the ``MdxSection`` graph stay byte-identical (mirrors the u5 wiring + invariant + Stage 1 binding contract). + """ + sanitized = _validate_structure_overrides(structure_overrides or {}) + applied_zones = 0 + skipped_zones = 0 + per_zone: list[dict] = [] + for zone in zones_data: + zone_id = zone.get("position") + if not isinstance(zone_id, str) or zone_id not in sanitized: + continue + slot_payload = zone.get("slot_payload") + if not isinstance(slot_payload, dict): + continue + mutated = _apply_structure_override(slot_payload, sanitized[zone_id]) + if mutated: + applied_zones += 1 + else: + skipped_zones += 1 + per_zone.append({"position": zone_id, "mutated": mutated}) + return { + "applied_zones": applied_zones, + "skipped_zones": skipped_zones, + "per_zone": per_zone, + } + + def _check_post_ai_coverage_invariant( units, ai_repair_records: list[dict], @@ -3131,7 +3253,14 @@ def render_slide(slide_title: str, slide_footer: Optional[str], rendered_partial, f"zones_data[{zone_index}] template_id={template_id!r}", ) - zone["partial_html"] = rendered_partial + # IMP-56 (#90) u9 — Step 13 text_path stamp (u8 stamper, pure + # deterministic). Injects ``data-text-path="{slot_key}.{line_index}"`` + # onto each ``text-line`` opening tag so the frontend SlideCanvas + # (u10+) can attribute per-line edits back to the text_overrides + # axis. Idempotent + forward-compat: non-list slots are silently + # skipped, excess text-lines pass through unstamped, and an + # already-stamped element is left unchanged. + zone["partial_html"] = _stamp_zone_html(rendered_partial, slot_payload) base = env.get_template("slide_base.html") rendered_base = base.render( @@ -4889,6 +5018,8 @@ def run_phase_z2_mvp1( override_section_assignments: Optional[dict[str, list[str]]] = None, override_image_overrides: Optional[dict[str, dict]] = None, override_slide_css: Optional[str] = None, + override_text_overrides: Optional[dict[str, dict[str, str]]] = None, + override_structure_overrides: Optional[dict[str, dict[str, list[str]]]] = None, reuse_from: Optional[str] = None, ) -> Path: """MVP-1.5b entry — single slide + composition planner v0 + 8 preset vocabulary. @@ -4912,6 +5043,32 @@ def run_phase_z2_mvp1( backend contract (KNOWN_AXES u1 + Vite allowlist u2 + typed client u3 + stamper u4) end-to-end addressable from CLI without diverging the function signature. + override_text_overrides : {zone_id: {text_path: value}} — IMP-56 (#90) u5 + axis. ``text_path`` = ``{slot_key}.{line_index}`` stamped + by the u8 ``text_path_stamper`` (pending). Applied at + Step 12 AFTER the AI-repair apply (IMP-47B u5) and + BEFORE the ``step12_slot_payload.json`` artifact emit so + the audit reflects user-edit final state. Per-zone + tolerant — stale paths (frame swap / layout regression) + skip silently. raw_content preserved (mutates + ``zone['slot_payload']`` only ; ``debug_zones`` graph + untouched). CLI / persistence fallback wiring is u16 + scope. + override_structure_overrides : {zone_id: {slot_order|hidden_slots: [slot_key,...]}} + — IMP-56 (#90) u7 axis. SCOPE LOCKED to + ``slot_order`` (partial reorder) + ``hidden_slots`` + (hide). Frame swap is rejected at the u6 validate + gate (stays on the existing ``frames`` axis) so the + Phase Z no-AI-HTML-structure invariant remains + intact. Applied at Step 12 AFTER the u5 + text_overrides apply and BEFORE the + ``step12_slot_payload.json`` artifact emit. Per-zone + tolerant — stale slot_keys silently no-op (counted + as ``skipped_zones``). raw_content preserved (only + top-level slot_payload key membership + ordering + mutated ; per-slot ``list[str]`` line content + + ``debug_zones`` graph untouched). CLI / persistence + fallback wiring is u16 scope. override_slide_css : Optional slide-level CSS string — IMP-45 (#74) u4 axis. Marker-wrapped @@ -389,5 +421,22 @@
{% endif %}
+ diff --git a/tests/phase_z2/test_slide_base_print_mode.py b/tests/phase_z2/test_slide_base_print_mode.py new file mode 100644 index 0000000..cd8da47 --- /dev/null +++ b/tests/phase_z2/test_slide_base_print_mode.py @@ -0,0 +1,151 @@ +"""IMP-90 (#90) u17 — slide_base.html print-mode contract tests. + +Stage 2 plan contract (unit u17): + Step 22 user-edit + Export track. The Phase Z2 print path MUST + auto-expand
popups so the FULL raw_content (MDX 원문 무손실 + 보존) is included when the user prints / exports from the browser. + + u17 introduces two coordinated surfaces in + ``templates/phase_z2/slide_base.html``: + + 1. ``@media print`` CSS block — neutralizes the on-screen-only body + centering / box-shadow / 280px popup card clipping so the slide + prints at 1280×720 with the expanded popup body in static flow. + + 2. ``beforeprint`` / ``afterprint`` JavaScript hook at body level — + toggles ``details.open`` to ``true`` before the print snapshot + and restores the user's prior open/closed state afterwards. Body + level (outside any ``
...
`` block) preserves + the IMP-35 u8 popup-render JS-free invariant + (tests/phase_z2/test_slide_base_popup_render.py + ``test_popup_emits_no_javascript_on_render_path``). + +Invariants locked here: + P-1: ``@media print`` block is emitted exactly once in the render. + P-2: ``@page`` size matches the 1280×720 slide canvas. + P-3: ``.slide`` box-shadow + body padding/min-height neutralized at + print time. + P-4: ``.zone__popup-summary`` hidden, popup body switches from + absolute to static flow with unconstrained height — the popup + card chrome (border / shadow / 280px max-height) is unset. + P-5: ``beforeprint`` + ``afterprint`` listeners are wired at body + level (NOT inside the per-zone details block) so the popup + render path stays JS-free. + P-6: Restore semantics — the script preserves the user's prior + open/closed state via a single ``dataset.imp90PrintRestore`` key + (no global state, no event-bus mutation). +""" +from __future__ import annotations + +import re + +from src.phase_z2_pipeline import render_slide + + +def _layout_css() -> dict: + return {"areas": '"primary"', "cols": "1fr", "rows": "1fr"} + + +def _zone(**overrides) -> dict: + base = { + "position": "primary", + "template_id": "__empty__", + "slot_payload": {}, + } + base.update(overrides) + return base + + +def _render() -> str: + return render_slide( + slide_title="t", + slide_footer=None, + zones_data=[_zone()], + layout_preset="single", + layout_css=_layout_css(), + gap_px=14, + ) + + +# ─── P-1 ─ media print block presence ─────────────────────────────── + + +def test_media_print_block_emitted_once(): + html = _render() + matches = re.findall(r"@media\s+print\s*\{", html) + assert len(matches) == 1 + + +# ─── P-2 ─ @page size matches slide canvas ────────────────────────── + + +def test_page_size_matches_slide_canvas(): + html = _render() + flat = re.sub(r"\s+", " ", html) + assert "@page { size: 1280px 720px; margin: 0; }" in flat + + +# ─── P-3 ─ standalone chrome neutralized at print ─────────────────── + + +def test_slide_box_shadow_neutralized_at_print(): + html = _render() + flat = re.sub(r"\s+", " ", html) + print_block = re.search(r"@media\s+print\s*\{(.*?)\}\s*", flat) + assert print_block is not None + body = print_block.group(1) + assert "box-shadow: none !important" in body + assert "padding: 0 !important" in body + assert "min-height: 0 !important" in body + + +# ─── P-4 ─ popup body switches to static flow, summary hidden ─────── + + +def test_popup_card_chrome_unset_at_print(): + html = _render() + flat = re.sub(r"\s+", " ", html) + print_block = re.search(r"@media\s+print\s*\{(.*?)\}\s*", flat) + assert print_block is not None + body = print_block.group(1) + assert ".zone__popup-summary { display: none !important; }" in body + assert "position: static !important" in body + assert "max-height: none !important" in body + assert "overflow: visible !important" in body + + +# ─── P-5 ─ beforeprint hook is body-level (NOT inside
) ──── + + +def test_beforeprint_and_afterprint_listeners_present(): + html = _render() + assert "addEventListener('beforeprint'" in html + assert "addEventListener('afterprint'" in html + + +def test_print_script_is_outside_any_details_block(): + """The IMP-35 u8 popup render path is JS-free. Our print script + sits at body level after the slide div, so no