feat(#90): IMP-56 u1-u19 catch-up before final close (post-u20 push fix)
Some checks failed
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 20s
Some checks failed
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 20s
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<EditMode> = ["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<HTMLDivElement>(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<EditMode>("off");
|
||||
const isEditMode = editMode !== "off";
|
||||
const editGates = computeEditModeGates(editMode, !!isPendingLayout);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(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<HTMLImageElement>(
|
||||
'.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<string, string[]>
|
||||
>({});
|
||||
useEffect(() => {
|
||||
if (editMode !== "structure" || isPendingLayout) {
|
||||
setSlotKeysByZone({});
|
||||
return;
|
||||
}
|
||||
const doc = iframeRef.current?.contentDocument;
|
||||
if (!doc) return;
|
||||
const next: Record<string, string[]> = {};
|
||||
doc.querySelectorAll(".zone[data-zone-position]").forEach((zEl) => {
|
||||
const zoneId = (zEl as HTMLElement).getAttribute("data-zone-position");
|
||||
if (!zoneId) return;
|
||||
const seen = new Set<string>();
|
||||
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({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 편집 모드 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 && (
|
||||
<div
|
||||
data-testid="edit-mode-toolbar"
|
||||
className="absolute top-2 right-2 z-30 flex gap-1"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
>
|
||||
{EDIT_MODES.map((mode) => {
|
||||
const active = editMode === mode;
|
||||
const label =
|
||||
mode === "text" ? "✏ 텍스트" : mode === "structure" ? "▦ 구조" : "🖼 이미지/존";
|
||||
const title =
|
||||
mode === "text"
|
||||
? "텍스트 편집 — 텍스트 클릭하여 직접 수정"
|
||||
: mode === "structure"
|
||||
? "구조 편집 — slot 순서 / 숨김 변경 (u14 펜딩)"
|
||||
: "이미지/존 편집 — 이미지 드래그·리사이즈 + 존 리사이즈";
|
||||
return (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
data-testid={`edit-mode-${mode}`}
|
||||
aria-pressed={active}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsEditMode((p) => !p);
|
||||
setEditMode((prev) => nextEditMode(prev, mode));
|
||||
}}
|
||||
className={`absolute top-2 right-2 z-30 text-[10px] font-bold uppercase tracking-tighter px-2.5 py-1 rounded shadow transition ${
|
||||
isEditMode
|
||||
className={`text-[10px] font-bold uppercase tracking-tighter px-2.5 py-1 rounded shadow transition ${
|
||||
active
|
||||
? "bg-emerald-500 text-white hover:bg-emerald-600 ring-2 ring-emerald-200"
|
||||
: "bg-white text-slate-700 hover:bg-slate-100 border border-slate-200"
|
||||
}`}
|
||||
style={{ pointerEvents: "auto" }}
|
||||
title={
|
||||
isEditMode
|
||||
? "편집 모드 — 텍스트 클릭하여 수정. 다시 클릭하여 종료. (변경은 frontend 만, backend 반영 미구현)"
|
||||
: "텍스트 직접 편집 모드 진입"
|
||||
}
|
||||
title={title}
|
||||
>
|
||||
{isEditMode ? "✏ 편집 중 (클릭 종료)" : "✏ 편집"}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
@@ -375,7 +577,13 @@ export default function SlideCanvas({
|
||||
className="w-full h-full border-0 block"
|
||||
scrolling="no"
|
||||
sandbox="allow-same-origin allow-scripts"
|
||||
style={{ pointerEvents: isEditMode ? "auto" : "none" }}
|
||||
// IMP-90 (#90) u12 — iframe pointer-events gate. 'text' needs
|
||||
// pe:auto so the user can click into text fields; 'image-zone'
|
||||
// needs pe:auto so user-content image clicks can reach the
|
||||
// in-iframe click handler that drives `selectedImageId`.
|
||||
// 'structure' and 'off' keep pe:none — structure has no
|
||||
// in-iframe gesture (u14 will overlay React-side controls).
|
||||
style={{ pointerEvents: editGates.iframePointerAuto ? "auto" : "none" }}
|
||||
onLoad={(e) => {
|
||||
// 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<HTMLDivElement>) => {
|
||||
// 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<HTMLDivElement>
|
||||
) => {
|
||||
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({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 */}
|
||||
<div
|
||||
@@ -941,8 +1156,10 @@ export default function SlideCanvas({
|
||||
strips intercept the perimeter while the un-covered iframe
|
||||
interior keeps text-edit reachability intact.
|
||||
pendingLayout mode already has wrapper pointerEvents:auto,
|
||||
so these surfaces are only needed in edit mode. */}
|
||||
{isEditMode && !isPendingLayout && onZoneResize && (
|
||||
so these surfaces are only needed in edit mode.
|
||||
IMP-90 u12: image-zone-mode-only — text / structure 모드는
|
||||
zone drag 안 함 (editGates.zoneGestures = false 두 모드 모두). */}
|
||||
{editGates.zoneGestures && !isPendingLayout && onZoneResize && (
|
||||
<>
|
||||
<div
|
||||
onMouseDown={handleZoneMouseDown}
|
||||
@@ -987,6 +1204,39 @@ export default function SlideCanvas({
|
||||
);
|
||||
})}
|
||||
|
||||
{/* IMP-90 (#90) u14 — structure edit overlay (slot reorder +
|
||||
hide). Renders only in `editMode === "structure"` over each
|
||||
measured zone, positioned at the zone's top-right inside the
|
||||
slide-absolute coord space. Slot keys come from u14 iframe
|
||||
traversal (`slotKeysByZone`). Mutations emit through
|
||||
onStructureEdit; u15 will debounce + PUT. */}
|
||||
{!isPendingLayout && editMode === "structure" && finalHtmlUrl &&
|
||||
slidePlan?.zones.map((zone) => {
|
||||
const m = measuredZones[zone.zone_id];
|
||||
if (!m) return null;
|
||||
const slotKeys = slotKeysByZone[zone.zone_id] ?? [];
|
||||
const current = structureOverrides?.[zone.zone_id];
|
||||
return (
|
||||
<div
|
||||
key={`struct-${zone.id}`}
|
||||
className="absolute z-30"
|
||||
style={{
|
||||
left: m.x * W_SCALED,
|
||||
top: m.y * H_SCALED,
|
||||
width: m.w * W_SCALED,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<StructureEditOverlay
|
||||
zoneId={zone.zone_id}
|
||||
slotKeys={slotKeys}
|
||||
current={current}
|
||||
onChange={onStructureEdit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* ── 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];
|
||||
|
||||
165
Front/client/src/components/StructureEditOverlay.tsx
Normal file
165
Front/client/src/components/StructureEditOverlay.tsx
Normal file
@@ -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<string>;
|
||||
/** 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<string>,
|
||||
slotOrder?: ReadonlyArray<string> | null,
|
||||
): string[] {
|
||||
if (!slotOrder || slotOrder.length === 0) return [...slotKeys];
|
||||
const allowed = new Set(slotKeys);
|
||||
const seen = new Set<string>();
|
||||
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<T>(
|
||||
arr: ReadonlyArray<T>,
|
||||
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<string>) => {
|
||||
onChange?.(zoneId, {
|
||||
slot_order: nextOrder,
|
||||
hidden_slots: Array.from(nextHidden),
|
||||
});
|
||||
};
|
||||
return (
|
||||
<div
|
||||
data-testid={`structure-overlay-${zoneId}`}
|
||||
className="bg-white/95 border border-emerald-300 rounded shadow p-2 flex flex-col gap-1 text-[10px]"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
>
|
||||
<div className="font-bold uppercase tracking-wider text-emerald-700 mb-1">
|
||||
▦ {zoneId}
|
||||
</div>
|
||||
{effective.length === 0 ? (
|
||||
<div className="text-slate-400 italic">slot 없음</div>
|
||||
) : (
|
||||
effective.map((key, i) => (
|
||||
<div
|
||||
key={key}
|
||||
data-testid={`slot-${zoneId}-${key}`}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<span
|
||||
className={`flex-1 truncate ${
|
||||
hidden.has(key) ? "text-slate-400 line-through" : "text-slate-700"
|
||||
}`}
|
||||
>
|
||||
{key}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`slot-up-${zoneId}-${key}`}
|
||||
disabled={i === 0}
|
||||
onClick={() => emit(moveItem(effective, i, -1), hidden)}
|
||||
className="px-1 rounded border border-slate-200 disabled:opacity-30 hover:bg-slate-100"
|
||||
title="위로"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`slot-down-${zoneId}-${key}`}
|
||||
disabled={i === effective.length - 1}
|
||||
onClick={() => emit(moveItem(effective, i, 1), hidden)}
|
||||
className="px-1 rounded border border-slate-200 disabled:opacity-30 hover:bg-slate-100"
|
||||
title="아래로"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`slot-hide-${zoneId}-${key}`}
|
||||
aria-pressed={hidden.has(key)}
|
||||
onClick={() => {
|
||||
const nh = new Set(hidden);
|
||||
if (nh.has(key)) nh.delete(key);
|
||||
else nh.add(key);
|
||||
emit(effective, nh);
|
||||
}}
|
||||
className="px-1 rounded border border-slate-200 hover:bg-slate-100"
|
||||
title={hidden.has(key) ? "표시" : "숨김"}
|
||||
>
|
||||
{hidden.has(key) ? "🚫" : "👁"}
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -75,6 +75,37 @@ export type ImageOverridesOverride = Record<string, ImageOverride>;
|
||||
*/
|
||||
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<string, string>;
|
||||
export type TextOverridesOverride = Record<string, TextOverridesPerZone>;
|
||||
|
||||
/**
|
||||
* 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<string, StructureOverridePerZone>;
|
||||
|
||||
/** 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). */
|
||||
|
||||
@@ -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<string, Record<string, string>>;
|
||||
structure_overrides: Record<string, { slot_order?: string[]; hidden_slots?: string[] }>;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<UserOverrides>` (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<string, unknown> {
|
||||
return !!x && typeof x === "object" && !Array.isArray(x);
|
||||
}
|
||||
|
||||
function _dedupeStringList(arr: unknown): string[] {
|
||||
if (!Array.isArray(arr)) return [];
|
||||
const seen = new Set<string>();
|
||||
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<UserOverrides> | 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<UserOverrides> | 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,
|
||||
|
||||
282
Front/client/tests/imp90_connect_endpoint.test.ts
Normal file
282
Front/client/tests/imp90_connect_endpoint.test.ts
Normal file
@@ -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<string, string>,
|
||||
body: "",
|
||||
ended: false,
|
||||
};
|
||||
return {
|
||||
state,
|
||||
res: {
|
||||
writeHead(status: number, headers?: Record<string, string>) {
|
||||
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/<slug>.html on success", () => {
|
||||
seedRun(daRoot, "mdx03_run", "<html>03</html>");
|
||||
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("<html>03</html>");
|
||||
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", "<html>04</html>");
|
||||
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", "<html>05</html>");
|
||||
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");
|
||||
});
|
||||
});
|
||||
219
Front/client/tests/imp90_edit_mode_gating.test.tsx
Normal file
219
Front/client/tests/imp90_edit_mode_gating.test.tsx
Normal file
@@ -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<EditMode>(ALL_MODES)(
|
||||
"pendingLayout=true forces every gate false (editMode=%s)",
|
||||
(mode) => {
|
||||
const g = computeEditModeGates(mode, true);
|
||||
expect(g).toEqual<EditModeGates>({
|
||||
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<EditModeGates>({
|
||||
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<EditModeGates>({
|
||||
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<EditMode>(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<EditMode>(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<EditMode>(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<EditMode>(ALL_MODES)(
|
||||
"textEditing → iframePointerAuto (editMode=%s)",
|
||||
(mode) => {
|
||||
const g = computeEditModeGates(mode, false);
|
||||
if (g.textEditing) expect(g.iframePointerAuto).toBe(true);
|
||||
}
|
||||
);
|
||||
it.each<EditMode>(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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
133
Front/client/tests/imp90_edit_mode_state.test.tsx
Normal file
133
Front/client/tests/imp90_edit_mode_state.test.tsx
Normal file
@@ -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<EditMode>(["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<EditMode>(["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");
|
||||
});
|
||||
});
|
||||
255
Front/client/tests/imp90_export_endpoint.test.ts
Normal file
255
Front/client/tests/imp90_export_endpoint.test.ts
Normal file
@@ -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<string, string>,
|
||||
body: "",
|
||||
ended: false,
|
||||
};
|
||||
return {
|
||||
state,
|
||||
res: {
|
||||
writeHead(status: number, headers?: Record<string, string>) {
|
||||
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, Buffer | 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");
|
||||
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 = "<html><style>body{color:red;}</style><body>hi</body></html>";
|
||||
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"), "<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", "<html><body>03</body></html>");
|
||||
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("<html><body>03</body></html>");
|
||||
});
|
||||
|
||||
it("inlines assets in final.html when run dir has assets/", () => {
|
||||
const pngBytes = Buffer.from("PNGDATA");
|
||||
seedRun(
|
||||
daRoot,
|
||||
"mdx05_run",
|
||||
"<html><body><div style=\"background: url(assets/f/x.png)\"></div></body></html>",
|
||||
{ "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)");
|
||||
});
|
||||
});
|
||||
150
Front/client/tests/imp90_structure_overlay.test.tsx
Normal file
150
Front/client/tests/imp90_structure_overlay.test.tsx
Normal file
@@ -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<string>([], 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]);
|
||||
});
|
||||
});
|
||||
259
Front/client/tests/imp90_text_edit_capture.test.tsx
Normal file
259
Front/client/tests/imp90_text_edit_capture.test.tsx
Normal file
@@ -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<string, string>;
|
||||
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<TextEditCapture>({
|
||||
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<TextEditCapture>({
|
||||
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`);
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -35,6 +35,8 @@ import {
|
||||
deriveUserOverridesKey,
|
||||
remapPersistedFramesToZoneFrames,
|
||||
saveImageOverride,
|
||||
saveTextOverride,
|
||||
saveStructureOverride,
|
||||
} from "../src/utils/slidePlanUtils";
|
||||
|
||||
// ─── Fixtures ───────────────────────────────────────────────────────────────
|
||||
@@ -59,6 +61,11 @@ function makeSelection(overrides?: Partial<UserSelection["overrides"]>): 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<string, Record<string, string>>,
|
||||
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({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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": "<id>", "slug": "<mdx-stem>"}.
|
||||
// • Copies <DESIGN_AGENT_ROOT>/data/runs/<run_id>/phase_z2/final.html →
|
||||
// <CEL_PROJECT_ROOT>/public/slides/<slug>.html (overwrite).
|
||||
// • If <run_dir>/phase_z2/assets/ exists, mirrors its contents into
|
||||
// <CEL_PROJECT_ROOT>/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": "<id>"}.
|
||||
// • Reads <DESIGN_AGENT_ROOT>/data/runs/<run_id>/phase_z2/final.html.
|
||||
// • Inlines every `url(assets/<frame>/<file>)` 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 `<run_id>.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/<rel-path>)` (with optional single/double quotes,
|
||||
// optional surrounding whitespace). The Phase Z2 render path emits
|
||||
// `url(assets/<frame>/<file>.png)` verbatim into inline `style="..."`
|
||||
// custom-property declarations (see slide_base.html `--card-frame-bg`
|
||||
// etc.) — there is no `<link rel="stylesheet">` or `<img src>` 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();
|
||||
|
||||
@@ -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 <style> block injected into ``final.html``
|
||||
at Step 13 via :func:`src.slide_css_injector.inject_slide_css`
|
||||
@@ -6607,6 +6764,59 @@ def run_phase_z2_mvp1(
|
||||
note="IMP-47B u6 — Step 12 AI repair gather + apply records per unit (route, skip_reason, apply_status, proposal). u7 coverage_invariant = pre/post AI source_section_ids set comparison.",
|
||||
)
|
||||
|
||||
# ─── Step 12 IMP-56 #90 u5 — Apply persisted text_overrides ───
|
||||
# User text edits captured by the frontend (u12 capture path, pending)
|
||||
# re-apply here so the next render shows them without re-clicking.
|
||||
# Per-zone tolerant — stale ``text_path`` entries (frame swap / layout
|
||||
# regression) skip silently. raw_content preserved : the helper only
|
||||
# mutates ``zone["slot_payload"]`` ; ``debug_zones[i].source_section_ids``
|
||||
# + ``MdxSection`` graph untouched. Audit artifact emits BEFORE the
|
||||
# slot_payload artifact so reviewers see the post-override state.
|
||||
text_overrides_audit = _apply_text_overrides_to_zones(
|
||||
override_text_overrides or {}, zones_data,
|
||||
)
|
||||
_write_step_artifact(
|
||||
run_dir, 12, "text_overrides",
|
||||
data=text_overrides_audit,
|
||||
step_status="done",
|
||||
pipeline_path_connected=True,
|
||||
inputs=["data/user_overrides/*.json"],
|
||||
outputs=["step12_text_overrides.json"],
|
||||
note=(
|
||||
"IMP-56 #90 u5 — user text edit re-apply to slot_payload "
|
||||
"(per-zone tolerant; raw_content preserved). text_path = "
|
||||
"{slot_key}.{line_index} (u8 stamper contract)."
|
||||
),
|
||||
)
|
||||
|
||||
# ─── Step 12 IMP-56 #90 u7 — Apply persisted structure_overrides ───
|
||||
# User reorder / hide choices captured by the frontend (u13 capture
|
||||
# path, pending) re-apply here. SCOPE LOCKED to slot_order +
|
||||
# hidden_slots — frame swap rejected at u6 validate gate (stays on
|
||||
# the existing `frames` axis) so the Phase Z no-AI-HTML-structure
|
||||
# invariant remains intact. raw_content preserved : helper only
|
||||
# reorders / removes top-level slot_payload keys ; per-slot line
|
||||
# lists + ``debug_zones`` + ``MdxSection`` graph untouched. Audit
|
||||
# artifact emits BEFORE the slot_payload artifact so reviewers see
|
||||
# the post-override state.
|
||||
structure_overrides_audit = _apply_structure_overrides_to_zones(
|
||||
override_structure_overrides or {}, zones_data,
|
||||
)
|
||||
_write_step_artifact(
|
||||
run_dir, 12, "structure_overrides",
|
||||
data=structure_overrides_audit,
|
||||
step_status="done",
|
||||
pipeline_path_connected=True,
|
||||
inputs=["data/user_overrides/*.json"],
|
||||
outputs=["step12_structure_overrides.json"],
|
||||
note=(
|
||||
"IMP-56 #90 u7 — user reorder / hide re-apply to "
|
||||
"slot_payload (SCOPE LOCKED to slot_order + hidden_slots ; "
|
||||
"frame swap stays on the `frames` axis ; raw_content "
|
||||
"preserved)."
|
||||
),
|
||||
)
|
||||
|
||||
# ─── Step 12: Slot Payload (actual values, mapper.py 결과) ───
|
||||
_write_step_artifact(
|
||||
run_dir, 12, "slot_payload",
|
||||
@@ -8203,6 +8413,15 @@ if __name__ == "__main__":
|
||||
)
|
||||
sys.exit(2)
|
||||
|
||||
# IMP-56 (#90) u16 — text_overrides + structure_overrides are file-only
|
||||
# axes (frontend captures via /api/user-overrides PUT; no CLI flag
|
||||
# surface, by design — per-line text edits and per-zone slot reorders /
|
||||
# hides are too granular for argparse). Initialize empty here so the
|
||||
# user_overrides.json fallback below can fill them, mirroring the
|
||||
# ``overrides_images`` (IMP-51 #79 u6) post-CLI / pre-_persisted shape.
|
||||
overrides_text: dict[str, dict[str, str]] = {}
|
||||
overrides_structure: dict[str, dict[str, list[str]]] = {}
|
||||
|
||||
# IMP-52 (#80) u2 — user_overrides.json persistence fallback.
|
||||
# After argparse fully parses CLI flags, fill ONLY the axes the user
|
||||
# did NOT pass on the command line. CLI payload always wins over the
|
||||
@@ -8309,6 +8528,51 @@ if __name__ == "__main__":
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
overrides_images = _accepted_img
|
||||
# text_overrides — file-only (no CLI flag) → fill from file as
|
||||
# dict[zone_id, dict[text_path, value]]. IMP-56 (#90) u16. Inline
|
||||
# gate accepts only str-keyed inner dicts; the u4 validator runs
|
||||
# again at Step 12 apply time, where malformed inner entries
|
||||
# surface as silent per-zone skips in the ``step12_text_overrides``
|
||||
# audit (Phase Z PZ-4 "no silent shrink" — count, don't drop).
|
||||
if not overrides_text:
|
||||
_file_text = _persisted.get("text_overrides")
|
||||
if isinstance(_file_text, dict):
|
||||
_accepted_text: dict[str, dict[str, str]] = {}
|
||||
for _zid, _payload in _file_text.items():
|
||||
if isinstance(_zid, str) and isinstance(_payload, dict):
|
||||
_entries = {
|
||||
str(_k): str(_v)
|
||||
for _k, _v in _payload.items()
|
||||
if isinstance(_k, str) and isinstance(_v, str)
|
||||
}
|
||||
if _entries:
|
||||
_accepted_text[_zid] = _entries
|
||||
overrides_text = _accepted_text
|
||||
# structure_overrides — file-only → fill from file as dict[zone_id,
|
||||
# {slot_order|hidden_slots: list[str]}]. IMP-56 (#90) u16. Inner
|
||||
# keys locked to the two allowed names; the u6 validator drops any
|
||||
# other key + frame-swap payloads at apply time so the CLI gate
|
||||
# only does the list[str] structural coercion. Empty inner dicts
|
||||
# are dropped here so ``overrides_structure or None`` collapses to
|
||||
# ``None`` on the call site (matches IMP-51 #79 u6 ``or None`` shape).
|
||||
if not overrides_structure:
|
||||
_file_struct = _persisted.get("structure_overrides")
|
||||
if isinstance(_file_struct, dict):
|
||||
_accepted_struct: dict[str, dict[str, list[str]]] = {}
|
||||
for _zid, _payload in _file_struct.items():
|
||||
if isinstance(_zid, str) and isinstance(_payload, dict):
|
||||
_entries_s: dict[str, list[str]] = {}
|
||||
for _k, _v in _payload.items():
|
||||
if (
|
||||
_k in ("slot_order", "hidden_slots")
|
||||
and isinstance(_v, list)
|
||||
):
|
||||
_entries_s[_k] = [
|
||||
s for s in _v if isinstance(s, str)
|
||||
]
|
||||
if _entries_s:
|
||||
_accepted_struct[_zid] = _entries_s
|
||||
overrides_structure = _accepted_struct
|
||||
|
||||
# IMP-43 (#72) u1 — fail-closed reuse_from precondition guard.
|
||||
# Placed AFTER the user_overrides.json merge so persisted overrides
|
||||
@@ -8350,5 +8614,7 @@ if __name__ == "__main__":
|
||||
override_section_assignments=overrides_section_assignments or None,
|
||||
override_image_overrides=overrides_images or None,
|
||||
override_slide_css=_final_override_slide_css,
|
||||
override_text_overrides=overrides_text or None,
|
||||
override_structure_overrides=overrides_structure or None,
|
||||
reuse_from=args.reuse_from,
|
||||
)
|
||||
|
||||
189
src/structure_override_resolver.py
Normal file
189
src/structure_override_resolver.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""IMP-56 (#90) u6 — structure_override resolver (validator + apply).
|
||||
|
||||
Step-22 user structure-edit persist axis. Consumed by Step 12 (u7 wiring)
|
||||
so a prior render's reorder / hide choices re-apply to the next render
|
||||
without re-clicking.
|
||||
|
||||
Schema (defined verbatim in ``src/user_overrides_io.py:30`` u2) ::
|
||||
|
||||
structure_overrides = {
|
||||
<zone_id>: {
|
||||
"slot_order": [<slot_key>, ...], # optional, partial reorder
|
||||
"hidden_slots": [<slot_key>, ...], # optional, hide these slot_keys
|
||||
},
|
||||
...
|
||||
}
|
||||
|
||||
SCOPE LOCK (Stage 2 u6 contract, IMP-56 #90 u2 docstring) :
|
||||
|
||||
The only allowed inner keys are ``slot_order`` and ``hidden_slots``.
|
||||
Any other key (e.g., ``frame_id``, ``template_id``, ``unit_id``,
|
||||
``slot_payload``) is treated as a frame-swap / DOM-rebuild attempt and
|
||||
is DROPPED at validate time. Frame swap stays on the existing
|
||||
``frames`` axis so the Phase Z no-AI-HTML-structure invariant remains
|
||||
intact. There is intentionally NO escape hatch through this axis.
|
||||
|
||||
API (deterministic, no AI) :
|
||||
|
||||
- ``validate_structure_overrides(overrides)`` → sanitized copy. Per-entry
|
||||
tolerant (drops malformed rows; never rejects the whole batch — mirrors
|
||||
``src.text_override_resolver.validate_text_overrides`` u4 contract).
|
||||
- ``apply_structure_override(zone, override)`` → ``True`` if the slot-payload
|
||||
mapping was mutated (any hide or any reorder), ``False`` otherwise. The
|
||||
``zone`` argument is the slot-payload mapping at Step 12 (a mutable
|
||||
mapping whose keys are slot_keys and whose values are typically
|
||||
``list[str]`` of lines). Identity-preserving: mutates in-place via
|
||||
``clear`` + ``update`` so caller references remain valid.
|
||||
|
||||
Guardrails (refs : Stage 1 binding contract, Stage 2 unit u6) :
|
||||
|
||||
- raw_content preservation is a wiring-layer (u7) responsibility — the
|
||||
resolver only ever reorders / removes top-level slot_payload entries.
|
||||
Per-slot ``list[str]`` line content is never inspected or mutated here.
|
||||
- AI-isolation : pure deterministic Python; no LLM calls.
|
||||
- Carve-out (IMP-46 #62) : brand-new module, does not touch the #76
|
||||
commit ``1186ad8`` cache region.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping, MutableMapping
|
||||
|
||||
|
||||
class InvalidStructureOverride(ValueError):
|
||||
"""Reserved for future strict-mode parse errors.
|
||||
|
||||
Currently unused — the resolver follows the u4 per-entry-tolerant
|
||||
contract and silently drops malformed rows at validate time rather
|
||||
than raising. Kept as a public surface so u7 wiring (and future
|
||||
strict-mode callers) can distinguish source-malformation from
|
||||
stale-DOM misses without an API rev.
|
||||
"""
|
||||
|
||||
|
||||
_ALLOWED_INNER_KEYS: frozenset[str] = frozenset({"slot_order", "hidden_slots"})
|
||||
|
||||
|
||||
def _sanitize_slot_list(raw: Any) -> list[str]:
|
||||
"""Return a fresh list of non-empty string slot_keys (drop the rest)."""
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for slot in raw:
|
||||
if not isinstance(slot, str) or not slot:
|
||||
continue
|
||||
if slot in seen:
|
||||
# De-dup defensively — a duplicate slot_key in slot_order would
|
||||
# be meaningless (dicts can hold each key once); duplicate in
|
||||
# hidden_slots is redundant. Drop subsequent occurrences.
|
||||
continue
|
||||
seen.add(slot)
|
||||
out.append(slot)
|
||||
return out
|
||||
|
||||
|
||||
def validate_structure_overrides(
|
||||
overrides: Any,
|
||||
) -> dict[str, dict[str, list[str]]]:
|
||||
"""Return a sanitized copy of ``overrides`` (per-entry tolerant).
|
||||
|
||||
Drops:
|
||||
- non-string or empty zone_ids,
|
||||
- non-mapping per-zone payloads,
|
||||
- per-zone inner keys other than ``slot_order`` / ``hidden_slots``
|
||||
(frame-swap attempts are dropped at this gate — see SCOPE LOCK),
|
||||
- non-list ``slot_order`` / ``hidden_slots`` values,
|
||||
- non-string or empty slot_key entries within those lists,
|
||||
- per-zone payloads that contain neither a non-empty ``slot_order``
|
||||
nor a non-empty ``hidden_slots`` after sanitization (empty intent
|
||||
carries no signal).
|
||||
|
||||
Returns a fresh ``dict`` AND fresh nested dicts / lists so callers can
|
||||
use the result as a working buffer without aliasing the persisted
|
||||
payload from ``user_overrides_io.load``.
|
||||
"""
|
||||
if not isinstance(overrides, Mapping):
|
||||
return {}
|
||||
out: dict[str, dict[str, list[str]]] = {}
|
||||
for zone_id, mapping in overrides.items():
|
||||
if not isinstance(zone_id, str) or not zone_id:
|
||||
continue
|
||||
if not isinstance(mapping, Mapping):
|
||||
continue
|
||||
zone_out: dict[str, list[str]] = {}
|
||||
for inner_key, inner_value in mapping.items():
|
||||
if inner_key not in _ALLOWED_INNER_KEYS:
|
||||
# Frame-swap attempt or unknown key — drop silently per
|
||||
# SCOPE LOCK. No mechanism through this axis.
|
||||
continue
|
||||
sanitized = _sanitize_slot_list(inner_value)
|
||||
if sanitized:
|
||||
zone_out[inner_key] = sanitized
|
||||
if zone_out:
|
||||
out[zone_id] = zone_out
|
||||
return out
|
||||
|
||||
|
||||
def apply_structure_override(
|
||||
zone: MutableMapping[str, Any],
|
||||
override: Mapping[str, Any],
|
||||
) -> bool:
|
||||
"""Apply ONE structure override to ``zone`` in-place.
|
||||
|
||||
``zone`` is the slot-payload mapping at Step 12 — i.e. a mutable
|
||||
mapping whose keys are slot_keys and whose values are the per-slot
|
||||
line lists (or other content payload). Mutation is restricted to
|
||||
top-level key membership + ordering; per-slot values are NEVER
|
||||
inspected or modified here.
|
||||
|
||||
``override`` is the per-zone payload after :func:`validate_structure_overrides`
|
||||
sanitization — i.e. a mapping with only ``slot_order`` and / or
|
||||
``hidden_slots`` keys, each holding a list of non-empty str slot_keys.
|
||||
This function is also defensive: if non-list values leak through, they
|
||||
are treated as empty (no raise).
|
||||
|
||||
Semantics :
|
||||
1. ``hidden_slots`` are popped first. Entries absent from ``zone``
|
||||
are silently skipped (stale slot_keys from a prior frame).
|
||||
2. ``slot_order`` partially reorders the surviving slot_keys:
|
||||
listed keys (that are present in ``zone``) move to the front in
|
||||
the given order; remaining keys keep their original relative
|
||||
order at the tail. Unknown slot_keys are silently skipped.
|
||||
|
||||
Returns ``True`` if the zone's slot-payload mapping was mutated (any
|
||||
hide that removed a key OR any reorder that changed key order),
|
||||
``False`` otherwise. Identity-preserving: rebuilds via
|
||||
``clear`` + ``update`` so the caller's reference to ``zone`` remains
|
||||
valid.
|
||||
"""
|
||||
mutated = False
|
||||
|
||||
raw_hidden = override.get("hidden_slots") if isinstance(override, Mapping) else None
|
||||
hidden = _sanitize_slot_list(raw_hidden)
|
||||
for slot in hidden:
|
||||
if slot in zone:
|
||||
del zone[slot]
|
||||
mutated = True
|
||||
|
||||
raw_order = override.get("slot_order") if isinstance(override, Mapping) else None
|
||||
desired_order_seed = _sanitize_slot_list(raw_order)
|
||||
|
||||
current_order = list(zone.keys())
|
||||
desired_order: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for slot in desired_order_seed:
|
||||
if slot in zone and slot not in seen:
|
||||
desired_order.append(slot)
|
||||
seen.add(slot)
|
||||
for slot in current_order:
|
||||
if slot not in seen:
|
||||
desired_order.append(slot)
|
||||
seen.add(slot)
|
||||
|
||||
if desired_order != current_order:
|
||||
snapshot = {k: zone[k] for k in desired_order}
|
||||
zone.clear()
|
||||
zone.update(snapshot)
|
||||
mutated = True
|
||||
|
||||
return mutated
|
||||
143
src/text_override_resolver.py
Normal file
143
src/text_override_resolver.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""IMP-56 (#90) u4 — text_override resolver (validator + apply).
|
||||
|
||||
Step-22 user text-edit persist axis. Consumed by Step 12 (u5 wiring) so a
|
||||
prior render's text edits re-apply to the next render without re-clicking.
|
||||
|
||||
Schema (defined verbatim in ``src/user_overrides_io.py:29`` u1) ::
|
||||
|
||||
text_overrides = {
|
||||
<zone_id>: {<text_path>: <value: str>},
|
||||
...
|
||||
}
|
||||
|
||||
``text_path`` is the ``{slot_key}.{line_index}`` stamp emitted at Step 13
|
||||
by the u8 ``text_path_stamper`` (pending unit) and surfaced to the frontend
|
||||
SlideCanvas (u12) as ``data-text-path`` attributes on editable text nodes.
|
||||
The ``{slot_key}`` is a frame contract slot identifier (e.g.,
|
||||
``slot_title``); the ``{line_index}`` is the 0-based ordinal of the line
|
||||
within that slot's rendered text (typically one bullet / one paragraph).
|
||||
|
||||
API (deterministic, no AI) :
|
||||
|
||||
- ``parse_text_path(text_path)`` → ``(slot_key, line_index)`` or raises.
|
||||
- ``validate_text_overrides(overrides)`` → sanitized copy (drops malformed
|
||||
per-entry; never rejects the whole batch — mirrors the per-entry
|
||||
tolerance contract of ``src.image_id_stamper.build_image_overrides_style``
|
||||
IMP-51 #79 u7).
|
||||
- ``apply_text_override(zone, text_path, value)`` → ``True`` on in-place
|
||||
mutation; ``False`` if the path is absent / out-of-range. The ``zone``
|
||||
argument is the slot-lines mapping at Step 12 — i.e. a mutable mapping
|
||||
where ``zone[slot_key]`` is a ``list[str]`` of line strings. Wiring at
|
||||
Step 12 (u5) is responsible for extracting that mapping from whatever
|
||||
composition object holds it; this resolver is decoupled from the wrapper
|
||||
shape so it can be re-targeted at Stage 5 (Step 12) layer-A or layer-B
|
||||
composition data without an API rev.
|
||||
|
||||
Guardrails (refs : Stage 1 binding contract, Stage 2 unit u4) :
|
||||
|
||||
- raw_content preservation is a wiring-layer (u5) responsibility — the
|
||||
resolver itself only ever mutates the lines mapping it was handed.
|
||||
- AI-isolation : pure deterministic Python; no LLM calls.
|
||||
- Carve-out (IMP-46 #62) : brand-new module, does not touch the #76
|
||||
commit ``1186ad8`` cache region.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping, MutableMapping
|
||||
|
||||
|
||||
class InvalidTextOverride(ValueError):
|
||||
"""Raised when a ``text_path`` is malformed (parse-time)."""
|
||||
|
||||
|
||||
def parse_text_path(text_path: str) -> tuple[str, int]:
|
||||
"""Parse ``{slot_key}.{line_index}`` into ``(slot_key, line_index)``.
|
||||
|
||||
``slot_key`` may itself contain ``.`` (e.g., compound keys), so the
|
||||
parse splits on the LAST ``.`` only — ``rpartition`` semantics.
|
||||
"""
|
||||
if not isinstance(text_path, str) or not text_path:
|
||||
raise InvalidTextOverride(
|
||||
f"text_path must be a non-empty string, got: {text_path!r}"
|
||||
)
|
||||
if "." not in text_path:
|
||||
raise InvalidTextOverride(
|
||||
f"text_path must contain '.' separator, got: {text_path!r}"
|
||||
)
|
||||
slot_key, _, idx_str = text_path.rpartition(".")
|
||||
if not slot_key or not idx_str:
|
||||
raise InvalidTextOverride(
|
||||
f"text_path slot_key and line_index must both be non-empty, "
|
||||
f"got: {text_path!r}"
|
||||
)
|
||||
try:
|
||||
idx = int(idx_str)
|
||||
except ValueError as exc:
|
||||
raise InvalidTextOverride(
|
||||
f"text_path line_index must be int, got: {text_path!r}"
|
||||
) from exc
|
||||
if idx < 0:
|
||||
raise InvalidTextOverride(
|
||||
f"text_path line_index must be >= 0, got: {idx} in {text_path!r}"
|
||||
)
|
||||
return slot_key, idx
|
||||
|
||||
|
||||
def validate_text_overrides(overrides: Any) -> dict[str, dict[str, str]]:
|
||||
"""Return a sanitized copy of ``overrides`` (per-entry tolerant).
|
||||
|
||||
Drops:
|
||||
- non-string or empty zone_ids,
|
||||
- non-mapping per-zone payloads,
|
||||
- non-string text_path keys, non-string values,
|
||||
- text_paths that fail :func:`parse_text_path`.
|
||||
|
||||
Returns a fresh ``dict`` so callers can mutate without aliasing the
|
||||
persisted payload from ``user_overrides_io.load``.
|
||||
"""
|
||||
if not isinstance(overrides, Mapping):
|
||||
return {}
|
||||
out: dict[str, dict[str, str]] = {}
|
||||
for zone_id, mapping in overrides.items():
|
||||
if not isinstance(zone_id, str) or not zone_id:
|
||||
continue
|
||||
if not isinstance(mapping, Mapping):
|
||||
continue
|
||||
zone_out: dict[str, str] = {}
|
||||
for text_path, value in mapping.items():
|
||||
if not isinstance(text_path, str) or not isinstance(value, str):
|
||||
continue
|
||||
try:
|
||||
parse_text_path(text_path)
|
||||
except InvalidTextOverride:
|
||||
continue
|
||||
zone_out[text_path] = value
|
||||
if zone_out:
|
||||
out[zone_id] = zone_out
|
||||
return out
|
||||
|
||||
|
||||
def apply_text_override(
|
||||
zone: MutableMapping[str, Any],
|
||||
text_path: str,
|
||||
value: str,
|
||||
) -> bool:
|
||||
"""Apply ONE text override to ``zone`` in-place.
|
||||
|
||||
``zone`` is the slot-lines mapping at Step 12 — i.e. a mutable mapping
|
||||
where ``zone[slot_key]`` is a ``list[str]`` of line strings.
|
||||
|
||||
Returns ``True`` when the value was replaced. Returns ``False`` (no
|
||||
mutation) when the ``slot_key`` is absent, the slot is not a list, or
|
||||
``line_index`` is out of range. Out-of-range / absent paths are NOT an
|
||||
error — they happen naturally when a prior render's overrides target a
|
||||
slot the new render no longer emits (frame swap, layout regression).
|
||||
"""
|
||||
slot_key, idx = parse_text_path(text_path)
|
||||
if slot_key not in zone:
|
||||
return False
|
||||
lines = zone[slot_key]
|
||||
if not isinstance(lines, list) or idx >= len(lines):
|
||||
return False
|
||||
lines[idx] = value
|
||||
return True
|
||||
155
src/text_path_stamper.py
Normal file
155
src/text_path_stamper.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""IMP-56 (#90) u8 — text_path stamper for Phase Z final.html.
|
||||
|
||||
Annotates rendered ``text-line`` DOM elements with a stable
|
||||
``data-text-path="{slot_key}.{line_index}"`` attribute so the frontend
|
||||
SlideCanvas (u10~u12) can attribute per-line edits back to the
|
||||
``text_overrides`` axis (u1 schema, u4 resolver, u5 Step-12 apply).
|
||||
|
||||
DOM contract (single point of truth — mirrored verbatim across the axis) ::
|
||||
|
||||
.text-line[data-text-path="{slot_key}.{line_index}"]
|
||||
|
||||
The ``{slot_key}.{line_index}`` grammar matches
|
||||
:func:`src.text_override_resolver.parse_text_path` verbatim (split on LAST
|
||||
``.`` — compound slot keys with embedded dots are supported).
|
||||
|
||||
The text-line element format is emitted by every Phase Z family / frame
|
||||
template (e.g. ``templates/phase_z2/families/bim_current_problems_paired.html``
|
||||
line 143)::
|
||||
|
||||
<div class="text-line[ ...modifier classes...]">{{ line.text | safe }}</div>
|
||||
|
||||
The stamper finds each ``text-line`` opening tag with a permissive regex
|
||||
and injects ``data-text-path="..."`` as the FIRST attribute. Existing
|
||||
attributes (class, etc.) are preserved verbatim. The injection is
|
||||
idempotent — a previously stamped element is left alone.
|
||||
|
||||
Stamping order : the stamper iterates ``slot_payload`` in dict-iteration
|
||||
order and yields one stamp per ``list`` entry. The DOM walk consumes
|
||||
stamps in left-to-right order; templates currently emit slot lines in
|
||||
the same order they appear in ``slot_payload`` so the alignment holds.
|
||||
If a future template diverges, u9 wiring can pre-build the desired
|
||||
``(slot_key, line_index)`` sequence and pass it explicitly through the
|
||||
``stamps`` arg of :func:`stamp_zone_html`.
|
||||
|
||||
Forward-compat / safety :
|
||||
- Scalar (non-list) slot values are silently skipped — they render
|
||||
outside ``text-line`` divs (frame title, pill labels, etc.) and are
|
||||
not addressable via the line-index grammar.
|
||||
- Excess ``text-line`` elements beyond ``sum(len(v) for v in
|
||||
slot_payload.values() if isinstance(v, list))`` are left unstamped.
|
||||
- Re-stamping (idempotent) preserves the first stamp.
|
||||
|
||||
Guardrails (refs : Stage 1 binding contract, Stage 2 unit u8) :
|
||||
- AI-isolation : pure deterministic Python; no LLM calls.
|
||||
- Carve-out (IMP-46 #62) : brand-new module; does not touch the #76
|
||||
commit ``1186ad8`` cache region.
|
||||
- Idempotent : ``data-text-path`` probe short-circuits before re-inject.
|
||||
- u9 wiring (separate unit) is the only consumer; this module emits no
|
||||
artifacts and reads no global state.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Iterable, Iterator, Mapping
|
||||
|
||||
TEXT_PATH_ATTR: str = "data-text-path"
|
||||
|
||||
# Matches a ``<div ... class="... text-line ..." ...>`` opening tag.
|
||||
# Group 1 captures the inner attribute string verbatim (incl. leading
|
||||
# whitespace) so the rewriter can re-emit it unchanged after injection.
|
||||
_TEXT_LINE_TAG_RE = re.compile(
|
||||
r'<div\b((?=[^>]*\bclass\s*=\s*"[^"]*\btext-line\b)[^>]*?)>',
|
||||
flags=re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
# Probe for an existing ``data-text-path`` attribute (any value, any
|
||||
# quote) so re-stamping is idempotent.
|
||||
_HAS_TEXT_PATH_RE = re.compile(r"""\bdata-text-path\s*=""", flags=re.IGNORECASE)
|
||||
|
||||
|
||||
def build_text_path(slot_key: str, line_index: int) -> str:
|
||||
"""Return the canonical ``{slot_key}.{line_index}`` text_path string.
|
||||
|
||||
Mirrors the inverse of :func:`src.text_override_resolver.parse_text_path`
|
||||
(last-dot split). ``slot_key`` may itself contain ``.`` (compound keys).
|
||||
"""
|
||||
if not isinstance(slot_key, str) or not slot_key:
|
||||
raise ValueError(
|
||||
f"slot_key must be a non-empty string, got: {slot_key!r}"
|
||||
)
|
||||
if isinstance(line_index, bool) or not isinstance(line_index, int):
|
||||
raise ValueError(
|
||||
f"line_index must be a non-negative int, got: {line_index!r}"
|
||||
)
|
||||
if line_index < 0:
|
||||
raise ValueError(
|
||||
f"line_index must be a non-negative int, got: {line_index!r}"
|
||||
)
|
||||
return f"{slot_key}.{line_index}"
|
||||
|
||||
|
||||
def iter_zone_stamps(
|
||||
slot_payload: Mapping[str, Any],
|
||||
) -> Iterator[tuple[str, int]]:
|
||||
"""Yield ``(slot_key, line_index)`` for every list-valued slot line.
|
||||
|
||||
Iteration order matches ``slot_payload`` dict iteration order. Non-
|
||||
string / empty slot_keys are skipped. Non-list values are skipped
|
||||
(scalar slots render outside ``text-line`` divs).
|
||||
"""
|
||||
if not isinstance(slot_payload, Mapping):
|
||||
return
|
||||
for slot_key, value in slot_payload.items():
|
||||
if not isinstance(slot_key, str) or not slot_key:
|
||||
continue
|
||||
if not isinstance(value, list):
|
||||
continue
|
||||
for line_index in range(len(value)):
|
||||
yield slot_key, line_index
|
||||
|
||||
|
||||
def stamp_zone_html(
|
||||
zone_html: str,
|
||||
slot_payload_or_stamps: Mapping[str, Any] | Iterable[tuple[str, int]],
|
||||
) -> str:
|
||||
"""Stamp ``text-line`` opening tags in ``zone_html`` with ``data-text-path``.
|
||||
|
||||
The second arg accepts either:
|
||||
- a ``slot_payload`` ``Mapping`` (uses :func:`iter_zone_stamps` order), or
|
||||
- an iterable of pre-built ``(slot_key, line_index)`` tuples.
|
||||
|
||||
Stamps are consumed in left-to-right DOM order. A text-line already
|
||||
carrying ``data-text-path`` is left unchanged (idempotent). Excess
|
||||
text-line elements beyond the stamp sequence are also left unchanged.
|
||||
|
||||
Returns ``zone_html`` unchanged when there are no stamps to apply or
|
||||
the input is not a non-empty string.
|
||||
"""
|
||||
if not isinstance(zone_html, str) or not zone_html:
|
||||
return zone_html
|
||||
if isinstance(slot_payload_or_stamps, Mapping):
|
||||
stamps = list(iter_zone_stamps(slot_payload_or_stamps))
|
||||
else:
|
||||
stamps = [
|
||||
(sk, li)
|
||||
for (sk, li) in slot_payload_or_stamps
|
||||
if isinstance(sk, str) and sk and isinstance(li, int)
|
||||
and not isinstance(li, bool) and li >= 0
|
||||
]
|
||||
if not stamps:
|
||||
return zone_html
|
||||
counter = {"i": 0}
|
||||
|
||||
def _replace(match: re.Match[str]) -> str:
|
||||
attrs = match.group(1) or ""
|
||||
if _HAS_TEXT_PATH_RE.search(attrs):
|
||||
return match.group(0)
|
||||
i = counter["i"]
|
||||
if i >= len(stamps):
|
||||
return match.group(0)
|
||||
counter["i"] = i + 1
|
||||
slot_key, line_index = stamps[i]
|
||||
path = build_text_path(slot_key, line_index)
|
||||
return f'<div {TEXT_PATH_ATTR}="{path}"{attrs}>'
|
||||
|
||||
return _TEXT_LINE_TAG_RE.sub(_replace, zone_html)
|
||||
@@ -5,10 +5,18 @@ auto-restores user choices without re-clicking. Source of truth = MDX-keyed
|
||||
file (stem of the MDX path), NOT ``data/runs/<run_id>/`` which mints a fresh
|
||||
run_id per ``/api/run`` invocation.
|
||||
|
||||
Schema (7 axes; stable order; IMP-51 #79 u1 added ``image_overrides``;
|
||||
Schema (9 axes; stable order; IMP-51 #79 u1 added ``image_overrides``;
|
||||
IMP-45 #74 u1 added ``slide_css``; IMP-55 #93 u1 added
|
||||
``manual_section_assignment`` as a bool intent marker so the backend can
|
||||
distinguish a user drag-drop from frontend auto-carry zone_sections):
|
||||
distinguish a user drag-drop from frontend auto-carry zone_sections;
|
||||
IMP-56 #90 u1 added ``text_overrides`` as a Step-22 text-edit persist axis
|
||||
keyed by ``{zone_id: {text_path: value}}`` where ``text_path`` is the
|
||||
``{slot_key}.{line_index}`` stamp emitted by u8; IMP-56 #90 u2 added
|
||||
``structure_overrides`` as a Step-22 structure-edit persist axis keyed by
|
||||
``{zone_id: {"slot_order": [<slot_key>, ...], "hidden_slots": [<slot_key>, ...]}}``
|
||||
— scope is intentionally LOCKED to slot reorder + hide; frame swap stays
|
||||
on the existing ``frames`` axis to prevent the Phase Z regression of
|
||||
AI-driven HTML structure mutation):
|
||||
|
||||
{
|
||||
"layout": <string|null>,
|
||||
@@ -17,7 +25,9 @@ distinguish a user drag-drop from frontend auto-carry zone_sections):
|
||||
"frames": {<unit_id>: <template_id>},
|
||||
"image_overrides": {<image_id>: {"x": float, "y": float, "w": float, "h": float}},
|
||||
"slide_css": <string|null>,
|
||||
"manual_section_assignment": <bool>
|
||||
"manual_section_assignment": <bool>,
|
||||
"text_overrides": {<zone_id>: {<text_path>: <string>}},
|
||||
"structure_overrides": {<zone_id>: {"slot_order": [<slot_key>, ...], "hidden_slots": [<slot_key>, ...]}}
|
||||
}
|
||||
|
||||
``image_id`` is the stable identifier emitted by the user-content image
|
||||
@@ -58,13 +68,21 @@ from typing import Any, Optional
|
||||
_PKG_ROOT = Path(__file__).resolve().parent.parent
|
||||
DEFAULT_OVERRIDES_ROOT = _PKG_ROOT / "data" / "user_overrides"
|
||||
|
||||
# The seven in-scope axes (IMP-51 #79 u1 added ``image_overrides``; IMP-45
|
||||
# The nine in-scope axes (IMP-51 #79 u1 added ``image_overrides``; IMP-45
|
||||
# #74 u1 added ``slide_css``; IMP-55 #93 u1 added
|
||||
# ``manual_section_assignment`` — bool intent marker that gates whether
|
||||
# persisted ``zone_sections`` are consumed by the backend pipeline). Any
|
||||
# other top-level key in the file is preserved but ignored by callers —
|
||||
# keeps the file forward-compatible with future axes (e.g., zone_sizes)
|
||||
# without a schema bump here.
|
||||
# persisted ``zone_sections`` are consumed by the backend pipeline; IMP-56
|
||||
# #90 u1 added ``text_overrides`` — Step-22 text-edit persist axis keyed by
|
||||
# ``{zone_id: {text_path: value}}`` where ``text_path`` is the
|
||||
# ``{slot_key}.{line_index}`` stamp emitted by u8 / consumed by u4+u5;
|
||||
# IMP-56 #90 u2 added ``structure_overrides`` — Step-22 structure-edit
|
||||
# persist axis keyed by ``{zone_id: {"slot_order": [...], "hidden_slots":
|
||||
# [...]}}``, scope LOCKED to slot reorder + hide so the resolver (u6) /
|
||||
# Step-12 apply (u7) cannot mutate frame identity — frame swap stays on
|
||||
# the existing ``frames`` axis to keep Phase Z's no-AI-HTML-structure
|
||||
# invariant intact). Any other top-level key in the file is preserved but
|
||||
# ignored by callers — keeps the file forward-compatible with future axes
|
||||
# (e.g., zone_sizes) without a schema bump here.
|
||||
KNOWN_AXES: tuple[str, ...] = (
|
||||
"layout",
|
||||
"zone_geometries",
|
||||
@@ -73,6 +91,8 @@ KNOWN_AXES: tuple[str, ...] = (
|
||||
"image_overrides",
|
||||
"slide_css",
|
||||
"manual_section_assignment",
|
||||
"text_overrides",
|
||||
"structure_overrides",
|
||||
)
|
||||
|
||||
# Key validation — MDX stem must be safe for filesystem use. Allow
|
||||
|
||||
@@ -355,6 +355,38 @@
|
||||
line-height: 1.5;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
/* ── IMP-90 u17 : print mode (Step 22 user-edit + Export).
|
||||
Companion JS at body end opens popups so FULL raw_content prints. */
|
||||
@media print {
|
||||
@page { size: 1280px 720px; margin: 0; }
|
||||
html, body {
|
||||
background: #fff !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
min-height: 0 !important;
|
||||
display: block !important;
|
||||
}
|
||||
.slide {
|
||||
box-shadow: none !important;
|
||||
page-break-inside: avoid;
|
||||
break-inside: avoid;
|
||||
}
|
||||
.zone__popup-summary { display: none !important; }
|
||||
.zone__popup-details,
|
||||
.zone__popup-details[open] { position: static !important; }
|
||||
.zone__popup-body {
|
||||
position: static !important;
|
||||
top: auto !important;
|
||||
right: auto !important;
|
||||
max-height: none !important;
|
||||
overflow: visible !important;
|
||||
box-shadow: none !important;
|
||||
border: none !important;
|
||||
width: auto !important;
|
||||
padding: 6px 0 0 0 !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -389,5 +421,22 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<script>
|
||||
// IMP-90 u17 — beforeprint popup auto-expand (CLAUDE.md 자세히보기 contract).
|
||||
// Body-level handler (outside any per-zone popup block) so the popup-render
|
||||
// JS-free invariant (IMP-35 u8) is preserved on the per-zone path.
|
||||
window.addEventListener('beforeprint', function () {
|
||||
document.querySelectorAll('details').forEach(function (d) {
|
||||
d.dataset.imp90PrintRestore = d.open ? '1' : '0';
|
||||
d.open = true;
|
||||
});
|
||||
});
|
||||
window.addEventListener('afterprint', function () {
|
||||
document.querySelectorAll('details').forEach(function (d) {
|
||||
if (d.dataset.imp90PrintRestore === '0') d.open = false;
|
||||
delete d.dataset.imp90PrintRestore;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
151
tests/phase_z2/test_slide_base_print_mode.py
Normal file
151
tests/phase_z2/test_slide_base_print_mode.py
Normal file
@@ -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 <details> 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 ``<details>...</details>`` 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*</style>", 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*</style>", 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 <details>) ────
|
||||
|
||||
|
||||
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 <script> appears
|
||||
inside a <details>...</details> popup block."""
|
||||
html = _render(
|
||||
)
|
||||
# No <details> in the no-popup baseline — but the assertion still
|
||||
# holds defensively: locate every <details>...</details> block (if
|
||||
# any) and confirm no <script> tag appears inside.
|
||||
for block in re.findall(r"<details[\s>].*?</details>", html, re.DOTALL):
|
||||
assert "<script" not in block
|
||||
assert "addEventListener" not in block
|
||||
|
||||
|
||||
# ─── P-6 ─ restore semantics ────────────────────────────────────────
|
||||
|
||||
|
||||
def test_restore_uses_single_dataset_key():
|
||||
"""Restore strategy uses one dataset key
|
||||
(``dataset.imp90PrintRestore``) — no global Set/Map, no mutation
|
||||
of any other DOM attribute. Locks the minimal-surface contract."""
|
||||
html = _render()
|
||||
assert "imp90PrintRestore" in html
|
||||
# Restore branch only sets open=false when the prior state was '0'.
|
||||
assert "imp90PrintRestore === '0'" in html
|
||||
assert "d.open = true" in html
|
||||
216
tests/test_phase_z2_structure_overrides.py
Normal file
216
tests/test_phase_z2_structure_overrides.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""IMP-56 (#90) u7 — Step 12 ``structure_overrides`` apply unit tests.
|
||||
|
||||
Synthetic — exercises ``_apply_structure_overrides_to_zones`` directly
|
||||
without running the full Phase Z 22-step pipeline. The helper is
|
||||
decoupled from ``MdxSection`` / ``CompositionUnit`` graphs and only
|
||||
consumes a minimal ``[{position, slot_payload}, ...]`` zone list, so a
|
||||
synthetic fixture is sufficient to lock the contract.
|
||||
|
||||
Coverage axes (Stage 2 plan u7 + Stage 1 binding contract) :
|
||||
|
||||
- reorder happy path : ``slot_order`` partial reorder mutates
|
||||
``zone['slot_payload']`` key order in place (caller reference stays
|
||||
valid via clear+update rebuild contract documented at u6).
|
||||
- hide happy path : ``hidden_slots`` pops the listed keys.
|
||||
- stale slot_key : absent slot_keys silently no-op (count toward
|
||||
``skipped_zones`` if the whole override produces no mutation).
|
||||
- SCOPE LOCK : frame-swap-shaped inner keys (``frame_id``,
|
||||
``template_id``) are dropped by the u6 validate gate and therefore
|
||||
never reach the apply path here.
|
||||
- raw_content preservation : per-slot ``list[str]`` line content
|
||||
untouched; out-of-band sentinels (mirror of ``debug_zones`` graph)
|
||||
stay byte-identical.
|
||||
- audit shape : ``applied_zones`` / ``skipped_zones`` / ``per_zone``
|
||||
keys present and counts consistent with the input batch.
|
||||
- empty / ``None`` batch is a no-op (empty audit).
|
||||
|
||||
Fully synthetic per Codex generalization guardrail (MOCK_ prefix).
|
||||
NO real catalog template_id / frame_id, NO ``v4_full32_result.yaml``
|
||||
dependency.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_pipeline import _apply_structure_overrides_to_zones
|
||||
|
||||
|
||||
# ─── Synthetic fixture helpers ──────────────────────────────────────
|
||||
|
||||
|
||||
def _zone(position: str, slot_payload: dict) -> dict:
|
||||
"""Minimal zone dict mirroring the Step 12 ``zones_data[i]`` shape."""
|
||||
return {
|
||||
"position": position,
|
||||
"template_id": "MOCK_T_phase_z2_structure_overrides",
|
||||
"slot_payload": slot_payload,
|
||||
"content_weight": 1.0,
|
||||
"min_height_px": 200,
|
||||
}
|
||||
|
||||
|
||||
# ─── Case 1 : reorder happy path ──────────────────────────────────────
|
||||
|
||||
|
||||
def test_apply_reorders_slot_payload_keys_in_place():
|
||||
payload = {"slot_a": ["A"], "slot_b": ["B"], "slot_c": ["C"]}
|
||||
zones = [_zone("top", payload)]
|
||||
overrides = {"top": {"slot_order": ["slot_c", "slot_a"]}}
|
||||
|
||||
audit = _apply_structure_overrides_to_zones(overrides, zones)
|
||||
|
||||
# Caller reference (payload) stays valid via clear+update rebuild.
|
||||
assert zones[0]["slot_payload"] is payload
|
||||
assert list(payload.keys()) == ["slot_c", "slot_a", "slot_b"]
|
||||
# Per-slot list[str] content untouched (raw_content invariant).
|
||||
assert payload["slot_a"] == ["A"]
|
||||
assert payload["slot_b"] == ["B"]
|
||||
assert payload["slot_c"] == ["C"]
|
||||
assert audit["applied_zones"] == 1
|
||||
assert audit["skipped_zones"] == 0
|
||||
assert audit["per_zone"] == [{"position": "top", "mutated": True}]
|
||||
|
||||
|
||||
# ─── Case 2 : hide happy path ─────────────────────────────────────────
|
||||
|
||||
|
||||
def test_apply_hides_listed_slot_keys():
|
||||
payload = {"slot_a": ["A"], "slot_b": ["B"], "slot_c": ["C"]}
|
||||
zones = [_zone("top", payload)]
|
||||
overrides = {"top": {"hidden_slots": ["slot_b"]}}
|
||||
|
||||
audit = _apply_structure_overrides_to_zones(overrides, zones)
|
||||
|
||||
assert "slot_b" not in payload
|
||||
assert list(payload.keys()) == ["slot_a", "slot_c"]
|
||||
assert audit["applied_zones"] == 1
|
||||
assert audit["per_zone"] == [{"position": "top", "mutated": True}]
|
||||
|
||||
|
||||
# ─── Case 3 : stale slot_key — frame swap / layout regression ─────────
|
||||
|
||||
|
||||
def test_stale_slot_key_silently_no_op():
|
||||
"""Absent slot_keys produce no mutation; the zone counts toward skipped_zones."""
|
||||
payload = {"slot_a": ["A"]}
|
||||
zones = [_zone("top", payload)]
|
||||
overrides = {
|
||||
"top": {
|
||||
"hidden_slots": ["slot_missing"], # absent — no-op
|
||||
"slot_order": ["slot_also_missing"], # absent — no-op
|
||||
},
|
||||
}
|
||||
|
||||
audit = _apply_structure_overrides_to_zones(overrides, zones)
|
||||
|
||||
assert list(payload.keys()) == ["slot_a"]
|
||||
assert audit["applied_zones"] == 0
|
||||
assert audit["skipped_zones"] == 1
|
||||
assert audit["per_zone"] == [{"position": "top", "mutated": False}]
|
||||
|
||||
|
||||
# ─── Case 4 : SCOPE LOCK — frame swap shape dropped at validate ───────
|
||||
|
||||
|
||||
def test_frame_swap_keys_dropped_at_validate_no_mutation():
|
||||
payload = {"slot_a": ["A"], "slot_b": ["B"]}
|
||||
zones = [_zone("top", payload)]
|
||||
# frame_id / template_id / slot_payload as inner keys are the canonical
|
||||
# frame-swap / DOM-rebuild shapes the SCOPE LOCK rejects.
|
||||
overrides = {
|
||||
"top": {
|
||||
"frame_id": "MOCK_OTHER_FRAME",
|
||||
"template_id": "MOCK_OTHER_TEMPLATE",
|
||||
"slot_payload": {"slot_a": ["overwritten"]},
|
||||
},
|
||||
}
|
||||
|
||||
audit = _apply_structure_overrides_to_zones(overrides, zones)
|
||||
|
||||
# No mutation: validate gate drops the whole zone payload (no allowed
|
||||
# inner key remains), so the zone never reaches the apply loop.
|
||||
assert payload == {"slot_a": ["A"], "slot_b": ["B"]}
|
||||
assert audit["applied_zones"] == 0
|
||||
assert audit["skipped_zones"] == 0
|
||||
assert audit["per_zone"] == []
|
||||
|
||||
|
||||
# ─── Case 5 : raw_content preservation invariant ──────────────────────
|
||||
|
||||
|
||||
def test_raw_content_sentinel_untouched():
|
||||
"""Helper must not mutate anything outside zone['slot_payload']
|
||||
AND must not mutate per-slot list[str] line content."""
|
||||
raw_sentinel = ["MOCK_S1", "MOCK_S2"]
|
||||
payload = {"slot_a": ["line 1", "line 2"], "slot_b": ["line 3"]}
|
||||
zones = [_zone("top", payload)]
|
||||
zones[0]["source_section_ids_sentinel"] = raw_sentinel # out-of-band
|
||||
zones[0]["raw_content_sentinel"] = "- line 1\n- line 2\n"
|
||||
|
||||
_apply_structure_overrides_to_zones(
|
||||
{"top": {"slot_order": ["slot_b", "slot_a"]}}, zones,
|
||||
)
|
||||
|
||||
# Out-of-band fields untouched.
|
||||
assert zones[0]["source_section_ids_sentinel"] is raw_sentinel
|
||||
assert zones[0]["source_section_ids_sentinel"] == ["MOCK_S1", "MOCK_S2"]
|
||||
assert zones[0]["raw_content_sentinel"] == "- line 1\n- line 2\n"
|
||||
# Per-slot list[str] line content byte-identical.
|
||||
assert payload["slot_a"] == ["line 1", "line 2"]
|
||||
assert payload["slot_b"] == ["line 3"]
|
||||
|
||||
|
||||
# ─── Case 6 : empty / None / irrelevant batch is no-op ────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"batch",
|
||||
[
|
||||
None,
|
||||
{},
|
||||
{"top": {}}, # empty per-zone
|
||||
{"missing_zone": {"slot_order": ["slot_a"]}}, # stale zone_id
|
||||
],
|
||||
)
|
||||
def test_empty_or_irrelevant_batch_is_noop(batch):
|
||||
payload = {"slot_a": ["A"]}
|
||||
zones = [_zone("top", payload)]
|
||||
audit = _apply_structure_overrides_to_zones(batch, zones)
|
||||
|
||||
assert list(payload.keys()) == ["slot_a"]
|
||||
assert audit["applied_zones"] == 0
|
||||
assert audit["skipped_zones"] == 0
|
||||
assert audit["per_zone"] == []
|
||||
|
||||
|
||||
# ─── Case 7 : zone without slot_payload skipped (defensive) ───────────
|
||||
|
||||
|
||||
def test_zone_without_slot_payload_skipped():
|
||||
zones = [{"position": "top"}] # no slot_payload key (defensive contract)
|
||||
audit = _apply_structure_overrides_to_zones(
|
||||
{"top": {"slot_order": ["slot_a"]}}, zones,
|
||||
)
|
||||
assert audit["per_zone"] == []
|
||||
assert audit["applied_zones"] == 0
|
||||
assert audit["skipped_zones"] == 0
|
||||
|
||||
|
||||
# ─── Case 8 : combined reorder + hide in a single zone ────────────────
|
||||
|
||||
|
||||
def test_combined_reorder_and_hide_in_one_zone():
|
||||
payload = {"slot_a": ["A"], "slot_b": ["B"], "slot_c": ["C"]}
|
||||
zones = [_zone("top", payload)]
|
||||
overrides = {
|
||||
"top": {
|
||||
"hidden_slots": ["slot_b"],
|
||||
"slot_order": ["slot_c", "slot_a"],
|
||||
},
|
||||
}
|
||||
|
||||
audit = _apply_structure_overrides_to_zones(overrides, zones)
|
||||
|
||||
assert list(payload.keys()) == ["slot_c", "slot_a"]
|
||||
assert audit["applied_zones"] == 1
|
||||
assert audit["per_zone"] == [{"position": "top", "mutated": True}]
|
||||
158
tests/test_phase_z2_text_overrides.py
Normal file
158
tests/test_phase_z2_text_overrides.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""IMP-56 (#90) u5 — Step 12 ``text_overrides`` apply unit tests.
|
||||
|
||||
Synthetic — exercises ``_apply_text_overrides_to_zones`` directly without
|
||||
running the full Phase Z 22-step pipeline. The helper is decoupled from
|
||||
``MdxSection`` / ``CompositionUnit`` graphs and only consumes a minimal
|
||||
``[{position, slot_payload}, ...]`` zone list, so a synthetic fixture is
|
||||
sufficient to lock the contract.
|
||||
|
||||
Coverage axes (Stage 2 plan u5 + Stage 1 binding contract) :
|
||||
|
||||
- sanitized batch : malformed text_path / non-string value drops per-row
|
||||
(mirrors ``image_id_stamper.build_image_overrides_style`` u7 tolerance).
|
||||
- stale path : frame swap / layout regression → ``skipped``, not error.
|
||||
- raw_content preservation : helper never touches ``debug_zones`` / unit
|
||||
graph (asserted by zero-mutation on an out-of-band sentinel mapping).
|
||||
- audit shape : ``applied`` / ``skipped`` / ``per_zone`` keys present and
|
||||
counts consistent with the input batch.
|
||||
- empty / ``None`` override input is a no-op (empty audit).
|
||||
|
||||
Fully synthetic per Codex generalization guardrail (MOCK_ prefix).
|
||||
NO real catalog template_id / frame_id, NO ``v4_full32_result.yaml``
|
||||
dependency.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_pipeline import _apply_text_overrides_to_zones
|
||||
|
||||
|
||||
# ─── Synthetic fixture helpers ──────────────────────────────────────
|
||||
|
||||
|
||||
def _zone(position: str, slot_payload: dict) -> dict:
|
||||
"""Minimal zone dict mirroring the Step 12 ``zones_data[i]`` shape."""
|
||||
return {
|
||||
"position": position,
|
||||
"template_id": "MOCK_T_phase_z2_text_overrides",
|
||||
"slot_payload": slot_payload,
|
||||
"content_weight": 1.0,
|
||||
"min_height_px": 200,
|
||||
}
|
||||
|
||||
|
||||
# ─── Case 1 : happy path — list[str] slot mutation ────────────────────
|
||||
|
||||
|
||||
def test_apply_replaces_list_line_in_place():
|
||||
zones = [
|
||||
_zone("top", {"slot_title": ["original headline"], "slot_body": ["a", "b", "c"]}),
|
||||
_zone("bottom", {"slot_caption": ["caption A"]}),
|
||||
]
|
||||
overrides = {
|
||||
"top": {"slot_title.0": "edited headline", "slot_body.1": "edited B"},
|
||||
}
|
||||
|
||||
audit = _apply_text_overrides_to_zones(overrides, zones)
|
||||
|
||||
assert zones[0]["slot_payload"]["slot_title"] == ["edited headline"]
|
||||
assert zones[0]["slot_payload"]["slot_body"] == ["a", "edited B", "c"]
|
||||
# bottom zone untouched (no override entry)
|
||||
assert zones[1]["slot_payload"]["slot_caption"] == ["caption A"]
|
||||
assert audit["applied"] == 2
|
||||
assert audit["skipped"] == 0
|
||||
assert audit["per_zone"] == [{"position": "top", "applied": 2, "skipped": 0}]
|
||||
|
||||
|
||||
# ─── Case 2 : stale text_path — frame swap / layout regression ────────
|
||||
|
||||
|
||||
def test_stale_text_path_skipped_silently():
|
||||
"""Absent slot_key + out-of-range line_index both count as skipped, not errors."""
|
||||
zones = [_zone("top", {"slot_title": ["only one line"]})]
|
||||
overrides = {
|
||||
"top": {
|
||||
"slot_title.0": "ok", # applied
|
||||
"slot_title.99": "out of range", # skipped (idx > len)
|
||||
"slot_missing.0": "stale frame", # skipped (slot absent)
|
||||
},
|
||||
}
|
||||
|
||||
audit = _apply_text_overrides_to_zones(overrides, zones)
|
||||
|
||||
assert zones[0]["slot_payload"]["slot_title"] == ["ok"]
|
||||
assert "slot_missing" not in zones[0]["slot_payload"]
|
||||
assert audit["applied"] == 1
|
||||
assert audit["skipped"] == 2
|
||||
assert audit["per_zone"][0] == {"position": "top", "applied": 1, "skipped": 2}
|
||||
|
||||
|
||||
# ─── Case 3 : malformed input — per-entry tolerance ────────────────────
|
||||
|
||||
|
||||
def test_malformed_entries_dropped_in_validate():
|
||||
"""Non-string value / malformed text_path drop in ``validate_text_overrides``."""
|
||||
zones = [_zone("top", {"slot_title": ["original", "second"]})]
|
||||
overrides = {
|
||||
"top": {
|
||||
"slot_title.0": "good", # kept
|
||||
"slot_title.bad": "ignored", # dropped (non-int idx)
|
||||
"slot_title.1": 123, # dropped (non-str value)
|
||||
"no_dot": "ignored", # dropped (missing '.')
|
||||
},
|
||||
"": {"slot_title.0": "empty zone id dropped"}, # zone_id sanitization
|
||||
123: {"slot_title.0": "non-string zone id dropped"},
|
||||
}
|
||||
|
||||
audit = _apply_text_overrides_to_zones(overrides, zones)
|
||||
|
||||
# only the well-formed entry applied
|
||||
assert zones[0]["slot_payload"]["slot_title"] == ["good", "second"]
|
||||
assert audit["applied"] == 1
|
||||
assert audit["skipped"] == 0
|
||||
|
||||
|
||||
# ─── Case 4 : raw_content preservation invariant ──────────────────────
|
||||
|
||||
|
||||
def test_raw_content_sentinel_untouched():
|
||||
"""Helper must not mutate anything outside ``zone['slot_payload']``.
|
||||
|
||||
Out-of-band fields (mirror of ``debug_zones[i].source_section_ids`` /
|
||||
MdxSection graph) stay byte-identical — Stage 1 binding contract.
|
||||
"""
|
||||
raw_sentinel = ["MOCK_S1", "MOCK_S2"]
|
||||
zones = [_zone("top", {"slot_title": ["original"]})]
|
||||
zones[0]["source_section_ids_sentinel"] = raw_sentinel # out-of-band
|
||||
zones[0]["raw_content_sentinel"] = "- original bullet\n- second\n"
|
||||
|
||||
_apply_text_overrides_to_zones({"top": {"slot_title.0": "edited"}}, zones)
|
||||
|
||||
assert zones[0]["source_section_ids_sentinel"] is raw_sentinel # same object
|
||||
assert zones[0]["source_section_ids_sentinel"] == ["MOCK_S1", "MOCK_S2"]
|
||||
assert zones[0]["raw_content_sentinel"] == "- original bullet\n- second\n"
|
||||
|
||||
|
||||
# ─── Case 5 : empty / None batch is no-op ──────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload", [None, {}, {"top": {}}, {"missing_zone": {"slot.0": "x"}}])
|
||||
def test_empty_or_irrelevant_batch_is_noop(payload):
|
||||
zones = [_zone("top", {"slot_title": ["unchanged"]})]
|
||||
audit = _apply_text_overrides_to_zones(payload, zones)
|
||||
|
||||
assert zones[0]["slot_payload"]["slot_title"] == ["unchanged"]
|
||||
assert audit["applied"] == 0
|
||||
assert audit["skipped"] == 0
|
||||
|
||||
|
||||
# ─── Case 6 : zone without slot_payload skipped (defensive) ───────────
|
||||
|
||||
|
||||
def test_zone_without_slot_payload_skipped():
|
||||
zones = [{"position": "top"}] # no slot_payload key (defensive contract)
|
||||
audit = _apply_text_overrides_to_zones({"top": {"slot.0": "x"}}, zones)
|
||||
assert audit["per_zone"] == []
|
||||
assert audit["applied"] == 0
|
||||
assert audit["skipped"] == 0
|
||||
212
tests/test_phase_z2_text_path_stamper.py
Normal file
212
tests/test_phase_z2_text_path_stamper.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""IMP-56 (#90) u9 — Step 13 ``text_path_stamper`` wiring tests.
|
||||
|
||||
Verifies that :func:`src.phase_z2_pipeline.render_slide` stamps each
|
||||
rendered ``text-line`` opening tag with
|
||||
``data-text-path="{slot_key}.{line_index}"`` via the u8 stamper
|
||||
(``src.text_path_stamper.stamp_zone_html``). This is the wiring unit
|
||||
companion to the u8 module-level tests at
|
||||
``tests/test_text_path_stamper.py``.
|
||||
|
||||
Coverage axes (Stage 2 plan u9 + Stage 1 binding contract) :
|
||||
|
||||
- happy path : real ``bim_current_problems_paired`` template emits
|
||||
``text-line`` divs for list-valued slots; the stamper attaches
|
||||
``data-text-path`` with matching ``{slot_key}.{line_index}``.
|
||||
- non-list slots skipped : ``title`` / ``row_*_left_label`` (scalars)
|
||||
do NOT receive ``data-text-path`` attributes.
|
||||
- empty list slots emit no stamps : rows whose body list is empty
|
||||
contribute zero stamps.
|
||||
- deterministic : repeated calls produce byte-identical HTML
|
||||
(no nondeterministic mutation of ``slot_payload`` between renders).
|
||||
- empty zone : the ``__empty__`` template_id short-circuit emits no
|
||||
``data-text-path`` (the stamper short-circuits on empty stamps).
|
||||
|
||||
Fully synthetic slot_payload — no real Phase Z run, no
|
||||
``v4_full32_result.yaml`` dependency. Uses the real
|
||||
``bim_current_problems_paired`` family template only to exercise the
|
||||
genuine Jinja2 + slide_base render path.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_pipeline import render_slide
|
||||
|
||||
|
||||
# ─── Fixture helpers ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _layout_css() -> dict:
|
||||
"""Minimal valid layout_css for a single-zone slide.
|
||||
|
||||
Mirrors tests/phase_z2/test_slide_base_embedded_mode.py shape.
|
||||
"""
|
||||
return {"areas": '"primary"', "cols": "1fr", "rows": "1fr"}
|
||||
|
||||
|
||||
def _paired_slot_payload(
|
||||
*,
|
||||
left_lines: list[str] | None = None,
|
||||
right_lines: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""Build a slot_payload for the bim_current_problems_paired family.
|
||||
|
||||
Only row_1 is populated by default; rows 2-4 stay empty so the
|
||||
template short-circuits to zero text-line divs for them.
|
||||
"""
|
||||
left_lines = left_lines if left_lines is not None else ["L1a", "L1b"]
|
||||
right_lines = right_lines if right_lines is not None else ["R1a"]
|
||||
payload: dict = {
|
||||
"title": "Synthetic Title",
|
||||
"row_1_left_label": "left pill 1",
|
||||
"row_1_left_body": [{"text": t, "indent": 0} for t in left_lines],
|
||||
"row_1_right_label": "right pill 1",
|
||||
"row_1_right_body": [{"text": t, "indent": 0} for t in right_lines],
|
||||
}
|
||||
for r in (2, 3, 4):
|
||||
payload[f"row_{r}_left_label"] = f"left pill {r}"
|
||||
payload[f"row_{r}_left_body"] = []
|
||||
payload[f"row_{r}_right_label"] = f"right pill {r}"
|
||||
payload[f"row_{r}_right_body"] = []
|
||||
return payload
|
||||
|
||||
|
||||
def _zone(template_id: str, slot_payload: dict) -> dict:
|
||||
return {
|
||||
"position": "primary",
|
||||
"template_id": template_id,
|
||||
"slot_payload": slot_payload,
|
||||
}
|
||||
|
||||
|
||||
def _render(zones: list[dict]) -> str:
|
||||
return render_slide(
|
||||
slide_title="t",
|
||||
slide_footer=None,
|
||||
zones_data=zones,
|
||||
layout_preset="single",
|
||||
layout_css=_layout_css(),
|
||||
gap_px=14,
|
||||
embedded_mode="embedded",
|
||||
)
|
||||
|
||||
|
||||
# ─── Case 1 : happy path — list-valued slots stamped ─────────────────
|
||||
|
||||
|
||||
def test_render_slide_stamps_text_path_per_line():
|
||||
"""Each list-valued slot line gets data-text-path={slot}.{index}."""
|
||||
payload = _paired_slot_payload(
|
||||
left_lines=["left line A", "left line B"],
|
||||
right_lines=["right line A"],
|
||||
)
|
||||
html = _render([_zone("bim_current_problems_paired", payload)])
|
||||
|
||||
# left body 2 lines + right body 1 line = 3 stamps in row 1.
|
||||
assert 'data-text-path="row_1_left_body.0"' in html
|
||||
assert 'data-text-path="row_1_left_body.1"' in html
|
||||
assert 'data-text-path="row_1_right_body.0"' in html
|
||||
# row 2-4 are empty → no stamps for those slot_keys.
|
||||
assert "row_2_left_body" not in html
|
||||
assert "row_3_right_body" not in html
|
||||
|
||||
|
||||
def test_stamps_preserve_class_attribute():
|
||||
"""data-text-path injected before existing class attribute, both present."""
|
||||
payload = _paired_slot_payload(left_lines=["only left"], right_lines=[])
|
||||
html = _render([_zone("bim_current_problems_paired", payload)])
|
||||
|
||||
# The original class="text-line..." must survive verbatim alongside
|
||||
# the injected data-text-path attribute on the same opening tag.
|
||||
assert re.search(
|
||||
r'<div\s+data-text-path="row_1_left_body\.0"\s+class="text-line[^"]*">',
|
||||
html,
|
||||
) is not None
|
||||
|
||||
|
||||
# ─── Case 2 : non-list slots are NOT stamped ─────────────────────────
|
||||
|
||||
|
||||
def test_non_list_slots_not_stamped():
|
||||
"""Scalar slot values (title, *_label) get no data-text-path."""
|
||||
payload = _paired_slot_payload(left_lines=["x"], right_lines=["y"])
|
||||
html = _render([_zone("bim_current_problems_paired", payload)])
|
||||
|
||||
# Scalar slots present in slot_payload as strings — must not receive
|
||||
# data-text-path stamps (u8 contract: scalar slots skipped silently
|
||||
# because they render outside text-line divs).
|
||||
assert 'data-text-path="title' not in html
|
||||
assert 'data-text-path="row_1_left_label' not in html
|
||||
assert 'data-text-path="row_1_right_label' not in html
|
||||
|
||||
|
||||
# ─── Case 3 : empty list slots contribute no stamps ──────────────────
|
||||
|
||||
|
||||
def test_empty_list_slots_no_stamps():
|
||||
"""Empty list slot yields zero stamps; template emits zero text-line divs."""
|
||||
payload = _paired_slot_payload(left_lines=[], right_lines=[])
|
||||
html = _render([_zone("bim_current_problems_paired", payload)])
|
||||
|
||||
# No row 1 lines at all (both bodies empty) → no row_1_*_body stamps.
|
||||
assert "data-text-path" not in html
|
||||
|
||||
|
||||
# ─── Case 4 : deterministic — repeated render produces same HTML ─────
|
||||
|
||||
|
||||
def test_render_with_stamp_is_deterministic():
|
||||
"""Same slot_payload → byte-identical HTML across two render_slide calls.
|
||||
|
||||
Guards against the wiring layer accidentally mutating slot_payload
|
||||
between renders (the stamper itself only reads slot_payload; it
|
||||
operates on rendered_partial). Also guards against double-stamping.
|
||||
"""
|
||||
payload_1 = _paired_slot_payload()
|
||||
payload_2 = _paired_slot_payload()
|
||||
html_1 = _render([_zone("bim_current_problems_paired", payload_1)])
|
||||
html_2 = _render([_zone("bim_current_problems_paired", payload_2)])
|
||||
|
||||
assert html_1 == html_2
|
||||
# Counts must match — no double-stamp side effect on shared module state.
|
||||
assert html_1.count("data-text-path=") == html_2.count("data-text-path=")
|
||||
# 2 left + 1 right = 3 stamps for the default fixture.
|
||||
assert html_1.count("data-text-path=") == 3
|
||||
|
||||
|
||||
# ─── Case 5 : __empty__ short-circuit emits no stamps ────────────────
|
||||
|
||||
|
||||
def test_empty_template_short_circuit_no_stamps():
|
||||
"""``template_id=__empty__`` short-circuits before stamping; no stamps."""
|
||||
html = _render([_zone("__empty__", {})])
|
||||
assert "data-text-path" not in html
|
||||
|
||||
|
||||
# ─── Case 6 : slot_payload preserved (raw_content invariant) ─────────
|
||||
|
||||
|
||||
def test_render_does_not_mutate_slot_payload():
|
||||
"""Stamping must not mutate slot_payload list/dict contents.
|
||||
|
||||
The stamper operates on rendered_partial HTML; the source
|
||||
slot_payload should be byte-identical before and after render_slide.
|
||||
Locks the raw_content preservation invariant at the wiring layer.
|
||||
"""
|
||||
payload = _paired_slot_payload(
|
||||
left_lines=["preserved A", "preserved B"],
|
||||
right_lines=["preserved C"],
|
||||
)
|
||||
# Snapshot key list/dict identities and content.
|
||||
snapshot_left = list(payload["row_1_left_body"])
|
||||
snapshot_left_text = [item["text"] for item in snapshot_left]
|
||||
snapshot_right_text = [item["text"] for item in payload["row_1_right_body"]]
|
||||
|
||||
_ = _render([_zone("bim_current_problems_paired", payload)])
|
||||
|
||||
assert [item["text"] for item in payload["row_1_left_body"]] == snapshot_left_text
|
||||
assert [item["text"] for item in payload["row_1_right_body"]] == snapshot_right_text
|
||||
# Scalar slots untouched too.
|
||||
assert payload["title"] == "Synthetic Title"
|
||||
392
tests/test_structure_override_resolver.py
Normal file
392
tests/test_structure_override_resolver.py
Normal file
@@ -0,0 +1,392 @@
|
||||
"""IMP-56 (#90) u6 — tests for ``src.structure_override_resolver``.
|
||||
|
||||
Covers the resolver contract called out in the Stage 2 plan :
|
||||
|
||||
1. ``validate_structure_overrides`` returns ``{}`` for non-mapping input.
|
||||
2. ``validate_structure_overrides`` preserves well-formed entries.
|
||||
3. ``validate_structure_overrides`` drops malformed per-entry rows without
|
||||
rejecting the whole batch (per-entry tolerance — mirrors u4
|
||||
text_override_resolver contract).
|
||||
4. ``validate_structure_overrides`` REJECTS frame swap (any inner key
|
||||
other than slot_order / hidden_slots is silently dropped — SCOPE LOCK).
|
||||
5. ``validate_structure_overrides`` drops non-list slot_order / hidden_slots
|
||||
values.
|
||||
6. ``validate_structure_overrides`` drops non-string or empty slot_keys
|
||||
inside slot_order / hidden_slots.
|
||||
7. ``validate_structure_overrides`` de-duplicates slot_key entries within
|
||||
each list.
|
||||
8. ``validate_structure_overrides`` returns fresh nested dicts AND lists
|
||||
(caller can mutate without aliasing the source).
|
||||
9. ``validate_structure_overrides`` drops per-zone payloads that contain
|
||||
neither a non-empty slot_order nor a non-empty hidden_slots after
|
||||
sanitization.
|
||||
10. ``apply_structure_override`` removes hidden_slots in-place and returns
|
||||
``True``.
|
||||
11. ``apply_structure_override`` reorders the slot-payload mapping per
|
||||
slot_order (partial reorder; unmentioned slots keep tail order).
|
||||
12. ``apply_structure_override`` combines hide + reorder atomically.
|
||||
13. ``apply_structure_override`` silently skips stale slot_keys (frame
|
||||
swap / layout regression) without raising.
|
||||
14. ``apply_structure_override`` returns ``False`` (no mutation) on a
|
||||
no-op override (empty, or all stale).
|
||||
15. ``apply_structure_override`` preserves the caller's reference identity
|
||||
on ``zone`` (in-place mutation via clear + update).
|
||||
16. ``apply_structure_override`` NEVER inspects or mutates per-slot
|
||||
payload values — only top-level key membership / ordering.
|
||||
17. ``apply_structure_override`` is defensive against non-list
|
||||
slot_order / hidden_slots leaking through (treats as empty, no raise).
|
||||
|
||||
All tests are pure-Python — no filesystem, no Selenium, no fixtures.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from src.structure_override_resolver import (
|
||||
InvalidStructureOverride,
|
||||
apply_structure_override,
|
||||
validate_structure_overrides,
|
||||
)
|
||||
|
||||
|
||||
# -- module surface ---------------------------------------------------------
|
||||
|
||||
|
||||
def test_invalid_structure_override_is_value_error_subclass():
|
||||
# Reserved future strict-mode exception — kept as a public surface so
|
||||
# u7 / strict callers can branch on source-malformation vs stale-DOM.
|
||||
assert issubclass(InvalidStructureOverride, ValueError)
|
||||
|
||||
|
||||
# -- validate_structure_overrides ------------------------------------------
|
||||
|
||||
|
||||
def test_validate_structure_overrides_non_mapping_returns_empty():
|
||||
for bad_input in [None, [], "string", 42, 1.5]:
|
||||
assert validate_structure_overrides(bad_input) == {}
|
||||
|
||||
|
||||
def test_validate_structure_overrides_passes_well_formed():
|
||||
payload = {
|
||||
"zone-top": {
|
||||
"slot_order": ["slot_title", "slot_body"],
|
||||
"hidden_slots": ["slot_caption"],
|
||||
},
|
||||
"zone-bottom": {"slot_order": ["slot_a", "slot_b"]},
|
||||
"zone-only-hide": {"hidden_slots": ["slot_x"]},
|
||||
}
|
||||
out = validate_structure_overrides(payload)
|
||||
assert out == {
|
||||
"zone-top": {
|
||||
"slot_order": ["slot_title", "slot_body"],
|
||||
"hidden_slots": ["slot_caption"],
|
||||
},
|
||||
"zone-bottom": {"slot_order": ["slot_a", "slot_b"]},
|
||||
"zone-only-hide": {"hidden_slots": ["slot_x"]},
|
||||
}
|
||||
assert out is not payload # fresh dict
|
||||
|
||||
|
||||
def test_validate_structure_overrides_per_entry_tolerance():
|
||||
payload = {
|
||||
"zone-top": {
|
||||
"slot_order": ["slot_title", "slot_body"],
|
||||
"hidden_slots": ["slot_caption"],
|
||||
},
|
||||
"": {"slot_order": ["x"]}, # empty zone_id dropped
|
||||
42: {"slot_order": ["y"]}, # non-string zone_id dropped
|
||||
"zone-non-mapping": "not a dict", # non-mapping payload dropped
|
||||
"zone-bottom": {"hidden_slots": ["slot_aux"]},
|
||||
}
|
||||
out = validate_structure_overrides(payload)
|
||||
assert out == {
|
||||
"zone-top": {
|
||||
"slot_order": ["slot_title", "slot_body"],
|
||||
"hidden_slots": ["slot_caption"],
|
||||
},
|
||||
"zone-bottom": {"hidden_slots": ["slot_aux"]},
|
||||
}
|
||||
|
||||
|
||||
def test_validate_structure_overrides_rejects_frame_swap_inner_keys():
|
||||
# SCOPE LOCK — frame swap attempts MUST be silently dropped. The only
|
||||
# mechanism for swapping a frame is the existing ``frames`` axis; this
|
||||
# resolver intentionally has no escape hatch so the Phase Z
|
||||
# no-AI-HTML-structure invariant stays intact.
|
||||
payload = {
|
||||
"zone-top": {
|
||||
"slot_order": ["slot_title"],
|
||||
# The following 4 keys are all frame-swap / DOM-rebuild
|
||||
# attempts and MUST be dropped by validate.
|
||||
"frame_id": "compare_v2",
|
||||
"template_id": "topic_left_right",
|
||||
"unit_id": "03-1+03-2",
|
||||
"slot_payload": {"injected_slot": ["unsafe"]},
|
||||
},
|
||||
}
|
||||
out = validate_structure_overrides(payload)
|
||||
assert out == {"zone-top": {"slot_order": ["slot_title"]}}
|
||||
|
||||
|
||||
def test_validate_structure_overrides_rejects_frame_swap_zone_with_no_lock_keys():
|
||||
# If a per-zone payload contains ONLY frame-swap attempts (no
|
||||
# slot_order / hidden_slots), the whole zone gets dropped after
|
||||
# sanitization (no signal remains).
|
||||
payload = {
|
||||
"zone-attempt-swap": {
|
||||
"frame_id": "compare_v2",
|
||||
"template_id": "topic_left_right",
|
||||
},
|
||||
}
|
||||
out = validate_structure_overrides(payload)
|
||||
assert out == {}
|
||||
|
||||
|
||||
def test_validate_structure_overrides_drops_non_list_slot_arrays():
|
||||
payload = {
|
||||
"zone-top": {
|
||||
"slot_order": "not a list",
|
||||
"hidden_slots": {"also": "not a list"},
|
||||
},
|
||||
"zone-bottom": {
|
||||
"slot_order": 42,
|
||||
"hidden_slots": None,
|
||||
},
|
||||
"zone-good": {"slot_order": ["slot_title"]},
|
||||
}
|
||||
out = validate_structure_overrides(payload)
|
||||
assert out == {"zone-good": {"slot_order": ["slot_title"]}}
|
||||
|
||||
|
||||
def test_validate_structure_overrides_drops_bad_slot_key_entries():
|
||||
payload = {
|
||||
"zone-top": {
|
||||
"slot_order": [
|
||||
"good_slot",
|
||||
"", # empty string dropped
|
||||
42, # non-string dropped
|
||||
None, # non-string dropped
|
||||
{"nested": "obj"}, # non-string dropped
|
||||
"another_good",
|
||||
],
|
||||
"hidden_slots": ["", "valid_hide", 99, "valid_hide_2"],
|
||||
},
|
||||
}
|
||||
out = validate_structure_overrides(payload)
|
||||
assert out == {
|
||||
"zone-top": {
|
||||
"slot_order": ["good_slot", "another_good"],
|
||||
"hidden_slots": ["valid_hide", "valid_hide_2"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_validate_structure_overrides_dedupes_slot_key_entries():
|
||||
payload = {
|
||||
"zone-top": {
|
||||
"slot_order": ["a", "b", "a", "c", "b"],
|
||||
"hidden_slots": ["x", "x", "y", "x"],
|
||||
},
|
||||
}
|
||||
out = validate_structure_overrides(payload)
|
||||
assert out == {
|
||||
"zone-top": {
|
||||
"slot_order": ["a", "b", "c"],
|
||||
"hidden_slots": ["x", "y"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_validate_structure_overrides_drops_empty_payload_after_sanitization():
|
||||
# Per-zone payloads that have empty slot_order AND empty hidden_slots
|
||||
# after sanitization carry no signal → drop the zone entirely.
|
||||
payload = {
|
||||
"zone-empty-lists": {"slot_order": [], "hidden_slots": []},
|
||||
"zone-only-bad-entries": {"slot_order": ["", None, 99]},
|
||||
"zone-good": {"slot_order": ["slot_title"]},
|
||||
}
|
||||
out = validate_structure_overrides(payload)
|
||||
assert out == {"zone-good": {"slot_order": ["slot_title"]}}
|
||||
|
||||
|
||||
def test_validate_structure_overrides_returns_fresh_nested_dicts_and_lists():
|
||||
# Mutating the returned dict's per-zone payload (or any list inside)
|
||||
# must not leak back into the source.
|
||||
payload = {
|
||||
"zone-top": {
|
||||
"slot_order": ["a", "b"],
|
||||
"hidden_slots": ["x"],
|
||||
},
|
||||
}
|
||||
out = validate_structure_overrides(payload)
|
||||
out["zone-top"]["slot_order"].append("mutated")
|
||||
out["zone-top"]["hidden_slots"].append("mutated_hide")
|
||||
out["zone-top"]["new_key"] = "leaked?"
|
||||
assert payload["zone-top"]["slot_order"] == ["a", "b"]
|
||||
assert payload["zone-top"]["hidden_slots"] == ["x"]
|
||||
assert "new_key" not in payload["zone-top"]
|
||||
|
||||
|
||||
# -- apply_structure_override ----------------------------------------------
|
||||
|
||||
|
||||
def test_apply_structure_override_hide_only_mutates_in_place():
|
||||
zone: dict = {
|
||||
"slot_title": ["title line"],
|
||||
"slot_body": ["body line"],
|
||||
"slot_caption": ["caption"],
|
||||
}
|
||||
assert apply_structure_override(zone, {"hidden_slots": ["slot_caption"]}) is True
|
||||
assert list(zone.keys()) == ["slot_title", "slot_body"]
|
||||
assert zone == {
|
||||
"slot_title": ["title line"],
|
||||
"slot_body": ["body line"],
|
||||
}
|
||||
|
||||
|
||||
def test_apply_structure_override_reorder_only_partial():
|
||||
# Partial reorder — listed keys move to front in order; unmentioned
|
||||
# keys keep their original relative order at the tail.
|
||||
zone: dict = {
|
||||
"slot_title": ["t"],
|
||||
"slot_body": ["b"],
|
||||
"slot_caption": ["c"],
|
||||
"slot_aux": ["a"],
|
||||
}
|
||||
assert apply_structure_override(zone, {"slot_order": ["slot_aux", "slot_title"]}) is True
|
||||
assert list(zone.keys()) == ["slot_aux", "slot_title", "slot_body", "slot_caption"]
|
||||
|
||||
|
||||
def test_apply_structure_override_combines_hide_and_reorder():
|
||||
zone: dict = {
|
||||
"slot_title": ["t"],
|
||||
"slot_body": ["b"],
|
||||
"slot_caption": ["c"],
|
||||
"slot_aux": ["a"],
|
||||
}
|
||||
override = {
|
||||
"slot_order": ["slot_aux", "slot_body"],
|
||||
"hidden_slots": ["slot_caption"],
|
||||
}
|
||||
assert apply_structure_override(zone, override) is True
|
||||
# slot_caption hidden; slot_aux + slot_body moved to front; remaining
|
||||
# (slot_title) appended at tail in original order.
|
||||
assert list(zone.keys()) == ["slot_aux", "slot_body", "slot_title"]
|
||||
|
||||
|
||||
def test_apply_structure_override_silently_skips_stale_slot_keys():
|
||||
# Frame swap / layout regression — the prior render's override
|
||||
# references slot_keys that the new render's frame no longer emits.
|
||||
# The resolver must silently skip those without raising.
|
||||
zone: dict = {"slot_title": ["t"], "slot_body": ["b"]}
|
||||
override = {
|
||||
"slot_order": ["slot_phantom_1", "slot_body", "slot_phantom_2"],
|
||||
"hidden_slots": ["slot_does_not_exist"],
|
||||
}
|
||||
assert apply_structure_override(zone, override) is True
|
||||
# slot_body moves to front; slot_title appended at tail; phantoms
|
||||
# silently ignored; hidden_slots no-op.
|
||||
assert list(zone.keys()) == ["slot_body", "slot_title"]
|
||||
assert zone == {"slot_body": ["b"], "slot_title": ["t"]}
|
||||
|
||||
|
||||
def test_apply_structure_override_no_op_returns_false():
|
||||
# Empty override → no mutation.
|
||||
zone: dict = {"slot_title": ["t"], "slot_body": ["b"]}
|
||||
snapshot = dict(zone)
|
||||
assert apply_structure_override(zone, {}) is False
|
||||
assert zone == snapshot
|
||||
assert list(zone.keys()) == ["slot_title", "slot_body"]
|
||||
|
||||
|
||||
def test_apply_structure_override_all_stale_returns_false():
|
||||
# All slot_keys in the override are absent from zone → no mutation.
|
||||
zone: dict = {"slot_title": ["t"], "slot_body": ["b"]}
|
||||
snapshot = dict(zone)
|
||||
override = {
|
||||
"slot_order": ["phantom_a", "phantom_b"],
|
||||
"hidden_slots": ["phantom_c"],
|
||||
}
|
||||
assert apply_structure_override(zone, override) is False
|
||||
assert zone == snapshot
|
||||
assert list(zone.keys()) == ["slot_title", "slot_body"]
|
||||
|
||||
|
||||
def test_apply_structure_override_already_in_desired_order_returns_false():
|
||||
# slot_order matches the existing key order exactly → no mutation.
|
||||
zone: dict = {"slot_title": ["t"], "slot_body": ["b"]}
|
||||
override = {"slot_order": ["slot_title", "slot_body"]}
|
||||
assert apply_structure_override(zone, override) is False
|
||||
assert list(zone.keys()) == ["slot_title", "slot_body"]
|
||||
|
||||
|
||||
def test_apply_structure_override_preserves_zone_reference_identity():
|
||||
# In-place mutation via clear + update — caller's reference must
|
||||
# remain valid after reorder.
|
||||
zone: dict = {"slot_a": ["a"], "slot_b": ["b"], "slot_c": ["c"]}
|
||||
zone_ref = zone # capture reference
|
||||
apply_structure_override(zone, {"slot_order": ["slot_c", "slot_a"]})
|
||||
assert zone_ref is zone
|
||||
assert list(zone.keys()) == ["slot_c", "slot_a", "slot_b"]
|
||||
|
||||
|
||||
def test_apply_structure_override_never_inspects_per_slot_values():
|
||||
# The resolver MUST NOT inspect / mutate per-slot list[str] contents.
|
||||
# Use weird non-list values to confirm passthrough.
|
||||
zone: dict = {
|
||||
"slot_a": ["a1", "a2", "a3"],
|
||||
"slot_b": {"nested": "object"}, # non-list payload — passthrough
|
||||
"slot_c": None, # None payload — passthrough
|
||||
"slot_d": 42, # int payload — passthrough
|
||||
}
|
||||
snapshot = {k: zone[k] for k in zone}
|
||||
apply_structure_override(zone, {"slot_order": ["slot_d", "slot_a"]})
|
||||
assert list(zone.keys()) == ["slot_d", "slot_a", "slot_b", "slot_c"]
|
||||
# values are identity-preserved
|
||||
for key in zone:
|
||||
assert zone[key] is snapshot[key]
|
||||
|
||||
|
||||
def test_apply_structure_override_defensive_on_non_list_arrays():
|
||||
# If a non-validated override leaks through, non-list slot_order /
|
||||
# hidden_slots should be treated as empty rather than raising.
|
||||
zone: dict = {"slot_title": ["t"], "slot_body": ["b"]}
|
||||
snapshot = dict(zone)
|
||||
override = {
|
||||
"slot_order": "not a list",
|
||||
"hidden_slots": {"also": "not a list"},
|
||||
}
|
||||
assert apply_structure_override(zone, override) is False
|
||||
assert zone == snapshot
|
||||
assert list(zone.keys()) == ["slot_title", "slot_body"]
|
||||
|
||||
|
||||
def test_apply_structure_override_hide_wins_over_reorder():
|
||||
# Edge case: a slot_key appears in BOTH slot_order and hidden_slots.
|
||||
# hidden_slots is applied first, so the slot is gone by the time
|
||||
# reorder runs — the reorder entry silently no-ops.
|
||||
zone: dict = {"slot_a": ["a"], "slot_b": ["b"], "slot_c": ["c"]}
|
||||
override = {
|
||||
"slot_order": ["slot_b", "slot_a", "slot_c"],
|
||||
"hidden_slots": ["slot_b"],
|
||||
}
|
||||
assert apply_structure_override(zone, override) is True
|
||||
# slot_b removed first; slot_a + slot_c reordered to front (slot_b
|
||||
# silently skipped because it no longer exists).
|
||||
assert list(zone.keys()) == ["slot_a", "slot_c"]
|
||||
|
||||
|
||||
def test_apply_structure_override_returns_true_on_pure_reorder_only():
|
||||
# Pure reorder (no hide) — should still return True when key order
|
||||
# actually changes.
|
||||
zone: dict = {"slot_a": ["a"], "slot_b": ["b"]}
|
||||
override = {"slot_order": ["slot_b", "slot_a"]}
|
||||
assert apply_structure_override(zone, override) is True
|
||||
assert list(zone.keys()) == ["slot_b", "slot_a"]
|
||||
|
||||
|
||||
def test_apply_structure_override_returns_true_on_pure_hide_only():
|
||||
# Pure hide (no reorder) — should still return True when a key was
|
||||
# actually removed.
|
||||
zone: dict = {"slot_a": ["a"], "slot_b": ["b"]}
|
||||
override = {"hidden_slots": ["slot_a"]}
|
||||
assert apply_structure_override(zone, override) is True
|
||||
assert list(zone.keys()) == ["slot_b"]
|
||||
188
tests/test_text_override_resolver.py
Normal file
188
tests/test_text_override_resolver.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""IMP-56 (#90) u4 — tests for ``src.text_override_resolver``.
|
||||
|
||||
Covers the resolver contract called out in the Stage 2 plan :
|
||||
|
||||
1. ``parse_text_path`` happy path : ``slot_title.0`` → ``("slot_title", 0)``.
|
||||
2. ``parse_text_path`` rejects: empty / non-string / no '.' / negative
|
||||
index / non-int index / empty slot_key / empty line_index.
|
||||
3. ``parse_text_path`` ``rpartition`` semantics — compound slot_key with
|
||||
internal '.' is preserved (split is on LAST '.').
|
||||
4. ``validate_text_overrides`` returns ``{}`` for non-mapping input.
|
||||
5. ``validate_text_overrides`` drops malformed per-entry rows without
|
||||
rejecting the whole batch (mirrors image_id_stamper per-entry tolerance).
|
||||
6. ``validate_text_overrides`` preserves well-formed entries.
|
||||
7. ``validate_text_overrides`` returns a fresh dict (caller can mutate
|
||||
without aliasing the source).
|
||||
8. ``apply_text_override`` happy path mutates in-place and returns ``True``.
|
||||
9. ``apply_text_override`` returns ``False`` (no mutation) on absent slot.
|
||||
10. ``apply_text_override`` returns ``False`` (no mutation) on
|
||||
line_index >= len(lines) (forward-compat with frame swap / layout
|
||||
regression dropping the slot row).
|
||||
11. ``apply_text_override`` returns ``False`` (no mutation) on a
|
||||
non-list slot (defensive against malformed zone wrappers).
|
||||
12. ``apply_text_override`` preserves untouched lines / other slots.
|
||||
|
||||
All tests are pure-Python — no filesystem, no Selenium, no fixtures.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.text_override_resolver import (
|
||||
InvalidTextOverride,
|
||||
apply_text_override,
|
||||
parse_text_path,
|
||||
validate_text_overrides,
|
||||
)
|
||||
|
||||
|
||||
# -- parse_text_path --------------------------------------------------------
|
||||
|
||||
|
||||
def test_parse_text_path_simple():
|
||||
assert parse_text_path("slot_title.0") == ("slot_title", 0)
|
||||
assert parse_text_path("slot_body.5") == ("slot_body", 5)
|
||||
|
||||
|
||||
def test_parse_text_path_compound_slot_key():
|
||||
# rpartition semantics — split on LAST '.' so compound keys survive.
|
||||
# Note: a numeric-looking compound suffix (e.g., 'slot.1.5') parses to
|
||||
# ('slot.1', 5) by design; callers that want a strict identifier-only
|
||||
# slot_key should enforce that in their own stamper (u8).
|
||||
assert parse_text_path("slot.compound.2") == ("slot.compound", 2)
|
||||
assert parse_text_path("slot_title.1.5") == ("slot_title.1", 5)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad_path",
|
||||
[
|
||||
"",
|
||||
"no_dot",
|
||||
".0",
|
||||
"slot_title.",
|
||||
"slot_title.-1",
|
||||
"slot_title.abc",
|
||||
],
|
||||
)
|
||||
def test_parse_text_path_rejects_malformed(bad_path):
|
||||
with pytest.raises(InvalidTextOverride):
|
||||
parse_text_path(bad_path)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_type", [None, 0, 1.5, [], {}, b"slot.0"])
|
||||
def test_parse_text_path_rejects_non_string(bad_type):
|
||||
with pytest.raises(InvalidTextOverride):
|
||||
parse_text_path(bad_type) # type: ignore[arg-type]
|
||||
|
||||
|
||||
# -- validate_text_overrides ------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_input", [None, [], "string", 42, 1.5])
|
||||
def test_validate_text_overrides_non_mapping_returns_empty(bad_input):
|
||||
assert validate_text_overrides(bad_input) == {}
|
||||
|
||||
|
||||
def test_validate_text_overrides_passes_well_formed():
|
||||
payload = {
|
||||
"zone-top": {"slot_title.0": "edited headline"},
|
||||
"zone-bottom": {"slot_body.0": "line A", "slot_body.1": "line B"},
|
||||
}
|
||||
out = validate_text_overrides(payload)
|
||||
assert out == payload
|
||||
assert out is not payload # fresh dict
|
||||
|
||||
|
||||
def test_validate_text_overrides_per_entry_tolerance():
|
||||
# Mix of well-formed + every variety of malformed; only the well-formed
|
||||
# rows should survive.
|
||||
payload = {
|
||||
"zone-top": {
|
||||
"slot_title.0": "good",
|
||||
"no_dot": "bad path",
|
||||
"slot_x.abc": "bad index",
|
||||
"slot_y.-1": "negative",
|
||||
42: "non-string path",
|
||||
"slot_z.0": 99, # non-string value
|
||||
},
|
||||
"": {"slot_title.0": "bad zone id"}, # empty zone_id dropped
|
||||
42: {"slot_title.0": "non-string zone id"}, # non-string dropped
|
||||
"zone-empty-after-filter": {"no_dot": "x"}, # entire zone drops
|
||||
"zone-non-mapping": "not a dict", # non-mapping dropped
|
||||
"zone-bottom": {"slot_body.0": "kept"},
|
||||
}
|
||||
out = validate_text_overrides(payload)
|
||||
assert out == {
|
||||
"zone-top": {"slot_title.0": "good"},
|
||||
"zone-bottom": {"slot_body.0": "kept"},
|
||||
}
|
||||
|
||||
|
||||
def test_validate_text_overrides_returns_fresh_nested_dicts():
|
||||
# Mutating the returned dict's per-zone payload must not leak back
|
||||
# into the source (callers should be able to use the result as a
|
||||
# working buffer).
|
||||
payload = {"zone-top": {"slot_title.0": "v"}}
|
||||
out = validate_text_overrides(payload)
|
||||
out["zone-top"]["slot_title.0"] = "mutated"
|
||||
assert payload["zone-top"]["slot_title.0"] == "v"
|
||||
|
||||
|
||||
# -- apply_text_override ----------------------------------------------------
|
||||
|
||||
|
||||
def test_apply_text_override_happy_path_mutates_in_place():
|
||||
zone: dict = {"slot_title": ["orig"]}
|
||||
assert apply_text_override(zone, "slot_title.0", "edited") is True
|
||||
assert zone == {"slot_title": ["edited"]}
|
||||
|
||||
|
||||
def test_apply_text_override_multi_line_slot():
|
||||
zone: dict = {"slot_body": ["line A", "line B", "line C"]}
|
||||
assert apply_text_override(zone, "slot_body.1", "REPLACED") is True
|
||||
assert zone == {"slot_body": ["line A", "REPLACED", "line C"]}
|
||||
|
||||
|
||||
def test_apply_text_override_absent_slot_returns_false_no_mutation():
|
||||
zone: dict = {"slot_title": ["orig"]}
|
||||
assert apply_text_override(zone, "slot_missing.0", "x") is False
|
||||
assert zone == {"slot_title": ["orig"]}
|
||||
|
||||
|
||||
def test_apply_text_override_out_of_range_returns_false_no_mutation():
|
||||
# Forward-compat: a prior render's text_path may target an index the
|
||||
# new render's slot no longer emits (frame swap / layout regression).
|
||||
# This is NOT an error — the override is silently skipped.
|
||||
zone: dict = {"slot_body": ["only one line"]}
|
||||
assert apply_text_override(zone, "slot_body.5", "x") is False
|
||||
assert zone == {"slot_body": ["only one line"]}
|
||||
|
||||
|
||||
def test_apply_text_override_non_list_slot_returns_false_no_mutation():
|
||||
# Defensive: if a wrapper passes a non-list slot value, do not crash.
|
||||
zone: dict = {"slot_title": "not a list"}
|
||||
assert apply_text_override(zone, "slot_title.0", "x") is False
|
||||
assert zone == {"slot_title": "not a list"}
|
||||
|
||||
|
||||
def test_apply_text_override_preserves_other_slots_and_lines():
|
||||
zone: dict = {
|
||||
"slot_title": ["title line"],
|
||||
"slot_body": ["body line A", "body line B"],
|
||||
"slot_caption": ["caption"],
|
||||
}
|
||||
assert apply_text_override(zone, "slot_body.0", "EDITED A") is True
|
||||
assert zone == {
|
||||
"slot_title": ["title line"], # untouched
|
||||
"slot_body": ["EDITED A", "body line B"], # only line 0 changed
|
||||
"slot_caption": ["caption"], # untouched
|
||||
}
|
||||
|
||||
|
||||
def test_apply_text_override_propagates_parse_errors():
|
||||
# apply delegates path parsing to parse_text_path; malformed paths
|
||||
# raise (NOT return False) so callers can distinguish "skipped because
|
||||
# path didn't match the live DOM" from "path was malformed at source".
|
||||
zone: dict = {"slot_title": ["x"]}
|
||||
with pytest.raises(InvalidTextOverride):
|
||||
apply_text_override(zone, "no_dot", "x")
|
||||
307
tests/test_text_path_stamper.py
Normal file
307
tests/test_text_path_stamper.py
Normal file
@@ -0,0 +1,307 @@
|
||||
"""IMP-56 (#90) u8 — scoped tests for src.text_path_stamper.
|
||||
|
||||
Covers: path formatting, slot iteration, DOM injection, idempotence,
|
||||
excess-element handling, compound slot keys, and inverse symmetry with
|
||||
src.text_override_resolver.parse_text_path.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.text_override_resolver import parse_text_path
|
||||
from src.text_path_stamper import (
|
||||
TEXT_PATH_ATTR,
|
||||
build_text_path,
|
||||
iter_zone_stamps,
|
||||
stamp_zone_html,
|
||||
)
|
||||
|
||||
|
||||
# ─── build_text_path ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_build_text_path_basic():
|
||||
assert build_text_path("slot_title", 0) == "slot_title.0"
|
||||
|
||||
|
||||
def test_build_text_path_nonzero_index():
|
||||
assert build_text_path("row_3_left_body", 5) == "row_3_left_body.5"
|
||||
|
||||
|
||||
def test_build_text_path_compound_slot_key():
|
||||
# slot_key may itself contain '.'; parse_text_path uses rpartition.
|
||||
path = build_text_path("group.slot.compound", 2)
|
||||
assert path == "group.slot.compound.2"
|
||||
slot_key, line_index = parse_text_path(path)
|
||||
assert slot_key == "group.slot.compound"
|
||||
assert line_index == 2
|
||||
|
||||
|
||||
def test_build_text_path_round_trip_with_resolver():
|
||||
# Inverse symmetry with src.text_override_resolver.parse_text_path.
|
||||
for slot_key, idx in [
|
||||
("title", 0),
|
||||
("row_1_left_body", 7),
|
||||
("a.b", 3),
|
||||
]:
|
||||
assert parse_text_path(build_text_path(slot_key, idx)) == (slot_key, idx)
|
||||
|
||||
|
||||
def test_build_text_path_rejects_empty_slot_key():
|
||||
with pytest.raises(ValueError):
|
||||
build_text_path("", 0)
|
||||
|
||||
|
||||
def test_build_text_path_rejects_non_string_slot_key():
|
||||
with pytest.raises(ValueError):
|
||||
build_text_path(123, 0) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_build_text_path_rejects_negative_index():
|
||||
with pytest.raises(ValueError):
|
||||
build_text_path("slot", -1)
|
||||
|
||||
|
||||
def test_build_text_path_rejects_non_int_index():
|
||||
with pytest.raises(ValueError):
|
||||
build_text_path("slot", "0") # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_build_text_path_rejects_bool_index():
|
||||
# bool is an int subclass — must still be rejected to avoid silent
|
||||
# path corruption (True → 'slot.1', False → 'slot.0').
|
||||
with pytest.raises(ValueError):
|
||||
build_text_path("slot", True) # type: ignore[arg-type]
|
||||
|
||||
|
||||
# ─── iter_zone_stamps ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_iter_zone_stamps_list_slots():
|
||||
payload = {
|
||||
"body": ["line A", "line B", "line C"],
|
||||
}
|
||||
assert list(iter_zone_stamps(payload)) == [
|
||||
("body", 0), ("body", 1), ("body", 2),
|
||||
]
|
||||
|
||||
|
||||
def test_iter_zone_stamps_preserves_dict_order():
|
||||
payload = {
|
||||
"row_2": ["x", "y"],
|
||||
"row_1": ["p"],
|
||||
"row_3": ["q", "r"],
|
||||
}
|
||||
assert list(iter_zone_stamps(payload)) == [
|
||||
("row_2", 0), ("row_2", 1),
|
||||
("row_1", 0),
|
||||
("row_3", 0), ("row_3", 1),
|
||||
]
|
||||
|
||||
|
||||
def test_iter_zone_stamps_skips_non_list_values():
|
||||
payload = {
|
||||
"title": "Frame Title", # scalar — skipped
|
||||
"label": 42, # scalar — skipped
|
||||
"lines": ["a", "b"], # list — yielded
|
||||
"meta": {"k": "v"}, # mapping — skipped
|
||||
}
|
||||
assert list(iter_zone_stamps(payload)) == [("lines", 0), ("lines", 1)]
|
||||
|
||||
|
||||
def test_iter_zone_stamps_skips_empty_or_non_string_keys():
|
||||
payload = {
|
||||
"": ["x"],
|
||||
123: ["y"], # type: ignore[dict-item]
|
||||
"ok": ["z"],
|
||||
}
|
||||
assert list(iter_zone_stamps(payload)) == [("ok", 0)]
|
||||
|
||||
|
||||
def test_iter_zone_stamps_empty_payload():
|
||||
assert list(iter_zone_stamps({})) == []
|
||||
|
||||
|
||||
def test_iter_zone_stamps_non_mapping():
|
||||
assert list(iter_zone_stamps(None)) == [] # type: ignore[arg-type]
|
||||
assert list(iter_zone_stamps([])) == [] # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_iter_zone_stamps_empty_list_value():
|
||||
payload = {"lines": []}
|
||||
assert list(iter_zone_stamps(payload)) == []
|
||||
|
||||
|
||||
# ─── stamp_zone_html ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_stamp_zone_html_basic_stamping():
|
||||
html = (
|
||||
'<div class="zone">'
|
||||
'<div class="text-line">A</div>'
|
||||
'<div class="text-line">B</div>'
|
||||
'</div>'
|
||||
)
|
||||
out = stamp_zone_html(html, {"body": ["A", "B"]})
|
||||
assert f'<div {TEXT_PATH_ATTR}="body.0" class="text-line">A</div>' in out
|
||||
assert f'<div {TEXT_PATH_ATTR}="body.1" class="text-line">B</div>' in out
|
||||
|
||||
|
||||
def test_stamp_zone_html_preserves_modifier_classes():
|
||||
html = (
|
||||
'<div class="text-line text-line--bullet">A</div>'
|
||||
'<div class="text-line text-line--indent-1">B</div>'
|
||||
)
|
||||
out = stamp_zone_html(html, {"body": ["A", "B"]})
|
||||
assert 'class="text-line text-line--bullet"' in out
|
||||
assert 'class="text-line text-line--indent-1"' in out
|
||||
assert f'{TEXT_PATH_ATTR}="body.0"' in out
|
||||
assert f'{TEXT_PATH_ATTR}="body.1"' in out
|
||||
|
||||
|
||||
def test_stamp_zone_html_idempotent():
|
||||
html = '<div class="text-line">A</div>'
|
||||
once = stamp_zone_html(html, {"body": ["A"]})
|
||||
twice = stamp_zone_html(once, {"body": ["A"]})
|
||||
assert once == twice
|
||||
# Only one occurrence of the attribute on the tag.
|
||||
assert twice.count(TEXT_PATH_ATTR) == 1
|
||||
|
||||
|
||||
def test_stamp_zone_html_excess_text_lines_unstamped():
|
||||
# 3 text-line divs, only 2 stamps available — last div left alone.
|
||||
html = (
|
||||
'<div class="text-line">A</div>'
|
||||
'<div class="text-line">B</div>'
|
||||
'<div class="text-line">C</div>'
|
||||
)
|
||||
out = stamp_zone_html(html, {"body": ["A", "B"]})
|
||||
assert f'{TEXT_PATH_ATTR}="body.0"' in out
|
||||
assert f'{TEXT_PATH_ATTR}="body.1"' in out
|
||||
# The third div remains unstamped (no data-text-path).
|
||||
assert out.count(TEXT_PATH_ATTR) == 2
|
||||
|
||||
|
||||
def test_stamp_zone_html_excess_stamps_no_crash():
|
||||
# 1 text-line, 3 stamps available — only the first is consumed.
|
||||
html = '<div class="text-line">A</div>'
|
||||
out = stamp_zone_html(html, {"body": ["A", "B", "C"]})
|
||||
assert f'{TEXT_PATH_ATTR}="body.0"' in out
|
||||
assert f'{TEXT_PATH_ATTR}="body.1"' not in out
|
||||
|
||||
|
||||
def test_stamp_zone_html_no_text_lines_no_op():
|
||||
html = '<div class="zone"><span>frame title</span></div>'
|
||||
out = stamp_zone_html(html, {"body": ["A"]})
|
||||
assert out == html
|
||||
|
||||
|
||||
def test_stamp_zone_html_empty_payload_no_op():
|
||||
html = '<div class="text-line">A</div>'
|
||||
assert stamp_zone_html(html, {}) == html
|
||||
assert stamp_zone_html(html, []) == html
|
||||
|
||||
|
||||
def test_stamp_zone_html_empty_html_no_op():
|
||||
assert stamp_zone_html("", {"body": ["A"]}) == ""
|
||||
|
||||
|
||||
def test_stamp_zone_html_non_string_html_no_op():
|
||||
assert stamp_zone_html(None, {"body": ["A"]}) is None # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_stamp_zone_html_walks_multiple_slots_in_order():
|
||||
# Mirrors the bim_current_problems_paired family template line shape:
|
||||
# multiple slots producing interleaved text-line divs.
|
||||
html = (
|
||||
'<div class="text-line">title-x</div>' # slot_a.0
|
||||
'<div class="text-line">title-y</div>' # slot_a.1
|
||||
'<div class="text-line">body-1</div>' # slot_b.0
|
||||
'<div class="text-line">body-2</div>' # slot_b.1
|
||||
'<div class="text-line">body-3</div>' # slot_b.2
|
||||
)
|
||||
payload = {
|
||||
"slot_a": ["title-x", "title-y"],
|
||||
"slot_b": ["body-1", "body-2", "body-3"],
|
||||
}
|
||||
out = stamp_zone_html(html, payload)
|
||||
assert f'{TEXT_PATH_ATTR}="slot_a.0"' in out
|
||||
assert f'{TEXT_PATH_ATTR}="slot_a.1"' in out
|
||||
assert f'{TEXT_PATH_ATTR}="slot_b.0"' in out
|
||||
assert f'{TEXT_PATH_ATTR}="slot_b.1"' in out
|
||||
assert f'{TEXT_PATH_ATTR}="slot_b.2"' in out
|
||||
|
||||
|
||||
def test_stamp_zone_html_does_not_match_unrelated_divs():
|
||||
# A div that is NOT a text-line must not be stamped.
|
||||
html = (
|
||||
'<div class="zone">'
|
||||
'<div class="other-class">untouched</div>'
|
||||
'<div class="text-line">A</div>'
|
||||
'</div>'
|
||||
)
|
||||
out = stamp_zone_html(html, {"body": ["A"]})
|
||||
assert 'class="other-class"' in out
|
||||
assert f'{TEXT_PATH_ATTR}="body.0"' in out
|
||||
assert out.count(TEXT_PATH_ATTR) == 1
|
||||
|
||||
|
||||
def test_stamp_zone_html_accepts_explicit_stamp_sequence():
|
||||
# When the caller wants to override dict-iteration order (or stamp
|
||||
# a subset), they can pass a list of (slot_key, line_index) tuples.
|
||||
html = (
|
||||
'<div class="text-line">first</div>'
|
||||
'<div class="text-line">second</div>'
|
||||
)
|
||||
stamps = [("custom", 7), ("custom", 9)]
|
||||
out = stamp_zone_html(html, stamps)
|
||||
assert f'{TEXT_PATH_ATTR}="custom.7"' in out
|
||||
assert f'{TEXT_PATH_ATTR}="custom.9"' in out
|
||||
|
||||
|
||||
def test_stamp_zone_html_explicit_sequence_drops_malformed():
|
||||
html = '<div class="text-line">A</div>'
|
||||
# Mix valid + malformed: only the valid stamp is consumed.
|
||||
stamps = [("", 0), ("ok", -1), ("ok", "0"), ("ok", 3)] # type: ignore[list-item]
|
||||
out = stamp_zone_html(html, stamps) # type: ignore[arg-type]
|
||||
assert f'{TEXT_PATH_ATTR}="ok.3"' in out
|
||||
assert out.count(TEXT_PATH_ATTR) == 1
|
||||
|
||||
|
||||
def test_stamp_zone_html_compound_slot_key():
|
||||
# Compound slot keys (with embedded '.') round-trip through stamp +
|
||||
# parse symmetry — the resolver's rpartition split recovers the
|
||||
# original (slot_key, line_index).
|
||||
html = '<div class="text-line">x</div>'
|
||||
out = stamp_zone_html(html, {"a.b.c": ["x"]})
|
||||
assert f'{TEXT_PATH_ATTR}="a.b.c.0"' in out
|
||||
# Spot-check resolver inverse on the emitted path.
|
||||
assert parse_text_path("a.b.c.0") == ("a.b.c", 0)
|
||||
|
||||
|
||||
def test_stamp_zone_html_idempotent_when_some_lines_prestamped():
|
||||
# If some text-line elements are already stamped, the prestamped tag
|
||||
# is preserved verbatim AND the stamp counter is NOT advanced for it
|
||||
# (so the next unstamped tag gets the next-in-sequence stamp).
|
||||
html = (
|
||||
f'<div {TEXT_PATH_ATTR}="manual.0" class="text-line">A</div>'
|
||||
'<div class="text-line">B</div>'
|
||||
)
|
||||
out = stamp_zone_html(html, {"body": ["A", "B"]})
|
||||
# First tag preserved as-is (idempotent short-circuit).
|
||||
assert 'manual.0' in out
|
||||
# Second tag consumes the first available stamp ('body.0'); the
|
||||
# prestamped tag does NOT consume from the stamp sequence.
|
||||
assert f'{TEXT_PATH_ATTR}="body.0"' in out
|
||||
# Total occurrences of the attribute = 2 (one manual + one fresh).
|
||||
assert out.count(TEXT_PATH_ATTR) == 2
|
||||
|
||||
|
||||
def test_stamp_zone_html_text_line_with_attributes_before_class():
|
||||
# The regex must match text-line opening tags regardless of attribute
|
||||
# ordering — class may not be the first attribute.
|
||||
html = '<div data-foo="x" class="text-line">A</div>'
|
||||
out = stamp_zone_html(html, {"body": ["A"]})
|
||||
assert f'{TEXT_PATH_ATTR}="body.0"' in out
|
||||
assert 'data-foo="x"' in out
|
||||
assert 'class="text-line"' in out
|
||||
@@ -3,9 +3,13 @@
|
||||
Covers the persisted axes called out in the Stage 2 plan
|
||||
(IMP-51 #79 u1 extended this to 5 axes by adding ``image_overrides``;
|
||||
IMP-45 #74 u1 extended to 6 axes by adding ``slide_css``;
|
||||
IMP-55 #93 u1 extended to 7 axes by adding ``manual_section_assignment``):
|
||||
IMP-55 #93 u1 extended to 7 axes by adding ``manual_section_assignment``;
|
||||
IMP-56 #90 u1 extended to 8 axes by adding ``text_overrides``;
|
||||
IMP-56 #90 u2 extended to 9 axes by adding ``structure_overrides`` —
|
||||
scope LOCKED to ``{slot_order, hidden_slots}``; frame swap stays on the
|
||||
existing ``frames`` axis):
|
||||
|
||||
1. Round-trip ``save`` → ``load`` (7 KNOWN_AXES + foreign top-level keys).
|
||||
1. Round-trip ``save`` → ``load`` (9 KNOWN_AXES + foreign top-level keys).
|
||||
2. Unknown-key passthrough (foreign axes preserved across partial merges).
|
||||
3. Missing / corrupt / non-object behavior (graceful ``{}`` + stderr warning).
|
||||
4. Invalid keys (``InvalidOverrideKey`` raised on traversal / separators /
|
||||
@@ -123,25 +127,50 @@ def _full_payload() -> dict:
|
||||
},
|
||||
"slide_css": "<style>.slide .frame-process-product .label { font-size: 14px; }</style>",
|
||||
"manual_section_assignment": True,
|
||||
"text_overrides": {"zone-top": {"slot_title.0": "edited headline"}},
|
||||
"structure_overrides": {
|
||||
"zone-top": {
|
||||
"slot_order": ["slot_title", "slot_body"],
|
||||
"hidden_slots": ["slot_caption"],
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_known_axes_includes_image_overrides():
|
||||
"""IMP-51 #79 u1 — ``image_overrides`` is a known axis (now 7 total)."""
|
||||
"""IMP-51 #79 u1 — ``image_overrides`` is a known axis (now 9 total)."""
|
||||
assert "image_overrides" in KNOWN_AXES
|
||||
assert len(KNOWN_AXES) == 7
|
||||
assert len(KNOWN_AXES) == 9
|
||||
|
||||
|
||||
def test_known_axes_includes_slide_css():
|
||||
"""IMP-45 #74 u1 — ``slide_css`` is a known axis (7 total)."""
|
||||
"""IMP-45 #74 u1 — ``slide_css`` is a known axis (9 total)."""
|
||||
assert "slide_css" in KNOWN_AXES
|
||||
assert len(KNOWN_AXES) == 7
|
||||
assert len(KNOWN_AXES) == 9
|
||||
|
||||
|
||||
def test_known_axes_includes_manual_section_assignment():
|
||||
"""IMP-55 #93 u1 — bool intent marker is a known axis (7 total)."""
|
||||
"""IMP-55 #93 u1 — bool intent marker is a known axis (9 total)."""
|
||||
assert "manual_section_assignment" in KNOWN_AXES
|
||||
assert len(KNOWN_AXES) == 7
|
||||
assert len(KNOWN_AXES) == 9
|
||||
|
||||
|
||||
def test_known_axes_includes_text_overrides():
|
||||
"""IMP-56 #90 u1 — ``text_overrides`` is a known axis (9 total)."""
|
||||
assert "text_overrides" in KNOWN_AXES
|
||||
assert len(KNOWN_AXES) == 9
|
||||
|
||||
|
||||
def test_known_axes_includes_structure_overrides():
|
||||
"""IMP-56 #90 u2 — ``structure_overrides`` is a known axis (9 total).
|
||||
|
||||
Scope is locked to ``{slot_order, hidden_slots}`` only; frame swap
|
||||
stays on the existing ``frames`` axis (verified at resolver u6 + Step
|
||||
12 apply u7). The IO layer itself only enforces the axis name + the
|
||||
foreign-key preservation invariant.
|
||||
"""
|
||||
assert "structure_overrides" in KNOWN_AXES
|
||||
assert len(KNOWN_AXES) == 9
|
||||
|
||||
|
||||
def test_save_then_load_round_trip(tmp_path):
|
||||
@@ -170,6 +199,8 @@ def test_save_partial_payload_preserves_other_axes(tmp_path):
|
||||
assert loaded["image_overrides"] == _full_payload()["image_overrides"]
|
||||
assert loaded["slide_css"] == _full_payload()["slide_css"]
|
||||
assert loaded["manual_section_assignment"] is True
|
||||
assert loaded["text_overrides"] == _full_payload()["text_overrides"]
|
||||
assert loaded["structure_overrides"] == _full_payload()["structure_overrides"]
|
||||
|
||||
|
||||
def test_save_partial_image_overrides_preserves_other_axes(tmp_path):
|
||||
@@ -193,6 +224,8 @@ def test_save_partial_image_overrides_preserves_other_axes(tmp_path):
|
||||
assert loaded["frames"] == _full_payload()["frames"]
|
||||
assert loaded["slide_css"] == _full_payload()["slide_css"]
|
||||
assert loaded["manual_section_assignment"] is True
|
||||
assert loaded["text_overrides"] == _full_payload()["text_overrides"]
|
||||
assert loaded["structure_overrides"] == _full_payload()["structure_overrides"]
|
||||
|
||||
|
||||
def test_save_axis_replaces_not_deep_merges(tmp_path):
|
||||
@@ -265,12 +298,15 @@ def test_save_writes_pretty_sorted_json_for_diffability(tmp_path):
|
||||
key = "03"
|
||||
save(key, _full_payload(), root=tmp_path)
|
||||
raw = (tmp_path / "03.json").read_text(encoding="utf-8")
|
||||
# sort_keys=True → KNOWN_AXES come out alphabetically
|
||||
# sort_keys=True → KNOWN_AXES come out alphabetically. ``structure_overrides``
|
||||
# (IMP-56 #90 u2) sorts between ``slide_css`` and ``text_overrides``.
|
||||
pos_frames = raw.index('"frames"')
|
||||
pos_image_overrides = raw.index('"image_overrides"')
|
||||
pos_layout = raw.index('"layout"')
|
||||
pos_manual = raw.index('"manual_section_assignment"')
|
||||
pos_slide_css = raw.index('"slide_css"')
|
||||
pos_structure_overrides = raw.index('"structure_overrides"')
|
||||
pos_text_overrides = raw.index('"text_overrides"')
|
||||
pos_zg = raw.index('"zone_geometries"')
|
||||
pos_zs = raw.index('"zone_sections"')
|
||||
assert (
|
||||
@@ -279,6 +315,8 @@ def test_save_writes_pretty_sorted_json_for_diffability(tmp_path):
|
||||
< pos_layout
|
||||
< pos_manual
|
||||
< pos_slide_css
|
||||
< pos_structure_overrides
|
||||
< pos_text_overrides
|
||||
< pos_zg
|
||||
< pos_zs
|
||||
)
|
||||
|
||||
@@ -55,6 +55,13 @@ def _exec_main_block(
|
||||
# the section-assignment axis; the new kwargs are captured here so any
|
||||
# follow-up test can pin them without re-touching this harness.
|
||||
override_slide_css=None,
|
||||
# IMP-56 (#90) u16 — absorb the two new file-only axes added to
|
||||
# ``run_phase_z2_mvp1`` so the existing harness keeps working when
|
||||
# the CLI dispatch passes the new kwargs through. Both default to
|
||||
# ``None`` so the no-file / corrupt-file / invalid-stem tests can
|
||||
# continue asserting "all overrides None".
|
||||
override_text_overrides=None,
|
||||
override_structure_overrides=None,
|
||||
reuse_from=None,
|
||||
):
|
||||
captured["mdx_path"] = mdx_path
|
||||
@@ -65,6 +72,8 @@ def _exec_main_block(
|
||||
captured["override_section_assignments"] = override_section_assignments
|
||||
captured["override_image_overrides"] = override_image_overrides
|
||||
captured["override_slide_css"] = override_slide_css
|
||||
captured["override_text_overrides"] = override_text_overrides
|
||||
captured["override_structure_overrides"] = override_structure_overrides
|
||||
captured["reuse_from"] = reuse_from
|
||||
|
||||
monkeypatch.setattr(_pz2, "run_phase_z2_mvp1", _fake_run)
|
||||
@@ -600,3 +609,69 @@ def test_cli_section_assignment_works_without_persisted_file(
|
||||
)
|
||||
|
||||
assert captured["override_section_assignments"] == {"top": ["cli-only"]}
|
||||
|
||||
|
||||
# -- 6. IMP-56 (#90) u16 — text_overrides + structure_overrides file fallback
|
||||
|
||||
|
||||
def test_file_text_overrides_flow_through_when_no_cli(tmp_path, monkeypatch):
|
||||
"""text_overrides axis is file-only — JSON payload reaches run kwarg."""
|
||||
_redirect_overrides_root(tmp_path, monkeypatch)
|
||||
(tmp_path / "03.json").write_text(
|
||||
json.dumps({
|
||||
"text_overrides": {
|
||||
"top": {"title.0": "edited title", "body.1": "edited line"},
|
||||
"bottom_l": {"caption.0": "edited caption"},
|
||||
},
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
_exec_main_block(
|
||||
captured, ["src.phase_z2_pipeline", "03.mdx"], monkeypatch,
|
||||
)
|
||||
|
||||
assert captured["override_text_overrides"] == {
|
||||
"top": {"title.0": "edited title", "body.1": "edited line"},
|
||||
"bottom_l": {"caption.0": "edited caption"},
|
||||
}
|
||||
# No structure payload on file → kwarg collapses to None via ``or None``.
|
||||
assert captured["override_structure_overrides"] is None
|
||||
|
||||
|
||||
def test_file_structure_overrides_flow_through_when_no_cli(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
"""structure_overrides axis is file-only; inner keys locked to
|
||||
slot_order + hidden_slots (any other inner key is dropped by the
|
||||
CLI gate). Non-string list elements are dropped too."""
|
||||
_redirect_overrides_root(tmp_path, monkeypatch)
|
||||
(tmp_path / "03.json").write_text(
|
||||
json.dumps({
|
||||
"structure_overrides": {
|
||||
"top": {
|
||||
"slot_order": ["c", "a", "b"],
|
||||
"hidden_slots": ["d"],
|
||||
# foreign key — must be dropped by the CLI gate
|
||||
"frame_id": "swap_attempt",
|
||||
# non-string elements — must be dropped per-entry
|
||||
"slot_order_with_junk": ["x", 1, None, "y"],
|
||||
},
|
||||
"bottom": {"hidden_slots": ["e"]},
|
||||
},
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
_exec_main_block(
|
||||
captured, ["src.phase_z2_pipeline", "03.mdx"], monkeypatch,
|
||||
)
|
||||
|
||||
assert captured["override_structure_overrides"] == {
|
||||
"top": {"slot_order": ["c", "a", "b"], "hidden_slots": ["d"]},
|
||||
"bottom": {"hidden_slots": ["e"]},
|
||||
}
|
||||
# No text payload on file → kwarg collapses to None.
|
||||
assert captured["override_text_overrides"] is None
|
||||
|
||||
Reference in New Issue
Block a user