Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c53722ad0b | ||
|
|
cacc5b30db | ||
|
|
d9d338416a | ||
|
|
f3ef4d917c | ||
|
|
7c93031f9b | ||
|
|
c1df656312 | ||
|
|
6f1c7367e0 | ||
|
|
bd8bcf748b | ||
|
|
9388e25e76 | ||
|
|
ee97f4fc78 | ||
|
|
79f9ea5c92 | ||
|
|
2ef02f5f18 | ||
|
|
1186ad8ae2 | ||
|
|
f358604fb3 |
@@ -9,6 +9,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { motion } from 'framer-motion';
|
||||
import type { Zone, InternalRegion, UserSelection, FrameCandidate, SlidePlan } from '../types/designAgent';
|
||||
import { getSectionsForZone } from '../utils/slidePlanUtils';
|
||||
import { buildBadgeTitle } from '../services/applicationMode';
|
||||
|
||||
interface FramePanelProps {
|
||||
slidePlan: SlidePlan | null;
|
||||
@@ -19,20 +20,6 @@ interface FramePanelProps {
|
||||
onNoDesignToggle: () => void;
|
||||
}
|
||||
|
||||
// ─── IMP-41 u3 — application_mode consequence tooltip map (issue #70) ────────
|
||||
// Keyed by application_mode VALUE (backend authoritative), NOT V4 label.
|
||||
// Source = src/phase_z2_pipeline.py APPLICATION_MODE_BY_V4_LABEL (:107-112)
|
||||
// emitted via Step 9 unit.application_candidates[] and forwarded by
|
||||
// designAgentApi.ts (IMP-41 u2). When applicationMode is absent (legacy
|
||||
// fixtures pre-IMP-32, or candidate filtered out at Step 9) the tooltip
|
||||
// falls back to the raw V4 label string per Stage 2 contract.
|
||||
const APPLICATION_MODE_TOOLTIP_KR: Record<string, string> = {
|
||||
direct_insert: "코드 직접 적용",
|
||||
same_frame_with_adjustment: "AI 보강 필요",
|
||||
layout_or_region_change: "AI restructure 필요",
|
||||
exclude: "render path 제외",
|
||||
};
|
||||
|
||||
export default function FramePanel({
|
||||
slidePlan,
|
||||
selectedZone,
|
||||
@@ -60,11 +47,6 @@ export default function FramePanel({
|
||||
return userSelection.overrides.zone_frames[targetRegion.id] || targetRegion.frame_match_strategy.frame_id;
|
||||
}, [selectedZone, selectedRegion, userSelection.overrides.zone_frames]);
|
||||
|
||||
// IMP-47B u11 — reject-click confirm guard. Per #76 policy: 사용자가 reject
|
||||
// 카드 명시 클릭 → backend `--override-frame` 전달 + reject frame 유지 + AI 재구성.
|
||||
// The window.confirm makes the AI-rebuild intent explicit (deselecting an
|
||||
// already-applied reject frame does not prompt). Pure UX gate — no state
|
||||
// mutation here; the parent `onFrameSelect` still owns the override apply.
|
||||
const handleFrameSelect = React.useCallback(
|
||||
(candidate: FrameCandidate) => {
|
||||
const isReject = candidate.label === "reject";
|
||||
@@ -269,35 +251,29 @@ export default function FramePanel({
|
||||
</span>
|
||||
)}
|
||||
{/* V4 label badge */}
|
||||
{candidate.label && (() => {
|
||||
// IMP-41 u3 — applicationMode-keyed Korean consequence
|
||||
// tooltip with legacy fallback. applicationMode is
|
||||
// forwarded by designAgentApi.ts (u2) from Step 9
|
||||
// unit.application_candidates[]; undefined when the
|
||||
// backend did not emit a mapping for this candidate.
|
||||
const consequence = candidate.applicationMode
|
||||
? APPLICATION_MODE_TOOLTIP_KR[candidate.applicationMode]
|
||||
: undefined;
|
||||
const badgeTitle = consequence
|
||||
? `${consequence} (${candidate.applicationMode})`
|
||||
: `V4 label: ${candidate.label}`;
|
||||
return (
|
||||
<span
|
||||
className={`text-[8px] font-black uppercase tracking-tight px-1.5 py-0.5 rounded ${
|
||||
candidate.label === "use_as_is"
|
||||
? "bg-emerald-100 text-emerald-700"
|
||||
: candidate.label === "light_edit"
|
||||
? "bg-blue-100 text-blue-700"
|
||||
: candidate.label === "restructure"
|
||||
? "bg-amber-100 text-amber-700"
|
||||
: "bg-red-100 text-red-700"
|
||||
}`}
|
||||
title={badgeTitle}
|
||||
>
|
||||
{candidate.label}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
{/* IMP-41 u5 — tooltip delegated to pure helper
|
||||
`buildBadgeTitle` (services/applicationMode.ts).
|
||||
applicationMode is forwarded by designAgentApi.ts
|
||||
(u4) from Step 9 unit.application_candidates[];
|
||||
helper falls back to the raw V4 label when the
|
||||
mode is undefined or unknown. Badge color mapping
|
||||
is intentionally untouched per Stage 2 scope. */}
|
||||
{candidate.label && (
|
||||
<span
|
||||
className={`text-[8px] font-black uppercase tracking-tight px-1.5 py-0.5 rounded ${
|
||||
candidate.label === "use_as_is"
|
||||
? "bg-emerald-100 text-emerald-700"
|
||||
: candidate.label === "light_edit"
|
||||
? "bg-blue-100 text-blue-700"
|
||||
: candidate.label === "restructure"
|
||||
? "bg-amber-100 text-amber-700"
|
||||
: "bg-red-100 text-red-700"
|
||||
}`}
|
||||
title={buildBadgeTitle(candidate.label, candidate.applicationMode)}
|
||||
>
|
||||
{candidate.label}
|
||||
</span>
|
||||
)}
|
||||
{/* IMP-29 u3 — route hint chip (skip when direct_render = default). */}
|
||||
{showRouteChip && (
|
||||
<span
|
||||
|
||||
@@ -21,6 +21,14 @@ import type {
|
||||
UserSelection,
|
||||
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;
|
||||
@@ -51,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;
|
||||
@@ -70,6 +96,8 @@ export default function SlideCanvas({
|
||||
onSlideClick,
|
||||
onSectionDrop,
|
||||
onZoneResize,
|
||||
imageOverrides,
|
||||
onImageResize,
|
||||
}: SlideCanvasProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [scale, setScale] = useState(1);
|
||||
@@ -91,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 과 배타적 (충돌 방지).
|
||||
@@ -118,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) => {
|
||||
@@ -130,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]);
|
||||
|
||||
@@ -154,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).
|
||||
@@ -351,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);
|
||||
}
|
||||
@@ -465,10 +578,9 @@ export default function SlideCanvas({
|
||||
const makeResizeHandler = (
|
||||
direction: ResizeDir
|
||||
) => (ev: React.MouseEvent<HTMLDivElement>) => {
|
||||
// resize 는 pendingLayout 모드에서만 — 첫 초안 (normal) 과 편집 모드에서는
|
||||
// frame HTML 이 reflow 못 해서 의미 없음. layout 변경 후 빈 layout 에서만
|
||||
// zone 자유 배치.
|
||||
if (!isPendingLayout || !onZoneResize) return;
|
||||
// resize 는 pendingLayout OR 편집 모드 활성. 2026-05-22 demo hot-fix —
|
||||
// frame partial 에 @container aspect-ratio 회전이 들어가서 fixed px 제약 사라짐.
|
||||
if ((!isPendingLayout && !isEditMode) || !onZoneResize) return;
|
||||
if (!measuredSlideBody) return;
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
@@ -485,6 +597,12 @@ export default function SlideCanvas({
|
||||
const affectsTop = direction === "top" || direction === "nw" || direction === "ne";
|
||||
const affectsBottom = direction === "bottom" || direction === "sw" || direction === "se";
|
||||
|
||||
// 2026-05-22 demo hot-fix — iframe 이 마우스 가로채서 mouseup leak 일어남
|
||||
// (편집 모드에서 iframe pointerEvents=auto). drag 동안 iframe 강제 none.
|
||||
const iframeEl = iframeRef.current;
|
||||
const prevIframePE = iframeEl ? iframeEl.style.pointerEvents : "";
|
||||
if (iframeEl) iframeEl.style.pointerEvents = "none";
|
||||
|
||||
const onMove = (mv: MouseEvent) => {
|
||||
const dx = (mv.clientX - startMouseX) / slideBodyWidthPx;
|
||||
const dy = (mv.clientY - startMouseY) / slideBodyHeightPx;
|
||||
@@ -511,6 +629,7 @@ export default function SlideCanvas({
|
||||
const onUp = () => {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
if (iframeEl) iframeEl.style.pointerEvents = prevIframePE;
|
||||
};
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
@@ -532,7 +651,7 @@ export default function SlideCanvas({
|
||||
ev: React.MouseEvent<HTMLDivElement>
|
||||
) => {
|
||||
ev.stopPropagation();
|
||||
const canDrag = !!(isPendingLayout && measuredSlideBody && onZoneResize);
|
||||
const canDrag = !!((isPendingLayout || isEditMode) && measuredSlideBody && onZoneResize);
|
||||
const startMouseX = ev.clientX;
|
||||
const startMouseY = ev.clientY;
|
||||
const startGeom = { ...localGeom };
|
||||
@@ -543,25 +662,26 @@ export default function SlideCanvas({
|
||||
? H_SCALED * measuredSlideBody!.h
|
||||
: 1;
|
||||
let dragged = false;
|
||||
const dragThresholdPx = 5;
|
||||
|
||||
// 2026-05-22 demo hot-fix — same iframe pointer-events fix as makeResizeHandler.
|
||||
const iframeEl = iframeRef.current;
|
||||
const prevIframePE = iframeEl ? iframeEl.style.pointerEvents : "";
|
||||
if (iframeEl) iframeEl.style.pointerEvents = "none";
|
||||
|
||||
const onMove = (mv: MouseEvent) => {
|
||||
if (!canDrag) return;
|
||||
const dxPx = mv.clientX - startMouseX;
|
||||
const dyPx = mv.clientY - startMouseY;
|
||||
if (!dragged && Math.hypot(dxPx, dyPx) > dragThresholdPx) {
|
||||
if (!dragged && crossedDragThreshold(dxPx, dyPx)) {
|
||||
dragged = true;
|
||||
}
|
||||
if (dragged) {
|
||||
const dx = dxPx / slideBodyWidthPx;
|
||||
const dy = dyPx / slideBodyHeightPx;
|
||||
const newX = Math.max(
|
||||
0,
|
||||
Math.min(1 - startGeom.w, startGeom.x + dx)
|
||||
);
|
||||
const newY = Math.max(
|
||||
0,
|
||||
Math.min(1 - startGeom.h, startGeom.y + dy)
|
||||
const { x: newX, y: newY } = clampZoneMove(
|
||||
startGeom,
|
||||
dxPx,
|
||||
dyPx,
|
||||
slideBodyWidthPx,
|
||||
slideBodyHeightPx
|
||||
);
|
||||
onZoneResize!({
|
||||
[zone.zone_id]: {
|
||||
@@ -576,6 +696,7 @@ export default function SlideCanvas({
|
||||
const onUp = () => {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
if (iframeEl) iframeEl.style.pointerEvents = prevIframePE;
|
||||
if (!dragged) {
|
||||
// 단순 click 으로 처리 — onZoneClick.
|
||||
onZoneClick?.(zone.id);
|
||||
@@ -671,6 +792,8 @@ export default function SlideCanvas({
|
||||
} ${
|
||||
isDragOver
|
||||
? "border-4 border-emerald-500 bg-emerald-100/30 shadow-[0_0_0_4px_rgba(16,185,129,0.3)]"
|
||||
: isSelected && isEditMode
|
||||
? "border-2 border-emerald-500 bg-emerald-500/10 shadow-[0_0_0_2px_rgba(16,185,129,0.25)]"
|
||||
: isSelected && !isEditMode
|
||||
? "border-2 border-blue-500 bg-blue-500/10 shadow-[0_0_0_2px_rgba(59,130,246,0.2)]"
|
||||
: !isEditMode
|
||||
@@ -747,11 +870,12 @@ export default function SlideCanvas({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step C : zone resize handles — 8 방향. pendingLayout 모드만 활성
|
||||
(frame html 의 fixed px 디자인 한계로 첫 초안 / 편집 모드 resize 의미 X).
|
||||
{/* Step C : zone resize handles — 8 방향. pendingLayout OR 편집 모드 활성.
|
||||
2026-05-22 demo hot-fix — frame partial 에 @container aspect-ratio 회전
|
||||
들어간 후 fixed px 제약 사라져 편집 모드 resize 도 의미 있음.
|
||||
edge handle (top/bottom/left/right) : 한 boundary 이동
|
||||
corner handle (nw/ne/sw/se) : 두 boundary 동시. */}
|
||||
{isPendingLayout && onZoneResize && (
|
||||
{(isPendingLayout || isEditMode) && onZoneResize && (
|
||||
<>
|
||||
{/* top edge */}
|
||||
<div
|
||||
@@ -819,9 +943,252 @@ export default function SlideCanvas({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* IMP-54 u1: edit-mode body-drag gesture surfaces.
|
||||
wrapper sets pointerEvents:none in edit mode (see above) to
|
||||
preserve iframe text-edit clicks (A8 guardrail), so the
|
||||
wrapper-level handleZoneMouseDown is unreachable in edit mode.
|
||||
These 4 perimeter strips + top-left grip provide a separate
|
||||
pointer-event surface routing into handleZoneMouseDown.
|
||||
zIndex 25 sits BELOW the 8 resize handles (z-30) so resize
|
||||
gesture wins in overlap regions, and ABOVE the iframe so the
|
||||
strips intercept the perimeter while the un-covered iframe
|
||||
interior keeps text-edit reachability intact.
|
||||
pendingLayout mode already has wrapper pointerEvents:auto,
|
||||
so these surfaces are only needed in edit mode. */}
|
||||
{isEditMode && !isPendingLayout && onZoneResize && (
|
||||
<>
|
||||
<div
|
||||
onMouseDown={handleZoneMouseDown}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="absolute top-0 left-0 right-0 h-2 cursor-grab active:cursor-grabbing hover:bg-emerald-500/20 transition"
|
||||
style={{ pointerEvents: "auto", zIndex: 25 }}
|
||||
title="zone 이동 — 드래그"
|
||||
/>
|
||||
<div
|
||||
onMouseDown={handleZoneMouseDown}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="absolute bottom-0 left-0 right-0 h-2 cursor-grab active:cursor-grabbing hover:bg-emerald-500/20 transition"
|
||||
style={{ pointerEvents: "auto", zIndex: 25 }}
|
||||
title="zone 이동 — 드래그"
|
||||
/>
|
||||
<div
|
||||
onMouseDown={handleZoneMouseDown}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="absolute top-0 left-0 bottom-0 w-2 cursor-grab active:cursor-grabbing hover:bg-emerald-500/20 transition"
|
||||
style={{ pointerEvents: "auto", zIndex: 25 }}
|
||||
title="zone 이동 — 드래그"
|
||||
/>
|
||||
<div
|
||||
onMouseDown={handleZoneMouseDown}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="absolute top-0 right-0 bottom-0 w-2 cursor-grab active:cursor-grabbing hover:bg-emerald-500/20 transition"
|
||||
style={{ pointerEvents: "auto", zIndex: 25 }}
|
||||
title="zone 이동 — 드래그"
|
||||
/>
|
||||
{/* visible grip affordance — placed below the section label
|
||||
(top-1 left-1 container) so the two don't overlap. */}
|
||||
<div
|
||||
onMouseDown={handleZoneMouseDown}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="absolute top-7 left-1 w-3 h-3 bg-emerald-500/70 border border-emerald-700 rounded-full cursor-grab active:cursor-grabbing shadow hover:scale-125 transition"
|
||||
style={{ pointerEvents: "auto", zIndex: 25 }}
|
||||
title="zone 이동 — 드래그하여 위치 변경"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
// IMP-54 u4 — vitest coverage for the pure drag-math helpers extracted in u3
|
||||
// (`Front/client/src/components/slideCanvasDragMath.ts`).
|
||||
//
|
||||
// Stage 2 contract (`Stage 2 Exit Report → implementation_units → u4`):
|
||||
// • Threshold pass/fail at 5 px (strict `Math.hypot > 5`).
|
||||
// • Clamp negative delta to 0 on both axes.
|
||||
// • Clamp max-edge delta to `1 - startGeom.w` (x) and `1 - startGeom.h` (y).
|
||||
//
|
||||
// The helpers are pure (no React, no DOM) so we drive them directly with
|
||||
// numeric inputs — no fake timers, no fetch stubs, no component mount.
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
DRAG_THRESHOLD_PX,
|
||||
IMAGE_RESIZE_MIN_SIZE_PERCENT,
|
||||
clampImagePercentGeometry,
|
||||
clampZoneMove,
|
||||
crossedDragThreshold,
|
||||
type ImagePercentGeom,
|
||||
type ZoneFracGeom,
|
||||
} from "./slideCanvasDragMath";
|
||||
|
||||
describe("DRAG_THRESHOLD_PX", () => {
|
||||
it("is 5", () => {
|
||||
expect(DRAG_THRESHOLD_PX).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("crossedDragThreshold", () => {
|
||||
it("returns false for zero movement (still a click)", () => {
|
||||
expect(crossedDragThreshold(0, 0)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false just below threshold — 3,4 → hypot 5 with strict >", () => {
|
||||
expect(crossedDragThreshold(3, 4)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false at exactly the threshold along each axis", () => {
|
||||
// strict inequality: Math.hypot(5, 0) === 5, not > 5
|
||||
expect(crossedDragThreshold(5, 0)).toBe(false);
|
||||
expect(crossedDragThreshold(0, 5)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true once distance exceeds threshold", () => {
|
||||
expect(crossedDragThreshold(4, 4)).toBe(true); // hypot ≈ 5.6568
|
||||
expect(crossedDragThreshold(6, 0)).toBe(true);
|
||||
expect(crossedDragThreshold(0, 6)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats negative deltas symmetrically (Euclidean distance)", () => {
|
||||
expect(crossedDragThreshold(-3, -4)).toBe(false);
|
||||
expect(crossedDragThreshold(-4, -4)).toBe(true);
|
||||
expect(crossedDragThreshold(-6, 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clampZoneMove", () => {
|
||||
// 1000 × 1000 slide body so 1 px == 0.001 frac — keeps the arithmetic
|
||||
// exact and the boundary deltas (1000 px) round-trip back to `1 - w/h`.
|
||||
const W = 1000;
|
||||
const H = 1000;
|
||||
const baseGeom: ZoneFracGeom = { x: 0.1, y: 0.2, w: 0.3, h: 0.4 };
|
||||
|
||||
it("applies in-bounds delta as startGeom + (dPx / slideBodySize)", () => {
|
||||
expect(clampZoneMove(baseGeom, 100, 50, W, H)).toEqual({
|
||||
x: 0.2,
|
||||
y: 0.25,
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps negative delta to 0 on both axes", () => {
|
||||
expect(clampZoneMove(baseGeom, -1000, -1000, W, H)).toEqual({
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps max-edge delta to (1 - w) on x and (1 - h) on y", () => {
|
||||
expect(clampZoneMove(baseGeom, 1000, 1000, W, H)).toEqual({
|
||||
x: 1 - baseGeom.w, // 0.7
|
||||
y: 1 - baseGeom.h, // 0.6
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps the two axes independently (negative x, in-bounds y)", () => {
|
||||
expect(clampZoneMove(baseGeom, -1000, 50, W, H)).toEqual({
|
||||
x: 0,
|
||||
y: 0.25,
|
||||
});
|
||||
});
|
||||
|
||||
it("honours non-square slide bodies via per-axis division", () => {
|
||||
// dxPx 100 / 500 = 0.2 fr; dyPx 100 / 250 = 0.4 fr (hits the y boundary).
|
||||
// x is checked with toBeCloseTo because 0.1 + 0.2 is the canonical IEEE-754
|
||||
// floating-point trap (0.30000000000000004) — the clamp logic is correct,
|
||||
// it just inherits JS number precision. y stays exact since it clamps to
|
||||
// the boundary `1 - h`.
|
||||
const result = clampZoneMove(baseGeom, 100, 100, 500, 250);
|
||||
expect(result.x).toBeCloseTo(0.3, 10);
|
||||
expect(result.y).toBe(1 - baseGeom.h); // 0.6
|
||||
});
|
||||
|
||||
it("returns only { x, y } — width / height are preserved by the caller", () => {
|
||||
const out = clampZoneMove(baseGeom, 0, 0, W, H);
|
||||
expect(out).toEqual({ x: 0.1, y: 0.2 });
|
||||
expect("w" in out).toBe(false);
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
// IMP-54 u3 — pure drag math extracted from SlideCanvas.tsx
|
||||
// `handleZoneMouseDown` (`Front/client/src/components/SlideCanvas.tsx:537-598`).
|
||||
//
|
||||
// Resize math (`makeResizeHandler` at SlideCanvas.tsx:465-523) is intentionally
|
||||
// NOT touched — it has its own independent geometry model (per-side
|
||||
// `affectsLeft/Right/Top/Bottom`, `minSize`, `1 - startGeom.x/y` cap) that
|
||||
// must not regress.
|
||||
//
|
||||
// Two responsibilities live here:
|
||||
//
|
||||
// 1. Drag-vs-click classification — a pointer must travel more than
|
||||
// `DRAG_THRESHOLD_PX` (Euclidean distance from the mousedown origin)
|
||||
// before mousedown→mousemove is treated as a drag. Below the
|
||||
// threshold the gesture stays a click, which the caller surfaces as
|
||||
// `onZoneClick(zone.id)` in `onUp`.
|
||||
//
|
||||
// 2. Pixel-delta → slide-body fraction conversion plus clamp to keep the
|
||||
// moved zone fully inside the slide body. Width/height are preserved
|
||||
// verbatim by this helper — only `x` and `y` move.
|
||||
//
|
||||
// Both helpers are pure (no React, no DOM, no side effects) so vitest can
|
||||
// drive them directly. The numeric contract is the inline behavior that
|
||||
// existed before the extraction; this file is a relocation, not a behavior
|
||||
// change.
|
||||
|
||||
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 {
|
||||
return Math.hypot(dxPx, dyPx) > DRAG_THRESHOLD_PX;
|
||||
}
|
||||
|
||||
/** Zone geometry in slide-body fraction space (each component ∈ [0, 1]).
|
||||
* Mirrors the shape the SlideCanvas pipeline already uses for
|
||||
* `localGeom` / `overrideGeom` / `onZoneResize` payloads. */
|
||||
export interface ZoneFracGeom {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
/** Convert a pixel-space drag delta into a slide-body fraction delta, apply
|
||||
* it to `startGeom.{x, y}`, and clamp so the zone never escapes the slide
|
||||
* body (`x ∈ [0, 1 - w]`, `y ∈ [0, 1 - h]`). `w` and `h` are not modified.
|
||||
*
|
||||
* The caller (`SlideCanvas.tsx` `handleZoneMouseDown` onMove) guarantees
|
||||
* `slideBodyWidthPx > 0` and `slideBodyHeightPx > 0` via the
|
||||
* `measuredSlideBody` precondition, so this helper does not re-guard
|
||||
* divide-by-zero. */
|
||||
export function clampZoneMove(
|
||||
startGeom: ZoneFracGeom,
|
||||
dxPx: number,
|
||||
dyPx: number,
|
||||
slideBodyWidthPx: number,
|
||||
slideBodyHeightPx: number,
|
||||
): { x: number; y: number } {
|
||||
const dx = dxPx / slideBodyWidthPx;
|
||||
const dy = dyPx / slideBodyHeightPx;
|
||||
const x = Math.max(0, Math.min(1 - startGeom.w, startGeom.x + dx));
|
||||
const y = Math.max(0, Math.min(1 - startGeom.h, startGeom.y + dy));
|
||||
return { x, y };
|
||||
}
|
||||
+197
-45
@@ -2,7 +2,7 @@
|
||||
* Home - 메인 페이지 (Zone-Centric 슬라이드 빌더)
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo, useEffect } from "react";
|
||||
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { DesignAgentState, LayoutPresetId, Zone } from "../types/designAgent";
|
||||
import {
|
||||
@@ -15,6 +15,10 @@ import {
|
||||
getSelectedRegion,
|
||||
moveSectionToZone,
|
||||
saveZoneSizes,
|
||||
saveImageOverride,
|
||||
deriveUserOverridesKey,
|
||||
applyPersistedNonFrameOverrides,
|
||||
remapPersistedFramesToZoneFrames,
|
||||
} from "../utils/slidePlanUtils";
|
||||
import {
|
||||
parseMdxFile,
|
||||
@@ -25,6 +29,12 @@ import {
|
||||
type RunMeta,
|
||||
type PipelineOverrides,
|
||||
} from "../services/designAgentApi";
|
||||
import {
|
||||
flushUserOverrides,
|
||||
getUserOverrides,
|
||||
saveUserOverrides,
|
||||
type UserOverrides,
|
||||
} from "../services/userOverridesApi";
|
||||
|
||||
import LeftMdxPanel from "../components/LeftMdxPanel";
|
||||
import SlideCanvas from "../components/SlideCanvas";
|
||||
@@ -63,6 +73,14 @@ export default function Home() {
|
||||
// section drag drop + frame 선택). null 이면 평소 모드 (final.html 표시).
|
||||
const [pendingLayout, setPendingLayout] = useState<LayoutPresetId | null>(null);
|
||||
|
||||
// IMP-52 u6 — restore-on-reopen: persisted user_overrides.json fetched at
|
||||
// handleFileUpload time. layout / zone_geometries / zone_sections are
|
||||
// seeded into userSelection immediately (so handleGenerate forwards them
|
||||
// as CLI args). frames are stashed here because their on-disk key
|
||||
// (unit_id = section_ids joined by "+") only maps to region.id after
|
||||
// loadRun rebuilds the slidePlan — see handleGenerate post-loadRun.
|
||||
const persistedOverridesRef = useRef<Partial<UserOverrides>>({});
|
||||
|
||||
// pendingLayout 활성 시 effective slidePlan = pendingZones 가 swap 된 plan.
|
||||
// 그 외 = default state.slidePlan. 모든 zone / region lookup (handleFrameSelect /
|
||||
// getSelectedZone / SlideCanvas) 이 일관되게 이 effectiveSlidePlan 사용.
|
||||
@@ -180,7 +198,19 @@ export default function Home() {
|
||||
|
||||
try {
|
||||
const content = await parseMdxFile(file);
|
||||
setState((p) => ({ ...p, normalizedContent: content, isLoading: false }));
|
||||
// IMP-52 u6 — restore-on-reopen. Key = MDX stem (matches backend
|
||||
// u2 fallback's Path(args.mdx_path).stem). getUserOverrides returns
|
||||
// {} on miss / corrupt / network failure (u5 contract) so the upload
|
||||
// path never fails on a fresh MDX.
|
||||
const overridesKey = deriveUserOverridesKey(file.name);
|
||||
const persisted = await getUserOverrides(overridesKey);
|
||||
persistedOverridesRef.current = persisted;
|
||||
setState((p) => ({
|
||||
...p,
|
||||
normalizedContent: content,
|
||||
userSelection: applyPersistedNonFrameOverrides(p.userSelection, persisted),
|
||||
isLoading: false,
|
||||
}));
|
||||
toast.success(`"${file.name}" 분석 완료 — 하단 버튼으로 슬라이드 생성하세요.`);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -257,9 +287,11 @@ export default function Home() {
|
||||
const overrides: PipelineOverrides = {};
|
||||
const sourcePlan = effectiveSlidePlan;
|
||||
if (sourcePlan && state.slidePlan) {
|
||||
const defaultLayout = state.slidePlan.layout_preset;
|
||||
// 2026-05-22 demo hot-fix — 이전 비교 가드 (default !== override) 제거.
|
||||
// restore loop 이 default = override 로 sync 시 override 안 보내고 backend
|
||||
// default fallback 발생. user 가 명시한 layout 이 있으면 무조건 보냄.
|
||||
const overrideLayout = state.userSelection.overrides.layout_preset;
|
||||
if (overrideLayout && overrideLayout !== defaultLayout) {
|
||||
if (overrideLayout) {
|
||||
overrides.layout = overrideLayout;
|
||||
}
|
||||
const frames: Record<string, string> = {};
|
||||
@@ -302,12 +334,9 @@ export default function Home() {
|
||||
overrides.zoneGeometries = zoneGeometries;
|
||||
}
|
||||
|
||||
// IMP-08 B-3 : zoneSections forward only when the user diverged from
|
||||
// the auto plan. Codex Stage 3 R3 B3 fix : `createInitialUserSelection`
|
||||
// seeds `zone_sections` with the default placement, so a literal copy
|
||||
// would pollute backend assignment-source provenance even on a fresh
|
||||
// re-render. Diff against `sourcePlan.zones[].section_ids` per zone and
|
||||
// only emit zones whose section list differs.
|
||||
// 2026-05-22 — IMP-08 B-3 원래 동작 (sameAsDefault with effectiveSlidePlan) 복귀.
|
||||
// 시연 안정성 우선. section swap 은 별 path (수동 drag detection) 로 풀어야 함.
|
||||
// 임시 over-aggressive fix 가 default flow 깨뜨려 PARTIAL_COVERAGE 발생했음.
|
||||
const userZoneSections = state.userSelection.overrides.zone_sections;
|
||||
if (userZoneSections) {
|
||||
const defaultByZone = new Map<string, string[]>();
|
||||
@@ -349,6 +378,12 @@ export default function Home() {
|
||||
toast.info(`Phase Z 파이프라인 실행 중... ${overrideSummary}`);
|
||||
|
||||
try {
|
||||
// IMP-52 u10 — Force-commit any pending debounced PUTs before backend
|
||||
// reads user_overrides.json on pipeline entry. Without this, a user
|
||||
// who changes an override (300ms debounce window) and immediately
|
||||
// clicks Generate would race the PUT against /api/run; the u2
|
||||
// fallback could then load a stale persisted document.
|
||||
await flushUserOverrides();
|
||||
const result = await runPipeline(state.uploadedFile, overrides);
|
||||
|
||||
if (!result.success || !result.final_html_exists) {
|
||||
@@ -362,20 +397,44 @@ export default function Home() {
|
||||
}
|
||||
|
||||
const { normalizedContent, slidePlan, runMeta } = await loadRun(result.run_id);
|
||||
setState((p) => ({
|
||||
...p,
|
||||
normalizedContent,
|
||||
// IMP-52 u6 — post-loadRun frame remap. persistedOverridesRef holds
|
||||
// the user_overrides.json read at handleFileUpload time. Frames there
|
||||
// are keyed by unit_id (section_ids joined by "+"); the in-memory
|
||||
// zone_frames is keyed by region.id. Remap against the new slidePlan
|
||||
// zones so SlideCanvas's override-vs-default preview indicator shows
|
||||
// the user's persisted choice without forcing them to re-click.
|
||||
const restoredZoneFrames = remapPersistedFramesToZoneFrames(
|
||||
slidePlan,
|
||||
userSelection: createInitialUserSelection(slidePlan),
|
||||
isLoading: false,
|
||||
}));
|
||||
persistedOverridesRef.current.frames as Record<string, string> | undefined,
|
||||
);
|
||||
setState((p) => {
|
||||
// IMP-52 u6 — restore-on-reopen: re-layer the persisted non-frame
|
||||
// axes (layout / zone_geometries / zone_sections) onto the post-load
|
||||
// `base`. `createInitialUserSelection` rebuilds from slidePlan and
|
||||
// drops anything the backend fallback could not round-trip through
|
||||
// a CLI arg — `zone_geometries` in particular has no slidePlan
|
||||
// representation, so without this merge the user would see their
|
||||
// resized zones revert on every Generate.
|
||||
const base = applyPersistedNonFrameOverrides(
|
||||
createInitialUserSelection(slidePlan),
|
||||
persistedOverridesRef.current,
|
||||
);
|
||||
return {
|
||||
...p,
|
||||
normalizedContent,
|
||||
slidePlan,
|
||||
userSelection: {
|
||||
...base,
|
||||
overrides: {
|
||||
...base.overrides,
|
||||
zone_frames: { ...base.overrides.zone_frames, ...restoredZoneFrames },
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
};
|
||||
});
|
||||
setRunMeta(runMeta);
|
||||
toast.success(`run "${result.run_id}" 완료 — ${runMeta.status}`);
|
||||
// IMP-47B u11 — surface Step 12 AI repair failure axes (error /
|
||||
// coverage_violated / unsupported_kind) as a human_review notification.
|
||||
// Auto-pipeline first ([[feedback_auto_pipeline_first]]): no review_queue
|
||||
// insertion — just an explicit error toast directing the user to pick
|
||||
// another frame or edit manually. Helper returns null on success path.
|
||||
const aiReviewMsg = formatAiRepairHumanReviewMessage(runMeta.ai_repair_status);
|
||||
if (aiReviewMsg) toast.error(aiReviewMsg);
|
||||
} catch (err) {
|
||||
@@ -391,10 +450,21 @@ export default function Home() {
|
||||
const handleSectionDrop = useCallback((sectionId: string, zoneId: string) => {
|
||||
setState((p) => {
|
||||
const newSelection = moveSectionToZone(p.userSelection, sectionId, zoneId);
|
||||
return {
|
||||
...p,
|
||||
userSelection: selectZone(newSelection, zoneId) // 이동된 존 자동 선택
|
||||
};
|
||||
const finalSelection = selectZone(newSelection, zoneId); // 이동된 존 자동 선택
|
||||
// IMP-52 u7 — persist the post-drop zone_sections snapshot. The on-disk
|
||||
// schema axis (`zone_sections`) shares the in-memory shape (zone_id →
|
||||
// section_ids), so we forward the full mutated value; the u4 PUT path
|
||||
// replaces this axis atomically while preserving the foreign axes.
|
||||
// p.uploadedFile gate skips persistence before any MDX is loaded —
|
||||
// the demo-mode initial render path would otherwise PUT to the empty
|
||||
// key. saveUserOverrides is debounced (300ms) and per-key coalesced.
|
||||
if (p.uploadedFile) {
|
||||
const key = deriveUserOverridesKey(p.uploadedFile.name);
|
||||
void saveUserOverrides(key, {
|
||||
zone_sections: finalSelection.overrides.zone_sections,
|
||||
});
|
||||
}
|
||||
return { ...p, userSelection: finalSelection };
|
||||
});
|
||||
setRightTab("frame");
|
||||
setHasPendingChanges(true);
|
||||
@@ -420,10 +490,18 @@ export default function Home() {
|
||||
|
||||
// ── Layout 선택 ──
|
||||
const handleLayoutSelect = useCallback((layoutId: string) => {
|
||||
setState((p) => ({
|
||||
...p,
|
||||
userSelection: applyLayout(p.userSelection, layoutId as LayoutPresetId)
|
||||
}));
|
||||
setState((p) => {
|
||||
const newSelection = applyLayout(p.userSelection, layoutId as LayoutPresetId);
|
||||
// IMP-52 u7 — persist the selected layout preset id. The on-disk
|
||||
// `layout` axis is a single string; `applyLayout` validates the
|
||||
// preset id before mutating the selection, so the value here is
|
||||
// already the LayoutPresetId we want to round-trip.
|
||||
if (p.uploadedFile) {
|
||||
const key = deriveUserOverridesKey(p.uploadedFile.name);
|
||||
void saveUserOverrides(key, { layout: layoutId });
|
||||
}
|
||||
return { ...p, userSelection: newSelection };
|
||||
});
|
||||
setHasPendingChanges(true);
|
||||
}, []);
|
||||
|
||||
@@ -436,22 +514,64 @@ export default function Home() {
|
||||
}, []);
|
||||
|
||||
const handleZoneResize = useCallback((geometries: Record<string, { x: number; y: number; w: number; h: number }>) => {
|
||||
setState((p) => ({
|
||||
...p,
|
||||
userSelection: {
|
||||
...p.userSelection,
|
||||
overrides: {
|
||||
...p.userSelection.overrides,
|
||||
zone_geometries: {
|
||||
...p.userSelection.overrides.zone_geometries,
|
||||
...geometries
|
||||
}
|
||||
}
|
||||
setState((p) => {
|
||||
const mergedGeometries = {
|
||||
...p.userSelection.overrides.zone_geometries,
|
||||
...geometries,
|
||||
};
|
||||
// IMP-52 u7 — persist the merged zone_geometries snapshot. Resize
|
||||
// gestures fire repeatedly during a drag; the 300ms u5 debounce
|
||||
// collapses them into a single PUT at gesture-end, so we don't
|
||||
// need to gate on resize-finished here.
|
||||
if (p.uploadedFile) {
|
||||
const key = deriveUserOverridesKey(p.uploadedFile.name);
|
||||
void saveUserOverrides(key, { zone_geometries: mergedGeometries });
|
||||
}
|
||||
}));
|
||||
return {
|
||||
...p,
|
||||
userSelection: {
|
||||
...p.userSelection,
|
||||
overrides: {
|
||||
...p.userSelection.overrides,
|
||||
zone_geometries: mergedGeometries,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
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 재설정 = 매우 느림).
|
||||
@@ -491,10 +611,40 @@ export default function Home() {
|
||||
return;
|
||||
}
|
||||
|
||||
setState((p) => ({
|
||||
...p,
|
||||
userSelection: applyFrame(p.userSelection, region.id, frameId)
|
||||
}));
|
||||
setState((p) => {
|
||||
const newSelection = applyFrame(p.userSelection, region.id, frameId);
|
||||
// IMP-52 u7 — persist frames keyed by `unit_id`. The on-disk schema
|
||||
// uses `unit_id = zone.section_ids.join("+")` (the same convention
|
||||
// handleGenerate uses when forwarding `overrides.frames` to the
|
||||
// backend CLI). `zone_frames` is keyed by region.id, so we walk
|
||||
// the effectiveSlidePlan zones to translate. Only true user
|
||||
// overrides are persisted — `createInitialUserSelection` pre-fills
|
||||
// `zone_frames[region.id]` with `region.frame_match_strategy.frame_id`
|
||||
// (backend default) for every region, so we mirror handleGenerate's
|
||||
// `overrideFrameId !== defaultFrameId` gate to avoid leaking defaults
|
||||
// into user_overrides.json. Zones with no sections are skipped.
|
||||
if (p.uploadedFile && effectiveSlidePlan) {
|
||||
const framesByUnitId: Record<string, string> = {};
|
||||
for (const z of effectiveSlidePlan.zones) {
|
||||
const r = z.internal_regions[0];
|
||||
if (!r) continue;
|
||||
if (!Array.isArray(z.section_ids) || z.section_ids.length === 0) continue;
|
||||
const unitId = z.section_ids.join("+");
|
||||
const overrideId = newSelection.overrides.zone_frames?.[r.id];
|
||||
const defaultFrameId = r.frame_match_strategy.frame_id;
|
||||
if (
|
||||
typeof overrideId === "string" &&
|
||||
overrideId.length > 0 &&
|
||||
overrideId !== defaultFrameId
|
||||
) {
|
||||
framesByUnitId[unitId] = overrideId;
|
||||
}
|
||||
}
|
||||
const key = deriveUserOverridesKey(p.uploadedFile.name);
|
||||
void saveUserOverrides(key, { frames: framesByUnitId });
|
||||
}
|
||||
return { ...p, userSelection: newSelection };
|
||||
});
|
||||
setHasPendingChanges(true);
|
||||
}, [effectiveSlidePlan, state.userSelection]);
|
||||
|
||||
@@ -627,6 +777,8 @@ export default function Home() {
|
||||
onSectionDrop={handleSectionDrop}
|
||||
onLayoutResize={handleLayoutResize}
|
||||
onZoneResize={handleZoneResize}
|
||||
imageOverrides={state.userSelection.overrides.image_overrides}
|
||||
onImageResize={handleImageResize}
|
||||
/>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// ─── IMP-41 u2 — application_mode helper (issue #70) ────────────────────────
|
||||
// Pure deterministic helpers for forwarding backend Step 9
|
||||
// `unit.application_candidates[]` to the FramePanel V4-label badge tooltip.
|
||||
//
|
||||
// Keyed by backend `application_mode` VALUE (NOT V4 label) — preserves the
|
||||
// AI-isolation contract: tooltip text is a read-only display of backend
|
||||
// authority, never re-derived on the frontend from V4 label.
|
||||
//
|
||||
// Source of truth = src/phase_z2_pipeline.py APPLICATION_MODE_BY_V4_LABEL
|
||||
// (:107-112) emitted via _application_candidates_for_unit() (:3071-3092)
|
||||
// onto unit.application_candidates[] in step09_application_plan.json.
|
||||
|
||||
/** Backend application_mode enumeration (verbatim from APPLICATION_MODE_BY_V4_LABEL). */
|
||||
export type ApplicationMode =
|
||||
| 'direct_insert'
|
||||
| 'same_frame_with_adjustment'
|
||||
| 'layout_or_region_change'
|
||||
| 'exclude';
|
||||
|
||||
/** Korean consequence phrases per issue #70 spec item #2. Keyed by mode VALUE. */
|
||||
export const APPLICATION_MODE_TOOLTIP_KR: Record<ApplicationMode, string> = {
|
||||
direct_insert: '코드 직접 적용',
|
||||
same_frame_with_adjustment: 'AI 보강 필요',
|
||||
layout_or_region_change: 'AI restructure 필요',
|
||||
exclude: 'render path 제외',
|
||||
};
|
||||
|
||||
/**
|
||||
* Compose the V4-label badge tooltip title. When `applicationMode` resolves
|
||||
* to a known mode the title shows the Korean consequence + raw mode token;
|
||||
* otherwise (undefined or unknown — legacy fixtures pre-IMP-32) it falls
|
||||
* back to the raw V4 label string per Stage 2 contract.
|
||||
*/
|
||||
export function buildBadgeTitle(
|
||||
label: string,
|
||||
applicationMode: string | undefined,
|
||||
): string {
|
||||
const consequence = applicationMode
|
||||
? APPLICATION_MODE_TOOLTIP_KR[applicationMode as ApplicationMode]
|
||||
: undefined;
|
||||
return consequence
|
||||
? `${consequence} (${applicationMode})`
|
||||
: `V4 label: ${label}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Map<template_id, applicationCandidate> from a Step 9
|
||||
* `unit.application_candidates[]` array. Entries with a non-string or empty
|
||||
* `template_id` are skipped. First occurrence wins on duplicate keys.
|
||||
* Pure — does NOT sort, slice, or filter by label/confidence.
|
||||
*/
|
||||
export function mergeApplicationCandidates(
|
||||
applicationCandidates: unknown,
|
||||
): Map<string, any> {
|
||||
const out = new Map<string, any>();
|
||||
if (!Array.isArray(applicationCandidates)) return out;
|
||||
for (const ac of applicationCandidates) {
|
||||
const key = (ac as any)?.template_id;
|
||||
if (typeof key === 'string' && key.length > 0 && !out.has(key)) {
|
||||
out.set(key, ac);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
MOCK_FRAME_CANDIDATES_SECTION1,
|
||||
} from "../data/mockDesignAgentData";
|
||||
|
||||
import { mergeApplicationCandidates } from "./applicationMode";
|
||||
|
||||
/** 네트워크 지연 시뮬레이션 */
|
||||
const simulateDelay = (ms: number = 800) =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms));
|
||||
@@ -223,10 +225,6 @@ export interface FilteredSectionReason {
|
||||
position?: string | null;
|
||||
}
|
||||
|
||||
// IMP-47B u11 — verbatim mirror of step20_slide_status.ai_repair_status (u8 schema).
|
||||
// Surfaces Step 12 AI repair outcomes so the frontend can render a
|
||||
// human_review notification when AI proposal validation, coverage, or call
|
||||
// itself failed. Enum / field names kept verbatim — no frontend redefinition.
|
||||
export interface AiRepairStatus {
|
||||
status: "ok" | "applied" | "unsupported_kind" | "coverage_violated" | "error" | string;
|
||||
counts: {
|
||||
@@ -266,21 +264,9 @@ export interface RunMeta {
|
||||
layout_candidates: string[]; // step07 layout_candidates list
|
||||
region_layout_candidates_by_zone: Record<string, string[]>; // step08 placeholder
|
||||
display_strategy_candidates_by_zone: Record<string, string[]>; // step08 placeholder
|
||||
/** IMP-47B u11 — Step 12 AI repair outcome (u8 surfacing). null when
|
||||
* step20 omits the field (legacy runs / pipeline aborted before Step 12). */
|
||||
ai_repair_status: AiRepairStatus | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* IMP-47B u11 — Build the human_review notification text when Step 12 AI repair
|
||||
* reports a failure axis. Returns null when no notification is needed (success,
|
||||
* no AI invocation, or human_review_required=false). Pure function — no DOM, no
|
||||
* toast side-effect — so it can be unit-tested without React Testing Library.
|
||||
*
|
||||
* Failure axes mapped to user-facing text (verbatim policy from
|
||||
* IMP-47B #76 guardrail: "AI 호출 실패 / proposal validation 실패 / coverage 미달
|
||||
* → frontend 에 명확한 notification").
|
||||
*/
|
||||
export function formatAiRepairHumanReviewMessage(
|
||||
ai: AiRepairStatus | null | undefined,
|
||||
): string | null {
|
||||
@@ -589,22 +575,15 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
|
||||
if (lp !== 0) return lp;
|
||||
return (b.confidence ?? 0) - (a.confidence ?? 0);
|
||||
});
|
||||
// ─── IMP-41 u2 — application_candidates enrichment (issue #70) ───────────
|
||||
// ─── IMP-41 u4 — application_candidates enrichment (issue #70) ───────────
|
||||
// Backend Step 9 emits `unit.application_candidates[]` (src/phase_z2_pipeline.py
|
||||
// _application_candidates_for_unit, :3071-3092) one entry per v4 candidate with
|
||||
// application_mode / auto_applicable / delegated_to derived from
|
||||
// APPLICATION_MODE_BY_V4_LABEL (:107-112). Enrichment ONLY — does NOT alter
|
||||
// candidate source priority, sorting, or TOP_N_FRAMES slicing.
|
||||
const applicationCandidates: any[] = Array.isArray(unit.application_candidates)
|
||||
? unit.application_candidates
|
||||
: [];
|
||||
const applicationModeMap = new Map<string, any>();
|
||||
applicationCandidates.forEach((ac: any) => {
|
||||
const key = ac?.template_id;
|
||||
if (typeof key === "string" && key.length > 0) {
|
||||
applicationModeMap.set(key, ac);
|
||||
}
|
||||
});
|
||||
// APPLICATION_MODE_BY_V4_LABEL (:107-112). Indexing delegated to the pure
|
||||
// helper `mergeApplicationCandidates` (services/applicationMode.ts) keyed
|
||||
// by template_id. Enrichment ONLY — does NOT alter candidate source
|
||||
// priority, sorting, or TOP_N_FRAMES slicing.
|
||||
const applicationModeMap = mergeApplicationCandidates(unit.application_candidates);
|
||||
const frameCandidates: FrameCandidate[] = v4Source
|
||||
.slice(0, TOP_N_FRAMES)
|
||||
.map((c: any) => {
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
// IMP-52 u5 — typed frontend client for `/api/user-overrides/:key` (GET + PUT).
|
||||
//
|
||||
// The on-disk schema (KNOWN_AXES) and endpoint contract are owned by:
|
||||
// • src/user_overrides_io.py (Python — backend pipeline fallback, u1/u2)
|
||||
// • Front/vite.config.ts (handleGet/PutUserOverrides, u3/u4)
|
||||
// This module is the typed view used by Home.tsx restore-on-reopen (u6) and
|
||||
// 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
|
||||
// `{}` so the frontend reopen flow never crashes on a fresh MDX.
|
||||
// • Typed `saveUserOverrides(key, partial)` → schedules a 300ms-debounced
|
||||
// PUT carrying ONLY the axes the user has mutated since the last flush.
|
||||
// Per-axis coalescing: a later call overwrites the same axis in the
|
||||
// pending payload; axes the user did not mutate are NOT sent (the
|
||||
// server-side merge in u4 preserves them on disk).
|
||||
// • Per-key debounce buckets — rapid edits to MDX "03" do not delay the
|
||||
// flush for MDX "04".
|
||||
// • Explicit clear sentinel: `partial[axis] = null` forwards to the PUT
|
||||
// body verbatim so u4 `mergeUserOverrides` can `delete` the axis on disk.
|
||||
// • `flushUserOverrides()` / `flushUserOverrides(key)` force an immediate
|
||||
// PUT (used by tests + Home.tsx Generate flow to ensure outstanding
|
||||
// writes commit before pipeline run).
|
||||
|
||||
const ENDPOINT_BASE = "/api/user-overrides";
|
||||
const DEBOUNCE_MS = 300;
|
||||
|
||||
// ── Schema (mirror of backend KNOWN_AXES; see header comment) ───────────────
|
||||
|
||||
/** unit_id → template_id. unit_id = source_section_ids joined by "+". */
|
||||
export type FramesOverride = Record<string, string>;
|
||||
|
||||
/** zone_id → 0-1 normalized geometry inside slide-body. */
|
||||
export type ZoneGeometryOverride = {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
};
|
||||
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). */
|
||||
export type UserOverridesPartial = {
|
||||
[K in keyof UserOverrides]?: UserOverrides[K] | null;
|
||||
};
|
||||
|
||||
// ── Per-key debounce buckets ────────────────────────────────────────────────
|
||||
|
||||
type PendingBucket = {
|
||||
partial: UserOverridesPartial;
|
||||
timer: ReturnType<typeof setTimeout> | null;
|
||||
waiters: Array<{
|
||||
resolve: (merged: Partial<UserOverrides>) => void;
|
||||
reject: (err: unknown) => void;
|
||||
}>;
|
||||
};
|
||||
|
||||
const buckets = new Map<string, PendingBucket>();
|
||||
|
||||
function getBucket(key: string): PendingBucket {
|
||||
let b = buckets.get(key);
|
||||
if (!b) {
|
||||
b = { partial: {}, timer: null, waiters: [] };
|
||||
buckets.set(key, b);
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
// ── GET ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch the persisted user_overrides for `key` (MDX stem). Returns `{}` on
|
||||
* any failure mode (network error, 4xx/5xx, non-object body) so the caller
|
||||
* can use it unconditionally during MDX reopen without branching on
|
||||
* error paths.
|
||||
*/
|
||||
export async function getUserOverrides(
|
||||
key: string,
|
||||
): Promise<Partial<UserOverrides>> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${ENDPOINT_BASE}/${encodeURIComponent(key)}`, {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
if (!res.ok) return {};
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = await res.json();
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
return {};
|
||||
}
|
||||
return parsed as Partial<UserOverrides>;
|
||||
}
|
||||
|
||||
// ── PUT (debounced) ─────────────────────────────────────────────────────────
|
||||
|
||||
async function flushBucket(
|
||||
key: string,
|
||||
bucket: PendingBucket,
|
||||
): Promise<void> {
|
||||
const payload = bucket.partial;
|
||||
const waiters = bucket.waiters;
|
||||
bucket.partial = {};
|
||||
bucket.timer = null;
|
||||
bucket.waiters = [];
|
||||
|
||||
let merged: Partial<UserOverrides> = {};
|
||||
try {
|
||||
const res = await fetch(`${ENDPOINT_BASE}/${encodeURIComponent(key)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (res.ok) {
|
||||
try {
|
||||
const parsed = (await res.json()) as unknown;
|
||||
if (
|
||||
typeof parsed === "object" &&
|
||||
parsed !== null &&
|
||||
!Array.isArray(parsed)
|
||||
) {
|
||||
merged = parsed as Partial<UserOverrides>;
|
||||
}
|
||||
} catch {
|
||||
// server returned 200 with non-JSON body → treat as empty merged
|
||||
}
|
||||
} else {
|
||||
const err = new Error(`PUT ${ENDPOINT_BASE}/${key} → ${res.status}`);
|
||||
waiters.forEach((w) => w.reject(err));
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
waiters.forEach((w) => w.reject(err));
|
||||
return;
|
||||
}
|
||||
waiters.forEach((w) => w.resolve(merged));
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a debounced PUT to persist the mutated axes. Resolves with the
|
||||
* server-side merged document when the debounced PUT eventually fires.
|
||||
* Multiple rapid calls for the same `key` coalesce into a single PUT;
|
||||
* a later call's value for a given axis overrides an earlier pending value.
|
||||
* Calls for different `key`s are isolated.
|
||||
*/
|
||||
export function saveUserOverrides(
|
||||
key: string,
|
||||
partial: UserOverridesPartial,
|
||||
): Promise<Partial<UserOverrides>> {
|
||||
const bucket = getBucket(key);
|
||||
// Per-axis coalescing — later mutations replace earlier pending values.
|
||||
for (const axis of Object.keys(partial) as Array<keyof UserOverridesPartial>) {
|
||||
bucket.partial[axis] = partial[axis] as never;
|
||||
}
|
||||
const p = new Promise<Partial<UserOverrides>>((resolve, reject) => {
|
||||
bucket.waiters.push({ resolve, reject });
|
||||
});
|
||||
if (bucket.timer !== null) clearTimeout(bucket.timer);
|
||||
bucket.timer = setTimeout(() => {
|
||||
void flushBucket(key, bucket);
|
||||
}, DEBOUNCE_MS);
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-flush pending debounced writes. With no arg, flushes ALL pending
|
||||
* keys (used before pipeline runs so the backend reads the latest file).
|
||||
* With a key, flushes only that key's bucket.
|
||||
*
|
||||
* Resolves after every flushed bucket's PUT completes. Per-bucket errors
|
||||
* are swallowed at the flush level — the original caller's
|
||||
* saveUserOverrides() promise still rejects to its owner via the waiter.
|
||||
*/
|
||||
export async function flushUserOverrides(key?: string): Promise<void> {
|
||||
const targets: Array<[string, PendingBucket]> = [];
|
||||
if (key !== undefined) {
|
||||
const b = buckets.get(key);
|
||||
if (b && b.timer !== null) targets.push([key, b]);
|
||||
} else {
|
||||
buckets.forEach((b, k) => {
|
||||
if (b.timer !== null) targets.push([k, b]);
|
||||
});
|
||||
}
|
||||
const flushPromises = targets.map(([k, b]) => {
|
||||
if (b.timer !== null) {
|
||||
clearTimeout(b.timer);
|
||||
b.timer = null;
|
||||
}
|
||||
return flushBucket(k, b);
|
||||
});
|
||||
await Promise.all(flushPromises);
|
||||
}
|
||||
|
||||
/** Test-only — clears all pending buckets without firing PUTs. */
|
||||
export function __resetUserOverridesBuckets_FOR_TEST(): void {
|
||||
buckets.forEach((b) => {
|
||||
if (b.timer !== null) clearTimeout(b.timer);
|
||||
});
|
||||
buckets.clear();
|
||||
}
|
||||
@@ -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 }>;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,120 @@
|
||||
import type { UserSelection, SlidePlan, Zone, InternalRegion, LayoutPresetId } from "../types/designAgent";
|
||||
import type { UserOverrides } from "../services/userOverridesApi";
|
||||
|
||||
// ─── IMP-52 u6 — restore-on-reopen helpers (pure, exported for testing) ────
|
||||
// These helpers compose persisted `user_overrides.json` payloads (typed by
|
||||
// the u5 service) onto the in-memory `UserSelection`. They live here rather
|
||||
// than inline in Home.tsx so vitest can drive them in a node environment
|
||||
// without booting React or pulling in the radix-ui / lucide UI deps that
|
||||
// Home.tsx requires. Home.tsx wires these into:
|
||||
// • handleFileUpload (pre-Generate layout / zone_geometries / zone_sections
|
||||
// seed so handleGenerate's CLI-args build picks them up)
|
||||
// • handleGenerate post-loadRun (frame remap unit_id → region.id over the
|
||||
// freshly built slidePlan)
|
||||
// The on-disk schema and clear-sentinel semantics are owned by:
|
||||
// • src/user_overrides_io.py (KNOWN_AXES, u1)
|
||||
// • Front/vite.config.ts mergeUserOverrides (u4)
|
||||
// • Front/client/src/services/userOverridesApi.ts (UserOverrides type, u5)
|
||||
// Any KNOWN_AXES drift must land in those files first.
|
||||
|
||||
/**
|
||||
* Derive the `/api/user-overrides/:key` MDX-stem key from a filename.
|
||||
* Strips a trailing `.mdx` (case-insensitive). The key matches the Python
|
||||
* `Path(args.mdx_path).stem` derivation used by the backend fallback (u2),
|
||||
* so the same persisted file is read from both ends without translation.
|
||||
*/
|
||||
export function deriveUserOverridesKey(filename: string): string {
|
||||
return filename.replace(/\.mdx$/i, "");
|
||||
}
|
||||
|
||||
const LAYOUT_PRESET_IDS = new Set<string>([
|
||||
"single",
|
||||
"horizontal-2",
|
||||
"vertical-2",
|
||||
"top-1-bottom-2",
|
||||
"top-2-bottom-1",
|
||||
"left-1-right-2",
|
||||
"left-2-right-1",
|
||||
"grid-2x2",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Layer the three non-frame axes from a persisted `user_overrides.json`
|
||||
* payload onto an existing `UserSelection`. Foreign / unrecognized payload
|
||||
* shapes are silently ignored — the u5 GET path already returns `{}` on
|
||||
* corrupt files, but we revalidate here so hand-edited files or future
|
||||
* forward-compat axes cannot poison the in-memory state.
|
||||
*
|
||||
* Frames are NOT layered here because the on-disk key (`unit_id` =
|
||||
* section_ids joined by `+`) only resolves after the slidePlan zones are
|
||||
* known. Use `remapPersistedFramesToZoneFrames` in the post-loadRun step.
|
||||
*/
|
||||
export function applyPersistedNonFrameOverrides(
|
||||
selection: UserSelection,
|
||||
persisted: Partial<UserOverrides> | null | undefined,
|
||||
): UserSelection {
|
||||
if (!persisted || typeof persisted !== "object") return selection;
|
||||
const next = { ...selection.overrides };
|
||||
if (typeof persisted.layout === "string" && LAYOUT_PRESET_IDS.has(persisted.layout)) {
|
||||
next.layout_preset = persisted.layout as LayoutPresetId;
|
||||
}
|
||||
if (
|
||||
persisted.zone_geometries &&
|
||||
typeof persisted.zone_geometries === "object" &&
|
||||
!Array.isArray(persisted.zone_geometries)
|
||||
) {
|
||||
next.zone_geometries = { ...persisted.zone_geometries };
|
||||
}
|
||||
if (
|
||||
persisted.zone_sections &&
|
||||
typeof persisted.zone_sections === "object" &&
|
||||
!Array.isArray(persisted.zone_sections)
|
||||
) {
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remap persisted frames (`unit_id` → template_id) to the in-memory
|
||||
* `zone_frames` (region.id → template_id) using the freshly built
|
||||
* slidePlan zones. `unit_id` follows handleGenerate's convention:
|
||||
* `zone.section_ids.join("+")`. Persisted entries whose unit_id no longer
|
||||
* matches any zone (e.g. user changed zone_sections between sessions) are
|
||||
* silently dropped.
|
||||
*/
|
||||
export function remapPersistedFramesToZoneFrames(
|
||||
slidePlan: SlidePlan | null | undefined,
|
||||
framesByUnitId: Record<string, string> | null | undefined,
|
||||
): Record<string, string> {
|
||||
if (!slidePlan || !framesByUnitId || typeof framesByUnitId !== "object") {
|
||||
return {};
|
||||
}
|
||||
const out: Record<string, string> = {};
|
||||
for (const zone of slidePlan.zones) {
|
||||
const region = zone.internal_regions[0];
|
||||
if (!region) continue;
|
||||
if (!Array.isArray(zone.section_ids) || zone.section_ids.length === 0) continue;
|
||||
const unitId = zone.section_ids.join("+");
|
||||
const templateId = framesByUnitId[unitId];
|
||||
if (typeof templateId === "string" && templateId.length > 0) {
|
||||
out[region.id] = templateId;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase Z 초기 선택 상태 생성
|
||||
@@ -39,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 {
|
||||
@@ -60,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,
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
// IMP-41 u3 — Vitest coverage for application_mode helper (issue #70).
|
||||
//
|
||||
// Scope (Stage 2 unit u3 contract):
|
||||
// 1) buildBadgeTitle: composite output for each known mode + legacy fallback
|
||||
// (undefined applicationMode) + unknown fallback (string not in
|
||||
// APPLICATION_MODE_TOOLTIP_KR).
|
||||
// 2) mergeApplicationCandidates: array → Map<template_id, candidate>
|
||||
// semantics, including skip-missing-key and empty-input.
|
||||
//
|
||||
// Pure helper unit test — no React, no DOM, no fetch. Aligns with the
|
||||
// AI-isolation contract: assertions key by backend application_mode VALUE,
|
||||
// never by V4 label.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
buildBadgeTitle,
|
||||
mergeApplicationCandidates,
|
||||
APPLICATION_MODE_TOOLTIP_KR,
|
||||
} from "../src/services/applicationMode";
|
||||
|
||||
describe("buildBadgeTitle (IMP-41 u3)", () => {
|
||||
it("returns composite '<consequence> (<mode>)' for direct_insert", () => {
|
||||
expect(buildBadgeTitle("use_as_is", "direct_insert")).toBe(
|
||||
`${APPLICATION_MODE_TOOLTIP_KR.direct_insert} (direct_insert)`,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns composite output for same_frame_with_adjustment", () => {
|
||||
expect(
|
||||
buildBadgeTitle("light_edit", "same_frame_with_adjustment"),
|
||||
).toBe(
|
||||
`${APPLICATION_MODE_TOOLTIP_KR.same_frame_with_adjustment} (same_frame_with_adjustment)`,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns composite output for layout_or_region_change", () => {
|
||||
expect(
|
||||
buildBadgeTitle("restructure", "layout_or_region_change"),
|
||||
).toBe(
|
||||
`${APPLICATION_MODE_TOOLTIP_KR.layout_or_region_change} (layout_or_region_change)`,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns composite output for exclude", () => {
|
||||
expect(buildBadgeTitle("reject", "exclude")).toBe(
|
||||
`${APPLICATION_MODE_TOOLTIP_KR.exclude} (exclude)`,
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to 'V4 label: <label>' when applicationMode is undefined (legacy fixtures pre-IMP-32)", () => {
|
||||
expect(buildBadgeTitle("use_as_is", undefined)).toBe("V4 label: use_as_is");
|
||||
});
|
||||
|
||||
it("falls back to 'V4 label: <label>' when applicationMode is an unknown string", () => {
|
||||
expect(buildBadgeTitle("light_edit", "some_future_mode")).toBe(
|
||||
"V4 label: light_edit",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeApplicationCandidates (IMP-41 u3)", () => {
|
||||
it("returns empty Map when input is undefined", () => {
|
||||
const result = mergeApplicationCandidates(undefined);
|
||||
expect(result).toBeInstanceOf(Map);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it("returns empty Map when input is null", () => {
|
||||
const result = mergeApplicationCandidates(null);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it("returns empty Map when input is not an array", () => {
|
||||
expect(mergeApplicationCandidates({ template_id: "f01" }).size).toBe(0);
|
||||
expect(mergeApplicationCandidates("f01").size).toBe(0);
|
||||
expect(mergeApplicationCandidates(42).size).toBe(0);
|
||||
});
|
||||
|
||||
it("returns empty Map when input is an empty array", () => {
|
||||
expect(mergeApplicationCandidates([]).size).toBe(0);
|
||||
});
|
||||
|
||||
it("keys entries by template_id and preserves the candidate payload", () => {
|
||||
const ac1 = {
|
||||
template_id: "f01",
|
||||
label: "use_as_is",
|
||||
application_mode: "direct_insert",
|
||||
auto_applicable: true,
|
||||
delegated_to: null,
|
||||
};
|
||||
const ac2 = {
|
||||
template_id: "f17",
|
||||
label: "light_edit",
|
||||
application_mode: "same_frame_with_adjustment",
|
||||
auto_applicable: false,
|
||||
delegated_to: "step10_contract_check",
|
||||
};
|
||||
const result = mergeApplicationCandidates([ac1, ac2]);
|
||||
expect(result.size).toBe(2);
|
||||
expect(result.get("f01")).toBe(ac1);
|
||||
expect(result.get("f17")).toBe(ac2);
|
||||
});
|
||||
|
||||
it("skips entries with missing or non-string template_id", () => {
|
||||
const result = mergeApplicationCandidates([
|
||||
{ label: "use_as_is" }, // missing template_id
|
||||
{ template_id: "", label: "light_edit" }, // empty string
|
||||
{ template_id: 17, label: "restructure" }, // non-string
|
||||
{ template_id: "f29", label: "reject" }, // valid
|
||||
]);
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.has("f29")).toBe(true);
|
||||
expect(result.has("")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the first occurrence on duplicate template_id keys (deterministic)", () => {
|
||||
const first = { template_id: "f01", label: "use_as_is" };
|
||||
const second = { template_id: "f01", label: "reject" };
|
||||
const result = mergeApplicationCandidates([first, second]);
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.get("f01")).toBe(first);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
// IMP-47B u11 — Frontend ai_repair_status notification surfacing.
|
||||
//
|
||||
// Scope (Stage 2 unit u11 contract):
|
||||
// 1) loadRun → RunMeta.ai_repair_status exposes the u8 step20 payload.
|
||||
// 2) formatAiRepairHumanReviewMessage(...) returns user-facing notification
|
||||
// text on the three failure axes (error / coverage_violated /
|
||||
// unsupported_kind) and returns null on success / no-AI paths.
|
||||
//
|
||||
// Pure-function unit test (no React Testing Library required — vitest is
|
||||
// already in devDependencies; @testing-library/* is NOT installed). The
|
||||
// Home.tsx wiring is a 2-line site that calls this helper after
|
||||
// setRunMeta(...); covering the helper covers the user-visible message text
|
||||
// directly without DOM rendering.
|
||||
//
|
||||
// File extension is `.tsx` per Stage 2 unit contract path; no JSX is required
|
||||
// for these assertions but the extension allows future RTL-based tests to
|
||||
// land here without renaming.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
formatAiRepairHumanReviewMessage,
|
||||
type AiRepairStatus,
|
||||
} from "../src/services/designAgentApi";
|
||||
|
||||
const baseCounts = {
|
||||
total: 1,
|
||||
applied: 0,
|
||||
no_proposal: 0,
|
||||
no_zone_match: 0,
|
||||
unsupported_kind: 0,
|
||||
error: 0,
|
||||
};
|
||||
|
||||
describe("formatAiRepairHumanReviewMessage (IMP-47B u11)", () => {
|
||||
it("returns null when ai_repair_status is null (legacy / pre-Step12 abort)", () => {
|
||||
expect(formatAiRepairHumanReviewMessage(null)).toBeNull();
|
||||
expect(formatAiRepairHumanReviewMessage(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when human_review_required=false (success / no-AI path)", () => {
|
||||
const ok: AiRepairStatus = {
|
||||
status: "ok",
|
||||
counts: { ...baseCounts, total: 0 },
|
||||
unsupported_kind_records: [],
|
||||
error_records: [],
|
||||
coverage_status: "ok",
|
||||
dropped_section_ids: [],
|
||||
human_review_required: false,
|
||||
};
|
||||
expect(formatAiRepairHumanReviewMessage(ok)).toBeNull();
|
||||
|
||||
const applied: AiRepairStatus = {
|
||||
...ok,
|
||||
status: "applied",
|
||||
counts: { ...baseCounts, total: 1, applied: 1 },
|
||||
};
|
||||
expect(formatAiRepairHumanReviewMessage(applied)).toBeNull();
|
||||
});
|
||||
|
||||
it("surfaces AI call failures with count + frame/manual guidance", () => {
|
||||
const errored: AiRepairStatus = {
|
||||
status: "error",
|
||||
counts: { ...baseCounts, total: 2, error: 2 },
|
||||
unsupported_kind_records: [],
|
||||
error_records: [
|
||||
{ unit_index: 0, source_section_ids: ["03-1"], error: "timeout" },
|
||||
{ unit_index: 1, source_section_ids: ["03-2"], error: "validation" },
|
||||
],
|
||||
coverage_status: "ok",
|
||||
dropped_section_ids: [],
|
||||
human_review_required: true,
|
||||
};
|
||||
const msg = formatAiRepairHumanReviewMessage(errored);
|
||||
expect(msg).not.toBeNull();
|
||||
expect(msg).toContain("AI 재구성 호출 실패");
|
||||
expect(msg).toContain("2");
|
||||
expect(msg).toContain("다른 frame 선택 또는 수동 편집 필요");
|
||||
});
|
||||
|
||||
it("surfaces coverage violations with the dropped section ids", () => {
|
||||
const dropped: AiRepairStatus = {
|
||||
status: "coverage_violated",
|
||||
counts: { ...baseCounts, total: 1, applied: 1 },
|
||||
unsupported_kind_records: [],
|
||||
error_records: [],
|
||||
coverage_status: "violated",
|
||||
dropped_section_ids: ["03-2"],
|
||||
human_review_required: true,
|
||||
};
|
||||
const msg = formatAiRepairHumanReviewMessage(dropped);
|
||||
expect(msg).not.toBeNull();
|
||||
expect(msg).toContain("콘텐츠 누락");
|
||||
expect(msg).toContain("03-2");
|
||||
expect(msg).toContain("다른 frame 선택 또는 수동 편집 필요");
|
||||
});
|
||||
|
||||
it("surfaces unsupported proposal kinds with the unsupported count", () => {
|
||||
const unsupported: AiRepairStatus = {
|
||||
status: "unsupported_kind",
|
||||
counts: { ...baseCounts, total: 1, unsupported_kind: 1 },
|
||||
unsupported_kind_records: [
|
||||
{
|
||||
unit_index: 0,
|
||||
source_section_ids: ["03-1"],
|
||||
apply_status: "unsupported_kind_for_reject_route:builder_options_patch",
|
||||
},
|
||||
],
|
||||
error_records: [],
|
||||
coverage_status: "ok",
|
||||
dropped_section_ids: [],
|
||||
human_review_required: true,
|
||||
};
|
||||
const msg = formatAiRepairHumanReviewMessage(unsupported);
|
||||
expect(msg).not.toBeNull();
|
||||
expect(msg).toContain("AI 제안 형식 미지원");
|
||||
expect(msg).toContain("1");
|
||||
expect(msg).toContain("다른 frame 선택 또는 수동 편집 필요");
|
||||
});
|
||||
|
||||
it("falls back to a generic human_review message on unknown status enums", () => {
|
||||
const future: AiRepairStatus = {
|
||||
status: "future_axis_not_yet_mapped",
|
||||
counts: { ...baseCounts, total: 0 },
|
||||
unsupported_kind_records: [],
|
||||
error_records: [],
|
||||
coverage_status: "ok",
|
||||
dropped_section_ids: [],
|
||||
human_review_required: true,
|
||||
};
|
||||
const msg = formatAiRepairHumanReviewMessage(future);
|
||||
expect(msg).not.toBeNull();
|
||||
expect(msg).toContain("human_review");
|
||||
expect(msg).toContain("future_axis_not_yet_mapped");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,809 @@
|
||||
// IMP-52 u3/u4 — vitest coverage for the vite `/api/user-overrides/:key`
|
||||
// GET and PUT endpoints and their supporting helpers.
|
||||
//
|
||||
// Scope:
|
||||
// u3 (read path):
|
||||
// 1) isValidUserOverridesKey: accept MDX-stem keys (03, 03__DX_BIM,
|
||||
// a-b.c), reject empty / leading-dot / `..` / `/` / `\` /
|
||||
// disallowed chars. Mirrors src/user_overrides_io.validate_key so
|
||||
// backend (u2) and frontend endpoint (u3) agree on every key.
|
||||
// 2) userOverridesPath: returns <root>/data/user_overrides/<key>.json.
|
||||
// 3) handleGetUserOverrides: method != GET → false (next chained for
|
||||
// PUT); invalid key → 400; missing file → 200 {}; corrupt JSON /
|
||||
// non-object root → 200 {} (graceful degrade per u1 load contract);
|
||||
// valid object JSON → 200 with parsed payload echoed back.
|
||||
//
|
||||
// u4 (write path):
|
||||
// 4) mergeUserOverrides: only KNOWN_USER_OVERRIDES_AXES mutated;
|
||||
// foreign top-level keys preserved; null clears axis; non-axis
|
||||
// partial keys dropped (allowlist).
|
||||
// 5) atomicWriteUserOverrides: tmp + rename; parent dir auto-created.
|
||||
// 6) handlePutUserOverrides: method != PUT → false (next chained);
|
||||
// invalid key → 400; invalid JSON → 400; non-object body → 400;
|
||||
// success → 200 with merged result; partial-merge preserves axes
|
||||
// not in payload; foreign-key preserve on disk; allowlist drops
|
||||
// unknown payload keys; explicit null clears; corrupt existing →
|
||||
// recover to clean state.
|
||||
//
|
||||
// Tests exercise the pure handlers with mock req/res — no real vite server.
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
KNOWN_USER_OVERRIDES_AXES,
|
||||
USER_OVERRIDES_KEY_RE,
|
||||
atomicWriteUserOverrides,
|
||||
handleGetUserOverrides,
|
||||
handlePutUserOverrides,
|
||||
isValidUserOverridesKey,
|
||||
mergeUserOverrides,
|
||||
userOverridesPath,
|
||||
} from "../../vite.config";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mock res helper — captures writeHead(status, headers) + end(body) so the
|
||||
// handler can be invoked synchronously without spawning a TCP socket.
|
||||
// ---------------------------------------------------------------------------
|
||||
function makeMockRes() {
|
||||
const state = {
|
||||
statusCode: 0,
|
||||
headers: {} as Record<string, string>,
|
||||
body: "",
|
||||
ended: false,
|
||||
};
|
||||
return {
|
||||
state,
|
||||
res: {
|
||||
writeHead(status: number, headers?: Record<string, string>) {
|
||||
state.statusCode = status;
|
||||
if (headers) state.headers = headers;
|
||||
},
|
||||
end(body?: string) {
|
||||
state.body = body ?? "";
|
||||
state.ended = true;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("USER_OVERRIDES_KEY_RE (IMP-52 u3)", () => {
|
||||
it("matches Python validate_key regex literally", () => {
|
||||
// The pattern locked in src/user_overrides_io.py:_KEY_RE — any drift here
|
||||
// means backend pipeline fallback (u2) and the vite endpoint disagree on
|
||||
// which keys are routable, which is the single failure mode that would
|
||||
// silently lose persisted overrides.
|
||||
expect(USER_OVERRIDES_KEY_RE.source).toBe(
|
||||
"^[A-Za-z0-9_][A-Za-z0-9_.\\-]*$",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidUserOverridesKey (IMP-52 u3)", () => {
|
||||
it("accepts MDX-stem-style keys actually used in samples/mdx/", () => {
|
||||
// 03 / 04 / 05 are the wired sample MDXs (vite.config.ts:SAMPLE_MDX_MAP).
|
||||
expect(isValidUserOverridesKey("03")).toBe(true);
|
||||
expect(isValidUserOverridesKey("04")).toBe(true);
|
||||
expect(isValidUserOverridesKey("05")).toBe(true);
|
||||
// Stage 1 EVIDENCE references 03__DX_BIM... — must round-trip.
|
||||
expect(isValidUserOverridesKey("03__DX_BIM")).toBe(true);
|
||||
expect(isValidUserOverridesKey("a-b.c")).toBe(true);
|
||||
expect(isValidUserOverridesKey("a")).toBe(true);
|
||||
expect(isValidUserOverridesKey("_leading_underscore")).toBe(true);
|
||||
expect(isValidUserOverridesKey("9starts_with_digit")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects empty and whitespace-only keys", () => {
|
||||
expect(isValidUserOverridesKey("")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects path-traversal substrings", () => {
|
||||
// `..` rejected explicitly even if the rest of the regex would allow it
|
||||
// — `a..b` would otherwise pass the char class.
|
||||
expect(isValidUserOverridesKey("..")).toBe(false);
|
||||
expect(isValidUserOverridesKey("a..b")).toBe(false);
|
||||
expect(isValidUserOverridesKey("../escape")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects path separators", () => {
|
||||
expect(isValidUserOverridesKey("a/b")).toBe(false);
|
||||
expect(isValidUserOverridesKey("a\\b")).toBe(false);
|
||||
expect(isValidUserOverridesKey("/")).toBe(false);
|
||||
expect(isValidUserOverridesKey("\\")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects keys starting with a non-word character", () => {
|
||||
expect(isValidUserOverridesKey(".hidden")).toBe(false);
|
||||
expect(isValidUserOverridesKey("-leading-dash")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects characters outside [A-Za-z0-9_.-]", () => {
|
||||
expect(isValidUserOverridesKey("a b")).toBe(false);
|
||||
expect(isValidUserOverridesKey("a:b")).toBe(false);
|
||||
expect(isValidUserOverridesKey("a*b")).toBe(false);
|
||||
expect(isValidUserOverridesKey("a%2Fb")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("userOverridesPath (IMP-52 u3)", () => {
|
||||
it("resolves <root>/data/user_overrides/<key>.json regardless of OS sep", () => {
|
||||
const root = path.join("X:", "design_agent");
|
||||
const got = userOverridesPath(root, "03");
|
||||
expect(got).toBe(path.join(root, "data", "user_overrides", "03.json"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleGetUserOverrides (IMP-52 u3)", () => {
|
||||
let tmpRoot: string;
|
||||
let overridesDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "imp52-u3-"));
|
||||
overridesDir = path.join(tmpRoot, "data", "user_overrides");
|
||||
fs.mkdirSync(overridesDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns false (next chained) when method != GET", () => {
|
||||
const { res, state } = makeMockRes();
|
||||
const handled = handleGetUserOverrides(
|
||||
{ method: "PUT", url: "/03" },
|
||||
res,
|
||||
tmpRoot,
|
||||
);
|
||||
expect(handled).toBe(false);
|
||||
// Crucial for u4: PUT must reach its own middleware unobstructed.
|
||||
expect(state.ended).toBe(false);
|
||||
expect(state.statusCode).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 400 on invalid key (path traversal)", () => {
|
||||
const { res, state } = makeMockRes();
|
||||
const handled = handleGetUserOverrides(
|
||||
{ method: "GET", url: "/../escape" },
|
||||
res,
|
||||
tmpRoot,
|
||||
);
|
||||
expect(handled).toBe(true);
|
||||
expect(state.statusCode).toBe(400);
|
||||
expect(JSON.parse(state.body)).toEqual({ error: "invalid key" });
|
||||
});
|
||||
|
||||
it("returns 400 on invalid key (missing key segment)", () => {
|
||||
const { res, state } = makeMockRes();
|
||||
const handled = handleGetUserOverrides(
|
||||
{ method: "GET", url: "/" },
|
||||
res,
|
||||
tmpRoot,
|
||||
);
|
||||
expect(handled).toBe(true);
|
||||
expect(state.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 200 {} on missing file (graceful degrade)", () => {
|
||||
const { res, state } = makeMockRes();
|
||||
const handled = handleGetUserOverrides(
|
||||
{ method: "GET", url: "/03" },
|
||||
res,
|
||||
tmpRoot,
|
||||
);
|
||||
expect(handled).toBe(true);
|
||||
expect(state.statusCode).toBe(200);
|
||||
expect(state.body).toBe("{}");
|
||||
});
|
||||
|
||||
it("returns 200 {} on corrupt JSON (graceful degrade)", () => {
|
||||
fs.writeFileSync(path.join(overridesDir, "03.json"), "{not json", "utf-8");
|
||||
const { res, state } = makeMockRes();
|
||||
const handled = handleGetUserOverrides(
|
||||
{ method: "GET", url: "/03" },
|
||||
res,
|
||||
tmpRoot,
|
||||
);
|
||||
expect(handled).toBe(true);
|
||||
expect(state.statusCode).toBe(200);
|
||||
expect(state.body).toBe("{}");
|
||||
});
|
||||
|
||||
it("returns 200 {} when JSON root is not an object", () => {
|
||||
// Mirrors u1 load() which treats non-object roots as corrupt — covers
|
||||
// both arrays and primitives so the frontend never receives a shape
|
||||
// the typed service (u5) can't deserialize.
|
||||
fs.writeFileSync(
|
||||
path.join(overridesDir, "arr.json"),
|
||||
JSON.stringify([1, 2, 3]),
|
||||
"utf-8",
|
||||
);
|
||||
const { res, state } = makeMockRes();
|
||||
const handled = handleGetUserOverrides(
|
||||
{ method: "GET", url: "/arr" },
|
||||
res,
|
||||
tmpRoot,
|
||||
);
|
||||
expect(handled).toBe(true);
|
||||
expect(state.statusCode).toBe(200);
|
||||
expect(state.body).toBe("{}");
|
||||
|
||||
fs.writeFileSync(path.join(overridesDir, "num.json"), "42", "utf-8");
|
||||
const { res: res2, state: state2 } = makeMockRes();
|
||||
handleGetUserOverrides({ method: "GET", url: "/num" }, res2, tmpRoot);
|
||||
expect(state2.statusCode).toBe(200);
|
||||
expect(state2.body).toBe("{}");
|
||||
});
|
||||
|
||||
it("returns 200 with parsed JSON object on hit", () => {
|
||||
const payload = {
|
||||
layout: "two_zone_split",
|
||||
frames: { "03-1+03-2": "frame_07" },
|
||||
zone_geometries: {
|
||||
top: { x: 0.05, y: 0.1, w: 0.9, h: 0.3 },
|
||||
},
|
||||
zone_sections: { top: ["03-1", "03-2"] },
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(overridesDir, "03.json"),
|
||||
JSON.stringify(payload),
|
||||
"utf-8",
|
||||
);
|
||||
const { res, state } = makeMockRes();
|
||||
const handled = handleGetUserOverrides(
|
||||
{ method: "GET", url: "/03" },
|
||||
res,
|
||||
tmpRoot,
|
||||
);
|
||||
expect(handled).toBe(true);
|
||||
expect(state.statusCode).toBe(200);
|
||||
expect(state.headers["Content-Type"]).toBe(
|
||||
"application/json; charset=utf-8",
|
||||
);
|
||||
expect(JSON.parse(state.body)).toEqual(payload);
|
||||
});
|
||||
|
||||
it("preserves foreign top-level keys in the response", () => {
|
||||
// Forward-compat with future axes (e.g., zone_sizes, image_overrides).
|
||||
// u1 save() preserves them on the disk side; u3 GET must surface them
|
||||
// so the frontend service (u5) can decide whether to act on them.
|
||||
const payload = {
|
||||
layout: "single_zone",
|
||||
zone_sizes: { top: 0.42 }, // not part of KNOWN_AXES yet
|
||||
custom_extension: { foo: "bar" },
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(overridesDir, "future.json"),
|
||||
JSON.stringify(payload),
|
||||
"utf-8",
|
||||
);
|
||||
const { res, state } = makeMockRes();
|
||||
handleGetUserOverrides({ method: "GET", url: "/future" }, res, tmpRoot);
|
||||
expect(state.statusCode).toBe(200);
|
||||
expect(JSON.parse(state.body)).toEqual(payload);
|
||||
});
|
||||
|
||||
it("strips the leading slash and ignores query string when keying", () => {
|
||||
fs.writeFileSync(
|
||||
path.join(overridesDir, "03.json"),
|
||||
JSON.stringify({ layout: "x" }),
|
||||
"utf-8",
|
||||
);
|
||||
const { res, state } = makeMockRes();
|
||||
handleGetUserOverrides(
|
||||
{ method: "GET", url: "/03?ts=1747884800" },
|
||||
res,
|
||||
tmpRoot,
|
||||
);
|
||||
expect(state.statusCode).toBe(200);
|
||||
expect(JSON.parse(state.body)).toEqual({ layout: "x" });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IMP-52 u4 — PUT endpoint coverage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("KNOWN_USER_OVERRIDES_AXES (IMP-52 u4)", () => {
|
||||
it("matches the Python KNOWN_AXES tuple in src/user_overrides_io.py", () => {
|
||||
// The on-disk schema is shared with backend pipeline fallback (u2).
|
||||
// Any drift here means a PUT could write an axis that the Python
|
||||
// load() ignores, or vice-versa, silently losing user overrides.
|
||||
expect(KNOWN_USER_OVERRIDES_AXES).toEqual([
|
||||
"layout",
|
||||
"zone_geometries",
|
||||
"zone_sections",
|
||||
"frames",
|
||||
"image_overrides",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeUserOverrides (IMP-52 u4)", () => {
|
||||
it("only mutates KNOWN_AXES present in partial", () => {
|
||||
const existing = {
|
||||
layout: "old",
|
||||
frames: { "03-1": "frame_01" },
|
||||
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } },
|
||||
zone_sections: { top: ["03-1"] },
|
||||
};
|
||||
const merged = mergeUserOverrides(existing, { layout: "new" });
|
||||
expect(merged.layout).toBe("new");
|
||||
// axes not in partial are preserved
|
||||
expect(merged.frames).toEqual({ "03-1": "frame_01" });
|
||||
expect(merged.zone_geometries).toEqual({
|
||||
top: { x: 0, y: 0, w: 1, h: 0.5 },
|
||||
});
|
||||
expect(merged.zone_sections).toEqual({ top: ["03-1"] });
|
||||
});
|
||||
|
||||
it("preserves foreign top-level keys in existing", () => {
|
||||
// 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 },
|
||||
schema_version: 2,
|
||||
};
|
||||
const merged = mergeUserOverrides(existing, { layout: "new" });
|
||||
expect(merged.zone_sizes).toEqual({ top: 0.42 });
|
||||
expect(merged.schema_version).toBe(2);
|
||||
});
|
||||
|
||||
it("clears axis when partial value is null (explicit clear)", () => {
|
||||
const existing = { layout: "x", frames: { "03-1": "f01" } };
|
||||
const merged = mergeUserOverrides(existing, { layout: null });
|
||||
expect("layout" in merged).toBe(false);
|
||||
expect(merged.frames).toEqual({ "03-1": "f01" });
|
||||
});
|
||||
|
||||
it("drops non-axis keys in partial (allowlist)", () => {
|
||||
// PUT payload may carry junk fields (typo, malicious key); allowlist
|
||||
// ensures only the 5 axes can be written to disk.
|
||||
const merged = mergeUserOverrides(
|
||||
{},
|
||||
{ layout: "x", random_key: "evil", __proto__: "x" } as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
);
|
||||
expect(merged.layout).toBe("x");
|
||||
expect("random_key" in merged).toBe(false);
|
||||
});
|
||||
|
||||
it("merges all 5 axes when present in partial", () => {
|
||||
const merged = mergeUserOverrides(
|
||||
{},
|
||||
{
|
||||
layout: "two_zone_split",
|
||||
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));
|
||||
mergeUserOverrides(existing, { layout: "new", layout_evil: "x" } as Record<
|
||||
string,
|
||||
unknown
|
||||
>);
|
||||
expect(existing).toEqual(snapshot);
|
||||
});
|
||||
});
|
||||
|
||||
describe("atomicWriteUserOverrides (IMP-52 u4)", () => {
|
||||
let tmpRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "imp52-u4-aw-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("creates parent dir if missing and writes JSON content", () => {
|
||||
const filePath = path.join(tmpRoot, "data", "user_overrides", "03.json");
|
||||
expect(fs.existsSync(path.dirname(filePath))).toBe(false);
|
||||
atomicWriteUserOverrides(filePath, { layout: "x" });
|
||||
expect(fs.existsSync(filePath)).toBe(true);
|
||||
expect(JSON.parse(fs.readFileSync(filePath, "utf-8"))).toEqual({
|
||||
layout: "x",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves no .tmp residue after a successful write", () => {
|
||||
const filePath = path.join(tmpRoot, "data", "user_overrides", "03.json");
|
||||
atomicWriteUserOverrides(filePath, { layout: "x" });
|
||||
const dirContents = fs.readdirSync(path.dirname(filePath));
|
||||
expect(dirContents).toEqual(["03.json"]);
|
||||
});
|
||||
|
||||
it("overwrites an existing file atomically", () => {
|
||||
const filePath = path.join(tmpRoot, "data", "user_overrides", "03.json");
|
||||
atomicWriteUserOverrides(filePath, { layout: "v1" });
|
||||
atomicWriteUserOverrides(filePath, { layout: "v2" });
|
||||
expect(JSON.parse(fs.readFileSync(filePath, "utf-8"))).toEqual({
|
||||
layout: "v2",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// req mock — EventEmitter with method/url + a `send(body)` helper that
|
||||
// emits the data chunk and then `end`, mirroring the node IncomingMessage
|
||||
// flow used by vite's dev middlewares.
|
||||
function makeMockReq(opts: {
|
||||
method?: string;
|
||||
url?: string;
|
||||
}): EventEmitter & { method?: string; url?: string; send: (body: string) => void } {
|
||||
const ee = new EventEmitter() as EventEmitter & {
|
||||
method?: string;
|
||||
url?: string;
|
||||
send: (body: string) => void;
|
||||
};
|
||||
ee.method = opts.method;
|
||||
ee.url = opts.url;
|
||||
ee.send = (body: string) => {
|
||||
if (body.length > 0) ee.emit("data", Buffer.from(body, "utf-8"));
|
||||
ee.emit("end");
|
||||
};
|
||||
return ee;
|
||||
}
|
||||
|
||||
describe("handlePutUserOverrides (IMP-52 u4)", () => {
|
||||
let tmpRoot: string;
|
||||
let overridesDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "imp52-u4-"));
|
||||
overridesDir = path.join(tmpRoot, "data", "user_overrides");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns false (next chained) when method != PUT", () => {
|
||||
const req = makeMockReq({ method: "GET", url: "/03" });
|
||||
const { res, state } = makeMockRes();
|
||||
const handled = handlePutUserOverrides(req, res, tmpRoot);
|
||||
expect(handled).toBe(false);
|
||||
expect(state.ended).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 400 on invalid key", () => {
|
||||
const req = makeMockReq({ method: "PUT", url: "/../escape" });
|
||||
const { res, state } = makeMockRes();
|
||||
const handled = handlePutUserOverrides(req, res, tmpRoot);
|
||||
expect(handled).toBe(true);
|
||||
expect(state.statusCode).toBe(400);
|
||||
expect(JSON.parse(state.body)).toEqual({ error: "invalid key" });
|
||||
});
|
||||
|
||||
it("returns 400 on invalid JSON body", () => {
|
||||
const req = makeMockReq({ method: "PUT", url: "/03" });
|
||||
const { res, state } = makeMockRes();
|
||||
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
||||
req.send("{not json");
|
||||
expect(state.statusCode).toBe(400);
|
||||
expect(JSON.parse(state.body)).toEqual({ error: "invalid JSON" });
|
||||
// file MUST NOT have been created on parse failure
|
||||
expect(fs.existsSync(path.join(overridesDir, "03.json"))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 400 when JSON body is an array", () => {
|
||||
const req = makeMockReq({ method: "PUT", url: "/03" });
|
||||
const { res, state } = makeMockRes();
|
||||
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
||||
req.send(JSON.stringify([1, 2, 3]));
|
||||
expect(state.statusCode).toBe(400);
|
||||
expect(JSON.parse(state.body)).toEqual({
|
||||
error: "body must be a JSON object",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 400 when JSON body is a primitive", () => {
|
||||
const req = makeMockReq({ method: "PUT", url: "/03" });
|
||||
const { res, state } = makeMockRes();
|
||||
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
||||
req.send("42");
|
||||
expect(state.statusCode).toBe(400);
|
||||
expect(JSON.parse(state.body)).toEqual({
|
||||
error: "body must be a JSON object",
|
||||
});
|
||||
});
|
||||
|
||||
it("creates the override file on first PUT and returns merged body", () => {
|
||||
const req = makeMockReq({ method: "PUT", url: "/03" });
|
||||
const { res, state } = makeMockRes();
|
||||
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
||||
|
||||
const payload = { layout: "two_zone_split" };
|
||||
req.send(JSON.stringify(payload));
|
||||
|
||||
expect(state.statusCode).toBe(200);
|
||||
expect(state.headers["Content-Type"]).toBe(
|
||||
"application/json; charset=utf-8",
|
||||
);
|
||||
expect(JSON.parse(state.body)).toEqual({ layout: "two_zone_split" });
|
||||
|
||||
const filePath = path.join(overridesDir, "03.json");
|
||||
expect(fs.existsSync(filePath)).toBe(true);
|
||||
expect(JSON.parse(fs.readFileSync(filePath, "utf-8"))).toEqual({
|
||||
layout: "two_zone_split",
|
||||
});
|
||||
});
|
||||
|
||||
it("partial-merges: axes absent from payload are preserved on disk", () => {
|
||||
fs.mkdirSync(overridesDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(overridesDir, "03.json"),
|
||||
JSON.stringify({
|
||||
layout: "old",
|
||||
frames: { "03-1": "frame_01" },
|
||||
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({ layout: "new" }));
|
||||
|
||||
expect(state.statusCode).toBe(200);
|
||||
const onDisk = JSON.parse(
|
||||
fs.readFileSync(path.join(overridesDir, "03.json"), "utf-8"),
|
||||
);
|
||||
expect(onDisk).toEqual({
|
||||
layout: "new",
|
||||
frames: { "03-1": "frame_01" },
|
||||
zone_sections: { top: ["03-1"] },
|
||||
});
|
||||
});
|
||||
|
||||
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 },
|
||||
schema_version: 2,
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const req = makeMockReq({ method: "PUT", url: "/future" });
|
||||
const { res } = makeMockRes();
|
||||
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
||||
req.send(JSON.stringify({ layout: "new" }));
|
||||
|
||||
const onDisk = JSON.parse(
|
||||
fs.readFileSync(path.join(overridesDir, "future.json"), "utf-8"),
|
||||
);
|
||||
expect(onDisk.zone_sizes).toEqual({ top: 0.42 });
|
||||
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" });
|
||||
const { res, state } = makeMockRes();
|
||||
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
||||
|
||||
req.send(
|
||||
JSON.stringify({
|
||||
layout: "two_zone_split",
|
||||
random_evil_key: "should not persist",
|
||||
}),
|
||||
);
|
||||
|
||||
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" });
|
||||
expect("random_evil_key" in onDisk).toBe(false);
|
||||
});
|
||||
|
||||
it("clears an axis when payload sets it to null", () => {
|
||||
fs.mkdirSync(overridesDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(overridesDir, "03.json"),
|
||||
JSON.stringify({ layout: "old", frames: { "03-1": "f01" } }),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const req = makeMockReq({ method: "PUT", url: "/03" });
|
||||
const { res } = makeMockRes();
|
||||
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
||||
req.send(JSON.stringify({ layout: null }));
|
||||
|
||||
const onDisk = JSON.parse(
|
||||
fs.readFileSync(path.join(overridesDir, "03.json"), "utf-8"),
|
||||
);
|
||||
expect("layout" in onDisk).toBe(false);
|
||||
expect(onDisk.frames).toEqual({ "03-1": "f01" });
|
||||
});
|
||||
|
||||
it("recovers from corrupt existing file (graceful degrade)", () => {
|
||||
fs.mkdirSync(overridesDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(overridesDir, "03.json"),
|
||||
"{this is not JSON",
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const req = makeMockReq({ method: "PUT", url: "/03" });
|
||||
const { res, state } = makeMockRes();
|
||||
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
||||
req.send(JSON.stringify({ layout: "recovered" }));
|
||||
|
||||
expect(state.statusCode).toBe(200);
|
||||
const onDisk = JSON.parse(
|
||||
fs.readFileSync(path.join(overridesDir, "03.json"), "utf-8"),
|
||||
);
|
||||
expect(onDisk).toEqual({ layout: "recovered" });
|
||||
});
|
||||
|
||||
it("treats array-rooted existing file as empty (graceful degrade)", () => {
|
||||
fs.mkdirSync(overridesDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(overridesDir, "03.json"),
|
||||
JSON.stringify(["not", "an", "object"]),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const req = makeMockReq({ method: "PUT", url: "/03" });
|
||||
const { res, state } = makeMockRes();
|
||||
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
||||
req.send(JSON.stringify({ layout: "recovered" }));
|
||||
|
||||
expect(state.statusCode).toBe(200);
|
||||
const onDisk = JSON.parse(
|
||||
fs.readFileSync(path.join(overridesDir, "03.json"), "utf-8"),
|
||||
);
|
||||
expect(onDisk).toEqual({ layout: "recovered" });
|
||||
});
|
||||
|
||||
it("strips the leading slash and ignores query string when keying", () => {
|
||||
const req = makeMockReq({
|
||||
method: "PUT",
|
||||
url: "/03?ts=1747884800",
|
||||
});
|
||||
const { res, state } = makeMockRes();
|
||||
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
||||
req.send(JSON.stringify({ layout: "x" }));
|
||||
|
||||
expect(state.statusCode).toBe(200);
|
||||
expect(fs.existsSync(path.join(overridesDir, "03.json"))).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts an empty body as a no-op partial (no axes mutated)", () => {
|
||||
fs.mkdirSync(overridesDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(overridesDir, "03.json"),
|
||||
JSON.stringify({ layout: "kept" }),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const req = makeMockReq({ method: "PUT", url: "/03" });
|
||||
const { res, state } = makeMockRes();
|
||||
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
||||
req.send("");
|
||||
|
||||
expect(state.statusCode).toBe(200);
|
||||
const onDisk = JSON.parse(
|
||||
fs.readFileSync(path.join(overridesDir, "03.json"), "utf-8"),
|
||||
);
|
||||
expect(onDisk).toEqual({ layout: "kept" });
|
||||
});
|
||||
|
||||
it("accepts a chunked PUT body (concatenates data events)", () => {
|
||||
const req = makeMockReq({ method: "PUT", url: "/03" });
|
||||
const { res, state } = makeMockRes();
|
||||
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
||||
|
||||
const body = JSON.stringify({
|
||||
layout: "two_zone_split",
|
||||
frames: { "03-1": "frame_01" },
|
||||
});
|
||||
// Emit in two halves to simulate a fragmented HTTP body.
|
||||
const half = Math.floor(body.length / 2);
|
||||
req.emit("data", Buffer.from(body.slice(0, half), "utf-8"));
|
||||
req.emit("data", Buffer.from(body.slice(half), "utf-8"));
|
||||
req.emit("end");
|
||||
|
||||
expect(state.statusCode).toBe(200);
|
||||
expect(JSON.parse(state.body)).toEqual({
|
||||
layout: "two_zone_split",
|
||||
frames: { "03-1": "frame_01" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,462 @@
|
||||
// IMP-52 u6 — vitest coverage for restore-on-reopen helpers used by
|
||||
// `Home.tsx` to layer persisted `user_overrides.json` payloads onto the
|
||||
// in-memory `UserSelection` and `slidePlan`.
|
||||
//
|
||||
// Scope (Stage 2 unit u6 contract):
|
||||
// 1) deriveUserOverridesKey(filename) — MDX-stem key derivation that
|
||||
// matches backend u2 fallback's `Path(args.mdx_path).stem`. Strips
|
||||
// `.mdx` case-insensitively; preserves everything else.
|
||||
// 2) applyPersistedNonFrameOverrides(selection, persisted) — layers
|
||||
// layout / zone_geometries / zone_sections onto an existing selection.
|
||||
// Frames are NOT layered here (unit_id key requires slidePlan).
|
||||
// Foreign / unrecognized payloads degrade silently (no throw, no
|
||||
// partial mutation).
|
||||
// 3) remapPersistedFramesToZoneFrames(slidePlan, framesByUnitId) —
|
||||
// remaps frames (unit_id → template_id) to zone_frames (region.id →
|
||||
// template_id). Stale unit_ids (no matching zone) drop silently;
|
||||
// zones without internal_regions[0] or without section_ids are
|
||||
// skipped without throwing.
|
||||
//
|
||||
// All helpers are pure; tests run in vitest's default node environment
|
||||
// without RTL / jsdom. Home.tsx wiring sites (handleFileUpload pre-Generate
|
||||
// seed + handleGenerate post-loadRun frame remap) are 1-line call sites that
|
||||
// these helpers cover end-to-end.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type {
|
||||
LayoutPresetId,
|
||||
SlidePlan,
|
||||
UserSelection,
|
||||
Zone,
|
||||
} from "../src/types/designAgent";
|
||||
import {
|
||||
applyPersistedNonFrameOverrides,
|
||||
createInitialUserSelection,
|
||||
deriveUserOverridesKey,
|
||||
remapPersistedFramesToZoneFrames,
|
||||
saveImageOverride,
|
||||
} from "../src/utils/slidePlanUtils";
|
||||
|
||||
// ─── Fixtures ───────────────────────────────────────────────────────────────
|
||||
|
||||
function makeSelection(overrides?: Partial<UserSelection["overrides"]>): UserSelection {
|
||||
return {
|
||||
selectedSectionId: null,
|
||||
selectedZoneId: null,
|
||||
selectedRegionId: null,
|
||||
overrides: {
|
||||
layout_preset: undefined,
|
||||
zone_frames: {},
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeZone(
|
||||
partial: { id: string; zone_id: string; section_ids: string[]; region_id?: string },
|
||||
): Zone {
|
||||
return {
|
||||
id: partial.id,
|
||||
zone_id: partial.zone_id,
|
||||
section_ids: partial.section_ids,
|
||||
position: { x: 0, y: 0, width: 1, height: 1 },
|
||||
internal_regions: [
|
||||
{
|
||||
id: partial.region_id ?? `${partial.id}-r0`,
|
||||
region_id: "region-single",
|
||||
role: "primary",
|
||||
content_type: "text_block",
|
||||
ratio_estimate: 1,
|
||||
content_unit_ids: [],
|
||||
frame_match_strategy: {
|
||||
kind: "frame_match",
|
||||
frame_id: null,
|
||||
display_strategy: "inline_full",
|
||||
},
|
||||
frame_candidates: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function makeSlidePlan(zones: Zone[], layout: LayoutPresetId = "single"): SlidePlan {
|
||||
return {
|
||||
id: "plan-1",
|
||||
title: "test plan",
|
||||
layout_preset: layout,
|
||||
zones,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── deriveUserOverridesKey ─────────────────────────────────────────────────
|
||||
|
||||
describe("deriveUserOverridesKey (IMP-52 u6)", () => {
|
||||
it("strips trailing .mdx", () => {
|
||||
expect(deriveUserOverridesKey("03__DX_BIM_value_chain.mdx")).toBe(
|
||||
"03__DX_BIM_value_chain",
|
||||
);
|
||||
});
|
||||
|
||||
it("strips .MDX case-insensitively", () => {
|
||||
expect(deriveUserOverridesKey("04_demo.MDX")).toBe("04_demo");
|
||||
expect(deriveUserOverridesKey("05_intro.Mdx")).toBe("05_intro");
|
||||
});
|
||||
|
||||
it("returns the filename unchanged when no .mdx suffix", () => {
|
||||
expect(deriveUserOverridesKey("03__DX_BIM_value_chain")).toBe(
|
||||
"03__DX_BIM_value_chain",
|
||||
);
|
||||
expect(deriveUserOverridesKey("notes.txt")).toBe("notes.txt");
|
||||
});
|
||||
|
||||
it("only strips the final .mdx, preserves dots inside the stem", () => {
|
||||
expect(deriveUserOverridesKey("05.2_layer.mdx")).toBe("05.2_layer");
|
||||
});
|
||||
|
||||
it("returns empty string for empty input", () => {
|
||||
expect(deriveUserOverridesKey("")).toBe("");
|
||||
});
|
||||
|
||||
it("matches backend Path(args.mdx_path).stem for the canonical demo MDXs", () => {
|
||||
// These are the three canonical samples loaded by /api/sample-mdx; the
|
||||
// key on both ends must agree so a write from frontend (PUT) is found
|
||||
// by backend (u2 fallback on next pipeline run).
|
||||
expect(deriveUserOverridesKey("03_demo.mdx")).toBe("03_demo");
|
||||
expect(deriveUserOverridesKey("04_demo.mdx")).toBe("04_demo");
|
||||
expect(deriveUserOverridesKey("05_demo.mdx")).toBe("05_demo");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── applyPersistedNonFrameOverrides ────────────────────────────────────────
|
||||
|
||||
describe("applyPersistedNonFrameOverrides (IMP-52 u6)", () => {
|
||||
it("layers layout / zone_geometries / zone_sections", () => {
|
||||
const sel = makeSelection();
|
||||
const persisted = {
|
||||
layout: "horizontal-2",
|
||||
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.4 } },
|
||||
zone_sections: { top: ["03-1"], bottom: ["03-2"] },
|
||||
} as const;
|
||||
const next = applyPersistedNonFrameOverrides(sel, persisted);
|
||||
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"],
|
||||
bottom: ["03-2"],
|
||||
});
|
||||
});
|
||||
|
||||
it("does NOT layer frames (frames need post-loadRun remap)", () => {
|
||||
const sel = makeSelection({ zone_frames: { "r-existing": "tpl-existing" } });
|
||||
const persisted = {
|
||||
frames: { "03-1+03-2": "tpl-persisted" },
|
||||
};
|
||||
const next = applyPersistedNonFrameOverrides(sel, persisted);
|
||||
// zone_frames is untouched here; the post-loadRun remap step owns it.
|
||||
expect(next.overrides.zone_frames).toEqual({ "r-existing": "tpl-existing" });
|
||||
});
|
||||
|
||||
it("rejects layout values outside the 8 known preset ids", () => {
|
||||
const sel = makeSelection({ layout_preset: "single" });
|
||||
const next = applyPersistedNonFrameOverrides(sel, {
|
||||
layout: "rogue-layout" as unknown as string,
|
||||
});
|
||||
// Stays at the original — preset whitelist guards against hand-edited
|
||||
// files or future schema drift.
|
||||
expect(next.overrides.layout_preset).toBe("single");
|
||||
});
|
||||
|
||||
it("ignores zone_geometries when the payload axis is an array", () => {
|
||||
const sel = makeSelection({ zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } } });
|
||||
const next = applyPersistedNonFrameOverrides(sel, {
|
||||
zone_geometries: [] as unknown as Record<string, { x: number; y: number; w: number; h: number }>,
|
||||
});
|
||||
expect(next.overrides.zone_geometries).toEqual({
|
||||
top: { x: 0, y: 0, w: 1, h: 0.5 },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the selection unchanged when persisted is null / undefined / non-object", () => {
|
||||
const sel = makeSelection({ layout_preset: "single" });
|
||||
expect(applyPersistedNonFrameOverrides(sel, null)).toEqual(sel);
|
||||
expect(applyPersistedNonFrameOverrides(sel, undefined)).toEqual(sel);
|
||||
});
|
||||
|
||||
it("returns the selection unchanged when persisted is empty {}", () => {
|
||||
const sel = makeSelection({ layout_preset: "single" });
|
||||
const next = applyPersistedNonFrameOverrides(sel, {});
|
||||
expect(next.overrides.layout_preset).toBe("single");
|
||||
expect(next.overrides.zone_geometries).toEqual({});
|
||||
expect(next.overrides.zone_sections).toEqual({});
|
||||
});
|
||||
|
||||
it("returns a NEW selection object (no mutation of input)", () => {
|
||||
const sel = makeSelection();
|
||||
const next = applyPersistedNonFrameOverrides(sel, { layout: "vertical-2" });
|
||||
expect(next).not.toBe(sel);
|
||||
expect(next.overrides).not.toBe(sel.overrides);
|
||||
// Input still pristine.
|
||||
expect(sel.overrides.layout_preset).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── remapPersistedFramesToZoneFrames ───────────────────────────────────────
|
||||
|
||||
describe("remapPersistedFramesToZoneFrames (IMP-52 u6)", () => {
|
||||
it("maps unit_id (section_ids joined by +) to region.id", () => {
|
||||
const plan = makeSlidePlan([
|
||||
makeZone({ id: "z-top", zone_id: "top", section_ids: ["03-1"], region_id: "r-top" }),
|
||||
makeZone({ id: "z-bot", zone_id: "bottom", section_ids: ["03-2", "03-3"], region_id: "r-bot" }),
|
||||
]);
|
||||
const remapped = remapPersistedFramesToZoneFrames(plan, {
|
||||
"03-1": "tpl-a",
|
||||
"03-2+03-3": "tpl-b",
|
||||
});
|
||||
expect(remapped).toEqual({
|
||||
"r-top": "tpl-a",
|
||||
"r-bot": "tpl-b",
|
||||
});
|
||||
});
|
||||
|
||||
it("silently drops persisted entries whose unit_id matches no zone", () => {
|
||||
const plan = makeSlidePlan([
|
||||
makeZone({ id: "z-top", zone_id: "top", section_ids: ["03-1"], region_id: "r-top" }),
|
||||
]);
|
||||
const remapped = remapPersistedFramesToZoneFrames(plan, {
|
||||
"03-1": "tpl-a",
|
||||
"stale-section-id": "tpl-stale", // user changed zone_sections between sessions
|
||||
});
|
||||
expect(remapped).toEqual({ "r-top": "tpl-a" });
|
||||
});
|
||||
|
||||
it("returns {} when slidePlan is null / undefined", () => {
|
||||
expect(remapPersistedFramesToZoneFrames(null, { "03-1": "tpl-a" })).toEqual({});
|
||||
expect(remapPersistedFramesToZoneFrames(undefined, { "03-1": "tpl-a" })).toEqual({});
|
||||
});
|
||||
|
||||
it("returns {} when framesByUnitId is null / undefined / {}", () => {
|
||||
const plan = makeSlidePlan([
|
||||
makeZone({ id: "z-top", zone_id: "top", section_ids: ["03-1"], region_id: "r-top" }),
|
||||
]);
|
||||
expect(remapPersistedFramesToZoneFrames(plan, null)).toEqual({});
|
||||
expect(remapPersistedFramesToZoneFrames(plan, undefined)).toEqual({});
|
||||
expect(remapPersistedFramesToZoneFrames(plan, {})).toEqual({});
|
||||
});
|
||||
|
||||
it("skips zones with empty section_ids (no unit_id to derive)", () => {
|
||||
const plan = makeSlidePlan([
|
||||
makeZone({ id: "z-empty", zone_id: "empty", section_ids: [], region_id: "r-empty" }),
|
||||
makeZone({ id: "z-top", zone_id: "top", section_ids: ["03-1"], region_id: "r-top" }),
|
||||
]);
|
||||
const remapped = remapPersistedFramesToZoneFrames(plan, {
|
||||
"": "tpl-should-not-match-empty-join",
|
||||
"03-1": "tpl-a",
|
||||
});
|
||||
expect(remapped).toEqual({ "r-top": "tpl-a" });
|
||||
});
|
||||
|
||||
it("skips zones without internal_regions[0]", () => {
|
||||
const plan: SlidePlan = {
|
||||
id: "plan-x",
|
||||
title: "no regions",
|
||||
layout_preset: "single",
|
||||
zones: [
|
||||
{
|
||||
id: "z-bare",
|
||||
zone_id: "bare",
|
||||
section_ids: ["03-1"],
|
||||
position: { x: 0, y: 0, width: 1, height: 1 },
|
||||
internal_regions: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(remapPersistedFramesToZoneFrames(plan, { "03-1": "tpl-a" })).toEqual({});
|
||||
});
|
||||
|
||||
it("ignores persisted entries with empty / non-string template_id", () => {
|
||||
const plan = makeSlidePlan([
|
||||
makeZone({ id: "z-top", zone_id: "top", section_ids: ["03-1"], region_id: "r-top" }),
|
||||
]);
|
||||
const remapped = remapPersistedFramesToZoneFrames(plan, {
|
||||
"03-1": "" as unknown as string,
|
||||
});
|
||||
expect(remapped).toEqual({});
|
||||
});
|
||||
|
||||
it("preserves the user-selected template even when slidePlan layout would imply a different default", () => {
|
||||
// Backend u2 fallback should already have applied the user's frame
|
||||
// override via CLI args, but if the plan's default frame_match_strategy
|
||||
// disagrees, the post-loadRun remap still surfaces the user's choice
|
||||
// for the SlideCanvas override-vs-default preview indicator.
|
||||
const plan = makeSlidePlan([
|
||||
makeZone({ id: "z-top", zone_id: "top", section_ids: ["03-1"], region_id: "r-top" }),
|
||||
]);
|
||||
const remapped = remapPersistedFramesToZoneFrames(plan, {
|
||||
"03-1": "user-chosen-tpl",
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,561 @@
|
||||
// IMP-52 u5 — vitest coverage for the typed frontend client at
|
||||
// `Front/client/src/services/userOverridesApi.ts`.
|
||||
//
|
||||
// Scope (Stage 2 unit u5 contract):
|
||||
// 1) getUserOverrides:
|
||||
// • 200 with object body → typed payload echoed.
|
||||
// • 200 with array / primitive / non-JSON body → {} (graceful).
|
||||
// • 4xx / 5xx → {}.
|
||||
// • fetch reject (network) → {} (no throw to caller).
|
||||
// 2) saveUserOverrides:
|
||||
// • Single call: PUT fires after exactly 300 ms with the mutated-axis
|
||||
// partial as body (NOT a full snapshot of UserOverrides).
|
||||
// • Rapid coalescing: N calls in <300 ms window collapse to ONE PUT
|
||||
// carrying the union of mutated axes.
|
||||
// • Per-axis later-wins: later call's value replaces earlier pending
|
||||
// value for the same axis; axes the user did not touch stay absent.
|
||||
// • null sentinel: forwarded verbatim so u4 mergeUserOverrides can
|
||||
// `delete` the axis on disk.
|
||||
// • Per-key isolation: rapid edits to "03" do not delay flush of "04".
|
||||
// • Promise resolves with the server-side merged document.
|
||||
// • Promise rejects on 4xx/5xx and on fetch reject.
|
||||
// 3) flushUserOverrides:
|
||||
// • No arg → flushes all pending buckets immediately (no 300 ms wait).
|
||||
// • Specific key → flushes only that bucket; other buckets stay
|
||||
// pending.
|
||||
// • No-op when no buckets are pending.
|
||||
//
|
||||
// All tests mock `fetch` and use `vi.useFakeTimers()` to make the 300 ms
|
||||
// debounce deterministic — no real wall-clock waits.
|
||||
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
type Mock,
|
||||
} from "vitest";
|
||||
import {
|
||||
__resetUserOverridesBuckets_FOR_TEST,
|
||||
flushUserOverrides,
|
||||
getUserOverrides,
|
||||
saveUserOverrides,
|
||||
type UserOverridesPartial,
|
||||
} from "../src/services/userOverridesApi";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fetch mock — minimal Response stub with the two methods the service uses
|
||||
// (.ok / .status / .json()). We track the call log so debounce + coalescing
|
||||
// can be asserted by counting PUTs and inspecting their bodies.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type MockResponse = {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
json: () => Promise<unknown>;
|
||||
};
|
||||
|
||||
function mockResponse(body: unknown, ok = true, status = 200): MockResponse {
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
json: async () => body,
|
||||
};
|
||||
}
|
||||
|
||||
let fetchMock: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
vi.useFakeTimers();
|
||||
__resetUserOverridesBuckets_FOR_TEST();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
__resetUserOverridesBuckets_FOR_TEST();
|
||||
});
|
||||
|
||||
// Microtask-flushing helper. vi.advanceTimersByTime fires timers, but the
|
||||
// promise chain inside flushBucket (await fetch → await res.json() → resolve
|
||||
// waiters) needs the microtask queue to drain before assertions run.
|
||||
async function drainMicrotasks(): Promise<void> {
|
||||
// Multiple ticks because each `await` in flushBucket adds another tick.
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
function lastPutBody(): unknown {
|
||||
const lastCall = fetchMock.mock.calls.at(-1);
|
||||
if (!lastCall) throw new Error("fetch was not called");
|
||||
const init = lastCall[1] as RequestInit | undefined;
|
||||
if (!init?.body) throw new Error("fetch was called without a body");
|
||||
return JSON.parse(String(init.body));
|
||||
}
|
||||
|
||||
function putCallsCount(): number {
|
||||
return fetchMock.mock.calls.filter(
|
||||
(call) => (call[1] as RequestInit | undefined)?.method === "PUT",
|
||||
).length;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// getUserOverrides
|
||||
// ============================================================================
|
||||
|
||||
describe("getUserOverrides (IMP-52 u5)", () => {
|
||||
it("issues GET against /api/user-overrides/<key>", async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockResponse({ layout: "x" }));
|
||||
await getUserOverrides("03");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("/api/user-overrides/03");
|
||||
expect((init as RequestInit).method).toBe("GET");
|
||||
});
|
||||
|
||||
it("returns the parsed object on 200 with object body", async () => {
|
||||
const payload = {
|
||||
layout: "two_zone_split",
|
||||
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"] },
|
||||
};
|
||||
fetchMock.mockResolvedValueOnce(mockResponse(payload));
|
||||
const got = await getUserOverrides("03");
|
||||
expect(got).toEqual(payload);
|
||||
});
|
||||
|
||||
it("returns {} when JSON root is an array (mirrors u3 graceful degrade)", async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockResponse([1, 2, 3]));
|
||||
expect(await getUserOverrides("03")).toEqual({});
|
||||
});
|
||||
|
||||
it("returns {} when JSON root is a primitive", async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockResponse(42));
|
||||
expect(await getUserOverrides("03")).toEqual({});
|
||||
});
|
||||
|
||||
it("returns {} when JSON root is null", async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockResponse(null));
|
||||
expect(await getUserOverrides("03")).toEqual({});
|
||||
});
|
||||
|
||||
it("returns {} on 4xx (invalid key path from u3)", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockResponse({ error: "invalid key" }, false, 400),
|
||||
);
|
||||
expect(await getUserOverrides("..")).toEqual({});
|
||||
});
|
||||
|
||||
it("returns {} on 5xx", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockResponse({ error: "boom" }, false, 500),
|
||||
);
|
||||
expect(await getUserOverrides("03")).toEqual({});
|
||||
});
|
||||
|
||||
it("returns {} when response.json() throws (non-JSON body)", async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => {
|
||||
throw new SyntaxError("Unexpected token");
|
||||
},
|
||||
});
|
||||
expect(await getUserOverrides("03")).toEqual({});
|
||||
});
|
||||
|
||||
it("returns {} when fetch rejects (network error) — does NOT throw", async () => {
|
||||
fetchMock.mockRejectedValueOnce(new Error("network down"));
|
||||
await expect(getUserOverrides("03")).resolves.toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// saveUserOverrides — debounce + coalescing
|
||||
// ============================================================================
|
||||
|
||||
describe("saveUserOverrides (IMP-52 u5) — debounce", () => {
|
||||
it("does NOT fire fetch before 300 ms have elapsed", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({ layout: "two_zone_split" }));
|
||||
void saveUserOverrides("03", { layout: "two_zone_split" });
|
||||
|
||||
vi.advanceTimersByTime(299);
|
||||
await drainMicrotasks();
|
||||
expect(putCallsCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("fires exactly one PUT at the 300 ms boundary", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({ layout: "two_zone_split" }));
|
||||
void saveUserOverrides("03", { layout: "two_zone_split" });
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
expect(putCallsCount()).toBe(1);
|
||||
|
||||
const lastCall = fetchMock.mock.calls.at(-1)!;
|
||||
expect(lastCall[0]).toBe("/api/user-overrides/03");
|
||||
expect((lastCall[1] as RequestInit).method).toBe("PUT");
|
||||
expect((lastCall[1] as RequestInit).headers).toMatchObject({
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
expect(lastPutBody()).toEqual({ layout: "two_zone_split" });
|
||||
});
|
||||
|
||||
it("PUT body contains ONLY the mutated axis (not a full snapshot)", async () => {
|
||||
// The frontend handler only knows the axis it just mutated; the server
|
||||
// is responsible for partial-merge against axes already on disk.
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03", {
|
||||
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } },
|
||||
});
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
|
||||
const body = lastPutBody() as Record<string, unknown>;
|
||||
expect(Object.keys(body)).toEqual(["zone_geometries"]);
|
||||
expect("layout" in body).toBe(false);
|
||||
expect("frames" in body).toBe(false);
|
||||
expect("zone_sections" in body).toBe(false);
|
||||
});
|
||||
|
||||
it("coalesces N rapid calls into a SINGLE PUT after the debounce", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03", { layout: "old" });
|
||||
vi.advanceTimersByTime(100);
|
||||
void saveUserOverrides("03", { frames: { "03-1": "frame_01" } });
|
||||
vi.advanceTimersByTime(100);
|
||||
void saveUserOverrides("03", { zone_sections: { top: ["03-1"] } });
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
// After 300 ms total (but the timer was reset each call to start the
|
||||
// 300 ms window over), so we need one more 300 ms to fire.
|
||||
expect(putCallsCount()).toBe(0);
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
expect(putCallsCount()).toBe(1);
|
||||
|
||||
// All three axes accumulated.
|
||||
const body = lastPutBody() as Record<string, unknown>;
|
||||
expect(body).toEqual({
|
||||
layout: "old",
|
||||
frames: { "03-1": "frame_01" },
|
||||
zone_sections: { top: ["03-1"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("per-axis later-wins: same axis mutated twice keeps the LAST value", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03", { layout: "first" });
|
||||
void saveUserOverrides("03", { layout: "second" });
|
||||
void saveUserOverrides("03", { layout: "final" });
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
expect(putCallsCount()).toBe(1);
|
||||
expect(lastPutBody()).toEqual({ layout: "final" });
|
||||
});
|
||||
|
||||
it("forwards null sentinel verbatim (explicit clear)", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03", { layout: null });
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
expect(lastPutBody()).toEqual({ layout: null });
|
||||
});
|
||||
|
||||
it("null can override a prior non-null pending value for the same axis", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03", { layout: "two_zone_split" });
|
||||
void saveUserOverrides("03", { layout: null });
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
expect(lastPutBody()).toEqual({ layout: null });
|
||||
});
|
||||
|
||||
it("resolves the caller promise with the server-merged document", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockResponse({
|
||||
layout: "two_zone_split",
|
||||
// server's view includes axes preserved on disk that the partial
|
||||
// PUT did NOT carry — confirms we surface the full merged state.
|
||||
frames: { "03-1": "frame_01" },
|
||||
}),
|
||||
);
|
||||
const p = saveUserOverrides("03", { layout: "two_zone_split" });
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
|
||||
await expect(p).resolves.toEqual({
|
||||
layout: "two_zone_split",
|
||||
frames: { "03-1": "frame_01" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects all coalesced waiters on 5xx response", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockResponse({ error: "write failed" }, false, 500),
|
||||
);
|
||||
const p1 = saveUserOverrides("03", { layout: "x" });
|
||||
const p2 = saveUserOverrides("03", { frames: { "03-1": "f01" } });
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
|
||||
await expect(p1).rejects.toThrow(/500/);
|
||||
await expect(p2).rejects.toThrow(/500/);
|
||||
});
|
||||
|
||||
it("rejects waiters on fetch network error", async () => {
|
||||
fetchMock.mockRejectedValueOnce(new Error("ECONNRESET"));
|
||||
const p = saveUserOverrides("03", { layout: "x" });
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
|
||||
await expect(p).rejects.toThrow("ECONNRESET");
|
||||
});
|
||||
|
||||
it("after a successful flush, a new save starts a fresh debounce window", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03", { layout: "first" });
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
expect(putCallsCount()).toBe(1);
|
||||
expect(lastPutBody()).toEqual({ layout: "first" });
|
||||
|
||||
void saveUserOverrides("03", { layout: "second" });
|
||||
vi.advanceTimersByTime(299);
|
||||
await drainMicrotasks();
|
||||
expect(putCallsCount()).toBe(1); // not fired yet
|
||||
vi.advanceTimersByTime(1);
|
||||
await drainMicrotasks();
|
||||
expect(putCallsCount()).toBe(2);
|
||||
expect(lastPutBody()).toEqual({ layout: "second" });
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// saveUserOverrides — per-key isolation
|
||||
// ============================================================================
|
||||
|
||||
describe("saveUserOverrides (IMP-52 u5) — per-key isolation", () => {
|
||||
it("rapid edits to key A do not delay key B's flush", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
// Schedule a save on "03"
|
||||
void saveUserOverrides("03", { layout: "x" });
|
||||
// Schedule a save on "04" at t=0
|
||||
void saveUserOverrides("04", { layout: "y" });
|
||||
|
||||
vi.advanceTimersByTime(150);
|
||||
// Keep extending "03"'s window
|
||||
void saveUserOverrides("03", { layout: "x2" });
|
||||
|
||||
// "04" should still fire at t=300 (untouched after first call)
|
||||
vi.advanceTimersByTime(150); // t=300
|
||||
await drainMicrotasks();
|
||||
|
||||
const puts = fetchMock.mock.calls.filter(
|
||||
(c) => (c[1] as RequestInit).method === "PUT",
|
||||
);
|
||||
expect(puts.length).toBe(1);
|
||||
expect(puts[0][0]).toBe("/api/user-overrides/04");
|
||||
expect(JSON.parse(String((puts[0][1] as RequestInit).body))).toEqual({
|
||||
layout: "y",
|
||||
});
|
||||
});
|
||||
|
||||
it("each key's PUT carries only that key's mutated axes", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03", { layout: "for-03" });
|
||||
void saveUserOverrides("04", { frames: { "04-1": "frame_05" } });
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
|
||||
const puts = fetchMock.mock.calls.filter(
|
||||
(c) => (c[1] as RequestInit).method === "PUT",
|
||||
);
|
||||
expect(puts.length).toBe(2);
|
||||
|
||||
const byUrl = new Map(
|
||||
puts.map((c) => [
|
||||
c[0],
|
||||
JSON.parse(String((c[1] as RequestInit).body)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
]),
|
||||
);
|
||||
expect(byUrl.get("/api/user-overrides/03")).toEqual({ layout: "for-03" });
|
||||
expect(byUrl.get("/api/user-overrides/04")).toEqual({
|
||||
frames: { "04-1": "frame_05" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// flushUserOverrides
|
||||
// ============================================================================
|
||||
|
||||
describe("flushUserOverrides (IMP-52 u5)", () => {
|
||||
it("with no arg, flushes ALL pending buckets immediately (no 300 ms wait)", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03", { layout: "x" });
|
||||
void saveUserOverrides("04", { layout: "y" });
|
||||
|
||||
expect(putCallsCount()).toBe(0);
|
||||
const flushP = flushUserOverrides();
|
||||
await drainMicrotasks();
|
||||
await flushP;
|
||||
|
||||
expect(putCallsCount()).toBe(2);
|
||||
});
|
||||
|
||||
it("with a key arg, flushes only that bucket; others stay pending", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03", { layout: "x" });
|
||||
void saveUserOverrides("04", { layout: "y" });
|
||||
|
||||
await flushUserOverrides("03");
|
||||
await drainMicrotasks();
|
||||
|
||||
const puts = fetchMock.mock.calls.filter(
|
||||
(c) => (c[1] as RequestInit).method === "PUT",
|
||||
);
|
||||
expect(puts.length).toBe(1);
|
||||
expect(puts[0][0]).toBe("/api/user-overrides/03");
|
||||
|
||||
// "04" should still fire at the regular 300 ms boundary.
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
expect(putCallsCount()).toBe(2);
|
||||
});
|
||||
|
||||
it("is a no-op when no buckets are pending", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
await flushUserOverrides();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resolves the original saveUserOverrides promise via the in-flight PUT", async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockResponse({ layout: "flushed" }));
|
||||
const savePromise = saveUserOverrides("03", { layout: "flushed" });
|
||||
const flushPromise = flushUserOverrides();
|
||||
await drainMicrotasks();
|
||||
await flushPromise;
|
||||
await expect(savePromise).resolves.toEqual({ layout: "flushed" });
|
||||
});
|
||||
|
||||
it("propagates PUT failure as caller rejection (flush itself swallows)", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockResponse({ error: "boom" }, false, 500),
|
||||
);
|
||||
const savePromise = saveUserOverrides("03", { layout: "x" });
|
||||
// flush itself should not throw — the original waiter takes the rejection.
|
||||
const flushPromise = flushUserOverrides();
|
||||
await drainMicrotasks();
|
||||
await expect(flushPromise).resolves.toBeUndefined();
|
||||
await expect(savePromise).rejects.toThrow(/500/);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// type-level export sanity check (compile-time evidence; runtime no-op)
|
||||
// ============================================================================
|
||||
|
||||
describe("UserOverridesPartial type (IMP-52 u5)", () => {
|
||||
it("permits per-axis null sentinels and partial keys", () => {
|
||||
// Compile-time only — if any of these stops being a valid assignment,
|
||||
// the test suite fails at build with a TS error before this assertion
|
||||
// runs. The expect() is a placebo to keep vitest happy.
|
||||
const a: UserOverridesPartial = { layout: "x" };
|
||||
const b: UserOverridesPartial = { layout: null };
|
||||
const c: UserOverridesPartial = { frames: { unit: "tmpl" } };
|
||||
const d: UserOverridesPartial = {};
|
||||
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 } },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,569 @@
|
||||
// IMP-52 u10 — Frontend write-side regression coverage.
|
||||
//
|
||||
// Stage 2 unit u10 contract:
|
||||
// 1) All 4 in-scope mutation handlers persist their axis.
|
||||
// 2) zone_sizes is NOT persisted (handleLayoutResize stays in-memory).
|
||||
// 3) Write-before-Generate ordering — flushUserOverrides forces pending
|
||||
// PUTs to commit before the pipeline run begins.
|
||||
// 4) Restore-on-reopen end-to-end — getUserOverrides → non-frame layering
|
||||
// and post-loadRun frame remap compose into a single restored state.
|
||||
//
|
||||
// React Testing Library is NOT installed in this repo (devDependencies has
|
||||
// vitest only). Home.tsx's mutation handlers live inside `useCallback`
|
||||
// closures so they cannot be invoked from a test without mounting the
|
||||
// component. We cover them with two complementary tactics:
|
||||
// • Source-pattern grep on Home.tsx that pins the exact wiring shape per
|
||||
// handler. A regression that drops or rewires a `saveUserOverrides`
|
||||
// call fails here loudly.
|
||||
// • End-to-end mocked-fetch tests on the `userOverridesApi` flow with the
|
||||
// payload shapes that Home.tsx produces — proves the contract the
|
||||
// handlers depend on still holds.
|
||||
//
|
||||
// File extension is `.ts` (no JSX). All tests run in vitest's default node
|
||||
// environment; fetch is stubbed with vi.stubGlobal and timers are faked so
|
||||
// the 300ms debounce in `saveUserOverrides` is deterministic.
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
type Mock,
|
||||
} from "vitest";
|
||||
import {
|
||||
__resetUserOverridesBuckets_FOR_TEST,
|
||||
flushUserOverrides,
|
||||
getUserOverrides,
|
||||
saveUserOverrides,
|
||||
type UserOverridesPartial,
|
||||
} from "../src/services/userOverridesApi";
|
||||
import {
|
||||
applyPersistedNonFrameOverrides,
|
||||
createInitialUserSelection,
|
||||
deriveUserOverridesKey,
|
||||
remapPersistedFramesToZoneFrames,
|
||||
} from "../src/utils/slidePlanUtils";
|
||||
import type { SlidePlan, Zone } from "../src/types/designAgent";
|
||||
|
||||
// ─── Source-pattern regression ─────────────────────────────────────────────
|
||||
// Without RTL we can't dispatch a click and read `fetch.mock.calls`. Instead
|
||||
// we read Home.tsx as text and assert each in-scope handler closure contains
|
||||
// the exact wiring that Stage 2 u7 specified. This is brittle in a good way:
|
||||
// if a handler is renamed or its `saveUserOverrides` call is moved/removed,
|
||||
// the assertion fires with a clear "X handler does not persist Y axis"
|
||||
// message instead of silently regressing in prod.
|
||||
|
||||
const HOME_TSX_PATH = path.resolve(
|
||||
__dirname,
|
||||
"..",
|
||||
"src",
|
||||
"pages",
|
||||
"Home.tsx",
|
||||
);
|
||||
const HOME_TSX = fs.readFileSync(HOME_TSX_PATH, "utf-8");
|
||||
|
||||
/**
|
||||
* Slice the `const <name> = useCallback(...)` block out of Home.tsx. The
|
||||
* handlers are well-formed and end either at the next `const handle...`
|
||||
* declaration or at the next top-level `const ` at 2-space indent.
|
||||
*/
|
||||
function sliceHandler(source: string, name: string): string {
|
||||
const start = source.indexOf(`const ${name} = useCallback(`);
|
||||
if (start === -1) {
|
||||
throw new Error(`handler "${name}" not found in Home.tsx`);
|
||||
}
|
||||
// Find the next handler / top-level const after `start`.
|
||||
const nextHandler = source.indexOf("\n const handle", start + 1);
|
||||
const nextConst = source.indexOf("\n const ", start + 1);
|
||||
const candidates = [nextHandler, nextConst].filter((i) => i > start);
|
||||
const end = candidates.length > 0 ? Math.min(...candidates) : source.length;
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
describe("Home.tsx write-side wiring (IMP-52 u10) — source pattern", () => {
|
||||
it("handleSectionDrop persists zone_sections behind uploadedFile gate", () => {
|
||||
const block = sliceHandler(HOME_TSX, "handleSectionDrop");
|
||||
// gate
|
||||
expect(block).toMatch(/if\s*\(\s*p\.uploadedFile\s*\)/);
|
||||
// axis key + value source
|
||||
expect(block).toMatch(
|
||||
/saveUserOverrides\([\s\S]*?zone_sections:\s*finalSelection\.overrides\.zone_sections/,
|
||||
);
|
||||
// key derivation
|
||||
expect(block).toMatch(/deriveUserOverridesKey\(p\.uploadedFile\.name\)/);
|
||||
});
|
||||
|
||||
it("handleLayoutSelect persists `layout` axis behind uploadedFile gate", () => {
|
||||
const block = sliceHandler(HOME_TSX, "handleLayoutSelect");
|
||||
expect(block).toMatch(/if\s*\(\s*p\.uploadedFile\s*\)/);
|
||||
expect(block).toMatch(
|
||||
/saveUserOverrides\([\s\S]*?layout:\s*layoutId\s*\}/,
|
||||
);
|
||||
expect(block).toMatch(/deriveUserOverridesKey\(p\.uploadedFile\.name\)/);
|
||||
});
|
||||
|
||||
it("handleZoneResize persists merged zone_geometries behind uploadedFile gate", () => {
|
||||
const block = sliceHandler(HOME_TSX, "handleZoneResize");
|
||||
expect(block).toMatch(/if\s*\(\s*p\.uploadedFile\s*\)/);
|
||||
// merged geometry (not the partial delta) is persisted so the on-disk
|
||||
// axis is a complete snapshot of all currently-resized zones.
|
||||
expect(block).toMatch(
|
||||
/saveUserOverrides\([\s\S]*?zone_geometries:\s*mergedGeometries/,
|
||||
);
|
||||
expect(block).toMatch(/deriveUserOverridesKey\(p\.uploadedFile\.name\)/);
|
||||
});
|
||||
|
||||
it("handleFrameSelect persists frames-by-unit_id with default-frame gate", () => {
|
||||
const block = sliceHandler(HOME_TSX, "handleFrameSelect");
|
||||
expect(block).toMatch(/if\s*\(\s*p\.uploadedFile\s*&&\s*effectiveSlidePlan\s*\)/);
|
||||
// unit_id derivation matches handleGenerate's CLI-forwarding contract
|
||||
expect(block).toMatch(/z\.section_ids\.join\(\s*"\+"\s*\)/);
|
||||
// default-frame gate (rewind fix from Codex #17 / Claude #18)
|
||||
expect(block).toMatch(/overrideId\s*!==\s*defaultFrameId/);
|
||||
// axis key
|
||||
expect(block).toMatch(
|
||||
/saveUserOverrides\([\s\S]*?frames:\s*framesByUnitId/,
|
||||
);
|
||||
});
|
||||
|
||||
it("handleLayoutResize does NOT call saveUserOverrides (zone_sizes excluded)", () => {
|
||||
const block = sliceHandler(HOME_TSX, "handleLayoutResize");
|
||||
expect(block).not.toMatch(/saveUserOverrides/);
|
||||
// Sanity: handleLayoutResize still writes zone_sizes in-memory.
|
||||
expect(block).toMatch(/saveZoneSizes/);
|
||||
});
|
||||
|
||||
it("handleGenerate does NOT call saveUserOverrides (read-only re: persistence layer)", () => {
|
||||
const block = sliceHandler(HOME_TSX, "handleGenerate");
|
||||
// handleGenerate forwards overrides through runPipeline → /api/run, not
|
||||
// through /api/user-overrides. The persistence layer is owned by the
|
||||
// four mutation handlers; Generate must not introduce a competing
|
||||
// write path that could clobber a partially-edited bucket.
|
||||
expect(block).not.toMatch(/saveUserOverrides\(/);
|
||||
});
|
||||
|
||||
it("no handler in Home.tsx persists the zone_sizes axis", () => {
|
||||
// Top-level regression: searching the whole file rules out a future
|
||||
// accidental wiring inside a new handler we forgot to enumerate above.
|
||||
expect(HOME_TSX).not.toMatch(
|
||||
/saveUserOverrides\([\s\S]{0,200}?zone_sizes\s*:/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Payload-shape contract via mocked fetch ───────────────────────────────
|
||||
// Drive `saveUserOverrides` with the exact payload shapes each in-scope
|
||||
// handler produces in Home.tsx. Asserts that (a) the PUT body matches what
|
||||
// the on-disk schema (u1 / u4) accepts and (b) the partial-axis contract
|
||||
// holds — only the mutated axis is sent, never a full snapshot.
|
||||
|
||||
type MockResponse = {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
json: () => Promise<unknown>;
|
||||
};
|
||||
|
||||
function mockResponse(body: unknown, ok = true, status = 200): MockResponse {
|
||||
return { ok, status, json: async () => body };
|
||||
}
|
||||
|
||||
let fetchMock: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
vi.useFakeTimers();
|
||||
__resetUserOverridesBuckets_FOR_TEST();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
__resetUserOverridesBuckets_FOR_TEST();
|
||||
});
|
||||
|
||||
async function drainMicrotasks(): Promise<void> {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
function lastPutBody(): unknown {
|
||||
const lastCall = fetchMock.mock.calls.at(-1);
|
||||
if (!lastCall) throw new Error("fetch was not called");
|
||||
const init = lastCall[1] as RequestInit | undefined;
|
||||
if (!init?.body) throw new Error("fetch called without a body");
|
||||
return JSON.parse(String(init.body));
|
||||
}
|
||||
|
||||
describe("save payload contract per axis (IMP-52 u10)", () => {
|
||||
it("section-drop payload: PUT body carries only zone_sections", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
// Shape produced by handleSectionDrop after moveSectionToZone.
|
||||
const payload: UserOverridesPartial = {
|
||||
zone_sections: {
|
||||
top: ["03-1", "03-2"],
|
||||
bottom: ["03-3"],
|
||||
},
|
||||
};
|
||||
void saveUserOverrides("03_demo", payload);
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const body = lastPutBody() as Record<string, unknown>;
|
||||
expect(Object.keys(body)).toEqual(["zone_sections"]);
|
||||
expect(body.zone_sections).toEqual(payload.zone_sections);
|
||||
});
|
||||
|
||||
it("layout-select payload: PUT body carries only `layout` (string)", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03_demo", { layout: "two-column" });
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
const body = lastPutBody() as Record<string, unknown>;
|
||||
expect(Object.keys(body)).toEqual(["layout"]);
|
||||
expect(body.layout).toBe("two-column");
|
||||
});
|
||||
|
||||
it("zone-resize payload: PUT body carries only zone_geometries (merged snapshot)", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
const merged = {
|
||||
top: { x: 0, y: 0, w: 1, h: 0.42 },
|
||||
bottom_l: { x: 0, y: 0.42, w: 0.5, h: 0.58 },
|
||||
bottom_r: { x: 0.5, y: 0.42, w: 0.5, h: 0.58 },
|
||||
};
|
||||
void saveUserOverrides("03_demo", { zone_geometries: merged });
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
const body = lastPutBody() as Record<string, unknown>;
|
||||
expect(Object.keys(body)).toEqual(["zone_geometries"]);
|
||||
expect(body.zone_geometries).toEqual(merged);
|
||||
});
|
||||
|
||||
it("frame-select payload: PUT body carries only frames (unit_id → template_id)", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
// Shape produced by handleFrameSelect after the default-frame gate:
|
||||
// only zones the user explicitly chose a non-default frame for.
|
||||
const framesByUnitId = {
|
||||
"03-1": "process_product_two_way",
|
||||
"03-2+03-3": "three_parallel_requirements",
|
||||
};
|
||||
void saveUserOverrides("03_demo", { frames: framesByUnitId });
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
const body = lastPutBody() as Record<string, unknown>;
|
||||
expect(Object.keys(body)).toEqual(["frames"]);
|
||||
expect(body.frames).toEqual(framesByUnitId);
|
||||
});
|
||||
|
||||
it("frame-select payload with empty framesByUnitId still PUTs (replaces axis with {})", async () => {
|
||||
// When the user reverts the last frame override back to the backend
|
||||
// default, handleFrameSelect computes `framesByUnitId = {}`. The PUT
|
||||
// path still fires so the on-disk `frames` axis is cleared to the empty
|
||||
// object via u4's partial-merge replace semantics. Foreign axes
|
||||
// (layout / zone_geometries / zone_sections) remain on disk.
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03_demo", { frames: {} });
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
expect(lastPutBody()).toEqual({ frames: {} });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── zone_sizes axis is not part of the on-disk schema ─────────────────────
|
||||
|
||||
describe("zone_sizes axis exclusion (IMP-52 u10)", () => {
|
||||
it("UserOverridesPartial type does not include zone_sizes at compile time", () => {
|
||||
// Compile-time check: this assignment must be a TS error. The runtime
|
||||
// assertion below is a placebo; the meaningful evidence is that the
|
||||
// suite *builds*. If a future schema bump adds zone_sizes to
|
||||
// UserOverrides, this comment serves as the migration touchpoint.
|
||||
// @ts-expect-error — zone_sizes is intentionally not part of UserOverridesPartial
|
||||
const _bad: UserOverridesPartial = { zone_sizes: { layout_group_1: [0.5, 0.5] } };
|
||||
void _bad;
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it("Home.tsx never imports a write helper that would persist zone_sizes", () => {
|
||||
// handleLayoutResize delegates to saveZoneSizes (in-memory), not
|
||||
// saveUserOverrides. Cross-check the import line and the handler body.
|
||||
expect(HOME_TSX).toMatch(/import\s*\{[^}]*\bsaveZoneSizes\b[^}]*\}\s*from\s*"\.\.\/utils\/slidePlanUtils"/);
|
||||
const block = sliceHandler(HOME_TSX, "handleLayoutResize");
|
||||
expect(block).toMatch(/saveZoneSizes\(/);
|
||||
expect(block).not.toMatch(/saveUserOverrides/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Write-before-Generate ordering ────────────────────────────────────────
|
||||
// The four mutation handlers schedule debounced PUTs (300ms). If the user
|
||||
// hits Generate before the debounce fires, the persistence layer must not
|
||||
// drop the pending writes. `flushUserOverrides` is the contract: callers can
|
||||
// force-commit pending buckets before pipeline kickoff so the backend u2
|
||||
// fallback reads the latest file.
|
||||
|
||||
describe("write-before-Generate ordering (IMP-52 u10)", () => {
|
||||
// The service-level tests below prove the `flushUserOverrides` contract in
|
||||
// isolation. The two source-pattern checks here pin the *real* Generate
|
||||
// call site so a future refactor that drops the flush — re-exposing the
|
||||
// 300ms debounce race against `runPipeline` / the u2 backend fallback —
|
||||
// fails loudly. Without React Testing Library we cannot dispatch a click
|
||||
// on the Generate button, so we read Home.tsx as text and assert (a) the
|
||||
// import names `flushUserOverrides`, (b) the `handleGenerate` closure
|
||||
// awaits the flush before it awaits `runPipeline`.
|
||||
|
||||
it("Home.tsx imports flushUserOverrides from userOverridesApi", () => {
|
||||
expect(HOME_TSX).toMatch(
|
||||
/import\s*\{[^}]*\bflushUserOverrides\b[^}]*\}\s*from\s*"\.\.\/services\/userOverridesApi"/,
|
||||
);
|
||||
});
|
||||
|
||||
it("handleGenerate awaits flushUserOverrides before awaiting runPipeline", () => {
|
||||
const block = sliceHandler(HOME_TSX, "handleGenerate");
|
||||
expect(block).toMatch(/await\s+flushUserOverrides\s*\(\s*\)/);
|
||||
expect(block).toMatch(/await\s+runPipeline\s*\(/);
|
||||
const flushIdx = block.search(/await\s+flushUserOverrides\s*\(/);
|
||||
const runIdx = block.search(/await\s+runPipeline\s*\(/);
|
||||
expect(flushIdx).toBeGreaterThan(-1);
|
||||
expect(runIdx).toBeGreaterThan(-1);
|
||||
expect(flushIdx).toBeLessThan(runIdx);
|
||||
});
|
||||
|
||||
it("flushUserOverrides commits a pending PUT before its 300ms debounce fires", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({ layout: "two-column" }));
|
||||
const savePromise = saveUserOverrides("03_demo", { layout: "two-column" });
|
||||
|
||||
// Without flush, the PUT would not fire for another 300ms.
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
const flushPromise = flushUserOverrides();
|
||||
await drainMicrotasks();
|
||||
await flushPromise;
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("/api/user-overrides/03_demo");
|
||||
expect((init as RequestInit).method).toBe("PUT");
|
||||
|
||||
// Caller's promise resolves with the server-merged document — so a
|
||||
// pre-Generate `await flushUserOverrides()` can be paired with
|
||||
// `await savePromise` for stronger ordering if needed.
|
||||
await expect(savePromise).resolves.toEqual({ layout: "two-column" });
|
||||
});
|
||||
|
||||
it("flushUserOverrides (no arg) flushes pending writes across multiple MDX keys", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03_demo", { layout: "two-column" });
|
||||
void saveUserOverrides("04_demo", { frames: { "04-1": "tpl_a" } });
|
||||
void saveUserOverrides("05_demo", {
|
||||
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } },
|
||||
});
|
||||
|
||||
await flushUserOverrides();
|
||||
await drainMicrotasks();
|
||||
|
||||
const putUrls = fetchMock.mock.calls
|
||||
.filter((c) => (c[1] as RequestInit).method === "PUT")
|
||||
.map((c) => c[0]);
|
||||
expect(putUrls).toEqual(
|
||||
expect.arrayContaining([
|
||||
"/api/user-overrides/03_demo",
|
||||
"/api/user-overrides/04_demo",
|
||||
"/api/user-overrides/05_demo",
|
||||
]),
|
||||
);
|
||||
expect(putUrls).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("flushUserOverrides is a no-op when no writes are pending", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
await flushUserOverrides();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("post-flush, a new save schedules a fresh 300ms debounce window", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03_demo", { layout: "two-column" });
|
||||
await flushUserOverrides();
|
||||
await drainMicrotasks();
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(
|
||||
(c) => (c[1] as RequestInit).method === "PUT",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
|
||||
// Second save after Generate completes — must not piggy-back on the
|
||||
// already-flushed bucket; must re-arm a fresh debounce.
|
||||
void saveUserOverrides("03_demo", { layout: "hero-detail" });
|
||||
vi.advanceTimersByTime(299);
|
||||
await drainMicrotasks();
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(
|
||||
(c) => (c[1] as RequestInit).method === "PUT",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
vi.advanceTimersByTime(1);
|
||||
await drainMicrotasks();
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(
|
||||
(c) => (c[1] as RequestInit).method === "PUT",
|
||||
),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Restore-on-reopen — end-to-end compose ────────────────────────────────
|
||||
// u6 covers the helpers in isolation. This test wires them together with a
|
||||
// mocked GET response in the order Home.tsx invokes them at file-upload
|
||||
// time (key derive → fetch persisted → layer non-frame axes pre-loadRun →
|
||||
// remap frames post-loadRun) to pin the integration contract.
|
||||
|
||||
function makeZone(partial: {
|
||||
id: string;
|
||||
zone_id: string;
|
||||
section_ids: string[];
|
||||
default_frame_id?: string | null;
|
||||
}): Zone {
|
||||
return {
|
||||
id: partial.id,
|
||||
zone_id: partial.zone_id,
|
||||
section_ids: partial.section_ids,
|
||||
position: { x: 0, y: 0, width: 1, height: 1 },
|
||||
internal_regions: [
|
||||
{
|
||||
id: `${partial.id}-r0`,
|
||||
region_id: "region-single",
|
||||
role: "primary",
|
||||
content_type: "text_block",
|
||||
ratio_estimate: 1,
|
||||
content_unit_ids: [],
|
||||
frame_match_strategy: {
|
||||
kind: "frame_match",
|
||||
frame_id: partial.default_frame_id ?? null,
|
||||
display_strategy: "inline_full",
|
||||
},
|
||||
frame_candidates: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("restore-on-reopen end-to-end (IMP-52 u10)", () => {
|
||||
it("getUserOverrides → non-frame layer + post-load frame remap composes a restored selection", async () => {
|
||||
// GET returns the persisted file for "03_demo". The `layout` value
|
||||
// must be a real LayoutPresetId — applyPersistedNonFrameOverrides
|
||||
// validates against the 8-preset whitelist (slidePlanUtils.ts:30).
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockResponse({
|
||||
layout: "horizontal-2",
|
||||
frames: { "03-1": "process_product_two_way" },
|
||||
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.42 } },
|
||||
zone_sections: { top: ["03-1"], bottom: ["03-2", "03-3"] },
|
||||
}),
|
||||
);
|
||||
|
||||
const key = deriveUserOverridesKey("03_demo.mdx");
|
||||
expect(key).toBe("03_demo");
|
||||
|
||||
// Step 1: Home.tsx fetches at handleFileUpload time.
|
||||
const persisted = await getUserOverrides(key);
|
||||
expect(persisted.layout).toBe("horizontal-2");
|
||||
|
||||
// Step 2: pre-loadRun layering applies layout / zone_geometries /
|
||||
// zone_sections onto a fresh selection. Frames are deferred because
|
||||
// the unit_id key cannot be remapped without a slidePlan yet.
|
||||
const seededSelection = applyPersistedNonFrameOverrides(
|
||||
createInitialUserSelection(null),
|
||||
persisted,
|
||||
);
|
||||
expect(seededSelection.overrides.layout_preset).toBe("horizontal-2");
|
||||
expect(seededSelection.overrides.zone_geometries).toEqual({
|
||||
top: { x: 0, y: 0, w: 1, h: 0.42 },
|
||||
});
|
||||
expect(seededSelection.overrides.zone_sections).toEqual({
|
||||
top: ["03-1"],
|
||||
bottom: ["03-2", "03-3"],
|
||||
});
|
||||
// Frames must NOT have been layered at this stage.
|
||||
expect(seededSelection.overrides.zone_frames).toEqual({});
|
||||
|
||||
// Step 3: post-loadRun, Home.tsx has a slidePlan. Remap unit_id-keyed
|
||||
// frames to region.id-keyed frames against the rebuilt plan.
|
||||
const plan: SlidePlan = {
|
||||
id: "plan-3",
|
||||
title: "demo",
|
||||
layout_preset: "horizontal-2",
|
||||
zones: [
|
||||
makeZone({
|
||||
id: "z-top",
|
||||
zone_id: "top",
|
||||
section_ids: ["03-1"],
|
||||
default_frame_id: "some_default_frame",
|
||||
}),
|
||||
makeZone({
|
||||
id: "z-bot",
|
||||
zone_id: "bottom",
|
||||
section_ids: ["03-2", "03-3"],
|
||||
default_frame_id: null,
|
||||
}),
|
||||
],
|
||||
};
|
||||
const remapped = remapPersistedFramesToZoneFrames(
|
||||
plan,
|
||||
persisted.frames,
|
||||
);
|
||||
expect(remapped).toEqual({
|
||||
"z-top-r0": "process_product_two_way",
|
||||
});
|
||||
|
||||
// Step 4: post-loadRun merge — Home.tsx layers `remapped` onto
|
||||
// `createInitialUserSelection(slidePlan)` so the SlideCanvas
|
||||
// override-vs-default preview indicator surfaces the restored choice.
|
||||
const finalSelection = {
|
||||
...applyPersistedNonFrameOverrides(
|
||||
createInitialUserSelection(plan),
|
||||
persisted,
|
||||
),
|
||||
};
|
||||
finalSelection.overrides = {
|
||||
...finalSelection.overrides,
|
||||
zone_frames: { ...finalSelection.overrides.zone_frames, ...remapped },
|
||||
};
|
||||
expect(finalSelection.overrides.zone_frames["z-top-r0"]).toBe(
|
||||
"process_product_two_way",
|
||||
);
|
||||
expect(finalSelection.overrides.layout_preset).toBe("horizontal-2");
|
||||
expect(finalSelection.overrides.zone_sections).toEqual({
|
||||
top: ["03-1"],
|
||||
bottom: ["03-2", "03-3"],
|
||||
});
|
||||
});
|
||||
|
||||
it("missing persisted file (GET returns {}) leaves the selection at backend defaults", async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockResponse({}));
|
||||
const persisted = await getUserOverrides(deriveUserOverridesKey("new_file.mdx"));
|
||||
expect(persisted).toEqual({});
|
||||
|
||||
const plan: SlidePlan = {
|
||||
id: "plan-x",
|
||||
title: "fresh",
|
||||
layout_preset: "single",
|
||||
zones: [
|
||||
makeZone({ id: "z-only", zone_id: "main", section_ids: ["x-1"] }),
|
||||
],
|
||||
};
|
||||
const seeded = applyPersistedNonFrameOverrides(
|
||||
createInitialUserSelection(plan),
|
||||
persisted,
|
||||
);
|
||||
// No override applied → layout_preset, geometries, sections all from
|
||||
// the slidePlan defaults; remap yields {} so no frames layered.
|
||||
expect(seeded.overrides.layout_preset).toBe("single");
|
||||
expect(seeded.overrides.zone_geometries).toEqual({});
|
||||
expect(remapPersistedFramesToZoneFrames(plan, persisted.frames)).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -204,12 +204,310 @@ function vitePluginStorageProxy(): Plugin {
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// IMP-52 u3/u4 — user_overrides.json persistence (MDX-stem keyed store).
|
||||
//
|
||||
// On-disk layout: <DESIGN_AGENT_ROOT>/data/user_overrides/<key>.json. Mirrors
|
||||
// the Python contract in src/user_overrides_io.py — same validate_key regex,
|
||||
// same graceful-degrade (corrupt → {}) so backend pipeline entry fallback
|
||||
// (u2) and the vite endpoints (u3 GET, u4 PUT) agree on every file.
|
||||
//
|
||||
// Helpers are named exports so vitest can drive handleGetUserOverrides /
|
||||
// handlePutUserOverrides with mock req/res without booting a real dev
|
||||
// server. vite still consumes the default `defineConfig` export below.
|
||||
// =============================================================================
|
||||
|
||||
export const USER_OVERRIDES_KEY_RE = /^[A-Za-z0-9_][A-Za-z0-9_.\-]*$/;
|
||||
|
||||
// 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 (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;
|
||||
|
||||
export function isValidUserOverridesKey(key: string): boolean {
|
||||
if (!key) return false;
|
||||
if (key.includes("..")) return false;
|
||||
if (key.includes("/") || key.includes("\\")) return false;
|
||||
return USER_OVERRIDES_KEY_RE.test(key);
|
||||
}
|
||||
|
||||
export function userOverridesPath(root: string, key: string): string {
|
||||
return path.join(root, "data", "user_overrides", `${key}.json`);
|
||||
}
|
||||
|
||||
// Minimal req/res shapes — node IncomingMessage / ServerResponse have many
|
||||
// fields the handler does not touch, so we accept a structural subset for
|
||||
// testability.
|
||||
type GetReqLike = { method?: string; url?: string };
|
||||
type PutReqLike = {
|
||||
method?: string;
|
||||
url?: string;
|
||||
on(event: "data" | "end" | "error", cb: (...args: any[]) => void): unknown;
|
||||
};
|
||||
type ResLike = {
|
||||
writeHead: (status: number, headers?: Record<string, string>) => void;
|
||||
end: (body?: string) => void;
|
||||
};
|
||||
|
||||
// IMP-52 u3 — GET /api/user-overrides/:key handler. Returns true when the
|
||||
// handler took over the response, false when the caller should `next()`.
|
||||
// Invariants:
|
||||
// • method != GET → false (chain continues; u4 PUT may handle)
|
||||
// • invalid key → 400 {"error":"invalid key"}
|
||||
// • file missing → 200 {}
|
||||
// • file unreadable/corrupt → 200 {} (graceful degrade, mirrors u1 load)
|
||||
// • non-object JSON root → 200 {} (mirrors u1 load)
|
||||
// • valid object JSON → 200 with parsed JSON body
|
||||
export function handleGetUserOverrides(
|
||||
req: GetReqLike,
|
||||
res: ResLike,
|
||||
root: string,
|
||||
): boolean {
|
||||
if (req.method !== "GET") return false;
|
||||
|
||||
const url = req.url || "";
|
||||
const key = url.split("?")[0].replace(/^\//, "");
|
||||
|
||||
if (!isValidUserOverridesKey(key)) {
|
||||
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ error: "invalid key" }));
|
||||
return true;
|
||||
}
|
||||
|
||||
const filePath = userOverridesPath(root, key);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end("{}");
|
||||
return true;
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, "utf-8");
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end("{}");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end("{}");
|
||||
return true;
|
||||
}
|
||||
|
||||
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify(parsed));
|
||||
return true;
|
||||
}
|
||||
|
||||
// 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)
|
||||
// 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(
|
||||
existing: Record<string, unknown>,
|
||||
partial: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const merged: Record<string, unknown> = { ...existing };
|
||||
for (const axis of KNOWN_USER_OVERRIDES_AXES) {
|
||||
if (!(axis in partial)) continue;
|
||||
const value = partial[axis];
|
||||
if (value === null) {
|
||||
delete merged[axis];
|
||||
} else {
|
||||
merged[axis] = value;
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
// IMP-52 u4 — atomic file write via tmp + rename. Mirrors the
|
||||
// `_atomic_write_json` semantics in src/user_overrides_io.py so a
|
||||
// crashed/interrupted PUT cannot leave a half-written .json on disk
|
||||
// (the next GET / pipeline-entry read would otherwise return {} via
|
||||
// graceful degrade, silently losing the user's prior overrides).
|
||||
export function atomicWriteUserOverrides(
|
||||
filePath: string,
|
||||
data: Record<string, unknown>,
|
||||
): void {
|
||||
const dir = path.dirname(filePath);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
const tmpName = path.join(
|
||||
dir,
|
||||
`.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`,
|
||||
);
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
tmpName,
|
||||
JSON.stringify(data, null, 2) + "\n",
|
||||
"utf-8",
|
||||
);
|
||||
fs.renameSync(tmpName, filePath);
|
||||
} catch (err) {
|
||||
try {
|
||||
fs.unlinkSync(tmpName);
|
||||
} catch {
|
||||
// best-effort cleanup; the rename source may not exist on early failure
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// IMP-52 u4 — PUT /api/user-overrides/:key handler. Returns true when the
|
||||
// handler took over the response, false when the caller should `next()`.
|
||||
// Invariants:
|
||||
// • method != PUT → false (chain continues; GET runs first)
|
||||
// • invalid key → 400 {"error":"invalid key"}
|
||||
// • body > 1MB → 413 {"error":"payload too large"}
|
||||
// • invalid JSON → 400 {"error":"invalid JSON"}
|
||||
// • non-object JSON root → 400 {"error":"body must be a JSON object"}
|
||||
// • write failure → 500 {"error":"write failed: ..."}
|
||||
// • success → 200 with merged JSON body
|
||||
//
|
||||
// Existing-file read uses the same graceful-degrade rules as GET (corrupt
|
||||
// JSON / non-object root → treat as empty {}) so a PUT cannot fail solely
|
||||
// because a prior file is unparseable — the new payload replaces it.
|
||||
export function handlePutUserOverrides(
|
||||
req: PutReqLike,
|
||||
res: ResLike,
|
||||
root: string,
|
||||
): boolean {
|
||||
if (req.method !== "PUT") return false;
|
||||
|
||||
const url = req.url || "";
|
||||
const key = url.split("?")[0].replace(/^\//, "");
|
||||
|
||||
if (!isValidUserOverridesKey(key)) {
|
||||
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ error: "invalid key" }));
|
||||
return true;
|
||||
}
|
||||
|
||||
let body = "";
|
||||
let aborted = false;
|
||||
|
||||
req.on("data", (chunk: Buffer | string) => {
|
||||
if (aborted) return;
|
||||
body += typeof chunk === "string" ? chunk : chunk.toString();
|
||||
if (body.length > USER_OVERRIDES_PUT_MAX_BYTES) {
|
||||
aborted = true;
|
||||
res.writeHead(413, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
});
|
||||
res.end(JSON.stringify({ error: "payload too large" }));
|
||||
}
|
||||
});
|
||||
|
||||
req.on("end", () => {
|
||||
if (aborted) return;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = body.length > 0 ? JSON.parse(body) : {};
|
||||
} catch {
|
||||
res.writeHead(400, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
});
|
||||
res.end(JSON.stringify({ error: "invalid JSON" }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof parsed !== "object" ||
|
||||
parsed === null ||
|
||||
Array.isArray(parsed)
|
||||
) {
|
||||
res.writeHead(400, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
});
|
||||
res.end(JSON.stringify({ error: "body must be a JSON object" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const partial = parsed as Record<string, unknown>;
|
||||
const filePath = userOverridesPath(root, key);
|
||||
|
||||
// Load existing — corrupt / non-object → {} so the PUT still succeeds
|
||||
// and recovers the file to a clean state. Mirrors u1 load() graceful
|
||||
// degrade.
|
||||
let existing: Record<string, unknown> = {};
|
||||
if (fs.existsSync(filePath)) {
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, "utf-8");
|
||||
const ex = JSON.parse(raw);
|
||||
if (
|
||||
typeof ex === "object" &&
|
||||
ex !== null &&
|
||||
!Array.isArray(ex)
|
||||
) {
|
||||
existing = ex as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
// corrupt → treat as empty
|
||||
}
|
||||
}
|
||||
|
||||
const merged = mergeUserOverrides(existing, partial);
|
||||
|
||||
try {
|
||||
atomicWriteUserOverrides(filePath, merged);
|
||||
} catch (err) {
|
||||
res.writeHead(500, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
});
|
||||
res.end(JSON.stringify({ error: `write failed: ${String(err)}` }));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
});
|
||||
res.end(JSON.stringify(merged));
|
||||
});
|
||||
|
||||
req.on("error", () => {
|
||||
if (aborted) return;
|
||||
aborted = true;
|
||||
res.writeHead(500, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
});
|
||||
res.end(JSON.stringify({ error: "request error" }));
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Phase Z API Plugin — MDX 업로드 → 파이프라인 실행 → 결과 노출
|
||||
//
|
||||
// Endpoints (vite dev middleware) :
|
||||
// POST /api/run multipart/JSON body {filename, content} → run_id
|
||||
// GET /data/runs/{run_id}/{path} → {DESIGN_AGENT_ROOT}/data/runs/{run_id}/phase_z2/{path}
|
||||
// GET /api/user-overrides/{key} → data/user_overrides/{key}.json (IMP-52 u3)
|
||||
// PUT /api/user-overrides/{key} → partial-merge save (IMP-52 u4)
|
||||
//
|
||||
// 환경 변수 (선택) :
|
||||
// DESIGN_AGENT_ROOT python pipeline 실행 cwd. default = D:/ad-hoc/kei/design_agent
|
||||
@@ -464,6 +762,19 @@ function vitePluginPhaseZApi(): Plugin {
|
||||
fs.createReadStream(previewPath).pipe(res);
|
||||
});
|
||||
|
||||
// ── GET / PUT /api/user-overrides/{key} → data/user_overrides/{key}.json ──
|
||||
// IMP-52 u3 (GET) + u4 (PUT) — MDX-stem keyed user overrides. Logic
|
||||
// lives in the pure helpers (handleGetUserOverrides / handlePutUserOverrides)
|
||||
// so vitest can exercise them without booting vite. Both handlers
|
||||
// return false when the HTTP method does not match, so they chain
|
||||
// cleanly: GET first, then PUT, then next() for everything else
|
||||
// (e.g., OPTIONS / preflight handled by upstream middleware).
|
||||
server.middlewares.use("/api/user-overrides", (req, res, next) => {
|
||||
if (handleGetUserOverrides(req, res, DESIGN_AGENT_ROOT)) return;
|
||||
if (handlePutUserOverrides(req, res, DESIGN_AGENT_ROOT)) return;
|
||||
next();
|
||||
});
|
||||
|
||||
// ── GET /data/runs/{run_id}/{path} → {RUNS_DIR}/{run_id}/phase_z2/{path} ──
|
||||
server.middlewares.use("/data/runs", (req, res, next) => {
|
||||
if (req.method !== "GET") return next();
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
"""Catalog ↔ partial ↔ builder invariant audit CLI (IMP-#85 u3a / u3b).
|
||||
|
||||
Offline audit of `templates/phase_z2/catalog/frame_contracts.yaml` against
|
||||
the on-disk frame partials and the runtime `PAYLOAD_BUILDERS` registry.
|
||||
|
||||
Reports diff surface so first-fix iteration sees the entire catalog drift,
|
||||
not just the first failure (matches the boot-time invariant's aggregation
|
||||
behavior in `_check_catalog_builder_invariant`).
|
||||
|
||||
Invariants (scope-locked per Stage 2):
|
||||
I1 partial existence — `templates/phase_z2/families/{template_id}.html`
|
||||
must exist for live (non-VP) contracts.
|
||||
I2 builder declared — live contracts must declare a non-empty
|
||||
`payload.builder`.
|
||||
I3 builder registered — declared builders must be members of
|
||||
`src.phase_z2_mapper.PAYLOAD_BUILDERS`.
|
||||
I4 slot_payload refs — every key generated by the contract's builder
|
||||
must appear as a `slot_payload.<key>` reference in
|
||||
the partial. Direction A only (dead generated key).
|
||||
Skipped when the partial uses dynamic bracket
|
||||
access (`slot_payload[...]`) — those refs cannot be
|
||||
resolved statically; the relevant generated keys
|
||||
are presumed reachable via the dynamic form.
|
||||
|
||||
`visual_pending: true` contracts are skipped for I1–I4 (data-driven from
|
||||
catalog, no hard-coded frame allow-list; matches u2 invariant scope).
|
||||
|
||||
Exit codes:
|
||||
0 — all invariants pass on live (non-VP) contracts.
|
||||
1 — one or more violations reported.
|
||||
|
||||
Usage::
|
||||
|
||||
python scripts/audit_frame_invariants.py
|
||||
python scripts/audit_frame_invariants.py --catalog <path> --partials-dir <path>
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
import yaml
|
||||
|
||||
DEFAULT_CATALOG_PATH = (
|
||||
REPO_ROOT / "templates" / "phase_z2" / "catalog" / "frame_contracts.yaml"
|
||||
)
|
||||
DEFAULT_PARTIALS_DIR = REPO_ROOT / "templates" / "phase_z2" / "families"
|
||||
|
||||
|
||||
def _format_path(path: Path) -> str:
|
||||
try:
|
||||
return str(path.relative_to(REPO_ROOT))
|
||||
except ValueError:
|
||||
return str(path)
|
||||
|
||||
|
||||
def _is_visual_pending(contract: dict) -> bool:
|
||||
return contract.get("visual_pending") is True
|
||||
|
||||
|
||||
def _iter_live_contracts(catalog: dict) -> Iterable[tuple[str, dict]]:
|
||||
for template_id, contract in catalog.items():
|
||||
if not isinstance(contract, dict):
|
||||
continue
|
||||
if _is_visual_pending(contract):
|
||||
continue
|
||||
yield template_id, contract
|
||||
|
||||
|
||||
def check_i1_partial_existence(
|
||||
catalog: dict, partials_dir: Path
|
||||
) -> list[str]:
|
||||
"""I1 — Live contracts must have `families/{template_id}.html` on disk."""
|
||||
violations: list[str] = []
|
||||
for template_id, _contract in _iter_live_contracts(catalog):
|
||||
partial_path = partials_dir / f"{template_id}.html"
|
||||
if not partial_path.is_file():
|
||||
violations.append(
|
||||
f"I1 partial-missing: contract '{template_id}' has no "
|
||||
f"partial file at {_format_path(partial_path)}."
|
||||
)
|
||||
return violations
|
||||
|
||||
|
||||
def check_i2_builder_declared(catalog: dict) -> list[str]:
|
||||
"""I2 — Live contracts must declare a non-empty `payload.builder`."""
|
||||
violations: list[str] = []
|
||||
for template_id, contract in _iter_live_contracts(catalog):
|
||||
payload = contract.get("payload") or {}
|
||||
if not isinstance(payload, dict):
|
||||
violations.append(
|
||||
f"I2 builder-undeclared: contract '{template_id}' has "
|
||||
f"non-dict payload (type={type(payload).__name__})."
|
||||
)
|
||||
continue
|
||||
builder_name = payload.get("builder")
|
||||
if not builder_name:
|
||||
violations.append(
|
||||
f"I2 builder-undeclared: contract '{template_id}' is "
|
||||
f"missing payload.builder."
|
||||
)
|
||||
return violations
|
||||
|
||||
|
||||
def check_i3_builder_registered(
|
||||
catalog: dict, registered_builders: set[str]
|
||||
) -> list[str]:
|
||||
"""I3 — Declared builders must be members of PAYLOAD_BUILDERS registry."""
|
||||
violations: list[str] = []
|
||||
for template_id, contract in _iter_live_contracts(catalog):
|
||||
payload = contract.get("payload") or {}
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
builder_name = payload.get("builder")
|
||||
if not builder_name:
|
||||
continue
|
||||
if builder_name not in registered_builders:
|
||||
violations.append(
|
||||
f"I3 builder-unregistered: contract '{template_id}' "
|
||||
f"references payload.builder='{builder_name}' not in "
|
||||
f"PAYLOAD_BUILDERS."
|
||||
)
|
||||
return violations
|
||||
|
||||
|
||||
_SLOT_PAYLOAD_DOT_RE = re.compile(r"slot_payload\.([A-Za-z_][A-Za-z0-9_]*)")
|
||||
_SLOT_PAYLOAD_BRACKET_RE = re.compile(r"slot_payload\s*\[")
|
||||
|
||||
|
||||
def extract_static_slot_refs(partial_text: str) -> set[str]:
|
||||
"""Return the set of `slot_payload.<key>` dot-access references."""
|
||||
return set(_SLOT_PAYLOAD_DOT_RE.findall(partial_text))
|
||||
|
||||
|
||||
def partial_uses_dynamic_slot_access(partial_text: str) -> bool:
|
||||
"""True if the partial dereferences `slot_payload[...]` (dynamic key)."""
|
||||
return bool(_SLOT_PAYLOAD_BRACKET_RE.search(partial_text))
|
||||
|
||||
|
||||
def expected_payload_keys(contract: dict) -> set[str]:
|
||||
"""Statically compute the set of payload keys the contract's builder produces.
|
||||
|
||||
Mirrors `src.phase_z2_mapper`'s registered builders (IMP-#85 u3b). Returns
|
||||
an empty set when the builder is unknown — I3 already flags that drift.
|
||||
"""
|
||||
payload = contract.get("payload") or {}
|
||||
if not isinstance(payload, dict):
|
||||
return set()
|
||||
keys: set[str] = set()
|
||||
title_spec = payload.get("title")
|
||||
if isinstance(title_spec, dict) and title_spec.get("source"):
|
||||
keys.add("title")
|
||||
|
||||
builder = payload.get("builder")
|
||||
options = payload.get("builder_options") or {}
|
||||
if not isinstance(options, dict):
|
||||
options = {}
|
||||
|
||||
if builder == "items_with_role":
|
||||
array_root = options.get("array_root")
|
||||
if array_root:
|
||||
keys.add(array_root)
|
||||
elif builder == "process_product_pair":
|
||||
for col in options.get("columns") or []:
|
||||
if not isinstance(col, dict):
|
||||
continue
|
||||
if col.get("title_to"):
|
||||
keys.add(col["title_to"])
|
||||
if col.get("body_to"):
|
||||
keys.add(col["body_to"])
|
||||
elif builder == "quadrant_flat_slots":
|
||||
pad_to = int(options.get("pad_to", 4))
|
||||
label_key = options.get("label_key_pattern", "quadrant_{n}_label")
|
||||
body_key = options.get("body_key_pattern", "quadrant_{n}_body")
|
||||
for n in range(1, pad_to + 1):
|
||||
keys.add(label_key.format(n=n))
|
||||
keys.add(body_key.format(n=n))
|
||||
elif builder == "cycle_intersect_3":
|
||||
pad_to = int(options.get("pad_to", 3))
|
||||
label_key = options.get("label_key_pattern", "circle_{n}_label")
|
||||
for n in range(1, pad_to + 1):
|
||||
keys.add(label_key.format(n=n))
|
||||
keys.add("intersection")
|
||||
elif builder == "compare_table_2col":
|
||||
keys.update({"col_a_label", "col_b_label", "rows"})
|
||||
elif builder == "paired_rows_4x2_slots":
|
||||
label_key = options.get("label_key_pattern", "row_{r}_{side}_label")
|
||||
body_key = options.get("body_key_pattern", "row_{r}_{side}_body")
|
||||
rows = int(options.get("rows", 4))
|
||||
sides = options.get("sides", ["left", "right"]) or []
|
||||
for r in range(1, rows + 1):
|
||||
for side in sides:
|
||||
keys.add(label_key.format(r=r, side=side))
|
||||
keys.add(body_key.format(r=r, side=side))
|
||||
return keys
|
||||
|
||||
|
||||
def check_i4_slot_payload_refs(
|
||||
catalog: dict,
|
||||
partials_dir: Path,
|
||||
registered_builders: set[str],
|
||||
) -> list[str]:
|
||||
"""I4 — every generated payload key must be referenced by the partial.
|
||||
|
||||
Direction A only (dead key). Skipped when the partial uses dynamic
|
||||
bracket access (`slot_payload[...]`) — generated keys are presumed
|
||||
reached via the dynamic form and cannot be resolved statically.
|
||||
|
||||
Contracts already failing I1 (missing partial) or I3 (unregistered
|
||||
builder) are skipped so the same drift is not double-reported.
|
||||
"""
|
||||
violations: list[str] = []
|
||||
for template_id, contract in _iter_live_contracts(catalog):
|
||||
payload = contract.get("payload") or {}
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
builder_name = payload.get("builder")
|
||||
if not builder_name or builder_name not in registered_builders:
|
||||
continue
|
||||
partial_path = partials_dir / f"{template_id}.html"
|
||||
if not partial_path.is_file():
|
||||
continue
|
||||
partial_text = partial_path.read_text(encoding="utf-8")
|
||||
if partial_uses_dynamic_slot_access(partial_text):
|
||||
continue
|
||||
static_refs = extract_static_slot_refs(partial_text)
|
||||
expected = expected_payload_keys(contract)
|
||||
orphans = sorted(expected - static_refs)
|
||||
for key in orphans:
|
||||
violations.append(
|
||||
f"I4 generated-key-orphan: contract '{template_id}' builder "
|
||||
f"'{builder_name}' produces payload key '{key}' but partial "
|
||||
f"never references slot_payload.{key}."
|
||||
)
|
||||
return violations
|
||||
|
||||
|
||||
def run_audit(
|
||||
catalog_path: Path = DEFAULT_CATALOG_PATH,
|
||||
partials_dir: Path = DEFAULT_PARTIALS_DIR,
|
||||
) -> list[str]:
|
||||
"""Load catalog + registry and aggregate I1-I4 violations.
|
||||
|
||||
Registry is imported here (not at module import) so the script can be
|
||||
inspected without triggering the boot-time catalog invariant.
|
||||
"""
|
||||
from src.phase_z2_mapper import PAYLOAD_BUILDERS
|
||||
|
||||
catalog = yaml.safe_load(catalog_path.read_text(encoding="utf-8")) or {}
|
||||
registered = set(PAYLOAD_BUILDERS.keys())
|
||||
|
||||
violations: list[str] = []
|
||||
violations.extend(check_i1_partial_existence(catalog, partials_dir))
|
||||
violations.extend(check_i2_builder_declared(catalog))
|
||||
violations.extend(check_i3_builder_registered(catalog, registered))
|
||||
violations.extend(check_i4_slot_payload_refs(catalog, partials_dir, registered))
|
||||
return violations
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Audit Phase Z-2 catalog ↔ partials ↔ builder registry."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--catalog",
|
||||
type=Path,
|
||||
default=DEFAULT_CATALOG_PATH,
|
||||
help="Path to frame_contracts.yaml",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--partials-dir",
|
||||
type=Path,
|
||||
default=DEFAULT_PARTIALS_DIR,
|
||||
help="Directory containing families/{template_id}.html partials",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
violations = run_audit(args.catalog, args.partials_dir)
|
||||
if not violations:
|
||||
print("audit_frame_invariants: PASS (I1-I4 clean on live contracts).")
|
||||
return 0
|
||||
|
||||
print(
|
||||
f"audit_frame_invariants: FAIL ({len(violations)} violation(s)):"
|
||||
)
|
||||
for v in violations:
|
||||
print(f" - {v}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -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
|
||||
@@ -50,6 +50,7 @@ def route_ai_fallback(
|
||||
internal_region: dict[str, Any],
|
||||
mdx_text: str,
|
||||
client: AiFallbackClient | None = None,
|
||||
fingerprints: dict | None = None,
|
||||
) -> AiFallbackProposal | None:
|
||||
"""Route a fallback request through cache → prompt → client → validate.
|
||||
|
||||
@@ -57,13 +58,18 @@ def route_ai_fallback(
|
||||
not ``ai_adaptation_required`` — both gates short-circuit BEFORE any
|
||||
prompt/client work, so the normal-path AI call count stays at 0
|
||||
(PZ-1).
|
||||
|
||||
``fingerprints`` is forwarded into ``read_proposal`` so that
|
||||
contract / partial / catalog SHA mismatches invalidate stale cache
|
||||
entries (IMP-46 #62 Axis R). When ``None`` the cache layer skips
|
||||
fingerprint comparison (legacy behaviour).
|
||||
"""
|
||||
if not settings.ai_fallback_enabled:
|
||||
return None
|
||||
route = v4_result.get("route") or v4_result.get("imp05_route_hint")
|
||||
if route != V4_ROUTE_AI_ADAPTATION:
|
||||
return None
|
||||
cached = read_proposal(cache_key)
|
||||
cached = read_proposal(cache_key, fingerprints=fingerprints)
|
||||
if cached is not None:
|
||||
validate_proposal(
|
||||
cached,
|
||||
|
||||
@@ -200,6 +200,7 @@ def gather_step12_ai_repair_proposals(
|
||||
figma_partial_json=figma_partial_json,
|
||||
internal_region=internal_region,
|
||||
mdx_text=mdx_text,
|
||||
fingerprints=fingerprints,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — record + continue, no AI re-raise
|
||||
record["ai_called"] = True
|
||||
|
||||
@@ -73,6 +73,247 @@ STEP17_AI_REPAIR_BLOCKED_REASON = (
|
||||
)
|
||||
|
||||
|
||||
# IMP-35 (#64) u4 — POPUP cascade AI split-decision contract (API gated).
|
||||
#
|
||||
# Step 17 POPUP escalation needs an AI hook to decide *what content* stays in
|
||||
# the body (summary/subset) vs. moves into the <details> popup (full MDX).
|
||||
# That hook is the AI split-decision contract. u4 ships the contract surface
|
||||
# (function signature + record schema + cascade_stage + route_for_label +
|
||||
# skip_reason) WITHOUT enabling the Anthropic API. The deterministic POPUP
|
||||
# gate executor (u5) runs ahead of this contract and stamps
|
||||
# popup_escalation_plan + has_popup; u4's hook is a forward-compatible
|
||||
# placeholder so downstream wiring (u5 executor / future IMP activating the
|
||||
# API) can rely on a stable schema. ``api_gated=True`` on every record makes
|
||||
# the gate state machine-readable; ``ai_called`` stays False everywhere.
|
||||
#
|
||||
# Per feedback_ai_isolation_contract: AI = fallback path only. The contract
|
||||
# function MUST NOT import route_ai_fallback, the u4 client (despite name
|
||||
# collision — u4 here is the IMP-35 unit, not the Step 12 client module),
|
||||
# or any anthropic SDK symbol. Structural import guards in the test surface
|
||||
# already enforce this and continue to hold after this change.
|
||||
STEP17_POPUP_SPLIT_DECISION_API_GATED_REASON = (
|
||||
"step17_popup_split_decision_api_gated"
|
||||
)
|
||||
|
||||
|
||||
# IMP-35 (#64) u5 — deterministic POPUP gate executor (cascade-terminal).
|
||||
#
|
||||
# Runs AFTER the DETERMINISTIC stage exhausts and BEFORE the AI_REPAIR
|
||||
# cascade stage (canonical OVERFLOW_CASCADE_ORDER). Per unit:
|
||||
#
|
||||
# 1. Idempotency (q2): if a unit carries ``has_popup=True`` already,
|
||||
# ``run_step17_popup_gate`` short-circuits with
|
||||
# ``gate_status="idempotent_short_circuit"``. No duplicate plan,
|
||||
# no re-routing. Re-running Step 17 on already-escalated units is
|
||||
# safe — the gate emits a deterministic record per unit but does
|
||||
# NOT re-stamp the plan or flip the marker. The persistence of
|
||||
# ``has_popup`` and ``popup_escalation_plan`` on the unit itself
|
||||
# (see step 4 below) is what makes the second call observe the
|
||||
# stamp from the first call and short-circuit correctly.
|
||||
# 2. Classification: ``classification_for_unit(unit)`` returns the
|
||||
# fit_classifier row associated with this unit (or ``None`` if the
|
||||
# unit has no overflow on this run).
|
||||
# 3. Plan: ``plan_for_classification(cls)`` is the router u3 stub
|
||||
# (``src.phase_z2_router.plan_details_popup_escalation``). Only
|
||||
# the categories in ``POPUP_ESCALATION_CATEGORIES`` of the router
|
||||
# surface (currently ``structural_major_overflow`` and
|
||||
# ``tabular_overflow``) emit a feasible plan; anything else falls
|
||||
# through to ``gate_status="infeasible_category"`` so the gate
|
||||
# never silently escalates the wrong overflow shape.
|
||||
# 4. Feasible plan → record stamps ``popup_escalation_plan`` and
|
||||
# flips ``has_popup=True`` in the returned record AND persists
|
||||
# the same two fields on the unit via ``setattr`` (``unit.has_popup``
|
||||
# and ``unit.popup_escalation_plan``). The unit-side persistence
|
||||
# is the q2 idempotency contract: a second call to
|
||||
# ``run_step17_popup_gate`` over the same unit reads
|
||||
# ``unit.has_popup=True`` at step 1 and short-circuits before
|
||||
# classification / plan callable invocation. The marker is also
|
||||
# what u6 composition binding and u7 render wiring read from the
|
||||
# unit downstream.
|
||||
#
|
||||
# AI isolation contract: NO Anthropic call inside this gate. The
|
||||
# deterministic split between popup body (full MDX) and preview
|
||||
# (summary/subset) is composed downstream from container px budgets
|
||||
# (q3 — preview_chars derives from container px telemetry already on
|
||||
# the retry_trace). The u4 AI hook (``gather_step17_popup_split_decisions``)
|
||||
# sits at the same cascade stage but is API-gated (``api_gated=True``)
|
||||
# and never invoked from this deterministic path. ``ai_called=False`` on
|
||||
# every record this gate emits.
|
||||
#
|
||||
# cascade_stage="popup" on every record so Step 17 retry-trace consumers
|
||||
# can multiplex DETERMINISTIC / POPUP / AI_REPAIR records without
|
||||
# ambiguity. The schema mirrors :func:`gather_step17_popup_split_decisions`
|
||||
# (unit_index / source_section_ids / frame_template_id / label /
|
||||
# route_hint / provisional) PLUS u5-specific fields:
|
||||
# ``gate_status`` / ``popup_escalation_plan`` / ``has_popup`` /
|
||||
# ``skip_reason`` (only set for non-escalated gate_status values).
|
||||
STEP17_POPUP_GATE_ESCALATED_REASON = "step17_popup_gate_escalated"
|
||||
STEP17_POPUP_GATE_IDEMPOTENT_SHORT_CIRCUIT_REASON = (
|
||||
"step17_popup_gate_idempotent_short_circuit"
|
||||
)
|
||||
STEP17_POPUP_GATE_INFEASIBLE_CATEGORY_REASON = (
|
||||
"step17_popup_gate_infeasible_category"
|
||||
)
|
||||
STEP17_POPUP_GATE_NO_CLASSIFICATION_REASON = (
|
||||
"step17_popup_gate_no_classification_for_unit"
|
||||
)
|
||||
|
||||
|
||||
def run_step17_popup_gate(
|
||||
units: Iterable[Any],
|
||||
*,
|
||||
classification_for_unit: Callable[[Any], dict | None],
|
||||
route_for_label: Callable[[str | None], str | None],
|
||||
plan_for_classification: Callable[[dict], dict],
|
||||
) -> list[dict]:
|
||||
"""Deterministic POPUP gate executor for Step 17 cascade (IMP-35 u5).
|
||||
|
||||
See module-level block comment (immediately above) for the full
|
||||
contract — idempotency (q2), classification source, router u3 stub
|
||||
coupling, AI isolation, and cascade_stage multiplexing.
|
||||
|
||||
Args:
|
||||
units: provisional / non-provisional Step 17 units. The gate is
|
||||
agnostic to provisional state; the marker ``has_popup`` flows
|
||||
from this function regardless.
|
||||
classification_for_unit: maps a unit to its fit_classifier
|
||||
classification row (or ``None`` if the unit has no overflow).
|
||||
Tests inject a fake dict / lookup; the pipeline composes
|
||||
this from ``fit_classification.classifications`` matched by
|
||||
``zone_position``.
|
||||
route_for_label: same callable shape as
|
||||
:func:`gather_step17_ai_repair_proposals` /
|
||||
:func:`gather_step17_popup_split_decisions`. The route hint
|
||||
is stamped on every record for downstream consumers.
|
||||
plan_for_classification: the router u3 stub
|
||||
(``src.phase_z2_router.plan_details_popup_escalation``).
|
||||
Injected as a callable so this module stays decoupled from
|
||||
the router surface and tests can stub the plan output.
|
||||
|
||||
Returns:
|
||||
list[dict] — one record per unit. Records carry
|
||||
``cascade_stage="popup"`` and ``ai_called=False`` everywhere.
|
||||
Feasible-escalation records also carry
|
||||
``popup_escalation_plan`` (the router u3 plan dict) and
|
||||
``has_popup=True``. Non-escalation records carry a
|
||||
``skip_reason`` enum.
|
||||
"""
|
||||
records: list[dict] = []
|
||||
for index, unit in enumerate(units):
|
||||
label = getattr(unit, "label", None)
|
||||
already_escalated = bool(getattr(unit, "has_popup", False))
|
||||
record: dict = {
|
||||
"unit_index": index,
|
||||
"source_section_ids": list(
|
||||
getattr(unit, "source_section_ids", []) or []
|
||||
),
|
||||
"frame_template_id": getattr(unit, "frame_template_id", None),
|
||||
"label": label,
|
||||
"route_hint": route_for_label(label),
|
||||
"provisional": bool(getattr(unit, "provisional", False)),
|
||||
"cascade_stage": OverflowCascadeStage.POPUP.value,
|
||||
"ai_called": False,
|
||||
"has_popup": already_escalated,
|
||||
"popup_escalation_plan": None,
|
||||
"gate_status": None,
|
||||
"skip_reason": None,
|
||||
}
|
||||
if already_escalated:
|
||||
# q2 idempotency — short-circuit. The previously stamped
|
||||
# popup_escalation_plan stays on the unit (carried by u6/u7
|
||||
# composition); this gate does NOT re-emit it.
|
||||
record["gate_status"] = "idempotent_short_circuit"
|
||||
record["skip_reason"] = (
|
||||
STEP17_POPUP_GATE_IDEMPOTENT_SHORT_CIRCUIT_REASON
|
||||
)
|
||||
records.append(record)
|
||||
continue
|
||||
classification = classification_for_unit(unit)
|
||||
if not classification:
|
||||
record["gate_status"] = "no_classification"
|
||||
record["skip_reason"] = STEP17_POPUP_GATE_NO_CLASSIFICATION_REASON
|
||||
records.append(record)
|
||||
continue
|
||||
plan = plan_for_classification(classification)
|
||||
record["popup_escalation_plan"] = plan
|
||||
if plan and plan.get("feasible"):
|
||||
record["gate_status"] = "escalated"
|
||||
record["has_popup"] = True
|
||||
record["skip_reason"] = None
|
||||
# q2 idempotency persistence — stamp the marker AND the plan
|
||||
# on the unit itself so a second run of the gate over the
|
||||
# same unit observes ``unit.has_popup=True`` at the top of
|
||||
# the loop and short-circuits before re-invoking the
|
||||
# classification / plan callables. The unit-side persistence
|
||||
# is also what u6 composition binding and u7 render wiring
|
||||
# read downstream.
|
||||
setattr(unit, "has_popup", True)
|
||||
setattr(unit, "popup_escalation_plan", plan)
|
||||
else:
|
||||
# Plan rejected by router (wrong category). Defensive guard —
|
||||
# the gate must not silently escalate the wrong overflow
|
||||
# shape (see router u3 plan_details_popup_escalation defensive
|
||||
# guard).
|
||||
record["gate_status"] = "infeasible_category"
|
||||
record["skip_reason"] = (
|
||||
STEP17_POPUP_GATE_INFEASIBLE_CATEGORY_REASON
|
||||
)
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
def gather_step17_popup_split_decisions(
|
||||
units: Iterable[Any],
|
||||
*,
|
||||
route_for_label: Callable[[str | None], str | None],
|
||||
) -> list[dict]:
|
||||
"""Return one API-gated split-decision record per unit (POPUP cascade).
|
||||
|
||||
Schema mirrors :func:`gather_step17_ai_repair_proposals` so a Step 17
|
||||
artifact consumer can multiplex DETERMINISTIC / POPUP / AI_REPAIR records
|
||||
onto the same retry trace. POPUP-specific fields:
|
||||
|
||||
* ``cascade_stage`` — always ``"popup"``.
|
||||
* ``api_gated`` — always ``True`` at u4. Future IMP activating the
|
||||
Anthropic API for popup splitting will flip this to ``False`` for
|
||||
units that traversed the deterministic POPUP gate (u5) without
|
||||
resolving via summary-only.
|
||||
* ``ai_called`` — always ``False`` at u4 (contract surface only).
|
||||
* ``skip_reason`` — always
|
||||
:data:`STEP17_POPUP_SPLIT_DECISION_API_GATED_REASON`.
|
||||
* ``split_decision`` — always ``None`` at u4. Once activated, this will
|
||||
carry the AI-proposed ``{"body_preview": ..., "popup_full": ...}``
|
||||
pair; u5 deterministic gate fills the same field deterministically
|
||||
from container px budgets (preview_chars) and never invokes AI.
|
||||
|
||||
Per IMP-35 u4 binding contract: the API stays gated. No Anthropic call,
|
||||
no route_ai_fallback import, no client instantiation. Structural import
|
||||
tests in :mod:`tests.phase_z2_ai_fallback.test_step17` continue to lock
|
||||
these guarantees.
|
||||
"""
|
||||
records: list[dict] = []
|
||||
for index, unit in enumerate(units):
|
||||
label = getattr(unit, "label", None)
|
||||
record: dict = {
|
||||
"unit_index": index,
|
||||
"source_section_ids": list(
|
||||
getattr(unit, "source_section_ids", []) or []
|
||||
),
|
||||
"frame_template_id": getattr(unit, "frame_template_id", None),
|
||||
"label": label,
|
||||
"route_hint": route_for_label(label),
|
||||
"provisional": bool(getattr(unit, "provisional", False)),
|
||||
"cascade_stage": OverflowCascadeStage.POPUP.value,
|
||||
"ai_called": False,
|
||||
"api_gated": True,
|
||||
"skip_reason": STEP17_POPUP_SPLIT_DECISION_API_GATED_REASON,
|
||||
"split_decision": None,
|
||||
"error": None,
|
||||
}
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
def gather_step17_ai_repair_proposals(
|
||||
units: Iterable[Any],
|
||||
*,
|
||||
|
||||
@@ -315,6 +315,321 @@ def select_display_strategy_candidates(
|
||||
return [s for s in order if s in eligible]
|
||||
|
||||
|
||||
# ─── IMP-35 (#64) u6 — Composition popup binding (yaml strategy -> zone payload) ─
|
||||
#
|
||||
# Stage 2 binding contract (unit u6):
|
||||
# Step 17 POPUP gate (u5 in src/phase_z2_ai_fallback/step17.py) stamps
|
||||
# ``unit.has_popup=True`` AND ``unit.popup_escalation_plan=<plan>`` on
|
||||
# composition units whose overflow category routes to
|
||||
# ``details_popup_escalation``. u6 is the composition-side binding that
|
||||
# translates the unit-side marker into a deterministic zone payload
|
||||
# structure that u7 (pipeline composer -> render_slide wiring) reads to
|
||||
# emit the ``<details>/<summary>`` markup u8 will add to slide_base.html.
|
||||
#
|
||||
# Inputs (unit-side, all duck-typed via getattr):
|
||||
# has_popup — bool (False default; u5 sets True on
|
||||
# feasible escalation only)
|
||||
# popup_escalation_plan — dict | None (u3 router plan from
|
||||
# plan_details_popup_escalation; carries
|
||||
# feasible / category / rationale /
|
||||
# needs_split_decision)
|
||||
# raw_content — str (the source MDX content; popup body
|
||||
# source per CLAUDE.md 자세히보기 원칙)
|
||||
#
|
||||
# Outputs (zone payload binding dict):
|
||||
# display_strategy — catalog strategy id read from
|
||||
# display_strategies.yaml (NOT hardcoded).
|
||||
# ``inline_full`` when has_popup=False.
|
||||
# ``inline_preview_with_details`` when
|
||||
# has_popup=True (preview = excerpt from
|
||||
# container px budget downstream; popup body
|
||||
# preserves the FULL original).
|
||||
# popup_body_source — str | None — the FULL raw_content. u7 passes
|
||||
# this verbatim to the renderer; the popup
|
||||
# body is the MDX 원문 (자세히보기 원칙),
|
||||
# never summarized in the body branch.
|
||||
# None when has_popup=False.
|
||||
# detail_trigger — dict | None — placement + label read from
|
||||
# the catalog strategy entry's
|
||||
# ``detail_trigger``. None when has_popup=False.
|
||||
# preserves_original — bool — echoed from the catalog entry.
|
||||
# MUST be True for popup-binding strategies
|
||||
# (absolute user lock — 오답노트 #5 /
|
||||
# IMPROVEMENT-REDESIGN.md §3.6 line 110).
|
||||
# has_popup — bool — echoed for downstream multiplex.
|
||||
# popup_escalation_plan — dict | None — echoed verbatim (u5 plan).
|
||||
# Provides traceability into the router
|
||||
# category + rationale for downstream debug.
|
||||
# strategy_meta — dict — full catalog entry (description /
|
||||
# applies_to / forbidden_for / detail_trigger)
|
||||
# so downstream traces can self-explain without
|
||||
# re-reading the yaml.
|
||||
#
|
||||
# Guardrails honored:
|
||||
# - feedback_ai_isolation_contract — NO AI call. Reads catalog + unit
|
||||
# state only. The deterministic POPUP gate (u5) already established
|
||||
# the marker; this function is pure composition-side binding.
|
||||
# - feedback_no_hardcoding — strategy id is the ONLY name reference, and
|
||||
# it is the catalog key (yaml is source of truth). detail_trigger
|
||||
# placement / label come from the catalog entry, not literals.
|
||||
# - MDX 원문 무손실 보존 — popup_body_source = full raw_content.
|
||||
# u6 NEVER trims or summarizes; the body preview (excerpt from
|
||||
# container px budget) is composed by u7 downstream.
|
||||
# - Phase Z spacing 방향 — u6 binds a strategy that EXPANDS capacity
|
||||
# (popup escalation) instead of shrinking common margins.
|
||||
|
||||
# Strategy id used when the unit carries no popup escalation marker.
|
||||
# Catalog read — yaml is source of truth.
|
||||
POPUP_BINDING_NO_POPUP_STRATEGY_ID = "inline_full"
|
||||
|
||||
# Strategy id used when the unit carries has_popup=True (deterministic
|
||||
# choice — the preview body is a px-budget excerpt of the original, the
|
||||
# popup body holds the FULL original per CLAUDE.md 자세히보기 원칙).
|
||||
# u5 q3 — preview_chars deterministic from container px telemetry; that
|
||||
# is an excerpt-from-original pattern, which matches
|
||||
# ``inline_preview_with_details``. ``details_only`` (summary-only body)
|
||||
# is the alternative future axis when an AI/summarizer is available.
|
||||
POPUP_BINDING_ESCALATED_STRATEGY_ID = "inline_preview_with_details"
|
||||
|
||||
|
||||
def bind_popup_display_strategy(unit) -> dict:
|
||||
"""Bind catalog popup display strategy to a zone payload (IMP-35 u6).
|
||||
|
||||
Reads the unit-side ``has_popup`` + ``popup_escalation_plan`` markers
|
||||
stamped by Step 17 POPUP gate (u5) and produces a zone payload dict
|
||||
that u7 wires into the renderer. The catalog
|
||||
(``display_strategies.yaml``) is the source of truth for both the
|
||||
strategy id and the detail_trigger placement / label — no hardcoded
|
||||
string literals.
|
||||
|
||||
Args:
|
||||
unit: a CompositionUnit (or any duck-typed object exposing
|
||||
``has_popup`` / ``popup_escalation_plan`` / ``raw_content``).
|
||||
``has_popup`` defaults to False when the attribute is absent
|
||||
(units that never went through the Step 17 POPUP gate).
|
||||
|
||||
Returns:
|
||||
zone payload binding dict (see module-level u6 contract block
|
||||
immediately above for the full schema).
|
||||
|
||||
Raises:
|
||||
RuntimeError: if the chosen catalog strategy id is missing from
|
||||
the loaded ``DISPLAY_STRATEGIES`` mapping. Defensive guard —
|
||||
yaml drift would otherwise cause downstream KeyError on a
|
||||
stale string literal. The constants
|
||||
``POPUP_BINDING_NO_POPUP_STRATEGY_ID`` /
|
||||
``POPUP_BINDING_ESCALATED_STRATEGY_ID`` must always resolve
|
||||
against the catalog at import time.
|
||||
"""
|
||||
has_popup = bool(getattr(unit, "has_popup", False))
|
||||
plan = getattr(unit, "popup_escalation_plan", None)
|
||||
raw_content = getattr(unit, "raw_content", "") or ""
|
||||
|
||||
strategy_id = (
|
||||
POPUP_BINDING_ESCALATED_STRATEGY_ID
|
||||
if has_popup
|
||||
else POPUP_BINDING_NO_POPUP_STRATEGY_ID
|
||||
)
|
||||
meta = DISPLAY_STRATEGIES.get(strategy_id)
|
||||
if meta is None:
|
||||
raise RuntimeError(
|
||||
f"bind_popup_display_strategy: catalog drift — strategy id "
|
||||
f"{strategy_id!r} is missing from display_strategies.yaml. "
|
||||
f"Loaded keys: {sorted(DISPLAY_STRATEGIES)}."
|
||||
)
|
||||
|
||||
if not has_popup:
|
||||
return {
|
||||
"display_strategy": strategy_id,
|
||||
"popup_body_source": None,
|
||||
"detail_trigger": None,
|
||||
"preserves_original": bool(meta.get("preserves_original")),
|
||||
"has_popup": False,
|
||||
"popup_escalation_plan": None,
|
||||
"strategy_meta": meta,
|
||||
}
|
||||
|
||||
# has_popup=True path. preserves_original MUST be True per the catalog
|
||||
# absolute user lock — defensive guard against yaml drift.
|
||||
if not meta.get("preserves_original"):
|
||||
raise RuntimeError(
|
||||
f"bind_popup_display_strategy: catalog invariant violated — "
|
||||
f"popup-binding strategy {strategy_id!r} has preserves_original="
|
||||
f"{meta.get('preserves_original')!r}; MDX 원문 무손실 보존 "
|
||||
f"requires preserves_original=True (오답노트 #5 / "
|
||||
f"IMPROVEMENT-REDESIGN.md §3.6 line 110)."
|
||||
)
|
||||
trigger_meta = meta.get("detail_trigger") or {}
|
||||
return {
|
||||
"display_strategy": strategy_id,
|
||||
# MDX 원문 무손실 보존 — popup body = full raw_content (verbatim).
|
||||
"popup_body_source": raw_content,
|
||||
"detail_trigger": {
|
||||
"placement": trigger_meta.get("placement"),
|
||||
"label": trigger_meta.get("label"),
|
||||
},
|
||||
"preserves_original": True,
|
||||
"has_popup": True,
|
||||
"popup_escalation_plan": plan,
|
||||
"strategy_meta": meta,
|
||||
}
|
||||
|
||||
|
||||
# ─── IMP-35 (#64) u7 — Pipeline composer -> render_slide wiring ──
|
||||
#
|
||||
# Stage 2 wiring contract (unit u7):
|
||||
# u6 (``bind_popup_display_strategy``) produced the deterministic zone
|
||||
# binding from the unit-side marker stamped by Step 17 POPUP gate (u5).
|
||||
# u7 wires that binding into the pipeline composer's zones_data so the
|
||||
# render_slide call site (and downstream slide_base.html consumer u8)
|
||||
# sees three uniform render-context field names per zone:
|
||||
#
|
||||
# has_popup : bool — escalation marker echo
|
||||
# popup_html : str — popup body source (full ``raw_content`` per u6;
|
||||
# u8 wraps it in ``<details>/<summary>``).
|
||||
# ``None`` when has_popup=False.
|
||||
# preview_text : str — px-budgeted excerpt of ``raw_content`` shown in
|
||||
# the body / inline_preview slot. NEVER trims
|
||||
# inside a line — line-boundary cut only — and
|
||||
# the popup body retains the FULL original
|
||||
# (MDX 원문 무손실 보존). ``None`` when
|
||||
# has_popup=False.
|
||||
#
|
||||
# The full u6 binding is also echoed on the zone dict under
|
||||
# ``popup_binding`` so downstream debug / catalog-aware consumers can
|
||||
# self-explain without re-reading the yaml.
|
||||
#
|
||||
# Why the preview is a deterministic line-budget cut (u5 q3 resolution):
|
||||
# The popup body holds the FULL original verbatim, so the preview loses
|
||||
# no information — it just truncates at a deterministic boundary that
|
||||
# fits the container height telemetry. Container telemetry source is the
|
||||
# per-unit ``min_height_px`` (frame visual_hints), which is what the
|
||||
# pipeline composer already knows at the zones_data append site.
|
||||
#
|
||||
# We never re-summarize, never AI-call, never reorder. Char-budget cut
|
||||
# would risk splitting CJK words mid-character — line-boundary cut is
|
||||
# the closest deterministic surface to ``raw_content`` semantics
|
||||
# (MDX paragraph / bullet boundaries).
|
||||
#
|
||||
# Guardrails honored:
|
||||
# - feedback_ai_isolation_contract — pure deterministic helper. No
|
||||
# anthropic import, no AI fallback router path.
|
||||
# - MDX 원문 무손실 보존 — preview is a CUT, never a rewrite; popup body
|
||||
# stays equal to ``raw_content``.
|
||||
# - feedback_no_hardcoding — line metric is parametric (line_height_px
|
||||
# defaults to slide_base.html body line metric ~18 px = 11 px font *
|
||||
# 1.6 line-height + ~0.4 px ascent guard). u9 will surface the literal
|
||||
# value source.
|
||||
|
||||
# Line height in px used to convert a container-height budget into a
|
||||
# line-count budget. Matches slide_base.html ``--font-body`` (11 px) at
|
||||
# the ``.text-line`` line-height (1.6). Default — NOT a hardcoded magic
|
||||
# constant: ``compute_popup_preview_text`` accepts an override so the
|
||||
# downstream renderer (u8) or per-frame contracts can pass a tighter
|
||||
# value if a frame uses a smaller body font.
|
||||
POPUP_PREVIEW_DEFAULT_LINE_HEIGHT_PX = 18.0
|
||||
|
||||
|
||||
def compute_popup_preview_text(
|
||||
raw_content: str,
|
||||
container_height_px: float,
|
||||
*,
|
||||
line_height_px: float = POPUP_PREVIEW_DEFAULT_LINE_HEIGHT_PX,
|
||||
) -> str:
|
||||
"""Px-budgeted preview excerpt of ``raw_content`` (IMP-35 u7).
|
||||
|
||||
Deterministic line-boundary cut — returns the leading lines of
|
||||
``raw_content`` that fit within ``container_height_px`` at the slide
|
||||
body line metric. Never trims inside a line (no mid-CJK-word cut);
|
||||
the popup body (u6 ``popup_body_source``) retains the FULL original
|
||||
verbatim so this excerpt loses no information.
|
||||
|
||||
Args:
|
||||
raw_content: the unit's source MDX content; the popup body
|
||||
source per CLAUDE.md 자세히보기 원칙.
|
||||
container_height_px: container height telemetry. The pipeline
|
||||
composer passes ``min_height_px`` (frame visual_hints) at
|
||||
the zones_data append site. Non-positive values fall back
|
||||
to returning the full content unchanged (popup gate would
|
||||
not have fired without a real container budget anyway).
|
||||
line_height_px: px per body line. Default matches slide_base.html
|
||||
``.text-line`` (11 px font * 1.6 line-height + guard).
|
||||
Overridable for tighter-font frames.
|
||||
|
||||
Returns:
|
||||
The leading lines that fit the budget, joined verbatim. If the
|
||||
content already fits, returns ``raw_content`` unchanged.
|
||||
"""
|
||||
if not raw_content:
|
||||
return ""
|
||||
if container_height_px <= 0 or line_height_px <= 0:
|
||||
# No budget signal — return the full content unchanged. u5 POPUP
|
||||
# gate would not have fired without a real container budget, so
|
||||
# this branch is only reachable for non-popup units (where the
|
||||
# preview is anyway unused — see compose_zone_popup_payload).
|
||||
return raw_content
|
||||
max_lines = int(container_height_px // line_height_px)
|
||||
if max_lines < 1:
|
||||
max_lines = 1
|
||||
lines = raw_content.splitlines(keepends=False)
|
||||
if len(lines) <= max_lines:
|
||||
return raw_content
|
||||
# Re-join with "\n" — splitlines drops the terminator so a verbatim
|
||||
# round-trip of the leading lines is "\n".join(...). Preserves the
|
||||
# exact head of raw_content up to the chosen line boundary.
|
||||
return "\n".join(lines[:max_lines])
|
||||
|
||||
|
||||
def compose_zone_popup_payload(unit, container_height_px: float) -> dict:
|
||||
"""Compose the per-zone popup render-context payload (IMP-35 u7).
|
||||
|
||||
Reads u6 ``bind_popup_display_strategy(unit)`` and surfaces the three
|
||||
uniform render-context field names the pipeline composer attaches to
|
||||
each zone in ``zones_data``. The full u6 binding is also echoed
|
||||
under ``popup_binding`` so downstream debug / u8 / u9 consumers can
|
||||
self-explain without re-reading the yaml.
|
||||
|
||||
Args:
|
||||
unit: a CompositionUnit (or any duck-typed object exposing
|
||||
``has_popup`` / ``popup_escalation_plan`` / ``raw_content``).
|
||||
container_height_px: container height telemetry. The pipeline
|
||||
composer passes ``min_height_px`` at the zones_data append
|
||||
site. The non-popup branch ignores the value (preview_text
|
||||
is always None when has_popup=False).
|
||||
|
||||
Returns:
|
||||
Dict with the four wiring keys (``has_popup``, ``popup_html``,
|
||||
``preview_text``, ``popup_binding``). Spreadable into a zone
|
||||
dict via ``zones_data.append({..., **payload})``.
|
||||
"""
|
||||
binding = bind_popup_display_strategy(unit)
|
||||
has_popup = bool(binding.get("has_popup"))
|
||||
if not has_popup:
|
||||
return {
|
||||
"has_popup": False,
|
||||
"popup_html": None,
|
||||
"preview_text": None,
|
||||
"popup_binding": binding,
|
||||
}
|
||||
raw_content = getattr(unit, "raw_content", "") or ""
|
||||
popup_html = binding.get("popup_body_source")
|
||||
preview_text = compute_popup_preview_text(raw_content, container_height_px)
|
||||
return {
|
||||
"has_popup": True,
|
||||
# popup body = FULL raw_content (u6 popup_body_source). u8 wraps
|
||||
# this in <details>/<summary> markup on slide_base.html.
|
||||
"popup_html": popup_html,
|
||||
# body preview = px-budgeted line-boundary cut of raw_content.
|
||||
# NEVER trims inside a line; popup body holds the FULL original
|
||||
# so this excerpt loses no information.
|
||||
"preview_text": preview_text,
|
||||
# Full u6 binding echo — downstream debug surfaces (catalog
|
||||
# detail_trigger placement, popup_escalation_plan category /
|
||||
# rationale) without re-reading yaml.
|
||||
"popup_binding": binding,
|
||||
}
|
||||
|
||||
|
||||
# ─── CompositionUnit ────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
@@ -925,3 +1240,341 @@ def plan_composition(sections, v4_lookup_fn, v4_label_to_status: dict,
|
||||
}
|
||||
|
||||
return units, preset, debug
|
||||
|
||||
|
||||
# ─── IMP-48 — Re-split All-Reject Merges (#77, Stage 2 / u1~u3) ─────
|
||||
|
||||
def resplit_all_reject_merges(
|
||||
units: list[CompositionUnit],
|
||||
sections,
|
||||
v4_lookup_fn,
|
||||
v4_label_to_status: dict,
|
||||
allowed_statuses: set[str],
|
||||
*,
|
||||
capacity_fit_fn=None,
|
||||
v4_candidates_lookup_fn=None,
|
||||
section_assignment_override: bool = False,
|
||||
) -> tuple[list[CompositionUnit], dict]:
|
||||
"""Re-split merged composition units whose rank-1 V4 label is ``reject``.
|
||||
|
||||
IMP-48 (#77) — Step 6 post-pass that decomposes a merged unit
|
||||
(``parent_merged`` / ``parent_merged_inferred``) carrying ``label=reject``
|
||||
into per-section singles, so child sections with non-reject rank-1 V4
|
||||
evidence can flow through the normal use_as_is / light_edit / restructure
|
||||
paths instead of being handed to IMP-47B (#76) as a single blob.
|
||||
|
||||
Stage 2 / u3 slice (current revision) :
|
||||
u1 contract (detection scan + override skip + idempotent single-
|
||||
exclusion) + u2 per-section Branch-1 rebuild (each rebuilt single
|
||||
carries ``merge_type="single"`` + the section's OWN rank-1 V4
|
||||
evidence via ``v4_lookup_fn`` + the section's original
|
||||
``raw_content`` from ``sections``) are both preserved. u3 adds the
|
||||
gating + swap path :
|
||||
|
||||
1. **Coverage equality** — every child section in
|
||||
``source_section_ids`` MUST rebuild successfully. Any
|
||||
``section_not_found`` / ``no_v4_match`` rebuild result short-
|
||||
circuits that merged unit to ``reason="incomplete_rebuild"``.
|
||||
2. **Beneficial split** — at least one rebuilt single MUST have
|
||||
``label != "reject"`` (Stage 2 Q2 Codex YES — "≥1 section
|
||||
gains non-reject frame"). Otherwise that merged unit short-
|
||||
circuits to ``reason="no_beneficial_split"`` and IMP-47B (#76)
|
||||
handles the merge directly.
|
||||
3. **Layout cap (≤ 4 units)** — projected post-split unit count
|
||||
(across ALL detected merges that would split) MUST be ≤ 4.
|
||||
Otherwise EVERY would-be split is aborted with
|
||||
``reason="layout_cap_exceeded"`` (Stage 2 Q2 default — keep
|
||||
merged, no partial split; v0 ``select_layout_preset`` supports
|
||||
1~4 units max).
|
||||
4. **Telemetry** — every single produced by an APPLIED split has
|
||||
``selection_path="resplit_from_merge"`` (Stage 1 Q3 YES,
|
||||
additive field reuse — no schema add).
|
||||
5. **Audit payload** — ``audit["applied"]`` reflects whether ANY
|
||||
merge actually split. ``audit["split_units"]`` /
|
||||
``audit["skipped_units"]`` capture per-merge decisions.
|
||||
``audit["post_split_unit_count"]`` reflects the returned list
|
||||
length. ``audit["post_split_layout_preset"]`` is filled via
|
||||
``select_layout_preset(out_units)`` when ``applied=True``,
|
||||
None otherwise (u5 also re-derives in pipeline scope).
|
||||
|
||||
``out_units`` is the post-resplit unit list (merged removed +
|
||||
singles inserted, in original ordering). When no merge splits,
|
||||
``out_units`` is byte-identical to input ``units`` and
|
||||
``applied=False`` — the audit's ``skipped_reason`` becomes
|
||||
``"no_split_applied"``.
|
||||
|
||||
Detection signal (★ no-hardcoding, AI=0) :
|
||||
``merge_type ∈ {"parent_merged", "parent_merged_inferred"}``
|
||||
AND ``label == "reject"``
|
||||
AND ``len(source_section_ids) >= 2``
|
||||
|
||||
Signal uses only ``merge_type`` + ``label`` + section count — never
|
||||
section_id, template_id, MDX filename, or sample identifier.
|
||||
|
||||
Override skip (Stage 2 Q1 — kwarg per Codex YES) :
|
||||
``section_assignment_override=True`` makes the helper a no-op. User-
|
||||
driven ``zoneSections`` (#6 IMP-06) is the ground truth and must not
|
||||
be second-guessed by an automatic re-split.
|
||||
|
||||
Idempotency (max_retry=1, Stage 2 lock) :
|
||||
u2's rebuilt units carry ``merge_type="single"``, which is excluded
|
||||
from the detection filter by construction. A second pass through
|
||||
this helper finds nothing — no inner loop, no recursion.
|
||||
|
||||
Frame-swap guardrail (★ feedback_ai_isolation_contract) :
|
||||
u2 rebuilds each child section's single from its OWN rank-1 V4
|
||||
evidence via ``v4_lookup_fn``. The merged unit's parent /
|
||||
representative ``template_id`` is discarded along with the merge
|
||||
itself — no swap of one section's frame onto another section.
|
||||
|
||||
Args:
|
||||
units: composition units from ``plan_composition()``.
|
||||
sections: original section list (forwarded to u2 for per-section
|
||||
``raw_content`` lookup — merged units carry the joined string,
|
||||
not the individual child source).
|
||||
v4_lookup_fn: ``(section_id) -> V4Match | None`` (rank-1). Forwarded
|
||||
to u2 — identical evidence source as ``plan_composition``.
|
||||
v4_label_to_status: V4 label → Phase Z status mapping (forwarded).
|
||||
allowed_statuses: auto-renderable status set (forwarded).
|
||||
capacity_fit_fn: optional capacity fit injector (forwarded to u2).
|
||||
v4_candidates_lookup_fn: optional Step 6-A candidates fn (forwarded).
|
||||
section_assignment_override: True iff user supplied
|
||||
``zoneSections`` / ``section_assignment_plan`` (IMP-06 chain).
|
||||
|
||||
Returns:
|
||||
``(out_units, audit)`` :
|
||||
``out_units`` = post-resplit units (u1: identical to input).
|
||||
``audit`` = ``imp48_resplit`` payload following Stage 1 schema::
|
||||
|
||||
{
|
||||
"applied": bool, # u1: always False
|
||||
"split_units": [...], # u3 fills with per-section singles
|
||||
"skipped_units": [...], # u3 fills with kept-merged + reason
|
||||
"post_split_unit_count": int,
|
||||
"post_split_layout_preset": Optional[str],
|
||||
"skipped_reason": str, # u1: contract-stage reason
|
||||
"detected_units": [...], # u1: u2's rebuild targets
|
||||
}
|
||||
"""
|
||||
# ``allowed_statuses`` is forwarded for signature symmetry with
|
||||
# ``plan_composition`` but unused inside the helper — Stage 2 / Codex YES
|
||||
# fixed the beneficial-split threshold to ``single.label != "reject"``
|
||||
# (Stage 1 contract "non-reject rank-1"). Future axes may widen the
|
||||
# threshold using ``allowed_statuses``; until then the parameter is
|
||||
# explicitly deleted to silence lint without losing the public contract.
|
||||
del allowed_statuses
|
||||
|
||||
audit: dict = {
|
||||
"applied": False,
|
||||
"split_units": [],
|
||||
"skipped_units": [],
|
||||
"post_split_unit_count": len(units),
|
||||
"post_split_layout_preset": None,
|
||||
"detected_units": [],
|
||||
"rebuild_attempts": [],
|
||||
}
|
||||
|
||||
if section_assignment_override:
|
||||
audit["skipped_reason"] = "section_assignment_override"
|
||||
return units, audit
|
||||
|
||||
detected = [
|
||||
u for u in units
|
||||
if u.merge_type in {"parent_merged", "parent_merged_inferred"}
|
||||
and u.label == "reject"
|
||||
and len(u.source_section_ids) >= 2
|
||||
]
|
||||
audit["detected_units"] = [
|
||||
{
|
||||
"source_section_ids": list(u.source_section_ids),
|
||||
"merge_type": u.merge_type,
|
||||
"template_id": u.frame_template_id,
|
||||
"label": u.label,
|
||||
}
|
||||
for u in detected
|
||||
]
|
||||
if not detected:
|
||||
audit["skipped_reason"] = "no_detection"
|
||||
return units, audit
|
||||
|
||||
# u2 — per-section Branch-1 rebuild for each detected merged-reject unit.
|
||||
# Mirrors ``collect_candidates`` Branch 1 (single per section). Each rebuilt
|
||||
# single carries the section's OWN rank-1 V4 evidence — the merged unit's
|
||||
# parent/representative template_id is discarded along with the merge.
|
||||
# ★ feedback_ai_isolation_contract : no frame swap (each section's own V4).
|
||||
# ★ MDX_raw_content_invariant : raw_content taken from sections list.
|
||||
# ★ idempotency : merge_type="single" excludes singles
|
||||
# from re-detection on any later pass.
|
||||
section_by_id = {s.section_id: s for s in sections}
|
||||
|
||||
def _v4_cands(section_id: str) -> list:
|
||||
return v4_candidates_lookup_fn(section_id) if v4_candidates_lookup_fn else []
|
||||
|
||||
rebuild_attempts: list[dict] = []
|
||||
for merged_unit in detected:
|
||||
section_singles: list[dict] = []
|
||||
for sid in merged_unit.source_section_ids:
|
||||
section = section_by_id.get(sid)
|
||||
if section is None:
|
||||
section_singles.append({
|
||||
"section_id": sid,
|
||||
"build_result": "section_not_found",
|
||||
"unit": None,
|
||||
})
|
||||
continue
|
||||
match = v4_lookup_fn(sid)
|
||||
if match is None:
|
||||
section_singles.append({
|
||||
"section_id": sid,
|
||||
"build_result": "no_v4_match",
|
||||
"unit": None,
|
||||
})
|
||||
continue
|
||||
single = CompositionUnit(
|
||||
source_section_ids=[sid],
|
||||
merge_type="single",
|
||||
frame_template_id=match.template_id,
|
||||
frame_id=match.frame_id,
|
||||
frame_number=match.frame_number,
|
||||
confidence=match.confidence,
|
||||
label=match.label,
|
||||
phase_z_status=v4_label_to_status.get(match.label, "unknown"),
|
||||
v4_rank=getattr(match, "v4_rank", None),
|
||||
selection_path=getattr(match, "selection_path", "rank_1"),
|
||||
fallback_reason=getattr(match, "fallback_reason", None),
|
||||
raw_content=section.raw_content,
|
||||
title=section.title,
|
||||
v4_candidates=_v4_cands(sid),
|
||||
provisional=getattr(match, "provisional", False),
|
||||
)
|
||||
_apply_capacity_fit(single, capacity_fit_fn)
|
||||
score_candidate(single)
|
||||
section_singles.append({
|
||||
"section_id": sid,
|
||||
"build_result": "ok",
|
||||
"unit": single,
|
||||
})
|
||||
rebuild_attempts.append({
|
||||
"merged_source_section_ids": list(merged_unit.source_section_ids),
|
||||
"merged_merge_type": merged_unit.merge_type,
|
||||
"merged_template_id": merged_unit.frame_template_id,
|
||||
"section_singles": section_singles,
|
||||
})
|
||||
|
||||
audit["rebuild_attempts"] = rebuild_attempts
|
||||
|
||||
# u3 — gating + swap path.
|
||||
# Per-merge decision: split | skip(reason). Then a cumulative layout-cap
|
||||
# check aborts ALL would-be splits if projected post-split count > 4
|
||||
# (Stage 2 Q2 default — keep merged, no partial split; v0
|
||||
# ``select_layout_preset`` supports 1~4 units max).
|
||||
plans: list[dict] = []
|
||||
for merged_unit, attempt in zip(detected, rebuild_attempts):
|
||||
required_sids = set(merged_unit.source_section_ids)
|
||||
built_sids = {
|
||||
entry["section_id"]
|
||||
for entry in attempt["section_singles"]
|
||||
if entry["build_result"] == "ok"
|
||||
}
|
||||
if built_sids != required_sids:
|
||||
# Some sections failed to rebuild — coverage equality violated.
|
||||
# IMP-47B (#76) will handle the merged unit directly.
|
||||
plans.append({
|
||||
"merged": merged_unit,
|
||||
"decision": "skip",
|
||||
"reason": "incomplete_rebuild",
|
||||
"missing": sorted(required_sids - built_sids),
|
||||
})
|
||||
continue
|
||||
built_units = [
|
||||
entry["unit"]
|
||||
for entry in attempt["section_singles"]
|
||||
if entry["build_result"] == "ok"
|
||||
]
|
||||
non_reject_count = sum(1 for u in built_units if u.label != "reject")
|
||||
if non_reject_count == 0:
|
||||
# No child section gains a non-reject frame — split is not
|
||||
# beneficial. IMP-47B (#76) handles the merge directly.
|
||||
plans.append({
|
||||
"merged": merged_unit,
|
||||
"decision": "skip",
|
||||
"reason": "no_beneficial_split",
|
||||
})
|
||||
continue
|
||||
plans.append({
|
||||
"merged": merged_unit,
|
||||
"decision": "split",
|
||||
"singles": built_units,
|
||||
"non_reject_count": non_reject_count,
|
||||
})
|
||||
|
||||
# Cumulative layout-cap projection across all would-be splits.
|
||||
projected_count = len(units)
|
||||
for plan in plans:
|
||||
if plan["decision"] == "split":
|
||||
projected_count += len(plan["singles"]) - 1
|
||||
if projected_count > 4:
|
||||
for plan in plans:
|
||||
if plan["decision"] == "split":
|
||||
plan["decision"] = "skip"
|
||||
plan["reason"] = "layout_cap_exceeded"
|
||||
plan["projected_count"] = projected_count
|
||||
|
||||
# Build out_units by walking the input list once. Identity match by
|
||||
# ``id(unit)`` keeps the swap deterministic and preserves order.
|
||||
plan_by_unit_id = {id(plan["merged"]): plan for plan in plans}
|
||||
out_units: list[CompositionUnit] = []
|
||||
applied = False
|
||||
for unit in units:
|
||||
plan = plan_by_unit_id.get(id(unit))
|
||||
if plan is None:
|
||||
out_units.append(unit)
|
||||
continue
|
||||
if plan["decision"] == "split":
|
||||
applied = True
|
||||
for single in plan["singles"]:
|
||||
# ★ Stage 1 Q3 YES — additive telemetry tag, no schema add.
|
||||
# Overrides the v4 match's selection_path for split-produced
|
||||
# singles only; non-resplit code paths are unaffected.
|
||||
single.selection_path = "resplit_from_merge"
|
||||
out_units.extend(plan["singles"])
|
||||
audit["split_units"].append({
|
||||
"merged_source_section_ids": list(plan["merged"].source_section_ids),
|
||||
"merged_template_id": plan["merged"].frame_template_id,
|
||||
"non_reject_count": plan["non_reject_count"],
|
||||
"split_singles": [
|
||||
{
|
||||
"section_id": s.source_section_ids[0],
|
||||
"template_id": s.frame_template_id,
|
||||
"label": s.label,
|
||||
"phase_z_status": s.phase_z_status,
|
||||
}
|
||||
for s in plan["singles"]
|
||||
],
|
||||
})
|
||||
else: # skip
|
||||
out_units.append(unit)
|
||||
skip_entry: dict = {
|
||||
"merged_source_section_ids": list(plan["merged"].source_section_ids),
|
||||
"merged_template_id": plan["merged"].frame_template_id,
|
||||
"reason": plan["reason"],
|
||||
}
|
||||
if plan["reason"] == "incomplete_rebuild":
|
||||
skip_entry["missing_section_ids"] = list(plan["missing"])
|
||||
if plan["reason"] == "layout_cap_exceeded":
|
||||
skip_entry["projected_post_split_count"] = plan["projected_count"]
|
||||
audit["skipped_units"].append(skip_entry)
|
||||
|
||||
audit["applied"] = applied
|
||||
audit["post_split_unit_count"] = len(out_units)
|
||||
if applied:
|
||||
# ``select_layout_preset`` is deterministic on unit count (v0).
|
||||
# u5 (pipeline) re-derives layout preset over the same out_units list;
|
||||
# both values stay consistent by construction.
|
||||
audit["post_split_layout_preset"] = select_layout_preset(out_units)
|
||||
audit.pop("skipped_reason", None)
|
||||
else:
|
||||
audit["post_split_layout_preset"] = None
|
||||
audit["skipped_reason"] = "no_split_applied"
|
||||
|
||||
return out_units, audit
|
||||
|
||||
@@ -34,8 +34,12 @@ frame_reselect (V4 top-k 의 다른 frame)
|
||||
details_popup_escalation (가장 invasive — content popup, 마지막 resort)
|
||||
```
|
||||
|
||||
`details_popup_escalation` 은 본 매핑에 *없음* — tabular_overflow / structural_major_overflow /
|
||||
frame_reselect 실패 이후 단계에서 다룸 (별 step).
|
||||
IMP-35 (#64) u2 — cascade terminal landed. `frame_reselect_insufficient`
|
||||
(post-frame remeasure failure, classifier path locked in u1) now routes onto
|
||||
`details_popup_escalation`. The status table records the popup action as
|
||||
MISSING here; the actual executor stub + MISSING→IMPLEMENTED flip lives in
|
||||
`src/phase_z2_router.py` (u3 surface), so this module advertises the cascade
|
||||
terminal without claiming an implementation it does not own.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -74,6 +78,13 @@ FAILURE_TYPE_DESCRIPTIONS: dict[str, str] = {
|
||||
"font_step_compression salvage step failed — FONT_SIZE_STEPS exhausted "
|
||||
"down to the floor without resolving overflow (or text_metrics missing)"
|
||||
),
|
||||
"frame_reselect_insufficient": (
|
||||
"frame_reselect salvage step failed — V4 top-k alternate frame swap "
|
||||
"re-rendered + post-frame remeasure (run_overflow_check) still fails. "
|
||||
"IMP-35 (#64) u1 contract: emitted from salvage_steps[-1].action == "
|
||||
"'frame_reselect' AND passed=False AND post_salvage_overflow present. "
|
||||
"Routes to details_popup_escalation in u2 (cascade terminal)."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -86,6 +97,12 @@ SALVAGE_FAILURE_TYPE_BY_ACTION: dict[str, str] = {
|
||||
"cross_zone_redistribute": "cross_zone_redistribute_insufficient",
|
||||
"glue_compression": "glue_absorption_insufficient",
|
||||
"font_step_compression": "font_step_insufficient",
|
||||
# IMP-35 (#64) u1: post-frame remeasure failure. frame_reselect salvage step
|
||||
# writes a salvage_steps entry with action='frame_reselect', passed=False,
|
||||
# and post_salvage_overflow populated by run_overflow_check on the swapped
|
||||
# frame's HTML. classifier reads that entry; u2 adds the NEXT_ACTION row
|
||||
# that routes this onto details_popup_escalation.
|
||||
"frame_reselect": "frame_reselect_insufficient",
|
||||
}
|
||||
|
||||
|
||||
@@ -98,6 +115,14 @@ NEXT_ACTION_BY_FAILURE: dict[str, str] = {
|
||||
"glue_absorption_insufficient": "font_step_compression",
|
||||
"font_step_insufficient": "layout_adjust",
|
||||
"rerender_still_fails": "frame_reselect",
|
||||
# IMP-35 (#64) u2 — cascade terminal. frame_reselect salvage exhausted
|
||||
# (post-frame remeasure failed; classifier path gated on
|
||||
# post_salvage_overflow per u1/q4) escalates onto details_popup_escalation.
|
||||
# Popup body holds full MDX source; preview shows summary/subset
|
||||
# (CLAUDE.md 자세히보기 원칙). Executor + MISSING→IMPLEMENTED flip lands
|
||||
# in u3 (src/phase_z2_router.py); this module owns the cascade mapping
|
||||
# only.
|
||||
"frame_reselect_insufficient": "details_popup_escalation",
|
||||
"not_attempted": "none",
|
||||
}
|
||||
|
||||
@@ -127,6 +152,12 @@ NEXT_ACTION_RATIONALE: dict[str, str] = {
|
||||
"frame/zone 조합 자체 부적합, V4 top-k 의 다른 frame 평가 (frame_reselect). "
|
||||
"popup 직행은 아직 빠름 (tabular / structural_major 가 아닌 한)"
|
||||
),
|
||||
"frame_reselect_insufficient": (
|
||||
"V4 top-k frame swap + 명시적 post-frame remeasure 까지 했는데도 overflow "
|
||||
"잔존 → cascade terminal 인 details_popup_escalation 으로 escalate. "
|
||||
"본문 = summary/subset, popup = MDX 원문 (자세히보기 원칙). "
|
||||
"AI repair 진입 전 deterministic 마지막 단계."
|
||||
),
|
||||
"not_attempted": (
|
||||
"retry 시도 자체가 없었음 (visual ok 등) — escalation 불필요"
|
||||
),
|
||||
@@ -145,6 +176,12 @@ NEXT_ACTION_IMPLEMENTATION_STATUS: dict[str, str] = {
|
||||
"font_step_compression": "IMPLEMENTED", # u6 plan_font_step_compression + apply_font_step_compression_css
|
||||
"layout_adjust": "MISSING",
|
||||
"frame_reselect": "MISSING",
|
||||
# IMP-35 (#64) u2 — cascade terminal advertised as MISSING here. The
|
||||
# router executor stub + MISSING→IMPLEMENTED flip lives in
|
||||
# src/phase_z2_router.py (u3). Keeping this entry as MISSING until u3
|
||||
# lands prevents premature "popup ready" claims from the failure-router
|
||||
# surface.
|
||||
"details_popup_escalation": "MISSING",
|
||||
"none": "n/a",
|
||||
}
|
||||
|
||||
@@ -170,21 +207,40 @@ def classify_retry_failure(retry_trace: dict) -> Optional[dict]:
|
||||
# case 0.7 : salvage chain attempted and ended in a salvage-level failure.
|
||||
# zone_ratio_retry 가 먼저 실패한 뒤 _attempt_salvage_chain 이 가동된 path —
|
||||
# 마지막 salvage step 의 action 으로 failure_type 을 분류한다. u3 가 routing.
|
||||
#
|
||||
# IMP-35 (#64) u1 — q4 explicit remeasure contract: the frame_reselect
|
||||
# branch is gated on post_salvage_overflow being present on the salvage
|
||||
# step. A bare passed=False flag with no remeasure payload is *not*
|
||||
# sufficient to emit frame_reselect_insufficient (which routes to
|
||||
# details_popup_escalation in u2). When the gate fails, the classifier
|
||||
# falls through to lower-priority cases so the salvage trace surfaces as
|
||||
# an unmatched defensive fallback instead of a spurious popup escalation.
|
||||
salvage_steps = retry_trace.get("salvage_steps") or []
|
||||
if salvage_steps:
|
||||
last = salvage_steps[-1] or {}
|
||||
if not last.get("passed"):
|
||||
action = (last.get("action") or "").lower()
|
||||
ftype = SALVAGE_FAILURE_TYPE_BY_ACTION.get(action)
|
||||
if ftype is not None:
|
||||
reason = last.get("failure_reason") or ""
|
||||
return {
|
||||
"failure_type": ftype,
|
||||
"classification_rule": (
|
||||
f"salvage_steps[-1].action == {action!r} "
|
||||
f"AND passed=False. raw failure_reason: {reason!r}"
|
||||
),
|
||||
}
|
||||
frame_reselect_blocked = (
|
||||
action == "frame_reselect"
|
||||
and not last.get("post_salvage_overflow")
|
||||
)
|
||||
if not frame_reselect_blocked:
|
||||
ftype = SALVAGE_FAILURE_TYPE_BY_ACTION.get(action)
|
||||
if ftype is not None:
|
||||
reason = last.get("failure_reason") or ""
|
||||
rule_suffix = (
|
||||
" AND post_salvage_overflow present"
|
||||
if action == "frame_reselect"
|
||||
else ""
|
||||
)
|
||||
return {
|
||||
"failure_type": ftype,
|
||||
"classification_rule": (
|
||||
f"salvage_steps[-1].action == {action!r} "
|
||||
f"AND passed=False{rule_suffix}. "
|
||||
f"raw failure_reason: {reason!r}"
|
||||
),
|
||||
}
|
||||
|
||||
# case 1 : retry 시도 자체 안 됨 (router_active=False 또는 다른 action)
|
||||
if not retry_trace.get("retry_attempted"):
|
||||
|
||||
+65
-3
@@ -42,6 +42,22 @@ class FitError(Exception):
|
||||
"""
|
||||
|
||||
|
||||
class BuilderMissingError(FitError):
|
||||
"""Contract.payload.builder ↔ PAYLOAD_BUILDERS registry mismatch.
|
||||
|
||||
FitError subclass — pipeline 의 기존 `except FitError` 경로가 그대로
|
||||
adapter_needed 로 라우팅 (mdx04 hard crash 차단, IMP-#85 u1).
|
||||
"""
|
||||
|
||||
|
||||
class CatalogInvariantError(Exception):
|
||||
"""Catalog ↔ runtime registry drift detected at load time.
|
||||
|
||||
Boot-time invariant violation (IMP-#85 u2). Distinct from FitError:
|
||||
runtime fallback 대상이 아니라 catalog wiring 결함 (fail-fast).
|
||||
"""
|
||||
|
||||
|
||||
# ─── Catalog loading ──────────────────────────────────────────────
|
||||
|
||||
_CATALOG_CACHE: dict | None = None
|
||||
@@ -50,7 +66,9 @@ _CATALOG_CACHE: dict | None = None
|
||||
def load_frame_contracts() -> dict:
|
||||
global _CATALOG_CACHE
|
||||
if _CATALOG_CACHE is None:
|
||||
_CATALOG_CACHE = yaml.safe_load(CATALOG_PATH.read_text(encoding="utf-8")) or {}
|
||||
catalog = yaml.safe_load(CATALOG_PATH.read_text(encoding="utf-8")) or {}
|
||||
_check_catalog_builder_invariant(catalog)
|
||||
_CATALOG_CACHE = catalog
|
||||
return _CATALOG_CACHE
|
||||
|
||||
|
||||
@@ -686,6 +704,50 @@ PAYLOAD_BUILDERS: dict[str, Callable] = {
|
||||
}
|
||||
|
||||
|
||||
# ─── Catalog builder invariant (IMP-#85 u2) ──────────────────────
|
||||
|
||||
def _check_catalog_builder_invariant(catalog: dict) -> None:
|
||||
"""Every non-`visual_pending` contract must declare a registered builder.
|
||||
|
||||
`visual_pending: true` contracts are scaffolding records whose builders
|
||||
are tracked as VP backlog (별 axis IMP-04b / #42) — skipped here so the
|
||||
catalog can keep declaring them without breaking boot.
|
||||
|
||||
Violations are aggregated and raised together so first-fix iteration sees
|
||||
the full drift surface, not just the first row.
|
||||
|
||||
Raises:
|
||||
CatalogInvariantError — when one or more live (non-VP) contracts
|
||||
either omit `payload.builder` or reference a name absent from
|
||||
`PAYLOAD_BUILDERS`.
|
||||
"""
|
||||
violations: list[str] = []
|
||||
for template_id, contract in catalog.items():
|
||||
if not isinstance(contract, dict):
|
||||
continue
|
||||
if contract.get("visual_pending") is True:
|
||||
continue
|
||||
payload = contract.get("payload") or {}
|
||||
builder_name = payload.get("builder") if isinstance(payload, dict) else None
|
||||
if not builder_name:
|
||||
violations.append(
|
||||
f"Contract '{template_id}' (non-VP) missing payload.builder."
|
||||
)
|
||||
continue
|
||||
if builder_name not in PAYLOAD_BUILDERS:
|
||||
violations.append(
|
||||
f"Contract '{template_id}' (non-VP) references payload.builder="
|
||||
f"'{builder_name}' not in PAYLOAD_BUILDERS registry."
|
||||
)
|
||||
if violations:
|
||||
raise CatalogInvariantError(
|
||||
f"Catalog builder invariant violated "
|
||||
f"({len(violations)} non-VP contract(s)):\n - "
|
||||
+ "\n - ".join(violations)
|
||||
+ f"\nRegistered builders: {sorted(PAYLOAD_BUILDERS.keys())}"
|
||||
)
|
||||
|
||||
|
||||
# ─── Generic mapper (single dispatch via builder) ────────────────
|
||||
|
||||
def _check_cardinality(contract: dict, units: list, section) -> None:
|
||||
@@ -843,13 +905,13 @@ def map_with_contract(section, contract: dict) -> dict:
|
||||
payload_spec = contract["payload"]
|
||||
builder_name = payload_spec.get("builder")
|
||||
if not builder_name:
|
||||
raise ValueError(
|
||||
raise BuilderMissingError(
|
||||
f"Contract '{contract['template_id']}' missing payload.builder. "
|
||||
f"available: {sorted(PAYLOAD_BUILDERS.keys())}"
|
||||
)
|
||||
builder = PAYLOAD_BUILDERS.get(builder_name)
|
||||
if builder is None:
|
||||
raise ValueError(
|
||||
raise BuilderMissingError(
|
||||
f"Contract '{contract['template_id']}' references payload.builder="
|
||||
f"'{builder_name}' but PAYLOAD_BUILDERS has no such entry. "
|
||||
f"available: {sorted(PAYLOAD_BUILDERS.keys())}"
|
||||
|
||||
+542
-6
@@ -41,8 +41,10 @@ from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
from phase_z2_composition import (
|
||||
LAYOUT_PRESETS,
|
||||
CompositionUnit,
|
||||
compose_zone_popup_payload,
|
||||
derive_parent_id,
|
||||
plan_composition,
|
||||
resplit_all_reject_merges,
|
||||
select_display_strategy_candidates,
|
||||
select_layout_candidates,
|
||||
select_region_layout_candidates,
|
||||
@@ -56,7 +58,7 @@ from phase_z2_mapper import (
|
||||
map_with_contract,
|
||||
)
|
||||
from phase_z2_classifier import classify_visual_runtime_check
|
||||
from phase_z2_router import route_fit_classification
|
||||
from phase_z2_router import plan_details_popup_escalation, route_fit_classification
|
||||
from phase_z2_retry import (
|
||||
DEFAULT_SAFETY_MARGIN_PX,
|
||||
apply_cross_zone_redistribute_css,
|
||||
@@ -84,6 +86,13 @@ from phase_z2_placement_planner import plan_placement
|
||||
# stays in src/config.py + src/phase_z2_ai_fallback/router.py.
|
||||
from src.phase_z2_ai_fallback.step12 import gather_step12_ai_repair_proposals
|
||||
|
||||
# IMP-35 (#64) u5 — Step 17 deterministic POPUP gate executor. Runs after
|
||||
# the salvage cascade exhausts at cascade-terminal action
|
||||
# ``details_popup_escalation`` (router u3 / failure_router u2) and BEFORE
|
||||
# the AI_REPAIR cascade stage. Stamps ``popup_escalation_plan`` and the
|
||||
# idempotent ``has_popup`` marker onto retry_trace per unit. No AI call.
|
||||
from src.phase_z2_ai_fallback.step17 import run_step17_popup_gate
|
||||
|
||||
|
||||
# ─── Constants ──────────────────────────────────────────────────
|
||||
|
||||
@@ -1090,6 +1099,20 @@ def lookup_v4_all_judgments(
|
||||
return out
|
||||
|
||||
|
||||
def _is_visual_pending(template_id: str) -> bool:
|
||||
"""IMP-#85 u4 — return True iff catalog marks contract as ``visual_pending``.
|
||||
|
||||
Data-driven from ``frame_contracts.yaml`` (no hard-coded frame allow-list).
|
||||
Used by ``lookup_v4_candidates`` to exclude VP frames from the live
|
||||
candidate set; ``lookup_v4_all_judgments`` raw telemetry stays untouched
|
||||
(Step 7-A axis preserves full 32-frame evidence for the frontend).
|
||||
"""
|
||||
contract = get_contract(template_id)
|
||||
if not isinstance(contract, dict):
|
||||
return False
|
||||
return contract.get("visual_pending") is True
|
||||
|
||||
|
||||
def lookup_v4_candidates(
|
||||
v4: dict,
|
||||
section_id: str,
|
||||
@@ -1103,6 +1126,7 @@ def lookup_v4_candidates(
|
||||
v4_candidates = [
|
||||
c for c in judgments_full32
|
||||
if c["label"] != "reject"
|
||||
and not visual_pending(c.template_id) # IMP-#85 u4
|
||||
][:max_n]
|
||||
|
||||
Returns:
|
||||
@@ -1114,6 +1138,11 @@ def lookup_v4_candidates(
|
||||
lookup_v4_match() (rank-1) 는 그대로. Step 6 의 plan_composition()
|
||||
호출처 무변. 본 함수는 Step 5 artifact + Step 9 application_plan input
|
||||
위한 새 entry point.
|
||||
|
||||
IMP-#85 u4 — visual_pending frames are excluded from the live candidate
|
||||
set (catalog scaffolding without registered builder would crash the
|
||||
mapper). lookup_v4_all_judgments raw telemetry is intentionally NOT
|
||||
gated here.
|
||||
"""
|
||||
resolved = _resolve_v4_section_key(v4, section_id, alias_keys=alias_keys)
|
||||
sec = v4.get("mdx_sections", {}).get(resolved) if resolved else None
|
||||
@@ -1124,6 +1153,9 @@ def lookup_v4_candidates(
|
||||
for j in judgments:
|
||||
if j.get("label") == "reject":
|
||||
continue
|
||||
tid = j.get("template_id")
|
||||
if tid and _is_visual_pending(tid):
|
||||
continue
|
||||
candidates.append(_v4_match_from_judgment(section_id, j))
|
||||
if len(candidates) >= max_n:
|
||||
break
|
||||
@@ -1688,6 +1720,14 @@ def _override_to_grid_tracks(
|
||||
R = len(rows_grid)
|
||||
C = len(rows_grid[0])
|
||||
|
||||
# Hot-fix (2026-05-22): partial override 버그 fix — override 없는 track 은
|
||||
# default 비율로 fallback. 이전엔 0 반환 → normalize 후 다른 track 이 모든 공간 흡수.
|
||||
_default_result = _build_grid_dynamic_2d(preset, zones_data, gap=gap)
|
||||
_default_widths = _default_result.get("widths_px", []) or []
|
||||
_default_heights = _default_result.get("heights_px", []) or []
|
||||
_sum_w = sum(_default_widths) if _default_widths else 1.0
|
||||
_sum_h = sum(_default_heights) if _default_heights else 1.0
|
||||
|
||||
occupancy: list[tuple[dict, set[int], set[int]]] = []
|
||||
for z in zones_data:
|
||||
pos = z["position"]
|
||||
@@ -1702,17 +1742,19 @@ def _override_to_grid_tracks(
|
||||
single = [z for z, rr, _cc in occupancy if rr == {idx}]
|
||||
allspan = [z for z, rr, _cc in occupancy if rr == set(range(R))]
|
||||
key = "h"
|
||||
_fallback = (_default_heights[idx] / _sum_h) if idx < len(_default_heights) and _sum_h else (1.0 / R)
|
||||
else:
|
||||
single = [z for z, _rr, cc in occupancy if cc == {idx}]
|
||||
allspan = [z for z, _rr, cc in occupancy if cc == set(range(C))]
|
||||
key = "w"
|
||||
_fallback = (_default_widths[idx] / _sum_w) if idx < len(_default_widths) and _sum_w else (1.0 / C)
|
||||
candidates = single or allspan
|
||||
vals = [
|
||||
float(override_zone_geometries[z["position"]][key])
|
||||
for z in candidates
|
||||
if z["position"] in override_zone_geometries
|
||||
]
|
||||
return max(vals) if vals else 0.0
|
||||
return max(vals) if vals else _fallback
|
||||
|
||||
row_values = [_track_value(r, "row") for r in range(R)]
|
||||
col_values = [_track_value(c, "col") for c in range(C)]
|
||||
@@ -1792,10 +1834,18 @@ def build_layout_css(layout_preset: str, zones_data: list[dict],
|
||||
if override_zone_geometries:
|
||||
if layout_preset == "horizontal-2":
|
||||
# heights_px override — zone 의 h 비율로 SLIDE_BODY_HEIGHT 분배.
|
||||
# Hot-fix (2026-05-22): partial override = 나머지 공간을 비-override zone 들에
|
||||
# 균등 분배 (drag boundary intent). 이전엔 0.0 fallback → 100/0 깨짐.
|
||||
overridden_h = sum(
|
||||
float(override_zone_geometries[p]["h"])
|
||||
for p in positions if p in override_zone_geometries
|
||||
)
|
||||
non_overridden = [p for p in positions if p not in override_zone_geometries]
|
||||
per_non = max(0.0, 1.0 - overridden_h) / max(len(non_overridden), 1)
|
||||
ratios = []
|
||||
for pos in positions:
|
||||
geom = override_zone_geometries.get(pos)
|
||||
ratios.append(float(geom["h"]) if geom else 0.0)
|
||||
ratios.append(float(geom["h"]) if geom else per_non)
|
||||
total = sum(ratios)
|
||||
if total > 0:
|
||||
heights_px = [int(round(r / total * SLIDE_BODY_HEIGHT)) for r in ratios]
|
||||
@@ -1817,10 +1867,18 @@ def build_layout_css(layout_preset: str, zones_data: list[dict],
|
||||
# cols override — zone 의 w 비율로 fr 분배 (legacy: fr-string cols).
|
||||
# PR 1 keeps fr-string cols for legacy preserve; widths_px is
|
||||
# populated in pixels for _compute_per_zone_geometry length contract.
|
||||
# Hot-fix (2026-05-22): partial override = 나머지 공간을 비-override zone 들에
|
||||
# 균등 분배 (drag boundary intent). 이전엔 0.0 fallback → 100/0 깨짐.
|
||||
overridden_w = sum(
|
||||
float(override_zone_geometries[p]["w"])
|
||||
for p in positions if p in override_zone_geometries
|
||||
)
|
||||
non_overridden = [p for p in positions if p not in override_zone_geometries]
|
||||
per_non = max(0.0, 1.0 - overridden_w) / max(len(non_overridden), 1)
|
||||
ratios = []
|
||||
for pos in positions:
|
||||
geom = override_zone_geometries.get(pos)
|
||||
ratios.append(float(geom["w"]) if geom else 0.0)
|
||||
ratios.append(float(geom["w"]) if geom else per_non)
|
||||
total = sum(ratios)
|
||||
if total > 0:
|
||||
cols = " ".join(f"{round(r / total * 100, 2)}fr" for r in ratios)
|
||||
@@ -2449,6 +2507,41 @@ def _attempt_salvage_chain(
|
||||
return trace
|
||||
|
||||
|
||||
def _remeasure_after_frame_reselect(
|
||||
*, candidate_path: Path, plan: Optional[dict] = None,
|
||||
) -> dict:
|
||||
"""IMP-35 (#64) u1 — post-frame remeasure helper for the cascade terminal.
|
||||
|
||||
Contract (q4 / Stage 2): frame_reselect_insufficient is detected by an
|
||||
*explicit overflow re-measure* after a V4 top-k alternate frame swap —
|
||||
NOT a failure-flag carryover. This helper runs run_overflow_check on the
|
||||
re-rendered candidate HTML and shapes the salvage_steps entry that
|
||||
classify_retry_failure / SALVAGE_FAILURE_TYPE_BY_ACTION read.
|
||||
|
||||
Future frame_reselect orchestrator (post-IMP-35) writes the candidate
|
||||
HTML and calls this helper to append the entry to retry_trace.salvage_steps.
|
||||
On passed=True the orchestrator promotes the candidate to final.html; on
|
||||
passed=False the classifier emits frame_reselect_insufficient → u2 routes
|
||||
onto details_popup_escalation (Step 17 POPUP gate / u5).
|
||||
"""
|
||||
candidate_overflow = run_overflow_check(candidate_path)
|
||||
passed = bool(candidate_overflow.get("passed", False))
|
||||
return {
|
||||
"action": "frame_reselect",
|
||||
"plan": plan,
|
||||
"passed": passed,
|
||||
"candidate_path": (
|
||||
str(candidate_path.relative_to(PROJECT_ROOT))
|
||||
if candidate_path.is_absolute() else str(candidate_path)
|
||||
),
|
||||
"post_salvage_overflow": candidate_overflow,
|
||||
"failure_reason": (
|
||||
None if passed
|
||||
else (candidate_overflow.get("fail_reasons") or "post-frame remeasure: overflow persists")
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def render_slide(slide_title: str, slide_footer: Optional[str],
|
||||
zones_data: list[dict], layout_preset: str,
|
||||
layout_css: dict, gap_px: int = GRID_GAP,
|
||||
@@ -3379,6 +3472,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.
|
||||
|
||||
@@ -3392,6 +3486,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:
|
||||
@@ -3966,6 +4069,52 @@ def run_phase_z2_mvp1(
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# IMP-48 (#77) — re-split merged-reject units into per-section singles.
|
||||
# One-shot, deterministic (AI=0) post-pass. Fires AFTER all Step 6 settling
|
||||
# chains (initial plan_composition / u12 mixed admission / u4 provisional
|
||||
# retry / empty-shell) and AFTER section_assignment_plan is known, but
|
||||
# BEFORE the Step 6 artifact write below — so the artifact reflects the
|
||||
# post-resplit unit list. SKIPS when --override-section-assignments is
|
||||
# active (IMP-06 / #6 is the ground truth). Helper guardrails (coverage
|
||||
# equality / beneficial split / layout cap ≤ 4) keep mdx03 byte-identical
|
||||
# (no-op on use_as_is / light_edit slides). u5 re-derives layout_preset
|
||||
# below using the audit payload.
|
||||
units, _imp48_audit = resplit_all_reject_merges(
|
||||
units,
|
||||
sections,
|
||||
lookup_fn,
|
||||
V4_LABEL_TO_PHASE_Z_STATUS,
|
||||
MVP1_ALLOWED_STATUSES,
|
||||
capacity_fit_fn=compute_capacity_fit,
|
||||
v4_candidates_lookup_fn=candidates_lookup_fn,
|
||||
section_assignment_override=section_assignment_plan is not None,
|
||||
)
|
||||
comp_debug["imp48_resplit"] = _imp48_audit
|
||||
# u5 — re-derive layout_preset from helper audit (post-split count via
|
||||
# select_layout_preset(out_units)). Helper guarantees post_split_unit_count
|
||||
# ≤ 4 (layout cap abort), so the derived preset is always renderable by
|
||||
# LAYOUT_PRESETS. Respect --override-layout when present (user's explicit
|
||||
# choice wins over auto-redrive; mirrors the override gate above at L3697).
|
||||
if _imp48_audit.get("applied"):
|
||||
_imp48_post_preset = _imp48_audit.get("post_split_layout_preset")
|
||||
if _imp48_post_preset and not layout_override_applied:
|
||||
if _imp48_post_preset != layout_preset:
|
||||
print(
|
||||
f" [IMP-48] layout_preset re-derived: {layout_preset} → "
|
||||
f"{_imp48_post_preset} (post-split unit count="
|
||||
f"{_imp48_audit.get('post_split_unit_count')})",
|
||||
file=sys.stderr,
|
||||
)
|
||||
layout_preset = _imp48_post_preset
|
||||
print(
|
||||
f" [IMP-48] re-split applied — "
|
||||
f"split={len(_imp48_audit.get('split_units', []))} "
|
||||
f"skipped={len(_imp48_audit.get('skipped_units', []))} "
|
||||
f"post_count={_imp48_audit.get('post_split_unit_count')} "
|
||||
f"post_preset={_imp48_audit.get('post_split_layout_preset')!r}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
print(f" preset : {layout_preset} ({len(units)} units, composition v0 count-based)")
|
||||
for u in units:
|
||||
print(f" unit : {u.source_section_ids} merge={u.merge_type} → "
|
||||
@@ -4011,6 +4160,15 @@ def run_phase_z2_mvp1(
|
||||
}
|
||||
for u in units
|
||||
],
|
||||
# IMP-48 (#77) — re-split audit. Additive field. AI=0 deterministic
|
||||
# one-shot post-pass on Step 6 settling result. applied=True means
|
||||
# ≥1 parent_merged / parent_merged_inferred reject unit was split
|
||||
# into per-section singles; selected_units already reflects the
|
||||
# post-split list. Skipped reasons (incomplete_rebuild /
|
||||
# no_beneficial_split / layout_cap_exceeded) keep the merged unit
|
||||
# for IMP-47B (#76) AI handoff. section_assignment_override skip
|
||||
# honors IMP-06 (#6) zoneSections ground truth.
|
||||
"imp48_resplit": _imp48_audit,
|
||||
},
|
||||
step_status="done",
|
||||
pipeline_path_connected=True,
|
||||
@@ -4020,6 +4178,11 @@ def run_phase_z2_mvp1(
|
||||
"composition v0 count-based — sections → candidates → score → greedy select. "
|
||||
"Step 6-A (사용자 lock 2026-05-08): selected_units[i].v4_candidates 추가 "
|
||||
"(non-reject max-6 후보 list, candidates[0] = 단일 frame_* 와 일관). "
|
||||
"IMP-48 (#77, 2026-05-22): merged-reject 자동 분리 post-pass — "
|
||||
"parent_merged / parent_merged_inferred + label=reject + ≥2 sections "
|
||||
"→ per-section singles (each own rank-1 V4 evidence + raw_content 보존). "
|
||||
"guardrails: coverage equality / beneficial split (≥1 non-reject) / "
|
||||
"layout cap (≤4 units). imp48_resplit audit additive. "
|
||||
"logic 무변 — runtime 결과 동일. Step 9 application_plan input."
|
||||
),
|
||||
)
|
||||
@@ -4135,6 +4298,11 @@ def run_phase_z2_mvp1(
|
||||
# first-render invariant holds; u5 will surface the provisional flag as
|
||||
# a zone class + needs-adaptation badge.
|
||||
if unit.frame_template_id == "__empty__":
|
||||
# IMP-35 u7 — popup payload wiring. Empty-shell units never go
|
||||
# through the Step 17 POPUP gate (no raw content to escalate),
|
||||
# so compose_zone_popup_payload returns the no-popup branch
|
||||
# (has_popup=False, popup_html=None, preview_text=None).
|
||||
_popup_payload = compose_zone_popup_payload(unit, 0)
|
||||
zones_data.append({
|
||||
"position": position,
|
||||
"template_id": "__empty__",
|
||||
@@ -4144,6 +4312,7 @@ def run_phase_z2_mvp1(
|
||||
"assignment_source": "imp30_u4_empty_shell",
|
||||
"section_assignment_override": False,
|
||||
"provisional": bool(getattr(unit, "provisional", False)),
|
||||
**_popup_payload,
|
||||
})
|
||||
debug_zones.append({
|
||||
"position": position,
|
||||
@@ -4265,15 +4434,87 @@ def run_phase_z2_mvp1(
|
||||
try:
|
||||
slot_payload = map_mdx_to_slots(synth_section, unit.frame_template_id)
|
||||
except FitError as e:
|
||||
_fit_error_str = str(e)
|
||||
_unit_provisional = bool(getattr(unit, "provisional", False))
|
||||
adapter_record = {
|
||||
"position": position,
|
||||
"source_section_ids": unit.source_section_ids,
|
||||
"merge_type": unit.merge_type,
|
||||
"template_id": unit.frame_template_id,
|
||||
"reason": "fit_error",
|
||||
"fit_error": str(e),
|
||||
"fit_error": _fit_error_str,
|
||||
}
|
||||
adapter_needed_units.append(adapter_record)
|
||||
# IMP-86 u1 — placeholder zones_data + debug_zone keep the failed
|
||||
# unit's preset position so downstream build_layout_css /
|
||||
# _compute_per_zone_geometry observe len(zones_data) == active
|
||||
# layout preset's css_areas rows (R). Without this both arrays
|
||||
# drift relative to R, raising
|
||||
# ValueError("heights_px length N != grid rows R=M") at
|
||||
# _compute_per_zone_geometry. Mirrors the IMP-30 empty-shell
|
||||
# pattern: zones_data uses template_id="__empty__" so render_slide
|
||||
# short-circuits to empty partial_html; debug_zone keeps the
|
||||
# original V4 evidence and adapter_needed_units stays the
|
||||
# authoritative adapter signal.
|
||||
# IMP-86 u5 — per-record telemetry. adapter_needed=True +
|
||||
# mapper_fit_error=<str(FitError)> + provisional mirror the
|
||||
# adapter signal directly on each placeholder record so
|
||||
# debug.json / final.html consumers can identify the adapter
|
||||
# contract surface from the zones array alone, without joining
|
||||
# against slide_status.adapter_needed_units. adapter_needed_units
|
||||
# itself is unchanged (still the authoritative per-slide list).
|
||||
_placeholder_popup = compose_zone_popup_payload(unit, 0)
|
||||
zones_data.append({
|
||||
"position": position,
|
||||
"template_id": "__empty__",
|
||||
"slot_payload": {},
|
||||
"content_weight": {"score": 0},
|
||||
"min_height_px": min_height_px,
|
||||
"assignment_source": "imp86_u1_adapter_needed",
|
||||
"section_assignment_override": False,
|
||||
"provisional": _unit_provisional,
|
||||
"adapter_needed": True,
|
||||
"mapper_fit_error": _fit_error_str,
|
||||
**_placeholder_popup,
|
||||
})
|
||||
debug_zones.append({
|
||||
"position": position,
|
||||
"source_section_ids": list(unit.source_section_ids),
|
||||
"merge_type": unit.merge_type,
|
||||
"title": unit.title,
|
||||
"v4_rank1_frame_id": unit.frame_id,
|
||||
"v4_rank1_frame_number": unit.frame_number,
|
||||
"v4_template_id": unit.frame_template_id,
|
||||
"v4_label": unit.label,
|
||||
"v4_confidence": float(unit.confidence or 0.0),
|
||||
"v4_selected_rank": unit.v4_rank,
|
||||
"selection_path": unit.selection_path,
|
||||
"fallback_reason": unit.fallback_reason,
|
||||
"fallback_used": bool(unit.selection_path and "fallback" in unit.selection_path),
|
||||
"phase_z_status": unit.phase_z_status,
|
||||
"composition_score": float(unit.score or 0.0),
|
||||
"composition_rationale": dict(unit.rationale or {}),
|
||||
"composition_notes": list(unit.notes),
|
||||
"mapper_type": "adapter_needed",
|
||||
"contract_id": unit.frame_template_id,
|
||||
"contract_frame_id": contract_frame_id,
|
||||
"builder": builder_name,
|
||||
"min_height_px": min_height_px,
|
||||
"slot_payload_keys": [],
|
||||
"content_truncated_count": None,
|
||||
"assets_dir": None,
|
||||
"content_weight": {"score": 0},
|
||||
"placement_trace": placement_trace,
|
||||
"assignment_source": "imp86_u1_adapter_needed",
|
||||
"section_assignment_override": False,
|
||||
"replaced_auto_unit": None,
|
||||
"skipped_collided_auto_units": [],
|
||||
"uncovered_section_ids": [],
|
||||
"skipped_reason": "imp86_u1_adapter_needed_mapper_fit_error",
|
||||
"provisional": _unit_provisional,
|
||||
"adapter_needed": True,
|
||||
"mapper_fit_error": _fit_error_str,
|
||||
})
|
||||
print(f" adapter : zone--{position} {unit.source_section_ids} → "
|
||||
f"{unit.frame_template_id} FitError → adapter_needed (skip render)")
|
||||
continue
|
||||
@@ -4314,6 +4555,15 @@ def run_phase_z2_mvp1(
|
||||
# needs-adaptation badge. Default False keeps non-provisional zones
|
||||
# byte-identical to pre-u5; only u1-synthesized rank-1 fills or u4
|
||||
# empty-shell synthesize provisional=True units.
|
||||
#
|
||||
# IMP-35 u7 — popup payload wiring. `compose_zone_popup_payload(unit,
|
||||
# min_height_px)` reads u6 binding (yaml strategy + popup_body_source)
|
||||
# AND derives a px-budgeted preview from min_height_px. Surfaces three
|
||||
# uniform render-context fields per zone (has_popup / popup_html /
|
||||
# preview_text) plus the full u6 binding under `popup_binding` for
|
||||
# u8 / u9 downstream consumers. Non-popup units (has_popup=False)
|
||||
# return the no-popup branch — byte-identical zone shape pre-u7.
|
||||
_popup_payload = compose_zone_popup_payload(unit, min_height_px)
|
||||
zones_data.append({
|
||||
"position": position,
|
||||
"template_id": unit.frame_template_id,
|
||||
@@ -4323,6 +4573,7 @@ def run_phase_z2_mvp1(
|
||||
"assignment_source": plan_assignment_source,
|
||||
"section_assignment_override": plan_section_override,
|
||||
"provisional": bool(getattr(unit, "provisional", False)),
|
||||
**_popup_payload,
|
||||
})
|
||||
debug_zones.append({
|
||||
"position": position,
|
||||
@@ -4378,6 +4629,12 @@ def run_phase_z2_mvp1(
|
||||
pos = record["position"]
|
||||
if pos in renderable_positions:
|
||||
continue
|
||||
# IMP-35 u7 — popup payload wiring for unrenderable empty
|
||||
# plan record. No CompositionUnit exists for this branch
|
||||
# (section-assignment plan produced no unit), so we stamp the
|
||||
# no-popup defaults directly. Keeps the zone shape uniform
|
||||
# across all three append paths so slide_base.html (u8) does
|
||||
# not have to branch on the presence of popup fields.
|
||||
zones_data.append({
|
||||
"position": pos,
|
||||
"template_id": "__empty__",
|
||||
@@ -4389,6 +4646,10 @@ def run_phase_z2_mvp1(
|
||||
record.get("skipped_reason")
|
||||
or "section_assignment_override_empty_or_unrenderable"
|
||||
),
|
||||
"has_popup": False,
|
||||
"popup_html": None,
|
||||
"preview_text": None,
|
||||
"popup_binding": None,
|
||||
})
|
||||
debug_zones.append({
|
||||
"position": pos,
|
||||
@@ -4746,6 +5007,38 @@ def run_phase_z2_mvp1(
|
||||
|
||||
# 6. Build layout CSS — horizontal-2 = dynamic heights (regression preserve), 그 외 = fr default.
|
||||
# Step D-ext : override_zone_geometries 가 들어오면 layout_css 강제.
|
||||
# IMP-86 u2 — pre-build layout invariant guard. zones_data / debug_zones
|
||||
# MUST be cardinality- and position-aligned with the active layout
|
||||
# preset's css_areas tokens before build_layout_css derives heights_px
|
||||
# / widths_px and _compute_per_zone_geometry validates them against R/C.
|
||||
# If a mapper FitError path (IMP-86 u1) — or any future zone-drop path —
|
||||
# forgets to append a placeholder, the resulting shape drift would
|
||||
# surface as a confusing `heights_px length N != grid rows R=M`
|
||||
# ValueError deep inside the geometry helper. This guard fails fast at
|
||||
# the pipeline boundary with preset / expected positions / actual
|
||||
# positions / count diagnostics so the root cause is obvious from the
|
||||
# log line (factual_verification: value + path + upstream).
|
||||
_active_preset = LAYOUT_PRESETS[layout_preset]
|
||||
_expected_positions = _parse_css_areas(_active_preset["css_areas"])[1]
|
||||
_actual_positions = [zd["position"] for zd in zones_data]
|
||||
_debug_positions = [dz["position"] for dz in debug_zones]
|
||||
if (
|
||||
len(zones_data) != len(_expected_positions)
|
||||
or len(debug_zones) != len(_expected_positions)
|
||||
or sorted(_actual_positions) != sorted(_expected_positions)
|
||||
or sorted(_debug_positions) != sorted(_expected_positions)
|
||||
):
|
||||
raise ValueError(
|
||||
"phase_z2_pipeline pre-build layout invariant violation: "
|
||||
f"layout_preset={layout_preset!r} "
|
||||
f"css_areas={_active_preset['css_areas']!r} "
|
||||
f"expected_positions={_expected_positions!r} "
|
||||
f"zones_data_positions={_actual_positions!r} "
|
||||
f"debug_zones_positions={_debug_positions!r} "
|
||||
f"zones_count={len(zones_data)} "
|
||||
f"debug_count={len(debug_zones)} "
|
||||
f"expected_count={len(_expected_positions)}"
|
||||
)
|
||||
layout_css = build_layout_css(
|
||||
layout_preset, zones_data, override_zone_geometries=override_zone_geometries
|
||||
)
|
||||
@@ -5288,6 +5581,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")
|
||||
@@ -5488,6 +5811,54 @@ def run_phase_z2_mvp1(
|
||||
# fields become None (no failure to classify, no escalation pending).
|
||||
enrich_retry_trace_with_failure_classification(retry_trace)
|
||||
|
||||
# 11.8 IMP-35 (#64) u5 — Step 17 deterministic POPUP gate executor.
|
||||
# Runs after the salvage cascade exits at cascade-terminal action
|
||||
# `details_popup_escalation` (router u3 IMPLEMENTED + failure_router u2
|
||||
# cascade row). Stamps popup_escalation_plan + idempotent has_popup
|
||||
# marker per unit onto retry_trace["popup_gate_records"]. Deterministic
|
||||
# gate — no AI call (feedback_ai_isolation_contract); the u4
|
||||
# api_gated split-decision hook is a separate cascade-stage record
|
||||
# consumed only when a future IMP activates the Anthropic API.
|
||||
# Consumer side (composition popup binding / render wiring) lands in
|
||||
# u6 / u7. q1 (per-unit), q2 (idempotent via has_popup), q3
|
||||
# (deterministic from fit_classification) — see Stage 2 plan.
|
||||
# next_proposed_action is the single canonical signal: it is set by
|
||||
# enrich_retry_trace_with_failure_classification via failure_router u2
|
||||
# (NEXT_ACTION_BY_FAILURE), which routes frame_reselect_insufficient ->
|
||||
# details_popup_escalation. This check is independent of whether the
|
||||
# salvage chain block ran, so the popup gate fires for any retry path
|
||||
# that lands on the cascade-terminal popup action.
|
||||
_next_action = (
|
||||
retry_trace.get("next_action_proposal") or {}
|
||||
).get("next_proposed_action")
|
||||
if _next_action == "details_popup_escalation":
|
||||
_popup_cls_by_zone = {
|
||||
c.get("zone_position"): c
|
||||
for c in (fit_classification.get("classifications") or [])
|
||||
if c.get("category") in {
|
||||
"structural_major_overflow",
|
||||
"tabular_overflow",
|
||||
}
|
||||
}
|
||||
_zone_by_ssids = {
|
||||
tuple(z.get("source_section_ids") or []): z.get("position")
|
||||
for z in debug_zones
|
||||
}
|
||||
|
||||
def _classification_for_unit(u):
|
||||
ssids = tuple(getattr(u, "source_section_ids", []) or [])
|
||||
zone_pos = _zone_by_ssids.get(ssids)
|
||||
return _popup_cls_by_zone.get(zone_pos) if zone_pos else None
|
||||
|
||||
retry_trace["popup_gate_records"] = run_step17_popup_gate(
|
||||
units,
|
||||
classification_for_unit=_classification_for_unit,
|
||||
route_for_label=_imp05_route_hint,
|
||||
plan_for_classification=plan_details_popup_escalation,
|
||||
)
|
||||
retry_trace["popup_gate_executed"] = True
|
||||
retry_trace["popup_gate_terminal_action"] = "details_popup_escalation"
|
||||
|
||||
# ─── Step 17: Implemented Action (retry) ───
|
||||
_write_step_artifact(
|
||||
run_dir, 17, "retry_trace",
|
||||
@@ -5757,6 +6128,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
|
||||
@@ -5856,11 +6248,155 @@ 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
|
||||
# persisted file (Stage 2 lock: "CLI > file, 결손 축만 채움").
|
||||
# MDX stem keys the persistence file; invalid stems / corrupt file
|
||||
# degrade gracefully (warning to stderr + no override injected).
|
||||
from src.user_overrides_io import (
|
||||
InvalidOverrideKey,
|
||||
load as _load_user_overrides,
|
||||
validate_key as _validate_overrides_key,
|
||||
)
|
||||
|
||||
_final_override_layout = args.override_layout
|
||||
try:
|
||||
_ov_key = _validate_overrides_key(Path(args.mdx_path).stem)
|
||||
except InvalidOverrideKey as _exc:
|
||||
print(
|
||||
f"[user_overrides] warning: cannot derive persistence key from "
|
||||
f"mdx_path {args.mdx_path!r}: {_exc}; skipping fallback.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
_ov_key = None
|
||||
if _ov_key is not None:
|
||||
_persisted = _load_user_overrides(_ov_key)
|
||||
# layout — CLI None → fill from file (must be str).
|
||||
if _final_override_layout is None:
|
||||
_file_layout = _persisted.get("layout")
|
||||
if isinstance(_file_layout, str) and _file_layout:
|
||||
_final_override_layout = _file_layout
|
||||
# frames — CLI empty → fill from file (must be dict[str, str]).
|
||||
if not overrides_frames:
|
||||
_file_frames = _persisted.get("frames")
|
||||
if isinstance(_file_frames, dict):
|
||||
overrides_frames = {
|
||||
str(k): str(v)
|
||||
for k, v in _file_frames.items()
|
||||
if isinstance(k, str) and isinstance(v, str)
|
||||
}
|
||||
# zone_geometries — CLI empty → fill from file (dict[str, dict]).
|
||||
if not overrides_geoms:
|
||||
_file_geoms = _persisted.get("zone_geometries")
|
||||
if isinstance(_file_geoms, dict):
|
||||
_accepted: dict[str, dict] = {}
|
||||
for _zid, _g in _file_geoms.items():
|
||||
if (
|
||||
isinstance(_zid, str)
|
||||
and isinstance(_g, dict)
|
||||
and all(k in _g for k in ("x", "y", "w", "h"))
|
||||
):
|
||||
try:
|
||||
_accepted[_zid] = {
|
||||
"x": float(_g["x"]),
|
||||
"y": float(_g["y"]),
|
||||
"w": float(_g["w"]),
|
||||
"h": float(_g["h"]),
|
||||
}
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
overrides_geoms = _accepted
|
||||
# zone_sections — CLI empty → fill from file (dict[str, list[str]]).
|
||||
if not overrides_section_assignments:
|
||||
_file_sections = _persisted.get("zone_sections")
|
||||
if isinstance(_file_sections, dict):
|
||||
_accepted_sec: dict[str, list[str]] = {}
|
||||
for _zid, _sec_list in _file_sections.items():
|
||||
if isinstance(_zid, str) and isinstance(_sec_list, list):
|
||||
_sids = [s for s in _sec_list if isinstance(s, str) and s]
|
||||
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,
|
||||
args.run_id,
|
||||
override_layout=args.override_layout,
|
||||
override_layout=_final_override_layout,
|
||||
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,
|
||||
)
|
||||
|
||||
+123
-2
@@ -56,12 +56,24 @@ ACTION_RATIONALE: dict[str, str] = {
|
||||
"위 매핑 모두 미적용 — 마지막 fallback (현재 코드는 sys.exit 으로 abort)",
|
||||
}
|
||||
|
||||
# 각 action 의 *현재 코드* 구현 상태 (2026-04-29 기준; IMP-12 u7 cascade 2026-05-18)
|
||||
# 각 action 의 *현재 코드* 구현 상태 (2026-04-29 기준; IMP-12 u7 cascade 2026-05-18;
|
||||
# IMP-35 u3 popup-stub 2026-05-23)
|
||||
# A2 단계에서 이 매핑이 *어디까지 자동 처리되고 어디서 막히는지* trace 확보용
|
||||
ACTION_IMPLEMENTATION_STATUS: dict[str, str] = {
|
||||
"zone_ratio_retry": "IMPLEMENTED", # A3 (2026-04-29) phase_z2_retry.plan_zone_ratio_retry + pipeline orchestration
|
||||
"layout_adjust": "MISSING",
|
||||
"details_popup_escalation": "MISSING", # CLAUDE.md 의 <details> 원칙은 있음, runtime 미구현
|
||||
# IMP-35 (#64) u3 — MISSING → IMPLEMENTED on the primary router surface.
|
||||
# `plan_details_popup_escalation` (below) provides the deterministic stub
|
||||
# that downstream units consume: u4 binds the AI split-decision contract
|
||||
# in `src/phase_z2_ai_fallback/step17.py`; u5 wires the Step 17 POPUP
|
||||
# gate executor in `src/phase_z2_pipeline.py`. Router-level mapping is
|
||||
# decoupled from orchestrator wiring (same precedent as the IMP-12 u7
|
||||
# cascade actions below): IMPLEMENTED here reflects deterministic
|
||||
# *surface availability* (importable stub), not whether a given pipeline
|
||||
# run has invoked it. The failure_router companion surface
|
||||
# (NEXT_ACTION_IMPLEMENTATION_STATUS in phase_z2_failure_router.py) keeps
|
||||
# `details_popup_escalation` as MISSING until u5 lands the pipeline gate.
|
||||
"details_popup_escalation": "IMPLEMENTED",
|
||||
"frame_reselect": "PARTIAL", # IMP-05 pre-render rank-2/3 fallback implemented; post-render rerender trace-only
|
||||
"adapter_needed": "PARTIAL", # composition v0.1.1 의 mapper FitError catch
|
||||
"abort": "IMPLEMENTED", # sys.exit(1) — pipeline 의 현재 default
|
||||
@@ -185,3 +197,112 @@ def route_fit_classification(fit_classification: dict) -> dict:
|
||||
"MISSING 이면 그 action 은 실행 X 이고 기존 abort/status 흐름 (sys.exit(1)) 으로 종료."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ─── IMP-35 (#64) u3 — details_popup_escalation deterministic stub ─
|
||||
# Surface contract for the cascade-terminal popup escalation. This stub
|
||||
# does NOT mutate HTML / CSS / MDX content; it emits the canonical plan
|
||||
# marker that the Step 17 POPUP gate (u5) and the AI split-decision hook
|
||||
# (u4) consume. Keeping the executor surface here (next to the primary
|
||||
# ACTION_BY_CATEGORY mapping) lets the router report IMPLEMENTED for
|
||||
# `details_popup_escalation` while u4/u5 are still landing.
|
||||
#
|
||||
# Contract (locked in Stage 2 IMPLEMENTATION_UNITS u3):
|
||||
# - Inputs: classification dict (a single fit_classifier output row).
|
||||
# The category MUST be one of the two ACTION_BY_CATEGORY
|
||||
# rows that map onto `details_popup_escalation` —
|
||||
# `structural_major_overflow` or `tabular_overflow`.
|
||||
# Other categories raise the stub's defensive guard (so
|
||||
# callers do not silently popup-escalate the wrong category).
|
||||
# - Output: popup_escalation_plan dict with `feasible=True`,
|
||||
# `stub=True`, the source category, the canonical
|
||||
# ACTION_RATIONALE entry, and `needs_split_decision=True`
|
||||
# to flag that u4 (AI hook) must run before u5 renders.
|
||||
# - No side effects (no AI call, no MDX read, no HTML mutation).
|
||||
#
|
||||
# Guardrails honored:
|
||||
# - feedback_ai_isolation_contract: stub is deterministic-with-data;
|
||||
# no AI call inside the router surface.
|
||||
# - Phase Z spacing 방향: stub does not shrink common margins; it
|
||||
# expands capacity by routing content to popup downstream.
|
||||
# - 자세히보기 원칙 (CLAUDE.md): plan carries the marker that u5 uses
|
||||
# to put MDX 원문 in popup body and a summary/subset in preview.
|
||||
# - 1 turn = 1 unit: this is router-surface only. u4/u5 own the
|
||||
# downstream wiring on their respective files.
|
||||
|
||||
|
||||
# Categories that legitimately escalate onto details_popup_escalation
|
||||
# per the ACTION_BY_CATEGORY mapping above. Kept as a derived constant
|
||||
# so the router cannot drift away from the single source of truth.
|
||||
POPUP_ESCALATION_CATEGORIES: frozenset[str] = frozenset(
|
||||
category
|
||||
for category, action in ACTION_BY_CATEGORY.items()
|
||||
if action == "details_popup_escalation"
|
||||
)
|
||||
|
||||
|
||||
def plan_details_popup_escalation(classification: dict) -> dict:
|
||||
"""Cascade-terminal popup escalation plan stub (IMP-35 u3).
|
||||
|
||||
Returns a deterministic popup_escalation_plan marker. The actual
|
||||
content split (popup_html / preview_text / has_popup payload) is
|
||||
composed downstream: u4 binds the AI split-decision contract on
|
||||
`src/phase_z2_ai_fallback/step17.py`; u5 wires the Step 17 POPUP
|
||||
gate executor on `src/phase_z2_pipeline.py`.
|
||||
|
||||
Args:
|
||||
classification: a single fit_classifier classification dict.
|
||||
Must contain a `category` key. Only the categories that
|
||||
map onto `details_popup_escalation` in ACTION_BY_CATEGORY
|
||||
(currently `structural_major_overflow` and `tabular_overflow`)
|
||||
are accepted; any other category produces an
|
||||
`feasible=False` plan with `failure_reason` so the caller
|
||||
never silently popup-escalates the wrong overflow shape.
|
||||
|
||||
Returns:
|
||||
popup_escalation_plan dict with at least:
|
||||
action : "details_popup_escalation"
|
||||
feasible : True/False (True for accepted categories)
|
||||
stub : True (marks u3 surface; u4/u5 fill in)
|
||||
category : echoed from input
|
||||
rationale : canonical ACTION_RATIONALE entry
|
||||
needs_split_decision : True (u4 AI hook must run before u5 renders)
|
||||
mapping_source : "IMP-35 u3 plan_details_popup_escalation stub"
|
||||
note : downstream-wiring pointer text
|
||||
"""
|
||||
category = (classification or {}).get("category")
|
||||
base = {
|
||||
"action": "details_popup_escalation",
|
||||
"stub": True,
|
||||
"category": category,
|
||||
"mapping_source": "IMP-35 u3 plan_details_popup_escalation stub",
|
||||
}
|
||||
if category not in POPUP_ESCALATION_CATEGORIES:
|
||||
return {
|
||||
**base,
|
||||
"feasible": False,
|
||||
"needs_split_decision": False,
|
||||
"rationale": "",
|
||||
"failure_reason": (
|
||||
f"category {category!r} does not map onto details_popup_escalation "
|
||||
f"in ACTION_BY_CATEGORY. Accepted categories: "
|
||||
f"{sorted(POPUP_ESCALATION_CATEGORIES)}. Defensive guard — "
|
||||
f"router must not silently popup-escalate the wrong overflow shape."
|
||||
),
|
||||
"note": (
|
||||
"u3 stub — caller passed a category that should not popup-escalate. "
|
||||
"Honour the ACTION_BY_CATEGORY mapping at the router entry point."
|
||||
),
|
||||
}
|
||||
return {
|
||||
**base,
|
||||
"feasible": True,
|
||||
"needs_split_decision": True,
|
||||
"rationale": ACTION_RATIONALE.get(category, ""),
|
||||
"note": (
|
||||
"u3 stub — actual content split planning lands in u4 "
|
||||
"(AI split-decision contract on src/phase_z2_ai_fallback/step17.py) "
|
||||
"and u5 (Step 17 POPUP gate executor on src/phase_z2_pipeline.py). "
|
||||
"popup body = MDX 원문, preview = summary/subset (자세히보기 원칙)."
|
||||
),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
"""IMP-52 (#80) u1 — user_overrides.json persistence layer (backend IO).
|
||||
|
||||
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 (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>},
|
||||
"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"``).
|
||||
|
||||
Behavior :
|
||||
- ``load(key)`` — file missing or corrupt → ``{}`` (warning to stderr on corrupt).
|
||||
- ``save(key, partial)`` — merges only the supplied axes onto the existing
|
||||
file, preserving (a) unknown top-level keys (foreign-key preserve) and
|
||||
(b) axes not present in the partial payload. Atomic write via tmp+rename.
|
||||
- ``override_path(key, root=None)`` — resolves the persistence path under
|
||||
``data/user_overrides/<key>.json``.
|
||||
|
||||
Guardrails (refs : ``user_overrides_io`` Stage 2 lock) :
|
||||
- Deterministic code, no AI fallback.
|
||||
- ``key`` validation rejects path traversal / separators / dot-prefix.
|
||||
- ``save`` is a deep-shallow merge — per-axis dict mutation does not delete
|
||||
prior keys unless caller passes ``None`` for that axis (explicit clear).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
# Persistence root — MDX-keyed, decoupled from data/runs/<run_id>/.
|
||||
# Resolved at call time so tests can monkeypatch via ``root=`` parameter.
|
||||
_PKG_ROOT = Path(__file__).resolve().parent.parent
|
||||
DEFAULT_OVERRIDES_ROOT = _PKG_ROOT / "data" / "user_overrides"
|
||||
|
||||
# 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
|
||||
# are e.g. ``01``, ``03``, ``03__DX...``). Reject leading dot, path
|
||||
# separators, and traversal.
|
||||
_KEY_RE = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_.\-]*$")
|
||||
|
||||
|
||||
class InvalidOverrideKey(ValueError):
|
||||
"""Raised when ``key`` is not a safe MDX stem."""
|
||||
|
||||
|
||||
def validate_key(key: str) -> str:
|
||||
"""Validate that ``key`` is a safe MDX stem; return it unchanged.
|
||||
|
||||
Rejects empty strings, path separators (``/`` ``\\``), traversal
|
||||
(``..``), and leading dot. Callers should pass ``Path(mdx_path).stem``.
|
||||
"""
|
||||
if not isinstance(key, str) or not key:
|
||||
raise InvalidOverrideKey(f"key must be a non-empty string, got: {key!r}")
|
||||
if not _KEY_RE.match(key):
|
||||
raise InvalidOverrideKey(
|
||||
f"key must match {_KEY_RE.pattern!r} (alphanumerics, '_', '-', '.'; "
|
||||
f"no leading dot, no separators); got: {key!r}"
|
||||
)
|
||||
if ".." in key:
|
||||
raise InvalidOverrideKey(f"key must not contain '..'; got: {key!r}")
|
||||
return key
|
||||
|
||||
|
||||
def override_path(key: str, root: Optional[Path] = None) -> Path:
|
||||
"""Resolve the on-disk path for ``key``'s override file."""
|
||||
validate_key(key)
|
||||
base = Path(root) if root is not None else DEFAULT_OVERRIDES_ROOT
|
||||
return base / f"{key}.json"
|
||||
|
||||
|
||||
def load(key: str, root: Optional[Path] = None) -> dict[str, Any]:
|
||||
"""Load persisted overrides for ``key``.
|
||||
|
||||
Missing file → ``{}``. Corrupt JSON → warning to stderr + ``{}``.
|
||||
Returns the raw mapping (including any foreign keys); callers should
|
||||
pick the KNOWN_AXES they care about.
|
||||
"""
|
||||
path = override_path(key, root=root)
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
print(
|
||||
f"[user_overrides_io] warning: failed to read {path} ({exc}); "
|
||||
f"treating as empty.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return {}
|
||||
if not isinstance(data, dict):
|
||||
print(
|
||||
f"[user_overrides_io] warning: {path} is not a JSON object "
|
||||
f"(got {type(data).__name__}); treating as empty.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return {}
|
||||
return data
|
||||
|
||||
|
||||
def save(key: str, partial: dict[str, Any], root: Optional[Path] = None) -> Path:
|
||||
"""Merge ``partial`` onto the persisted overrides for ``key`` and write atomically.
|
||||
|
||||
Merge semantics :
|
||||
- Only keys present in ``partial`` are mutated. Other axes (including
|
||||
foreign keys outside KNOWN_AXES) are preserved verbatim.
|
||||
- For each axis present in ``partial``, the new value REPLACES the prior
|
||||
value (no per-zone deep-merge). Callers that want to add a single
|
||||
zone must read → mutate → save with the full updated axis dict.
|
||||
- Pass ``None`` for an axis to clear it (remove the key from the file).
|
||||
"""
|
||||
if not isinstance(partial, dict):
|
||||
raise TypeError(
|
||||
f"partial must be a dict, got {type(partial).__name__}: {partial!r}"
|
||||
)
|
||||
path = override_path(key, root=root)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
current = load(key, root=root)
|
||||
for axis_key, axis_value in partial.items():
|
||||
if axis_value is None:
|
||||
current.pop(axis_key, None)
|
||||
else:
|
||||
current[axis_key] = axis_value
|
||||
_atomic_write_json(path, current)
|
||||
return path
|
||||
|
||||
|
||||
def _atomic_write_json(path: Path, data: dict[str, Any]) -> None:
|
||||
"""Write ``data`` to ``path`` atomically via tmp file + os.replace."""
|
||||
fd, tmp_name = tempfile.mkstemp(
|
||||
prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent)
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2, sort_keys=True)
|
||||
f.write("\n")
|
||||
os.replace(tmp_name, path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp_name)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
@@ -23,6 +23,10 @@ three_parallel_requirements:
|
||||
frame_id: 1171281190
|
||||
family: three_parallel
|
||||
|
||||
# IMP-36 (Gitea #65 u2) partial-backed contract axis bools.
|
||||
rotation_eligible: true # P1: aspect-ratio container-query rotation
|
||||
body_fit_pattern2: true # P2: cqh/clamp/--max-body-lines body fit
|
||||
|
||||
source_shape: top_bullets
|
||||
cardinality:
|
||||
strict: 3
|
||||
@@ -79,6 +83,10 @@ process_product_two_way:
|
||||
frame_id: 1171281210
|
||||
family: two_column_h3
|
||||
|
||||
# IMP-36 (Gitea #65 u2) partial-backed contract axis bools.
|
||||
rotation_eligible: false # P1: 2-col compare table — rotation 부적합 opt-out
|
||||
body_fit_pattern2: false # P2: 미적용 (Stage 2 selection)
|
||||
|
||||
source_shape: h3_subsections
|
||||
cardinality:
|
||||
strict: 2 # F29 frame = 2 visual columns. ≠2 → fallback.
|
||||
@@ -122,7 +130,7 @@ process_product_two_way:
|
||||
body_parser: column_with_transform # 첫 top-bullet AS-IS/TO-BE 표 인식
|
||||
- title_to: banner_right
|
||||
body_to: product
|
||||
body_parser: column_plain # 모든 section = 일반 text_lines
|
||||
body_parser: column_with_transform # IMP-36 (Gitea #65 u2) P3 parity — process column 과 동일 transform 인식 (좌/우 대칭)
|
||||
|
||||
|
||||
bim_issues_quadrant_four:
|
||||
@@ -130,6 +138,10 @@ bim_issues_quadrant_four:
|
||||
frame_id: 1171281193
|
||||
family: bim_issues_quadrant
|
||||
|
||||
# IMP-36 (Gitea #65 u2) partial-backed contract axis bools.
|
||||
rotation_eligible: false # P1: 4-quadrant 고정 grid — rotation 부적합
|
||||
body_fit_pattern2: false # P2: 미적용 (Stage 2 selection)
|
||||
|
||||
source_shape: top_bullets
|
||||
# F16 정책 = pad_to=4 + truncate>4 (legacy 와 동일). cardinality strict 화는 본 transition 범위 외.
|
||||
# 향후 normal path 안정 후 strict 적용 + 위반 시 fallback path (FitError) 검토.
|
||||
@@ -193,6 +205,10 @@ three_persona_benefits:
|
||||
frame_id: 1171281191
|
||||
family: cards
|
||||
|
||||
# IMP-36 (Gitea #65 u2) partial-backed contract axis bools.
|
||||
rotation_eligible: true # P1: aspect-ratio container-query rotation
|
||||
body_fit_pattern2: true # P2: cqh/clamp/--max-body-lines body fit
|
||||
|
||||
source_shape: top_bullets
|
||||
cardinality:
|
||||
strict: 3 # 3 persona = strict.
|
||||
@@ -258,6 +274,10 @@ construction_goals_three_circle_intersection:
|
||||
frame_id: 1171281189
|
||||
family: diagram
|
||||
|
||||
# IMP-36 (Gitea #65 u2) partial-backed contract axis bools.
|
||||
rotation_eligible: false # P1: 3-circle SVG diagram — rotation 부적합 (좌표 의존)
|
||||
body_fit_pattern2: false # P2: 미적용 (Stage 2 selection)
|
||||
|
||||
source_shape: top_bullets
|
||||
cardinality:
|
||||
strict: 3 # 3 메인 원 — strict.
|
||||
@@ -328,6 +348,10 @@ construction_bim_three_usage:
|
||||
frame_id: 1171281182
|
||||
family: cards
|
||||
|
||||
# IMP-36 (Gitea #65 u2) partial-backed contract axis bools.
|
||||
rotation_eligible: false # P1: 3 stacked rows — rotation 부적합 (수평 row 구조)
|
||||
body_fit_pattern2: false # P2: 미적용 (Stage 2 selection)
|
||||
|
||||
source_shape: top_bullets
|
||||
cardinality:
|
||||
strict: 3
|
||||
@@ -397,6 +421,10 @@ bim_dx_comparison_table:
|
||||
frame_id: 1171281195
|
||||
family: table
|
||||
|
||||
# IMP-36 (Gitea #65 u2) partial-backed contract axis bools.
|
||||
rotation_eligible: false # P1: 2-col compare table — rotation 부적합 opt-out (issue body 명시)
|
||||
body_fit_pattern2: false # P2: 미적용 (Stage 2 selection)
|
||||
|
||||
source_shape: top_bullets
|
||||
# NOTE (Codex round 43 §F1-c) : top-level `cardinality.strict: 2` = *column 수*
|
||||
# (col_a / col_b). data row 수 는 별 — `sub_zones.rows.cardinality` 의 `{min:1, max:12}`.
|
||||
@@ -462,6 +490,10 @@ dx_sw_necessity_three_perspectives:
|
||||
frame_id: 1171281198
|
||||
family: cards
|
||||
|
||||
# IMP-36 (Gitea #65 u2) partial-backed contract axis bools.
|
||||
rotation_eligible: true # P1: aspect-ratio container-query rotation
|
||||
body_fit_pattern2: true # P2: cqh/clamp/--max-body-lines body fit
|
||||
|
||||
source_shape: top_bullets
|
||||
cardinality:
|
||||
strict: 3 # 3 perspective columns
|
||||
@@ -528,6 +560,10 @@ info_management_what_how_when:
|
||||
frame_id: 1171281179
|
||||
family: cards
|
||||
|
||||
# IMP-36 (Gitea #65 u2) partial-backed contract axis bools.
|
||||
rotation_eligible: true # P1: aspect-ratio container-query rotation
|
||||
body_fit_pattern2: true # P2: cqh/clamp/--max-body-lines body fit
|
||||
|
||||
source_shape: top_bullets
|
||||
cardinality:
|
||||
strict: 3 # 3 sections (What / How / When)
|
||||
@@ -587,6 +623,10 @@ sw_reality_three_emphasis:
|
||||
frame_id: 1171281209
|
||||
family: cards
|
||||
|
||||
# IMP-36 (Gitea #65 u2) partial-backed contract axis bools.
|
||||
rotation_eligible: false # P1: 미적용 (Stage 2 selection — future eligibility TBD)
|
||||
body_fit_pattern2: false # P2: 미적용 (Stage 2 selection)
|
||||
|
||||
source_shape: top_bullets
|
||||
cardinality:
|
||||
strict: 3
|
||||
@@ -644,6 +684,10 @@ bim_current_problems_paired:
|
||||
frame_id: 1171281194
|
||||
family: cards
|
||||
|
||||
# IMP-36 (Gitea #65 u2) partial-backed contract axis bools.
|
||||
rotation_eligible: false # P1: 4x2 paired rows — rotation 부적합 (2-axis row×side 구조)
|
||||
body_fit_pattern2: false # P2: 미적용 (Stage 2 selection)
|
||||
|
||||
source_shape: top_bullets # mapper split_source allow-list 정합 (Codex round 60)
|
||||
layout_variant: paired_rows_4x2_alternating_pills # runtime projection model
|
||||
cardinality:
|
||||
@@ -735,6 +779,10 @@ app_sw_package_vs_solution:
|
||||
frame_id: 1171281203
|
||||
family: table
|
||||
|
||||
# IMP-36 (Gitea #65 u2) partial-backed contract axis bools.
|
||||
rotation_eligible: false # P1: 2-col compare table — rotation 부적합 opt-out
|
||||
body_fit_pattern2: true # P2: cqh/clamp/--max-body-lines body fit (Stage 1 canonical source)
|
||||
|
||||
source_shape: h3_subsections # F29 와 동일 — 2 h3 subsection = 2 column.
|
||||
cardinality:
|
||||
strict: 2 # 2 column (Package / Solution) — NOT row count.
|
||||
@@ -783,6 +831,10 @@ pre_construction_model_info_stacked:
|
||||
frame_id: 1171281180
|
||||
family: list
|
||||
|
||||
# IMP-36 (Gitea #65 u2) partial-backed contract axis bools.
|
||||
rotation_eligible: false # P1: 5-color cycle pill list — rotation 부적합
|
||||
body_fit_pattern2: false # P2: 미적용 (Stage 2 selection)
|
||||
|
||||
source_shape: top_bullets
|
||||
cardinality:
|
||||
min: 4 # analysis.md min 4 / Figma 5-color cycle design floor.
|
||||
|
||||
@@ -18,11 +18,18 @@ builder/parser 0.
|
||||
- figma_to_html (1171281198) = source/evidence — 386-line index.html + assets/.
|
||||
- Phase Z = runtime — 본 commit adds catalog + partial + smoke fixture.
|
||||
|
||||
PROMOTED — CSS :
|
||||
- 3 column header bg : dark green (`#296B55` family, Figma green theme)
|
||||
- header text white bold + green accent
|
||||
- title gradient (#000 → #883700, F13/F14/F12/F11/F18 zone-title family)
|
||||
- card border + bullet markers (green family)
|
||||
PROMOTED — CSS (verbatim from figma_to_html_agent/blocks/1171281198/index.html) :
|
||||
- 3 column header bg : two-stop vertical adaptation of upstream horizontal
|
||||
banner gradient end-stops — start `rgb(15, 50, 30)` (upstream :54, stop 0%),
|
||||
end `rgb(60, 52, 34)` (upstream :64, stop 100%). Adaptation surface =
|
||||
direction (90deg → 180deg) + stop count (11 → 2); colors verbatim.
|
||||
- header text white bold + green accent (white #fff verbatim, see upstream
|
||||
:202 `color: #ffffff`)
|
||||
- title gradient (#000 → #883700, F13/F14/F12/F11/F18 zone-title family;
|
||||
shared zone-title token, not from this frame)
|
||||
- card border + bullet markers : `#1d4d3e` (upstream :208 `.card-title-1`
|
||||
`-webkit-text-stroke: 1.5px #1d4d3e`). Replaces earlier eyeballed
|
||||
`#296B55` approximation (IMP-49 #78 u1).
|
||||
|
||||
NOT PROMOTED (P1 case-by-case, compact zone fit) :
|
||||
- 상단 dark green banner (Figma 의 큰 visual 영역, MDX 의 *title 만* 핵심)
|
||||
@@ -58,6 +65,12 @@ slots :
|
||||
gap: 6px;
|
||||
font-family: 'Noto Sans KR', 'Pretendard', sans-serif;
|
||||
word-break: keep-all;
|
||||
/* IMP-36 (Gitea #65 u6) P1 root — enable container queries on .f20b.
|
||||
container-type: size unlocks cqh/cqi/cqw + aspect-ratio matching against
|
||||
this element. container-name: f20b-root namespaces the @container rule
|
||||
below (partial-fidelity lock per IMP-49 #78 — no cross-frame borrowing). */
|
||||
container-type: size;
|
||||
container-name: f20b-root;
|
||||
}
|
||||
.f20b__title {
|
||||
font-size: var(--font-zone-title);
|
||||
@@ -80,7 +93,7 @@ slots :
|
||||
}
|
||||
.f20b__col {
|
||||
display: flex; flex-direction: column;
|
||||
border: 2px solid #296B55; /* PROMOTED — green family from Figma */
|
||||
border: 2px solid #1d4d3e; /* PROMOTED — verbatim from upstream :208 (.card-title-1 -webkit-text-stroke) */
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
@@ -89,7 +102,7 @@ slots :
|
||||
|
||||
/* header bar (top of each card, dark green per Figma) */
|
||||
.f20b__header {
|
||||
background: linear-gradient(180deg, #296B55 0%, #123328 100%); /* PROMOTED — Figma green theme */
|
||||
background: linear-gradient(180deg, rgb(15, 50, 30) 0%, rgb(60, 52, 34) 100%); /* PROMOTED — verbatim end-stops from upstream :54 (0%) and :64 (100%); 11-stop horizontal banner adapted to 2-stop vertical card header */
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: var(--font-sub-title);
|
||||
@@ -121,18 +134,47 @@ slots :
|
||||
content: "\2713"; /* ✓ check mark — green theme */
|
||||
position: absolute;
|
||||
left: 0; top: 0;
|
||||
color: #296B55; /* PROMOTED — green family */
|
||||
color: #1d4d3e; /* PROMOTED — verbatim from upstream :208 (.card-title-1 -webkit-text-stroke) */
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* IMP-36 (Gitea #65 u6) P2 body fit — line-height clamp scales with the
|
||||
per-column bullet count via the inline `--max-body-lines` Jinja style on
|
||||
each `.f20b__body`. 60cqh ≈ 3-col card body share of `.f20b` root height
|
||||
(zone-title ~15cqh + card header ~10cqh + remaining ~75cqh, split 60cqh
|
||||
for text lines + reserve for padding/gap). font-size unchanged
|
||||
(guardrail #6 — Stage 2 spec). Fallback 3 = file-header default
|
||||
"body 3-5 bullets per column". */
|
||||
.f20b__body .text-line {
|
||||
line-height: clamp(1.15em, calc(60cqh / var(--max-body-lines, 3)), 1.6em);
|
||||
}
|
||||
|
||||
/* IMP-36 (Gitea #65 u6) P1 rotation rule — when the surrounding zone is
|
||||
narrow (aspect-ratio < 1.5), collapse the 3-column grid to a single
|
||||
column. The threshold matches the IMP-36 Stage 2 canonical (vertical-2 /
|
||||
세로형). Card header / body / bullet styles remain unchanged. */
|
||||
@container f20b-root (aspect-ratio < 1.5) {
|
||||
.f20b__cols { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- IMP-49 #78 u2 — namespace scope note :
|
||||
`.f20b__*` is an AUTHORING-ORDINAL namespace (ordinal "20b" in this catalog's
|
||||
authoring sequence), NOT the Figma frame_id 1171281198. The structural link
|
||||
to the source frame is the `data-frame-id="1171281198"` attribute on the
|
||||
root <div> below. Cross-frame `.fNb__` class reuse is forbidden — class names
|
||||
MUST stay within their owning partial (see [[feedback_partial_figma_audit]]).
|
||||
Selector names and catalog references (frame_contracts.yaml :492,:497,:502)
|
||||
are intentionally unchanged in this unit. -->
|
||||
|
||||
<div class="f20b" data-frame-id="1171281198" data-template-id="dx_sw_necessity_three_perspectives">
|
||||
<div class="f20b__title">{{ slot_payload.title }}</div>
|
||||
<div class="f20b__cols">
|
||||
{# 3 columns — quadrant_flat_slots produces perspective_N_label / perspective_N_body for N=1..3 #}
|
||||
<div class="f20b__col">
|
||||
<div class="f20b__header">{{ slot_payload.perspective_1_label | safe }}</div>
|
||||
<div class="f20b__body">
|
||||
{# IMP-36 (Gitea #65 u6) P2 — inline `--max-body-lines` per column; code composes (Phase Z guardrail #7), defensive fallback to 3 matches the CSS default. #}
|
||||
<div class="f20b__body" style="--max-body-lines: {{ (slot_payload.perspective_1_body | length) if slot_payload.perspective_1_body else 3 }};">
|
||||
{% if slot_payload.perspective_1_body %}
|
||||
{% for line in slot_payload.perspective_1_body %}<div class="text-line text-line--bullet{% if line.indent > 0 %} text-line--indent-{{ line.indent }}{% endif %}">{{ line.text | safe }}</div>{% endfor %}
|
||||
{% endif %}
|
||||
@@ -140,7 +182,7 @@ slots :
|
||||
</div>
|
||||
<div class="f20b__col">
|
||||
<div class="f20b__header">{{ slot_payload.perspective_2_label | safe }}</div>
|
||||
<div class="f20b__body">
|
||||
<div class="f20b__body" style="--max-body-lines: {{ (slot_payload.perspective_2_body | length) if slot_payload.perspective_2_body else 3 }};">
|
||||
{% if slot_payload.perspective_2_body %}
|
||||
{% for line in slot_payload.perspective_2_body %}<div class="text-line text-line--bullet{% if line.indent > 0 %} text-line--indent-{{ line.indent }}{% endif %}">{{ line.text | safe }}</div>{% endfor %}
|
||||
{% endif %}
|
||||
@@ -148,7 +190,7 @@ slots :
|
||||
</div>
|
||||
<div class="f20b__col">
|
||||
<div class="f20b__header">{{ slot_payload.perspective_3_label | safe }}</div>
|
||||
<div class="f20b__body">
|
||||
<div class="f20b__body" style="--max-body-lines: {{ (slot_payload.perspective_3_body | length) if slot_payload.perspective_3_body else 3 }};">
|
||||
{% if slot_payload.perspective_3_body %}
|
||||
{% for line in slot_payload.perspective_3_body %}<div class="text-line text-line--bullet{% if line.indent > 0 %} text-line--indent-{{ line.indent }}{% endif %}">{{ line.text | safe }}</div>{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
@@ -48,6 +48,12 @@ slots : title, section_1/2/3_label, section_1/2/3_body
|
||||
gap: 6px;
|
||||
font-family: 'Noto Sans KR', 'Pretendard', sans-serif;
|
||||
word-break: keep-all;
|
||||
/* IMP-36 (Gitea #65 u7) P1 — container-type: size unlocks cqh/cqi/cqw +
|
||||
aspect-ratio measurement on .f8b. container-name: f8b-root namespaces
|
||||
the rotation rule below (IMP-49 partial-fidelity lock — no cross-frame
|
||||
.fNb__ class borrowing). */
|
||||
container-type: size;
|
||||
container-name: f8b-root;
|
||||
}
|
||||
.f8b__title {
|
||||
font-size: var(--font-zone-title);
|
||||
@@ -110,6 +116,16 @@ slots : title, section_1/2/3_label, section_1/2/3_body
|
||||
position: relative;
|
||||
padding-left: 14px;
|
||||
}
|
||||
/* IMP-36 (Gitea #65 u7) P2 body fit — line-height cqh/clamp against the
|
||||
bullet count rendered per column. font-size 미변경 (사용자 룰 + Stage 2
|
||||
guardrail #6). 60cqh = approx body region share of .f8b after title +
|
||||
section header (title ≈ 15cqh + per-col header ≈ 12cqh → body ≈ 60cqh).
|
||||
fallback = 4 (file header L42 watch threshold "body 5+ bullets per
|
||||
column" → typical < 5). Additive cascade override; does not mutate the
|
||||
pre-existing .f8b__body .text-line block above. */
|
||||
.f8b__body .text-line {
|
||||
line-height: clamp(1.15em, calc(60cqh / var(--max-body-lines, 4)), 1.6em);
|
||||
}
|
||||
.f8b__body .text-line--bullet::before {
|
||||
content: "•";
|
||||
position: absolute;
|
||||
@@ -119,6 +135,14 @@ slots : title, section_1/2/3_label, section_1/2/3_body
|
||||
.f8b__col:nth-child(1) .f8b__body .text-line--bullet::before { color: #2563eb; }
|
||||
.f8b__col:nth-child(2) .f8b__body .text-line--bullet::before { color: #ea580c; }
|
||||
.f8b__col:nth-child(3) .f8b__body .text-line--bullet::before { color: #16a34a; }
|
||||
|
||||
/* IMP-36 (Gitea #65 u7) P1 rotation rule — when zone aspect-ratio narrows
|
||||
below 1.5 (vertical-2 narrow / 임의 세로형 geometry), flip the 3-column
|
||||
grid to single column. Card header / body / bullet styles unchanged —
|
||||
only grid-template-columns flips from 1fr 1fr 1fr to 1fr. */
|
||||
@container f8b-root (aspect-ratio < 1.5) {
|
||||
.f8b__cols { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="f8b" data-frame-id="1171281179" data-template-id="info_management_what_how_when">
|
||||
@@ -126,7 +150,7 @@ slots : title, section_1/2/3_label, section_1/2/3_body
|
||||
<div class="f8b__cols">
|
||||
<div class="f8b__col">
|
||||
<div class="f8b__header">{{ slot_payload.section_1_label | safe }}</div>
|
||||
<div class="f8b__body">
|
||||
<div class="f8b__body" style="--max-body-lines: {{ (slot_payload.section_1_body | length) if slot_payload.section_1_body else 4 }};">
|
||||
{% if slot_payload.section_1_body %}
|
||||
{% for line in slot_payload.section_1_body %}<div class="text-line text-line--bullet{% if line.indent > 0 %} text-line--indent-{{ line.indent }}{% endif %}">{{ line.text | safe }}</div>{% endfor %}
|
||||
{% endif %}
|
||||
@@ -134,7 +158,7 @@ slots : title, section_1/2/3_label, section_1/2/3_body
|
||||
</div>
|
||||
<div class="f8b__col">
|
||||
<div class="f8b__header">{{ slot_payload.section_2_label | safe }}</div>
|
||||
<div class="f8b__body">
|
||||
<div class="f8b__body" style="--max-body-lines: {{ (slot_payload.section_2_body | length) if slot_payload.section_2_body else 4 }};">
|
||||
{% if slot_payload.section_2_body %}
|
||||
{% for line in slot_payload.section_2_body %}<div class="text-line text-line--bullet{% if line.indent > 0 %} text-line--indent-{{ line.indent }}{% endif %}">{{ line.text | safe }}</div>{% endfor %}
|
||||
{% endif %}
|
||||
@@ -142,7 +166,7 @@ slots : title, section_1/2/3_label, section_1/2/3_body
|
||||
</div>
|
||||
<div class="f8b__col">
|
||||
<div class="f8b__header">{{ slot_payload.section_3_label | safe }}</div>
|
||||
<div class="f8b__body">
|
||||
<div class="f8b__body" style="--max-body-lines: {{ (slot_payload.section_3_body | length) if slot_payload.section_3_body else 4 }};">
|
||||
{% if slot_payload.section_3_body %}
|
||||
{% for line in slot_payload.section_3_body %}<div class="text-line text-line--bullet{% if line.indent > 0 %} text-line--indent-{{ line.indent }}{% endif %}">{{ line.text | safe }}</div>{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
@@ -34,6 +34,12 @@ slots: title, pillars[].{label, color_class, sections[].{heading, bullets[]}}
|
||||
gap: 4px;
|
||||
font-family: 'Noto Sans KR', 'Pretendard', sans-serif;
|
||||
word-break: keep-all;
|
||||
/* IMP-36 (Gitea #65 u4) P1 — partial-side container query root.
|
||||
container-type: size 로 aspect-ratio 측정 가능 (cqh / cqi / cqw 도
|
||||
동일 root 기준). container-name: f13b-root 는 frame_contracts.yaml
|
||||
rotation_eligible: true 와 짝. */
|
||||
container-type: size;
|
||||
container-name: f13b-root;
|
||||
}
|
||||
.f13b__title {
|
||||
font-size: var(--font-zone-title);
|
||||
@@ -126,6 +132,22 @@ slots: title, pillars[].{label, color_class, sections[].{heading, bullets[]}}
|
||||
}
|
||||
/* desc 안 .text-line 색 override */
|
||||
.f13b__desc .text-line { color: #3E3523; }
|
||||
|
||||
/* IMP-36 (Gitea #65 u4) P2 — body fit via cqh + clamp + --max-body-lines.
|
||||
section 의 text_lines 개수가 늘면 line-height 가 비례로 축소. font-size
|
||||
미변경 (사용자 룰). --max-body-lines fallback = 4 (section 평균 줄 수).
|
||||
20cqh = 한 section 이 차지하는 .f13b 컨테이너 비율 근사치 (3 section /
|
||||
col, body 영역 ≈ 80cqh → 25cqh/section 중 line 영역 ≈ 20cqh). */
|
||||
.f13b__desc {
|
||||
line-height: clamp(1.2em, calc(20cqh / var(--max-body-lines, 4)), 1.6em);
|
||||
}
|
||||
|
||||
/* IMP-36 (Gitea #65 u4) P1 — aspect-ratio < 1.5 rotation rule. zone 의
|
||||
가로:세로 비가 1.5 미만으로 좁아지면 (vertical-2 narrow / 또는 임의
|
||||
세로형 geometry) 3-col grid 가 1-col stack 으로 회전. */
|
||||
@container f13b-root (aspect-ratio < 1.5) {
|
||||
.f13b__cols { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="f13b" data-frame-id="1171281190" data-template-id="three_parallel_requirements">
|
||||
@@ -144,7 +166,7 @@ slots: title, pillars[].{label, color_class, sections[].{heading, bullets[]}}
|
||||
<div class="f13b__section">
|
||||
<div class="f13b__heading">{{ section.heading | safe }}</div>
|
||||
{% if section.text_lines %}
|
||||
<div class="f13b__desc">
|
||||
<div class="f13b__desc" style="--max-body-lines: {{ section.text_lines | length }};">
|
||||
{% for line in section.text_lines %}<div class="text-line text-line--bullet{% if line.indent > 0 %} text-line--indent-{{ line.indent }}{% endif %}">{{ line.text | safe }}</div>{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -70,6 +70,13 @@ Asset path runtime resolution :
|
||||
gap: 6px;
|
||||
font-family: 'Noto Sans KR', 'Pretendard', sans-serif;
|
||||
word-break: keep-all;
|
||||
/* IMP-36 (Gitea #65 u5) P1 — partial-side container query root.
|
||||
container-type: size 로 aspect-ratio 측정 가능 (cqh / cqi / cqw 도
|
||||
동일 root 기준). container-name: f14b-root 는 frame_contracts.yaml
|
||||
rotation_eligible: true 와 짝. Circle badge (.f14b__badge aspect-ratio
|
||||
1/1) 는 별도 element — 본 root 의 aspect-ratio 측정 대상 아님. */
|
||||
container-type: size;
|
||||
container-name: f14b-root;
|
||||
}
|
||||
.f14b__title {
|
||||
font-size: var(--font-zone-title);
|
||||
@@ -176,6 +183,22 @@ Asset path runtime resolution :
|
||||
position: relative;
|
||||
padding-left: 14px;
|
||||
}
|
||||
/* IMP-36 (Gitea #65 u5) P2 — body fit via cqh + clamp + --max-body-lines.
|
||||
persona.body 의 bullet 개수가 늘면 line-height 가 비례로 축소. font-size
|
||||
미변경 (사용자 룰). --max-body-lines fallback = 7 (Figma 원본 frame 평균
|
||||
bullet 수, file header L8 참조). 60cqh = .f14b__body 영역 비율 근사치
|
||||
(title ≈ 15cqh + badge ≈ 18cqh + photo ≈ 7cqh → body ≈ 60cqh). 본 clamp
|
||||
은 .text-line 의 var(--lh-body) 를 override (cascade 우선순위). */
|
||||
.f14b__body .text-line {
|
||||
line-height: clamp(1.15em, calc(60cqh / var(--max-body-lines, 7)), 1.6em);
|
||||
}
|
||||
/* IMP-36 (Gitea #65 u5) P1 — aspect-ratio < 1.5 rotation rule. zone 의
|
||||
가로:세로 비가 1.5 미만으로 좁아지면 3-col grid 가 1-col stack 으로
|
||||
회전. Circle badge (.f14b__badge aspect-ratio 1/1) 는 col 내부 element
|
||||
이므로 회전 후에도 원형 유지. */
|
||||
@container f14b-root (aspect-ratio < 1.5) {
|
||||
.f14b__cols { grid-template-columns: 1fr; }
|
||||
}
|
||||
.f14b__body .text-line--bullet::before {
|
||||
content: "\2713";
|
||||
position: absolute;
|
||||
@@ -226,7 +249,7 @@ Asset path runtime resolution :
|
||||
</div>
|
||||
|
||||
{# body — bullets with CSS check marker #}
|
||||
<div class="f14b__body">
|
||||
<div class="f14b__body" style="--max-body-lines: {{ (persona.body | length) if persona.body else 7 }};">
|
||||
{% if persona.body %}
|
||||
{% for line in persona.body %}<div class="text-line text-line--bullet{% if line.indent > 0 %} text-line--indent-{{ line.indent }}{% endif %}">{{ line.text | safe }}</div>{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
@@ -20,6 +20,16 @@
|
||||
# applies_to: list[str] (content types that can use this strategy)
|
||||
# forbidden_for: list[str] (content types that MUST NOT use this strategy)
|
||||
# preserves_original: bool (true = original content kept somewhere — popup/detail)
|
||||
# preview_chars: int | null (IMP-35 u9 — soft char budget for the inline body
|
||||
# shown alongside the popup trigger; null when the
|
||||
# strategy has no popup. The popup body itself
|
||||
# ALWAYS holds the FULL original — preview_chars
|
||||
# governs only the inline preview/summary surface.)
|
||||
# popup_target_slot: str | null
|
||||
# (IMP-35 u9 — frame Layer B slot identifier the
|
||||
# popup trigger anchors to. null when the strategy
|
||||
# has no popup. See CLAUDE.md "위계 + 용어" →
|
||||
# "Frame Slot" / "Layer B" for the slot vocabulary.)
|
||||
|
||||
|
||||
inline_full:
|
||||
@@ -27,6 +37,9 @@ inline_full:
|
||||
applies_to: [text_block, table, image, details, decorative_element]
|
||||
forbidden_for: []
|
||||
preserves_original: true # all content is inline, original = inline
|
||||
# IMP-35 u9 — inline_full has no popup → both popup-wiring fields are null.
|
||||
preview_chars: null
|
||||
popup_target_slot: null
|
||||
|
||||
|
||||
inline_preview_with_details:
|
||||
@@ -34,6 +47,9 @@ inline_preview_with_details:
|
||||
applies_to: [text_block, table, details]
|
||||
forbidden_for: [decorative_element]
|
||||
preserves_original: true # User lock — original content kept in popup
|
||||
# IMP-35 u9 — partial preview body inline; popup body holds FULL original.
|
||||
preview_chars: 240
|
||||
popup_target_slot: primary
|
||||
detail_trigger:
|
||||
placement: top-right # 본문 흐름 방해 X / 보조 동작 위치 / 안정 (user 2026-05-07)
|
||||
label: details # identifier — display text 는 partial/UI 별 axis
|
||||
@@ -45,6 +61,11 @@ details_only:
|
||||
applies_to: [text_block, table, details]
|
||||
forbidden_for: [decorative_element]
|
||||
preserves_original: true # User lock — full content in popup
|
||||
# IMP-35 u9 — summary-only inline surface (smaller char budget); popup body
|
||||
# holds FULL original. preview_chars > 0 because details_only still emits a
|
||||
# short summary line — it is NOT a "no body" surface (that is `dropped`).
|
||||
preview_chars: 80
|
||||
popup_target_slot: primary
|
||||
detail_trigger:
|
||||
placement: top-right # user lock — popup 진입 일관 위치
|
||||
label: details
|
||||
@@ -60,3 +81,6 @@ dropped:
|
||||
applies_to: [decorative_element]
|
||||
forbidden_for: [text_block, table, image, details]
|
||||
preserves_original: false # decorative only — no original to preserve
|
||||
# IMP-35 u9 — dropped has no popup and no body surface → both fields null.
|
||||
preview_chars: null
|
||||
popup_target_slot: null
|
||||
|
||||
@@ -290,6 +290,71 @@
|
||||
font-family: monospace;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
/* ── IMP-35 u8 : popup details/summary (Step 17 POPUP gate escalation) ──
|
||||
When the Step 17 POPUP gate escalates a unit (zone.has_popup=True),
|
||||
slide_base renders a JS-free <details>/<summary> wrapper in the zone.
|
||||
The body of the frame stays as zone.partial_html (the FIT-version of
|
||||
content); the popup body holds the FULL original raw_content (MDX 원문
|
||||
무손실 보존 — 오답노트 #5 / IMPROVEMENT-REDESIGN.md §3.6 line 110).
|
||||
Placement (default top-right) is read from
|
||||
zone.popup_binding.detail_trigger.placement
|
||||
(templates/phase_z2/regions/display_strategies.yaml). HTML-native
|
||||
<details> per CLAUDE.md 자세히보기 contract — no JavaScript. */
|
||||
.zone__popup-details {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
font-family: 'Pretendard', sans-serif;
|
||||
}
|
||||
.zone__popup-details--top-right {
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
}
|
||||
.zone__popup-details--top-left {
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
}
|
||||
.zone__popup-details--bottom-right {
|
||||
bottom: 4px;
|
||||
right: 4px;
|
||||
}
|
||||
.zone__popup-details--bottom-left {
|
||||
bottom: 4px;
|
||||
left: 4px;
|
||||
}
|
||||
.zone__popup-summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
background: rgba(30, 41, 59, 0.85);
|
||||
color: #fff;
|
||||
border-radius: 2px;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1.2;
|
||||
user-select: none;
|
||||
}
|
||||
.zone__popup-summary::-webkit-details-marker { display: none; }
|
||||
.zone__popup-summary::marker { content: ""; }
|
||||
.zone__popup-body {
|
||||
position: absolute;
|
||||
top: 22px;
|
||||
right: 0;
|
||||
width: 360px;
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
padding: 8px 10px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--color-border, #e2e8f0);
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||
white-space: pre-wrap;
|
||||
word-break: keep-all;
|
||||
font-size: 10px;
|
||||
line-height: 1.5;
|
||||
color: #1e293b;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -301,9 +366,19 @@
|
||||
<div class="slide-body">
|
||||
<div class="layout-{{ layout_preset }}">
|
||||
{% for zone in zones %}
|
||||
<div class="zone{% if zone.provisional %} zone--provisional{% endif %}" data-zone-position="{{ zone.position }}" data-template-id="{{ zone.template_id }}"{% if zone.provisional %} data-provisional="1"{% endif %} style="grid-area: {{ zone.position }};">
|
||||
<div class="zone{% if zone.provisional %} zone--provisional{% endif %}" data-zone-position="{{ zone.position }}" data-template-id="{{ zone.template_id }}"{% if zone.provisional %} data-provisional="1"{% endif %}{% if zone.has_popup %} data-has-popup="1"{% endif %} style="grid-area: {{ zone.position }};">
|
||||
{% if zone.provisional %}<span class="zone__needs-adaptation-badge" aria-label="needs user or AI adaptation">needs adaptation</span>{% endif %}
|
||||
{{ zone.partial_html | safe }}
|
||||
{% if zone.has_popup %}
|
||||
{% set _popup_trigger = (zone.popup_binding.detail_trigger if zone.popup_binding else None) or {} %}
|
||||
{% set _popup_placement = _popup_trigger.placement or 'top-right' %}
|
||||
{% set _popup_label = _popup_trigger.label or 'details' %}
|
||||
{% set _popup_strategy = (zone.popup_binding.display_strategy if zone.popup_binding else 'inline_preview_with_details') %}
|
||||
<details class="zone__popup-details zone__popup-details--{{ _popup_placement }}" data-display-strategy="{{ _popup_strategy }}" data-popup-placement="{{ _popup_placement }}">
|
||||
<summary class="zone__popup-summary">{{ _popup_label }}</summary>
|
||||
<div class="zone__popup-body">{{ zone.popup_html }}</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""IMP-#85 u7 — pytest env isolation for src.config defaults.
|
||||
|
||||
This conftest.py runs BEFORE any test module is imported by pytest.
|
||||
Setting ``os.environ["AI_FALLBACK_*"]`` here overrides values that the
|
||||
live ``.env`` file would otherwise inject through ``pydantic-settings``
|
||||
(priority: init args > os.environ > env_file). The ``src.config``
|
||||
module-level ``settings = Settings()`` singleton is therefore built
|
||||
against the test-clean environment when src.config is first imported
|
||||
during test collection.
|
||||
|
||||
Scope (per Stage 2 plan u7):
|
||||
* Restore the default-OFF contract for ``ai_fallback_enabled`` so
|
||||
``tests/test_phase_z2_ai_fallback_config.py`` and
|
||||
``tests/test_imp47b_step12_ai_wiring.py`` (which lock the
|
||||
flag-off short-circuit) match the source-of-truth default in
|
||||
``src/config.py``.
|
||||
* Restore the default-OFF contract for ``ai_fallback_auto_cache``.
|
||||
|
||||
Out of scope:
|
||||
* Touching ``ANTHROPIC_API_KEY`` / ``KEI_API_URL`` / ``LOG_LEVEL``.
|
||||
* Resetting the ``src.config.settings`` singleton mid-session.
|
||||
Tests that need to flip ``settings.ai_fallback_enabled`` at
|
||||
runtime mutate the singleton directly (mirrors the production
|
||||
``--auto-cache`` CLI path in ``src/phase_z2_pipeline.py``).
|
||||
|
||||
IMP-35 baseline-red invariance carve-out
|
||||
========================================
|
||||
The IMP-35 baseline-red invariance gate at
|
||||
``tests/phase_z2/test_imp35_baseline_red_invariance.py`` spawns a child
|
||||
pytest subprocess that targets ONLY the two baseline-area files:
|
||||
|
||||
tests/test_imp47b_step12_ai_wiring.py
|
||||
tests/test_phase_z2_ai_fallback_config.py
|
||||
|
||||
That gate's binding contract (Stage 2 u11 lock) is that those four
|
||||
registered known-red tests STAY RED until a follow-up issue
|
||||
deregisters them. If this conftest blindly forces
|
||||
``AI_FALLBACK_ENABLED=false`` in the gate's subprocess, the
|
||||
``test_ai_fallback_master_flag_default_off`` registered red flips
|
||||
green and the invariance gate trips — a real cross-issue contract
|
||||
conflict (see Codex #8 Stage 3 verification of IMP-#85 u7).
|
||||
|
||||
The carve-out below detects that exact subprocess signature
|
||||
(positional ``.py`` targets are entirely baseline-area files) and
|
||||
skips env isolation, leaving the gate's child process in its native
|
||||
``.env``-loaded state. Every other pytest invocation — full-suite
|
||||
``pytest -q tests``, the IMP-#85 smoke targets, single-file dev runs
|
||||
on non-baseline files — still gets the default-OFF isolation.
|
||||
|
||||
Per ``feedback_demo_env_toggle_policy``: demo activation belongs in
|
||||
``.env`` only. The override below is test-scoped (lives under
|
||||
``tests/``) and never propagates into ``src/`` or ``vite.config``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# File suffixes (basenames) of the IMP-35 baseline-red area files.
|
||||
# The IMP-35 gate spawns its subprocess with these as the sole positional
|
||||
# pytest targets. Suffix matching is used so the detection is robust
|
||||
# across Windows/POSIX path separators and absolute/relative cwd.
|
||||
_IMP35_BASELINE_AREA_FILE_SUFFIXES: tuple[str, ...] = (
|
||||
"test_imp47b_step12_ai_wiring.py",
|
||||
"test_phase_z2_ai_fallback_config.py",
|
||||
)
|
||||
|
||||
|
||||
def _is_imp35_baseline_subprocess() -> bool:
|
||||
"""True iff the current pytest argv targets ONLY IMP-35 baseline-area files.
|
||||
|
||||
The IMP-35 baseline-red invariance gate
|
||||
(``tests/phase_z2/test_imp35_baseline_red_invariance.py``) runs:
|
||||
|
||||
python -m pytest -q --tb=no -p no:cacheprovider \\
|
||||
tests/test_imp47b_step12_ai_wiring.py \\
|
||||
tests/test_phase_z2_ai_fallback_config.py
|
||||
|
||||
The two trailing positional ``.py`` arguments are the signature.
|
||||
We compare on basename suffix so the check is path-separator and
|
||||
cwd agnostic.
|
||||
|
||||
Returning True here suppresses the ``AI_FALLBACK_*`` env override
|
||||
so the baseline-red registry contract (Stage 2 u11 lock) holds for
|
||||
the gate's child process while every other invocation
|
||||
(full-suite, IMP-#85 smokes, mixed-target dev runs) still gets the
|
||||
default-OFF isolation.
|
||||
"""
|
||||
file_targets = [arg for arg in sys.argv[1:] if arg.endswith(".py")]
|
||||
if not file_targets:
|
||||
return False
|
||||
return all(
|
||||
any(
|
||||
arg.replace("\\", "/").endswith(suffix)
|
||||
for suffix in _IMP35_BASELINE_AREA_FILE_SUFFIXES
|
||||
)
|
||||
for arg in file_targets
|
||||
)
|
||||
|
||||
|
||||
if _is_imp35_baseline_subprocess():
|
||||
# Drop any inherited AI_FALLBACK_* values so the gate's child process
|
||||
# falls back to the live ``.env`` (AI_FALLBACK_ENABLED=true) — the
|
||||
# exact precondition under which the four registered baseline-red
|
||||
# tests are red. ``pop`` is no-op when the key is absent, so a
|
||||
# developer running the gate manually with a clean environment is
|
||||
# unaffected.
|
||||
os.environ.pop("AI_FALLBACK_ENABLED", None)
|
||||
os.environ.pop("AI_FALLBACK_AUTO_CACHE", None)
|
||||
else:
|
||||
os.environ["AI_FALLBACK_ENABLED"] = "false"
|
||||
os.environ["AI_FALLBACK_AUTO_CACHE"] = "false"
|
||||
@@ -2,11 +2,27 @@
|
||||
|
||||
Stage 1 finding: line 564 previously referenced a non-existent ID ("IMP-31").
|
||||
The legitimate slot is IMP-17 (Gitea #17, carve-out — AI fallback only, normal path 밖).
|
||||
Line 565 (IMP-29 frontend zone-level override) must remain untouched.
|
||||
The reject anchor previously referenced IMP-29 (frontend zone-level override); it has
|
||||
since been superseded by IMP-47B u1 (2026-05-21) which corrects the reject disposition
|
||||
to AI re-construction over the rank-1 reject frame.
|
||||
|
||||
Anchor re-pin (2026-05-20, IMP-30 u1 follow-up): V4Match.provisional field added at
|
||||
src/phase_z2_pipeline.py:179-184 shifted the route-hint table down by six lines.
|
||||
Pinned line numbers updated from 564/565 → 570/571 to track the actual anchor location.
|
||||
Pinned line numbers were updated 564/565 → 570/571.
|
||||
|
||||
Anchor re-pin (2026-05-22, IMP-36 u1 / Gitea #65 Stage 2): IMP-47B supersession at
|
||||
src/phase_z2_pipeline.py:579-582 expanded the reject hint comment by four lines, which
|
||||
shifted only the post-comment table downward. The restructure anchor itself moved from
|
||||
570 → 578 because additional comment context was inserted between the table header and
|
||||
the restructure line. Re-pinned 570 → 578 (restructure / IMP-17) and 571 → 579
|
||||
(reject / IMP-47B supersession of the prior IMP-29 reference).
|
||||
|
||||
Anchor re-pin (2026-05-23, IMP-35 u1/u5/u7 / Gitea #64 Stage 3): IMP-35 added a
|
||||
single-line ``compose_zone_popup_payload`` import (u7) plus a 7-line
|
||||
``run_step17_popup_gate`` import block (u5) ahead of the route-hint table, totaling
|
||||
+8 lines of pre-anchor additions. The post-import body shifted uniformly downward;
|
||||
the restructure anchor moved 578 → 586 and the reject anchor moved 579 → 587.
|
||||
Re-pinned 578 → 586 (restructure / IMP-17) and 579 → 587 (reject / IMP-47B).
|
||||
|
||||
Run: pytest -q tests/orchestrator_unit/test_imp17_comment_anchor.py
|
||||
"""
|
||||
@@ -20,14 +36,17 @@ def _lines() -> list[str]:
|
||||
return PIPELINE.read_text(encoding="utf-8").splitlines()
|
||||
|
||||
|
||||
def test_line_570_references_imp17_not_imp31():
|
||||
line = _lines()[569] # 1-indexed line 570
|
||||
assert "restructure" in line, f"line 570 anchor drifted: {line!r}"
|
||||
assert "IMP-17" in line, f"line 570 must reference IMP-17 (carve-out): {line!r}"
|
||||
assert "IMP-31" not in line, f"line 570 must not reference non-existent IMP-31: {line!r}"
|
||||
def test_line_586_references_imp17_not_imp31():
|
||||
line = _lines()[585] # 1-indexed line 586
|
||||
assert "restructure" in line, f"line 586 anchor drifted: {line!r}"
|
||||
assert "IMP-17" in line, f"line 586 must reference IMP-17 (carve-out): {line!r}"
|
||||
assert "IMP-31" not in line, f"line 586 must not reference non-existent IMP-31: {line!r}"
|
||||
|
||||
|
||||
def test_line_571_still_references_imp29():
|
||||
line = _lines()[570] # 1-indexed line 571
|
||||
assert "reject" in line, f"line 571 anchor drifted: {line!r}"
|
||||
assert "IMP-29" in line, f"line 571 must still reference IMP-29 frontend override: {line!r}"
|
||||
def test_line_587_references_imp47b_supersession():
|
||||
line = _lines()[586] # 1-indexed line 587
|
||||
assert "reject" in line, f"line 587 anchor drifted: {line!r}"
|
||||
assert "IMP-47B" in line, (
|
||||
f"line 587 must reference IMP-47B (supersedes prior IMP-29 reject disposition): "
|
||||
f"{line!r}"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# IMP-#85 u5 fixture — non-VP contract whose payload.builder is absent from
|
||||
# `PAYLOAD_BUILDERS`. Drives the u2 boot invariant + audit I3 negative paths.
|
||||
#
|
||||
# Scope (Stage 2 lock): regression coverage only. Not a runtime catalog entry.
|
||||
# Frame id is in the 9999xxx range so any accidental cross-reference is obvious.
|
||||
|
||||
imp85_u5_missing_builder_frame:
|
||||
template_id: imp85_u5_missing_builder_frame
|
||||
frame_id: 9999001
|
||||
family: imp85_u5_fixture
|
||||
source_shape: top_bullets
|
||||
cardinality:
|
||||
strict: 3
|
||||
payload:
|
||||
title:
|
||||
source: section.title
|
||||
builder: definitely_not_a_registered_builder_imp85_u5
|
||||
@@ -0,0 +1,23 @@
|
||||
# IMP-#85 u5 fixture — non-VP contract whose `items_with_role` builder produces
|
||||
# a `slot_payload.<array_root>` key the partial never references. Drives the
|
||||
# audit I4 (generated-key-orphan) negative path.
|
||||
#
|
||||
# Scope (Stage 2 lock): regression coverage only. The corresponding partial is
|
||||
# written into a tmp dir by the test (it must NOT use `slot_payload[...]`
|
||||
# bracket access, otherwise I4 suppresses correctly and the assertion fails).
|
||||
|
||||
imp85_u5_undeclared_slot_frame:
|
||||
template_id: imp85_u5_undeclared_slot_frame
|
||||
frame_id: 9999002
|
||||
family: imp85_u5_fixture
|
||||
source_shape: top_bullets
|
||||
cardinality:
|
||||
strict: 3
|
||||
payload:
|
||||
title:
|
||||
source: section.title
|
||||
builder: items_with_role
|
||||
builder_options:
|
||||
item_parser: pillar_item
|
||||
array_root: orphan_array_root_imp85_u5
|
||||
role_field: color_class
|
||||
@@ -0,0 +1,333 @@
|
||||
"""IMP-35 (#64) u6 — Composition popup binding tests.
|
||||
|
||||
Stage 2 binding contract (unit u6):
|
||||
``bind_popup_display_strategy`` in ``src/phase_z2_composition.py`` is
|
||||
the composition-side binding that translates the unit-side marker
|
||||
(``has_popup`` + ``popup_escalation_plan``) stamped by the Step 17
|
||||
POPUP gate (u5 in ``src/phase_z2_ai_fallback/step17.py``) into a
|
||||
deterministic zone payload structure that u7 wires into the renderer.
|
||||
|
||||
Key invariants this file locks:
|
||||
1. Strategy id is the catalog key (yaml is source of truth) — no
|
||||
hardcoded literal string drift between code and
|
||||
``display_strategies.yaml``.
|
||||
2. ``has_popup=False`` units bind to ``inline_full`` (no popup).
|
||||
3. ``has_popup=True`` units bind to ``inline_preview_with_details``
|
||||
(preview = excerpt from container px budget downstream; popup
|
||||
body holds the FULL original per CLAUDE.md 자세히보기 원칙).
|
||||
4. ``popup_body_source`` is the FULL ``raw_content``, verbatim —
|
||||
u6 NEVER trims or summarizes (MDX 원문 무손실 보존, 오답노트 #5,
|
||||
IMPROVEMENT-REDESIGN.md §3.6 line 110).
|
||||
5. ``detail_trigger.placement`` / ``label`` come from the catalog
|
||||
entry's ``detail_trigger`` block, not from code constants.
|
||||
6. The popup-binding strategy MUST have ``preserves_original=True``
|
||||
in the catalog (defensive yaml-drift guard).
|
||||
7. No AI call. ``bind_popup_display_strategy`` is pure composition-
|
||||
side binding — feedback_ai_isolation_contract.
|
||||
|
||||
Cross-references:
|
||||
- u3 router stub (``plan_details_popup_escalation``):
|
||||
tests/phase_z2/test_phase_z2_router_popup.py
|
||||
- u4 api_gated split-decision contract:
|
||||
tests/phase_z2_ai_fallback/test_step17.py
|
||||
- u5 Step 17 POPUP gate (stamps the marker u6 reads):
|
||||
tests/phase_z2/test_phase_z2_step17_popup_gate.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_composition import (
|
||||
DISPLAY_STRATEGIES,
|
||||
POPUP_BINDING_ESCALATED_STRATEGY_ID,
|
||||
POPUP_BINDING_NO_POPUP_STRATEGY_ID,
|
||||
bind_popup_display_strategy,
|
||||
)
|
||||
|
||||
|
||||
# ─── Synthetic stubs ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class _StubUnit:
|
||||
"""Minimal duck-typed CompositionUnit for u6 binding tests.
|
||||
|
||||
Mirrors only the fields ``bind_popup_display_strategy`` reads via
|
||||
getattr — keeps the test independent of the full CompositionUnit
|
||||
dataclass evolution (e.g., IMP-30 / IMP-48 axis additions).
|
||||
"""
|
||||
|
||||
raw_content: str = "MOCK_ORIGINAL_CONTENT"
|
||||
has_popup: bool = False
|
||||
popup_escalation_plan: Optional[dict] = None
|
||||
|
||||
|
||||
def _stub_popup_plan(category: str = "structural_major_overflow") -> dict:
|
||||
"""Mirror the shape ``plan_details_popup_escalation`` returns on a
|
||||
feasible escalation. u6 echoes this verbatim — no field is consumed
|
||||
here other than as a traceable payload."""
|
||||
return {
|
||||
"action": "details_popup_escalation",
|
||||
"stub": True,
|
||||
"feasible": True,
|
||||
"category": category,
|
||||
"needs_split_decision": True,
|
||||
"rationale": "MOCK_RATIONALE",
|
||||
"mapping_source": "IMP-35 u3 plan_details_popup_escalation stub",
|
||||
}
|
||||
|
||||
|
||||
# ─── Catalog constants are catalog keys (no hardcoded drift) ─────────
|
||||
|
||||
|
||||
def test_popup_binding_strategy_ids_are_catalog_keys():
|
||||
"""u6 — both constants used by the binder must resolve against the
|
||||
yaml catalog. Defensive guard against catalog rename / removal."""
|
||||
assert POPUP_BINDING_NO_POPUP_STRATEGY_ID in DISPLAY_STRATEGIES
|
||||
assert POPUP_BINDING_ESCALATED_STRATEGY_ID in DISPLAY_STRATEGIES
|
||||
|
||||
|
||||
def test_popup_binding_escalated_strategy_preserves_original_in_catalog():
|
||||
"""u6 — the escalated-path strategy MUST preserve original content
|
||||
in the catalog (yaml lock — MDX 원문 무손실 보존). If yaml drift ever
|
||||
flips this to False, the binder must surface the violation; this
|
||||
test locks the catalog side of that invariant."""
|
||||
meta = DISPLAY_STRATEGIES[POPUP_BINDING_ESCALATED_STRATEGY_ID]
|
||||
assert meta.get("preserves_original") is True, (
|
||||
"Catalog entry for the popup-binding strategy must declare "
|
||||
"preserves_original=True (오답노트 #5 / IMPROVEMENT-REDESIGN.md §3.6)."
|
||||
)
|
||||
|
||||
|
||||
def test_popup_binding_escalated_strategy_has_detail_trigger_in_catalog():
|
||||
"""u6 — the escalated-path strategy MUST declare a detail_trigger
|
||||
block with placement + label in the catalog. The binder reads from
|
||||
the yaml — no code-side string literal drift."""
|
||||
meta = DISPLAY_STRATEGIES[POPUP_BINDING_ESCALATED_STRATEGY_ID]
|
||||
trigger = meta.get("detail_trigger")
|
||||
assert isinstance(trigger, dict)
|
||||
assert trigger.get("placement"), (
|
||||
"Catalog detail_trigger.placement must be non-empty so the binder "
|
||||
"can stamp a deterministic trigger position on the zone payload."
|
||||
)
|
||||
assert trigger.get("label"), (
|
||||
"Catalog detail_trigger.label must be non-empty so the binder "
|
||||
"can stamp a deterministic trigger identifier on the zone payload."
|
||||
)
|
||||
|
||||
|
||||
# ─── has_popup=False path ────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_bind_returns_inline_full_when_unit_has_no_popup_marker():
|
||||
"""u6 — units that never went through the Step 17 POPUP gate carry
|
||||
has_popup=False. The binder returns the catalog ``inline_full``
|
||||
strategy with no popup body / no detail trigger."""
|
||||
unit = _StubUnit(raw_content="MOCK_BODY", has_popup=False)
|
||||
payload = bind_popup_display_strategy(unit)
|
||||
assert payload["display_strategy"] == POPUP_BINDING_NO_POPUP_STRATEGY_ID
|
||||
assert payload["popup_body_source"] is None
|
||||
assert payload["detail_trigger"] is None
|
||||
assert payload["has_popup"] is False
|
||||
assert payload["popup_escalation_plan"] is None
|
||||
# preserves_original mirrors the catalog inline_full entry.
|
||||
expected_preserves = bool(
|
||||
DISPLAY_STRATEGIES[POPUP_BINDING_NO_POPUP_STRATEGY_ID].get(
|
||||
"preserves_original"
|
||||
)
|
||||
)
|
||||
assert payload["preserves_original"] is expected_preserves
|
||||
|
||||
|
||||
def test_bind_default_when_unit_has_no_has_popup_attr_at_all():
|
||||
"""u6 — defensive default. Units that lack the ``has_popup`` attr
|
||||
entirely (e.g., third-party duck-typed stubs that don't carry the
|
||||
Step 17 marker) bind to the no-popup path. The getattr() default
|
||||
branch must hold."""
|
||||
|
||||
class _BareUnit:
|
||||
raw_content = "MOCK_BODY"
|
||||
|
||||
payload = bind_popup_display_strategy(_BareUnit())
|
||||
assert payload["display_strategy"] == POPUP_BINDING_NO_POPUP_STRATEGY_ID
|
||||
assert payload["has_popup"] is False
|
||||
assert payload["popup_body_source"] is None
|
||||
|
||||
|
||||
# ─── has_popup=True path ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_bind_returns_inline_preview_with_details_when_has_popup_true():
|
||||
"""u6 — feasible POPUP gate escalation flips the binder onto the
|
||||
``inline_preview_with_details`` strategy (preview = px-budget
|
||||
excerpt downstream; popup body holds FULL original)."""
|
||||
plan = _stub_popup_plan()
|
||||
unit = _StubUnit(
|
||||
raw_content="MOCK_BODY",
|
||||
has_popup=True,
|
||||
popup_escalation_plan=plan,
|
||||
)
|
||||
payload = bind_popup_display_strategy(unit)
|
||||
assert payload["display_strategy"] == POPUP_BINDING_ESCALATED_STRATEGY_ID
|
||||
assert payload["has_popup"] is True
|
||||
assert payload["popup_escalation_plan"] is plan
|
||||
|
||||
|
||||
def test_bind_popup_body_source_is_full_raw_content_verbatim():
|
||||
"""u6 — popup body MUST be the FULL raw_content, byte-for-byte.
|
||||
The binder NEVER trims or summarizes (MDX 원문 무손실 보존 —
|
||||
오답노트 #5 / IMPROVEMENT-REDESIGN.md §3.6 line 110). u7 composes
|
||||
the body preview from container px telemetry downstream."""
|
||||
full_text = (
|
||||
"## MOCK_SECTION_TITLE\n\n"
|
||||
"- bullet one with **bold** marker\n"
|
||||
"- bullet two with *italic* marker\n"
|
||||
"- bullet three trailing\n"
|
||||
"\n"
|
||||
"| col_a | col_b |\n| --- | --- |\n| MOCK | DATA |\n"
|
||||
)
|
||||
unit = _StubUnit(
|
||||
raw_content=full_text,
|
||||
has_popup=True,
|
||||
popup_escalation_plan=_stub_popup_plan(),
|
||||
)
|
||||
payload = bind_popup_display_strategy(unit)
|
||||
assert payload["popup_body_source"] == full_text
|
||||
# Verbatim guarantee — no length-trimming side channel.
|
||||
assert len(payload["popup_body_source"]) == len(full_text)
|
||||
|
||||
|
||||
def test_bind_detail_trigger_placement_and_label_come_from_catalog():
|
||||
"""u6 — detail_trigger.placement / label MUST be read from the yaml
|
||||
catalog entry's detail_trigger block, not from code constants. This
|
||||
test compares the binder output against a fresh catalog read so a
|
||||
catalog rename (e.g., placement: top-right → top-left) propagates
|
||||
automatically."""
|
||||
catalog_trigger = (
|
||||
DISPLAY_STRATEGIES[POPUP_BINDING_ESCALATED_STRATEGY_ID]
|
||||
.get("detail_trigger") or {}
|
||||
)
|
||||
unit = _StubUnit(
|
||||
has_popup=True,
|
||||
popup_escalation_plan=_stub_popup_plan(),
|
||||
)
|
||||
payload = bind_popup_display_strategy(unit)
|
||||
assert payload["detail_trigger"] == {
|
||||
"placement": catalog_trigger.get("placement"),
|
||||
"label": catalog_trigger.get("label"),
|
||||
}
|
||||
|
||||
|
||||
def test_bind_preserves_original_is_true_on_popup_path():
|
||||
"""u6 — the popup-binding strategy MUST surface preserves_original=
|
||||
True so downstream consumers can rely on the absolute user lock
|
||||
(오답노트 #5). The binder mirrors the catalog value (which the
|
||||
catalog-side test already locks)."""
|
||||
unit = _StubUnit(
|
||||
has_popup=True,
|
||||
popup_escalation_plan=_stub_popup_plan(),
|
||||
)
|
||||
payload = bind_popup_display_strategy(unit)
|
||||
assert payload["preserves_original"] is True
|
||||
|
||||
|
||||
def test_bind_strategy_meta_is_the_full_catalog_entry():
|
||||
"""u6 — strategy_meta echoes the full catalog entry so downstream
|
||||
debug traces can self-explain without re-reading the yaml. Tests
|
||||
that the binder does not strip / re-shape the catalog dict."""
|
||||
expected_meta = DISPLAY_STRATEGIES[POPUP_BINDING_ESCALATED_STRATEGY_ID]
|
||||
unit = _StubUnit(
|
||||
has_popup=True,
|
||||
popup_escalation_plan=_stub_popup_plan(),
|
||||
)
|
||||
payload = bind_popup_display_strategy(unit)
|
||||
assert payload["strategy_meta"] is expected_meta
|
||||
|
||||
|
||||
def test_bind_popup_escalation_plan_is_echoed_verbatim():
|
||||
"""u6 — the popup_escalation_plan from u5 is echoed verbatim onto
|
||||
the zone payload so downstream debug surfaces can trace WHICH router
|
||||
category triggered the escalation (structural_major_overflow vs
|
||||
tabular_overflow). Object identity is preserved (no dict copy)."""
|
||||
plan = _stub_popup_plan("tabular_overflow")
|
||||
unit = _StubUnit(
|
||||
has_popup=True,
|
||||
popup_escalation_plan=plan,
|
||||
)
|
||||
payload = bind_popup_display_strategy(unit)
|
||||
assert payload["popup_escalation_plan"] is plan
|
||||
assert payload["popup_escalation_plan"]["category"] == "tabular_overflow"
|
||||
|
||||
|
||||
# ─── Defensive guards ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_bind_raises_when_strategy_id_missing_from_catalog(monkeypatch):
|
||||
"""u6 defensive guard — if catalog drift removes the escalated
|
||||
strategy id, the binder must raise RuntimeError rather than silently
|
||||
falling back to a wrong strategy. Locks the "yaml is source of
|
||||
truth" invariant against accidental rename."""
|
||||
drifted_catalog = {
|
||||
k: v for k, v in DISPLAY_STRATEGIES.items()
|
||||
if k != POPUP_BINDING_ESCALATED_STRATEGY_ID
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"src.phase_z2_composition.DISPLAY_STRATEGIES",
|
||||
drifted_catalog,
|
||||
)
|
||||
unit = _StubUnit(
|
||||
has_popup=True,
|
||||
popup_escalation_plan=_stub_popup_plan(),
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="catalog drift"):
|
||||
bind_popup_display_strategy(unit)
|
||||
|
||||
|
||||
def test_bind_raises_when_escalated_strategy_loses_preserves_original(
|
||||
monkeypatch,
|
||||
):
|
||||
"""u6 defensive guard — if the catalog entry for the escalated
|
||||
strategy ever flips preserves_original to False (yaml drift), the
|
||||
binder must raise RuntimeError. The absolute user lock — MDX 원문
|
||||
무손실 보존 — must NOT silently degrade through the binding layer."""
|
||||
drifted_meta = {
|
||||
**DISPLAY_STRATEGIES[POPUP_BINDING_ESCALATED_STRATEGY_ID],
|
||||
"preserves_original": False,
|
||||
}
|
||||
drifted_catalog = {
|
||||
**DISPLAY_STRATEGIES,
|
||||
POPUP_BINDING_ESCALATED_STRATEGY_ID: drifted_meta,
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"src.phase_z2_composition.DISPLAY_STRATEGIES",
|
||||
drifted_catalog,
|
||||
)
|
||||
unit = _StubUnit(
|
||||
has_popup=True,
|
||||
popup_escalation_plan=_stub_popup_plan(),
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="preserves_original"):
|
||||
bind_popup_display_strategy(unit)
|
||||
|
||||
|
||||
# ─── AI isolation contract (structural import lock) ─────────────────
|
||||
|
||||
|
||||
def test_composition_module_does_not_import_anthropic_or_route_ai_fallback():
|
||||
"""u6 — bind_popup_display_strategy MUST stay AI-free. Structural
|
||||
guard — composition module is allowed to consult the catalog and
|
||||
unit state, never the Anthropic SDK / route_ai_fallback path. This
|
||||
mirrors the import-isolation pattern locked by u5 tests in
|
||||
tests/phase_z2_ai_fallback/test_step17.py."""
|
||||
import src.phase_z2_composition as composition_module
|
||||
|
||||
source = composition_module.__file__
|
||||
assert source is not None
|
||||
with open(source, encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
assert "import anthropic" not in text
|
||||
assert "from anthropic" not in text
|
||||
assert "route_ai_fallback" not in text
|
||||
@@ -99,3 +99,63 @@ def test_fr_default_single_returns_full_body():
|
||||
per_zone = _compute_per_zone_geometry(layout_css, debug_zones, GRID_GAP)
|
||||
assert per_zone[0]["zone_height_px"] == SLIDE_BODY_HEIGHT
|
||||
assert per_zone[0]["zone_width_px"] == SLIDE_BODY_WIDTH
|
||||
|
||||
|
||||
def _placeholder_zone(position: str) -> dict:
|
||||
# IMP-86 u3 — mirror the u1 mapper-FitError placeholder zones_data
|
||||
# record shape (`src/phase_z2_pipeline.py:4459-4469`) used to keep the
|
||||
# failed unit's preset position in zones_data so build_layout_css /
|
||||
# _compute_per_zone_geometry observe len(zones_data) == active preset's
|
||||
# css_areas rows (R). content_weight.score == 0 ensures the placeholder
|
||||
# does not steal weight from the surviving normal zone.
|
||||
return {
|
||||
"position": position,
|
||||
"template_id": "__empty__",
|
||||
"slot_payload": {},
|
||||
"content_weight": {"score": 0},
|
||||
"min_height_px": 100,
|
||||
"assignment_source": "imp86_u1_adapter_needed",
|
||||
"section_assignment_override": False,
|
||||
"provisional": False,
|
||||
}
|
||||
|
||||
|
||||
def test_horizontal_2_normal_plus_placeholder_preserves_R2_cardinality():
|
||||
"""IMP-86 u3 — horizontal-2 with one mapper-success zone + one
|
||||
mapper-FitError placeholder (per IMP-86 u1) must keep heights_px /
|
||||
debug_zones / per_zone cardinality locked at R=2.
|
||||
|
||||
Reproduces the bug scenario in the issue body (mdx03 reject override
|
||||
where 03-2 hits adapter_needed) at the helper level: if the FitError
|
||||
path forgets to append a placeholder, heights_px length 1 vs R=2
|
||||
raises ValueError at `_compute_per_zone_geometry`. With the u1
|
||||
placeholder, all three artifacts (heights_px, debug_zones, per_zone)
|
||||
stay at length 2 and the geometry helper succeeds.
|
||||
"""
|
||||
zones = [_zone("top", 1.0), _placeholder_zone("bottom")]
|
||||
layout_css = build_layout_css("horizontal-2", zones)
|
||||
debug_zones = [{"position": "top"}, {"position": "bottom"}]
|
||||
|
||||
# heights_px length is locked to R=2 (parsed from preset css_areas).
|
||||
assert layout_css["areas"] == '"top" "bottom"'
|
||||
assert len(layout_css["heights_px"]) == 2
|
||||
# widths_px length is locked to C=1.
|
||||
assert len(layout_css["widths_px"]) == 1
|
||||
|
||||
# Placeholder zone (score=0) gets its min_height_px (100) and the
|
||||
# surviving normal zone absorbs the remaining body height after gap.
|
||||
assert layout_css["heights_px"][1] == 100
|
||||
assert (
|
||||
layout_css["heights_px"][0] + layout_css["heights_px"][1] + GRID_GAP
|
||||
== SLIDE_BODY_HEIGHT
|
||||
)
|
||||
|
||||
per_zone = _compute_per_zone_geometry(layout_css, debug_zones, GRID_GAP)
|
||||
# per_zone cardinality matches debug_zones (which matches R=2).
|
||||
assert len(per_zone) == 2
|
||||
assert [pz["position"] for pz in per_zone] == ["top", "bottom"]
|
||||
assert per_zone[0]["zone_height_px"] == layout_css["heights_px"][0]
|
||||
assert per_zone[1]["zone_height_px"] == layout_css["heights_px"][1]
|
||||
# Both zones share the single column => width == SLIDE_BODY_WIDTH.
|
||||
assert per_zone[0]["zone_width_px"] == SLIDE_BODY_WIDTH
|
||||
assert per_zone[1]["zone_width_px"] == SLIDE_BODY_WIDTH
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"""IMP-35 (#64) u9 — display_strategies.yaml popup-wiring catalog tests.
|
||||
|
||||
Stage 2 binding contract (unit u9):
|
||||
``templates/phase_z2/regions/display_strategies.yaml`` is the source of
|
||||
truth for the popup-wiring axis. u9 adds two strategy-level fields:
|
||||
|
||||
preview_chars : int | null
|
||||
Soft char budget for the inline body shown alongside the popup
|
||||
trigger. ``null`` when the strategy has no popup (``inline_full``,
|
||||
``dropped``). For popup-bearing strategies the value is the soft
|
||||
budget for the INLINE preview / summary surface only — the popup
|
||||
body itself ALWAYS holds the FULL original (MDX 원문 무손실 보존,
|
||||
오답노트 #5 / IMPROVEMENT-REDESIGN.md §3.6 line 110).
|
||||
|
||||
popup_target_slot : str | null
|
||||
Frame Layer B slot identifier the popup trigger anchors to.
|
||||
``null`` when the strategy has no popup. See CLAUDE.md
|
||||
"위계 + 용어" → "Frame Slot" / "Layer B" for the slot vocabulary.
|
||||
|
||||
Invariants this file locks (catalog side only — u9 is "data only"):
|
||||
|
||||
1. Both fields exist on every catalog entry (no missing keys).
|
||||
2. ``preview_chars`` is ``int >= 0`` for popup-bearing strategies
|
||||
(``inline_preview_with_details``, ``details_only``) and ``None`` for
|
||||
non-popup strategies (``inline_full``, ``dropped``).
|
||||
3. ``popup_target_slot`` is a non-empty ``str`` for popup-bearing
|
||||
strategies and ``None`` for non-popup strategies.
|
||||
4. The two fields are mutually consistent — both null OR both populated
|
||||
within a single strategy entry (no half-wired strategy).
|
||||
5. The popup-bearing strategies still preserve original content
|
||||
(popup body = full original; preview_chars governs only the inline
|
||||
surface, never the popup body).
|
||||
|
||||
Cross-references:
|
||||
- u6 binder (consumes ``DISPLAY_STRATEGIES`` via catalog key):
|
||||
src/phase_z2_composition.py:bind_popup_display_strategy
|
||||
- u6 binding tests (existing — must still pass with u9 fields added):
|
||||
tests/phase_z2/test_composition_popup_strategy.py
|
||||
- u7 preview text helper (line-budget cut; the char-budget axis u9
|
||||
introduces is forward config the future wiring will honor):
|
||||
src/phase_z2_composition.py:compute_popup_preview_text
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_composition import (
|
||||
DISPLAY_STRATEGIES,
|
||||
POPUP_BINDING_ESCALATED_STRATEGY_ID,
|
||||
POPUP_BINDING_NO_POPUP_STRATEGY_ID,
|
||||
)
|
||||
|
||||
|
||||
# Catalog keys grouped by popup capability. Sourced from the loaded
|
||||
# DISPLAY_STRATEGIES so a yaml-side rename surfaces immediately (no
|
||||
# hardcoded duplicate of catalog keys outside the binder constants).
|
||||
_POPUP_BEARING_STRATEGY_IDS = (
|
||||
"inline_preview_with_details",
|
||||
"details_only",
|
||||
)
|
||||
_NON_POPUP_STRATEGY_IDS = (
|
||||
"inline_full",
|
||||
"dropped",
|
||||
)
|
||||
|
||||
|
||||
def test_all_strategies_declare_preview_chars_field():
|
||||
"""Every catalog entry MUST declare ``preview_chars`` (int or null).
|
||||
Missing key = yaml drift; the binder + future wiring need a present
|
||||
field to read deterministically."""
|
||||
for name, meta in DISPLAY_STRATEGIES.items():
|
||||
assert "preview_chars" in meta, (
|
||||
f"display_strategies.yaml entry {name!r} is missing the u9 "
|
||||
f"`preview_chars` field. Every entry must declare it (int >= 0 "
|
||||
f"for popup-bearing strategies, null otherwise)."
|
||||
)
|
||||
|
||||
|
||||
def test_all_strategies_declare_popup_target_slot_field():
|
||||
"""Every catalog entry MUST declare ``popup_target_slot`` (str or
|
||||
null). Missing key = yaml drift."""
|
||||
for name, meta in DISPLAY_STRATEGIES.items():
|
||||
assert "popup_target_slot" in meta, (
|
||||
f"display_strategies.yaml entry {name!r} is missing the u9 "
|
||||
f"`popup_target_slot` field. Every entry must declare it "
|
||||
f"(non-empty str for popup-bearing strategies, null otherwise)."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("strategy_id", _POPUP_BEARING_STRATEGY_IDS)
|
||||
def test_popup_bearing_strategies_have_nonnegative_int_preview_chars(strategy_id):
|
||||
"""Popup-bearing strategies declare ``preview_chars`` as ``int >= 0``.
|
||||
The popup body itself always holds the FULL original (user lock), so
|
||||
this budget governs only the INLINE preview / summary surface."""
|
||||
meta = DISPLAY_STRATEGIES[strategy_id]
|
||||
value = meta.get("preview_chars")
|
||||
assert isinstance(value, int) and not isinstance(value, bool), (
|
||||
f"display_strategies.yaml {strategy_id!r} preview_chars must be an "
|
||||
f"int (got {type(value).__name__}={value!r}). The future wiring "
|
||||
f"reads it as a deterministic budget — bool / float / str would "
|
||||
f"silently break downstream comparisons."
|
||||
)
|
||||
assert value >= 0, (
|
||||
f"display_strategies.yaml {strategy_id!r} preview_chars must be "
|
||||
f">= 0 (got {value!r}). Negative budgets are not a valid surface."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("strategy_id", _POPUP_BEARING_STRATEGY_IDS)
|
||||
def test_popup_bearing_strategies_have_nonempty_string_popup_target_slot(strategy_id):
|
||||
"""Popup-bearing strategies declare ``popup_target_slot`` as a
|
||||
non-empty ``str`` — the frame Layer B slot identifier the popup
|
||||
trigger anchors to."""
|
||||
meta = DISPLAY_STRATEGIES[strategy_id]
|
||||
value = meta.get("popup_target_slot")
|
||||
assert isinstance(value, str), (
|
||||
f"display_strategies.yaml {strategy_id!r} popup_target_slot must "
|
||||
f"be a str (got {type(value).__name__}={value!r})."
|
||||
)
|
||||
assert value, (
|
||||
f"display_strategies.yaml {strategy_id!r} popup_target_slot must "
|
||||
f"be a non-empty string identifying a frame Layer B slot."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("strategy_id", _NON_POPUP_STRATEGY_IDS)
|
||||
def test_non_popup_strategies_have_null_preview_chars(strategy_id):
|
||||
"""Non-popup strategies (``inline_full`` / ``dropped``) declare
|
||||
``preview_chars`` as null — they have no popup-side budget axis."""
|
||||
meta = DISPLAY_STRATEGIES[strategy_id]
|
||||
assert meta.get("preview_chars") is None, (
|
||||
f"display_strategies.yaml {strategy_id!r} has no popup; "
|
||||
f"preview_chars must be null (got {meta.get('preview_chars')!r})."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("strategy_id", _NON_POPUP_STRATEGY_IDS)
|
||||
def test_non_popup_strategies_have_null_popup_target_slot(strategy_id):
|
||||
"""Non-popup strategies declare ``popup_target_slot`` as null —
|
||||
nothing for the popup trigger to anchor to."""
|
||||
meta = DISPLAY_STRATEGIES[strategy_id]
|
||||
assert meta.get("popup_target_slot") is None, (
|
||||
f"display_strategies.yaml {strategy_id!r} has no popup; "
|
||||
f"popup_target_slot must be null (got {meta.get('popup_target_slot')!r})."
|
||||
)
|
||||
|
||||
|
||||
def test_popup_wiring_fields_are_mutually_consistent_per_strategy():
|
||||
"""For every catalog entry, ``preview_chars`` and ``popup_target_slot``
|
||||
must be either BOTH null OR BOTH populated. A half-wired strategy
|
||||
(one null, one populated) is a yaml-drift bug — surfaces here."""
|
||||
for name, meta in DISPLAY_STRATEGIES.items():
|
||||
preview = meta.get("preview_chars")
|
||||
slot = meta.get("popup_target_slot")
|
||||
both_null = preview is None and slot is None
|
||||
both_set = preview is not None and slot is not None
|
||||
assert both_null or both_set, (
|
||||
f"display_strategies.yaml {name!r} has inconsistent popup "
|
||||
f"wiring fields — preview_chars={preview!r}, "
|
||||
f"popup_target_slot={slot!r}. Must be both null OR both set."
|
||||
)
|
||||
|
||||
|
||||
def test_binder_constants_point_to_popup_bearing_strategies():
|
||||
"""The u6 binder constants must continue to resolve against the
|
||||
catalog entries that carry u9 popup-wiring fields. Cross-axis lock
|
||||
between the binder (u6) and the catalog (u9) — drift on either side
|
||||
breaks the popup path silently."""
|
||||
assert POPUP_BINDING_ESCALATED_STRATEGY_ID in _POPUP_BEARING_STRATEGY_IDS, (
|
||||
f"u6 binder POPUP_BINDING_ESCALATED_STRATEGY_ID points to "
|
||||
f"{POPUP_BINDING_ESCALATED_STRATEGY_ID!r} which is NOT a popup-"
|
||||
f"bearing strategy per the u9 catalog axis."
|
||||
)
|
||||
assert POPUP_BINDING_NO_POPUP_STRATEGY_ID in _NON_POPUP_STRATEGY_IDS, (
|
||||
f"u6 binder POPUP_BINDING_NO_POPUP_STRATEGY_ID points to "
|
||||
f"{POPUP_BINDING_NO_POPUP_STRATEGY_ID!r} which IS popup-bearing "
|
||||
f"per the u9 catalog axis — wiring would be miscategorised."
|
||||
)
|
||||
|
||||
|
||||
def test_popup_bearing_strategies_still_preserve_original():
|
||||
"""u9 does not alter the existing absolute user lock: popup-bearing
|
||||
strategies have ``preserves_original=True`` (popup body == full
|
||||
original). u9 only adds inline-surface budget fields — must NOT
|
||||
silently degrade the existing invariant."""
|
||||
for strategy_id in _POPUP_BEARING_STRATEGY_IDS:
|
||||
meta = DISPLAY_STRATEGIES[strategy_id]
|
||||
assert meta.get("preserves_original") is True, (
|
||||
f"display_strategies.yaml {strategy_id!r} must preserve "
|
||||
f"original content even after u9 — preview_chars governs "
|
||||
f"the inline surface only, never the popup body."
|
||||
)
|
||||
@@ -0,0 +1,339 @@
|
||||
"""IMP-35 (#64) u11 — baseline-red invariance gate.
|
||||
|
||||
Stage 2 binding contract (unit u11):
|
||||
IMP-35 inherits a four-test red baseline from prior phases that is
|
||||
explicitly OUT OF SCOPE for this issue:
|
||||
|
||||
1. tests/test_imp47b_step12_ai_wiring.py
|
||||
::test_mixed_units_classified_by_route_and_provisional_flag
|
||||
2. tests/test_imp47b_step12_ai_wiring.py
|
||||
::test_reject_provisional_unit_reaches_router_short_circuit
|
||||
3. tests/test_imp47b_step12_ai_wiring.py
|
||||
::test_step12_ai_repair_artifact_writes_json_serialisable_records
|
||||
4. tests/test_phase_z2_ai_fallback_config.py
|
||||
::test_ai_fallback_master_flag_default_off
|
||||
|
||||
u11 does NOT fix these. u11 LOCKS the count + identity of the
|
||||
baseline-red set so that IMP-35 cannot silently grow the red surface
|
||||
while the issue is in-flight. A follow-up issue (Stage 2 plan
|
||||
`follow_up_candidates`) tracks the actual repair.
|
||||
|
||||
Invariance semantics:
|
||||
- The exact four baseline-red node ids resolve to real, collectible
|
||||
pytest items (a rename / delete is caught up front; the gate cannot
|
||||
be defeated by silently removing the failing test).
|
||||
- Running pytest on the BROADER baseline-area files
|
||||
(``tests/test_imp47b_step12_ai_wiring.py`` +
|
||||
``tests/test_phase_z2_ai_fallback_config.py``) yields EXACTLY four
|
||||
FAILED node ids and zero ERROR node ids; the FAILED set is exactly
|
||||
the documented baseline-red set.
|
||||
- A NEW red introduced by IMP-35 in the baseline area flips the
|
||||
FAILED count above four AND/OR introduces an extra FAILED node id
|
||||
that is not in the baseline set; either branch fails this gate.
|
||||
|
||||
AI isolation contract (`feedback_ai_isolation_contract`):
|
||||
The invariance gate runs pytest in a child process and parses stdout.
|
||||
It must NOT import the Anthropic SDK and must NOT route through
|
||||
``route_ai_fallback``. The structural import test below locks this.
|
||||
|
||||
Stage 2 plan source: Stage 2 exit report u11 — "u11 acknowledges the
|
||||
current four red baseline tests as pre-existing and adds an invariance
|
||||
gate so IMP-35 cannot worsen them."
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# === BASELINE-RED REGISTRY (frozen by Stage 2 u11 contract) ===
|
||||
#
|
||||
# Order is informational only; the gate compares as a set. Each entry
|
||||
# is a fully-qualified pytest node id resolvable from the repo root.
|
||||
IMP35_BASELINE_RED_NODE_IDS: tuple[str, ...] = (
|
||||
"tests/test_imp47b_step12_ai_wiring.py"
|
||||
"::test_mixed_units_classified_by_route_and_provisional_flag",
|
||||
"tests/test_imp47b_step12_ai_wiring.py"
|
||||
"::test_reject_provisional_unit_reaches_router_short_circuit",
|
||||
"tests/test_imp47b_step12_ai_wiring.py"
|
||||
"::test_step12_ai_repair_artifact_writes_json_serialisable_records",
|
||||
"tests/test_phase_z2_ai_fallback_config.py"
|
||||
"::test_ai_fallback_master_flag_default_off",
|
||||
)
|
||||
|
||||
# Files that own the baseline-red set. The "no-new-red in baseline area"
|
||||
# axis runs pytest on this set and checks that ONLY the registry above
|
||||
# fails.
|
||||
IMP35_BASELINE_RED_AREA_FILES: tuple[str, ...] = (
|
||||
"tests/test_imp47b_step12_ai_wiring.py",
|
||||
"tests/test_phase_z2_ai_fallback_config.py",
|
||||
)
|
||||
|
||||
|
||||
# === Repo root resolution (subprocess CWD anchor) ===
|
||||
|
||||
# tests/phase_z2/<this file>.py -> parents[2] = repo root.
|
||||
_REPO_ROOT: Path = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
# === pytest stdout parsers ===
|
||||
|
||||
# Matches lines like:
|
||||
# FAILED tests/test_imp47b_step12_ai_wiring.py::test_xxx
|
||||
# and:
|
||||
# FAILED tests/test_imp47b_step12_ai_wiring.py::test_xxx - AssertionError: ...
|
||||
# The capture group is the bare node id (no trailing failure detail).
|
||||
_FAILED_LINE_RE = re.compile(r"^FAILED\s+(\S+?)(?:\s+-\s+.*)?$", re.MULTILINE)
|
||||
|
||||
# Matches lines like:
|
||||
# ERROR tests/test_xxx.py::test_yyy
|
||||
_ERROR_LINE_RE = re.compile(r"^ERROR\s+(\S+?)(?:\s+-\s+.*)?$", re.MULTILINE)
|
||||
|
||||
# Matches the pytest tail summary line (sub-second timing field varies):
|
||||
# 4 failed, 6 passed in 2.27s
|
||||
_TAIL_SUMMARY_RE = re.compile(
|
||||
r"^(?P<body>.*?)\s+in\s+\d+(?:\.\d+)?s\s*$", re.MULTILINE
|
||||
)
|
||||
|
||||
|
||||
def _run_pytest_collect_only(node_ids: tuple[str, ...]) -> subprocess.CompletedProcess:
|
||||
"""Run ``pytest --collect-only -q`` against the supplied node ids.
|
||||
|
||||
Used to confirm the baseline-red registry resolves to real, currently
|
||||
collectible tests. If a test is renamed / moved / deleted out from
|
||||
under the registry, pytest's collection failure is the signal.
|
||||
"""
|
||||
return subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pytest",
|
||||
"--collect-only",
|
||||
"-q",
|
||||
*node_ids,
|
||||
],
|
||||
cwd=_REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def _run_pytest_quiet(targets: tuple[str, ...]) -> subprocess.CompletedProcess:
|
||||
"""Run ``pytest -q --tb=no -p no:cacheprovider`` against ``targets``.
|
||||
|
||||
``-p no:cacheprovider`` keeps the gate hermetic across reruns; the
|
||||
parent pytest invocation that triggers this child process must not
|
||||
poison or be poisoned by the child's cache state.
|
||||
"""
|
||||
return subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pytest",
|
||||
"-q",
|
||||
"--tb=no",
|
||||
"-p",
|
||||
"no:cacheprovider",
|
||||
*targets,
|
||||
],
|
||||
cwd=_REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def _parse_failed_node_ids(stdout: str) -> set[str]:
|
||||
"""Extract the set of FAILED node ids from pytest's ``--tb=no -q`` stdout."""
|
||||
return {match.group(1) for match in _FAILED_LINE_RE.finditer(stdout)}
|
||||
|
||||
|
||||
def _parse_error_node_ids(stdout: str) -> set[str]:
|
||||
"""Extract the set of ERROR node ids from pytest's ``--tb=no -q`` stdout."""
|
||||
return {match.group(1) for match in _ERROR_LINE_RE.finditer(stdout)}
|
||||
|
||||
|
||||
# === Tests ===
|
||||
|
||||
|
||||
def test_imp35_baseline_red_registry_has_exactly_four_node_ids() -> None:
|
||||
"""The baseline-red registry is a frozen four-tuple (Stage 2 u11 lock)."""
|
||||
assert len(IMP35_BASELINE_RED_NODE_IDS) == 4
|
||||
assert len(set(IMP35_BASELINE_RED_NODE_IDS)) == 4, (
|
||||
"IMP-35 baseline-red registry must not contain duplicate node ids; "
|
||||
"duplicates would silently weaken the invariance gate."
|
||||
)
|
||||
|
||||
|
||||
def test_imp35_baseline_red_registry_node_ids_are_well_formed() -> None:
|
||||
"""Each baseline-red node id must look like ``tests/<file>.py::<test>``."""
|
||||
for node_id in IMP35_BASELINE_RED_NODE_IDS:
|
||||
assert node_id.startswith("tests/"), (
|
||||
f"IMP-35 baseline-red registry node id {node_id!r} must live "
|
||||
"under tests/ — registry entries point at repo-rooted node ids."
|
||||
)
|
||||
assert ".py::" in node_id, (
|
||||
f"IMP-35 baseline-red registry node id {node_id!r} must use the "
|
||||
"<file>.py::<test_name> pytest node id grammar."
|
||||
)
|
||||
|
||||
|
||||
def test_imp35_baseline_red_registry_files_match_area_inventory() -> None:
|
||||
"""Registry node ids must all live in declared baseline-area files.
|
||||
|
||||
Locks the cross-axis link between :data:`IMP35_BASELINE_RED_NODE_IDS`
|
||||
and :data:`IMP35_BASELINE_RED_AREA_FILES` — adding a registry entry
|
||||
without expanding the area sweep (or vice versa) is the kind of
|
||||
half-wiring that would silently let the gate miss new reds.
|
||||
"""
|
||||
declared_files = set(IMP35_BASELINE_RED_AREA_FILES)
|
||||
for node_id in IMP35_BASELINE_RED_NODE_IDS:
|
||||
file_part, _, _ = node_id.partition("::")
|
||||
assert file_part in declared_files, (
|
||||
f"IMP-35 baseline-red registry entry {node_id!r} references "
|
||||
f"{file_part!r}, which is not in IMP35_BASELINE_RED_AREA_FILES. "
|
||||
"Update both lists together or the area sweep will miss new reds."
|
||||
)
|
||||
|
||||
|
||||
def test_imp35_baseline_red_node_ids_resolve_to_collectible_tests() -> None:
|
||||
"""``pytest --collect-only`` must resolve every baseline-red node id.
|
||||
|
||||
A failure here means a baseline-red test was renamed / deleted /
|
||||
moved out from under the gate; the registry must be updated in the
|
||||
same commit (or, if the test was fixed, the follow-up issue must
|
||||
deregister it).
|
||||
"""
|
||||
result = _run_pytest_collect_only(IMP35_BASELINE_RED_NODE_IDS)
|
||||
# ``pytest --collect-only`` exits 0 on full collection, 2/4/5 on
|
||||
# collection errors. Exit code 5 = no tests collected ("not found").
|
||||
assert result.returncode in (0,), (
|
||||
"pytest --collect-only failed for the IMP-35 baseline-red "
|
||||
f"registry (rc={result.returncode}).\n"
|
||||
f"STDOUT:\n{result.stdout}\n"
|
||||
f"STDERR:\n{result.stderr}"
|
||||
)
|
||||
|
||||
|
||||
def test_imp35_baseline_red_invariance_gate_failed_set_matches_registry() -> None:
|
||||
"""Running pytest on the baseline area must FAIL EXACTLY the registry.
|
||||
|
||||
This is the core invariance contract. If IMP-35 work breaks a 5th
|
||||
test in the baseline area, the FAILED set diverges from the registry
|
||||
and this gate trips. If IMP-35 accidentally fixes one of the four,
|
||||
the FAILED set shrinks below four and this gate also trips — at
|
||||
which point the registry is removed from the failing test (the
|
||||
follow-up issue deregisters it) and the gate is re-locked.
|
||||
"""
|
||||
result = _run_pytest_quiet(IMP35_BASELINE_RED_AREA_FILES)
|
||||
|
||||
# The baseline area is currently red: pytest MUST exit non-zero. A
|
||||
# zero return code here would mean the baseline magically went green
|
||||
# (or the parser missed the failures); both branches require human
|
||||
# review before the registry is updated.
|
||||
assert result.returncode != 0, (
|
||||
"IMP-35 baseline-red area is expected to fail (4 known reds). "
|
||||
"A clean pytest exit means either the baseline was unexpectedly "
|
||||
"fixed (deregister via follow-up issue) or the gate's subprocess "
|
||||
"did not reach the failing tests.\n"
|
||||
f"STDOUT:\n{result.stdout}\n"
|
||||
f"STDERR:\n{result.stderr}"
|
||||
)
|
||||
|
||||
failed_ids = _parse_failed_node_ids(result.stdout)
|
||||
error_ids = _parse_error_node_ids(result.stdout)
|
||||
expected = set(IMP35_BASELINE_RED_NODE_IDS)
|
||||
|
||||
assert error_ids == set(), (
|
||||
"IMP-35 baseline-red invariance gate found ERROR-state tests "
|
||||
f"in the baseline area (expected zero): {sorted(error_ids)}.\n"
|
||||
f"STDOUT:\n{result.stdout}"
|
||||
)
|
||||
|
||||
assert failed_ids == expected, (
|
||||
"IMP-35 baseline-red invariance gate detected drift between the "
|
||||
"registered baseline-red set and the actual pytest FAILED set.\n"
|
||||
f" registered (expected): {sorted(expected)}\n"
|
||||
f" actual (observed): {sorted(failed_ids)}\n"
|
||||
f" unexpected new reds: {sorted(failed_ids - expected)}\n"
|
||||
f" unexpectedly green: {sorted(expected - failed_ids)}\n"
|
||||
"If new reds appear above, IMP-35 has silently grown the red "
|
||||
"surface (u11 contract violation). If reds are unexpectedly "
|
||||
"green, the follow-up issue must deregister them.\n"
|
||||
f"STDOUT:\n{result.stdout}"
|
||||
)
|
||||
|
||||
|
||||
def test_imp35_baseline_red_invariance_gate_failed_count_is_exactly_four() -> None:
|
||||
"""Count-only assertion: the baseline area has exactly four FAILED nodes.
|
||||
|
||||
Complements the identity check above. Even if a parser bug or
|
||||
output-format change ever weakens the identity check, the bare count
|
||||
still catches the "did a new red sneak in?" failure mode.
|
||||
"""
|
||||
result = _run_pytest_quiet(IMP35_BASELINE_RED_AREA_FILES)
|
||||
failed_ids = _parse_failed_node_ids(result.stdout)
|
||||
assert len(failed_ids) == 4, (
|
||||
"IMP-35 baseline-red invariance gate expected exactly 4 FAILED "
|
||||
f"node ids in the baseline area; observed {len(failed_ids)}: "
|
||||
f"{sorted(failed_ids)}.\n"
|
||||
f"STDOUT:\n{result.stdout}"
|
||||
)
|
||||
|
||||
|
||||
def test_imp35_baseline_red_invariance_module_has_no_ai_imports() -> None:
|
||||
"""AI isolation contract — u11 invariance gate must stay pure stdlib.
|
||||
|
||||
Mirrors the structural import lock used by u6 / u7 / u10. The gate
|
||||
is deterministic-with-data (subprocess pytest + regex parse); any
|
||||
Anthropic SDK import or route through the AI fallback router would
|
||||
violate the ``feedback_ai_isolation_contract`` lock.
|
||||
|
||||
The check is AST-based so the assertion bodies (which reference
|
||||
forbidden tokens by name) do not self-trigger a string-substring
|
||||
false positive.
|
||||
"""
|
||||
forbidden_module_prefix = "anthropic"
|
||||
forbidden_attr_substring = "route_ai_fallback"
|
||||
|
||||
module_source = Path(__file__).read_text(encoding="utf-8")
|
||||
tree = ast.parse(module_source)
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
root = alias.name.split(".", 1)[0]
|
||||
assert root != forbidden_module_prefix, (
|
||||
"IMP-35 u11 invariance gate must not import the "
|
||||
f"Anthropic SDK (found ``import {alias.name}``)."
|
||||
)
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
if node.module is None:
|
||||
continue
|
||||
root = node.module.split(".", 1)[0]
|
||||
assert root != forbidden_module_prefix, (
|
||||
"IMP-35 u11 invariance gate must not import from the "
|
||||
f"Anthropic SDK (found ``from {node.module} import ...``)."
|
||||
)
|
||||
for alias in node.names:
|
||||
assert forbidden_attr_substring not in alias.name, (
|
||||
"IMP-35 u11 invariance gate must not route through the "
|
||||
"AI fallback router (found "
|
||||
f"``from {node.module} import {alias.name}``)."
|
||||
)
|
||||
elif isinstance(node, ast.Call):
|
||||
func = node.func
|
||||
if isinstance(func, ast.Name):
|
||||
assert forbidden_attr_substring not in func.id, (
|
||||
"IMP-35 u11 invariance gate must not call into the "
|
||||
f"AI fallback router (found call to ``{func.id}``)."
|
||||
)
|
||||
elif isinstance(func, ast.Attribute):
|
||||
assert forbidden_attr_substring not in func.attr, (
|
||||
"IMP-35 u11 invariance gate must not call into the "
|
||||
f"AI fallback router (found call to ``.{func.attr}``)."
|
||||
)
|
||||
@@ -0,0 +1,166 @@
|
||||
"""IMP-36 (Gitea #65) — P1/P2 fit/rotation generalization static checks.
|
||||
|
||||
Coupled with u2 (frame_contracts.yaml two-bool axis + F29 P3 parity).
|
||||
Asserts:
|
||||
(1) contract-level axis booleans on the 13 partial-backed contracts and
|
||||
their absence on the 19 builder-only contracts;
|
||||
(2) F29 P3 parity (both columns declare column_with_transform);
|
||||
(3) partial-side CSS signatures —
|
||||
P1 (rotation_eligible=true) → ``container-name: f<N>b-root`` +
|
||||
``container-type: size`` + ``@container <name> (aspect-ratio < 1.5)``.
|
||||
P2 (body_fit_pattern2=true) → ``--max-body-lines`` + ``cqh`` + ``clamp(``
|
||||
in the body line-height clamp.
|
||||
|
||||
Per Stage 2 plan the partial-side P1/P2 assertions for F13/F14/F20/F8 begin
|
||||
passing only after u4-u7 land. F23 (Stage 1 canonical P2 source) already
|
||||
satisfies P2 at u3 time. F23 explicitly stays P1=false (no rotation rule)
|
||||
per the in-file lock at templates/phase_z2/families/app_sw_package_vs_solution.html
|
||||
line 64.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
CONTRACTS_PATH = ROOT / "templates" / "phase_z2" / "catalog" / "frame_contracts.yaml"
|
||||
FAMILIES_DIR = ROOT / "templates" / "phase_z2" / "families"
|
||||
|
||||
|
||||
EXPECTED_P1_TRUE = {
|
||||
"three_parallel_requirements",
|
||||
"three_persona_benefits",
|
||||
"dx_sw_necessity_three_perspectives",
|
||||
"info_management_what_how_when",
|
||||
}
|
||||
EXPECTED_P1_FALSE = {
|
||||
"app_sw_package_vs_solution",
|
||||
"bim_current_problems_paired",
|
||||
"bim_dx_comparison_table",
|
||||
"bim_issues_quadrant_four",
|
||||
"construction_bim_three_usage",
|
||||
"construction_goals_three_circle_intersection",
|
||||
"pre_construction_model_info_stacked",
|
||||
"process_product_two_way",
|
||||
"sw_reality_three_emphasis",
|
||||
}
|
||||
EXPECTED_P2_TRUE = EXPECTED_P1_TRUE | {"app_sw_package_vs_solution"}
|
||||
EXPECTED_P2_FALSE = EXPECTED_P1_FALSE - {"app_sw_package_vs_solution"}
|
||||
|
||||
# P1 container-name convention = f<frame_id>b-root, declared in Stage 2 plan.
|
||||
CONTAINER_NAMES = {
|
||||
"three_parallel_requirements": "f13b-root",
|
||||
"three_persona_benefits": "f14b-root",
|
||||
"dx_sw_necessity_three_perspectives": "f20b-root",
|
||||
"info_management_what_how_when": "f8b-root",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def contracts() -> dict:
|
||||
return yaml.safe_load(CONTRACTS_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def partial_files() -> set[str]:
|
||||
return {p.stem for p in FAMILIES_DIR.glob("*.html")}
|
||||
|
||||
|
||||
# ─── contract metadata axis ────────────────────────────────────────────────
|
||||
def test_partial_backed_thirteen_carry_both_flags(contracts, partial_files):
|
||||
partial_backed = {k for k in contracts if k in partial_files}
|
||||
assert len(partial_backed) == 13, sorted(partial_backed)
|
||||
missing = [
|
||||
tid
|
||||
for tid in partial_backed
|
||||
if "rotation_eligible" not in contracts[tid]
|
||||
or "body_fit_pattern2" not in contracts[tid]
|
||||
]
|
||||
assert missing == [], missing
|
||||
bad_type = [
|
||||
tid
|
||||
for tid in partial_backed
|
||||
if not isinstance(contracts[tid]["rotation_eligible"], bool)
|
||||
or not isinstance(contracts[tid]["body_fit_pattern2"], bool)
|
||||
]
|
||||
assert bad_type == [], bad_type
|
||||
|
||||
|
||||
def test_builder_only_nineteen_carry_neither_flag(contracts, partial_files):
|
||||
builder_only = {k for k in contracts if k not in partial_files}
|
||||
assert len(builder_only) == 19, sorted(builder_only)
|
||||
leaked = [
|
||||
tid
|
||||
for tid in builder_only
|
||||
if "rotation_eligible" in contracts[tid] or "body_fit_pattern2" in contracts[tid]
|
||||
]
|
||||
assert leaked == [], leaked
|
||||
|
||||
|
||||
def test_rotation_eligible_true_set(contracts):
|
||||
actual = {k for k, v in contracts.items() if v.get("rotation_eligible") is True}
|
||||
assert actual == EXPECTED_P1_TRUE
|
||||
|
||||
|
||||
def test_rotation_eligible_false_set(contracts):
|
||||
actual = {k for k, v in contracts.items() if v.get("rotation_eligible") is False}
|
||||
assert actual == EXPECTED_P1_FALSE
|
||||
|
||||
|
||||
def test_body_fit_pattern2_true_set(contracts):
|
||||
actual = {k for k, v in contracts.items() if v.get("body_fit_pattern2") is True}
|
||||
assert actual == EXPECTED_P2_TRUE
|
||||
|
||||
|
||||
def test_body_fit_pattern2_false_set(contracts):
|
||||
actual = {k for k, v in contracts.items() if v.get("body_fit_pattern2") is False}
|
||||
assert actual == EXPECTED_P2_FALSE
|
||||
|
||||
|
||||
def test_f29_columns_both_with_transform(contracts):
|
||||
"""P3 parity — F29 (process_product_two_way) columns[*].body_parser symmetry."""
|
||||
cols = contracts["process_product_two_way"]["payload"]["builder_options"]["columns"]
|
||||
parsers = [c.get("body_parser") for c in cols]
|
||||
assert parsers == ["column_with_transform", "column_with_transform"], parsers
|
||||
|
||||
|
||||
# ─── partial-side CSS axis (u4-u7 progressively satisfy) ───────────────────
|
||||
@pytest.mark.parametrize("tid", sorted(EXPECTED_P1_TRUE))
|
||||
def test_p1_partial_declares_aspect_ratio_rotation(tid):
|
||||
"""P1=true partials declare ``container-name``/``container-type`` and an
|
||||
``@container <name> (aspect-ratio < 1.5)`` rotation rule. Satisfied by
|
||||
F13/F14/F20/F8 in u4-u7."""
|
||||
css = (FAMILIES_DIR / f"{tid}.html").read_text(encoding="utf-8")
|
||||
name = CONTAINER_NAMES[tid]
|
||||
assert f"container-name: {name}" in css, tid
|
||||
assert "container-type: size" in css, tid
|
||||
assert f"@container {name} (aspect-ratio < 1.5)" in css, tid
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tid", sorted(EXPECTED_P1_FALSE))
|
||||
def test_p1_false_partial_has_no_rotation_rule(tid):
|
||||
"""P1=false partials must not declare ``aspect-ratio < 1.5`` rotation
|
||||
rule. F23 may still keep its own container-name for P2 cqh — only the
|
||||
rotation rule signature is forbidden here."""
|
||||
css = (FAMILIES_DIR / f"{tid}.html").read_text(encoding="utf-8")
|
||||
assert "aspect-ratio < 1.5" not in css, tid
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tid", sorted(EXPECTED_P2_TRUE))
|
||||
def test_p2_partial_uses_cqh_clamp_max_body_lines(tid):
|
||||
"""P2=true partials declare ``--max-body-lines`` + ``cqh`` + ``clamp(``
|
||||
body line-height clamp. Satisfied by F23 today; F13/F14/F20/F8 land u4-u7."""
|
||||
css = (FAMILIES_DIR / f"{tid}.html").read_text(encoding="utf-8")
|
||||
assert "--max-body-lines" in css, tid
|
||||
assert "cqh" in css, tid
|
||||
assert "clamp(" in css, tid
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tid", sorted(EXPECTED_P2_FALSE))
|
||||
def test_p2_false_partial_has_no_max_body_lines(tid):
|
||||
"""P2=false partials must not declare ``--max-body-lines``."""
|
||||
css = (FAMILIES_DIR / f"{tid}.html").read_text(encoding="utf-8")
|
||||
assert "--max-body-lines" not in css, tid
|
||||
@@ -0,0 +1,299 @@
|
||||
"""IMP-36 (Gitea #65 u8) — Selenium self-fire for the P1/P2 generalization.
|
||||
|
||||
For each of the four P1+P2 partials (F13 ``three_parallel_requirements``,
|
||||
F14 ``three_persona_benefits``, F20 ``dx_sw_necessity_three_perspectives``,
|
||||
F8 ``info_management_what_how_when``), the partial's ``<style>`` block is
|
||||
rendered with a minimal structural skeleton inside a fixed-size outer div
|
||||
at two aspect ratios — wide (1200x675, aspect 1.78) and tall (600x600,
|
||||
aspect 1.0) — and verified live in headless Chrome:
|
||||
|
||||
* P1 (container-query rotation): grid-template-columns goes from 3 tracks
|
||||
(wide, aspect >= 1.5) to 1 track (tall, aspect < 1.5).
|
||||
* P2 (cqh/clamp line-height): computed line-height on the body text element
|
||||
differs between wide and tall because ``cqh`` scales with container height.
|
||||
* P2 invariant (Stage 2 guardrail #6 / IMP-36 contract): the additive P2
|
||||
rule body declares ``line-height: clamp(...)`` only — no ``font-size``
|
||||
mutation. Enforced by static text scan of each partial.
|
||||
|
||||
OVERFLOW_CASCADE_ORDER must remain a 4-tuple — the Step 17 cascade contract
|
||||
is not altered by IMP-36 (P1/P2 are CSS-only self-fire; no new Python stage
|
||||
is introduced — "no new Python surface" per Stage 2 plan).
|
||||
|
||||
Chromedriver resolution mirrors the pipeline order (``PROJECT_ROOT/
|
||||
chromedriver{,.exe}`` -> PATH -> Selenium Manager). When no driver resolves
|
||||
the suite skips; under ``PHASE_Z_REQUIRE_SELENIUM=1`` the skip becomes a
|
||||
strict xfail so CI cannot silently lose coverage.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_ai_fallback.step17 import OVERFLOW_CASCADE_ORDER
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
FAMILIES = PROJECT_ROOT / "templates" / "phase_z2" / "families"
|
||||
|
||||
|
||||
# ─── chromedriver guard (mirrors test_phase_z2_step14_image_check) ───
|
||||
|
||||
def _selenium_manager_resolvable() -> bool:
|
||||
try:
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.chrome.options import Options as _Opts
|
||||
except Exception:
|
||||
return False
|
||||
opts = _Opts()
|
||||
for arg in ("--headless=new", "--no-sandbox", "--disable-dev-shm-usage"):
|
||||
opts.add_argument(arg)
|
||||
try:
|
||||
drv = webdriver.Chrome(options=opts)
|
||||
except Exception:
|
||||
return False
|
||||
try:
|
||||
drv.quit()
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
def _chromedriver_resolvable() -> bool:
|
||||
for candidate in (PROJECT_ROOT / "chromedriver", PROJECT_ROOT / "chromedriver.exe"):
|
||||
if candidate.is_file():
|
||||
return True
|
||||
if shutil.which("chromedriver") or shutil.which("chromedriver.exe"):
|
||||
return True
|
||||
return _selenium_manager_resolvable()
|
||||
|
||||
|
||||
_REQUIRE_SELENIUM = os.environ.get("PHASE_Z_REQUIRE_SELENIUM") == "1"
|
||||
_DRIVER_AVAILABLE = _chromedriver_resolvable()
|
||||
|
||||
if not _DRIVER_AVAILABLE:
|
||||
if _REQUIRE_SELENIUM:
|
||||
pytestmark = pytest.mark.xfail(
|
||||
strict=True,
|
||||
reason="PHASE_Z_REQUIRE_SELENIUM=1 but chromedriver is unresolvable",
|
||||
)
|
||||
else:
|
||||
pytestmark = pytest.mark.skip(
|
||||
reason=(
|
||||
"chromedriver unresolvable (PROJECT_ROOT/chromedriver{,.exe} + PATH + Selenium Manager); "
|
||||
"set PHASE_Z_REQUIRE_SELENIUM=1 to make this a hard failure"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ─── frame harness table ─────────────────────────────────────────────
|
||||
# stem = partial filename (no .html)
|
||||
# root = top-level container-query class (target of container-type:size)
|
||||
# cols = grid class that rotates 3->1 under aspect < 1.5
|
||||
# col_inner = minimal markup for one column with one body text element.
|
||||
# The inline --max-body-lines value is chosen so the P2 clamp
|
||||
# does not saturate at both aspects (otherwise wide and tall
|
||||
# would compute identical line-height). F13 uses 20cqh / N so
|
||||
# N=8 splits the clamp band; F14/F20/F8 use 60cqh / N so N=20
|
||||
# splits theirs.
|
||||
# text_sel = CSS selector for the body text element to measure
|
||||
# p2_re = regex for the IMP-36 P2 rule body (must contain line-height
|
||||
# clamp and must NOT contain font-size)
|
||||
#
|
||||
# Font-size invariance is asserted uniformly for all four frames — IMP-36 P2
|
||||
# mutates line-height / --max-body-lines only (Stage 2 guardrail #6).
|
||||
FRAMES = [
|
||||
{
|
||||
"stem": "three_parallel_requirements",
|
||||
"root": "f13b",
|
||||
"cols": "f13b__cols",
|
||||
"col_inner": (
|
||||
'<div class="f13b__col"><div class="f13b__body">'
|
||||
'<div class="f13b__section"><div class="f13b__desc" '
|
||||
'style="--max-body-lines: 8;">'
|
||||
'<div class="text-line">line a</div>'
|
||||
'<div class="text-line">line b</div>'
|
||||
"</div></div></div></div>"
|
||||
),
|
||||
"text_sel": ".f13b__desc",
|
||||
"p2_re": r"\.f13b__desc\s*\{\s*line-height:\s*clamp\([^}]*\}",
|
||||
},
|
||||
{
|
||||
"stem": "three_persona_benefits",
|
||||
"root": "f14b",
|
||||
"cols": "f14b__cols",
|
||||
"col_inner": (
|
||||
'<div class="f14b__col"><div class="f14b__body" '
|
||||
'style="--max-body-lines: 20;">'
|
||||
'<div class="text-line">line a</div>'
|
||||
'<div class="text-line">line b</div>'
|
||||
"</div></div>"
|
||||
),
|
||||
"text_sel": ".f14b__body .text-line",
|
||||
"p2_re": r"\.f14b__body\s+\.text-line\s*\{\s*line-height:\s*clamp\([^}]*\}",
|
||||
},
|
||||
{
|
||||
"stem": "dx_sw_necessity_three_perspectives",
|
||||
"root": "f20b",
|
||||
"cols": "f20b__cols",
|
||||
"col_inner": (
|
||||
'<div class="f20b__col"><div class="f20b__body" '
|
||||
'style="--max-body-lines: 20;">'
|
||||
'<div class="text-line">line a</div>'
|
||||
'<div class="text-line">line b</div>'
|
||||
"</div></div>"
|
||||
),
|
||||
"text_sel": ".f20b__body .text-line",
|
||||
"p2_re": r"\.f20b__body\s+\.text-line\s*\{\s*line-height:\s*clamp\([^}]*\}",
|
||||
},
|
||||
{
|
||||
"stem": "info_management_what_how_when",
|
||||
"root": "f8b",
|
||||
"cols": "f8b__cols",
|
||||
"col_inner": (
|
||||
'<div class="f8b__col"><div class="f8b__body" '
|
||||
'style="--max-body-lines: 20;">'
|
||||
'<div class="text-line">line a</div>'
|
||||
'<div class="text-line">line b</div>'
|
||||
"</div></div>"
|
||||
),
|
||||
"text_sel": ".f8b__body .text-line",
|
||||
"p2_re": r"\.f8b__body\s+\.text-line\s*\{\s*line-height:\s*clamp\([^}]*\}",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _read_style_block(partial: Path) -> str:
|
||||
text = partial.read_text(encoding="utf-8")
|
||||
m = re.search(r"<style>(.*?)</style>", text, flags=re.DOTALL)
|
||||
assert m, f"<style> block missing in {partial}"
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def _harness_html(frame: dict, outer_w: int, outer_h: int) -> str:
|
||||
style = _read_style_block(FAMILIES / f"{frame['stem']}.html")
|
||||
cols_html = (
|
||||
f'<div class="{frame["cols"]}">' + (frame["col_inner"] * 3) + "</div>"
|
||||
)
|
||||
return (
|
||||
"<!doctype html><html><head><meta charset='utf-8'><style>"
|
||||
":root{"
|
||||
" --font-body:10px; --font-sub-title:12px; --font-zone-title:13px;"
|
||||
" --font-caption:10px;"
|
||||
" --lh-body:1.4; --lh-sub-title:1.3; --lh-zone-title:1.3;"
|
||||
"}"
|
||||
"html,body{margin:0;padding:0;font-size:10px;}"
|
||||
f".outer{{width:{outer_w}px;height:{outer_h}px;}}"
|
||||
f".outer > .{frame['root']}{{width:100%;height:100%;}}"
|
||||
f"{style}</style></head><body>"
|
||||
f'<div class="outer"><div class="{frame["root"]}">'
|
||||
f"{cols_html}"
|
||||
"</div></div></body></html>"
|
||||
)
|
||||
|
||||
|
||||
def _new_driver():
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.chrome.options import Options as _Opts
|
||||
opts = _Opts()
|
||||
for arg in ("--headless=new", "--no-sandbox", "--disable-dev-shm-usage"):
|
||||
opts.add_argument(arg)
|
||||
drv_path = None
|
||||
for cand in (PROJECT_ROOT / "chromedriver", PROJECT_ROOT / "chromedriver.exe"):
|
||||
if cand.is_file():
|
||||
drv_path = str(cand)
|
||||
break
|
||||
if drv_path is None:
|
||||
which = shutil.which("chromedriver") or shutil.which("chromedriver.exe")
|
||||
if which:
|
||||
drv_path = which
|
||||
if drv_path:
|
||||
from selenium.webdriver.chrome.service import Service
|
||||
return webdriver.Chrome(service=Service(executable_path=drv_path), options=opts)
|
||||
return webdriver.Chrome(options=opts)
|
||||
|
||||
|
||||
def _measure(drv, frame: dict, html_path: Path) -> dict:
|
||||
drv.get(html_path.resolve().as_uri())
|
||||
cols_tpl = drv.execute_script(
|
||||
"return getComputedStyle(document.querySelector(arguments[0])).gridTemplateColumns;",
|
||||
f".{frame['cols']}",
|
||||
)
|
||||
lh = drv.execute_script(
|
||||
"var el = document.querySelector(arguments[0]); "
|
||||
"return el ? getComputedStyle(el).lineHeight : null;",
|
||||
frame["text_sel"],
|
||||
)
|
||||
fs = drv.execute_script(
|
||||
"var el = document.querySelector(arguments[0]); "
|
||||
"return el ? getComputedStyle(el).fontSize : null;",
|
||||
frame["text_sel"],
|
||||
)
|
||||
tracks = [t for t in (cols_tpl or "").split() if t]
|
||||
return {"cols": cols_tpl, "tracks": len(tracks), "lh": lh, "fs": fs}
|
||||
|
||||
|
||||
# ─── live (Selenium) parametrized check ──────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize("frame", FRAMES, ids=[f["stem"] for f in FRAMES])
|
||||
def test_p1_rotation_and_p2_lineheight_self_fire(tmp_path: Path, frame: dict) -> None:
|
||||
"""P1: 3-track grid rotates to 1-track when aspect < 1.5.
|
||||
P2: line-height differs between aspects (cqh-driven clamp evaluates
|
||||
differently as container height changes).
|
||||
Font-size invariance is asserted uniformly for all four frames — IMP-36
|
||||
P2 mutates line-height / --max-body-lines only (Stage 2 guardrail #6)."""
|
||||
wide_path = tmp_path / f"{frame['stem']}_wide.html"
|
||||
tall_path = tmp_path / f"{frame['stem']}_tall.html"
|
||||
wide_path.write_text(_harness_html(frame, 1200, 600), encoding="utf-8")
|
||||
tall_path.write_text(_harness_html(frame, 400, 400), encoding="utf-8")
|
||||
|
||||
drv = _new_driver()
|
||||
try:
|
||||
drv.set_window_size(1400, 900)
|
||||
wide = _measure(drv, frame, wide_path)
|
||||
tall = _measure(drv, frame, tall_path)
|
||||
finally:
|
||||
try:
|
||||
drv.quit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
assert wide["tracks"] == 3, (frame["stem"], wide)
|
||||
assert tall["tracks"] == 1, (frame["stem"], tall)
|
||||
assert wide["lh"] is not None and tall["lh"] is not None, (frame["stem"], wide, tall)
|
||||
assert wide["lh"] != tall["lh"], (frame["stem"], wide, tall)
|
||||
assert wide["fs"] == tall["fs"], (frame["stem"], wide, tall)
|
||||
|
||||
|
||||
# ─── static (no-Selenium) guards ─────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize("frame", FRAMES, ids=[f["stem"] for f in FRAMES])
|
||||
def test_p2_rule_declares_line_height_only(frame: dict) -> None:
|
||||
"""IMP-36 P2 invariant — the additive P2 rule body must contain
|
||||
``line-height: clamp(`` and must NOT declare ``font-size``."""
|
||||
body = (FAMILIES / f"{frame['stem']}.html").read_text(encoding="utf-8")
|
||||
m = re.search(frame["p2_re"], body, flags=re.DOTALL)
|
||||
assert m, f"{frame['stem']}: P2 clamp rule not located via /{frame['p2_re']}/"
|
||||
rule_body = m.group(0)
|
||||
assert "line-height:" in rule_body, f"{frame['stem']}: P2 rule missing line-height: {rule_body!r}"
|
||||
assert "clamp(" in rule_body, f"{frame['stem']}: P2 rule missing clamp(: {rule_body!r}"
|
||||
assert "font-size" not in rule_body, (
|
||||
f"{frame['stem']}: P2 rule must not declare font-size — got {rule_body!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_overflow_cascade_order_is_four_tuple() -> None:
|
||||
"""IMP-36 must not alter the Step 17 cascade contract. P1/P2 are CSS-only
|
||||
self-fire (no new Python stage); the 4-tuple stays intact."""
|
||||
assert isinstance(OVERFLOW_CASCADE_ORDER, tuple)
|
||||
assert len(OVERFLOW_CASCADE_ORDER) == 4
|
||||
assert [stage.value for stage in OVERFLOW_CASCADE_ORDER] == [
|
||||
"deterministic",
|
||||
"popup",
|
||||
"ai_repair",
|
||||
"user_override",
|
||||
]
|
||||
@@ -117,3 +117,136 @@ def test_rerender_still_fails_preserved_routes_to_frame_reselect():
|
||||
assert fc["failure_type"] == "rerender_still_fails"
|
||||
nr = route_retry_failure("rerender_still_fails")
|
||||
assert nr["next_proposed_action"] == "frame_reselect"
|
||||
|
||||
|
||||
def test_frame_reselect_insufficient_classifier_emits_from_salvage_steps():
|
||||
"""IMP-35 (#64) u1 — post-frame remeasure contract.
|
||||
|
||||
When the future frame_reselect orchestrator appends a salvage_steps entry
|
||||
with action='frame_reselect', passed=False, and a post-frame remeasure
|
||||
in post_salvage_overflow, the classifier must emit frame_reselect_insufficient
|
||||
via SALVAGE_FAILURE_TYPE_BY_ACTION (q4 = explicit remeasure, not flag
|
||||
carryover). NEXT_ACTION routing (→ details_popup_escalation) landed in u2;
|
||||
see test_frame_reselect_insufficient_routes_to_details_popup_escalation
|
||||
below for the u2-locked routing contract.
|
||||
"""
|
||||
from src.phase_z2_failure_router import (
|
||||
FAILURE_TYPE_DESCRIPTIONS,
|
||||
SALVAGE_FAILURE_TYPE_BY_ACTION,
|
||||
)
|
||||
# Registry contract: the new failure_type + SALVAGE action mapping exist.
|
||||
assert "frame_reselect_insufficient" in FAILURE_TYPE_DESCRIPTIONS
|
||||
assert SALVAGE_FAILURE_TYPE_BY_ACTION["frame_reselect"] == "frame_reselect_insufficient"
|
||||
|
||||
trace = {
|
||||
"retry_attempted": True,
|
||||
"retry_passed": False,
|
||||
"salvage_passed": False,
|
||||
"salvage_steps": [
|
||||
{
|
||||
"action": "frame_reselect",
|
||||
"passed": False,
|
||||
"failure_reason": "post-frame remeasure: overflow persists",
|
||||
"post_salvage_overflow": {"passed": False, "fail_reasons": ["body still clipped"]},
|
||||
}
|
||||
],
|
||||
}
|
||||
fc = classify_retry_failure(trace)
|
||||
assert fc is not None
|
||||
assert fc["failure_type"] == "frame_reselect_insufficient"
|
||||
assert "frame_reselect" in fc["classification_rule"]
|
||||
# q4 contract: classification_rule MUST cite post_salvage_overflow so the
|
||||
# remeasure evidence is auditable from the trace (not a bare action flag).
|
||||
assert "post_salvage_overflow" in fc["classification_rule"]
|
||||
|
||||
|
||||
def test_frame_reselect_without_post_salvage_overflow_is_not_classified_as_insufficient():
|
||||
"""IMP-35 (#64) u1 — q4 negative guard.
|
||||
|
||||
A failed frame_reselect salvage step **without** post_salvage_overflow
|
||||
evidence must NOT be classified as frame_reselect_insufficient. q4 of the
|
||||
Stage 2 plan locks the contract: classification requires an explicit
|
||||
post-frame remeasure payload, not a carried/manual failure flag. Without
|
||||
that evidence the classifier falls through to the lower-priority cases
|
||||
(defensive fallback) so the cascade never escalates onto
|
||||
details_popup_escalation spuriously.
|
||||
"""
|
||||
trace = {
|
||||
"retry_attempted": True,
|
||||
"retry_passed": False,
|
||||
"salvage_passed": False,
|
||||
"salvage_steps": [
|
||||
{
|
||||
"action": "frame_reselect",
|
||||
"passed": False,
|
||||
"failure_reason": "carried failure flag — no remeasure payload",
|
||||
# post_salvage_overflow intentionally absent
|
||||
}
|
||||
],
|
||||
}
|
||||
fc = classify_retry_failure(trace)
|
||||
assert fc is not None
|
||||
assert fc["failure_type"] != "frame_reselect_insufficient", (
|
||||
"frame_reselect without post_salvage_overflow must not classify as "
|
||||
"frame_reselect_insufficient (q4 contract — explicit remeasure, not "
|
||||
"failure-flag carryover)."
|
||||
)
|
||||
# Routing must NOT escalate onto details_popup_escalation when the gate
|
||||
# is not satisfied. u2 landed the frame_reselect_insufficient →
|
||||
# details_popup_escalation mapping; this negative path protects against
|
||||
# premature popup escalation when classifier fell through to a lower-
|
||||
# priority failure type (not frame_reselect_insufficient).
|
||||
nr = route_retry_failure(fc["failure_type"])
|
||||
assert nr["next_proposed_action"] != "details_popup_escalation"
|
||||
|
||||
|
||||
def test_frame_reselect_insufficient_routes_to_details_popup_escalation():
|
||||
"""IMP-35 (#64) u2 — cascade terminal routing contract.
|
||||
|
||||
frame_reselect_insufficient is the deterministic cascade terminal. u2
|
||||
locks the NEXT_ACTION_BY_FAILURE row so the failure_router escalates onto
|
||||
details_popup_escalation when (and only when) u1's q4-gated classifier
|
||||
has emitted the insufficient verdict. Implementation status is reported
|
||||
as MISSING here because the executor stub + MISSING→IMPLEMENTED flip
|
||||
live in src/phase_z2_router.py (u3); the failure_router surface must not
|
||||
claim implementation it does not own.
|
||||
"""
|
||||
# Direct mapping (u2 lock)
|
||||
assert NEXT_ACTION_BY_FAILURE["frame_reselect_insufficient"] == (
|
||||
"details_popup_escalation"
|
||||
)
|
||||
# u2 advertises cascade terminal as MISSING; u3 flips it on the router
|
||||
# surface (separate file). Until u3 lands, failure_router must report
|
||||
# MISSING to avoid premature "popup ready" claims.
|
||||
assert NEXT_ACTION_IMPLEMENTATION_STATUS["details_popup_escalation"] == "MISSING"
|
||||
|
||||
nr = route_retry_failure("frame_reselect_insufficient")
|
||||
assert nr["next_proposed_action"] == "details_popup_escalation"
|
||||
assert nr["next_action_implementation_status"] == "MISSING"
|
||||
assert "details_popup_escalation" in (nr["next_action_rationale"] or "")
|
||||
|
||||
# End-to-end via the classifier path: q4 contract satisfied →
|
||||
# enrichment composes the cascade terminal proposal onto the trace.
|
||||
trace = {
|
||||
"retry_attempted": True,
|
||||
"retry_passed": False,
|
||||
"salvage_passed": False,
|
||||
"salvage_steps": [
|
||||
{
|
||||
"action": "frame_reselect",
|
||||
"passed": False,
|
||||
"failure_reason": "post-frame remeasure: overflow persists",
|
||||
"post_salvage_overflow": {
|
||||
"passed": False,
|
||||
"fail_reasons": ["body still clipped"],
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
enrich_retry_trace_with_failure_classification(trace)
|
||||
assert trace["failure_classification"]["failure_type"] == (
|
||||
"frame_reselect_insufficient"
|
||||
)
|
||||
assert trace["next_action_proposal"]["next_proposed_action"] == (
|
||||
"details_popup_escalation"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
"""IMP-35 (#64) u7 — Pipeline composer -> render_slide wiring tests.
|
||||
|
||||
Stage 2 wiring contract (unit u7):
|
||||
u6 (``bind_popup_display_strategy`` in ``src/phase_z2_composition.py``)
|
||||
produced the composition-side binding from the unit-side marker stamped
|
||||
by Step 17 POPUP gate (u5). u7 is the pipeline composer side: it
|
||||
surfaces three uniform render-context field names per zone in
|
||||
``zones_data`` so slide_base.html (u8) sees the same shape on every
|
||||
zone regardless of whether the unit went through the POPUP gate:
|
||||
|
||||
has_popup : bool — escalation marker echo
|
||||
popup_html : str — popup body source (FULL ``raw_content``
|
||||
per u6 ``popup_body_source``; u8 wraps
|
||||
it in ``<details>/<summary>``). ``None``
|
||||
when has_popup=False.
|
||||
preview_text : str — px-budgeted line-boundary excerpt of
|
||||
``raw_content`` shown in the body /
|
||||
inline_preview slot. ``None`` when
|
||||
has_popup=False. Popup body retains
|
||||
the FULL original so the excerpt loses
|
||||
no information.
|
||||
|
||||
Key invariants this file locks:
|
||||
1. ``compose_zone_popup_payload`` returns the three uniform field
|
||||
names plus the full u6 binding under ``popup_binding`` for
|
||||
downstream debug.
|
||||
2. has_popup=False units bind to the no-popup branch — popup_html
|
||||
and preview_text are both ``None``, popup_binding echoes u6
|
||||
``inline_full`` strategy.
|
||||
3. has_popup=True units bind to the popup branch — popup_html ==
|
||||
u6 ``popup_body_source`` == FULL ``raw_content`` (MDX 원문
|
||||
무손실 보존, 오답노트 #5 / IMPROVEMENT-REDESIGN.md §3.6 line 110),
|
||||
and preview_text is a deterministic line-boundary excerpt.
|
||||
4. ``compute_popup_preview_text`` is a CUT, never a rewrite —
|
||||
``raw_content.startswith(preview_text)`` when the content
|
||||
exceeds the container budget; otherwise preview == full content.
|
||||
5. Line-boundary cut never trims inside a line (no mid-CJK-word cut).
|
||||
6. Non-positive container budget falls back to the full content
|
||||
(no spurious truncation when telemetry is missing — popup gate
|
||||
would not have fired without a real budget anyway).
|
||||
7. AI isolation contract — pure deterministic helpers; no anthropic
|
||||
import, no route_ai_fallback path.
|
||||
|
||||
Cross-references:
|
||||
- u3 router stub (``plan_details_popup_escalation``):
|
||||
tests/phase_z2/test_phase_z2_router_popup.py
|
||||
- u4 api_gated split-decision contract:
|
||||
tests/phase_z2_ai_fallback/test_step17.py
|
||||
- u5 Step 17 POPUP gate (stamps the marker u7 reads via u6):
|
||||
tests/phase_z2/test_phase_z2_step17_popup_gate.py
|
||||
- u6 composition popup binding (input to u7):
|
||||
tests/phase_z2/test_composition_popup_strategy.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_composition import (
|
||||
DISPLAY_STRATEGIES,
|
||||
POPUP_BINDING_ESCALATED_STRATEGY_ID,
|
||||
POPUP_BINDING_NO_POPUP_STRATEGY_ID,
|
||||
POPUP_PREVIEW_DEFAULT_LINE_HEIGHT_PX,
|
||||
compose_zone_popup_payload,
|
||||
compute_popup_preview_text,
|
||||
)
|
||||
|
||||
|
||||
# ─── Synthetic stubs ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class _StubUnit:
|
||||
"""Minimal duck-typed CompositionUnit for u7 wiring tests.
|
||||
|
||||
Mirrors only the fields ``compose_zone_popup_payload`` reads via
|
||||
getattr — keeps the test independent of CompositionUnit dataclass
|
||||
evolution (IMP-30 / IMP-48 axis additions).
|
||||
"""
|
||||
|
||||
raw_content: str = "MOCK_ORIGINAL_CONTENT"
|
||||
has_popup: bool = False
|
||||
popup_escalation_plan: Optional[dict] = None
|
||||
|
||||
|
||||
def _stub_popup_plan(category: str = "structural_major_overflow") -> dict:
|
||||
"""Mirror the shape ``plan_details_popup_escalation`` returns on a
|
||||
feasible escalation. u7 echoes this verbatim via u6 binding — no
|
||||
field is consumed here other than as a traceable payload."""
|
||||
return {
|
||||
"action": "details_popup_escalation",
|
||||
"stub": True,
|
||||
"feasible": True,
|
||||
"category": category,
|
||||
"needs_split_decision": True,
|
||||
"rationale": "MOCK_RATIONALE",
|
||||
"mapping_source": "IMP-35 u3 plan_details_popup_escalation stub",
|
||||
}
|
||||
|
||||
|
||||
# ─── compose_zone_popup_payload — uniform render-context surface ─────
|
||||
|
||||
|
||||
def test_payload_returns_uniform_field_names():
|
||||
"""u7 — every payload (popup or not) MUST surface the same four
|
||||
field names so slide_base.html (u8) does not have to branch on the
|
||||
presence of popup fields. Field uniformity is the wiring contract."""
|
||||
payload = compose_zone_popup_payload(_StubUnit(has_popup=False), 200)
|
||||
assert set(payload.keys()) == {
|
||||
"has_popup",
|
||||
"popup_html",
|
||||
"preview_text",
|
||||
"popup_binding",
|
||||
}
|
||||
payload_popup = compose_zone_popup_payload(
|
||||
_StubUnit(
|
||||
raw_content="MOCK_BODY",
|
||||
has_popup=True,
|
||||
popup_escalation_plan=_stub_popup_plan(),
|
||||
),
|
||||
200,
|
||||
)
|
||||
assert set(payload_popup.keys()) == {
|
||||
"has_popup",
|
||||
"popup_html",
|
||||
"preview_text",
|
||||
"popup_binding",
|
||||
}
|
||||
|
||||
|
||||
def test_payload_has_popup_false_returns_no_popup_branch():
|
||||
"""u7 — has_popup=False units bind to the no-popup branch: both
|
||||
popup_html and preview_text are None, popup_binding echoes the u6
|
||||
``inline_full`` strategy."""
|
||||
unit = _StubUnit(raw_content="MOCK_BODY", has_popup=False)
|
||||
payload = compose_zone_popup_payload(unit, 200)
|
||||
assert payload["has_popup"] is False
|
||||
assert payload["popup_html"] is None
|
||||
assert payload["preview_text"] is None
|
||||
binding = payload["popup_binding"]
|
||||
assert isinstance(binding, dict)
|
||||
assert binding["display_strategy"] == POPUP_BINDING_NO_POPUP_STRATEGY_ID
|
||||
assert binding["has_popup"] is False
|
||||
|
||||
|
||||
def test_payload_default_when_unit_lacks_has_popup_attr_at_all():
|
||||
"""u7 defensive default — units that lack the has_popup attribute
|
||||
entirely (e.g., third-party duck-typed stubs) bind to the no-popup
|
||||
path through the getattr() default branch (mirrors u6 test)."""
|
||||
|
||||
class _BareUnit:
|
||||
raw_content = "MOCK_BODY"
|
||||
|
||||
payload = compose_zone_popup_payload(_BareUnit(), 200)
|
||||
assert payload["has_popup"] is False
|
||||
assert payload["popup_html"] is None
|
||||
assert payload["preview_text"] is None
|
||||
|
||||
|
||||
def test_payload_has_popup_true_popup_html_is_full_raw_content_verbatim():
|
||||
"""u7 — popup_html MUST be the FULL raw_content verbatim. u6
|
||||
popup_body_source already locks this at the binding layer; u7
|
||||
must NOT re-shape, trim, or HTML-escape on the way to the zone
|
||||
dict. MDX 원문 무손실 보존 (오답노트 #5)."""
|
||||
full_text = (
|
||||
"## MOCK_SECTION_TITLE\n\n"
|
||||
"- bullet one with **bold** marker\n"
|
||||
"- bullet two with *italic* marker\n"
|
||||
"- bullet three trailing\n"
|
||||
"\n"
|
||||
"| col_a | col_b |\n| --- | --- |\n| MOCK | DATA |\n"
|
||||
)
|
||||
unit = _StubUnit(
|
||||
raw_content=full_text,
|
||||
has_popup=True,
|
||||
popup_escalation_plan=_stub_popup_plan(),
|
||||
)
|
||||
payload = compose_zone_popup_payload(unit, container_height_px=200)
|
||||
assert payload["popup_html"] == full_text
|
||||
assert len(payload["popup_html"]) == len(full_text)
|
||||
|
||||
|
||||
def test_payload_has_popup_true_preview_text_is_deterministic_line_cut():
|
||||
"""u7 — preview_text MUST be a deterministic line-boundary excerpt
|
||||
of raw_content. With container_height_px=36 and the default
|
||||
line metric (18 px), the budget = 2 lines."""
|
||||
full_text = "line1\nline2\nline3\nline4\nline5"
|
||||
unit = _StubUnit(
|
||||
raw_content=full_text,
|
||||
has_popup=True,
|
||||
popup_escalation_plan=_stub_popup_plan(),
|
||||
)
|
||||
payload = compose_zone_popup_payload(unit, container_height_px=36)
|
||||
assert payload["preview_text"] == "line1\nline2"
|
||||
# popup body still holds the FULL original — no information loss.
|
||||
assert payload["popup_html"] == full_text
|
||||
|
||||
|
||||
def test_payload_popup_binding_echoes_full_u6_output():
|
||||
"""u7 — popup_binding MUST echo the full u6 output so debug
|
||||
consumers can read display_strategy / detail_trigger / strategy_meta
|
||||
/ popup_escalation_plan without re-reading the yaml."""
|
||||
plan = _stub_popup_plan("tabular_overflow")
|
||||
unit = _StubUnit(
|
||||
raw_content="MOCK_BODY",
|
||||
has_popup=True,
|
||||
popup_escalation_plan=plan,
|
||||
)
|
||||
payload = compose_zone_popup_payload(unit, container_height_px=200)
|
||||
binding = payload["popup_binding"]
|
||||
assert binding["display_strategy"] == POPUP_BINDING_ESCALATED_STRATEGY_ID
|
||||
assert binding["has_popup"] is True
|
||||
assert binding["popup_escalation_plan"] is plan
|
||||
# detail_trigger comes from the catalog entry's detail_trigger block.
|
||||
catalog_trigger = (
|
||||
DISPLAY_STRATEGIES[POPUP_BINDING_ESCALATED_STRATEGY_ID]
|
||||
.get("detail_trigger") or {}
|
||||
)
|
||||
assert binding["detail_trigger"] == {
|
||||
"placement": catalog_trigger.get("placement"),
|
||||
"label": catalog_trigger.get("label"),
|
||||
}
|
||||
|
||||
|
||||
# ─── compute_popup_preview_text — deterministic line-budget cut ──────
|
||||
|
||||
|
||||
def test_preview_returns_empty_string_when_raw_content_is_empty():
|
||||
"""u7 — empty raw_content returns empty preview; no IndexError /
|
||||
TypeError on the splitlines path."""
|
||||
assert compute_popup_preview_text("", container_height_px=200) == ""
|
||||
|
||||
|
||||
def test_preview_returns_full_content_when_it_fits_budget():
|
||||
"""u7 — when the content already fits the container budget, the
|
||||
preview equals the full content (no spurious truncation)."""
|
||||
full_text = "line1\nline2\nline3"
|
||||
# budget = 200 / 18 = 11 lines → fits 3 lines easily.
|
||||
assert (
|
||||
compute_popup_preview_text(full_text, container_height_px=200)
|
||||
== full_text
|
||||
)
|
||||
|
||||
|
||||
def test_preview_truncates_to_line_budget_when_content_overflows():
|
||||
"""u7 — when the content exceeds the budget, the preview is the
|
||||
leading N lines that fit, joined verbatim with '\\n'. Never trims
|
||||
inside a line (no mid-CJK-word cut)."""
|
||||
full_text = "L1\nL2\nL3\nL4\nL5\nL6"
|
||||
# budget = 54 / 18 = 3 lines.
|
||||
assert (
|
||||
compute_popup_preview_text(full_text, container_height_px=54)
|
||||
== "L1\nL2\nL3"
|
||||
)
|
||||
|
||||
|
||||
def test_preview_is_a_prefix_of_raw_content_when_truncated():
|
||||
"""u7 — invariant: a truncated preview is a CUT, never a rewrite.
|
||||
raw_content.startswith(preview_text) MUST hold when truncation
|
||||
happened. Locks the line-boundary semantics — preview is always a
|
||||
leading-substring of raw_content (modulo \\n re-join, which matches
|
||||
splitlines round-trip)."""
|
||||
full_text = (
|
||||
"- 첫 번째 항목 (CJK)\n"
|
||||
"- 두 번째 항목 (CJK)\n"
|
||||
"- 세 번째 항목 (CJK)\n"
|
||||
"- 네 번째 항목 (CJK)\n"
|
||||
"- 다섯 번째 항목 (CJK)\n"
|
||||
)
|
||||
preview = compute_popup_preview_text(full_text, container_height_px=54)
|
||||
# 3 lines budget. preview ends at the third line boundary.
|
||||
assert preview == "- 첫 번째 항목 (CJK)\n- 두 번째 항목 (CJK)\n- 세 번째 항목 (CJK)"
|
||||
# Leading-substring guarantee — raw_content starts with preview verbatim.
|
||||
assert full_text.startswith(preview)
|
||||
|
||||
|
||||
def test_preview_never_returns_empty_string_when_budget_floors_to_zero():
|
||||
"""u7 — if container_height_px is positive but smaller than one
|
||||
line, the floor would yield 0 lines. The helper clamps max_lines
|
||||
to at least 1 so the preview always contains at least the first
|
||||
line (otherwise the popup wrapper would have an empty preview
|
||||
slot — UX degradation)."""
|
||||
full_text = "first line\nsecond line"
|
||||
# budget = 5 / 18 = 0 floor → clamp to 1.
|
||||
assert (
|
||||
compute_popup_preview_text(full_text, container_height_px=5)
|
||||
== "first line"
|
||||
)
|
||||
|
||||
|
||||
def test_preview_falls_back_to_full_content_when_budget_non_positive():
|
||||
"""u7 — non-positive container_height_px (0 or negative) returns
|
||||
the full content unchanged. u5 POPUP gate would not have fired
|
||||
without a real budget, so this branch is only reachable for
|
||||
non-popup units (where preview is unused). No spurious truncation."""
|
||||
full_text = "line1\nline2\nline3"
|
||||
assert (
|
||||
compute_popup_preview_text(full_text, container_height_px=0)
|
||||
== full_text
|
||||
)
|
||||
assert (
|
||||
compute_popup_preview_text(full_text, container_height_px=-100)
|
||||
== full_text
|
||||
)
|
||||
|
||||
|
||||
def test_preview_falls_back_to_full_content_when_line_height_non_positive():
|
||||
"""u7 defensive guard — non-positive line_height_px override would
|
||||
divide-by-zero. Helper falls back to the full content unchanged
|
||||
(no spurious truncation, no exception)."""
|
||||
full_text = "line1\nline2\nline3"
|
||||
assert (
|
||||
compute_popup_preview_text(
|
||||
full_text, container_height_px=200, line_height_px=0
|
||||
)
|
||||
== full_text
|
||||
)
|
||||
|
||||
|
||||
def test_preview_default_line_height_constant_matches_slide_base_body_metric():
|
||||
"""u7 no-hardcoding lock — the default line height constant is a
|
||||
parametric default (not a magic literal). Locked at 18 px to match
|
||||
slide_base.html ``--font-body`` (11 px) * line-height (1.6) + guard.
|
||||
If slide_base.html body metric changes, this test should fail and
|
||||
force an explicit re-derivation."""
|
||||
assert POPUP_PREVIEW_DEFAULT_LINE_HEIGHT_PX == 18.0
|
||||
|
||||
|
||||
def test_preview_accepts_line_height_override():
|
||||
"""u7 — line_height_px is overridable so a tighter-font frame can
|
||||
pass a smaller line metric. Locks the parametric contract."""
|
||||
full_text = "L1\nL2\nL3\nL4\nL5\nL6"
|
||||
# budget = 30 / 10 = 3 lines under override.
|
||||
assert (
|
||||
compute_popup_preview_text(
|
||||
full_text, container_height_px=30, line_height_px=10.0
|
||||
)
|
||||
== "L1\nL2\nL3"
|
||||
)
|
||||
|
||||
|
||||
# ─── Integration: pipeline composer attaches popup payload to zone ────
|
||||
|
||||
|
||||
def test_pipeline_zone_dict_includes_popup_fields():
|
||||
"""u7 — the pipeline composer (src/phase_z2_pipeline.py) calls
|
||||
``compose_zone_popup_payload(unit, min_height_px)`` per-unit and
|
||||
spreads the four wiring keys into the zone dict via
|
||||
``zones_data.append({..., **payload})``. This test rebuilds the
|
||||
spread surface against a synthetic unit + container budget to lock
|
||||
the integration contract without booting the entire pipeline."""
|
||||
unit = _StubUnit(
|
||||
raw_content="line1\nline2\nline3\nline4\nline5\nline6",
|
||||
has_popup=True,
|
||||
popup_escalation_plan=_stub_popup_plan(),
|
||||
)
|
||||
base_zone = {
|
||||
"position": "single",
|
||||
"template_id": "MOCK_FRAME",
|
||||
"slot_payload": {},
|
||||
"content_weight": {"score": 0},
|
||||
"min_height_px": 54, # 3 lines budget at default metric.
|
||||
"assignment_source": "MOCK",
|
||||
"section_assignment_override": False,
|
||||
"provisional": False,
|
||||
}
|
||||
popup_payload = compose_zone_popup_payload(unit, base_zone["min_height_px"])
|
||||
zone = {**base_zone, **popup_payload}
|
||||
assert zone["has_popup"] is True
|
||||
assert zone["popup_html"] == unit.raw_content
|
||||
assert zone["preview_text"] == "line1\nline2\nline3"
|
||||
assert isinstance(zone["popup_binding"], dict)
|
||||
# Spread MUST NOT clobber the pre-existing zone fields — popup
|
||||
# payload keys are disjoint from the base zone dict keys.
|
||||
assert zone["position"] == "single"
|
||||
assert zone["template_id"] == "MOCK_FRAME"
|
||||
assert zone["min_height_px"] == 54
|
||||
|
||||
|
||||
def test_pipeline_zone_dict_no_popup_keys_are_uniform_across_branches():
|
||||
"""u7 — the pipeline composer has three zones_data.append sites
|
||||
(empty-shell unit, main renderable unit, unrenderable empty plan
|
||||
record). All three MUST stamp the same four wiring keys with
|
||||
consistent shape so slide_base.html (u8) does not have to branch
|
||||
on key presence. This test locks the no-popup defaults stamped by
|
||||
the unrenderable empty plan branch."""
|
||||
no_popup_defaults = {
|
||||
"has_popup": False,
|
||||
"popup_html": None,
|
||||
"preview_text": None,
|
||||
"popup_binding": None, # unrenderable branch — no unit, no u6 binding.
|
||||
}
|
||||
# And compose_zone_popup_payload for a no-popup unit MUST surface
|
||||
# the same three render-context keys (popup_binding differs — it
|
||||
# holds the u6 ``inline_full`` echo when there IS a unit).
|
||||
payload = compose_zone_popup_payload(_StubUnit(has_popup=False), 200)
|
||||
for k in ("has_popup", "popup_html", "preview_text"):
|
||||
assert no_popup_defaults[k] == payload[k]
|
||||
|
||||
|
||||
# ─── AI isolation contract (structural import lock) ─────────────────
|
||||
|
||||
|
||||
def test_composition_module_does_not_import_anthropic_or_route_ai_fallback():
|
||||
"""u7 — compose_zone_popup_payload + compute_popup_preview_text MUST
|
||||
stay AI-free. Mirrors the import-isolation pattern locked by u4/u5
|
||||
tests. composition module is allowed to consult the catalog and
|
||||
unit state, never the Anthropic SDK / route_ai_fallback path."""
|
||||
import src.phase_z2_composition as composition_module
|
||||
|
||||
source = composition_module.__file__
|
||||
assert source is not None
|
||||
with open(source, encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
assert "import anthropic" not in text
|
||||
assert "from anthropic" not in text
|
||||
assert "route_ai_fallback" not in text
|
||||
@@ -0,0 +1,209 @@
|
||||
"""IMP-35 (#64) u3 — router popup escalation stub tests.
|
||||
|
||||
Stage 2 binding contract (unit u3):
|
||||
- `details_popup_escalation` MISSING → IMPLEMENTED on the *primary* router
|
||||
surface (`src/phase_z2_router.py`). Downstream surfaces remain decoupled:
|
||||
* `src/phase_z2_failure_router.py` keeps the cascade-terminal entry as
|
||||
MISSING until u5 wires the Step 17 POPUP gate executor.
|
||||
* `src/phase_z2_ai_fallback/step17.py` (u4) binds the AI split-decision
|
||||
contract that the stub flags via `needs_split_decision=True`.
|
||||
- `plan_details_popup_escalation(classification)` stub is the deterministic
|
||||
executor surface — no AI call, no HTML/CSS/MDX mutation. It emits the
|
||||
canonical popup_escalation_plan marker that u4/u5 consume.
|
||||
- The two ACTION_BY_CATEGORY rows that map onto `details_popup_escalation`
|
||||
— `structural_major_overflow` and `tabular_overflow` — must route to the
|
||||
cascade terminal via `route_action` / `route_fit_classification`.
|
||||
|
||||
Cross-references:
|
||||
- u1 (frame_reselect_insufficient classifier gate, q4 contract):
|
||||
tests/phase_z2/test_phase_z2_failure_router_cascade.py::
|
||||
test_frame_reselect_insufficient_classifier_emits_from_salvage_steps
|
||||
tests/phase_z2/test_phase_z2_failure_router_cascade.py::
|
||||
test_frame_reselect_without_post_salvage_overflow_is_not_classified_as_insufficient
|
||||
- u2 (failure_router cascade terminal row + MISSING status lock):
|
||||
tests/phase_z2/test_phase_z2_failure_router_cascade.py::
|
||||
test_frame_reselect_insufficient_routes_to_details_popup_escalation
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from src.phase_z2_router import (
|
||||
ACTION_BY_CATEGORY,
|
||||
ACTION_IMPLEMENTATION_STATUS,
|
||||
ACTION_RATIONALE,
|
||||
POPUP_ESCALATION_CATEGORIES,
|
||||
plan_details_popup_escalation,
|
||||
route_action,
|
||||
route_fit_classification,
|
||||
)
|
||||
|
||||
|
||||
def test_action_implementation_status_details_popup_escalation_flipped_to_implemented():
|
||||
"""IMP-35 u3 — primary router surface flip.
|
||||
|
||||
`ACTION_IMPLEMENTATION_STATUS["details_popup_escalation"]` was MISSING
|
||||
prior to u3 (Stage 2 binding). u3 lands the deterministic
|
||||
`plan_details_popup_escalation` stub on the router surface, so the
|
||||
status must read IMPLEMENTED here. u5 owns the matching flip on the
|
||||
failure_router surface — until u5 lands, the failure_router still
|
||||
reports MISSING (locked by the u2 test).
|
||||
"""
|
||||
assert (
|
||||
ACTION_IMPLEMENTATION_STATUS["details_popup_escalation"] == "IMPLEMENTED"
|
||||
), (
|
||||
"u3 must flip the primary router surface from MISSING to IMPLEMENTED. "
|
||||
"The failure_router companion surface stays MISSING until u5 (see u2 "
|
||||
"test test_frame_reselect_insufficient_routes_to_details_popup_escalation)."
|
||||
)
|
||||
|
||||
|
||||
def test_structural_major_overflow_routes_to_details_popup_escalation_implemented():
|
||||
"""IMP-35 u3 — `structural_major_overflow` is one of the two
|
||||
ACTION_BY_CATEGORY rows that map onto the cascade terminal. After u3
|
||||
flips the status, `route_action` must report IMPLEMENTED for that
|
||||
routing.
|
||||
"""
|
||||
assert ACTION_BY_CATEGORY["structural_major_overflow"] == "details_popup_escalation"
|
||||
routing = route_action("structural_major_overflow")
|
||||
assert routing["proposed_action"] == "details_popup_escalation"
|
||||
assert routing["implementation_status"] == "IMPLEMENTED"
|
||||
assert routing["mapping_source"] == "spec §4 ACTION_BY_CATEGORY"
|
||||
# rationale text must remain non-empty so trace explains *why* this
|
||||
# category escalates (downstream debugging hinges on it).
|
||||
assert (routing["rationale"] or "").strip(), (
|
||||
"rationale text must be present so the router trace explains why "
|
||||
"structural_major_overflow escalates onto the popup terminal."
|
||||
)
|
||||
|
||||
|
||||
def test_tabular_overflow_routes_to_details_popup_escalation_implemented():
|
||||
"""IMP-35 u3 — `tabular_overflow` is the second ACTION_BY_CATEGORY row
|
||||
that maps onto the cascade terminal. Same surface flip applies.
|
||||
"""
|
||||
assert ACTION_BY_CATEGORY["tabular_overflow"] == "details_popup_escalation"
|
||||
routing = route_action("tabular_overflow")
|
||||
assert routing["proposed_action"] == "details_popup_escalation"
|
||||
assert routing["implementation_status"] == "IMPLEMENTED"
|
||||
|
||||
|
||||
def test_popup_escalation_categories_is_derived_from_action_by_category():
|
||||
"""IMP-35 u3 — POPUP_ESCALATION_CATEGORIES must be the *derived*
|
||||
projection of ACTION_BY_CATEGORY (single source of truth). If a future
|
||||
edit changes which categories map onto details_popup_escalation, this
|
||||
constant must follow automatically; the stub guard relies on it.
|
||||
"""
|
||||
expected = frozenset(
|
||||
category
|
||||
for category, action in ACTION_BY_CATEGORY.items()
|
||||
if action == "details_popup_escalation"
|
||||
)
|
||||
assert POPUP_ESCALATION_CATEGORIES == expected
|
||||
# Sanity: at u3 landing time, the two locked categories are present.
|
||||
assert "structural_major_overflow" in POPUP_ESCALATION_CATEGORIES
|
||||
assert "tabular_overflow" in POPUP_ESCALATION_CATEGORIES
|
||||
|
||||
|
||||
def test_plan_details_popup_escalation_returns_feasible_plan_for_structural_major():
|
||||
"""IMP-35 u3 — accepted category produces a feasible popup escalation
|
||||
plan with the canonical stub shape. u4 (AI hook) reads
|
||||
`needs_split_decision=True`; u5 (POPUP gate executor) reads
|
||||
`feasible=True` + `category` + `rationale` to compose the
|
||||
popup_html / preview_text / has_popup payload.
|
||||
"""
|
||||
plan = plan_details_popup_escalation({"category": "structural_major_overflow"})
|
||||
assert plan["action"] == "details_popup_escalation"
|
||||
assert plan["feasible"] is True
|
||||
assert plan["stub"] is True
|
||||
assert plan["needs_split_decision"] is True
|
||||
assert plan["category"] == "structural_major_overflow"
|
||||
assert plan["rationale"] == ACTION_RATIONALE["structural_major_overflow"]
|
||||
assert plan["mapping_source"] == "IMP-35 u3 plan_details_popup_escalation stub"
|
||||
# No side-effect markers: stub must not pretend to have done downstream work.
|
||||
for forbidden_key in ("popup_html", "preview_text", "has_popup", "ai_decision"):
|
||||
assert forbidden_key not in plan, (
|
||||
f"u3 stub must NOT carry {forbidden_key!r} — that payload is "
|
||||
f"composed downstream (u4 AI hook + u5 POPUP gate executor)."
|
||||
)
|
||||
|
||||
|
||||
def test_plan_details_popup_escalation_returns_feasible_plan_for_tabular():
|
||||
"""IMP-35 u3 — tabular_overflow is the second accepted category."""
|
||||
plan = plan_details_popup_escalation({"category": "tabular_overflow"})
|
||||
assert plan["feasible"] is True
|
||||
assert plan["stub"] is True
|
||||
assert plan["needs_split_decision"] is True
|
||||
assert plan["category"] == "tabular_overflow"
|
||||
assert plan["rationale"] == ACTION_RATIONALE["tabular_overflow"]
|
||||
|
||||
|
||||
def test_plan_details_popup_escalation_rejects_non_popup_category():
|
||||
"""IMP-35 u3 — defensive guard. Calling the stub with a category that
|
||||
does not map onto `details_popup_escalation` in ACTION_BY_CATEGORY must
|
||||
NOT silently popup-escalate. The stub returns `feasible=False` with a
|
||||
`failure_reason` citing the accepted categories so the caller can
|
||||
surface the misuse in trace.
|
||||
"""
|
||||
plan = plan_details_popup_escalation({"category": "minor_overflow"})
|
||||
assert plan["action"] == "details_popup_escalation"
|
||||
assert plan["feasible"] is False
|
||||
assert plan["stub"] is True
|
||||
assert plan["needs_split_decision"] is False
|
||||
assert plan["category"] == "minor_overflow"
|
||||
assert "failure_reason" in plan
|
||||
assert "ACTION_BY_CATEGORY" in plan["failure_reason"]
|
||||
|
||||
|
||||
def test_plan_details_popup_escalation_rejects_missing_category():
|
||||
"""IMP-35 u3 — defensive guard for malformed classification dict
|
||||
(no `category` key). Stub must not raise; it must return a
|
||||
`feasible=False` plan so the caller never crashes the cascade.
|
||||
"""
|
||||
plan = plan_details_popup_escalation({})
|
||||
assert plan["feasible"] is False
|
||||
assert plan["needs_split_decision"] is False
|
||||
assert plan["category"] is None
|
||||
assert "failure_reason" in plan
|
||||
|
||||
plan_none = plan_details_popup_escalation(None) # type: ignore[arg-type]
|
||||
assert plan_none["feasible"] is False
|
||||
assert plan_none["category"] is None
|
||||
|
||||
|
||||
def test_route_fit_classification_carries_popup_escalation_to_implemented_summary():
|
||||
"""IMP-35 u3 — end-to-end via the fit_classification → router path.
|
||||
|
||||
When a fit_classification reports a `structural_major_overflow` row,
|
||||
`route_fit_classification` must:
|
||||
- attach `proposed_action == "details_popup_escalation"` onto the
|
||||
classification entry
|
||||
- report IMPLEMENTED in `implementation_status_summary`
|
||||
- NOT list `details_popup_escalation` in
|
||||
`missing_actions_pending_impl` (status is now IMPLEMENTED).
|
||||
"""
|
||||
fit_classification = {
|
||||
"visual_check_passed": False,
|
||||
"classifications": [
|
||||
{
|
||||
"source": "body",
|
||||
"zone_position": "bottom",
|
||||
"category": "structural_major_overflow",
|
||||
},
|
||||
{
|
||||
"source": "table:summary",
|
||||
"zone_position": "bottom",
|
||||
"category": "tabular_overflow",
|
||||
},
|
||||
],
|
||||
}
|
||||
summary = route_fit_classification(fit_classification)
|
||||
assert summary["router_active"] is True
|
||||
assert summary["routed_count"] == 2
|
||||
assert "details_popup_escalation" in summary["proposed_actions_summary"]
|
||||
# Both rows escalated onto the popup terminal — status summary must
|
||||
# therefore reflect 2 IMPLEMENTED counts (no MISSING) for u3.
|
||||
assert summary["implementation_status_summary"].get("IMPLEMENTED") == 2
|
||||
assert "details_popup_escalation" not in summary["missing_actions_pending_impl"]
|
||||
# Per-row enrichment carries the new IMPLEMENTED status onto the
|
||||
# classification entries (in-place mutation contract preserved).
|
||||
for cls in fit_classification["classifications"]:
|
||||
assert cls["proposed_action"] == "details_popup_escalation"
|
||||
assert cls["proposed_action_implementation_status"] == "IMPLEMENTED"
|
||||
@@ -0,0 +1,551 @@
|
||||
"""IMP-35 (#64) u5 — Step 17 deterministic POPUP gate executor tests.
|
||||
|
||||
Stage 2 binding contract (unit u5):
|
||||
- ``run_step17_popup_gate`` is the deterministic cascade-terminal gate
|
||||
that stamps ``popup_escalation_plan`` + idempotent ``has_popup``
|
||||
marker per unit. Runs AFTER the DETERMINISTIC stage exhausts and
|
||||
BEFORE the AI_REPAIR cascade stage (canonical OVERFLOW_CASCADE_ORDER).
|
||||
- No AI call: deterministic-with-data. ``ai_called=False`` on every
|
||||
record. The u4 ``gather_step17_popup_split_decisions`` AI hook is
|
||||
a SEPARATE cascade-stage surface (api_gated) and is NOT invoked
|
||||
from this gate.
|
||||
- q1 (per-unit), q2 (idempotent via ``has_popup``), q3 (deterministic
|
||||
split from container px telemetry — preview / popup body composed
|
||||
downstream in u6 / u7).
|
||||
|
||||
Cross-references:
|
||||
- u3 router stub (``plan_details_popup_escalation``) — accepted
|
||||
categories ``structural_major_overflow`` / ``tabular_overflow``:
|
||||
tests/phase_z2/test_phase_z2_router_popup.py
|
||||
- u1 + u2 cascade-terminal classifier + NEXT_ACTION row:
|
||||
tests/phase_z2/test_phase_z2_failure_router_cascade.py
|
||||
- u4 api_gated split-decision contract:
|
||||
tests/phase_z2_ai_fallback/test_step17.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from src.phase_z2_ai_fallback.step17 import (
|
||||
STEP17_POPUP_GATE_ESCALATED_REASON,
|
||||
STEP17_POPUP_GATE_IDEMPOTENT_SHORT_CIRCUIT_REASON,
|
||||
STEP17_POPUP_GATE_INFEASIBLE_CATEGORY_REASON,
|
||||
STEP17_POPUP_GATE_NO_CLASSIFICATION_REASON,
|
||||
OverflowCascadeStage,
|
||||
run_step17_popup_gate,
|
||||
)
|
||||
from src.phase_z2_router import plan_details_popup_escalation
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeUnit:
|
||||
label: str | None = "restructure"
|
||||
provisional: bool = True
|
||||
frame_template_id: str = "tmpl"
|
||||
source_section_ids: list[str] = field(default_factory=lambda: ["s1"])
|
||||
has_popup: bool = False
|
||||
|
||||
|
||||
_ROUTE_HINTS: dict[str | None, str | None] = {
|
||||
"use_as_is": "direct_render",
|
||||
"light_edit": "deterministic_minor_adjustment",
|
||||
"restructure": "ai_adaptation_required",
|
||||
"reject": "design_reference_only",
|
||||
None: None,
|
||||
}
|
||||
|
||||
|
||||
def _route_for_label(label: str | None) -> str | None:
|
||||
return _ROUTE_HINTS.get(label)
|
||||
|
||||
|
||||
def _always_popup_classification(category: str = "structural_major_overflow"):
|
||||
"""Helper: classification_for_unit fake returning a popup category."""
|
||||
cls = {"category": category, "zone_position": "top"}
|
||||
return lambda _unit: cls
|
||||
|
||||
|
||||
def _no_classification(_unit):
|
||||
"""Helper: classification_for_unit fake returning None (no overflow)."""
|
||||
return None
|
||||
|
||||
|
||||
# ─── Reason constants ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_popup_gate_reason_constants_are_distinct_and_stable():
|
||||
"""u5 — gate_status / skip_reason enum constants must be machine-readable
|
||||
and disjoint. Consumers parse the trace by these strings."""
|
||||
reasons = {
|
||||
STEP17_POPUP_GATE_ESCALATED_REASON,
|
||||
STEP17_POPUP_GATE_IDEMPOTENT_SHORT_CIRCUIT_REASON,
|
||||
STEP17_POPUP_GATE_INFEASIBLE_CATEGORY_REASON,
|
||||
STEP17_POPUP_GATE_NO_CLASSIFICATION_REASON,
|
||||
}
|
||||
assert len(reasons) == 4
|
||||
assert STEP17_POPUP_GATE_ESCALATED_REASON == "step17_popup_gate_escalated"
|
||||
assert (
|
||||
STEP17_POPUP_GATE_IDEMPOTENT_SHORT_CIRCUIT_REASON
|
||||
== "step17_popup_gate_idempotent_short_circuit"
|
||||
)
|
||||
assert (
|
||||
STEP17_POPUP_GATE_INFEASIBLE_CATEGORY_REASON
|
||||
== "step17_popup_gate_infeasible_category"
|
||||
)
|
||||
assert (
|
||||
STEP17_POPUP_GATE_NO_CLASSIFICATION_REASON
|
||||
== "step17_popup_gate_no_classification_for_unit"
|
||||
)
|
||||
|
||||
|
||||
# ─── Basic shape + cascade_stage ─────────────────────────────────────
|
||||
|
||||
|
||||
def test_popup_gate_with_empty_units_returns_empty_list():
|
||||
records = run_step17_popup_gate(
|
||||
[],
|
||||
classification_for_unit=_no_classification,
|
||||
route_for_label=_route_for_label,
|
||||
plan_for_classification=plan_details_popup_escalation,
|
||||
)
|
||||
assert records == []
|
||||
|
||||
|
||||
def test_popup_gate_returns_one_record_per_unit():
|
||||
units = [
|
||||
FakeUnit(label="restructure"),
|
||||
FakeUnit(label="reject"),
|
||||
FakeUnit(label="use_as_is"),
|
||||
]
|
||||
records = run_step17_popup_gate(
|
||||
units,
|
||||
classification_for_unit=_no_classification,
|
||||
route_for_label=_route_for_label,
|
||||
plan_for_classification=plan_details_popup_escalation,
|
||||
)
|
||||
assert len(records) == 3
|
||||
|
||||
|
||||
def test_popup_gate_cascade_stage_is_popup_everywhere():
|
||||
"""u5 — gate runs at OverflowCascadeStage.POPUP, never AI_REPAIR."""
|
||||
units = [
|
||||
FakeUnit(label="restructure"),
|
||||
FakeUnit(label="reject"),
|
||||
FakeUnit(label=None),
|
||||
]
|
||||
records = run_step17_popup_gate(
|
||||
units,
|
||||
classification_for_unit=_no_classification,
|
||||
route_for_label=_route_for_label,
|
||||
plan_for_classification=plan_details_popup_escalation,
|
||||
)
|
||||
for record in records:
|
||||
assert record["cascade_stage"] == OverflowCascadeStage.POPUP.value
|
||||
assert record["cascade_stage"] != OverflowCascadeStage.AI_REPAIR.value
|
||||
|
||||
|
||||
def test_popup_gate_ai_called_is_false_everywhere():
|
||||
"""u5 — deterministic gate. NO Anthropic call. Never invokes AI even
|
||||
when classification is present and plan is feasible. The AI hook is
|
||||
a separate cascade-stage surface (u4 gather_step17_popup_split_decisions,
|
||||
api_gated=True)."""
|
||||
units = [FakeUnit(label="restructure")]
|
||||
records = run_step17_popup_gate(
|
||||
units,
|
||||
classification_for_unit=_always_popup_classification(),
|
||||
route_for_label=_route_for_label,
|
||||
plan_for_classification=plan_details_popup_escalation,
|
||||
)
|
||||
assert all(record["ai_called"] is False for record in records)
|
||||
|
||||
|
||||
def test_popup_gate_preserves_unit_metadata():
|
||||
"""u5 — schema mirrors u4 (unit_index, source_section_ids,
|
||||
frame_template_id, label, provisional, route_hint)."""
|
||||
units = [
|
||||
FakeUnit(
|
||||
label="restructure",
|
||||
provisional=True,
|
||||
frame_template_id="frame_05_overview",
|
||||
source_section_ids=["s1", "s2"],
|
||||
)
|
||||
]
|
||||
record = run_step17_popup_gate(
|
||||
units,
|
||||
classification_for_unit=_no_classification,
|
||||
route_for_label=_route_for_label,
|
||||
plan_for_classification=plan_details_popup_escalation,
|
||||
)[0]
|
||||
assert record["unit_index"] == 0
|
||||
assert record["frame_template_id"] == "frame_05_overview"
|
||||
assert record["source_section_ids"] == ["s1", "s2"]
|
||||
assert record["label"] == "restructure"
|
||||
assert record["provisional"] is True
|
||||
assert record["route_hint"] == "ai_adaptation_required"
|
||||
|
||||
|
||||
# ─── Feasible escalation path: stamp popup_escalation_plan + has_popup ──
|
||||
|
||||
|
||||
def test_popup_gate_feasible_path_stamps_plan_and_has_popup_marker():
|
||||
"""u5 binding contract — when classification is a popup category
|
||||
(structural_major_overflow / tabular_overflow) and plan is feasible,
|
||||
the gate stamps popup_escalation_plan and flips has_popup=True."""
|
||||
units = [FakeUnit(label="restructure", has_popup=False)]
|
||||
record = run_step17_popup_gate(
|
||||
units,
|
||||
classification_for_unit=_always_popup_classification(
|
||||
"structural_major_overflow"
|
||||
),
|
||||
route_for_label=_route_for_label,
|
||||
plan_for_classification=plan_details_popup_escalation,
|
||||
)[0]
|
||||
assert record["gate_status"] == "escalated"
|
||||
assert record["has_popup"] is True
|
||||
assert record["popup_escalation_plan"] is not None
|
||||
plan = record["popup_escalation_plan"]
|
||||
assert plan["action"] == "details_popup_escalation"
|
||||
assert plan["feasible"] is True
|
||||
assert plan["category"] == "structural_major_overflow"
|
||||
assert plan["needs_split_decision"] is True
|
||||
|
||||
|
||||
def test_popup_gate_feasible_path_for_tabular_overflow():
|
||||
"""u5 — tabular_overflow is the second popup-mapped category. Both
|
||||
categories must successfully escalate through this gate."""
|
||||
units = [FakeUnit(label="light_edit", has_popup=False)]
|
||||
record = run_step17_popup_gate(
|
||||
units,
|
||||
classification_for_unit=_always_popup_classification("tabular_overflow"),
|
||||
route_for_label=_route_for_label,
|
||||
plan_for_classification=plan_details_popup_escalation,
|
||||
)[0]
|
||||
assert record["gate_status"] == "escalated"
|
||||
assert record["has_popup"] is True
|
||||
assert record["popup_escalation_plan"]["category"] == "tabular_overflow"
|
||||
|
||||
|
||||
# ─── Idempotency (q2) ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_popup_gate_idempotent_short_circuit_when_has_popup_already_true():
|
||||
"""u5 q2 — re-running Step 17 on a unit that already carries
|
||||
has_popup=True must short-circuit. NO duplicate plan, NO re-routing.
|
||||
The previously stamped marker stays True; gate_status records the
|
||||
short-circuit explicitly."""
|
||||
units = [FakeUnit(label="restructure", has_popup=True)]
|
||||
# Even if classification would emit a feasible plan, idempotency
|
||||
# short-circuit takes precedence.
|
||||
record = run_step17_popup_gate(
|
||||
units,
|
||||
classification_for_unit=_always_popup_classification(),
|
||||
route_for_label=_route_for_label,
|
||||
plan_for_classification=plan_details_popup_escalation,
|
||||
)[0]
|
||||
assert record["gate_status"] == "idempotent_short_circuit"
|
||||
assert (
|
||||
record["skip_reason"]
|
||||
== STEP17_POPUP_GATE_IDEMPOTENT_SHORT_CIRCUIT_REASON
|
||||
)
|
||||
assert record["has_popup"] is True
|
||||
# No duplicate plan emitted — the plan field stays None on the
|
||||
# short-circuit record (the previously stamped plan lives on the
|
||||
# unit, not re-stamped here).
|
||||
assert record["popup_escalation_plan"] is None
|
||||
|
||||
|
||||
def test_popup_gate_lifecycle_first_call_escalates_second_call_short_circuits():
|
||||
"""u5 q2 lifecycle — the actual rerun contract this gate must satisfy.
|
||||
|
||||
Scenario the Codex rewind flagged: a unit starts with
|
||||
``has_popup=False``; the first call to ``run_step17_popup_gate``
|
||||
escalates it (gate_status='escalated', record has_popup=True). On
|
||||
the SAME unit (no manual marker reset), a second call must observe
|
||||
the persisted ``unit.has_popup=True`` and short-circuit with
|
||||
``gate_status='idempotent_short_circuit'`` — without re-invoking
|
||||
the plan callable and without re-stamping the plan on the record.
|
||||
|
||||
This locks the unit-side persistence of ``has_popup`` and
|
||||
``popup_escalation_plan`` (set via ``setattr`` on the feasible
|
||||
escalation path). Without that persistence, a rerun would re-emit
|
||||
a duplicate escalation record and re-invoke the router stub —
|
||||
contradicting q2 / IMP-35 u5.
|
||||
"""
|
||||
unit = FakeUnit(label="restructure", has_popup=False)
|
||||
units = [unit]
|
||||
|
||||
plan_calls: list[dict] = []
|
||||
|
||||
def _spy_plan(classification):
|
||||
plan_calls.append(classification)
|
||||
return plan_details_popup_escalation(classification)
|
||||
|
||||
# First call: feasible escalation. Unit should be stamped on its own
|
||||
# attributes (not just the record) so a rerun can short-circuit.
|
||||
first = run_step17_popup_gate(
|
||||
units,
|
||||
classification_for_unit=_always_popup_classification(
|
||||
"structural_major_overflow"
|
||||
),
|
||||
route_for_label=_route_for_label,
|
||||
plan_for_classification=_spy_plan,
|
||||
)[0]
|
||||
assert first["gate_status"] == "escalated"
|
||||
assert first["has_popup"] is True
|
||||
assert first["popup_escalation_plan"] is not None
|
||||
assert first["popup_escalation_plan"]["feasible"] is True
|
||||
# Unit-side persistence — this is the contract the rewind required.
|
||||
assert getattr(unit, "has_popup") is True
|
||||
assert getattr(unit, "popup_escalation_plan") is not None
|
||||
assert (
|
||||
getattr(unit, "popup_escalation_plan")["action"]
|
||||
== "details_popup_escalation"
|
||||
)
|
||||
assert len(plan_calls) == 1
|
||||
|
||||
# Second call on the SAME unit (no reset) must short-circuit.
|
||||
second = run_step17_popup_gate(
|
||||
units,
|
||||
classification_for_unit=_always_popup_classification(
|
||||
"structural_major_overflow"
|
||||
),
|
||||
route_for_label=_route_for_label,
|
||||
plan_for_classification=_spy_plan,
|
||||
)[0]
|
||||
assert second["gate_status"] == "idempotent_short_circuit"
|
||||
assert (
|
||||
second["skip_reason"]
|
||||
== STEP17_POPUP_GATE_IDEMPOTENT_SHORT_CIRCUIT_REASON
|
||||
)
|
||||
assert second["has_popup"] is True
|
||||
# No duplicate plan emitted on the rerun record (the unit-side plan
|
||||
# is what u6/u7 consume; the gate does not re-stamp on rerun).
|
||||
assert second["popup_escalation_plan"] is None
|
||||
# plan callable must NOT be invoked again on the rerun — the
|
||||
# idempotent short-circuit branch fires before classification or
|
||||
# plan is consulted.
|
||||
assert len(plan_calls) == 1, (
|
||||
"plan_for_classification must NOT be invoked on the second call "
|
||||
"over an already-escalated unit (q2 idempotent short-circuit)."
|
||||
)
|
||||
# Unit-side state stays stamped (not reset by the rerun).
|
||||
assert getattr(unit, "has_popup") is True
|
||||
assert getattr(unit, "popup_escalation_plan") is not None
|
||||
|
||||
|
||||
def test_popup_gate_lifecycle_infeasible_path_does_not_persist_marker_on_unit():
|
||||
"""u5 — symmetric guard. The infeasible_category branch must NOT
|
||||
set ``unit.has_popup=True`` or stamp ``unit.popup_escalation_plan``.
|
||||
A rerun on such a unit re-evaluates classification (no short-circuit)
|
||||
— the marker is reserved for actually-escalated units."""
|
||||
unit = FakeUnit(label="light_edit", has_popup=False)
|
||||
units = [unit]
|
||||
|
||||
plan_calls: list[dict] = []
|
||||
|
||||
def _spy_plan(classification):
|
||||
plan_calls.append(classification)
|
||||
return plan_details_popup_escalation(classification)
|
||||
|
||||
first = run_step17_popup_gate(
|
||||
units,
|
||||
classification_for_unit=_always_popup_classification("minor_overflow"),
|
||||
route_for_label=_route_for_label,
|
||||
plan_for_classification=_spy_plan,
|
||||
)[0]
|
||||
assert first["gate_status"] == "infeasible_category"
|
||||
# Unit-side marker NOT stamped on the infeasible path.
|
||||
assert getattr(unit, "has_popup") is False
|
||||
assert getattr(unit, "popup_escalation_plan", None) is None
|
||||
assert len(plan_calls) == 1
|
||||
|
||||
second = run_step17_popup_gate(
|
||||
units,
|
||||
classification_for_unit=_always_popup_classification("minor_overflow"),
|
||||
route_for_label=_route_for_label,
|
||||
plan_for_classification=_spy_plan,
|
||||
)[0]
|
||||
# Second call must re-evaluate (no short-circuit) — plan_callable
|
||||
# invoked again, gate_status still infeasible_category.
|
||||
assert second["gate_status"] == "infeasible_category"
|
||||
assert len(plan_calls) == 2
|
||||
|
||||
|
||||
def test_popup_gate_idempotent_short_circuit_does_not_call_plan_callable():
|
||||
"""u5 q2 — the plan_for_classification callable must NOT be invoked
|
||||
when idempotency short-circuit fires. Guards against duplicate work."""
|
||||
calls: list[dict] = []
|
||||
|
||||
def _spy_plan(classification):
|
||||
calls.append(classification)
|
||||
return plan_details_popup_escalation(classification)
|
||||
|
||||
units = [FakeUnit(label="restructure", has_popup=True)]
|
||||
run_step17_popup_gate(
|
||||
units,
|
||||
classification_for_unit=_always_popup_classification(),
|
||||
route_for_label=_route_for_label,
|
||||
plan_for_classification=_spy_plan,
|
||||
)
|
||||
assert calls == [], (
|
||||
"plan_for_classification must NOT be invoked when the unit already "
|
||||
"carries has_popup=True (idempotent short-circuit takes precedence)."
|
||||
)
|
||||
|
||||
|
||||
# ─── No-classification path ──────────────────────────────────────────
|
||||
|
||||
|
||||
def test_popup_gate_no_classification_skips_with_skip_reason():
|
||||
"""u5 — when classification_for_unit returns None (no overflow on
|
||||
this unit), the gate records gate_status='no_classification' and
|
||||
does NOT call plan_for_classification."""
|
||||
calls: list[dict] = []
|
||||
|
||||
def _spy_plan(classification):
|
||||
calls.append(classification)
|
||||
return plan_details_popup_escalation(classification)
|
||||
|
||||
units = [FakeUnit(label="restructure")]
|
||||
record = run_step17_popup_gate(
|
||||
units,
|
||||
classification_for_unit=_no_classification,
|
||||
route_for_label=_route_for_label,
|
||||
plan_for_classification=_spy_plan,
|
||||
)[0]
|
||||
assert record["gate_status"] == "no_classification"
|
||||
assert (
|
||||
record["skip_reason"] == STEP17_POPUP_GATE_NO_CLASSIFICATION_REASON
|
||||
)
|
||||
assert record["has_popup"] is False
|
||||
assert record["popup_escalation_plan"] is None
|
||||
assert calls == []
|
||||
|
||||
|
||||
# ─── Infeasible category path (router defensive guard) ──────────────
|
||||
|
||||
|
||||
def test_popup_gate_infeasible_category_records_skip_reason_and_keeps_has_popup_false():
|
||||
"""u5 — when classification_for_unit returns a NON-popup category
|
||||
(e.g., minor_overflow), plan_details_popup_escalation emits
|
||||
feasible=False. The gate must NOT silently escalate; it records
|
||||
gate_status='infeasible_category', stamps the plan dict (with
|
||||
feasible=False) so traces are auditable, and leaves has_popup=False."""
|
||||
units = [FakeUnit(label="light_edit", has_popup=False)]
|
||||
record = run_step17_popup_gate(
|
||||
units,
|
||||
classification_for_unit=_always_popup_classification("minor_overflow"),
|
||||
route_for_label=_route_for_label,
|
||||
plan_for_classification=plan_details_popup_escalation,
|
||||
)[0]
|
||||
assert record["gate_status"] == "infeasible_category"
|
||||
assert (
|
||||
record["skip_reason"]
|
||||
== STEP17_POPUP_GATE_INFEASIBLE_CATEGORY_REASON
|
||||
)
|
||||
assert record["has_popup"] is False
|
||||
# plan dict is still recorded for trace auditability (router u3
|
||||
# emits feasible=False with failure_reason).
|
||||
assert record["popup_escalation_plan"] is not None
|
||||
assert record["popup_escalation_plan"]["feasible"] is False
|
||||
assert "failure_reason" in record["popup_escalation_plan"]
|
||||
|
||||
|
||||
# ─── Mixed batch — per-unit gate decisions are independent ──────────
|
||||
|
||||
|
||||
def test_popup_gate_per_unit_decisions_are_independent():
|
||||
"""u5 q1 — gate runs per-unit. Mixed batch: one feasible-escalation,
|
||||
one idempotent short-circuit, one infeasible-category, one
|
||||
no-classification. Each record reflects its own unit's path."""
|
||||
units = [
|
||||
FakeUnit(label="restructure", has_popup=False), # 0 escalate
|
||||
FakeUnit(label="reject", has_popup=True), # 1 idempotent
|
||||
FakeUnit(label="light_edit", has_popup=False), # 2 infeasible
|
||||
FakeUnit(label="use_as_is", has_popup=False), # 3 no_cls
|
||||
]
|
||||
|
||||
def _classification_for_unit(unit):
|
||||
idx = next(i for i, u in enumerate(units) if u is unit)
|
||||
if idx == 0:
|
||||
return {"category": "structural_major_overflow"}
|
||||
if idx == 1:
|
||||
return {"category": "tabular_overflow"}
|
||||
if idx == 2:
|
||||
return {"category": "minor_overflow"}
|
||||
return None
|
||||
|
||||
records = run_step17_popup_gate(
|
||||
units,
|
||||
classification_for_unit=_classification_for_unit,
|
||||
route_for_label=_route_for_label,
|
||||
plan_for_classification=plan_details_popup_escalation,
|
||||
)
|
||||
assert [r["gate_status"] for r in records] == [
|
||||
"escalated",
|
||||
"idempotent_short_circuit",
|
||||
"infeasible_category",
|
||||
"no_classification",
|
||||
]
|
||||
assert [r["has_popup"] for r in records] == [True, True, False, False]
|
||||
|
||||
|
||||
# ─── route_for_label callable is honored ────────────────────────────
|
||||
|
||||
|
||||
def test_popup_gate_route_for_label_callable_is_honored_per_unit():
|
||||
"""u5 — route_for_label callable shape mirrors u4 / Step 12 / Step 17
|
||||
AI_REPAIR. The route_hint must be stamped on every record regardless
|
||||
of gate path (escalated / idempotent / infeasible / no_cls)."""
|
||||
units = [
|
||||
FakeUnit(label="use_as_is"),
|
||||
FakeUnit(label="light_edit"),
|
||||
FakeUnit(label="restructure"),
|
||||
FakeUnit(label="reject"),
|
||||
FakeUnit(label=None),
|
||||
]
|
||||
records = run_step17_popup_gate(
|
||||
units,
|
||||
classification_for_unit=_no_classification,
|
||||
route_for_label=_route_for_label,
|
||||
plan_for_classification=plan_details_popup_escalation,
|
||||
)
|
||||
assert [r["route_hint"] for r in records] == [
|
||||
"direct_render",
|
||||
"deterministic_minor_adjustment",
|
||||
"ai_adaptation_required",
|
||||
"design_reference_only",
|
||||
None,
|
||||
]
|
||||
|
||||
|
||||
# ─── plan_for_classification injection lock ─────────────────────────
|
||||
|
||||
|
||||
def test_popup_gate_plan_for_classification_callable_is_used_not_imported_directly():
|
||||
"""u5 — plan_for_classification is a callable parameter, not a module-
|
||||
level import inside the gate. Pipeline injects the real router stub;
|
||||
tests inject a stub. This keeps the gate decoupled from the router
|
||||
surface for testability and isolation."""
|
||||
sentinel_plan = {
|
||||
"action": "details_popup_escalation",
|
||||
"feasible": True,
|
||||
"stub": True,
|
||||
"category": "structural_major_overflow",
|
||||
"needs_split_decision": True,
|
||||
"mapping_source": "test sentinel",
|
||||
}
|
||||
|
||||
def _sentinel_plan_for(_classification):
|
||||
return sentinel_plan
|
||||
|
||||
units = [FakeUnit(label="restructure", has_popup=False)]
|
||||
record = run_step17_popup_gate(
|
||||
units,
|
||||
classification_for_unit=_always_popup_classification(),
|
||||
route_for_label=_route_for_label,
|
||||
plan_for_classification=_sentinel_plan_for,
|
||||
)[0]
|
||||
assert record["popup_escalation_plan"] is sentinel_plan
|
||||
assert record["gate_status"] == "escalated"
|
||||
assert record["has_popup"] is True
|
||||
@@ -0,0 +1,305 @@
|
||||
"""IMP-35 (#64) u10 — MDX preservation guard tests.
|
||||
|
||||
Stage 2 binding contract (unit u10):
|
||||
After Step 17 POPUP gate (u5) stamps the unit, composition (u6) binds
|
||||
the strategy, pipeline (u7) wires the render context, and slide_base
|
||||
(u8) renders the ``<details>/<summary>`` wrapper, the end-to-end
|
||||
invariant the user lock requires is:
|
||||
|
||||
MDX 원문 무손실 보존 (오답노트 #5 / IMPROVEMENT-REDESIGN.md §3.6
|
||||
line 110, CLAUDE.md 자세히보기 원칙):
|
||||
- popup body == FULL ``raw_content`` (byte-for-byte verbatim)
|
||||
- body preview == SUBSET of ``raw_content`` (deterministic
|
||||
leading-substring CUT — never a rewrite, never a re-summary)
|
||||
- the original is ALWAYS reachable via the popup; the preview
|
||||
loses no information because the popup holds the full source
|
||||
- no structural element is dropped: text_block / table / image
|
||||
/ ``<details>`` counts in popup body match the original
|
||||
|
||||
u6 and u7 each lock pieces of this invariant on their own surface.
|
||||
u10 locks the END-TO-END no-content-drop guarantee on the rendered
|
||||
payload — the surface a downstream verifier (Selenium / vision gate)
|
||||
would inspect — so a future refactor on either u6 or u7 cannot
|
||||
silently degrade MDX preservation without this test failing first.
|
||||
|
||||
Key invariants this file locks:
|
||||
1. popup_html (full source) preserves every structural element from
|
||||
raw_content byte-for-byte: bullet lines, paragraph blocks, markdown
|
||||
table rows, image markdown, and nested ``<details>`` blocks.
|
||||
2. preview_text is a deterministic leading-substring CUT of
|
||||
raw_content — ``raw_content.startswith(preview_text)`` holds when
|
||||
truncation happened.
|
||||
3. Combined invariant: popup_html holds the FULL original even when
|
||||
preview_text is shorter, so no content is dropped — the full
|
||||
source is always reachable via the popup.
|
||||
4. has_popup=False path: popup_html / preview_text are both None.
|
||||
There is no popup escalation, so by definition no escalation can
|
||||
drop content; the frame's partial_html (rendered separately by
|
||||
slide_base.html and not part of u7 popup wiring) holds the inline
|
||||
body.
|
||||
5. AI isolation contract — pure deterministic preservation check;
|
||||
no anthropic import, no route_ai_fallback path.
|
||||
|
||||
Cross-references:
|
||||
- u6 composition popup binding (popup_body_source = full raw_content):
|
||||
tests/phase_z2/test_composition_popup_strategy.py
|
||||
- u7 pipeline wiring (popup_html = popup_body_source verbatim;
|
||||
preview_text is a deterministic line-budget cut):
|
||||
tests/phase_z2/test_phase_z2_pipeline_popup_wiring.py
|
||||
- u8 slide_base.html render surface (autoescaped popup body):
|
||||
tests/phase_z2/test_slide_base_popup_render.py
|
||||
- u9 display_strategies.yaml catalog (preserves_original=True for the
|
||||
popup-bearing strategy):
|
||||
tests/phase_z2/test_display_strategies_popup.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_composition import compose_zone_popup_payload
|
||||
|
||||
|
||||
# ─── Synthetic stubs ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class _StubUnit:
|
||||
"""Minimal duck-typed CompositionUnit for u10 preservation tests."""
|
||||
|
||||
raw_content: str = "MOCK_ORIGINAL_CONTENT"
|
||||
has_popup: bool = False
|
||||
popup_escalation_plan: Optional[dict] = None
|
||||
|
||||
|
||||
def _stub_popup_plan() -> dict:
|
||||
"""Mirror the plan_details_popup_escalation feasible-escalation shape
|
||||
(u3). u10 only echoes the plan into the unit so the binder reaches
|
||||
the popup branch; no field is consumed here."""
|
||||
return {
|
||||
"action": "details_popup_escalation",
|
||||
"stub": True,
|
||||
"feasible": True,
|
||||
"category": "structural_major_overflow",
|
||||
"needs_split_decision": True,
|
||||
"rationale": "MOCK_RATIONALE",
|
||||
"mapping_source": "IMP-35 u3 plan_details_popup_escalation stub",
|
||||
}
|
||||
|
||||
|
||||
# ─── Deterministic structural-element counters ──────────────────────
|
||||
|
||||
|
||||
def _count_markdown_bullet_lines(text: str) -> int:
|
||||
"""Count leading-``-`` markdown bullet lines (- / * / + at line start)."""
|
||||
return sum(
|
||||
1 for line in text.splitlines() if re.match(r"^\s*[-*+]\s+", line)
|
||||
)
|
||||
|
||||
|
||||
def _count_markdown_table_rows(text: str) -> int:
|
||||
"""Count markdown table rows (lines with ``|`` somewhere)."""
|
||||
return sum(1 for line in text.splitlines() if "|" in line)
|
||||
|
||||
|
||||
def _count_markdown_images(text: str) -> int:
|
||||
"""Count markdown image references ````."""
|
||||
return len(re.findall(r"!\[[^\]]*\]\([^)]+\)", text))
|
||||
|
||||
|
||||
def _count_details_blocks(text: str) -> int:
|
||||
"""Count nested ``<details>`` blocks in raw_content (rare — used to
|
||||
lock the invariant even when MDX already carries native popups)."""
|
||||
return len(re.findall(r"<details\b", text, flags=re.IGNORECASE))
|
||||
|
||||
|
||||
# ─── Sample MDX content (structural diversity for the count guard) ──
|
||||
|
||||
|
||||
_FULL_MDX_SAMPLE = (
|
||||
"## MOCK_SECTION_TITLE\n"
|
||||
"\n"
|
||||
"Paragraph one explaining the MOCK topic. Lorem ipsum dolor sit amet.\n"
|
||||
"\n"
|
||||
"- bullet one with **bold** marker\n"
|
||||
"- bullet two with *italic* marker\n"
|
||||
"- bullet three trailing\n"
|
||||
"\n"
|
||||
"| col_a | col_b |\n"
|
||||
"| --- | --- |\n"
|
||||
"| MOCK_A | MOCK_B |\n"
|
||||
"| MOCK_C | MOCK_D |\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"<details><summary>MOCK_NESTED_TRIGGER</summary>"
|
||||
"<p>MOCK_NESTED_BODY</p></details>\n"
|
||||
"\n"
|
||||
"Paragraph two — closing remarks for the MOCK topic.\n"
|
||||
)
|
||||
|
||||
|
||||
# ─── Popup body = full raw_content (byte-for-byte) ───────────────────
|
||||
|
||||
|
||||
def test_popup_body_byte_for_byte_equal_to_raw_content():
|
||||
"""u10 — the end-to-end invariant: popup_html on the rendered payload
|
||||
is byte-for-byte equal to the unit's raw_content. u6 + u7 already
|
||||
lock this on their own surface; u10 re-asserts on the payload a
|
||||
downstream verifier (Selenium / vision gate) would inspect."""
|
||||
unit = _StubUnit(
|
||||
raw_content=_FULL_MDX_SAMPLE,
|
||||
has_popup=True,
|
||||
popup_escalation_plan=_stub_popup_plan(),
|
||||
)
|
||||
payload = compose_zone_popup_payload(unit, container_height_px=200)
|
||||
assert payload["popup_html"] == _FULL_MDX_SAMPLE
|
||||
assert len(payload["popup_html"]) == len(_FULL_MDX_SAMPLE)
|
||||
|
||||
|
||||
def test_popup_body_preserves_bullet_line_count():
|
||||
"""u10 — text_block count equality. Every bullet line present in
|
||||
raw_content MUST also be present in popup_html. A future refactor
|
||||
that accidentally trims popup body to a summary would drop bullets
|
||||
and fail this guard."""
|
||||
unit = _StubUnit(
|
||||
raw_content=_FULL_MDX_SAMPLE,
|
||||
has_popup=True,
|
||||
popup_escalation_plan=_stub_popup_plan(),
|
||||
)
|
||||
payload = compose_zone_popup_payload(unit, container_height_px=200)
|
||||
assert _count_markdown_bullet_lines(payload["popup_html"]) == (
|
||||
_count_markdown_bullet_lines(_FULL_MDX_SAMPLE)
|
||||
)
|
||||
|
||||
|
||||
def test_popup_body_preserves_markdown_table_row_count():
|
||||
"""u10 — table count equality. Markdown table rows (header / divider
|
||||
/ data) MUST all survive the popup wiring."""
|
||||
unit = _StubUnit(
|
||||
raw_content=_FULL_MDX_SAMPLE,
|
||||
has_popup=True,
|
||||
popup_escalation_plan=_stub_popup_plan(),
|
||||
)
|
||||
payload = compose_zone_popup_payload(unit, container_height_px=200)
|
||||
assert _count_markdown_table_rows(payload["popup_html"]) == (
|
||||
_count_markdown_table_rows(_FULL_MDX_SAMPLE)
|
||||
)
|
||||
|
||||
|
||||
def test_popup_body_preserves_image_reference_count():
|
||||
"""u10 — image count equality. Markdown ```` references
|
||||
MUST all survive (CLAUDE.md: 이미지는 원본 그대로 사용, 크기만 조절 —
|
||||
popup escalation must not silently drop image refs)."""
|
||||
unit = _StubUnit(
|
||||
raw_content=_FULL_MDX_SAMPLE,
|
||||
has_popup=True,
|
||||
popup_escalation_plan=_stub_popup_plan(),
|
||||
)
|
||||
payload = compose_zone_popup_payload(unit, container_height_px=200)
|
||||
assert _count_markdown_images(payload["popup_html"]) == (
|
||||
_count_markdown_images(_FULL_MDX_SAMPLE)
|
||||
)
|
||||
|
||||
|
||||
def test_popup_body_preserves_nested_details_block_count():
|
||||
"""u10 — nested ``<details>`` blocks. Even when MDX already carries
|
||||
a native popup, the u10 popup escalation MUST NOT collapse or drop
|
||||
nested ``<details>`` markers."""
|
||||
unit = _StubUnit(
|
||||
raw_content=_FULL_MDX_SAMPLE,
|
||||
has_popup=True,
|
||||
popup_escalation_plan=_stub_popup_plan(),
|
||||
)
|
||||
payload = compose_zone_popup_payload(unit, container_height_px=200)
|
||||
assert _count_details_blocks(payload["popup_html"]) == (
|
||||
_count_details_blocks(_FULL_MDX_SAMPLE)
|
||||
)
|
||||
|
||||
|
||||
# ─── Preview = deterministic leading-substring CUT ──────────────────
|
||||
|
||||
|
||||
def test_preview_text_is_a_leading_substring_of_raw_content_when_truncated():
|
||||
"""u10 — preview is a CUT, never a rewrite. When truncation happens,
|
||||
raw_content MUST start with preview_text verbatim (line-boundary
|
||||
cut semantics; popup body retains the FULL original)."""
|
||||
unit = _StubUnit(
|
||||
raw_content=_FULL_MDX_SAMPLE,
|
||||
has_popup=True,
|
||||
popup_escalation_plan=_stub_popup_plan(),
|
||||
)
|
||||
# 2-line budget — far smaller than the multi-line sample.
|
||||
payload = compose_zone_popup_payload(unit, container_height_px=36)
|
||||
preview = payload["preview_text"]
|
||||
assert preview, "preview_text must be non-empty when truncation fires"
|
||||
assert _FULL_MDX_SAMPLE.startswith(preview), (
|
||||
"preview_text must be a leading-substring of raw_content "
|
||||
"(MDX 원문 무손실 보존 — preview is a CUT, never a rewrite)."
|
||||
)
|
||||
# The popup body still holds the FULL original — no information loss.
|
||||
assert payload["popup_html"] == _FULL_MDX_SAMPLE
|
||||
|
||||
|
||||
def test_no_content_drop_when_preview_is_shorter_than_popup_body():
|
||||
"""u10 — combined no-drop invariant. preview_text may be a strict
|
||||
prefix of popup_html (shorter), but the popup body always holds the
|
||||
full original. The user can always reach every line of the source
|
||||
via the popup, even when the inline preview shows only the head."""
|
||||
unit = _StubUnit(
|
||||
raw_content=_FULL_MDX_SAMPLE,
|
||||
has_popup=True,
|
||||
popup_escalation_plan=_stub_popup_plan(),
|
||||
)
|
||||
payload = compose_zone_popup_payload(unit, container_height_px=36)
|
||||
preview = payload["preview_text"]
|
||||
popup_body = payload["popup_html"]
|
||||
# preview is strictly shorter when truncation fires.
|
||||
assert len(preview) < len(popup_body)
|
||||
# popup_body is the FULL original — every line of raw_content is
|
||||
# present in popup_body regardless of the inline preview budget.
|
||||
for line in _FULL_MDX_SAMPLE.splitlines():
|
||||
assert line in popup_body, (
|
||||
f"MDX preservation guard violated — line {line!r} not present "
|
||||
f"in popup body."
|
||||
)
|
||||
|
||||
|
||||
# ─── has_popup=False path: no popup, no escalation, no drop ─────────
|
||||
|
||||
|
||||
def test_no_popup_path_yields_no_popup_html_no_preview_text():
|
||||
"""u10 — when the Step 17 POPUP gate did not fire, no popup
|
||||
escalation happens. popup_html and preview_text are both None.
|
||||
By construction this branch cannot drop content (no escalation),
|
||||
and the frame's partial_html (rendered separately by slide_base
|
||||
and not part of u7 popup wiring) holds the inline body."""
|
||||
unit = _StubUnit(raw_content=_FULL_MDX_SAMPLE, has_popup=False)
|
||||
payload = compose_zone_popup_payload(unit, container_height_px=200)
|
||||
assert payload["has_popup"] is False
|
||||
assert payload["popup_html"] is None
|
||||
assert payload["preview_text"] is None
|
||||
|
||||
|
||||
# ─── AI isolation contract (structural import lock) ─────────────────
|
||||
|
||||
|
||||
def test_popup_mdx_preservation_module_has_no_ai_imports():
|
||||
"""u10 — preservation guard MUST stay AI-free. Structural guard:
|
||||
composition module (where compose_zone_popup_payload lives) is
|
||||
allowed to consult the catalog and unit state, never the Anthropic
|
||||
SDK / route_ai_fallback path. Mirrors u6 / u7 import-isolation
|
||||
pattern (feedback_ai_isolation_contract)."""
|
||||
import src.phase_z2_composition as composition_module
|
||||
|
||||
source = composition_module.__file__
|
||||
assert source is not None
|
||||
with open(source, encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
assert "import anthropic" not in text
|
||||
assert "from anthropic" not in text
|
||||
assert "route_ai_fallback" not in text
|
||||
@@ -0,0 +1,413 @@
|
||||
"""IMP-35 (#64) u8 — slide_base.html details/summary popup render tests.
|
||||
|
||||
Stage 2 wiring contract (unit u8):
|
||||
u7 (``compose_zone_popup_payload`` in ``src/phase_z2_pipeline.py``) wired
|
||||
four uniform per-zone render-context keys into every ``zones_data``
|
||||
entry::
|
||||
|
||||
has_popup : bool
|
||||
popup_html : str | None (FULL ``raw_content`` verbatim when
|
||||
has_popup=True)
|
||||
preview_text : str | None
|
||||
popup_binding : dict | None (u6 binding — includes
|
||||
``display_strategy``,
|
||||
``detail_trigger.{placement,label}``)
|
||||
|
||||
u8 is the slide_base.html consumer side: it renders a JS-free
|
||||
``<details>/<summary>`` wrapper inside the zone div when
|
||||
``zone.has_popup`` is True. The summary acts as the toggle, the body
|
||||
holds the FULL ``popup_html``. The frame's existing ``partial_html``
|
||||
remains the zone body (inline preview / FIT-version of content); the
|
||||
popup body holds the original — never replaces the partial.
|
||||
|
||||
Key invariants this file locks:
|
||||
1. has_popup=False → no ``<details>`` element emitted (byte-identical
|
||||
contract for non-popup zones, no regression to pre-u8).
|
||||
2. has_popup=True → exactly one ``<details class="zone__popup-details
|
||||
zone__popup-details--<placement>">`` per zone with a ``<summary>``
|
||||
trigger and a ``<div class="zone__popup-body">`` holding the full
|
||||
popup_html.
|
||||
3. Popup body content is HTML-escaped (Jinja2 autoescape is ON for
|
||||
slide_base.html — popup_html is plain MDX text, never raw HTML).
|
||||
A ``<script>`` literal in raw_content MUST appear escaped, never as
|
||||
an executable tag.
|
||||
4. Whitespace inside the popup body is preserved via the
|
||||
``.zone__popup-body`` CSS contract (``white-space: pre-wrap``).
|
||||
Locks MDX 원문 무손실 보존 — newline structure of raw_content is
|
||||
visible verbatim (오답노트 #5 / IMPROVEMENT-REDESIGN.md §3.6
|
||||
line 110).
|
||||
5. Placement / label / strategy id are READ from
|
||||
``zone.popup_binding.detail_trigger.{placement,label}`` and
|
||||
``zone.popup_binding.display_strategy`` — no hardcoded literal
|
||||
drift from the catalog
|
||||
(``templates/phase_z2/regions/display_strategies.yaml``).
|
||||
6. Defensive defaults: a popup zone whose ``popup_binding`` is ``None``
|
||||
(the unrenderable empty-plan branch of the pipeline composer
|
||||
stamps ``popup_binding=None``) still renders sane defaults
|
||||
(``placement=top-right``, ``label=details``,
|
||||
``display_strategy=inline_preview_with_details``) — no
|
||||
KeyError/AttributeError on the Jinja2 path.
|
||||
7. The zone div carries ``data-has-popup="1"`` exactly when
|
||||
has_popup=True — downstream observability anchor.
|
||||
|
||||
Cross-references:
|
||||
- u5 Step 17 POPUP gate (stamps the marker on the unit):
|
||||
tests/phase_z2/test_phase_z2_step17_popup_gate.py
|
||||
- u6 composition popup binding (produces the binding dict u8 reads):
|
||||
tests/phase_z2/test_composition_popup_strategy.py
|
||||
- u7 pipeline composer wiring (puts the four keys into zones_data):
|
||||
tests/phase_z2/test_phase_z2_pipeline_popup_wiring.py
|
||||
- display strategy catalog (placement / label source of truth):
|
||||
templates/phase_z2/regions/display_strategies.yaml
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_pipeline import render_slide
|
||||
|
||||
|
||||
# ─── Test scaffolding ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def _layout_css() -> dict:
|
||||
return {"areas": '"primary"', "cols": "1fr", "rows": "1fr"}
|
||||
|
||||
|
||||
def _no_popup_zone(**overrides) -> dict:
|
||||
"""Baseline non-popup zone (matches the four-key wiring from u7
|
||||
when has_popup=False — popup_binding may be None for the empty plan
|
||||
branch or the u6 ``inline_full`` echo for renderable no-popup units;
|
||||
here we exercise the empty-plan branch where popup_binding=None)."""
|
||||
base = {
|
||||
"position": "primary",
|
||||
"template_id": "__empty__",
|
||||
"slot_payload": {},
|
||||
"has_popup": False,
|
||||
"popup_html": None,
|
||||
"preview_text": None,
|
||||
"popup_binding": None,
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
def _popup_binding(
|
||||
*,
|
||||
placement: str = "top-right",
|
||||
label: str = "details",
|
||||
strategy: str = "inline_preview_with_details",
|
||||
) -> dict:
|
||||
"""Matches the u6 binding shape (subset relevant to u8 render)."""
|
||||
return {
|
||||
"display_strategy": strategy,
|
||||
"detail_trigger": {"placement": placement, "label": label},
|
||||
"has_popup": True,
|
||||
"popup_escalation_plan": {"action": "details_popup_escalation"},
|
||||
}
|
||||
|
||||
|
||||
def _popup_zone(
|
||||
*,
|
||||
popup_html: str = "MOCK_POPUP_BODY_FULL_ORIGINAL",
|
||||
binding: dict | None = None,
|
||||
**overrides,
|
||||
) -> dict:
|
||||
"""Baseline popup zone (has_popup=True) for u8 rendering tests."""
|
||||
base = {
|
||||
"position": "primary",
|
||||
"template_id": "__empty__",
|
||||
"slot_payload": {},
|
||||
"has_popup": True,
|
||||
"popup_html": popup_html,
|
||||
"preview_text": "MOCK_PREVIEW",
|
||||
"popup_binding": binding if binding is not None else _popup_binding(),
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
def _render(zones: list[dict]) -> str:
|
||||
return render_slide(
|
||||
slide_title="t",
|
||||
slide_footer=None,
|
||||
zones_data=zones,
|
||||
layout_preset="single",
|
||||
layout_css=_layout_css(),
|
||||
gap_px=14,
|
||||
)
|
||||
|
||||
|
||||
# ─── Invariant 1 — no details on no-popup zone ───────────────────────
|
||||
|
||||
|
||||
def _body_section(html: str) -> str:
|
||||
"""Return the HTML between </style> and </body> so assertions can
|
||||
target the rendered body content without false positives on the
|
||||
in-template CSS block (which legitimately declares the popup CSS
|
||||
classes regardless of whether any zone emits a popup)."""
|
||||
end_of_style = html.index("</style>") + len("</style>")
|
||||
return html[end_of_style:]
|
||||
|
||||
|
||||
def test_zone_without_popup_does_not_render_details_element():
|
||||
"""has_popup=False → no ``<details class="zone__popup-details">``
|
||||
element emitted. The CSS class declarations stay in <style> (CSS
|
||||
contract lives once in the template); what MUST NOT appear is the
|
||||
element instance in the body."""
|
||||
body = _body_section(_render([_no_popup_zone()]))
|
||||
assert "<details" not in body
|
||||
assert "zone__popup-details" not in body
|
||||
assert "zone__popup-summary" not in body
|
||||
assert "zone__popup-body" not in body
|
||||
assert "data-has-popup" not in body
|
||||
|
||||
|
||||
def test_zone_without_popup_keeps_existing_zone_attrs():
|
||||
"""No regression on the zone div for non-popup zones — the
|
||||
data-zone-position + data-template-id contract from pre-u8 stays
|
||||
intact."""
|
||||
html = _render([_no_popup_zone()])
|
||||
assert 'data-zone-position="primary"' in html
|
||||
assert 'data-template-id="__empty__"' in html
|
||||
|
||||
|
||||
# ─── Invariant 2 — exactly one details on popup zone ────────────────
|
||||
|
||||
|
||||
def test_zone_with_popup_renders_details_summary_body_triple():
|
||||
"""has_popup=True → exactly one ``<details class="zone__popup-details
|
||||
...">`` per zone with a ``<summary class="zone__popup-summary">``
|
||||
trigger AND a ``<div class="zone__popup-body">`` body."""
|
||||
html = _render([_popup_zone()])
|
||||
details_matches = re.findall(
|
||||
r'<details class="zone__popup-details[^"]*"', html
|
||||
)
|
||||
assert len(details_matches) == 1
|
||||
assert 'class="zone__popup-summary"' in html
|
||||
assert 'class="zone__popup-body"' in html
|
||||
|
||||
|
||||
def test_zone_with_popup_marks_zone_div_with_data_has_popup_attr():
|
||||
"""The zone div carries ``data-has-popup="1"`` exactly when
|
||||
has_popup=True (downstream observability anchor)."""
|
||||
html = _render([_popup_zone()])
|
||||
assert 'data-has-popup="1"' in html
|
||||
|
||||
|
||||
def test_zone_without_popup_does_not_carry_data_has_popup_attr():
|
||||
"""has_popup=False zone div MUST NOT carry the data-has-popup
|
||||
attribute (otherwise the observability anchor lies)."""
|
||||
html = _render([_no_popup_zone()])
|
||||
assert "data-has-popup" not in html
|
||||
|
||||
|
||||
# ─── Invariant 3 — escaping (XSS safety + literal preservation) ──────
|
||||
|
||||
|
||||
def test_popup_body_html_special_chars_are_escaped():
|
||||
"""popup_html is plain MDX text. A literal ``<script>`` in
|
||||
raw_content MUST appear escaped (Jinja2 autoescape ON), never as an
|
||||
executable tag. Locks XSS guard + MDX-as-text contract."""
|
||||
payload = "<script>alert(1)</script>"
|
||||
html = _render([_popup_zone(popup_html=payload)])
|
||||
# Raw <script> tag MUST NOT appear inside popup body.
|
||||
assert "<script>alert(1)</script>" not in html
|
||||
# Escaped form MUST appear (& -> & lt -> <).
|
||||
assert "<script>alert(1)</script>" in html
|
||||
|
||||
|
||||
def test_popup_body_ampersand_and_quotes_are_escaped():
|
||||
"""Literal ``&`` ``<`` ``>`` ``"`` ``'`` in popup_html are
|
||||
autoescaped — round-trip safe through the HTML body."""
|
||||
payload = "A & B < C > D \" E ' F"
|
||||
html = _render([_popup_zone(popup_html=payload)])
|
||||
assert "&" in html
|
||||
assert "<" in html
|
||||
assert ">" in html
|
||||
# Raw form of the un-escaped ampersand sequence must not appear.
|
||||
assert "A & B < C > D" not in html
|
||||
|
||||
|
||||
# ─── Invariant 4 — whitespace preservation contract ──────────────────
|
||||
|
||||
|
||||
def test_popup_body_preserves_newlines_in_content_verbatim():
|
||||
"""popup_html with newlines is emitted verbatim into the body —
|
||||
no collapse, no trim. Visual newline preservation is the CSS
|
||||
contract (.zone__popup-body { white-space: pre-wrap }) but the
|
||||
underlying text MUST carry the newlines through to the HTML."""
|
||||
payload = "line one\nline two\nline three"
|
||||
html = _render([_popup_zone(popup_html=payload)])
|
||||
# The exact body text appears between the body div tags.
|
||||
body_match = re.search(
|
||||
r'<div class="zone__popup-body">(.*?)</div>',
|
||||
html,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert body_match is not None
|
||||
assert body_match.group(1) == payload
|
||||
|
||||
|
||||
def test_popup_body_css_class_declares_whitespace_pre_wrap():
|
||||
"""The CSS contract that makes the preserved newlines actually
|
||||
visible is ``.zone__popup-body { white-space: pre-wrap }`` in
|
||||
slide_base.html. Locks the styling axis — without this rule the
|
||||
preserved newlines collapse in render."""
|
||||
html = _render([_popup_zone()])
|
||||
# Compress whitespace before regex match (CSS block formatting
|
||||
# may vary across edits).
|
||||
flat = re.sub(r"\s+", " ", html)
|
||||
assert ".zone__popup-body" in flat
|
||||
assert "white-space: pre-wrap" in flat
|
||||
|
||||
|
||||
def test_popup_body_holds_full_raw_content_verbatim():
|
||||
"""popup_html (FULL raw_content from u7 / u6) appears in the body
|
||||
char-for-char (modulo HTML escape on special chars). No trim, no
|
||||
summary substitution — MDX 원문 무손실 보존 (오답노트 #5)."""
|
||||
payload = (
|
||||
"## MOCK_SECTION_TITLE\n\n"
|
||||
"- bullet 1\n"
|
||||
"- bullet 2\n"
|
||||
"- bullet 3 with **bold**\n"
|
||||
)
|
||||
html = _render([_popup_zone(popup_html=payload)])
|
||||
# Extract the popup body content.
|
||||
body_match = re.search(
|
||||
r'<div class="zone__popup-body">(.*?)</div>',
|
||||
html,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert body_match is not None
|
||||
# ** stays as ** (autoescape only touches HTML special chars).
|
||||
assert body_match.group(1) == payload
|
||||
|
||||
|
||||
# ─── Invariant 5 — placement / label / strategy from binding ─────────
|
||||
|
||||
|
||||
def test_popup_placement_class_modifier_reflects_binding_placement():
|
||||
"""The placement (top-right / top-left / bottom-right / bottom-left)
|
||||
is READ from zone.popup_binding.detail_trigger.placement and
|
||||
surfaces as the BEM modifier on the details element."""
|
||||
for placement in ("top-right", "top-left", "bottom-right", "bottom-left"):
|
||||
zone = _popup_zone(binding=_popup_binding(placement=placement))
|
||||
html = _render([zone])
|
||||
assert f"zone__popup-details--{placement}" in html
|
||||
assert f'data-popup-placement="{placement}"' in html
|
||||
|
||||
|
||||
def test_popup_summary_label_reflects_binding_label():
|
||||
"""The summary trigger text is READ from
|
||||
zone.popup_binding.detail_trigger.label — no hardcoded literal in
|
||||
the template (catalog drift guard)."""
|
||||
zone = _popup_zone(binding=_popup_binding(label="자세히"))
|
||||
html = _render([zone])
|
||||
assert ">자세히</summary>" in html
|
||||
|
||||
|
||||
def test_popup_data_display_strategy_attr_reflects_binding_strategy_id():
|
||||
"""The details element carries data-display-strategy=<strategy_id>
|
||||
from the binding so downstream observability (DOM scrape, test
|
||||
introspection) can identify which catalog strategy fired."""
|
||||
zone = _popup_zone(binding=_popup_binding(strategy="details_only"))
|
||||
html = _render([zone])
|
||||
assert 'data-display-strategy="details_only"' in html
|
||||
|
||||
|
||||
# ─── Invariant 6 — defensive defaults (binding=None / missing keys) ──
|
||||
|
||||
|
||||
def test_popup_zone_with_binding_none_uses_defensive_defaults():
|
||||
"""The unrenderable empty-plan branch of the pipeline composer
|
||||
stamps popup_binding=None (u7 wiring). u8 MUST render sane defaults
|
||||
rather than KeyError/AttributeError on the Jinja2 path: placement =
|
||||
top-right, label = 'details', strategy =
|
||||
inline_preview_with_details."""
|
||||
zone = _popup_zone(binding=None)
|
||||
html = _render([zone])
|
||||
assert "zone__popup-details--top-right" in html
|
||||
assert ">details</summary>" in html
|
||||
assert 'data-display-strategy="inline_preview_with_details"' in html
|
||||
|
||||
|
||||
def test_popup_zone_with_partial_binding_falls_back_per_missing_key():
|
||||
"""A binding dict missing detail_trigger (defensive — should not
|
||||
happen in normal u6 output, but the template MUST be robust) falls
|
||||
back to the same defaults as binding=None."""
|
||||
partial_binding = {
|
||||
"display_strategy": "inline_preview_with_details",
|
||||
# detail_trigger intentionally omitted.
|
||||
}
|
||||
zone = _popup_zone(binding=partial_binding)
|
||||
html = _render([zone])
|
||||
assert "zone__popup-details--top-right" in html
|
||||
assert ">details</summary>" in html
|
||||
|
||||
|
||||
# ─── Invariant 7 — multi-zone rendering ─────────────────────────────
|
||||
|
||||
|
||||
def test_only_popup_zones_emit_details_in_multi_zone_slide():
|
||||
"""Mixed slide: zone A has_popup=False, zone B has_popup=True.
|
||||
Exactly ONE <details> block in the rendered HTML, on zone B only."""
|
||||
zone_a = _no_popup_zone(position="left")
|
||||
zone_b = _popup_zone(position="right")
|
||||
html = _render([
|
||||
zone_a,
|
||||
zone_b,
|
||||
])
|
||||
matches = re.findall(r'<details class="zone__popup-details', html)
|
||||
assert len(matches) == 1
|
||||
# zone B is the right grid-area — popup details should sit within
|
||||
# the zone whose div carries data-zone-position="right".
|
||||
right_zone_block = re.search(
|
||||
r'<div class="zone" data-zone-position="right"[^>]*>(.*?)</div>\s*</div>',
|
||||
html,
|
||||
re.DOTALL,
|
||||
)
|
||||
# If the regex above doesn't anchor (template HTML evolves), fall
|
||||
# back to checking the details element appears AFTER the right
|
||||
# zone marker but BEFORE the next zone marker.
|
||||
if right_zone_block is None:
|
||||
right_idx = html.index('data-zone-position="right"')
|
||||
assert html.find("zone__popup-details", right_idx) > right_idx
|
||||
# And the left zone block should NOT contain the popup.
|
||||
left_end = html.index('data-zone-position="right"')
|
||||
assert "zone__popup-details" not in html[:left_end]
|
||||
else:
|
||||
assert "zone__popup-details" in right_zone_block.group(1)
|
||||
|
||||
|
||||
# ─── Determinism + smoke check ──────────────────────────────────────
|
||||
|
||||
|
||||
def test_popup_render_is_deterministic_across_calls():
|
||||
"""Two calls with identical input produce byte-identical HTML —
|
||||
no order-dependence on dict iteration, no time-based identifier."""
|
||||
zone = _popup_zone(popup_html="MOCK\nMULTI\nLINE")
|
||||
assert _render([zone]) == _render([zone])
|
||||
|
||||
|
||||
def test_popup_emits_no_javascript_on_render_path():
|
||||
"""CLAUDE.md 자세히보기 contract — HTML-native ``<details>`` ONLY,
|
||||
no JavaScript hook on the popup render path (print auto-expand is a
|
||||
separate OOS axis per IMP-35 scope-lock)."""
|
||||
html = _render([_popup_zone()])
|
||||
# The slide_base.html embedded-mode <script> is allowed (separate
|
||||
# axis). What MUST NOT appear is any popup-specific JS handler.
|
||||
# Search the popup details block for inline JS attributes.
|
||||
details_block_match = re.search(
|
||||
r'<details class="zone__popup-details.*?</details>',
|
||||
html,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert details_block_match is not None
|
||||
block = details_block_match.group(0)
|
||||
for js_attr in ("onclick=", "onload=", "onopen=", "ontoggle="):
|
||||
assert js_attr not in block
|
||||
# And no <script> tag inside the details body.
|
||||
assert "<script" not in block
|
||||
@@ -0,0 +1,232 @@
|
||||
"""IMP-86 u4 — integration: mdx03 reject override reaches Step 12 AI audit
|
||||
without heights_px ValueError; default-path regression.
|
||||
|
||||
Locks the Stage 2 guardrails from the IMP-86 issue body. Pre-u1, the mapper
|
||||
``FitError`` handler appended only to ``adapter_needed_units`` and skipped
|
||||
``zones_data`` / ``debug_zones`` — leaving them at ``len=1`` while
|
||||
``build_layout_css("horizontal-2", ...)`` still returned ``R=2``. The
|
||||
``_compute_per_zone_geometry`` invariant then raised
|
||||
``ValueError("heights_px length 1 != grid rows R=2")`` BEFORE Step 12
|
||||
fired, so the AI router was never reached and ``step12_ai_repair.json``
|
||||
was never written. Post u1+u2 :
|
||||
|
||||
* u1 placeholder zone keeps cardinality at R=2;
|
||||
* u2 pre-build invariant guard surfaces drift before
|
||||
``_compute_per_zone_geometry`` raises;
|
||||
* Step 12 AI router is reached and the audit artifact is produced;
|
||||
* ``debug.json`` carries the adapter_needed marker (existing channel —
|
||||
u5 will add finer per-record telemetry).
|
||||
|
||||
AI router is patched to a deterministic ``None``-returning stub so the
|
||||
integration coverage proves the wiring without any network / API / model
|
||||
dependency (``feedback_ai_isolation_contract`` — AI = fallback only).
|
||||
The pair ``(ai_called=False, skip_reason='router_short_circuit')`` is
|
||||
the gather-side surface (``src/phase_z2_ai_fallback/step12.py:210-213``)
|
||||
for ``route_ai_fallback → None`` and is the explicit "router reached and
|
||||
returned None" signal — distinct from earlier-gate skips
|
||||
(``not_provisional`` / ``route_not_ai_adaptation:<hint>``) which would
|
||||
indicate the router was NOT reached.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
import src.phase_z2_ai_fallback.step12 as step12_mod
|
||||
import src.phase_z2_pipeline as pz2
|
||||
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
_SAMPLE_MDX_PATH = _REPO_ROOT / "samples" / "mdx_batch" / "03.mdx"
|
||||
|
||||
|
||||
def _stub_router_short_circuit() -> MagicMock:
|
||||
"""AI-router stand-in returning ``None``.
|
||||
|
||||
Gather records ``(ai_called=False, skip_reason='router_short_circuit')``
|
||||
per ``src/phase_z2_ai_fallback/step12.py:210-213`` when the router
|
||||
returns ``None``. Asserting that pair on the reject record proves the
|
||||
router was actually invoked — vs. the earlier ``not_provisional`` or
|
||||
``route_not_ai_adaptation:<hint>`` skips which would indicate the
|
||||
router was NEVER reached (the IMP-86 pre-fix failure mode, which
|
||||
manifested as a ``ValueError`` crash before Step 12 even fired).
|
||||
"""
|
||||
return MagicMock(return_value=None)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_integration_reject_override_reaches_step12_without_value_error(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
"""mdx03 + ``--override-frame 03-2=bim_dx_comparison_table`` must reach
|
||||
Step 12 AI router without raising ``heights_px`` ValueError, and the
|
||||
Step 12 audit artifact must reflect router reach on the provisional
|
||||
reject unit.
|
||||
"""
|
||||
if not _SAMPLE_MDX_PATH.is_file():
|
||||
pytest.skip(f"sample MDX not present: {_SAMPLE_MDX_PATH}")
|
||||
|
||||
monkeypatch.setattr(pz2, "RUNS_DIR", tmp_path / "runs")
|
||||
router = _stub_router_short_circuit()
|
||||
monkeypatch.setattr(step12_mod, "route_ai_fallback", router)
|
||||
|
||||
run_id = "imp86_u4_reject_override_integration"
|
||||
pz2.run_phase_z2_mvp1(
|
||||
_SAMPLE_MDX_PATH,
|
||||
run_id=run_id,
|
||||
override_frames={"03-2": "bim_dx_comparison_table"},
|
||||
)
|
||||
|
||||
run_dir = tmp_path / "runs" / run_id / "phase_z2"
|
||||
|
||||
# Guardrail 1 — step12_ai_repair.json present. Pre-IMP-86, the
|
||||
# pipeline crashed at _compute_per_zone_geometry before Step 12 fired
|
||||
# so this artifact was MISSING under reject override (issue body
|
||||
# primary symptom: "AI 호출 0 / step12_ai_repair.json 미생성").
|
||||
step12_path = run_dir / "steps" / "step12_ai_repair.json"
|
||||
assert step12_path.is_file(), (
|
||||
f"step12_ai_repair.json missing at {step12_path} — pre-IMP-86 "
|
||||
"pipeline crashed at _compute_per_zone_geometry ValueError "
|
||||
"before Step 12 fired."
|
||||
)
|
||||
step12_data = json.loads(step12_path.read_text(encoding="utf-8"))
|
||||
per_unit = step12_data["data"]["per_unit"]
|
||||
assert len(per_unit) >= 1, "step12 per_unit must contain at least one record"
|
||||
|
||||
# Guardrail 2 — provisional reject unit reached the AI router.
|
||||
# _apply_frame_override_to_unit (src/phase_z2_pipeline.py:1199-1208)
|
||||
# promotes the unit to provisional + label='reject' when the override
|
||||
# target matches a V4 reject judgment; the IMP-47B u1 route map then
|
||||
# sends reject → 'ai_adaptation_required'.
|
||||
reject_records = [
|
||||
r for r in per_unit if r.get("source_section_ids") == ["03-2"]
|
||||
]
|
||||
assert len(reject_records) == 1, (
|
||||
f"expected exactly one per_unit record for 03-2; got {reject_records}"
|
||||
)
|
||||
reject = reject_records[0]
|
||||
assert reject["provisional"] is True, (
|
||||
"03-2 unit must be provisional after reject override per "
|
||||
"_apply_frame_override_to_unit reject-judgment promotion"
|
||||
)
|
||||
assert reject["route_hint"] == "ai_adaptation_required", (
|
||||
f"03-2 route_hint must be 'ai_adaptation_required' "
|
||||
f"(IMP-47B u1 reject→AI map); got {reject['route_hint']}"
|
||||
)
|
||||
# The stub router returned None → gather records router_short_circuit.
|
||||
# This pair is the explicit "router reached" surface; the alternative
|
||||
# skips (not_provisional / route_not_ai_adaptation:*) would mean the
|
||||
# router was NEVER called — the IMP-86 pre-fix failure mode.
|
||||
assert reject["skip_reason"] == "router_short_circuit", (
|
||||
f"03-2 must reach the (stubbed) AI router and record "
|
||||
f"router_short_circuit; got skip_reason={reject['skip_reason']}, "
|
||||
f"error={reject.get('error')}"
|
||||
)
|
||||
assert reject["ai_called"] is False
|
||||
assert reject["error"] is None
|
||||
router.assert_called()
|
||||
|
||||
# Guardrail 3 — pipeline reached Step 20 (no heights_px ValueError).
|
||||
# The pre-IMP-86 crash happened BEFORE the Step 7 layout artifact, so
|
||||
# the Step 20 final-status artifact is the strongest "pipeline did
|
||||
# not crash mid-flight" signal.
|
||||
step20_path = run_dir / "steps" / "step20_slide_status.json"
|
||||
assert step20_path.is_file(), (
|
||||
f"step20_slide_status.json missing at {step20_path} — pipeline "
|
||||
"did not reach final step (likely crashed mid-pipeline)."
|
||||
)
|
||||
|
||||
# Guardrail 4 — debug.json adapter_needed marker. u1 placeholder
|
||||
# writes a parallel zones_data + debug_zone record, but
|
||||
# adapter_needed_units stays the authoritative adapter signal for
|
||||
# debug.json consumers. u5 adds finer per-placeholder telemetry
|
||||
# (adapter_needed=True / mapper_fit_error) on the placeholder records
|
||||
# themselves (asserted in Guardrail 5 below).
|
||||
# write_debug_json (src/phase_z2_pipeline.py:3254-3292) nests the
|
||||
# slide_status payload from compute_slide_status (src/phase_z2_pipeline.py:2939-3128)
|
||||
# under the top-level "slide_status" key, so adapter_needed_count and
|
||||
# adapter_needed_units are addressed via debug["slide_status"][...]
|
||||
# — not at the top level.
|
||||
debug_path = run_dir / "debug.json"
|
||||
assert debug_path.is_file(), f"debug.json missing at {debug_path}"
|
||||
debug = json.loads(debug_path.read_text(encoding="utf-8"))
|
||||
slide_status = debug.get("slide_status") or {}
|
||||
assert slide_status.get("adapter_needed_count", 0) >= 1, (
|
||||
f"debug.json slide_status.adapter_needed_count must reflect the "
|
||||
f"FitError on 03-2; got {slide_status.get('adapter_needed_count')}"
|
||||
)
|
||||
adapter_units = slide_status.get("adapter_needed_units") or []
|
||||
assert any(
|
||||
"03-2" in (a.get("source_section_ids") or []) for a in adapter_units
|
||||
), (
|
||||
f"slide_status.adapter_needed_units must include the 03-2 reject "
|
||||
f"override unit; got {adapter_units}"
|
||||
)
|
||||
|
||||
# Guardrail 5 (IMP-86 u5) — per-placeholder telemetry on debug.json
|
||||
# zones[] entry. Source: src/phase_z2_pipeline.py FitError handler
|
||||
# appends adapter_needed=True + mapper_fit_error=<str(e)> + provisional
|
||||
# on the placeholder debug_zone record so consumers reading the zones
|
||||
# array alone (without joining against slide_status) can identify the
|
||||
# adapter contract surface. adapter_needed_units stays the
|
||||
# authoritative per-slide list (preserved by Guardrail 4 above).
|
||||
debug_zones = debug.get("zones") or []
|
||||
reject_zones = [
|
||||
z for z in debug_zones if list(z.get("source_section_ids") or []) == ["03-2"]
|
||||
]
|
||||
assert len(reject_zones) == 1, (
|
||||
f"expected exactly one debug zones[] record for 03-2 (placeholder "
|
||||
f"from FitError handler); got {reject_zones}"
|
||||
)
|
||||
reject_zone = reject_zones[0]
|
||||
assert reject_zone.get("adapter_needed") is True, (
|
||||
f"debug zones[] placeholder for 03-2 must set adapter_needed=True; "
|
||||
f"got {reject_zone.get('adapter_needed')!r}"
|
||||
)
|
||||
fit_err_msg = reject_zone.get("mapper_fit_error")
|
||||
assert isinstance(fit_err_msg, str) and fit_err_msg, (
|
||||
f"debug zones[] placeholder for 03-2 must carry a non-empty "
|
||||
f"mapper_fit_error string (FitError raised by map_mdx_to_slots); "
|
||||
f"got {fit_err_msg!r}"
|
||||
)
|
||||
assert reject_zone.get("provisional") is True, (
|
||||
f"debug zones[] placeholder for 03-2 must mirror the unit "
|
||||
f"provisional state (reject override → provisional=True); got "
|
||||
f"{reject_zone.get('provisional')!r}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_integration_default_path_no_override_no_regression(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
"""mdx03 default path (no override) must still run end-to-end. The u1
|
||||
placeholder + u2 invariant guard must be no-ops when the mapper
|
||||
succeeds for every unit (default mdx03 path; the FitError branch is
|
||||
not entered).
|
||||
"""
|
||||
if not _SAMPLE_MDX_PATH.is_file():
|
||||
pytest.skip(f"sample MDX not present: {_SAMPLE_MDX_PATH}")
|
||||
|
||||
monkeypatch.setattr(pz2, "RUNS_DIR", tmp_path / "runs")
|
||||
monkeypatch.setattr(
|
||||
step12_mod, "route_ai_fallback", _stub_router_short_circuit()
|
||||
)
|
||||
|
||||
run_id = "imp86_u4_default_path_regression"
|
||||
pz2.run_phase_z2_mvp1(_SAMPLE_MDX_PATH, run_id=run_id)
|
||||
|
||||
run_dir = tmp_path / "runs" / run_id / "phase_z2"
|
||||
# Pre-Step 12 crash would surface as both artifacts missing; default
|
||||
# path must produce both.
|
||||
assert (run_dir / "steps" / "step12_ai_repair.json").is_file(), (
|
||||
"step12_ai_repair.json missing on default mdx03 path — u1/u2 "
|
||||
"must be no-ops when mapper succeeds for every unit."
|
||||
)
|
||||
assert (run_dir / "steps" / "step20_slide_status.json").is_file(), (
|
||||
"step20_slide_status.json missing on default mdx03 path — "
|
||||
"pipeline did not complete."
|
||||
)
|
||||
@@ -86,7 +86,7 @@ def test_router_returns_none_when_route_not_ai_adaptation(monkeypatch):
|
||||
def test_router_returns_cached_when_cache_hit(monkeypatch):
|
||||
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
|
||||
cached = _make_proposal()
|
||||
monkeypatch.setattr(router_mod, "read_proposal", lambda key: cached)
|
||||
monkeypatch.setattr(router_mod, "read_proposal", lambda key, **_: cached)
|
||||
client = MagicMock(spec=AiFallbackClient)
|
||||
result = route_ai_fallback(**_call_kwargs(), client=client)
|
||||
assert result is cached
|
||||
@@ -99,16 +99,97 @@ def test_router_validates_cached_proposal(monkeypatch):
|
||||
proposal_kind=ProposalKind.BUILDER_OPTIONS_PATCH,
|
||||
payload={"unknown_key": "x"},
|
||||
)
|
||||
monkeypatch.setattr(router_mod, "read_proposal", lambda key: bad_cached)
|
||||
monkeypatch.setattr(router_mod, "read_proposal", lambda key, **_: bad_cached)
|
||||
client = MagicMock(spec=AiFallbackClient)
|
||||
with pytest.raises(AiFallbackValidationError):
|
||||
route_ai_fallback(**_call_kwargs(), client=client)
|
||||
client.request_proposal.assert_not_called()
|
||||
|
||||
|
||||
def test_router_forwards_fingerprints_and_misses_on_mismatch(monkeypatch):
|
||||
"""Scope: router-level (IMP-46 #62 Axis R u2).
|
||||
|
||||
When caller supplies ``fingerprints`` and the cache layer returns
|
||||
``None`` (simulating a strict-equality mismatch against the stored
|
||||
contract/partial/catalog SHA), the router proceeds to call the
|
||||
client. The exact ``fingerprints`` dict supplied by the caller must
|
||||
appear in the kwargs passed to ``read_proposal``.
|
||||
"""
|
||||
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
|
||||
captured: dict = {}
|
||||
|
||||
def _spy_read_proposal(key, *, fingerprints=None):
|
||||
captured["key"] = key
|
||||
captured["fingerprints"] = fingerprints
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(router_mod, "read_proposal", _spy_read_proposal)
|
||||
proposal = _make_proposal()
|
||||
client = MagicMock(spec=AiFallbackClient)
|
||||
client.request_proposal.return_value = proposal
|
||||
supplied = {"contract_sha": "aaa", "partial_sha": "bbb", "catalog_sha": "ccc"}
|
||||
result = route_ai_fallback(
|
||||
**_call_kwargs(), client=client, fingerprints=supplied
|
||||
)
|
||||
assert result is proposal
|
||||
assert captured["fingerprints"] == supplied
|
||||
client.request_proposal.assert_called_once()
|
||||
|
||||
|
||||
def test_router_forwards_fingerprints_and_hits_on_match(monkeypatch):
|
||||
"""Scope: router-level (IMP-46 #62 Axis R u2).
|
||||
|
||||
When caller supplies ``fingerprints`` and the cache layer returns a
|
||||
cached proposal (simulating strict-equality match against stored
|
||||
SHA bundle), the router short-circuits without calling the client.
|
||||
The forwarded kwarg must equal the caller-supplied dict exactly.
|
||||
"""
|
||||
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
|
||||
cached = _make_proposal()
|
||||
captured: dict = {}
|
||||
|
||||
def _spy_read_proposal(key, *, fingerprints=None):
|
||||
captured["fingerprints"] = fingerprints
|
||||
return cached
|
||||
|
||||
monkeypatch.setattr(router_mod, "read_proposal", _spy_read_proposal)
|
||||
client = MagicMock(spec=AiFallbackClient)
|
||||
supplied = {"contract_sha": "xxx", "partial_sha": "yyy", "catalog_sha": "zzz"}
|
||||
result = route_ai_fallback(
|
||||
**_call_kwargs(), client=client, fingerprints=supplied
|
||||
)
|
||||
assert result is cached
|
||||
assert captured["fingerprints"] == supplied
|
||||
client.request_proposal.assert_not_called()
|
||||
|
||||
|
||||
def test_router_forwards_fingerprints_none_for_legacy_callers(monkeypatch):
|
||||
"""Scope: router-level (IMP-46 #62 Axis R u2).
|
||||
|
||||
Legacy callers that omit the ``fingerprints`` kwarg must result in
|
||||
``read_proposal`` being invoked with ``fingerprints=None`` (cache
|
||||
layer skips fingerprint comparison — legacy no-invalidation
|
||||
behaviour preserved).
|
||||
"""
|
||||
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
|
||||
cached = _make_proposal()
|
||||
captured: dict = {}
|
||||
|
||||
def _spy_read_proposal(key, *, fingerprints=None):
|
||||
captured["fingerprints"] = fingerprints
|
||||
return cached
|
||||
|
||||
monkeypatch.setattr(router_mod, "read_proposal", _spy_read_proposal)
|
||||
client = MagicMock(spec=AiFallbackClient)
|
||||
result = route_ai_fallback(**_call_kwargs(), client=client)
|
||||
assert result is cached
|
||||
assert captured["fingerprints"] is None
|
||||
client.request_proposal.assert_not_called()
|
||||
|
||||
|
||||
def test_router_calls_client_and_returns_validated_proposal(monkeypatch):
|
||||
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
|
||||
monkeypatch.setattr(router_mod, "read_proposal", lambda key: None)
|
||||
monkeypatch.setattr(router_mod, "read_proposal", lambda key, **_: None)
|
||||
proposal = _make_proposal()
|
||||
client = MagicMock(spec=AiFallbackClient)
|
||||
client.request_proposal.return_value = proposal
|
||||
@@ -121,7 +202,7 @@ def test_router_calls_client_and_returns_validated_proposal(monkeypatch):
|
||||
|
||||
def test_router_propagates_validation_error(monkeypatch):
|
||||
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
|
||||
monkeypatch.setattr(router_mod, "read_proposal", lambda key: None)
|
||||
monkeypatch.setattr(router_mod, "read_proposal", lambda key, **_: None)
|
||||
bad = AiFallbackProposal(
|
||||
proposal_kind=ProposalKind.BUILDER_OPTIONS_PATCH,
|
||||
payload={"unknown_key": "x"},
|
||||
@@ -134,7 +215,7 @@ def test_router_propagates_validation_error(monkeypatch):
|
||||
|
||||
def test_router_propagates_budget_exceeded(monkeypatch):
|
||||
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
|
||||
monkeypatch.setattr(router_mod, "read_proposal", lambda key: None)
|
||||
monkeypatch.setattr(router_mod, "read_proposal", lambda key, **_: None)
|
||||
client = MagicMock(spec=AiFallbackClient)
|
||||
client.request_proposal.side_effect = AiFallbackBudgetExceeded("over")
|
||||
with pytest.raises(AiFallbackBudgetExceeded):
|
||||
@@ -143,7 +224,7 @@ def test_router_propagates_budget_exceeded(monkeypatch):
|
||||
|
||||
def test_router_propagates_circuit_open(monkeypatch):
|
||||
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
|
||||
monkeypatch.setattr(router_mod, "read_proposal", lambda key: None)
|
||||
monkeypatch.setattr(router_mod, "read_proposal", lambda key, **_: None)
|
||||
client = MagicMock(spec=AiFallbackClient)
|
||||
client.request_proposal.side_effect = AiFallbackCircuitOpen("tripped")
|
||||
with pytest.raises(AiFallbackCircuitOpen):
|
||||
|
||||
@@ -500,3 +500,105 @@ def test_production_non_provisional_reject_skipped_before_route_gate(monkeypatch
|
||||
assert records[0]["skip_reason"] == "not_provisional"
|
||||
assert records[0]["ai_called"] is False
|
||||
router.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# IMP-46 u4 — Step 12 ↔ router fingerprints forwarding (integration scope)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Locks the producer→consumer wiring added in u3: Step 12 builds the
|
||||
# fingerprints dict at step12.py:179-185, stamps it onto record["fingerprints"]
|
||||
# at step12.py:185, and now (post-u3) forwards the SAME object into
|
||||
# route_ai_fallback via the fingerprints= kwarg at step12.py:203. These tests
|
||||
# assert end-to-end forwarding (router receives exactly record["fingerprints"]
|
||||
# for AI-eligible units; router untouched and record["fingerprints"] is None
|
||||
# for skipped / non-AI records).
|
||||
|
||||
|
||||
def test_router_receives_exactly_record_fingerprints_for_ai_eligible(monkeypatch):
|
||||
"""Integration scope: route_ai_fallback fingerprints kwarg == record['fingerprints']."""
|
||||
router = MagicMock(return_value=None)
|
||||
monkeypatch.setattr(step12_mod, "route_ai_fallback", router)
|
||||
contract = {"frame_id": "fid_123", "payload": {"k": "v"}, "sub_zones": []}
|
||||
partial = {"deeper": [9, 8, 7], "shallow": "x"}
|
||||
catalog_value = "c0ffee00" * 8
|
||||
recs = _call(
|
||||
[_ai_unit()],
|
||||
get_contract_fn=lambda _t: contract,
|
||||
figma_partial_loader=lambda _t: partial,
|
||||
catalog_sha_loader=lambda: catalog_value,
|
||||
)
|
||||
record_fingerprints = recs[0]["fingerprints"]
|
||||
router.assert_called_once()
|
||||
forwarded = router.call_args.kwargs["fingerprints"]
|
||||
# Strict equality: same keys, same SHA values — Step 12 producer is wired
|
||||
# to the router consumer with no transformation between them.
|
||||
assert forwarded == record_fingerprints
|
||||
assert forwarded == {
|
||||
"contract_sha": hashlib.sha256(
|
||||
json.dumps(contract, sort_keys=True, ensure_ascii=False).encode("utf-8")
|
||||
).hexdigest(),
|
||||
"partial_sha": hashlib.sha256(
|
||||
json.dumps(partial, sort_keys=True, ensure_ascii=False).encode("utf-8")
|
||||
).hexdigest(),
|
||||
"catalog_sha": catalog_value,
|
||||
}
|
||||
|
||||
|
||||
def test_router_fingerprints_kwarg_is_present_even_with_default_catalog(monkeypatch):
|
||||
"""Integration scope: fingerprints kwarg is supplied (not omitted) even when catalog_sha defaults to ''."""
|
||||
router = MagicMock(return_value=None)
|
||||
monkeypatch.setattr(step12_mod, "route_ai_fallback", router)
|
||||
_call([_ai_unit()])
|
||||
router.assert_called_once()
|
||||
# The fingerprints kwarg must be present in the call (not relying on the
|
||||
# router's default None) — proves step12.py:203 forwards explicitly.
|
||||
assert "fingerprints" in router.call_args.kwargs
|
||||
forwarded = router.call_args.kwargs["fingerprints"]
|
||||
assert isinstance(forwarded, dict)
|
||||
assert set(forwarded.keys()) == {"contract_sha", "partial_sha", "catalog_sha"}
|
||||
assert forwarded["catalog_sha"] == "" # default sentinel, still forwarded
|
||||
|
||||
|
||||
def test_router_not_called_and_fingerprints_none_for_non_provisional(monkeypatch):
|
||||
"""Integration scope: non-provisional unit → router untouched, record['fingerprints'] is None."""
|
||||
router = MagicMock(return_value=None)
|
||||
monkeypatch.setattr(step12_mod, "route_ai_fallback", router)
|
||||
recs = _call([FakeUnit(label="restructure", provisional=False)])
|
||||
router.assert_not_called()
|
||||
assert recs[0]["ai_called"] is False
|
||||
assert recs[0]["skip_reason"] == "not_provisional"
|
||||
assert recs[0]["fingerprints"] is None
|
||||
assert recs[0]["cache_key"] is None
|
||||
|
||||
|
||||
def test_router_not_called_and_fingerprints_none_for_non_ai_route(monkeypatch):
|
||||
"""Integration scope: light_edit (non-AI route) → router untouched, record['fingerprints'] is None."""
|
||||
router = MagicMock(return_value=None)
|
||||
monkeypatch.setattr(step12_mod, "route_ai_fallback", router)
|
||||
recs = _call([FakeUnit(label="light_edit", provisional=True)])
|
||||
router.assert_not_called()
|
||||
assert recs[0]["ai_called"] is False
|
||||
assert recs[0]["skip_reason"] == (
|
||||
"route_not_ai_adaptation:deterministic_minor_adjustment"
|
||||
)
|
||||
assert recs[0]["fingerprints"] is None
|
||||
assert recs[0]["cache_key"] is None
|
||||
|
||||
|
||||
def test_mixed_units_router_receives_fingerprints_only_for_ai_eligible(monkeypatch):
|
||||
"""Integration scope: in a mixed batch, only the AI-eligible unit forwards fingerprints to router."""
|
||||
router = MagicMock(return_value=None)
|
||||
monkeypatch.setattr(step12_mod, "route_ai_fallback", router)
|
||||
units = [
|
||||
FakeUnit(label="restructure", provisional=False), # not_provisional
|
||||
FakeUnit(label="light_edit", provisional=True), # non-AI route
|
||||
_ai_unit(), # AI-eligible
|
||||
]
|
||||
recs = _call(units)
|
||||
# Exactly one router invocation — the AI-eligible unit.
|
||||
router.assert_called_once()
|
||||
forwarded = router.call_args.kwargs["fingerprints"]
|
||||
assert forwarded == recs[2]["fingerprints"]
|
||||
# Skipped records carry None.
|
||||
assert recs[0]["fingerprints"] is None
|
||||
assert recs[1]["fingerprints"] is None
|
||||
|
||||
@@ -21,8 +21,10 @@ from src.phase_z2_ai_fallback import step17 as step17_mod
|
||||
from src.phase_z2_ai_fallback.step17 import (
|
||||
OVERFLOW_CASCADE_ORDER,
|
||||
STEP17_AI_REPAIR_BLOCKED_REASON,
|
||||
STEP17_POPUP_SPLIT_DECISION_API_GATED_REASON,
|
||||
OverflowCascadeStage,
|
||||
gather_step17_ai_repair_proposals,
|
||||
gather_step17_popup_split_decisions,
|
||||
)
|
||||
|
||||
|
||||
@@ -163,6 +165,160 @@ def test_gather_with_empty_units_returns_empty_list():
|
||||
assert records == []
|
||||
|
||||
|
||||
# ─── IMP-35 u4: POPUP cascade AI split-decision contract (API gated) ─────
|
||||
|
||||
|
||||
def test_popup_split_decision_api_gated_reason_constant_value():
|
||||
"""u4 binding contract — API-gated skip_reason is a stable, machine-readable
|
||||
constant that downstream consumers can distinguish from the AI_REPAIR
|
||||
block reason. Never collide with STEP17_AI_REPAIR_BLOCKED_REASON."""
|
||||
assert (
|
||||
STEP17_POPUP_SPLIT_DECISION_API_GATED_REASON
|
||||
== "step17_popup_split_decision_api_gated"
|
||||
)
|
||||
assert (
|
||||
STEP17_POPUP_SPLIT_DECISION_API_GATED_REASON
|
||||
!= STEP17_AI_REPAIR_BLOCKED_REASON
|
||||
)
|
||||
|
||||
|
||||
def test_popup_split_decision_returns_one_record_per_unit():
|
||||
units = [
|
||||
FakeUnit(label="restructure", provisional=True),
|
||||
FakeUnit(label="reject", provisional=False),
|
||||
FakeUnit(label="use_as_is", provisional=True),
|
||||
]
|
||||
records = gather_step17_popup_split_decisions(
|
||||
units, route_for_label=_route_for_label
|
||||
)
|
||||
assert len(records) == 3
|
||||
|
||||
|
||||
def test_popup_split_decision_cascade_stage_is_popup():
|
||||
"""u4 — cascade_stage must mark these records as the POPUP stage, NOT
|
||||
AI_REPAIR. This lets consumers multiplex POPUP and AI_REPAIR records on
|
||||
the same retry trace without ambiguity."""
|
||||
units = [FakeUnit(label="restructure", provisional=True)]
|
||||
record = gather_step17_popup_split_decisions(
|
||||
units, route_for_label=_route_for_label
|
||||
)[0]
|
||||
assert record["cascade_stage"] == OverflowCascadeStage.POPUP.value
|
||||
assert record["cascade_stage"] != OverflowCascadeStage.AI_REPAIR.value
|
||||
|
||||
|
||||
def test_popup_split_decision_api_gated_flag_true():
|
||||
"""u4 — api_gated=True everywhere. The flag is the primary state signal
|
||||
consumers read to decide whether the AI hook is active."""
|
||||
units = [FakeUnit(label="restructure", provisional=True)]
|
||||
record = gather_step17_popup_split_decisions(
|
||||
units, route_for_label=_route_for_label
|
||||
)[0]
|
||||
assert record["api_gated"] is True
|
||||
|
||||
|
||||
def test_popup_split_decision_ai_called_is_false_and_no_proposal():
|
||||
"""u4 — ai_called=False, split_decision=None, error=None. The hook is the
|
||||
contract surface only; the Anthropic API is NOT invoked at u4."""
|
||||
units = [FakeUnit(label="restructure", provisional=True)]
|
||||
record = gather_step17_popup_split_decisions(
|
||||
units, route_for_label=_route_for_label
|
||||
)[0]
|
||||
assert record["ai_called"] is False
|
||||
assert record["split_decision"] is None
|
||||
assert record["error"] is None
|
||||
|
||||
|
||||
def test_popup_split_decision_skip_reason_is_api_gated():
|
||||
"""u4 — every record carries the API-gated skip_reason regardless of
|
||||
label / provisional / route_hint."""
|
||||
units = [
|
||||
FakeUnit(label="restructure", provisional=True),
|
||||
FakeUnit(label="reject", provisional=False),
|
||||
FakeUnit(label="use_as_is", provisional=True),
|
||||
FakeUnit(label=None, provisional=False),
|
||||
]
|
||||
records = gather_step17_popup_split_decisions(
|
||||
units, route_for_label=_route_for_label
|
||||
)
|
||||
for record in records:
|
||||
assert (
|
||||
record["skip_reason"]
|
||||
== STEP17_POPUP_SPLIT_DECISION_API_GATED_REASON
|
||||
)
|
||||
|
||||
|
||||
def test_popup_split_decision_honors_route_for_label():
|
||||
"""u4 — route_for_label callable is applied per unit. Verifies the hook
|
||||
surface accepts the same label→route mapping as the AI_REPAIR path."""
|
||||
units = [
|
||||
FakeUnit(label="restructure", provisional=True),
|
||||
FakeUnit(label="reject", provisional=False),
|
||||
FakeUnit(label="use_as_is", provisional=True),
|
||||
FakeUnit(label="light_edit", provisional=False),
|
||||
FakeUnit(label=None, provisional=False),
|
||||
]
|
||||
records = gather_step17_popup_split_decisions(
|
||||
units, route_for_label=_route_for_label
|
||||
)
|
||||
assert [r["route_hint"] for r in records] == [
|
||||
"ai_adaptation_required",
|
||||
"design_reference_only",
|
||||
"direct_render",
|
||||
"deterministic_minor_adjustment",
|
||||
None,
|
||||
]
|
||||
|
||||
|
||||
def test_popup_split_decision_preserves_unit_metadata():
|
||||
"""u4 — schema mirrors gather_step17_ai_repair_proposals (unit_index,
|
||||
source_section_ids, frame_template_id, label, provisional)."""
|
||||
units = [
|
||||
FakeUnit(
|
||||
label="restructure",
|
||||
provisional=True,
|
||||
frame_template_id="frame_05_overview",
|
||||
source_section_ids=["s1", "s2"],
|
||||
)
|
||||
]
|
||||
record = gather_step17_popup_split_decisions(
|
||||
units, route_for_label=_route_for_label
|
||||
)[0]
|
||||
assert record["unit_index"] == 0
|
||||
assert record["frame_template_id"] == "frame_05_overview"
|
||||
assert record["source_section_ids"] == ["s1", "s2"]
|
||||
assert record["label"] == "restructure"
|
||||
assert record["provisional"] is True
|
||||
|
||||
|
||||
def test_popup_split_decision_with_empty_units_returns_empty_list():
|
||||
records = gather_step17_popup_split_decisions(
|
||||
[], route_for_label=_route_for_label
|
||||
)
|
||||
assert records == []
|
||||
|
||||
|
||||
def test_popup_split_decision_record_schema_disjoint_from_ai_repair_extras():
|
||||
"""u4 — POPUP record must carry api_gated + split_decision keys; the
|
||||
AI_REPAIR record carries proposal (not split_decision). This lock keeps
|
||||
the two contract surfaces machine-distinguishable on the retry trace."""
|
||||
units = [FakeUnit(label="restructure", provisional=True)]
|
||||
popup_rec = gather_step17_popup_split_decisions(
|
||||
units, route_for_label=_route_for_label
|
||||
)[0]
|
||||
ai_repair_rec = gather_step17_ai_repair_proposals(
|
||||
units, route_for_label=_route_for_label
|
||||
)[0]
|
||||
# POPUP-specific keys
|
||||
assert "api_gated" in popup_rec
|
||||
assert "split_decision" in popup_rec
|
||||
# AI_REPAIR-specific key
|
||||
assert "proposal" in ai_repair_rec
|
||||
# Disjoint payload keys (the two contracts must NOT cross-leak):
|
||||
assert "proposal" not in popup_rec
|
||||
assert "split_decision" not in ai_repair_rec
|
||||
assert "api_gated" not in ai_repair_rec
|
||||
|
||||
|
||||
# ─── Structural guarantee: u9 must NOT import route_ai_fallback / anthropic ─
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
"""IMP-#85 u3a — Audit CLI invariants I1-I3.
|
||||
|
||||
Scope (Stage 2 lock):
|
||||
I1 partial existence — `templates/phase_z2/families/{template_id}.html`
|
||||
must exist for live (non-VP) contracts.
|
||||
I2 builder declared — live contracts must declare non-empty
|
||||
`payload.builder`.
|
||||
I3 builder registered — declared builders must be in PAYLOAD_BUILDERS.
|
||||
|
||||
`visual_pending: true` skipped for all of I1-I3 (data-driven from catalog,
|
||||
no hard-coded frame allow-list; matches u2 invariant scope).
|
||||
|
||||
Out of scope (별 axis):
|
||||
- I4 slot_payload references (u3b).
|
||||
- V4 runtime VP filter (u4).
|
||||
- Implementing the 17 missing VP builders.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
SCRIPT_PATH = REPO_ROOT / "scripts" / "audit_frame_invariants.py"
|
||||
|
||||
|
||||
def _write_yaml(path: Path, payload: dict) -> Path:
|
||||
path.write_text(yaml.safe_dump(payload, sort_keys=False), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def _run_cli(catalog: Path, partials: Path) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(SCRIPT_PATH),
|
||||
"--catalog",
|
||||
str(catalog),
|
||||
"--partials-dir",
|
||||
str(partials),
|
||||
],
|
||||
cwd=str(REPO_ROOT),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def test_prod_catalog_audit_passes(tmp_path):
|
||||
"""Prod catalog + prod partials dir → I1-I3 PASS (live contracts clean)."""
|
||||
from scripts.audit_frame_invariants import (
|
||||
DEFAULT_CATALOG_PATH,
|
||||
DEFAULT_PARTIALS_DIR,
|
||||
run_audit,
|
||||
)
|
||||
|
||||
violations = run_audit(DEFAULT_CATALOG_PATH, DEFAULT_PARTIALS_DIR)
|
||||
assert violations == [], (
|
||||
"Prod live contracts (non-VP) must satisfy I1-I3 invariants. "
|
||||
f"Got: {violations}"
|
||||
)
|
||||
|
||||
|
||||
def test_i1_partial_missing_for_live_contract(tmp_path):
|
||||
"""Live contract without families/{template_id}.html → I1 violation."""
|
||||
from src.phase_z2_mapper import PAYLOAD_BUILDERS
|
||||
from scripts.audit_frame_invariants import check_i1_partial_existence
|
||||
|
||||
sample_builder = next(iter(PAYLOAD_BUILDERS.keys()))
|
||||
catalog = {
|
||||
"missing_partial_frame": {
|
||||
"template_id": "missing_partial_frame",
|
||||
"payload": {"builder": sample_builder},
|
||||
},
|
||||
}
|
||||
partials_dir = tmp_path / "families"
|
||||
partials_dir.mkdir()
|
||||
violations = check_i1_partial_existence(catalog, partials_dir)
|
||||
assert len(violations) == 1
|
||||
assert "I1 partial-missing" in violations[0]
|
||||
assert "missing_partial_frame" in violations[0]
|
||||
|
||||
|
||||
def test_i1_partial_present_no_violation(tmp_path):
|
||||
"""Live contract with partial on disk → no I1 violation."""
|
||||
from scripts.audit_frame_invariants import check_i1_partial_existence
|
||||
|
||||
catalog = {
|
||||
"ok_frame": {
|
||||
"template_id": "ok_frame",
|
||||
"payload": {"builder": "items_with_role"},
|
||||
},
|
||||
}
|
||||
partials_dir = tmp_path / "families"
|
||||
partials_dir.mkdir()
|
||||
(partials_dir / "ok_frame.html").write_text("<div/>", encoding="utf-8")
|
||||
assert check_i1_partial_existence(catalog, partials_dir) == []
|
||||
|
||||
|
||||
def test_i1_skips_visual_pending(tmp_path):
|
||||
"""visual_pending: true with no partial → I1 skip (no violation)."""
|
||||
from scripts.audit_frame_invariants import check_i1_partial_existence
|
||||
|
||||
catalog = {
|
||||
"vp_frame": {
|
||||
"template_id": "vp_frame",
|
||||
"visual_pending": True,
|
||||
"payload": {"builder": "definitely_not_registered"},
|
||||
},
|
||||
}
|
||||
partials_dir = tmp_path / "families"
|
||||
partials_dir.mkdir()
|
||||
assert check_i1_partial_existence(catalog, partials_dir) == []
|
||||
|
||||
|
||||
def test_i2_missing_builder_field():
|
||||
"""Live contract without payload.builder → I2 violation."""
|
||||
from scripts.audit_frame_invariants import check_i2_builder_declared
|
||||
|
||||
catalog = {
|
||||
"no_builder_frame": {
|
||||
"template_id": "no_builder_frame",
|
||||
"payload": {},
|
||||
},
|
||||
}
|
||||
violations = check_i2_builder_declared(catalog)
|
||||
assert len(violations) == 1
|
||||
assert "I2 builder-undeclared" in violations[0]
|
||||
assert "no_builder_frame" in violations[0]
|
||||
|
||||
|
||||
def test_i2_skips_visual_pending():
|
||||
"""visual_pending: true without builder → I2 skip."""
|
||||
from scripts.audit_frame_invariants import check_i2_builder_declared
|
||||
|
||||
catalog = {
|
||||
"vp_frame": {
|
||||
"template_id": "vp_frame",
|
||||
"visual_pending": True,
|
||||
"payload": {},
|
||||
},
|
||||
}
|
||||
assert check_i2_builder_declared(catalog) == []
|
||||
|
||||
|
||||
def test_i3_unregistered_builder():
|
||||
"""Live contract with unknown builder → I3 violation."""
|
||||
from scripts.audit_frame_invariants import check_i3_builder_registered
|
||||
|
||||
catalog = {
|
||||
"ghost_frame": {
|
||||
"template_id": "ghost_frame",
|
||||
"payload": {"builder": "ghost_builder_xyz"},
|
||||
},
|
||||
}
|
||||
violations = check_i3_builder_registered(
|
||||
catalog, registered_builders={"items_with_role"}
|
||||
)
|
||||
assert len(violations) == 1
|
||||
assert "I3 builder-unregistered" in violations[0]
|
||||
assert "ghost_frame" in violations[0]
|
||||
assert "ghost_builder_xyz" in violations[0]
|
||||
|
||||
|
||||
def test_i3_registered_builder_passes():
|
||||
"""Live contract with registered builder → no I3 violation."""
|
||||
from scripts.audit_frame_invariants import check_i3_builder_registered
|
||||
|
||||
catalog = {
|
||||
"ok_frame": {
|
||||
"template_id": "ok_frame",
|
||||
"payload": {"builder": "items_with_role"},
|
||||
},
|
||||
}
|
||||
assert check_i3_builder_registered(
|
||||
catalog, registered_builders={"items_with_role"}
|
||||
) == []
|
||||
|
||||
|
||||
def test_i3_skips_visual_pending():
|
||||
"""visual_pending: true with unregistered builder → I3 skip."""
|
||||
from scripts.audit_frame_invariants import check_i3_builder_registered
|
||||
|
||||
catalog = {
|
||||
"vp_frame": {
|
||||
"template_id": "vp_frame",
|
||||
"visual_pending": True,
|
||||
"payload": {"builder": "vp_only_builder"},
|
||||
},
|
||||
}
|
||||
assert check_i3_builder_registered(
|
||||
catalog, registered_builders={"items_with_role"}
|
||||
) == []
|
||||
|
||||
|
||||
def test_cli_exit_zero_on_clean_catalog(tmp_path):
|
||||
"""CLI exit code 0 + PASS line on clean (live) catalog."""
|
||||
catalog_path = tmp_path / "catalog.yaml"
|
||||
partials_dir = tmp_path / "families"
|
||||
partials_dir.mkdir()
|
||||
(partials_dir / "ok_frame.html").write_text("<div/>", encoding="utf-8")
|
||||
_write_yaml(
|
||||
catalog_path,
|
||||
{
|
||||
"ok_frame": {
|
||||
"template_id": "ok_frame",
|
||||
"payload": {"builder": "items_with_role"},
|
||||
},
|
||||
"vp_frame": {
|
||||
"template_id": "vp_frame",
|
||||
"visual_pending": True,
|
||||
"payload": {"builder": "unregistered_xyz"},
|
||||
},
|
||||
},
|
||||
)
|
||||
result = _run_cli(catalog_path, partials_dir)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert "PASS" in result.stdout
|
||||
|
||||
|
||||
def test_cli_exit_one_on_violations(tmp_path):
|
||||
"""CLI exit code 1 + aggregated violations listed on drift."""
|
||||
catalog_path = tmp_path / "catalog.yaml"
|
||||
partials_dir = tmp_path / "families"
|
||||
partials_dir.mkdir()
|
||||
_write_yaml(
|
||||
catalog_path,
|
||||
{
|
||||
"frame_a": {
|
||||
"template_id": "frame_a",
|
||||
"payload": {"builder": "ghost_builder"},
|
||||
},
|
||||
"frame_b": {
|
||||
"template_id": "frame_b",
|
||||
"payload": {},
|
||||
},
|
||||
},
|
||||
)
|
||||
result = _run_cli(catalog_path, partials_dir)
|
||||
assert result.returncode == 1, result.stdout + result.stderr
|
||||
assert "FAIL" in result.stdout
|
||||
assert "frame_a" in result.stdout
|
||||
assert "frame_b" in result.stdout
|
||||
assert "I1" in result.stdout
|
||||
assert "I2" in result.stdout or "I3" in result.stdout
|
||||
@@ -0,0 +1,444 @@
|
||||
"""IMP-#85 u3b — Audit CLI invariant I4 (slot_payload ↔ builder generated keys).
|
||||
|
||||
Scope (Stage 2 lock):
|
||||
I4 slot_payload refs — every key generated by the contract's builder must
|
||||
appear as a `slot_payload.<key>` reference in the
|
||||
partial. Direction A only (dead generated key).
|
||||
Skipped when the partial uses dynamic bracket
|
||||
access (`slot_payload[...]`).
|
||||
|
||||
`visual_pending: true` skipped (data-driven from catalog, matches u2/u3a
|
||||
invariant scope; no hard-coded frame allow-list).
|
||||
|
||||
Out of scope (별 axis):
|
||||
- V4 runtime VP filter (u4).
|
||||
- Catalog regression coverage suite (u5).
|
||||
- Implementing the 17 missing VP builders.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
SCRIPT_PATH = REPO_ROOT / "scripts" / "audit_frame_invariants.py"
|
||||
|
||||
|
||||
def _write_yaml(path: Path, payload: dict) -> Path:
|
||||
path.write_text(yaml.safe_dump(payload, sort_keys=False), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def _run_cli(catalog: Path, partials: Path) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(SCRIPT_PATH),
|
||||
"--catalog",
|
||||
str(catalog),
|
||||
"--partials-dir",
|
||||
str(partials),
|
||||
],
|
||||
cwd=str(REPO_ROOT),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def test_prod_catalog_audit_passes_i4():
|
||||
"""Prod catalog + prod partials dir → no I4 violations on live contracts."""
|
||||
from scripts.audit_frame_invariants import (
|
||||
DEFAULT_CATALOG_PATH,
|
||||
DEFAULT_PARTIALS_DIR,
|
||||
check_i4_slot_payload_refs,
|
||||
)
|
||||
from src.phase_z2_mapper import PAYLOAD_BUILDERS
|
||||
|
||||
catalog = yaml.safe_load(
|
||||
DEFAULT_CATALOG_PATH.read_text(encoding="utf-8")
|
||||
) or {}
|
||||
registered = set(PAYLOAD_BUILDERS.keys())
|
||||
violations = check_i4_slot_payload_refs(
|
||||
catalog, DEFAULT_PARTIALS_DIR, registered
|
||||
)
|
||||
assert violations == [], (
|
||||
"Prod live contracts must satisfy I4 (every generated key is "
|
||||
"referenced by the partial, or partial uses dynamic access). "
|
||||
f"Got: {violations}"
|
||||
)
|
||||
|
||||
|
||||
def test_extract_static_slot_refs_finds_dot_access():
|
||||
from scripts.audit_frame_invariants import extract_static_slot_refs
|
||||
|
||||
partial = (
|
||||
"{{ slot_payload.title }}\n"
|
||||
"{% if slot_payload.foo %}<b>{{ slot_payload.foo }}</b>{% endif %}\n"
|
||||
"{% for x in slot_payload.bar %}{{ x }}{% endfor %}\n"
|
||||
)
|
||||
refs = extract_static_slot_refs(partial)
|
||||
assert refs == {"title", "foo", "bar"}
|
||||
|
||||
|
||||
def test_extract_static_slot_refs_ignores_dynamic_bracket():
|
||||
from scripts.audit_frame_invariants import extract_static_slot_refs
|
||||
|
||||
partial = "{{ slot_payload['pill_' ~ n ~ '_label'] }}"
|
||||
# Dynamic access does NOT contribute dot-access refs.
|
||||
assert extract_static_slot_refs(partial) == set()
|
||||
|
||||
|
||||
def test_partial_uses_dynamic_slot_access_detects_bracket():
|
||||
from scripts.audit_frame_invariants import partial_uses_dynamic_slot_access
|
||||
|
||||
dynamic = "{{ slot_payload['pill_' ~ n ~ '_label'] }}"
|
||||
static = "{{ slot_payload.title }} and {{ slot_payload.body }}"
|
||||
assert partial_uses_dynamic_slot_access(dynamic) is True
|
||||
assert partial_uses_dynamic_slot_access(static) is False
|
||||
|
||||
|
||||
def test_expected_keys_quadrant_flat_slots_default_pattern():
|
||||
from scripts.audit_frame_invariants import expected_payload_keys
|
||||
|
||||
contract = {
|
||||
"payload": {
|
||||
"title": {"source": "section.title"},
|
||||
"builder": "quadrant_flat_slots",
|
||||
"builder_options": {
|
||||
"item_parser": "quadrant_item",
|
||||
"pad_to": 4,
|
||||
"label_key_pattern": "quadrant_{n}_label",
|
||||
"body_key_pattern": "quadrant_{n}_body",
|
||||
},
|
||||
}
|
||||
}
|
||||
keys = expected_payload_keys(contract)
|
||||
assert "title" in keys
|
||||
for n in range(1, 5):
|
||||
assert f"quadrant_{n}_label" in keys
|
||||
assert f"quadrant_{n}_body" in keys
|
||||
|
||||
|
||||
def test_expected_keys_quadrant_flat_slots_custom_pattern():
|
||||
from scripts.audit_frame_invariants import expected_payload_keys
|
||||
|
||||
contract = {
|
||||
"payload": {
|
||||
"title": {"source": "section.title"},
|
||||
"builder": "quadrant_flat_slots",
|
||||
"builder_options": {
|
||||
"item_parser": "quadrant_item",
|
||||
"pad_to": 3,
|
||||
"label_key_pattern": "category_{n}_label",
|
||||
"body_key_pattern": "category_{n}_body",
|
||||
},
|
||||
}
|
||||
}
|
||||
keys = expected_payload_keys(contract)
|
||||
assert keys == {
|
||||
"title",
|
||||
"category_1_label", "category_2_label", "category_3_label",
|
||||
"category_1_body", "category_2_body", "category_3_body",
|
||||
}
|
||||
|
||||
|
||||
def test_expected_keys_cycle_intersect_3():
|
||||
from scripts.audit_frame_invariants import expected_payload_keys
|
||||
|
||||
contract = {
|
||||
"payload": {
|
||||
"title": {"source": "section.title"},
|
||||
"builder": "cycle_intersect_3",
|
||||
"builder_options": {
|
||||
"item_parser": "quadrant_item",
|
||||
"pad_to": 3,
|
||||
"label_key_pattern": "circle_{n}_label",
|
||||
},
|
||||
}
|
||||
}
|
||||
keys = expected_payload_keys(contract)
|
||||
assert keys == {
|
||||
"title", "circle_1_label", "circle_2_label", "circle_3_label",
|
||||
"intersection",
|
||||
}
|
||||
|
||||
|
||||
def test_expected_keys_compare_table_2col():
|
||||
from scripts.audit_frame_invariants import expected_payload_keys
|
||||
|
||||
contract = {
|
||||
"payload": {
|
||||
"title": {"source": "section.title"},
|
||||
"builder": "compare_table_2col",
|
||||
"builder_options": {"item_parser": "compare_row_2col_item"},
|
||||
}
|
||||
}
|
||||
keys = expected_payload_keys(contract)
|
||||
assert keys == {"title", "col_a_label", "col_b_label", "rows"}
|
||||
|
||||
|
||||
def test_expected_keys_paired_rows_4x2_slots():
|
||||
from scripts.audit_frame_invariants import expected_payload_keys
|
||||
|
||||
contract = {
|
||||
"payload": {
|
||||
"title": {"source": "section.title"},
|
||||
"builder": "paired_rows_4x2_slots",
|
||||
"builder_options": {
|
||||
"item_parser": "quadrant_item",
|
||||
"label_key_pattern": "row_{r}_{side}_label",
|
||||
"body_key_pattern": "row_{r}_{side}_body",
|
||||
"rows": 4,
|
||||
"sides": ["left", "right"],
|
||||
},
|
||||
}
|
||||
}
|
||||
keys = expected_payload_keys(contract)
|
||||
assert "title" in keys
|
||||
for r in range(1, 5):
|
||||
for side in ("left", "right"):
|
||||
assert f"row_{r}_{side}_label" in keys
|
||||
assert f"row_{r}_{side}_body" in keys
|
||||
|
||||
|
||||
def test_expected_keys_process_product_pair():
|
||||
from scripts.audit_frame_invariants import expected_payload_keys
|
||||
|
||||
contract = {
|
||||
"payload": {
|
||||
"title": {"source": "section.title"},
|
||||
"builder": "process_product_pair",
|
||||
"builder_options": {
|
||||
"pad_sections_to": 3,
|
||||
"columns": [
|
||||
{"title_to": "banner_left", "body_to": "process",
|
||||
"body_parser": "column_with_transform"},
|
||||
{"title_to": "banner_right", "body_to": "product",
|
||||
"body_parser": "column_with_transform"},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
keys = expected_payload_keys(contract)
|
||||
assert keys == {"title", "banner_left", "process", "banner_right", "product"}
|
||||
|
||||
|
||||
def test_expected_keys_items_with_role():
|
||||
from scripts.audit_frame_invariants import expected_payload_keys
|
||||
|
||||
contract = {
|
||||
"payload": {
|
||||
"title": {"source": "section.title"},
|
||||
"builder": "items_with_role",
|
||||
"builder_options": {
|
||||
"item_parser": "pillar_item",
|
||||
"array_root": "pillars",
|
||||
},
|
||||
}
|
||||
}
|
||||
keys = expected_payload_keys(contract)
|
||||
assert keys == {"title", "pillars"}
|
||||
|
||||
|
||||
def test_i4_dead_generated_key_flagged(tmp_path):
|
||||
"""Builder produces key X, partial doesn't reference it → I4 violation."""
|
||||
from scripts.audit_frame_invariants import check_i4_slot_payload_refs
|
||||
|
||||
partials_dir = tmp_path / "families"
|
||||
partials_dir.mkdir()
|
||||
# Partial only references `title` — missing category_2_label / _body etc.
|
||||
(partials_dir / "drift_frame.html").write_text(
|
||||
"<div>{{ slot_payload.title }}</div>"
|
||||
"<div>{{ slot_payload.category_1_label }}</div>"
|
||||
"<div>{{ slot_payload.category_1_body }}</div>",
|
||||
encoding="utf-8",
|
||||
)
|
||||
catalog = {
|
||||
"drift_frame": {
|
||||
"template_id": "drift_frame",
|
||||
"payload": {
|
||||
"title": {"source": "section.title"},
|
||||
"builder": "quadrant_flat_slots",
|
||||
"builder_options": {
|
||||
"item_parser": "quadrant_item",
|
||||
"pad_to": 2,
|
||||
"label_key_pattern": "category_{n}_label",
|
||||
"body_key_pattern": "category_{n}_body",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
violations = check_i4_slot_payload_refs(
|
||||
catalog, partials_dir, registered_builders={"quadrant_flat_slots"}
|
||||
)
|
||||
msgs = "\n".join(violations)
|
||||
assert "I4 generated-key-orphan" in msgs
|
||||
assert "drift_frame" in msgs
|
||||
assert "category_2_label" in msgs
|
||||
assert "category_2_body" in msgs
|
||||
# category_1 keys ARE referenced — must NOT be flagged.
|
||||
assert "slot_payload.category_1_label." not in msgs
|
||||
assert "slot_payload.category_1_body." not in msgs
|
||||
|
||||
|
||||
def test_i4_skips_partial_with_dynamic_bracket_access(tmp_path):
|
||||
"""Dynamic bracket access in partial → I4 skipped (cannot resolve statically)."""
|
||||
from scripts.audit_frame_invariants import check_i4_slot_payload_refs
|
||||
|
||||
partials_dir = tmp_path / "families"
|
||||
partials_dir.mkdir()
|
||||
(partials_dir / "dynamic_frame.html").write_text(
|
||||
"{{ slot_payload.title }}\n"
|
||||
"{% for n in range(1, 6) %}"
|
||||
"{{ slot_payload['pill_' ~ n ~ '_label'] }}"
|
||||
"{{ slot_payload['pill_' ~ n ~ '_body'] }}"
|
||||
"{% endfor %}",
|
||||
encoding="utf-8",
|
||||
)
|
||||
catalog = {
|
||||
"dynamic_frame": {
|
||||
"template_id": "dynamic_frame",
|
||||
"payload": {
|
||||
"title": {"source": "section.title"},
|
||||
"builder": "quadrant_flat_slots",
|
||||
"builder_options": {
|
||||
"item_parser": "quadrant_item",
|
||||
"pad_to": 5,
|
||||
"label_key_pattern": "pill_{n}_label",
|
||||
"body_key_pattern": "pill_{n}_body",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
violations = check_i4_slot_payload_refs(
|
||||
catalog, partials_dir, registered_builders={"quadrant_flat_slots"}
|
||||
)
|
||||
assert violations == [], (
|
||||
"Dynamic bracket access must suppress I4 (cannot resolve statically); "
|
||||
f"got: {violations}"
|
||||
)
|
||||
|
||||
|
||||
def test_i4_skips_visual_pending(tmp_path):
|
||||
"""VP contract with drift → I4 skip (no violation)."""
|
||||
from scripts.audit_frame_invariants import check_i4_slot_payload_refs
|
||||
|
||||
partials_dir = tmp_path / "families"
|
||||
partials_dir.mkdir()
|
||||
(partials_dir / "vp_frame.html").write_text(
|
||||
"<div>nothing</div>", encoding="utf-8"
|
||||
)
|
||||
catalog = {
|
||||
"vp_frame": {
|
||||
"template_id": "vp_frame",
|
||||
"visual_pending": True,
|
||||
"payload": {
|
||||
"title": {"source": "section.title"},
|
||||
"builder": "quadrant_flat_slots",
|
||||
"builder_options": {
|
||||
"item_parser": "quadrant_item", "pad_to": 4,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
violations = check_i4_slot_payload_refs(
|
||||
catalog, partials_dir, registered_builders={"quadrant_flat_slots"}
|
||||
)
|
||||
assert violations == []
|
||||
|
||||
|
||||
def test_i4_skips_unregistered_builder(tmp_path):
|
||||
"""Unregistered builder (already an I3 hit) → I4 silent on same contract."""
|
||||
from scripts.audit_frame_invariants import check_i4_slot_payload_refs
|
||||
|
||||
partials_dir = tmp_path / "families"
|
||||
partials_dir.mkdir()
|
||||
(partials_dir / "ghost_frame.html").write_text(
|
||||
"{{ slot_payload.title }}", encoding="utf-8"
|
||||
)
|
||||
catalog = {
|
||||
"ghost_frame": {
|
||||
"template_id": "ghost_frame",
|
||||
"payload": {
|
||||
"title": {"source": "section.title"},
|
||||
"builder": "ghost_builder_not_in_registry",
|
||||
},
|
||||
},
|
||||
}
|
||||
violations = check_i4_slot_payload_refs(
|
||||
catalog, partials_dir, registered_builders={"quadrant_flat_slots"}
|
||||
)
|
||||
assert violations == [], (
|
||||
"Unregistered builder is already flagged by I3 — I4 must stay silent "
|
||||
f"on the same contract; got: {violations}"
|
||||
)
|
||||
|
||||
|
||||
def test_i4_skips_missing_partial(tmp_path):
|
||||
"""Missing partial (already I1 hit) → I4 silent on same contract."""
|
||||
from scripts.audit_frame_invariants import check_i4_slot_payload_refs
|
||||
|
||||
partials_dir = tmp_path / "families"
|
||||
partials_dir.mkdir()
|
||||
# No partial file written.
|
||||
catalog = {
|
||||
"missing_partial_frame": {
|
||||
"template_id": "missing_partial_frame",
|
||||
"payload": {
|
||||
"title": {"source": "section.title"},
|
||||
"builder": "quadrant_flat_slots",
|
||||
"builder_options": {
|
||||
"item_parser": "quadrant_item", "pad_to": 4,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
violations = check_i4_slot_payload_refs(
|
||||
catalog, partials_dir, registered_builders={"quadrant_flat_slots"}
|
||||
)
|
||||
assert violations == []
|
||||
|
||||
|
||||
def test_cli_pass_on_prod_paths(tmp_path):
|
||||
"""End-to-end CLI on prod paths reports PASS with I1-I4 wording."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(SCRIPT_PATH)],
|
||||
cwd=str(REPO_ROOT),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert "PASS (I1-I4 clean" in result.stdout
|
||||
|
||||
|
||||
def test_cli_fail_on_synthetic_i4_drift(tmp_path):
|
||||
"""CLI exits 1 + emits I4 violation when a non-VP contract has dead keys."""
|
||||
partials_dir = tmp_path / "families"
|
||||
partials_dir.mkdir()
|
||||
(partials_dir / "drift_frame.html").write_text(
|
||||
"{{ slot_payload.title }}", encoding="utf-8"
|
||||
)
|
||||
catalog_path = _write_yaml(
|
||||
tmp_path / "frame_contracts.yaml",
|
||||
{
|
||||
"drift_frame": {
|
||||
"template_id": "drift_frame",
|
||||
"payload": {
|
||||
"title": {"source": "section.title"},
|
||||
"builder": "quadrant_flat_slots",
|
||||
"builder_options": {
|
||||
"item_parser": "quadrant_item", "pad_to": 2,
|
||||
"label_key_pattern": "category_{n}_label",
|
||||
"body_key_pattern": "category_{n}_body",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
result = _run_cli(catalog_path, partials_dir)
|
||||
assert result.returncode == 1, result.stdout + result.stderr
|
||||
assert "I4 generated-key-orphan" in result.stdout
|
||||
assert "category_1_label" in result.stdout
|
||||
@@ -79,3 +79,149 @@ def test_catalog_entry_count_matches_frame_count():
|
||||
f"catalog shape inconsistent: entries={entry_count} "
|
||||
f"templates={template_count} frames={frame_count}"
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────── IMP-#85 u5 regression coverage ────────────────────────
|
||||
#
|
||||
# Scope (Stage 2 lock):
|
||||
# - Prod catalog passes the audit CLI (run_audit) end-to-end.
|
||||
# - Non-VP fixture catalogs reproduce the boot invariant (u2) + audit (u3a/u3b)
|
||||
# negative paths: missing payload.builder, missing partial, undeclared
|
||||
# slot_payload reference (I4 generated-key-orphan).
|
||||
# - Same fixtures with `visual_pending: true` MUST be silently skipped — the
|
||||
# data-driven VP scope guard from u2/u3a/u3b must not regress.
|
||||
#
|
||||
# Out of scope:
|
||||
# - Implementing the 17 missing VP builders (별 P0 / IMP-04b backlog).
|
||||
# - Visual rendering of fixture frames.
|
||||
#
|
||||
# Path-convention note (tests/CLAUDE.md §F-5):
|
||||
# Stage 2 plan named `tests/fixtures/catalog/` but the project convention
|
||||
# reserves the root `tests/fixtures/` for non-Phase-Z fixtures (creation
|
||||
# requires a separate issue). Phase-Z YAML fixtures live under
|
||||
# `tests/phase_z2/fixtures/`. The u5 fixtures therefore live at
|
||||
# `tests/phase_z2/fixtures/catalog/`.
|
||||
|
||||
import yaml
|
||||
|
||||
from scripts.audit_frame_invariants import (
|
||||
DEFAULT_CATALOG_PATH,
|
||||
DEFAULT_PARTIALS_DIR,
|
||||
run_audit,
|
||||
)
|
||||
from src import phase_z2_mapper
|
||||
from src.phase_z2_mapper import (
|
||||
CatalogInvariantError,
|
||||
PAYLOAD_BUILDERS,
|
||||
_check_catalog_builder_invariant,
|
||||
)
|
||||
|
||||
_IMP85_FIXTURES_DIR = Path(__file__).parent / "phase_z2" / "fixtures" / "catalog"
|
||||
_MISSING_BUILDER_FIXTURE = _IMP85_FIXTURES_DIR / "missing_builder_non_vp.yaml"
|
||||
_UNDECLARED_SLOT_FIXTURE = _IMP85_FIXTURES_DIR / "undeclared_slot_ref_non_vp.yaml"
|
||||
|
||||
|
||||
def _load_fixture_catalog(path: Path) -> dict:
|
||||
with path.open(encoding="utf-8") as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _reset_catalog_cache_for_imp85():
|
||||
"""Some tests below load fixture YAMLs into the boot invariant; ensure the
|
||||
prod cache is untouched on entry/exit so other tests stay deterministic."""
|
||||
phase_z2_mapper._CATALOG_CACHE = None
|
||||
yield
|
||||
phase_z2_mapper._CATALOG_CACHE = None
|
||||
|
||||
|
||||
def test_prod_catalog_audit_clean():
|
||||
"""IMP-#85 u5 — prod catalog + prod partials dir pass audit (I1-I4 clean)."""
|
||||
violations = run_audit(DEFAULT_CATALOG_PATH, DEFAULT_PARTIALS_DIR)
|
||||
assert violations == [], (
|
||||
f"Prod catalog audit reported {len(violations)} violation(s):\n - "
|
||||
+ "\n - ".join(violations)
|
||||
)
|
||||
|
||||
|
||||
def test_missing_builder_fixture_raises_catalog_invariant(
|
||||
_reset_catalog_cache_for_imp85,
|
||||
):
|
||||
"""Fixture: non-VP contract with unregistered builder → u2 invariant raise."""
|
||||
catalog = _load_fixture_catalog(_MISSING_BUILDER_FIXTURE)
|
||||
with pytest.raises(CatalogInvariantError) as exc:
|
||||
_check_catalog_builder_invariant(catalog)
|
||||
msg = str(exc.value)
|
||||
assert "imp85_u5_missing_builder_frame" in msg
|
||||
assert "definitely_not_a_registered_builder_imp85_u5" in msg
|
||||
|
||||
|
||||
def test_missing_builder_fixture_audit_reports_i3(tmp_path):
|
||||
"""Fixture: non-VP contract with unregistered builder → audit I3 + I1.
|
||||
|
||||
The fixture frame's template_id has no partial on disk (tmp_path is empty),
|
||||
so I1 fires as well. I3 is the primary assertion target; I1 surfacing is
|
||||
expected and asserted to lock both audit paths together.
|
||||
"""
|
||||
violations = run_audit(_MISSING_BUILDER_FIXTURE, tmp_path)
|
||||
joined = "\n".join(violations)
|
||||
assert any(
|
||||
v.startswith("I3 builder-unregistered:")
|
||||
and "imp85_u5_missing_builder_frame" in v
|
||||
for v in violations
|
||||
), f"expected I3 builder-unregistered violation, got:\n{joined}"
|
||||
assert any(
|
||||
v.startswith("I1 partial-missing:")
|
||||
and "imp85_u5_missing_builder_frame" in v
|
||||
for v in violations
|
||||
), f"expected I1 partial-missing violation, got:\n{joined}"
|
||||
|
||||
|
||||
def test_undeclared_slot_fixture_audit_reports_i4(tmp_path):
|
||||
"""Fixture: non-VP contract with valid builder but orphan generated key.
|
||||
|
||||
`items_with_role` + `array_root: orphan_array_root_imp85_u5` produces
|
||||
`slot_payload.orphan_array_root_imp85_u5`. The temp partial below contains
|
||||
`slot_payload.title` only (no bracket access), so I4 must fire on the
|
||||
orphan array_root key.
|
||||
"""
|
||||
partials_dir = tmp_path / "families"
|
||||
partials_dir.mkdir()
|
||||
partial = partials_dir / "imp85_u5_undeclared_slot_frame.html"
|
||||
partial.write_text(
|
||||
"<div>{{ slot_payload.title }}</div>",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
violations = run_audit(_UNDECLARED_SLOT_FIXTURE, partials_dir)
|
||||
joined = "\n".join(violations)
|
||||
assert any(
|
||||
v.startswith("I4 generated-key-orphan:")
|
||||
and "imp85_u5_undeclared_slot_frame" in v
|
||||
and "orphan_array_root_imp85_u5" in v
|
||||
for v in violations
|
||||
), f"expected I4 generated-key-orphan violation, got:\n{joined}"
|
||||
|
||||
|
||||
def test_fixtures_with_visual_pending_true_are_skipped(
|
||||
tmp_path, _reset_catalog_cache_for_imp85,
|
||||
):
|
||||
"""VP scope guard — flipping `visual_pending: true` on fixture frames must
|
||||
silence both the boot invariant (u2) and the audit CLI (I1-I4)."""
|
||||
missing = _load_fixture_catalog(_MISSING_BUILDER_FIXTURE)
|
||||
undeclared = _load_fixture_catalog(_UNDECLARED_SLOT_FIXTURE)
|
||||
for entry in (*missing.values(), *undeclared.values()):
|
||||
entry["visual_pending"] = True
|
||||
|
||||
_check_catalog_builder_invariant(missing)
|
||||
_check_catalog_builder_invariant(undeclared)
|
||||
|
||||
vp_yaml = tmp_path / "vp_only.yaml"
|
||||
vp_yaml.write_text(yaml.safe_dump({**missing, **undeclared}), encoding="utf-8")
|
||||
partials_dir = tmp_path / "families"
|
||||
partials_dir.mkdir()
|
||||
violations = run_audit(vp_yaml, partials_dir)
|
||||
assert violations == [], (
|
||||
f"VP frames must be silently skipped, got:\n - "
|
||||
+ "\n - ".join(violations)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,64 @@
|
||||
"""IMP-49 #78 — partial Figma provenance regression test.
|
||||
|
||||
For `templates/phase_z2/families/dx_sw_necessity_three_perspectives.html`,
|
||||
extract color literals (hex + rgb/rgba, whitespace-preserving) from the
|
||||
<style> block, then assert each non-whitelisted literal exists byte-identically
|
||||
in `figma_to_html_agent/blocks/1171281198/index.html`.
|
||||
|
||||
Whitelist composition (per Stage 2 EXIT REPORT, IMP-49 #78):
|
||||
- Neutrals: #fff, #1a1a1a (page-default text / surface)
|
||||
- Shared zone-title token: #000, #883700, rgba(50,44,30,0.4)
|
||||
(F13/F14/F12/F11/F18 zone-title family — not sourced from frame 20)
|
||||
|
||||
Guardrails:
|
||||
- rgb()/rgba() literals matched as substrings, so whitespace (single space
|
||||
after comma) is preserved byte-for-byte against upstream.
|
||||
- Failure message surfaces the offending literal and the whitelist.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
PARTIAL_PATH = (
|
||||
PROJECT_ROOT / "templates" / "phase_z2" / "families"
|
||||
/ "dx_sw_necessity_three_perspectives.html"
|
||||
)
|
||||
UPSTREAM_PATH = (
|
||||
PROJECT_ROOT / "figma_to_html_agent" / "blocks" / "1171281198" / "index.html"
|
||||
)
|
||||
|
||||
COLOR_WHITELIST = frozenset({
|
||||
"#fff",
|
||||
"#1a1a1a",
|
||||
"#000",
|
||||
"#883700",
|
||||
"rgba(50,44,30,0.4)",
|
||||
})
|
||||
|
||||
HEX_RE = re.compile(r"#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{4}|[0-9a-fA-F]{3})\b")
|
||||
RGB_RE = re.compile(r"rgba?\([^)]*\)")
|
||||
STYLE_RE = re.compile(r"<style>(.*?)</style>", re.DOTALL)
|
||||
|
||||
|
||||
def _extract_style_block(html_text: str) -> str:
|
||||
match = STYLE_RE.search(html_text)
|
||||
assert match, f"partial {PARTIAL_PATH} must contain a <style> block"
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def test_partial_color_literals_byte_identical_to_upstream() -> None:
|
||||
partial_text = PARTIAL_PATH.read_text(encoding="utf-8")
|
||||
upstream_text = UPSTREAM_PATH.read_text(encoding="utf-8")
|
||||
css_text = _extract_style_block(partial_text)
|
||||
literals = HEX_RE.findall(css_text) + RGB_RE.findall(css_text)
|
||||
assert literals, "partial <style> must contain at least one color literal"
|
||||
missing = sorted(
|
||||
{lit for lit in literals if lit not in COLOR_WHITELIST and lit not in upstream_text}
|
||||
)
|
||||
assert not missing, (
|
||||
"Non-whitelisted color literals in partial must be byte-identical to "
|
||||
f"upstream {UPSTREAM_PATH.relative_to(PROJECT_ROOT)}. "
|
||||
f"Missing: {missing}. Whitelist: {sorted(COLOR_WHITELIST)}."
|
||||
)
|
||||
@@ -0,0 +1,259 @@
|
||||
"""IMP-#85 u4 — lookup_v4_candidates visual_pending filter regression tests.
|
||||
|
||||
Scope (Stage 2 lock):
|
||||
- ``visual_pending: true`` frames are excluded from the live candidate set
|
||||
returned by ``lookup_v4_candidates`` (mdx04 hard-crash path closure).
|
||||
- Filter is data-driven from catalog ``visual_pending`` field (no hard-coded
|
||||
frame allow-list, per Stage 2 guardrail + ``feedback_no_hardcoding``).
|
||||
- ``lookup_v4_all_judgments`` raw telemetry MUST remain untouched — full 32
|
||||
judgments (reject + VP inclusive) preserved for frontend Step 7-A axis.
|
||||
- Existing ``label == "reject"`` filter and ``max_n`` cap behavior unchanged.
|
||||
|
||||
Out of scope (other IMP-#85 units / future axes):
|
||||
- Implementing the 17 missing VP builders (별 P0 backlog, IMP-04b / #42).
|
||||
- VP semantics redefinition / VP frame removal from V4 evidence.
|
||||
- Adapter pipeline redesign.
|
||||
|
||||
Synthetic naming convention (per ``test_phase_z2_v4_fallback.py`` E1 lock):
|
||||
``MOCK_`` prefix mandatory. ``_a`` / ``_b`` suffixes = enumeration, not
|
||||
ordering / priority. Rank expressed by ``v4_full_rank``, never by suffix.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src import phase_z2_pipeline
|
||||
from src.phase_z2_pipeline import (
|
||||
_is_visual_pending,
|
||||
lookup_v4_all_judgments,
|
||||
lookup_v4_candidates,
|
||||
)
|
||||
|
||||
|
||||
# ─── Synthetic catalog stub ──────────────────────────────────────
|
||||
# Maps template_id → contract dict (None means catalog-unregistered).
|
||||
|
||||
_MOCK_CATALOG: dict[str, object] = {
|
||||
"MOCK_template_live_a": {"visual_pending": False},
|
||||
"MOCK_template_live_b": {"visual_pending": False},
|
||||
"MOCK_template_live_no_vp": {}, # no visual_pending key at all → treated as live
|
||||
"MOCK_template_vp_a": {"visual_pending": True},
|
||||
"MOCK_template_vp_b": {"visual_pending": True},
|
||||
# MOCK_template_missing_contract intentionally absent (get_contract → None)
|
||||
}
|
||||
|
||||
|
||||
def _mock_get_contract(template_id: str):
|
||||
return _MOCK_CATALOG.get(template_id)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patch_catalog(monkeypatch):
|
||||
"""Monkeypatch module-level ``get_contract`` so ``_is_visual_pending``
|
||||
reads from ``_MOCK_CATALOG`` without touching prod ``frame_contracts.yaml``.
|
||||
"""
|
||||
monkeypatch.setattr(
|
||||
"src.phase_z2_pipeline.get_contract", _mock_get_contract
|
||||
)
|
||||
|
||||
|
||||
def _make_v4(judgments: list[dict], section_id: str = "S1") -> dict:
|
||||
return {"mdx_sections": {section_id: {"judgments_full32": judgments}}}
|
||||
|
||||
|
||||
def _j(rank: int, template_id: str, frame_id: str, label: str = "use_as_is",
|
||||
confidence: float = 0.9) -> dict:
|
||||
return {
|
||||
"frame_id": frame_id,
|
||||
"frame_number": rank,
|
||||
"template_id": template_id,
|
||||
"confidence": confidence,
|
||||
"label": label,
|
||||
"v4_full_rank": rank,
|
||||
}
|
||||
|
||||
|
||||
# ─── _is_visual_pending helper ──────────────────────────────────
|
||||
|
||||
|
||||
def test_is_visual_pending_true_for_vp_contract(patch_catalog):
|
||||
"""VP-flagged contract → True."""
|
||||
assert _is_visual_pending("MOCK_template_vp_a") is True
|
||||
|
||||
|
||||
def test_is_visual_pending_false_for_live_contract(patch_catalog):
|
||||
"""Live (explicit visual_pending=False) contract → False."""
|
||||
assert _is_visual_pending("MOCK_template_live_a") is False
|
||||
|
||||
|
||||
def test_is_visual_pending_false_when_key_absent(patch_catalog):
|
||||
"""Contract without ``visual_pending`` field → False (default = live)."""
|
||||
assert _is_visual_pending("MOCK_template_live_no_vp") is False
|
||||
|
||||
|
||||
def test_is_visual_pending_false_for_unregistered_contract(patch_catalog):
|
||||
"""``get_contract`` → None → False (no spurious gating on unknown ids).
|
||||
|
||||
Catalog drift (unregistered template_id) is caught by catalog invariant
|
||||
(u2 boot + u3 audit), not by this runtime helper.
|
||||
"""
|
||||
assert _is_visual_pending("MOCK_template_missing_contract") is False
|
||||
|
||||
|
||||
# ─── lookup_v4_candidates VP filter ─────────────────────────────
|
||||
|
||||
|
||||
def test_vp_rank_1_excluded_live_rank_2_promoted(patch_catalog):
|
||||
"""mdx04 crash-path shape — rank-1 VP frame is skipped, live rank-2 wins.
|
||||
|
||||
Mirrors the production 04-2.x case where ``sw_dependency_four_problems``
|
||||
(VP, builder = ``cards_4_grid`` absent from registry) appeared at high
|
||||
rank and crashed the mapper. With the u4 filter, the VP candidate is
|
||||
skipped and a live candidate is returned instead.
|
||||
"""
|
||||
v4 = _make_v4([
|
||||
_j(1, "MOCK_template_vp_a", "MOCK_frame_001", "restructure"),
|
||||
_j(2, "MOCK_template_live_a", "MOCK_frame_002", "use_as_is"),
|
||||
])
|
||||
|
||||
candidates = lookup_v4_candidates(v4, "S1", max_n=6)
|
||||
|
||||
assert [c.template_id for c in candidates] == ["MOCK_template_live_a"]
|
||||
|
||||
|
||||
def test_all_vp_yields_empty_candidates(patch_catalog):
|
||||
"""All candidates VP → empty list (Step 9 fallback signal).
|
||||
|
||||
0-length output remains the documented ``no_non_reject_v4_candidate``
|
||||
signal for the Step 9 fallback path; VP exclusion preserves this contract.
|
||||
"""
|
||||
v4 = _make_v4([
|
||||
_j(1, "MOCK_template_vp_a", "MOCK_frame_001", "use_as_is"),
|
||||
_j(2, "MOCK_template_vp_b", "MOCK_frame_002", "light_edit"),
|
||||
])
|
||||
|
||||
candidates = lookup_v4_candidates(v4, "S1", max_n=6)
|
||||
|
||||
assert candidates == []
|
||||
|
||||
|
||||
def test_vp_and_reject_both_filtered(patch_catalog):
|
||||
"""VP and reject co-occur — both filtered; only live non-reject survive."""
|
||||
v4 = _make_v4([
|
||||
_j(1, "MOCK_template_vp_a", "MOCK_frame_001", "use_as_is"),
|
||||
_j(2, "MOCK_template_live_a", "MOCK_frame_002", "reject"),
|
||||
_j(3, "MOCK_template_live_b", "MOCK_frame_003", "use_as_is"),
|
||||
])
|
||||
|
||||
candidates = lookup_v4_candidates(v4, "S1", max_n=6)
|
||||
|
||||
assert [c.template_id for c in candidates] == ["MOCK_template_live_b"]
|
||||
|
||||
|
||||
def test_unregistered_contract_not_filtered_by_vp(patch_catalog):
|
||||
"""Unregistered template_id (get_contract → None) is NOT VP-filtered.
|
||||
|
||||
VP gating only applies when catalog declares ``visual_pending: true``.
|
||||
Catalog drift (template_id absent from catalog entirely) is a separate
|
||||
failure mode covered by catalog invariant (u2) and audit (u3a) — runtime
|
||||
VP filter stays silent on that axis.
|
||||
"""
|
||||
v4 = _make_v4([
|
||||
_j(1, "MOCK_template_missing_contract", "MOCK_frame_001", "use_as_is"),
|
||||
])
|
||||
|
||||
candidates = lookup_v4_candidates(v4, "S1", max_n=6)
|
||||
|
||||
assert [c.template_id for c in candidates] == ["MOCK_template_missing_contract"]
|
||||
|
||||
|
||||
def test_max_n_applies_after_vp_filter(patch_catalog):
|
||||
"""``max_n`` caps the live-eligible list after VP and reject filtering.
|
||||
|
||||
Three live candidates + ``max_n=2`` → first two live frames are returned.
|
||||
"""
|
||||
v4 = _make_v4([
|
||||
_j(1, "MOCK_template_vp_a", "MOCK_frame_001", "use_as_is"),
|
||||
_j(2, "MOCK_template_live_a", "MOCK_frame_002", "use_as_is"),
|
||||
_j(3, "MOCK_template_live_b", "MOCK_frame_003", "use_as_is"),
|
||||
_j(4, "MOCK_template_live_no_vp", "MOCK_frame_004", "use_as_is"),
|
||||
])
|
||||
|
||||
candidates = lookup_v4_candidates(v4, "S1", max_n=2)
|
||||
|
||||
assert [c.template_id for c in candidates] == [
|
||||
"MOCK_template_live_a",
|
||||
"MOCK_template_live_b",
|
||||
]
|
||||
|
||||
|
||||
def test_only_live_candidates_pass_unchanged(patch_catalog):
|
||||
"""No VP / no reject → behavior identical to pre-u4 (regression guard)."""
|
||||
v4 = _make_v4([
|
||||
_j(1, "MOCK_template_live_a", "MOCK_frame_001", "use_as_is"),
|
||||
_j(2, "MOCK_template_live_b", "MOCK_frame_002", "light_edit"),
|
||||
])
|
||||
|
||||
candidates = lookup_v4_candidates(v4, "S1", max_n=6)
|
||||
|
||||
assert [c.template_id for c in candidates] == [
|
||||
"MOCK_template_live_a",
|
||||
"MOCK_template_live_b",
|
||||
]
|
||||
|
||||
|
||||
# ─── lookup_v4_all_judgments untouched (Step 7-A axis preservation) ───
|
||||
|
||||
|
||||
def test_all_judgments_includes_vp_frames(patch_catalog):
|
||||
"""Raw 32-judgment telemetry MUST include VP frames (not gated).
|
||||
|
||||
Stage 2 explicit guardrail — frontend Step 7-A axis needs full 32-frame
|
||||
PNG evidence including VP scaffolding. The u4 filter applies ONLY to the
|
||||
live candidate path, not the raw judgments path.
|
||||
"""
|
||||
v4 = _make_v4([
|
||||
_j(1, "MOCK_template_vp_a", "MOCK_frame_001", "restructure"),
|
||||
_j(2, "MOCK_template_live_a", "MOCK_frame_002", "use_as_is"),
|
||||
_j(3, "MOCK_template_vp_b", "MOCK_frame_003", "light_edit"),
|
||||
])
|
||||
|
||||
all_judgments = lookup_v4_all_judgments(v4, "S1")
|
||||
|
||||
assert [j.template_id for j in all_judgments] == [
|
||||
"MOCK_template_vp_a",
|
||||
"MOCK_template_live_a",
|
||||
"MOCK_template_vp_b",
|
||||
]
|
||||
|
||||
|
||||
def test_all_judgments_includes_reject_and_vp(patch_catalog):
|
||||
"""Raw judgments preserves BOTH reject AND VP — confirms u4 narrowed scope."""
|
||||
v4 = _make_v4([
|
||||
_j(1, "MOCK_template_vp_a", "MOCK_frame_001", "restructure"),
|
||||
_j(2, "MOCK_template_live_a", "MOCK_frame_002", "reject"),
|
||||
_j(3, "MOCK_template_live_b", "MOCK_frame_003", "use_as_is"),
|
||||
])
|
||||
|
||||
all_judgments = lookup_v4_all_judgments(v4, "S1")
|
||||
candidates = lookup_v4_candidates(v4, "S1", max_n=6)
|
||||
|
||||
# raw telemetry: 3 (all preserved)
|
||||
assert len(all_judgments) == 3
|
||||
# live candidates: 1 (vp + reject filtered)
|
||||
assert [c.template_id for c in candidates] == ["MOCK_template_live_b"]
|
||||
|
||||
|
||||
# ─── Empty section / missing v4 ─────────────────────────────────
|
||||
|
||||
|
||||
def test_empty_judgments_returns_empty(patch_catalog):
|
||||
"""No judgments → empty list (unchanged from pre-u4)."""
|
||||
v4 = _make_v4([])
|
||||
assert lookup_v4_candidates(v4, "S1", max_n=6) == []
|
||||
|
||||
|
||||
def test_unknown_section_returns_empty(patch_catalog):
|
||||
"""Section_id not in V4 → empty list (unchanged from pre-u4)."""
|
||||
v4 = _make_v4([_j(1, "MOCK_template_live_a", "MOCK_frame_001")])
|
||||
assert lookup_v4_candidates(v4, "SECTION_NOT_PRESENT", max_n=6) == []
|
||||
@@ -0,0 +1,129 @@
|
||||
"""IMP-#85 u6 — mdx04 VP routing regression against the real V4 evidence.
|
||||
|
||||
Scope (Stage 2 lock):
|
||||
- Use the production ``tests/matching/v4_full32_result.yaml`` + the production
|
||||
``templates/phase_z2/catalog/frame_contracts.yaml`` (no fixtures, no mocks).
|
||||
- Prove that ``sw_dependency_four_problems`` (VP rank-1 on ``04-2.1``, VP
|
||||
rank-2 on ``04-2.2``) is excluded from ``lookup_v4_candidates`` after u4,
|
||||
while ``lookup_v4_all_judgments`` retains it as Step 7-A raw telemetry.
|
||||
- Guard mdx03 dynamically — the actual rank-1 winners on ``03-1`` / ``03-2``
|
||||
must be non-VP per catalog AND must survive into live candidates.
|
||||
- VP gating is asserted data-driven (catalog ``visual_pending: true`` flag),
|
||||
never hard-coded — matches Stage 1/2 ``feedback_no_hardcoding`` guardrail.
|
||||
|
||||
Out of scope:
|
||||
- Implementing the 17 missing VP builders (별 P0 backlog, IMP-04b / #42).
|
||||
- VP semantics redefinition or VP frame removal from V4 evidence.
|
||||
- Adapter pipeline redesign.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from src.phase_z2_mapper import get_contract
|
||||
from src.phase_z2_pipeline import (
|
||||
load_v4_result,
|
||||
lookup_v4_all_judgments,
|
||||
lookup_v4_candidates,
|
||||
)
|
||||
|
||||
CRASH_TEMPLATE_ID = "sw_dependency_four_problems"
|
||||
|
||||
|
||||
def _rank1_template_id(v4: dict, section_id: str) -> str:
|
||||
judgments = v4["mdx_sections"][section_id]["judgments_full32"]
|
||||
return judgments[0]["template_id"]
|
||||
|
||||
|
||||
# ─── Dynamic catalog proof — VP flag is data-driven ─────────────
|
||||
|
||||
|
||||
def test_crash_template_is_visual_pending_in_catalog():
|
||||
"""Catalog declares ``sw_dependency_four_problems.visual_pending: true``.
|
||||
|
||||
Locks the data-driven contract — the entire u4 / u6 chain rests on this
|
||||
YAML flag, not a hard-coded frame allow-list. If the catalog ever drops
|
||||
the flag without registering the ``cards_4_grid`` builder, this assertion
|
||||
surfaces the regression before mdx04 crashes the mapper.
|
||||
"""
|
||||
contract = get_contract(CRASH_TEMPLATE_ID)
|
||||
assert isinstance(contract, dict), CRASH_TEMPLATE_ID
|
||||
assert contract.get("visual_pending") is True
|
||||
|
||||
|
||||
# ─── mdx04-2.1 — VP frame at rank 1 ─────────────────────────────
|
||||
|
||||
|
||||
def test_mdx04_2_1_excludes_vp_rank_1_from_live_candidates():
|
||||
"""``04-2.1`` rank-1 is the VP crash frame — must NOT appear in live set.
|
||||
|
||||
Every surviving live candidate (if any) must itself be non-VP per catalog;
|
||||
the section may legitimately produce an empty list (all remaining entries
|
||||
are reject), which is the documented ``no_non_reject_v4_candidate`` signal
|
||||
routed to the Step 9 fallback path.
|
||||
"""
|
||||
v4 = load_v4_result()
|
||||
assert _rank1_template_id(v4, "04-2.1") == CRASH_TEMPLATE_ID
|
||||
|
||||
candidates = lookup_v4_candidates(v4, "04-2.1", max_n=6)
|
||||
tids = [c.template_id for c in candidates]
|
||||
|
||||
assert CRASH_TEMPLATE_ID not in tids
|
||||
for tid in tids:
|
||||
contract = get_contract(tid) or {}
|
||||
assert contract.get("visual_pending") is not True, (
|
||||
f"04-2.1: surviving live candidate {tid} is VP"
|
||||
)
|
||||
|
||||
|
||||
def test_mdx04_2_1_retains_vp_frame_in_raw_judgments():
|
||||
"""Step 7-A axis preservation — raw 32-entry telemetry still carries VP."""
|
||||
v4 = load_v4_result()
|
||||
all_tids = [j.template_id for j in lookup_v4_all_judgments(v4, "04-2.1")]
|
||||
assert CRASH_TEMPLATE_ID in all_tids
|
||||
|
||||
|
||||
# ─── mdx04-2.2 — VP frame at rank 2 ─────────────────────────────
|
||||
|
||||
|
||||
def test_mdx04_2_2_excludes_vp_rank_2_from_live_candidates():
|
||||
"""``04-2.2`` rank-2 is the VP crash frame — rank-1 live frame must win."""
|
||||
v4 = load_v4_result()
|
||||
rank_1 = _rank1_template_id(v4, "04-2.2")
|
||||
rank_1_contract = get_contract(rank_1) or {}
|
||||
# Pre-condition for this regression: rank-1 on 04-2.2 is non-VP.
|
||||
assert rank_1_contract.get("visual_pending") is not True
|
||||
|
||||
candidates = lookup_v4_candidates(v4, "04-2.2", max_n=6)
|
||||
tids = [c.template_id for c in candidates]
|
||||
|
||||
assert CRASH_TEMPLATE_ID not in tids
|
||||
assert tids[0] == rank_1
|
||||
|
||||
|
||||
def test_mdx04_2_2_retains_vp_frame_in_raw_judgments():
|
||||
"""Raw judgments path preserves VP frame regardless of its rank."""
|
||||
v4 = load_v4_result()
|
||||
all_tids = [j.template_id for j in lookup_v4_all_judgments(v4, "04-2.2")]
|
||||
assert CRASH_TEMPLATE_ID in all_tids
|
||||
|
||||
|
||||
# ─── mdx03 dynamic guard — non-VP rank-1 survives ───────────────
|
||||
|
||||
|
||||
def test_mdx03_rank_1_non_vp_survives_live_candidates():
|
||||
"""Non-VP rank-1 winners on mdx03 sections must still win after u4.
|
||||
|
||||
Dynamic check — pulls rank-1 from the V4 yaml + catalog VP flag at runtime.
|
||||
No hard-coded template_id list; only the regression contract is asserted.
|
||||
"""
|
||||
v4 = load_v4_result()
|
||||
for section_id in ("03-1", "03-2"):
|
||||
rank_1 = _rank1_template_id(v4, section_id)
|
||||
contract = get_contract(rank_1) or {}
|
||||
assert contract.get("visual_pending") is not True, (
|
||||
f"{section_id} rank-1 ({rank_1}) unexpectedly VP — guard precondition broken"
|
||||
)
|
||||
candidates = lookup_v4_candidates(v4, section_id, max_n=6)
|
||||
tids = [c.template_id for c in candidates]
|
||||
assert tids and tids[0] == rank_1, (
|
||||
f"{section_id}: expected rank-1 ({rank_1}) live, got {tids}"
|
||||
)
|
||||
@@ -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
|
||||
@@ -0,0 +1,587 @@
|
||||
"""IMP-48 (#77) u6 — Unit tests for ``resplit_all_reject_merges`` helper.
|
||||
|
||||
Scope (this slice — Stage 2 plan u6):
|
||||
|
||||
The helper ``resplit_all_reject_merges`` in
|
||||
``src/phase_z2_composition.py`` is a deterministic Step 6 post-pass that
|
||||
decomposes a merged ``parent_merged`` / ``parent_merged_inferred`` unit
|
||||
carrying ``label="reject"`` into per-section singles. This file exercises
|
||||
the helper directly with synthetic stub V4 matches + stub sections; it
|
||||
does NOT touch the pipeline hook (that is u7/u8/u9's regression scope).
|
||||
|
||||
u6 cases covered (Stage 2 plan):
|
||||
|
||||
1. **Detection** — merged-reject is detected when
|
||||
``merge_type ∈ {"parent_merged", "parent_merged_inferred"}``,
|
||||
``label == "reject"``, and ``len(source_section_ids) >= 2``.
|
||||
Singles / non-reject merges / one-child merges are ignored.
|
||||
2. **Beneficial split** — at least one rebuilt single with
|
||||
``label != "reject"`` → ``applied=True``, merged replaced by
|
||||
per-section singles tagged ``selection_path="resplit_from_merge"``.
|
||||
3. **Non-beneficial keep-merged** — all rebuilt singles are reject →
|
||||
``applied=False``, merged kept, ``skipped_units[0].reason ==
|
||||
"no_beneficial_split"``.
|
||||
4. **Layout-cap keep-merged** — projected post-split count > 4 →
|
||||
EVERY would-be split aborts with ``reason="layout_cap_exceeded"``
|
||||
(Stage 2 Q2 default — no partial split; v0 ``select_layout_preset``
|
||||
supports 1~4 units only).
|
||||
5. **Override skip** — ``section_assignment_override=True`` short-
|
||||
circuits before detection with ``skipped_reason=
|
||||
"section_assignment_override"`` (IMP-06 #6 zoneSections stays
|
||||
ground truth).
|
||||
6. **Coverage invariant** — missing section / missing V4 match
|
||||
records ``skipped_units[*].reason == "incomplete_rebuild"`` with
|
||||
the missing section ids surfaced. Merged unit is preserved.
|
||||
7. **Idempotent re-entry** — calling the helper again on its own
|
||||
output is a no-op (singles are excluded by ``merge_type=="single"``).
|
||||
8. **Audit shape invariants** — Stage 1 schema (``applied``,
|
||||
``split_units``, ``skipped_units``, ``post_split_unit_count``,
|
||||
``post_split_layout_preset``) is always present.
|
||||
|
||||
★ AI=0 throughout — PZ-1 deterministic code path only.
|
||||
★ No-hardcoding (RULE_7) — stubs use MOCK_ prefixed identifiers; no
|
||||
real catalog template_id / frame_id / MDX sample identifier leaks.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
from src.phase_z2_composition import (
|
||||
CompositionUnit,
|
||||
resplit_all_reject_merges,
|
||||
)
|
||||
|
||||
|
||||
# ─── Synthetic stubs (MOCK_ prefix mandatory — IMP-30 u3 convention) ───
|
||||
|
||||
|
||||
@dataclass
|
||||
class _StubV4Match:
|
||||
template_id: str
|
||||
frame_id: str
|
||||
frame_number: int
|
||||
confidence: float
|
||||
label: str
|
||||
v4_rank: Optional[int] = None
|
||||
selection_path: str = "rank_1"
|
||||
fallback_reason: Optional[str] = None
|
||||
provisional: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class _StubSection:
|
||||
section_id: str
|
||||
title: str = ""
|
||||
raw_content: str = ""
|
||||
|
||||
|
||||
_LABEL_TO_STATUS = {
|
||||
"use_as_is": "matched_zone",
|
||||
"light_edit": "adapt_matched_zone",
|
||||
"restructure": "extract_matched_zone",
|
||||
"reject": "fallback_candidate",
|
||||
}
|
||||
|
||||
_ALLOWED_STATUSES = {"matched_zone", "adapt_matched_zone"}
|
||||
|
||||
|
||||
def _make_lookup(matches: dict[str, _StubV4Match]):
|
||||
"""Build a (section_id) -> V4Match | None lookup over the given map."""
|
||||
def _fn(section_id: str) -> Optional[_StubV4Match]:
|
||||
return matches.get(section_id)
|
||||
return _fn
|
||||
|
||||
|
||||
def _make_merged_unit(
|
||||
*,
|
||||
merge_type: str,
|
||||
source_section_ids: list[str],
|
||||
label: str = "reject",
|
||||
template_id: str = "MOCK_TMPL_PARENT",
|
||||
) -> CompositionUnit:
|
||||
"""Construct a merged CompositionUnit shaped like collect_candidates output."""
|
||||
return CompositionUnit(
|
||||
source_section_ids=list(source_section_ids),
|
||||
merge_type=merge_type,
|
||||
frame_template_id=template_id,
|
||||
frame_id="MOCK_FRM_PARENT",
|
||||
frame_number=99,
|
||||
confidence=0.10,
|
||||
label=label,
|
||||
phase_z_status=_LABEL_TO_STATUS.get(label, "unknown"),
|
||||
raw_content="MERGED RAW CONTENT (joined string from children)",
|
||||
title="MOCK_PARENT",
|
||||
)
|
||||
|
||||
|
||||
def _make_single_unit(
|
||||
section_id: str,
|
||||
*,
|
||||
label: str = "use_as_is",
|
||||
template_id: Optional[str] = None,
|
||||
) -> CompositionUnit:
|
||||
"""Construct a single CompositionUnit shaped like collect_candidates output."""
|
||||
return CompositionUnit(
|
||||
source_section_ids=[section_id],
|
||||
merge_type="single",
|
||||
frame_template_id=template_id or f"MOCK_TMPL_{section_id}",
|
||||
frame_id=f"MOCK_FRM_{section_id}",
|
||||
frame_number=hash(section_id) % 32,
|
||||
confidence=0.80,
|
||||
label=label,
|
||||
phase_z_status=_LABEL_TO_STATUS.get(label, "unknown"),
|
||||
raw_content=f"section {section_id} content",
|
||||
title=section_id,
|
||||
)
|
||||
|
||||
|
||||
# ─── Case 1 : Detection — what counts as a merged-reject ─────────────
|
||||
|
||||
|
||||
def test_detection_ignores_single_units():
|
||||
"""``merge_type="single"`` units never enter detection (idempotency anchor)."""
|
||||
units = [_make_single_unit("MOCK_S1", label="reject")]
|
||||
sections = [_StubSection("MOCK_S1", raw_content="single reject")]
|
||||
lookup = _make_lookup({
|
||||
"MOCK_S1": _StubV4Match("MOCK_TMPL_S1", "MOCK_FRM_S1", 1, 0.1, "reject"),
|
||||
})
|
||||
|
||||
out_units, audit = resplit_all_reject_merges(
|
||||
units, sections, lookup, _LABEL_TO_STATUS, _ALLOWED_STATUSES,
|
||||
)
|
||||
|
||||
assert out_units == units
|
||||
assert audit["applied"] is False
|
||||
assert audit["detected_units"] == []
|
||||
assert audit["skipped_reason"] == "no_detection"
|
||||
|
||||
|
||||
def test_detection_ignores_non_reject_merge():
|
||||
"""A merged unit with ``label != "reject"`` is not in scope."""
|
||||
units = [_make_merged_unit(
|
||||
merge_type="parent_merged",
|
||||
source_section_ids=["MOCK_S1", "MOCK_S2"],
|
||||
label="light_edit",
|
||||
)]
|
||||
sections = [
|
||||
_StubSection("MOCK_S1", raw_content="c1"),
|
||||
_StubSection("MOCK_S2", raw_content="c2"),
|
||||
]
|
||||
lookup = _make_lookup({
|
||||
"MOCK_S1": _StubV4Match("MOCK_TMPL_S1", "MOCK_FRM_S1", 1, 0.9, "use_as_is"),
|
||||
"MOCK_S2": _StubV4Match("MOCK_TMPL_S2", "MOCK_FRM_S2", 2, 0.9, "use_as_is"),
|
||||
})
|
||||
|
||||
out_units, audit = resplit_all_reject_merges(
|
||||
units, sections, lookup, _LABEL_TO_STATUS, _ALLOWED_STATUSES,
|
||||
)
|
||||
|
||||
assert out_units == units
|
||||
assert audit["applied"] is False
|
||||
assert audit["detected_units"] == []
|
||||
assert audit["skipped_reason"] == "no_detection"
|
||||
|
||||
|
||||
def test_detection_ignores_one_child_merge():
|
||||
"""``len(source_section_ids) < 2`` excludes from detection."""
|
||||
units = [_make_merged_unit(
|
||||
merge_type="parent_merged",
|
||||
source_section_ids=["MOCK_S1"],
|
||||
label="reject",
|
||||
)]
|
||||
sections = [_StubSection("MOCK_S1", raw_content="c1")]
|
||||
lookup = _make_lookup({
|
||||
"MOCK_S1": _StubV4Match("MOCK_TMPL_S1", "MOCK_FRM_S1", 1, 0.1, "reject"),
|
||||
})
|
||||
|
||||
out_units, audit = resplit_all_reject_merges(
|
||||
units, sections, lookup, _LABEL_TO_STATUS, _ALLOWED_STATUSES,
|
||||
)
|
||||
|
||||
assert out_units == units
|
||||
assert audit["applied"] is False
|
||||
assert audit["detected_units"] == []
|
||||
|
||||
|
||||
def test_detection_picks_parent_merged_reject():
|
||||
"""``parent_merged`` + reject + ≥2 sids → detected."""
|
||||
units = [_make_merged_unit(
|
||||
merge_type="parent_merged",
|
||||
source_section_ids=["MOCK_S1", "MOCK_S2"],
|
||||
label="reject",
|
||||
)]
|
||||
sections = [
|
||||
_StubSection("MOCK_S1", raw_content="c1"),
|
||||
_StubSection("MOCK_S2", raw_content="c2"),
|
||||
]
|
||||
# All children also reject → detection only; gating skipped via no_beneficial.
|
||||
lookup = _make_lookup({
|
||||
"MOCK_S1": _StubV4Match("MOCK_TMPL_S1", "MOCK_FRM_S1", 1, 0.1, "reject"),
|
||||
"MOCK_S2": _StubV4Match("MOCK_TMPL_S2", "MOCK_FRM_S2", 2, 0.1, "reject"),
|
||||
})
|
||||
|
||||
_, audit = resplit_all_reject_merges(
|
||||
units, sections, lookup, _LABEL_TO_STATUS, _ALLOWED_STATUSES,
|
||||
)
|
||||
|
||||
assert len(audit["detected_units"]) == 1
|
||||
assert audit["detected_units"][0]["merge_type"] == "parent_merged"
|
||||
assert audit["detected_units"][0]["label"] == "reject"
|
||||
assert audit["detected_units"][0]["source_section_ids"] == ["MOCK_S1", "MOCK_S2"]
|
||||
|
||||
|
||||
def test_detection_picks_parent_merged_inferred_reject():
|
||||
"""``parent_merged_inferred`` + reject + ≥2 sids → detected."""
|
||||
units = [_make_merged_unit(
|
||||
merge_type="parent_merged_inferred",
|
||||
source_section_ids=["MOCK_S1", "MOCK_S2"],
|
||||
label="reject",
|
||||
)]
|
||||
sections = [
|
||||
_StubSection("MOCK_S1", raw_content="c1"),
|
||||
_StubSection("MOCK_S2", raw_content="c2"),
|
||||
]
|
||||
lookup = _make_lookup({
|
||||
"MOCK_S1": _StubV4Match("MOCK_TMPL_S1", "MOCK_FRM_S1", 1, 0.1, "reject"),
|
||||
"MOCK_S2": _StubV4Match("MOCK_TMPL_S2", "MOCK_FRM_S2", 2, 0.1, "reject"),
|
||||
})
|
||||
|
||||
_, audit = resplit_all_reject_merges(
|
||||
units, sections, lookup, _LABEL_TO_STATUS, _ALLOWED_STATUSES,
|
||||
)
|
||||
|
||||
assert len(audit["detected_units"]) == 1
|
||||
assert audit["detected_units"][0]["merge_type"] == "parent_merged_inferred"
|
||||
|
||||
|
||||
# ─── Case 2 : Beneficial split — applied path ────────────────────────
|
||||
|
||||
|
||||
def test_beneficial_split_applied_when_one_child_non_reject():
|
||||
"""≥1 rebuilt single with ``label != "reject"`` → apply the split."""
|
||||
merged = _make_merged_unit(
|
||||
merge_type="parent_merged",
|
||||
source_section_ids=["MOCK_S1", "MOCK_S2"],
|
||||
label="reject",
|
||||
template_id="MOCK_TMPL_PARENT_DISCARDED",
|
||||
)
|
||||
units = [merged]
|
||||
sections = [
|
||||
_StubSection("MOCK_S1", title="S1", raw_content="MDX raw of S1"),
|
||||
_StubSection("MOCK_S2", title="S2", raw_content="MDX raw of S2"),
|
||||
]
|
||||
lookup = _make_lookup({
|
||||
"MOCK_S1": _StubV4Match("MOCK_TMPL_S1", "MOCK_FRM_S1", 1, 0.9, "use_as_is"),
|
||||
"MOCK_S2": _StubV4Match("MOCK_TMPL_S2", "MOCK_FRM_S2", 2, 0.1, "reject"),
|
||||
})
|
||||
|
||||
out_units, audit = resplit_all_reject_merges(
|
||||
units, sections, lookup, _LABEL_TO_STATUS, _ALLOWED_STATUSES,
|
||||
)
|
||||
|
||||
assert audit["applied"] is True
|
||||
# Merged removed, two singles inserted in order.
|
||||
assert len(out_units) == 2
|
||||
assert [u.merge_type for u in out_units] == ["single", "single"]
|
||||
assert [u.source_section_ids for u in out_units] == [["MOCK_S1"], ["MOCK_S2"]]
|
||||
# ★ feedback_ai_isolation_contract — singles use their OWN rank-1 V4 evidence,
|
||||
# not the discarded merged parent's template_id.
|
||||
assert out_units[0].frame_template_id == "MOCK_TMPL_S1"
|
||||
assert out_units[1].frame_template_id == "MOCK_TMPL_S2"
|
||||
assert merged.frame_template_id not in {out_units[0].frame_template_id,
|
||||
out_units[1].frame_template_id}
|
||||
# ★ MDX_raw_content_invariant — singles use per-section raw_content (not the joined merged string).
|
||||
assert out_units[0].raw_content == "MDX raw of S1"
|
||||
assert out_units[1].raw_content == "MDX raw of S2"
|
||||
# ★ Stage 1 Q3 YES — selection_path tag applied only to split-produced singles.
|
||||
assert out_units[0].selection_path == "resplit_from_merge"
|
||||
assert out_units[1].selection_path == "resplit_from_merge"
|
||||
# Audit shape.
|
||||
assert len(audit["split_units"]) == 1
|
||||
split = audit["split_units"][0]
|
||||
assert split["merged_source_section_ids"] == ["MOCK_S1", "MOCK_S2"]
|
||||
assert split["non_reject_count"] == 1
|
||||
assert {s["section_id"] for s in split["split_singles"]} == {"MOCK_S1", "MOCK_S2"}
|
||||
assert audit["skipped_units"] == []
|
||||
assert audit["post_split_unit_count"] == 2
|
||||
assert audit["post_split_layout_preset"] == "horizontal-2"
|
||||
# ``skipped_reason`` removed when applied=True.
|
||||
assert "skipped_reason" not in audit
|
||||
|
||||
|
||||
def test_beneficial_split_preserves_full_coverage():
|
||||
"""Coverage invariant — split increases unit count, never reduces section coverage."""
|
||||
merged = _make_merged_unit(
|
||||
merge_type="parent_merged_inferred",
|
||||
source_section_ids=["MOCK_S1", "MOCK_S2", "MOCK_S3"],
|
||||
label="reject",
|
||||
)
|
||||
units = [merged]
|
||||
sections = [
|
||||
_StubSection("MOCK_S1", raw_content="c1"),
|
||||
_StubSection("MOCK_S2", raw_content="c2"),
|
||||
_StubSection("MOCK_S3", raw_content="c3"),
|
||||
]
|
||||
lookup = _make_lookup({
|
||||
"MOCK_S1": _StubV4Match("MOCK_TMPL_S1", "MOCK_FRM_S1", 1, 0.9, "use_as_is"),
|
||||
"MOCK_S2": _StubV4Match("MOCK_TMPL_S2", "MOCK_FRM_S2", 2, 0.8, "light_edit"),
|
||||
"MOCK_S3": _StubV4Match("MOCK_TMPL_S3", "MOCK_FRM_S3", 3, 0.1, "reject"),
|
||||
})
|
||||
|
||||
out_units, audit = resplit_all_reject_merges(
|
||||
units, sections, lookup, _LABEL_TO_STATUS, _ALLOWED_STATUSES,
|
||||
)
|
||||
|
||||
assert audit["applied"] is True
|
||||
covered = {sid for u in out_units for sid in u.source_section_ids}
|
||||
assert covered == set(merged.source_section_ids) # ★ dropped_zero_invariant
|
||||
|
||||
|
||||
# ─── Case 3 : Non-beneficial keep-merged ─────────────────────────────
|
||||
|
||||
|
||||
def test_non_beneficial_split_keeps_merged_when_all_children_reject():
|
||||
"""All rebuilt singles are reject → split is not beneficial; merged kept."""
|
||||
merged = _make_merged_unit(
|
||||
merge_type="parent_merged",
|
||||
source_section_ids=["MOCK_S1", "MOCK_S2"],
|
||||
label="reject",
|
||||
)
|
||||
units = [merged]
|
||||
sections = [
|
||||
_StubSection("MOCK_S1", raw_content="c1"),
|
||||
_StubSection("MOCK_S2", raw_content="c2"),
|
||||
]
|
||||
lookup = _make_lookup({
|
||||
"MOCK_S1": _StubV4Match("MOCK_TMPL_S1", "MOCK_FRM_S1", 1, 0.1, "reject"),
|
||||
"MOCK_S2": _StubV4Match("MOCK_TMPL_S2", "MOCK_FRM_S2", 2, 0.1, "reject"),
|
||||
})
|
||||
|
||||
out_units, audit = resplit_all_reject_merges(
|
||||
units, sections, lookup, _LABEL_TO_STATUS, _ALLOWED_STATUSES,
|
||||
)
|
||||
|
||||
assert audit["applied"] is False
|
||||
# Merged preserved by identity (IMP-47B #76 handles it directly).
|
||||
assert out_units == [merged]
|
||||
assert audit["split_units"] == []
|
||||
assert len(audit["skipped_units"]) == 1
|
||||
skip = audit["skipped_units"][0]
|
||||
assert skip["reason"] == "no_beneficial_split"
|
||||
assert skip["merged_source_section_ids"] == ["MOCK_S1", "MOCK_S2"]
|
||||
assert audit["post_split_unit_count"] == 1
|
||||
assert audit["post_split_layout_preset"] is None
|
||||
|
||||
|
||||
# ─── Case 4 : Layout-cap keep-merged ─────────────────────────────────
|
||||
|
||||
|
||||
def test_layout_cap_aborts_split_when_projected_count_exceeds_four():
|
||||
"""Projected post-split count > 4 → ALL would-be splits aborted.
|
||||
|
||||
Setup: 1 single (non-target) + 1 merged-reject of 4 sections.
|
||||
Initial unit count = 2. If split applied, post-split = 5 (> 4 cap).
|
||||
Stage 2 Q2 default — keep merged, no partial split.
|
||||
"""
|
||||
other_single = _make_single_unit("MOCK_OTHER", label="use_as_is")
|
||||
merged = _make_merged_unit(
|
||||
merge_type="parent_merged",
|
||||
source_section_ids=["MOCK_S1", "MOCK_S2", "MOCK_S3", "MOCK_S4"],
|
||||
label="reject",
|
||||
)
|
||||
units = [other_single, merged]
|
||||
sections = [
|
||||
_StubSection("MOCK_OTHER", raw_content="other"),
|
||||
_StubSection("MOCK_S1", raw_content="c1"),
|
||||
_StubSection("MOCK_S2", raw_content="c2"),
|
||||
_StubSection("MOCK_S3", raw_content="c3"),
|
||||
_StubSection("MOCK_S4", raw_content="c4"),
|
||||
]
|
||||
lookup = _make_lookup({
|
||||
"MOCK_OTHER": _StubV4Match("MOCK_TMPL_O", "MOCK_FRM_O", 0, 0.9, "use_as_is"),
|
||||
# Beneficial in principle (some non-reject), but cap aborts.
|
||||
"MOCK_S1": _StubV4Match("MOCK_TMPL_S1", "MOCK_FRM_S1", 1, 0.9, "use_as_is"),
|
||||
"MOCK_S2": _StubV4Match("MOCK_TMPL_S2", "MOCK_FRM_S2", 2, 0.8, "light_edit"),
|
||||
"MOCK_S3": _StubV4Match("MOCK_TMPL_S3", "MOCK_FRM_S3", 3, 0.1, "reject"),
|
||||
"MOCK_S4": _StubV4Match("MOCK_TMPL_S4", "MOCK_FRM_S4", 4, 0.1, "reject"),
|
||||
})
|
||||
|
||||
out_units, audit = resplit_all_reject_merges(
|
||||
units, sections, lookup, _LABEL_TO_STATUS, _ALLOWED_STATUSES,
|
||||
)
|
||||
|
||||
assert audit["applied"] is False
|
||||
assert out_units == units # byte-identical fallback for IMP-47B handoff
|
||||
assert audit["split_units"] == []
|
||||
assert len(audit["skipped_units"]) == 1
|
||||
skip = audit["skipped_units"][0]
|
||||
assert skip["reason"] == "layout_cap_exceeded"
|
||||
assert skip["projected_post_split_count"] == 5
|
||||
assert audit["post_split_unit_count"] == 2
|
||||
assert audit["post_split_layout_preset"] is None
|
||||
|
||||
|
||||
# ─── Case 5 : Override skip ──────────────────────────────────────────
|
||||
|
||||
|
||||
def test_override_skip_short_circuits_before_detection():
|
||||
"""``section_assignment_override=True`` (IMP-06 #6) makes the helper a no-op."""
|
||||
merged = _make_merged_unit(
|
||||
merge_type="parent_merged",
|
||||
source_section_ids=["MOCK_S1", "MOCK_S2"],
|
||||
label="reject",
|
||||
)
|
||||
units = [merged]
|
||||
sections = [
|
||||
_StubSection("MOCK_S1", raw_content="c1"),
|
||||
_StubSection("MOCK_S2", raw_content="c2"),
|
||||
]
|
||||
lookup = _make_lookup({
|
||||
"MOCK_S1": _StubV4Match("MOCK_TMPL_S1", "MOCK_FRM_S1", 1, 0.9, "use_as_is"),
|
||||
"MOCK_S2": _StubV4Match("MOCK_TMPL_S2", "MOCK_FRM_S2", 2, 0.8, "light_edit"),
|
||||
})
|
||||
|
||||
out_units, audit = resplit_all_reject_merges(
|
||||
units, sections, lookup, _LABEL_TO_STATUS, _ALLOWED_STATUSES,
|
||||
section_assignment_override=True,
|
||||
)
|
||||
|
||||
assert out_units == units # byte-identical
|
||||
assert audit["applied"] is False
|
||||
assert audit["skipped_reason"] == "section_assignment_override"
|
||||
# Override is upstream of detection — never enumerates.
|
||||
assert audit["detected_units"] == []
|
||||
assert audit["split_units"] == []
|
||||
assert audit["skipped_units"] == []
|
||||
|
||||
|
||||
# ─── Case 6 : Coverage invariant — incomplete rebuild ────────────────
|
||||
|
||||
|
||||
def test_incomplete_rebuild_keeps_merged_when_section_missing():
|
||||
"""A merged unit referencing a section absent from ``sections`` →
|
||||
``incomplete_rebuild`` skip with the missing id surfaced.
|
||||
"""
|
||||
merged = _make_merged_unit(
|
||||
merge_type="parent_merged",
|
||||
source_section_ids=["MOCK_S1", "MOCK_MISSING"],
|
||||
label="reject",
|
||||
)
|
||||
units = [merged]
|
||||
sections = [_StubSection("MOCK_S1", raw_content="c1")] # MOCK_MISSING absent
|
||||
lookup = _make_lookup({
|
||||
"MOCK_S1": _StubV4Match("MOCK_TMPL_S1", "MOCK_FRM_S1", 1, 0.9, "use_as_is"),
|
||||
})
|
||||
|
||||
out_units, audit = resplit_all_reject_merges(
|
||||
units, sections, lookup, _LABEL_TO_STATUS, _ALLOWED_STATUSES,
|
||||
)
|
||||
|
||||
assert audit["applied"] is False
|
||||
assert out_units == [merged]
|
||||
assert audit["split_units"] == []
|
||||
assert len(audit["skipped_units"]) == 1
|
||||
skip = audit["skipped_units"][0]
|
||||
assert skip["reason"] == "incomplete_rebuild"
|
||||
assert skip["missing_section_ids"] == ["MOCK_MISSING"]
|
||||
|
||||
|
||||
def test_incomplete_rebuild_keeps_merged_when_v4_match_missing():
|
||||
"""A merged unit referencing a section without V4 evidence →
|
||||
``incomplete_rebuild`` skip.
|
||||
"""
|
||||
merged = _make_merged_unit(
|
||||
merge_type="parent_merged_inferred",
|
||||
source_section_ids=["MOCK_S1", "MOCK_S2"],
|
||||
label="reject",
|
||||
)
|
||||
units = [merged]
|
||||
sections = [
|
||||
_StubSection("MOCK_S1", raw_content="c1"),
|
||||
_StubSection("MOCK_S2", raw_content="c2"),
|
||||
]
|
||||
lookup = _make_lookup({
|
||||
# MOCK_S2 deliberately omitted to simulate missing V4 evidence.
|
||||
"MOCK_S1": _StubV4Match("MOCK_TMPL_S1", "MOCK_FRM_S1", 1, 0.9, "use_as_is"),
|
||||
})
|
||||
|
||||
out_units, audit = resplit_all_reject_merges(
|
||||
units, sections, lookup, _LABEL_TO_STATUS, _ALLOWED_STATUSES,
|
||||
)
|
||||
|
||||
assert audit["applied"] is False
|
||||
assert out_units == [merged]
|
||||
skip = audit["skipped_units"][0]
|
||||
assert skip["reason"] == "incomplete_rebuild"
|
||||
assert skip["missing_section_ids"] == ["MOCK_S2"]
|
||||
|
||||
|
||||
# ─── Case 7 : Idempotent re-entry ────────────────────────────────────
|
||||
|
||||
|
||||
def test_idempotent_re_entry_is_noop_after_split():
|
||||
"""Running the helper a second time on its own output detects nothing
|
||||
(singles are excluded by construction). max_retry=1 (Stage 2 lock).
|
||||
"""
|
||||
merged = _make_merged_unit(
|
||||
merge_type="parent_merged",
|
||||
source_section_ids=["MOCK_S1", "MOCK_S2"],
|
||||
label="reject",
|
||||
)
|
||||
units = [merged]
|
||||
sections = [
|
||||
_StubSection("MOCK_S1", raw_content="c1"),
|
||||
_StubSection("MOCK_S2", raw_content="c2"),
|
||||
]
|
||||
lookup = _make_lookup({
|
||||
"MOCK_S1": _StubV4Match("MOCK_TMPL_S1", "MOCK_FRM_S1", 1, 0.9, "use_as_is"),
|
||||
"MOCK_S2": _StubV4Match("MOCK_TMPL_S2", "MOCK_FRM_S2", 2, 0.8, "light_edit"),
|
||||
})
|
||||
|
||||
first_out, first_audit = resplit_all_reject_merges(
|
||||
units, sections, lookup, _LABEL_TO_STATUS, _ALLOWED_STATUSES,
|
||||
)
|
||||
assert first_audit["applied"] is True
|
||||
|
||||
# Second pass over the post-resplit list — should be a no-op.
|
||||
second_out, second_audit = resplit_all_reject_merges(
|
||||
first_out, sections, lookup, _LABEL_TO_STATUS, _ALLOWED_STATUSES,
|
||||
)
|
||||
assert second_audit["applied"] is False
|
||||
assert second_audit["detected_units"] == []
|
||||
assert second_audit["skipped_reason"] == "no_detection"
|
||||
assert second_out == first_out # output byte-identical
|
||||
|
||||
|
||||
# ─── Case 8 : Audit shape invariants ─────────────────────────────────
|
||||
|
||||
|
||||
def test_audit_payload_always_has_stage_1_keys():
|
||||
"""Every return path must include the Stage 1 schema keys (additive only)."""
|
||||
required = {
|
||||
"applied",
|
||||
"split_units",
|
||||
"skipped_units",
|
||||
"post_split_unit_count",
|
||||
"post_split_layout_preset",
|
||||
}
|
||||
|
||||
# 1) override skip
|
||||
_, audit_override = resplit_all_reject_merges(
|
||||
[], [], _make_lookup({}), _LABEL_TO_STATUS, _ALLOWED_STATUSES,
|
||||
section_assignment_override=True,
|
||||
)
|
||||
assert required.issubset(audit_override)
|
||||
|
||||
# 2) no detection (empty units)
|
||||
_, audit_empty = resplit_all_reject_merges(
|
||||
[], [], _make_lookup({}), _LABEL_TO_STATUS, _ALLOWED_STATUSES,
|
||||
)
|
||||
assert required.issubset(audit_empty)
|
||||
assert audit_empty["post_split_unit_count"] == 0
|
||||
assert audit_empty["post_split_layout_preset"] is None
|
||||
assert audit_empty["skipped_reason"] == "no_detection"
|
||||
|
||||
# 3) applied path — see test_beneficial_split_applied_when_one_child_non_reject
|
||||
# already asserts the full applied shape.
|
||||
@@ -0,0 +1,185 @@
|
||||
"""IMP-#85 u2 — load_frame_contracts catalog builder invariant.
|
||||
|
||||
Scope (Stage 2 lock):
|
||||
- Prod `frame_contracts.yaml` (32 frames) passes invariant on load.
|
||||
- `visual_pending: true` contracts are skipped — backlog 별 axis (IMP-04b / #42).
|
||||
- Non-VP contracts with missing or unknown `payload.builder` raise
|
||||
`CatalogInvariantError` (boot-time fail-fast).
|
||||
- Failed invariant must NOT populate `_CATALOG_CACHE` (retry-able).
|
||||
|
||||
Out of scope:
|
||||
- Implementing the 17 missing VP builders (별 P0 / IMP-04b backlog).
|
||||
- Audit CLI invariants I1–I4 (u3a / u3b).
|
||||
- Lookup-side VP filter (u4).
|
||||
- Catalog regression fixtures via tests/fixtures/catalog/ (u5).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src import phase_z2_mapper
|
||||
from src.phase_z2_mapper import (
|
||||
CatalogInvariantError,
|
||||
PAYLOAD_BUILDERS,
|
||||
_check_catalog_builder_invariant,
|
||||
load_frame_contracts,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_catalog_cache():
|
||||
phase_z2_mapper._CATALOG_CACHE = None
|
||||
yield
|
||||
phase_z2_mapper._CATALOG_CACHE = None
|
||||
|
||||
|
||||
def test_prod_catalog_passes_invariant():
|
||||
"""Prod frame_contracts.yaml load 시 invariant violation 없음 (32 frames)."""
|
||||
catalog = load_frame_contracts()
|
||||
assert isinstance(catalog, dict)
|
||||
assert len(catalog) >= 30
|
||||
|
||||
|
||||
def test_invariant_skips_visual_pending_contract_with_unknown_builder():
|
||||
"""visual_pending: true 인 contract 는 builder 가 unknown 이어도 skip."""
|
||||
catalog = {
|
||||
"vp_frame": {
|
||||
"template_id": "vp_frame",
|
||||
"visual_pending": True,
|
||||
"payload": {"builder": "definitely_not_a_registered_builder"},
|
||||
},
|
||||
}
|
||||
_check_catalog_builder_invariant(catalog)
|
||||
|
||||
|
||||
def test_invariant_skips_vp_contract_missing_builder_field():
|
||||
"""visual_pending: true contract 의 payload 가 builder field 자체를 안 가져도 skip."""
|
||||
catalog = {
|
||||
"vp_frame": {
|
||||
"template_id": "vp_frame",
|
||||
"visual_pending": True,
|
||||
"payload": {},
|
||||
},
|
||||
}
|
||||
_check_catalog_builder_invariant(catalog)
|
||||
|
||||
|
||||
def test_invariant_raises_on_non_vp_missing_builder_field():
|
||||
"""visual_pending 이 없거나 false 인 contract 의 payload.builder 누락 → raise."""
|
||||
catalog = {
|
||||
"live_frame": {
|
||||
"template_id": "live_frame",
|
||||
"payload": {},
|
||||
},
|
||||
}
|
||||
with pytest.raises(CatalogInvariantError) as exc:
|
||||
_check_catalog_builder_invariant(catalog)
|
||||
msg = str(exc.value)
|
||||
assert "live_frame" in msg
|
||||
assert "missing payload.builder" in msg
|
||||
|
||||
|
||||
def test_invariant_raises_on_non_vp_unknown_builder():
|
||||
"""non-VP contract 의 payload.builder 가 PAYLOAD_BUILDERS 에 없으면 raise."""
|
||||
catalog = {
|
||||
"live_frame": {
|
||||
"template_id": "live_frame",
|
||||
"payload": {"builder": "definitely_not_a_registered_builder"},
|
||||
},
|
||||
}
|
||||
with pytest.raises(CatalogInvariantError) as exc:
|
||||
_check_catalog_builder_invariant(catalog)
|
||||
msg = str(exc.value)
|
||||
assert "live_frame" in msg
|
||||
assert "definitely_not_a_registered_builder" in msg
|
||||
|
||||
|
||||
def test_invariant_passes_on_non_vp_registered_builder():
|
||||
"""non-VP contract 가 registered builder 를 가리키면 통과."""
|
||||
sample_builder = next(iter(PAYLOAD_BUILDERS.keys()))
|
||||
catalog = {
|
||||
"live_frame": {
|
||||
"template_id": "live_frame",
|
||||
"payload": {"builder": sample_builder},
|
||||
},
|
||||
}
|
||||
_check_catalog_builder_invariant(catalog)
|
||||
|
||||
|
||||
def test_invariant_aggregates_multiple_violations_excluding_vp():
|
||||
"""여러 non-VP 위반이 있으면 모두 message 에 포함. VP frame 은 제외."""
|
||||
catalog = {
|
||||
"frame_a": {
|
||||
"template_id": "frame_a",
|
||||
"payload": {"builder": "missing_x"},
|
||||
},
|
||||
"frame_b": {
|
||||
"template_id": "frame_b",
|
||||
"payload": {},
|
||||
},
|
||||
"vp_frame": {
|
||||
"template_id": "vp_frame",
|
||||
"visual_pending": True,
|
||||
"payload": {"builder": "missing_y"},
|
||||
},
|
||||
}
|
||||
with pytest.raises(CatalogInvariantError) as exc:
|
||||
_check_catalog_builder_invariant(catalog)
|
||||
msg = str(exc.value)
|
||||
assert "frame_a" in msg
|
||||
assert "frame_b" in msg
|
||||
assert "vp_frame" not in msg
|
||||
assert "missing_x" in msg
|
||||
assert "missing_y" not in msg
|
||||
|
||||
|
||||
def test_invariant_treats_visual_pending_false_as_live():
|
||||
"""visual_pending: false (explicit) 는 live 와 동일하게 검증."""
|
||||
catalog = {
|
||||
"live_frame": {
|
||||
"template_id": "live_frame",
|
||||
"visual_pending": False,
|
||||
"payload": {"builder": "missing_x"},
|
||||
},
|
||||
}
|
||||
with pytest.raises(CatalogInvariantError):
|
||||
_check_catalog_builder_invariant(catalog)
|
||||
|
||||
|
||||
def test_load_frame_contracts_failure_does_not_populate_cache(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
"""invariant 실패 시 _CATALOG_CACHE 가 populate 되지 않음 (retry 가능)."""
|
||||
bad_yaml = tmp_path / "bad.yaml"
|
||||
bad_yaml.write_text(
|
||||
"live_frame:\n"
|
||||
" template_id: live_frame\n"
|
||||
" payload:\n"
|
||||
" builder: nonexistent_builder_xyz\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(phase_z2_mapper, "CATALOG_PATH", bad_yaml)
|
||||
monkeypatch.setattr(phase_z2_mapper, "_CATALOG_CACHE", None)
|
||||
|
||||
with pytest.raises(CatalogInvariantError):
|
||||
load_frame_contracts()
|
||||
assert phase_z2_mapper._CATALOG_CACHE is None
|
||||
|
||||
|
||||
def test_load_frame_contracts_success_populates_cache(monkeypatch, tmp_path):
|
||||
"""invariant 통과 시 _CATALOG_CACHE 가 populate 되어 두 번째 호출이 동일 dict."""
|
||||
sample_builder = next(iter(PAYLOAD_BUILDERS.keys()))
|
||||
good_yaml = tmp_path / "good.yaml"
|
||||
good_yaml.write_text(
|
||||
"live_frame:\n"
|
||||
" template_id: live_frame\n"
|
||||
f" payload:\n builder: {sample_builder}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(phase_z2_mapper, "CATALOG_PATH", good_yaml)
|
||||
monkeypatch.setattr(phase_z2_mapper, "_CATALOG_CACHE", None)
|
||||
|
||||
first = load_frame_contracts()
|
||||
second = load_frame_contracts()
|
||||
assert first is second
|
||||
assert "live_frame" in first
|
||||
@@ -0,0 +1,85 @@
|
||||
"""IMP-#85 u1 — mapper missing-builder dispatch raises BuilderMissingError.
|
||||
|
||||
Scope (Stage 2 lock):
|
||||
- `BuilderMissingError` exists and is a subclass of `FitError`.
|
||||
- `map_with_contract` raises `BuilderMissingError` when
|
||||
`contract.payload.builder` references an unknown registry entry, OR
|
||||
when `payload.builder` is empty/missing.
|
||||
- Because it subclasses `FitError`, the existing pipeline
|
||||
`except FitError` route in `src/phase_z2_pipeline.py` continues to
|
||||
catch the failure and emit an `adapter_needed` record instead of a
|
||||
hard crash (mdx04 `sw_dependency_four_problems` / `cards_4_grid`
|
||||
regression evidence).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_mapper import (
|
||||
BuilderMissingError,
|
||||
FitError,
|
||||
PAYLOAD_BUILDERS,
|
||||
map_with_contract,
|
||||
)
|
||||
|
||||
|
||||
def _make_section(raw_content: str = "- a\n- b\n- c"):
|
||||
return SimpleNamespace(
|
||||
section_id="test-sec",
|
||||
raw_content=raw_content,
|
||||
title="t",
|
||||
order=1,
|
||||
)
|
||||
|
||||
|
||||
def test_builder_missing_error_is_fit_error_subclass():
|
||||
assert issubclass(BuilderMissingError, FitError)
|
||||
|
||||
|
||||
def test_unknown_builder_raises_builder_missing_error():
|
||||
unknown = "definitely_not_a_registered_builder"
|
||||
assert unknown not in PAYLOAD_BUILDERS
|
||||
contract = {
|
||||
"template_id": "fake_contract_unknown_builder",
|
||||
"source_shape": "top_bullets",
|
||||
"cardinality": {},
|
||||
"payload": {"builder": unknown},
|
||||
}
|
||||
with pytest.raises(BuilderMissingError) as exc:
|
||||
map_with_contract(_make_section(), contract)
|
||||
assert unknown in str(exc.value)
|
||||
assert "fake_contract_unknown_builder" in str(exc.value)
|
||||
|
||||
|
||||
def test_missing_builder_field_raises_builder_missing_error():
|
||||
contract = {
|
||||
"template_id": "fake_contract_missing_builder_field",
|
||||
"source_shape": "top_bullets",
|
||||
"cardinality": {},
|
||||
"payload": {},
|
||||
}
|
||||
with pytest.raises(BuilderMissingError) as exc:
|
||||
map_with_contract(_make_section(), contract)
|
||||
assert "missing payload.builder" in str(exc.value)
|
||||
|
||||
|
||||
def test_builder_missing_error_caught_by_fit_error_handler():
|
||||
"""Pipeline 의 `except FitError` 경로가 그대로 잡아주는지 검증.
|
||||
|
||||
실제 pipeline import 없이 동일 패턴을 재현하여 subclass 의 의도된
|
||||
routing 효과(adapter_needed) 가 깨지지 않는지 확인.
|
||||
"""
|
||||
contract = {
|
||||
"template_id": "fake_contract_routing_check",
|
||||
"source_shape": "top_bullets",
|
||||
"cardinality": {},
|
||||
"payload": {"builder": "no_such_builder"},
|
||||
}
|
||||
caught = False
|
||||
try:
|
||||
map_with_contract(_make_section(), contract)
|
||||
except FitError:
|
||||
caught = True
|
||||
assert caught, "BuilderMissingError must propagate through `except FitError`"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,127 @@
|
||||
"""IMP-#85 u7 — subprocess smoke for mdx03 / mdx04 / mdx05 pipeline runs.
|
||||
|
||||
These smokes exercise the IMP-#85 catalog ↔ contract ↔ builder
|
||||
invariant + runtime VP gate end-to-end against real MDX inputs:
|
||||
|
||||
* mdx03 — non-VP rank-1 path stays clean (exit 0).
|
||||
* mdx04 — the original IMP-#85 hard-crash signature
|
||||
(``BuilderMissingError ... PAYLOAD_BUILDERS has no such entry``)
|
||||
is GONE. u1 converted the uncaught ``ValueError`` into a
|
||||
``BuilderMissingError(FitError)`` subclass; the pipeline's
|
||||
existing ``except FitError`` at ``src/phase_z2_pipeline.py:4436``
|
||||
catches it and the zone is routed to
|
||||
``adapter_needed (skip render)``. Anything that crashes
|
||||
*downstream* of that routing (e.g. layout_css zone aggregation
|
||||
when all live zones are adapter_needed) is a separate axis and
|
||||
out of scope for this issue (see follow_up_issue_candidates).
|
||||
* mdx05 — non-VP rank-1 path stays clean (exit 0).
|
||||
|
||||
Each subprocess gets a unique run_id so the runs do not collide on
|
||||
disk when pytest is invoked concurrently or with -x retry.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
SAMPLES_DIR = REPO_ROOT / "samples" / "mdx_batch"
|
||||
|
||||
# Original IMP-#85 crash signature (issue body verbatim). u1 converted
|
||||
# the uncaught ``ValueError`` raised from the mapper's missing-builder
|
||||
# branch into a ``BuilderMissingError(FitError)`` subclass that the
|
||||
# pipeline catches. The string below was the marker of the uncaught
|
||||
# propagation; it must no longer appear in stdout/stderr of a mdx04
|
||||
# subprocess run.
|
||||
IMP85_OLD_CRASH_MARKER = "PAYLOAD_BUILDERS has no such entry"
|
||||
|
||||
|
||||
def _run_pipeline(mdx_name: str, run_id: str, timeout: int = 240) -> subprocess.CompletedProcess:
|
||||
"""Spawn ``python -m src.phase_z2_pipeline <mdx> <run_id>`` and capture I/O."""
|
||||
return subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"src.phase_z2_pipeline",
|
||||
str(SAMPLES_DIR / mdx_name),
|
||||
run_id,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
cwd=str(REPO_ROOT),
|
||||
)
|
||||
|
||||
|
||||
def _unique_run_id(prefix: str) -> str:
|
||||
return f"{prefix}_imp85_smoke_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mdx_name,prefix",
|
||||
[
|
||||
("03.mdx", "mdx03"),
|
||||
("05.mdx", "mdx05"),
|
||||
],
|
||||
)
|
||||
def test_non_vp_smoke_runs_clean(mdx_name: str, prefix: str) -> None:
|
||||
"""mdx03 / mdx05 hit non-VP rank-1 frames; the pipeline runs to exit 0.
|
||||
|
||||
Non-VP rank-1 selection is the normal Phase Z path and the
|
||||
primary regression guard that u1-u6 do not perturb mapper /
|
||||
pipeline behaviour for non-VP routes.
|
||||
"""
|
||||
cp = _run_pipeline(mdx_name, _unique_run_id(prefix))
|
||||
assert cp.returncode == 0, (
|
||||
f"{mdx_name} pipeline returncode={cp.returncode}\n"
|
||||
f"--- stderr tail ---\n{cp.stderr[-1500:]}\n"
|
||||
f"--- stdout tail ---\n{cp.stdout[-1500:]}"
|
||||
)
|
||||
|
||||
|
||||
def test_mdx04_no_longer_emits_imp85_crash_signature() -> None:
|
||||
"""mdx04 must no longer surface the IMP-#85 uncaught crash marker.
|
||||
|
||||
Before u1: missing-builder ``ValueError``
|
||||
(``'PAYLOAD_BUILDERS has no such entry'``) propagated uncaught and
|
||||
killed the pipeline at the mapper call site
|
||||
(``src/phase_z2_pipeline.py:4411-4413``, ``except FitError``
|
||||
only). After u1: the mapper raises
|
||||
``BuilderMissingError(FitError)``, the pipeline catches it at the
|
||||
same ``except FitError`` block, and the zone is recorded under
|
||||
``adapter_needed (skip render)``.
|
||||
|
||||
This smoke asserts only that the original IMP-#85 marker is gone
|
||||
from both stdout and stderr — downstream crashes (e.g.
|
||||
``build_layout_css`` zone aggregation when all live zones are
|
||||
adapter_needed) belong to a separate axis and are tracked as a
|
||||
follow-up issue candidate.
|
||||
"""
|
||||
cp = _run_pipeline("04.mdx", _unique_run_id("mdx04"))
|
||||
combined = cp.stdout + cp.stderr
|
||||
assert IMP85_OLD_CRASH_MARKER not in combined, (
|
||||
"IMP-#85 original crash signature still present in pipeline output:\n"
|
||||
f"--- stderr tail ---\n{cp.stderr[-1500:]}\n"
|
||||
f"--- stdout tail ---\n{cp.stdout[-1500:]}"
|
||||
)
|
||||
|
||||
|
||||
def test_conftest_env_isolation_active_for_ai_fallback_defaults() -> None:
|
||||
"""Direct assertion that ``tests/conftest.py`` isolated the AI
|
||||
fallback env vars BEFORE ``src.config`` was first imported.
|
||||
|
||||
With ``AI_FALLBACK_ENABLED=true`` in the live ``.env``, the
|
||||
Settings default-OFF contract would otherwise be violated whenever
|
||||
a developer runs ``pytest -q tests`` against a checkout that has a
|
||||
live operator ``.env``. This test pins the contract to the source
|
||||
of truth (``src/config.py`` defaults).
|
||||
"""
|
||||
from src.config import Settings
|
||||
|
||||
s = Settings()
|
||||
assert s.ai_fallback_enabled is False
|
||||
assert s.ai_fallback_auto_cache is False
|
||||
@@ -0,0 +1,262 @@
|
||||
"""IMP-52 (#80) u8 — backend tests for ``src.user_overrides_io``.
|
||||
|
||||
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`` (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 /
|
||||
leading dot / empty).
|
||||
|
||||
All tests inject ``root=tmp_path`` so they never touch the real
|
||||
``data/user_overrides/`` directory.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from src.user_overrides_io import (
|
||||
DEFAULT_OVERRIDES_ROOT,
|
||||
InvalidOverrideKey,
|
||||
KNOWN_AXES,
|
||||
load,
|
||||
override_path,
|
||||
save,
|
||||
validate_key,
|
||||
)
|
||||
|
||||
|
||||
# -- key validation ---------------------------------------------------------
|
||||
|
||||
|
||||
def test_validate_key_accepts_typical_mdx_stems():
|
||||
for key in ("01", "03", "03__DX_master", "sample.v2", "a-b_c.1"):
|
||||
assert validate_key(key) == key
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad_key",
|
||||
[
|
||||
"",
|
||||
"..",
|
||||
"../escape",
|
||||
"sub/dir",
|
||||
"sub\\dir",
|
||||
".hidden",
|
||||
"-leading-dash",
|
||||
".",
|
||||
"name with space",
|
||||
"name?",
|
||||
],
|
||||
)
|
||||
def test_validate_key_rejects_unsafe(bad_key):
|
||||
with pytest.raises(InvalidOverrideKey):
|
||||
validate_key(bad_key)
|
||||
|
||||
|
||||
def test_validate_key_rejects_non_string():
|
||||
for bad in (None, 123, b"bytes", ["list"], {"d": 1}):
|
||||
with pytest.raises(InvalidOverrideKey):
|
||||
validate_key(bad) # type: ignore[arg-type]
|
||||
|
||||
|
||||
# -- override_path ----------------------------------------------------------
|
||||
|
||||
|
||||
def test_override_path_uses_default_root_when_unspecified():
|
||||
p = override_path("sample")
|
||||
assert p.parent == DEFAULT_OVERRIDES_ROOT
|
||||
assert p.name == "sample.json"
|
||||
|
||||
|
||||
def test_override_path_honors_explicit_root(tmp_path):
|
||||
p = override_path("sample", root=tmp_path)
|
||||
assert p == tmp_path / "sample.json"
|
||||
|
||||
|
||||
# -- load: missing / corrupt / non-object -----------------------------------
|
||||
|
||||
|
||||
def test_load_missing_file_returns_empty_dict(tmp_path):
|
||||
assert load("nope", root=tmp_path) == {}
|
||||
|
||||
|
||||
def test_load_corrupt_json_warns_and_returns_empty(tmp_path, capsys):
|
||||
path = override_path("corrupt", root=tmp_path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("{ this is not valid json", encoding="utf-8")
|
||||
result = load("corrupt", root=tmp_path)
|
||||
assert result == {}
|
||||
captured = capsys.readouterr()
|
||||
assert "failed to read" in captured.err
|
||||
assert str(path) in captured.err
|
||||
|
||||
|
||||
def test_load_non_object_json_warns_and_returns_empty(tmp_path, capsys):
|
||||
path = override_path("list_root", root=tmp_path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("[1, 2, 3]", encoding="utf-8")
|
||||
result = load("list_root", root=tmp_path)
|
||||
assert result == {}
|
||||
captured = capsys.readouterr()
|
||||
assert "not a JSON object" in captured.err
|
||||
|
||||
|
||||
# -- save: round-trip + partial-merge + foreign-key preserve ----------------
|
||||
|
||||
|
||||
def _full_payload() -> dict:
|
||||
return {
|
||||
"layout": "sidebar-right",
|
||||
"zone_geometries": {
|
||||
"zone-top": {"x": 40.0, "y": 50.0, "w": 1200.0, "h": 120.0},
|
||||
},
|
||||
"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()
|
||||
written = save(key, payload, root=tmp_path)
|
||||
assert written.exists()
|
||||
assert written == tmp_path / "03.json"
|
||||
|
||||
loaded = load(key, root=tmp_path)
|
||||
for axis in KNOWN_AXES:
|
||||
assert loaded[axis] == payload[axis], f"axis {axis!r} did not round-trip"
|
||||
|
||||
|
||||
def test_save_partial_payload_preserves_other_axes(tmp_path):
|
||||
key = "03"
|
||||
save(key, _full_payload(), root=tmp_path)
|
||||
|
||||
save(key, {"layout": "two-column"}, root=tmp_path)
|
||||
loaded = load(key, root=tmp_path)
|
||||
|
||||
assert loaded["layout"] == "two-column"
|
||||
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):
|
||||
key = "03"
|
||||
save(key, {"frames": {"03-1": "frame_a", "03-2": "frame_b"}}, root=tmp_path)
|
||||
save(key, {"frames": {"03-3": "frame_c"}}, root=tmp_path)
|
||||
loaded = load(key, root=tmp_path)
|
||||
assert loaded["frames"] == {"03-3": "frame_c"}
|
||||
|
||||
|
||||
def test_save_none_clears_axis(tmp_path):
|
||||
key = "03"
|
||||
save(key, _full_payload(), root=tmp_path)
|
||||
save(key, {"layout": None}, root=tmp_path)
|
||||
loaded = load(key, root=tmp_path)
|
||||
assert "layout" not in loaded
|
||||
assert loaded["zone_geometries"] == _full_payload()["zone_geometries"]
|
||||
assert loaded["frames"] == _full_payload()["frames"]
|
||||
|
||||
|
||||
def test_save_preserves_foreign_top_level_keys(tmp_path):
|
||||
"""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",
|
||||
"zone_sizes": {"zone-top": "tall"},
|
||||
"schema_version": "experimental-1",
|
||||
}
|
||||
path.write_text(json.dumps(pre_seed), encoding="utf-8")
|
||||
|
||||
save(key, {"layout": "sidebar-right"}, root=tmp_path)
|
||||
|
||||
loaded = load(key, root=tmp_path)
|
||||
assert loaded["layout"] == "sidebar-right"
|
||||
assert loaded["zone_sizes"] == pre_seed["zone_sizes"]
|
||||
assert loaded["schema_version"] == pre_seed["schema_version"]
|
||||
|
||||
|
||||
def test_save_creates_parent_directory(tmp_path):
|
||||
nested = tmp_path / "deep" / "nest"
|
||||
assert not nested.exists()
|
||||
save("03", {"layout": "two-column"}, root=nested)
|
||||
assert (nested / "03.json").exists()
|
||||
|
||||
|
||||
def test_save_writes_pretty_sorted_json_for_diffability(tmp_path):
|
||||
key = "03"
|
||||
save(key, _full_payload(), root=tmp_path)
|
||||
raw = (tmp_path / "03.json").read_text(encoding="utf-8")
|
||||
# sort_keys=True → KNOWN_AXES come out alphabetically
|
||||
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_image_overrides < pos_layout < pos_zg < pos_zs
|
||||
|
||||
|
||||
def test_save_leaves_no_tmp_file_on_success(tmp_path):
|
||||
save("03", _full_payload(), root=tmp_path)
|
||||
leftovers = [p for p in tmp_path.iterdir() if p.name != "03.json"]
|
||||
assert leftovers == [], f"tmp files leaked: {leftovers!r}"
|
||||
|
||||
|
||||
def test_save_rejects_non_dict_partial(tmp_path):
|
||||
with pytest.raises(TypeError):
|
||||
save("03", ["not", "a", "dict"], root=tmp_path) # type: ignore[arg-type]
|
||||
|
||||
|
||||
# -- save / load propagate key validation -----------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_key", ["", "..", "sub/dir", ".hidden"])
|
||||
def test_save_rejects_invalid_key(tmp_path, bad_key):
|
||||
with pytest.raises(InvalidOverrideKey):
|
||||
save(bad_key, {"layout": "two-column"}, root=tmp_path)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_key", ["", "..", "sub/dir", ".hidden"])
|
||||
def test_load_rejects_invalid_key(tmp_path, bad_key):
|
||||
with pytest.raises(InvalidOverrideKey):
|
||||
load(bad_key, root=tmp_path)
|
||||
@@ -0,0 +1,410 @@
|
||||
"""IMP-52 (#80) u9 — backend tests for the argparse persistence fallback.
|
||||
|
||||
Stage 2 u9 scope (per the Exit Report):
|
||||
|
||||
1. Per-axis fill — file value flows through when CLI omits the axis.
|
||||
2. CLI-wins — CLI value beats file value on the same axis.
|
||||
3. No-file noop — missing file → ``run_phase_z2_mvp1`` gets all-None.
|
||||
4. Corrupt-file warn — invalid JSON / non-object → stderr warning + skip.
|
||||
5. Invalid stem warn — ``Path(args.mdx_path).stem`` rejected by validator
|
||||
→ warning + fallback skipped wholesale.
|
||||
|
||||
We exec the ``if __name__ == "__main__"`` block of
|
||||
``src.phase_z2_pipeline`` directly inside the module's namespace, after
|
||||
(a) monkeypatching ``src.user_overrides_io.DEFAULT_OVERRIDES_ROOT`` to a
|
||||
tmp directory and (b) replacing ``run_phase_z2_mvp1`` with a recording
|
||||
stub. This exercises the production fallback verbatim without the cost
|
||||
of a real pipeline invocation.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import json
|
||||
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:
|
||||
"""Redirect the on-disk persistence root so tests never touch
|
||||
``data/user_overrides/``."""
|
||||
monkeypatch.setattr(_io, "DEFAULT_OVERRIDES_ROOT", tmp_path)
|
||||
|
||||
|
||||
def _write_full_payload(tmp_path: Path, stem: str = "03") -> Path:
|
||||
path = tmp_path / f"{stem}.json"
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"layout": "sidebar-right",
|
||||
"frames": {"03-1": "frame_file_a", "03-1+03-2": "frame_file_b"},
|
||||
"zone_geometries": {
|
||||
"top": {"x": 0.0, "y": 0.0, "w": 1.0, "h": 0.3},
|
||||
"bottom": {"x": 0.0, "y": 0.3, "w": 1.0, "h": 0.7},
|
||||
},
|
||||
"zone_sections": {
|
||||
"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",
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
# -- 1. no-file noop -------------------------------------------------------
|
||||
|
||||
|
||||
def test_no_overrides_file_passes_none_overrides(tmp_path, monkeypatch):
|
||||
_redirect_overrides_root(tmp_path, monkeypatch)
|
||||
captured: dict[str, Any] = {}
|
||||
_exec_main_block(captured, ["src.phase_z2_pipeline", "03.mdx"], monkeypatch)
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
# -- 2. file fills every axis when CLI is empty ----------------------------
|
||||
|
||||
|
||||
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")
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
_exec_main_block(captured, ["src.phase_z2_pipeline", "03.mdx"], monkeypatch)
|
||||
|
||||
assert captured["override_layout"] == "sidebar-right"
|
||||
assert captured["override_frames"] == {
|
||||
"03-1": "frame_file_a",
|
||||
"03-1+03-2": "frame_file_b",
|
||||
}
|
||||
assert captured["override_zone_geometries"] == {
|
||||
"top": {"x": 0.0, "y": 0.0, "w": 1.0, "h": 0.3},
|
||||
"bottom": {"x": 0.0, "y": 0.3, "w": 1.0, "h": 0.7},
|
||||
}
|
||||
assert captured["override_section_assignments"] == {
|
||||
"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 -----------------------------------
|
||||
|
||||
|
||||
def test_cli_layout_overrides_file_layout(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-layout", "two-column"],
|
||||
monkeypatch,
|
||||
)
|
||||
|
||||
# layout from CLI; remaining axes still filled from file.
|
||||
assert captured["override_layout"] == "two-column"
|
||||
assert captured["override_frames"] == {
|
||||
"03-1": "frame_file_a",
|
||||
"03-1+03-2": "frame_file_b",
|
||||
}
|
||||
assert captured["override_zone_geometries"] is not None
|
||||
assert captured["override_section_assignments"] is not None
|
||||
|
||||
|
||||
def test_cli_frames_overrides_file_frames(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-frame",
|
||||
"03-1=cli_frame_x",
|
||||
],
|
||||
monkeypatch,
|
||||
)
|
||||
|
||||
# CLI ``frames`` payload wholly replaces file ``frames`` (per-axis win).
|
||||
assert captured["override_frames"] == {"03-1": "cli_frame_x"}
|
||||
# Other axes still come from the file.
|
||||
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 ----------------
|
||||
|
||||
|
||||
def test_corrupt_json_warns_and_skips_fallback(tmp_path, monkeypatch, capsys):
|
||||
_redirect_overrides_root(tmp_path, monkeypatch)
|
||||
(tmp_path / "03.json").write_text("{ not valid json", encoding="utf-8")
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
_exec_main_block(captured, ["src.phase_z2_pipeline", "03.mdx"], monkeypatch)
|
||||
|
||||
err = capsys.readouterr().err
|
||||
assert "failed to read" in err
|
||||
# ``or None`` collapses empty dicts back to None on the call site.
|
||||
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
|
||||
assert captured["override_image_overrides"] is None
|
||||
|
||||
|
||||
def test_non_object_top_level_warns_and_skips_fallback(
|
||||
tmp_path, monkeypatch, capsys
|
||||
):
|
||||
_redirect_overrides_root(tmp_path, monkeypatch)
|
||||
(tmp_path / "03.json").write_text("[1, 2, 3]", encoding="utf-8")
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
_exec_main_block(captured, ["src.phase_z2_pipeline", "03.mdx"], monkeypatch)
|
||||
|
||||
err = capsys.readouterr().err
|
||||
assert "not a JSON object" in err
|
||||
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
|
||||
assert captured["override_image_overrides"] is None
|
||||
|
||||
|
||||
# -- 5. invalid MDX stem warns and skips fallback wholesale ---------------
|
||||
|
||||
|
||||
def test_invalid_mdx_stem_warns_and_skips_fallback(
|
||||
tmp_path, monkeypatch, capsys
|
||||
):
|
||||
_redirect_overrides_root(tmp_path, monkeypatch)
|
||||
# Seed a file the loader would otherwise consume; the invalid stem must
|
||||
# short-circuit before any read happens.
|
||||
_write_full_payload(tmp_path, "03")
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
# ``Path(".hidden.mdx").stem`` == ".hidden" → leading dot → InvalidOverrideKey.
|
||||
_exec_main_block(
|
||||
captured, ["src.phase_z2_pipeline", ".hidden.mdx"], monkeypatch
|
||||
)
|
||||
|
||||
err = capsys.readouterr().err
|
||||
assert "cannot derive persistence key" in err
|
||||
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
|
||||
assert captured["override_image_overrides"] is None
|
||||
|
||||
|
||||
# -- 6. per-axis partial fill (file fills only what CLI omits) ------------
|
||||
|
||||
|
||||
def test_per_axis_partial_fill_mixes_cli_and_file(tmp_path, monkeypatch):
|
||||
"""File carries frames + zone_geometries; CLI supplies layout only.
|
||||
|
||||
Expected: ``override_layout`` = CLI value, ``override_frames`` and
|
||||
``override_zone_geometries`` = file values, ``override_section_assignments``
|
||||
= None (neither side provided it).
|
||||
"""
|
||||
_redirect_overrides_root(tmp_path, monkeypatch)
|
||||
(tmp_path / "03.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"frames": {"03-1": "frame_only_file"},
|
||||
"zone_geometries": {
|
||||
"top": {"x": 0.0, "y": 0.0, "w": 1.0, "h": 0.5},
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
_exec_main_block(
|
||||
captured,
|
||||
[
|
||||
"src.phase_z2_pipeline",
|
||||
"03.mdx",
|
||||
"--override-layout",
|
||||
"sidebar-right",
|
||||
],
|
||||
monkeypatch,
|
||||
)
|
||||
|
||||
assert captured["override_layout"] == "sidebar-right"
|
||||
assert captured["override_frames"] == {"03-1": "frame_only_file"}
|
||||
assert captured["override_zone_geometries"] == {
|
||||
"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