feat(#79): IMP-51 image_overrides axis (u1~u11 backend stamp+CLI+CSS inject + frontend drag/resize+persistence + tests)
This commit is contained in:
@@ -22,9 +22,13 @@ import type {
|
||||
NormalizedContent,
|
||||
} from "../types/designAgent";
|
||||
import {
|
||||
IMAGE_RESIZE_MIN_SIZE_PERCENT,
|
||||
clampImagePercentGeometry,
|
||||
clampZoneMove,
|
||||
crossedDragThreshold,
|
||||
type ImageDragDirection,
|
||||
} from "./slideCanvasDragMath";
|
||||
import type { ImageOverridesOverride } from "../services/userOverridesApi";
|
||||
|
||||
interface SlideCanvasProps {
|
||||
slidePlan: SlidePlan | null;
|
||||
@@ -55,6 +59,24 @@ interface SlideCanvasProps {
|
||||
onZoneResize?: (
|
||||
geometries: Record<string, { x: number; y: number; w: number; h: number }>
|
||||
) => void;
|
||||
/** IMP-51 (#79) u8 — persisted slide-absolute image geometries
|
||||
* (image_id → {x,y,w,h} as percent of 1280×720, range 0–100). Mirrors
|
||||
* the u3 typed-client `ImageOverride` contract and the u7 stamper that
|
||||
* emits CSS `left/top/width/height: {value}%`. Forward-compat optional;
|
||||
* u11 wires this from `userSelection.overrides.image_overrides`. When
|
||||
* present, SlideCanvas displays the persisted geometry instead of the
|
||||
* iframe-measured baseline. */
|
||||
imageOverrides?: ImageOverridesOverride;
|
||||
/** IMP-51 (#79) u8 — emitted when the user drags or resizes a stamped
|
||||
* user-content image. Geometry is slide-absolute percent (0–100 of
|
||||
* 1280×720), matching the persisted axis schema (u3 typed client) and
|
||||
* the u7 CSS injection that writes the values directly into
|
||||
* `left/top/width/height: {value}%`. u10 wires this to a persistence
|
||||
* handler that updates `image_overrides` on user_overrides.json. */
|
||||
onImageResize?: (
|
||||
imageId: string,
|
||||
geometry: { x: number; y: number; w: number; h: number }
|
||||
) => void;
|
||||
}
|
||||
|
||||
const SLIDE_W = 1280;
|
||||
@@ -74,6 +96,8 @@ export default function SlideCanvas({
|
||||
onSlideClick,
|
||||
onSectionDrop,
|
||||
onZoneResize,
|
||||
imageOverrides,
|
||||
onImageResize,
|
||||
}: SlideCanvasProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [scale, setScale] = useState(1);
|
||||
@@ -95,6 +119,24 @@ export default function SlideCanvas({
|
||||
// Step B : section drag-drop drop target. 사용자가 LeftMdxPanel 의 section 카드
|
||||
// 를 drag 해서 zone 에 drop 시 그 zone 에 section 할당. dragOver 시 강조 표시.
|
||||
const [dragOverZoneId, setDragOverZoneId] = useState<string | null>(null);
|
||||
// IMP-51 (#79) u8 — measured user-content image bboxes inside iframe
|
||||
// (slide-absolute percent of 1280×720, range 0–100). key = data-image-id
|
||||
// stamped by u4 (`src/image_id_stamper.py`). Populated in the iframe
|
||||
// onLoad measure block alongside measuredZones / measuredSlideBody.
|
||||
// Units intentionally match the persisted `image_overrides` axis (u3
|
||||
// typed client) and the u7 CSS injection so the overlay math has a
|
||||
// single coord space across measured/persisted/emitted values. Used as
|
||||
// the baseline geometry when no persisted override exists for that id;
|
||||
// `imageOverrides` prop (u11-fed) wins when present.
|
||||
const [measuredImages, setMeasuredImages] = useState<
|
||||
Record<string, { x: number; y: number; w: number; h: number }>
|
||||
>({});
|
||||
// IMP-51 (#79) u8 — currently selected user-content image id (= the one
|
||||
// whose drag/resize handles are shown). Set by the click-listener
|
||||
// installed inside the iframe contentDocument when edit mode is active.
|
||||
// Reset on finalHtmlUrl change and on edit-mode exit so stale ids never
|
||||
// leak across runs.
|
||||
const [selectedImageId, setSelectedImageId] = useState<string | null>(null);
|
||||
// HTML 편집 모드 — 글벗 패턴 (designMode + contentEditable + outline CSS) 차용.
|
||||
// 활성 시 iframe 안 텍스트 element 직접 클릭하여 수정 가능. backend 반영은 별 작업.
|
||||
// pendingLayout 과 배타적 (충돌 방지).
|
||||
@@ -122,6 +164,10 @@ 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-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) {
|
||||
doc.designMode = "on";
|
||||
doc.querySelectorAll(".slide *").forEach((el) => {
|
||||
@@ -134,17 +180,49 @@ export default function SlideCanvas({
|
||||
onContentEdit?.();
|
||||
};
|
||||
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.
|
||||
const imgEls = doc.querySelectorAll<HTMLImageElement>(
|
||||
'.slide img[data-image-role="user-content"][data-image-id]'
|
||||
);
|
||||
imgEls.forEach((imgEl) => {
|
||||
const imgId = imgEl.dataset.imageId;
|
||||
if (!imgId) return;
|
||||
const handler = (ev: Event) => {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
setSelectedImageId(imgId);
|
||||
};
|
||||
const prevCursor = imgEl.style.cursor;
|
||||
const prevOutline = imgEl.style.outline;
|
||||
imgEl.style.cursor = "pointer";
|
||||
imgEl.style.outline = "1px dashed rgba(16, 185, 129, 0.55)";
|
||||
imgEl.addEventListener("click", handler);
|
||||
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);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (inputHandler && doc) {
|
||||
doc.removeEventListener("input", inputHandler);
|
||||
}
|
||||
imageClickBindings.forEach(({ el, handler, prevCursor, prevOutline }) => {
|
||||
el.removeEventListener("click", handler);
|
||||
el.style.cursor = prevCursor;
|
||||
el.style.outline = prevOutline;
|
||||
});
|
||||
};
|
||||
}, [isEditMode, finalHtmlUrl, onContentEdit]);
|
||||
|
||||
@@ -158,6 +236,10 @@ export default function SlideCanvas({
|
||||
useEffect(() => {
|
||||
setMeasuredZones({});
|
||||
setMeasuredSlideBody(null);
|
||||
// IMP-51 (#79) u8 — image measurements + selection are per-render;
|
||||
// drop both so the new iframe's onLoad starts clean.
|
||||
setMeasuredImages({});
|
||||
setSelectedImageId(null);
|
||||
}, [finalHtmlUrl]);
|
||||
|
||||
// 16:9 비율 유지하며 컨테이너에 통째로 fit (스크롤 X).
|
||||
@@ -355,6 +437,33 @@ export default function SlideCanvas({
|
||||
h: r.height / SLIDE_H,
|
||||
});
|
||||
}
|
||||
|
||||
// ── IMP-51 (#79) u8 — user-content image bbox 측정 ──
|
||||
// u4 stamper 가 부착한 data-image-id 가 있는 img 만 잡음
|
||||
// (decorative / frame img 제외). 측정 결과는 1280×720 기준
|
||||
// 슬라이드-절대 percent (0–100) — image_overrides axis (u3
|
||||
// 타입 + u7 CSS `left/top/width/height: {value}%` 주입) 와
|
||||
// 동일한 좌표계라서 측정 / 영구 저장 / emit 가 1:1 매칭됨.
|
||||
const imageEls = doc.querySelectorAll<HTMLImageElement>(
|
||||
'.slide img[data-image-role="user-content"][data-image-id]'
|
||||
);
|
||||
const measuredImg: Record<
|
||||
string,
|
||||
{ x: number; y: number; w: number; h: number }
|
||||
> = {};
|
||||
imageEls.forEach((imgEl) => {
|
||||
const id = imgEl.dataset.imageId;
|
||||
if (!id) return;
|
||||
const r = imgEl.getBoundingClientRect();
|
||||
if (r.width <= 0 || r.height <= 0) return;
|
||||
measuredImg[id] = {
|
||||
x: (r.left / SLIDE_W) * 100,
|
||||
y: (r.top / SLIDE_H) * 100,
|
||||
w: (r.width / SLIDE_W) * 100,
|
||||
h: (r.height / SLIDE_H) * 100,
|
||||
};
|
||||
});
|
||||
setMeasuredImages(measuredImg);
|
||||
} catch (err) {
|
||||
console.warn("[SlideCanvas] iframe inject/measure 실패:", err);
|
||||
}
|
||||
@@ -891,6 +1000,195 @@ export default function SlideCanvas({
|
||||
</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`
|
||||
(iframe-measured baseline). pendingLayout suppresses the image
|
||||
overlay so zone editing and image editing never compete for the
|
||||
same pointer events.
|
||||
|
||||
For every stamped user-content image we render a transparent
|
||||
wrapper at the image's slide-absolute coords. Wrapper picks up
|
||||
the body-drag gesture (move the image without resizing). When
|
||||
the image is the `selectedImageId` we additionally render 8
|
||||
resize handles. Aspect ratio is LOCKED on corner drags by
|
||||
default; holding Shift during the drag unlocks it (matches the
|
||||
issue contract "corner_resize_ratio_default_locked_shift_unlock").
|
||||
|
||||
Coordinate space: slide-absolute percent (0–100) throughout —
|
||||
measured / persisted / emitted values share the same units as
|
||||
the u7 CSS injector (`left/top/width/height: {value}%`) and the
|
||||
u3 typed-client `ImageOverride` contract. CSS values are
|
||||
written verbatim ({geom.x}%, no scale factor) and pixel deltas
|
||||
from MouseEvent are converted to percent via
|
||||
`(dx_px / W_SCALED) * 100` so the round-trip drag → save →
|
||||
re-render produces identical geometry. IMP-51 (#79) u9 moved
|
||||
the resize / move math to `clampImagePercentGeometry` in
|
||||
`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 &&
|
||||
Object.entries({ ...measuredImages, ...(imageOverrides ?? {}) }).map(
|
||||
([imageId]) => {
|
||||
const persisted = imageOverrides?.[imageId];
|
||||
const measured = measuredImages[imageId];
|
||||
// override 우선; 없으면 measured baseline. 둘 다 없으면 skip.
|
||||
const geom = persisted ?? measured;
|
||||
if (!geom) return null;
|
||||
const isSelected = selectedImageId === imageId;
|
||||
|
||||
const beginDrag = (
|
||||
ev: React.MouseEvent<HTMLDivElement>,
|
||||
direction: ImageDragDirection
|
||||
) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
setSelectedImageId(imageId);
|
||||
const startMouseX = ev.clientX;
|
||||
const startMouseY = ev.clientY;
|
||||
const startGeom = { ...geom };
|
||||
|
||||
// 2026-05-22 demo hot-fix parity — iframe 이 마우스 가로
|
||||
// 채서 mouseup leak 일어남 (편집 모드에서 pe=auto).
|
||||
const iframeEl = iframeRef.current;
|
||||
const prevIframePE = iframeEl ? iframeEl.style.pointerEvents : "";
|
||||
if (iframeEl) iframeEl.style.pointerEvents = "none";
|
||||
|
||||
const isCorner =
|
||||
direction === "nw" ||
|
||||
direction === "ne" ||
|
||||
direction === "sw" ||
|
||||
direction === "se";
|
||||
|
||||
const onMove = (mv: MouseEvent) => {
|
||||
// Convert pixel delta on the on-screen scaled slide
|
||||
// back into percent-of-slide so all downstream math
|
||||
// shares the persisted axis's coord space. W_SCALED /
|
||||
// H_SCALED already include the wrapper scale factor,
|
||||
// so dividing then multiplying by 100 gives a stable
|
||||
// value regardless of viewport zoom.
|
||||
const dx = ((mv.clientX - startMouseX) / W_SCALED) * 100;
|
||||
const dy = ((mv.clientY - startMouseY) / H_SCALED) * 100;
|
||||
// IMP-51 (#79) u9 — boundary contract lives in the
|
||||
// pure helper so vitest can verify it directly.
|
||||
// Aspect lock is default on for corner handles and
|
||||
// released when Shift is held.
|
||||
const aspectLocked = isCorner && !mv.shiftKey;
|
||||
const next = clampImagePercentGeometry(
|
||||
startGeom,
|
||||
dx,
|
||||
dy,
|
||||
direction,
|
||||
aspectLocked,
|
||||
IMAGE_RESIZE_MIN_SIZE_PERCENT,
|
||||
);
|
||||
onImageResize(imageId, next);
|
||||
};
|
||||
const onUp = () => {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
if (iframeEl) iframeEl.style.pointerEvents = prevIframePE;
|
||||
};
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`img-overlay-${imageId}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-image-overlay-id={imageId}
|
||||
onMouseDown={(ev) => beginDrag(ev, "move")}
|
||||
className={`absolute z-30 ${
|
||||
isSelected
|
||||
? "border-2 border-emerald-500 bg-emerald-500/5 shadow-[0_0_0_2px_rgba(16,185,129,0.25)]"
|
||||
: "border border-dashed border-emerald-400/60 hover:border-emerald-500"
|
||||
} cursor-grab active:cursor-grabbing`}
|
||||
style={{
|
||||
left: `${geom.x}%`,
|
||||
top: `${geom.y}%`,
|
||||
width: `${geom.w}%`,
|
||||
height: `${geom.h}%`,
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
title={
|
||||
isSelected
|
||||
? "이미지 이동 — 드래그 / 모서리 핸들 = 크기 (Shift = 비율 해제)"
|
||||
: "클릭하여 선택"
|
||||
}
|
||||
>
|
||||
<span className="absolute top-1 left-1 text-[9px] font-black uppercase tracking-tighter px-1.5 py-0.5 rounded bg-emerald-600/90 text-white shadow pointer-events-none">
|
||||
IMG
|
||||
</span>
|
||||
|
||||
{isSelected && (
|
||||
<>
|
||||
{/* edges */}
|
||||
<div
|
||||
onMouseDown={(ev) => beginDrag(ev, "top")}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="absolute -top-1 left-1/4 w-1/2 h-2 bg-emerald-500/70 hover:bg-emerald-500 rounded cursor-ns-resize z-40 transition shadow"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
title="상단"
|
||||
/>
|
||||
<div
|
||||
onMouseDown={(ev) => beginDrag(ev, "bottom")}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="absolute -bottom-1 left-1/4 w-1/2 h-2 bg-emerald-500/70 hover:bg-emerald-500 rounded cursor-ns-resize z-40 transition shadow"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
title="하단"
|
||||
/>
|
||||
<div
|
||||
onMouseDown={(ev) => beginDrag(ev, "left")}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="absolute top-1/4 -left-1 h-1/2 w-2 bg-emerald-500/70 hover:bg-emerald-500 rounded cursor-ew-resize z-40 transition shadow"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
title="좌측"
|
||||
/>
|
||||
<div
|
||||
onMouseDown={(ev) => beginDrag(ev, "right")}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="absolute top-1/4 -right-1 h-1/2 w-2 bg-emerald-500/70 hover:bg-emerald-500 rounded cursor-ew-resize z-40 transition shadow"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
title="우측"
|
||||
/>
|
||||
{/* corners — aspect locked by default, Shift unlocks */}
|
||||
<div
|
||||
onMouseDown={(ev) => beginDrag(ev, "nw")}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="absolute -top-1 -left-1 w-3 h-3 bg-white border-2 border-emerald-500 rounded-sm cursor-nwse-resize z-40 hover:scale-125 transition shadow"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
title="좌상단 (Shift = 비율 해제)"
|
||||
/>
|
||||
<div
|
||||
onMouseDown={(ev) => beginDrag(ev, "ne")}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="absolute -top-1 -right-1 w-3 h-3 bg-white border-2 border-emerald-500 rounded-sm cursor-nesw-resize z-40 hover:scale-125 transition shadow"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
title="우상단 (Shift = 비율 해제)"
|
||||
/>
|
||||
<div
|
||||
onMouseDown={(ev) => beginDrag(ev, "sw")}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="absolute -bottom-1 -left-1 w-3 h-3 bg-white border-2 border-emerald-500 rounded-sm cursor-nesw-resize z-40 hover:scale-125 transition shadow"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
title="좌하단 (Shift = 비율 해제)"
|
||||
/>
|
||||
<div
|
||||
onMouseDown={(ev) => beginDrag(ev, "se")}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="absolute -bottom-1 -right-1 w-4 h-4 bg-white border-2 border-emerald-500 rounded-sm cursor-nwse-resize z-40 hover:scale-125 transition shadow"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
title="우하단 (Shift = 비율 해제)"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -13,8 +13,11 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
DRAG_THRESHOLD_PX,
|
||||
IMAGE_RESIZE_MIN_SIZE_PERCENT,
|
||||
clampImagePercentGeometry,
|
||||
clampZoneMove,
|
||||
crossedDragThreshold,
|
||||
type ImagePercentGeom,
|
||||
type ZoneFracGeom,
|
||||
} from "./slideCanvasDragMath";
|
||||
|
||||
@@ -105,3 +108,118 @@ describe("clampZoneMove", () => {
|
||||
expect("h" in out).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// IMP-51 (#79) u9 — image overlay resize / move math.
|
||||
// Boundary contract (must match the inline u8 math Codex #16 verified):
|
||||
// • slide-bound invariant — x+w ≤ 100 ∧ y+h ≤ 100 for ALL valid inputs,
|
||||
// including small-near-edge geoms where the existing minSize floor
|
||||
// would otherwise have pushed past the slide bound.
|
||||
// • aspect-locked corner — baseAspect = startGeom.w / startGeom.h is
|
||||
// preserved exactly; the wFloor uses `min(minSize, maxW, maxH*baseAspect)`
|
||||
// so a floor application never violates either axis.
|
||||
// The two concrete Codex #15 reproductions are encoded explicitly below
|
||||
// so a future regression on the boundary math fails this suite directly.
|
||||
describe("IMAGE_RESIZE_MIN_SIZE_PERCENT", () => {
|
||||
it("is 2 (percent of slide bbox)", () => {
|
||||
expect(IMAGE_RESIZE_MIN_SIZE_PERCENT).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clampImagePercentGeometry", () => {
|
||||
const baseGeom: ImagePercentGeom = { x: 10, y: 10, w: 20, h: 10 };
|
||||
|
||||
describe("direction = 'move'", () => {
|
||||
it("translates and clamps both axes; preserves w/h", () => {
|
||||
expect(
|
||||
clampImagePercentGeometry(baseGeom, 5, 7, "move", false),
|
||||
).toEqual({ x: 15, y: 17, w: 20, h: 10 });
|
||||
});
|
||||
|
||||
it("clamps negative deltas to (0, 0)", () => {
|
||||
expect(
|
||||
clampImagePercentGeometry(baseGeom, -1000, -1000, "move", false),
|
||||
).toEqual({ x: 0, y: 0, w: 20, h: 10 });
|
||||
});
|
||||
|
||||
it("clamps max-edge deltas to (100 - w, 100 - h)", () => {
|
||||
expect(
|
||||
clampImagePercentGeometry(baseGeom, 1000, 1000, "move", false),
|
||||
).toEqual({ x: 80, y: 90, w: 20, h: 10 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge resize — independent per-axis clamp", () => {
|
||||
it("right edge clamps width to 100 - startGeom.x", () => {
|
||||
const out = clampImagePercentGeometry(baseGeom, 1000, 0, "right", false);
|
||||
expect(out).toEqual({ x: 10, y: 10, w: 90, h: 10 });
|
||||
expect(out.x + out.w).toBeLessThanOrEqual(100);
|
||||
});
|
||||
|
||||
it("left drag dx=-100 emits {x:0,y:10,w:30,h:10} (Codex regression)", () => {
|
||||
// From Codex #15 / #16 verification — ordinary left drag past the
|
||||
// slide edge should pin x at 0 and grow w by the original x amount.
|
||||
expect(
|
||||
clampImagePercentGeometry(baseGeom, -100, 0, "left", false),
|
||||
).toEqual({ x: 0, y: 10, w: 30, h: 10 });
|
||||
});
|
||||
|
||||
it("near-edge right resize keeps x + w ≤ 100 (Codex #15 reproduction)", () => {
|
||||
// Pre-fix: minSize=2 floor applied AFTER span clamp would emit
|
||||
// {x:99, w:2} so x+w=101. Post-fix: floor caps at maxW=1.
|
||||
const start: ImagePercentGeom = { x: 99, y: 10, w: 0.5, h: 10 };
|
||||
const out = clampImagePercentGeometry(start, 1, 0, "right", false);
|
||||
expect(out).toEqual({ x: 99, y: 10, w: 1, h: 10 });
|
||||
expect(out.x + out.w).toBe(100);
|
||||
});
|
||||
|
||||
it("top/bottom edges are symmetric to left/right", () => {
|
||||
const bottom = clampImagePercentGeometry(baseGeom, 0, 1000, "bottom", false);
|
||||
expect(bottom).toEqual({ x: 10, y: 10, w: 20, h: 90 });
|
||||
const top = clampImagePercentGeometry(baseGeom, 0, -100, "top", false);
|
||||
expect(top).toEqual({ x: 10, y: 0, w: 20, h: 20 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("corner resize — aspect locked (default Shift-off)", () => {
|
||||
it("NW drag dx=-100,dy=-100 emits {x:0,y:5,w:30,h:15} (Codex regression)", () => {
|
||||
// From Codex #16 verification — aspect-locked NW past the slide
|
||||
// edge: rightEdge=30, bottomEdge=20, baseAspect=2. Independent
|
||||
// clamps give x=0,w=30,y=0,h=20. Aspect block then picks the
|
||||
// limiting axis: newH = 30/2 = 15 (≤20). Re-anchor: y = 20 - 15 = 5.
|
||||
expect(
|
||||
clampImagePercentGeometry(baseGeom, -100, -100, "nw", true),
|
||||
).toEqual({ x: 0, y: 5, w: 30, h: 15 });
|
||||
});
|
||||
|
||||
it("tiny near-corner NE resize stays within bounds (Codex #15 reproduction)", () => {
|
||||
// Pre-fix: dual-axis minSize floor would emit w=2, h=2 with
|
||||
// re-anchor pushing x+w past 100. Post-fix: wFloor caps at
|
||||
// min(2, maxW=1, maxH*baseAspect=1) = 1, so newW=1, newH=1.
|
||||
const start: ImagePercentGeom = { x: 99, y: 99, w: 0.5, h: 0.5 };
|
||||
const out = clampImagePercentGeometry(start, 1, -1, "ne", true);
|
||||
expect(out).toEqual({ x: 99, y: 98.5, w: 1, h: 1 });
|
||||
expect(out.x + out.w).toBeLessThanOrEqual(100);
|
||||
expect(out.y + out.h).toBeLessThanOrEqual(100);
|
||||
});
|
||||
|
||||
it("preserves baseAspect exactly when the floor is hit", () => {
|
||||
// 2:1 aspect ratio (w=20, h=10); large negative drag past edges
|
||||
// hits wFloor. newW/newH ratio must equal baseAspect.
|
||||
const out = clampImagePercentGeometry(
|
||||
baseGeom, -1000, -1000, "nw", true,
|
||||
);
|
||||
expect(out.w / out.h).toBeCloseTo(baseGeom.w / baseGeom.h, 10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("corner resize — Shift unlock (independent edges)", () => {
|
||||
it("SE without aspect lock degenerates to right + bottom edges", () => {
|
||||
const corner = clampImagePercentGeometry(baseGeom, 1000, 1000, "se", false);
|
||||
const sides = clampImagePercentGeometry(
|
||||
clampImagePercentGeometry(baseGeom, 1000, 0, "right", false),
|
||||
0, 1000, "bottom", false,
|
||||
);
|
||||
expect(corner).toEqual(sides);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,143 @@
|
||||
|
||||
export const DRAG_THRESHOLD_PX = 5;
|
||||
|
||||
// IMP-51 (#79) u9 — image overlay resize / move math extracted from
|
||||
// SlideCanvas.tsx `beginDrag` onMove (lines 1092–1219 of the u8 patch).
|
||||
// Slide-absolute percent coordinate space (0–100 on both axes), matching
|
||||
// the persisted `image_overrides` axis (`src/user_overrides_io.py` u1
|
||||
// KNOWN_AXES) and the typed client `ImageOverride` shape (`userOverridesApi.ts`
|
||||
// u3). The math is the contract Codex #16 verified post-u8 — this file
|
||||
// is a relocation, not a behavior change. SlideCanvas calls it from a
|
||||
// single hook so future tweaks need to update one place + the vitest
|
||||
// suite alongside.
|
||||
export const IMAGE_RESIZE_MIN_SIZE_PERCENT = 2;
|
||||
|
||||
/** Image overlay geometry in slide-absolute percent (each component ∈ [0, 100]).
|
||||
* Mirrors `ImageOverride` from `services/userOverridesApi.ts` (u3) so this
|
||||
* shape moves end-to-end through stamper → overlay → persisted axis. */
|
||||
export interface ImagePercentGeom {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export type ImageDragDirection =
|
||||
| "move"
|
||||
| "left"
|
||||
| "right"
|
||||
| "top"
|
||||
| "bottom"
|
||||
| "nw"
|
||||
| "ne"
|
||||
| "sw"
|
||||
| "se";
|
||||
|
||||
/** Apply a percent-space drag delta to `startGeom` per `direction` and clamp.
|
||||
*
|
||||
* Contract (must match the inline u8 math Codex #16 verified):
|
||||
* • `direction === "move"` → translate only; w/h preserved verbatim;
|
||||
* x/y clamped to `[0, 100 - w]` and `[0, 100 - h]`.
|
||||
* • Edge handle (`left|right|top|bottom`) → one axis only; opposite
|
||||
* edge pinned so x+w ≤ 100 and y+h ≤ 100 hold.
|
||||
* • Corner handle (`nw|ne|sw|se`) with `aspectLocked=false` → two
|
||||
* independent edges (same per-edge clamp as above).
|
||||
* • Corner handle with `aspectLocked=true` → preserves
|
||||
* `baseAspect = startGeom.w / startGeom.h`; the pinned-opposite-corner
|
||||
* stays fixed; the floored axis is `w` and `h` is re-derived so the
|
||||
* aspect ratio is exact even at the minSize floor.
|
||||
*
|
||||
* `minSize` is best-effort: when the available span (e.g. `100 - startGeom.x`
|
||||
* for `affectsRight`) is below `minSize`, the floor caps at the span itself
|
||||
* so the slide-bound invariant (x+w ≤ 100 ∧ y+h ≤ 100) is never violated.
|
||||
* Pure / deterministic / no DOM access — vitest drives it directly. */
|
||||
export function clampImagePercentGeometry(
|
||||
startGeom: ImagePercentGeom,
|
||||
dxPercent: number,
|
||||
dyPercent: number,
|
||||
direction: ImageDragDirection,
|
||||
aspectLocked: boolean,
|
||||
minSize: number = IMAGE_RESIZE_MIN_SIZE_PERCENT,
|
||||
): ImagePercentGeom {
|
||||
if (direction === "move") {
|
||||
const x = Math.max(0, Math.min(100 - startGeom.w, startGeom.x + dxPercent));
|
||||
const y = Math.max(0, Math.min(100 - startGeom.h, startGeom.y + dyPercent));
|
||||
return { x, y, w: startGeom.w, h: startGeom.h };
|
||||
}
|
||||
|
||||
const affectsLeft =
|
||||
direction === "left" || direction === "nw" || direction === "sw";
|
||||
const affectsRight =
|
||||
direction === "right" || direction === "ne" || direction === "se";
|
||||
const affectsTop =
|
||||
direction === "top" || direction === "nw" || direction === "ne";
|
||||
const affectsBottom =
|
||||
direction === "bottom" || direction === "sw" || direction === "se";
|
||||
const isCorner =
|
||||
direction === "nw" ||
|
||||
direction === "ne" ||
|
||||
direction === "sw" ||
|
||||
direction === "se";
|
||||
|
||||
const rightEdge = startGeom.x + startGeom.w;
|
||||
const bottomEdge = startGeom.y + startGeom.h;
|
||||
let x = startGeom.x;
|
||||
let y = startGeom.y;
|
||||
let w = startGeom.w;
|
||||
let h = startGeom.h;
|
||||
|
||||
if (affectsRight) {
|
||||
const maxW = 100 - startGeom.x;
|
||||
const floor = Math.min(minSize, maxW);
|
||||
w = Math.max(floor, Math.min(maxW, startGeom.w + dxPercent));
|
||||
}
|
||||
if (affectsBottom) {
|
||||
const maxH = 100 - startGeom.y;
|
||||
const floor = Math.min(minSize, maxH);
|
||||
h = Math.max(floor, Math.min(maxH, startGeom.h + dyPercent));
|
||||
}
|
||||
if (affectsLeft) {
|
||||
const floor = Math.min(minSize, rightEdge);
|
||||
x = Math.max(0, Math.min(rightEdge - floor, startGeom.x + dxPercent));
|
||||
w = rightEdge - x;
|
||||
}
|
||||
if (affectsTop) {
|
||||
const floor = Math.min(minSize, bottomEdge);
|
||||
y = Math.max(0, Math.min(bottomEdge - floor, startGeom.y + dyPercent));
|
||||
h = bottomEdge - y;
|
||||
}
|
||||
|
||||
if (isCorner && aspectLocked) {
|
||||
const baseAspect =
|
||||
startGeom.w > 0 && startGeom.h > 0 ? startGeom.w / startGeom.h : 1;
|
||||
if (baseAspect > 0) {
|
||||
const maxW = affectsLeft ? rightEdge : 100 - startGeom.x;
|
||||
const maxH = affectsTop ? bottomEdge : 100 - startGeom.y;
|
||||
let newW = w;
|
||||
let newH = newW / baseAspect;
|
||||
if (newH > maxH) {
|
||||
newH = maxH;
|
||||
newW = newH * baseAspect;
|
||||
}
|
||||
if (newW > maxW) {
|
||||
newW = maxW;
|
||||
newH = newW / baseAspect;
|
||||
}
|
||||
const wFloor = Math.min(minSize, maxW, maxH * baseAspect);
|
||||
if (newW < wFloor) {
|
||||
newW = wFloor;
|
||||
newH = newW / baseAspect;
|
||||
}
|
||||
w = newW;
|
||||
h = newH;
|
||||
x = affectsLeft ? rightEdge - w : startGeom.x;
|
||||
y = affectsTop ? bottomEdge - h : startGeom.y;
|
||||
}
|
||||
}
|
||||
|
||||
return { x, y, w, h };
|
||||
}
|
||||
|
||||
/** Returns true once the pointer has travelled far enough from the mousedown
|
||||
* origin to be treated as a drag rather than a click. */
|
||||
export function crossedDragThreshold(dxPx: number, dyPx: number): boolean {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
getSelectedRegion,
|
||||
moveSectionToZone,
|
||||
saveZoneSizes,
|
||||
saveImageOverride,
|
||||
deriveUserOverridesKey,
|
||||
applyPersistedNonFrameOverrides,
|
||||
remapPersistedFramesToZoneFrames,
|
||||
@@ -540,6 +541,37 @@ export default function Home() {
|
||||
setHasPendingChanges(true);
|
||||
}, []);
|
||||
|
||||
// IMP-51 (#79) u10 — wire SlideCanvas's user-content image drag/resize
|
||||
// emit into the 5th persisted axis. Mirrors handleZoneResize exactly:
|
||||
// • merge the single (imageId → {x,y,w,h}) tick onto the prior
|
||||
// in-memory `image_overrides` map via the u11 `saveImageOverride`
|
||||
// helper so the immutable update path is shared with the test suite,
|
||||
// • forward the full merged snapshot through `saveUserOverrides`
|
||||
// (the u3 typed client) under the `image_overrides` key — the 300ms
|
||||
// debounce defined alongside `zone_geometries` collapses the
|
||||
// per-mousemove emits into one PUT at gesture-end,
|
||||
// • flip `hasPendingChanges` so the "선택대로 재생성하기" CTA appears.
|
||||
// Coordinates are slide-absolute percent (0–100) from u8/u9 — passed
|
||||
// through unchanged so the on-disk schema matches the SlideCanvas
|
||||
// overlay, the stamper selector (u4), and the render-time CSS
|
||||
// injector (u7) without any per-zone transform.
|
||||
const handleImageResize = useCallback(
|
||||
(imageId: string, geometry: { x: number; y: number; w: number; h: number }) => {
|
||||
setState((p) => {
|
||||
const nextSelection = saveImageOverride(p.userSelection, imageId, geometry);
|
||||
if (p.uploadedFile) {
|
||||
const key = deriveUserOverridesKey(p.uploadedFile.name);
|
||||
void saveUserOverrides(key, {
|
||||
image_overrides: nextSelection.overrides.image_overrides,
|
||||
});
|
||||
}
|
||||
return { ...p, userSelection: nextSelection };
|
||||
});
|
||||
setHasPendingChanges(true);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// 편집 모드 텍스트 변경 시 hasPendingChanges 활성. useCallback 으로 reference 안정화 —
|
||||
// SlideCanvas 의 useEffect 가 매번 rerun 안 하도록 (resize drag 매 mousemove 마다
|
||||
// re-render 시 useEffect retrigger → iframe contentEditable 재설정 = 매우 느림).
|
||||
@@ -745,6 +777,8 @@ export default function Home() {
|
||||
onSectionDrop={handleSectionDrop}
|
||||
onLayoutResize={handleLayoutResize}
|
||||
onZoneResize={handleZoneResize}
|
||||
imageOverrides={state.userSelection.overrides.image_overrides}
|
||||
onImageResize={handleImageResize}
|
||||
/>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -7,6 +7,12 @@
|
||||
// the four mutation handlers (u7). It does NOT own the schema — any change
|
||||
// to KNOWN_AXES must land in u1/u4 first, then reflect here.
|
||||
//
|
||||
// IMP-51 (#79) u3 — added `image_overrides` (5th axis). `image_id` → percent-
|
||||
// of-slide {x,y,w,h}. Mirrors src/user_overrides_io.py KNOWN_AXES (u1) and
|
||||
// Front/vite.config.ts KNOWN_USER_OVERRIDES_AXES (u2). Backend stamper +
|
||||
// render-time CSS injection ride on u4~u7; the SlideCanvas drag/resize
|
||||
// handles that drive this axis ride on u8~u11.
|
||||
//
|
||||
// Contract (Stage 2 unit u5 summary):
|
||||
// • Typed `getUserOverrides(key)` → returns `Partial<UserOverrides>` from
|
||||
// the GET endpoint. Missing / corrupt / non-object payloads degrade to
|
||||
@@ -44,12 +50,28 @@ export type ZoneGeometriesOverride = Record<string, ZoneGeometryOverride>;
|
||||
/** zone_id → ordered list of section_ids assigned to that zone. */
|
||||
export type ZoneSectionsOverride = Record<string, string[]>;
|
||||
|
||||
/**
|
||||
* IMP-51 #79 u3 — image_id → percent-of-slide geometry. Matches the user-
|
||||
* content image selector `.slide img[data-image-role="user-content"]`
|
||||
* (stamper in u4) and the render-time CSS injection map (u7). Coordinates
|
||||
* are slide-absolute percent (0–100) so SlideCanvas drag handles (u8~u11)
|
||||
* map 1:1 with the persisted axis without per-zone transforms.
|
||||
*/
|
||||
export type ImageOverride = {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
};
|
||||
export type ImageOverridesOverride = Record<string, ImageOverride>;
|
||||
|
||||
/** Full on-disk schema. All axes optional — file may carry any subset. */
|
||||
export interface UserOverrides {
|
||||
layout: string;
|
||||
frames: FramesOverride;
|
||||
zone_geometries: ZoneGeometriesOverride;
|
||||
zone_sections: ZoneSectionsOverride;
|
||||
image_overrides: ImageOverridesOverride;
|
||||
}
|
||||
|
||||
/** Partial-mutation payload. `null` is the explicit clear sentinel (mirrors u4). */
|
||||
|
||||
@@ -206,6 +206,13 @@ export interface UserSelection {
|
||||
zone_sections: Record<string, string[]>; // zoneId -> sectionIds[]
|
||||
zone_sizes: Record<string, number[]>; // layoutGroupId -> [size1, size2, ...]
|
||||
zone_geometries: Record<string, { x: number; y: number; w: number; h: number }>; // zone_id -> geometry
|
||||
// IMP-51 (#79) u11 — image_id → slide-absolute percent geometry (0–100
|
||||
// on each axis). image_id is stamped by `src/image_id_stamper.py` (u4)
|
||||
// on user-content `<img>` tags; the same key is consumed by the u7 CSS
|
||||
// injector and the SlideCanvas u8 overlay. Shape mirrors the on-disk
|
||||
// `image_overrides` axis (KNOWN_AXES, src/user_overrides_io.py u1) and
|
||||
// the typed-client `ImageOverridesOverride` (services/userOverridesApi.ts u3).
|
||||
image_overrides: Record<string, { x: number; y: number; w: number; h: number }>;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,18 @@ export function applyPersistedNonFrameOverrides(
|
||||
) {
|
||||
next.zone_sections = { ...persisted.zone_sections };
|
||||
}
|
||||
// IMP-51 (#79) u11 — layer the 5th persisted axis (`image_overrides`) by
|
||||
// the same array / non-object guard the zone_geometries branch uses. The
|
||||
// u3 typed client (services/userOverridesApi.ts) shape and the on-disk
|
||||
// KNOWN_AXES entry (src/user_overrides_io.py u1) are both flat dicts
|
||||
// (image_id → {x,y,w,h} percent-of-slide), so a shallow copy is enough.
|
||||
if (
|
||||
persisted.image_overrides &&
|
||||
typeof persisted.image_overrides === "object" &&
|
||||
!Array.isArray(persisted.image_overrides)
|
||||
) {
|
||||
next.image_overrides = { ...persisted.image_overrides };
|
||||
}
|
||||
return { ...selection, overrides: next };
|
||||
}
|
||||
|
||||
@@ -143,13 +155,17 @@ export function createInitialUserSelection(slidePlan?: SlidePlan | null): UserSe
|
||||
zone_sections: initialSections,
|
||||
zone_sizes: {},
|
||||
zone_geometries: {},
|
||||
// IMP-51 (#79) u11 — image_overrides axis starts empty; entries land
|
||||
// here via `saveImageOverride` (SlideCanvas drag/resize handler) and
|
||||
// are seeded on reopen via `applyPersistedNonFrameOverrides`.
|
||||
image_overrides: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function saveZoneGeometry(
|
||||
selection: UserSelection,
|
||||
zoneId: string,
|
||||
selection: UserSelection,
|
||||
zoneId: string,
|
||||
geometry: { x: number; y: number; w: number; h: number }
|
||||
): UserSelection {
|
||||
return {
|
||||
@@ -164,6 +180,32 @@ export function saveZoneGeometry(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* IMP-51 (#79) u11 — record a single `image_id` → slide-absolute percent
|
||||
* geometry on the in-memory selection. Mirrors `saveZoneGeometry` but on
|
||||
* the 5th persisted axis (`image_overrides`); the SlideCanvas drag/resize
|
||||
* handler (u8) emits one entry per pointer move, and u10's Home wiring
|
||||
* funnels each emit through this helper before scheduling the debounced
|
||||
* PUT. Pure / immutable — returns a fresh `UserSelection`; the input is
|
||||
* never mutated. Existing entries for the same `imageId` are replaced.
|
||||
*/
|
||||
export function saveImageOverride(
|
||||
selection: UserSelection,
|
||||
imageId: string,
|
||||
geometry: { x: number; y: number; w: number; h: number },
|
||||
): UserSelection {
|
||||
return {
|
||||
...selection,
|
||||
overrides: {
|
||||
...selection.overrides,
|
||||
image_overrides: {
|
||||
...selection.overrides.image_overrides,
|
||||
[imageId]: geometry,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function saveZoneSizes(selection: UserSelection, groupId: string, sizes: number[]): UserSelection {
|
||||
return {
|
||||
...selection,
|
||||
|
||||
@@ -315,6 +315,7 @@ describe("KNOWN_USER_OVERRIDES_AXES (IMP-52 u4)", () => {
|
||||
"zone_geometries",
|
||||
"zone_sections",
|
||||
"frames",
|
||||
"image_overrides",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -338,16 +339,19 @@ describe("mergeUserOverrides (IMP-52 u4)", () => {
|
||||
});
|
||||
|
||||
it("preserves foreign top-level keys in existing", () => {
|
||||
// Forward-compat: future axes (zone_sizes, image_overrides, etc.) on
|
||||
// disk must survive PUT writes that only touch the 4 in-scope axes.
|
||||
// Forward-compat: future axes (zone_sizes, schema_version, etc.) on
|
||||
// disk must survive PUT writes that only touch the 5 in-scope axes.
|
||||
// `image_overrides` is no longer a foreign key after IMP-51 #79 u2 —
|
||||
// it joined KNOWN_USER_OVERRIDES_AXES — so we probe with axes that
|
||||
// are still NOT in the allowlist.
|
||||
const existing = {
|
||||
layout: "old",
|
||||
zone_sizes: { top: 0.42 },
|
||||
image_overrides: { img1: { x: 0.1 } },
|
||||
schema_version: 2,
|
||||
};
|
||||
const merged = mergeUserOverrides(existing, { layout: "new" });
|
||||
expect(merged.zone_sizes).toEqual({ top: 0.42 });
|
||||
expect(merged.image_overrides).toEqual({ img1: { x: 0.1 } });
|
||||
expect(merged.schema_version).toBe(2);
|
||||
});
|
||||
|
||||
it("clears axis when partial value is null (explicit clear)", () => {
|
||||
@@ -359,7 +363,7 @@ describe("mergeUserOverrides (IMP-52 u4)", () => {
|
||||
|
||||
it("drops non-axis keys in partial (allowlist)", () => {
|
||||
// PUT payload may carry junk fields (typo, malicious key); allowlist
|
||||
// ensures only the 4 axes can be written to disk.
|
||||
// ensures only the 5 axes can be written to disk.
|
||||
const merged = mergeUserOverrides(
|
||||
{},
|
||||
{ layout: "x", random_key: "evil", __proto__: "x" } as Record<
|
||||
@@ -371,7 +375,7 @@ describe("mergeUserOverrides (IMP-52 u4)", () => {
|
||||
expect("random_key" in merged).toBe(false);
|
||||
});
|
||||
|
||||
it("merges all 4 axes when present in partial", () => {
|
||||
it("merges all 5 axes when present in partial", () => {
|
||||
const merged = mergeUserOverrides(
|
||||
{},
|
||||
{
|
||||
@@ -379,16 +383,47 @@ describe("mergeUserOverrides (IMP-52 u4)", () => {
|
||||
frames: { "03-1+03-2": "frame_07" },
|
||||
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } },
|
||||
zone_sections: { top: ["03-1", "03-2"] },
|
||||
image_overrides: { "img-1": { x: 0.1, y: 0.2, w: 0.3, h: 0.25 } },
|
||||
},
|
||||
);
|
||||
expect(Object.keys(merged).sort()).toEqual([
|
||||
"frames",
|
||||
"image_overrides",
|
||||
"layout",
|
||||
"zone_geometries",
|
||||
"zone_sections",
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves image_overrides when absent from partial (5th axis IMP-51 #79 u2)", () => {
|
||||
// Sibling axis of layout/frames/zone_geometries/zone_sections: a PUT
|
||||
// that touches only layout must NOT erase the image_overrides map
|
||||
// already on disk. Mirrors the partial-merge invariant for the 4
|
||||
// pre-existing axes.
|
||||
const existing = {
|
||||
layout: "old",
|
||||
image_overrides: { "img-1": { x: 0.1, y: 0.2, w: 0.3, h: 0.25 } },
|
||||
};
|
||||
const merged = mergeUserOverrides(existing, { layout: "new" });
|
||||
expect(merged.image_overrides).toEqual({
|
||||
"img-1": { x: 0.1, y: 0.2, w: 0.3, h: 0.25 },
|
||||
});
|
||||
expect(merged.layout).toBe("new");
|
||||
});
|
||||
|
||||
it("clears image_overrides when partial value is null (explicit clear)", () => {
|
||||
// Same null-sentinel contract as the 4 sibling axes — `null` removes
|
||||
// the axis from disk so the next render reverts to baseline (no
|
||||
// user image position/size override).
|
||||
const existing = {
|
||||
layout: "x",
|
||||
image_overrides: { "img-1": { x: 0.1, y: 0.2, w: 0.3, h: 0.25 } },
|
||||
};
|
||||
const merged = mergeUserOverrides(existing, { image_overrides: null });
|
||||
expect("image_overrides" in merged).toBe(false);
|
||||
expect(merged.layout).toBe("x");
|
||||
});
|
||||
|
||||
it("does not mutate the existing input", () => {
|
||||
const existing = { layout: "old", frames: { a: "b" } };
|
||||
const snapshot = JSON.parse(JSON.stringify(existing));
|
||||
@@ -572,13 +607,15 @@ describe("handlePutUserOverrides (IMP-52 u4)", () => {
|
||||
});
|
||||
|
||||
it("preserves foreign top-level keys on disk (forward-compat)", () => {
|
||||
// `image_overrides` is no longer a foreign key after IMP-51 #79 u2;
|
||||
// probe with axes that are still NOT in KNOWN_USER_OVERRIDES_AXES.
|
||||
fs.mkdirSync(overridesDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(overridesDir, "future.json"),
|
||||
JSON.stringify({
|
||||
layout: "old",
|
||||
zone_sizes: { top: 0.42 },
|
||||
image_overrides: { img1: { x: 0.1 } },
|
||||
schema_version: 2,
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
@@ -592,10 +629,48 @@ describe("handlePutUserOverrides (IMP-52 u4)", () => {
|
||||
fs.readFileSync(path.join(overridesDir, "future.json"), "utf-8"),
|
||||
);
|
||||
expect(onDisk.zone_sizes).toEqual({ top: 0.42 });
|
||||
expect(onDisk.image_overrides).toEqual({ img1: { x: 0.1 } });
|
||||
expect(onDisk.schema_version).toBe(2);
|
||||
expect(onDisk.layout).toBe("new");
|
||||
});
|
||||
|
||||
it("persists image_overrides partial-merge and preserves sibling axes (IMP-51 #79 u2)", () => {
|
||||
// 5th axis end-to-end PUT round-trip: writing only image_overrides
|
||||
// must NOT touch the 4 sibling axes already on disk. Mirrors the
|
||||
// existing partial-merge test for layout above.
|
||||
fs.mkdirSync(overridesDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(overridesDir, "03.json"),
|
||||
JSON.stringify({
|
||||
layout: "two_zone_split",
|
||||
frames: { "03-1": "frame_01" },
|
||||
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } },
|
||||
zone_sections: { top: ["03-1"] },
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const req = makeMockReq({ method: "PUT", url: "/03" });
|
||||
const { res, state } = makeMockRes();
|
||||
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
||||
req.send(
|
||||
JSON.stringify({
|
||||
image_overrides: { "img-1": { x: 0.1, y: 0.2, w: 0.3, h: 0.25 } },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(state.statusCode).toBe(200);
|
||||
const onDisk = JSON.parse(
|
||||
fs.readFileSync(path.join(overridesDir, "03.json"), "utf-8"),
|
||||
);
|
||||
expect(onDisk).toEqual({
|
||||
layout: "two_zone_split",
|
||||
frames: { "03-1": "frame_01" },
|
||||
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } },
|
||||
zone_sections: { top: ["03-1"] },
|
||||
image_overrides: { "img-1": { x: 0.1, y: 0.2, w: 0.3, h: 0.25 } },
|
||||
});
|
||||
});
|
||||
|
||||
it("drops non-axis payload keys (allowlist enforced at write)", () => {
|
||||
fs.mkdirSync(overridesDir, { recursive: true });
|
||||
const req = makeMockReq({ method: "PUT", url: "/03" });
|
||||
|
||||
@@ -31,8 +31,10 @@ import type {
|
||||
} from "../src/types/designAgent";
|
||||
import {
|
||||
applyPersistedNonFrameOverrides,
|
||||
createInitialUserSelection,
|
||||
deriveUserOverridesKey,
|
||||
remapPersistedFramesToZoneFrames,
|
||||
saveImageOverride,
|
||||
} from "../src/utils/slidePlanUtils";
|
||||
|
||||
// ─── Fixtures ───────────────────────────────────────────────────────────────
|
||||
@@ -48,6 +50,10 @@ function makeSelection(overrides?: Partial<UserSelection["overrides"]>): UserSel
|
||||
zone_sections: {},
|
||||
zone_sizes: {},
|
||||
zone_geometries: {},
|
||||
// IMP-51 (#79) u11 — keep the fixture in sync with the 5th persisted
|
||||
// axis declared on `UserSelection.overrides`. Empty by default so the
|
||||
// existing IMP-52 cases remain unchanged in shape.
|
||||
image_overrides: {},
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
@@ -300,3 +306,157 @@ describe("remapPersistedFramesToZoneFrames (IMP-52 u6)", () => {
|
||||
expect(remapped["r-top"]).toBe("user-chosen-tpl");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── IMP-51 (#79) u11 — image_overrides axis ────────────────────────────────
|
||||
// New 5th persisted axis. The on-disk schema (KNOWN_AXES,
|
||||
// src/user_overrides_io.py u1), the typed client
|
||||
// (services/userOverridesApi.ts u3 ImageOverridesOverride), the Vite
|
||||
// allowlist (vite.config.ts u2), and the backend CLI flag (--override-image
|
||||
// in src/phase_z2_pipeline.py u5) all expect `image_id` → percent-of-slide
|
||||
// geometry. u11 owns the in-memory mirror on `UserSelection.overrides`
|
||||
// (declared in types/designAgent.ts) plus the three pure helpers that
|
||||
// Home.tsx (u10) wires:
|
||||
// • applyPersistedNonFrameOverrides — restore-on-reopen layer.
|
||||
// • createInitialUserSelection — fresh-slide initializer.
|
||||
// • saveImageOverride — single-image record helper invoked by the
|
||||
// SlideCanvas u8 drag/resize handler.
|
||||
|
||||
describe("image_overrides axis — applyPersistedNonFrameOverrides (IMP-51 u11)", () => {
|
||||
it("layers a flat image_overrides dict onto the selection", () => {
|
||||
const sel = makeSelection();
|
||||
const persisted = {
|
||||
image_overrides: {
|
||||
"img-abc1234567": { x: 10, y: 15, w: 30.5, h: 25 },
|
||||
"img-deadbeef00": { x: 50, y: 50, w: 40, h: 40 },
|
||||
},
|
||||
};
|
||||
const next = applyPersistedNonFrameOverrides(sel, persisted);
|
||||
expect(next.overrides.image_overrides).toEqual({
|
||||
"img-abc1234567": { x: 10, y: 15, w: 30.5, h: 25 },
|
||||
"img-deadbeef00": { x: 50, y: 50, w: 40, h: 40 },
|
||||
});
|
||||
// Untouched axes stay at their fixture defaults so the round-trip is
|
||||
// safe to interleave with the other four axes.
|
||||
expect(next.overrides.zone_geometries).toEqual({});
|
||||
expect(next.overrides.zone_sections).toEqual({});
|
||||
expect(next.overrides.layout_preset).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores image_overrides when the payload axis is an array", () => {
|
||||
const sel = makeSelection({
|
||||
image_overrides: { "img-existing00": { x: 1, y: 2, w: 30, h: 40 } },
|
||||
});
|
||||
const next = applyPersistedNonFrameOverrides(sel, {
|
||||
image_overrides: [] as unknown as Record<
|
||||
string,
|
||||
{ x: number; y: number; w: number; h: number }
|
||||
>,
|
||||
});
|
||||
// Same guard the zone_geometries branch uses — array payloads from a
|
||||
// hand-edited file are rejected and the prior in-memory value stays.
|
||||
expect(next.overrides.image_overrides).toEqual({
|
||||
"img-existing00": { x: 1, y: 2, w: 30, h: 40 },
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores image_overrides when the payload axis is null", () => {
|
||||
const sel = makeSelection({
|
||||
image_overrides: { "img-existing00": { x: 0, y: 0, w: 100, h: 100 } },
|
||||
});
|
||||
const next = applyPersistedNonFrameOverrides(sel, {
|
||||
image_overrides: null,
|
||||
});
|
||||
expect(next.overrides.image_overrides).toEqual({
|
||||
"img-existing00": { x: 0, y: 0, w: 100, h: 100 },
|
||||
});
|
||||
});
|
||||
|
||||
it("layers image_overrides alongside the four IMP-52 axes in one call", () => {
|
||||
const sel = makeSelection();
|
||||
const next = applyPersistedNonFrameOverrides(sel, {
|
||||
layout: "horizontal-2",
|
||||
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.4 } },
|
||||
zone_sections: { top: ["03-1"] },
|
||||
image_overrides: { "img-abc1234567": { x: 25, y: 25, w: 50, h: 50 } },
|
||||
});
|
||||
expect(next.overrides.layout_preset).toBe("horizontal-2");
|
||||
expect(next.overrides.zone_geometries).toEqual({
|
||||
top: { x: 0, y: 0, w: 1, h: 0.4 },
|
||||
});
|
||||
expect(next.overrides.zone_sections).toEqual({ top: ["03-1"] });
|
||||
expect(next.overrides.image_overrides).toEqual({
|
||||
"img-abc1234567": { x: 25, y: 25, w: 50, h: 50 },
|
||||
});
|
||||
});
|
||||
|
||||
it("seeds an empty image_overrides on a fresh selection (createInitialUserSelection)", () => {
|
||||
const sel = createInitialUserSelection();
|
||||
expect(sel.overrides.image_overrides).toEqual({});
|
||||
// Mirrors the shape Home.tsx receives before any user interaction —
|
||||
// SlideCanvas u8 expects the axis to exist (not undefined) so its
|
||||
// `Object.entries(measured + persisted)` merge never crashes.
|
||||
});
|
||||
});
|
||||
|
||||
describe("image_overrides axis — saveImageOverride (IMP-51 u11)", () => {
|
||||
const ID_A = "img-abc1234567";
|
||||
const ID_B = "img-deadbeef00";
|
||||
|
||||
it("adds a new image_id entry on an empty axis", () => {
|
||||
const sel = makeSelection();
|
||||
const next = saveImageOverride(sel, ID_A, { x: 10, y: 15, w: 30.5, h: 25 });
|
||||
expect(next.overrides.image_overrides).toEqual({
|
||||
[ID_A]: { x: 10, y: 15, w: 30.5, h: 25 },
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces an existing entry under the same image_id (most recent drag wins)", () => {
|
||||
const sel = makeSelection({
|
||||
image_overrides: { [ID_A]: { x: 0, y: 0, w: 20, h: 20 } },
|
||||
});
|
||||
const next = saveImageOverride(sel, ID_A, { x: 50, y: 50, w: 30, h: 30 });
|
||||
expect(next.overrides.image_overrides).toEqual({
|
||||
[ID_A]: { x: 50, y: 50, w: 30, h: 30 },
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves sibling image_id entries when adding a new one", () => {
|
||||
const sel = makeSelection({
|
||||
image_overrides: { [ID_A]: { x: 10, y: 10, w: 20, h: 20 } },
|
||||
});
|
||||
const next = saveImageOverride(sel, ID_B, { x: 60, y: 60, w: 30, h: 30 });
|
||||
expect(next.overrides.image_overrides).toEqual({
|
||||
[ID_A]: { x: 10, y: 10, w: 20, h: 20 },
|
||||
[ID_B]: { x: 60, y: 60, w: 30, h: 30 },
|
||||
});
|
||||
});
|
||||
|
||||
it("does NOT touch the other four override axes", () => {
|
||||
const sel = makeSelection({
|
||||
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } },
|
||||
zone_sections: { top: ["03-1"] },
|
||||
zone_frames: { "r-top": "tpl-a" },
|
||||
layout_preset: "horizontal-2",
|
||||
});
|
||||
const next = saveImageOverride(sel, ID_A, { x: 10, y: 10, w: 20, h: 20 });
|
||||
expect(next.overrides.zone_geometries).toEqual({
|
||||
top: { x: 0, y: 0, w: 1, h: 0.5 },
|
||||
});
|
||||
expect(next.overrides.zone_sections).toEqual({ top: ["03-1"] });
|
||||
expect(next.overrides.zone_frames).toEqual({ "r-top": "tpl-a" });
|
||||
expect(next.overrides.layout_preset).toBe("horizontal-2");
|
||||
});
|
||||
|
||||
it("returns a NEW selection object (no input mutation)", () => {
|
||||
const sel = makeSelection({
|
||||
image_overrides: { [ID_A]: { x: 0, y: 0, w: 10, h: 10 } },
|
||||
});
|
||||
const before = { ...sel.overrides.image_overrides };
|
||||
const next = saveImageOverride(sel, ID_B, { x: 30, y: 30, w: 20, h: 20 });
|
||||
expect(next).not.toBe(sel);
|
||||
expect(next.overrides).not.toBe(sel.overrides);
|
||||
expect(next.overrides.image_overrides).not.toBe(sel.overrides.image_overrides);
|
||||
// Input still pristine.
|
||||
expect(sel.overrides.image_overrides).toEqual(before);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -480,6 +480,82 @@ describe("UserOverridesPartial type (IMP-52 u5)", () => {
|
||||
const b: UserOverridesPartial = { layout: null };
|
||||
const c: UserOverridesPartial = { frames: { unit: "tmpl" } };
|
||||
const d: UserOverridesPartial = {};
|
||||
expect([a, b, c, d]).toHaveLength(4);
|
||||
const e: UserOverridesPartial = {
|
||||
image_overrides: { "img-1": { x: 10, y: 20, w: 30, h: 25 } },
|
||||
};
|
||||
const f: UserOverridesPartial = { image_overrides: null };
|
||||
expect([a, b, c, d, e, f]).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// IMP-51 #79 u3 — image_overrides axis (5th axis) parity coverage
|
||||
//
|
||||
// Same debounce / coalescing / clear / per-key isolation guarantees as the
|
||||
// 4 sibling axes (layout / frames / zone_geometries / zone_sections), but
|
||||
// asserted explicitly so a regression in the type or the runtime allowlist
|
||||
// fails here instead of in a downstream u8~u11 handler.
|
||||
// ============================================================================
|
||||
|
||||
describe("saveUserOverrides (IMP-51 #79 u3) — image_overrides axis", () => {
|
||||
it("PUT body carries only image_overrides when that is the sole mutated axis", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03", {
|
||||
image_overrides: { "img-1": { x: 10, y: 20, w: 30, h: 25 } },
|
||||
});
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
|
||||
const body = lastPutBody() as Record<string, unknown>;
|
||||
expect(Object.keys(body)).toEqual(["image_overrides"]);
|
||||
expect(body.image_overrides).toEqual({
|
||||
"img-1": { x: 10, y: 20, w: 30, h: 25 },
|
||||
});
|
||||
expect("layout" in body).toBe(false);
|
||||
expect("frames" in body).toBe(false);
|
||||
expect("zone_geometries" in body).toBe(false);
|
||||
expect("zone_sections" in body).toBe(false);
|
||||
});
|
||||
|
||||
it("per-axis later-wins: same image_id mutated twice keeps the LAST value", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03", {
|
||||
image_overrides: { "img-1": { x: 0, y: 0, w: 50, h: 50 } },
|
||||
});
|
||||
void saveUserOverrides("03", {
|
||||
image_overrides: { "img-1": { x: 25, y: 25, w: 30, h: 30 } },
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
expect(putCallsCount()).toBe(1);
|
||||
expect(lastPutBody()).toEqual({
|
||||
image_overrides: { "img-1": { x: 25, y: 25, w: 30, h: 30 } },
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards null sentinel verbatim (clear all image_overrides on disk)", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03", { image_overrides: null });
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
expect(lastPutBody()).toEqual({ image_overrides: null });
|
||||
});
|
||||
|
||||
it("coalesces with sibling axes in a single PUT", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03", { layout: "two_zone_split" });
|
||||
void saveUserOverrides("03", {
|
||||
image_overrides: { "img-1": { x: 10, y: 20, w: 30, h: 25 } },
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
expect(putCallsCount()).toBe(1);
|
||||
expect(lastPutBody()).toEqual({
|
||||
layout: "two_zone_split",
|
||||
image_overrides: { "img-1": { x: 10, y: 20, w: 30, h: 25 } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -219,20 +219,23 @@ function vitePluginStorageProxy(): Plugin {
|
||||
|
||||
export const USER_OVERRIDES_KEY_RE = /^[A-Za-z0-9_][A-Za-z0-9_.\-]*$/;
|
||||
|
||||
// The four in-scope axes — exact mirror of KNOWN_AXES in
|
||||
// The five in-scope axes — exact mirror of KNOWN_AXES in
|
||||
// src/user_overrides_io.py. 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).
|
||||
export const KNOWN_USER_OVERRIDES_AXES = [
|
||||
"layout",
|
||||
"zone_geometries",
|
||||
"zone_sections",
|
||||
"frames",
|
||||
"image_overrides",
|
||||
] as const;
|
||||
export type KnownUserOverridesAxis = (typeof KNOWN_USER_OVERRIDES_AXES)[number];
|
||||
|
||||
// 1MB cap on PUT bodies. Override files in practice are < 10KB (4 axes,
|
||||
// 1MB cap on PUT bodies. Override files in practice are < 10KB (5 axes,
|
||||
// each a small dict). The cap is a safety net against runaway client
|
||||
// loops, not a real schema constraint.
|
||||
const USER_OVERRIDES_PUT_MAX_BYTES = 1_000_000;
|
||||
@@ -319,9 +322,9 @@ export function handleGetUserOverrides(
|
||||
// IMP-52 u4 — pure merge function. Mirrors src/user_overrides_io.save():
|
||||
// • Only KNOWN_USER_OVERRIDES_AXES present in `partial` are mutated.
|
||||
// • Axes absent from `partial` are preserved verbatim from `existing`.
|
||||
// • Foreign top-level keys in `existing` (future axes like zone_sizes
|
||||
// or image_overrides) are preserved verbatim — allowlist guards what
|
||||
// the PUT writes, NOT what the file already holds.
|
||||
// • Foreign top-level keys in `existing` (future axes like zone_sizes)
|
||||
// are preserved verbatim — allowlist guards what the PUT writes, NOT
|
||||
// what the file already holds.
|
||||
// • `partial[axis] = null` is the explicit clear sentinel (remove key).
|
||||
// • Any non-axis keys in `partial` are silently dropped (allowlist).
|
||||
export function mergeUserOverrides(
|
||||
|
||||
264
src/image_id_stamper.py
Normal file
264
src/image_id_stamper.py
Normal file
@@ -0,0 +1,264 @@
|
||||
"""IMP-51 (#79) u4 — user-content image stamper for Phase Z final.html.
|
||||
|
||||
Annotates user-content ``<img>`` elements with a stable id + role
|
||||
attribute so the frontend SlideCanvas (u8~u11) can attach drag/resize
|
||||
handles and the backend CSS injector (u7) can re-apply persisted geometry
|
||||
on the next render.
|
||||
|
||||
DOM selector contract (single point of truth shared across the axis) :
|
||||
|
||||
.slide img[data-image-role="user-content"]
|
||||
|
||||
This selector is mirrored verbatim in :
|
||||
|
||||
- ``Front/client/src/components/SlideCanvas.tsx`` (u8 handle attach target)
|
||||
- ``Front/client/src/services/userOverridesApi.ts`` (u3 doc reference)
|
||||
- ``src/phase_z2_pipeline.py`` u7 hook (CSS injector — pending unit)
|
||||
|
||||
Decorative imgs (frame backgrounds, figma assets, dx-figures, decorative
|
||||
icons) are NOT stamped, so they are NOT matched by the selector and remain
|
||||
unaffected. The allowlist that decides "what counts as user-content" is
|
||||
passed in by the caller (typically ``stage0_normalized_assets["images"]``);
|
||||
this module does not encode the source-of-truth itself.
|
||||
|
||||
Stable id contract :
|
||||
|
||||
image_id = "img-" + sha1(src)[:10]
|
||||
|
||||
Deterministic across renders so persisted ``image_overrides`` entries
|
||||
(keyed on ``image_id`` per ``src/user_overrides_io.py`` u1) re-apply
|
||||
automatically. Duplicate srcs in the same slide get an ordinal suffix
|
||||
("-1", "-2", ...) appended in DOM order; the first occurrence has no
|
||||
suffix.
|
||||
|
||||
Forward-compat : current Phase Z final.html emits zero user-content
|
||||
``<img>`` elements (``stage0_normalized_assets["images"]`` is empty across
|
||||
all recent verify runs). ``stamp_user_content_images(html, sources=())``
|
||||
is a pure no-op in that case — returns ``(html, [])`` without scanning.
|
||||
|
||||
Guardrails :
|
||||
|
||||
- No-hardcoding : the allowlist is caller-supplied, never inferred from
|
||||
sample filenames or path heuristics.
|
||||
- Idempotent : stamping a previously-stamped tag is a no-op (the
|
||||
``data-image-role`` probe short-circuits before re-injecting).
|
||||
- AI-isolation : this module is 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
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from typing import Iterable
|
||||
|
||||
USER_CONTENT_IMAGE_SELECTOR: str = '.slide img[data-image-role="user-content"]'
|
||||
|
||||
IMAGE_ROLE_ATTR: str = "data-image-role"
|
||||
IMAGE_ROLE_VALUE: str = "user-content"
|
||||
IMAGE_ID_ATTR: str = "data-image-id"
|
||||
|
||||
# Matches a single ``<img ...>`` tag. Permissive on attribute order and
|
||||
# whitespace; captures the inner attribute string + an optional XHTML
|
||||
# self-close slash. Phase Z renders well-formed Jinja2 output (no inline
|
||||
# ``<`` in attribute values), so a regex is safe here without pulling in
|
||||
# an HTML parser.
|
||||
_IMG_TAG_RE = re.compile(
|
||||
r"<img\b([^>]*?)(/?)>",
|
||||
flags=re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
# Matches the ``src="..."`` or ``src='...'`` attribute. Group 1 = double,
|
||||
# group 2 = single. Quote style is preserved by callers that re-emit the
|
||||
# tag verbatim.
|
||||
_SRC_ATTR_RE = re.compile(
|
||||
r"""\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)')""",
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
# Probe for an existing ``data-image-role`` attribute (any value, any
|
||||
# quote) so re-stamping is idempotent.
|
||||
_ROLE_ATTR_RE = re.compile(r"""\bdata-image-role\s*=""", flags=re.IGNORECASE)
|
||||
|
||||
|
||||
def stable_image_id(src: str, ordinal: int = 0) -> str:
|
||||
"""Return the deterministic ``image_id`` for ``src``.
|
||||
|
||||
``ordinal`` disambiguates repeated occurrences of the same ``src`` in
|
||||
the same slide (0 = first occurrence, no suffix; 1 → ``-1``; ...).
|
||||
"""
|
||||
if not isinstance(src, str):
|
||||
raise TypeError(f"src must be a string, got {type(src).__name__}: {src!r}")
|
||||
if ordinal < 0:
|
||||
raise ValueError(f"ordinal must be >= 0, got {ordinal}")
|
||||
digest = hashlib.sha1(src.encode("utf-8")).hexdigest()[:10]
|
||||
base = f"img-{digest}"
|
||||
return base if ordinal == 0 else f"{base}-{ordinal}"
|
||||
|
||||
|
||||
def stamp_user_content_images(
|
||||
html: str,
|
||||
sources: Iterable[str] = (),
|
||||
) -> tuple[str, list[str]]:
|
||||
"""Stamp user-content ``<img>`` tags in ``html`` with role + stable id.
|
||||
|
||||
``sources`` is the allowlist of ``src`` attribute values that count as
|
||||
user-content (typically ``stage0_normalized_assets["images"]``). Any
|
||||
``<img>`` whose ``src`` value is in ``sources`` is rewritten to include
|
||||
``data-image-role="user-content"`` and ``data-image-id="<stable_id>"``.
|
||||
Other ``<img>`` tags (decorative, figma, frame-internal) are left
|
||||
unchanged byte-for-byte.
|
||||
|
||||
Returns ``(modified_html, stamped_image_ids)`` where the id list is
|
||||
in DOM (left-to-right) order. The list may contain duplicates only
|
||||
via the ordinal-suffix path (``img-<hash>``, ``img-<hash>-1``, ...);
|
||||
ordering is what the caller persists as the canonical key sequence.
|
||||
|
||||
Forward-compat : empty / all-non-string ``sources`` → pure no-op
|
||||
(``html`` returned unchanged, empty list). This is the current Phase
|
||||
Z state since ``stage0_normalized_assets["images"]`` is empty.
|
||||
"""
|
||||
allow = {s for s in sources if isinstance(s, str) and s}
|
||||
if not allow:
|
||||
return html, []
|
||||
|
||||
stamped: list[str] = []
|
||||
seen_ordinal: dict[str, int] = {}
|
||||
|
||||
def _replace(match: re.Match[str]) -> str:
|
||||
attrs = match.group(1) or ""
|
||||
self_close = match.group(2) or ""
|
||||
src_match = _SRC_ATTR_RE.search(attrs)
|
||||
if src_match is None:
|
||||
return match.group(0)
|
||||
src = src_match.group(1) if src_match.group(1) is not None else src_match.group(2)
|
||||
if src not in allow:
|
||||
return match.group(0)
|
||||
if _ROLE_ATTR_RE.search(attrs):
|
||||
return match.group(0)
|
||||
ordinal = seen_ordinal.get(src, 0)
|
||||
seen_ordinal[src] = ordinal + 1
|
||||
image_id = stable_image_id(src, ordinal=ordinal)
|
||||
stamped.append(image_id)
|
||||
injected = (
|
||||
f' {IMAGE_ROLE_ATTR}="{IMAGE_ROLE_VALUE}"'
|
||||
f' {IMAGE_ID_ATTR}="{image_id}"'
|
||||
)
|
||||
return f"<img{injected}{attrs}{self_close}>"
|
||||
|
||||
new_html = _IMG_TAG_RE.sub(_replace, html)
|
||||
return new_html, stamped
|
||||
|
||||
|
||||
# ─── IMP-51 (#79) u7 — render-time CSS injection ──────────────────────────
|
||||
|
||||
# Marker comments wrap the injected ``<style>`` block so re-injection on a
|
||||
# previously-injected document is idempotent (the wrapper is found by a
|
||||
# simple substring probe and the inner CSS is replaced in place).
|
||||
_IMP51_STYLE_MARKER_OPEN: str = "<!-- IMP-51 image_overrides start -->"
|
||||
_IMP51_STYLE_MARKER_CLOSE: str = "<!-- IMP-51 image_overrides end -->"
|
||||
|
||||
_IMP51_STYLE_BLOCK_RE = re.compile(
|
||||
re.escape(_IMP51_STYLE_MARKER_OPEN) + r".*?" + re.escape(_IMP51_STYLE_MARKER_CLOSE),
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
_HEAD_CLOSE_RE = re.compile(r"</head\s*>", flags=re.IGNORECASE)
|
||||
_BODY_OPEN_RE = re.compile(r"<body\b[^>]*>", flags=re.IGNORECASE)
|
||||
|
||||
|
||||
def build_image_overrides_style(
|
||||
image_overrides: dict,
|
||||
stamped_ids: Iterable[str],
|
||||
) -> str:
|
||||
"""Build CSS rule text for persisted ``image_overrides``.
|
||||
|
||||
For every ``image_id`` that appears in BOTH ``stamped_ids`` (the DOM
|
||||
order of stamps returned by :func:`stamp_user_content_images`) AND
|
||||
``image_overrides`` (the persisted geometry mapping from ``u1``
|
||||
``user_overrides_io``), emit one absolute-position rule of the form ::
|
||||
|
||||
.slide img[data-image-role="user-content"][data-image-id="<id>"] {
|
||||
position: absolute;
|
||||
left: <x>%; top: <y>%;
|
||||
width: <w>%; height: <h>%;
|
||||
}
|
||||
|
||||
Coordinates are ``%`` of the slide bounding box (slide-absolute, per
|
||||
Stage 2 scope-lock). ``.slide`` already declares ``position: relative``
|
||||
in ``templates/phase_z2/slide_base.html`` so the absolute coordinates
|
||||
resolve against the slide frame.
|
||||
|
||||
Rules are emitted in ``stamped_ids`` order so the output is
|
||||
byte-deterministic across renders (critical for diff-based verifiers).
|
||||
Override entries for ids NOT in ``stamped_ids`` are silently dropped —
|
||||
those keys cannot be produced via the SlideCanvas pathway (the
|
||||
frontend only knows the ids actually present in the DOM). Per-entry
|
||||
malformed geometries (non-dict / missing axis / non-coercible value)
|
||||
are dropped silently; the whole batch is never rejected.
|
||||
|
||||
Returns ``""`` when no rules are emitted so the caller can skip
|
||||
``<style>`` injection entirely (forward-compat no-op when Phase Z
|
||||
final.html still emits zero user-content imgs).
|
||||
"""
|
||||
if not image_overrides:
|
||||
return ""
|
||||
rules: list[str] = []
|
||||
for iid in stamped_ids:
|
||||
geom = image_overrides.get(iid)
|
||||
if not isinstance(geom, dict):
|
||||
continue
|
||||
try:
|
||||
x = float(geom["x"])
|
||||
y = float(geom["y"])
|
||||
w = float(geom["w"])
|
||||
h = float(geom["h"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
rules.append(
|
||||
f'.slide img[{IMAGE_ROLE_ATTR}="{IMAGE_ROLE_VALUE}"]'
|
||||
f'[{IMAGE_ID_ATTR}="{iid}"] {{ '
|
||||
f"position: absolute; "
|
||||
f"left: {x}%; top: {y}%; "
|
||||
f"width: {w}%; height: {h}%; "
|
||||
f"}}"
|
||||
)
|
||||
return "\n".join(rules)
|
||||
|
||||
|
||||
def inject_image_overrides_style(html: str, css: str) -> str:
|
||||
"""Inject a marker-wrapped ``<style>`` block carrying ``css`` into ``html``.
|
||||
|
||||
Empty ``css`` → ``html`` returned unchanged (no DOM mutation). This
|
||||
preserves the byte-for-byte identity of forward-compat renders where
|
||||
no overrides apply.
|
||||
|
||||
When a previously-injected marker block is present, its inner CSS is
|
||||
replaced in place (idempotent re-injection — second call with the
|
||||
same overrides produces an identical document).
|
||||
|
||||
Injection precedence when no existing marker is found :
|
||||
|
||||
1. Before the first ``</head>`` (case-insensitive)
|
||||
2. Immediately after the first ``<body ...>`` open tag
|
||||
3. At the start of the document
|
||||
|
||||
Phase Z ``slide_base.html`` always emits ``</head>`` so path 1 wins
|
||||
for production renders; paths 2/3 are defensive fallbacks for
|
||||
unusual fragment inputs (tests, partials).
|
||||
"""
|
||||
if not css:
|
||||
return html
|
||||
block = (
|
||||
f"{_IMP51_STYLE_MARKER_OPEN}\n"
|
||||
f"<style>\n{css}\n</style>\n"
|
||||
f"{_IMP51_STYLE_MARKER_CLOSE}"
|
||||
)
|
||||
if _IMP51_STYLE_MARKER_OPEN in html:
|
||||
return _IMP51_STYLE_BLOCK_RE.sub(lambda _m: block, html, count=1)
|
||||
head_close = _HEAD_CLOSE_RE.search(html)
|
||||
if head_close is not None:
|
||||
idx = head_close.start()
|
||||
return html[:idx] + block + "\n" + html[idx:]
|
||||
body_open = _BODY_OPEN_RE.search(html)
|
||||
if body_open is not None:
|
||||
idx = body_open.end()
|
||||
return html[:idx] + "\n" + block + html[idx:]
|
||||
return block + "\n" + html
|
||||
@@ -3406,6 +3406,7 @@ def run_phase_z2_mvp1(
|
||||
override_frames: Optional[dict[str, str]] = None,
|
||||
override_zone_geometries: Optional[dict[str, dict]] = None,
|
||||
override_section_assignments: Optional[dict[str, list[str]]] = None,
|
||||
override_image_overrides: Optional[dict[str, dict]] = None,
|
||||
) -> Path:
|
||||
"""MVP-1.5b entry — single slide + composition planner v0 + 8 preset vocabulary.
|
||||
|
||||
@@ -3419,6 +3420,15 @@ def run_phase_z2_mvp1(
|
||||
으로 강제. unit_id = "+".join(source_section_ids) (e.g., "03-1"
|
||||
또는 "03-1+03-2"). 매칭 unit 의 v4_candidates 에 있는 entry 면
|
||||
그 entry 의 score / label 도 함께 갱신. 없으면 template_id 만 변경.
|
||||
override_image_overrides : {image_id: {x, y, w, h}} — IMP-51 (#79) u5 axis.
|
||||
image_id = stable id stamped on user-content `<img>` tags by
|
||||
``src/image_id_stamper.py`` (u4). x/y/w/h are percent-of-slide
|
||||
coordinates (0–100, slide-absolute). Forward-compat kwarg: the
|
||||
render-time CSS injection that consumes this mapping lands in
|
||||
u7; until u7 wires the consumer, accepting the kwarg keeps the
|
||||
backend contract (KNOWN_AXES u1 + Vite allowlist u2 + typed
|
||||
client u3 + stamper u4) end-to-end addressable from CLI without
|
||||
diverging the function signature.
|
||||
"""
|
||||
mdx_path = Path(mdx_path)
|
||||
if run_id is None:
|
||||
@@ -5375,6 +5385,36 @@ def run_phase_z2_mvp1(
|
||||
# 7. Render single slide
|
||||
html = render_slide(slide_title, slide_footer, zones_data, layout_preset, layout_css)
|
||||
|
||||
# IMP-51 (#79) u4 + u7 — stamp user-content imgs with stable id /
|
||||
# role attrs, then inject persisted `image_overrides` CSS so the
|
||||
# next render re-applies the user-edited geometry.
|
||||
#
|
||||
# Forward-compat: `stage0_normalized_assets["images"]` is empty in
|
||||
# every current Phase Z run (Q1 = A confirmed at Stage 1), so the
|
||||
# stamper returns an empty `stamped_image_ids` list and the CSS
|
||||
# builder short-circuits to "". The HTML is therefore byte-for-byte
|
||||
# identical to the pre-IMP-51 output until Phase Z starts emitting
|
||||
# user-content imgs (separate axis, out of scope for #79).
|
||||
from src.image_id_stamper import (
|
||||
build_image_overrides_style,
|
||||
inject_image_overrides_style,
|
||||
stamp_user_content_images,
|
||||
)
|
||||
_user_content_image_srcs = [
|
||||
(entry.get("path") or entry.get("src") or "")
|
||||
for entry in (stage0_normalized_assets.get("images") or [])
|
||||
if isinstance(entry, dict)
|
||||
]
|
||||
html, _stamped_image_ids = stamp_user_content_images(
|
||||
html, sources=_user_content_image_srcs,
|
||||
)
|
||||
if override_image_overrides:
|
||||
_image_overrides_css = build_image_overrides_style(
|
||||
override_image_overrides, _stamped_image_ids,
|
||||
)
|
||||
if _image_overrides_css:
|
||||
html = inject_image_overrides_style(html, _image_overrides_css)
|
||||
|
||||
# 8. Write final.html
|
||||
out_path = run_dir / "final.html"
|
||||
out_path.write_text(html, encoding="utf-8")
|
||||
@@ -5844,6 +5884,27 @@ if __name__ == "__main__":
|
||||
"--override-section-assignment bottom=03-2,03-3"
|
||||
),
|
||||
)
|
||||
# IMP-51 (#79) u5 — image override CLI flag. IMAGE_ID = stable id stamped
|
||||
# on user-content `<img>` tags by src/image_id_stamper.py (u4). X,Y,W,H =
|
||||
# percent-of-slide coordinates (0–100, slide-absolute), consistent with
|
||||
# the typed client `ImageOverride` shape (u3, userOverridesApi.ts) and
|
||||
# the persisted `image_overrides` axis (u1, KNOWN_AXES). The render-time
|
||||
# CSS injection consuming this mapping lands in u7; u5 is the CLI surface.
|
||||
parser.add_argument(
|
||||
"--override-image",
|
||||
dest="override_image_overrides",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="IMAGE_ID=X,Y,W,H",
|
||||
help=(
|
||||
"user-content image 의 slide-absolute geometry 강제. IMAGE_ID = "
|
||||
"src/image_id_stamper.py 가 stamp 한 `data-image-id` value "
|
||||
"(e.g., img-1a2b3c4d5e). X,Y,W,H = percent-of-slide (0–100, "
|
||||
"slide-absolute) — typed client ImageOverride shape 와 일치. "
|
||||
"multiple flags: --override-image img-abc=10,15,30,25 "
|
||||
"--override-image img-def=50,15,40,40"
|
||||
),
|
||||
)
|
||||
# IMP-46 u5 — auto-cache opt-in. When set, ``cache.save_proposal``
|
||||
# bypasses the ``user_approved`` gate only (``visual_check_passed``
|
||||
# is never bypassable). Source of truth is
|
||||
@@ -5943,6 +6004,54 @@ if __name__ == "__main__":
|
||||
_seen_sections_across_zones[sid] = zid
|
||||
overrides_section_assignments[zid] = section_ids
|
||||
|
||||
# IMP-51 (#79) u5 — parse --override-image into dict[str, dict[str, float]].
|
||||
# Mirrors --override-zone-geometry parsing pattern: each flag is
|
||||
# IMAGE_ID=X,Y,W,H with 4 floats; multiple flags accumulate. Hard errors
|
||||
# on missing `=` / wrong float count / non-numeric values / empty IMAGE_ID
|
||||
# / duplicate IMAGE_ID. The on-disk schema (u1 KNOWN_AXES) and typed
|
||||
# client (u3 ImageOverride) both expect percent-of-slide values in
|
||||
# 0–100; the CLI accepts floats without range clamping here so the
|
||||
# error remains the user's mistake to read rather than a silent shift.
|
||||
overrides_images: dict[str, dict[str, float]] = {}
|
||||
for ov in args.override_image_overrides:
|
||||
if "=" not in ov:
|
||||
print(
|
||||
f"[error] --override-image must be IMAGE_ID=X,Y,W,H, got: '{ov}'",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
iid, vals = ov.split("=", 1)
|
||||
iid = iid.strip()
|
||||
if not iid:
|
||||
print(
|
||||
f"[error] --override-image IMAGE_ID must be non-empty, got: '{ov}'",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
if iid in overrides_images:
|
||||
print(
|
||||
f"[error] --override-image duplicate IMAGE_ID '{iid}' "
|
||||
f"(first assignment kept). Provide each image only once.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
parts = vals.split(",")
|
||||
if len(parts) != 4:
|
||||
print(
|
||||
f"[error] --override-image expects 4 floats X,Y,W,H, got: '{vals}'",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
try:
|
||||
x, y, w, h = (float(p) for p in parts)
|
||||
except ValueError:
|
||||
print(
|
||||
f"[error] --override-image floats parse fail: '{vals}'",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
overrides_images[iid] = {"x": x, "y": y, "w": w, "h": h}
|
||||
|
||||
# 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
|
||||
@@ -6013,6 +6122,30 @@ if __name__ == "__main__":
|
||||
if _sids:
|
||||
_accepted_sec[_zid] = _sids
|
||||
overrides_section_assignments = _accepted_sec
|
||||
# image_overrides — CLI empty → fill from file (dict[str, dict]).
|
||||
# IMP-51 (#79) u6 — mirrors zone_geometries validation: only accept
|
||||
# mappings of {image_id: {x,y,w,h}} with float-coercible values.
|
||||
if not overrides_images:
|
||||
_file_images = _persisted.get("image_overrides")
|
||||
if isinstance(_file_images, dict):
|
||||
_accepted_img: dict[str, dict] = {}
|
||||
for _iid, _g in _file_images.items():
|
||||
if (
|
||||
isinstance(_iid, str)
|
||||
and _iid
|
||||
and isinstance(_g, dict)
|
||||
and all(k in _g for k in ("x", "y", "w", "h"))
|
||||
):
|
||||
try:
|
||||
_accepted_img[_iid] = {
|
||||
"x": float(_g["x"]),
|
||||
"y": float(_g["y"]),
|
||||
"w": float(_g["w"]),
|
||||
"h": float(_g["h"]),
|
||||
}
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
overrides_images = _accepted_img
|
||||
|
||||
run_phase_z2_mvp1(
|
||||
args.mdx_path,
|
||||
@@ -6021,4 +6154,5 @@ if __name__ == "__main__":
|
||||
override_frames=overrides_frames or None,
|
||||
override_zone_geometries=overrides_geoms or None,
|
||||
override_section_assignments=overrides_section_assignments or None,
|
||||
override_image_overrides=overrides_images or None,
|
||||
)
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
"""IMP-52 (#80) u1 — user_overrides.json persistence layer (backend IO).
|
||||
|
||||
Persists the four CLI-wired override axes per MDX so a subsequent render
|
||||
Persists the CLI-wired override axes per MDX so a subsequent render
|
||||
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 (4 axes; stable order):
|
||||
Schema (5 axes; stable order; IMP-51 #79 u1 added ``image_overrides``):
|
||||
|
||||
{
|
||||
"layout": <string|null>,
|
||||
"zone_geometries": {<zone_id>: {"x": float, "y": float, "w": float, "h": float}},
|
||||
"zone_sections": {<zone_id>: [<section_id>, ...]},
|
||||
"frames": {<unit_id>: <template_id>}
|
||||
"frames": {<unit_id>: <template_id>},
|
||||
"image_overrides": {<image_id>: {"x": float, "y": float, "w": float, "h": float}}
|
||||
}
|
||||
|
||||
``image_id`` is the stable identifier emitted by the user-content image
|
||||
stamper (IMP-51 u4) and matched via the selector
|
||||
``.slide img[data-image-role="user-content"]``. Coordinates are
|
||||
percent-of-slide (zone-agnostic, slide-absolute) to match the SlideCanvas
|
||||
edit-mode handle conventions in IMP-51 u8~u11.
|
||||
|
||||
``unit_id`` is the convention already used by ``--override-frame`` :
|
||||
``"+".join(source_section_ids)`` (e.g., ``"03-1"`` or ``"03-1+03-2"``).
|
||||
|
||||
@@ -46,10 +53,17 @@ from typing import Any, Optional
|
||||
_PKG_ROOT = Path(__file__).resolve().parent.parent
|
||||
DEFAULT_OVERRIDES_ROOT = _PKG_ROOT / "data" / "user_overrides"
|
||||
|
||||
# The four in-scope axes. 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, image_overrides) without a schema bump here.
|
||||
KNOWN_AXES: tuple[str, ...] = ("layout", "zone_geometries", "zone_sections", "frames")
|
||||
# The five in-scope axes (IMP-51 #79 u1 added ``image_overrides``). 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",
|
||||
"zone_sections",
|
||||
"frames",
|
||||
"image_overrides",
|
||||
)
|
||||
|
||||
# Key validation — MDX stem must be safe for filesystem use. Allow
|
||||
# alphanumerics, underscore, hyphen, and dot in the middle (sample stems
|
||||
@@ -92,7 +106,7 @@ def load(key: str, root: Optional[Path] = None) -> dict[str, Any]:
|
||||
|
||||
Missing file → ``{}``. Corrupt JSON → warning to stderr + ``{}``.
|
||||
Returns the raw mapping (including any foreign keys); callers should
|
||||
pick the four KNOWN_AXES they care about.
|
||||
pick the KNOWN_AXES they care about.
|
||||
"""
|
||||
path = override_path(key, root=root)
|
||||
if not path.exists():
|
||||
|
||||
400
tests/test_image_id_stamper.py
Normal file
400
tests/test_image_id_stamper.py
Normal file
@@ -0,0 +1,400 @@
|
||||
"""IMP-51 (#79) u4 — tests for ``src.image_id_stamper``.
|
||||
|
||||
Covers the stamping contract called out in the Stage 2 plan :
|
||||
|
||||
1. ``USER_CONTENT_IMAGE_SELECTOR`` constant matches the canonical string
|
||||
shared with the frontend (u3 typed client, u8 SlideCanvas).
|
||||
2. ``stable_image_id`` is deterministic across calls and across runs
|
||||
(same ``src`` → same id), and the ordinal suffix only appears for
|
||||
occurrences > 0.
|
||||
3. ``stamp_user_content_images`` is a pure no-op when ``sources`` is
|
||||
empty / all-non-string (forward-compat invariant — current Phase Z
|
||||
final.html has zero user-content imgs).
|
||||
4. Allowlisted srcs are stamped; non-allowlisted (decorative) srcs are
|
||||
left byte-for-byte unchanged.
|
||||
5. Idempotent under re-stamping (the role-attr probe short-circuits).
|
||||
6. Duplicate srcs in DOM order get an ordinal suffix.
|
||||
7. Single-quoted ``src`` is recognized.
|
||||
8. Self-closing XHTML ``<img />`` is preserved.
|
||||
9. ``<img>`` tags without a ``src`` attribute are skipped (no crash).
|
||||
|
||||
All tests are pure-Python — no filesystem, no Selenium, no fixtures.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.image_id_stamper import (
|
||||
IMAGE_ID_ATTR,
|
||||
IMAGE_ROLE_ATTR,
|
||||
IMAGE_ROLE_VALUE,
|
||||
USER_CONTENT_IMAGE_SELECTOR,
|
||||
build_image_overrides_style,
|
||||
inject_image_overrides_style,
|
||||
stable_image_id,
|
||||
stamp_user_content_images,
|
||||
)
|
||||
|
||||
|
||||
# -- selector contract ------------------------------------------------------
|
||||
|
||||
|
||||
def test_selector_matches_canonical_string():
|
||||
# MUST stay verbatim in sync with the frontend mirror in
|
||||
# Front/client/src/services/userOverridesApi.ts and the SlideCanvas
|
||||
# query target in u8. Drift here breaks the persisted-override loop.
|
||||
assert USER_CONTENT_IMAGE_SELECTOR == '.slide img[data-image-role="user-content"]'
|
||||
|
||||
|
||||
def test_attribute_constants_match_selector_components():
|
||||
assert IMAGE_ROLE_ATTR == "data-image-role"
|
||||
assert IMAGE_ROLE_VALUE == "user-content"
|
||||
assert IMAGE_ID_ATTR == "data-image-id"
|
||||
assert IMAGE_ROLE_ATTR in USER_CONTENT_IMAGE_SELECTOR
|
||||
assert f'"{IMAGE_ROLE_VALUE}"' in USER_CONTENT_IMAGE_SELECTOR
|
||||
|
||||
|
||||
# -- stable_image_id --------------------------------------------------------
|
||||
|
||||
|
||||
def test_stable_image_id_deterministic_same_src():
|
||||
a = stable_image_id("/uploads/photo.png")
|
||||
b = stable_image_id("/uploads/photo.png")
|
||||
assert a == b
|
||||
assert a.startswith("img-")
|
||||
# sha1[:10] + "img-" prefix → fixed length.
|
||||
assert len(a) == len("img-") + 10
|
||||
|
||||
|
||||
def test_stable_image_id_differs_for_different_src():
|
||||
assert stable_image_id("/a.png") != stable_image_id("/b.png")
|
||||
|
||||
|
||||
def test_stable_image_id_ordinal_zero_has_no_suffix():
|
||||
assert "-" not in stable_image_id("/x.png", ordinal=0)[4:]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ordinal", [1, 2, 7])
|
||||
def test_stable_image_id_ordinal_suffix(ordinal):
|
||||
base = stable_image_id("/x.png", ordinal=0)
|
||||
suffixed = stable_image_id("/x.png", ordinal=ordinal)
|
||||
assert suffixed == f"{base}-{ordinal}"
|
||||
|
||||
|
||||
def test_stable_image_id_rejects_non_string_src():
|
||||
with pytest.raises(TypeError):
|
||||
stable_image_id(None) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_stable_image_id_rejects_negative_ordinal():
|
||||
with pytest.raises(ValueError):
|
||||
stable_image_id("/x.png", ordinal=-1)
|
||||
|
||||
|
||||
# -- stamp_user_content_images : forward-compat no-op -----------------------
|
||||
|
||||
|
||||
def test_stamp_no_sources_is_pure_noop():
|
||||
html = '<div class="slide"><img src="/decorative.png"></div>'
|
||||
out, ids = stamp_user_content_images(html, sources=())
|
||||
assert out == html
|
||||
assert ids == []
|
||||
|
||||
|
||||
def test_stamp_all_non_string_sources_is_noop():
|
||||
html = '<img src="/x.png">'
|
||||
out, ids = stamp_user_content_images(html, sources=[None, 123, ""]) # type: ignore[list-item]
|
||||
assert out == html
|
||||
assert ids == []
|
||||
|
||||
|
||||
def test_stamp_empty_html_is_safe():
|
||||
out, ids = stamp_user_content_images("", sources=["/x.png"])
|
||||
assert out == ""
|
||||
assert ids == []
|
||||
|
||||
|
||||
# -- stamp_user_content_images : allowlist semantics ------------------------
|
||||
|
||||
|
||||
def test_stamp_user_content_src_stamps_role_and_id():
|
||||
html = '<div class="slide"><img src="/u/p.png" alt="photo"></div>'
|
||||
out, ids = stamp_user_content_images(html, sources=["/u/p.png"])
|
||||
expected_id = stable_image_id("/u/p.png")
|
||||
assert ids == [expected_id]
|
||||
assert IMAGE_ROLE_ATTR in out
|
||||
assert f'{IMAGE_ROLE_ATTR}="{IMAGE_ROLE_VALUE}"' in out
|
||||
assert f'{IMAGE_ID_ATTR}="{expected_id}"' in out
|
||||
# Original attribute (alt) preserved.
|
||||
assert 'alt="photo"' in out
|
||||
|
||||
|
||||
def test_stamp_decorative_src_left_unchanged():
|
||||
html = (
|
||||
'<div class="slide">'
|
||||
'<img src="/figma/bg.png">'
|
||||
'<img src="/u/photo.png">'
|
||||
'</div>'
|
||||
)
|
||||
out, ids = stamp_user_content_images(html, sources=["/u/photo.png"])
|
||||
# decorative img untouched (no data-image-role injected on it)
|
||||
assert '<img src="/figma/bg.png">' in out
|
||||
# user-content img stamped
|
||||
assert ids == [stable_image_id("/u/photo.png")]
|
||||
# decorative bg.png must not appear with the role attr
|
||||
assert '/figma/bg.png' in out
|
||||
decorative_segment = out.split('<img', 1)[1].split('>', 1)[0]
|
||||
assert IMAGE_ROLE_ATTR not in decorative_segment
|
||||
|
||||
|
||||
def test_stamp_is_idempotent_on_second_invocation():
|
||||
html = '<div class="slide"><img src="/u/p.png"></div>'
|
||||
once, ids1 = stamp_user_content_images(html, sources=["/u/p.png"])
|
||||
twice, ids2 = stamp_user_content_images(once, sources=["/u/p.png"])
|
||||
assert twice == once
|
||||
assert ids1 == [stable_image_id("/u/p.png")]
|
||||
# second invocation finds the role attr already present → no new id
|
||||
assert ids2 == []
|
||||
|
||||
|
||||
def test_stamp_duplicate_src_gets_ordinal_suffix_in_dom_order():
|
||||
html = (
|
||||
'<div class="slide">'
|
||||
'<img src="/u/dup.png">'
|
||||
'<img src="/u/dup.png">'
|
||||
'<img src="/u/dup.png">'
|
||||
'</div>'
|
||||
)
|
||||
_, ids = stamp_user_content_images(html, sources=["/u/dup.png"])
|
||||
base = stable_image_id("/u/dup.png", ordinal=0)
|
||||
assert ids == [base, f"{base}-1", f"{base}-2"]
|
||||
|
||||
|
||||
def test_stamp_recognizes_single_quoted_src():
|
||||
html = "<div class=\"slide\"><img src='/u/p.png'></div>"
|
||||
out, ids = stamp_user_content_images(html, sources=["/u/p.png"])
|
||||
assert ids == [stable_image_id("/u/p.png")]
|
||||
assert IMAGE_ROLE_ATTR in out
|
||||
|
||||
|
||||
def test_stamp_preserves_self_closing_xhtml_form():
|
||||
html = '<div class="slide"><img src="/u/p.png" /></div>'
|
||||
out, ids = stamp_user_content_images(html, sources=["/u/p.png"])
|
||||
assert ids == [stable_image_id("/u/p.png")]
|
||||
# self-close slash retained
|
||||
assert "/>" in out
|
||||
# role injected before existing attrs
|
||||
assert f'<img {IMAGE_ROLE_ATTR}="{IMAGE_ROLE_VALUE}"' in out
|
||||
|
||||
|
||||
def test_stamp_img_without_src_is_left_unchanged():
|
||||
html = '<div class="slide"><img alt="no src"></div>'
|
||||
out, ids = stamp_user_content_images(html, sources=["/u/p.png"])
|
||||
assert out == html
|
||||
assert ids == []
|
||||
|
||||
|
||||
def test_stamp_returned_ids_persist_across_renders():
|
||||
# Same allowlist + same DOM order → same id sequence on a fresh
|
||||
# render. This is the invariant that lets user_overrides.json keys
|
||||
# re-apply on the next pipeline run without re-clicking.
|
||||
html = '<div class="slide"><img src="/u/a.png"><img src="/u/b.png"></div>'
|
||||
_, ids_first = stamp_user_content_images(html, sources=["/u/a.png", "/u/b.png"])
|
||||
_, ids_second = stamp_user_content_images(html, sources=["/u/a.png", "/u/b.png"])
|
||||
assert ids_first == ids_second
|
||||
assert ids_first == [stable_image_id("/u/a.png"), stable_image_id("/u/b.png")]
|
||||
|
||||
|
||||
# -- build_image_overrides_style : CSS builder (u7) -----------------------
|
||||
|
||||
|
||||
def test_build_style_empty_overrides_returns_empty_string():
|
||||
# Forward-compat invariant — None / {} produces "" so the caller
|
||||
# can short-circuit the <style> injection without DOM mutation.
|
||||
assert build_image_overrides_style({}, []) == ""
|
||||
assert build_image_overrides_style({}, ["img-abc"]) == ""
|
||||
|
||||
|
||||
def test_build_style_no_stamped_ids_returns_empty_string():
|
||||
# Override present but no stamped imgs in the DOM (Q1 = A current
|
||||
# Phase Z state) — no rules emitted.
|
||||
out = build_image_overrides_style({"img-abc": {"x": 1, "y": 2, "w": 3, "h": 4}}, [])
|
||||
assert out == ""
|
||||
|
||||
|
||||
def test_build_style_emits_rule_for_stamped_id_present_in_overrides():
|
||||
iid = stable_image_id("/u/p.png")
|
||||
out = build_image_overrides_style(
|
||||
{iid: {"x": 10, "y": 20, "w": 30.5, "h": 25}},
|
||||
[iid],
|
||||
)
|
||||
assert f'[{IMAGE_ID_ATTR}="{iid}"]' in out
|
||||
assert f'[{IMAGE_ROLE_ATTR}="{IMAGE_ROLE_VALUE}"]' in out
|
||||
assert "position: absolute" in out
|
||||
assert "left: 10" in out and "top: 20" in out
|
||||
assert "width: 30.5" in out and "height: 25" in out
|
||||
|
||||
|
||||
def test_build_style_drops_overrides_for_unstamped_ids():
|
||||
# Override exists for an id that was not stamped on this render
|
||||
# → silently dropped (the SlideCanvas pathway cannot produce
|
||||
# such keys; persisted-but-stale entries must NOT inject CSS).
|
||||
iid_stamped = stable_image_id("/u/here.png")
|
||||
iid_stale = stable_image_id("/u/removed.png")
|
||||
out = build_image_overrides_style(
|
||||
{
|
||||
iid_stamped: {"x": 10, "y": 10, "w": 20, "h": 20},
|
||||
iid_stale: {"x": 90, "y": 90, "w": 5, "h": 5},
|
||||
},
|
||||
[iid_stamped],
|
||||
)
|
||||
assert iid_stamped in out
|
||||
assert iid_stale not in out
|
||||
|
||||
|
||||
def test_build_style_emits_rules_in_stamped_id_order():
|
||||
# Deterministic CSS output across renders — rules sorted by DOM
|
||||
# order (stamped_ids), not by dict insertion order.
|
||||
iid_a = stable_image_id("/u/a.png")
|
||||
iid_b = stable_image_id("/u/b.png")
|
||||
out = build_image_overrides_style(
|
||||
# dict insertion order: b then a
|
||||
{
|
||||
iid_b: {"x": 0, "y": 0, "w": 50, "h": 50},
|
||||
iid_a: {"x": 50, "y": 50, "w": 50, "h": 50},
|
||||
},
|
||||
# stamped order: a then b
|
||||
[iid_a, iid_b],
|
||||
)
|
||||
assert out.index(iid_a) < out.index(iid_b)
|
||||
|
||||
|
||||
def test_build_style_drops_malformed_geometry_entries():
|
||||
iid_valid = stable_image_id("/u/ok.png")
|
||||
iid_missing_axis = stable_image_id("/u/missing.png")
|
||||
iid_non_numeric = stable_image_id("/u/bad.png")
|
||||
iid_non_dict = stable_image_id("/u/list.png")
|
||||
out = build_image_overrides_style(
|
||||
{
|
||||
iid_valid: {"x": 1, "y": 2, "w": 3, "h": 4},
|
||||
iid_missing_axis: {"x": 1, "y": 2, "w": 3}, # no h
|
||||
iid_non_numeric: {"x": "abc", "y": 2, "w": 3, "h": 4},
|
||||
iid_non_dict: [1, 2, 3, 4],
|
||||
},
|
||||
[iid_valid, iid_missing_axis, iid_non_numeric, iid_non_dict],
|
||||
)
|
||||
assert iid_valid in out
|
||||
assert iid_missing_axis not in out
|
||||
assert iid_non_numeric not in out
|
||||
assert iid_non_dict not in out
|
||||
|
||||
|
||||
def test_build_style_coerces_int_geometry_to_float_rules():
|
||||
# JSON-loaded int values round-trip through float(...) so the
|
||||
# emitted CSS uses numeric values acceptable to the browser parser.
|
||||
iid = stable_image_id("/u/p.png")
|
||||
out = build_image_overrides_style({iid: {"x": 1, "y": 2, "w": 3, "h": 4}}, [iid])
|
||||
assert "left: 1.0%" in out
|
||||
assert "top: 2.0%" in out
|
||||
assert "width: 3.0%" in out
|
||||
assert "height: 4.0%" in out
|
||||
|
||||
|
||||
# -- inject_image_overrides_style : <style> block injector (u7) -----------
|
||||
|
||||
|
||||
def test_inject_style_empty_css_returns_html_unchanged():
|
||||
html = "<html><head></head><body>x</body></html>"
|
||||
assert inject_image_overrides_style(html, "") == html
|
||||
|
||||
|
||||
def test_inject_style_inserts_before_head_close():
|
||||
html = "<html><head><title>t</title></head><body>x</body></html>"
|
||||
out = inject_image_overrides_style(html, ".x { color: red; }")
|
||||
assert "<style>" in out
|
||||
# injected block sits before </head>, NOT after <body>
|
||||
head_idx = out.lower().index("</head>")
|
||||
body_idx = out.lower().index("<body")
|
||||
style_idx = out.index("<style>")
|
||||
assert style_idx < head_idx < body_idx
|
||||
|
||||
|
||||
def test_inject_style_case_insensitive_head_close():
|
||||
html = "<HTML><HEAD></HEAD><BODY>x</BODY></HTML>"
|
||||
out = inject_image_overrides_style(html, ".x { color: red; }")
|
||||
assert "<style>" in out
|
||||
# injection before </HEAD>
|
||||
assert out.index("<style>") < out.upper().index("</HEAD>")
|
||||
|
||||
|
||||
def test_inject_style_falls_back_to_body_open_when_no_head():
|
||||
html = "<body>x</body>"
|
||||
out = inject_image_overrides_style(html, ".x { color: red; }")
|
||||
assert "<style>" in out
|
||||
# injected right after the <body> open tag
|
||||
body_open_end = out.index(">", out.index("<body")) + 1
|
||||
assert out[body_open_end:].lstrip().startswith("<!-- IMP-51")
|
||||
|
||||
|
||||
def test_inject_style_falls_back_to_document_start_when_no_head_or_body():
|
||||
html = "<div>fragment</div>"
|
||||
out = inject_image_overrides_style(html, ".x { color: red; }")
|
||||
assert out.startswith("<!-- IMP-51 image_overrides start -->")
|
||||
assert "<style>" in out
|
||||
assert out.rstrip().endswith("</div>")
|
||||
|
||||
|
||||
def test_inject_style_is_idempotent_on_second_call():
|
||||
html = "<html><head></head><body>x</body></html>"
|
||||
css = ".x { color: red; }"
|
||||
once = inject_image_overrides_style(html, css)
|
||||
twice = inject_image_overrides_style(once, css)
|
||||
assert twice == once
|
||||
# Marker block appears exactly once after both invocations.
|
||||
assert once.count("<!-- IMP-51 image_overrides start -->") == 1
|
||||
assert twice.count("<!-- IMP-51 image_overrides start -->") == 1
|
||||
|
||||
|
||||
def test_inject_style_replaces_existing_block_with_new_css():
|
||||
# Re-injection with different CSS replaces the previous block in
|
||||
# place — the marker pair is found and its body is swapped out.
|
||||
html = "<html><head></head><body>x</body></html>"
|
||||
first = inject_image_overrides_style(html, ".old { color: red; }")
|
||||
second = inject_image_overrides_style(first, ".new { color: blue; }")
|
||||
assert ".old" not in second
|
||||
assert ".new" in second
|
||||
assert second.count("<!-- IMP-51 image_overrides start -->") == 1
|
||||
|
||||
|
||||
def test_inject_style_wraps_block_with_marker_comments():
|
||||
html = "<html><head></head><body>x</body></html>"
|
||||
out = inject_image_overrides_style(html, ".x { color: red; }")
|
||||
assert "<!-- IMP-51 image_overrides start -->" in out
|
||||
assert "<!-- IMP-51 image_overrides end -->" in out
|
||||
# Open marker precedes close marker, with the <style> tag between.
|
||||
s = out.index("<!-- IMP-51 image_overrides start -->")
|
||||
e = out.index("<!-- IMP-51 image_overrides end -->")
|
||||
assert s < out.index("<style>") < out.index("</style>") < e
|
||||
|
||||
|
||||
# -- end-to-end stamp → build → inject (u4 + u7 chained) ------------------
|
||||
|
||||
|
||||
def test_stamp_then_build_then_inject_round_trip():
|
||||
html = (
|
||||
"<html><head><title>t</title></head>"
|
||||
'<body><div class="slide"><img src="/u/p.png"></div></body>'
|
||||
"</html>"
|
||||
)
|
||||
stamped_html, ids = stamp_user_content_images(html, sources=["/u/p.png"])
|
||||
assert ids == [stable_image_id("/u/p.png")]
|
||||
css = build_image_overrides_style(
|
||||
{ids[0]: {"x": 12.5, "y": 25, "w": 40, "h": 30}}, ids,
|
||||
)
|
||||
out = inject_image_overrides_style(stamped_html, css)
|
||||
# The stamped attribute is still present on the <img> AND the
|
||||
# injected rule targets that same id.
|
||||
assert f'{IMAGE_ID_ATTR}="{ids[0]}"' in out
|
||||
assert f'[{IMAGE_ID_ATTR}="{ids[0]}"]' in out
|
||||
assert "left: 12.5%" in out
|
||||
assert "<style>" in out
|
||||
348
tests/test_phase_z2_cli_overrides.py
Normal file
348
tests/test_phase_z2_cli_overrides.py
Normal file
@@ -0,0 +1,348 @@
|
||||
"""IMP-51 (#79) u5 — focused tests for the ``--override-image`` CLI surface.
|
||||
|
||||
Stage 2 u5 scope (per the Exit Report):
|
||||
|
||||
- Successful parse: single flag + multiple flags accumulate.
|
||||
- Forwarding: parsed mapping reaches ``run_phase_z2_mvp1`` as
|
||||
``override_image_overrides={image_id: {"x", "y", "w", "h"}}``.
|
||||
- Empty payload: omitting ``--override-image`` forwards ``None``
|
||||
(CLI ``or None`` collapse, sibling pattern to other axes).
|
||||
- Hard-error cases (each must ``sys.exit(2)`` with a stderr message):
|
||||
* missing ``=``
|
||||
* empty ``IMAGE_ID``
|
||||
* duplicate ``IMAGE_ID``
|
||||
* wrong float count (not 4)
|
||||
* non-numeric float component
|
||||
|
||||
The harness mirrors ``tests/test_user_overrides_pipeline_fallback.py`` —
|
||||
the ``if __name__ == "__main__"`` block of ``src.phase_z2_pipeline`` is
|
||||
exec'd inside the module's namespace after monkeypatching
|
||||
``run_phase_z2_mvp1`` with a recording stub. This exercises the actual
|
||||
production parser without invoking the real pipeline.
|
||||
|
||||
The persistence fallback is silenced by redirecting
|
||||
``src.user_overrides_io.DEFAULT_OVERRIDES_ROOT`` to a clean tmp directory
|
||||
so persisted state from prior runs cannot bleed into the parser-only
|
||||
assertions here.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
import src.phase_z2_pipeline as _pz2
|
||||
import src.user_overrides_io as _io
|
||||
|
||||
|
||||
# -- harness ---------------------------------------------------------------
|
||||
|
||||
|
||||
def _exec_main_block(
|
||||
captured: dict[str, Any], argv: list[str], monkeypatch
|
||||
) -> None:
|
||||
"""Run the ``__main__`` body of phase_z2_pipeline.py with a fake
|
||||
``run_phase_z2_mvp1`` so its kwargs are observable."""
|
||||
|
||||
def _fake_run(
|
||||
mdx_path,
|
||||
run_id,
|
||||
*,
|
||||
override_layout=None,
|
||||
override_frames=None,
|
||||
override_zone_geometries=None,
|
||||
override_section_assignments=None,
|
||||
override_image_overrides=None,
|
||||
):
|
||||
captured["mdx_path"] = mdx_path
|
||||
captured["run_id"] = run_id
|
||||
captured["override_layout"] = override_layout
|
||||
captured["override_frames"] = override_frames
|
||||
captured["override_zone_geometries"] = override_zone_geometries
|
||||
captured["override_section_assignments"] = override_section_assignments
|
||||
captured["override_image_overrides"] = override_image_overrides
|
||||
|
||||
monkeypatch.setattr(_pz2, "run_phase_z2_mvp1", _fake_run)
|
||||
monkeypatch.setattr(sys, "argv", argv)
|
||||
|
||||
src_path = Path(_pz2.__file__)
|
||||
source = src_path.read_text(encoding="utf-8")
|
||||
tree = ast.parse(source)
|
||||
for node in tree.body:
|
||||
if (
|
||||
isinstance(node, ast.If)
|
||||
and isinstance(node.test, ast.Compare)
|
||||
and isinstance(node.test.left, ast.Name)
|
||||
and node.test.left.id == "__name__"
|
||||
):
|
||||
block = ast.Module(body=node.body, type_ignores=[])
|
||||
exec(compile(block, str(src_path), "exec"), _pz2.__dict__)
|
||||
return
|
||||
raise AssertionError("no `if __name__ == '__main__'` block found")
|
||||
|
||||
|
||||
def _redirect_overrides_root(tmp_path: Path, monkeypatch) -> None:
|
||||
"""Isolate the persistence fallback so file state never leaks in."""
|
||||
monkeypatch.setattr(_io, "DEFAULT_OVERRIDES_ROOT", tmp_path)
|
||||
|
||||
|
||||
# -- success paths --------------------------------------------------------
|
||||
|
||||
|
||||
def test_no_image_override_forwards_none(tmp_path, monkeypatch):
|
||||
"""When ``--override-image`` is omitted, the kwarg must be ``None``
|
||||
(the parser's accumulator stays empty → ``overrides_images or None``)."""
|
||||
_redirect_overrides_root(tmp_path, monkeypatch)
|
||||
captured: dict[str, Any] = {}
|
||||
_exec_main_block(captured, ["src.phase_z2_pipeline", "03.mdx"], monkeypatch)
|
||||
|
||||
assert captured["override_image_overrides"] is None
|
||||
|
||||
|
||||
def test_single_image_override_parses_and_forwards(tmp_path, monkeypatch):
|
||||
_redirect_overrides_root(tmp_path, monkeypatch)
|
||||
captured: dict[str, Any] = {}
|
||||
_exec_main_block(
|
||||
captured,
|
||||
[
|
||||
"src.phase_z2_pipeline",
|
||||
"03.mdx",
|
||||
"--override-image",
|
||||
"img-abc=10,15,30.5,25",
|
||||
],
|
||||
monkeypatch,
|
||||
)
|
||||
|
||||
assert captured["override_image_overrides"] == {
|
||||
"img-abc": {"x": 10.0, "y": 15.0, "w": 30.5, "h": 25.0},
|
||||
}
|
||||
|
||||
|
||||
def test_multiple_image_overrides_accumulate(tmp_path, monkeypatch):
|
||||
_redirect_overrides_root(tmp_path, monkeypatch)
|
||||
captured: dict[str, Any] = {}
|
||||
_exec_main_block(
|
||||
captured,
|
||||
[
|
||||
"src.phase_z2_pipeline",
|
||||
"03.mdx",
|
||||
"--override-image",
|
||||
"img-abc=10,15,30,25",
|
||||
"--override-image",
|
||||
"img-def=50,15,40,40",
|
||||
],
|
||||
monkeypatch,
|
||||
)
|
||||
|
||||
assert captured["override_image_overrides"] == {
|
||||
"img-abc": {"x": 10.0, "y": 15.0, "w": 30.0, "h": 25.0},
|
||||
"img-def": {"x": 50.0, "y": 15.0, "w": 40.0, "h": 40.0},
|
||||
}
|
||||
|
||||
|
||||
def test_image_override_strips_whitespace_in_image_id(tmp_path, monkeypatch):
|
||||
"""``iid.strip()`` is intentional — match sibling --override-frame and
|
||||
--override-zone-geometry leniency on surrounding whitespace."""
|
||||
_redirect_overrides_root(tmp_path, monkeypatch)
|
||||
captured: dict[str, Any] = {}
|
||||
_exec_main_block(
|
||||
captured,
|
||||
[
|
||||
"src.phase_z2_pipeline",
|
||||
"03.mdx",
|
||||
"--override-image",
|
||||
" img-pad =5,5,10,10",
|
||||
],
|
||||
monkeypatch,
|
||||
)
|
||||
|
||||
assert captured["override_image_overrides"] == {
|
||||
"img-pad": {"x": 5.0, "y": 5.0, "w": 10.0, "h": 10.0},
|
||||
}
|
||||
|
||||
|
||||
# -- hard-error paths -----------------------------------------------------
|
||||
|
||||
|
||||
def test_image_override_missing_equals_exits(tmp_path, monkeypatch, capsys):
|
||||
_redirect_overrides_root(tmp_path, monkeypatch)
|
||||
captured: dict[str, Any] = {}
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
_exec_main_block(
|
||||
captured,
|
||||
[
|
||||
"src.phase_z2_pipeline",
|
||||
"03.mdx",
|
||||
"--override-image",
|
||||
"img-abc10,15,30,25",
|
||||
],
|
||||
monkeypatch,
|
||||
)
|
||||
|
||||
assert excinfo.value.code == 2
|
||||
err = capsys.readouterr().err
|
||||
assert "--override-image must be IMAGE_ID=X,Y,W,H" in err
|
||||
|
||||
|
||||
def test_image_override_empty_image_id_exits(tmp_path, monkeypatch, capsys):
|
||||
_redirect_overrides_root(tmp_path, monkeypatch)
|
||||
captured: dict[str, Any] = {}
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
_exec_main_block(
|
||||
captured,
|
||||
[
|
||||
"src.phase_z2_pipeline",
|
||||
"03.mdx",
|
||||
"--override-image",
|
||||
"=10,15,30,25",
|
||||
],
|
||||
monkeypatch,
|
||||
)
|
||||
|
||||
assert excinfo.value.code == 2
|
||||
err = capsys.readouterr().err
|
||||
assert "IMAGE_ID must be non-empty" in err
|
||||
|
||||
|
||||
def test_image_override_whitespace_only_image_id_exits(
|
||||
tmp_path, monkeypatch, capsys
|
||||
):
|
||||
"""``iid.strip()`` must collapse whitespace-only IDs into the empty-ID
|
||||
error path (otherwise a spurious key would land in the mapping)."""
|
||||
_redirect_overrides_root(tmp_path, monkeypatch)
|
||||
captured: dict[str, Any] = {}
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
_exec_main_block(
|
||||
captured,
|
||||
[
|
||||
"src.phase_z2_pipeline",
|
||||
"03.mdx",
|
||||
"--override-image",
|
||||
" =10,15,30,25",
|
||||
],
|
||||
monkeypatch,
|
||||
)
|
||||
|
||||
assert excinfo.value.code == 2
|
||||
err = capsys.readouterr().err
|
||||
assert "IMAGE_ID must be non-empty" in err
|
||||
|
||||
|
||||
def test_image_override_duplicate_image_id_exits(
|
||||
tmp_path, monkeypatch, capsys
|
||||
):
|
||||
_redirect_overrides_root(tmp_path, monkeypatch)
|
||||
captured: dict[str, Any] = {}
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
_exec_main_block(
|
||||
captured,
|
||||
[
|
||||
"src.phase_z2_pipeline",
|
||||
"03.mdx",
|
||||
"--override-image",
|
||||
"img-abc=10,15,30,25",
|
||||
"--override-image",
|
||||
"img-abc=20,25,30,35",
|
||||
],
|
||||
monkeypatch,
|
||||
)
|
||||
|
||||
assert excinfo.value.code == 2
|
||||
err = capsys.readouterr().err
|
||||
assert "duplicate IMAGE_ID 'img-abc'" in err
|
||||
|
||||
|
||||
def test_image_override_wrong_float_count_exits(
|
||||
tmp_path, monkeypatch, capsys
|
||||
):
|
||||
_redirect_overrides_root(tmp_path, monkeypatch)
|
||||
captured: dict[str, Any] = {}
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
_exec_main_block(
|
||||
captured,
|
||||
[
|
||||
"src.phase_z2_pipeline",
|
||||
"03.mdx",
|
||||
"--override-image",
|
||||
"img-abc=10,15,30",
|
||||
],
|
||||
monkeypatch,
|
||||
)
|
||||
|
||||
assert excinfo.value.code == 2
|
||||
err = capsys.readouterr().err
|
||||
assert "expects 4 floats X,Y,W,H" in err
|
||||
|
||||
|
||||
def test_image_override_too_many_floats_exits(
|
||||
tmp_path, monkeypatch, capsys
|
||||
):
|
||||
_redirect_overrides_root(tmp_path, monkeypatch)
|
||||
captured: dict[str, Any] = {}
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
_exec_main_block(
|
||||
captured,
|
||||
[
|
||||
"src.phase_z2_pipeline",
|
||||
"03.mdx",
|
||||
"--override-image",
|
||||
"img-abc=10,15,30,25,99",
|
||||
],
|
||||
monkeypatch,
|
||||
)
|
||||
|
||||
assert excinfo.value.code == 2
|
||||
err = capsys.readouterr().err
|
||||
assert "expects 4 floats X,Y,W,H" in err
|
||||
|
||||
|
||||
def test_image_override_non_numeric_value_exits(
|
||||
tmp_path, monkeypatch, capsys
|
||||
):
|
||||
_redirect_overrides_root(tmp_path, monkeypatch)
|
||||
captured: dict[str, Any] = {}
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
_exec_main_block(
|
||||
captured,
|
||||
[
|
||||
"src.phase_z2_pipeline",
|
||||
"03.mdx",
|
||||
"--override-image",
|
||||
"img-abc=10,abc,30,25",
|
||||
],
|
||||
monkeypatch,
|
||||
)
|
||||
|
||||
assert excinfo.value.code == 2
|
||||
err = capsys.readouterr().err
|
||||
assert "floats parse fail" in err
|
||||
|
||||
|
||||
# -- isolation guard ------------------------------------------------------
|
||||
|
||||
|
||||
def test_image_override_does_not_leak_into_sibling_axes(tmp_path, monkeypatch):
|
||||
"""A populated image override must not perturb the other four axes."""
|
||||
_redirect_overrides_root(tmp_path, monkeypatch)
|
||||
captured: dict[str, Any] = {}
|
||||
_exec_main_block(
|
||||
captured,
|
||||
[
|
||||
"src.phase_z2_pipeline",
|
||||
"03.mdx",
|
||||
"--override-image",
|
||||
"img-abc=10,15,30,25",
|
||||
],
|
||||
monkeypatch,
|
||||
)
|
||||
|
||||
assert captured["override_image_overrides"] == {
|
||||
"img-abc": {"x": 10.0, "y": 15.0, "w": 30.0, "h": 25.0},
|
||||
}
|
||||
assert captured["override_layout"] is None
|
||||
assert captured["override_frames"] is None
|
||||
assert captured["override_zone_geometries"] is None
|
||||
assert captured["override_section_assignments"] is None
|
||||
@@ -1,8 +1,9 @@
|
||||
"""IMP-52 (#80) u8 — backend tests for ``src.user_overrides_io``.
|
||||
|
||||
Covers the four axes called out in the Stage 2 plan:
|
||||
Covers the persisted axes called out in the Stage 2 plan
|
||||
(IMP-51 #79 u1 extended this to 5 axes by adding ``image_overrides``):
|
||||
|
||||
1. Round-trip ``save`` → ``load`` (4 KNOWN_AXES + foreign top-level keys).
|
||||
1. Round-trip ``save`` → ``load`` (5 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 /
|
||||
@@ -115,9 +116,18 @@ def _full_payload() -> dict:
|
||||
},
|
||||
"zone_sections": {"zone-top": ["03-1", "03-2"]},
|
||||
"frames": {"03-1+03-2": "frame_two_way_compare"},
|
||||
"image_overrides": {
|
||||
"img-1": {"x": 10.0, "y": 20.0, "w": 30.0, "h": 25.0},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_known_axes_includes_image_overrides():
|
||||
"""IMP-51 #79 u1 — ``image_overrides`` is a known axis (5 total)."""
|
||||
assert "image_overrides" in KNOWN_AXES
|
||||
assert len(KNOWN_AXES) == 5
|
||||
|
||||
|
||||
def test_save_then_load_round_trip(tmp_path):
|
||||
key = "03"
|
||||
payload = _full_payload()
|
||||
@@ -141,6 +151,28 @@ def test_save_partial_payload_preserves_other_axes(tmp_path):
|
||||
assert loaded["zone_geometries"] == _full_payload()["zone_geometries"]
|
||||
assert loaded["zone_sections"] == _full_payload()["zone_sections"]
|
||||
assert loaded["frames"] == _full_payload()["frames"]
|
||||
assert loaded["image_overrides"] == _full_payload()["image_overrides"]
|
||||
|
||||
|
||||
def test_save_partial_image_overrides_preserves_other_axes(tmp_path):
|
||||
"""IMP-51 #79 u1 — partial ``image_overrides`` write preserves siblings."""
|
||||
key = "03"
|
||||
save(key, _full_payload(), root=tmp_path)
|
||||
|
||||
save(
|
||||
key,
|
||||
{"image_overrides": {"img-9": {"x": 5.0, "y": 5.0, "w": 50.0, "h": 50.0}}},
|
||||
root=tmp_path,
|
||||
)
|
||||
loaded = load(key, root=tmp_path)
|
||||
|
||||
assert loaded["image_overrides"] == {
|
||||
"img-9": {"x": 5.0, "y": 5.0, "w": 50.0, "h": 50.0}
|
||||
}
|
||||
assert loaded["layout"] == _full_payload()["layout"]
|
||||
assert loaded["zone_geometries"] == _full_payload()["zone_geometries"]
|
||||
assert loaded["zone_sections"] == _full_payload()["zone_sections"]
|
||||
assert loaded["frames"] == _full_payload()["frames"]
|
||||
|
||||
|
||||
def test_save_axis_replaces_not_deep_merges(tmp_path):
|
||||
@@ -162,14 +194,15 @@ def test_save_none_clears_axis(tmp_path):
|
||||
|
||||
|
||||
def test_save_preserves_foreign_top_level_keys(tmp_path):
|
||||
"""Forward-compat: axes outside KNOWN_AXES (zone_sizes, image_overrides,
|
||||
schema_version, ...) must survive a partial merge on a known axis."""
|
||||
"""Forward-compat: axes outside KNOWN_AXES (zone_sizes, schema_version,
|
||||
...) must survive a partial merge on a known axis. (IMP-51 #79 u1
|
||||
promoted ``image_overrides`` to a known axis, so it is no longer
|
||||
exercised here as a foreign key.)"""
|
||||
key = "03"
|
||||
path = override_path(key, root=tmp_path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
pre_seed = {
|
||||
"layout": "single-column",
|
||||
"image_overrides": {"img-1": {"position": "right", "size": "small"}},
|
||||
"zone_sizes": {"zone-top": "tall"},
|
||||
"schema_version": "experimental-1",
|
||||
}
|
||||
@@ -179,7 +212,6 @@ def test_save_preserves_foreign_top_level_keys(tmp_path):
|
||||
|
||||
loaded = load(key, root=tmp_path)
|
||||
assert loaded["layout"] == "sidebar-right"
|
||||
assert loaded["image_overrides"] == pre_seed["image_overrides"]
|
||||
assert loaded["zone_sizes"] == pre_seed["zone_sizes"]
|
||||
assert loaded["schema_version"] == pre_seed["schema_version"]
|
||||
|
||||
@@ -197,10 +229,11 @@ def test_save_writes_pretty_sorted_json_for_diffability(tmp_path):
|
||||
raw = (tmp_path / "03.json").read_text(encoding="utf-8")
|
||||
# sort_keys=True → KNOWN_AXES come out alphabetically
|
||||
pos_frames = raw.index('"frames"')
|
||||
pos_image_overrides = raw.index('"image_overrides"')
|
||||
pos_layout = raw.index('"layout"')
|
||||
pos_zg = raw.index('"zone_geometries"')
|
||||
pos_zs = raw.index('"zone_sections"')
|
||||
assert pos_frames < pos_layout < pos_zg < pos_zs
|
||||
assert pos_frames < pos_image_overrides < pos_layout < pos_zg < pos_zs
|
||||
|
||||
|
||||
def test_save_leaves_no_tmp_file_on_success(tmp_path):
|
||||
|
||||
@@ -47,6 +47,7 @@ def _exec_main_block(
|
||||
override_frames=None,
|
||||
override_zone_geometries=None,
|
||||
override_section_assignments=None,
|
||||
override_image_overrides=None,
|
||||
):
|
||||
captured["mdx_path"] = mdx_path
|
||||
captured["run_id"] = run_id
|
||||
@@ -54,6 +55,7 @@ def _exec_main_block(
|
||||
captured["override_frames"] = override_frames
|
||||
captured["override_zone_geometries"] = override_zone_geometries
|
||||
captured["override_section_assignments"] = override_section_assignments
|
||||
captured["override_image_overrides"] = override_image_overrides
|
||||
|
||||
monkeypatch.setattr(_pz2, "run_phase_z2_mvp1", _fake_run)
|
||||
monkeypatch.setattr(sys, "argv", argv)
|
||||
@@ -95,6 +97,10 @@ def _write_full_payload(tmp_path: Path, stem: str = "03") -> Path:
|
||||
"top": ["03-1"],
|
||||
"bottom": ["03-2", "03-3"],
|
||||
},
|
||||
"image_overrides": {
|
||||
"img-file-a": {"x": 10.0, "y": 15.0, "w": 30.0, "h": 25.0},
|
||||
"img-file-b": {"x": 50.0, "y": 50.0, "w": 40.0, "h": 40.0},
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
@@ -114,6 +120,7 @@ def test_no_overrides_file_passes_none_overrides(tmp_path, monkeypatch):
|
||||
assert captured["override_frames"] is None
|
||||
assert captured["override_zone_geometries"] is None
|
||||
assert captured["override_section_assignments"] is None
|
||||
assert captured["override_image_overrides"] is None
|
||||
# MDX path / run_id propagate untouched.
|
||||
assert captured["mdx_path"] == Path("03.mdx")
|
||||
assert captured["run_id"] is None
|
||||
@@ -122,7 +129,7 @@ def test_no_overrides_file_passes_none_overrides(tmp_path, monkeypatch):
|
||||
# -- 2. file fills every axis when CLI is empty ----------------------------
|
||||
|
||||
|
||||
def test_file_only_fills_all_four_axes_when_cli_empty(tmp_path, monkeypatch):
|
||||
def test_file_only_fills_all_five_axes_when_cli_empty(tmp_path, monkeypatch):
|
||||
_redirect_overrides_root(tmp_path, monkeypatch)
|
||||
_write_full_payload(tmp_path, "03")
|
||||
|
||||
@@ -142,6 +149,10 @@ def test_file_only_fills_all_four_axes_when_cli_empty(tmp_path, monkeypatch):
|
||||
"top": ["03-1"],
|
||||
"bottom": ["03-2", "03-3"],
|
||||
}
|
||||
assert captured["override_image_overrides"] == {
|
||||
"img-file-a": {"x": 10.0, "y": 15.0, "w": 30.0, "h": 25.0},
|
||||
"img-file-b": {"x": 50.0, "y": 50.0, "w": 40.0, "h": 40.0},
|
||||
}
|
||||
|
||||
|
||||
# -- 3. CLI beats file on the same axis -----------------------------------
|
||||
@@ -190,6 +201,37 @@ def test_cli_frames_overrides_file_frames(tmp_path, monkeypatch):
|
||||
assert captured["override_layout"] == "sidebar-right"
|
||||
assert captured["override_zone_geometries"] is not None
|
||||
assert captured["override_section_assignments"] is not None
|
||||
assert captured["override_image_overrides"] is not None
|
||||
|
||||
|
||||
# -- 3b. CLI image override beats file image override (IMP-51 #79 u6) -----
|
||||
|
||||
|
||||
def test_cli_image_override_overrides_file_image_overrides(tmp_path, monkeypatch):
|
||||
_redirect_overrides_root(tmp_path, monkeypatch)
|
||||
_write_full_payload(tmp_path, "03")
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
_exec_main_block(
|
||||
captured,
|
||||
[
|
||||
"src.phase_z2_pipeline",
|
||||
"03.mdx",
|
||||
"--override-image",
|
||||
"img-cli=70,80,20,15",
|
||||
],
|
||||
monkeypatch,
|
||||
)
|
||||
|
||||
# CLI ``image_overrides`` payload wholly replaces file payload (per-axis).
|
||||
assert captured["override_image_overrides"] == {
|
||||
"img-cli": {"x": 70.0, "y": 80.0, "w": 20.0, "h": 15.0},
|
||||
}
|
||||
# Other axes still come from the file.
|
||||
assert captured["override_layout"] == "sidebar-right"
|
||||
assert captured["override_frames"] is not None
|
||||
assert captured["override_zone_geometries"] is not None
|
||||
assert captured["override_section_assignments"] is not None
|
||||
|
||||
|
||||
# -- 4. corrupt / non-object file warns and skips fallback ----------------
|
||||
@@ -209,6 +251,7 @@ def test_corrupt_json_warns_and_skips_fallback(tmp_path, monkeypatch, capsys):
|
||||
assert captured["override_frames"] is None
|
||||
assert captured["override_zone_geometries"] is None
|
||||
assert captured["override_section_assignments"] is None
|
||||
assert captured["override_image_overrides"] is None
|
||||
|
||||
|
||||
def test_non_object_top_level_warns_and_skips_fallback(
|
||||
@@ -226,6 +269,7 @@ def test_non_object_top_level_warns_and_skips_fallback(
|
||||
assert captured["override_frames"] is None
|
||||
assert captured["override_zone_geometries"] is None
|
||||
assert captured["override_section_assignments"] is None
|
||||
assert captured["override_image_overrides"] is None
|
||||
|
||||
|
||||
# -- 5. invalid MDX stem warns and skips fallback wholesale ---------------
|
||||
@@ -251,6 +295,7 @@ def test_invalid_mdx_stem_warns_and_skips_fallback(
|
||||
assert captured["override_frames"] is None
|
||||
assert captured["override_zone_geometries"] is None
|
||||
assert captured["override_section_assignments"] is None
|
||||
assert captured["override_image_overrides"] is None
|
||||
|
||||
|
||||
# -- 6. per-axis partial fill (file fills only what CLI omits) ------------
|
||||
@@ -294,3 +339,72 @@ def test_per_axis_partial_fill_mixes_cli_and_file(tmp_path, monkeypatch):
|
||||
"top": {"x": 0.0, "y": 0.0, "w": 1.0, "h": 0.5},
|
||||
}
|
||||
assert captured["override_section_assignments"] is None
|
||||
assert captured["override_image_overrides"] is None
|
||||
|
||||
|
||||
# -- 7. image_overrides fallback edge cases (IMP-51 #79 u6) ---------------
|
||||
|
||||
|
||||
def test_image_overrides_fallback_drops_malformed_entries(tmp_path, monkeypatch):
|
||||
"""File carries a mix of valid + malformed image_overrides entries.
|
||||
|
||||
Expected: valid entry survives; malformed entries (non-string id,
|
||||
empty id, non-dict value, missing key, non-numeric value) are silently
|
||||
dropped — no exception propagates.
|
||||
"""
|
||||
_redirect_overrides_root(tmp_path, monkeypatch)
|
||||
(tmp_path / "03.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"image_overrides": {
|
||||
"img-valid": {"x": 1.0, "y": 2.0, "w": 3.0, "h": 4.0},
|
||||
"": {"x": 1.0, "y": 2.0, "w": 3.0, "h": 4.0},
|
||||
"img-not-dict": "oops",
|
||||
"img-missing-h": {"x": 1.0, "y": 2.0, "w": 3.0},
|
||||
"img-bad-value": {"x": "abc", "y": 2.0, "w": 3.0, "h": 4.0},
|
||||
}
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
_exec_main_block(captured, ["src.phase_z2_pipeline", "03.mdx"], monkeypatch)
|
||||
|
||||
assert captured["override_image_overrides"] == {
|
||||
"img-valid": {"x": 1.0, "y": 2.0, "w": 3.0, "h": 4.0},
|
||||
}
|
||||
|
||||
|
||||
def test_image_overrides_fallback_non_dict_axis_is_ignored(tmp_path, monkeypatch):
|
||||
"""File ``image_overrides`` is a non-dict (list); fallback silently skips."""
|
||||
_redirect_overrides_root(tmp_path, monkeypatch)
|
||||
(tmp_path / "03.json").write_text(
|
||||
json.dumps({"image_overrides": ["not", "a", "dict"]}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
_exec_main_block(captured, ["src.phase_z2_pipeline", "03.mdx"], monkeypatch)
|
||||
|
||||
# ``overrides_images`` stays empty; ``or None`` collapses on call site.
|
||||
assert captured["override_image_overrides"] is None
|
||||
|
||||
|
||||
def test_image_overrides_fallback_coerces_int_values_to_float(tmp_path, monkeypatch):
|
||||
"""JSON-loaded ints (e.g. ``10`` not ``10.0``) must coerce to float."""
|
||||
_redirect_overrides_root(tmp_path, monkeypatch)
|
||||
(tmp_path / "03.json").write_text(
|
||||
json.dumps(
|
||||
{"image_overrides": {"img-int": {"x": 10, "y": 20, "w": 30, "h": 40}}}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
_exec_main_block(captured, ["src.phase_z2_pipeline", "03.mdx"], monkeypatch)
|
||||
|
||||
coerced = captured["override_image_overrides"]
|
||||
assert coerced == {"img-int": {"x": 10.0, "y": 20.0, "w": 30.0, "h": 40.0}}
|
||||
for axis_value in coerced["img-int"].values():
|
||||
assert isinstance(axis_value, float)
|
||||
|
||||
Reference in New Issue
Block a user