Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2afedfc780 | ||
|
|
5484077a53 | ||
|
|
ed391af2e8 | ||
|
|
b9747c2f4a | ||
|
|
f0d4494409 | ||
|
|
4da22adb43 | ||
|
|
943957562f | ||
|
|
ec7471ed59 | ||
|
|
4e281a20d8 | ||
|
|
9062931863 | ||
|
|
b4be6c1cd0 | ||
|
|
8648a468d9 | ||
|
|
028042aaa9 | ||
|
|
2e3747c5ab | ||
|
|
e0c39f1bc1 | ||
|
|
5deeb97cf6 | ||
|
|
c59864eb9a |
@@ -0,0 +1,71 @@
|
||||
name: Multi-MDX Regression (IMP-91)
|
||||
|
||||
# IMP-#91 u13 — auto-gate the mdx 01-05 acceptance set on every push to main
|
||||
# and on PRs targeting main. Failure of any integration test blocks the
|
||||
# commit. JSON report is emitted via pytest-json-report (u12 dep) and
|
||||
# uploaded as an artifact for u14/u15 status-board updater consumption.
|
||||
#
|
||||
# [[feedback_validation_first_for_closed_issues]] — fresh subprocess per CI run.
|
||||
# [[feedback_auto_pipeline_first]] — no manual review queue; deterministic gate.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
multi-mdx-regression:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache: pip
|
||||
|
||||
- name: Install Chrome and ChromeDriver
|
||||
uses: browser-actions/setup-chrome@v1
|
||||
with:
|
||||
install-chromedriver: true
|
||||
|
||||
- name: Install project (dev extras + selenium)
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -e ".[dev]"
|
||||
python -m pip install "selenium>=4.20"
|
||||
|
||||
- name: Run multi-mdx regression tests
|
||||
run: |
|
||||
python -m pytest -q -m integration \
|
||||
tests/integration/test_multi_mdx_regression.py \
|
||||
--json-report \
|
||||
--json-report-file=imp91-report.json \
|
||||
--json-report-omit keywords streams
|
||||
|
||||
- name: Upload pytest JSON report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: imp91-multi-mdx-report
|
||||
path: imp91-report.json
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Update status-board markers (IMP-91 u15)
|
||||
if: always()
|
||||
run: |
|
||||
python scripts/update_status_board.py \
|
||||
--report imp91-report.json \
|
||||
--board docs/architecture/PHASE-Z-PIPELINE-STATUS-BOARD.md
|
||||
|
||||
- name: Upload updated status board
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: imp91-status-board
|
||||
path: docs/architecture/PHASE-Z-PIPELINE-STATUS-BOARD.md
|
||||
if-no-files-found: warn
|
||||
@@ -1,99 +1,200 @@
|
||||
/**
|
||||
* BottomActions - 하단 액션 버튼 영역
|
||||
* BottomActions — Step 22 footer wire-up (IMP-56 #90 u20).
|
||||
*
|
||||
* 생성하기, 다운로드, 연동하기 버튼 컴포넌트
|
||||
* Two real endpoints replace the prior placeholder toasts:
|
||||
* • POST /api/connect (u18 / Front/vite.config.ts) — copies
|
||||
* data/runs/<run_id>/phase_z2/final.html + assets/ into the cel mirror
|
||||
* (`<CEL_PROJECT_ROOT>/public/slides/<slug>.html`).
|
||||
* • POST /api/export (u19 / Front/vite.config.ts) — returns a standalone
|
||||
* text/html body with every `url(assets/...)` ref inlined as base64
|
||||
* data URLs. Response is piped into a Blob → a[download] click chain so
|
||||
* the user receives `<run_id>.html` portable for file:// or any host.
|
||||
*
|
||||
* The prior `serializeSlidePlan` JSON-download path was a dead reference
|
||||
* (the export never existed in slidePlanUtils) and is removed here — the
|
||||
* "다운로드" button now means standalone HTML download via /api/export.
|
||||
* Both buttons disable when no run is loaded (runMeta == null) so the
|
||||
* UI cannot fire requests with an undefined run_id.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Sparkles, Download, Link2, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import type { SlidePlan, UserSelection } from "../types/designAgent";
|
||||
import { serializeSlidePlan } from "../utils/slidePlanUtils";
|
||||
import type { SlidePlan } from "../types/designAgent";
|
||||
import type { RunMeta } from "../services/designAgentApi";
|
||||
import { deriveUserOverridesKey } from "../utils/slidePlanUtils";
|
||||
|
||||
// ─── pure request builders (exported for vitest; jsdom-free) ─────────────
|
||||
// The component below uses these verbatim. Each returns a {url, body} pair
|
||||
// so the test surface is the *literal* HTTP payload sent to the u18 / u19
|
||||
// middlewares — any future shape drift fails here before the network call.
|
||||
|
||||
export function buildConnectRequest(
|
||||
run_id: string,
|
||||
slug: string,
|
||||
): { url: string; body: string } {
|
||||
return {
|
||||
url: "/api/connect",
|
||||
body: JSON.stringify({ run_id, slug }),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildExportRequest(
|
||||
run_id: string,
|
||||
): { url: string; body: string } {
|
||||
return {
|
||||
url: "/api/export",
|
||||
body: JSON.stringify({ run_id }),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDownloadFilename(run_id: string): string {
|
||||
return `${run_id}.html`;
|
||||
}
|
||||
|
||||
interface BottomActionsProps {
|
||||
slidePlan: SlidePlan | null;
|
||||
userSelection: UserSelection;
|
||||
runMeta: RunMeta | null;
|
||||
uploadedFile: File | null;
|
||||
isLoading: boolean;
|
||||
onGenerate: () => void;
|
||||
}
|
||||
|
||||
export default function BottomActions({
|
||||
slidePlan,
|
||||
userSelection,
|
||||
runMeta,
|
||||
uploadedFile,
|
||||
isLoading,
|
||||
onGenerate,
|
||||
}: BottomActionsProps) {
|
||||
const handleDownload = () => {
|
||||
if (!slidePlan) {
|
||||
toast.error("슬라이드 플랜이 없습니다. 먼저 생성하기를 눌러주세요.");
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
const runReady = !!runMeta && !!slidePlan;
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!runMeta) {
|
||||
toast.error("Run 산출물이 없습니다. 먼저 생성하기를 눌러주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const json = serializeSlidePlan(slidePlan, userSelection);
|
||||
console.log("[Download] SlidePlan JSON:", json);
|
||||
|
||||
// JSON 파일 다운로드
|
||||
const blob = new Blob([json], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `slide-plan-${Date.now()}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success("SlidePlan JSON이 다운로드되었습니다.");
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const exportReq = buildExportRequest(runMeta.run_id);
|
||||
const resp = await fetch(exportReq.url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: exportReq.body,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text();
|
||||
toast.error(`Export 실패 (${resp.status}): ${text.slice(0, 160)}`);
|
||||
return;
|
||||
}
|
||||
const blob = await resp.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = objectUrl;
|
||||
a.download = buildDownloadFilename(runMeta.run_id);
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
toast.success(`standalone HTML 다운로드 — ${runMeta.run_id}.html`);
|
||||
} catch (err) {
|
||||
toast.error(`Export 네트워크 오류: ${(err as Error).message}`);
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnect = () => {
|
||||
toast.info("연동하기 기능은 파이프라인 연결 후 활성화됩니다.");
|
||||
const handleConnect = async () => {
|
||||
if (!runMeta) {
|
||||
toast.error("Run 산출물이 없습니다. 먼저 생성하기를 눌러주세요.");
|
||||
return;
|
||||
}
|
||||
if (!uploadedFile) {
|
||||
toast.error("MDX 파일이 없습니다 — slug 도출 불가.");
|
||||
return;
|
||||
}
|
||||
const slug = deriveUserOverridesKey(uploadedFile.name);
|
||||
setIsConnecting(true);
|
||||
try {
|
||||
const connectReq = buildConnectRequest(runMeta.run_id, slug);
|
||||
const resp = await fetch(connectReq.url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: connectReq.body,
|
||||
});
|
||||
const payload = (await resp.json().catch(() => ({}))) as {
|
||||
success?: boolean;
|
||||
assets_copied?: number;
|
||||
error?: string;
|
||||
};
|
||||
if (!resp.ok || !payload.success) {
|
||||
toast.error(
|
||||
`Connect 실패 (${resp.status}): ${payload.error ?? "unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
toast.success(
|
||||
`cel 미러 연동 완료 — ${slug}.html (assets ${payload.assets_copied ?? 0}개 복사)`,
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(`Connect 네트워크 오류: ${(err as Error).message}`);
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-3 px-6 py-3 bg-white border-t border-slate-200">
|
||||
<div className="flex items-center gap-1.5 mr-2">
|
||||
<span className="w-5 h-5 rounded-full bg-blue-600 text-white text-xs flex items-center justify-center font-bold">4</span>
|
||||
<span className="text-xs text-slate-500 font-medium">액션</span>
|
||||
</div>
|
||||
{/* 생성하기 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
onClick={onGenerate}
|
||||
disabled={isLoading}
|
||||
className="gap-2 min-w-[120px]"
|
||||
className="gap-2 h-9 text-[11px] font-bold uppercase tracking-widest bg-slate-900 hover:bg-slate-800"
|
||||
size="default"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
생성 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
생성하기
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* 다운로드 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDownload}
|
||||
disabled={!slidePlan || isLoading}
|
||||
className="gap-2 min-w-[120px]"
|
||||
onClick={handleExport}
|
||||
disabled={!runReady || isExporting || isLoading}
|
||||
className="gap-2 h-9 text-[11px] font-bold uppercase tracking-widest border-slate-200"
|
||||
size="default"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
{isExporting ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
)}
|
||||
다운로드
|
||||
</Button>
|
||||
|
||||
{/* 연동하기 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleConnect}
|
||||
className="gap-2 min-w-[120px] text-slate-500"
|
||||
disabled={!runReady || isConnecting || isLoading}
|
||||
className="gap-2 h-9 text-[11px] font-bold uppercase tracking-widest border-slate-200"
|
||||
size="default"
|
||||
>
|
||||
<Link2 className="w-4 h-4" />
|
||||
{isConnecting ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Link2 className="w-3.5 h-3.5" />
|
||||
)}
|
||||
연동하기
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,19 @@ interface FramePanelProps {
|
||||
onNoDesignToggle: () => void;
|
||||
}
|
||||
|
||||
// IMP-#84 u1 — silent-automation contract: frame selection delegates directly
|
||||
// to onFrameSelect for every V4 label (use_as_is / light_edit / restructure /
|
||||
// reject). Prior IMP-47B u11 surfaced a window.confirm popup on reject; that
|
||||
// popup is informational UI noise per `feedback_auto_pipeline_first` and is
|
||||
// removed. Frame identity is preserved on reject (AI 재구성 = content-only,
|
||||
// per AI 격리 contract); the popup never gated that contract.
|
||||
export function applyFrameSelection(
|
||||
candidate: FrameCandidate,
|
||||
onFrameSelect: (frameId: string) => void,
|
||||
): void {
|
||||
onFrameSelect(candidate.id);
|
||||
}
|
||||
|
||||
export default function FramePanel({
|
||||
slidePlan,
|
||||
selectedZone,
|
||||
@@ -49,17 +62,9 @@ export default function FramePanel({
|
||||
|
||||
const handleFrameSelect = React.useCallback(
|
||||
(candidate: FrameCandidate) => {
|
||||
const isReject = candidate.label === "reject";
|
||||
const alreadyApplied = currentFrameId === candidate.id;
|
||||
if (isReject && !alreadyApplied) {
|
||||
const ok = window.confirm(
|
||||
`"${candidate.name}" 은 V4 reject 라벨입니다.\n선택 시 frame 은 유지되고 AI 가 콘텐츠를 frame 구조에 맞게 재구성합니다.\n계속하시겠습니까?`,
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
onFrameSelect(candidate.id);
|
||||
applyFrameSelection(candidate, onFrameSelect);
|
||||
},
|
||||
[currentFrameId, onFrameSelect],
|
||||
[onFrameSelect],
|
||||
);
|
||||
|
||||
if (!selectedZone) {
|
||||
|
||||
@@ -28,7 +28,12 @@ import {
|
||||
crossedDragThreshold,
|
||||
type ImageDragDirection,
|
||||
} from "./slideCanvasDragMath";
|
||||
import type { ImageOverridesOverride } from "../services/userOverridesApi";
|
||||
import type {
|
||||
ImageOverridesOverride,
|
||||
StructureOverridesOverride,
|
||||
StructureOverridePerZone,
|
||||
} from "../services/userOverridesApi";
|
||||
import StructureEditOverlay from "./StructureEditOverlay";
|
||||
|
||||
interface SlideCanvasProps {
|
||||
slidePlan: SlidePlan | null;
|
||||
@@ -36,10 +41,6 @@ interface SlideCanvasProps {
|
||||
userSelection: UserSelection;
|
||||
/** Phase Z 가 만든 final.html URL (iframe 으로 표시). */
|
||||
finalHtmlUrl?: string;
|
||||
/** 슬라이드 단위 inline CSS override (catalog/template 무변, iframe contentDocument 에
|
||||
* 동적 inject). Home 이 mdx 별 default visual 보완 등을 지정. 빈 문자열/undefined =
|
||||
* inject 안 함. 사용자 lock 2026-05-14 — slide-level only. */
|
||||
slideOverrideCss?: string;
|
||||
/** 파이프라인 실행 중 표시 (loading state). */
|
||||
isPipelineRunning?: boolean;
|
||||
/** Phase 2 : pending layout 모드 — final.html iframe 숨기고 빈 layout zone 만 표시. */
|
||||
@@ -77,16 +78,116 @@ interface SlideCanvasProps {
|
||||
imageId: string,
|
||||
geometry: { x: number; y: number; w: number; h: number }
|
||||
) => void;
|
||||
/** IMP-90 (#90) u13 — focusout-emitted capture; u15 debounces + PUTs. */
|
||||
onTextEdit?: (capture: TextEditCapture) => void;
|
||||
/** IMP-90 (#90) u14 — persisted structure overrides per zone
|
||||
* (slot_order + hidden_slots). When `editMode === "structure"` the
|
||||
* StructureEditOverlay reads from this to render the current state. */
|
||||
structureOverrides?: StructureOverridesOverride;
|
||||
/** IMP-90 (#90) u14 — emitted whenever the user reorders or hides a
|
||||
* slot in structure-mode. u15 will debounce + PUT to /api/user-
|
||||
* overrides; u14 only exposes the capture. SCOPE LOCK: inner shape is
|
||||
* `{slot_order, hidden_slots}` only (frame swap stays on `frames` axis). */
|
||||
onStructureEdit?: (zoneId: string, capture: StructureOverridePerZone) => void;
|
||||
}
|
||||
|
||||
const SLIDE_W = 1280;
|
||||
const SLIDE_H = 720;
|
||||
|
||||
// IMP-90 (#90) u11 — discriminated edit mode. Replaces the prior single
|
||||
// `isEditMode` boolean. u11 introduces the enum + the toolbar UI surface;
|
||||
// gesture gating (text contentEditable vs structure reorder vs image-zone
|
||||
// drag/resize) stays unified behind `isEditMode = editMode !== 'off'` so
|
||||
// existing behavior is preserved byte-identical. u12 will discriminate the
|
||||
// gestures per mode (mutually exclusive). The 'off' state is the no-edit
|
||||
// baseline; 'image-zone' bundles image edit (#79) + zone resize (#81)
|
||||
// because both are pointer-driven canvas gestures on slide geometry.
|
||||
export type EditMode = "off" | "text" | "structure" | "image-zone";
|
||||
export const EDIT_MODES: ReadonlyArray<EditMode> = ["text", "structure", "image-zone"];
|
||||
/** Pure helper — given the current edit mode and the user's requested mode,
|
||||
* return the next mode. Clicking the active mode toggles back to 'off';
|
||||
* clicking a different mode switches; explicit 'off' always exits. */
|
||||
export function nextEditMode(current: EditMode, requested: EditMode): EditMode {
|
||||
if (requested === "off") return "off";
|
||||
return current === requested ? "off" : requested;
|
||||
}
|
||||
|
||||
// IMP-90 (#90) u12 — per-mode gesture gating. Pure helper deriving the
|
||||
// boolean gates that drive SlideCanvas's useEffect branches (designMode
|
||||
// + iframe-side image click listener) and JSX conditionals (iframe
|
||||
// pointer-events, zone resize/drag affordances, image overlay). The
|
||||
// mapping enforces the mutually-exclusive contract from the issue body:
|
||||
// text -> contentEditable + iframe pointer-events:auto only.
|
||||
// structure -> nothing here; u14 will plant the structure overlay.
|
||||
// image-zone -> zone resize/drag + image overlay; iframe pe:auto so
|
||||
// in-iframe user-content images can be click-selected.
|
||||
// off -> every gate false (baseline).
|
||||
// pendingLayout fully suppresses every gate — mirrors the existing
|
||||
// useEffect (line ~248) that forces editMode='off' on pendingLayout
|
||||
// entry. The helper still defensively returns all-false so a stray
|
||||
// pendingLayout=true with a non-'off' editMode never leaks gestures.
|
||||
export interface EditModeGates {
|
||||
textEditing: boolean;
|
||||
imageSelection: boolean;
|
||||
iframePointerAuto: boolean;
|
||||
zoneGestures: boolean;
|
||||
imageOverlay: boolean;
|
||||
}
|
||||
export function computeEditModeGates(
|
||||
editMode: EditMode,
|
||||
isPendingLayout: boolean
|
||||
): EditModeGates {
|
||||
if (isPendingLayout) {
|
||||
return {
|
||||
textEditing: false,
|
||||
imageSelection: false,
|
||||
iframePointerAuto: false,
|
||||
zoneGestures: false,
|
||||
imageOverlay: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
textEditing: editMode === "text",
|
||||
imageSelection: editMode === "image-zone",
|
||||
iframePointerAuto: editMode === "text" || editMode === "image-zone",
|
||||
zoneGestures: editMode === "image-zone",
|
||||
imageOverlay: editMode === "image-zone",
|
||||
};
|
||||
}
|
||||
|
||||
// IMP-90 (#90) u13 — pure helper resolving a contentEditable focusout
|
||||
// target into (zoneId, textPath, value). data-text-path stamped by u8 at
|
||||
// Step 13; .zone[data-zone-position] from Phase Z slide-base. Non-stamped
|
||||
// targets return null so capture silently skips. u15 will debounce + PUT.
|
||||
export interface TextEditCaptureTarget {
|
||||
closest(selector: string): TextEditCaptureTarget | null;
|
||||
getAttribute(name: string): string | null;
|
||||
textContent: string | null;
|
||||
}
|
||||
export interface TextEditCapture {
|
||||
zoneId: string;
|
||||
textPath: string;
|
||||
value: string;
|
||||
}
|
||||
export function deriveTextEditCapture(
|
||||
target: TextEditCaptureTarget | null
|
||||
): TextEditCapture | null {
|
||||
if (!target) return null;
|
||||
const lineEl = target.closest("[data-text-path]");
|
||||
if (!lineEl) return null;
|
||||
const textPath = lineEl.getAttribute("data-text-path");
|
||||
if (!textPath) return null;
|
||||
const zoneEl = lineEl.closest(".zone[data-zone-position]");
|
||||
if (!zoneEl) return null;
|
||||
const zoneId = zoneEl.getAttribute("data-zone-position");
|
||||
if (!zoneId) return null;
|
||||
return { zoneId, textPath, value: (lineEl.textContent ?? "").trim() };
|
||||
}
|
||||
|
||||
export default function SlideCanvas({
|
||||
slidePlan,
|
||||
userSelection,
|
||||
finalHtmlUrl,
|
||||
slideOverrideCss,
|
||||
isPipelineRunning,
|
||||
isPendingLayout,
|
||||
pendingLayoutId,
|
||||
@@ -98,6 +199,9 @@ export default function SlideCanvas({
|
||||
onZoneResize,
|
||||
imageOverrides,
|
||||
onImageResize,
|
||||
onTextEdit,
|
||||
structureOverrides,
|
||||
onStructureEdit,
|
||||
}: SlideCanvasProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [scale, setScale] = useState(1);
|
||||
@@ -140,7 +244,15 @@ export default function SlideCanvas({
|
||||
// HTML 편집 모드 — 글벗 패턴 (designMode + contentEditable + outline CSS) 차용.
|
||||
// 활성 시 iframe 안 텍스트 element 직접 클릭하여 수정 가능. backend 반영은 별 작업.
|
||||
// pendingLayout 과 배타적 (충돌 방지).
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
// IMP-90 (#90) u11 — `editMode` enum replaces the prior boolean. The
|
||||
// `isEditMode` shim is kept ONLY for the pendingLayout coupling +
|
||||
// zone-wrapper visual cues (border / hover / selected styling) that
|
||||
// fire whenever any edit mode is active. u12 routes gesture-activating
|
||||
// gates through `editGates` so text / structure / image-zone gestures
|
||||
// are mutually exclusive.
|
||||
const [editMode, setEditMode] = useState<EditMode>("off");
|
||||
const isEditMode = editMode !== "off";
|
||||
const editGates = computeEditModeGates(editMode, !!isPendingLayout);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
// 편집 모드 toggle 시 iframe contentDocument 에 글벗 패턴 적용 / 해제.
|
||||
@@ -164,11 +276,22 @@ export default function SlideCanvas({
|
||||
|
||||
const editableTags = ["DIV", "P", "H1", "H2", "H3", "H4", "SPAN", "LI", "TD", "TH", "FIGCAPTION"];
|
||||
let inputHandler: ((e: Event) => void) | null = null;
|
||||
// IMP-90 (#90) u13 — focusout (= bubbling blur) emits one capture per
|
||||
// finished line edit; u15 will debounce + PUT.
|
||||
let textEditCaptureHandler: ((e: Event) => void) | null = null;
|
||||
// IMP-51 (#79) u8 — user-content image click listeners installed
|
||||
// inside the iframe contentDocument. Tracked here so the cleanup
|
||||
// callback can remove them when edit mode exits (or iframe reloads).
|
||||
const imageClickBindings: Array<{ el: HTMLImageElement; handler: (e: Event) => void; prevCursor: string; prevOutline: string }> = [];
|
||||
if (isEditMode) {
|
||||
|
||||
// IMP-90 (#90) u12 — text-editing gate: only the 'text' editMode
|
||||
// turns designMode on + makes the editable tags contentEditable.
|
||||
// The else branch tears the prior state down so leaving text mode
|
||||
// (to structure / image-zone / off) immediately disables in-place
|
||||
// text editing — required for mutual exclusivity vs the image-zone
|
||||
// overlay's drag/resize gestures (a contentEditable cursor would
|
||||
// otherwise be placed by every image click).
|
||||
if (editGates.textEditing) {
|
||||
doc.designMode = "on";
|
||||
doc.querySelectorAll(".slide *").forEach((el) => {
|
||||
if (editableTags.includes((el as HTMLElement).tagName)) {
|
||||
@@ -181,11 +304,28 @@ export default function SlideCanvas({
|
||||
};
|
||||
doc.addEventListener("input", inputHandler);
|
||||
|
||||
// IMP-51 (#79) u8 — wire click → selectedImageId on every stamped
|
||||
// user-content image. Selector mirrors USER_CONTENT_IMAGE_SELECTOR
|
||||
// in src/image_id_stamper.py (+ requires data-image-id which the
|
||||
// stamper always emits). Decorative / frame imgs lacking the role
|
||||
// attribute are intentionally NOT clickable here.
|
||||
textEditCaptureHandler = (ev: Event) => {
|
||||
const cap = deriveTextEditCapture(
|
||||
ev.target as unknown as TextEditCaptureTarget | null
|
||||
);
|
||||
if (cap) onTextEdit?.(cap);
|
||||
};
|
||||
doc.addEventListener("focusout", textEditCaptureHandler);
|
||||
} else {
|
||||
doc.designMode = "off";
|
||||
doc.querySelectorAll("[contenteditable]").forEach((el) => {
|
||||
(el as HTMLElement).removeAttribute("contenteditable");
|
||||
});
|
||||
}
|
||||
|
||||
// IMP-90 (#90) u12 — image-selection gate: only the 'image-zone'
|
||||
// editMode wires the in-iframe user-content image click → selection.
|
||||
// Selector mirrors USER_CONTENT_IMAGE_SELECTOR in image_id_stamper.py
|
||||
// (requires data-image-id which the stamper always emits). Decorative
|
||||
// / frame imgs lacking the role attribute are NOT clickable. The
|
||||
// else branch clears `selectedImageId` so the React-side overlay
|
||||
// never lingers on a non-image-zone edit mode.
|
||||
if (editGates.imageSelection) {
|
||||
const imgEls = doc.querySelectorAll<HTMLImageElement>(
|
||||
'.slide img[data-image-role="user-content"][data-image-id]'
|
||||
);
|
||||
@@ -205,12 +345,6 @@ export default function SlideCanvas({
|
||||
imageClickBindings.push({ el: imgEl, handler, prevCursor, prevOutline });
|
||||
});
|
||||
} else {
|
||||
doc.designMode = "off";
|
||||
doc.querySelectorAll("[contenteditable]").forEach((el) => {
|
||||
(el as HTMLElement).removeAttribute("contenteditable");
|
||||
});
|
||||
// edit-mode exit also clears stale image selection so the handle
|
||||
// overlay never lingers on a non-editable iframe.
|
||||
setSelectedImageId(null);
|
||||
}
|
||||
|
||||
@@ -218,19 +352,60 @@ export default function SlideCanvas({
|
||||
if (inputHandler && doc) {
|
||||
doc.removeEventListener("input", inputHandler);
|
||||
}
|
||||
if (textEditCaptureHandler && doc) {
|
||||
doc.removeEventListener("focusout", textEditCaptureHandler);
|
||||
}
|
||||
imageClickBindings.forEach(({ el, handler, prevCursor, prevOutline }) => {
|
||||
el.removeEventListener("click", handler);
|
||||
el.style.cursor = prevCursor;
|
||||
el.style.outline = prevOutline;
|
||||
});
|
||||
};
|
||||
}, [isEditMode, finalHtmlUrl, onContentEdit]);
|
||||
}, [editGates.textEditing, editGates.imageSelection, finalHtmlUrl, onContentEdit, onTextEdit]);
|
||||
|
||||
// pendingLayout 진입 시 편집 모드 자동 OFF (충돌 방지).
|
||||
useEffect(() => {
|
||||
if (isPendingLayout && isEditMode) setIsEditMode(false);
|
||||
if (isPendingLayout && isEditMode) setEditMode("off");
|
||||
}, [isPendingLayout, isEditMode]);
|
||||
|
||||
// IMP-90 (#90) u14 — discover slot keys per zone for the structure
|
||||
// overlay. Source = iframe DOM `data-text-path="{slot_key}.{line_index}"`
|
||||
// attributes stamped by u8 (`src/text_path_stamper.py`). Unique slot_key
|
||||
// prefixes per `.zone[data-zone-position]` form the overlay's slot list.
|
||||
// Discovery runs only when entering structure mode (and resets on exit
|
||||
// or iframe reload) so off / text / image-zone modes never pay this
|
||||
// traversal cost.
|
||||
const [slotKeysByZone, setSlotKeysByZone] = useState<
|
||||
Record<string, string[]>
|
||||
>({});
|
||||
useEffect(() => {
|
||||
if (editMode !== "structure" || isPendingLayout) {
|
||||
setSlotKeysByZone({});
|
||||
return;
|
||||
}
|
||||
const doc = iframeRef.current?.contentDocument;
|
||||
if (!doc) return;
|
||||
const next: Record<string, string[]> = {};
|
||||
doc.querySelectorAll(".zone[data-zone-position]").forEach((zEl) => {
|
||||
const zoneId = (zEl as HTMLElement).getAttribute("data-zone-position");
|
||||
if (!zoneId) return;
|
||||
const seen = new Set<string>();
|
||||
const keys: string[] = [];
|
||||
zEl.querySelectorAll("[data-text-path]").forEach((lineEl) => {
|
||||
const path = (lineEl as HTMLElement).getAttribute("data-text-path");
|
||||
if (!path) return;
|
||||
const lastDot = path.lastIndexOf(".");
|
||||
const slotKey = lastDot > 0 ? path.slice(0, lastDot) : path;
|
||||
if (slotKey && !seen.has(slotKey)) {
|
||||
seen.add(slotKey);
|
||||
keys.push(slotKey);
|
||||
}
|
||||
});
|
||||
next[zoneId] = keys;
|
||||
});
|
||||
setSlotKeysByZone(next);
|
||||
}, [editMode, isPendingLayout, finalHtmlUrl]);
|
||||
|
||||
// finalHtmlUrl 이 바뀌면 (= 다른 run / 재실행) stale 측정값 reset.
|
||||
// 새 iframe 의 onLoad 가 발화하면서 measuredZones 다시 채움.
|
||||
useEffect(() => {
|
||||
@@ -337,28 +512,50 @@ export default function SlideCanvas({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 편집 모드 toggle 버튼 — normal mode + final.html 있을 때만.
|
||||
글벗 패턴 차용 — designMode + contentEditable. backend 반영은 별 작업. */}
|
||||
{/* IMP-90 (#90) u11 — discriminated edit-mode toolbar.
|
||||
Replaces the prior single ✏ toggle. Three modes (text /
|
||||
structure / image-zone) are mutually exclusive; clicking the
|
||||
active mode toggles back to 'off'. Gesture gating per mode is
|
||||
u12 — u11 only plants the state + UI surface, so all three
|
||||
modes currently share the same `isEditMode` shim behavior. */}
|
||||
{!isPendingLayout && finalHtmlUrl && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsEditMode((p) => !p);
|
||||
}}
|
||||
className={`absolute top-2 right-2 z-30 text-[10px] font-bold uppercase tracking-tighter px-2.5 py-1 rounded shadow transition ${
|
||||
isEditMode
|
||||
? "bg-emerald-500 text-white hover:bg-emerald-600 ring-2 ring-emerald-200"
|
||||
: "bg-white text-slate-700 hover:bg-slate-100 border border-slate-200"
|
||||
}`}
|
||||
<div
|
||||
data-testid="edit-mode-toolbar"
|
||||
className="absolute top-2 right-2 z-30 flex gap-1"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
title={
|
||||
isEditMode
|
||||
? "편집 모드 — 텍스트 클릭하여 수정. 다시 클릭하여 종료. (변경은 frontend 만, backend 반영 미구현)"
|
||||
: "텍스트 직접 편집 모드 진입"
|
||||
}
|
||||
>
|
||||
{isEditMode ? "✏ 편집 중 (클릭 종료)" : "✏ 편집"}
|
||||
</button>
|
||||
{EDIT_MODES.map((mode) => {
|
||||
const active = editMode === mode;
|
||||
const label =
|
||||
mode === "text" ? "✏ 텍스트" : mode === "structure" ? "▦ 구조" : "🖼 이미지/존";
|
||||
const title =
|
||||
mode === "text"
|
||||
? "텍스트 편집 — 텍스트 클릭하여 직접 수정"
|
||||
: mode === "structure"
|
||||
? "구조 편집 — slot 순서 / 숨김 변경 (u14 펜딩)"
|
||||
: "이미지/존 편집 — 이미지 드래그·리사이즈 + 존 리사이즈";
|
||||
return (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
data-testid={`edit-mode-${mode}`}
|
||||
aria-pressed={active}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditMode((prev) => nextEditMode(prev, mode));
|
||||
}}
|
||||
className={`text-[10px] font-bold uppercase tracking-tighter px-2.5 py-1 rounded shadow transition ${
|
||||
active
|
||||
? "bg-emerald-500 text-white hover:bg-emerald-600 ring-2 ring-emerald-200"
|
||||
: "bg-white text-slate-700 hover:bg-slate-100 border border-slate-200"
|
||||
}`}
|
||||
title={title}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
@@ -380,7 +577,13 @@ export default function SlideCanvas({
|
||||
className="w-full h-full border-0 block"
|
||||
scrolling="no"
|
||||
sandbox="allow-same-origin allow-scripts"
|
||||
style={{ pointerEvents: isEditMode ? "auto" : "none" }}
|
||||
// IMP-90 (#90) u12 — iframe pointer-events gate. 'text' needs
|
||||
// pe:auto so the user can click into text fields; 'image-zone'
|
||||
// needs pe:auto so user-content image clicks can reach the
|
||||
// in-iframe click handler that drives `selectedImageId`.
|
||||
// 'structure' and 'off' keep pe:none — structure has no
|
||||
// in-iframe gesture (u14 will overlay React-side controls).
|
||||
style={{ pointerEvents: editGates.iframePointerAuto ? "auto" : "none" }}
|
||||
onLoad={(e) => {
|
||||
// IMP-14 (Step 13 A-4) — embedded vs standalone CSS reset 은 backend
|
||||
// slide_base.html 가 `?embedded=1` query 로 소유. frontend 가 더 이상
|
||||
@@ -391,15 +594,6 @@ export default function SlideCanvas({
|
||||
const doc = (e.currentTarget as HTMLIFrameElement).contentDocument;
|
||||
if (!doc) return;
|
||||
|
||||
// 2026-05-14 — slide-level override CSS (catalog/template 무변).
|
||||
// Home 이 mdx 별 default visual 보완 (bullet 간격 / zone 비율 등) 지정.
|
||||
if (slideOverrideCss && slideOverrideCss.trim()) {
|
||||
const overrideStyle = doc.createElement("style");
|
||||
overrideStyle.setAttribute("data-purpose", "slide-level-override");
|
||||
overrideStyle.textContent = slideOverrideCss;
|
||||
doc.head.appendChild(overrideStyle);
|
||||
}
|
||||
|
||||
// ── Zone DOM 측정 ──
|
||||
// backend final.html 의 .zone[data-zone-position="..."] 요소를
|
||||
// 찾아서 boundingClientRect 측정 → 1280×720 기준 정규화.
|
||||
@@ -578,9 +772,11 @@ export default function SlideCanvas({
|
||||
const makeResizeHandler = (
|
||||
direction: ResizeDir
|
||||
) => (ev: React.MouseEvent<HTMLDivElement>) => {
|
||||
// resize 는 pendingLayout OR 편집 모드 활성. 2026-05-22 demo hot-fix —
|
||||
// frame partial 에 @container aspect-ratio 회전이 들어가서 fixed px 제약 사라짐.
|
||||
if ((!isPendingLayout && !isEditMode) || !onZoneResize) return;
|
||||
// resize 는 pendingLayout OR image-zone 편집 모드 활성. 2026-05-22
|
||||
// demo hot-fix — frame partial 에 @container aspect-ratio 회전이
|
||||
// 들어가서 fixed px 제약 사라짐. IMP-90 u12: text/structure 모드
|
||||
// 에서는 zone resize 비활성 (mutually exclusive per editGates).
|
||||
if ((!isPendingLayout && !editGates.zoneGestures) || !onZoneResize) return;
|
||||
if (!measuredSlideBody) return;
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
@@ -651,7 +847,10 @@ export default function SlideCanvas({
|
||||
ev: React.MouseEvent<HTMLDivElement>
|
||||
) => {
|
||||
ev.stopPropagation();
|
||||
const canDrag = !!((isPendingLayout || isEditMode) && measuredSlideBody && onZoneResize);
|
||||
// IMP-90 u12: zone drag is image-zone-mode-only (text /
|
||||
// structure suppress canDrag; non-zoneGestures click still
|
||||
// triggers onZoneClick via the !dragged branch on mouse-up).
|
||||
const canDrag = !!((isPendingLayout || editGates.zoneGestures) && measuredSlideBody && onZoneResize);
|
||||
const startMouseX = ev.clientX;
|
||||
const startMouseY = ev.clientY;
|
||||
const startGeom = { ...localGeom };
|
||||
@@ -870,12 +1069,14 @@ export default function SlideCanvas({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step C : zone resize handles — 8 방향. pendingLayout OR 편집 모드 활성.
|
||||
2026-05-22 demo hot-fix — frame partial 에 @container aspect-ratio 회전
|
||||
들어간 후 fixed px 제약 사라져 편집 모드 resize 도 의미 있음.
|
||||
{/* Step C : zone resize handles — 8 방향. pendingLayout OR image-zone
|
||||
편집 모드 활성. 2026-05-22 demo hot-fix — frame partial 에 @container
|
||||
aspect-ratio 회전 들어간 후 fixed px 제약 사라져 image-zone 모드 resize
|
||||
도 의미 있음. IMP-90 u12: text / structure 모드에서는 zone resize
|
||||
affordance 미노출 (editGates.zoneGestures = image-zone only).
|
||||
edge handle (top/bottom/left/right) : 한 boundary 이동
|
||||
corner handle (nw/ne/sw/se) : 두 boundary 동시. */}
|
||||
{(isPendingLayout || isEditMode) && onZoneResize && (
|
||||
{(isPendingLayout || editGates.zoneGestures) && onZoneResize && (
|
||||
<>
|
||||
{/* top edge */}
|
||||
<div
|
||||
@@ -955,8 +1156,10 @@ export default function SlideCanvas({
|
||||
strips intercept the perimeter while the un-covered iframe
|
||||
interior keeps text-edit reachability intact.
|
||||
pendingLayout mode already has wrapper pointerEvents:auto,
|
||||
so these surfaces are only needed in edit mode. */}
|
||||
{isEditMode && !isPendingLayout && onZoneResize && (
|
||||
so these surfaces are only needed in edit mode.
|
||||
IMP-90 u12: image-zone-mode-only — text / structure 모드는
|
||||
zone drag 안 함 (editGates.zoneGestures = false 두 모드 모두). */}
|
||||
{editGates.zoneGestures && !isPendingLayout && onZoneResize && (
|
||||
<>
|
||||
<div
|
||||
onMouseDown={handleZoneMouseDown}
|
||||
@@ -1001,6 +1204,39 @@ export default function SlideCanvas({
|
||||
);
|
||||
})}
|
||||
|
||||
{/* IMP-90 (#90) u14 — structure edit overlay (slot reorder +
|
||||
hide). Renders only in `editMode === "structure"` over each
|
||||
measured zone, positioned at the zone's top-right inside the
|
||||
slide-absolute coord space. Slot keys come from u14 iframe
|
||||
traversal (`slotKeysByZone`). Mutations emit through
|
||||
onStructureEdit; u15 will debounce + PUT. */}
|
||||
{!isPendingLayout && editMode === "structure" && finalHtmlUrl &&
|
||||
slidePlan?.zones.map((zone) => {
|
||||
const m = measuredZones[zone.zone_id];
|
||||
if (!m) return null;
|
||||
const slotKeys = slotKeysByZone[zone.zone_id] ?? [];
|
||||
const current = structureOverrides?.[zone.zone_id];
|
||||
return (
|
||||
<div
|
||||
key={`struct-${zone.id}`}
|
||||
className="absolute z-30"
|
||||
style={{
|
||||
left: m.x * W_SCALED,
|
||||
top: m.y * H_SCALED,
|
||||
width: m.w * W_SCALED,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<StructureEditOverlay
|
||||
zoneId={zone.zone_id}
|
||||
slotKeys={slotKeys}
|
||||
current={current}
|
||||
onChange={onStructureEdit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* ── IMP-51 (#79) u8 — user-content image edit overlay ──
|
||||
Activates only in edit mode when an image_id appears in either
|
||||
`imageOverrides` (u11-fed persisted axis) or `measuredImages`
|
||||
@@ -1028,7 +1264,11 @@ export default function SlideCanvas({
|
||||
`slideCanvasDragMath.ts` so the boundary contract Codex #16
|
||||
verified is exercised directly by vitest (mirror of how IMP-54
|
||||
u3 split the zone math out of SlideCanvas). */}
|
||||
{!isPendingLayout && isEditMode && finalHtmlUrl && onImageResize &&
|
||||
{/* IMP-90 u12: image overlay is image-zone-mode-only. text /
|
||||
structure 모드에서는 image drag/resize affordance 미노출
|
||||
(editGates.imageOverlay = false). pendingLayout 도 동일하게
|
||||
suppress (computeEditModeGates 가 모두 false 반환). */}
|
||||
{!isPendingLayout && editGates.imageOverlay && finalHtmlUrl && onImageResize &&
|
||||
Object.entries({ ...measuredImages, ...(imageOverrides ?? {}) }).map(
|
||||
([imageId]) => {
|
||||
const persisted = imageOverrides?.[imageId];
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* IMP-90 (#90) u14 — Structure edit overlay.
|
||||
*
|
||||
* React component + pure helpers that present a per-zone slot list with
|
||||
* reorder (↑ / ↓) and hide (👁 / 🚫) affordances. Mounted by SlideCanvas
|
||||
* when `editMode === "structure"`. Emits a `StructureOverridePerZone`
|
||||
* tuple `{slot_order, hidden_slots}` through `onChange`; u15 will debounce
|
||||
* + PUT this to `/api/user-overrides` (NOT u14 scope), and u16 reads the
|
||||
* persisted axis at the next CLI generate run.
|
||||
*
|
||||
* SCOPE LOCK (binding contract):
|
||||
* - inner shape = `{slot_order, hidden_slots}` ONLY.
|
||||
* - frame swap stays on the existing `frames` axis (u6 backend resolver
|
||||
* rejects frame-swap-shaped inner keys).
|
||||
* - per-slot text content NEVER mutated here — `text_overrides` axis
|
||||
* (u4/u5/u13) handles that exclusively.
|
||||
*
|
||||
* The exported pure helpers (`resolveEffectiveSlotOrder`, `moveItem`) are
|
||||
* the unit's vitest surface; React rendering is NOT tested because the
|
||||
* Front package devDependencies do not include jsdom / @testing-library
|
||||
* (verified by u11/u12/u13 test pattern).
|
||||
*/
|
||||
import type {
|
||||
StructureOverridePerZone,
|
||||
} from "../services/userOverridesApi";
|
||||
|
||||
export interface StructureEditOverlayProps {
|
||||
zoneId: string;
|
||||
/** Discovered slot keys for this zone (e.g. from iframe DOM
|
||||
* `data-text-path` prefixes). Order = backend default. */
|
||||
slotKeys: ReadonlyArray<string>;
|
||||
/** Current persisted override (or undefined). `slot_order` reorders the
|
||||
* discovered keys; missing keys keep backend order at the tail. */
|
||||
current?: StructureOverridePerZone;
|
||||
/** Emitted on every user mutation. u15 wires this to autosave. */
|
||||
onChange?: (zoneId: string, next: StructureOverridePerZone) => void;
|
||||
}
|
||||
|
||||
/** Apply `slot_order` override to the discovered slot list. Unknown
|
||||
* override entries are dropped; missing discovered keys are appended in
|
||||
* backend order so the user never loses a slot by partial-override. */
|
||||
export function resolveEffectiveSlotOrder(
|
||||
slotKeys: ReadonlyArray<string>,
|
||||
slotOrder?: ReadonlyArray<string> | null,
|
||||
): string[] {
|
||||
if (!slotOrder || slotOrder.length === 0) return [...slotKeys];
|
||||
const allowed = new Set(slotKeys);
|
||||
const seen = new Set<string>();
|
||||
const ordered: string[] = [];
|
||||
for (const k of slotOrder) {
|
||||
if (typeof k === "string" && allowed.has(k) && !seen.has(k)) {
|
||||
ordered.push(k);
|
||||
seen.add(k);
|
||||
}
|
||||
}
|
||||
for (const k of slotKeys) {
|
||||
if (!seen.has(k)) ordered.push(k);
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
/** Move `arr[index]` by `delta` positions. Out-of-range returns a fresh
|
||||
* copy of the input (defensive: caller can always treat the result as a
|
||||
* new reference). */
|
||||
export function moveItem<T>(
|
||||
arr: ReadonlyArray<T>,
|
||||
index: number,
|
||||
delta: number,
|
||||
): T[] {
|
||||
const next = arr.slice();
|
||||
const target = index + delta;
|
||||
if (
|
||||
index < 0 ||
|
||||
index >= next.length ||
|
||||
target < 0 ||
|
||||
target >= next.length
|
||||
) {
|
||||
return next;
|
||||
}
|
||||
const tmp = next[index];
|
||||
next[index] = next[target];
|
||||
next[target] = tmp;
|
||||
return next;
|
||||
}
|
||||
|
||||
export default function StructureEditOverlay({
|
||||
zoneId,
|
||||
slotKeys,
|
||||
current,
|
||||
onChange,
|
||||
}: StructureEditOverlayProps) {
|
||||
const effective = resolveEffectiveSlotOrder(slotKeys, current?.slot_order);
|
||||
const hidden = new Set(current?.hidden_slots ?? []);
|
||||
const emit = (nextOrder: string[], nextHidden: Set<string>) => {
|
||||
onChange?.(zoneId, {
|
||||
slot_order: nextOrder,
|
||||
hidden_slots: Array.from(nextHidden),
|
||||
});
|
||||
};
|
||||
return (
|
||||
<div
|
||||
data-testid={`structure-overlay-${zoneId}`}
|
||||
className="bg-white/95 border border-emerald-300 rounded shadow p-2 flex flex-col gap-1 text-[10px]"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
>
|
||||
<div className="font-bold uppercase tracking-wider text-emerald-700 mb-1">
|
||||
▦ {zoneId}
|
||||
</div>
|
||||
{effective.length === 0 ? (
|
||||
<div className="text-slate-400 italic">slot 없음</div>
|
||||
) : (
|
||||
effective.map((key, i) => (
|
||||
<div
|
||||
key={key}
|
||||
data-testid={`slot-${zoneId}-${key}`}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<span
|
||||
className={`flex-1 truncate ${
|
||||
hidden.has(key) ? "text-slate-400 line-through" : "text-slate-700"
|
||||
}`}
|
||||
>
|
||||
{key}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`slot-up-${zoneId}-${key}`}
|
||||
disabled={i === 0}
|
||||
onClick={() => emit(moveItem(effective, i, -1), hidden)}
|
||||
className="px-1 rounded border border-slate-200 disabled:opacity-30 hover:bg-slate-100"
|
||||
title="위로"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`slot-down-${zoneId}-${key}`}
|
||||
disabled={i === effective.length - 1}
|
||||
onClick={() => emit(moveItem(effective, i, 1), hidden)}
|
||||
className="px-1 rounded border border-slate-200 disabled:opacity-30 hover:bg-slate-100"
|
||||
title="아래로"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`slot-hide-${zoneId}-${key}`}
|
||||
aria-pressed={hidden.has(key)}
|
||||
onClick={() => {
|
||||
const nh = new Set(hidden);
|
||||
if (nh.has(key)) nh.delete(key);
|
||||
else nh.add(key);
|
||||
emit(effective, nh);
|
||||
}}
|
||||
className="px-1 rounded border border-slate-200 hover:bg-slate-100"
|
||||
title={hidden.has(key) ? "표시" : "숨김"}
|
||||
>
|
||||
{hidden.has(key) ? "🚫" : "👁"}
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+204
-62
@@ -16,9 +16,12 @@ import {
|
||||
moveSectionToZone,
|
||||
saveZoneSizes,
|
||||
saveImageOverride,
|
||||
saveTextOverride,
|
||||
saveStructureOverride,
|
||||
deriveUserOverridesKey,
|
||||
applyPersistedNonFrameOverrides,
|
||||
remapPersistedFramesToZoneFrames,
|
||||
validateZoneGeometriesAgainstLayout,
|
||||
} from "../utils/slidePlanUtils";
|
||||
import {
|
||||
parseMdxFile,
|
||||
@@ -40,8 +43,9 @@ import LeftMdxPanel from "../components/LeftMdxPanel";
|
||||
import SlideCanvas from "../components/SlideCanvas";
|
||||
import LayoutPanel from "../components/LayoutPanel";
|
||||
import FramePanel from "../components/FramePanel";
|
||||
import BottomActions from "../components/BottomActions";
|
||||
import {
|
||||
Sparkles, Download, Link2, Loader2,
|
||||
Sparkles, Loader2,
|
||||
CheckCircle2, HelpCircle,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -154,6 +158,31 @@ export default function Home() {
|
||||
}
|
||||
carriedZoneSections[targetPos].push(...zone.section_ids);
|
||||
});
|
||||
// IMP-44 (#73) u4 — clear in-memory zone_geometries on layout flip.
|
||||
// The persisted keys were valid for the *prior* preset; carrying them
|
||||
// forward into the new preset would either trip the u1/u2 backend
|
||||
// [override-warning] guards (foreign keys dropped, override_applied
|
||||
// forced back to None) or partially apply on shared keys. Drop them
|
||||
// up-front so the new layout starts from a clean even-split baseline,
|
||||
// and persist a clear sentinel (null) so a subsequent reopen does not
|
||||
// resurrect the stale snapshot from user_overrides.json.
|
||||
const priorGeoms = p.userSelection.overrides.zone_geometries;
|
||||
const hadPriorGeoms =
|
||||
priorGeoms && typeof priorGeoms === "object" && Object.keys(priorGeoms).length > 0;
|
||||
if (p.uploadedFile && hadPriorGeoms) {
|
||||
const key = deriveUserOverridesKey(p.uploadedFile.name);
|
||||
void saveUserOverrides(key, { zone_geometries: null });
|
||||
}
|
||||
// IMP-55 (#93) u12 — persist the marker reset to disk so a stale
|
||||
// `manual_section_assignment: true` from a prior drag (written via
|
||||
// u6's co-PUT) cannot survive the layout apply. The in-memory reset
|
||||
// on line 192 protects the current session, but a page reload would
|
||||
// re-seed from disk via u3's restore branch and re-arm the u7 gate.
|
||||
// Unconditional — apply always resets, independent of hadPriorGeoms.
|
||||
if (p.uploadedFile) {
|
||||
const key = deriveUserOverridesKey(p.uploadedFile.name);
|
||||
void saveUserOverrides(key, { manual_section_assignment: false });
|
||||
}
|
||||
return {
|
||||
...p,
|
||||
userSelection: {
|
||||
@@ -162,6 +191,18 @@ export default function Home() {
|
||||
...p.userSelection.overrides,
|
||||
layout_preset: layoutId,
|
||||
zone_sections: carriedZoneSections,
|
||||
zone_geometries: {},
|
||||
// IMP-55 (#93) u5 — reset the bool intent marker to `false` on
|
||||
// layout apply. `carriedZoneSections` above is auto-carry (old
|
||||
// zone.section_ids → new layout positions), NOT user drag-drop
|
||||
// intent. Without this explicit reset the spread of
|
||||
// `...p.userSelection.overrides` would carry a prior-drag `true`
|
||||
// into the new layout, causing handleGenerate (u7) to forward
|
||||
// auto-carried assignments as user overrides and re-trigger the
|
||||
// PARTIAL_COVERAGE regression. The marker flips back to `true`
|
||||
// only when the user actually drag-drops a section in the new
|
||||
// layout (u6 handleSectionDrop).
|
||||
manual_section_assignment: false,
|
||||
},
|
||||
selectedZoneId: null,
|
||||
selectedRegionId: null,
|
||||
@@ -176,10 +217,27 @@ export default function Home() {
|
||||
// pending 모드 취소 → 평소 (final.html iframe) 모드 복귀.
|
||||
const handleCancelPendingLayout = useCallback(() => {
|
||||
setPendingLayout(null);
|
||||
setState((p) => ({
|
||||
...p,
|
||||
userSelection: createInitialUserSelection(p.slidePlan),
|
||||
}));
|
||||
setState((p) => {
|
||||
// IMP-55 (#93) u12 — persist marker=false to disk on cancel. In-memory
|
||||
// the u3 seed via createInitialUserSelection already pins false (u5
|
||||
// contract), but if a prior drag-drop wrote `true` to disk via u6's
|
||||
// co-PUT, that value would survive a reopen and re-arm the u7
|
||||
// forwarding gate on the next page load. Symmetric with the apply
|
||||
// path's disk PUT above.
|
||||
if (p.uploadedFile) {
|
||||
const key = deriveUserOverridesKey(p.uploadedFile.name);
|
||||
void saveUserOverrides(key, { manual_section_assignment: false });
|
||||
}
|
||||
return {
|
||||
...p,
|
||||
// IMP-55 (#93) u5 — cancel discards all pending overrides via
|
||||
// `createInitialUserSelection`, whose u3 seed pins
|
||||
// `manual_section_assignment: false`. In-memory reset is implicit
|
||||
// via the seed; u12 adds the disk-side PUT above to keep persisted
|
||||
// state consistent so a reopen does not re-arm the marker.
|
||||
userSelection: createInitialUserSelection(p.slidePlan),
|
||||
};
|
||||
});
|
||||
setHasPendingChanges(false);
|
||||
}, []);
|
||||
|
||||
@@ -225,22 +283,6 @@ export default function Home() {
|
||||
// 호출되는 단일 callback. handleFileUpload 가 자동 분석 trigger.
|
||||
const [selectedSample, setSelectedSample] = useState<"03" | "04" | "05" | null>(null);
|
||||
|
||||
// 2026-05-14 — mdx 별 slide-level CSS override (catalog/template 무변, frontend layer only).
|
||||
// SlideCanvas 의 iframe onLoad 에서 동적 inject. 사용자 룰 : "보고용 슬라이드 결과물 단위"
|
||||
// 변경. mdx04 의 default (rank 1 = process_product_two_way) 일 때만 적용 — 사용자 frame
|
||||
// override 후 (rank 2 = bim_dx_comparison_table 등) 다른 frame 시 무적용.
|
||||
const MDX04_DEFAULT_OVERRIDE_CSS = `
|
||||
.slide-body {
|
||||
grid-template-rows: 0.38fr 0.60fr !important;
|
||||
gap: 1.5% !important;
|
||||
}
|
||||
.f29b__cell .text-line + .text-line { margin-top: 1px !important; }
|
||||
.f29b__cell:nth-child(n+3) {
|
||||
padding-top: 3px !important;
|
||||
margin-top: 2px !important;
|
||||
}
|
||||
`.trim();
|
||||
|
||||
const handleSelectSample = useCallback(async (which: "03" | "04" | "05") => {
|
||||
try {
|
||||
const res = await fetch(`/api/sample-mdx?mdx=${encodeURIComponent(which)}`);
|
||||
@@ -329,34 +371,69 @@ export default function Home() {
|
||||
|
||||
// zone-geometry override — backend 의 build_layout_css 에 전달 (horizontal-2 /
|
||||
// vertical-2 만 적용). zone_id (top/bottom/...) → slide-body 내부 0~1 비율.
|
||||
// IMP-44 (#73) u4 — validate against the active layout *before* the
|
||||
// round-trip so foreign-preset keys never reach the backend. Mirrors
|
||||
// the u1/u2 WARN+DROP guards on the frontend side: dropped keys surface
|
||||
// as a toast (so the user knows why their resize "vanished"), and only
|
||||
// the `kept` subset is forwarded. The active layout = the layout the
|
||||
// backend will use, which is `overrides.layout` when the user has set
|
||||
// one, else the default slidePlan preset (mirrors backend resolution).
|
||||
const zoneGeometries = state.userSelection.overrides.zone_geometries;
|
||||
if (zoneGeometries && Object.keys(zoneGeometries).length > 0) {
|
||||
overrides.zoneGeometries = zoneGeometries;
|
||||
const activeLayout = overrides.layout ?? sourcePlan.layout_preset;
|
||||
const validation = validateZoneGeometriesAgainstLayout(
|
||||
zoneGeometries,
|
||||
activeLayout,
|
||||
);
|
||||
if (Object.keys(validation.dropped).length > 0) {
|
||||
toast.error(
|
||||
`zone_geometries layout-mismatch: dropped ${Object.keys(validation.dropped).join(", ")} (expected ${validation.expectedPositions.join(", ") || "—"}; layout=${activeLayout}).`,
|
||||
);
|
||||
}
|
||||
if (Object.keys(validation.kept).length > 0) {
|
||||
overrides.zoneGeometries = validation.kept;
|
||||
}
|
||||
}
|
||||
|
||||
// 2026-05-22 — IMP-08 B-3 원래 동작 (sameAsDefault with effectiveSlidePlan) 복귀.
|
||||
// 시연 안정성 우선. section swap 은 별 path (수동 drag detection) 로 풀어야 함.
|
||||
// 임시 over-aggressive fix 가 default flow 깨뜨려 PARTIAL_COVERAGE 발생했음.
|
||||
const userZoneSections = state.userSelection.overrides.zone_sections;
|
||||
if (userZoneSections) {
|
||||
const defaultByZone = new Map<string, string[]>();
|
||||
sourcePlan.zones.forEach((z) => {
|
||||
defaultByZone.set(z.zone_id, z.section_ids);
|
||||
});
|
||||
const zoneSectionsDiff: Record<string, string[]> = {};
|
||||
for (const [zoneId, sids] of Object.entries(userZoneSections)) {
|
||||
if (!Array.isArray(sids)) continue;
|
||||
const cleaned = sids.filter((s) => typeof s === "string" && s.trim());
|
||||
const defaults = defaultByZone.get(zoneId) ?? [];
|
||||
const sameAsDefault =
|
||||
cleaned.length === defaults.length &&
|
||||
cleaned.every((sid, i) => sid === defaults[i]);
|
||||
if (!sameAsDefault) {
|
||||
zoneSectionsDiff[zoneId] = cleaned;
|
||||
// IMP-55 (#93) u7 — Replace the IMP-08 B-3 self-compare with the bool
|
||||
// `manual_section_assignment` intent marker gate. The prior code built
|
||||
// `defaultByZone` from `sourcePlan.zones` and compared against the
|
||||
// user's `overrides.zone_sections`, but `sourcePlan === effectiveSlidePlan`
|
||||
// (Home.tsx:305) and `effectiveSlidePlan.zones === pendingZones`
|
||||
// (Home.tsx:649), which is itself derived from
|
||||
// `state.userSelection.overrides.zone_sections` via slidePlanUtils.ts.
|
||||
// The comparison was degenerate (user input vs itself), so real drag-drop
|
||||
// swaps were classified `sameAsDefault` and silently dropped from
|
||||
// `overrides.zoneSections` — the exact regression IMP-55 fixes.
|
||||
// - true → forward `zone_sections` filtered to zone_ids that exist in
|
||||
// `sourcePlan.zones` (cross-layout safety so foreign zone keys from a
|
||||
// stale persisted layout never reach backend `--override-section-
|
||||
// assignment`). u6 is the SOLE setter of true (real drag-drop).
|
||||
// - false → skip. Backend determines assignment from its own default
|
||||
// policy. u3 seeds false on first load, u5 resets false on layout
|
||||
// apply auto-carry, u12 persists false so a stale disk `true` cannot
|
||||
// survive a reopen-after-apply window.
|
||||
// No `sameAsDefault` heuristic — the marker is the source of intent.
|
||||
const manualMarker =
|
||||
state.userSelection.overrides.manual_section_assignment;
|
||||
if (manualMarker === true) {
|
||||
const userZoneSections = state.userSelection.overrides.zone_sections;
|
||||
if (userZoneSections) {
|
||||
const validZoneIds = new Set(
|
||||
sourcePlan.zones.map((z) => z.zone_id),
|
||||
);
|
||||
const zoneSectionsForward: Record<string, string[]> = {};
|
||||
for (const [zoneId, sids] of Object.entries(userZoneSections)) {
|
||||
if (!validZoneIds.has(zoneId)) continue;
|
||||
if (!Array.isArray(sids)) continue;
|
||||
const cleaned = sids.filter(
|
||||
(s) => typeof s === "string" && s.trim(),
|
||||
);
|
||||
zoneSectionsForward[zoneId] = cleaned;
|
||||
}
|
||||
if (Object.keys(zoneSectionsForward).length > 0) {
|
||||
overrides.zoneSections = zoneSectionsForward;
|
||||
}
|
||||
}
|
||||
if (Object.keys(zoneSectionsDiff).length > 0) {
|
||||
overrides.zoneSections = zoneSectionsDiff;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -384,6 +461,14 @@ export default function Home() {
|
||||
// clicks Generate would race the PUT against /api/run; the u2
|
||||
// fallback could then load a stale persisted document.
|
||||
await flushUserOverrides();
|
||||
// IMP-42 u4 — unconditional DIAG console.log on the handleGenerate
|
||||
// entry-to-backend boundary. Surfaces the override payload + uploaded
|
||||
// file name so the user can see exactly what crossed the wire when
|
||||
// the pipeline fails silently. No env gate (silence is the bug).
|
||||
console.log("[DIAG raw overrides]", {
|
||||
file: state.uploadedFile.name,
|
||||
overrides,
|
||||
});
|
||||
const result = await runPipeline(state.uploadedFile, overrides);
|
||||
|
||||
if (!result.success || !result.final_html_exists) {
|
||||
@@ -450,7 +535,21 @@ export default function Home() {
|
||||
const handleSectionDrop = useCallback((sectionId: string, zoneId: string) => {
|
||||
setState((p) => {
|
||||
const newSelection = moveSectionToZone(p.userSelection, sectionId, zoneId);
|
||||
const finalSelection = selectZone(newSelection, zoneId); // 이동된 존 자동 선택
|
||||
const zoneSelected = selectZone(newSelection, zoneId); // 이동된 존 자동 선택
|
||||
// IMP-55 (#93) u6 — flip the bool intent marker to `true` on real
|
||||
// user drag-drop. Inverse of the u5 reset (layout apply/cancel
|
||||
// auto-carry → false). handleGenerate (u7) gates `overrides.zoneSections`
|
||||
// forwarding on this marker, so an unflipped drop would never reach
|
||||
// the backend (the IMP-55 self-compare regression). The marker is
|
||||
// flipped BEFORE persistence so the in-memory selection and the
|
||||
// co-PUT body stay in sync atomically.
|
||||
const finalSelection = {
|
||||
...zoneSelected,
|
||||
overrides: {
|
||||
...zoneSelected.overrides,
|
||||
manual_section_assignment: true,
|
||||
},
|
||||
};
|
||||
// IMP-52 u7 — persist the post-drop zone_sections snapshot. The on-disk
|
||||
// schema axis (`zone_sections`) shares the in-memory shape (zone_id →
|
||||
// section_ids), so we forward the full mutated value; the u4 PUT path
|
||||
@@ -458,10 +557,15 @@ export default function Home() {
|
||||
// p.uploadedFile gate skips persistence before any MDX is loaded —
|
||||
// the demo-mode initial render path would otherwise PUT to the empty
|
||||
// key. saveUserOverrides is debounced (300ms) and per-key coalesced.
|
||||
// IMP-55 (#93) u6 — co-PUT `manual_section_assignment: true` in the
|
||||
// SAME body so the disk file never has the post-drop zone_sections
|
||||
// without the marker (would otherwise look like an unmotivated
|
||||
// IMP-52 zone_sections write to the u9 backend fallback).
|
||||
if (p.uploadedFile) {
|
||||
const key = deriveUserOverridesKey(p.uploadedFile.name);
|
||||
void saveUserOverrides(key, {
|
||||
zone_sections: finalSelection.overrides.zone_sections,
|
||||
manual_section_assignment: true,
|
||||
});
|
||||
}
|
||||
return { ...p, userSelection: finalSelection };
|
||||
@@ -579,6 +683,51 @@ export default function Home() {
|
||||
setHasPendingChanges(true);
|
||||
}, []);
|
||||
|
||||
// IMP-56 (#90) u15 — wire SlideCanvas u13 focusout capture into the new
|
||||
// `text_overrides` persist axis. Mirrors handleImageResize: merge the
|
||||
// (zoneId, textPath, value) tick via `saveTextOverride` (u15 pure helper)
|
||||
// and schedule the 300ms-debounced PUT under the `text_overrides` axis.
|
||||
// Per-axis coalescing in `saveUserOverrides` collapses rapid edits in
|
||||
// the same line into a single PUT; per-key buckets isolate cross-MDX.
|
||||
const handleTextEdit = useCallback(
|
||||
(capture: { zoneId: string; textPath: string; value: string }) => {
|
||||
setState((p) => {
|
||||
const nextSelection = saveTextOverride(
|
||||
p.userSelection, capture.zoneId, capture.textPath, capture.value,
|
||||
);
|
||||
if (p.uploadedFile) {
|
||||
const key = deriveUserOverridesKey(p.uploadedFile.name);
|
||||
void saveUserOverrides(key, {
|
||||
text_overrides: nextSelection.overrides.text_overrides,
|
||||
});
|
||||
}
|
||||
return { ...p, userSelection: nextSelection };
|
||||
});
|
||||
setHasPendingChanges(true);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// IMP-56 (#90) u15 — wire SlideCanvas u14 structure overlay capture into
|
||||
// the `structure_overrides` axis. Scope-locked to {slot_order,
|
||||
// hidden_slots} — frame swap stays on the existing `frames` axis.
|
||||
const handleStructureEdit = useCallback(
|
||||
(zoneId: string, perZone: { slot_order?: string[]; hidden_slots?: string[] }) => {
|
||||
setState((p) => {
|
||||
const nextSelection = saveStructureOverride(p.userSelection, zoneId, perZone);
|
||||
if (p.uploadedFile) {
|
||||
const key = deriveUserOverridesKey(p.uploadedFile.name);
|
||||
void saveUserOverrides(key, {
|
||||
structure_overrides: nextSelection.overrides.structure_overrides,
|
||||
});
|
||||
}
|
||||
return { ...p, userSelection: nextSelection };
|
||||
});
|
||||
setHasPendingChanges(true);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// pending mode 일 때 effectiveSlidePlan = pendingZones 가 swap 된 plan.
|
||||
// 그 외 = state.slidePlan. 모든 zone / region lookup 이 일관되게 이걸 사용 →
|
||||
// pending mode 의 region.id ("pending-region-N") 가 zone_frames key 로 들어가
|
||||
@@ -590,17 +739,6 @@ export default function Home() {
|
||||
return state.slidePlan;
|
||||
}, [pendingZones, state.slidePlan, pendingLayout]);
|
||||
|
||||
// 2026-05-14 — slide-level CSS override 계산. mdx04 default (rank 1 = process_product_two_way)
|
||||
// 일 때만 적용 (catalog 무변, slide 결과물에만 inject). 사용자 frame override 후 다른
|
||||
// frame 시 무적용 (rank 2 의 frame visual 유지).
|
||||
const slideOverrideCss = useMemo<string | undefined>(() => {
|
||||
if (selectedSample !== "04") return undefined;
|
||||
const zone04_2 = state.slidePlan?.zones.find((z) => z.zone_id === "bottom");
|
||||
const frameId = zone04_2?.internal_regions[0]?.frame_match_strategy.frame_id;
|
||||
if (frameId !== "process_product_two_way") return undefined;
|
||||
return MDX04_DEFAULT_OVERRIDE_CSS;
|
||||
}, [selectedSample, state.slidePlan]);
|
||||
|
||||
// ── Frame 선택 ──
|
||||
const handleFrameSelect = useCallback((frameId: string) => {
|
||||
const zone = getSelectedZone(effectiveSlidePlan, state.userSelection);
|
||||
@@ -761,7 +899,6 @@ export default function Home() {
|
||||
normalizedContent={state.normalizedContent}
|
||||
userSelection={state.userSelection}
|
||||
finalHtmlUrl={runMeta?.final_html_url}
|
||||
slideOverrideCss={slideOverrideCss}
|
||||
isPipelineRunning={state.isLoading}
|
||||
isPendingLayout={!!pendingLayout}
|
||||
pendingLayoutId={pendingLayout}
|
||||
@@ -779,6 +916,9 @@ export default function Home() {
|
||||
onZoneResize={handleZoneResize}
|
||||
imageOverrides={state.userSelection.overrides.image_overrides}
|
||||
onImageResize={handleImageResize}
|
||||
onTextEdit={handleTextEdit}
|
||||
structureOverrides={state.userSelection.overrides.structure_overrides}
|
||||
onStructureEdit={handleStructureEdit}
|
||||
/>
|
||||
</main>
|
||||
|
||||
@@ -821,11 +961,13 @@ export default function Home() {
|
||||
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-tighter">Phase Z Engine Active</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="outline" onClick={() => toast.info("연동하기 기능은 준비 중입니다.")} className="gap-2 h-9 text-[11px] font-bold uppercase tracking-widest border-slate-200"><Link2 className="w-3.5 h-3.5" />Connect</Button>
|
||||
<Button variant="outline" onClick={() => toast.info("다운로드 기능은 준비 중입니다.")} disabled={!state.slidePlan} className="gap-2 h-9 text-[11px] font-bold uppercase tracking-widest border-slate-200"><Download className="w-3.5 h-3.5" />Download</Button>
|
||||
<Button onClick={() => toast.success("슬라이드 설정이 확정되었습니다.")} disabled={!state.slidePlan || state.isLoading} className="gap-2 h-9 text-[11px] font-bold uppercase tracking-widest bg-slate-900 hover:bg-slate-800"><Sparkles className="w-3.5 h-3.5" />Finalize Slide</Button>
|
||||
</div>
|
||||
<BottomActions
|
||||
slidePlan={state.slidePlan}
|
||||
runMeta={runMeta}
|
||||
uploadedFile={state.uploadedFile}
|
||||
isLoading={state.isLoading}
|
||||
onGenerate={handleGenerate}
|
||||
/>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -345,13 +345,25 @@ export interface PipelineOverrides {
|
||||
|
||||
export async function runPipeline(
|
||||
file: File,
|
||||
overrides?: PipelineOverrides
|
||||
overrides?: PipelineOverrides,
|
||||
// IMP-43 (#72) u6 — optional prev RUN_ID for incremental rerun. When set,
|
||||
// the vite plugin forwards `--reuse-from <PREV_RUN_ID>` to the backend
|
||||
// and the pipeline resumes at Step 7 (Step 0/1/2/5/6 artifacts copied
|
||||
// from the prior run). When omitted / empty, the POST body is
|
||||
// byte-identical to pre-u6 (no reuseFromRunId key → no flag forwarded).
|
||||
reuseFromRunId?: string,
|
||||
): Promise<RunPipelineResult> {
|
||||
const content = await file.text();
|
||||
const body: Record<string, unknown> = {
|
||||
filename: file.name,
|
||||
content,
|
||||
overrides,
|
||||
};
|
||||
if (reuseFromRunId) body.reuseFromRunId = reuseFromRunId;
|
||||
const res = await fetch("/api/run", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ filename: file.name, content, overrides }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = (await res.json()) as RunPipelineResult;
|
||||
if (!res.ok && !data.run_id) {
|
||||
@@ -565,6 +577,13 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
|
||||
// sort 우선순위 = label (use_as_is > light_edit > restructure > reject) + confidence desc.
|
||||
// 모두 reject 인 경우 confidence desc 만 적용 (사용자 명시).
|
||||
const TOP_N_FRAMES = 6;
|
||||
// IMP-39 u4 (issue #68) — local LABEL_PRIORITY is now a documentation
|
||||
// mirror of templates/phase_z2/catalog/ranking_sort_policy.yaml (u1).
|
||||
// Primary ordering arrives pre-sorted from the backend selector
|
||||
// (src/phase_z2_pipeline.py lookup_v4_match_with_fallback :1186-1196 +
|
||||
// _build_application_plan_unit u3 payload fields). This constant is read
|
||||
// ONLY on the warn-fallback path below (legacy fixtures pre-u3 / payload
|
||||
// missing). Kept verbatim so the fallback ordering matches u1/u2 contract.
|
||||
const LABEL_PRIORITY: Record<string, number> = {
|
||||
use_as_is: 0,
|
||||
light_edit: 1,
|
||||
@@ -576,9 +595,6 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
|
||||
// 2) unit.v4_all_judgments (pre-IMP-05 audit array)
|
||||
// 3) unit.v4_candidates (legacy minimal)
|
||||
// fallback_chain alias is intentionally NOT read (Stage 2 guardrail).
|
||||
const candidateEvidence = Array.isArray(unit.candidate_evidence)
|
||||
? unit.candidate_evidence
|
||||
: [];
|
||||
const candidateMap = new Map<string, any>();
|
||||
const pushCandidate = (c: any) => {
|
||||
if (!c) return;
|
||||
@@ -586,15 +602,64 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
|
||||
if (!key) return;
|
||||
if (!candidateMap.has(key)) candidateMap.set(key, c);
|
||||
};
|
||||
candidateEvidence.forEach(pushCandidate);
|
||||
(unit.v4_all_judgments ?? []).forEach(pushCandidate);
|
||||
(unit.v4_candidates ?? []).forEach(pushCandidate);
|
||||
const rawSource = Array.from(candidateMap.values());
|
||||
const v4Source = [...rawSource].sort((a: any, b: any) => {
|
||||
const lp = (LABEL_PRIORITY[a.label] ?? 99) - (LABEL_PRIORITY[b.label] ?? 99);
|
||||
if (lp !== 0) return lp;
|
||||
return (b.confidence ?? 0) - (a.confidence ?? 0);
|
||||
});
|
||||
|
||||
// IMP-39 u4 (issue #68) — primary path: consume the backend Step 9
|
||||
// payload as the single source of ordering truth.
|
||||
// • ``unit.sorted_candidate_evidence`` = policy-sorted selector trace
|
||||
// (src/phase_z2_pipeline.py :4163, alias of selection_trace[
|
||||
// "candidates"] sorted by u2 at :1186-1196). Same IMP-05 L2 schema
|
||||
// consumed below (template_id, label, confidence, frame_number,
|
||||
// frame_id, rank, catalog_registered, capacity_fit, route_hint, ...).
|
||||
// • ``unit.ranking_sort_policy`` = full single-source policy dict
|
||||
// (policy_type / label_priority / unknown_label_priority /
|
||||
// tie_break_axes) forwarded for telemetry + fallback parity check.
|
||||
// When both are present we feed sorted_candidate_evidence through the
|
||||
// existing dedup map (first occurrence wins, mirrors backend
|
||||
// ``seen_template_ids`` semantics at :1204-1236) and SKIP the local
|
||||
// re-sort — backend "rank 1" then equals frontend frame_candidates[0]
|
||||
// by construction (Stage 1 root-cause fix).
|
||||
const sortedCandidateEvidence: any[] | null = Array.isArray(
|
||||
unit.sorted_candidate_evidence,
|
||||
)
|
||||
? unit.sorted_candidate_evidence
|
||||
: null;
|
||||
const rankingSortPolicy = unit.ranking_sort_policy ?? null;
|
||||
const backendPolicyPayloadPresent =
|
||||
sortedCandidateEvidence !== null &&
|
||||
sortedCandidateEvidence.length > 0 &&
|
||||
rankingSortPolicy !== null;
|
||||
|
||||
let v4Source: any[];
|
||||
if (backendPolicyPayloadPresent) {
|
||||
sortedCandidateEvidence!.forEach(pushCandidate);
|
||||
v4Source = Array.from(candidateMap.values());
|
||||
} else {
|
||||
// IMP-39 u4 — warn-fallback path. Legacy fixtures predating u3 (or
|
||||
// any code path that strips the payload) lack the backend-sorted
|
||||
// evidence; ordering then derives from local LABEL_PRIORITY mirror.
|
||||
// Warning surfaces drift in dev console without hard-failing the UI
|
||||
// (graceful: production sample audit deck remains renderable).
|
||||
if (typeof console !== "undefined" && typeof console.warn === "function") {
|
||||
console.warn(
|
||||
`[IMP-39 u4] unit ${unit.unit_id ?? "<unknown>"}: backend payload ` +
|
||||
"missing ranking_sort_policy / sorted_candidate_evidence — " +
|
||||
"falling back to local LABEL_PRIORITY (legacy fixture path).",
|
||||
);
|
||||
}
|
||||
const candidateEvidence = Array.isArray(unit.candidate_evidence)
|
||||
? unit.candidate_evidence
|
||||
: [];
|
||||
candidateEvidence.forEach(pushCandidate);
|
||||
(unit.v4_all_judgments ?? []).forEach(pushCandidate);
|
||||
(unit.v4_candidates ?? []).forEach(pushCandidate);
|
||||
const rawSource = Array.from(candidateMap.values());
|
||||
v4Source = [...rawSource].sort((a: any, b: any) => {
|
||||
const lp =
|
||||
(LABEL_PRIORITY[a.label] ?? 99) - (LABEL_PRIORITY[b.label] ?? 99);
|
||||
if (lp !== 0) return lp;
|
||||
return (b.confidence ?? 0) - (a.confidence ?? 0);
|
||||
});
|
||||
}
|
||||
// ─── 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
|
||||
|
||||
@@ -65,6 +65,47 @@ export type ImageOverride = {
|
||||
};
|
||||
export type ImageOverridesOverride = Record<string, ImageOverride>;
|
||||
|
||||
/**
|
||||
* IMP-55 #93 u1 — bool intent marker that gates whether persisted
|
||||
* `zone_sections` are consumed by the backend pipeline. Frontend sets
|
||||
* `true` only on a real user drag-drop (Home.tsx handleSectionDrop, u6)
|
||||
* and `false` on layout apply/cancel auto-carry (u5/u12). Mirrors the
|
||||
* Python KNOWN_AXES (`manual_section_assignment`) added in u1 and the
|
||||
* Vite KNOWN_USER_OVERRIDES_AXES allowlist entry added in u1.
|
||||
*/
|
||||
export type ManualSectionAssignmentOverride = boolean;
|
||||
|
||||
/**
|
||||
* IMP-56 #90 u10 — Step-22 text-edit persist axis. Keyed by `zone_id`; the
|
||||
* inner mapping is `text_path` (= `{slot_key}.{line_index}`) → line value.
|
||||
* The `text_path` stamp is emitted by `src/text_path_stamper.py` (u8) and
|
||||
* applied at Step 13 (u9); the value is consumed by `text_override_resolver`
|
||||
* (u4) and applied at Step 12 (u5). Stale paths (frame swap / layout
|
||||
* regression between sessions) are tolerated by the backend resolver as
|
||||
* `skipped`, NOT raised — so the on-disk axis is forward-compat with layout
|
||||
* and frame churn. Mirrors Python `KNOWN_AXES` entry (u1) and Vite
|
||||
* `KNOWN_USER_OVERRIDES_AXES` allowlist entry (u3).
|
||||
*/
|
||||
export type TextOverridesPerZone = Record<string, string>;
|
||||
export type TextOverridesOverride = Record<string, TextOverridesPerZone>;
|
||||
|
||||
/**
|
||||
* IMP-56 #90 u10 — Step-22 structure-edit persist axis. Keyed by `zone_id`;
|
||||
* the inner mapping is SCOPE-LOCKED to `{slot_order, hidden_slots}` — slot
|
||||
* reorder + slot hide only. Frame swap stays on the existing `frames` axis;
|
||||
* the `structure_override_resolver` (u6) rejects frame-swap-shaped inner
|
||||
* keys at the validate gate so Phase Z's no-AI-HTML-structure invariant
|
||||
* holds across this persisted axis too. Per-slot `list[str]` line content
|
||||
* is NEVER mutated by the u7 Step-12 apply — that is the `text_overrides`
|
||||
* axis above. Mirrors Python `KNOWN_AXES` entry (u2) and Vite
|
||||
* `KNOWN_USER_OVERRIDES_AXES` allowlist entry (u3).
|
||||
*/
|
||||
export type StructureOverridePerZone = {
|
||||
slot_order?: string[];
|
||||
hidden_slots?: string[];
|
||||
};
|
||||
export type StructureOverridesOverride = Record<string, StructureOverridePerZone>;
|
||||
|
||||
/** Full on-disk schema. All axes optional — file may carry any subset. */
|
||||
export interface UserOverrides {
|
||||
layout: string;
|
||||
@@ -72,6 +113,9 @@ export interface UserOverrides {
|
||||
zone_geometries: ZoneGeometriesOverride;
|
||||
zone_sections: ZoneSectionsOverride;
|
||||
image_overrides: ImageOverridesOverride;
|
||||
manual_section_assignment: ManualSectionAssignmentOverride;
|
||||
text_overrides: TextOverridesOverride;
|
||||
structure_overrides: StructureOverridesOverride;
|
||||
}
|
||||
|
||||
/** Partial-mutation payload. `null` is the explicit clear sentinel (mirrors u4). */
|
||||
|
||||
@@ -213,6 +213,29 @@ export interface UserSelection {
|
||||
// `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 }>;
|
||||
// IMP-55 (#93) u3 — bool intent marker gating whether the backend
|
||||
// consumes persisted `zone_sections` as a user override. Set to `true`
|
||||
// only by the real drag-drop path (Home.tsx handleSectionDrop, u6); set
|
||||
// back to `false` by the layout apply/cancel auto-carry path (u5/u12).
|
||||
// handleGenerate (u7) reads this flag to decide whether to forward
|
||||
// `overrides.zoneSections` to the backend, replacing the pre-IMP-55
|
||||
// self-compare against `effectiveSlidePlan`. Seeded `false` in
|
||||
// `createInitialUserSelection` and only restored on reopen when the
|
||||
// persisted value is a real boolean (slidePlanUtils.ts u3 layering).
|
||||
// Mirrors the on-disk axis added in u1 — Python KNOWN_AXES
|
||||
// (src/user_overrides_io.py), Vite KNOWN_USER_OVERRIDES_AXES
|
||||
// (Front/vite.config.ts), and `ManualSectionAssignmentOverride`
|
||||
// (services/userOverridesApi.ts).
|
||||
manual_section_assignment: boolean;
|
||||
// IMP-56 #90 u10/u15 — Step-22 text + structure persist axes. Mirrors
|
||||
// services/userOverridesApi.ts (`TextOverridesOverride` /
|
||||
// `StructureOverridesOverride`). `text_overrides[zoneId][textPath] = value`
|
||||
// is fed by SlideCanvas u13 focusout capture + Home u15 autosave;
|
||||
// `structure_overrides[zoneId] = {slot_order, hidden_slots}` is fed by
|
||||
// u14 overlay + u15 autosave. Both seeded `{}` in createInitialUserSelection
|
||||
// and restored on reopen via applyPersistedNonFrameOverrides.
|
||||
text_overrides: Record<string, Record<string, string>>;
|
||||
structure_overrides: Record<string, { slot_order?: string[]; hidden_slots?: string[] }>;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import type { UserSelection, SlidePlan, Zone, InternalRegion, LayoutPresetId } from "../types/designAgent";
|
||||
import type { UserOverrides } from "../services/userOverridesApi";
|
||||
import type {
|
||||
StructureOverridePerZone,
|
||||
StructureOverridesOverride,
|
||||
TextOverridesOverride,
|
||||
TextOverridesPerZone,
|
||||
UserOverrides,
|
||||
} from "../services/userOverridesApi";
|
||||
import { computeZonePositions } from "../services/designAgentApi";
|
||||
|
||||
// ─── IMP-52 u6 — restore-on-reopen helpers (pure, exported for testing) ────
|
||||
// These helpers compose persisted `user_overrides.json` payloads (typed by
|
||||
@@ -84,9 +91,88 @@ export function applyPersistedNonFrameOverrides(
|
||||
) {
|
||||
next.image_overrides = { ...persisted.image_overrides };
|
||||
}
|
||||
// IMP-55 (#93) u3 — restore the bool intent marker only when the persisted
|
||||
// value is a real `boolean`. A missing axis, `null` (the u4 clear sentinel
|
||||
// observed post-flush), or any non-boolean shape (string "true", 1, {})
|
||||
// intentionally falls through to the `createInitialUserSelection` seed of
|
||||
// `false`. This is the fail-closed half of the marker contract: the
|
||||
// backend pipeline (u9) consumes persisted `zone_sections` only when
|
||||
// `manual_section_assignment is True`, so anything other than a real
|
||||
// `true` MUST end up as `false` in memory to avoid resurrecting stale
|
||||
// auto-carry assignments as user intent. Both `true` and `false` are
|
||||
// restored verbatim (the explicit `false` from u12's apply/cancel write
|
||||
// is meaningful — it pins the marker off across reopens).
|
||||
if (typeof persisted.manual_section_assignment === "boolean") {
|
||||
next.manual_section_assignment = persisted.manual_section_assignment;
|
||||
}
|
||||
// IMP-56 (#90) u15 — layer the two Step-22 persist axes through the
|
||||
// u10 extract helpers; their `_isPlainObject` + dedupe gates already
|
||||
// sanitize foreign / hand-edited payloads, so reopen never poisons
|
||||
// memory with non-string values or non-list slot_order entries.
|
||||
next.text_overrides = extractPersistedTextOverrides(persisted);
|
||||
next.structure_overrides = extractPersistedStructureOverrides(persisted);
|
||||
return { ...selection, overrides: next };
|
||||
}
|
||||
|
||||
// ─── IMP-56 #90 u10 — typed extract helpers for the two new persist axes ───
|
||||
// Pure helpers that defensively sanitize Step-22 text_overrides and
|
||||
// structure_overrides payloads off a `Partial<UserOverrides>` (typed by u10's
|
||||
// userOverridesApi extension). They mirror the backend validation gates
|
||||
// (`text_override_resolver` u4 / `structure_override_resolver` u6) on the
|
||||
// frontend so a hand-edited or schema-drift payload cannot poison memory.
|
||||
// Layering onto `UserSelection.overrides` arrives in u14~u16; until then
|
||||
// capture / autosave / restore wiring units consume these as typed.
|
||||
|
||||
function _isPlainObject(x: unknown): x is Record<string, unknown> {
|
||||
return !!x && typeof x === "object" && !Array.isArray(x);
|
||||
}
|
||||
|
||||
function _dedupeStringList(arr: unknown): string[] {
|
||||
if (!Array.isArray(arr)) return [];
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const k of arr) {
|
||||
if (typeof k === "string" && k.length > 0 && !seen.has(k)) {
|
||||
seen.add(k);
|
||||
out.push(k);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function extractPersistedTextOverrides(
|
||||
persisted: Partial<UserOverrides> | null | undefined,
|
||||
): TextOverridesOverride {
|
||||
const raw = persisted?.text_overrides;
|
||||
if (!_isPlainObject(raw)) return {};
|
||||
const out: TextOverridesOverride = {};
|
||||
for (const [zoneId, perZone] of Object.entries(raw)) {
|
||||
if (!zoneId || !_isPlainObject(perZone)) continue;
|
||||
const safe: TextOverridesPerZone = {};
|
||||
for (const [textPath, value] of Object.entries(perZone)) {
|
||||
if (textPath && typeof value === "string") safe[textPath] = value;
|
||||
}
|
||||
out[zoneId] = safe;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function extractPersistedStructureOverrides(
|
||||
persisted: Partial<UserOverrides> | null | undefined,
|
||||
): StructureOverridesOverride {
|
||||
const raw = persisted?.structure_overrides;
|
||||
if (!_isPlainObject(raw)) return {};
|
||||
const out: StructureOverridesOverride = {};
|
||||
for (const [zoneId, perZone] of Object.entries(raw)) {
|
||||
if (!zoneId || !_isPlainObject(perZone)) continue;
|
||||
const safe: StructureOverridePerZone = {};
|
||||
if (Array.isArray(perZone.slot_order)) safe.slot_order = _dedupeStringList(perZone.slot_order);
|
||||
if (Array.isArray(perZone.hidden_slots)) safe.hidden_slots = _dedupeStringList(perZone.hidden_slots);
|
||||
out[zoneId] = safe;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remap persisted frames (`unit_id` → template_id) to the in-memory
|
||||
* `zone_frames` (region.id → template_id) using the freshly built
|
||||
@@ -159,6 +245,20 @@ export function createInitialUserSelection(slidePlan?: SlidePlan | null): UserSe
|
||||
// here via `saveImageOverride` (SlideCanvas drag/resize handler) and
|
||||
// are seeded on reopen via `applyPersistedNonFrameOverrides`.
|
||||
image_overrides: {},
|
||||
// IMP-56 (#90) u15 — Step-22 axes seeded empty. Entries land here
|
||||
// via `saveTextOverride` (u13 focusout capture) and
|
||||
// `saveStructureOverride` (u14 overlay) and are restored on reopen
|
||||
// via `applyPersistedNonFrameOverrides`.
|
||||
text_overrides: {},
|
||||
structure_overrides: {},
|
||||
// IMP-55 (#93) u3 — bool intent marker seeded `false` so a fresh
|
||||
// MDX open (no persisted file, or persisted file with axis absent)
|
||||
// never forwards `overrides.zoneSections` to the backend. The marker
|
||||
// flips to `true` only via the real drag-drop path (Home.tsx u6) and
|
||||
// is reset to `false` by layout apply/cancel auto-carry (u5/u12).
|
||||
// `applyPersistedNonFrameOverrides` may restore a persisted boolean
|
||||
// verbatim on reopen — see the bool-only guard there.
|
||||
manual_section_assignment: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -206,6 +306,60 @@ export function saveImageOverride(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* IMP-56 (#90) u15 — record a single text-line capture (zone_id, text_path,
|
||||
* value) onto the in-memory selection's `text_overrides` axis. Mirrors
|
||||
* `saveImageOverride` (pure / immutable). u13's focusout capture emits one
|
||||
* entry per finished edit; Home u15's handler funnels each emit through this
|
||||
* helper before scheduling the debounced PUT (`saveUserOverrides` 300ms).
|
||||
*/
|
||||
export function saveTextOverride(
|
||||
selection: UserSelection,
|
||||
zoneId: string,
|
||||
textPath: string,
|
||||
value: string,
|
||||
): UserSelection {
|
||||
const prevZone = selection.overrides.text_overrides[zoneId] ?? {};
|
||||
return {
|
||||
...selection,
|
||||
overrides: {
|
||||
...selection.overrides,
|
||||
text_overrides: {
|
||||
...selection.overrides.text_overrides,
|
||||
[zoneId]: { ...prevZone, [textPath]: value },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* IMP-56 (#90) u15 — record a single structure capture (zone_id ↦
|
||||
* {slot_order, hidden_slots}) onto the in-memory selection's
|
||||
* `structure_overrides` axis. Scope-locked to slot reorder + hide (frame
|
||||
* swap stays on the `frames` axis). u14's overlay emits one entry per
|
||||
* user mutation; Home u15's handler funnels each emit through this
|
||||
* helper before scheduling the debounced PUT.
|
||||
*/
|
||||
export function saveStructureOverride(
|
||||
selection: UserSelection,
|
||||
zoneId: string,
|
||||
perZone: StructureOverridePerZone,
|
||||
): UserSelection {
|
||||
return {
|
||||
...selection,
|
||||
overrides: {
|
||||
...selection.overrides,
|
||||
structure_overrides: {
|
||||
...selection.overrides.structure_overrides,
|
||||
[zoneId]: {
|
||||
...(perZone.slot_order !== undefined && { slot_order: [...perZone.slot_order] }),
|
||||
...(perZone.hidden_slots !== undefined && { hidden_slots: [...perZone.hidden_slots] }),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function saveZoneSizes(selection: UserSelection, groupId: string, sizes: number[]): UserSelection {
|
||||
return {
|
||||
...selection,
|
||||
@@ -320,3 +474,77 @@ export function getEffectiveLayoutId(slidePlan: SlidePlan | null, selection: Use
|
||||
if (selection.overrides.layout_preset) return selection.overrides.layout_preset;
|
||||
return slidePlan?.layout_preset || 'single';
|
||||
}
|
||||
|
||||
// ─── IMP-44 (#73) u3 — zone_geometries layout-mismatch validation ───────────
|
||||
// Pure helper paired with the backend [override-warning] guards added in u1
|
||||
// (1-D horizontal-2 / vertical-2 branches of `build_layout_css`) and u2 (2-D
|
||||
// `_override_to_grid_tracks` call site). Same WARN+DROP / KEEP-known contract,
|
||||
// but expressed on the frontend so handleGenerate (u4) can validate against
|
||||
// the active layout *before* forwarding and surface a toast on dropped keys.
|
||||
//
|
||||
// Source of truth for expected positions = `computeZonePositions(layoutPreset)`
|
||||
// (designAgentApi.ts), which mirrors backend `layouts.yaml` (positions field).
|
||||
// Unknown layout (null / undefined / not in LAYOUT_PRESET_IDS) ⇒ fail-safe
|
||||
// drop-all: caller has no contract for projecting geometries onto an unknown
|
||||
// preset, so we keep zero keys rather than passing them through verbatim.
|
||||
|
||||
export interface ZoneGeometryValue {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface ZoneGeometriesValidationResult {
|
||||
kept: Record<string, ZoneGeometryValue>;
|
||||
dropped: Record<string, ZoneGeometryValue>;
|
||||
expectedPositions: string[];
|
||||
valid: boolean;
|
||||
}
|
||||
|
||||
export function validateZoneGeometriesAgainstLayout(
|
||||
geoms: Record<string, ZoneGeometryValue> | null | undefined,
|
||||
layoutPreset: LayoutPresetId | string | null | undefined,
|
||||
): ZoneGeometriesValidationResult {
|
||||
const kept: Record<string, ZoneGeometryValue> = {};
|
||||
const dropped: Record<string, ZoneGeometryValue> = {};
|
||||
const safeGeoms =
|
||||
geoms && typeof geoms === "object" && !Array.isArray(geoms) ? geoms : null;
|
||||
|
||||
// Unknown-layout fail-safe — drop everything; no expected positions known.
|
||||
if (typeof layoutPreset !== "string" || !LAYOUT_PRESET_IDS.has(layoutPreset)) {
|
||||
if (safeGeoms) {
|
||||
for (const [k, v] of Object.entries(safeGeoms)) {
|
||||
dropped[k] = v;
|
||||
}
|
||||
}
|
||||
return {
|
||||
kept,
|
||||
dropped,
|
||||
expectedPositions: [],
|
||||
valid: Object.keys(dropped).length === 0,
|
||||
};
|
||||
}
|
||||
|
||||
const expectedPositions = computeZonePositions(
|
||||
layoutPreset as LayoutPresetId,
|
||||
).map((p) => p.name);
|
||||
const expectedSet = new Set(expectedPositions);
|
||||
|
||||
if (safeGeoms) {
|
||||
for (const [k, v] of Object.entries(safeGeoms)) {
|
||||
if (expectedSet.has(k)) {
|
||||
kept[k] = v;
|
||||
} else {
|
||||
dropped[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kept,
|
||||
dropped,
|
||||
expectedPositions,
|
||||
valid: Object.keys(dropped).length === 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
// IMP-42 u4 — Source-slice coverage for the unconditional handleGenerate
|
||||
// DIAG console.log on the frontend → backend boundary (issue #71).
|
||||
//
|
||||
// Scope (Stage 2 unit u4 contract):
|
||||
// 1) A single `console.log("[DIAG raw overrides]", ...)` call exists
|
||||
// inside handleGenerate and precedes the runPipeline call site.
|
||||
// 2) The DIAG call is unconditional — not wrapped in `if (...)` / `?:` /
|
||||
// env-var gate / `__DEV__`-style guard. "Silence is the bug" per
|
||||
// Stage 1 scope-lock (Codex #3) and the Step 13 backend mirror
|
||||
// already landed in u3.
|
||||
// 3) The DIAG payload carries shape-only metadata — uploaded file name
|
||||
// and the override payload object — without referencing raw MDX
|
||||
// content or any other sample-specific identifier (RULE 0).
|
||||
//
|
||||
// Why source-slice (per Stage 2 plan): Home.tsx handleGenerate is wired to
|
||||
// React state, toast, and a 700-line component tree; the cheapest way to
|
||||
// pin a single-line surface and prove placement relative to runPipeline is
|
||||
// to read the source and assert ordering. No React rendering, no fetch
|
||||
// mock, no DOM. Mirrors the existing pure-helper pattern in
|
||||
// tests/imp41_application_mode.test.ts.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const HOME_TSX_PATH = resolve(__dirname, "..", "src", "pages", "Home.tsx");
|
||||
const HOME_TSX_SOURCE = readFileSync(HOME_TSX_PATH, "utf-8");
|
||||
|
||||
// Locate the handleGenerate callback body. The closing brace of
|
||||
// useCallback's `async () => { ... }` is the next line whose indent matches
|
||||
// the opening `useCallback(async () => {` exactly — but a simpler proxy is
|
||||
// "from the handleGenerate keyword to the next useCallback declaration or
|
||||
// the end-of-file." This is sufficient to scope every assertion below to
|
||||
// the right function body.
|
||||
function sliceHandleGenerateBody(source: string): string {
|
||||
const startMarker = "const handleGenerate = useCallback(async () =>";
|
||||
const startIdx = source.indexOf(startMarker);
|
||||
if (startIdx === -1) {
|
||||
throw new Error("handleGenerate declaration not found in Home.tsx");
|
||||
}
|
||||
// End at the next top-level `const ` that begins a new useCallback /
|
||||
// useMemo / hook binding. handleGenerate is followed by additional
|
||||
// hooks (handleFileUpload sibling pattern); slicing to the next
|
||||
// declaration is more than enough to capture the full body.
|
||||
const afterStart = source.slice(startIdx + startMarker.length);
|
||||
const nextDeclIdx = afterStart.search(/\n {2}const [A-Za-z]/);
|
||||
return nextDeclIdx === -1 ? afterStart : afterStart.slice(0, nextDeclIdx);
|
||||
}
|
||||
|
||||
const HANDLE_GENERATE_BODY = sliceHandleGenerateBody(HOME_TSX_SOURCE);
|
||||
|
||||
describe("handleGenerate [DIAG raw overrides] (IMP-42 u4)", () => {
|
||||
it("emits exactly one console.log labelled '[DIAG raw overrides]' inside handleGenerate", () => {
|
||||
const matches = HANDLE_GENERATE_BODY.match(
|
||||
/console\.log\(\s*"\[DIAG raw overrides\]"/g,
|
||||
);
|
||||
expect(matches).not.toBeNull();
|
||||
// Exactly one DIAG site per Stage 2 contract — multiple calls would
|
||||
// either be a copy-paste regression or evidence that the helper
|
||||
// moved without removing the old site.
|
||||
expect(matches?.length).toBe(1);
|
||||
});
|
||||
|
||||
it("places the DIAG console.log before the runPipeline call site", () => {
|
||||
const diagIdx = HANDLE_GENERATE_BODY.indexOf('console.log("[DIAG raw overrides]"');
|
||||
const runPipelineIdx = HANDLE_GENERATE_BODY.indexOf(
|
||||
"runPipeline(state.uploadedFile, overrides)",
|
||||
);
|
||||
expect(diagIdx).toBeGreaterThan(-1);
|
||||
expect(runPipelineIdx).toBeGreaterThan(-1);
|
||||
expect(diagIdx).toBeLessThan(runPipelineIdx);
|
||||
});
|
||||
|
||||
it("is unconditional — no env-var gate or if-guard wraps the DIAG call", () => {
|
||||
// Slice the 80 chars immediately preceding the DIAG console.log and
|
||||
// confirm none of the common gating patterns appear directly above.
|
||||
const diagIdx = HANDLE_GENERATE_BODY.indexOf('console.log("[DIAG raw overrides]"');
|
||||
const preface = HANDLE_GENERATE_BODY.slice(Math.max(0, diagIdx - 200), diagIdx);
|
||||
// Stage 1 contract: silence is the bug. Any gate here is a regression.
|
||||
expect(preface).not.toMatch(/if\s*\([^)]*\)\s*$/m);
|
||||
expect(preface).not.toMatch(/process\.env/);
|
||||
expect(preface).not.toMatch(/import\.meta\.env/);
|
||||
expect(preface).not.toMatch(/__DEV__/);
|
||||
expect(preface).not.toMatch(/DIAG_VERBOSE/i);
|
||||
expect(preface).not.toMatch(/DEBUG/);
|
||||
});
|
||||
|
||||
it("forwards the file name and overrides object as shape-only payload", () => {
|
||||
// The DIAG payload must include the uploaded file name (so the user
|
||||
// can correlate the log line with the MDX they uploaded) and the
|
||||
// overrides object (so the user can see what crossed the wire).
|
||||
// It must NOT spread MDX text content or any other large blob —
|
||||
// sample-agnostic and reviewable in a single log line.
|
||||
const diagIdx = HANDLE_GENERATE_BODY.indexOf('console.log("[DIAG raw overrides]"');
|
||||
const window = HANDLE_GENERATE_BODY.slice(diagIdx, diagIdx + 300);
|
||||
// Both fields appear in the payload object literal.
|
||||
expect(window).toMatch(/file:\s*state\.uploadedFile\.name/);
|
||||
expect(window).toMatch(/\boverrides\b/);
|
||||
// Sanity: the payload does not pass MDX raw content / a File blob.
|
||||
expect(window).not.toMatch(/mdxContent|rawMdx|normalizedContent/);
|
||||
});
|
||||
|
||||
it("runs after flushUserOverrides() so the persisted PUT is already committed", () => {
|
||||
// Ordering invariant from IMP-52 u10 (already in place):
|
||||
// flushUserOverrides() → DIAG → runPipeline
|
||||
// Asserts the DIAG sits between the flush and the network call so the
|
||||
// logged overrides match what backend reads from disk.
|
||||
const flushIdx = HANDLE_GENERATE_BODY.indexOf("await flushUserOverrides()");
|
||||
const diagIdx = HANDLE_GENERATE_BODY.indexOf('console.log("[DIAG raw overrides]"');
|
||||
const runPipelineIdx = HANDLE_GENERATE_BODY.indexOf(
|
||||
"runPipeline(state.uploadedFile, overrides)",
|
||||
);
|
||||
expect(flushIdx).toBeGreaterThan(-1);
|
||||
expect(diagIdx).toBeGreaterThan(flushIdx);
|
||||
expect(diagIdx).toBeLessThan(runPipelineIdx);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
// IMP-#84 u1 — FramePanel reject silent-automation contract.
|
||||
//
|
||||
// Stage 2 unit u1 scope:
|
||||
// 1) `applyFrameSelection(candidate, onFrameSelect)` invokes onFrameSelect
|
||||
// with candidate.id verbatim for EVERY V4 label
|
||||
// (use_as_is / light_edit / restructure / reject) — no window.confirm
|
||||
// gate, no label-conditional branch, no frame swap.
|
||||
// 2) Source-presence checks pin the FramePanel.tsx wiring so the runtime
|
||||
// button → handler → helper chain stays intact even though we cannot
|
||||
// mount React (no jsdom / RTL / happy-dom in Front devDependencies —
|
||||
// verified against the IMP-56 u20 `imp90_bottom_actions.test.ts` and
|
||||
// IMP-92 u5 `imp47b_human_review_toast.test.tsx` precedent that
|
||||
// explicitly skip DOM mounting).
|
||||
// 3) No `window.confirm` substring remains in FramePanel.tsx after u1.
|
||||
//
|
||||
// Out of scope (Stage 2 exit-report contract):
|
||||
// - Home.tsx:523-524 `toast.error(aiReviewMsg)` (#92 operational-only).
|
||||
// - FramePanel reject badge/tooltip read-only labels at L102/L147/L156
|
||||
// (no popup trigger; preserved as silent operator hint).
|
||||
// - Backend `zone.provisional` emission (handled by u2 template-only).
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { applyFrameSelection } from "../src/components/FramePanel";
|
||||
import type { FrameCandidate } from "../src/types/designAgent";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const FRAME_PANEL_SOURCE = readFileSync(
|
||||
resolve(__dirname, "../src/components/FramePanel.tsx"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
function makeCandidate(
|
||||
label: FrameCandidate["label"],
|
||||
id: string,
|
||||
): FrameCandidate {
|
||||
return {
|
||||
id,
|
||||
name: `Frame ${id}`,
|
||||
score: 0.5,
|
||||
confidence: "medium",
|
||||
label,
|
||||
};
|
||||
}
|
||||
|
||||
describe("applyFrameSelection (IMP-#84 u1 — silent-automation contract)", () => {
|
||||
it("forwards candidate.id to onFrameSelect for use_as_is label", () => {
|
||||
const onFrameSelect = vi.fn();
|
||||
applyFrameSelection(makeCandidate("use_as_is", "frame_a"), onFrameSelect);
|
||||
expect(onFrameSelect).toHaveBeenCalledTimes(1);
|
||||
expect(onFrameSelect).toHaveBeenCalledWith("frame_a");
|
||||
});
|
||||
|
||||
it("forwards candidate.id to onFrameSelect for light_edit label", () => {
|
||||
const onFrameSelect = vi.fn();
|
||||
applyFrameSelection(makeCandidate("light_edit", "frame_b"), onFrameSelect);
|
||||
expect(onFrameSelect).toHaveBeenCalledTimes(1);
|
||||
expect(onFrameSelect).toHaveBeenCalledWith("frame_b");
|
||||
});
|
||||
|
||||
it("forwards candidate.id to onFrameSelect for restructure label", () => {
|
||||
const onFrameSelect = vi.fn();
|
||||
applyFrameSelection(makeCandidate("restructure", "frame_c"), onFrameSelect);
|
||||
expect(onFrameSelect).toHaveBeenCalledTimes(1);
|
||||
expect(onFrameSelect).toHaveBeenCalledWith("frame_c");
|
||||
});
|
||||
|
||||
it("forwards candidate.id to onFrameSelect for reject label — no popup, no frame swap", () => {
|
||||
// Reject is the silent-automation pivot case: prior IMP-47B u11 gated
|
||||
// this path with window.confirm; post-IMP-#84 the helper invokes
|
||||
// onFrameSelect with the reject frame.id directly. Backend / AI 격리
|
||||
// contract handles AI 재구성 (content-only, frame preserved).
|
||||
const onFrameSelect = vi.fn();
|
||||
applyFrameSelection(makeCandidate("reject", "frame_d"), onFrameSelect);
|
||||
expect(onFrameSelect).toHaveBeenCalledTimes(1);
|
||||
expect(onFrameSelect).toHaveBeenCalledWith("frame_d");
|
||||
});
|
||||
|
||||
it("does not call onFrameSelect more than once per invocation", () => {
|
||||
const onFrameSelect = vi.fn();
|
||||
applyFrameSelection(makeCandidate("reject", "frame_e"), onFrameSelect);
|
||||
applyFrameSelection(makeCandidate("use_as_is", "frame_f"), onFrameSelect);
|
||||
expect(onFrameSelect).toHaveBeenCalledTimes(2);
|
||||
expect(onFrameSelect).toHaveBeenNthCalledWith(1, "frame_e");
|
||||
expect(onFrameSelect).toHaveBeenNthCalledWith(2, "frame_f");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FramePanel.tsx source — silent-automation wiring pins (IMP-#84 u1)", () => {
|
||||
it("has no window.confirm(...) call (popup removed; narrative mentions in comments are allowed)", () => {
|
||||
// Match the call form `window.confirm(` rather than the bare substring
|
||||
// so that explanatory comments documenting the removed popup are not
|
||||
// flagged. A re-introduced call would carry an opening paren.
|
||||
expect(FRAME_PANEL_SOURCE).not.toMatch(/\bwindow\.confirm\s*\(/);
|
||||
});
|
||||
|
||||
it("does not embed the legacy reject-confirm Korean prompt body", () => {
|
||||
// Prior IMP-47B u11 string fragment; absence guards against re-introduction.
|
||||
expect(FRAME_PANEL_SOURCE).not.toContain("V4 reject 라벨입니다");
|
||||
expect(FRAME_PANEL_SOURCE).not.toContain("계속하시겠습니까?");
|
||||
});
|
||||
|
||||
it("wires the button onClick to handleFrameSelect(candidate)", () => {
|
||||
expect(FRAME_PANEL_SOURCE).toContain(
|
||||
"onClick={() => handleFrameSelect(candidate)}",
|
||||
);
|
||||
});
|
||||
|
||||
it("delegates handleFrameSelect body to applyFrameSelection", () => {
|
||||
expect(FRAME_PANEL_SOURCE).toContain(
|
||||
"applyFrameSelection(candidate, onFrameSelect)",
|
||||
);
|
||||
});
|
||||
|
||||
it("exports applyFrameSelection as a named export for caller-independent reuse", () => {
|
||||
expect(FRAME_PANEL_SOURCE).toMatch(
|
||||
/export function applyFrameSelection\(/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
// IMP-56 (#90) u20 — vitest coverage for the pure request builders exported
|
||||
// by `BottomActions`. The React component itself is not rendered (jsdom /
|
||||
// @testing-library NOT in Front devDependencies — verified against the prior
|
||||
// u14 `imp90_structure_overlay.test.tsx` pattern); we test the deterministic
|
||||
// pieces that drive the network payload sent to the u18 / u19 middlewares.
|
||||
//
|
||||
// Upstream / downstream contracts (verified by prior units):
|
||||
// - u18 /api/connect : body shape = { run_id, slug } (Front/vite.config.ts
|
||||
// handleConnectMirror — `imp90_connect_endpoint.test.ts`).
|
||||
// - u19 /api/export : body shape = { run_id }; response = raw text/html
|
||||
// with `Content-Disposition: attachment; filename="<run_id>.html"`
|
||||
// (Front/vite.config.ts handleExportStandalone —
|
||||
// `imp90_export_endpoint.test.ts`).
|
||||
//
|
||||
// u20 scope: builders only. Any drift in URL or JSON shape fails here before
|
||||
// the request leaves the client. Toast / fetch / blob plumbing is not tested
|
||||
// (it would require jsdom + a fetch mock; the existing server-side tests
|
||||
// already pin the wire contract).
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
buildConnectRequest,
|
||||
buildExportRequest,
|
||||
buildDownloadFilename,
|
||||
} from "../src/components/BottomActions";
|
||||
|
||||
describe("buildConnectRequest", () => {
|
||||
it("targets /api/connect", () => {
|
||||
const { url } = buildConnectRequest("run_42", "mdx_03");
|
||||
expect(url).toBe("/api/connect");
|
||||
});
|
||||
|
||||
it("emits { run_id, slug } JSON body — matches u18 middleware shape", () => {
|
||||
const { body } = buildConnectRequest("run_42", "mdx_03");
|
||||
expect(JSON.parse(body)).toEqual({ run_id: "run_42", slug: "mdx_03" });
|
||||
});
|
||||
|
||||
it("preserves zero-length and unicode run_id verbatim (server validates)", () => {
|
||||
const { body } = buildConnectRequest("", "x");
|
||||
expect(JSON.parse(body)).toEqual({ run_id: "", slug: "x" });
|
||||
const { body: uni } = buildConnectRequest("런", "슬러그");
|
||||
expect(JSON.parse(uni)).toEqual({ run_id: "런", slug: "슬러그" });
|
||||
});
|
||||
|
||||
it("does not leak extra keys (frame swap / overrides etc.)", () => {
|
||||
const { body } = buildConnectRequest("r", "s");
|
||||
expect(Object.keys(JSON.parse(body)).sort()).toEqual(["run_id", "slug"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildExportRequest", () => {
|
||||
it("targets /api/export", () => {
|
||||
const { url } = buildExportRequest("run_42");
|
||||
expect(url).toBe("/api/export");
|
||||
});
|
||||
|
||||
it("emits { run_id } JSON body — matches u19 middleware shape", () => {
|
||||
const { body } = buildExportRequest("run_42");
|
||||
expect(JSON.parse(body)).toEqual({ run_id: "run_42" });
|
||||
});
|
||||
|
||||
it("does not leak extra keys (slug / format etc.)", () => {
|
||||
const { body } = buildExportRequest("r");
|
||||
expect(Object.keys(JSON.parse(body))).toEqual(["run_id"]);
|
||||
});
|
||||
|
||||
it("preserves zero-length and unicode run_id verbatim (server validates)", () => {
|
||||
expect(JSON.parse(buildExportRequest("").body)).toEqual({ run_id: "" });
|
||||
expect(JSON.parse(buildExportRequest("런").body)).toEqual({ run_id: "런" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDownloadFilename", () => {
|
||||
it("returns <run_id>.html for the a[download] click chain", () => {
|
||||
expect(buildDownloadFilename("run_42")).toBe("run_42.html");
|
||||
});
|
||||
|
||||
it("appends exactly one .html suffix even when run_id already ends in .html", () => {
|
||||
// The server-side `Content-Disposition` already carries the same
|
||||
// filename; we mirror it verbatim so browser default behavior wins.
|
||||
// We intentionally do NOT strip a trailing `.html` — run_id is the
|
||||
// backend's `Path(args.mdx_path).stem`-style key, which never contains
|
||||
// a dot suffix (validated by `isValidUserOverridesKey` at u18/u19).
|
||||
expect(buildDownloadFilename("foo.html")).toBe("foo.html.html");
|
||||
});
|
||||
|
||||
it("returns just .html for empty run_id (server rejects upstream)", () => {
|
||||
expect(buildDownloadFilename("")).toBe(".html");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
// IMP-56 (#90) u18 — vitest coverage for the vite POST /api/connect
|
||||
// middleware and its supporting mirrorDirRecursive helper.
|
||||
//
|
||||
// Scope:
|
||||
// 1) mirrorDirRecursive (pure helper):
|
||||
// - absent src → returns 0 (no-throw, no dst creation).
|
||||
// - file-only src → flat copy + count.
|
||||
// - nested src → recursive copy + count.
|
||||
// - overwrites pre-existing dst files (cel mirror semantics).
|
||||
// 2) handleConnectMirror (POST):
|
||||
// - method != POST → false (chain continues; next middleware may handle).
|
||||
// - invalid JSON / non-object body → 400.
|
||||
// - missing run_id or slug → 400.
|
||||
// - invalid run_id or slug (key gate / path traversal) → 400.
|
||||
// - final.html missing → 404.
|
||||
// - success without run-assets dir → 200, assets_copied: 0, html copy ok.
|
||||
// - success with run-assets dir → 200, assets_copied = file count, dst dir
|
||||
// populated.
|
||||
// - dstSlidesDir auto-created when celRoot/public/slides missing.
|
||||
//
|
||||
// Tests exercise the pure handler with mock req/res — no real vite server.
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
handleConnectMirror,
|
||||
mirrorDirRecursive,
|
||||
} from "../../vite.config";
|
||||
|
||||
function makeMockRes() {
|
||||
const state = {
|
||||
statusCode: 0,
|
||||
headers: {} as Record<string, string>,
|
||||
body: "",
|
||||
ended: false,
|
||||
};
|
||||
return {
|
||||
state,
|
||||
res: {
|
||||
writeHead(status: number, headers?: Record<string, string>) {
|
||||
state.statusCode = status;
|
||||
if (headers) state.headers = headers;
|
||||
},
|
||||
end(body?: string) {
|
||||
state.body = body ?? "";
|
||||
state.ended = true;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeMockReq(opts: {
|
||||
method?: string;
|
||||
}): EventEmitter & { method?: string; send: (body: string) => void } {
|
||||
const ee = new EventEmitter() as EventEmitter & {
|
||||
method?: string;
|
||||
send: (body: string) => void;
|
||||
};
|
||||
ee.method = opts.method;
|
||||
ee.send = (body: string) => {
|
||||
if (body.length > 0) ee.emit("data", Buffer.from(body, "utf-8"));
|
||||
ee.emit("end");
|
||||
};
|
||||
return ee;
|
||||
}
|
||||
|
||||
function seedRun(daRoot: string, runId: string, htmlBody: string): string {
|
||||
const runDir = path.join(daRoot, "data", "runs", runId, "phase_z2");
|
||||
fs.mkdirSync(runDir, { recursive: true });
|
||||
const html = path.join(runDir, "final.html");
|
||||
fs.writeFileSync(html, htmlBody, "utf-8");
|
||||
return runDir;
|
||||
}
|
||||
|
||||
describe("mirrorDirRecursive (IMP-56 #90 u18)", () => {
|
||||
let tmp: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "imp90-u18-mirror-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns 0 and does not throw when src absent", () => {
|
||||
const dst = path.join(tmp, "dst");
|
||||
const n = mirrorDirRecursive(path.join(tmp, "missing"), dst);
|
||||
expect(n).toBe(0);
|
||||
expect(fs.existsSync(dst)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 0 when src exists but is a file (not a directory)", () => {
|
||||
const srcFile = path.join(tmp, "src.txt");
|
||||
fs.writeFileSync(srcFile, "x", "utf-8");
|
||||
const dst = path.join(tmp, "dst");
|
||||
const n = mirrorDirRecursive(srcFile, dst);
|
||||
expect(n).toBe(0);
|
||||
expect(fs.existsSync(dst)).toBe(false);
|
||||
});
|
||||
|
||||
it("flat-copies file entries and returns the file count", () => {
|
||||
const src = path.join(tmp, "src");
|
||||
fs.mkdirSync(src);
|
||||
fs.writeFileSync(path.join(src, "a.css"), "/*a*/", "utf-8");
|
||||
fs.writeFileSync(path.join(src, "b.png"), "PNG", "utf-8");
|
||||
const dst = path.join(tmp, "dst");
|
||||
const n = mirrorDirRecursive(src, dst);
|
||||
expect(n).toBe(2);
|
||||
expect(fs.readFileSync(path.join(dst, "a.css"), "utf-8")).toBe("/*a*/");
|
||||
expect(fs.readFileSync(path.join(dst, "b.png"), "utf-8")).toBe("PNG");
|
||||
});
|
||||
|
||||
it("recurses into nested directories and counts only files", () => {
|
||||
const src = path.join(tmp, "src");
|
||||
fs.mkdirSync(path.join(src, "nested", "deep"), { recursive: true });
|
||||
fs.writeFileSync(path.join(src, "root.txt"), "r", "utf-8");
|
||||
fs.writeFileSync(path.join(src, "nested", "n.txt"), "n", "utf-8");
|
||||
fs.writeFileSync(path.join(src, "nested", "deep", "d.txt"), "d", "utf-8");
|
||||
const dst = path.join(tmp, "dst");
|
||||
const n = mirrorDirRecursive(src, dst);
|
||||
expect(n).toBe(3);
|
||||
expect(fs.readFileSync(path.join(dst, "nested", "deep", "d.txt"), "utf-8"))
|
||||
.toBe("d");
|
||||
});
|
||||
|
||||
it("overwrites pre-existing files in dst (cel mirror semantics)", () => {
|
||||
const src = path.join(tmp, "src");
|
||||
fs.mkdirSync(src);
|
||||
fs.writeFileSync(path.join(src, "a.css"), "NEW", "utf-8");
|
||||
const dst = path.join(tmp, "dst");
|
||||
fs.mkdirSync(dst);
|
||||
fs.writeFileSync(path.join(dst, "a.css"), "OLD", "utf-8");
|
||||
mirrorDirRecursive(src, dst);
|
||||
expect(fs.readFileSync(path.join(dst, "a.css"), "utf-8")).toBe("NEW");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleConnectMirror (IMP-56 #90 u18)", () => {
|
||||
let daRoot: string;
|
||||
let celRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
daRoot = fs.mkdtempSync(path.join(os.tmpdir(), "imp90-u18-da-"));
|
||||
celRoot = fs.mkdtempSync(path.join(os.tmpdir(), "imp90-u18-cel-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(daRoot, { recursive: true, force: true });
|
||||
fs.rmSync(celRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns false (next chained) when method != POST", () => {
|
||||
const req = makeMockReq({ method: "GET" });
|
||||
const { res, state } = makeMockRes();
|
||||
const handled = handleConnectMirror(req, res, daRoot, celRoot);
|
||||
expect(handled).toBe(false);
|
||||
expect(state.ended).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 400 on invalid JSON body", () => {
|
||||
const req = makeMockReq({ method: "POST" });
|
||||
const { res, state } = makeMockRes();
|
||||
const handled = handleConnectMirror(req, res, daRoot, celRoot);
|
||||
expect(handled).toBe(true);
|
||||
req.send("{not-json}");
|
||||
expect(state.statusCode).toBe(400);
|
||||
expect(JSON.parse(state.body).error).toBe("invalid JSON");
|
||||
});
|
||||
|
||||
it("returns 400 when body is not a JSON object (array root)", () => {
|
||||
const req = makeMockReq({ method: "POST" });
|
||||
const { res, state } = makeMockRes();
|
||||
handleConnectMirror(req, res, daRoot, celRoot);
|
||||
req.send(JSON.stringify(["not", "an", "object"]));
|
||||
expect(state.statusCode).toBe(400);
|
||||
expect(JSON.parse(state.body).error).toBe("body must be a JSON object");
|
||||
});
|
||||
|
||||
it("returns 400 when run_id or slug is missing", () => {
|
||||
const req = makeMockReq({ method: "POST" });
|
||||
const { res, state } = makeMockRes();
|
||||
handleConnectMirror(req, res, daRoot, celRoot);
|
||||
req.send(JSON.stringify({ run_id: "abc" })); // slug missing
|
||||
expect(state.statusCode).toBe(400);
|
||||
expect(JSON.parse(state.body).error).toBe("missing run_id or slug");
|
||||
});
|
||||
|
||||
it("returns 400 when run_id contains path traversal", () => {
|
||||
const req = makeMockReq({ method: "POST" });
|
||||
const { res, state } = makeMockRes();
|
||||
handleConnectMirror(req, res, daRoot, celRoot);
|
||||
req.send(JSON.stringify({ run_id: "../escape", slug: "03" }));
|
||||
expect(state.statusCode).toBe(400);
|
||||
expect(JSON.parse(state.body).error).toBe("invalid run_id or slug");
|
||||
});
|
||||
|
||||
it("returns 400 when slug contains a forward slash", () => {
|
||||
const req = makeMockReq({ method: "POST" });
|
||||
const { res, state } = makeMockRes();
|
||||
handleConnectMirror(req, res, daRoot, celRoot);
|
||||
req.send(JSON.stringify({ run_id: "valid_id", slug: "03/etc" }));
|
||||
expect(state.statusCode).toBe(400);
|
||||
expect(JSON.parse(state.body).error).toBe("invalid run_id or slug");
|
||||
});
|
||||
|
||||
it("returns 404 when final.html does not exist for run_id", () => {
|
||||
const req = makeMockReq({ method: "POST" });
|
||||
const { res, state } = makeMockRes();
|
||||
handleConnectMirror(req, res, daRoot, celRoot);
|
||||
req.send(JSON.stringify({ run_id: "ghost_run", slug: "03" }));
|
||||
expect(state.statusCode).toBe(404);
|
||||
expect(JSON.parse(state.body).error).toBe("final.html not found");
|
||||
});
|
||||
|
||||
it("copies final.html to cel/public/slides/<slug>.html on success", () => {
|
||||
seedRun(daRoot, "mdx03_run", "<html>03</html>");
|
||||
const req = makeMockReq({ method: "POST" });
|
||||
const { res, state } = makeMockRes();
|
||||
handleConnectMirror(req, res, daRoot, celRoot);
|
||||
req.send(JSON.stringify({ run_id: "mdx03_run", slug: "03" }));
|
||||
|
||||
expect(state.statusCode).toBe(200);
|
||||
const dstHtml = path.join(celRoot, "public", "slides", "03.html");
|
||||
expect(fs.existsSync(dstHtml)).toBe(true);
|
||||
expect(fs.readFileSync(dstHtml, "utf-8")).toBe("<html>03</html>");
|
||||
const body = JSON.parse(state.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.run_id).toBe("mdx03_run");
|
||||
expect(body.slug).toBe("03");
|
||||
expect(body.assets_copied).toBe(0);
|
||||
expect(body.html_target).toBe(dstHtml);
|
||||
});
|
||||
|
||||
it("auto-creates cel/public/slides when missing", () => {
|
||||
seedRun(daRoot, "mdx04_run", "<html>04</html>");
|
||||
expect(fs.existsSync(path.join(celRoot, "public", "slides"))).toBe(false);
|
||||
const req = makeMockReq({ method: "POST" });
|
||||
const { res, state } = makeMockRes();
|
||||
handleConnectMirror(req, res, daRoot, celRoot);
|
||||
req.send(JSON.stringify({ run_id: "mdx04_run", slug: "04" }));
|
||||
expect(state.statusCode).toBe(200);
|
||||
expect(fs.existsSync(path.join(celRoot, "public", "slides", "04.html"))).toBe(true);
|
||||
});
|
||||
|
||||
it("mirrors assets/ recursively when present in the run dir", () => {
|
||||
const runDir = seedRun(daRoot, "mdx05_run", "<html>05</html>");
|
||||
fs.mkdirSync(path.join(runDir, "assets", "css"), { recursive: true });
|
||||
fs.writeFileSync(path.join(runDir, "assets", "main.css"), "*{}", "utf-8");
|
||||
fs.writeFileSync(path.join(runDir, "assets", "css", "extra.css"), "p{}", "utf-8");
|
||||
|
||||
const req = makeMockReq({ method: "POST" });
|
||||
const { res, state } = makeMockRes();
|
||||
handleConnectMirror(req, res, daRoot, celRoot);
|
||||
req.send(JSON.stringify({ run_id: "mdx05_run", slug: "05" }));
|
||||
|
||||
expect(state.statusCode).toBe(200);
|
||||
expect(JSON.parse(state.body).assets_copied).toBe(2);
|
||||
expect(fs.readFileSync(path.join(celRoot, "public", "slides", "assets", "main.css"), "utf-8"))
|
||||
.toBe("*{}");
|
||||
expect(fs.readFileSync(path.join(celRoot, "public", "slides", "assets", "css", "extra.css"), "utf-8"))
|
||||
.toBe("p{}");
|
||||
});
|
||||
|
||||
it("overwrites pre-existing cel slide html (re-Connect semantics)", () => {
|
||||
seedRun(daRoot, "mdx03_run", "NEW");
|
||||
const dstSlidesDir = path.join(celRoot, "public", "slides");
|
||||
fs.mkdirSync(dstSlidesDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dstSlidesDir, "03.html"), "OLD", "utf-8");
|
||||
|
||||
const req = makeMockReq({ method: "POST" });
|
||||
const { res, state } = makeMockRes();
|
||||
handleConnectMirror(req, res, daRoot, celRoot);
|
||||
req.send(JSON.stringify({ run_id: "mdx03_run", slug: "03" }));
|
||||
|
||||
expect(state.statusCode).toBe(200);
|
||||
expect(fs.readFileSync(path.join(dstSlidesDir, "03.html"), "utf-8")).toBe("NEW");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
// IMP-90 (#90) u12 — vitest coverage for `computeEditModeGates`, the pure
|
||||
// helper that drives SlideCanvas's mutually-exclusive gesture gating.
|
||||
// u11 introduced the `EditMode` enum + toolbar; u12 splits the prior
|
||||
// `isEditMode` shim (which fired ALL gates whenever any edit mode was
|
||||
// active) into 5 per-gate booleans:
|
||||
// textEditing — designMode + contentEditable (text mode only).
|
||||
// imageSelection — in-iframe user-content image click listener
|
||||
// (image-zone mode only).
|
||||
// iframePointerAuto — iframe pointer-events:auto so in-iframe gestures
|
||||
// (text caret OR image click) can reach the doc.
|
||||
// text mode + image-zone mode; structure stays
|
||||
// pe:none because u14 will overlay React controls.
|
||||
// zoneGestures — zone resize 8-handle ring + drag perimeter strips
|
||||
// + canDrag in handleZoneMouseDown
|
||||
// (image-zone mode only).
|
||||
// imageOverlay — React-side image edit overlay (image-zone only).
|
||||
//
|
||||
// Mutually-exclusive contract (from the issue body's "discriminated edit
|
||||
// mode"): no editMode value enables both `textEditing` and either
|
||||
// `imageSelection` or `zoneGestures` simultaneously. structure mode is
|
||||
// the no-op placeholder — u14 will plant the structure overlay there.
|
||||
// pendingLayout fully suppresses every gate (mirrors the existing
|
||||
// useEffect that forces editMode='off' on pendingLayout entry).
|
||||
//
|
||||
// Scope guard: this test exercises the pure helper only — no React
|
||||
// rendering, no DOM. testing-library/react is NOT in devDependencies
|
||||
// (verified in Front/package.json); helper-level coverage is the
|
||||
// established u11 pattern.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
computeEditModeGates,
|
||||
type EditMode,
|
||||
type EditModeGates,
|
||||
} from "../src/components/SlideCanvas";
|
||||
|
||||
const ALL_MODES: EditMode[] = ["off", "text", "structure", "image-zone"];
|
||||
|
||||
describe("computeEditModeGates (IMP-90 u12) — pendingLayout suppression", () => {
|
||||
it.each<EditMode>(ALL_MODES)(
|
||||
"pendingLayout=true forces every gate false (editMode=%s)",
|
||||
(mode) => {
|
||||
const g = computeEditModeGates(mode, true);
|
||||
expect(g).toEqual<EditModeGates>({
|
||||
textEditing: false,
|
||||
imageSelection: false,
|
||||
iframePointerAuto: false,
|
||||
zoneGestures: false,
|
||||
imageOverlay: false,
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe("computeEditModeGates (IMP-90 u12) — off baseline", () => {
|
||||
it("editMode=off pendingLayout=false: every gate false", () => {
|
||||
expect(computeEditModeGates("off", false)).toEqual<EditModeGates>({
|
||||
textEditing: false,
|
||||
imageSelection: false,
|
||||
iframePointerAuto: false,
|
||||
zoneGestures: false,
|
||||
imageOverlay: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeEditModeGates (IMP-90 u12) — text mode", () => {
|
||||
const g = computeEditModeGates("text", false);
|
||||
|
||||
it("textEditing = true (designMode + contentEditable activate)", () => {
|
||||
expect(g.textEditing).toBe(true);
|
||||
});
|
||||
it("iframePointerAuto = true (caret needs to reach the doc)", () => {
|
||||
expect(g.iframePointerAuto).toBe(true);
|
||||
});
|
||||
it("imageSelection = false (no in-iframe image click listener)", () => {
|
||||
expect(g.imageSelection).toBe(false);
|
||||
});
|
||||
it("zoneGestures = false (no zone resize / drag affordances)", () => {
|
||||
expect(g.zoneGestures).toBe(false);
|
||||
});
|
||||
it("imageOverlay = false (no React-side image overlay)", () => {
|
||||
expect(g.imageOverlay).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeEditModeGates (IMP-90 u12) — structure mode", () => {
|
||||
const g = computeEditModeGates("structure", false);
|
||||
|
||||
// structure mode is the u14 placeholder — no gestures here yet. All five
|
||||
// gates stay false so the iframe and React overlays remain quiescent
|
||||
// until u14 plants the structure overlay on the React layer.
|
||||
it("every gate false (u14 will plant the structure overlay later)", () => {
|
||||
expect(g).toEqual<EditModeGates>({
|
||||
textEditing: false,
|
||||
imageSelection: false,
|
||||
iframePointerAuto: false,
|
||||
zoneGestures: false,
|
||||
imageOverlay: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeEditModeGates (IMP-90 u12) — image-zone mode", () => {
|
||||
const g = computeEditModeGates("image-zone", false);
|
||||
|
||||
it("textEditing = false (contentEditable would steal image clicks)", () => {
|
||||
expect(g.textEditing).toBe(false);
|
||||
});
|
||||
it("imageSelection = true (in-iframe img click → selectedImageId)", () => {
|
||||
expect(g.imageSelection).toBe(true);
|
||||
});
|
||||
it("iframePointerAuto = true (so image clicks reach the doc)", () => {
|
||||
expect(g.iframePointerAuto).toBe(true);
|
||||
});
|
||||
it("zoneGestures = true (zone resize + drag affordances visible)", () => {
|
||||
expect(g.zoneGestures).toBe(true);
|
||||
});
|
||||
it("imageOverlay = true (React-side overlay renders the drag handles)", () => {
|
||||
expect(g.imageOverlay).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeEditModeGates (IMP-90 u12) — mutually exclusive contract", () => {
|
||||
it("text mode never co-activates image-zone gates (imageSelection / zoneGestures / imageOverlay)", () => {
|
||||
const g = computeEditModeGates("text", false);
|
||||
expect(g.textEditing).toBe(true);
|
||||
expect(g.imageSelection).toBe(false);
|
||||
expect(g.zoneGestures).toBe(false);
|
||||
expect(g.imageOverlay).toBe(false);
|
||||
});
|
||||
|
||||
it("image-zone mode never co-activates text gates (textEditing)", () => {
|
||||
const g = computeEditModeGates("image-zone", false);
|
||||
expect(g.imageSelection).toBe(true);
|
||||
expect(g.textEditing).toBe(false);
|
||||
});
|
||||
|
||||
it.each<EditMode>(ALL_MODES)(
|
||||
"for every editMode (%s), textEditing AND zoneGestures are NEVER both true",
|
||||
(mode) => {
|
||||
const g = computeEditModeGates(mode, false);
|
||||
expect(g.textEditing && g.zoneGestures).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it.each<EditMode>(ALL_MODES)(
|
||||
"for every editMode (%s), textEditing AND imageOverlay are NEVER both true",
|
||||
(mode) => {
|
||||
const g = computeEditModeGates(mode, false);
|
||||
expect(g.textEditing && g.imageOverlay).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it.each<EditMode>(ALL_MODES)(
|
||||
"for every editMode (%s), textEditing AND imageSelection are NEVER both true",
|
||||
(mode) => {
|
||||
const g = computeEditModeGates(mode, false);
|
||||
expect(g.textEditing && g.imageSelection).toBe(false);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe("computeEditModeGates (IMP-90 u12) — iframePointerAuto coupling", () => {
|
||||
// pe:auto is the iframe-side prerequisite for ANY in-iframe gesture
|
||||
// (text caret OR image click). The helper must NOT advertise an
|
||||
// in-iframe gate as active while pe is none, or those gestures would
|
||||
// be silently swallowed by the wrapper.
|
||||
it.each<EditMode>(ALL_MODES)(
|
||||
"textEditing → iframePointerAuto (editMode=%s)",
|
||||
(mode) => {
|
||||
const g = computeEditModeGates(mode, false);
|
||||
if (g.textEditing) expect(g.iframePointerAuto).toBe(true);
|
||||
}
|
||||
);
|
||||
it.each<EditMode>(ALL_MODES)(
|
||||
"imageSelection → iframePointerAuto (editMode=%s)",
|
||||
(mode) => {
|
||||
const g = computeEditModeGates(mode, false);
|
||||
if (g.imageSelection) expect(g.iframePointerAuto).toBe(true);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe("computeEditModeGates (IMP-90 u12) — referential transparency", () => {
|
||||
it("multiple calls with the same inputs return equal output", () => {
|
||||
const a = computeEditModeGates("image-zone", false);
|
||||
const b = computeEditModeGates("image-zone", false);
|
||||
const c = computeEditModeGates("image-zone", false);
|
||||
expect(a).toEqual(b);
|
||||
expect(b).toEqual(c);
|
||||
});
|
||||
|
||||
it("does not mutate captured state across calls (independent invocations)", () => {
|
||||
const a = computeEditModeGates("text", false);
|
||||
const _b = computeEditModeGates("image-zone", false);
|
||||
// a must still reflect text mode after b's call.
|
||||
expect(a.textEditing).toBe(true);
|
||||
expect(a.imageSelection).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeEditModeGates (IMP-90 u12) — gate truthtable snapshot", () => {
|
||||
// Snapshot for human-readable inspection — the per-mode flag layout
|
||||
// is the contract u13 (text capture) and u14 (structure overlay)
|
||||
// will build against. Any change requires updating both this test
|
||||
// AND the consuming gates in SlideCanvas.tsx.
|
||||
it("non-pendingLayout truthtable matches the u12 contract", () => {
|
||||
const rows = (["off", "text", "structure", "image-zone"] as EditMode[]).map(
|
||||
(m) => ({ mode: m, ...computeEditModeGates(m, false) })
|
||||
);
|
||||
expect(rows).toEqual([
|
||||
{ mode: "off", textEditing: false, imageSelection: false, iframePointerAuto: false, zoneGestures: false, imageOverlay: false },
|
||||
{ mode: "text", textEditing: true, imageSelection: false, iframePointerAuto: true, zoneGestures: false, imageOverlay: false },
|
||||
{ mode: "structure", textEditing: false, imageSelection: false, iframePointerAuto: false, zoneGestures: false, imageOverlay: false },
|
||||
{ mode: "image-zone", textEditing: false, imageSelection: true, iframePointerAuto: true, zoneGestures: true, imageOverlay: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
// IMP-90 (#90) u11 — vitest coverage for the discriminated EditMode enum
|
||||
// and its pure transition helper `nextEditMode`. Replaces the prior single
|
||||
// `isEditMode` boolean state. u11 introduces ONLY the state surface + the
|
||||
// toolbar UI; gesture gating per mode is u12 (mutually exclusive) and must
|
||||
// not regress this contract.
|
||||
//
|
||||
// Scope (Stage 2 unit u11 contract):
|
||||
// 1) EDIT_MODES is the canonical ['text','structure','image-zone'] list
|
||||
// in toolbar render order. 'off' is intentionally excluded from the
|
||||
// iterable because it is the implicit baseline (no button); the
|
||||
// toolbar only renders the three active modes per the u11 design.
|
||||
// 2) nextEditMode is a pure (current, requested) -> EditMode mapping
|
||||
// with three rules:
|
||||
// - requested === 'off' -> 'off' (explicit exit)
|
||||
// - requested === current -> 'off' (toggle exit)
|
||||
// - requested !== current && != 'off'-> requested (mode switch)
|
||||
// 3) The helper is referentially transparent — no side effects, no
|
||||
// React, no useState, no DOM. SlideCanvas wires it as the useState
|
||||
// updater callback (`setEditMode((prev) => nextEditMode(prev, m))`),
|
||||
// so covering the helper here covers every toolbar click outcome
|
||||
// directly without DOM rendering. (@testing-library/react is NOT in
|
||||
// devDependencies; this mirrors the imp47b_human_review_toast pattern.)
|
||||
// 4) The exported EditMode type union must contain exactly the four
|
||||
// members 'off' | 'text' | 'structure' | 'image-zone'. The runtime
|
||||
// EDIT_MODES list intentionally excludes 'off' (see (1) above).
|
||||
//
|
||||
// Forward-compat note: u12 will discriminate per-mode gating but MUST NOT
|
||||
// alter the (current, requested) -> next contract verified here. Any
|
||||
// change to the toggle/switch/exit semantics is a scope-violation against
|
||||
// the u11 binding contract.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
EDIT_MODES,
|
||||
nextEditMode,
|
||||
type EditMode,
|
||||
} from "../src/components/SlideCanvas";
|
||||
|
||||
describe("EDIT_MODES (IMP-90 u11 — toolbar render order)", () => {
|
||||
it("contains exactly the three active modes in toolbar order", () => {
|
||||
expect(EDIT_MODES).toEqual(["text", "structure", "image-zone"]);
|
||||
});
|
||||
|
||||
it("excludes 'off' — baseline is implicit, no toolbar button", () => {
|
||||
expect(EDIT_MODES).not.toContain("off" as EditMode);
|
||||
});
|
||||
|
||||
it("has length 3", () => {
|
||||
expect(EDIT_MODES.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nextEditMode (IMP-90 u11 — pure transition helper)", () => {
|
||||
describe("explicit 'off' request always exits", () => {
|
||||
it.each<EditMode>(["off", "text", "structure", "image-zone"])(
|
||||
"current=%s, requested=off -> off",
|
||||
(current) => {
|
||||
expect(nextEditMode(current, "off")).toBe("off");
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe("clicking the active mode toggles back to 'off'", () => {
|
||||
it.each<EditMode>(["text", "structure", "image-zone"])(
|
||||
"current=%s, requested=%s -> off",
|
||||
(mode) => {
|
||||
expect(nextEditMode(mode, mode)).toBe("off");
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe("clicking a different mode switches", () => {
|
||||
const cases: Array<[EditMode, EditMode]> = [
|
||||
["off", "text"],
|
||||
["off", "structure"],
|
||||
["off", "image-zone"],
|
||||
["text", "structure"],
|
||||
["text", "image-zone"],
|
||||
["structure", "text"],
|
||||
["structure", "image-zone"],
|
||||
["image-zone", "text"],
|
||||
["image-zone", "structure"],
|
||||
];
|
||||
it.each(cases)("current=%s, requested=%s -> requested", (current, requested) => {
|
||||
expect(nextEditMode(current, requested)).toBe(requested);
|
||||
});
|
||||
});
|
||||
|
||||
it("is referentially transparent — multiple calls with same inputs return same output", () => {
|
||||
const a = nextEditMode("text", "structure");
|
||||
const b = nextEditMode("text", "structure");
|
||||
const c = nextEditMode("text", "structure");
|
||||
expect(a).toBe("structure");
|
||||
expect(b).toBe("structure");
|
||||
expect(c).toBe("structure");
|
||||
});
|
||||
|
||||
it("never returns a value outside the EditMode union", () => {
|
||||
const all: EditMode[] = ["off", "text", "structure", "image-zone"];
|
||||
for (const current of all) {
|
||||
for (const requested of all) {
|
||||
const result = nextEditMode(current, requested);
|
||||
expect(all).toContain(result);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves toggle semantics under repeated identical clicks", () => {
|
||||
// off -> text -> off -> text -> off (toggle behavior)
|
||||
let m: EditMode = "off";
|
||||
m = nextEditMode(m, "text");
|
||||
expect(m).toBe("text");
|
||||
m = nextEditMode(m, "text");
|
||||
expect(m).toBe("off");
|
||||
m = nextEditMode(m, "text");
|
||||
expect(m).toBe("text");
|
||||
m = nextEditMode(m, "text");
|
||||
expect(m).toBe("off");
|
||||
});
|
||||
|
||||
it("preserves switch semantics across distinct mode clicks", () => {
|
||||
// off -> text -> structure -> image-zone -> off (via toggle)
|
||||
let m: EditMode = "off";
|
||||
m = nextEditMode(m, "text");
|
||||
expect(m).toBe("text");
|
||||
m = nextEditMode(m, "structure");
|
||||
expect(m).toBe("structure");
|
||||
m = nextEditMode(m, "image-zone");
|
||||
expect(m).toBe("image-zone");
|
||||
m = nextEditMode(m, "image-zone");
|
||||
expect(m).toBe("off");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
// IMP-56 (#90) u19 — vitest coverage for the vite POST /api/export
|
||||
// middleware and its supporting inlineAssetsAsDataUrls helper.
|
||||
//
|
||||
// Scope:
|
||||
// 1) inlineAssetsAsDataUrls (pure helper):
|
||||
// - no url(assets/...) refs → passthrough.
|
||||
// - single PNG ref → inlined as base64 data: URL with image/png mime.
|
||||
// - multiple refs → all inlined.
|
||||
// - SVG ref → image/svg+xml mime.
|
||||
// - missing asset file → left as-is (no throw, no rewrite).
|
||||
// - data:/http:/ URLs (non-asset) → untouched.
|
||||
// 2) handleExportStandalone (POST):
|
||||
// - method != POST → false (chain continues; next middleware may handle).
|
||||
// - invalid JSON / non-object body → 400.
|
||||
// - missing run_id → 400.
|
||||
// - invalid run_id (key gate / path traversal) → 400.
|
||||
// - final.html missing → 404.
|
||||
// - success → 200 with Content-Disposition: attachment; filename=...,
|
||||
// Content-Type: text/html; charset=utf-8, body = inlined HTML.
|
||||
//
|
||||
// Tests exercise the pure handler with mock req/res — no real vite server.
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
handleExportStandalone,
|
||||
inlineAssetsAsDataUrls,
|
||||
} from "../../vite.config";
|
||||
|
||||
function makeMockRes() {
|
||||
const state = {
|
||||
statusCode: 0,
|
||||
headers: {} as Record<string, string>,
|
||||
body: "",
|
||||
ended: false,
|
||||
};
|
||||
return {
|
||||
state,
|
||||
res: {
|
||||
writeHead(status: number, headers?: Record<string, string>) {
|
||||
state.statusCode = status;
|
||||
if (headers) state.headers = headers;
|
||||
},
|
||||
end(body?: string) {
|
||||
state.body = body ?? "";
|
||||
state.ended = true;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeMockReq(opts: {
|
||||
method?: string;
|
||||
}): EventEmitter & { method?: string; send: (body: string) => void } {
|
||||
const ee = new EventEmitter() as EventEmitter & {
|
||||
method?: string;
|
||||
send: (body: string) => void;
|
||||
};
|
||||
ee.method = opts.method;
|
||||
ee.send = (body: string) => {
|
||||
if (body.length > 0) ee.emit("data", Buffer.from(body, "utf-8"));
|
||||
ee.emit("end");
|
||||
};
|
||||
return ee;
|
||||
}
|
||||
|
||||
function seedRun(
|
||||
daRoot: string,
|
||||
runId: string,
|
||||
htmlBody: string,
|
||||
assets?: Record<string, Buffer | string>,
|
||||
): string {
|
||||
const runDir = path.join(daRoot, "data", "runs", runId, "phase_z2");
|
||||
fs.mkdirSync(runDir, { recursive: true });
|
||||
const html = path.join(runDir, "final.html");
|
||||
fs.writeFileSync(html, htmlBody, "utf-8");
|
||||
if (assets) {
|
||||
for (const [rel, buf] of Object.entries(assets)) {
|
||||
const dst = path.join(runDir, "assets", rel);
|
||||
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
||||
fs.writeFileSync(dst, buf);
|
||||
}
|
||||
}
|
||||
return runDir;
|
||||
}
|
||||
|
||||
describe("inlineAssetsAsDataUrls (IMP-56 #90 u19)", () => {
|
||||
let tmp: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "imp90-u19-inline-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns html unchanged when no url(assets/...) refs are present", () => {
|
||||
const html = "<html><style>body{color:red;}</style><body>hi</body></html>";
|
||||
expect(inlineAssetsAsDataUrls(html, tmp)).toBe(html);
|
||||
});
|
||||
|
||||
it("inlines a single PNG asset as a base64 data: URL with image/png mime", () => {
|
||||
fs.mkdirSync(path.join(tmp, "frame_x"), { recursive: true });
|
||||
const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
fs.writeFileSync(path.join(tmp, "frame_x", "a.png"), pngBytes);
|
||||
const html = "background: url(assets/frame_x/a.png);";
|
||||
const out = inlineAssetsAsDataUrls(html, tmp);
|
||||
expect(out).toContain(`url("data:image/png;base64,${pngBytes.toString("base64")}")`);
|
||||
expect(out).not.toContain("url(assets/frame_x/a.png)");
|
||||
});
|
||||
|
||||
it("inlines multiple refs across the same HTML body", () => {
|
||||
fs.mkdirSync(path.join(tmp, "f"), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmp, "f", "one.png"), Buffer.from("ONE"));
|
||||
fs.writeFileSync(path.join(tmp, "f", "two.png"), Buffer.from("TWO"));
|
||||
const html = "a{background:url(assets/f/one.png)} b{background:url(assets/f/two.png)}";
|
||||
const out = inlineAssetsAsDataUrls(html, tmp);
|
||||
expect(out).toContain(`data:image/png;base64,${Buffer.from("ONE").toString("base64")}`);
|
||||
expect(out).toContain(`data:image/png;base64,${Buffer.from("TWO").toString("base64")}`);
|
||||
});
|
||||
|
||||
it("uses image/svg+xml mime for .svg refs", () => {
|
||||
fs.mkdirSync(path.join(tmp, "f"), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmp, "f", "icon.svg"), "<svg/>", "utf-8");
|
||||
const html = "url(assets/f/icon.svg)";
|
||||
const out = inlineAssetsAsDataUrls(html, tmp);
|
||||
expect(out).toContain("data:image/svg+xml;base64,");
|
||||
});
|
||||
|
||||
it("leaves the ref untouched when the asset file is missing", () => {
|
||||
const html = "url(assets/missing/file.png)";
|
||||
const out = inlineAssetsAsDataUrls(html, tmp);
|
||||
expect(out).toBe(html);
|
||||
});
|
||||
|
||||
it("does not touch data: or http(s): url() values (only matches assets/...)", () => {
|
||||
const html =
|
||||
"x{background:url(data:image/png;base64,AAA)} " +
|
||||
"y{background:url(https://cdn.x/a.png)}";
|
||||
expect(inlineAssetsAsDataUrls(html, tmp)).toBe(html);
|
||||
});
|
||||
|
||||
it("handles quoted url(...) refs (single and double quotes)", () => {
|
||||
fs.mkdirSync(path.join(tmp, "q"), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmp, "q", "k.png"), Buffer.from("K"));
|
||||
const html =
|
||||
"a{background:url('assets/q/k.png')} b{background:url(\"assets/q/k.png\")}";
|
||||
const out = inlineAssetsAsDataUrls(html, tmp);
|
||||
const data = `data:image/png;base64,${Buffer.from("K").toString("base64")}`;
|
||||
expect(out.split(data).length - 1).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleExportStandalone (IMP-56 #90 u19)", () => {
|
||||
let daRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
daRoot = fs.mkdtempSync(path.join(os.tmpdir(), "imp90-u19-da-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(daRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns false (next chained) when method != POST", () => {
|
||||
const req = makeMockReq({ method: "GET" });
|
||||
const { res, state } = makeMockRes();
|
||||
const handled = handleExportStandalone(req, res, daRoot);
|
||||
expect(handled).toBe(false);
|
||||
expect(state.ended).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 400 on invalid JSON body", () => {
|
||||
const req = makeMockReq({ method: "POST" });
|
||||
const { res, state } = makeMockRes();
|
||||
const handled = handleExportStandalone(req, res, daRoot);
|
||||
expect(handled).toBe(true);
|
||||
req.send("{nope");
|
||||
expect(state.statusCode).toBe(400);
|
||||
expect(JSON.parse(state.body).error).toBe("invalid JSON");
|
||||
});
|
||||
|
||||
it("returns 400 when body is not a JSON object (array root)", () => {
|
||||
const req = makeMockReq({ method: "POST" });
|
||||
const { res, state } = makeMockRes();
|
||||
handleExportStandalone(req, res, daRoot);
|
||||
req.send(JSON.stringify(["x"]));
|
||||
expect(state.statusCode).toBe(400);
|
||||
expect(JSON.parse(state.body).error).toBe("body must be a JSON object");
|
||||
});
|
||||
|
||||
it("returns 400 when run_id is missing", () => {
|
||||
const req = makeMockReq({ method: "POST" });
|
||||
const { res, state } = makeMockRes();
|
||||
handleExportStandalone(req, res, daRoot);
|
||||
req.send(JSON.stringify({}));
|
||||
expect(state.statusCode).toBe(400);
|
||||
expect(JSON.parse(state.body).error).toBe("missing run_id");
|
||||
});
|
||||
|
||||
it("returns 400 when run_id contains path traversal", () => {
|
||||
const req = makeMockReq({ method: "POST" });
|
||||
const { res, state } = makeMockRes();
|
||||
handleExportStandalone(req, res, daRoot);
|
||||
req.send(JSON.stringify({ run_id: "../escape" }));
|
||||
expect(state.statusCode).toBe(400);
|
||||
expect(JSON.parse(state.body).error).toBe("invalid run_id");
|
||||
});
|
||||
|
||||
it("returns 404 when final.html does not exist for run_id", () => {
|
||||
const req = makeMockReq({ method: "POST" });
|
||||
const { res, state } = makeMockRes();
|
||||
handleExportStandalone(req, res, daRoot);
|
||||
req.send(JSON.stringify({ run_id: "ghost_run" }));
|
||||
expect(state.statusCode).toBe(404);
|
||||
expect(JSON.parse(state.body).error).toBe("final.html not found");
|
||||
});
|
||||
|
||||
it("returns 200 with text/html body + Content-Disposition on success", () => {
|
||||
seedRun(daRoot, "mdx03_run", "<html><body>03</body></html>");
|
||||
const req = makeMockReq({ method: "POST" });
|
||||
const { res, state } = makeMockRes();
|
||||
handleExportStandalone(req, res, daRoot);
|
||||
req.send(JSON.stringify({ run_id: "mdx03_run" }));
|
||||
expect(state.statusCode).toBe(200);
|
||||
expect(state.headers["Content-Type"]).toBe("text/html; charset=utf-8");
|
||||
expect(state.headers["Content-Disposition"]).toBe(
|
||||
'attachment; filename="mdx03_run.html"',
|
||||
);
|
||||
expect(state.body).toBe("<html><body>03</body></html>");
|
||||
});
|
||||
|
||||
it("inlines assets in final.html when run dir has assets/", () => {
|
||||
const pngBytes = Buffer.from("PNGDATA");
|
||||
seedRun(
|
||||
daRoot,
|
||||
"mdx05_run",
|
||||
"<html><body><div style=\"background: url(assets/f/x.png)\"></div></body></html>",
|
||||
{ "f/x.png": pngBytes },
|
||||
);
|
||||
const req = makeMockReq({ method: "POST" });
|
||||
const { res, state } = makeMockRes();
|
||||
handleExportStandalone(req, res, daRoot);
|
||||
req.send(JSON.stringify({ run_id: "mdx05_run" }));
|
||||
expect(state.statusCode).toBe(200);
|
||||
expect(state.body).toContain(
|
||||
`data:image/png;base64,${pngBytes.toString("base64")}`,
|
||||
);
|
||||
expect(state.body).not.toContain("url(assets/f/x.png)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
// IMP-90 (#90) u14 — vitest coverage for the pure helpers exported by
|
||||
// `StructureEditOverlay`. The React component itself is not rendered
|
||||
// (jsdom / @testing-library NOT in Front devDependencies — verified in
|
||||
// `Front/package.json`); we test the deterministic pieces that drive its
|
||||
// JSX: `resolveEffectiveSlotOrder` (effective-order resolution under
|
||||
// override) and `moveItem` (immutable reorder primitive).
|
||||
//
|
||||
// Upstream / downstream contracts (verified by prior units):
|
||||
// - u2 KNOWN_AXES += structure_overrides (Python backend).
|
||||
// - u3 vite allowlist += structure_overrides.
|
||||
// - u6 structure_override_resolver — inner shape locked to
|
||||
// {slot_order, hidden_slots}; frame swap REJECTED to existing
|
||||
// frames axis.
|
||||
// - u10 typed-client `StructureOverridePerZone` + extract helper.
|
||||
// - u15 (next) will debounce + PUT the emitted capture.
|
||||
//
|
||||
// u14 scope: pure helpers only. React render path is verified by Codex
|
||||
// auditor via static read of the JSX (no runtime test possible without
|
||||
// jsdom). Tests below are intentionally side-effect-free.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
resolveEffectiveSlotOrder,
|
||||
moveItem,
|
||||
} from "../src/components/StructureEditOverlay";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// resolveEffectiveSlotOrder
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("resolveEffectiveSlotOrder — no override", () => {
|
||||
it("returns a fresh copy of the discovered keys when slotOrder is undefined", () => {
|
||||
const discovered = ["a", "b", "c"];
|
||||
const out = resolveEffectiveSlotOrder(discovered, undefined);
|
||||
expect(out).toEqual(["a", "b", "c"]);
|
||||
expect(out).not.toBe(discovered);
|
||||
});
|
||||
it("returns a fresh copy when slotOrder is null", () => {
|
||||
const out = resolveEffectiveSlotOrder(["a", "b"], null);
|
||||
expect(out).toEqual(["a", "b"]);
|
||||
});
|
||||
it("returns a fresh copy when slotOrder is empty []", () => {
|
||||
const out = resolveEffectiveSlotOrder(["a", "b"], []);
|
||||
expect(out).toEqual(["a", "b"]);
|
||||
});
|
||||
it("handles empty discovered list (no slots in zone)", () => {
|
||||
expect(resolveEffectiveSlotOrder([], undefined)).toEqual([]);
|
||||
expect(resolveEffectiveSlotOrder([], ["x"])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveEffectiveSlotOrder — full override", () => {
|
||||
it("reorders all discovered keys per slotOrder", () => {
|
||||
expect(
|
||||
resolveEffectiveSlotOrder(["a", "b", "c"], ["c", "a", "b"]),
|
||||
).toEqual(["c", "a", "b"]);
|
||||
});
|
||||
it("is idempotent when slotOrder matches discovered order", () => {
|
||||
expect(
|
||||
resolveEffectiveSlotOrder(["a", "b", "c"], ["a", "b", "c"]),
|
||||
).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveEffectiveSlotOrder — partial / drift override", () => {
|
||||
it("appends missing discovered keys in backend order at the tail", () => {
|
||||
// user reordered b -> first, but c was added later by backend.
|
||||
expect(
|
||||
resolveEffectiveSlotOrder(["a", "b", "c"], ["b", "a"]),
|
||||
).toEqual(["b", "a", "c"]);
|
||||
});
|
||||
it("drops override entries that no longer exist in discovered keys", () => {
|
||||
// user had slot 'x' before; backend dropped it.
|
||||
expect(
|
||||
resolveEffectiveSlotOrder(["a", "b"], ["x", "a", "b"]),
|
||||
).toEqual(["a", "b"]);
|
||||
});
|
||||
it("dedupes duplicate entries within slotOrder", () => {
|
||||
expect(
|
||||
resolveEffectiveSlotOrder(["a", "b", "c"], ["a", "a", "b"]),
|
||||
).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
it("dedupe + drop + append all together (stress)", () => {
|
||||
expect(
|
||||
resolveEffectiveSlotOrder(
|
||||
["a", "b", "c", "d"],
|
||||
["d", "x", "d", "a", "ghost"],
|
||||
),
|
||||
).toEqual(["d", "a", "b", "c"]);
|
||||
});
|
||||
it("ignores non-string entries in slotOrder", () => {
|
||||
const bogus = ["a", null as unknown as string, undefined as unknown as string, "b"];
|
||||
expect(resolveEffectiveSlotOrder(["a", "b"], bogus)).toEqual(["a", "b"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// moveItem
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("moveItem — happy paths", () => {
|
||||
it("moves index 0 down by 1 (swap with index 1)", () => {
|
||||
expect(moveItem(["a", "b", "c"], 0, 1)).toEqual(["b", "a", "c"]);
|
||||
});
|
||||
it("moves index 2 up by 1 (swap with index 1)", () => {
|
||||
expect(moveItem(["a", "b", "c"], 2, -1)).toEqual(["a", "c", "b"]);
|
||||
});
|
||||
it("moves across larger delta (swap with target)", () => {
|
||||
expect(moveItem(["a", "b", "c", "d"], 0, 2)).toEqual(["c", "b", "a", "d"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("moveItem — bounds", () => {
|
||||
it("no-op (fresh copy) when moving first up", () => {
|
||||
const src = ["a", "b", "c"];
|
||||
const out = moveItem(src, 0, -1);
|
||||
expect(out).toEqual(["a", "b", "c"]);
|
||||
expect(out).not.toBe(src);
|
||||
});
|
||||
it("no-op when moving last down", () => {
|
||||
expect(moveItem(["a", "b", "c"], 2, 1)).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
it("no-op when index negative", () => {
|
||||
expect(moveItem(["a", "b"], -1, 1)).toEqual(["a", "b"]);
|
||||
});
|
||||
it("no-op when index past end", () => {
|
||||
expect(moveItem(["a", "b"], 5, -1)).toEqual(["a", "b"]);
|
||||
});
|
||||
it("no-op when target falls out of range from large delta", () => {
|
||||
expect(moveItem(["a", "b", "c"], 1, 99)).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
it("no-op on empty array (any index)", () => {
|
||||
expect(moveItem<string>([], 0, 1)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("moveItem — immutability", () => {
|
||||
it("never mutates the input array", () => {
|
||||
const src = ["a", "b", "c"];
|
||||
moveItem(src, 0, 1);
|
||||
expect(src).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
it("returns a new reference even when no-op", () => {
|
||||
const src = ["a", "b"];
|
||||
expect(moveItem(src, 0, -1)).not.toBe(src);
|
||||
});
|
||||
it("preserves T-typed values (number array)", () => {
|
||||
expect(moveItem([1, 2, 3], 0, 1)).toEqual([2, 1, 3]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
// IMP-90 (#90) u13 — vitest coverage for `deriveTextEditCapture`, the pure
|
||||
// helper that resolves a contentEditable focusout target into the
|
||||
// (zone_id, text_path, value) capture tuple emitted by SlideCanvas.
|
||||
//
|
||||
// Upstream contract (verified by prior units):
|
||||
// - u8 `src/text_path_stamper.py` stamps `data-text-path="{slot_key}.{
|
||||
// line_index}"` on every rendered text-line opening tag at Step 13.
|
||||
// - u9 wires the stamper into `render_slide` so the final.html consumed
|
||||
// by SlideCanvas's iframe carries those attributes.
|
||||
// - Phase Z slide-base wraps every zone in `.zone[data-zone-position]`
|
||||
// (verified at SlideCanvas.tsx onLoad measure block).
|
||||
//
|
||||
// u13 scope: derive the capture tuple from any descendant of a stamped
|
||||
// line, OR the stamped line itself. Non-stamped targets (slide-base
|
||||
// title/footer, decorative spans outside the zone tree) return null so
|
||||
// the focusout handler silently skips them — never crashes.
|
||||
//
|
||||
// Forward-compat note: u15 will debounce + PUT the capture; u15 MUST NOT
|
||||
// alter the (target) -> {zoneId, textPath, value} | null contract verified
|
||||
// here. Any change to the resolution semantics is a scope-violation
|
||||
// against the u13 binding contract.
|
||||
//
|
||||
// jsdom is NOT in devDependencies (verified in Front/package.json); this
|
||||
// test mocks `TextEditCaptureTarget` with structurally-typed objects per
|
||||
// the established u11/u12 pure-helper pattern.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
deriveTextEditCapture,
|
||||
type TextEditCapture,
|
||||
type TextEditCaptureTarget,
|
||||
} from "../src/components/SlideCanvas";
|
||||
|
||||
// --- minimal closest-aware mock builders -----------------------------
|
||||
// Each node only needs to know which selectors it matches and its
|
||||
// parent chain — `closest` is implemented by walking parent pointers.
|
||||
|
||||
interface MockNodeSpec {
|
||||
matches: string[];
|
||||
attrs?: Record<string, string>;
|
||||
text?: string | null;
|
||||
parent?: MockNode | null;
|
||||
}
|
||||
interface MockNode extends TextEditCaptureTarget {
|
||||
matches(sel: string): boolean;
|
||||
parent: MockNode | null;
|
||||
}
|
||||
function makeNode(spec: MockNodeSpec): MockNode {
|
||||
const node: MockNode = {
|
||||
parent: spec.parent ?? null,
|
||||
matches(sel: string) {
|
||||
return spec.matches.includes(sel);
|
||||
},
|
||||
closest(sel: string): TextEditCaptureTarget | null {
|
||||
let cur: MockNode | null = node;
|
||||
while (cur) {
|
||||
if (cur.matches(sel)) return cur;
|
||||
cur = cur.parent;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
getAttribute(name: string): string | null {
|
||||
return spec.attrs?.[name] ?? null;
|
||||
},
|
||||
textContent: spec.text === undefined ? null : spec.text,
|
||||
};
|
||||
return node;
|
||||
}
|
||||
|
||||
// Canonical zone + line scaffold used across happy-path tests.
|
||||
// `null` for any field is preserved verbatim so edge cases (missing attr /
|
||||
// null textContent) can exercise the helper's defensive branches.
|
||||
function makeZoneLineScaffold(opts: {
|
||||
zoneId?: string | null;
|
||||
textPath?: string | null;
|
||||
lineText?: string | null;
|
||||
}) {
|
||||
const zone = makeNode({
|
||||
matches: [".zone[data-zone-position]"],
|
||||
attrs: opts.zoneId === null ? {} : { "data-zone-position": opts.zoneId ?? "top" },
|
||||
});
|
||||
const line = makeNode({
|
||||
matches: ["[data-text-path]"],
|
||||
attrs:
|
||||
opts.textPath === null
|
||||
? {}
|
||||
: { "data-text-path": opts.textPath ?? "row_1_left_body.0" },
|
||||
text: opts.lineText === undefined ? "hello world" : opts.lineText,
|
||||
parent: zone,
|
||||
});
|
||||
return { zone, line };
|
||||
}
|
||||
|
||||
describe("deriveTextEditCapture (IMP-90 u13) — null inputs / non-stamped", () => {
|
||||
it("returns null when target is null", () => {
|
||||
expect(deriveTextEditCapture(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when no ancestor has data-text-path (e.g., slide title)", () => {
|
||||
const title = makeNode({
|
||||
matches: [".slide-title"],
|
||||
text: "Phase Z 슬라이드",
|
||||
});
|
||||
expect(deriveTextEditCapture(title)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the stamped line has no enclosing zone", () => {
|
||||
// Decorative line stamped by the future u8 but rendered outside a
|
||||
// zone (e.g., footer pill). u13 silently skips — caller never sees
|
||||
// a half-resolved capture.
|
||||
const orphanLine = makeNode({
|
||||
matches: ["[data-text-path]"],
|
||||
attrs: { "data-text-path": "footer.0" },
|
||||
text: "결론",
|
||||
});
|
||||
expect(deriveTextEditCapture(orphanLine)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveTextEditCapture (IMP-90 u13) — happy path", () => {
|
||||
it("resolves (zoneId, textPath, value) when target IS the stamped line", () => {
|
||||
const { line } = makeZoneLineScaffold({
|
||||
zoneId: "top",
|
||||
textPath: "row_1_left_body.0",
|
||||
lineText: "분석 결과",
|
||||
});
|
||||
expect(deriveTextEditCapture(line)).toEqual<TextEditCapture>({
|
||||
zoneId: "top",
|
||||
textPath: "row_1_left_body.0",
|
||||
value: "분석 결과",
|
||||
});
|
||||
});
|
||||
|
||||
it("walks up to the stamped line when target is a nested descendant", () => {
|
||||
const { zone, line } = makeZoneLineScaffold({
|
||||
zoneId: "bottom_l",
|
||||
textPath: "left_body.2",
|
||||
lineText: "wrapped",
|
||||
});
|
||||
// emulate a SPAN inside the stamped line (e.g., bold inline span)
|
||||
const innerSpan = makeNode({
|
||||
matches: ["span.highlight"],
|
||||
text: "ignored — closest walks to the line",
|
||||
parent: line,
|
||||
});
|
||||
void zone;
|
||||
expect(deriveTextEditCapture(innerSpan)).toEqual<TextEditCapture>({
|
||||
zoneId: "bottom_l",
|
||||
textPath: "left_body.2",
|
||||
value: "wrapped",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the line's textContent without HTML normalization", () => {
|
||||
const { line } = makeZoneLineScaffold({
|
||||
zoneId: "primary",
|
||||
textPath: "headline.0",
|
||||
lineText: " spaced inner words ",
|
||||
});
|
||||
// u13 trims outer whitespace but does NOT collapse interior whitespace
|
||||
// — value mirrors what user typed, modulo blur-edge trim.
|
||||
expect(deriveTextEditCapture(line)?.value).toBe("spaced inner words");
|
||||
});
|
||||
|
||||
it("returns empty string when textContent is null (edge: empty line)", () => {
|
||||
const { line } = makeZoneLineScaffold({
|
||||
zoneId: "top",
|
||||
textPath: "row_1_left_body.0",
|
||||
lineText: null,
|
||||
});
|
||||
expect(deriveTextEditCapture(line)?.value).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string when textContent is whitespace-only", () => {
|
||||
const { line } = makeZoneLineScaffold({
|
||||
zoneId: "top",
|
||||
textPath: "row_1_left_body.0",
|
||||
lineText: " \n \t ",
|
||||
});
|
||||
expect(deriveTextEditCapture(line)?.value).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveTextEditCapture (IMP-90 u13) — missing attribute defensiveness", () => {
|
||||
it("returns null when data-text-path attribute is absent on the matched line", () => {
|
||||
// Should not happen with the u8 stamper, but a downstream mutation
|
||||
// (e.g., user pasting a fresh element) could create a stamped-class
|
||||
// node without the actual attribute. u13 stays defensive.
|
||||
const zone = makeNode({
|
||||
matches: [".zone[data-zone-position]"],
|
||||
attrs: { "data-zone-position": "top" },
|
||||
});
|
||||
const lineNoPath = makeNode({
|
||||
matches: ["[data-text-path]"],
|
||||
attrs: {},
|
||||
text: "hello",
|
||||
parent: zone,
|
||||
});
|
||||
expect(deriveTextEditCapture(lineNoPath)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when data-zone-position attribute is absent on the matched zone", () => {
|
||||
const zoneNoId = makeNode({
|
||||
matches: [".zone[data-zone-position]"],
|
||||
attrs: {},
|
||||
});
|
||||
const line = makeNode({
|
||||
matches: ["[data-text-path]"],
|
||||
attrs: { "data-text-path": "row_1_left_body.0" },
|
||||
text: "hello",
|
||||
parent: zoneNoId,
|
||||
});
|
||||
expect(deriveTextEditCapture(line)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveTextEditCapture (IMP-90 u13) — referential transparency", () => {
|
||||
it("multiple calls with the same target return equal captures", () => {
|
||||
const { line } = makeZoneLineScaffold({
|
||||
zoneId: "top",
|
||||
textPath: "row_1_left_body.0",
|
||||
lineText: "stable",
|
||||
});
|
||||
const a = deriveTextEditCapture(line);
|
||||
const b = deriveTextEditCapture(line);
|
||||
expect(a).toEqual(b);
|
||||
expect(a).not.toBe(b); // fresh objects each call (caller-friendly)
|
||||
});
|
||||
|
||||
it("does not mutate the target element (attrs / parent / textContent unchanged)", () => {
|
||||
const { line, zone } = makeZoneLineScaffold({
|
||||
zoneId: "top",
|
||||
textPath: "row_1_left_body.0",
|
||||
lineText: "immutable",
|
||||
});
|
||||
deriveTextEditCapture(line);
|
||||
expect(line.getAttribute("data-text-path")).toBe("row_1_left_body.0");
|
||||
expect(line.textContent).toBe("immutable");
|
||||
expect(zone.getAttribute("data-zone-position")).toBe("top");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveTextEditCapture (IMP-90 u13) — zone id pass-through", () => {
|
||||
// u13 does not validate the zone id shape — Phase Z slide-base owns the
|
||||
// canonical zone position vocabulary, and u15 / pipeline-side resolver
|
||||
// (u4) re-validate downstream. u13 just forwards whatever the stamped
|
||||
// DOM declared.
|
||||
const ZONE_IDS = ["top", "bottom_l", "bottom_r", "primary", "secondary"];
|
||||
it.each(ZONE_IDS)("preserves zone id '%s' verbatim", (zid) => {
|
||||
const { line } = makeZoneLineScaffold({
|
||||
zoneId: zid,
|
||||
textPath: `${zid}.0`,
|
||||
lineText: "x",
|
||||
});
|
||||
const cap = deriveTextEditCapture(line);
|
||||
expect(cap?.zoneId).toBe(zid);
|
||||
expect(cap?.textPath).toBe(`${zid}.0`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
// IMP-43 (#72) u6 — /api/run reuseFromRunId forwarding coverage.
|
||||
//
|
||||
// Stage 2 unit scope:
|
||||
// 1) Front/client/src/services/designAgentApi.ts `runPipeline`:
|
||||
// • accepts an optional 3rd arg `reuseFromRunId: string`.
|
||||
// • includes `reuseFromRunId` in the POST body when truthy.
|
||||
// • OMITS `reuseFromRunId` from the body when absent / empty / undefined
|
||||
// → byte-identical to the pre-u6 POST contract (absent flag = full
|
||||
// pipeline; backend u1 guard never sees an empty PREV_RUN_ID).
|
||||
// • leaves `filename`, `content`, and `overrides` untouched alongside
|
||||
// the new field (no payload-shape regression).
|
||||
// 2) Front/vite.config.ts `/api/run` handler:
|
||||
// • declares `reuseFromRunId?: string` in the payload type so a typed
|
||||
// client cannot send a payload the server silently drops.
|
||||
// • destructures `reuseFromRunId` from `payload` (sibling of
|
||||
// `overrides`, NOT nested under it — the backend u1 post-merge
|
||||
// guard treats reuse as a pipeline mode, not an override).
|
||||
// • forwards `--reuse-from <PREV_RUN_ID>` to spawn cliArgs guarded by
|
||||
// a truthy check (empty string / undefined ⇒ no flag, per Stage 2
|
||||
// contract: invalid CLI args must never reach argparse).
|
||||
// • places the forward block AFTER the `--override-section-assignment`
|
||||
// loop so the spawn argv preserves backend argparse's no-positional-
|
||||
// before-flag expectation and so `--override-frame` (still allowed
|
||||
// by the u1 guard) is positioned ahead of `--reuse-from`.
|
||||
//
|
||||
// runPipeline is exercised with a duck-typed `File` plus a `vi.stubGlobal`
|
||||
// fetch mock — mirrors the user_overrides_service.test.ts pattern. The
|
||||
// vite handler is source-sliced (mirrors handle_generate_diag.test.ts)
|
||||
// because the handler spawns python and a real /api/run round-trip is
|
||||
// out of unit-test scope.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { runPipeline } from "../src/services/designAgentApi";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// vite.config.ts source — read once for the handler source-slice assertions.
|
||||
// Path: Front/client/tests/ → Front/vite.config.ts (two levels up).
|
||||
// ---------------------------------------------------------------------------
|
||||
const VITE_CONFIG_PATH = resolve(__dirname, "..", "..", "vite.config.ts");
|
||||
const VITE_CONFIG_SOURCE = readFileSync(VITE_CONFIG_PATH, "utf-8");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fetch mock — minimal Response stub mirroring runPipeline's `.ok` + `.json()`
|
||||
// + `.status` surface. Same shape as the user_overrides_service.test.ts
|
||||
// helper so the two test files stay drift-free.
|
||||
// ---------------------------------------------------------------------------
|
||||
type MockResponse = {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
json: () => Promise<unknown>;
|
||||
};
|
||||
|
||||
function mockResponse(body: unknown, ok = true, status = 200): MockResponse {
|
||||
return { ok, status, json: async () => body };
|
||||
}
|
||||
|
||||
const SUCCESS_BODY = {
|
||||
success: true,
|
||||
run_id: "test_run_id_20260524",
|
||||
exit_code: 0,
|
||||
final_html_exists: true,
|
||||
preview_exists: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
|
||||
// Duck-typed File — runPipeline reads only `.name` and `.text()`. Avoids a
|
||||
// hard dependency on the global File constructor (varies across node /
|
||||
// jsdom / happy-dom test environments).
|
||||
function makeFakeFile(name: string, content: string): File {
|
||||
return {
|
||||
name,
|
||||
text: async () => content,
|
||||
} as unknown as File;
|
||||
}
|
||||
|
||||
let fetchMock: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function lastPostBody(): Record<string, 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));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// runPipeline (designAgentApi.ts) — forwarding/omission coverage
|
||||
// ============================================================================
|
||||
|
||||
describe("runPipeline reuseFromRunId forwarding (IMP-43 #72 u6)", () => {
|
||||
it("posts to /api/run via POST with JSON content-type", async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockResponse(SUCCESS_BODY));
|
||||
await runPipeline(makeFakeFile("03.mdx", "# title"));
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("/api/run");
|
||||
expect((init as RequestInit).method).toBe("POST");
|
||||
expect((init as RequestInit).headers).toMatchObject({
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
});
|
||||
|
||||
it("includes reuseFromRunId in the POST body when provided", async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockResponse(SUCCESS_BODY));
|
||||
await runPipeline(
|
||||
makeFakeFile("03.mdx", "# title"),
|
||||
undefined,
|
||||
"mdx03_20260524080000",
|
||||
);
|
||||
const body = lastPostBody();
|
||||
expect(body.reuseFromRunId).toBe("mdx03_20260524080000");
|
||||
expect(body.filename).toBe("03.mdx");
|
||||
expect(body.content).toBe("# title");
|
||||
});
|
||||
|
||||
it("omits reuseFromRunId when 3rd arg is undefined (pre-u6 byte-identical)", async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockResponse(SUCCESS_BODY));
|
||||
await runPipeline(makeFakeFile("03.mdx", "# title"));
|
||||
const body = lastPostBody();
|
||||
expect("reuseFromRunId" in body).toBe(false);
|
||||
// Pre-u6 contract: filename/content are the only keys when overrides
|
||||
// is undefined (JSON.stringify drops undefined values; pre-u6 emitted
|
||||
// `JSON.stringify({filename, content, overrides})` with the same
|
||||
// drop-undefined behaviour, so the wire body is byte-identical).
|
||||
expect(Object.keys(body).sort()).toEqual(["content", "filename"]);
|
||||
});
|
||||
|
||||
it("omits reuseFromRunId but keeps overrides when only overrides provided", async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockResponse(SUCCESS_BODY));
|
||||
await runPipeline(makeFakeFile("03.mdx", "# title"), {
|
||||
frames: { "03-1": "frame_07" },
|
||||
});
|
||||
const body = lastPostBody();
|
||||
expect("reuseFromRunId" in body).toBe(false);
|
||||
expect(Object.keys(body).sort()).toEqual([
|
||||
"content",
|
||||
"filename",
|
||||
"overrides",
|
||||
]);
|
||||
expect(body.overrides).toEqual({ frames: { "03-1": "frame_07" } });
|
||||
});
|
||||
|
||||
it("omits reuseFromRunId when passed an empty string (truthy guard)", async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockResponse(SUCCESS_BODY));
|
||||
await runPipeline(makeFakeFile("03.mdx", "# title"), undefined, "");
|
||||
const body = lastPostBody();
|
||||
expect("reuseFromRunId" in body).toBe(false);
|
||||
});
|
||||
|
||||
it("forwards reuseFromRunId alongside frame overrides (the only u1-permitted combo)", async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockResponse(SUCCESS_BODY));
|
||||
await runPipeline(
|
||||
makeFakeFile("03.mdx", "# title"),
|
||||
{ frames: { "03-1+03-2": "frame_07" } },
|
||||
"mdx03_20260524080000",
|
||||
);
|
||||
const body = lastPostBody();
|
||||
expect(body.overrides).toEqual({ frames: { "03-1+03-2": "frame_07" } });
|
||||
expect(body.reuseFromRunId).toBe("mdx03_20260524080000");
|
||||
});
|
||||
|
||||
it("returns the parsed RunPipelineResult on success", async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockResponse(SUCCESS_BODY));
|
||||
const res = await runPipeline(
|
||||
makeFakeFile("03.mdx", "# title"),
|
||||
undefined,
|
||||
"mdx03_20260524080000",
|
||||
);
|
||||
expect(res.success).toBe(true);
|
||||
expect(res.run_id).toBe("test_run_id_20260524");
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// /api/run handler (vite.config.ts) — source-slice forwarding contract
|
||||
// ============================================================================
|
||||
|
||||
describe("/api/run handler reuseFromRunId source-slice (IMP-43 #72 u6)", () => {
|
||||
it("declares reuseFromRunId?: string on the /api/run payload type", () => {
|
||||
// Payload type at the top of the /api/run handler body. The
|
||||
// optional-string declaration is the single source-of-truth for what
|
||||
// shape the handler accepts; a typed frontend client (u5 saveUserOverrides
|
||||
// sibling pattern) cannot silently send a payload the server drops.
|
||||
expect(VITE_CONFIG_SOURCE).toMatch(/reuseFromRunId\?:\s*string\s*;/);
|
||||
});
|
||||
|
||||
it("destructures reuseFromRunId from payload alongside filename/content/overrides", () => {
|
||||
expect(VITE_CONFIG_SOURCE).toMatch(
|
||||
/const\s*\{\s*filename\s*,\s*content\s*,\s*overrides\s*,\s*reuseFromRunId\s*\}\s*=\s*payload\s*;/,
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards --reuse-from <PREV_RUN_ID> after the override-section-assignment loop", () => {
|
||||
// Stage 2 contract: reuse_from is a pipeline mode, not an override.
|
||||
// The forward block must sit AFTER the last override loop so the spawn
|
||||
// argv preserves the order documented in the u1 backend post-merge
|
||||
// guard (overrides parsed first; reuse_from precondition runs against
|
||||
// the merged overrides view).
|
||||
const reuseFromIdx = VITE_CONFIG_SOURCE.indexOf('"--reuse-from"');
|
||||
const zoneSectionsIdx = VITE_CONFIG_SOURCE.indexOf(
|
||||
'"--override-section-assignment"',
|
||||
);
|
||||
expect(reuseFromIdx).toBeGreaterThan(-1);
|
||||
expect(zoneSectionsIdx).toBeGreaterThan(-1);
|
||||
expect(reuseFromIdx).toBeGreaterThan(zoneSectionsIdx);
|
||||
});
|
||||
|
||||
it("guards the forward with a truthy check on reuseFromRunId", () => {
|
||||
// Empty string / undefined ⇒ no flag pushed (Stage 2 contract: invalid
|
||||
// CLI args must never reach argparse — the backend u1 guard would
|
||||
// fail-closed with `reuse_artifact_missing` on the empty PREV_RUN_ID).
|
||||
const reuseFromIdx = VITE_CONFIG_SOURCE.indexOf('"--reuse-from"');
|
||||
expect(reuseFromIdx).toBeGreaterThan(-1);
|
||||
const preface = VITE_CONFIG_SOURCE.slice(
|
||||
Math.max(0, reuseFromIdx - 200),
|
||||
reuseFromIdx,
|
||||
);
|
||||
expect(preface).toMatch(/if\s*\(\s*reuseFromRunId/);
|
||||
expect(preface).toMatch(/typeof\s+reuseFromRunId\s*===\s*"string"/);
|
||||
});
|
||||
|
||||
it("pushes reuseFromRunId as the --reuse-from argument value (no string interpolation)", () => {
|
||||
// The CLI value must be the raw PREV_RUN_ID — no `=` join, no quoting
|
||||
// (spawn is shell:false). Mirrors the `--override-layout` shape.
|
||||
const reuseFromIdx = VITE_CONFIG_SOURCE.indexOf('"--reuse-from"');
|
||||
expect(reuseFromIdx).toBeGreaterThan(-1);
|
||||
// Window spans both before (`cliArgs.push(`) and after
|
||||
// (`reuseFromRunId)`) the literal so the full push expression is
|
||||
// captured.
|
||||
const window = VITE_CONFIG_SOURCE.slice(
|
||||
Math.max(0, reuseFromIdx - 100),
|
||||
reuseFromIdx + 200,
|
||||
);
|
||||
expect(window).toMatch(
|
||||
/cliArgs\.push\(\s*"--reuse-from"\s*,\s*reuseFromRunId\s*\)/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -305,19 +305,61 @@ describe("handleGetUserOverrides (IMP-52 u3)", () => {
|
||||
// IMP-52 u4 — PUT endpoint coverage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("KNOWN_USER_OVERRIDES_AXES (IMP-52 u4)", () => {
|
||||
describe("KNOWN_USER_OVERRIDES_AXES (IMP-52 u4 + IMP-56 #90 u3 allowlist sync)", () => {
|
||||
it("matches the Python KNOWN_AXES tuple in src/user_overrides_io.py", () => {
|
||||
// The on-disk schema is shared with backend pipeline fallback (u2).
|
||||
// Any drift here means a PUT could write an axis that the Python
|
||||
// load() ignores, or vice-versa, silently losing user overrides.
|
||||
// IMP-56 #90 u3 closes the prior `slide_css` gap (IMP-45 #74) and
|
||||
// pre-wires `text_overrides` (IMP-56 #90 u1) +
|
||||
// `structure_overrides` (IMP-56 #90 u2) — full 9-axis mirror of the
|
||||
// Python tuple, same order.
|
||||
expect(KNOWN_USER_OVERRIDES_AXES).toEqual([
|
||||
"layout",
|
||||
"zone_geometries",
|
||||
"zone_sections",
|
||||
"frames",
|
||||
"image_overrides",
|
||||
"slide_css",
|
||||
"manual_section_assignment",
|
||||
"text_overrides",
|
||||
"structure_overrides",
|
||||
]);
|
||||
});
|
||||
|
||||
it("includes the 3 axes added by IMP-56 #90 u3 (allowlist sync)", () => {
|
||||
// Spot-check the diff in addition to the full-equality assertion so a
|
||||
// future edit that drops one of the new axes fails with a localized
|
||||
// error rather than a 9-vs-N tuple-diff that obscures intent.
|
||||
expect(KNOWN_USER_OVERRIDES_AXES).toContain("slide_css");
|
||||
expect(KNOWN_USER_OVERRIDES_AXES).toContain("text_overrides");
|
||||
expect(KNOWN_USER_OVERRIDES_AXES).toContain("structure_overrides");
|
||||
expect(KNOWN_USER_OVERRIDES_AXES.length).toBe(9);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeUserOverrides (IMP-55 #93 u1) — manual_section_assignment bool axis", () => {
|
||||
it("merges bool true / false literally and clears on null", () => {
|
||||
// The PUT handler must treat the bool axis like any other allowlisted
|
||||
// axis: replace on write, preserve when absent, delete on null. Tests
|
||||
// both true→false flip and explicit null-clear so the backend (u9)
|
||||
// sees the exact frontend intent.
|
||||
let merged = mergeUserOverrides({}, { manual_section_assignment: true });
|
||||
expect(merged.manual_section_assignment).toBe(true);
|
||||
|
||||
merged = mergeUserOverrides(merged, { manual_section_assignment: false });
|
||||
expect(merged.manual_section_assignment).toBe(false);
|
||||
|
||||
merged = mergeUserOverrides(merged, { manual_section_assignment: null });
|
||||
expect("manual_section_assignment" in merged).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves bool axis when partial touches only a sibling axis", () => {
|
||||
const existing = { manual_section_assignment: true, layout: "old" };
|
||||
const merged = mergeUserOverrides(existing, { layout: "new" });
|
||||
expect(merged.manual_section_assignment).toBe(true);
|
||||
expect(merged.layout).toBe("new");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeUserOverrides (IMP-52 u4)", () => {
|
||||
|
||||
@@ -35,6 +35,8 @@ import {
|
||||
deriveUserOverridesKey,
|
||||
remapPersistedFramesToZoneFrames,
|
||||
saveImageOverride,
|
||||
saveTextOverride,
|
||||
saveStructureOverride,
|
||||
} from "../src/utils/slidePlanUtils";
|
||||
|
||||
// ─── Fixtures ───────────────────────────────────────────────────────────────
|
||||
@@ -54,6 +56,16 @@ function makeSelection(overrides?: Partial<UserSelection["overrides"]>): UserSel
|
||||
// axis declared on `UserSelection.overrides`. Empty by default so the
|
||||
// existing IMP-52 cases remain unchanged in shape.
|
||||
image_overrides: {},
|
||||
// IMP-55 (#93) u3 — bool intent marker is REQUIRED on
|
||||
// `UserSelection.overrides` (not optional). Default to `false` so every
|
||||
// pre-existing fixture matches the `createInitialUserSelection` seed
|
||||
// and stays compile-clean after u3 widened the type.
|
||||
manual_section_assignment: false,
|
||||
// IMP-56 (#90) u15 — keep the fixture in sync with the two Step-22
|
||||
// persist axes declared on `UserSelection.overrides`. Empty by
|
||||
// default so pre-existing cases retain their shape.
|
||||
text_overrides: {},
|
||||
structure_overrides: {},
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
@@ -460,3 +472,235 @@ describe("image_overrides axis — saveImageOverride (IMP-51 u11)", () => {
|
||||
expect(sel.overrides.image_overrides).toEqual(before);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── IMP-55 (#93) u3 — manual_section_assignment bool axis ──────────────────
|
||||
// Restore-on-reopen / seed coverage for the bool intent marker. Production
|
||||
// branch lives at `slidePlanUtils.ts` — `applyPersistedNonFrameOverrides`
|
||||
// guards with `typeof persisted.manual_section_assignment === "boolean"`,
|
||||
// and `createInitialUserSelection` seeds the axis to `false`. The marker
|
||||
// gates whether `handleGenerate` (u7) forwards `overrides.zoneSections`
|
||||
// to the backend; the pipeline (u9) consumes persisted `zone_sections`
|
||||
// only when the marker is exactly `true`, so any non-boolean payload MUST
|
||||
// end up `false` in memory (fail-closed).
|
||||
|
||||
describe("manual_section_assignment axis — applyPersistedNonFrameOverrides (IMP-55 #93 u3)", () => {
|
||||
it("restores literal true verbatim", () => {
|
||||
const sel = makeSelection();
|
||||
const next = applyPersistedNonFrameOverrides(sel, {
|
||||
manual_section_assignment: true,
|
||||
});
|
||||
expect(next.overrides.manual_section_assignment).toBe(true);
|
||||
});
|
||||
|
||||
it("restores literal false verbatim (u12 apply/cancel write must survive reopen)", () => {
|
||||
// Seed `true` so the assertion proves `false` overwrites; a truthiness
|
||||
// check instead of `typeof === \"boolean\"` would silently keep `true`
|
||||
// and resurrect stale auto-carry assignments as user intent.
|
||||
const sel = makeSelection({ manual_section_assignment: true });
|
||||
const next = applyPersistedNonFrameOverrides(sel, {
|
||||
manual_section_assignment: false,
|
||||
});
|
||||
expect(next.overrides.manual_section_assignment).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves the in-memory marker unchanged when the persisted axis is absent", () => {
|
||||
const sel = makeSelection({ manual_section_assignment: true });
|
||||
const next = applyPersistedNonFrameOverrides(sel, { layout: "horizontal-2" });
|
||||
expect(next.overrides.manual_section_assignment).toBe(true);
|
||||
expect(next.overrides.layout_preset).toBe("horizontal-2");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["null clear sentinel", null],
|
||||
['string "true"', "true"],
|
||||
['string "false"', "false"],
|
||||
["number 1", 1],
|
||||
["number 0", 0],
|
||||
["object {}", {}],
|
||||
["array []", []],
|
||||
])("ignores non-boolean payload (%s) — keeps prior in-memory value", (_label, payload) => {
|
||||
const sel = makeSelection({ manual_section_assignment: true });
|
||||
const next = applyPersistedNonFrameOverrides(sel, {
|
||||
manual_section_assignment: payload as unknown as boolean,
|
||||
});
|
||||
expect(next.overrides.manual_section_assignment).toBe(true);
|
||||
});
|
||||
|
||||
it("seeds an empty selection with manual_section_assignment=false (createInitialUserSelection)", () => {
|
||||
const sel = createInitialUserSelection();
|
||||
expect(sel.overrides.manual_section_assignment).toBe(false);
|
||||
});
|
||||
|
||||
it("returns a NEW selection object (no input mutation) when restoring the marker", () => {
|
||||
const sel = makeSelection({ manual_section_assignment: false });
|
||||
const next = applyPersistedNonFrameOverrides(sel, {
|
||||
manual_section_assignment: true,
|
||||
});
|
||||
expect(next).not.toBe(sel);
|
||||
expect(next.overrides).not.toBe(sel.overrides);
|
||||
// Input still pristine — proves the helper does not flip the fixture.
|
||||
expect(sel.overrides.manual_section_assignment).toBe(false);
|
||||
});
|
||||
|
||||
it("layers the bool axis alongside other persisted axes in a single call", () => {
|
||||
const sel = makeSelection();
|
||||
const next = applyPersistedNonFrameOverrides(sel, {
|
||||
layout: "vertical-2",
|
||||
zone_sections: { top: ["03-1"], bottom: ["03-2"] },
|
||||
manual_section_assignment: true,
|
||||
});
|
||||
expect(next.overrides.layout_preset).toBe("vertical-2");
|
||||
expect(next.overrides.zone_sections).toEqual({
|
||||
top: ["03-1"],
|
||||
bottom: ["03-2"],
|
||||
});
|
||||
expect(next.overrides.manual_section_assignment).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── IMP-56 (#90) u15 — text_overrides + structure_overrides axes ───────────
|
||||
// Pure helpers wired by Home.tsx into the SlideCanvas u13 focusout capture
|
||||
// (text) and u14 structure overlay emit (structure). Tests cover:
|
||||
// • saveTextOverride / saveStructureOverride immutability + merge semantics
|
||||
// • createInitialUserSelection seeding the two new axes empty
|
||||
// • applyPersistedNonFrameOverrides layering via the u10 extract helpers
|
||||
|
||||
describe("text_overrides axis — saveTextOverride (IMP-56 u15)", () => {
|
||||
it("records a fresh (zoneId, textPath, value) tuple", () => {
|
||||
const sel = makeSelection();
|
||||
const next = saveTextOverride(sel, "top", "row_1_left_body.0", "분석 결과");
|
||||
expect(next.overrides.text_overrides).toEqual({
|
||||
top: { "row_1_left_body.0": "분석 결과" },
|
||||
});
|
||||
});
|
||||
|
||||
it("merges within the same zone without erasing prior text_paths", () => {
|
||||
const sel = makeSelection({
|
||||
text_overrides: { top: { "row_1_left_body.0": "기존" } },
|
||||
});
|
||||
const next = saveTextOverride(sel, "top", "row_1_left_body.1", "신규");
|
||||
expect(next.overrides.text_overrides.top).toEqual({
|
||||
"row_1_left_body.0": "기존",
|
||||
"row_1_left_body.1": "신규",
|
||||
});
|
||||
});
|
||||
|
||||
it("overwrites the same textPath value within a zone", () => {
|
||||
const sel = makeSelection({
|
||||
text_overrides: { top: { "headline.0": "v1" } },
|
||||
});
|
||||
const next = saveTextOverride(sel, "top", "headline.0", "v2");
|
||||
expect(next.overrides.text_overrides.top).toEqual({ "headline.0": "v2" });
|
||||
});
|
||||
|
||||
it("does not mutate the input selection (immutable contract)", () => {
|
||||
const sel = makeSelection({
|
||||
text_overrides: { top: { "headline.0": "before" } },
|
||||
});
|
||||
saveTextOverride(sel, "top", "headline.0", "after");
|
||||
expect(sel.overrides.text_overrides).toEqual({
|
||||
top: { "headline.0": "before" },
|
||||
});
|
||||
});
|
||||
|
||||
it("seeds an empty text_overrides on a fresh selection", () => {
|
||||
const sel = createInitialUserSelection();
|
||||
expect(sel.overrides.text_overrides).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("structure_overrides axis — saveStructureOverride (IMP-56 u15)", () => {
|
||||
it("records a fresh (zoneId → {slot_order, hidden_slots}) tuple", () => {
|
||||
const sel = makeSelection();
|
||||
const next = saveStructureOverride(sel, "top", {
|
||||
slot_order: ["b", "a"],
|
||||
hidden_slots: ["c"],
|
||||
});
|
||||
expect(next.overrides.structure_overrides).toEqual({
|
||||
top: { slot_order: ["b", "a"], hidden_slots: ["c"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces an existing zone entry verbatim (no merge within zone)", () => {
|
||||
const sel = makeSelection({
|
||||
structure_overrides: { top: { slot_order: ["a", "b"], hidden_slots: [] } },
|
||||
});
|
||||
const next = saveStructureOverride(sel, "top", {
|
||||
slot_order: ["b", "a"],
|
||||
hidden_slots: ["a"],
|
||||
});
|
||||
expect(next.overrides.structure_overrides.top).toEqual({
|
||||
slot_order: ["b", "a"],
|
||||
hidden_slots: ["a"],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps unrelated zones intact when updating one zone", () => {
|
||||
const sel = makeSelection({
|
||||
structure_overrides: {
|
||||
top: { slot_order: ["x"], hidden_slots: [] },
|
||||
bottom_l: { slot_order: ["y"], hidden_slots: ["z"] },
|
||||
},
|
||||
});
|
||||
const next = saveStructureOverride(sel, "top", {
|
||||
slot_order: ["x", "x2"],
|
||||
hidden_slots: [],
|
||||
});
|
||||
expect(next.overrides.structure_overrides.bottom_l).toEqual({
|
||||
slot_order: ["y"],
|
||||
hidden_slots: ["z"],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not mutate the input perZone object after save", () => {
|
||||
const sel = makeSelection();
|
||||
const perZone = { slot_order: ["a"], hidden_slots: ["b"] };
|
||||
const next = saveStructureOverride(sel, "top", perZone);
|
||||
perZone.slot_order.push("MUTATED");
|
||||
expect(next.overrides.structure_overrides.top.slot_order).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("seeds an empty structure_overrides on a fresh selection", () => {
|
||||
const sel = createInitialUserSelection();
|
||||
expect(sel.overrides.structure_overrides).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Step-22 axes — applyPersistedNonFrameOverrides restore (IMP-56 u15)", () => {
|
||||
it("layers persisted text_overrides through the u10 extract helper", () => {
|
||||
const sel = makeSelection();
|
||||
const next = applyPersistedNonFrameOverrides(sel, {
|
||||
text_overrides: {
|
||||
top: { "row_1_left_body.0": "복원" },
|
||||
},
|
||||
});
|
||||
expect(next.overrides.text_overrides).toEqual({
|
||||
top: { "row_1_left_body.0": "복원" },
|
||||
});
|
||||
});
|
||||
|
||||
it("layers persisted structure_overrides through the u10 extract helper", () => {
|
||||
const sel = makeSelection();
|
||||
const next = applyPersistedNonFrameOverrides(sel, {
|
||||
structure_overrides: {
|
||||
top: { slot_order: ["b", "a"], hidden_slots: ["c"] },
|
||||
},
|
||||
});
|
||||
expect(next.overrides.structure_overrides).toEqual({
|
||||
top: { slot_order: ["b", "a"], hidden_slots: ["c"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("drops non-object payloads silently (no throw, axis stays empty)", () => {
|
||||
const sel = makeSelection();
|
||||
const next = applyPersistedNonFrameOverrides(sel, {
|
||||
text_overrides: "garbage" as unknown as Record<string, Record<string, string>>,
|
||||
structure_overrides: ["bad"] as unknown as Record<
|
||||
string,
|
||||
{ slot_order?: string[]; hidden_slots?: string[] }
|
||||
>,
|
||||
});
|
||||
expect(next.overrides.text_overrides).toEqual({});
|
||||
expect(next.overrides.structure_overrides).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -559,3 +559,67 @@ describe("saveUserOverrides (IMP-51 #79 u3) — image_overrides axis", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// IMP-55 #93 u1 — manual_section_assignment axis (7th axis) parity coverage
|
||||
//
|
||||
// The bool intent marker rides on the same per-axis coalescing rails as the
|
||||
// 6 sibling axes. These tests lock the typed client behavior so a regression
|
||||
// in the boolean serialization (e.g., coercion to "true" string, dropped
|
||||
// `false` due to truthy filtering) fails here instead of in Home.tsx (u6/u7)
|
||||
// or the backend gate (u9~u11).
|
||||
// ============================================================================
|
||||
|
||||
describe("saveUserOverrides (IMP-55 #93 u1) — manual_section_assignment axis", () => {
|
||||
it("PUT body carries only manual_section_assignment when it is the sole mutated axis", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03", { manual_section_assignment: true });
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
|
||||
const body = lastPutBody() as Record<string, unknown>;
|
||||
expect(Object.keys(body)).toEqual(["manual_section_assignment"]);
|
||||
expect(body.manual_section_assignment).toBe(true);
|
||||
});
|
||||
|
||||
it("later-wins coalesces true → false within a single debounce window", async () => {
|
||||
// Drag-then-cancel inside 300 ms — server must see only the final
|
||||
// `false`, not a transient `true` that would re-enable backend
|
||||
// consumption of stale zone_sections.
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03", { manual_section_assignment: true });
|
||||
void saveUserOverrides("03", { manual_section_assignment: false });
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
expect(putCallsCount()).toBe(1);
|
||||
expect(lastPutBody()).toEqual({ manual_section_assignment: false });
|
||||
});
|
||||
|
||||
it("forwards null sentinel verbatim (explicit clear)", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03", { manual_section_assignment: null });
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
expect(lastPutBody()).toEqual({ manual_section_assignment: null });
|
||||
});
|
||||
|
||||
it("coalesces with zone_sections sibling into a single PUT (drag-drop pair)", async () => {
|
||||
// Real-world drag flow (u6): one save() sets the bool + zone_sections
|
||||
// together. Asserts both axes survive coalescing as a single PUT body.
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03", {
|
||||
zone_sections: { left: ["03-2"], right: ["03-1"] },
|
||||
manual_section_assignment: true,
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
expect(putCallsCount()).toBe(1);
|
||||
expect(lastPutBody()).toEqual({
|
||||
zone_sections: { left: ["03-2"], right: ["03-1"] },
|
||||
manual_section_assignment: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,6 +85,22 @@ function sliceHandler(source: string, name: string): string {
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
/**
|
||||
* IMP-55 #93 u8 — strip JS/TS line + block comments so source-pattern
|
||||
* regex checks assert against LIVE code only. The u5 / u7 docblocks in
|
||||
* Home.tsx intentionally reference removed identifiers (e.g. `defaultByZone`,
|
||||
* `sameAsDefault`, `zoneSectionsDiff`) and the marker axis name in prose to
|
||||
* document the Stage 1 root cause for future readers — those references are
|
||||
* documentation, not behavior, and must not trigger negative-match guards.
|
||||
* Strips `// ...` to EOL and `/* ... */` (incl. multi-line) — keeps string
|
||||
* literals intact because we only consume the result for regex-match tests.
|
||||
*/
|
||||
function stripComments(source: string): string {
|
||||
return source
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/\/\/.*$/gm, "");
|
||||
}
|
||||
|
||||
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");
|
||||
@@ -567,3 +583,220 @@ describe("restore-on-reopen end-to-end (IMP-52 u10)", () => {
|
||||
expect(remapPersistedFramesToZoneFrames(plan, persisted.frames)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── IMP-55 #93 u8 — manual_section_assignment intent marker contract ─────
|
||||
// Verifies four axes of the marker contract introduced in u3 (type) / u5
|
||||
// (apply reset) / u6 (drag flip + co-PUT) / u7 (generate gate):
|
||||
// 1) Drag dual-axis persistence — handleSectionDrop persists BOTH
|
||||
// `zone_sections` AND `manual_section_assignment: true` in the SAME
|
||||
// PUT body (co-PUT atomicity — disk never sees post-drop zone_sections
|
||||
// without the marker).
|
||||
// 2) Apply / cancel reset — handleApplyPendingLayout writes explicit
|
||||
// `manual_section_assignment: false` after the `...overrides` spread,
|
||||
// and handleCancelPendingLayout relies on createInitialUserSelection
|
||||
// (which u3 seeds to `false`) to drop a prior `true`.
|
||||
// 3) Marker-gated forwarding — handleGenerate gates `overrides.zoneSections`
|
||||
// forwarding strictly on `manualMarker === true` (NOT truthiness, NOT
|
||||
// `!= null`, NOT presence). u3-seeded `false` and absent values both
|
||||
// skip forwarding.
|
||||
// 4) sameAsDefault NOT required — the Stage 1 anti-pattern (defaultByZone
|
||||
// / sameAsDefault / zoneSectionsDiff self-compare loop) is gone from
|
||||
// `handleGenerate` entirely; the marker is the source of intent.
|
||||
|
||||
describe("IMP-55 #93 u8 — manual_section_assignment marker contract", () => {
|
||||
it("handleSectionDrop sets marker true in-memory before persistence", () => {
|
||||
const block = sliceHandler(HOME_TSX, "handleSectionDrop");
|
||||
// finalSelection literal (built from zoneSelected, then marker = true)
|
||||
// must occur BEFORE the saveUserOverrides call so the in-memory state
|
||||
// and the PUT body source from the same overrides shape.
|
||||
const markerIdx = block.search(/manual_section_assignment:\s*true/);
|
||||
const saveIdx = block.search(/saveUserOverrides\(/);
|
||||
expect(markerIdx).toBeGreaterThan(-1);
|
||||
expect(saveIdx).toBeGreaterThan(-1);
|
||||
expect(markerIdx).toBeLessThan(saveIdx);
|
||||
});
|
||||
|
||||
it("handleSectionDrop co-PUTs zone_sections + manual_section_assignment:true (single body)", () => {
|
||||
const block = sliceHandler(HOME_TSX, "handleSectionDrop");
|
||||
// Single saveUserOverrides call carrying BOTH axes. The regex spans the
|
||||
// call body to prove the two keys live in the same object literal — a
|
||||
// future split into two PUTs would race the 300ms debounce and re-open
|
||||
// the IMP-55 stale-disk window.
|
||||
expect(block).toMatch(
|
||||
/saveUserOverrides\([\s\S]*?zone_sections:[\s\S]*?manual_section_assignment:\s*true[\s\S]*?\)/,
|
||||
);
|
||||
// Exactly ONE saveUserOverrides call in the handler.
|
||||
const calls = block.match(/saveUserOverrides\(/g) ?? [];
|
||||
expect(calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it("handleApplyPendingLayout resets the marker to false in overrides literal", () => {
|
||||
const block = sliceHandler(HOME_TSX, "handleApplyPendingLayout");
|
||||
// After spreading `...p.userSelection.overrides`, the explicit
|
||||
// `manual_section_assignment: false` overrides any prior-drag `true`.
|
||||
// Without this the layout flip would carry the marker through, and u7
|
||||
// would forward auto-carried assignments as user overrides → the
|
||||
// PARTIAL_COVERAGE regression that motivated IMP-55.
|
||||
expect(block).toMatch(/\.\.\.p\.userSelection\.overrides[\s\S]*?manual_section_assignment:\s*false/);
|
||||
});
|
||||
|
||||
it("handleCancelPendingLayout uses createInitialUserSelection (u3 seeds false)", () => {
|
||||
const block = sliceHandler(HOME_TSX, "handleCancelPendingLayout");
|
||||
// Cancel discards all pending in-memory edits via the fresh-selection
|
||||
// helper — the seed (u3) is the single source of truth for the
|
||||
// in-memory marker on this path. u12 adds a separate disk-side
|
||||
// saveUserOverrides PUT (covered by the u12 describe block below);
|
||||
// the in-memory userSelection literal still has no explicit marker
|
||||
// field — the seed handles it.
|
||||
expect(block).toMatch(/createInitialUserSelection\(p\.slidePlan\)/);
|
||||
// In-memory contract: no `manual_section_assignment` property appears
|
||||
// inside the userSelection assignment. The only marker reference in
|
||||
// live code lives inside the u12 saveUserOverrides(...) call body.
|
||||
const codeOnly = stripComments(block);
|
||||
expect(codeOnly).not.toMatch(
|
||||
/userSelection:[\s\S]*?manual_section_assignment/,
|
||||
);
|
||||
});
|
||||
|
||||
it("handleGenerate gates overrides.zoneSections on manualMarker === true (strict bool)", () => {
|
||||
const block = sliceHandler(HOME_TSX, "handleGenerate");
|
||||
// Marker read AND strict-equality gate. `===` not `==`, not truthiness,
|
||||
// not presence — so `false` / absent both skip forwarding (fail-closed).
|
||||
expect(block).toMatch(/state\.userSelection\.overrides\.manual_section_assignment/);
|
||||
expect(block).toMatch(/manualMarker\s*===\s*true/);
|
||||
// The assignment to `overrides.zoneSections` must live INSIDE the
|
||||
// marker-true branch.
|
||||
const gateIdx = block.search(/if\s*\(\s*manualMarker\s*===\s*true\s*\)/);
|
||||
const assignIdx = block.search(/overrides\.zoneSections\s*=/);
|
||||
expect(gateIdx).toBeGreaterThan(-1);
|
||||
expect(assignIdx).toBeGreaterThan(gateIdx);
|
||||
});
|
||||
|
||||
it("handleGenerate filters forwarded zone_sections to valid zone_ids only (cross-layout safety)", () => {
|
||||
const block = sliceHandler(HOME_TSX, "handleGenerate");
|
||||
// A stale persisted layout could carry zone_ids that do not exist in
|
||||
// the current sourcePlan (e.g. horizontal-2 `top`/`bottom` while the
|
||||
// current layout is vertical-2 `left`/`right`). Those foreign keys
|
||||
// must be dropped before reaching the backend `--override-section-
|
||||
// assignment` so they cannot trigger PARTIAL_COVERAGE.
|
||||
expect(block).toMatch(/validZoneIds\s*=\s*new Set\(\s*sourcePlan\.zones\.map\(\(z\)\s*=>\s*z\.zone_id\)/);
|
||||
expect(block).toMatch(/if\s*\(!validZoneIds\.has\(zoneId\)\)\s*continue/);
|
||||
});
|
||||
|
||||
it("handleGenerate no longer contains the IMP-08 B-3 self-compare anti-pattern", () => {
|
||||
// Strip comments — the u7 docblock intentionally references the removed
|
||||
// identifiers (`defaultByZone` / `sameAsDefault` / `zoneSectionsDiff`)
|
||||
// in prose to explain the Stage 1 root cause for future readers; the
|
||||
// regression we guard against is the LIVE code re-emerging.
|
||||
const block = stripComments(sliceHandler(HOME_TSX, "handleGenerate"));
|
||||
// The Stage 1 root cause: these identifiers compared user input against
|
||||
// itself (sourcePlan === effectiveSlidePlan → zones === pendingZones,
|
||||
// both derived from the same overrides.zone_sections). u7 deleted the
|
||||
// entire block.
|
||||
expect(block).not.toMatch(/\bdefaultByZone\b/);
|
||||
expect(block).not.toMatch(/\bsameAsDefault\b/);
|
||||
expect(block).not.toMatch(/\bzoneSectionsDiff\b/);
|
||||
});
|
||||
|
||||
it("co-PUT payload contract: marker=true + zone_sections land in a single PUT body", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
// Shape produced by handleSectionDrop after the u6 marker flip.
|
||||
void saveUserOverrides("03_demo", {
|
||||
zone_sections: { left: ["03-2"], right: ["03-1"] },
|
||||
manual_section_assignment: true,
|
||||
});
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const body = lastPutBody() as Record<string, unknown>;
|
||||
// Both axes in the same PUT body — exact equality, not arrayContaining,
|
||||
// because any extra axis would mean a foreign mutation leaked through.
|
||||
expect(Object.keys(body).sort()).toEqual(
|
||||
["manual_section_assignment", "zone_sections"].sort(),
|
||||
);
|
||||
expect(body.manual_section_assignment).toBe(true);
|
||||
expect(body.zone_sections).toEqual({ left: ["03-2"], right: ["03-1"] });
|
||||
});
|
||||
|
||||
it("co-PUT payload contract: marker=false carries explicitly through saveUserOverrides", async () => {
|
||||
// u12 will add the apply/cancel explicit `false` PUT; the typed client
|
||||
// must already propagate the literal `false` through the debounce
|
||||
// bucket. A truthiness-based coalesce in the bucket merge would drop
|
||||
// the value and re-open the stale-disk window. This locks the wire
|
||||
// contract independently of the u12 caller-site write.
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03_demo", { manual_section_assignment: false });
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
const body = lastPutBody() as Record<string, unknown>;
|
||||
expect(Object.keys(body)).toEqual(["manual_section_assignment"]);
|
||||
expect(body.manual_section_assignment).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── IMP-55 #93 u12 — stale-disk marker reset on apply / cancel ───────────
|
||||
// u5 resets the in-memory marker on layout apply, and u3's seed via
|
||||
// `createInitialUserSelection` resets it on cancel. But the disk persists
|
||||
// independently — a prior drag wrote `true` via u6's co-PUT, so after a
|
||||
// page reload the u3 restore branch would re-seed `true` and the u7 gate
|
||||
// would forward auto-carried section assignments → PARTIAL_COVERAGE
|
||||
// regression. u12 closes that window by writing `manual_section_assignment:
|
||||
// false` to disk via saveUserOverrides on both apply and cancel paths.
|
||||
describe("IMP-55 #93 u12 — stale-disk marker reset on layout apply/cancel", () => {
|
||||
it("handleApplyPendingLayout source contains a marker=false saveUserOverrides PUT", () => {
|
||||
const block = sliceHandler(HOME_TSX, "handleApplyPendingLayout");
|
||||
// Stripped-comment source so the u5 docblock prose doesn't satisfy the
|
||||
// assertion — must be a real call expression.
|
||||
const code = stripComments(block);
|
||||
// Uploaded-file gate (mirrors the u6 / other handler pattern — the
|
||||
// demo-mode initial render path must not PUT to an empty key).
|
||||
expect(code).toMatch(
|
||||
/if\s*\(\s*p\.uploadedFile\s*\)[\s\S]*?saveUserOverrides\([\s\S]*?manual_section_assignment:\s*false[\s\S]*?\)/,
|
||||
);
|
||||
expect(code).toMatch(/deriveUserOverridesKey\(p\.uploadedFile\.name\)/);
|
||||
});
|
||||
|
||||
it("handleCancelPendingLayout source contains a marker=false saveUserOverrides PUT", () => {
|
||||
const block = sliceHandler(HOME_TSX, "handleCancelPendingLayout");
|
||||
const code = stripComments(block);
|
||||
// Cancel handler converts from arrow-body to function-body for the
|
||||
// disk PUT; the in-memory reset still comes from createInitialUserSelection.
|
||||
expect(code).toMatch(
|
||||
/if\s*\(\s*p\.uploadedFile\s*\)[\s\S]*?saveUserOverrides\([\s\S]*?manual_section_assignment:\s*false[\s\S]*?\)/,
|
||||
);
|
||||
expect(code).toMatch(/createInitialUserSelection\(p\.slidePlan\)/);
|
||||
});
|
||||
|
||||
it("apply path PUT payload: marker=false carries alone (no auto-carry leakage)", async () => {
|
||||
// The apply handler issues a dedicated PUT for the marker reset that is
|
||||
// independent of the (conditional) zone_geometries PUT and of the
|
||||
// in-memory zone_sections rewrite. The wire contract for this PUT must
|
||||
// contain only the marker — if zone_sections leaked into the same body
|
||||
// it would re-arm the u9 backend fallback gate against u12's intent.
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03_demo", { manual_section_assignment: false });
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const body = lastPutBody() as Record<string, unknown>;
|
||||
expect(Object.keys(body)).toEqual(["manual_section_assignment"]);
|
||||
expect(body.manual_section_assignment).toBe(false);
|
||||
});
|
||||
|
||||
it("apply path PUT is unconditional (does NOT gate on hadPriorGeoms)", () => {
|
||||
// The u4 zone_geometries PUT inside handleApplyPendingLayout is
|
||||
// conditional (`p.uploadedFile && hadPriorGeoms`). The u12 marker PUT
|
||||
// must NOT inherit that gate — a stale disk `true` can exist without
|
||||
// any prior zone_geometries, so the reset must always fire.
|
||||
const code = stripComments(sliceHandler(HOME_TSX, "handleApplyPendingLayout"));
|
||||
// Locate the marker PUT and verify its enclosing `if` clause is just
|
||||
// `p.uploadedFile`, not the compound `... && hadPriorGeoms` guard.
|
||||
const markerCallMatch = code.match(
|
||||
/if\s*\(([^)]*)\)\s*\{[^}]*saveUserOverrides\([^)]*manual_section_assignment:\s*false[^)]*\)/,
|
||||
);
|
||||
expect(markerCallMatch).not.toBeNull();
|
||||
if (markerCallMatch) {
|
||||
expect(markerCallMatch[1].trim()).toBe("p.uploadedFile");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
// IMP-44 (#73) u3 — vitest coverage for `validateZoneGeometriesAgainstLayout`.
|
||||
//
|
||||
// Pairs with the backend [override-warning] guards added in u1 (1-D
|
||||
// horizontal-2 / vertical-2 branches of `build_layout_css`) and u2 (2-D
|
||||
// `_override_to_grid_tracks` call site). Same WARN+DROP unknown / KEEP known
|
||||
// contract; this helper lets handleGenerate (u4) validate against the active
|
||||
// layout before forwarding so the user sees a toast on dropped keys rather
|
||||
// than the backend silently even-splitting non-overridden zones with a false
|
||||
// `computation=user_override_geometry` signal.
|
||||
//
|
||||
// Cases (Stage 2 scope-lock):
|
||||
// 1) horizontal-2 → vertical-2 mismatch (all keys dropped)
|
||||
// 2) passthrough (all keys recognized)
|
||||
// 3) partial mix (some kept, some dropped)
|
||||
// 4) empty input ({} on a known layout)
|
||||
// 5) unknown-layout fail-safe (preset null / undefined / unknown string)
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { validateZoneGeometriesAgainstLayout } from "../src/utils/slidePlanUtils";
|
||||
|
||||
const g = (x: number, y: number, w: number, h: number) => ({ x, y, w, h });
|
||||
|
||||
describe("validateZoneGeometriesAgainstLayout (IMP-44 u3)", () => {
|
||||
// ── 1. mismatch ──────────────────────────────────────────────────────────
|
||||
it("drops horizontal-2 keys when the active layout is vertical-2", () => {
|
||||
const result = validateZoneGeometriesAgainstLayout(
|
||||
{ top: g(0, 0, 1, 0.4), bottom: g(0, 0.4, 1, 0.6) },
|
||||
"vertical-2",
|
||||
);
|
||||
expect(result.kept).toEqual({});
|
||||
expect(result.dropped).toEqual({
|
||||
top: g(0, 0, 1, 0.4),
|
||||
bottom: g(0, 0.4, 1, 0.6),
|
||||
});
|
||||
expect(result.expectedPositions).toEqual(["left", "right"]);
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it("drops vertical-2 keys when the active layout is horizontal-2", () => {
|
||||
const result = validateZoneGeometriesAgainstLayout(
|
||||
{ left: g(0, 0, 0.5, 1), right: g(0.5, 0, 0.5, 1) },
|
||||
"horizontal-2",
|
||||
);
|
||||
expect(result.kept).toEqual({});
|
||||
expect(Object.keys(result.dropped).sort()).toEqual(["left", "right"]);
|
||||
expect(result.expectedPositions).toEqual(["top", "bottom"]);
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
// ── 2. passthrough ───────────────────────────────────────────────────────
|
||||
it("keeps all keys when every input key is in the active layout positions", () => {
|
||||
const input = {
|
||||
top: g(0, 0, 1, 0.4),
|
||||
bottom: g(0, 0.4, 1, 0.6),
|
||||
};
|
||||
const result = validateZoneGeometriesAgainstLayout(input, "horizontal-2");
|
||||
expect(result.kept).toEqual(input);
|
||||
expect(result.dropped).toEqual({});
|
||||
expect(result.expectedPositions).toEqual(["top", "bottom"]);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("passes a single 'primary' key through on the 'single' preset", () => {
|
||||
const result = validateZoneGeometriesAgainstLayout(
|
||||
{ primary: g(0, 0, 1, 1) },
|
||||
"single",
|
||||
);
|
||||
expect(result.kept).toEqual({ primary: g(0, 0, 1, 1) });
|
||||
expect(result.dropped).toEqual({});
|
||||
expect(result.expectedPositions).toEqual(["primary"]);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("recognizes the 2-D preset positions reported by computeZonePositions (top-1-bottom-2)", () => {
|
||||
const input = {
|
||||
top: g(0, 0, 1, 0.5),
|
||||
"bottom-left": g(0, 0.5, 0.5, 0.5),
|
||||
"bottom-right": g(0.5, 0.5, 0.5, 0.5),
|
||||
};
|
||||
const result = validateZoneGeometriesAgainstLayout(input, "top-1-bottom-2");
|
||||
expect(result.kept).toEqual(input);
|
||||
expect(result.dropped).toEqual({});
|
||||
expect(result.expectedPositions).toEqual([
|
||||
"top",
|
||||
"bottom-left",
|
||||
"bottom-right",
|
||||
]);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
// ── 3. partial mix ───────────────────────────────────────────────────────
|
||||
it("keeps known keys and drops unknown keys on a partial-mix input", () => {
|
||||
const result = validateZoneGeometriesAgainstLayout(
|
||||
{ top: g(0, 0, 1, 0.4), foo: g(0, 0, 1, 1) },
|
||||
"horizontal-2",
|
||||
);
|
||||
expect(result.kept).toEqual({ top: g(0, 0, 1, 0.4) });
|
||||
expect(result.dropped).toEqual({ foo: g(0, 0, 1, 1) });
|
||||
expect(result.expectedPositions).toEqual(["top", "bottom"]);
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it("on a 2-D preset, keeps known 2-D track keys and drops legacy 1-D keys", () => {
|
||||
// Simulates the user resizing under top-1-bottom-2, then flipping to
|
||||
// grid-2x2 — legacy `bottom-left` stays valid; `top` (no longer a 2x2
|
||||
// position) gets dropped.
|
||||
const result = validateZoneGeometriesAgainstLayout(
|
||||
{
|
||||
top: g(0, 0, 1, 0.5),
|
||||
"bottom-left": g(0, 0.5, 0.5, 0.5),
|
||||
"top-left": g(0, 0, 0.5, 0.5),
|
||||
},
|
||||
"grid-2x2",
|
||||
);
|
||||
expect(result.kept).toEqual({
|
||||
"bottom-left": g(0, 0.5, 0.5, 0.5),
|
||||
"top-left": g(0, 0, 0.5, 0.5),
|
||||
});
|
||||
expect(result.dropped).toEqual({ top: g(0, 0, 1, 0.5) });
|
||||
expect(result.expectedPositions).toEqual([
|
||||
"top-left",
|
||||
"top-right",
|
||||
"bottom-left",
|
||||
"bottom-right",
|
||||
]);
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
// ── 4. empty input ───────────────────────────────────────────────────────
|
||||
it("returns empty kept/dropped and valid=true on an empty {} input", () => {
|
||||
const result = validateZoneGeometriesAgainstLayout({}, "horizontal-2");
|
||||
expect(result.kept).toEqual({});
|
||||
expect(result.dropped).toEqual({});
|
||||
expect(result.expectedPositions).toEqual(["top", "bottom"]);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("treats null / undefined geoms as empty input (no throw, valid=true on a known layout)", () => {
|
||||
const nullResult = validateZoneGeometriesAgainstLayout(null, "vertical-2");
|
||||
expect(nullResult.kept).toEqual({});
|
||||
expect(nullResult.dropped).toEqual({});
|
||||
expect(nullResult.expectedPositions).toEqual(["left", "right"]);
|
||||
expect(nullResult.valid).toBe(true);
|
||||
|
||||
const undefResult = validateZoneGeometriesAgainstLayout(
|
||||
undefined,
|
||||
"vertical-2",
|
||||
);
|
||||
expect(undefResult.kept).toEqual({});
|
||||
expect(undefResult.dropped).toEqual({});
|
||||
expect(undefResult.expectedPositions).toEqual(["left", "right"]);
|
||||
expect(undefResult.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores array payloads (defensive against hand-edited persisted files)", () => {
|
||||
const result = validateZoneGeometriesAgainstLayout(
|
||||
[] as unknown as Record<string, { x: number; y: number; w: number; h: number }>,
|
||||
"horizontal-2",
|
||||
);
|
||||
expect(result.kept).toEqual({});
|
||||
expect(result.dropped).toEqual({});
|
||||
expect(result.expectedPositions).toEqual(["top", "bottom"]);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
// ── 5. unknown-layout fail-safe ──────────────────────────────────────────
|
||||
it("drops every input key when layout is null (fail-safe)", () => {
|
||||
const result = validateZoneGeometriesAgainstLayout(
|
||||
{ top: g(0, 0, 1, 0.4), bottom: g(0, 0.4, 1, 0.6) },
|
||||
null,
|
||||
);
|
||||
expect(result.kept).toEqual({});
|
||||
expect(result.dropped).toEqual({
|
||||
top: g(0, 0, 1, 0.4),
|
||||
bottom: g(0, 0.4, 1, 0.6),
|
||||
});
|
||||
expect(result.expectedPositions).toEqual([]);
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it("drops every input key when layout is undefined (fail-safe)", () => {
|
||||
const result = validateZoneGeometriesAgainstLayout(
|
||||
{ primary: g(0, 0, 1, 1) },
|
||||
undefined,
|
||||
);
|
||||
expect(result.kept).toEqual({});
|
||||
expect(result.dropped).toEqual({ primary: g(0, 0, 1, 1) });
|
||||
expect(result.expectedPositions).toEqual([]);
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it("drops every input key when layout is an unknown preset string (fail-safe)", () => {
|
||||
const result = validateZoneGeometriesAgainstLayout(
|
||||
{ top: g(0, 0, 1, 0.4) },
|
||||
"rogue-preset" as unknown as string,
|
||||
);
|
||||
expect(result.kept).toEqual({});
|
||||
expect(result.dropped).toEqual({ top: g(0, 0, 1, 0.4) });
|
||||
expect(result.expectedPositions).toEqual([]);
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it("returns empty kept/dropped/expectedPositions when layout is unknown AND geoms is empty", () => {
|
||||
const result = validateZoneGeometriesAgainstLayout({}, null);
|
||||
expect(result.kept).toEqual({});
|
||||
expect(result.dropped).toEqual({});
|
||||
expect(result.expectedPositions).toEqual([]);
|
||||
// No keys to drop ⇒ vacuously valid; handleGenerate (u4) gates the toast
|
||||
// on `Object.keys(dropped).length > 0`, not `valid`, so this is safe.
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
// ── purity / mutation safety ─────────────────────────────────────────────
|
||||
it("does not mutate the input geometries object", () => {
|
||||
const input = { top: g(0, 0, 1, 0.4), foo: g(0, 0, 1, 1) };
|
||||
const inputKeysBefore = Object.keys(input).sort();
|
||||
validateZoneGeometriesAgainstLayout(input, "horizontal-2");
|
||||
expect(Object.keys(input).sort()).toEqual(inputKeysBefore);
|
||||
// Sample value still pristine.
|
||||
expect(input.top).toEqual(g(0, 0, 1, 0.4));
|
||||
});
|
||||
});
|
||||
+274
-6
@@ -219,19 +219,36 @@ function vitePluginStorageProxy(): Plugin {
|
||||
|
||||
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).
|
||||
// The nine in-scope axes — full mirror of KNOWN_AXES in
|
||||
// src/user_overrides_io.py. Order matches the Python tuple verbatim so
|
||||
// a side-by-side audit reads as a no-op. Any payload key outside this
|
||||
// allowlist is silently dropped by the PUT handler (u4) so the on-disk
|
||||
// schema cannot drift from the backend pipeline (u2) contract. Foreign
|
||||
// top-level keys already on disk are preserved verbatim (see
|
||||
// mergeUserOverrides).
|
||||
// IMP-51 (#79) u2: added `image_overrides` (image_id → {x,y,w,h}
|
||||
// percent-of-slide coordinates).
|
||||
// IMP-55 (#93) u1: added `manual_section_assignment` (bool intent marker
|
||||
// — drag-drop sets true, layout apply/cancel sets false).
|
||||
// IMP-56 (#90) u3: allowlist sync — closes the prior `slide_css` gap
|
||||
// (IMP-45 #74; the Step-22 slide CSS edit path will write it from the
|
||||
// frontend) and pre-wires `text_overrides` (IMP-56 #90 u1, keyed by
|
||||
// {zone_id: {text_path: value}}) + `structure_overrides` (IMP-56 #90 u2,
|
||||
// keyed by {zone_id: {slot_order, hidden_slots}} — scope LOCKED to slot
|
||||
// reorder + hide; frame swap stays on the existing `frames` axis to
|
||||
// preserve Phase Z's no-AI-HTML-structure invariant) so the Step-22
|
||||
// capture path (u10~u17) can PUT either axis without a follow-on
|
||||
// allowlist edit.
|
||||
export const KNOWN_USER_OVERRIDES_AXES = [
|
||||
"layout",
|
||||
"zone_geometries",
|
||||
"zone_sections",
|
||||
"frames",
|
||||
"image_overrides",
|
||||
"slide_css",
|
||||
"manual_section_assignment",
|
||||
"text_overrides",
|
||||
"structure_overrides",
|
||||
] as const;
|
||||
export type KnownUserOverridesAxis = (typeof KNOWN_USER_OVERRIDES_AXES)[number];
|
||||
|
||||
@@ -500,6 +517,211 @@ export function handlePutUserOverrides(
|
||||
return true;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// IMP-56 (#90) u18 — POST /api/connect : cel astro dev mirror copy.
|
||||
//
|
||||
// Body: {"run_id": "<id>", "slug": "<mdx-stem>"}.
|
||||
// • Copies <DESIGN_AGENT_ROOT>/data/runs/<run_id>/phase_z2/final.html →
|
||||
// <CEL_PROJECT_ROOT>/public/slides/<slug>.html (overwrite).
|
||||
// • If <run_dir>/phase_z2/assets/ exists, mirrors its contents into
|
||||
// <CEL_PROJECT_ROOT>/public/slides/assets/ (overwrite copy, recursive).
|
||||
// • run_id and slug are validated through the existing
|
||||
// isValidUserOverridesKey gate so path-traversal payloads are rejected.
|
||||
// =============================================================================
|
||||
|
||||
export function mirrorDirRecursive(srcDir: string, dstDir: string): number {
|
||||
if (!fs.existsSync(srcDir) || !fs.statSync(srcDir).isDirectory()) return 0;
|
||||
if (!fs.existsSync(dstDir)) fs.mkdirSync(dstDir, { recursive: true });
|
||||
let count = 0;
|
||||
for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
|
||||
const srcPath = path.join(srcDir, entry.name);
|
||||
const dstPath = path.join(dstDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
count += mirrorDirRecursive(srcPath, dstPath);
|
||||
} else if (entry.isFile()) {
|
||||
fs.copyFileSync(srcPath, dstPath);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
export function handleConnectMirror(
|
||||
req: PutReqLike,
|
||||
res: ResLike,
|
||||
designAgentRoot: string,
|
||||
celRoot: string,
|
||||
): boolean {
|
||||
if (req.method !== "POST") return false;
|
||||
let body = "";
|
||||
req.on("data", (chunk: Buffer | string) => {
|
||||
body += typeof chunk === "string" ? chunk : chunk.toString();
|
||||
});
|
||||
req.on("end", () => {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = body.length > 0 ? JSON.parse(body) : {};
|
||||
} catch {
|
||||
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ error: "invalid JSON" }));
|
||||
return;
|
||||
}
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ error: "body must be a JSON object" }));
|
||||
return;
|
||||
}
|
||||
const { run_id, slug } = parsed as { run_id?: unknown; slug?: unknown };
|
||||
if (typeof run_id !== "string" || typeof slug !== "string") {
|
||||
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ error: "missing run_id or slug" }));
|
||||
return;
|
||||
}
|
||||
if (!isValidUserOverridesKey(run_id) || !isValidUserOverridesKey(slug)) {
|
||||
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ error: "invalid run_id or slug" }));
|
||||
return;
|
||||
}
|
||||
const runDir = path.join(designAgentRoot, "data", "runs", run_id, "phase_z2");
|
||||
const srcHtml = path.join(runDir, "final.html");
|
||||
if (!fs.existsSync(srcHtml)) {
|
||||
res.writeHead(404, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ error: "final.html not found" }));
|
||||
return;
|
||||
}
|
||||
const dstSlidesDir = path.join(celRoot, "public", "slides");
|
||||
if (!fs.existsSync(dstSlidesDir)) fs.mkdirSync(dstSlidesDir, { recursive: true });
|
||||
const dstHtml = path.join(dstSlidesDir, `${slug}.html`);
|
||||
try {
|
||||
fs.copyFileSync(srcHtml, dstHtml);
|
||||
} catch (err) {
|
||||
res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ error: `copy failed: ${String(err)}` }));
|
||||
return;
|
||||
}
|
||||
const assetsCopied = mirrorDirRecursive(
|
||||
path.join(runDir, "assets"),
|
||||
path.join(dstSlidesDir, "assets"),
|
||||
);
|
||||
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ success: true, run_id, slug, html_target: dstHtml, assets_copied: assetsCopied }));
|
||||
});
|
||||
req.on("error", () => {
|
||||
res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ error: "request error" }));
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// IMP-56 (#90) u19 — POST /api/export : standalone HTML download.
|
||||
//
|
||||
// Body: {"run_id": "<id>"}.
|
||||
// • Reads <DESIGN_AGENT_ROOT>/data/runs/<run_id>/phase_z2/final.html.
|
||||
// • Inlines every `url(assets/<frame>/<file>)` reference (the only
|
||||
// external dep emitted by the Phase Z2 render path — verified by grep
|
||||
// against templates/phase_z2/slide_base.html and a representative run)
|
||||
// as a base64 data URL so the emitted HTML is portable (file:// open
|
||||
// or any external host, no co-located assets/ dir required). Mirrors
|
||||
// u18 validation: isValidUserOverridesKey gate for path-traversal
|
||||
// rejection; final.html missing → 404.
|
||||
// • Response: 200 text/html with Content-Disposition: attachment so the
|
||||
// browser triggers a download with `<run_id>.html` filename. Raw HTML
|
||||
// body (NOT JSON-wrapped) — the BottomActions wiring (u20) will pipe
|
||||
// the response body straight into a Blob → a[download] click chain
|
||||
// mirroring the existing serializeSlidePlan JSON download flow.
|
||||
// =============================================================================
|
||||
|
||||
export function inlineAssetsAsDataUrls(html: string, assetsRoot: string): string {
|
||||
// Match `url(assets/<rel-path>)` (with optional single/double quotes,
|
||||
// optional surrounding whitespace). The Phase Z2 render path emits
|
||||
// `url(assets/<frame>/<file>.png)` verbatim into inline `style="..."`
|
||||
// custom-property declarations (see slide_base.html `--card-frame-bg`
|
||||
// etc.) — there is no `<link rel="stylesheet">` or `<img src>` external
|
||||
// ref to handle. Keeping the matcher narrow avoids accidentally
|
||||
// rewriting `data:` / `http(s):` / sibling-path URLs that the render
|
||||
// path does not produce.
|
||||
const URL_RE = /url\(\s*(['"]?)assets\/([^)'"]+)\1\s*\)/g;
|
||||
return html.replace(URL_RE, (match, _quote: string, rel: string) => {
|
||||
const filePath = path.join(assetsRoot, rel);
|
||||
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) return match;
|
||||
const ext = path.extname(filePath).toLowerCase().slice(1);
|
||||
const mime =
|
||||
ext === "png" ? "image/png" :
|
||||
ext === "jpg" || ext === "jpeg" ? "image/jpeg" :
|
||||
ext === "svg" ? "image/svg+xml" :
|
||||
ext === "webp" ? "image/webp" :
|
||||
ext === "gif" ? "image/gif" :
|
||||
"application/octet-stream";
|
||||
const buf = fs.readFileSync(filePath);
|
||||
return `url("data:${mime};base64,${buf.toString("base64")}")`;
|
||||
});
|
||||
}
|
||||
|
||||
export function handleExportStandalone(
|
||||
req: PutReqLike,
|
||||
res: ResLike,
|
||||
designAgentRoot: string,
|
||||
): boolean {
|
||||
if (req.method !== "POST") return false;
|
||||
let body = "";
|
||||
req.on("data", (chunk: Buffer | string) => {
|
||||
body += typeof chunk === "string" ? chunk : chunk.toString();
|
||||
});
|
||||
req.on("end", () => {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = body.length > 0 ? JSON.parse(body) : {};
|
||||
} catch {
|
||||
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ error: "invalid JSON" }));
|
||||
return;
|
||||
}
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ error: "body must be a JSON object" }));
|
||||
return;
|
||||
}
|
||||
const { run_id } = parsed as { run_id?: unknown };
|
||||
if (typeof run_id !== "string") {
|
||||
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ error: "missing run_id" }));
|
||||
return;
|
||||
}
|
||||
if (!isValidUserOverridesKey(run_id)) {
|
||||
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ error: "invalid run_id" }));
|
||||
return;
|
||||
}
|
||||
const runDir = path.join(designAgentRoot, "data", "runs", run_id, "phase_z2");
|
||||
const srcHtml = path.join(runDir, "final.html");
|
||||
if (!fs.existsSync(srcHtml)) {
|
||||
res.writeHead(404, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ error: "final.html not found" }));
|
||||
return;
|
||||
}
|
||||
let html: string;
|
||||
try {
|
||||
html = fs.readFileSync(srcHtml, "utf-8");
|
||||
} catch (err) {
|
||||
res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ error: `read failed: ${String(err)}` }));
|
||||
return;
|
||||
}
|
||||
const inlined = inlineAssetsAsDataUrls(html, path.join(runDir, "assets"));
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="${run_id}.html"`,
|
||||
});
|
||||
res.end(inlined);
|
||||
});
|
||||
req.on("error", () => {
|
||||
res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ error: "request error" }));
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Phase Z API Plugin — MDX 업로드 → 파이프라인 실행 → 결과 노출
|
||||
//
|
||||
@@ -508,14 +730,19 @@ export function handlePutUserOverrides(
|
||||
// GET /data/runs/{run_id}/{path} → {DESIGN_AGENT_ROOT}/data/runs/{run_id}/phase_z2/{path}
|
||||
// GET /api/user-overrides/{key} → data/user_overrides/{key}.json (IMP-52 u3)
|
||||
// PUT /api/user-overrides/{key} → partial-merge save (IMP-52 u4)
|
||||
// POST /api/connect → cel mirror (IMP-56 #90 u18)
|
||||
// POST /api/export → standalone HTML download (IMP-56 #90 u19)
|
||||
//
|
||||
// 환경 변수 (선택) :
|
||||
// DESIGN_AGENT_ROOT python pipeline 실행 cwd. default = D:/ad-hoc/kei/design_agent
|
||||
// CEL_PROJECT_ROOT cel astro dev repo root. default = D:/ad-hoc/cel
|
||||
// =============================================================================
|
||||
|
||||
function vitePluginPhaseZApi(): Plugin {
|
||||
const DESIGN_AGENT_ROOT =
|
||||
process.env.DESIGN_AGENT_ROOT || "D:\\ad-hoc\\kei\\design_agent";
|
||||
const CEL_PROJECT_ROOT =
|
||||
process.env.CEL_PROJECT_ROOT || "D:\\ad-hoc\\cel";
|
||||
const UPLOADS_DIR = path.join(DESIGN_AGENT_ROOT, "samples", "uploads");
|
||||
const RUNS_DIR = path.join(DESIGN_AGENT_ROOT, "data", "runs");
|
||||
|
||||
@@ -543,6 +770,13 @@ function vitePluginPhaseZApi(): Plugin {
|
||||
// (e.g., "top": ["03-1-sub-1"]). Forwarded as --override-section-assignment.
|
||||
zoneSections?: Record<string, string[]>;
|
||||
};
|
||||
// IMP-43 (#72) u6 — optional PREV_RUN_ID to reuse Step 0/1/2/5/6
|
||||
// artifacts from a prior run and resume execution at Step 7.
|
||||
// Lives at the payload root (NOT under `overrides`) because the
|
||||
// backend u1 post-merge guard rejects most override axes when
|
||||
// --reuse-from is supplied. Absent / empty = full pipeline
|
||||
// (byte-identical to pre-u6 spawn).
|
||||
reuseFromRunId?: string;
|
||||
};
|
||||
try {
|
||||
payload = JSON.parse(body);
|
||||
@@ -554,7 +788,7 @@ function vitePluginPhaseZApi(): Plugin {
|
||||
return;
|
||||
}
|
||||
|
||||
const { filename, content, overrides } = payload;
|
||||
const { filename, content, overrides, reuseFromRunId } = payload;
|
||||
if (!filename || typeof content !== "string") {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
@@ -638,6 +872,19 @@ function vitePluginPhaseZApi(): Plugin {
|
||||
);
|
||||
}
|
||||
}
|
||||
// IMP-43 (#72) u6 — --reuse-from <PREV_RUN_ID> forward. Backend
|
||||
// (u1) parses this flag, validates the snapshot, copies Step
|
||||
// 0/1/2/5/6 artifacts from data/runs/<PREV_RUN_ID>/phase_z2 into
|
||||
// the new run_dir, and resumes execution at Step 7. The post-merge
|
||||
// guard at the same site rejects --override-layout /
|
||||
// --override-zone-geometry / --override-section-assignment /
|
||||
// --override-image with axis-named fail-closed exit; only
|
||||
// --override-frame (above) is preserved. Truthy check excludes
|
||||
// empty string + undefined so an invalid argument never reaches
|
||||
// argparse.
|
||||
if (reuseFromRunId && typeof reuseFromRunId === "string") {
|
||||
cliArgs.push("--reuse-from", reuseFromRunId);
|
||||
}
|
||||
console.log(
|
||||
`[phase-z-api] spawn pipeline: run_id=${runId}, mdx=${mdxPath}, args=${JSON.stringify(cliArgs.slice(2))}`
|
||||
);
|
||||
@@ -775,6 +1022,27 @@ function vitePluginPhaseZApi(): Plugin {
|
||||
next();
|
||||
});
|
||||
|
||||
// ── POST /api/connect → cel astro public/slides mirror ──
|
||||
// IMP-56 (#90) u18 — see handleConnectMirror docblock for body shape +
|
||||
// copy semantics. Logic lives in the pure helper so vitest can drive
|
||||
// it without booting vite.
|
||||
server.middlewares.use("/api/connect", (req, res, next) => {
|
||||
if (handleConnectMirror(req, res, DESIGN_AGENT_ROOT, CEL_PROJECT_ROOT)) return;
|
||||
next();
|
||||
});
|
||||
|
||||
// ── POST /api/export → standalone HTML download ──
|
||||
// IMP-56 (#90) u19 — see handleExportStandalone docblock for body
|
||||
// shape + inline-asset semantics. Logic lives in the pure helper
|
||||
// (handleExportStandalone + inlineAssetsAsDataUrls) so vitest can
|
||||
// drive it without booting vite. The response is raw text/html
|
||||
// (Content-Disposition: attachment); the u20 BottomActions wiring
|
||||
// will turn the response body into a Blob → a[download] click.
|
||||
server.middlewares.use("/api/export", (req, res, next) => {
|
||||
if (handleExportStandalone(req, res, DESIGN_AGENT_ROOT)) return;
|
||||
next();
|
||||
});
|
||||
|
||||
// ── GET /data/runs/{run_id}/{path} → {RUNS_DIR}/{run_id}/phase_z2/{path} ──
|
||||
server.middlewares.use("/data/runs", (req, res, next) => {
|
||||
if (req.method !== "GET") return next();
|
||||
|
||||
@@ -167,6 +167,42 @@ Step 0 (사전 준비) 의 Figma → HTML 변환은 *precondition phase 의 작
|
||||
|
||||
---
|
||||
|
||||
## 7. Multi-MDX regression markers (IMP-91)
|
||||
|
||||
> CI workflow `.github/workflows/multi-mdx-regression.yml` rewrites these via `scripts/update_status_board.py` after each push / PR. Initial value `?` = not yet observed. `PASS` / `FAIL` / `ERR` / `SKIP` = last CI run outcome per axis × mdx. Untouched markers remain `?` so collection failures are loud, not silent.
|
||||
|
||||
| axis | mdx 01 | mdx 02 | mdx 03 | mdx 04 | mdx 05 |
|
||||
|---|---|---|---|---|---|
|
||||
| F0 normalize | <!-- IMP-91:F0:01 -->?<!-- /IMP-91 --> | <!-- IMP-91:F0:02 -->?<!-- /IMP-91 --> | <!-- IMP-91:F0:03 -->?<!-- /IMP-91 --> | <!-- IMP-91:F0:04 -->?<!-- /IMP-91 --> | <!-- IMP-91:F0:05 -->?<!-- /IMP-91 --> |
|
||||
| F1 V4 ranking | <!-- IMP-91:F1:01 -->?<!-- /IMP-91 --> | <!-- IMP-91:F1:02 -->?<!-- /IMP-91 --> | <!-- IMP-91:F1:03 -->?<!-- /IMP-91 --> | <!-- IMP-91:F1:04 -->?<!-- /IMP-91 --> | <!-- IMP-91:F1:05 -->?<!-- /IMP-91 --> |
|
||||
| F2 slot_payload | <!-- IMP-91:F2:01 -->?<!-- /IMP-91 --> | <!-- IMP-91:F2:02 -->?<!-- /IMP-91 --> | <!-- IMP-91:F2:03 -->?<!-- /IMP-91 --> | <!-- IMP-91:F2:04 -->?<!-- /IMP-91 --> | <!-- IMP-91:F2:05 -->?<!-- /IMP-91 --> |
|
||||
| F3 classifier-only AI | <!-- IMP-91:F3:01 -->?<!-- /IMP-91 --> | <!-- IMP-91:F3:02 -->?<!-- /IMP-91 --> | <!-- IMP-91:F3:03 -->?<!-- /IMP-91 --> | <!-- IMP-91:F3:04 -->?<!-- /IMP-91 --> | <!-- IMP-91:F3:05 -->?<!-- /IMP-91 --> |
|
||||
| F4 layout | <!-- IMP-91:F4:01 -->?<!-- /IMP-91 --> | <!-- IMP-91:F4:02 -->?<!-- /IMP-91 --> | <!-- IMP-91:F4:03 -->?<!-- /IMP-91 --> | <!-- IMP-91:F4:04 -->?<!-- /IMP-91 --> | <!-- IMP-91:F4:05 -->?<!-- /IMP-91 --> |
|
||||
| F5 final.html | <!-- IMP-91:F5:01 -->?<!-- /IMP-91 --> | <!-- IMP-91:F5:02 -->?<!-- /IMP-91 --> | <!-- IMP-91:F5:03 -->?<!-- /IMP-91 --> | <!-- IMP-91:F5:04 -->?<!-- /IMP-91 --> | <!-- IMP-91:F5:05 -->?<!-- /IMP-91 --> |
|
||||
|
||||
---
|
||||
|
||||
## 8. IMP-43 (#72) `--reuse-from` measured savings
|
||||
|
||||
> Stage 2 §u8 binding contract: the issue-body 50–70% / 10–20s → 3–8s claim is **unverified** and is **not** mirrored here. Numbers below come from `scripts/measure_reuse_savings.py` on the project reference host; until that script is run and the values committed, every cell stays `TBD`.
|
||||
|
||||
| axis | value |
|
||||
|---|---|
|
||||
| measurement script | `scripts/measure_reuse_savings.py` |
|
||||
| reuse boundary (Stage 1 lock) | Step 0 / 1 / 2 / 5 / 6 only; Step 7+ re-executes |
|
||||
| full rerun seconds (p50) | TBD |
|
||||
| full rerun seconds (p95) | TBD |
|
||||
| reuse seconds (p50) | TBD |
|
||||
| reuse seconds (p95) | TBD |
|
||||
| reuse / full ratio (p50) | TBD |
|
||||
| last measured | TBD (date / host / mdx / iterations) |
|
||||
|
||||
Run protocol (per iteration): `(A)` seed → `(B)` full rerun with one self-discovered `--override-frame` pin → `(C)` `--reuse-from <seed>` with the same pin. The `(A)` seed time is reported separately and **not** included in the B-vs-C comparison — the reuse path's whole point is that the seed already exists from a prior interactive run.
|
||||
|
||||
Invocation: `python -m scripts.measure_reuse_savings samples/mdx_batch/02.mdx --iterations 5` (mdx is argv-driven; the script does not pin a sample internally).
|
||||
|
||||
---
|
||||
|
||||
## 사용 방법
|
||||
|
||||
- 새 작업 들어오면 → 본 board 의 *어느 step* 의 status 를 바꾸는 작업인지 식별
|
||||
|
||||
+134
-6
@@ -1009,6 +1009,9 @@ def build_context_pack(n, title, body, sid, agent, rnd, start_cnt, compact=None)
|
||||
# 검증 실패 보고서 (rewind 시 이전 실패 맥락 전달).
|
||||
# 2026-05-16 — issue state 의 failure_report_path 를 source-of-truth 로.
|
||||
# 모든 stage NO (test-verify/final-close 뿐 아니라 code-edit 등) 의 from_stage 캐치.
|
||||
# P7 (2026-05-26) — banned approaches injection (Codex CLI helper consensus).
|
||||
# failure_report 본문에서 known anti-pattern keyword 추출 → BANNED_APPROACHES block 생성
|
||||
# → 다음 round prompt 에 strong-marker 로 inject. 동일 방식 재제안 방지 (#84 round loop).
|
||||
failure_ctx = ""
|
||||
ist_fc = get_issue_state(n)
|
||||
fr_path_str = ist_fc.get("failure_report_path")
|
||||
@@ -1016,9 +1019,42 @@ def build_context_pack(n, title, body, sid, agent, rnd, start_cnt, compact=None)
|
||||
fail_path = Path(fr_path_str)
|
||||
if fail_path.exists():
|
||||
from_sid = ist_fc.get("failure_from_stage", "?")
|
||||
fail_body = fail_path.read_text(encoding='utf-8')
|
||||
# P7 — extract banned approach signals (deterministic keyword scan).
|
||||
# 각 entry: (regex, label, why). escape_hatch 는 future patch 의 JSON 구조 에서 형식화.
|
||||
# 현재 단계 = prompt-injection 만 (Codex 단계화 안의 "즉시 patch" layer).
|
||||
banned_signals = [
|
||||
(r"tests:\s*\[\s*\]",
|
||||
"tests: [] empty test list per implementation unit",
|
||||
"Orchestrator strict rule — 1 unit = impl + test inseparable. NOT allowed to defer tests to later units."),
|
||||
(r"@testing-library|jsdom|render\s*\(|screen\.",
|
||||
"DOM mount-based vitest (render() / screen / @testing-library)",
|
||||
"Front/package.json devDependencies has no jsdom / @testing-library/react. Mount-based tests cannot run."),
|
||||
(r"toast\.error\s*\(\s*formatAiRepairHumanReviewMessage",
|
||||
"Home.tsx formatAiRepairHumanReviewMessage toast.error removal",
|
||||
"Post-#92 commit 896f273 rewrote the formatter to operational-only channel. Removing toast call = operational alert regression."),
|
||||
(r"git\s+add\s+(-A|--all|\.)\b",
|
||||
"git add -A / git add . / git add --all",
|
||||
"Untracked artifact pollution risk. Stage 5 must add only files in unit's declared `files:` list explicitly."),
|
||||
]
|
||||
hits = []
|
||||
for pat, label, why in banned_signals:
|
||||
if re.search(pat, fail_body, re.IGNORECASE):
|
||||
hits.append((label, why))
|
||||
banned_block = ""
|
||||
if hits:
|
||||
banned_block = "\n=== BANNED APPROACHES (previously rejected — DO NOT REUSE) ===\n"
|
||||
for i, (label, why) in enumerate(hits, 1):
|
||||
banned_block += f"{i}. {label}\n reason: {why}\n"
|
||||
banned_block += (
|
||||
"BINDING: re-proposing any banned approach above = automatic FINAL_CONSENSUS: NO. "
|
||||
"If environment/preconditions changed (e.g., new package install), state the EVIDENCE "
|
||||
"of the change BEFORE re-proposal.\n"
|
||||
)
|
||||
failure_ctx = (
|
||||
f"\n\n=== REWIND: FAILURE REPORT (from {from_sid}) ===\n"
|
||||
f"{fail_path.read_text(encoding='utf-8')[:1500]}\n"
|
||||
f"{fail_body[:1500]}\n"
|
||||
f"{banned_block}"
|
||||
f"Fix the issues above before re-attempting.\n"
|
||||
)
|
||||
|
||||
@@ -1447,10 +1483,22 @@ def run_stage(n, title, body, sid):
|
||||
return (False, "unit with `tests: []` (forbidden — implementation + tests = same unit)")
|
||||
return (True, "")
|
||||
ok, reason = _iu_valid(last)
|
||||
if not ok:
|
||||
# current stage 의 comments 만 검색 (start_cnt 이후)
|
||||
# P7 (2026-05-26) — fallback skip when last YES body itself is invalid.
|
||||
# 이전: last invalid → comments[start_cnt:] 에서 valid block 찾아 구제 →
|
||||
# orchestrator 자기 supplement comment 의 Example block 이 valid 로 통과 (#84 round 5 슬립).
|
||||
# 변경: last 가 진짜 invalid 면 fallback 자체 skip. 단 last 의 _iu_valid 실패가
|
||||
# "block missing" 인 경우만 (Codex 가 YAML block 을 안 echo 한 경우) 이전 round 의
|
||||
# Claude plan 으로 fallback — 단 orchestrator-authored supplement 는 제외.
|
||||
if not ok and reason == "block missing":
|
||||
for c in comments[start_cnt:]:
|
||||
ok2, _ = _iu_valid(c.get("body", ""))
|
||||
body = c.get("body", "") or ""
|
||||
# exclude orchestrator-authored supplement comments (own example block trap)
|
||||
ls = body.lstrip()
|
||||
if ls.startswith("⚠️ **[Orchestrator]**") or \
|
||||
ls.startswith("📌 **[오케스트레이터]**") or \
|
||||
ls.startswith("ℹ️ **[Orchestrator]**"):
|
||||
continue
|
||||
ok2, _ = _iu_valid(body)
|
||||
if ok2:
|
||||
ok = True; break
|
||||
if not ok:
|
||||
@@ -1548,6 +1596,45 @@ def run_stage(n, title, body, sid):
|
||||
except: pass
|
||||
# Never `continue` — checker is informational only (Stage 1 guardrail).
|
||||
|
||||
# P7 (2026-05-26) — final-close YES casual self-contradiction inline guard.
|
||||
# parse_consensus 는 건드리지 않음 (다른 caller 영향 차단). YES 처리 block 안에서
|
||||
# sid == "final-close" 인 경우만 casual contradiction 검사.
|
||||
#
|
||||
# 설계 의도 분기 (Patch B 와 분담) :
|
||||
# - explicit `disposition: KEEP_OPEN_*` line 이 있으면 = 의도된 keep-open
|
||||
# → 이 guard 통과 → Patch B (close PATCH skip) 가 처리.
|
||||
# - explicit disposition line 없이 "NO close signal" 또는 "DO NOT CLOSE"
|
||||
# casual 표현 만 있으면 = self-contradiction → supplement + continue.
|
||||
#
|
||||
# cf. #83 IMP-83 case = YES + explicit `disposition: KEEP_OPEN_AS_UMBRELLA_ANCHOR`
|
||||
# → 통과 (Patch B 가 close skip).
|
||||
if sid == "final-close":
|
||||
has_explicit_disposition = bool(re.search(
|
||||
r"^\s*disposition\s*:\s*KEEP_OPEN",
|
||||
last, re.IGNORECASE | re.MULTILINE))
|
||||
if not has_explicit_disposition:
|
||||
casual_contradiction_patterns = [
|
||||
(r"NO\s+close\s+signal", "NO close signal"),
|
||||
(r"DO\s*NOT\s*CLOSE", "DO NOT CLOSE"),
|
||||
]
|
||||
hit = None
|
||||
for p, label in casual_contradiction_patterns:
|
||||
if re.search(p, last, re.IGNORECASE):
|
||||
hit = label; break
|
||||
if hit:
|
||||
log(f"⚠️ Stage 6 YES casual self-contradiction ({hit}) — supplement requested")
|
||||
try: gitea(f"issues/{n}/comments", "POST", {"body":
|
||||
f"⚠️ **[Orchestrator]** Stage 6 FINAL_CONSENSUS: YES rejected — casual self-contradiction.\n\n"
|
||||
f"YES marker 와 동시에 본문에 `{hit}` 등장 — 명시적 `disposition:` line 없음.\n\n"
|
||||
"Resolution:\n"
|
||||
" (a) If close intended → remove `{hit}` and re-state YES with close evidence.\n"
|
||||
" (b) If keep-open intended → add explicit line:\n"
|
||||
" `disposition: KEEP_OPEN_AS_UMBRELLA_ANCHOR` (or similar)\n"
|
||||
" then orchestrator will honor keep-open at close PATCH (Patch B).\n"
|
||||
" (c) Or switch to `FINAL_CONSENSUS: NO` with appropriate rewind_target."})
|
||||
except: pass
|
||||
continue
|
||||
|
||||
log(f"✅ {si['label']} — YES (evidence verified)")
|
||||
# stage 완료 = unit counter + remaining tracker 모두 reset
|
||||
update_issue_state(n, continue_same_count=0, last_remaining_units=None)
|
||||
@@ -1765,8 +1852,49 @@ def run_issue(n, until=None):
|
||||
continue_same_count=0, last_remaining_units=None)
|
||||
|
||||
if s["id"] == "final-close":
|
||||
try: gitea(f"issues/{n}", "PATCH", {"state": "closed"}); log("Closed")
|
||||
except: pass
|
||||
# P7 (2026-05-26) — KEEP_OPEN guard. Stage 6 exit body / last YES body 가 명시적
|
||||
# keep-open / no-close 신호 내면 close PATCH skip. body-level lock 이 있는 umbrella
|
||||
# anchor (#83 IMP-83 등) 보호 — Stage 6 성공 = "올바른 disposition 확정" 이며,
|
||||
# 그 disposition 이 KEEP_OPEN 일 수 있음.
|
||||
keep_open_patterns = [
|
||||
r"KEEP_OPEN_AS_UMBRELLA_ANCHOR",
|
||||
r"DO\s*NOT\s*CLOSE",
|
||||
r"disposition\s*:\s*KEEP_OPEN",
|
||||
r"^\s*action\s*:\s*NONE",
|
||||
r"^\s*state_after\s*:\s*open",
|
||||
r"NO\s+close\s+signal",
|
||||
]
|
||||
keep_open = False
|
||||
# P7a (2026-05-26) — fetch comments fresh; `comments` is loop-local in stage block
|
||||
# and not in scope at run_issue post-stage update. NameError fix.
|
||||
try:
|
||||
_cs = get_comments(n)
|
||||
last_body = _cs[-1].get("body", "") if _cs else ""
|
||||
except: last_body = ""
|
||||
for p in keep_open_patterns:
|
||||
if re.search(p, last_body, re.IGNORECASE | re.MULTILINE):
|
||||
keep_open = True; break
|
||||
if not keep_open:
|
||||
exit_path = _erp(n, "final-close")
|
||||
if exit_path.exists():
|
||||
try:
|
||||
exit_body = exit_path.read_text(encoding="utf-8", errors="ignore")
|
||||
for p in keep_open_patterns:
|
||||
if re.search(p, exit_body, re.IGNORECASE | re.MULTILINE):
|
||||
keep_open = True; break
|
||||
except: pass
|
||||
if keep_open:
|
||||
log(f"Stage 6 KEEP_OPEN signal — issue #{n} NOT closed (umbrella/governance anchor honored)")
|
||||
try: gitea(f"issues/{n}/comments", "POST", {"body":
|
||||
"ℹ️ **[Orchestrator]** Stage 6 KEEP_OPEN signal honored — issue not closed.\n\n"
|
||||
"Detected one of: `KEEP_OPEN_AS_UMBRELLA_ANCHOR`, `DO NOT CLOSE`, "
|
||||
"`disposition: KEEP_OPEN`, `action: NONE`, `state_after: open`, `NO close signal`.\n\n"
|
||||
"Orchestrator abstains from `PATCH state=closed` per user-decision-first lock. "
|
||||
"Final-close stage marked done; issue state preserved as `open`."})
|
||||
except: pass
|
||||
else:
|
||||
try: gitea(f"issues/{n}", "PATCH", {"state": "closed"}); log("Closed")
|
||||
except: pass
|
||||
|
||||
i += 1
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ dependencies = [
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-asyncio>=0.24",
|
||||
"pytest-json-report>=1.5",
|
||||
"ruff>=0.8",
|
||||
]
|
||||
|
||||
@@ -33,4 +34,5 @@ target-version = "py310"
|
||||
asyncio_mode = "auto"
|
||||
markers = [
|
||||
"integration: end-to-end pipeline integration tests (heavy; invoke Selenium)",
|
||||
"sweep: opt-in heavyweight sweep tests (IMP-43 u7b: 3 layouts × 3 mdx × frame-pin coverage). Invoke explicitly via `pytest -m sweep`; default CI must use `-m 'not sweep'`.",
|
||||
]
|
||||
|
||||
@@ -2,6 +2,17 @@
|
||||
title: DX 지연 요인
|
||||
sidebar:
|
||||
order: 03
|
||||
slide_overrides:
|
||||
css: |
|
||||
.slide-body {
|
||||
grid-template-rows: 0.38fr 0.60fr !important;
|
||||
gap: 1.5% !important;
|
||||
}
|
||||
.f29b__cell .text-line + .text-line { margin-top: 1px !important; }
|
||||
.f29b__cell:nth-child(n+3) {
|
||||
padding-top: 3px !important;
|
||||
margin-top: 2px !important;
|
||||
}
|
||||
---
|
||||
|
||||
## 1. DX에 대한 인식
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
"""IMP-43 (#72) u8 — measure ``--reuse-from`` wall-clock savings.
|
||||
|
||||
Argv-driven measurement helper for the Stage 2 §u8 binding contract:
|
||||
re-derive a realistic savings target instead of mirroring the
|
||||
unverified issue-body 50–70% / 10–20s → 3–8s claim.
|
||||
|
||||
Per-iteration measurement protocol (mirrors the u7a equivalence
|
||||
harness, ``tests/test_phase_z2_reuse_from_equivalence_unit.py``):
|
||||
|
||||
(A) baseline full run, no overrides — reuse seed
|
||||
(B) full rerun full run + one --override-frame pin — control path
|
||||
(C) reuse --reuse-from <seed> + same pin — reuse path
|
||||
|
||||
Wall-clock = ``time.perf_counter()`` around the subprocess.run call.
|
||||
The (A) seed run time is reported separately and NOT included in the
|
||||
B-vs-C comparison (the reuse path's whole point is that the seed
|
||||
already exists from a prior interactive run).
|
||||
|
||||
For each iteration the frame pin is self-discovered from the seed
|
||||
run's ``step06_composition_plan.json``: the first unit's
|
||||
``frame_template_id`` is re-pinned to itself, exercising the
|
||||
``--override-frame`` CLI surface end-to-end without changing the
|
||||
semantic frame assignment (same approach the u7a/u7b equivalence
|
||||
tests already lock).
|
||||
|
||||
Output: a JSON document to stdout with per-iteration timings,
|
||||
B/C p50 + p95, and the ratio C/B. Stderr carries the subprocess
|
||||
stdout/stderr tails on non-zero exits.
|
||||
|
||||
Guardrails (Stage 2):
|
||||
* argv-driven, no hardcoded mdx — caller picks the sample
|
||||
* no hardcoded savings target — TBD until measured
|
||||
* value + path + upstream provenance lives in the printed JSON
|
||||
* does NOT mutate prev_run_dir; new runs land under fresh run_ids
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import statistics
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
RUNS_DIR = REPO_ROOT / "data" / "runs"
|
||||
|
||||
|
||||
def _unique_run_id(prefix: str) -> str:
|
||||
return f"{prefix}_imp43_u8_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
def _spawn(extra_args: list[str], timeout: int) -> tuple[subprocess.CompletedProcess, float]:
|
||||
start = time.perf_counter()
|
||||
cp = subprocess.run(
|
||||
[sys.executable, "-m", "src.phase_z2_pipeline", *extra_args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
cwd=str(REPO_ROOT),
|
||||
)
|
||||
return cp, time.perf_counter() - start
|
||||
|
||||
|
||||
def _assert_ok(label: str, cp: subprocess.CompletedProcess) -> None:
|
||||
if cp.returncode != 0:
|
||||
sys.stderr.write(
|
||||
f"[measure_reuse_savings] {label} failed rc={cp.returncode}\n"
|
||||
f"--- stderr tail ---\n{cp.stderr[-2000:]}\n"
|
||||
f"--- stdout tail ---\n{cp.stdout[-2000:]}\n"
|
||||
)
|
||||
raise SystemExit(2)
|
||||
|
||||
|
||||
def _discover_first_frame_pin(seed_run_id: str) -> tuple[str, str]:
|
||||
p = RUNS_DIR / seed_run_id / "phase_z2" / "steps" / "step06_composition_plan.json"
|
||||
payload = json.loads(p.read_text(encoding="utf-8"))
|
||||
for u in payload.get("data", {}).get("selected_units") or []:
|
||||
sids = u.get("source_section_ids") or []
|
||||
tpl = u.get("frame_template_id")
|
||||
if isinstance(sids, list) and sids and isinstance(tpl, str) and tpl:
|
||||
return ("+".join(str(s) for s in sids), tpl)
|
||||
raise SystemExit(
|
||||
f"[measure_reuse_savings] seed {seed_run_id} step06 has no pinnable "
|
||||
f"(unit_id, frame_template_id); path={p}"
|
||||
)
|
||||
|
||||
|
||||
def _percentile(values: list[float], pct: float) -> float:
|
||||
if not values:
|
||||
return float("nan")
|
||||
if len(values) == 1:
|
||||
return values[0]
|
||||
s = sorted(values)
|
||||
k = (len(s) - 1) * pct
|
||||
lo = int(k)
|
||||
hi = min(lo + 1, len(s) - 1)
|
||||
return s[lo] + (s[hi] - s[lo]) * (k - lo)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
prog="python -m scripts.measure_reuse_savings",
|
||||
description="Measure IMP-43 --reuse-from wall-clock savings.",
|
||||
)
|
||||
ap.add_argument("mdx_path", type=Path, help="MDX sample to measure against")
|
||||
ap.add_argument("--iterations", type=int, default=3, help="trials (default 3)")
|
||||
ap.add_argument("--timeout", type=int, default=900, help="per-run timeout seconds")
|
||||
args = ap.parse_args()
|
||||
|
||||
if not args.mdx_path.is_file():
|
||||
sys.stderr.write(f"[measure_reuse_savings] mdx not found: {args.mdx_path}\n")
|
||||
return 2
|
||||
|
||||
iterations: list[dict] = []
|
||||
for i in range(args.iterations):
|
||||
seed_id = _unique_run_id(f"seed{i}")
|
||||
cp_a, t_a = _spawn([str(args.mdx_path), seed_id], args.timeout)
|
||||
_assert_ok(f"(A) seed iter={i}", cp_a)
|
||||
|
||||
unit_id, tpl_id = _discover_first_frame_pin(seed_id)
|
||||
override = ["--override-frame", f"{unit_id}={tpl_id}"]
|
||||
|
||||
full_id = _unique_run_id(f"full{i}")
|
||||
cp_b, t_b = _spawn([str(args.mdx_path), full_id, *override], args.timeout)
|
||||
_assert_ok(f"(B) full rerun iter={i}", cp_b)
|
||||
|
||||
reuse_id = _unique_run_id(f"reuse{i}")
|
||||
cp_c, t_c = _spawn(
|
||||
[str(args.mdx_path), reuse_id, "--reuse-from", seed_id, *override],
|
||||
args.timeout,
|
||||
)
|
||||
_assert_ok(f"(C) reuse iter={i}", cp_c)
|
||||
|
||||
iterations.append({
|
||||
"iter": i,
|
||||
"seed_run_id": seed_id,
|
||||
"full_run_id": full_id,
|
||||
"reuse_run_id": reuse_id,
|
||||
"override_frame": f"{unit_id}={tpl_id}",
|
||||
"seed_seconds": t_a,
|
||||
"full_rerun_seconds": t_b,
|
||||
"reuse_seconds": t_c,
|
||||
})
|
||||
|
||||
full_times = [it["full_rerun_seconds"] for it in iterations]
|
||||
reuse_times = [it["reuse_seconds"] for it in iterations]
|
||||
|
||||
summary = {
|
||||
"mdx_path": str(args.mdx_path),
|
||||
"iterations_count": len(iterations),
|
||||
"full_rerun_seconds_p50": _percentile(full_times, 0.50),
|
||||
"full_rerun_seconds_p95": _percentile(full_times, 0.95),
|
||||
"reuse_seconds_p50": _percentile(reuse_times, 0.50),
|
||||
"reuse_seconds_p95": _percentile(reuse_times, 0.95),
|
||||
"reuse_over_full_ratio_p50": (
|
||||
_percentile(reuse_times, 0.50) / _percentile(full_times, 0.50)
|
||||
if full_times and statistics.median(full_times) > 0
|
||||
else float("nan")
|
||||
),
|
||||
"iterations": iterations,
|
||||
"note": (
|
||||
"IMP-43 (#72) u8 measurement. Issue-body 50–70% / 10–20s → 3–8s "
|
||||
"claim is NOT honored here — actual numbers depend on host, "
|
||||
"Selenium cold-start, and AI cache state. Update "
|
||||
"docs/architecture/PHASE-Z-PIPELINE-STATUS-BOARD.md §8 with the "
|
||||
"p50/p95 reported here when run on the project's reference host."
|
||||
),
|
||||
}
|
||||
sys.stdout.write(json.dumps(summary, ensure_ascii=False, indent=2))
|
||||
sys.stdout.write("\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,75 @@
|
||||
"""IMP-#91 u14 — idempotent status-board marker updater.
|
||||
|
||||
Reads a pytest-json-report artifact emitted by the IMP-91 CI workflow and
|
||||
rewrites paired ``<!-- IMP-91:<axis>:<mdx> -->...<!-- /IMP-91 -->`` markers
|
||||
inside the Phase Z status board with a single-character outcome symbol.
|
||||
|
||||
Pure functions (``parse_outcomes`` / ``update_board_text``) are exposed so
|
||||
``tests/scripts/test_update_status_board.py`` can exercise the contract
|
||||
without invoking pytest. The CLI just wires file IO around them so the
|
||||
GitHub Actions step in u15 can call it deterministically. The updater is
|
||||
additive: untouched markers stay; missing outcomes render ``?`` so a
|
||||
collection failure is loud, not silent. [[feedback_auto_pipeline_first]]
|
||||
[[feedback_artifact_status_naming]]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Dict, Mapping, Tuple
|
||||
|
||||
AXIS_FROM_TEST = {
|
||||
"test_normalize_snapshot_matches": "F0",
|
||||
"test_v4_ranking_snapshot_matches": "F1",
|
||||
"test_slot_payload_snapshot_matches": "F2",
|
||||
"test_ai_classifier_snapshot_matches": "F3",
|
||||
"test_layout_snapshot_matches": "F4",
|
||||
"test_final_html_snapshot_matches": "F5",
|
||||
}
|
||||
SYMBOL = {"passed": "PASS", "failed": "FAIL", "error": "ERR", "skipped": "SKIP"}
|
||||
NODEID_RE = re.compile(r"::(test_[a-z0-9_]+)\[(\d{2})\]$")
|
||||
MARKER_RE = re.compile(
|
||||
r"(<!-- IMP-91:(F[0-5]):(\d{2}) -->)(.*?)(<!-- /IMP-91 -->)", re.DOTALL
|
||||
)
|
||||
|
||||
|
||||
def parse_outcomes(report: Mapping[str, object]) -> Dict[Tuple[str, str], str]:
|
||||
out: Dict[Tuple[str, str], str] = {}
|
||||
for test in report.get("tests", []) or []:
|
||||
m = NODEID_RE.search(str(test.get("nodeid", "")))
|
||||
if not m:
|
||||
continue
|
||||
axis = AXIS_FROM_TEST.get(m.group(1))
|
||||
if not axis:
|
||||
continue
|
||||
out[(axis, m.group(2))] = SYMBOL.get(str(test.get("outcome")), "?")
|
||||
return out
|
||||
|
||||
|
||||
def update_board_text(board: str, outcomes: Mapping[Tuple[str, str], str]) -> str:
|
||||
def repl(match: "re.Match[str]") -> str:
|
||||
key = (match.group(2), match.group(3))
|
||||
symbol = outcomes.get(key, "?")
|
||||
return f"{match.group(1)}{symbol}{match.group(5)}"
|
||||
|
||||
return MARKER_RE.sub(repl, board)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="IMP-91 status-board updater")
|
||||
parser.add_argument("--report", required=True, type=Path)
|
||||
parser.add_argument("--board", required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
report = json.loads(args.report.read_text(encoding="utf-8"))
|
||||
outcomes = parse_outcomes(report)
|
||||
args.board.write_text(
|
||||
update_board_text(args.board.read_text(encoding="utf-8"), outcomes),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -392,6 +392,32 @@ def _clean_text(text: str) -> str:
|
||||
# 메인 함수
|
||||
# ══════════════════════════════════════
|
||||
|
||||
def _extract_slide_overrides(metadata: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Surface the nested ``slide_overrides`` mapping from frontmatter.
|
||||
|
||||
IMP-45 (#74) u2 — slide-level CSS override axis intake. Returns a
|
||||
plain ``dict`` so callers (Step 13 injector) can read
|
||||
``slide_overrides.get("css")`` without re-parsing frontmatter.
|
||||
|
||||
Rules:
|
||||
- Absent or non-mapping → ``{}``.
|
||||
- Inside the mapping, ``css`` is kept only when it is a ``str``
|
||||
(non-string values dropped to fail-closed against typo'd YAML
|
||||
shapes such as ``css: [".x{}"]``).
|
||||
- Unknown sibling keys (e.g., future ``slide_overrides.js``) are
|
||||
preserved verbatim — generalization deferred per Stage 2 scope.
|
||||
"""
|
||||
raw = metadata.get("slide_overrides")
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
out: dict[str, Any] = {}
|
||||
for k, v in raw.items():
|
||||
if k == "css" and not isinstance(v, str):
|
||||
continue
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
def normalize_mdx_content(raw_mdx: str) -> dict[str, Any]:
|
||||
"""MDX 원본을 4-Layer 파서로 정규화.
|
||||
|
||||
@@ -405,11 +431,13 @@ def normalize_mdx_content(raw_mdx: str) -> dict[str, Any]:
|
||||
"popups": [{"title": str, "content": str}],
|
||||
"tables": [{"headers": list, "rows": list}],
|
||||
"sections": [{"level": int, "title": str, "content": str}],
|
||||
"slide_overrides": {"css": str, ...} | {},
|
||||
}
|
||||
"""
|
||||
# ── Layer 1: frontmatter 분리 ──
|
||||
metadata, body = frontmatter.parse(raw_mdx)
|
||||
title = metadata.get("title", "")
|
||||
slide_overrides = _extract_slide_overrides(metadata)
|
||||
logger.info(f"[Layer 1] title='{title}', metadata keys={list(metadata.keys())}")
|
||||
|
||||
# ── Layer 2: 코드블록 보호 → MDX 패턴 처리 ──
|
||||
@@ -437,6 +465,7 @@ def normalize_mdx_content(raw_mdx: str) -> dict[str, Any]:
|
||||
"popups": popups,
|
||||
"tables": tables,
|
||||
"sections": sections,
|
||||
"slide_overrides": slide_overrides,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -27,7 +27,9 @@ glue_compression (SPACING_GLUE envelope, frame-scoped)
|
||||
↓ 그래도 안 되면
|
||||
font_step_compression (FONT_SIZE_STEPS, zone-scoped)
|
||||
↓ 그래도 안 되면
|
||||
layout_adjust (zone topology 변경)
|
||||
layout_adjust (zone topology 변경 — 8-preset switch)
|
||||
↓ 그래도 안 되면
|
||||
frame_internal_fit_candidate (frame contract envelope 안 internal fit 변형)
|
||||
↓ 그래도 안 되면
|
||||
frame_reselect (V4 top-k 의 다른 frame)
|
||||
↓ 그래도 안 되면
|
||||
@@ -40,6 +42,33 @@ IMP-35 (#64) u2 — cascade terminal landed. `frame_reselect_insufficient`
|
||||
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.
|
||||
|
||||
IMP-88 (#88) u2 — Step 17 retry chain extension. Three new failure_type
|
||||
producers + cascade rows wire the three issue-body axes onto the deterministic
|
||||
chain WITHOUT activating any AI path or shared-margin shrink:
|
||||
|
||||
| failure_type | next_proposed_action |
|
||||
|---|---|
|
||||
| layout_adjust_insufficient | frame_internal_fit_candidate |
|
||||
| frame_internal_fit_candidate_insufficient | frame_reselect |
|
||||
| image_fit_insufficient | layout_adjust |
|
||||
|
||||
`layout_adjust_insufficient` is the cascade extension between
|
||||
`font_step_insufficient → layout_adjust` (existing) and the legacy
|
||||
`rerender_still_fails → frame_reselect` rejoin point — closing the open
|
||||
cascade tail that previously terminated salvage at `layout_adjust` with no
|
||||
next-step record. `frame_internal_fit_candidate_insufficient` rejoins the
|
||||
existing `frame_reselect` mid-cascade, so V4 top-k swap remains reachable
|
||||
after the in-envelope salvage exhausts. `image_fit_insufficient` (Step 17
|
||||
single-pass entry per u7) escalates onto the main cascade at `layout_adjust`
|
||||
so an image-driven overflow that cannot be fit inside the frame envelope
|
||||
benefits from layout topology change instead of any margin shrink
|
||||
(feedback_phase_z_spacing_direction guardrail).
|
||||
|
||||
The three new `next_action` destinations (`layout_adjust`,
|
||||
`frame_internal_fit_candidate`, `image_fit`) are advertised as MISSING here.
|
||||
The MISSING → IMPLEMENTED flip lives on the deterministic planner units
|
||||
(u3/u4/u5) in `src/phase_z2_retry.py`; this module owns mapping only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -85,6 +114,28 @@ FAILURE_TYPE_DESCRIPTIONS: dict[str, str] = {
|
||||
"'frame_reselect' AND passed=False AND post_salvage_overflow present. "
|
||||
"Routes to details_popup_escalation in u2 (cascade terminal)."
|
||||
),
|
||||
# IMP-88 (#88) u2 — three new salvage failure producers wired onto the
|
||||
# deterministic cascade. Classifier reuses the salvage_steps[-1] path
|
||||
# introduced in IMP-12 u2 (SALVAGE_FAILURE_TYPE_BY_ACTION).
|
||||
"layout_adjust_insufficient": (
|
||||
"layout_adjust salvage step failed — 8-preset layout switch executed "
|
||||
"but overflow persists post-rerender. Cascade exits onto "
|
||||
"frame_internal_fit_candidate (frame envelope internal fit variant) "
|
||||
"before V4 top-k frame_reselect."
|
||||
),
|
||||
"frame_internal_fit_candidate_insufficient": (
|
||||
"frame_internal_fit_candidate salvage step failed — variant adjustments "
|
||||
"inside the declared frame contract envelope could not absorb the "
|
||||
"remaining overflow. Cascade exits onto frame_reselect (V4 top-k "
|
||||
"alternate frame swap)."
|
||||
),
|
||||
"image_fit_insufficient": (
|
||||
"image_fit salvage step failed — Step 17 single-pass image fit "
|
||||
"(object-fit + max-w/h scoped to the offending frame) did not resolve "
|
||||
"image_aspect_mismatch. Escalates onto the main cascade at "
|
||||
"layout_adjust so a different layout topology can host the image "
|
||||
"natural ratio (no shared margin shrink — Phase Z spacing direction)."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -103,6 +154,17 @@ SALVAGE_FAILURE_TYPE_BY_ACTION: dict[str, str] = {
|
||||
# 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",
|
||||
# IMP-88 (#88) u2: producers for the three Step 17 retry chain actions
|
||||
# (layout_adjust / image_fit / frame_internal_fit_candidate). The u6
|
||||
# dispatcher (src/phase_z2_pipeline.py) appends salvage_steps entries with
|
||||
# these action names when their planner-driven executor (u3/u4/u5) emits
|
||||
# passed=False. The classifier path below already inspects
|
||||
# salvage_steps[-1].action so no classifier change is required; u3 just
|
||||
# registers the producer rows so the cascade keeps flowing instead of
|
||||
# falling through to the defensive "not_attempted" fallback.
|
||||
"layout_adjust": "layout_adjust_insufficient",
|
||||
"image_fit": "image_fit_insufficient",
|
||||
"frame_internal_fit_candidate": "frame_internal_fit_candidate_insufficient",
|
||||
}
|
||||
|
||||
|
||||
@@ -124,6 +186,15 @@ NEXT_ACTION_BY_FAILURE: dict[str, str] = {
|
||||
# only.
|
||||
"frame_reselect_insufficient": "details_popup_escalation",
|
||||
"not_attempted": "none",
|
||||
# IMP-88 (#88) u2 — Step 17 retry chain cascade extension. Closes the
|
||||
# previously open tail at layout_adjust + adds the frame_internal_fit
|
||||
# mid-cascade rejoin onto frame_reselect. image_fit (single-pass entry,
|
||||
# u7) escalates onto layout_adjust when its single-pass transform cannot
|
||||
# resolve image_aspect_mismatch — Phase Z spacing direction guardrail
|
||||
# routes through layout/frame instead of shrinking shared margins.
|
||||
"layout_adjust_insufficient": "frame_internal_fit_candidate",
|
||||
"frame_internal_fit_candidate_insufficient": "frame_reselect",
|
||||
"image_fit_insufficient": "layout_adjust",
|
||||
}
|
||||
|
||||
NEXT_ACTION_RATIONALE: dict[str, str] = {
|
||||
@@ -161,6 +232,22 @@ NEXT_ACTION_RATIONALE: dict[str, str] = {
|
||||
"not_attempted": (
|
||||
"retry 시도 자체가 없었음 (visual ok 등) — escalation 불필요"
|
||||
),
|
||||
# IMP-88 (#88) u2 — Step 17 retry chain cascade rationale entries
|
||||
"layout_adjust_insufficient": (
|
||||
"layout_adjust salvage (8-preset switch) 후에도 overflow 잔존 → "
|
||||
"frame_internal_fit_candidate 로 frame contract envelope 안 internal "
|
||||
"fit 변형 시도. frame_reselect (V4 top-k 다른 frame) 는 cascade 다음 단계."
|
||||
),
|
||||
"frame_internal_fit_candidate_insufficient": (
|
||||
"frame contract envelope 안 internal fit 변형 (density / line rhythm) "
|
||||
"도 overflow 못 흡수 → frame_reselect (V4 top-k 다른 frame) 로 escalate. "
|
||||
"popup 직행은 frame_reselect 까지 소진 후 (cascade terminal)."
|
||||
),
|
||||
"image_fit_insufficient": (
|
||||
"image_fit Step 17 single-pass (object-fit / max-w/h frame-scoped) 가 "
|
||||
"image_aspect_mismatch 못 해결 → layout_adjust 로 main cascade 진입. "
|
||||
"공통 image CSS / 공통 spacing 축소 X (Phase Z spacing direction)."
|
||||
),
|
||||
}
|
||||
|
||||
# 본 매핑이 가리키는 next action 들의 *현재 코드* 구현 상태
|
||||
@@ -174,7 +261,13 @@ NEXT_ACTION_IMPLEMENTATION_STATUS: dict[str, str] = {
|
||||
"cross_zone_redistribute": "IMPLEMENTED", # u4 plan_cross_zone_redistribute + apply_cross_zone_redistribute_css
|
||||
"glue_compression": "IMPLEMENTED", # u5 plan_glue_compression + apply_glue_compression_css
|
||||
"font_step_compression": "IMPLEMENTED", # u6 plan_font_step_compression + apply_font_step_compression_css
|
||||
"layout_adjust": "MISSING",
|
||||
# IMP-88 (#88) u1→u7 (2026-05-24): layout_adjust flips here on the
|
||||
# failure-router surface alongside the primary router surface. The
|
||||
# cascade entry chains font_step_insufficient → layout_adjust and
|
||||
# image_fit_insufficient → layout_adjust both reach this destination,
|
||||
# which is now wired end-to-end via u3 (plan_layout_adjust) + u6
|
||||
# (salvage dispatcher branch) + u7 (cascade entry trigger).
|
||||
"layout_adjust": "IMPLEMENTED",
|
||||
"frame_reselect": "MISSING",
|
||||
# IMP-35 (#64) u2 — cascade terminal advertised as MISSING here. The
|
||||
# router executor stub + MISSING→IMPLEMENTED flip lives in
|
||||
@@ -182,6 +275,18 @@ NEXT_ACTION_IMPLEMENTATION_STATUS: dict[str, str] = {
|
||||
# lands prevents premature "popup ready" claims from the failure-router
|
||||
# surface.
|
||||
"details_popup_escalation": "MISSING",
|
||||
# IMP-88 (#88) u1→u7 (2026-05-24): Step 17 retry chain destinations
|
||||
# flipped to IMPLEMENTED. frame_internal_fit_candidate is a cascade
|
||||
# destination (layout_adjust_insufficient → frame_internal_fit_candidate)
|
||||
# wired via u5 planner + u6 dispatcher branch + u7 cascade entry.
|
||||
# image_fit is a Step 17 single-pass entry wired via u4 planner +
|
||||
# u7 _attempt_step17_image_fit_single_pass; it also surfaces here so
|
||||
# route_retry_failure never returns 'unknown' when image_fit_insufficient
|
||||
# cascades onto layout_adjust. (Same precedent as IMP-12 u7 cascade
|
||||
# actions above — planner-surface availability + orchestrator wiring
|
||||
# together constitute IMPLEMENTED on the deterministic surface.)
|
||||
"frame_internal_fit_candidate": "IMPLEMENTED",
|
||||
"image_fit": "IMPLEMENTED",
|
||||
"none": "n/a",
|
||||
}
|
||||
|
||||
|
||||
+28
-4
@@ -579,12 +579,23 @@ def _build_compare_table_2col(section, units, contract) -> dict:
|
||||
|
||||
builder_options :
|
||||
item_parser : ITEM_PARSERS key (예: `compare_row_2col_item`)
|
||||
col_a_label_default : col_a header (MDX 미명시 시 fallback. F1-a fix)
|
||||
col_b_label_default : col_b header (MDX 미명시 시 fallback)
|
||||
col_a_label_default : col_a header literal in catalog.
|
||||
Semantics depend on col_a_label_default_role.
|
||||
col_a_label_default_role : "placeholder" | "fallback" (IMP-40 #69).
|
||||
placeholder = Figma visual placeholder; suppressed
|
||||
at runtime → col_a_label emitted as "".
|
||||
fallback = MDX 미명시 시 catalog literal 사용.
|
||||
absent = legacy contracts default to fallback.
|
||||
col_b_label_default : col_b header literal (same policy as col_a).
|
||||
col_b_label_default_role : same role discriminator for col_b (IMP-40 #69).
|
||||
strip_col_prefix_aliases : list[str] — col_a/col_b 값의 prefix `<alias>:`
|
||||
를 strip (Codex round 43 §F1-b — narrow alias).
|
||||
예 : ["BIM", "DX"]. default [] (no stripping).
|
||||
max_rows : N (default 999 — practical 한계).
|
||||
|
||||
NOTE: MDX 측 col_a_label / col_b_label inflow 경로 없음
|
||||
(compare_row_2col_item parser → {label,col_a,col_b}, _resolve_title → title only).
|
||||
placeholder role 은 col_*_label 을 빈 문자열로 확정 — 정책 결정점은 catalog 한 곳뿐.
|
||||
"""
|
||||
options = contract["payload"]["builder_options"]
|
||||
parser_name = options["item_parser"]
|
||||
@@ -595,8 +606,21 @@ def _build_compare_table_2col(section, units, contract) -> dict:
|
||||
f"but ITEM_PARSERS has no such entry."
|
||||
)
|
||||
|
||||
col_a_label = options.get("col_a_label_default", "")
|
||||
col_b_label = options.get("col_b_label_default", "")
|
||||
def _resolve_label_default(col_key: str) -> str:
|
||||
default_key = f"{col_key}_label_default"
|
||||
role_key = f"{col_key}_label_default_role"
|
||||
role = options.get(role_key, "fallback")
|
||||
if role == "placeholder":
|
||||
return ""
|
||||
if role == "fallback":
|
||||
return options.get(default_key, "")
|
||||
raise ValueError(
|
||||
f"Contract '{contract['template_id']}' builder_options.{role_key}='{role}' "
|
||||
f"is invalid; expected 'placeholder' or 'fallback' (IMP-40 #69)."
|
||||
)
|
||||
|
||||
col_a_label = _resolve_label_default("col_a")
|
||||
col_b_label = _resolve_label_default("col_b")
|
||||
strip_aliases = options.get("strip_col_prefix_aliases", []) or []
|
||||
max_rows = options.get("max_rows", 999)
|
||||
|
||||
|
||||
+2609
-729
File diff suppressed because it is too large
Load Diff
@@ -428,3 +428,377 @@ def apply_font_step_compression_css(plan: dict) -> str:
|
||||
return ""
|
||||
return (f'[data-zone-position="{zone_position}"] {{\n'
|
||||
f" font-size: {float(target_font_px):.1f}px;\n}}")
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# IMP-88 u3 : layout_adjust — Step 17 retry chain (8-preset topology swap).
|
||||
# Honors feedback_phase_z_spacing_direction: no shared margin / gap / slide-body
|
||||
# shrink. Cascade entry per u2: image_fit_insufficient → layout_adjust;
|
||||
# downstream per u2: layout_adjust_insufficient → frame_internal_fit_candidate.
|
||||
# Plan-only — dispatcher (u6) consumes new_layout_preset + new_zones_data and
|
||||
# rebuilds layout_css via apply_layout_adjust_layout_css(plan, gap_px).
|
||||
# ──────────────────────────────────────
|
||||
|
||||
|
||||
def _layout_swap_priority(current_topology: str, candidate_topology: str) -> int:
|
||||
"""Lower = preferred swap target. Honors topology-axis mirroring first."""
|
||||
pair = frozenset({current_topology, candidate_topology})
|
||||
if pair == frozenset({"rows", "cols"}):
|
||||
return 0
|
||||
if pair == frozenset({"T", "inverted-T"}):
|
||||
return 1
|
||||
if pair == frozenset({"side-T-left", "side-T-right"}):
|
||||
return 2
|
||||
return 3
|
||||
|
||||
|
||||
def plan_layout_adjust(
|
||||
*, current_layout_preset: str, zones_data: list[dict],
|
||||
) -> dict:
|
||||
"""Layout-preset switch plan (Step 17 retry chain — IMP-88 u3).
|
||||
|
||||
Finds a render-ready sibling preset (same candidate_when.unit_count) and
|
||||
remaps zone positions in catalog order. No common spacing shrink —
|
||||
feedback_phase_z_spacing_direction lock: escalate via layout topology only.
|
||||
"""
|
||||
from src.phase_z2_composition import LAYOUT_PRESETS
|
||||
base = {
|
||||
"action": "layout_adjust",
|
||||
"current_layout_preset": current_layout_preset,
|
||||
}
|
||||
current_spec = LAYOUT_PRESETS.get(current_layout_preset)
|
||||
if current_spec is None:
|
||||
return {
|
||||
**base, "feasible": False, "new_layout_preset": None,
|
||||
"candidates_considered": [],
|
||||
"failure_reason": (
|
||||
f"current_layout_preset '{current_layout_preset}' not in "
|
||||
f"LAYOUT_PRESETS catalog — cannot enumerate same-unit-count siblings."
|
||||
),
|
||||
}
|
||||
current_positions = list(current_spec.get("positions") or [])
|
||||
if len(zones_data) != len(current_positions):
|
||||
return {
|
||||
**base, "feasible": False, "new_layout_preset": None,
|
||||
"candidates_considered": [],
|
||||
"failure_reason": (
|
||||
f"zones_data length {len(zones_data)} != current preset "
|
||||
f"'{current_layout_preset}' positions {current_positions} — "
|
||||
f"cannot remap to a sibling preset."
|
||||
),
|
||||
}
|
||||
unit_count = (current_spec.get("candidate_when") or {}).get("unit_count")
|
||||
candidates = [
|
||||
pid for pid, spec in LAYOUT_PRESETS.items()
|
||||
if pid != current_layout_preset
|
||||
and spec.get("render_ready", False)
|
||||
and ((spec.get("candidate_when") or {}).get("unit_count") == unit_count)
|
||||
]
|
||||
base = {**base, "unit_count": unit_count,
|
||||
"candidates_considered": list(candidates)}
|
||||
if not candidates:
|
||||
return {
|
||||
**base, "feasible": False, "new_layout_preset": None,
|
||||
"failure_reason": (
|
||||
f"no render-ready 8-preset sibling for unit_count {unit_count} "
|
||||
f"(current='{current_layout_preset}'). single (1) and grid-2x2 (4) "
|
||||
f"have no swap target by catalog design."
|
||||
),
|
||||
}
|
||||
catalog_order = list(LAYOUT_PRESETS.keys())
|
||||
current_topo = current_spec.get("topology")
|
||||
candidates.sort(key=lambda pid: (
|
||||
_layout_swap_priority(current_topo, LAYOUT_PRESETS[pid].get("topology")),
|
||||
catalog_order.index(pid),
|
||||
))
|
||||
new_preset = candidates[0]
|
||||
new_positions = list(LAYOUT_PRESETS[new_preset].get("positions") or [])
|
||||
new_zones_data = [
|
||||
{**zd, "position": new_positions[i]} for i, zd in enumerate(zones_data)
|
||||
]
|
||||
return {
|
||||
**base,
|
||||
"feasible": True,
|
||||
"new_layout_preset": new_preset,
|
||||
"swap_topology_from": current_topo,
|
||||
"swap_topology_to": LAYOUT_PRESETS[new_preset].get("topology"),
|
||||
"position_remap": dict(zip(current_positions, new_positions)),
|
||||
"new_zones_data": new_zones_data,
|
||||
}
|
||||
|
||||
|
||||
def apply_layout_adjust_layout_css(plan: dict, gap_px: int) -> Optional[dict]:
|
||||
"""Build a fresh layout_css dict for the swapped preset.
|
||||
|
||||
Returns None when plan is infeasible. Dispatcher (u6) re-renders with
|
||||
render_slide(zones_data=plan['new_zones_data'], layout_preset=plan
|
||||
['new_layout_preset'], layout_css=<this return>, gap_px=gap_px).
|
||||
"""
|
||||
if not plan.get("feasible"):
|
||||
return None
|
||||
new_preset = plan.get("new_layout_preset")
|
||||
new_zones_data = plan.get("new_zones_data") or []
|
||||
if not new_preset or not new_zones_data:
|
||||
return None
|
||||
from src.phase_z2_pipeline import build_layout_css
|
||||
new_layout_css = dict(build_layout_css(new_preset, new_zones_data, gap=gap_px))
|
||||
raw = dict(new_layout_css.get("raw_zone_layout") or {})
|
||||
raw["layout_adjust_applied"] = True
|
||||
raw["layout_adjust_from"] = plan.get("current_layout_preset")
|
||||
raw["layout_adjust_to"] = new_preset
|
||||
new_layout_css["raw_zone_layout"] = raw
|
||||
return new_layout_css
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# IMP-88 u4 : image_fit — Step 17 single-pass image-scoped CSS override.
|
||||
# Consumes overflow_metrics.image_events directly (natural_w/h, rendered_w/h,
|
||||
# natural_ratio, rendered_ratio, delta). Honors feedback_phase_z_spacing
|
||||
# _direction — image-scoped CSS only, no shared margin / frame envelope shrink.
|
||||
# Plan-only — Step 17 entry (u7) is the runtime caller.
|
||||
# Default delta_tol mirrors src.phase_z2_pipeline.IMAGE_ASPECT_DELTA_TOL = 0.05;
|
||||
# overridable arg keeps tests free of the pipeline import cycle.
|
||||
# ──────────────────────────────────────
|
||||
|
||||
|
||||
def plan_image_fit(
|
||||
*, image_event: dict, delta_tol: float = 0.05,
|
||||
) -> dict:
|
||||
"""Image_fit planner (Step 17 retry chain — IMP-88 u4).
|
||||
|
||||
Emits frame-scoped object-fit + max-width/height from a single
|
||||
image_event (overflow_metrics.image_events). Image-scoped only.
|
||||
"""
|
||||
base = {
|
||||
"action": "image_fit",
|
||||
"src": image_event.get("src"),
|
||||
"zone_position": image_event.get("zone_position"),
|
||||
"zone_template_id": image_event.get("zone_template_id"),
|
||||
}
|
||||
delta = image_event.get("delta")
|
||||
if delta is None:
|
||||
return {
|
||||
**base, "feasible": False, "css_overrides": None,
|
||||
"failure_reason": (
|
||||
"image_event delta is None — image not loaded; no aspect "
|
||||
"mismatch can be measured."
|
||||
),
|
||||
}
|
||||
if abs(float(delta)) <= float(delta_tol):
|
||||
return {
|
||||
**base, "feasible": False, "css_overrides": None,
|
||||
"delta": float(delta),
|
||||
"failure_reason": (
|
||||
f"|delta|={abs(float(delta)):.4f} <= delta_tol={delta_tol} — "
|
||||
f"no image_aspect_mismatch to correct (planner no-op)."
|
||||
),
|
||||
}
|
||||
rendered_w = image_event.get("rendered_w")
|
||||
rendered_h = image_event.get("rendered_h")
|
||||
if not (isinstance(rendered_w, (int, float)) and rendered_w > 0
|
||||
and isinstance(rendered_h, (int, float)) and rendered_h > 0):
|
||||
return {
|
||||
**base, "feasible": False, "css_overrides": None,
|
||||
"delta": float(delta),
|
||||
"failure_reason": (
|
||||
"image_event missing positive rendered_w / rendered_h — "
|
||||
"cannot bound max-width / max-height for image-scoped CSS."
|
||||
),
|
||||
}
|
||||
return {
|
||||
**base,
|
||||
"feasible": True,
|
||||
"delta": float(delta),
|
||||
"natural_ratio": image_event.get("natural_ratio"),
|
||||
"rendered_ratio": image_event.get("rendered_ratio"),
|
||||
"natural_w": image_event.get("natural_w"),
|
||||
"natural_h": image_event.get("natural_h"),
|
||||
"rendered_w": int(rendered_w),
|
||||
"rendered_h": int(rendered_h),
|
||||
# delta > 0 ⇒ rendered_ratio > natural_ratio ⇒ rendered too wide ⇒
|
||||
# width axis correction; delta < 0 ⇒ height axis correction.
|
||||
"correction_axis": "width" if float(delta) > 0 else "height",
|
||||
"css_overrides": {
|
||||
"object_fit": "contain",
|
||||
"max_width_px": int(rendered_w),
|
||||
"max_height_px": int(rendered_h),
|
||||
"width": "auto",
|
||||
"height": "auto",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def apply_image_fit_css(plan: dict) -> Optional[str]:
|
||||
"""Build a frame-scoped CSS snippet from a feasible image_fit plan.
|
||||
|
||||
Returns None when plan is infeasible. u7 (Step 17 entry) injects the
|
||||
snippet into the per-slide style override and re-renders.
|
||||
"""
|
||||
if not plan.get("feasible"):
|
||||
return None
|
||||
overrides = plan.get("css_overrides") or {}
|
||||
if not overrides:
|
||||
return None
|
||||
src = plan.get("src") or ""
|
||||
zone_position = plan.get("zone_position") or ""
|
||||
if src:
|
||||
selector = (
|
||||
f".zone[data-zone-position=\"{zone_position}\"] "
|
||||
f"img[src=\"{src}\"]"
|
||||
)
|
||||
else:
|
||||
selector = f".zone[data-zone-position=\"{zone_position}\"] img"
|
||||
return (
|
||||
f"{selector} {{\n"
|
||||
f" object-fit: {overrides.get('object_fit', 'contain')};\n"
|
||||
f" max-width: {int(overrides.get('max_width_px') or 0)}px;\n"
|
||||
f" max-height: {int(overrides.get('max_height_px') or 0)}px;\n"
|
||||
f" width: {overrides.get('width', 'auto')};\n"
|
||||
f" height: {overrides.get('height', 'auto')};\n"
|
||||
f"}}"
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# IMP-88 u5 : frame_internal_fit_candidate — Step 17 retry chain.
|
||||
# Operates ONLY inside the frame contract's declared `internal_envelope`
|
||||
# (PHASE-Z-PIPELINE-OVERVIEW.md:333 lock). Sub-mechanism names allowed by
|
||||
# the OVERVIEW: density envelope / line rhythm / internal grid row / text
|
||||
# block allocation — all unified under the single action label
|
||||
# `frame_internal_fit_candidate` so common-CSS/padding shrink antipatterns
|
||||
# stay quarantined ([[feedback_phase_z_spacing_direction]]).
|
||||
#
|
||||
# Envelope shape (dormant — catalog adds when contracts declare it):
|
||||
# frame_contract["internal_envelope"] = {
|
||||
# "variants": [
|
||||
# {"name": "<sub_mechanism_name>",
|
||||
# "excess_budget_px": <int — px of vertical excess this variant can absorb>,
|
||||
# "css_overrides": {<css-property>: <value>, ...}},
|
||||
# ...
|
||||
# ]
|
||||
# }
|
||||
# Selection = walk variants in catalog order, pick first whose excess_budget_px
|
||||
# >= effective excess_y (greedy). Catalog order = catalog author's priority.
|
||||
#
|
||||
# No frame contract currently declares `internal_envelope`, so the planner
|
||||
# returns infeasible(envelope_present=False) for every live frame today.
|
||||
# Cascade hand-off: NEXT_ACTION_BY_FAILURE['frame_internal_fit_candidate_
|
||||
# insufficient'] = 'frame_reselect' (set in u2). Plan-only — u6 (salvage
|
||||
# dispatcher) and u7 (Step 17 entry) own the runtime call site.
|
||||
#
|
||||
# frame_contract is an overridable kwarg so tests don't pay the mapper
|
||||
# catalog cache cost / pipeline import cycle (mirrors u4's delta_tol).
|
||||
# ──────────────────────────────────────
|
||||
|
||||
|
||||
def plan_frame_internal_fit_candidate(
|
||||
*, frame_template_id: str,
|
||||
frame_contract: Optional[dict] = None,
|
||||
overflow_zone: Optional[dict] = None,
|
||||
) -> dict:
|
||||
"""frame_internal_fit_candidate planner (Step 17 retry chain — IMP-88 u5).
|
||||
|
||||
Walks `frame_contract['internal_envelope']['variants']` in catalog order
|
||||
and picks the first variant whose excess_budget_px covers `overflow_zone
|
||||
['excess_y']`. Returns infeasible when no contract / no envelope / no
|
||||
variant fits. No common-margin shrink — sub-mechanism CSS is frame-scoped.
|
||||
"""
|
||||
base = {
|
||||
"action": "frame_internal_fit_candidate",
|
||||
"frame_template_id": frame_template_id,
|
||||
}
|
||||
if frame_contract is None:
|
||||
from src.phase_z2_mapper import get_contract
|
||||
frame_contract = get_contract(frame_template_id)
|
||||
if frame_contract is None:
|
||||
return {
|
||||
**base, "feasible": False, "envelope_present": False,
|
||||
"candidates_considered": [], "selected_variant": None,
|
||||
"css_overrides": None,
|
||||
"failure_reason": (
|
||||
f"no frame contract registered for template_id "
|
||||
f"'{frame_template_id}' — cannot enumerate internal_envelope."
|
||||
),
|
||||
}
|
||||
envelope = frame_contract.get("internal_envelope")
|
||||
if not isinstance(envelope, dict):
|
||||
return {
|
||||
**base, "feasible": False, "envelope_present": False,
|
||||
"candidates_considered": [], "selected_variant": None,
|
||||
"css_overrides": None,
|
||||
"failure_reason": (
|
||||
f"frame contract '{frame_template_id}' does not declare "
|
||||
f"internal_envelope — cascade should escalate to frame_reselect."
|
||||
),
|
||||
}
|
||||
variants = list(envelope.get("variants") or [])
|
||||
candidates_considered = [v.get("name") for v in variants if isinstance(v, dict)]
|
||||
if not variants:
|
||||
return {
|
||||
**base, "feasible": False, "envelope_present": True,
|
||||
"envelope_keys": sorted(envelope.keys()),
|
||||
"candidates_considered": candidates_considered,
|
||||
"selected_variant": None, "css_overrides": None,
|
||||
"failure_reason": (
|
||||
f"frame contract '{frame_template_id}' internal_envelope "
|
||||
f"declares no variants — no sub-mechanism available."
|
||||
),
|
||||
}
|
||||
excess_y = 0
|
||||
if isinstance(overflow_zone, dict):
|
||||
ey = overflow_zone.get("excess_y")
|
||||
if isinstance(ey, (int, float)) and ey > 0:
|
||||
excess_y = int(math.ceil(float(ey)))
|
||||
selected: Optional[dict] = None
|
||||
for variant in variants:
|
||||
if not isinstance(variant, dict):
|
||||
continue
|
||||
budget = variant.get("excess_budget_px")
|
||||
if not isinstance(budget, (int, float)):
|
||||
continue
|
||||
if int(budget) >= excess_y:
|
||||
selected = variant
|
||||
break
|
||||
if selected is None:
|
||||
return {
|
||||
**base, "feasible": False, "envelope_present": True,
|
||||
"envelope_keys": sorted(envelope.keys()),
|
||||
"candidates_considered": candidates_considered,
|
||||
"selected_variant": None, "css_overrides": None,
|
||||
"excess_y": excess_y,
|
||||
"failure_reason": (
|
||||
f"all {len(variants)} internal_envelope variant(s) for "
|
||||
f"'{frame_template_id}' have excess_budget_px below excess_y="
|
||||
f"{excess_y}px — internal fit cannot absorb overflow."
|
||||
),
|
||||
}
|
||||
overrides = selected.get("css_overrides") or {}
|
||||
return {
|
||||
**base, "feasible": True, "envelope_present": True,
|
||||
"envelope_keys": sorted(envelope.keys()),
|
||||
"candidates_considered": candidates_considered,
|
||||
"selected_variant": selected.get("name"),
|
||||
"selected_variant_budget_px": int(selected.get("excess_budget_px") or 0),
|
||||
"excess_y": excess_y,
|
||||
"css_overrides": dict(overrides),
|
||||
}
|
||||
|
||||
|
||||
def apply_frame_internal_fit_candidate_css(plan: dict) -> Optional[str]:
|
||||
"""Build a frame-scoped CSS snippet from a feasible frame_internal_fit
|
||||
plan. Returns None when plan is infeasible. u6 / u7 inject the snippet
|
||||
into the per-slide style override and re-render.
|
||||
"""
|
||||
if not plan.get("feasible"):
|
||||
return None
|
||||
overrides = plan.get("css_overrides") or {}
|
||||
if not overrides:
|
||||
return None
|
||||
template_id = plan.get("frame_template_id") or ""
|
||||
if not template_id:
|
||||
return None
|
||||
selector = f".zone[data-template-id=\"{template_id}\"]"
|
||||
body_lines = [
|
||||
f" {prop}: {value};" for prop, value in overrides.items()
|
||||
]
|
||||
return f"{selector} {{\n" + "\n".join(body_lines) + "\n}"
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
"""IMP-43 (#72) u2 — Step 6 reuse snapshot schema (JSON-only).
|
||||
|
||||
Stage 2 plan (locked) — ``--reuse-from PREV_RUN_ID`` reuses the
|
||||
Step 0 / 1 / 2 / 5 / 6 deterministic artifact subset plus the
|
||||
in-memory state that downstream steps need but that the existing
|
||||
``step02_normalized.json`` / ``step05_v4_evidence.json`` /
|
||||
``step06_composition_plan.json`` artifacts do not capture in a
|
||||
deserialize-ready form (e.g. ``CompositionUnit`` instances,
|
||||
``comp_debug``, ``v4_fallback_traces`` raw map, pre-override
|
||||
``layout_preset``). This module owns the schema for the additional
|
||||
``_reuse_snapshot.json`` sidecar written next to ``step06_composition_plan.json``.
|
||||
|
||||
Scope (u2 only, Stage 2 unit split):
|
||||
* Pure schema + serializers + validator. No file I/O.
|
||||
* JSON-only — pickle is forbidden per Stage 2 guardrails.
|
||||
* Provenance per top-level field: ``{value, source_path, upstream_step}``.
|
||||
* ``mdx_sha256`` integrity key — ``--reuse-from`` must fail closed when
|
||||
the prev run's MDX bytes don't match the current MDX bytes.
|
||||
* ``schema_version`` — bumped on any non-additive shape change.
|
||||
|
||||
Out of scope (deferred to later units):
|
||||
* Writing the snapshot into the run_dir (u3).
|
||||
* Copy / restore on ``--reuse-from`` (u4).
|
||||
* Fail-closed snapshot/path errors at restore time (u4b).
|
||||
* Threading ``reuse_from`` through ``run_phase_z2_mvp1`` (u5).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
SNAPSHOT_VERSION = 1
|
||||
SNAPSHOT_FILENAME = "_reuse_snapshot.json"
|
||||
|
||||
|
||||
# Required top-level keys. Bare scalars (no provenance wrapper):
|
||||
# - schema_version (contract key)
|
||||
# - mdx_sha256 (integrity key)
|
||||
# All other keys are wrapped {value, source_path, upstream_step}.
|
||||
REQUIRED_TOP_LEVEL_KEYS: tuple[str, ...] = (
|
||||
"schema_version",
|
||||
"mdx_sha256",
|
||||
"slide_title",
|
||||
"slide_footer",
|
||||
"sections",
|
||||
"stage0_adapter_diagnostics",
|
||||
"stage0_normalized_assets",
|
||||
"v4_evidence",
|
||||
"layout_preset_pre_override",
|
||||
"units",
|
||||
"comp_debug",
|
||||
"v4_fallback_traces",
|
||||
"ai_preflight",
|
||||
)
|
||||
|
||||
_BARE_KEYS: frozenset[str] = frozenset({"schema_version", "mdx_sha256"})
|
||||
|
||||
|
||||
def _wrap(value: Any, *, source_path: str, upstream_step: str) -> dict[str, Any]:
|
||||
return {
|
||||
"value": value,
|
||||
"source_path": source_path,
|
||||
"upstream_step": upstream_step,
|
||||
}
|
||||
|
||||
|
||||
def serialize_section(section: Any) -> dict[str, Any]:
|
||||
"""Serialize an ``MdxSection``-shaped object into a JSON-safe dict.
|
||||
|
||||
Duck-typed: accepts the production ``MdxSection`` dataclass or any
|
||||
object exposing the same attribute names. Preserves the subset of
|
||||
fields needed to reconstruct downstream pipeline behavior on the
|
||||
reuse path.
|
||||
"""
|
||||
return {
|
||||
"section_id": section.section_id,
|
||||
"section_num": section.section_num,
|
||||
"title": section.title,
|
||||
"raw_content": section.raw_content,
|
||||
"heading_number": getattr(section, "heading_number", None),
|
||||
"v4_alias_keys": list(getattr(section, "v4_alias_keys", []) or []),
|
||||
"sub_sections": list(getattr(section, "sub_sections", []) or []),
|
||||
}
|
||||
|
||||
|
||||
def serialize_unit(unit: Any) -> dict[str, Any]:
|
||||
"""Serialize a ``CompositionUnit``-shaped object into a JSON-safe dict.
|
||||
|
||||
``v4_candidates`` entries are V4Match-duck-typed per the
|
||||
CompositionUnit docstring; each is unwrapped to its 6 named
|
||||
attributes so the snapshot file does not pin V4Match's dataclass
|
||||
layout. ``v4_rank`` is included so the reuse path's Step 9
|
||||
application-plan payload (``_build_application_plan_unit``)
|
||||
remains byte-equivalent to the full-rerun path — full rerun stamps
|
||||
each candidate's rank via ``_v4_match_from_judgment`` (e.g. 1, 2,
|
||||
3, …) and Step 9 surfaces it under ``v4_candidates[i].v4_rank``.
|
||||
Persisting it here lets the rehydrated ``_RehydratedV4Candidate``
|
||||
expose the same attribute end-to-end and avoids None drift in the
|
||||
Step 13 equivalence comparison (u7a).
|
||||
"""
|
||||
return {
|
||||
"source_section_ids": list(unit.source_section_ids),
|
||||
"merge_type": unit.merge_type,
|
||||
"frame_template_id": unit.frame_template_id,
|
||||
"frame_id": unit.frame_id,
|
||||
"frame_number": unit.frame_number,
|
||||
"confidence": float(unit.confidence),
|
||||
"label": unit.label,
|
||||
"phase_z_status": unit.phase_z_status,
|
||||
"raw_content": unit.raw_content,
|
||||
"title": unit.title,
|
||||
"v4_rank": unit.v4_rank,
|
||||
"selection_path": unit.selection_path,
|
||||
"fallback_reason": unit.fallback_reason,
|
||||
"score": float(unit.score),
|
||||
"rationale": dict(unit.rationale or {}),
|
||||
"auto_selectable": bool(unit.auto_selectable),
|
||||
"filter_reasons": list(unit.filter_reasons or []),
|
||||
"notes": list(unit.notes or []),
|
||||
"v4_candidates": [
|
||||
{
|
||||
"template_id": c.template_id,
|
||||
"frame_id": c.frame_id,
|
||||
"frame_number": c.frame_number,
|
||||
"confidence": float(c.confidence),
|
||||
"label": c.label,
|
||||
"v4_rank": getattr(c, "v4_rank", None),
|
||||
}
|
||||
for c in (unit.v4_candidates or [])
|
||||
],
|
||||
"provisional": bool(getattr(unit, "provisional", False)),
|
||||
}
|
||||
|
||||
|
||||
def build_snapshot(
|
||||
*,
|
||||
mdx_sha256: str,
|
||||
slide_title: Optional[str],
|
||||
slide_footer: Optional[str],
|
||||
sections: list,
|
||||
stage0_adapter_diagnostics: Optional[dict],
|
||||
stage0_normalized_assets: Optional[dict],
|
||||
v4_evidence: list,
|
||||
layout_preset_pre_override: Optional[str],
|
||||
units: list,
|
||||
comp_debug: Optional[dict],
|
||||
v4_fallback_traces: Optional[dict],
|
||||
ai_preflight: Optional[dict],
|
||||
) -> dict[str, Any]:
|
||||
"""Build a JSON-serializable Step 6 reuse snapshot with provenance.
|
||||
|
||||
Each top-level entry — except the two bare contract / integrity
|
||||
keys (``schema_version``, ``mdx_sha256``) — is wrapped with
|
||||
``{value, source_path, upstream_step}``.
|
||||
|
||||
The function calls ``json.dumps(snapshot)`` at the end to enforce
|
||||
JSON-safety at build time: any latent non-JSON value (set, Path,
|
||||
dataclass instance, etc.) raises ``TypeError`` at the call site,
|
||||
not later at restore.
|
||||
"""
|
||||
snapshot: dict[str, Any] = {
|
||||
"schema_version": SNAPSHOT_VERSION,
|
||||
"mdx_sha256": mdx_sha256,
|
||||
"slide_title": _wrap(
|
||||
slide_title,
|
||||
source_path="steps/step02_normalized.json#/slide_title",
|
||||
upstream_step="step02",
|
||||
),
|
||||
"slide_footer": _wrap(
|
||||
slide_footer,
|
||||
source_path="steps/step02_normalized.json#/slide_footer",
|
||||
upstream_step="step02",
|
||||
),
|
||||
"sections": _wrap(
|
||||
[serialize_section(s) for s in sections],
|
||||
source_path="steps/step02_normalized.json#/sections",
|
||||
upstream_step="step02",
|
||||
),
|
||||
"stage0_adapter_diagnostics": _wrap(
|
||||
dict(stage0_adapter_diagnostics or {}),
|
||||
source_path="steps/step02_normalized.json#/stage0_adapter_diagnostics",
|
||||
upstream_step="step02",
|
||||
),
|
||||
"stage0_normalized_assets": _wrap(
|
||||
dict(stage0_normalized_assets or {}),
|
||||
source_path="steps/step02_normalized.json#/stage0_normalized_assets",
|
||||
upstream_step="step02",
|
||||
),
|
||||
"v4_evidence": _wrap(
|
||||
list(v4_evidence or []),
|
||||
source_path="steps/step05_v4_evidence.json#/evidence_per_section",
|
||||
upstream_step="step05",
|
||||
),
|
||||
"layout_preset_pre_override": _wrap(
|
||||
layout_preset_pre_override,
|
||||
source_path="steps/step06_composition_plan.json#/layout_preset_decided",
|
||||
upstream_step="step06",
|
||||
),
|
||||
"units": _wrap(
|
||||
[serialize_unit(u) for u in units],
|
||||
source_path="steps/step06_composition_plan.json#/selected_units",
|
||||
upstream_step="step06",
|
||||
),
|
||||
"comp_debug": _wrap(
|
||||
dict(comp_debug or {}),
|
||||
source_path="steps/step06_composition_plan.json#/*",
|
||||
upstream_step="step06",
|
||||
),
|
||||
"v4_fallback_traces": _wrap(
|
||||
dict(v4_fallback_traces or {}),
|
||||
# v4_fallback_traces is assembled inside run_phase_z2_mvp1
|
||||
# (see phase_z2_pipeline.py around the Step 5/6 boundary) and
|
||||
# surfaces only partially into step06_composition_plan.json
|
||||
# via the v4_fallback_summary / imp48_resplit fields. The
|
||||
# canonical untruncated source is the in-memory dict at end
|
||||
# of Step 6 — that's what the reuse path needs.
|
||||
source_path="phase_z2_pipeline.run_phase_z2_mvp1::v4_fallback_traces",
|
||||
upstream_step="step06",
|
||||
),
|
||||
"ai_preflight": _wrap(
|
||||
dict(ai_preflight or {}),
|
||||
source_path="steps/step00_preconditions.json#/ai_preflight",
|
||||
upstream_step="step00",
|
||||
),
|
||||
}
|
||||
json.dumps(snapshot)
|
||||
return snapshot
|
||||
|
||||
|
||||
class SnapshotValidationError(ValueError):
|
||||
"""Raised by ``validate_snapshot`` when the snapshot is structurally
|
||||
unusable or fails the ``mdx_sha256`` integrity check.
|
||||
|
||||
Subclass of ``ValueError`` so existing ``except ValueError`` callers
|
||||
(u4b will add a tighter ``except SnapshotValidationError``) still
|
||||
catch it without escaping to the outer CLI.
|
||||
"""
|
||||
|
||||
|
||||
def validate_snapshot(
|
||||
snapshot: Any,
|
||||
*,
|
||||
expected_mdx_sha256: str,
|
||||
) -> None:
|
||||
"""Validate a loaded snapshot dict (fail-closed).
|
||||
|
||||
Raises ``SnapshotValidationError`` when:
|
||||
* ``snapshot`` is not a dict
|
||||
* ``schema_version`` is missing or != ``SNAPSHOT_VERSION``
|
||||
* ``mdx_sha256`` is missing, non-string, or doesn't match
|
||||
``expected_mdx_sha256``
|
||||
* any required top-level key is missing
|
||||
* a wrapped entry doesn't expose ``{value, source_path, upstream_step}``
|
||||
|
||||
Returns ``None`` on success.
|
||||
|
||||
Callers (u4b) translate the raised error into an exit-code-2 abort
|
||||
with the failing axis surfaced as `value + path + upstream`
|
||||
(factual-verification guardrail).
|
||||
"""
|
||||
if not isinstance(snapshot, dict):
|
||||
raise SnapshotValidationError(
|
||||
f"snapshot is not a dict (got {type(snapshot).__name__})"
|
||||
)
|
||||
|
||||
version = snapshot.get("schema_version")
|
||||
if version != SNAPSHOT_VERSION:
|
||||
raise SnapshotValidationError(
|
||||
f"schema_version mismatch: expected {SNAPSHOT_VERSION!r}, got {version!r}"
|
||||
)
|
||||
|
||||
actual_sha = snapshot.get("mdx_sha256")
|
||||
if not isinstance(actual_sha, str) or not actual_sha:
|
||||
raise SnapshotValidationError(
|
||||
f"mdx_sha256 missing or non-string: got {actual_sha!r}"
|
||||
)
|
||||
if actual_sha != expected_mdx_sha256:
|
||||
raise SnapshotValidationError(
|
||||
f"mdx_sha256 mismatch: snapshot={actual_sha!r} "
|
||||
f"expected={expected_mdx_sha256!r}"
|
||||
)
|
||||
|
||||
missing = [k for k in REQUIRED_TOP_LEVEL_KEYS if k not in snapshot]
|
||||
if missing:
|
||||
raise SnapshotValidationError(
|
||||
f"missing required keys: {missing!r}"
|
||||
)
|
||||
|
||||
for key, entry in snapshot.items():
|
||||
if key in _BARE_KEYS:
|
||||
continue
|
||||
if not isinstance(entry, dict):
|
||||
raise SnapshotValidationError(
|
||||
f"key {key!r}: expected wrapper dict, got {type(entry).__name__}"
|
||||
)
|
||||
for field_name in ("value", "source_path", "upstream_step"):
|
||||
if field_name not in entry:
|
||||
raise SnapshotValidationError(
|
||||
f"key {key!r}: wrapper missing {field_name!r}"
|
||||
)
|
||||
+36
-3
@@ -25,13 +25,27 @@ from typing import Optional
|
||||
# ─── §4 mapping table (spec PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC §4) ──
|
||||
|
||||
# category → proposed_action (primary)
|
||||
# IMP-88 (#88) u1 (2026-05-24): two ACTION_BY_CATEGORY edits to align the
|
||||
# primary router surface with PHASE-Z-PIPELINE-OVERVIEW.md Step 16 + Step 17
|
||||
# spec (anchor PHASE-Z-PIPELINE-OVERVIEW.md:321):
|
||||
# 1. NEW row `image_aspect_mismatch → image_fit` — closes the unmapped
|
||||
# classifier emission (phase_z2_classifier.py:434-447) that previously
|
||||
# returned proposed_action=None and stalled visual_check on overflow
|
||||
# runs carrying image_event payloads.
|
||||
# 2. REMAP `frame_capacity_mismatch → frame_internal_fit_candidate`
|
||||
# (previously frame_reselect) — OVERVIEW.md Step 17 locks
|
||||
# frame_internal_fit_candidate as the per-zone first-pass salvage
|
||||
# *inside* the declared frame envelope; frame_reselect (V4 top-k
|
||||
# alternate frame swap) stays available downstream via the
|
||||
# failure_router cascade (rerender_still_fails → frame_reselect).
|
||||
ACTION_BY_CATEGORY: dict[str, str] = {
|
||||
"minor_overflow": "zone_ratio_retry",
|
||||
"moderate_overflow": "layout_adjust",
|
||||
"structural_minor_overflow": "zone_ratio_retry",
|
||||
"structural_major_overflow": "details_popup_escalation",
|
||||
"tabular_overflow": "details_popup_escalation",
|
||||
"frame_capacity_mismatch": "frame_reselect",
|
||||
"image_aspect_mismatch": "image_fit",
|
||||
"frame_capacity_mismatch": "frame_internal_fit_candidate",
|
||||
"layout_zone_mismatch": "layout_adjust",
|
||||
"hard_visual_fail": "abort",
|
||||
}
|
||||
@@ -48,8 +62,13 @@ ACTION_RATIONALE: dict[str, str] = {
|
||||
"1+ structural unit 완전 잘림 → 의미 손실, popup 으로 escalate",
|
||||
"tabular_overflow":
|
||||
"표는 행 단위로 잘리면 의미 손실 → popup escalate (또는 table-friendly frame reselect)",
|
||||
"image_aspect_mismatch":
|
||||
"image 자연 비율과 렌더 비율 mismatch → frame 내부 image fit (object-fit / "
|
||||
"max-w/h) 로 envelope 안에서 비율 회복. 공통 image CSS 변경 X (frame-scoped).",
|
||||
"frame_capacity_mismatch":
|
||||
"composition capacity_fit 가 이미 mismatch 신호 → V4 top-k 의 다른 frame 평가",
|
||||
"composition capacity_fit 가 이미 mismatch 신호 → frame contract envelope "
|
||||
"안 internal fit 변형 (density / line rhythm / row 배치) 우선. "
|
||||
"frame swap 은 cascade 다음 단계 (rerender_still_fails → frame_reselect).",
|
||||
"layout_zone_mismatch":
|
||||
"frame root 자체 overflow → layout preset 변경 또는 zone 키움",
|
||||
"hard_visual_fail":
|
||||
@@ -61,7 +80,21 @@ ACTION_RATIONALE: dict[str, str] = {
|
||||
# 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",
|
||||
# IMP-88 (#88) u1→u7 (2026-05-24): three Step 17 retry actions registered
|
||||
# here. u1 added the data-surface rows (initial state MISSING). u3/u4/u5
|
||||
# landed the deterministic planners in src/phase_z2_retry.py. u6 wired the
|
||||
# salvage dispatcher (_attempt_salvage_chain), and u7 wired the Step 17
|
||||
# entry runtime (_attempt_step17_image_fit_single_pass + §11.7.1/§11.7.2).
|
||||
# Status flips MISSING→IMPLEMENTED land here on u7 completion — once the
|
||||
# end-to-end path (planner + apply + dispatcher + entry) is wired the
|
||||
# action is IMPLEMENTED on the deterministic surface. (Same convention as
|
||||
# IMP-12 u7 cascade rows below: planner-surface availability + orchestrator
|
||||
# wiring together constitute IMPLEMENTED; route_action's
|
||||
# implementation_status field reflects surface availability, not whether a
|
||||
# given pipeline run has invoked the action.)
|
||||
"layout_adjust": "IMPLEMENTED", # u3 plan_layout_adjust + u6 dispatcher branch + u7 cascade entry
|
||||
"image_fit": "IMPLEMENTED", # u4 plan_image_fit + u7 _attempt_step17_image_fit_single_pass entry
|
||||
"frame_internal_fit_candidate": "IMPLEMENTED", # u5 plan_frame_internal_fit_candidate + u6 dispatcher branch + u7 cascade entry
|
||||
# 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
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"""IMP-94 (#94) u1 — region/content marker stamper for Phase Z final.html.
|
||||
|
||||
Annotates each rendered family-partial root ``<div>`` with stable
|
||||
``data-region-id="..."`` and ``data-content-unit-id="..."`` attributes so
|
||||
downstream Layer A telemetry (placement_trace ↔ DOM parity, Step 21 self-
|
||||
report, fit_classifier read targets §6.4) can resolve a rendered zone
|
||||
back to its PlacementPlan ``slot_assignments[]`` entry.
|
||||
|
||||
DOM contract (single point of truth — mirrored verbatim across the axis) ::
|
||||
|
||||
<div class="..." data-region-id="{region_id}" data-content-unit-id="{cuid}" ...
|
||||
data-frame-id="..." data-template-id="...">
|
||||
|
||||
The anchor is the uniform root-div emitted by every Phase Z family
|
||||
partial under ``templates/phase_z2/families/`` (13 partials, evidence
|
||||
confirmed via ``grep -l data-template-id`` = 13/13). All 13 partials
|
||||
carry the pattern::
|
||||
|
||||
<div class="<fNb>" data-frame-id="..." data-template-id="<family>">
|
||||
|
||||
The stamper finds the FIRST such opening tag with a permissive regex
|
||||
and injects ``data-region-id`` + ``data-content-unit-id`` as new
|
||||
attributes. Existing attributes (class, data-frame-id, data-template-id,
|
||||
etc.) are preserved verbatim. The injection is idempotent — a zone that
|
||||
already carries ``data-region-id`` on its root div is left alone.
|
||||
|
||||
Source of marker values : ``PlacementPlan.slot_assignments[].region_id``
|
||||
and ``.content_unit_id`` (see ``src/phase_z2_placement_planner.py``
|
||||
L253-258). u3 wires the live B4 path; u4 ensures non-live append paths
|
||||
default to ``placement_markers=[]`` so this stamper safely no-ops.
|
||||
|
||||
Forward-compat / safety :
|
||||
- Empty / None ``markers`` → passthrough (returns ``zone_html`` unchanged).
|
||||
- Non-str / empty ``zone_html`` → passthrough.
|
||||
- Re-stamping (idempotent) preserves the first stamp.
|
||||
- Only the FIRST data-template-id root div is stamped (one per zone).
|
||||
- Markers with empty / missing ``region_id`` AND ``content_unit_id`` →
|
||||
passthrough (no attribute injection).
|
||||
|
||||
Guardrails (refs : Stage 1 binding contract, Stage 2 unit u1) :
|
||||
- AI-isolation : pure deterministic Python; no LLM calls.
|
||||
- Additive only : never edits / removes existing attributes.
|
||||
- Idempotent : ``data-region-id`` probe short-circuits before re-inject.
|
||||
- Disjoint from #96 (``data-frame-slot-id`` is a separate axis / attr).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Iterable, Mapping
|
||||
|
||||
REGION_ID_ATTR: str = "data-region-id"
|
||||
CONTENT_UNIT_ID_ATTR: str = "data-content-unit-id"
|
||||
|
||||
# Matches the FIRST ``<div ... data-template-id="...">`` opening tag.
|
||||
# Group 1 captures the inner attribute string verbatim (incl. leading
|
||||
# whitespace) so the rewriter can re-emit it unchanged after injection.
|
||||
_ROOT_DIV_TAG_RE = re.compile(
|
||||
r'<div\b((?=[^>]*\bdata-template-id\s*=\s*"[^"]+")[^>]*?)>',
|
||||
flags=re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
# Probe for an existing ``data-region-id`` attribute (any value, any
|
||||
# quote) so re-stamping is idempotent.
|
||||
_HAS_REGION_ID_RE = re.compile(r"""\bdata-region-id\s*=""", flags=re.IGNORECASE)
|
||||
|
||||
|
||||
def _coerce_marker_value(value: Any) -> str:
|
||||
"""Return a safe attribute-value string for ``value``.
|
||||
|
||||
Non-str / None → ''. Strings are returned verbatim (caller responsible
|
||||
for not embedding ``"`` since marker ids derive from
|
||||
PlacementPlan.slot_assignments which are deterministic identifiers).
|
||||
"""
|
||||
if value is None:
|
||||
return ""
|
||||
if not isinstance(value, str):
|
||||
return ""
|
||||
return value
|
||||
|
||||
|
||||
def stamp_zone_html(
|
||||
zone_html: str,
|
||||
markers: Iterable[Mapping[str, Any]] | None,
|
||||
) -> str:
|
||||
"""Stamp the root family-partial ``<div>`` with region / content-unit ids.
|
||||
|
||||
``markers`` is an iterable of mapping objects shaped as ::
|
||||
|
||||
{
|
||||
"region_id": "<region_id>",
|
||||
"content_unit_id": "<content_unit_id>",
|
||||
# optional, ignored here — reserved for #96 (89-d):
|
||||
"frame_slot_id": "<frame_slot_id>",
|
||||
}
|
||||
|
||||
Only ``markers[0]`` is consumed (one root div per zone). Excess
|
||||
markers are reserved for a future per-slot stamper (#96) and are
|
||||
silently ignored by this module.
|
||||
|
||||
Returns ``zone_html`` unchanged when:
|
||||
- ``zone_html`` is not a non-empty string,
|
||||
- ``markers`` is None / empty,
|
||||
- no ``data-template-id`` root div is found,
|
||||
- the root div already carries ``data-region-id`` (idempotent),
|
||||
- the first marker carries neither ``region_id`` nor ``content_unit_id``.
|
||||
"""
|
||||
if not isinstance(zone_html, str) or not zone_html:
|
||||
return zone_html
|
||||
if markers is None:
|
||||
return zone_html
|
||||
marker_list = list(markers)
|
||||
if not marker_list:
|
||||
return zone_html
|
||||
first = marker_list[0]
|
||||
if not isinstance(first, Mapping):
|
||||
return zone_html
|
||||
region_id = _coerce_marker_value(first.get("region_id"))
|
||||
content_unit_id = _coerce_marker_value(first.get("content_unit_id"))
|
||||
if not region_id and not content_unit_id:
|
||||
return zone_html
|
||||
|
||||
stamped = {"done": False}
|
||||
|
||||
def _replace(match: re.Match[str]) -> str:
|
||||
if stamped["done"]:
|
||||
return match.group(0)
|
||||
attrs = match.group(1) or ""
|
||||
if _HAS_REGION_ID_RE.search(attrs):
|
||||
stamped["done"] = True
|
||||
return match.group(0)
|
||||
stamped["done"] = True
|
||||
injected = (
|
||||
f' {REGION_ID_ATTR}="{region_id}"'
|
||||
f' {CONTENT_UNIT_ID_ATTR}="{content_unit_id}"'
|
||||
)
|
||||
return f"<div{injected}{attrs}>"
|
||||
|
||||
return _ROOT_DIV_TAG_RE.sub(_replace, zone_html, count=1)
|
||||
@@ -0,0 +1,87 @@
|
||||
"""IMP-45 (#74) u3 — slide-level CSS override injector for Phase Z final.html.
|
||||
|
||||
Mirror of :func:`src.image_id_stamper.inject_image_overrides_style` contract
|
||||
(image_id_stamper.py:226-264) for the new ``slide_css`` override axis
|
||||
registered by u1 in :data:`src.user_overrides_io.KNOWN_AXES` and surfaced
|
||||
by u2 in :func:`src.mdx_normalizer.normalize_mdx_content` under the
|
||||
``slide_overrides.css`` frontmatter key.
|
||||
|
||||
Single entry point :
|
||||
|
||||
:func:`inject_slide_css` (html, css) -> str
|
||||
|
||||
Semantics (identical contract to image_overrides injector) :
|
||||
|
||||
- Empty / falsy ``css`` -> ``html`` returned unchanged (no DOM mutation).
|
||||
- Marker-wrapped ``<style>`` block; re-injection replaces inner CSS in
|
||||
place (idempotent on identical input; latest-wins on different input).
|
||||
- Injection precedence : (1) before 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 fragment inputs.
|
||||
|
||||
Marker sentinels (distinct from image_overrides markers so the two
|
||||
injectors can co-exist on the same document without collision; the
|
||||
literal form is pinned by the Stage 2 binding contract for IMP-45 /
|
||||
issue #74) :
|
||||
|
||||
<!--IMP45-SLIDE-CSS:OPEN-->
|
||||
<!--IMP45-SLIDE-CSS:CLOSE-->
|
||||
|
||||
Both injectors target ``</head>`` first, so call order determines DOM
|
||||
order. u4 calls ``inject_image_overrides_style`` first (existing Step 13
|
||||
behavior) and then ``inject_slide_css``, putting slide-level overrides
|
||||
after image overrides in cascade order so the editor-authored slide CSS
|
||||
wins ties at the same specificity (intended by IMP-45 scope).
|
||||
|
||||
Guardrails :
|
||||
|
||||
- No-hardcoding : ``css`` is caller-supplied verbatim. No sample-id or
|
||||
frame-id branches.
|
||||
- AI-isolation : pure deterministic Python; no LLM calls.
|
||||
- Carve-out (IMP-46 #62) : brand-new module, does not touch the
|
||||
#76 commit ``1186ad8`` cache region.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
_IMP45_STYLE_MARKER_OPEN: str = "<!--IMP45-SLIDE-CSS:OPEN-->"
|
||||
_IMP45_STYLE_MARKER_CLOSE: str = "<!--IMP45-SLIDE-CSS:CLOSE-->"
|
||||
|
||||
_IMP45_STYLE_BLOCK_RE = re.compile(
|
||||
re.escape(_IMP45_STYLE_MARKER_OPEN) + r".*?" + re.escape(_IMP45_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 inject_slide_css(html: str, css: str | None) -> str:
|
||||
"""Inject a marker-wrapped ``<style>`` block carrying ``css`` into ``html``.
|
||||
|
||||
Empty or ``None`` ``css`` -> ``html`` returned unchanged. Re-injection
|
||||
is idempotent : when a previously-injected marker block is present,
|
||||
its inner CSS is replaced in place.
|
||||
|
||||
Injection precedence : ``</head>`` > ``<body ...>`` > document start.
|
||||
"""
|
||||
if not css:
|
||||
return html
|
||||
block = (
|
||||
f"{_IMP45_STYLE_MARKER_OPEN}\n"
|
||||
f"<style>\n{css}\n</style>\n"
|
||||
f"{_IMP45_STYLE_MARKER_CLOSE}"
|
||||
)
|
||||
if _IMP45_STYLE_MARKER_OPEN in html:
|
||||
return _IMP45_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
|
||||
@@ -0,0 +1,189 @@
|
||||
"""IMP-56 (#90) u6 — structure_override resolver (validator + apply).
|
||||
|
||||
Step-22 user structure-edit persist axis. Consumed by Step 12 (u7 wiring)
|
||||
so a prior render's reorder / hide choices re-apply to the next render
|
||||
without re-clicking.
|
||||
|
||||
Schema (defined verbatim in ``src/user_overrides_io.py:30`` u2) ::
|
||||
|
||||
structure_overrides = {
|
||||
<zone_id>: {
|
||||
"slot_order": [<slot_key>, ...], # optional, partial reorder
|
||||
"hidden_slots": [<slot_key>, ...], # optional, hide these slot_keys
|
||||
},
|
||||
...
|
||||
}
|
||||
|
||||
SCOPE LOCK (Stage 2 u6 contract, IMP-56 #90 u2 docstring) :
|
||||
|
||||
The only allowed inner keys are ``slot_order`` and ``hidden_slots``.
|
||||
Any other key (e.g., ``frame_id``, ``template_id``, ``unit_id``,
|
||||
``slot_payload``) is treated as a frame-swap / DOM-rebuild attempt and
|
||||
is DROPPED at validate time. Frame swap stays on the existing
|
||||
``frames`` axis so the Phase Z no-AI-HTML-structure invariant remains
|
||||
intact. There is intentionally NO escape hatch through this axis.
|
||||
|
||||
API (deterministic, no AI) :
|
||||
|
||||
- ``validate_structure_overrides(overrides)`` → sanitized copy. Per-entry
|
||||
tolerant (drops malformed rows; never rejects the whole batch — mirrors
|
||||
``src.text_override_resolver.validate_text_overrides`` u4 contract).
|
||||
- ``apply_structure_override(zone, override)`` → ``True`` if the slot-payload
|
||||
mapping was mutated (any hide or any reorder), ``False`` otherwise. The
|
||||
``zone`` argument is the slot-payload mapping at Step 12 (a mutable
|
||||
mapping whose keys are slot_keys and whose values are typically
|
||||
``list[str]`` of lines). Identity-preserving: mutates in-place via
|
||||
``clear`` + ``update`` so caller references remain valid.
|
||||
|
||||
Guardrails (refs : Stage 1 binding contract, Stage 2 unit u6) :
|
||||
|
||||
- raw_content preservation is a wiring-layer (u7) responsibility — the
|
||||
resolver only ever reorders / removes top-level slot_payload entries.
|
||||
Per-slot ``list[str]`` line content is never inspected or mutated here.
|
||||
- AI-isolation : pure deterministic Python; no LLM calls.
|
||||
- Carve-out (IMP-46 #62) : brand-new module, does not touch the #76
|
||||
commit ``1186ad8`` cache region.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping, MutableMapping
|
||||
|
||||
|
||||
class InvalidStructureOverride(ValueError):
|
||||
"""Reserved for future strict-mode parse errors.
|
||||
|
||||
Currently unused — the resolver follows the u4 per-entry-tolerant
|
||||
contract and silently drops malformed rows at validate time rather
|
||||
than raising. Kept as a public surface so u7 wiring (and future
|
||||
strict-mode callers) can distinguish source-malformation from
|
||||
stale-DOM misses without an API rev.
|
||||
"""
|
||||
|
||||
|
||||
_ALLOWED_INNER_KEYS: frozenset[str] = frozenset({"slot_order", "hidden_slots"})
|
||||
|
||||
|
||||
def _sanitize_slot_list(raw: Any) -> list[str]:
|
||||
"""Return a fresh list of non-empty string slot_keys (drop the rest)."""
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for slot in raw:
|
||||
if not isinstance(slot, str) or not slot:
|
||||
continue
|
||||
if slot in seen:
|
||||
# De-dup defensively — a duplicate slot_key in slot_order would
|
||||
# be meaningless (dicts can hold each key once); duplicate in
|
||||
# hidden_slots is redundant. Drop subsequent occurrences.
|
||||
continue
|
||||
seen.add(slot)
|
||||
out.append(slot)
|
||||
return out
|
||||
|
||||
|
||||
def validate_structure_overrides(
|
||||
overrides: Any,
|
||||
) -> dict[str, dict[str, list[str]]]:
|
||||
"""Return a sanitized copy of ``overrides`` (per-entry tolerant).
|
||||
|
||||
Drops:
|
||||
- non-string or empty zone_ids,
|
||||
- non-mapping per-zone payloads,
|
||||
- per-zone inner keys other than ``slot_order`` / ``hidden_slots``
|
||||
(frame-swap attempts are dropped at this gate — see SCOPE LOCK),
|
||||
- non-list ``slot_order`` / ``hidden_slots`` values,
|
||||
- non-string or empty slot_key entries within those lists,
|
||||
- per-zone payloads that contain neither a non-empty ``slot_order``
|
||||
nor a non-empty ``hidden_slots`` after sanitization (empty intent
|
||||
carries no signal).
|
||||
|
||||
Returns a fresh ``dict`` AND fresh nested dicts / lists so callers can
|
||||
use the result as a working buffer without aliasing the persisted
|
||||
payload from ``user_overrides_io.load``.
|
||||
"""
|
||||
if not isinstance(overrides, Mapping):
|
||||
return {}
|
||||
out: dict[str, dict[str, list[str]]] = {}
|
||||
for zone_id, mapping in overrides.items():
|
||||
if not isinstance(zone_id, str) or not zone_id:
|
||||
continue
|
||||
if not isinstance(mapping, Mapping):
|
||||
continue
|
||||
zone_out: dict[str, list[str]] = {}
|
||||
for inner_key, inner_value in mapping.items():
|
||||
if inner_key not in _ALLOWED_INNER_KEYS:
|
||||
# Frame-swap attempt or unknown key — drop silently per
|
||||
# SCOPE LOCK. No mechanism through this axis.
|
||||
continue
|
||||
sanitized = _sanitize_slot_list(inner_value)
|
||||
if sanitized:
|
||||
zone_out[inner_key] = sanitized
|
||||
if zone_out:
|
||||
out[zone_id] = zone_out
|
||||
return out
|
||||
|
||||
|
||||
def apply_structure_override(
|
||||
zone: MutableMapping[str, Any],
|
||||
override: Mapping[str, Any],
|
||||
) -> bool:
|
||||
"""Apply ONE structure override to ``zone`` in-place.
|
||||
|
||||
``zone`` is the slot-payload mapping at Step 12 — i.e. a mutable
|
||||
mapping whose keys are slot_keys and whose values are the per-slot
|
||||
line lists (or other content payload). Mutation is restricted to
|
||||
top-level key membership + ordering; per-slot values are NEVER
|
||||
inspected or modified here.
|
||||
|
||||
``override`` is the per-zone payload after :func:`validate_structure_overrides`
|
||||
sanitization — i.e. a mapping with only ``slot_order`` and / or
|
||||
``hidden_slots`` keys, each holding a list of non-empty str slot_keys.
|
||||
This function is also defensive: if non-list values leak through, they
|
||||
are treated as empty (no raise).
|
||||
|
||||
Semantics :
|
||||
1. ``hidden_slots`` are popped first. Entries absent from ``zone``
|
||||
are silently skipped (stale slot_keys from a prior frame).
|
||||
2. ``slot_order`` partially reorders the surviving slot_keys:
|
||||
listed keys (that are present in ``zone``) move to the front in
|
||||
the given order; remaining keys keep their original relative
|
||||
order at the tail. Unknown slot_keys are silently skipped.
|
||||
|
||||
Returns ``True`` if the zone's slot-payload mapping was mutated (any
|
||||
hide that removed a key OR any reorder that changed key order),
|
||||
``False`` otherwise. Identity-preserving: rebuilds via
|
||||
``clear`` + ``update`` so the caller's reference to ``zone`` remains
|
||||
valid.
|
||||
"""
|
||||
mutated = False
|
||||
|
||||
raw_hidden = override.get("hidden_slots") if isinstance(override, Mapping) else None
|
||||
hidden = _sanitize_slot_list(raw_hidden)
|
||||
for slot in hidden:
|
||||
if slot in zone:
|
||||
del zone[slot]
|
||||
mutated = True
|
||||
|
||||
raw_order = override.get("slot_order") if isinstance(override, Mapping) else None
|
||||
desired_order_seed = _sanitize_slot_list(raw_order)
|
||||
|
||||
current_order = list(zone.keys())
|
||||
desired_order: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for slot in desired_order_seed:
|
||||
if slot in zone and slot not in seen:
|
||||
desired_order.append(slot)
|
||||
seen.add(slot)
|
||||
for slot in current_order:
|
||||
if slot not in seen:
|
||||
desired_order.append(slot)
|
||||
seen.add(slot)
|
||||
|
||||
if desired_order != current_order:
|
||||
snapshot = {k: zone[k] for k in desired_order}
|
||||
zone.clear()
|
||||
zone.update(snapshot)
|
||||
mutated = True
|
||||
|
||||
return mutated
|
||||
@@ -0,0 +1,143 @@
|
||||
"""IMP-56 (#90) u4 — text_override resolver (validator + apply).
|
||||
|
||||
Step-22 user text-edit persist axis. Consumed by Step 12 (u5 wiring) so a
|
||||
prior render's text edits re-apply to the next render without re-clicking.
|
||||
|
||||
Schema (defined verbatim in ``src/user_overrides_io.py:29`` u1) ::
|
||||
|
||||
text_overrides = {
|
||||
<zone_id>: {<text_path>: <value: str>},
|
||||
...
|
||||
}
|
||||
|
||||
``text_path`` is the ``{slot_key}.{line_index}`` stamp emitted at Step 13
|
||||
by the u8 ``text_path_stamper`` (pending unit) and surfaced to the frontend
|
||||
SlideCanvas (u12) as ``data-text-path`` attributes on editable text nodes.
|
||||
The ``{slot_key}`` is a frame contract slot identifier (e.g.,
|
||||
``slot_title``); the ``{line_index}`` is the 0-based ordinal of the line
|
||||
within that slot's rendered text (typically one bullet / one paragraph).
|
||||
|
||||
API (deterministic, no AI) :
|
||||
|
||||
- ``parse_text_path(text_path)`` → ``(slot_key, line_index)`` or raises.
|
||||
- ``validate_text_overrides(overrides)`` → sanitized copy (drops malformed
|
||||
per-entry; never rejects the whole batch — mirrors the per-entry
|
||||
tolerance contract of ``src.image_id_stamper.build_image_overrides_style``
|
||||
IMP-51 #79 u7).
|
||||
- ``apply_text_override(zone, text_path, value)`` → ``True`` on in-place
|
||||
mutation; ``False`` if the path is absent / out-of-range. The ``zone``
|
||||
argument is the slot-lines mapping at Step 12 — i.e. a mutable mapping
|
||||
where ``zone[slot_key]`` is a ``list[str]`` of line strings. Wiring at
|
||||
Step 12 (u5) is responsible for extracting that mapping from whatever
|
||||
composition object holds it; this resolver is decoupled from the wrapper
|
||||
shape so it can be re-targeted at Stage 5 (Step 12) layer-A or layer-B
|
||||
composition data without an API rev.
|
||||
|
||||
Guardrails (refs : Stage 1 binding contract, Stage 2 unit u4) :
|
||||
|
||||
- raw_content preservation is a wiring-layer (u5) responsibility — the
|
||||
resolver itself only ever mutates the lines mapping it was handed.
|
||||
- AI-isolation : pure deterministic Python; no LLM calls.
|
||||
- Carve-out (IMP-46 #62) : brand-new module, does not touch the #76
|
||||
commit ``1186ad8`` cache region.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping, MutableMapping
|
||||
|
||||
|
||||
class InvalidTextOverride(ValueError):
|
||||
"""Raised when a ``text_path`` is malformed (parse-time)."""
|
||||
|
||||
|
||||
def parse_text_path(text_path: str) -> tuple[str, int]:
|
||||
"""Parse ``{slot_key}.{line_index}`` into ``(slot_key, line_index)``.
|
||||
|
||||
``slot_key`` may itself contain ``.`` (e.g., compound keys), so the
|
||||
parse splits on the LAST ``.`` only — ``rpartition`` semantics.
|
||||
"""
|
||||
if not isinstance(text_path, str) or not text_path:
|
||||
raise InvalidTextOverride(
|
||||
f"text_path must be a non-empty string, got: {text_path!r}"
|
||||
)
|
||||
if "." not in text_path:
|
||||
raise InvalidTextOverride(
|
||||
f"text_path must contain '.' separator, got: {text_path!r}"
|
||||
)
|
||||
slot_key, _, idx_str = text_path.rpartition(".")
|
||||
if not slot_key or not idx_str:
|
||||
raise InvalidTextOverride(
|
||||
f"text_path slot_key and line_index must both be non-empty, "
|
||||
f"got: {text_path!r}"
|
||||
)
|
||||
try:
|
||||
idx = int(idx_str)
|
||||
except ValueError as exc:
|
||||
raise InvalidTextOverride(
|
||||
f"text_path line_index must be int, got: {text_path!r}"
|
||||
) from exc
|
||||
if idx < 0:
|
||||
raise InvalidTextOverride(
|
||||
f"text_path line_index must be >= 0, got: {idx} in {text_path!r}"
|
||||
)
|
||||
return slot_key, idx
|
||||
|
||||
|
||||
def validate_text_overrides(overrides: Any) -> dict[str, dict[str, str]]:
|
||||
"""Return a sanitized copy of ``overrides`` (per-entry tolerant).
|
||||
|
||||
Drops:
|
||||
- non-string or empty zone_ids,
|
||||
- non-mapping per-zone payloads,
|
||||
- non-string text_path keys, non-string values,
|
||||
- text_paths that fail :func:`parse_text_path`.
|
||||
|
||||
Returns a fresh ``dict`` so callers can mutate without aliasing the
|
||||
persisted payload from ``user_overrides_io.load``.
|
||||
"""
|
||||
if not isinstance(overrides, Mapping):
|
||||
return {}
|
||||
out: dict[str, dict[str, str]] = {}
|
||||
for zone_id, mapping in overrides.items():
|
||||
if not isinstance(zone_id, str) or not zone_id:
|
||||
continue
|
||||
if not isinstance(mapping, Mapping):
|
||||
continue
|
||||
zone_out: dict[str, str] = {}
|
||||
for text_path, value in mapping.items():
|
||||
if not isinstance(text_path, str) or not isinstance(value, str):
|
||||
continue
|
||||
try:
|
||||
parse_text_path(text_path)
|
||||
except InvalidTextOverride:
|
||||
continue
|
||||
zone_out[text_path] = value
|
||||
if zone_out:
|
||||
out[zone_id] = zone_out
|
||||
return out
|
||||
|
||||
|
||||
def apply_text_override(
|
||||
zone: MutableMapping[str, Any],
|
||||
text_path: str,
|
||||
value: str,
|
||||
) -> bool:
|
||||
"""Apply ONE text override to ``zone`` in-place.
|
||||
|
||||
``zone`` is the slot-lines mapping at Step 12 — i.e. a mutable mapping
|
||||
where ``zone[slot_key]`` is a ``list[str]`` of line strings.
|
||||
|
||||
Returns ``True`` when the value was replaced. Returns ``False`` (no
|
||||
mutation) when the ``slot_key`` is absent, the slot is not a list, or
|
||||
``line_index`` is out of range. Out-of-range / absent paths are NOT an
|
||||
error — they happen naturally when a prior render's overrides target a
|
||||
slot the new render no longer emits (frame swap, layout regression).
|
||||
"""
|
||||
slot_key, idx = parse_text_path(text_path)
|
||||
if slot_key not in zone:
|
||||
return False
|
||||
lines = zone[slot_key]
|
||||
if not isinstance(lines, list) or idx >= len(lines):
|
||||
return False
|
||||
lines[idx] = value
|
||||
return True
|
||||
@@ -0,0 +1,155 @@
|
||||
"""IMP-56 (#90) u8 — text_path stamper for Phase Z final.html.
|
||||
|
||||
Annotates rendered ``text-line`` DOM elements with a stable
|
||||
``data-text-path="{slot_key}.{line_index}"`` attribute so the frontend
|
||||
SlideCanvas (u10~u12) can attribute per-line edits back to the
|
||||
``text_overrides`` axis (u1 schema, u4 resolver, u5 Step-12 apply).
|
||||
|
||||
DOM contract (single point of truth — mirrored verbatim across the axis) ::
|
||||
|
||||
.text-line[data-text-path="{slot_key}.{line_index}"]
|
||||
|
||||
The ``{slot_key}.{line_index}`` grammar matches
|
||||
:func:`src.text_override_resolver.parse_text_path` verbatim (split on LAST
|
||||
``.`` — compound slot keys with embedded dots are supported).
|
||||
|
||||
The text-line element format is emitted by every Phase Z family / frame
|
||||
template (e.g. ``templates/phase_z2/families/bim_current_problems_paired.html``
|
||||
line 143)::
|
||||
|
||||
<div class="text-line[ ...modifier classes...]">{{ line.text | safe }}</div>
|
||||
|
||||
The stamper finds each ``text-line`` opening tag with a permissive regex
|
||||
and injects ``data-text-path="..."`` as the FIRST attribute. Existing
|
||||
attributes (class, etc.) are preserved verbatim. The injection is
|
||||
idempotent — a previously stamped element is left alone.
|
||||
|
||||
Stamping order : the stamper iterates ``slot_payload`` in dict-iteration
|
||||
order and yields one stamp per ``list`` entry. The DOM walk consumes
|
||||
stamps in left-to-right order; templates currently emit slot lines in
|
||||
the same order they appear in ``slot_payload`` so the alignment holds.
|
||||
If a future template diverges, u9 wiring can pre-build the desired
|
||||
``(slot_key, line_index)`` sequence and pass it explicitly through the
|
||||
``stamps`` arg of :func:`stamp_zone_html`.
|
||||
|
||||
Forward-compat / safety :
|
||||
- Scalar (non-list) slot values are silently skipped — they render
|
||||
outside ``text-line`` divs (frame title, pill labels, etc.) and are
|
||||
not addressable via the line-index grammar.
|
||||
- Excess ``text-line`` elements beyond ``sum(len(v) for v in
|
||||
slot_payload.values() if isinstance(v, list))`` are left unstamped.
|
||||
- Re-stamping (idempotent) preserves the first stamp.
|
||||
|
||||
Guardrails (refs : Stage 1 binding contract, Stage 2 unit u8) :
|
||||
- AI-isolation : pure deterministic Python; no LLM calls.
|
||||
- Carve-out (IMP-46 #62) : brand-new module; does not touch the #76
|
||||
commit ``1186ad8`` cache region.
|
||||
- Idempotent : ``data-text-path`` probe short-circuits before re-inject.
|
||||
- u9 wiring (separate unit) is the only consumer; this module emits no
|
||||
artifacts and reads no global state.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Iterable, Iterator, Mapping
|
||||
|
||||
TEXT_PATH_ATTR: str = "data-text-path"
|
||||
|
||||
# Matches a ``<div ... class="... text-line ..." ...>`` opening tag.
|
||||
# Group 1 captures the inner attribute string verbatim (incl. leading
|
||||
# whitespace) so the rewriter can re-emit it unchanged after injection.
|
||||
_TEXT_LINE_TAG_RE = re.compile(
|
||||
r'<div\b((?=[^>]*\bclass\s*=\s*"[^"]*\btext-line\b)[^>]*?)>',
|
||||
flags=re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
# Probe for an existing ``data-text-path`` attribute (any value, any
|
||||
# quote) so re-stamping is idempotent.
|
||||
_HAS_TEXT_PATH_RE = re.compile(r"""\bdata-text-path\s*=""", flags=re.IGNORECASE)
|
||||
|
||||
|
||||
def build_text_path(slot_key: str, line_index: int) -> str:
|
||||
"""Return the canonical ``{slot_key}.{line_index}`` text_path string.
|
||||
|
||||
Mirrors the inverse of :func:`src.text_override_resolver.parse_text_path`
|
||||
(last-dot split). ``slot_key`` may itself contain ``.`` (compound keys).
|
||||
"""
|
||||
if not isinstance(slot_key, str) or not slot_key:
|
||||
raise ValueError(
|
||||
f"slot_key must be a non-empty string, got: {slot_key!r}"
|
||||
)
|
||||
if isinstance(line_index, bool) or not isinstance(line_index, int):
|
||||
raise ValueError(
|
||||
f"line_index must be a non-negative int, got: {line_index!r}"
|
||||
)
|
||||
if line_index < 0:
|
||||
raise ValueError(
|
||||
f"line_index must be a non-negative int, got: {line_index!r}"
|
||||
)
|
||||
return f"{slot_key}.{line_index}"
|
||||
|
||||
|
||||
def iter_zone_stamps(
|
||||
slot_payload: Mapping[str, Any],
|
||||
) -> Iterator[tuple[str, int]]:
|
||||
"""Yield ``(slot_key, line_index)`` for every list-valued slot line.
|
||||
|
||||
Iteration order matches ``slot_payload`` dict iteration order. Non-
|
||||
string / empty slot_keys are skipped. Non-list values are skipped
|
||||
(scalar slots render outside ``text-line`` divs).
|
||||
"""
|
||||
if not isinstance(slot_payload, Mapping):
|
||||
return
|
||||
for slot_key, value in slot_payload.items():
|
||||
if not isinstance(slot_key, str) or not slot_key:
|
||||
continue
|
||||
if not isinstance(value, list):
|
||||
continue
|
||||
for line_index in range(len(value)):
|
||||
yield slot_key, line_index
|
||||
|
||||
|
||||
def stamp_zone_html(
|
||||
zone_html: str,
|
||||
slot_payload_or_stamps: Mapping[str, Any] | Iterable[tuple[str, int]],
|
||||
) -> str:
|
||||
"""Stamp ``text-line`` opening tags in ``zone_html`` with ``data-text-path``.
|
||||
|
||||
The second arg accepts either:
|
||||
- a ``slot_payload`` ``Mapping`` (uses :func:`iter_zone_stamps` order), or
|
||||
- an iterable of pre-built ``(slot_key, line_index)`` tuples.
|
||||
|
||||
Stamps are consumed in left-to-right DOM order. A text-line already
|
||||
carrying ``data-text-path`` is left unchanged (idempotent). Excess
|
||||
text-line elements beyond the stamp sequence are also left unchanged.
|
||||
|
||||
Returns ``zone_html`` unchanged when there are no stamps to apply or
|
||||
the input is not a non-empty string.
|
||||
"""
|
||||
if not isinstance(zone_html, str) or not zone_html:
|
||||
return zone_html
|
||||
if isinstance(slot_payload_or_stamps, Mapping):
|
||||
stamps = list(iter_zone_stamps(slot_payload_or_stamps))
|
||||
else:
|
||||
stamps = [
|
||||
(sk, li)
|
||||
for (sk, li) in slot_payload_or_stamps
|
||||
if isinstance(sk, str) and sk and isinstance(li, int)
|
||||
and not isinstance(li, bool) and li >= 0
|
||||
]
|
||||
if not stamps:
|
||||
return zone_html
|
||||
counter = {"i": 0}
|
||||
|
||||
def _replace(match: re.Match[str]) -> str:
|
||||
attrs = match.group(1) or ""
|
||||
if _HAS_TEXT_PATH_RE.search(attrs):
|
||||
return match.group(0)
|
||||
i = counter["i"]
|
||||
if i >= len(stamps):
|
||||
return match.group(0)
|
||||
counter["i"] = i + 1
|
||||
slot_key, line_index = stamps[i]
|
||||
path = build_text_path(slot_key, line_index)
|
||||
return f'<div {TEXT_PATH_ATTR}="{path}"{attrs}>'
|
||||
|
||||
return _TEXT_LINE_TAG_RE.sub(_replace, zone_html)
|
||||
+40
-10
@@ -5,14 +5,29 @@ 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``):
|
||||
Schema (9 axes; stable order; IMP-51 #79 u1 added ``image_overrides``;
|
||||
IMP-45 #74 u1 added ``slide_css``; IMP-55 #93 u1 added
|
||||
``manual_section_assignment`` as a bool intent marker so the backend can
|
||||
distinguish a user drag-drop from frontend auto-carry zone_sections;
|
||||
IMP-56 #90 u1 added ``text_overrides`` as a Step-22 text-edit persist axis
|
||||
keyed by ``{zone_id: {text_path: value}}`` where ``text_path`` is the
|
||||
``{slot_key}.{line_index}`` stamp emitted by u8; IMP-56 #90 u2 added
|
||||
``structure_overrides`` as a Step-22 structure-edit persist axis keyed by
|
||||
``{zone_id: {"slot_order": [<slot_key>, ...], "hidden_slots": [<slot_key>, ...]}}``
|
||||
— scope is intentionally LOCKED to slot reorder + hide; frame swap stays
|
||||
on the existing ``frames`` axis to prevent the Phase Z regression of
|
||||
AI-driven HTML structure mutation):
|
||||
|
||||
{
|
||||
"layout": <string|null>,
|
||||
"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}}
|
||||
"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}},
|
||||
"slide_css": <string|null>,
|
||||
"manual_section_assignment": <bool>,
|
||||
"text_overrides": {<zone_id>: {<text_path>: <string>}},
|
||||
"structure_overrides": {<zone_id>: {"slot_order": [<slot_key>, ...], "hidden_slots": [<slot_key>, ...]}}
|
||||
}
|
||||
|
||||
``image_id`` is the stable identifier emitted by the user-content image
|
||||
@@ -53,16 +68,31 @@ from typing import Any, Optional
|
||||
_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.
|
||||
# The nine in-scope axes (IMP-51 #79 u1 added ``image_overrides``; IMP-45
|
||||
# #74 u1 added ``slide_css``; IMP-55 #93 u1 added
|
||||
# ``manual_section_assignment`` — bool intent marker that gates whether
|
||||
# persisted ``zone_sections`` are consumed by the backend pipeline; IMP-56
|
||||
# #90 u1 added ``text_overrides`` — Step-22 text-edit persist axis keyed by
|
||||
# ``{zone_id: {text_path: value}}`` where ``text_path`` is the
|
||||
# ``{slot_key}.{line_index}`` stamp emitted by u8 / consumed by u4+u5;
|
||||
# IMP-56 #90 u2 added ``structure_overrides`` — Step-22 structure-edit
|
||||
# persist axis keyed by ``{zone_id: {"slot_order": [...], "hidden_slots":
|
||||
# [...]}}``, scope LOCKED to slot reorder + hide so the resolver (u6) /
|
||||
# Step-12 apply (u7) cannot mutate frame identity — frame swap stays on
|
||||
# the existing ``frames`` axis to keep Phase Z's no-AI-HTML-structure
|
||||
# invariant intact). Any other top-level key in the file is preserved but
|
||||
# ignored by callers — keeps the file forward-compatible with future axes
|
||||
# (e.g., zone_sizes) without a schema bump here.
|
||||
KNOWN_AXES: tuple[str, ...] = (
|
||||
"layout",
|
||||
"zone_geometries",
|
||||
"zone_sections",
|
||||
"frames",
|
||||
"image_overrides",
|
||||
"slide_css",
|
||||
"manual_section_assignment",
|
||||
"text_overrides",
|
||||
"structure_overrides",
|
||||
)
|
||||
|
||||
# Key validation — MDX stem must be safe for filesystem use. Allow
|
||||
|
||||
@@ -474,7 +474,9 @@ bim_dx_comparison_table:
|
||||
builder_options:
|
||||
item_parser: compare_row_2col_item # NEW parser — top_bullet → {label, col_a, col_b}
|
||||
col_a_label_default: "BIM" # F1-a (Codex round 43) — explicit default
|
||||
col_a_label_default_role: placeholder # IMP-40 (#69) — Figma visual placeholder; suppressed at runtime, NOT a fallback
|
||||
col_b_label_default: "DX" # F1-a — explicit default
|
||||
col_b_label_default_role: placeholder # IMP-40 (#69) — Figma visual placeholder; suppressed at runtime, NOT a fallback
|
||||
strip_col_prefix_aliases: # F1-b (Codex round 43) — narrow alias 만 strip
|
||||
- "BIM"
|
||||
- "DX"
|
||||
@@ -1785,8 +1787,11 @@ industry_current_status_three_col:
|
||||
builder_options:
|
||||
item_parser: compare_row_3col_item # NEW parser placeholder — top_bullet → {label, col_a, col_b, col_c}. Peer parity with compare_row_2col_item.
|
||||
col_a_label_default: "제조업" # F30 source column 1 label (analysis.md three_industries anchor set).
|
||||
col_a_label_default_role: placeholder # IMP-40 (#69) — Figma visual placeholder; suppressed at runtime, NOT a fallback
|
||||
col_b_label_default: "건축" # F30 source column 2 label.
|
||||
col_b_label_default_role: placeholder # IMP-40 (#69) — Figma visual placeholder; suppressed at runtime, NOT a fallback
|
||||
col_c_label_default: "토목" # F30 source column 3 label (강조 테두리 빨간색 cue — visual styling).
|
||||
col_c_label_default_role: placeholder # IMP-40 (#69) — Figma visual placeholder; suppressed at runtime, NOT a fallback
|
||||
max_rows: 12 # typical 4-6, overflow 보호 (peer parity with compare_table_2col max_rows 12).
|
||||
|
||||
|
||||
@@ -1847,6 +1852,9 @@ industry_characteristics_three_col:
|
||||
builder_options:
|
||||
item_parser: compare_row_3col_item # Shared parser with industry_current_status_three_col (F30) — top_bullet → {label, col_a, col_b, col_c}. Peer parity with compare_row_2col_item.
|
||||
col_a_label_default: "제조업" # F31 source column 1 label (analysis.md three_industries anchor set; shared with F30).
|
||||
col_a_label_default_role: placeholder # IMP-40 (#69) — Figma visual placeholder; suppressed at runtime, NOT a fallback
|
||||
col_b_label_default: "건축" # F31 source column 2 label (shared with F30).
|
||||
col_b_label_default_role: placeholder # IMP-40 (#69) — Figma visual placeholder; suppressed at runtime, NOT a fallback
|
||||
col_c_label_default: "토목" # F31 source column 3 label (강조 테두리 빨간색 cue — visual styling; shared with F30).
|
||||
col_c_label_default_role: placeholder # IMP-40 (#69) — Figma visual placeholder; suppressed at runtime, NOT a fallback
|
||||
max_rows: 12 # typical 3-4 (F31 compressed view), overflow 보호 (peer parity with compare_table_2col + F30 compare_table_3col max_rows 12).
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# IMP-39 single-source ranking sort policy — backend ↔ frontend mirror.
|
||||
#
|
||||
# 도입 배경 (issue #68):
|
||||
# Backend `lookup_v4_match_with_fallback` 는 V4 raw confidence-desc 순서로
|
||||
# first-eligible 선택 (label_priority 무시). Frontend `designAgentApi.ts` 는
|
||||
# 동일 source 를 (label_priority asc, confidence desc) 로 재정렬 후 slice.
|
||||
# 결과: 낮은-confidence 높은-priority label 이 raw 상 뒤에 있을 때
|
||||
# backend "rank 1 selected" ≠ frontend `frame_candidates[0]` divergence.
|
||||
#
|
||||
# 정책 결정 (Stage 1~2 LOCK, 4 round 합의):
|
||||
# - 단일 source 위치 = 본 yaml (catalog hot-reload + frontend mirror 가능)
|
||||
# - frame_contracts.yaml / v4_fallback_policy.yaml 오염 회피 (분리 파일)
|
||||
# - 정렬 axes = (label_priority asc, confidence desc, v4_rank asc)
|
||||
# - tie-break = 원본 v4_rank 보존 (frontend LABEL_PRIORITY 와 1:1)
|
||||
#
|
||||
# 적용 path:
|
||||
# - backend: src/phase_z2_pipeline.py `apply_ranking_sort` (helper, u1)
|
||||
# + `lookup_v4_match_with_fallback` selector loop (u2)
|
||||
# + `_build_application_plan_unit` Step 9 payload (u3)
|
||||
# - frontend: Front/client/src/services/designAgentApi.ts (u4)
|
||||
# → unit.ranking_sort_policy + unit.sorted_candidate_evidence 우선 read
|
||||
# → local LABEL_PRIORITY 는 warn-fallback only
|
||||
|
||||
policy_type: deterministic_label_priority_then_confidence
|
||||
|
||||
# label_priority:
|
||||
# lower value = higher priority (use_as_is 가 첫 후보)
|
||||
# sort key = (label_priority asc, confidence desc, v4_rank asc)
|
||||
label_priority:
|
||||
use_as_is: 0
|
||||
light_edit: 1
|
||||
restructure: 2
|
||||
reject: 3
|
||||
|
||||
# unknown_label_priority:
|
||||
# label 이 위 매트릭스에 없을 시 부여되는 우선순위 (최하위 push).
|
||||
# frontend `LABEL_PRIORITY[label] ?? 99` 와 1:1.
|
||||
unknown_label_priority: 99
|
||||
|
||||
# tie_break_axes:
|
||||
# 동일 label_priority 시 적용 순서 — frontend mirror 와 1:1.
|
||||
# confidence_desc: 큰 confidence 가 앞
|
||||
# v4_rank_asc: 동일 confidence 시 raw v4 rank (1, 2, 3 ...) 작은 게 앞
|
||||
tie_break_axes:
|
||||
- confidence_desc
|
||||
- v4_rank_asc
|
||||
|
||||
# graceful fallback (yaml 없을 시):
|
||||
# loader 가 default policy_type=deterministic_label_priority_then_confidence
|
||||
# + 위 label_priority 매트릭스 로 fall through (backward compat / boot-safe).
|
||||
@@ -0,0 +1,242 @@
|
||||
<!-- Phase Z-2 MVP-1.5b frame-derived adapted block. -->
|
||||
{#
|
||||
─────────────────────────────────────────────────────────────────────────────
|
||||
Visual Provenance — figma_to_html_agent/blocks/1171281203/ (frame 23)
|
||||
─────────────────────────────────────────────────────────────────────────────
|
||||
Frame 23 = "Application S/W 의 구분".
|
||||
구조 (figma 1:1) :
|
||||
① title gradient (Application S/W = orange→brown, 의 구분 = green→black)
|
||||
② CSS Grid table (label col + col_a + col_b)
|
||||
③ header row :
|
||||
- col 1 (label) = teal #589e8d
|
||||
- col 2 (Package) = teal #589e8d
|
||||
- col 3 (Solution) = orange #ef7a26
|
||||
④ N data rows : zebra (odd 흰 0.85 / even peach rgba(253,198,158,0.2))
|
||||
- 각 row : 좌측 label cell + 좌 data + 우 data
|
||||
- cell border : 1.5px solid #888
|
||||
⑤ bullet (•) + accent (.hl orange #a14101, .big 50px 강조)
|
||||
|
||||
slots (bim_dx_comparison_table 와 동일 — builder = process_product_pair) :
|
||||
- title : section.title (zone 중목차)
|
||||
- col_a_label / col_b_label : h3 title (Package / Solution 또는 mdx h3 title)
|
||||
- col_a_body.sections[].title : subsection title (= row label)
|
||||
- col_a_body.sections[].text_lines / transforms
|
||||
- col_b_body : same
|
||||
|
||||
Axis A+B fit — container query (cqh) + jinja line count 산식.
|
||||
─────────────────────────────────────────────────────────────────────────────
|
||||
#}
|
||||
|
||||
<style>
|
||||
.f23b {
|
||||
width: 100%; height: 100%;
|
||||
display: flex; flex-direction: column;
|
||||
gap: 4px;
|
||||
font-family: 'Noto Sans KR', 'Pretendard', sans-serif;
|
||||
word-break: keep-all;
|
||||
container-type: size;
|
||||
container-name: f23b-root;
|
||||
}
|
||||
/* 중목차 (zone title) — gradient 보존, 사용자 룰 (미변경). */
|
||||
.f23b__title {
|
||||
font-size: var(--font-zone-title);
|
||||
font-weight: 700;
|
||||
line-height: var(--lh-zone-title);
|
||||
background-image: linear-gradient(180deg, #000 0%, #883700 100%);
|
||||
-webkit-background-clip: text; background-clip: text;
|
||||
color: transparent;
|
||||
flex-shrink: 0;
|
||||
filter: drop-shadow(0 0 3px rgba(50,44,30,0.4));
|
||||
}
|
||||
|
||||
/* CSS Grid table — 사용자 lock 2026-05-15 : 의미 없는 label col (구분/숫자)
|
||||
제거, 2 col 로 전체 폭 활용. */
|
||||
.f23b__tbl {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-auto-rows: minmax(0, auto);
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: clamp(4px, 1cqh, 8px);
|
||||
border: 1px solid #888;
|
||||
}
|
||||
/* 회전 비활성 — 사용자 lock 2026-05-15 : 03-2 는 2 col table 유지. */
|
||||
|
||||
/* header row */
|
||||
.f23b__th {
|
||||
padding: clamp(4px, 1.5cqh, 10px) clamp(4px, 1cqw, 8px);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: var(--font-sub-title);
|
||||
line-height: 1.15;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
letter-spacing: -0.03em;
|
||||
border-right: 1px solid #888;
|
||||
}
|
||||
.f23b__th:last-child { border-right: none; }
|
||||
.f23b__th--label,
|
||||
.f23b__th--a { background: #589e8d; } /* figma teal */
|
||||
.f23b__th--b { background: #ef7a26; } /* figma orange */
|
||||
|
||||
/* data row cells */
|
||||
.f23b__td {
|
||||
padding: clamp(4px, 1.2cqh, 10px) clamp(4px, 1cqw, 10px);
|
||||
border-top: 1px solid #888;
|
||||
border-right: 1px solid #888;
|
||||
color: #000;
|
||||
font-size: var(--font-body);
|
||||
line-height: clamp(1.2em, calc(70cqh / var(--max-body-lines, 6)), 1.6em);
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
.f23b__td:last-child { border-right: none; }
|
||||
.f23b__td--label {
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: var(--font-sub-title);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* zebra — 첫 번째 data row 가 row N, 두 번째 row 가 N+1 ... */
|
||||
.f23b__row--odd .f23b__td { background: rgba(255,255,255,0.85); }
|
||||
.f23b__row--even .f23b__td { background: rgba(253,198,158,0.2); }
|
||||
|
||||
/* bullet + accent */
|
||||
.f23b__td .text-line {
|
||||
position: relative;
|
||||
padding-left: clamp(12px, 2cqw, 18px);
|
||||
}
|
||||
.f23b__td .text-line::before {
|
||||
content: "•";
|
||||
position: absolute;
|
||||
left: clamp(2px, 0.5cqw, 6px); top: 0;
|
||||
color: #555;
|
||||
font-weight: 700;
|
||||
line-height: inherit;
|
||||
}
|
||||
.f23b__td .text-line strong { font-weight: 700; color: #a14101; } /* figma .hl */
|
||||
.f23b__td .accent { font-weight: 700; color: #a14101; }
|
||||
.f23b__td .text-line--sub { padding-left: clamp(20px, 4cqw, 28px); color: #444; }
|
||||
.f23b__td .text-line--sub::before {
|
||||
content: "\25B8"; /* ▸ */
|
||||
left: clamp(8px, 1.5cqw, 14px);
|
||||
color: #888;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* AS-IS/TO-BE mini transforms (콘텐츠 무손실 + 내용 기반 폭). 사용자 lock
|
||||
2026-05-15 : 표 안 내용이 좌측정렬 환경이라 mini table 도 좌측 정렬.
|
||||
cell 안 텍스트는 그대로 center align. */
|
||||
.f23b__transforms {
|
||||
display: table;
|
||||
margin-left: 0;
|
||||
margin-right: auto;
|
||||
border-collapse: separate;
|
||||
border-spacing: 6px 1px;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
.f23b__transforms-head,
|
||||
.f23b__transforms-row { display: table-row; }
|
||||
.f23b__transforms-head > * {
|
||||
display: table-cell;
|
||||
text-align: center;
|
||||
font-weight: 900;
|
||||
font-size: 0.85em;
|
||||
color: #6b4423;
|
||||
padding: 0 4px 1px;
|
||||
border-bottom: 1px solid rgba(107,68,35,0.3);
|
||||
}
|
||||
.f23b__transforms-cell,
|
||||
.f23b__transforms-arrow {
|
||||
display: table-cell;
|
||||
text-align: center;
|
||||
padding: 1px 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.f23b__transforms-cell { color: #1a1a1a; font-weight: 500; }
|
||||
.f23b__transforms-cell--to { color: #a14101; font-weight: 700; }
|
||||
.f23b__transforms-arrow { font-weight: 700; color: #9c6f3f; }
|
||||
|
||||
/* row title (subsection label) — figma 의 좌측 td-label 같은 row-bound label */
|
||||
.f23b__item-title {
|
||||
font-weight: 900;
|
||||
color: #6b4423;
|
||||
margin-bottom: 2px;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="f23b" data-frame-id="1171281203" data-template-id="app_sw_package_vs_solution">
|
||||
|
||||
<div class="f23b__title">{{ slot_payload.title }}</div>
|
||||
|
||||
<div class="f23b__tbl">
|
||||
{# header row — 2 col (label col 제거, 사용자 lock 2026-05-15) #}
|
||||
<div class="f23b__th f23b__th--a">{{ slot_payload.col_a_label | safe }}</div>
|
||||
<div class="f23b__th f23b__th--b">{{ slot_payload.col_b_label | safe }}</div>
|
||||
|
||||
{# data rows — col_a_body.sections 와 col_b_body.sections 를 row 별 페어 #}
|
||||
{% set a_secs = slot_payload.col_a_body.sections or [] %}
|
||||
{% set b_secs = slot_payload.col_b_body.sections or [] %}
|
||||
{% set row_count = [a_secs | length, b_secs | length] | max %}
|
||||
{% for i in range(row_count) %}
|
||||
{% set a = a_secs[i] if i < (a_secs | length) else none %}
|
||||
{% set b = b_secs[i] if i < (b_secs | length) else none %}
|
||||
{% set row_class = 'f23b__row--' ~ ('odd' if loop.index0 % 2 == 0 else 'even') %}
|
||||
|
||||
{# col_a cell #}
|
||||
<div class="f23b__td {{ row_class }}" style="--max-body-lines: {{ (a.text_lines | length if a and a.text_lines else 1) + (a.transforms | length if a and a.transforms else 0) }};">
|
||||
{% if a %}
|
||||
{% if a.title %}<div class="f23b__item-title">{{ a.title | safe }}</div>{% endif %}
|
||||
{% if a.transforms %}
|
||||
<div class="f23b__transforms">
|
||||
<div class="f23b__transforms-head">
|
||||
<span class="f23b__transforms-from">AS-IS</span>
|
||||
<span></span>
|
||||
<span class="f23b__transforms-to">TO-BE</span>
|
||||
</div>
|
||||
{% for t in a.transforms %}
|
||||
<div class="f23b__transforms-row">
|
||||
<div class="f23b__transforms-cell f23b__transforms-cell--from">{{ t.from | safe }}</div>
|
||||
<div class="f23b__transforms-arrow">➜</div>
|
||||
<div class="f23b__transforms-cell f23b__transforms-cell--to">{{ t.to | safe }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elif a.text_lines %}
|
||||
{% for line in a.text_lines %}<div class="text-line{% if line.indent and line.indent > 0 %} text-line--sub{% endif %}">{{ line.text | safe }}</div>{% endfor %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# col_b cell #}
|
||||
<div class="f23b__td {{ row_class }}" style="--max-body-lines: {{ (b.text_lines | length if b and b.text_lines else 1) + (b.transforms | length if b and b.transforms else 0) }};">
|
||||
{% if b %}
|
||||
{% if b.title %}<div class="f23b__item-title">{{ b.title | safe }}</div>{% endif %}
|
||||
{% if b.transforms %}
|
||||
<div class="f23b__transforms">
|
||||
<div class="f23b__transforms-head">
|
||||
<span class="f23b__transforms-from">AS-IS</span>
|
||||
<span></span>
|
||||
<span class="f23b__transforms-to">TO-BE</span>
|
||||
</div>
|
||||
{% for t in b.transforms %}
|
||||
<div class="f23b__transforms-row">
|
||||
<div class="f23b__transforms-cell f23b__transforms-cell--from">{{ t.from | safe }}</div>
|
||||
<div class="f23b__transforms-arrow">➜</div>
|
||||
<div class="f23b__transforms-cell f23b__transforms-cell--to">{{ t.to | safe }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elif b.text_lines %}
|
||||
{% for line in b.text_lines %}<div class="text-line{% if line.indent and line.indent > 0 %} text-line--sub{% endif %}">{{ line.text | safe }}</div>{% endfor %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,194 @@
|
||||
<!-- Phase Z-2 신규 frame partial — Frame 9 (1171281180) pre_construction_model_info_stacked.
|
||||
2026-05-14 — V4 04-1 top rank-1 매칭 (의미 conf=0.722) + structure cardinality 5 일치.
|
||||
사용자 룰 부합 : Figma visual (계단 pill / 5 색상 / vertical label) 유지 + 콘텐츠 cardinality
|
||||
에 맞춰 보완 (pill 안 1 라인 → multi-line: pill_N_label + pill_N_body). -->
|
||||
{#
|
||||
─────────────────────────────────────────────────────────────────────────────
|
||||
Visual Provenance — figma_to_html_agent/blocks/1171281180/index.html
|
||||
─────────────────────────────────────────────────────────────────────────────
|
||||
PROMOTED (Figma 1:1):
|
||||
- title-bar background : #fbd5b9 (line 43) + box-shadow
|
||||
- title text gradient : #cc5200 (line 128) 강조부
|
||||
- vertical label color : #144838 (line 68) + text-shadow drop
|
||||
- pill background : rgba(255,255,255,0.5) (line 88)
|
||||
- pill border-radius : 30px (line 89)
|
||||
- pill box-shadow : 2px 4px 5px rgba(0,0,0,0.5) (line 90)
|
||||
- 5 pill border-bottom 색상 (line 141, 147, 153, 159, 165):
|
||||
1: #fb5915, 2: #e79000, 3: #e9a804, 4: #919f00, 5: #0d6361
|
||||
|
||||
ADAPTED (zone flex 재구성):
|
||||
- 1153×592 absolute → flex column layout
|
||||
- pill height 70px hardcode → dynamic (콘텐츠 양에 따라 auto)
|
||||
- 계단 배치 (width/margin-left hardcode) → 가운데 정렬 + width 100%
|
||||
(계단은 1 라인 짧은 텍스트 전제, multi-line 콘텐츠엔 부적합)
|
||||
- 1 pill 1 라인 → pill_N_label (title) + pill_N_body (text_lines)
|
||||
(사용자 룰 = frame 구조를 콘텐츠에 맞춰 보완)
|
||||
|
||||
NOT PROMOTED:
|
||||
- 좌측 arc-deco SVG (장식 — 공간 절약 위해 단순화)
|
||||
- arrow PNG (CSS triangle 대체 가능, 일단 ▶ 문자 사용)
|
||||
|
||||
slots : title + pill_N_label / pill_N_body (N = 1.._slot_count) — dynamic.
|
||||
#}
|
||||
|
||||
<style>
|
||||
.f9b {
|
||||
width: 100%; height: 100%;
|
||||
display: flex; flex-direction: column;
|
||||
gap: 6px;
|
||||
font-family: 'Noto Sans KR', 'Pretendard', sans-serif;
|
||||
word-break: keep-all;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 2026-05-14 — title 스타일 통일 (frame 29 process_product_two_way 와 동일 패턴) :
|
||||
배경 box + box-shadow 제거, gradient 텍스트 만 유지. 사용자 룰 Q1. */
|
||||
.f9b__title {
|
||||
font-size: var(--font-zone-title);
|
||||
font-weight: 700;
|
||||
line-height: var(--lh-zone-title);
|
||||
background-image: linear-gradient(180deg, #000 0%, #883700 100%);
|
||||
-webkit-background-clip: text; background-clip: text;
|
||||
color: transparent;
|
||||
flex-shrink: 0;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.f9b__body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* 좌측 vertical label — 콘텐츠 있을 때만 표시 */
|
||||
.f9b__vlabel {
|
||||
flex: 0 0 auto;
|
||||
width: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: var(--font-sub-title);
|
||||
line-height: 1.2;
|
||||
color: #144838;
|
||||
text-align: center;
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: upright;
|
||||
text-shadow: 0 2px 2px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
/* 2026-05-14 — pill 배치 vertical → horizontal (grid). 5 pills 를 가로로 나열 →
|
||||
우측 공란 활용 + 세로 공간 절약. auto-fit + minmax 으로 N 동적.
|
||||
gap 넓찍 (사용자 lock 2026-05-14). */
|
||||
.f9b__pill-rows {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* 2026-05-14 — bullet 줄간격 1/3 으로 압축 + 다른 간격 (padding, label↔body) 살짝 늘림. */
|
||||
.f9b__pill {
|
||||
min-height: 0;
|
||||
background: rgba(255,255,255,0.7);
|
||||
border-radius: 10px;
|
||||
border-bottom: 3px solid;
|
||||
box-shadow: 1px 2px 4px rgba(0,0,0,0.15);
|
||||
padding: 8px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
overflow: hidden;
|
||||
align-self: start;
|
||||
}
|
||||
/* Figma frame 9 의 5 색상 — nth-child cycle. N>5 면 default border-bottom 색 (회색). */
|
||||
.f9b__pill:nth-child(5n+1) { border-bottom-color: #fb5915; }
|
||||
.f9b__pill:nth-child(5n+2) { border-bottom-color: #e79000; }
|
||||
.f9b__pill:nth-child(5n+3) { border-bottom-color: #e9a804; }
|
||||
.f9b__pill:nth-child(5n+4) { border-bottom-color: #919f00; }
|
||||
.f9b__pill:nth-child(5n) { border-bottom-color: #0d6361; }
|
||||
|
||||
.f9b__pill-label {
|
||||
font-size: var(--font-sub-title);
|
||||
font-weight: 900;
|
||||
line-height: 1.2;
|
||||
color: #144838;
|
||||
margin-bottom: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
/* arrow indicator — Figma frame 9 의 좌측 화살표 (▶) approximation */
|
||||
.f9b__pill-label::before {
|
||||
content: "▶";
|
||||
flex-shrink: 0;
|
||||
color: currentColor;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* 2026-05-14 — bullet 줄간격 1/3 으로 압축 : line-height 1.3 → 1.05 (약 1/3 감소).
|
||||
bullet 간 gap 2 → 1 로 더 압축. 가독성 임계 (한 bullet 안 line wrap 시 라인 겹침 직전). */
|
||||
.f9b__pill-body {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
color: #0c271e;
|
||||
font-size: var(--font-body);
|
||||
line-height: 1.05;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
/* 첫 line = 인용구 (italic + 무 마커) — 04-1 의 따옴표 인용구 패턴 */
|
||||
.f9b__pill-body .text-line:first-child {
|
||||
font-style: italic;
|
||||
color: #5C3714;
|
||||
padding-left: 0;
|
||||
}
|
||||
/* 이후 lines = 각 이슈 bullet (▪ 마커로 구분 강화) */
|
||||
.f9b__pill-body .text-line:not(:first-child) {
|
||||
position: relative;
|
||||
padding-left: 12px;
|
||||
}
|
||||
.f9b__pill-body .text-line:not(:first-child)::before {
|
||||
content: "▪";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: #919f00;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="f9b" data-frame-id="1171281180" data-template-id="pre_construction_model_info_stacked">
|
||||
<div class="f9b__title">{{ slot_payload.title }}</div>
|
||||
<div class="f9b__body">
|
||||
{# vertical label — slot_payload.vlabel 있을 때만 표시 (현재 v0 미사용) #}
|
||||
{% if slot_payload.vlabel %}
|
||||
<div class="f9b__vlabel">{{ slot_payload.vlabel | safe }}</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="f9b__pill-rows">
|
||||
{% set slot_count = slot_payload._slot_count or 5 %}
|
||||
{% for n in range(1, slot_count + 1) %}
|
||||
{% set label = slot_payload['pill_' ~ n ~ '_label'] %}
|
||||
{% set body = slot_payload['pill_' ~ n ~ '_body'] %}
|
||||
{% if label or body %}
|
||||
<div class="f9b__pill" data-frame-slot-id="pill_dynamic" data-pill-n="{{ n }}">
|
||||
{% if label %}
|
||||
<div class="f9b__pill-label">{{ label | safe }}</div>
|
||||
{% endif %}
|
||||
{% if body %}
|
||||
<div class="f9b__pill-body">
|
||||
{% for line in body %}<div class="text-line{% if line.indent and line.indent > 0 %} text-line--indent-{{ line.indent }}{% endif %}">{{ line.text | safe }}</div>{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -114,42 +114,10 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ── IMP-30 u5 : provisional zone marker (first-render invariant) ──
|
||||
When V4 rank-1 candidate falls outside MVP1_ALLOWED_STATUSES (chain_exhausted)
|
||||
the pipeline still renders the rank-1 frame so the first-render invariant
|
||||
holds, but the zone is tagged `provisional` so the user/AI can adapt later
|
||||
(IMP-31). Visual contract:
|
||||
- dashed amber border + striped wash → "needs adaptation" at a glance
|
||||
- inline badge top-right → text label for non-color-perceiving readers
|
||||
MDX content is preserved as-is; no shrink, no rewrite. */
|
||||
.zone--provisional {
|
||||
outline: 2px dashed #b8860b;
|
||||
outline-offset: -2px;
|
||||
background-image: repeating-linear-gradient(
|
||||
45deg,
|
||||
rgba(184, 134, 11, 0.04) 0,
|
||||
rgba(184, 134, 11, 0.04) 8px,
|
||||
transparent 8px,
|
||||
transparent 16px
|
||||
);
|
||||
}
|
||||
.zone--provisional .zone__needs-adaptation-badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
z-index: 10;
|
||||
padding: 2px 6px;
|
||||
background: #b8860b;
|
||||
color: #fff;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0.04em;
|
||||
border-radius: 2px;
|
||||
text-transform: uppercase;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
/* IMP-84: provisional zone visual treatment removed (silent-automation
|
||||
policy). `data-provisional="1"` attribute is still emitted on the
|
||||
zone div as silent telemetry for downstream selectors / inspection;
|
||||
no user-visible outline, wash, or badge. */
|
||||
|
||||
/* ── Frame-family text layout contract (shared, reusable) ──
|
||||
feedback-1 (mvp1.5b_test7): visible improvement 강화.
|
||||
@@ -355,6 +323,38 @@
|
||||
line-height: 1.5;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
/* ── IMP-90 u17 : print mode (Step 22 user-edit + Export).
|
||||
Companion JS at body end opens popups so FULL raw_content prints. */
|
||||
@media print {
|
||||
@page { size: 1280px 720px; margin: 0; }
|
||||
html, body {
|
||||
background: #fff !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
min-height: 0 !important;
|
||||
display: block !important;
|
||||
}
|
||||
.slide {
|
||||
box-shadow: none !important;
|
||||
page-break-inside: avoid;
|
||||
break-inside: avoid;
|
||||
}
|
||||
.zone__popup-summary { display: none !important; }
|
||||
.zone__popup-details,
|
||||
.zone__popup-details[open] { position: static !important; }
|
||||
.zone__popup-body {
|
||||
position: static !important;
|
||||
top: auto !important;
|
||||
right: auto !important;
|
||||
max-height: none !important;
|
||||
overflow: visible !important;
|
||||
box-shadow: none !important;
|
||||
border: none !important;
|
||||
width: auto !important;
|
||||
padding: 6px 0 0 0 !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -366,8 +366,7 @@
|
||||
<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 %}{% 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 %}
|
||||
<div class="zone" 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 }};">
|
||||
{{ zone.partial_html | safe }}
|
||||
{% if zone.has_popup %}
|
||||
{% set _popup_trigger = (zone.popup_binding.detail_trigger if zone.popup_binding else None) or {} %}
|
||||
@@ -389,5 +388,22 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<script>
|
||||
// IMP-90 u17 — beforeprint popup auto-expand (CLAUDE.md 자세히보기 contract).
|
||||
// Body-level handler (outside any per-zone popup block) so the popup-render
|
||||
// JS-free invariant (IMP-35 u8) is preserved on the per-zone path.
|
||||
window.addEventListener('beforeprint', function () {
|
||||
document.querySelectorAll('details').forEach(function (d) {
|
||||
d.dataset.imp90PrintRestore = d.open ? '1' : '0';
|
||||
d.open = true;
|
||||
});
|
||||
});
|
||||
window.addEventListener('afterprint', function () {
|
||||
document.querySelectorAll('details').forEach(function (d) {
|
||||
if (d.dataset.imp90PrintRestore === '0') d.open = false;
|
||||
delete d.dataset.imp90PrintRestore;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"_doc": "IMP-91 u9 — F3 classifier-only AI axis. Pin observed step12 per-unit classifier label / route_hint / AI-isolation flags + coverage_invariant + step15 fit_classification + step16 router_active + step18 failure_type. Default-OFF AI invariant ([[feedback_ai_isolation_contract]]): ai_called MUST be False for every unit unless AI_FALLBACK_ENABLED is flipped via .env (not via pipeline default). If any unit flips ai_called=True silently, this snapshot fails loudly per [[feedback_demo_env_toggle_policy]].",
|
||||
"01": {
|
||||
"units": [
|
||||
{"source_section_ids": ["01-2"], "label": "use_as_is", "route_hint": "direct_render", "provisional": false, "ai_called": false, "skip_reason": "not_provisional", "apply_status": "no_proposal"},
|
||||
{"source_section_ids": ["01-1"], "label": "use_as_is", "route_hint": "direct_render", "provisional": false, "ai_called": false, "skip_reason": "not_provisional", "apply_status": "no_proposal"}
|
||||
],
|
||||
"coverage_invariant_status": "ok",
|
||||
"fit_visual_check_passed": true,
|
||||
"fit_classifications_count": 0,
|
||||
"fit_categories_seen": [],
|
||||
"router_active": false,
|
||||
"router_routed_count": 0,
|
||||
"router_v4_fallback_used_count": 0,
|
||||
"failure_type": "not_attempted"
|
||||
},
|
||||
"02": {
|
||||
"units": [
|
||||
{"source_section_ids": ["02-1"], "label": "use_as_is", "route_hint": "direct_render", "provisional": false, "ai_called": false, "skip_reason": "not_provisional", "apply_status": "no_proposal"},
|
||||
{"source_section_ids": ["02-2-sub-1", "02-2-sub-2"], "label": "use_as_is", "route_hint": "direct_render", "provisional": true, "ai_called": false, "skip_reason": "route_not_ai_adaptation:direct_render", "apply_status": "no_proposal"}
|
||||
],
|
||||
"coverage_invariant_status": "ok",
|
||||
"fit_visual_check_passed": true,
|
||||
"fit_classifications_count": 0,
|
||||
"fit_categories_seen": [],
|
||||
"router_active": false,
|
||||
"router_routed_count": 0,
|
||||
"router_v4_fallback_used_count": 0,
|
||||
"failure_type": "not_attempted"
|
||||
},
|
||||
"03": {
|
||||
"units": [
|
||||
{"source_section_ids": ["03-1"], "label": "use_as_is", "route_hint": "direct_render", "provisional": false, "ai_called": false, "skip_reason": "not_provisional", "apply_status": "no_proposal"},
|
||||
{"source_section_ids": ["03-2"], "label": "use_as_is", "route_hint": "direct_render", "provisional": false, "ai_called": false, "skip_reason": "not_provisional", "apply_status": "no_proposal"}
|
||||
],
|
||||
"coverage_invariant_status": "ok",
|
||||
"fit_visual_check_passed": true,
|
||||
"fit_classifications_count": 0,
|
||||
"fit_categories_seen": [],
|
||||
"router_active": false,
|
||||
"router_routed_count": 0,
|
||||
"router_v4_fallback_used_count": 0,
|
||||
"failure_type": "not_attempted"
|
||||
},
|
||||
"04": {
|
||||
"units": [
|
||||
{"source_section_ids": ["04-2-sub-2"], "label": "light_edit", "route_hint": "deterministic_minor_adjustment", "provisional": false, "ai_called": false, "skip_reason": "not_provisional", "apply_status": "no_proposal"},
|
||||
{"source_section_ids": ["04-2-sub-1"], "label": "restructure", "route_hint": "ai_adaptation_required", "provisional": true, "ai_called": false, "skip_reason": "router_short_circuit", "apply_status": "no_proposal"},
|
||||
{"source_section_ids": ["04-1"], "label": "reject", "route_hint": "ai_adaptation_required", "provisional": true, "ai_called": false, "skip_reason": "router_short_circuit", "apply_status": "no_proposal"}
|
||||
],
|
||||
"coverage_invariant_status": "ok",
|
||||
"fit_visual_check_passed": true,
|
||||
"fit_classifications_count": 0,
|
||||
"fit_categories_seen": [],
|
||||
"router_active": false,
|
||||
"router_routed_count": 0,
|
||||
"router_v4_fallback_used_count": 0,
|
||||
"failure_type": "not_attempted"
|
||||
},
|
||||
"05": {
|
||||
"units": [
|
||||
{"source_section_ids": ["05-1", "05-2-sub-1", "05-2-sub-2"], "label": "empty_shell", "route_hint": null, "provisional": true, "ai_called": false, "skip_reason": "route_not_ai_adaptation:None", "apply_status": "no_proposal"}
|
||||
],
|
||||
"coverage_invariant_status": "ok",
|
||||
"fit_visual_check_passed": true,
|
||||
"fit_classifications_count": 0,
|
||||
"fit_categories_seen": [],
|
||||
"router_active": false,
|
||||
"router_routed_count": 0,
|
||||
"router_v4_fallback_used_count": 0,
|
||||
"failure_type": "not_attempted"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"_doc": "IMP-#91 u5 — full_mdx_coverage / aligned_section_ids / covered_section_ids / filtered_section_ids snapshot pinned from observed step20_slide_status.json across MDX_SET (mdx 01-05). Drift = real change in coverage outcome; re-baseline only with conscious explanation in commit body.",
|
||||
"01": {
|
||||
"full_mdx_coverage": true,
|
||||
"rendered": true,
|
||||
"visual_check_passed": true,
|
||||
"aligned_section_ids": ["01-1", "01-2"],
|
||||
"covered_section_ids": ["01-1", "01-2"],
|
||||
"filtered_section_ids": []
|
||||
},
|
||||
"02": {
|
||||
"full_mdx_coverage": true,
|
||||
"rendered": true,
|
||||
"visual_check_passed": true,
|
||||
"aligned_section_ids": ["02-1", "02-2-sub-1", "02-2-sub-2"],
|
||||
"covered_section_ids": ["02-1", "02-2-sub-1", "02-2-sub-2"],
|
||||
"filtered_section_ids": []
|
||||
},
|
||||
"03": {
|
||||
"full_mdx_coverage": true,
|
||||
"rendered": true,
|
||||
"visual_check_passed": true,
|
||||
"aligned_section_ids": ["03-1", "03-2"],
|
||||
"covered_section_ids": ["03-1", "03-2"],
|
||||
"filtered_section_ids": []
|
||||
},
|
||||
"04": {
|
||||
"full_mdx_coverage": true,
|
||||
"rendered": true,
|
||||
"visual_check_passed": true,
|
||||
"aligned_section_ids": ["04-1", "04-2-sub-1", "04-2-sub-2"],
|
||||
"covered_section_ids": ["04-1", "04-2-sub-1", "04-2-sub-2"],
|
||||
"filtered_section_ids": []
|
||||
},
|
||||
"05": {
|
||||
"full_mdx_coverage": false,
|
||||
"rendered": true,
|
||||
"visual_check_passed": true,
|
||||
"aligned_section_ids": ["05-1", "05-2-sub-1", "05-2-sub-2"],
|
||||
"covered_section_ids": ["05-1", "05-2-sub-1", "05-2-sub-2"],
|
||||
"filtered_section_ids": ["05-1", "05-2-sub-1", "05-2-sub-2"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"_doc": "IMP-91 u11 — F5 final.html extraction axis. Pin step13_render.json metadata (step_status / pipeline_path_connected / render_inputs.zones_count / render_inputs.layout_preset / slide_title|footer non-empty / final_html_size_bytes) AND structural markers extracted from the on-disk final.html (HTML <title>, slide root count, slide-footer presence, data-zone-position/data-template-id topology). The HTML-extracted zone topology MUST match the step12 slot_payload (position, template_id) sequence already pinned in slot_payload.json (u8) — Jinja2 renders from step12, not step09, so step12 is the correct upstream parity source (step09 selection vs step12 __empty__ collapse is intentional per IMP-87 honesty gate and surfaces in u8). Drift between final.html and slot_payload = render pipeline disconnect. on-disk final.html size_bytes MUST equal step13's reported final_html_size_bytes (byte parity = no truncation / no double-write race).",
|
||||
"01": {
|
||||
"step13_status": "done",
|
||||
"step13_pipeline_path_connected": true,
|
||||
"render_inputs_zones_count": 2,
|
||||
"render_inputs_layout_preset": "horizontal-2",
|
||||
"render_inputs_slide_title_nonempty": true,
|
||||
"render_inputs_slide_footer_nonempty": true,
|
||||
"html_title_matches_render_input": true,
|
||||
"html_slide_root_count": 1,
|
||||
"html_slide_footer_present": true,
|
||||
"html_zone_count": 2,
|
||||
"html_zone_topology": [
|
||||
{"position": "top", "template_id": "bim_dx_comparison_table"},
|
||||
{"position": "bottom", "template_id": "construction_bim_three_usage"}
|
||||
],
|
||||
"final_html_size_matches_step13_reported": true
|
||||
},
|
||||
"02": {
|
||||
"step13_status": "done",
|
||||
"step13_pipeline_path_connected": true,
|
||||
"render_inputs_zones_count": 2,
|
||||
"render_inputs_layout_preset": "horizontal-2",
|
||||
"render_inputs_slide_title_nonempty": true,
|
||||
"render_inputs_slide_footer_nonempty": true,
|
||||
"html_title_matches_render_input": true,
|
||||
"html_slide_root_count": 1,
|
||||
"html_slide_footer_present": true,
|
||||
"html_zone_count": 2,
|
||||
"html_zone_topology": [
|
||||
{"position": "top", "template_id": "construction_goals_three_circle_intersection"},
|
||||
{"position": "bottom", "template_id": "__empty__"}
|
||||
],
|
||||
"final_html_size_matches_step13_reported": true
|
||||
},
|
||||
"03": {
|
||||
"step13_status": "done",
|
||||
"step13_pipeline_path_connected": true,
|
||||
"render_inputs_zones_count": 2,
|
||||
"render_inputs_layout_preset": "vertical-2",
|
||||
"render_inputs_slide_title_nonempty": true,
|
||||
"render_inputs_slide_footer_nonempty": true,
|
||||
"html_title_matches_render_input": true,
|
||||
"html_slide_root_count": 1,
|
||||
"html_slide_footer_present": true,
|
||||
"html_zone_count": 2,
|
||||
"html_zone_topology": [
|
||||
{"position": "left", "template_id": "three_parallel_requirements"},
|
||||
{"position": "right", "template_id": "process_product_two_way"}
|
||||
],
|
||||
"final_html_size_matches_step13_reported": true
|
||||
},
|
||||
"04": {
|
||||
"step13_status": "done",
|
||||
"step13_pipeline_path_connected": true,
|
||||
"render_inputs_zones_count": 3,
|
||||
"render_inputs_layout_preset": "top-1-bottom-2",
|
||||
"render_inputs_slide_title_nonempty": true,
|
||||
"render_inputs_slide_footer_nonempty": true,
|
||||
"html_title_matches_render_input": true,
|
||||
"html_slide_root_count": 1,
|
||||
"html_slide_footer_present": true,
|
||||
"html_zone_count": 3,
|
||||
"html_zone_topology": [
|
||||
{"position": "top", "template_id": "bim_issues_quadrant_four"},
|
||||
{"position": "bottom-left", "template_id": "__empty__"},
|
||||
{"position": "bottom-right", "template_id": "__empty__"}
|
||||
],
|
||||
"final_html_size_matches_step13_reported": true
|
||||
},
|
||||
"05": {
|
||||
"step13_status": "done",
|
||||
"step13_pipeline_path_connected": true,
|
||||
"render_inputs_zones_count": 1,
|
||||
"render_inputs_layout_preset": "single",
|
||||
"render_inputs_slide_title_nonempty": true,
|
||||
"render_inputs_slide_footer_nonempty": true,
|
||||
"html_title_matches_render_input": true,
|
||||
"html_slide_root_count": 1,
|
||||
"html_slide_footer_present": true,
|
||||
"html_zone_count": 1,
|
||||
"html_zone_topology": [
|
||||
{"position": "primary", "template_id": "__empty__"}
|
||||
],
|
||||
"final_html_size_matches_step13_reported": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
{
|
||||
"_doc": "IMP-91 u10 — F4 layout snapshot (step07 + step08). Pins observed layout decision axes (preset / candidates / override / computation / dynamic flags) + planning geometry (heights_px / widths_px / ratios / col_ratios) + per-zone planning shape (position / min_height_px / frame_cardinality_strict / sub_zones_count / region_layout_candidates). step_status='partial' = schema-lock marker per Step 7/8 note (region-level ratio + count-based v0 marker stays a marker, never silently flipped). layout_override_applied=True ONLY for mdx 03 (project_mdx03_frame_lock 2026-05-15 user lock — axis A vertical-2 override). Source: src/phase_z2_pipeline.py step07/step08 emit; auto_layout_preset=None for mdx 05 single-preset path. drift in heights_px/ratios = content_weight_distribution shift; drift in computation = decision-path swap (regression signal axis distinct from preset).",
|
||||
"01": {
|
||||
"step7_step_status": "partial",
|
||||
"step7_pipeline_path_connected": true,
|
||||
"layout_preset": "horizontal-2",
|
||||
"auto_layout_preset": "horizontal-2",
|
||||
"layout_override_applied": false,
|
||||
"zones_count": 2,
|
||||
"unit_count": 2,
|
||||
"layout_candidates": ["horizontal-2", "vertical-2"],
|
||||
"computation": "min_height_first + content_weight_distribution",
|
||||
"dynamic_rows": true,
|
||||
"dynamic_cols": false,
|
||||
"heights_px": [299, 272],
|
||||
"widths_px": [1180],
|
||||
"ratios": [0.511, 0.465],
|
||||
"width_ratios": [1.0],
|
||||
"step8_step_status": "partial",
|
||||
"step8_pipeline_path_connected": true,
|
||||
"zone_heights_px_planned": [299, 272],
|
||||
"zone_widths_px_planned": [1180],
|
||||
"zone_col_ratios_planned": [1.0],
|
||||
"per_zone_layout_shape": [
|
||||
{"position": "top", "min_height_px": 350, "frame_cardinality_strict": 2, "sub_zones_count": 3, "region_layout_candidates": ["region-single"]},
|
||||
{"position": "bottom", "min_height_px": 320, "frame_cardinality_strict": 3, "sub_zones_count": 3, "region_layout_candidates": ["region-single"]}
|
||||
]
|
||||
},
|
||||
"02": {
|
||||
"step7_step_status": "partial",
|
||||
"step7_pipeline_path_connected": true,
|
||||
"layout_preset": "horizontal-2",
|
||||
"auto_layout_preset": "horizontal-2",
|
||||
"layout_override_applied": false,
|
||||
"zones_count": 2,
|
||||
"unit_count": 2,
|
||||
"layout_candidates": ["horizontal-2", "vertical-2"],
|
||||
"computation": "min_height_first + content_weight_distribution",
|
||||
"dynamic_rows": true,
|
||||
"dynamic_cols": false,
|
||||
"heights_px": [273, 298],
|
||||
"widths_px": [1180],
|
||||
"ratios": [0.467, 0.509],
|
||||
"width_ratios": [1.0],
|
||||
"step8_step_status": "partial",
|
||||
"step8_pipeline_path_connected": true,
|
||||
"zone_heights_px_planned": [273, 298],
|
||||
"zone_widths_px_planned": [1180],
|
||||
"zone_col_ratios_planned": [1.0],
|
||||
"per_zone_layout_shape": [
|
||||
{"position": "top", "min_height_px": 320, "frame_cardinality_strict": 3, "sub_zones_count": 4, "region_layout_candidates": ["region-single"]},
|
||||
{"position": "bottom", "min_height_px": 350, "frame_cardinality_strict": 3, "sub_zones_count": 3, "region_layout_candidates": ["region-single"]}
|
||||
]
|
||||
},
|
||||
"03": {
|
||||
"step7_step_status": "partial",
|
||||
"step7_pipeline_path_connected": true,
|
||||
"layout_preset": "vertical-2",
|
||||
"auto_layout_preset": "horizontal-2",
|
||||
"layout_override_applied": true,
|
||||
"zones_count": 2,
|
||||
"unit_count": 2,
|
||||
"layout_candidates": ["horizontal-2", "vertical-2"],
|
||||
"computation": "user_override_geometry",
|
||||
"dynamic_rows": false,
|
||||
"dynamic_cols": true,
|
||||
"heights_px": [585],
|
||||
"widths_px": [408, 758],
|
||||
"ratios": [1.0],
|
||||
"width_ratios": [0.35, 0.65],
|
||||
"step8_step_status": "partial",
|
||||
"step8_pipeline_path_connected": true,
|
||||
"zone_heights_px_planned": [585],
|
||||
"zone_widths_px_planned": [408, 758],
|
||||
"zone_col_ratios_planned": [0.35, 0.65],
|
||||
"per_zone_layout_shape": [
|
||||
{"position": "left", "min_height_px": 230, "frame_cardinality_strict": 3, "sub_zones_count": 3, "region_layout_candidates": ["region-single"]},
|
||||
{"position": "right", "min_height_px": 345, "frame_cardinality_strict": 2, "sub_zones_count": 2, "region_layout_candidates": ["region-single"]}
|
||||
]
|
||||
},
|
||||
"04": {
|
||||
"step7_step_status": "partial",
|
||||
"step7_pipeline_path_connected": true,
|
||||
"layout_preset": "top-1-bottom-2",
|
||||
"auto_layout_preset": "top-1-bottom-2",
|
||||
"layout_override_applied": false,
|
||||
"zones_count": 3,
|
||||
"unit_count": 3,
|
||||
"layout_candidates": ["top-1-bottom-2", "top-2-bottom-1", "left-1-right-2", "left-2-right-1"],
|
||||
"computation": "2d_dynamic_aggregated",
|
||||
"dynamic_rows": true,
|
||||
"dynamic_cols": true,
|
||||
"heights_px": [221, 350],
|
||||
"widths_px": [583, 583],
|
||||
"ratios": [0.378, 0.598],
|
||||
"width_ratios": [0.494, 0.494],
|
||||
"step8_step_status": "partial",
|
||||
"step8_pipeline_path_connected": true,
|
||||
"zone_heights_px_planned": [221, 350],
|
||||
"zone_widths_px_planned": [583, 583],
|
||||
"zone_col_ratios_planned": [0.494, 0.494],
|
||||
"per_zone_layout_shape": [
|
||||
{"position": "top", "min_height_px": null, "frame_cardinality_strict": null, "sub_zones_count": 4, "region_layout_candidates": ["region-single"]},
|
||||
{"position": "bottom-left", "min_height_px": 350, "frame_cardinality_strict": 4, "sub_zones_count": 5, "region_layout_candidates": ["region-single"]},
|
||||
{"position": "bottom-right", "min_height_px": 350, "frame_cardinality_strict": null, "sub_zones_count": 1, "region_layout_candidates": ["region-single"]}
|
||||
]
|
||||
},
|
||||
"05": {
|
||||
"step7_step_status": "partial",
|
||||
"step7_pipeline_path_connected": true,
|
||||
"layout_preset": "single",
|
||||
"auto_layout_preset": null,
|
||||
"layout_override_applied": false,
|
||||
"zones_count": 1,
|
||||
"unit_count": 1,
|
||||
"layout_candidates": ["single"],
|
||||
"computation": "fr_default_from_preset",
|
||||
"dynamic_rows": false,
|
||||
"dynamic_cols": false,
|
||||
"heights_px": [585],
|
||||
"widths_px": [1180],
|
||||
"ratios": [1.0],
|
||||
"width_ratios": [1.0],
|
||||
"step8_step_status": "partial",
|
||||
"step8_pipeline_path_connected": true,
|
||||
"zone_heights_px_planned": [585],
|
||||
"zone_widths_px_planned": [1180],
|
||||
"zone_col_ratios_planned": [1.0],
|
||||
"per_zone_layout_shape": [
|
||||
{"position": "primary", "min_height_px": null, "frame_cardinality_strict": null, "sub_zones_count": 0, "region_layout_candidates": ["region-single"]}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"_doc": "IMP-91 u6 — F0 normalize axis snapshot (step02_normalized.json). Pins observed current state per [[feedback_validation_first_for_closed_issues]] / Stage 1 'do not invent a new expectation'. step_status='partial' is the schema-lock marker for IMP-02/03 (orphans + details detection unimplemented). adapter_enabled/used=false reflects default-OFF canary (chained adapter trace OFF). asset counts are step02 collection state (popups/images/tables list aggregation in stage0_normalized_assets); they may grow when IMP-03 detection lands and the snapshot will drift loudly.",
|
||||
"01": {
|
||||
"step_num": 2,
|
||||
"step_status": "partial",
|
||||
"pipeline_path_connected": true,
|
||||
"sections_count": 2,
|
||||
"section_ids": ["01-1", "01-2"],
|
||||
"orphans_count": 0,
|
||||
"details_count": 0,
|
||||
"adapter_enabled": false,
|
||||
"adapter_used": false,
|
||||
"assets_popups_count": 0,
|
||||
"assets_images_count": 0,
|
||||
"assets_tables_count": 0,
|
||||
"slide_title_nonempty": true,
|
||||
"slide_footer_nonempty": true
|
||||
},
|
||||
"02": {
|
||||
"step_num": 2,
|
||||
"step_status": "partial",
|
||||
"pipeline_path_connected": true,
|
||||
"sections_count": 2,
|
||||
"section_ids": ["02-1", "02-2"],
|
||||
"orphans_count": 0,
|
||||
"details_count": 0,
|
||||
"adapter_enabled": false,
|
||||
"adapter_used": false,
|
||||
"assets_popups_count": 0,
|
||||
"assets_images_count": 0,
|
||||
"assets_tables_count": 0,
|
||||
"slide_title_nonempty": true,
|
||||
"slide_footer_nonempty": true
|
||||
},
|
||||
"03": {
|
||||
"step_num": 2,
|
||||
"step_status": "partial",
|
||||
"pipeline_path_connected": true,
|
||||
"sections_count": 2,
|
||||
"section_ids": ["03-1", "03-2"],
|
||||
"orphans_count": 0,
|
||||
"details_count": 0,
|
||||
"adapter_enabled": false,
|
||||
"adapter_used": false,
|
||||
"assets_popups_count": 0,
|
||||
"assets_images_count": 0,
|
||||
"assets_tables_count": 0,
|
||||
"slide_title_nonempty": true,
|
||||
"slide_footer_nonempty": true
|
||||
},
|
||||
"04": {
|
||||
"step_num": 2,
|
||||
"step_status": "partial",
|
||||
"pipeline_path_connected": true,
|
||||
"sections_count": 2,
|
||||
"section_ids": ["04-1", "04-2"],
|
||||
"orphans_count": 0,
|
||||
"details_count": 0,
|
||||
"adapter_enabled": false,
|
||||
"adapter_used": false,
|
||||
"assets_popups_count": 0,
|
||||
"assets_images_count": 0,
|
||||
"assets_tables_count": 0,
|
||||
"slide_title_nonempty": true,
|
||||
"slide_footer_nonempty": true
|
||||
},
|
||||
"05": {
|
||||
"step_num": 2,
|
||||
"step_status": "partial",
|
||||
"pipeline_path_connected": true,
|
||||
"sections_count": 2,
|
||||
"section_ids": ["05-1", "05-2"],
|
||||
"orphans_count": 0,
|
||||
"details_count": 0,
|
||||
"adapter_enabled": false,
|
||||
"adapter_used": false,
|
||||
"assets_popups_count": 0,
|
||||
"assets_images_count": 0,
|
||||
"assets_tables_count": 0,
|
||||
"slide_title_nonempty": true,
|
||||
"slide_footer_nonempty": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"_doc": "IMP-#91 u8 — F2 slot_payload axis. Pins step12_slot_payload.json per_zone structural shape (position / template_id / builder / slot_names / list_slot_counts / dict_slot_sub_counts / string_slot_nonempty) for mdx 01-05. Pins SHAPE not literal content — text edits in MDX won't drift this snapshot, but builder swap / slot rename / missing slot / list-cardinality drift will. __empty__ zones have builder=null and zero slots.",
|
||||
"01": [
|
||||
{
|
||||
"position": "top",
|
||||
"template_id": "bim_dx_comparison_table",
|
||||
"builder": "compare_table_2col",
|
||||
"slot_names": ["col_a_label", "col_b_label", "rows", "title"],
|
||||
"list_slot_counts": {"rows": 2},
|
||||
"dict_slot_sub_counts": {},
|
||||
"string_slot_nonempty": {"col_a_label": false, "col_b_label": false, "title": true}
|
||||
},
|
||||
{
|
||||
"position": "bottom",
|
||||
"template_id": "construction_bim_three_usage",
|
||||
"builder": "quadrant_flat_slots",
|
||||
"slot_names": ["category_1_body", "category_1_label", "category_2_body", "category_2_label", "category_3_body", "category_3_label", "title"],
|
||||
"list_slot_counts": {"category_1_body": 2, "category_2_body": 2, "category_3_body": 2},
|
||||
"dict_slot_sub_counts": {},
|
||||
"string_slot_nonempty": {"category_1_label": true, "category_2_label": true, "category_3_label": true, "title": true}
|
||||
}
|
||||
],
|
||||
"02": [
|
||||
{
|
||||
"position": "top",
|
||||
"template_id": "construction_goals_three_circle_intersection",
|
||||
"builder": "cycle_intersect_3",
|
||||
"slot_names": ["circle_1_label", "circle_2_label", "circle_3_label", "intersection", "title"],
|
||||
"list_slot_counts": {},
|
||||
"dict_slot_sub_counts": {},
|
||||
"string_slot_nonempty": {"circle_1_label": true, "circle_2_label": true, "circle_3_label": true, "intersection": false, "title": true}
|
||||
},
|
||||
{
|
||||
"position": "bottom",
|
||||
"template_id": "__empty__",
|
||||
"builder": null,
|
||||
"slot_names": [],
|
||||
"list_slot_counts": {},
|
||||
"dict_slot_sub_counts": {},
|
||||
"string_slot_nonempty": {}
|
||||
}
|
||||
],
|
||||
"03": [
|
||||
{
|
||||
"position": "left",
|
||||
"template_id": "three_parallel_requirements",
|
||||
"builder": "items_with_role",
|
||||
"slot_names": ["pillars", "title"],
|
||||
"list_slot_counts": {"pillars": 3},
|
||||
"dict_slot_sub_counts": {},
|
||||
"string_slot_nonempty": {"title": true}
|
||||
},
|
||||
{
|
||||
"position": "right",
|
||||
"template_id": "process_product_two_way",
|
||||
"builder": "process_product_pair",
|
||||
"slot_names": ["banner_left", "banner_right", "process", "product", "title"],
|
||||
"list_slot_counts": {},
|
||||
"dict_slot_sub_counts": {"process": {"sections": 3}, "product": {"sections": 3}},
|
||||
"string_slot_nonempty": {"banner_left": true, "banner_right": true, "title": true}
|
||||
}
|
||||
],
|
||||
"04": [
|
||||
{
|
||||
"position": "top",
|
||||
"template_id": "bim_issues_quadrant_four",
|
||||
"builder": "quadrant_flat_slots",
|
||||
"slot_names": ["quadrant_1_body", "quadrant_1_label", "quadrant_2_body", "quadrant_2_label", "quadrant_3_body", "quadrant_3_label", "quadrant_4_body", "quadrant_4_label", "title"],
|
||||
"list_slot_counts": {"quadrant_1_body": 2, "quadrant_2_body": 2, "quadrant_3_body": 2, "quadrant_4_body": 2},
|
||||
"dict_slot_sub_counts": {},
|
||||
"string_slot_nonempty": {"quadrant_1_label": true, "quadrant_2_label": true, "quadrant_3_label": true, "quadrant_4_label": true, "title": true}
|
||||
},
|
||||
{
|
||||
"position": "bottom-left",
|
||||
"template_id": "__empty__",
|
||||
"builder": null,
|
||||
"slot_names": [],
|
||||
"list_slot_counts": {},
|
||||
"dict_slot_sub_counts": {},
|
||||
"string_slot_nonempty": {}
|
||||
},
|
||||
{
|
||||
"position": "bottom-right",
|
||||
"template_id": "__empty__",
|
||||
"builder": null,
|
||||
"slot_names": [],
|
||||
"list_slot_counts": {},
|
||||
"dict_slot_sub_counts": {},
|
||||
"string_slot_nonempty": {}
|
||||
}
|
||||
],
|
||||
"05": [
|
||||
{
|
||||
"position": "primary",
|
||||
"template_id": "__empty__",
|
||||
"builder": null,
|
||||
"slot_names": [],
|
||||
"list_slot_counts": {},
|
||||
"dict_slot_sub_counts": {},
|
||||
"string_slot_nonempty": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"_doc": "IMP-#91 u3 structural snapshot — pins observed step20 overall + step09 per-zone selected_template_id per mdx in the 01-05 acceptance set. Each entry is fresh-run evidence (not aspirational). Update only when an intentional pipeline change moves the observed value; treat unexplained drift as regression. [[feedback_validation_first_for_closed_issues]] [[feedback_artifact_status_naming]]",
|
||||
"01": {
|
||||
"overall": "PASS",
|
||||
"zone_count": 2,
|
||||
"zones": [
|
||||
{"position": "top", "selected_template_id": "bim_dx_comparison_table"},
|
||||
{"position": "bottom", "selected_template_id": "construction_bim_three_usage"}
|
||||
]
|
||||
},
|
||||
"02": {
|
||||
"overall": "PASS",
|
||||
"zone_count": 2,
|
||||
"zones": [
|
||||
{"position": "top", "selected_template_id": "construction_goals_three_circle_intersection"},
|
||||
{"position": "bottom", "selected_template_id": "three_persona_benefits"}
|
||||
]
|
||||
},
|
||||
"03": {
|
||||
"overall": "PASS",
|
||||
"zone_count": 2,
|
||||
"zones": [
|
||||
{"position": "left", "selected_template_id": "three_parallel_requirements"},
|
||||
{"position": "right", "selected_template_id": "process_product_two_way"}
|
||||
]
|
||||
},
|
||||
"04": {
|
||||
"overall": "PASS",
|
||||
"zone_count": 3,
|
||||
"zones": [
|
||||
{"position": "top", "selected_template_id": "bim_issues_quadrant_four"},
|
||||
{"position": "bottom-left", "selected_template_id": "sw_dependency_four_problems"},
|
||||
{"position": "bottom-right", "selected_template_id": "pre_construction_model_info_stacked"}
|
||||
]
|
||||
},
|
||||
"05": {
|
||||
"overall": "EMPTY_SHELL_NO_CONTENT",
|
||||
"zone_count": 1,
|
||||
"zones": [
|
||||
{"position": "primary", "selected_template_id": "__empty__"}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
{
|
||||
"_doc": "IMP-91 u7 — F1 V4 ranking observed snapshot (step05_v4_evidence). Pins v4_source (POSIX-normalized), aligned_section_ids, and per-section {section_id, candidate_status, candidates: [{template_id, label, confidence}]}. confidence kept at current 4-decimal rounding. Sections appear in pipeline-emitted order.",
|
||||
"01": {
|
||||
"v4_source": "tests/matching/v4_full32_result.yaml",
|
||||
"aligned_section_ids": ["01-1", "01-2"],
|
||||
"sections": [
|
||||
{
|
||||
"section_id": "01-1",
|
||||
"candidate_status": "ok",
|
||||
"candidates": [
|
||||
{"template_id": "construction_bim_three_usage", "label": "use_as_is", "confidence": 0.9101},
|
||||
{"template_id": "construction_goals_three_circle_intersection", "label": "light_edit", "confidence": 0.8261},
|
||||
{"template_id": "dx_sw_necessity_three_perspectives", "label": "light_edit", "confidence": 0.8168}
|
||||
]
|
||||
},
|
||||
{
|
||||
"section_id": "01-2",
|
||||
"candidate_status": "ok",
|
||||
"candidates": [
|
||||
{"template_id": "bim_dx_comparison_table", "label": "use_as_is", "confidence": 0.9459},
|
||||
{"template_id": "app_sw_package_vs_solution", "label": "restructure", "confidence": 0.6813}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"02": {
|
||||
"v4_source": "tests/matching/v4_full32_result.yaml",
|
||||
"aligned_section_ids": ["02-1", "02-2-sub-1", "02-2-sub-2"],
|
||||
"sections": [
|
||||
{
|
||||
"section_id": "02-1",
|
||||
"candidate_status": "ok",
|
||||
"candidates": [
|
||||
{"template_id": "construction_goals_three_circle_intersection", "label": "use_as_is", "confidence": 0.914}
|
||||
]
|
||||
},
|
||||
{
|
||||
"section_id": "02-2-sub-1",
|
||||
"candidate_status": "no_non_reject_v4_candidate",
|
||||
"candidates": []
|
||||
},
|
||||
{
|
||||
"section_id": "02-2-sub-2",
|
||||
"candidate_status": "no_non_reject_v4_candidate",
|
||||
"candidates": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"03": {
|
||||
"v4_source": "tests/matching/v4_full32_result.yaml",
|
||||
"aligned_section_ids": ["03-1", "03-2"],
|
||||
"sections": [
|
||||
{
|
||||
"section_id": "03-1",
|
||||
"candidate_status": "ok",
|
||||
"candidates": [
|
||||
{"template_id": "three_parallel_requirements", "label": "use_as_is", "confidence": 0.9268},
|
||||
{"template_id": "dx_sw_necessity_three_perspectives", "label": "light_edit", "confidence": 0.8413}
|
||||
]
|
||||
},
|
||||
{
|
||||
"section_id": "03-2",
|
||||
"candidate_status": "ok",
|
||||
"candidates": [
|
||||
{"template_id": "process_product_two_way", "label": "use_as_is", "confidence": 0.9198}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"04": {
|
||||
"v4_source": "tests/matching/v4_full32_result.yaml",
|
||||
"aligned_section_ids": ["04-1", "04-2-sub-1", "04-2-sub-2"],
|
||||
"sections": [
|
||||
{
|
||||
"section_id": "04-1",
|
||||
"candidate_status": "no_non_reject_v4_candidate",
|
||||
"candidates": []
|
||||
},
|
||||
{
|
||||
"section_id": "04-2-sub-1",
|
||||
"candidate_status": "no_non_reject_v4_candidate",
|
||||
"candidates": []
|
||||
},
|
||||
{
|
||||
"section_id": "04-2-sub-2",
|
||||
"candidate_status": "no_non_reject_v4_candidate",
|
||||
"candidates": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"05": {
|
||||
"v4_source": "tests/matching/v4_full32_result.yaml",
|
||||
"aligned_section_ids": ["05-1", "05-2-sub-1", "05-2-sub-2"],
|
||||
"sections": [
|
||||
{
|
||||
"section_id": "05-1",
|
||||
"candidate_status": "no_non_reject_v4_candidate",
|
||||
"candidates": []
|
||||
},
|
||||
{
|
||||
"section_id": "05-2-sub-1",
|
||||
"candidate_status": "no_non_reject_v4_candidate",
|
||||
"candidates": []
|
||||
},
|
||||
{
|
||||
"section_id": "05-2-sub-2",
|
||||
"candidate_status": "no_non_reject_v4_candidate",
|
||||
"candidates": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"_doc": "u4 — pin observed step14_visual_check overflow/clip per mdx 01-05. Fresh subprocess observation per [[feedback_validation_first_for_closed_issues]]; drift surfaces visual regression (overflow / clip) loudly per [[feedback_artifact_status_naming]] 3-axis honesty. Snapshot pinned to current-state, not to invented expectation (Stage 1 scope-lock).",
|
||||
"01": {
|
||||
"slide_overflowed": false,
|
||||
"slide_body_overflowed": false,
|
||||
"passed": true,
|
||||
"zones": [
|
||||
{"position": "top", "template_id": "bim_dx_comparison_table", "overflowed": false, "clipped_inner_count": 0},
|
||||
{"position": "bottom", "template_id": "construction_bim_three_usage", "overflowed": false, "clipped_inner_count": 0}
|
||||
]
|
||||
},
|
||||
"02": {
|
||||
"slide_overflowed": false,
|
||||
"slide_body_overflowed": false,
|
||||
"passed": true,
|
||||
"zones": [
|
||||
{"position": "top", "template_id": "construction_goals_three_circle_intersection", "overflowed": false, "clipped_inner_count": 0},
|
||||
{"position": "bottom", "template_id": "__empty__", "overflowed": false, "clipped_inner_count": 0}
|
||||
]
|
||||
},
|
||||
"03": {
|
||||
"slide_overflowed": false,
|
||||
"slide_body_overflowed": false,
|
||||
"passed": true,
|
||||
"zones": [
|
||||
{"position": "left", "template_id": "three_parallel_requirements", "overflowed": false, "clipped_inner_count": 0},
|
||||
{"position": "right", "template_id": "process_product_two_way", "overflowed": false, "clipped_inner_count": 0}
|
||||
]
|
||||
},
|
||||
"04": {
|
||||
"slide_overflowed": false,
|
||||
"slide_body_overflowed": false,
|
||||
"passed": true,
|
||||
"zones": [
|
||||
{"position": "top", "template_id": "bim_issues_quadrant_four", "overflowed": false, "clipped_inner_count": 0},
|
||||
{"position": "bottom-left", "template_id": "__empty__", "overflowed": false, "clipped_inner_count": 0},
|
||||
{"position": "bottom-right", "template_id": "__empty__", "overflowed": false, "clipped_inner_count": 0}
|
||||
]
|
||||
},
|
||||
"05": {
|
||||
"slide_overflowed": false,
|
||||
"slide_body_overflowed": false,
|
||||
"passed": true,
|
||||
"zones": [
|
||||
{"position": "primary", "template_id": "__empty__", "overflowed": false, "clipped_inner_count": 0}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,573 @@
|
||||
"""IMP-#91 u2 — multi-mdx regression CI scaffold (mdx 01-05 acceptance set).
|
||||
|
||||
Session-scoped subprocess cache that runs each MDX acceptance fixture
|
||||
exactly once. u3-u11 extend this module with per-axis assertions
|
||||
(structural / visual / coverage / F0-F5). u2 alone pins the cache
|
||||
contract: each mdx in ``MDX_SET`` produces a run directory under
|
||||
``data/runs/<run_id>/phase_z2/`` containing the step JSONs and
|
||||
``final.html`` that downstream parametrized tests will read.
|
||||
|
||||
[[feedback_validation_first_for_closed_issues]] — fresh subprocess per
|
||||
session, no frozen artifacts. [[feedback_artifact_status_naming]] — the
|
||||
overall status (PASS / RENDERED_WITH_VISUAL_REGRESSION /
|
||||
PARTIAL_COVERAGE / EMPTY_SHELL_NO_CONTENT) is asserted in u3-u5; u2
|
||||
only pins the artifact-production contract.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, NamedTuple
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
SAMPLES_DIR = REPO_ROOT / "samples" / "mdx_batch"
|
||||
RUNS_DIR = REPO_ROOT / "data" / "runs"
|
||||
SNAPSHOTS_DIR = Path(__file__).resolve().parent / "__snapshots__"
|
||||
MDX_SET = ("01", "02", "03", "04", "05")
|
||||
|
||||
|
||||
class PipelineRun(NamedTuple):
|
||||
mdx_id: str
|
||||
run_id: str
|
||||
returncode: int
|
||||
stdout: str
|
||||
stderr: str
|
||||
run_dir: Path
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def multi_mdx_runs() -> Dict[str, PipelineRun]:
|
||||
"""Run the Phase Z pipeline once per mdx in ``MDX_SET`` (session-cached)."""
|
||||
cache: Dict[str, PipelineRun] = {}
|
||||
for mdx_id in MDX_SET:
|
||||
run_id = f"imp91_{mdx_id}_{uuid.uuid4().hex[:8]}"
|
||||
cp = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"src.phase_z2_pipeline",
|
||||
str(SAMPLES_DIR / f"{mdx_id}.mdx"),
|
||||
run_id,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=360,
|
||||
cwd=str(REPO_ROOT),
|
||||
)
|
||||
cache[mdx_id] = PipelineRun(
|
||||
mdx_id=mdx_id,
|
||||
run_id=run_id,
|
||||
returncode=cp.returncode,
|
||||
stdout=cp.stdout,
|
||||
stderr=cp.stderr,
|
||||
run_dir=RUNS_DIR / run_id / "phase_z2",
|
||||
)
|
||||
return cache
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.parametrize("mdx_id", MDX_SET)
|
||||
def test_pipeline_run_produces_step20_status(
|
||||
mdx_id: str, multi_mdx_runs: Dict[str, PipelineRun]
|
||||
) -> None:
|
||||
"""Cache contract: every mdx subprocess produces step20_slide_status.json."""
|
||||
run = multi_mdx_runs[mdx_id]
|
||||
status_path = run.run_dir / "steps" / "step20_slide_status.json"
|
||||
assert status_path.is_file(), (
|
||||
f"{mdx_id}.mdx run {run.run_id} did not produce {status_path} "
|
||||
f"(returncode={run.returncode}); stderr tail: {run.stderr[-800:]}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.parametrize("mdx_id", MDX_SET)
|
||||
def test_structural_snapshot_matches(
|
||||
mdx_id: str, multi_mdx_runs: Dict[str, PipelineRun]
|
||||
) -> None:
|
||||
"""u3 — pin observed overall + per-zone selected_template_id against snapshot."""
|
||||
snapshot = json.loads((SNAPSHOTS_DIR / "structural.json").read_text(encoding="utf-8"))
|
||||
expected = snapshot[mdx_id]
|
||||
run = multi_mdx_runs[mdx_id]
|
||||
status = json.loads(
|
||||
(run.run_dir / "steps" / "step20_slide_status.json").read_text(encoding="utf-8")
|
||||
)["data"]
|
||||
frame_sel = json.loads(
|
||||
(run.run_dir / "steps" / "step09_frame_selection.json").read_text(encoding="utf-8")
|
||||
)["data"]
|
||||
zones = frame_sel.get("per_zone", [])
|
||||
actual_zones = [
|
||||
{"position": z.get("position"), "selected_template_id": z.get("selected_template_id")}
|
||||
for z in zones
|
||||
]
|
||||
assert status.get("overall") == expected["overall"], (
|
||||
f"{mdx_id}.mdx overall drift: expected {expected['overall']!r}, "
|
||||
f"got {status.get('overall')!r}"
|
||||
)
|
||||
assert len(actual_zones) == expected["zone_count"], (
|
||||
f"{mdx_id}.mdx zone_count drift: expected {expected['zone_count']}, "
|
||||
f"got {len(actual_zones)} (zones={actual_zones})"
|
||||
)
|
||||
assert actual_zones == expected["zones"], (
|
||||
f"{mdx_id}.mdx zone topology drift: expected {expected['zones']}, "
|
||||
f"got {actual_zones}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.parametrize("mdx_id", MDX_SET)
|
||||
def test_visual_snapshot_matches(
|
||||
mdx_id: str, multi_mdx_runs: Dict[str, PipelineRun]
|
||||
) -> None:
|
||||
"""u4 — pin observed step14 visual_check overflow/clip against snapshot."""
|
||||
snapshot = json.loads((SNAPSHOTS_DIR / "visual.json").read_text(encoding="utf-8"))
|
||||
expected = snapshot[mdx_id]
|
||||
run = multi_mdx_runs[mdx_id]
|
||||
visual = json.loads(
|
||||
(run.run_dir / "steps" / "step14_visual_check.json").read_text(encoding="utf-8")
|
||||
)["data"]
|
||||
slide_overflowed = visual.get("slide", {}).get("overflowed")
|
||||
slide_body_overflowed = visual.get("slide_body", {}).get("overflowed")
|
||||
visual_passed = visual.get("passed")
|
||||
actual_zones = [
|
||||
{
|
||||
"position": z.get("position"),
|
||||
"template_id": z.get("template_id"),
|
||||
"overflowed": z.get("overflowed"),
|
||||
"clipped_inner_count": len(z.get("clipped_inner") or []),
|
||||
}
|
||||
for z in visual.get("zones", [])
|
||||
]
|
||||
assert slide_overflowed == expected["slide_overflowed"], (
|
||||
f"{mdx_id}.mdx slide.overflowed drift: expected {expected['slide_overflowed']}, "
|
||||
f"got {slide_overflowed}"
|
||||
)
|
||||
assert slide_body_overflowed == expected["slide_body_overflowed"], (
|
||||
f"{mdx_id}.mdx slide_body.overflowed drift: expected {expected['slide_body_overflowed']}, "
|
||||
f"got {slide_body_overflowed}"
|
||||
)
|
||||
assert visual_passed == expected["passed"], (
|
||||
f"{mdx_id}.mdx visual_check.passed drift: expected {expected['passed']}, "
|
||||
f"got {visual_passed}"
|
||||
)
|
||||
assert actual_zones == expected["zones"], (
|
||||
f"{mdx_id}.mdx zone visual drift: expected {expected['zones']}, "
|
||||
f"got {actual_zones}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.parametrize("mdx_id", MDX_SET)
|
||||
def test_coverage_snapshot_matches(
|
||||
mdx_id: str, multi_mdx_runs: Dict[str, PipelineRun]
|
||||
) -> None:
|
||||
"""u5 — pin observed full_mdx_coverage + section_id parity against snapshot."""
|
||||
snapshot = json.loads((SNAPSHOTS_DIR / "coverage.json").read_text(encoding="utf-8"))
|
||||
expected = snapshot[mdx_id]
|
||||
run = multi_mdx_runs[mdx_id]
|
||||
status = json.loads(
|
||||
(run.run_dir / "steps" / "step20_slide_status.json").read_text(encoding="utf-8")
|
||||
)["data"]
|
||||
assert status.get("rendered") == expected["rendered"], (
|
||||
f"{mdx_id}.mdx rendered drift: expected {expected['rendered']}, "
|
||||
f"got {status.get('rendered')}"
|
||||
)
|
||||
assert status.get("visual_check_passed") == expected["visual_check_passed"], (
|
||||
f"{mdx_id}.mdx visual_check_passed drift: expected {expected['visual_check_passed']}, "
|
||||
f"got {status.get('visual_check_passed')}"
|
||||
)
|
||||
assert status.get("full_mdx_coverage") == expected["full_mdx_coverage"], (
|
||||
f"{mdx_id}.mdx full_mdx_coverage drift: expected {expected['full_mdx_coverage']}, "
|
||||
f"got {status.get('full_mdx_coverage')}"
|
||||
)
|
||||
assert sorted(status.get("aligned_section_ids") or []) == sorted(expected["aligned_section_ids"]), (
|
||||
f"{mdx_id}.mdx aligned_section_ids drift: expected {expected['aligned_section_ids']}, "
|
||||
f"got {status.get('aligned_section_ids')}"
|
||||
)
|
||||
assert sorted(status.get("covered_section_ids") or []) == sorted(expected["covered_section_ids"]), (
|
||||
f"{mdx_id}.mdx covered_section_ids drift: expected {expected['covered_section_ids']}, "
|
||||
f"got {status.get('covered_section_ids')}"
|
||||
)
|
||||
assert sorted(status.get("filtered_section_ids") or []) == sorted(expected["filtered_section_ids"]), (
|
||||
f"{mdx_id}.mdx filtered_section_ids drift: expected {expected['filtered_section_ids']}, "
|
||||
f"got {status.get('filtered_section_ids')}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.parametrize("mdx_id", MDX_SET)
|
||||
def test_normalize_snapshot_matches(
|
||||
mdx_id: str, multi_mdx_runs: Dict[str, PipelineRun]
|
||||
) -> None:
|
||||
"""u6 — F0 normalize: pin observed step02_normalized shape per mdx."""
|
||||
snapshot = json.loads((SNAPSHOTS_DIR / "normalize.json").read_text(encoding="utf-8"))
|
||||
expected = snapshot[mdx_id]
|
||||
run = multi_mdx_runs[mdx_id]
|
||||
raw = json.loads(
|
||||
(run.run_dir / "steps" / "step02_normalized.json").read_text(encoding="utf-8")
|
||||
)
|
||||
d = raw["data"]
|
||||
diag = d.get("stage0_adapter_diagnostics", {}) or {}
|
||||
assets = d.get("stage0_normalized_assets", {}) or {}
|
||||
actual = {
|
||||
"step_num": raw.get("step_num"),
|
||||
"step_status": raw.get("step_status"),
|
||||
"pipeline_path_connected": raw.get("pipeline_path_connected"),
|
||||
"sections_count": d.get("sections_count"),
|
||||
"section_ids": [s.get("section_id") for s in d.get("sections", [])],
|
||||
"orphans_count": len(d.get("orphans") or []),
|
||||
"details_count": len(d.get("details") or []),
|
||||
"adapter_enabled": diag.get("enabled"),
|
||||
"adapter_used": diag.get("used"),
|
||||
"assets_popups_count": len(assets.get("popups") or []),
|
||||
"assets_images_count": len(assets.get("images") or []),
|
||||
"assets_tables_count": len(assets.get("tables") or []),
|
||||
"slide_title_nonempty": bool(d.get("slide_title")),
|
||||
"slide_footer_nonempty": bool(d.get("slide_footer")),
|
||||
}
|
||||
for key, want in expected.items():
|
||||
got = actual[key]
|
||||
assert got == want, (
|
||||
f"{mdx_id}.mdx normalize.{key} drift: expected {want!r}, got {got!r}"
|
||||
)
|
||||
assert len(d.get("sections", [])) == expected["sections_count"], (
|
||||
f"{mdx_id}.mdx sections list length mismatch with sections_count: "
|
||||
f"sections_count={expected['sections_count']}, got len(sections)={len(d.get('sections', []))}"
|
||||
)
|
||||
for sect in d.get("sections", []):
|
||||
assert (sect.get("raw_content_length") or 0) > 0, (
|
||||
f"{mdx_id}.mdx section {sect.get('section_id')!r} has empty raw_content "
|
||||
f"(length={sect.get('raw_content_length')!r}) — normalize lost content"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.parametrize("mdx_id", MDX_SET)
|
||||
def test_v4_ranking_snapshot_matches(
|
||||
mdx_id: str, multi_mdx_runs: Dict[str, PipelineRun]
|
||||
) -> None:
|
||||
"""u7 — F1 V4 ranking: pin observed step05_v4_evidence per mdx.
|
||||
|
||||
Pins ``v4_source`` (POSIX-normalized for cross-platform stability),
|
||||
``aligned_section_ids``, and per-section
|
||||
``{section_id, candidate_status, candidates: [{template_id, label, confidence}]}``
|
||||
in pipeline-emitted order. Confidence stays at the current 4-decimal
|
||||
rounding emitted by the V4 yaml; drift any axis fails loudly so a
|
||||
re-baseline is a conscious commit, not a silent shift.
|
||||
"""
|
||||
snapshot = json.loads((SNAPSHOTS_DIR / "v4_ranking.json").read_text(encoding="utf-8"))
|
||||
expected = snapshot[mdx_id]
|
||||
run = multi_mdx_runs[mdx_id]
|
||||
raw = json.loads(
|
||||
(run.run_dir / "steps" / "step05_v4_evidence.json").read_text(encoding="utf-8")
|
||||
)
|
||||
data = raw["data"]
|
||||
actual_v4_source = str(data.get("v4_source") or "").replace("\\", "/")
|
||||
actual_sections = [
|
||||
{
|
||||
"section_id": ev.get("section_id"),
|
||||
"candidate_status": ev.get("candidate_status"),
|
||||
"candidates": [
|
||||
{
|
||||
"template_id": c.get("template_id"),
|
||||
"label": c.get("label"),
|
||||
"confidence": c.get("confidence"),
|
||||
}
|
||||
for c in (ev.get("v4_candidates") or [])
|
||||
],
|
||||
}
|
||||
for ev in (data.get("evidence_per_section") or [])
|
||||
]
|
||||
assert actual_v4_source == expected["v4_source"], (
|
||||
f"{mdx_id}.mdx v4_source drift: expected {expected['v4_source']!r}, "
|
||||
f"got {actual_v4_source!r}"
|
||||
)
|
||||
assert data.get("aligned_section_ids") == expected["aligned_section_ids"], (
|
||||
f"{mdx_id}.mdx aligned_section_ids drift: expected {expected['aligned_section_ids']}, "
|
||||
f"got {data.get('aligned_section_ids')}"
|
||||
)
|
||||
assert actual_sections == expected["sections"], (
|
||||
f"{mdx_id}.mdx V4 ranking drift: expected {expected['sections']}, "
|
||||
f"got {actual_sections}"
|
||||
)
|
||||
|
||||
|
||||
def _slot_payload_zone_shape(zone: dict) -> dict:
|
||||
"""Reduce a step12 per_zone entry to a content-agnostic structural shape.
|
||||
|
||||
Pins builder + slot names + per-slot list cardinality + dict sub-list
|
||||
counts + string non-empty flags. MDX text edits don't drift this; a
|
||||
builder swap, slot rename, missing slot, or list-cardinality change
|
||||
does. Sub-dict shape pins ``sections`` length only — deeper field
|
||||
pinning would require a fresh u8'-axis snapshot.
|
||||
"""
|
||||
sp = zone.get("slot_payload") or {}
|
||||
slot_names = sorted(sp.keys())
|
||||
list_slot_counts: dict = {}
|
||||
dict_slot_sub_counts: dict = {}
|
||||
string_slot_nonempty: dict = {}
|
||||
for name in slot_names:
|
||||
value = sp[name]
|
||||
if isinstance(value, list):
|
||||
list_slot_counts[name] = len(value)
|
||||
elif isinstance(value, dict):
|
||||
sub: dict = {}
|
||||
for sub_key, sub_val in value.items():
|
||||
if isinstance(sub_val, list):
|
||||
sub[sub_key] = len(sub_val)
|
||||
dict_slot_sub_counts[name] = sub
|
||||
elif isinstance(value, str):
|
||||
string_slot_nonempty[name] = bool(value.strip())
|
||||
return {
|
||||
"position": zone.get("position"),
|
||||
"template_id": zone.get("template_id"),
|
||||
"builder": zone.get("builder"),
|
||||
"slot_names": slot_names,
|
||||
"list_slot_counts": list_slot_counts,
|
||||
"dict_slot_sub_counts": dict_slot_sub_counts,
|
||||
"string_slot_nonempty": string_slot_nonempty,
|
||||
}
|
||||
|
||||
|
||||
_AI_UNIT_KEYS = (
|
||||
"source_section_ids", "label", "route_hint", "provisional",
|
||||
"ai_called", "skip_reason", "apply_status",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.parametrize("mdx_id", MDX_SET)
|
||||
def test_ai_classifier_snapshot_matches(
|
||||
mdx_id: str, multi_mdx_runs: Dict[str, PipelineRun]
|
||||
) -> None:
|
||||
"""u9 — F3 classifier-only AI: pin step12/15/16/18 classifier signals.
|
||||
|
||||
[[feedback_ai_isolation_contract]] / [[feedback_demo_env_toggle_policy]]
|
||||
central invariant: ``ai_called`` MUST stay False per unit by default;
|
||||
activation requires explicit .env toggle, never pipeline default.
|
||||
"""
|
||||
snapshot = json.loads((SNAPSHOTS_DIR / "ai_classifier.json").read_text(encoding="utf-8"))
|
||||
expected = snapshot[mdx_id]
|
||||
steps = multi_mdx_runs[mdx_id].run_dir / "steps"
|
||||
ai = json.loads((steps / "step12_ai_repair.json").read_text(encoding="utf-8"))["data"]
|
||||
fit = json.loads((steps / "step15_fit_classification.json").read_text(encoding="utf-8"))["data"]
|
||||
router = json.loads((steps / "step16_router_decision.json").read_text(encoding="utf-8"))["data"]
|
||||
failure = json.loads((steps / "step18_failure_classification.json").read_text(encoding="utf-8"))["data"]
|
||||
units = [{k: u.get(k) for k in _AI_UNIT_KEYS} for u in (ai.get("per_unit") or [])]
|
||||
actual = {
|
||||
"units": units,
|
||||
"coverage_invariant_status": (ai.get("coverage_invariant") or {}).get("status"),
|
||||
"fit_visual_check_passed": fit.get("visual_check_passed"),
|
||||
"fit_classifications_count": len(fit.get("classifications") or []),
|
||||
"fit_categories_seen": fit.get("categories_seen") or [],
|
||||
"router_active": router.get("router_active"),
|
||||
"router_routed_count": router.get("routed_count"),
|
||||
"router_v4_fallback_used_count": (router.get("v4_fallback_summary") or {}).get("fallback_used_count"),
|
||||
"failure_type": failure.get("failure_type"),
|
||||
}
|
||||
for key, want in expected.items():
|
||||
assert actual[key] == want, (
|
||||
f"{mdx_id}.mdx ai_classifier.{key} drift: expected {want!r}, got {actual[key]!r}"
|
||||
)
|
||||
breaches = [u for u in units if u["ai_called"] is not False]
|
||||
assert not breaches, (
|
||||
f"{mdx_id}.mdx F3 AI-isolation breach (ai_called must be False by default): {breaches}"
|
||||
)
|
||||
|
||||
|
||||
def _layout_zone_shape(zone: dict) -> dict:
|
||||
"""Reduce a step08 per_zone_plan entry to a content-agnostic F4 layout shape."""
|
||||
sub_zones = zone.get("sub_zones_planned") or []
|
||||
return {
|
||||
"position": zone.get("position"),
|
||||
"min_height_px": zone.get("min_height_px"),
|
||||
"frame_cardinality_strict": zone.get("frame_cardinality_strict"),
|
||||
"sub_zones_count": len(sub_zones),
|
||||
"region_layout_candidates": zone.get("region_layout_candidates") or [],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.parametrize("mdx_id", MDX_SET)
|
||||
def test_layout_snapshot_matches(
|
||||
mdx_id: str, multi_mdx_runs: Dict[str, PipelineRun]
|
||||
) -> None:
|
||||
"""u10 — F4 layout: pin step07_layout + step08_zone_region_ratios per mdx.
|
||||
|
||||
Pins the layout decision path (``layout_preset`` /
|
||||
``auto_layout_preset`` / ``layout_override_applied`` /
|
||||
``layout_candidates`` / ``computation``) + planning geometry
|
||||
(``heights_px`` / ``widths_px`` / ``ratios`` / ``width_ratios``) +
|
||||
per-zone planning shape (``position`` / ``min_height_px`` /
|
||||
``frame_cardinality_strict`` / ``sub_zones_count`` /
|
||||
``region_layout_candidates``). ``step_status='partial'`` is the
|
||||
Step 7/8 schema-lock marker (region-level ratio + count-based v0).
|
||||
mdx 03 is the only ``layout_override_applied=True`` case (vertical-2
|
||||
user override per project_mdx03_frame_lock 2026-05-15 lock); drift
|
||||
here flips F4 layer-A axis. mdx 04 ``top`` zone pins ``None`` for
|
||||
min_height_px + frame_cardinality_strict (no frame cardinality on
|
||||
the top zone — observed current state, not invented).
|
||||
"""
|
||||
snapshot = json.loads((SNAPSHOTS_DIR / "layout.json").read_text(encoding="utf-8"))
|
||||
expected = snapshot[mdx_id]
|
||||
run = multi_mdx_runs[mdx_id]
|
||||
s7 = json.loads(
|
||||
(run.run_dir / "steps" / "step07_layout.json").read_text(encoding="utf-8")
|
||||
)
|
||||
s8 = json.loads(
|
||||
(run.run_dir / "steps" / "step08_zone_region_ratios.json").read_text(encoding="utf-8")
|
||||
)
|
||||
d7 = s7.get("data") or {}
|
||||
d8 = s8.get("data") or {}
|
||||
css = d7.get("layout_css") or {}
|
||||
actual = {
|
||||
"step7_step_status": s7.get("step_status"),
|
||||
"step7_pipeline_path_connected": s7.get("pipeline_path_connected"),
|
||||
"layout_preset": d7.get("layout_preset"),
|
||||
"auto_layout_preset": d7.get("auto_layout_preset"),
|
||||
"layout_override_applied": d7.get("layout_override_applied"),
|
||||
"zones_count": d7.get("zones_count"),
|
||||
"unit_count": d7.get("unit_count"),
|
||||
"layout_candidates": d7.get("layout_candidates") or [],
|
||||
"computation": css.get("computation"),
|
||||
"dynamic_rows": css.get("dynamic_rows"),
|
||||
"dynamic_cols": css.get("dynamic_cols"),
|
||||
"heights_px": css.get("heights_px"),
|
||||
"widths_px": css.get("widths_px"),
|
||||
"ratios": css.get("ratios"),
|
||||
"width_ratios": css.get("width_ratios"),
|
||||
"step8_step_status": s8.get("step_status"),
|
||||
"step8_pipeline_path_connected": s8.get("pipeline_path_connected"),
|
||||
"zone_heights_px_planned": d8.get("zone_heights_px_planned"),
|
||||
"zone_widths_px_planned": d8.get("zone_widths_px_planned"),
|
||||
"zone_col_ratios_planned": d8.get("zone_col_ratios_planned"),
|
||||
"per_zone_layout_shape": [
|
||||
_layout_zone_shape(z) for z in (d8.get("per_zone_plan") or [])
|
||||
],
|
||||
}
|
||||
for key, want in expected.items():
|
||||
got = actual[key]
|
||||
assert got == want, (
|
||||
f"{mdx_id}.mdx layout.{key} drift: expected {want!r}, got {got!r}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.parametrize("mdx_id", MDX_SET)
|
||||
def test_slot_payload_snapshot_matches(
|
||||
mdx_id: str, multi_mdx_runs: Dict[str, PipelineRun]
|
||||
) -> None:
|
||||
"""u8 — F2 slot_payload: pin observed step12_slot_payload per_zone shape per mdx.
|
||||
|
||||
Snapshot pins content-agnostic structural shape (builder + slot
|
||||
names + list cardinality + dict sub-list counts + string non-empty
|
||||
flags), not literal payload text. MDX wording tweaks won't drift
|
||||
this; builder swap, slot rename, slot count drift, or __empty__
|
||||
transitions will. Empty zones must have ``builder is None`` and no
|
||||
slots — this is the IMP-87 empty_shell honesty contract surface for
|
||||
F2.
|
||||
"""
|
||||
snapshot = json.loads((SNAPSHOTS_DIR / "slot_payload.json").read_text(encoding="utf-8"))
|
||||
expected = snapshot[mdx_id]
|
||||
run = multi_mdx_runs[mdx_id]
|
||||
raw = json.loads(
|
||||
(run.run_dir / "steps" / "step12_slot_payload.json").read_text(encoding="utf-8")
|
||||
)
|
||||
per_zone = raw["data"].get("per_zone") or []
|
||||
actual = [_slot_payload_zone_shape(z) for z in per_zone]
|
||||
assert len(actual) == len(expected), (
|
||||
f"{mdx_id}.mdx step12 zone_count drift: expected {len(expected)}, "
|
||||
f"got {len(actual)} (positions={[z.get('position') for z in actual]})"
|
||||
)
|
||||
for idx, (act, exp) in enumerate(zip(actual, expected)):
|
||||
assert act == exp, (
|
||||
f"{mdx_id}.mdx step12 zone[{idx}] ({exp.get('position')!r}) shape drift: "
|
||||
f"expected {exp}, got {act}"
|
||||
)
|
||||
|
||||
|
||||
_ZONE_TAG_RE = re.compile(
|
||||
r'<div[^>]*\sdata-zone-position="([^"]+)"[^>]*\sdata-template-id="([^"]+)"',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_SLIDE_ROOT_RE = re.compile(r'<div\s+class="slide"\s+data-page="1"')
|
||||
_TITLE_RE = re.compile(r'<title>([^<]*)</title>', re.IGNORECASE)
|
||||
|
||||
|
||||
def _extract_html_zone_topology(html: str) -> List[dict]:
|
||||
"""Extract (position, template_id) pairs in document order from final.html."""
|
||||
return [
|
||||
{"position": m.group(1), "template_id": m.group(2)}
|
||||
for m in _ZONE_TAG_RE.finditer(html)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.parametrize("mdx_id", MDX_SET)
|
||||
def test_final_html_snapshot_matches(
|
||||
mdx_id: str, multi_mdx_runs: Dict[str, PipelineRun]
|
||||
) -> None:
|
||||
"""u11 — F5 final.html extraction: pin step13_render metadata + on-disk HTML structure.
|
||||
|
||||
Cross-snapshot parity gate: ``html_zone_topology`` (extracted from
|
||||
final.html via ``data-zone-position`` / ``data-template-id`` markers)
|
||||
MUST equal step12 slot_payload (u8) ``(position, template_id)``
|
||||
sequence — Jinja2 renders from step12, not step09, so this is the
|
||||
correct upstream parity (step09 selection vs step12 ``__empty__``
|
||||
collapse is intentional per IMP-87 honesty gate and surfaces in u8).
|
||||
Drift between final.html and slot_payload = render pipeline
|
||||
disconnect. ``final.html`` on-disk size also MUST equal step13's
|
||||
reported ``final_html_size_bytes`` — byte parity proves no
|
||||
truncation / no double-write race.
|
||||
"""
|
||||
snapshot = json.loads((SNAPSHOTS_DIR / "final_html.json").read_text(encoding="utf-8"))
|
||||
expected = snapshot[mdx_id]
|
||||
run = multi_mdx_runs[mdx_id]
|
||||
raw13 = json.loads(
|
||||
(run.run_dir / "steps" / "step13_render.json").read_text(encoding="utf-8")
|
||||
)
|
||||
d13 = raw13.get("data") or {}
|
||||
ri = d13.get("render_inputs") or {}
|
||||
final_path = run.run_dir / "final.html"
|
||||
assert final_path.is_file(), f"{mdx_id}.mdx final.html missing at {final_path}"
|
||||
html = final_path.read_text(encoding="utf-8")
|
||||
title_match = _TITLE_RE.search(html)
|
||||
html_title = title_match.group(1).strip() if title_match else ""
|
||||
html_topology = _extract_html_zone_topology(html)
|
||||
actual = {
|
||||
"step13_status": raw13.get("step_status"),
|
||||
"step13_pipeline_path_connected": raw13.get("pipeline_path_connected"),
|
||||
"render_inputs_zones_count": ri.get("zones_count"),
|
||||
"render_inputs_layout_preset": ri.get("layout_preset"),
|
||||
"render_inputs_slide_title_nonempty": bool((ri.get("slide_title") or "").strip()),
|
||||
"render_inputs_slide_footer_nonempty": bool((ri.get("slide_footer") or "").strip()),
|
||||
"html_title_matches_render_input": html_title == (ri.get("slide_title") or "").strip(),
|
||||
"html_slide_root_count": len(_SLIDE_ROOT_RE.findall(html)),
|
||||
"html_slide_footer_present": '<div class="slide-footer">' in html,
|
||||
"html_zone_count": len(html_topology),
|
||||
"html_zone_topology": html_topology,
|
||||
"final_html_size_matches_step13_reported": (
|
||||
final_path.stat().st_size == d13.get("final_html_size_bytes")
|
||||
),
|
||||
}
|
||||
for key, want in expected.items():
|
||||
assert actual[key] == want, (
|
||||
f"{mdx_id}.mdx final_html.{key} drift: expected {want!r}, got {actual[key]!r}"
|
||||
)
|
||||
slot_payload = json.loads(
|
||||
(SNAPSHOTS_DIR / "slot_payload.json").read_text(encoding="utf-8")
|
||||
)[mdx_id]
|
||||
slot_topology = [
|
||||
{"position": z["position"], "template_id": z["template_id"]}
|
||||
for z in slot_payload
|
||||
]
|
||||
assert html_topology == slot_topology, (
|
||||
f"{mdx_id}.mdx render pipeline disconnect: final.html zone topology "
|
||||
f"{html_topology} does not match step12 slot_payload topology "
|
||||
f"{slot_topology} (pinned in slot_payload.json u8)"
|
||||
)
|
||||
@@ -0,0 +1,56 @@
|
||||
fixture_id: synthetic_divergence
|
||||
purpose: |
|
||||
Backend - frontend "rank 1" divergence regression - IMP-39 (#68).
|
||||
Captures the Stage 1 root-cause scenario where the legacy backend
|
||||
(raw V4 confidence-desc order) selects a high-confidence
|
||||
lower-priority label, while the frontend (LABEL_PRIORITY asc +
|
||||
confidence desc) selects the lower-confidence higher-priority
|
||||
label. The single-source ranking policy
|
||||
(templates/phase_z2/catalog/ranking_sort_policy.yaml, u1) resolves
|
||||
the divergence so that both sides agree on "rank 1".
|
||||
|
||||
source: synthetic
|
||||
sample_agnostic: true
|
||||
notes:
|
||||
- No real frame_id / template_id / MDX section is referenced.
|
||||
- Only the four sort keys matter: label, confidence, v4_full_rank.
|
||||
- The `tag` field is a fixture-local identifier for assertions.
|
||||
- Field name `v4_full_rank` mirrors v4_full32_result.yaml shape so
|
||||
fixture and corpus audit (u8) share the same key contract.
|
||||
|
||||
raw_judgments:
|
||||
# confidence is strictly descending so v4_full_rank == raw V4
|
||||
# confidence-desc rank (same axis as v4_full32_result.yaml).
|
||||
- tag: synth_restructure_high
|
||||
label: restructure
|
||||
confidence: 0.92
|
||||
v4_full_rank: 1
|
||||
- tag: synth_light_edit_mid
|
||||
label: light_edit
|
||||
confidence: 0.70
|
||||
v4_full_rank: 2
|
||||
- tag: synth_use_as_is_low
|
||||
label: use_as_is
|
||||
confidence: 0.41
|
||||
v4_full_rank: 3
|
||||
- tag: synth_reject_low
|
||||
label: reject
|
||||
confidence: 0.30
|
||||
v4_full_rank: 4
|
||||
|
||||
expected_legacy_raw_order:
|
||||
- synth_restructure_high
|
||||
- synth_light_edit_mid
|
||||
- synth_use_as_is_low
|
||||
- synth_reject_low
|
||||
|
||||
expected_policy_sorted_order:
|
||||
- synth_use_as_is_low
|
||||
- synth_light_edit_mid
|
||||
- synth_restructure_high
|
||||
- synth_reject_low
|
||||
|
||||
divergence_axis:
|
||||
pre_policy_rank_1_tag: synth_restructure_high
|
||||
post_policy_rank_1_tag: synth_use_as_is_low
|
||||
frontend_candidate_0_tag: synth_use_as_is_low
|
||||
@@ -156,3 +156,109 @@ def test_top_1_bottom_2_dynamic_2d_populates_geometry():
|
||||
assert result["dynamic_cols"] is True
|
||||
assert len(result["heights_px"]) == 2 # R rows
|
||||
assert len(result["widths_px"]) == 2 # C cols
|
||||
|
||||
|
||||
# ────────────────────── IMP-44 u5 regression ──────────────────────
|
||||
# Regression coverage for the layout-override unknown-key guard
|
||||
# (Stage 1 root-cause #73). Asserts that foreign-preset keys are
|
||||
# dropped, structured [override-warning] is emitted, and
|
||||
# computation=user_override_geometry is NEVER reported when the
|
||||
# kept-key set is empty (no false override_applied=true).
|
||||
|
||||
|
||||
def test_imp44_h2_with_v2_keys_emits_warning_and_falls_through(capsys):
|
||||
"""horizontal-2 receiving vertical-2 keys (left/right) → all-unknown:
|
||||
drop both, emit warning, fall through to dynamic dispatch.
|
||||
computation must NOT be user_override_geometry."""
|
||||
zones = [_zone("top", 0.6), _zone("bottom", 0.4)]
|
||||
override = {
|
||||
"left": {"x": 0, "y": 0, "w": 0.5, "h": 1.0},
|
||||
"right": {"x": 0.5, "y": 0, "w": 0.5, "h": 1.0},
|
||||
}
|
||||
result = build_layout_css(
|
||||
"horizontal-2", zones, override_zone_geometries=override
|
||||
)
|
||||
captured = capsys.readouterr()
|
||||
assert "[override-warning]" in captured.err
|
||||
assert "layout_preset=horizontal-2" in captured.err
|
||||
assert "unknown_keys=['left', 'right']" in captured.err
|
||||
assert "expected_positions=['top', 'bottom']" in captured.err
|
||||
# All-unknown → no override applied (no silent fallback).
|
||||
assert result["computation"] != "user_override_geometry"
|
||||
raw = result.get("raw_zone_layout") or {}
|
||||
if isinstance(raw, dict):
|
||||
assert raw.get("override_applied") is not True
|
||||
|
||||
|
||||
def test_imp44_v2_with_h2_keys_emits_warning_and_falls_through(capsys):
|
||||
"""vertical-2 receiving horizontal-2 keys (top/bottom) → all-unknown:
|
||||
drop both, emit warning, fall through to dynamic dispatch.
|
||||
computation must NOT be user_override_geometry."""
|
||||
zones = [_zone("left", 0.5), _zone("right", 0.5)]
|
||||
override = {
|
||||
"top": {"x": 0, "y": 0, "w": 1.0, "h": 0.3},
|
||||
"bottom": {"x": 0, "y": 0.3, "w": 1.0, "h": 0.7},
|
||||
}
|
||||
result = build_layout_css(
|
||||
"vertical-2", zones, override_zone_geometries=override
|
||||
)
|
||||
captured = capsys.readouterr()
|
||||
assert "[override-warning]" in captured.err
|
||||
assert "layout_preset=vertical-2" in captured.err
|
||||
assert "unknown_keys=['bottom', 'top']" in captured.err
|
||||
assert "expected_positions=['left', 'right']" in captured.err
|
||||
assert result["computation"] != "user_override_geometry"
|
||||
raw = result.get("raw_zone_layout") or {}
|
||||
if isinstance(raw, dict):
|
||||
assert raw.get("override_applied") is not True
|
||||
|
||||
|
||||
def test_imp44_partial_mix_keeps_known_drops_unknown(capsys):
|
||||
"""horizontal-2 receiving {top (known), left (unknown)}: keep top,
|
||||
drop left, emit warning naming only 'left'. override_applied=True
|
||||
must hold and the source must contain only the kept key."""
|
||||
zones = [_zone("top", 0.6), _zone("bottom", 0.4)]
|
||||
override = {
|
||||
"top": {"x": 0, "y": 0, "w": 1.0, "h": 0.3},
|
||||
"left": {"x": 0, "y": 0, "w": 0.5, "h": 1.0},
|
||||
}
|
||||
result = build_layout_css(
|
||||
"horizontal-2", zones, override_zone_geometries=override
|
||||
)
|
||||
captured = capsys.readouterr()
|
||||
assert "[override-warning]" in captured.err
|
||||
assert "unknown_keys=['left']" in captured.err
|
||||
# Known key applied → user_override_geometry computation.
|
||||
assert result["computation"] == "user_override_geometry"
|
||||
raw = result["raw_zone_layout"]
|
||||
assert raw["override_applied"] is True
|
||||
assert set(raw["source"].keys()) == {"top"}
|
||||
# Sanity: top ratio (0.3) drives heights_px[0] < heights_px[1].
|
||||
assert result["heights_px"][0] < result["heights_px"][1]
|
||||
|
||||
|
||||
def test_imp44_2d_preset_with_h2_keys_emits_warning_and_falls_through(capsys):
|
||||
"""2-D preset (top-1-bottom-2) receiving horizontal-2 keys
|
||||
(top/bottom): all-unknown vs T positions
|
||||
{top, bottom-left, bottom-right} → drop all, emit warning,
|
||||
fall through to 2-D dynamic dispatch."""
|
||||
zones = [
|
||||
_zone("top", 0.5),
|
||||
_zone("bottom-left", 0.25),
|
||||
_zone("bottom-right", 0.25),
|
||||
]
|
||||
override = {
|
||||
"bottom": {"x": 0, "y": 0.3, "w": 1.0, "h": 0.7},
|
||||
}
|
||||
result = build_layout_css(
|
||||
"top-1-bottom-2", zones, override_zone_geometries=override
|
||||
)
|
||||
captured = capsys.readouterr()
|
||||
assert "[override-warning]" in captured.err
|
||||
assert "layout_preset=top-1-bottom-2" in captured.err
|
||||
assert "unknown_keys=['bottom']" in captured.err
|
||||
# All-unknown → 2-D dynamic fallback (not user_override_geometry).
|
||||
assert result["computation"] == "2d_dynamic_aggregated"
|
||||
raw = result.get("raw_zone_layout") or {}
|
||||
if isinstance(raw, dict):
|
||||
assert raw.get("override_applied") is not True
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
"""IMP-88 (#88) u2 — failure_router cascade extension tests.
|
||||
|
||||
Stage 2 binding contract (unit u2): the failure_router data-surface is
|
||||
extended so the three Step 17 retry chain actions (layout_adjust, image_fit,
|
||||
frame_internal_fit_candidate) participate in the deterministic cascade
|
||||
WITHOUT activating any AI path or shrinking shared margins.
|
||||
|
||||
Producer surface (`SALVAGE_FAILURE_TYPE_BY_ACTION`):
|
||||
- layout_adjust → layout_adjust_insufficient
|
||||
- image_fit → image_fit_insufficient
|
||||
- frame_internal_fit_candidate → frame_internal_fit_candidate_insufficient
|
||||
|
||||
Cascade extension (`NEXT_ACTION_BY_FAILURE`):
|
||||
- layout_adjust_insufficient → frame_internal_fit_candidate
|
||||
- frame_internal_fit_candidate_insufficient → frame_reselect
|
||||
- image_fit_insufficient → layout_adjust
|
||||
|
||||
Implementation status surface (`NEXT_ACTION_IMPLEMENTATION_STATUS`):
|
||||
- layout_adjust = IMPLEMENTED (u3 planner + u6 dispatcher + u7 entry)
|
||||
- image_fit = IMPLEMENTED (u4 planner + u7 Step 17 single-pass entry)
|
||||
- frame_internal_fit_candidate = IMPLEMENTED (u5 planner + u6 dispatcher + u7 entry)
|
||||
- frame_reselect = MISSING (separate axis, out of IMP-88 scope)
|
||||
- details_popup_escalation = MISSING here; flipped on the router surface
|
||||
(src/phase_z2_router.py) by IMP-35 u3.
|
||||
|
||||
Existing rows from IMP-12 / IMP-35 (#62 / #64) are guarded against regression.
|
||||
|
||||
Post u7 completion (2026-05-24): status assertions in this file reflect the
|
||||
IMPLEMENTED end-state. The `_registered_as_missing` test name is renamed to
|
||||
`_registered_as_implemented_after_u7` so the surface contract is honest
|
||||
about the post-u7 state.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from src.phase_z2_failure_router import (
|
||||
FAILURE_TYPE_DESCRIPTIONS,
|
||||
NEXT_ACTION_BY_FAILURE,
|
||||
NEXT_ACTION_IMPLEMENTATION_STATUS,
|
||||
NEXT_ACTION_RATIONALE,
|
||||
SALVAGE_FAILURE_TYPE_BY_ACTION,
|
||||
classify_retry_failure,
|
||||
enrich_retry_trace_with_failure_classification,
|
||||
route_retry_failure,
|
||||
)
|
||||
|
||||
|
||||
# ─── FAILURE_TYPE_DESCRIPTIONS registry ──────────────────────────
|
||||
|
||||
|
||||
def test_imp88_three_new_failure_type_descriptions_registered():
|
||||
"""u2 registers three new failure_type descriptions for the Step 17
|
||||
retry chain actions. Each entry is non-empty so trace consumers can
|
||||
surface a human-readable failure reason."""
|
||||
for ftype in (
|
||||
"layout_adjust_insufficient",
|
||||
"image_fit_insufficient",
|
||||
"frame_internal_fit_candidate_insufficient",
|
||||
):
|
||||
assert ftype in FAILURE_TYPE_DESCRIPTIONS, (
|
||||
f"u2 must register {ftype} in FAILURE_TYPE_DESCRIPTIONS"
|
||||
)
|
||||
assert FAILURE_TYPE_DESCRIPTIONS[ftype].strip(), (
|
||||
f"FAILURE_TYPE_DESCRIPTIONS[{ftype!r}] must be non-empty"
|
||||
)
|
||||
|
||||
|
||||
# ─── SALVAGE_FAILURE_TYPE_BY_ACTION producers ─────────────────────
|
||||
|
||||
|
||||
def test_imp88_three_new_salvage_failure_producers_registered():
|
||||
"""u2 wires three new producers so when the u6 dispatcher emits a
|
||||
salvage_steps[-1] entry whose action is one of the IMP-88 actions, the
|
||||
classifier route lands on the correct failure_type instead of falling
|
||||
through to the defensive not_attempted fallback."""
|
||||
assert SALVAGE_FAILURE_TYPE_BY_ACTION["layout_adjust"] == (
|
||||
"layout_adjust_insufficient"
|
||||
)
|
||||
assert SALVAGE_FAILURE_TYPE_BY_ACTION["image_fit"] == "image_fit_insufficient"
|
||||
assert SALVAGE_FAILURE_TYPE_BY_ACTION["frame_internal_fit_candidate"] == (
|
||||
"frame_internal_fit_candidate_insufficient"
|
||||
)
|
||||
|
||||
|
||||
def test_imp88_existing_salvage_producers_preserved():
|
||||
"""Regression guard — IMP-12 / IMP-35 producers stay intact after u2."""
|
||||
assert SALVAGE_FAILURE_TYPE_BY_ACTION["cross_zone_redistribute"] == (
|
||||
"cross_zone_redistribute_insufficient"
|
||||
)
|
||||
assert SALVAGE_FAILURE_TYPE_BY_ACTION["glue_compression"] == (
|
||||
"glue_absorption_insufficient"
|
||||
)
|
||||
assert SALVAGE_FAILURE_TYPE_BY_ACTION["font_step_compression"] == (
|
||||
"font_step_insufficient"
|
||||
)
|
||||
assert SALVAGE_FAILURE_TYPE_BY_ACTION["frame_reselect"] == (
|
||||
"frame_reselect_insufficient"
|
||||
)
|
||||
|
||||
|
||||
# ─── NEXT_ACTION_BY_FAILURE cascade ───────────────────────────────
|
||||
|
||||
|
||||
def test_imp88_layout_adjust_insufficient_routes_to_frame_internal_fit():
|
||||
"""Cascade extension: layout_adjust_insufficient → frame_internal_fit_candidate.
|
||||
|
||||
Closes the previously open cascade tail at layout_adjust (font_step_insufficient
|
||||
→ layout_adjust was the last existing row that could land on layout_adjust;
|
||||
after layout_adjust executed and failed there was no NEXT_ACTION row, so the
|
||||
dispatcher would terminate). u2 adds the frame envelope internal-fit step
|
||||
before frame_reselect.
|
||||
"""
|
||||
assert NEXT_ACTION_BY_FAILURE["layout_adjust_insufficient"] == (
|
||||
"frame_internal_fit_candidate"
|
||||
)
|
||||
|
||||
nr = route_retry_failure("layout_adjust_insufficient")
|
||||
assert nr["next_proposed_action"] == "frame_internal_fit_candidate"
|
||||
# frame_internal_fit_candidate is IMPLEMENTED after u5 planner + u6
|
||||
# dispatcher branch + u7 cascade entry.
|
||||
assert nr["next_action_implementation_status"] == "IMPLEMENTED"
|
||||
assert "frame_internal_fit_candidate" in (nr["next_action_rationale"] or "")
|
||||
|
||||
|
||||
def test_imp88_frame_internal_fit_insufficient_routes_to_frame_reselect():
|
||||
"""frame_internal_fit_candidate_insufficient → frame_reselect (V4 top-k swap).
|
||||
|
||||
Rejoins the existing rerender_still_fails → frame_reselect path mid-cascade.
|
||||
"""
|
||||
assert NEXT_ACTION_BY_FAILURE["frame_internal_fit_candidate_insufficient"] == (
|
||||
"frame_reselect"
|
||||
)
|
||||
|
||||
nr = route_retry_failure("frame_internal_fit_candidate_insufficient")
|
||||
assert nr["next_proposed_action"] == "frame_reselect"
|
||||
# frame_reselect is OUT of IMP-88 scope and stays MISSING (separate axis).
|
||||
assert nr["next_action_implementation_status"] == "MISSING"
|
||||
|
||||
|
||||
def test_imp88_image_fit_insufficient_routes_to_layout_adjust():
|
||||
"""image_fit (Step 17 single-pass entry per u7) escalates onto the main
|
||||
cascade at layout_adjust when the single-pass image fit transform cannot
|
||||
resolve image_aspect_mismatch. Phase Z spacing direction guardrail —
|
||||
no shared margin shrink, escalate through layout topology change instead.
|
||||
"""
|
||||
assert NEXT_ACTION_BY_FAILURE["image_fit_insufficient"] == "layout_adjust"
|
||||
|
||||
nr = route_retry_failure("image_fit_insufficient")
|
||||
assert nr["next_proposed_action"] == "layout_adjust"
|
||||
# layout_adjust is IMPLEMENTED after u3 planner + u6 dispatcher branch +
|
||||
# u7 cascade entry. Cascade now flows end-to-end on the deterministic path.
|
||||
assert nr["next_action_implementation_status"] == "IMPLEMENTED"
|
||||
# Rationale must not claim a margin shrink (Phase Z spacing direction
|
||||
# guardrail anchored at feedback_phase_z_spacing_direction).
|
||||
rationale = (nr["next_action_rationale"] or "").lower()
|
||||
assert "shrink" not in rationale
|
||||
assert "축소 x" in rationale or "축소x" in rationale or "spacing direction" in rationale
|
||||
|
||||
|
||||
def test_imp88_existing_cascade_rows_preserved():
|
||||
"""Regression guard — the IMP-12 / IMP-35 cascade rows stay intact after u2."""
|
||||
assert NEXT_ACTION_BY_FAILURE["donor_slack_insufficient"] == "cross_zone_redistribute"
|
||||
assert NEXT_ACTION_BY_FAILURE["no_donor_candidates"] == "cross_zone_redistribute"
|
||||
assert NEXT_ACTION_BY_FAILURE["cross_zone_redistribute_insufficient"] == "glue_compression"
|
||||
assert NEXT_ACTION_BY_FAILURE["glue_absorption_insufficient"] == "font_step_compression"
|
||||
assert NEXT_ACTION_BY_FAILURE["font_step_insufficient"] == "layout_adjust"
|
||||
assert NEXT_ACTION_BY_FAILURE["rerender_still_fails"] == "frame_reselect"
|
||||
assert NEXT_ACTION_BY_FAILURE["frame_reselect_insufficient"] == "details_popup_escalation"
|
||||
assert NEXT_ACTION_BY_FAILURE["not_attempted"] == "none"
|
||||
|
||||
|
||||
# ─── NEXT_ACTION_IMPLEMENTATION_STATUS surface ────────────────────
|
||||
|
||||
|
||||
def test_imp88_new_next_action_destinations_registered_as_implemented_after_u7():
|
||||
"""u2 registered the two new cascade destinations (initial MISSING). After
|
||||
u7 completion the rows flip to IMPLEMENTED on the failure-router surface:
|
||||
frame_internal_fit_candidate via u5 planner + u6 dispatcher + u7 cascade
|
||||
entry; image_fit via u4 planner + u7 Step 17 single-pass entry. (Same
|
||||
precedent as IMP-12 u7 cascade actions — planner-surface + orchestrator
|
||||
wiring together constitute IMPLEMENTED on the deterministic surface.)"""
|
||||
assert NEXT_ACTION_IMPLEMENTATION_STATUS["frame_internal_fit_candidate"] == "IMPLEMENTED"
|
||||
assert NEXT_ACTION_IMPLEMENTATION_STATUS["image_fit"] == "IMPLEMENTED"
|
||||
|
||||
|
||||
def test_imp88_existing_implementation_status_preserved():
|
||||
"""Regression guard — IMP-12 u7 + IMP-35 u3 status rows stay intact.
|
||||
Post u7 completion, layout_adjust on the failure-router surface flips
|
||||
to IMPLEMENTED alongside the primary router surface (u3 planner + u6
|
||||
dispatcher + u7 cascade entry)."""
|
||||
assert NEXT_ACTION_IMPLEMENTATION_STATUS["cross_zone_redistribute"] == "IMPLEMENTED"
|
||||
assert NEXT_ACTION_IMPLEMENTATION_STATUS["glue_compression"] == "IMPLEMENTED"
|
||||
assert NEXT_ACTION_IMPLEMENTATION_STATUS["font_step_compression"] == "IMPLEMENTED"
|
||||
assert NEXT_ACTION_IMPLEMENTATION_STATUS["layout_adjust"] == "IMPLEMENTED"
|
||||
assert NEXT_ACTION_IMPLEMENTATION_STATUS["frame_reselect"] == "MISSING"
|
||||
assert NEXT_ACTION_IMPLEMENTATION_STATUS["details_popup_escalation"] == "MISSING"
|
||||
assert NEXT_ACTION_IMPLEMENTATION_STATUS["none"] == "n/a"
|
||||
|
||||
|
||||
# ─── End-to-end classifier + router path ──────────────────────────
|
||||
|
||||
|
||||
def test_imp88_three_new_salvage_failures_classifier_path_end_to_end():
|
||||
"""End-to-end: a salvage_steps[-1] entry with one of the three IMP-88
|
||||
actions (layout_adjust / image_fit / frame_internal_fit_candidate) and
|
||||
passed=False routes through classifier → router and lands on the
|
||||
expected cascade next action. Confirms u2 producer + cascade rows are
|
||||
wired through `classify_retry_failure` + `route_retry_failure` together.
|
||||
"""
|
||||
cases = [
|
||||
(
|
||||
"layout_adjust",
|
||||
"layout_adjust_insufficient",
|
||||
"frame_internal_fit_candidate",
|
||||
),
|
||||
(
|
||||
"frame_internal_fit_candidate",
|
||||
"frame_internal_fit_candidate_insufficient",
|
||||
"frame_reselect",
|
||||
),
|
||||
(
|
||||
"image_fit",
|
||||
"image_fit_insufficient",
|
||||
"layout_adjust",
|
||||
),
|
||||
]
|
||||
for action, expected_ftype, expected_next in cases:
|
||||
trace = {
|
||||
"retry_attempted": True,
|
||||
"retry_passed": False,
|
||||
"salvage_passed": False,
|
||||
"salvage_steps": [
|
||||
{
|
||||
"action": action,
|
||||
"passed": False,
|
||||
"failure_reason": f"{action} salvage failed (test)",
|
||||
}
|
||||
],
|
||||
}
|
||||
fc = classify_retry_failure(trace)
|
||||
assert fc is not None, f"classifier returned None for action={action}"
|
||||
assert fc["failure_type"] == expected_ftype, (
|
||||
f"classifier emitted {fc['failure_type']!r} for action={action}, "
|
||||
f"expected {expected_ftype!r}"
|
||||
)
|
||||
nr = route_retry_failure(fc["failure_type"])
|
||||
assert nr["next_proposed_action"] == expected_next, (
|
||||
f"router routed {fc['failure_type']!r} → {nr['next_proposed_action']!r}, "
|
||||
f"expected {expected_next!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_imp88_enrichment_composes_layout_adjust_insufficient_proposal():
|
||||
"""End-to-end via enrich_retry_trace_with_failure_classification — the
|
||||
deterministic Step 17 dispatcher will read these fields off the trace,
|
||||
so verify the public wrapper attaches both failure_classification and
|
||||
next_action_proposal correctly for the layout_adjust salvage path."""
|
||||
trace = {
|
||||
"retry_attempted": True,
|
||||
"retry_passed": False,
|
||||
"salvage_passed": False,
|
||||
"salvage_steps": [
|
||||
{
|
||||
"action": "layout_adjust",
|
||||
"passed": False,
|
||||
"failure_reason": "layout_adjust preset switch did not fit",
|
||||
}
|
||||
],
|
||||
}
|
||||
enrich_retry_trace_with_failure_classification(trace)
|
||||
assert trace["failure_classification"]["failure_type"] == (
|
||||
"layout_adjust_insufficient"
|
||||
)
|
||||
assert trace["next_action_proposal"]["next_proposed_action"] == (
|
||||
"frame_internal_fit_candidate"
|
||||
)
|
||||
# frame_internal_fit_candidate is IMPLEMENTED after u5 planner + u6
|
||||
# dispatcher branch + u7 cascade entry.
|
||||
assert trace["next_action_proposal"]["next_action_implementation_status"] == (
|
||||
"IMPLEMENTED"
|
||||
)
|
||||
|
||||
|
||||
# ─── Rationale registry coverage ──────────────────────────────────
|
||||
|
||||
|
||||
def test_imp88_three_new_failure_rationales_registered():
|
||||
"""u2 registers a rationale entry for each new failure_type so the
|
||||
enrichment wrapper emits a non-empty rationale (debug-trace usability)."""
|
||||
for ftype in (
|
||||
"layout_adjust_insufficient",
|
||||
"image_fit_insufficient",
|
||||
"frame_internal_fit_candidate_insufficient",
|
||||
):
|
||||
assert ftype in NEXT_ACTION_RATIONALE, (
|
||||
f"u2 must register {ftype} in NEXT_ACTION_RATIONALE"
|
||||
)
|
||||
assert NEXT_ACTION_RATIONALE[ftype].strip(), (
|
||||
f"NEXT_ACTION_RATIONALE[{ftype!r}] must be non-empty"
|
||||
)
|
||||
@@ -0,0 +1,437 @@
|
||||
"""IMP-39 u8 (issue #68) - corpus audit over tests/matching/v4_full32_result.yaml.
|
||||
|
||||
Mirror-invariance regression on the REAL V4 full-32 judgments corpus
|
||||
(``tests/matching/v4_full32_result.yaml``). For every MDX section in the
|
||||
corpus, asserts that:
|
||||
|
||||
1. The backend ranking helper ``apply_ranking_sort`` (single-source
|
||||
policy via ``templates/phase_z2/catalog/ranking_sort_policy.yaml``)
|
||||
yields the same ordering as a Python mirror of the frontend
|
||||
candidate sort (``Front/client/src/services/designAgentApi.ts``
|
||||
warn-fallback path, lines 644-649). i.e. backend selector "rank 1"
|
||||
== frontend ``frame_candidates[0]`` by construction across the
|
||||
full corpus, with NO sample-specific carve-out.
|
||||
2. The tie-break contract (label_priority asc, confidence desc,
|
||||
v4_rank asc) holds when (label, confidence) ties occur in real
|
||||
data (e.g. multi-restructure sections like 01-1 where rank=8
|
||||
restructure rises above rank=5 reject under policy).
|
||||
3. Real-data DIVERGENCE between raw V4 confidence-desc order and
|
||||
policy-sorted order EXISTS in the corpus (audit honesty: proves
|
||||
the policy is non-trivial on real samples, not just synthetic
|
||||
u6 fixture).
|
||||
|
||||
Sample-agnostic axis (RULE 0 / RULE 7):
|
||||
- The test iterates ``data['mdx_sections']`` keys dynamically; no
|
||||
section ID (``01-2``, ``03-1``, ``04-2.1``, ...) is hardcoded as
|
||||
an assertion target. The corpus inventory is treated as a
|
||||
parametrize source, not a contract.
|
||||
- The test does NOT assert any specific ``frame_id`` /
|
||||
``template_id`` / ``frame_number``. Only the ordering contract
|
||||
is asserted.
|
||||
- The test does NOT depend on MDX 03/04/05 outcome / answer_map
|
||||
correctness; it only validates that the policy is applied
|
||||
uniformly across whatever sections the corpus happens to have.
|
||||
|
||||
Scope (u8, Stage 2 plan):
|
||||
- Real-data sweep of ``tests/matching/v4_full32_result.yaml``
|
||||
confirming backend / frontend mirror invariance under
|
||||
``apply_ranking_sort`` + ``LABEL_PRIORITY`` mirror.
|
||||
- Corpus uses ``v4_full_rank`` as the tie-break key, so calls pass
|
||||
``v4_rank_key="v4_full_rank"`` (matching u2 selector wiring).
|
||||
|
||||
Out of scope (other units):
|
||||
- u1 policy yaml shape: covered by ``test_ranking_sort_policy.py``.
|
||||
- u2 selector wiring: integration covered indirectly via u7.
|
||||
- u3 Step 9 payload forwarding: covered by u7.
|
||||
- u4 frontend mirror: covered by u7.
|
||||
- u5 pure permutation tests.
|
||||
- u6 SYNTHETIC divergence fixture
|
||||
(``tests/phase_z2/test_label_priority_synthetic.py``).
|
||||
- u7 mdx04 env-toggle e2e
|
||||
(``tests/phase_z2/test_imp39_mdx04_env_toggle_e2e.py``).
|
||||
- V4 matching algorithm correctness (out of #68 scope, owner #5).
|
||||
- ``MVP1_ALLOWED_STATUSES`` gate semantics (IMP-47B locked area).
|
||||
- capacity-fit / catalog contract validation (orthogonal to policy).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
_CORPUS_PATH = _REPO_ROOT / "tests" / "matching" / "v4_full32_result.yaml"
|
||||
|
||||
|
||||
# Frontend LABEL_PRIORITY verbatim mirror — Front/client/src/services/
|
||||
# designAgentApi.ts:575-580 + warn-fallback sort :644-649. Kept inline (not
|
||||
# imported from python policy) so this audit catches drift if the frontend
|
||||
# TS constant ever diverges from the yaml policy. The yaml-shape equality
|
||||
# is exercised separately in test_ranking_sort_policy.py (u5).
|
||||
_FRONTEND_LABEL_PRIORITY: Dict[str, int] = {
|
||||
"use_as_is": 0,
|
||||
"light_edit": 1,
|
||||
"restructure": 2,
|
||||
"reject": 3,
|
||||
}
|
||||
_FRONTEND_UNKNOWN_PRIORITY = 99
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_policy_cache():
|
||||
"""Mirror peer-test isolation - clear the cached single-source policy."""
|
||||
import src.phase_z2_pipeline as pipeline
|
||||
|
||||
pipeline._RANKING_SORT_POLICY_CACHE = None
|
||||
yield
|
||||
pipeline._RANKING_SORT_POLICY_CACHE = None
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def corpus() -> Dict[str, Any]:
|
||||
"""Load v4_full32_result.yaml exactly once per test module run."""
|
||||
assert _CORPUS_PATH.exists(), (
|
||||
f"Corpus audit source missing: {_CORPUS_PATH}. u8 requires "
|
||||
f"tests/matching/v4_full32_result.yaml present in repo."
|
||||
)
|
||||
with _CORPUS_PATH.open(encoding="utf-8") as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def section_ids(corpus) -> List[str]:
|
||||
"""Dynamic section inventory — NOT hardcoded.
|
||||
|
||||
Source = ``corpus['mdx_sections'].keys()``. The test asserts the
|
||||
set is non-empty and each entry has a populated ``judgments_full32``
|
||||
list. Section IDs themselves are treated as parametrize values, not
|
||||
assertion targets.
|
||||
"""
|
||||
return list(corpus["mdx_sections"].keys())
|
||||
|
||||
|
||||
def _frontend_mirror_sort(
|
||||
judgments: List[Dict[str, Any]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Pure-Python mirror of frontend warn-fallback ordering.
|
||||
|
||||
Mirrors ``Front/client/src/services/designAgentApi.ts:644-649``:
|
||||
v4Source.sort((a, b) => {
|
||||
const lp = (LABEL_PRIORITY[a.label] ?? 99) - (LABEL_PRIORITY[b.label] ?? 99);
|
||||
if (lp !== 0) return lp;
|
||||
return (b.confidence ?? 0) - (a.confidence ?? 0);
|
||||
});
|
||||
|
||||
NOTE on tie-break: the frontend warn-fallback path lacks the
|
||||
explicit v4_rank tie-break the backend policy carries (yaml
|
||||
``tie_break_axes: [confidence_desc, v4_rank_asc]``). When (label,
|
||||
confidence) are both equal, the frontend ``Array.prototype.sort``
|
||||
is now stable (ES2019), so original order is preserved. Backend
|
||||
``apply_ranking_sort`` also uses Python's stable Timsort and adds
|
||||
``v4_rank asc`` only as a positive tie-break which agrees with raw
|
||||
V4 order (v4_rank=1 first, raw V4 ordering is confidence-desc =
|
||||
same as input). Net effect: identical ordering across both paths
|
||||
on the real corpus. The audit below verifies this empirically.
|
||||
"""
|
||||
return sorted(
|
||||
judgments,
|
||||
key=lambda j: (
|
||||
_FRONTEND_LABEL_PRIORITY.get(j.get("label"), _FRONTEND_UNKNOWN_PRIORITY),
|
||||
-float(j.get("confidence", 0.0)),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _identity_key(judgment: Dict[str, Any]) -> tuple:
|
||||
"""Stable identity for a corpus judgment row.
|
||||
|
||||
``v4_full_rank`` is unique per section (1..32), so it serves as the
|
||||
section-local identity. Wrapped in a tuple with ``frame_number`` /
|
||||
``template_id`` for diagnostic richness in assert messages (these
|
||||
extras are NOT used to derive ordering; only for failure diagnosis).
|
||||
"""
|
||||
return (
|
||||
judgment.get("v4_full_rank"),
|
||||
judgment.get("frame_number"),
|
||||
judgment.get("template_id"),
|
||||
)
|
||||
|
||||
|
||||
# ─── corpus shape sanity ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_corpus_file_is_present_and_non_empty(corpus, section_ids):
|
||||
"""RULE 5 factual: corpus path + section inventory both surface up."""
|
||||
assert isinstance(corpus, dict)
|
||||
assert "mdx_sections" in corpus
|
||||
assert len(section_ids) > 0, (
|
||||
f"v4_full32_result.yaml has zero mdx_sections — corpus audit "
|
||||
f"cannot run. Path: {_CORPUS_PATH}"
|
||||
)
|
||||
for sec_id in section_ids:
|
||||
section = corpus["mdx_sections"][sec_id]
|
||||
judgments = section.get("judgments_full32")
|
||||
assert isinstance(judgments, list) and len(judgments) > 0, (
|
||||
f"Section {sec_id}: judgments_full32 missing or empty."
|
||||
)
|
||||
# Every judgment must carry the four sort-relevant fields.
|
||||
for j in judgments:
|
||||
assert "label" in j, f"{sec_id}: judgment missing 'label'."
|
||||
assert "confidence" in j, f"{sec_id}: judgment missing 'confidence'."
|
||||
assert "v4_full_rank" in j, (
|
||||
f"{sec_id}: judgment missing 'v4_full_rank' (tie-break key)."
|
||||
)
|
||||
|
||||
|
||||
# ─── backend ↔ frontend mirror invariance ───────────────────────────────
|
||||
|
||||
|
||||
def test_backend_policy_sort_matches_frontend_mirror_per_section(
|
||||
corpus, section_ids,
|
||||
):
|
||||
"""Per-section: backend ``apply_ranking_sort`` == frontend mirror order."""
|
||||
from src.phase_z2_pipeline import apply_ranking_sort
|
||||
|
||||
divergences: List[str] = []
|
||||
for sec_id in section_ids:
|
||||
judgments = corpus["mdx_sections"][sec_id]["judgments_full32"]
|
||||
|
||||
backend_sorted = apply_ranking_sort(
|
||||
judgments,
|
||||
v4_rank_key="v4_full_rank",
|
||||
)
|
||||
frontend_sorted = _frontend_mirror_sort(judgments)
|
||||
|
||||
backend_keys = [_identity_key(j) for j in backend_sorted]
|
||||
frontend_keys = [_identity_key(j) for j in frontend_sorted]
|
||||
if backend_keys != frontend_keys:
|
||||
divergences.append(
|
||||
f"section={sec_id} backend_head={backend_keys[0]} "
|
||||
f"frontend_head={frontend_keys[0]} "
|
||||
f"first_divergence_index="
|
||||
f"{next((i for i, (a, b) in enumerate(zip(backend_keys, frontend_keys)) if a != b), 'tail')}"
|
||||
)
|
||||
|
||||
assert not divergences, (
|
||||
"backend ↔ frontend mirror divergence on real corpus:\n "
|
||||
+ "\n ".join(divergences)
|
||||
)
|
||||
|
||||
|
||||
def test_backend_rank_1_equals_frontend_candidate_0_per_section(
|
||||
corpus, section_ids,
|
||||
):
|
||||
"""Stage 1 root-cause head-of-list invariant on every corpus section."""
|
||||
from src.phase_z2_pipeline import apply_ranking_sort
|
||||
|
||||
head_mismatches: List[str] = []
|
||||
for sec_id in section_ids:
|
||||
judgments = corpus["mdx_sections"][sec_id]["judgments_full32"]
|
||||
|
||||
backend_rank_1 = apply_ranking_sort(
|
||||
judgments,
|
||||
v4_rank_key="v4_full_rank",
|
||||
)[0]
|
||||
frontend_candidate_0 = _frontend_mirror_sort(judgments)[0]
|
||||
|
||||
if _identity_key(backend_rank_1) != _identity_key(frontend_candidate_0):
|
||||
head_mismatches.append(
|
||||
f"section={sec_id} "
|
||||
f"backend_rank_1={_identity_key(backend_rank_1)} "
|
||||
f"frontend_candidate_0={_identity_key(frontend_candidate_0)}"
|
||||
)
|
||||
|
||||
assert not head_mismatches, (
|
||||
"backend selector 'rank 1' diverges from frontend frame_candidates[0]:\n "
|
||||
+ "\n ".join(head_mismatches)
|
||||
)
|
||||
|
||||
|
||||
# ─── tie-break + label-priority contract on real data ──────────────────
|
||||
|
||||
|
||||
def test_policy_ordering_respects_label_priority_per_section(
|
||||
corpus, section_ids,
|
||||
):
|
||||
"""``label_priority`` weakly monotone across the policy-sorted list."""
|
||||
from src.phase_z2_pipeline import apply_ranking_sort
|
||||
|
||||
violations: List[str] = []
|
||||
for sec_id in section_ids:
|
||||
judgments = corpus["mdx_sections"][sec_id]["judgments_full32"]
|
||||
sorted_judgments = apply_ranking_sort(
|
||||
judgments,
|
||||
v4_rank_key="v4_full_rank",
|
||||
)
|
||||
priorities = [
|
||||
_FRONTEND_LABEL_PRIORITY.get(j["label"], _FRONTEND_UNKNOWN_PRIORITY)
|
||||
for j in sorted_judgments
|
||||
]
|
||||
for i in range(len(priorities) - 1):
|
||||
if priorities[i] > priorities[i + 1]:
|
||||
violations.append(
|
||||
f"section={sec_id} idx={i} prio={priorities[i]} > "
|
||||
f"idx={i + 1} prio={priorities[i + 1]}"
|
||||
)
|
||||
break
|
||||
|
||||
assert not violations, (
|
||||
"label_priority must be weakly monotone post-sort:\n "
|
||||
+ "\n ".join(violations)
|
||||
)
|
||||
|
||||
|
||||
def test_policy_confidence_desc_within_label_group_per_section(
|
||||
corpus, section_ids,
|
||||
):
|
||||
"""Within same label, confidence must be weakly descending."""
|
||||
from src.phase_z2_pipeline import apply_ranking_sort
|
||||
|
||||
violations: List[str] = []
|
||||
for sec_id in section_ids:
|
||||
judgments = corpus["mdx_sections"][sec_id]["judgments_full32"]
|
||||
sorted_judgments = apply_ranking_sort(
|
||||
judgments,
|
||||
v4_rank_key="v4_full_rank",
|
||||
)
|
||||
for i in range(len(sorted_judgments) - 1):
|
||||
a, b = sorted_judgments[i], sorted_judgments[i + 1]
|
||||
if a["label"] != b["label"]:
|
||||
continue
|
||||
if float(a["confidence"]) < float(b["confidence"]):
|
||||
violations.append(
|
||||
f"section={sec_id} idx={i} label={a['label']} "
|
||||
f"conf={a['confidence']} < idx={i + 1} conf={b['confidence']}"
|
||||
)
|
||||
break
|
||||
|
||||
assert not violations, (
|
||||
"confidence must be weakly desc within same-label runs:\n "
|
||||
+ "\n ".join(violations)
|
||||
)
|
||||
|
||||
|
||||
def test_policy_v4_full_rank_asc_within_label_confidence_ties(
|
||||
corpus, section_ids,
|
||||
):
|
||||
"""When (label, confidence) tie, smaller v4_full_rank first.
|
||||
|
||||
Real-data tie-break check. If no section in the corpus exhibits a
|
||||
(label, confidence) tie, the test passes vacuously — this is the
|
||||
correct contract: we only assert the tie-break behaviour where
|
||||
it can actually be observed in the real data. Pure-permutation
|
||||
tie-break coverage is owned by u5
|
||||
(``test_v4_rank_asc_tie_break_on_equal_confidence``).
|
||||
"""
|
||||
from src.phase_z2_pipeline import apply_ranking_sort
|
||||
|
||||
tie_break_violations: List[str] = []
|
||||
for sec_id in section_ids:
|
||||
judgments = corpus["mdx_sections"][sec_id]["judgments_full32"]
|
||||
sorted_judgments = apply_ranking_sort(
|
||||
judgments,
|
||||
v4_rank_key="v4_full_rank",
|
||||
)
|
||||
for i in range(len(sorted_judgments) - 1):
|
||||
a, b = sorted_judgments[i], sorted_judgments[i + 1]
|
||||
if a["label"] != b["label"]:
|
||||
continue
|
||||
if float(a["confidence"]) != float(b["confidence"]):
|
||||
continue
|
||||
if int(a["v4_full_rank"]) > int(b["v4_full_rank"]):
|
||||
tie_break_violations.append(
|
||||
f"section={sec_id} idx={i} v4_full_rank={a['v4_full_rank']} "
|
||||
f"> idx={i + 1} v4_full_rank={b['v4_full_rank']} "
|
||||
f"(label={a['label']} conf={a['confidence']})"
|
||||
)
|
||||
|
||||
assert not tie_break_violations, (
|
||||
"v4_full_rank must be weakly asc within (label, conf) ties:\n "
|
||||
+ "\n ".join(tie_break_violations)
|
||||
)
|
||||
|
||||
|
||||
# ─── audit honesty: real divergence exists ─────────────────────────────
|
||||
|
||||
|
||||
def test_corpus_exhibits_real_policy_divergence(corpus, section_ids):
|
||||
"""At least one section MUST show raw-V4-order != policy-order.
|
||||
|
||||
Honesty check (RULE 5): the corpus audit is meaningful only if the
|
||||
policy actually changes some real section's ordering. If every
|
||||
section already sorts the same way under raw V4 confidence-desc
|
||||
AND under the policy, then the policy is a no-op on this corpus
|
||||
and we should know about it — either the corpus needs richer
|
||||
samples or the divergence axis has shifted.
|
||||
|
||||
Currently observed (2026-05-24) raw-vs-policy mid-list divergence:
|
||||
sections with multi-label diversity where a lower-confidence
|
||||
higher-priority candidate sits behind a higher-confidence
|
||||
lower-priority one (e.g. section 01-1 has rank=8 restructure
|
||||
rising above rank=5/6/7 rejects under policy).
|
||||
"""
|
||||
from src.phase_z2_pipeline import apply_ranking_sort
|
||||
|
||||
any_divergence = False
|
||||
for sec_id in section_ids:
|
||||
judgments = corpus["mdx_sections"][sec_id]["judgments_full32"]
|
||||
# Raw V4 order: rows are stored in v4_full_rank asc (= confidence desc).
|
||||
raw_keys = [_identity_key(j) for j in judgments]
|
||||
policy_keys = [
|
||||
_identity_key(j)
|
||||
for j in apply_ranking_sort(judgments, v4_rank_key="v4_full_rank")
|
||||
]
|
||||
if raw_keys != policy_keys:
|
||||
any_divergence = True
|
||||
break
|
||||
|
||||
assert any_divergence, (
|
||||
"No corpus section shows raw-V4 vs policy ordering divergence. "
|
||||
"The policy is a no-op on this corpus — either re-curate the "
|
||||
"corpus or re-validate the divergence axis."
|
||||
)
|
||||
|
||||
|
||||
# ─── determinism + non-mutation on real corpus ─────────────────────────
|
||||
|
||||
|
||||
def test_policy_sort_is_deterministic_across_calls_per_section(
|
||||
corpus, section_ids,
|
||||
):
|
||||
"""Two consecutive calls on the same section yield identical ordering."""
|
||||
from src.phase_z2_pipeline import apply_ranking_sort
|
||||
|
||||
for sec_id in section_ids:
|
||||
judgments = corpus["mdx_sections"][sec_id]["judgments_full32"]
|
||||
first = [
|
||||
_identity_key(j)
|
||||
for j in apply_ranking_sort(judgments, v4_rank_key="v4_full_rank")
|
||||
]
|
||||
second = [
|
||||
_identity_key(j)
|
||||
for j in apply_ranking_sort(judgments, v4_rank_key="v4_full_rank")
|
||||
]
|
||||
assert first == second, (
|
||||
f"section={sec_id}: apply_ranking_sort is non-deterministic "
|
||||
f"across calls."
|
||||
)
|
||||
|
||||
|
||||
def test_corpus_input_lists_are_not_mutated(corpus, section_ids):
|
||||
"""Corpus rows survive ``apply_ranking_sort`` unchanged in place."""
|
||||
from src.phase_z2_pipeline import apply_ranking_sort
|
||||
|
||||
for sec_id in section_ids:
|
||||
judgments = corpus["mdx_sections"][sec_id]["judgments_full32"]
|
||||
snapshot = [_identity_key(j) for j in judgments]
|
||||
|
||||
apply_ranking_sort(judgments, v4_rank_key="v4_full_rank")
|
||||
|
||||
post = [_identity_key(j) for j in judgments]
|
||||
assert snapshot == post, (
|
||||
f"section={sec_id}: apply_ranking_sort mutated source list "
|
||||
f"in place (forbidden — see u5 non-mutation contract)."
|
||||
)
|
||||
@@ -0,0 +1,332 @@
|
||||
"""IMP-39 u7 (issue #68) — mdx04 env-toggle e2e (AI_FALLBACK_ENABLED=off).
|
||||
|
||||
Stage 2 u7 axis G:
|
||||
Run ``python -m src.phase_z2_pipeline samples/mdx_batch/04.mdx <run_id>``
|
||||
with ``AI_FALLBACK_ENABLED=off`` and assert that the backend selector's
|
||||
"rank 1" view agrees with the frontend ``frame_candidates[0]`` view —
|
||||
i.e., the Stage 1 root-cause divergence (Backend src/phase_z2_pipeline.py
|
||||
raw-confidence-desc iteration vs Frontend Front/client/src/services/
|
||||
designAgentApi.ts label-priority resort) cannot recur once both sides
|
||||
consume the single-source ranking_sort_policy.yaml contract (u1) via the
|
||||
Step 9 payload (u3) and the frontend primary-path mirror (u4).
|
||||
|
||||
Out of scope (per Stage 2 lock):
|
||||
* The IMP-85 mdx04 BuilderMissingError downstream surface — covered by
|
||||
``tests/test_pipeline_smoke_imp85.py``. This e2e does NOT pin the
|
||||
subprocess returncode; mdx04 may exit non-zero post-IMP-85 routing
|
||||
while still emitting ``step09_application_plan.json`` whose unit
|
||||
payload is what u3/u4 contract on.
|
||||
* MVP1_ALLOWED_STATUSES gate / v4_fallback_policy max-rank /
|
||||
capacity-fit / AI restructure / cache carve-out (IMP-46) / Phase Z
|
||||
spacing semantics — all unchanged by IMP-39.
|
||||
* Pure-permutation helper coverage (tests/test_ranking_sort_policy.py
|
||||
u5) and the SYNTHETIC divergence regression
|
||||
(tests/phase_z2/test_label_priority_synthetic.py u6).
|
||||
* Corpus audit over v4_full32_result.yaml — u8.
|
||||
|
||||
Demo env toggle policy (feedback_demo_env_toggle_policy 2026-05-08):
|
||||
The subprocess is spawned with an EXPLICIT
|
||||
``env={..., "AI_FALLBACK_ENABLED": "false"}`` override even though
|
||||
tests/conftest.py already sets the parent-process default to false.
|
||||
This keeps the toggle expectation visible at the test level and
|
||||
matches the .env-only activation policy (the .env file ships with
|
||||
``AI_FALLBACK_ENABLED=true``; the test isolates the off path).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from src.phase_z2_pipeline import apply_ranking_sort, load_ranking_sort_policy
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
_SAMPLE_MDX = _REPO_ROOT / "samples" / "mdx_batch" / "04.mdx"
|
||||
_RUNS_DIR = _REPO_ROOT / "data" / "runs"
|
||||
_POLICY_YAML = (
|
||||
_REPO_ROOT
|
||||
/ "templates"
|
||||
/ "phase_z2"
|
||||
/ "catalog"
|
||||
/ "ranking_sort_policy.yaml"
|
||||
)
|
||||
|
||||
# Mirrors Front/client/src/services/designAgentApi.ts :567 — frontend slices
|
||||
# the dedup'd v4Source to this many candidates. The test asserts that the
|
||||
# frontend frame_candidates[0] mirror still equals sorted_candidate_evidence[0]
|
||||
# for any TOP_N_FRAMES >= 1, but we honor the precise frontend constant so
|
||||
# the dedup-then-slice path is exercised verbatim (not paraphrased).
|
||||
_FRONTEND_TOP_N_FRAMES = 6
|
||||
|
||||
|
||||
def _frontend_frame_candidates(sorted_evidence: list[dict]) -> list[dict]:
|
||||
"""Pure-Python mirror of Front/client/src/services/designAgentApi.ts
|
||||
:586-650 primary path:
|
||||
|
||||
const candidateMap = new Map<string, any>();
|
||||
const pushCandidate = (c: any) => {
|
||||
if (!c) return;
|
||||
const key = c.template_id ?? c.id ?? c.frame_id;
|
||||
if (!key) return;
|
||||
if (!candidateMap.has(key)) candidateMap.set(key, c);
|
||||
};
|
||||
sortedCandidateEvidence!.forEach(pushCandidate);
|
||||
v4Source = Array.from(candidateMap.values());
|
||||
frameCandidates = v4Source.slice(0, TOP_N_FRAMES);
|
||||
|
||||
Same first-occurrence-wins dedup ordering, same slice cap, same key
|
||||
fallback chain. Kept inline (no shared util) so a TS-side refactor that
|
||||
diverges the contract is forced to update this mirror explicitly.
|
||||
"""
|
||||
seen: dict[Any, dict] = {}
|
||||
for c in sorted_evidence:
|
||||
if not isinstance(c, dict):
|
||||
continue
|
||||
key = c.get("template_id") or c.get("id") or c.get("frame_id")
|
||||
if key is None or key == "":
|
||||
continue
|
||||
if key not in seen:
|
||||
seen[key] = c
|
||||
return list(seen.values())[:_FRONTEND_TOP_N_FRAMES]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def mdx04_env_toggle_run() -> dict:
|
||||
"""Single subprocess run shared across u7 assertions.
|
||||
|
||||
Returns ``{"run_id": ..., "completed_process": ..., "plan_payload": ...}``.
|
||||
The IMP-85 downstream surface may push returncode != 0 for mdx04 (out of
|
||||
scope here) — we still expect ``step09_application_plan.json`` to be
|
||||
emitted, because u3 forwards the payload before any IMP-85 builder-fit
|
||||
path. The fixture xfails if mdx04 does not even reach step09.
|
||||
"""
|
||||
assert _SAMPLE_MDX.exists(), f"sample missing: {_SAMPLE_MDX}"
|
||||
run_id = f"imp39_u7_mdx04_{uuid.uuid4().hex[:8]}"
|
||||
env = dict(os.environ)
|
||||
env["AI_FALLBACK_ENABLED"] = "false"
|
||||
env["AI_FALLBACK_AUTO_CACHE"] = "false"
|
||||
cp = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"src.phase_z2_pipeline",
|
||||
str(_SAMPLE_MDX),
|
||||
run_id,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=240,
|
||||
cwd=str(_REPO_ROOT),
|
||||
env=env,
|
||||
)
|
||||
plan_path = (
|
||||
_RUNS_DIR
|
||||
/ run_id
|
||||
/ "phase_z2"
|
||||
/ "steps"
|
||||
/ "step09_application_plan.json"
|
||||
)
|
||||
if not plan_path.is_file():
|
||||
pytest.xfail(
|
||||
"mdx04 subprocess did not emit step09_application_plan.json "
|
||||
f"(IMP-85 area, out of scope for u7). returncode={cp.returncode}\n"
|
||||
f"--- stderr tail ---\n{cp.stderr[-1500:]}\n"
|
||||
f"--- stdout tail ---\n{cp.stdout[-1500:]}"
|
||||
)
|
||||
plan_payload = json.loads(plan_path.read_text(encoding="utf-8"))
|
||||
return {
|
||||
"run_id": run_id,
|
||||
"completed_process": cp,
|
||||
"plan_payload": plan_payload,
|
||||
}
|
||||
|
||||
|
||||
def _units_with_v4(plan_payload: dict) -> list[dict]:
|
||||
units = (plan_payload.get("data") or {}).get("units") or []
|
||||
return [
|
||||
u
|
||||
for u in units
|
||||
if isinstance(u.get("sorted_candidate_evidence"), list)
|
||||
and u["sorted_candidate_evidence"]
|
||||
]
|
||||
|
||||
|
||||
def test_mdx04_env_toggle_step9_emits_u3_payload_fields(mdx04_env_toggle_run):
|
||||
"""Every Step 9 unit in the mdx04 e2e run carries the u3 additive fields
|
||||
(``ranking_sort_policy`` + ``sorted_candidate_evidence``).
|
||||
|
||||
Locks: u3 payload forwarding (src/phase_z2_pipeline.py :4163-4164) is
|
||||
exercised by the real subprocess path on mdx04, not just an in-process
|
||||
helper smoke. Without this gate the u4 frontend primary path silently
|
||||
degrades to the LABEL_PRIORITY warn-fallback and the Stage 1 divergence
|
||||
can re-surface on legacy data.
|
||||
"""
|
||||
plan = mdx04_env_toggle_run["plan_payload"]
|
||||
units = (plan.get("data") or {}).get("units") or []
|
||||
assert units, "mdx04 application_plan emitted zero units"
|
||||
yaml_policy = yaml.safe_load(_POLICY_YAML.read_text(encoding="utf-8"))
|
||||
expected_policy_type = yaml_policy["policy_type"]
|
||||
expected_label_priority = yaml_policy["label_priority"]
|
||||
expected_unknown = yaml_policy["unknown_label_priority"]
|
||||
expected_tie_break = yaml_policy["tie_break_axes"]
|
||||
for u in units:
|
||||
assert "ranking_sort_policy" in u, (
|
||||
f"unit {u.get('unit_id')!r} missing ranking_sort_policy "
|
||||
"(u3 payload forwarding regressed)"
|
||||
)
|
||||
assert "sorted_candidate_evidence" in u, (
|
||||
f"unit {u.get('unit_id')!r} missing sorted_candidate_evidence "
|
||||
"(u3 payload forwarding regressed)"
|
||||
)
|
||||
pol = u["ranking_sort_policy"]
|
||||
assert pol.get("policy_type") == expected_policy_type
|
||||
assert pol.get("label_priority") == expected_label_priority
|
||||
assert pol.get("unknown_label_priority") == expected_unknown
|
||||
assert pol.get("tie_break_axes") == expected_tie_break
|
||||
|
||||
|
||||
def test_mdx04_sorted_candidate_evidence_is_policy_sorted(mdx04_env_toggle_run):
|
||||
"""``unit.sorted_candidate_evidence`` is already in policy order — i.e.,
|
||||
``apply_ranking_sort(evidence)`` is a no-op (idempotent).
|
||||
|
||||
This pins the u2 selector ordering invariant
|
||||
(src/phase_z2_pipeline.py :1186-1196 sorts ``judgments`` BEFORE the
|
||||
selector loop appends candidate_trace entries) against the real mdx04
|
||||
pipeline path. Any future change that re-sorts the trace post-iteration
|
||||
or appends out-of-order would fail this assertion.
|
||||
"""
|
||||
plan = mdx04_env_toggle_run["plan_payload"]
|
||||
units_with_v4 = _units_with_v4(plan)
|
||||
assert units_with_v4, (
|
||||
"mdx04 application_plan units have no V4 evidence; cannot evaluate "
|
||||
"the sort-idempotency invariant"
|
||||
)
|
||||
policy = load_ranking_sort_policy()
|
||||
for u in units_with_v4:
|
||||
evidence = u["sorted_candidate_evidence"]
|
||||
resorted = apply_ranking_sort(
|
||||
evidence,
|
||||
policy=policy,
|
||||
label_key="label",
|
||||
confidence_key="confidence",
|
||||
v4_rank_key="v4_full_rank",
|
||||
)
|
||||
order_in = [
|
||||
(c.get("label"), c.get("confidence"), c.get("template_id"))
|
||||
for c in evidence
|
||||
]
|
||||
order_out = [
|
||||
(c.get("label"), c.get("confidence"), c.get("template_id"))
|
||||
for c in resorted
|
||||
]
|
||||
assert order_in == order_out, (
|
||||
f"unit {u.get('unit_id')!r} sorted_candidate_evidence is not in "
|
||||
f"policy order (u2 selector-loop ordering regressed):\n"
|
||||
f" observed: {order_in[:6]}\n"
|
||||
f" expected: {order_out[:6]}"
|
||||
)
|
||||
|
||||
|
||||
def test_mdx04_backend_frontend_rank_one_mirror(mdx04_env_toggle_run):
|
||||
"""Stage 1 root-cause regression guard: backend "rank 1" view ≡
|
||||
frontend ``frame_candidates[0]`` view on real mdx04 data.
|
||||
|
||||
Backend view = ``sorted_candidate_evidence[0]`` (policy-sorted selector
|
||||
trace head — what the selector saw at iteration 1 of u2's sorted loop).
|
||||
Frontend view = first entry of the dedup-then-slice mirror computed by
|
||||
``_frontend_frame_candidates`` (Front/client/src/services/designAgentApi.ts
|
||||
:586-661 primary path verbatim).
|
||||
|
||||
These two MUST refer to the same V4 candidate (matched on
|
||||
``(template_id, label, confidence)``) for every unit emitted by the mdx04
|
||||
pipeline run under ``AI_FALLBACK_ENABLED=off``. A mismatch here is the
|
||||
exact post-fix surface of the Stage 1 root-cause divergence; the test is
|
||||
sample-agnostic in its assertion (the divergence is structurally
|
||||
impossible once both sides share the same source, not because mdx04
|
||||
specifically lacks the divergence shape).
|
||||
"""
|
||||
plan = mdx04_env_toggle_run["plan_payload"]
|
||||
units_with_v4 = _units_with_v4(plan)
|
||||
assert units_with_v4, "no V4-bearing units in mdx04 application_plan"
|
||||
for u in units_with_v4:
|
||||
evidence = u["sorted_candidate_evidence"]
|
||||
backend_head = evidence[0]
|
||||
frontend_candidates = _frontend_frame_candidates(evidence)
|
||||
assert frontend_candidates, (
|
||||
f"unit {u.get('unit_id')!r}: frontend dedup mirror produced "
|
||||
"an empty frame_candidates list (key fallback chain regressed)"
|
||||
)
|
||||
frontend_head = frontend_candidates[0]
|
||||
backend_key = (
|
||||
backend_head.get("template_id"),
|
||||
backend_head.get("label"),
|
||||
backend_head.get("confidence"),
|
||||
)
|
||||
frontend_key = (
|
||||
frontend_head.get("template_id"),
|
||||
frontend_head.get("label"),
|
||||
frontend_head.get("confidence"),
|
||||
)
|
||||
assert backend_key == frontend_key, (
|
||||
f"unit {u.get('unit_id')!r} backend rank-1 ≠ frontend "
|
||||
f"frame_candidates[0]:\n"
|
||||
f" backend : {backend_key}\n"
|
||||
f" frontend : {frontend_key}\n"
|
||||
" → Stage 1 root-cause divergence has re-surfaced; check u2/u3/u4 wiring."
|
||||
)
|
||||
|
||||
|
||||
def test_mdx04_application_status_ok_unit_selects_sorted_head(
|
||||
mdx04_env_toggle_run,
|
||||
):
|
||||
"""When a unit's selector actually chose a real (non-provisional)
|
||||
candidate (``application_status == "ok"`` and
|
||||
``selection_path == "rank_1"``), the chosen frame must be
|
||||
``sorted_candidate_evidence[0]``.
|
||||
|
||||
The candidate_evidence entry with ``decision == "selected"`` is the
|
||||
selector's resolved choice; under u2 the loop iterates policy-sorted
|
||||
order, so the head of ``sorted_candidate_evidence`` is the first
|
||||
iteration. If the head is "selected" the invariant holds; the test
|
||||
silently passes when no unit in this mdx04 run hits ok+rank_1 (the
|
||||
scenario is sample-shape dependent and not contractually guaranteed
|
||||
on every mdx04 emission).
|
||||
"""
|
||||
plan = mdx04_env_toggle_run["plan_payload"]
|
||||
units_with_v4 = _units_with_v4(plan)
|
||||
checked = 0
|
||||
for u in units_with_v4:
|
||||
if u.get("application_status") != "ok":
|
||||
continue
|
||||
if u.get("selection_path") != "rank_1":
|
||||
continue
|
||||
evidence = u["sorted_candidate_evidence"]
|
||||
head = evidence[0]
|
||||
selected_entries = [
|
||||
c for c in evidence if c.get("decision") == "selected"
|
||||
]
|
||||
assert selected_entries, (
|
||||
f"unit {u.get('unit_id')!r} has application_status=ok + "
|
||||
"selection_path=rank_1 but no candidate_trace entry is marked "
|
||||
"decision=selected (selector trace shape regressed)"
|
||||
)
|
||||
selected = selected_entries[0]
|
||||
assert selected.get("template_id") == head.get("template_id"), (
|
||||
f"unit {u.get('unit_id')!r}: backend selected template_id "
|
||||
f"{selected.get('template_id')!r} ≠ sorted_candidate_evidence[0]"
|
||||
f".template_id {head.get('template_id')!r}; u2 selector-loop "
|
||||
"order must place the selected candidate at index 0"
|
||||
)
|
||||
checked += 1
|
||||
# No hard floor — mdx04's V4 mix at the time of this test may yield zero
|
||||
# ok+rank_1 units (sample-shape contingent). The mirror invariance above
|
||||
# is the binding contract; this test is the stricter sub-invariant that
|
||||
# only fires when a unit hits the ok+rank_1 path.
|
||||
assert checked >= 0
|
||||
@@ -0,0 +1,323 @@
|
||||
"""IMP-40 u4 (issue #69) — synthetic role-policy matrix for
|
||||
``_build_compare_table_2col`` label-default discriminator.
|
||||
|
||||
Stage 2 u4 contract (verbatim)::
|
||||
|
||||
Add synthetic role-policy tests for placeholder, fallback, absent role,
|
||||
and unknown role using minimal Section plus contract inputs.
|
||||
|
||||
Why this is load-bearing
|
||||
========================
|
||||
|
||||
u1 (frame_contracts.yaml F18) and u2 (F30/F31) opt the catalog into the
|
||||
new ``{col_key}_label_default_role`` discriminator. u3 (mapper) implements
|
||||
the runtime branch::
|
||||
|
||||
role == "placeholder" → col_{a,b}_label = "" (Figma visual
|
||||
placeholder
|
||||
suppressed)
|
||||
role == "fallback" → col_{a,b}_label = catalog literal
|
||||
(legacy behavior)
|
||||
role absent → defaults to "fallback" (backward compat
|
||||
for legacy
|
||||
contracts)
|
||||
role unknown → ValueError (no silent
|
||||
miscategorization)
|
||||
|
||||
This file exercises that 4-row policy matrix at the
|
||||
``_build_compare_table_2col`` boundary with **synthetic** Section + contract
|
||||
inputs. No reliance on the YAML catalog, no sample-specific frame ids,
|
||||
no MDX 03 / 04 / 05 literals. Catalog-vs-mapper drift detection is the
|
||||
job of the integration snapshot path (u6), not this unit.
|
||||
|
||||
Scope (u4, Stage 2 plan)
|
||||
========================
|
||||
|
||||
* 4 policy rows: placeholder / fallback / absent / unknown.
|
||||
* ``SimpleNamespace`` Section stub (mirrors the pattern in
|
||||
``tests/test_phase_z2_mapper_builder_missing.py``).
|
||||
* Inline contract dicts — no catalog import.
|
||||
* ``title`` slot omitted (``_resolve_title`` returns ``{}`` when
|
||||
``payload.title.source`` is absent — verified at
|
||||
``src/phase_z2_mapper.py:371-382``); keeps the assertion surface focused
|
||||
on ``col_a_label`` / ``col_b_label`` resolution.
|
||||
* Synthetic catalog literals (``LITERAL_COL_A`` / ``LITERAL_COL_B``) keep
|
||||
the assertion sample-agnostic — the policy mechanism is the invariant,
|
||||
not any specific Figma placeholder string.
|
||||
|
||||
Out of scope (other units)
|
||||
==========================
|
||||
|
||||
* u1 catalog F18 role keys: covered by the integration snapshot drift in
|
||||
u6 + the catalog-shape check via grep.
|
||||
* u2 catalog F30 / F31 role keys: catalog-only, no runtime builder yet
|
||||
(``compare_table_3col`` builder activation is a downstream follow-up
|
||||
recorded in Stage 2 ``follow_up_candidates``).
|
||||
* u5 F18-reuse regression with non-BIM/DX top_bullets content: separate
|
||||
unit in the same file.
|
||||
* u6 mdx 01 F18 ``slot_payload`` snapshot refresh.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_mapper import _build_compare_table_2col
|
||||
|
||||
|
||||
# ─── Synthetic helpers ─────────────────────────────────────────────
|
||||
|
||||
_LITERAL_COL_A = "LITERAL_COL_A"
|
||||
_LITERAL_COL_B = "LITERAL_COL_B"
|
||||
|
||||
|
||||
def _make_section(raw_content: str = ""):
|
||||
"""Minimal Section stub — only the attributes the builder reads.
|
||||
|
||||
``_build_compare_table_2col`` only touches ``section`` indirectly via
|
||||
``_resolve_title``, which is a no-op when ``payload.title.source`` is
|
||||
absent. We still pass an empty ``raw_content`` so future regressions
|
||||
that start reading from it would surface immediately rather than
|
||||
silently passing on a placeholder.
|
||||
"""
|
||||
return SimpleNamespace(
|
||||
section_id="synthetic-imp40-u4",
|
||||
raw_content=raw_content,
|
||||
title="SYNTHETIC_TITLE",
|
||||
order=1,
|
||||
)
|
||||
|
||||
|
||||
def _make_contract(
|
||||
*,
|
||||
template_id: str,
|
||||
col_a_role: str | None,
|
||||
col_b_role: str | None,
|
||||
) -> dict:
|
||||
"""Inline contract dict — synthetic, sample-agnostic.
|
||||
|
||||
role=None → key omitted entirely (legacy / absent-role axis).
|
||||
"""
|
||||
builder_options: dict = {
|
||||
"item_parser": "compare_row_2col_item",
|
||||
"col_a_label_default": _LITERAL_COL_A,
|
||||
"col_b_label_default": _LITERAL_COL_B,
|
||||
}
|
||||
if col_a_role is not None:
|
||||
builder_options["col_a_label_default_role"] = col_a_role
|
||||
if col_b_role is not None:
|
||||
builder_options["col_b_label_default_role"] = col_b_role
|
||||
|
||||
return {
|
||||
"template_id": template_id,
|
||||
"source_shape": "top_bullets",
|
||||
"cardinality": {},
|
||||
"payload": {
|
||||
"builder": "compare_table_2col",
|
||||
"builder_options": builder_options,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ─── Policy row 1: placeholder → "" (Figma placeholder suppressed) ─
|
||||
|
||||
def test_placeholder_role_emits_empty_label_for_both_columns():
|
||||
"""role=placeholder MUST suppress the catalog literal at runtime.
|
||||
|
||||
This is the IMP-40 leak-fix invariant: even though the catalog still
|
||||
carries a Figma placeholder string (preserved for design preview),
|
||||
the builder MUST NOT inject it into the runtime payload.
|
||||
"""
|
||||
contract = _make_contract(
|
||||
template_id="synthetic_placeholder_both",
|
||||
col_a_role="placeholder",
|
||||
col_b_role="placeholder",
|
||||
)
|
||||
|
||||
payload = _build_compare_table_2col(_make_section(), units=[], contract=contract)
|
||||
|
||||
assert payload["col_a_label"] == ""
|
||||
assert payload["col_b_label"] == ""
|
||||
assert _LITERAL_COL_A not in payload["col_a_label"]
|
||||
assert _LITERAL_COL_B not in payload["col_b_label"]
|
||||
|
||||
|
||||
# ─── Policy row 2: fallback → catalog literal (legacy behavior) ────
|
||||
|
||||
def test_fallback_role_emits_catalog_literal_for_both_columns():
|
||||
"""role=fallback MUST preserve the pre-IMP-40 behavior byte-for-byte.
|
||||
|
||||
Legacy contracts that explicitly opt into ``fallback`` (or migrate
|
||||
forward from absent-role) must keep emitting the catalog literal so
|
||||
that frames where MDX genuinely omits a header still render a
|
||||
meaningful default.
|
||||
"""
|
||||
contract = _make_contract(
|
||||
template_id="synthetic_fallback_both",
|
||||
col_a_role="fallback",
|
||||
col_b_role="fallback",
|
||||
)
|
||||
|
||||
payload = _build_compare_table_2col(_make_section(), units=[], contract=contract)
|
||||
|
||||
assert payload["col_a_label"] == _LITERAL_COL_A
|
||||
assert payload["col_b_label"] == _LITERAL_COL_B
|
||||
|
||||
|
||||
# ─── Policy row 3: absent role → fallback (backward compatibility) ─
|
||||
|
||||
def test_absent_role_defaults_to_fallback_for_both_columns():
|
||||
"""Contracts without the new ``_role`` discriminator MUST be inert.
|
||||
|
||||
Backward compatibility guard: u1/u2 only add ``_role`` keys to the
|
||||
targeted F18 / F30 / F31 frames. Every other ``compare_table_2col``
|
||||
consumer in the catalog (current or future) that omits the key MUST
|
||||
continue to receive the catalog literal — same as pre-IMP-40.
|
||||
"""
|
||||
contract = _make_contract(
|
||||
template_id="synthetic_absent_role",
|
||||
col_a_role=None,
|
||||
col_b_role=None,
|
||||
)
|
||||
|
||||
payload = _build_compare_table_2col(_make_section(), units=[], contract=contract)
|
||||
|
||||
assert payload["col_a_label"] == _LITERAL_COL_A
|
||||
assert payload["col_b_label"] == _LITERAL_COL_B
|
||||
|
||||
|
||||
def test_partial_role_mix_is_resolved_per_column():
|
||||
"""Role discriminator MUST be resolved independently per column.
|
||||
|
||||
Synthetic edge case: col_a=placeholder, col_b absent. The placeholder
|
||||
column emits "", the absent column falls back to its catalog literal.
|
||||
Guards against any future refactor that accidentally couples the two
|
||||
columns through a shared resolution path.
|
||||
"""
|
||||
contract = _make_contract(
|
||||
template_id="synthetic_partial_mix",
|
||||
col_a_role="placeholder",
|
||||
col_b_role=None,
|
||||
)
|
||||
|
||||
payload = _build_compare_table_2col(_make_section(), units=[], contract=contract)
|
||||
|
||||
assert payload["col_a_label"] == ""
|
||||
assert payload["col_b_label"] == _LITERAL_COL_B
|
||||
|
||||
|
||||
# ─── Policy row 4: unknown role → ValueError (fail-fast) ───────────
|
||||
|
||||
def test_unknown_role_raises_value_error_with_contract_context():
|
||||
"""role=<garbage> MUST raise ValueError, not silently fall back.
|
||||
|
||||
A typo or stale role value in a hand-edited catalog must surface
|
||||
immediately at build time rather than being miscategorized as
|
||||
"fallback" or "placeholder". Error message MUST cite the contract
|
||||
template_id, the role key, and the invalid value to make catalog
|
||||
repair tractable.
|
||||
"""
|
||||
contract = _make_contract(
|
||||
template_id="synthetic_unknown_role",
|
||||
col_a_role="not_a_real_role",
|
||||
col_b_role="placeholder",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError) as exc:
|
||||
_build_compare_table_2col(_make_section(), units=[], contract=contract)
|
||||
|
||||
msg = str(exc.value)
|
||||
assert "synthetic_unknown_role" in msg
|
||||
assert "col_a_label_default_role" in msg
|
||||
assert "not_a_real_role" in msg
|
||||
assert "placeholder" in msg
|
||||
assert "fallback" in msg
|
||||
|
||||
|
||||
# ─── u5 : F18-reuse regression — non-BIM/DX rows + placeholder role ─
|
||||
|
||||
_F18_CATALOG_LITERAL_COL_A = "BIM" # Verbatim F18 col_a_label_default (frame_contracts.yaml:476)
|
||||
_F18_CATALOG_LITERAL_COL_B = "DX" # Verbatim F18 col_b_label_default (frame_contracts.yaml:477)
|
||||
_F18_LEAK_TOKENS = (_F18_CATALOG_LITERAL_COL_A, _F18_CATALOG_LITERAL_COL_B)
|
||||
|
||||
|
||||
def _make_f18_clone_contract() -> dict:
|
||||
"""F18-shaped contract — verbatim BIM/DX literals + placeholder role.
|
||||
|
||||
Mirrors the catalog state after u1: BIM/DX kept as Figma visual
|
||||
placeholders, but the role discriminator tells the builder to
|
||||
suppress them at runtime. The ``template_id`` is namespaced
|
||||
(``synthetic_f18_reuse_non_bim_dx``) so the test is not coupled to
|
||||
the real F18 frame id; the leak invariant is independent of the
|
||||
template_id string.
|
||||
"""
|
||||
return {
|
||||
"template_id": "synthetic_f18_reuse_non_bim_dx",
|
||||
"source_shape": "top_bullets",
|
||||
"cardinality": {},
|
||||
"payload": {
|
||||
"builder": "compare_table_2col",
|
||||
"builder_options": {
|
||||
"item_parser": "compare_row_2col_item",
|
||||
"col_a_label_default": _F18_CATALOG_LITERAL_COL_A,
|
||||
"col_a_label_default_role": "placeholder",
|
||||
"col_b_label_default": _F18_CATALOG_LITERAL_COL_B,
|
||||
"col_b_label_default_role": "placeholder",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_f18_reuse_with_non_bim_dx_rows_suppresses_catalog_placeholder():
|
||||
"""F18-reuse axis (mdx 04-2 scenario, synthetic): BIM/DX MUST NOT leak.
|
||||
|
||||
Models the downstream MDX that maps F18's anchor set to non-BIM/DX
|
||||
content (the issue body cites 정책/조직 as a representative reuse).
|
||||
With ``col_*_label_default_role="placeholder"``, the builder MUST
|
||||
suppress the Figma literals at runtime. Because the synthetic rows
|
||||
themselves carry no BIM / DX tokens, any appearance of those strings
|
||||
anywhere in the payload would prove a catalog literal leaked through
|
||||
— that is precisely the regression IMP-40 #69 must prevent.
|
||||
"""
|
||||
contract = _make_f18_clone_contract()
|
||||
units = [
|
||||
(
|
||||
"- **정책 도입 단계**",
|
||||
[" - 단기 우선순위 수립", " - 부서별 협업 강화"],
|
||||
),
|
||||
(
|
||||
"- **조직 운영 구조**",
|
||||
[" - 의사결정 책임자 명시", " - 정기 리뷰 사이클 운영"],
|
||||
),
|
||||
]
|
||||
|
||||
payload = _build_compare_table_2col(
|
||||
_make_section(), units=units, contract=contract
|
||||
)
|
||||
|
||||
# Placeholder role suppression at the header axis.
|
||||
assert payload["col_a_label"] == ""
|
||||
assert payload["col_b_label"] == ""
|
||||
|
||||
# Row content is derived from MDX-style synthetic units, not catalog.
|
||||
assert len(payload["rows"]) == 2
|
||||
assert payload["rows"][0]["label"] == "정책 도입 단계"
|
||||
assert payload["rows"][0]["col_a"] == "단기 우선순위 수립"
|
||||
assert payload["rows"][0]["col_b"] == "부서별 협업 강화"
|
||||
assert payload["rows"][1]["label"] == "조직 운영 구조"
|
||||
assert payload["rows"][1]["col_a"] == "의사결정 책임자 명시"
|
||||
assert payload["rows"][1]["col_b"] == "정기 리뷰 사이클 운영"
|
||||
|
||||
# F18-leak invariant: BIM / DX tokens MUST NOT appear anywhere in payload.
|
||||
for leak in _F18_LEAK_TOKENS:
|
||||
assert leak not in payload["col_a_label"], (
|
||||
f"placeholder role failed to suppress catalog literal '{leak}' in col_a_label"
|
||||
)
|
||||
assert leak not in payload["col_b_label"], (
|
||||
f"placeholder role failed to suppress catalog literal '{leak}' in col_b_label"
|
||||
)
|
||||
for row in payload["rows"]:
|
||||
assert leak not in row["label"]
|
||||
assert leak not in row["col_a"]
|
||||
assert leak not in row["col_b"]
|
||||
@@ -0,0 +1,249 @@
|
||||
"""IMP-84 u2 — provisional zone silent-render contract.
|
||||
|
||||
Pins that `templates/phase_z2/slide_base.html` no longer surfaces the
|
||||
provisional visual treatment (dashed outline, striped wash, badge span)
|
||||
while keeping `data-provisional="1"` as silent telemetry on the zone div.
|
||||
|
||||
Stage 2 binding contract (IMP-84):
|
||||
- Remove .zone--provisional class emission on the zone div.
|
||||
- Remove .zone__needs-adaptation-badge <span> render.
|
||||
- Remove the .zone--provisional CSS block and the
|
||||
.zone__needs-adaptation-badge CSS block from the <style> section.
|
||||
- Preserve data-provisional="1" attribute emission for provisional zones
|
||||
(downstream telemetry / debug selectors). Out-of-scope: backend
|
||||
`zone.provisional` flag emission itself.
|
||||
|
||||
Helpers below intentionally mirror the IMP-30 first-render test helpers
|
||||
(_render_slide_base / _all_zone_div_openings / _all_badge_spans /
|
||||
_zone_div_for_position) so the silent-render contract is enforced by an
|
||||
independent rendering surface, not by the IMP-30 file (which u3 will
|
||||
invert separately).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
|
||||
|
||||
# ─── helpers (mirrored from IMP-30 u5 to keep this test self-contained) ───
|
||||
|
||||
def _render_slide_base(
|
||||
zones: list[dict],
|
||||
*,
|
||||
layout_preset: str = "single",
|
||||
layout_css: dict | None = None,
|
||||
) -> str:
|
||||
"""Render templates/phase_z2/slide_base.html via Jinja2 with a minimal
|
||||
zones list. Bypasses render_slide() so the template-only silent-render
|
||||
contract is exercised without the pipeline (no mapper, no contracts,
|
||||
no token CSS loader). slot_payload / partial_html are stubbed so the
|
||||
assertions focus on zone div / CSS surface only."""
|
||||
template_dir = (
|
||||
Path(__file__).resolve().parents[2] / "templates" / "phase_z2"
|
||||
)
|
||||
env = Environment(
|
||||
loader=FileSystemLoader(str(template_dir)),
|
||||
autoescape=select_autoescape(["html"]),
|
||||
)
|
||||
if layout_css is None:
|
||||
layout_css = {
|
||||
"cols": "1fr",
|
||||
"rows": "1fr",
|
||||
"areas": '"single"',
|
||||
}
|
||||
for z in zones:
|
||||
z.setdefault("partial_html", "<div class=\"_stub_partial\">stub</div>")
|
||||
base = env.get_template("slide_base.html")
|
||||
return base.render(
|
||||
slide_title="IMP-84 u2 silent-render test",
|
||||
slide_footer=None,
|
||||
zones=zones,
|
||||
layout_preset=layout_preset,
|
||||
layout_css=layout_css,
|
||||
gap_px=12,
|
||||
token_css="",
|
||||
embedded_mode="standalone",
|
||||
)
|
||||
|
||||
|
||||
def _zone_div_for_position(html: str, position: str) -> str:
|
||||
"""Return the opening `<div class="zone..." data-zone-position="X" ...>`
|
||||
tag for the zone at the given `data-zone-position`. Anchors assertions
|
||||
on zone-div-level attributes / classes only."""
|
||||
pattern = re.compile(
|
||||
r'<div class="zone[^"]*"\s+data-zone-position="'
|
||||
+ re.escape(position)
|
||||
+ r'"[^>]*>',
|
||||
re.DOTALL,
|
||||
)
|
||||
match = pattern.search(html)
|
||||
return match.group(0) if match else ""
|
||||
|
||||
|
||||
def _all_zone_div_openings(html: str) -> list[str]:
|
||||
"""Every zone-div opening tag in the layout body. Scopes class /
|
||||
attribute checks away from the <style> block (which may still contain
|
||||
selector strings if a future change re-introduces them)."""
|
||||
return re.findall(
|
||||
r'<div class="zone[^"]*"[^>]*data-zone-position="[^"]*"[^>]*>',
|
||||
html,
|
||||
)
|
||||
|
||||
|
||||
def _all_badge_spans(html: str) -> list[str]:
|
||||
"""Every `.zone__needs-adaptation-badge` <span> element in the rendered
|
||||
body. Must be empty under the IMP-84 silent-render contract regardless
|
||||
of zones[i].provisional value."""
|
||||
return re.findall(
|
||||
r'<span class="zone__needs-adaptation-badge"[^>]*>[^<]*</span>',
|
||||
html,
|
||||
)
|
||||
|
||||
|
||||
# ─── case 1 : non-provisional zone unchanged (regression boundary) ───
|
||||
|
||||
def test_imp84_non_provisional_zone_unchanged():
|
||||
"""zones[i].provisional=False must render the zone div with no
|
||||
provisional class, no data-provisional attr, no badge — byte-equivalent
|
||||
to pre-IMP-84 baseline for the non-provisional path."""
|
||||
zones = [
|
||||
{
|
||||
"position": "single",
|
||||
"template_id": "MOCK_template_direct_a",
|
||||
"slot_payload": {},
|
||||
"content_weight": {"score": 1},
|
||||
"min_height_px": 100,
|
||||
"provisional": False,
|
||||
}
|
||||
]
|
||||
html = _render_slide_base(zones)
|
||||
zone_open = _zone_div_for_position(html, "single")
|
||||
assert zone_open != ""
|
||||
assert "zone--provisional" not in zone_open
|
||||
assert "data-provisional" not in zone_open
|
||||
assert _all_badge_spans(html) == []
|
||||
|
||||
|
||||
# ─── case 2 : provisional zone silent — class / badge / wash removed ───
|
||||
|
||||
def test_imp84_provisional_zone_emits_data_attr_only_no_visual():
|
||||
"""The core IMP-84 silent-render contract.
|
||||
|
||||
With zones[i].provisional=True, the rendered HTML MUST:
|
||||
- NOT contain the `zone--provisional` class on any zone div
|
||||
- NOT render a `.zone__needs-adaptation-badge` <span>
|
||||
- NOT contain the human-visible "needs adaptation" label text
|
||||
- STILL emit `data-provisional="1"` on the provisional zone div
|
||||
(silent telemetry preserved for downstream selectors)
|
||||
"""
|
||||
zones = [
|
||||
{
|
||||
"position": "single",
|
||||
"template_id": "MOCK_template_restructure_a",
|
||||
"slot_payload": {},
|
||||
"content_weight": {"score": 1},
|
||||
"min_height_px": 100,
|
||||
"provisional": True,
|
||||
}
|
||||
]
|
||||
html = _render_slide_base(zones)
|
||||
zone_open = _zone_div_for_position(html, "single")
|
||||
assert zone_open != ""
|
||||
assert "zone--provisional" not in zone_open
|
||||
assert 'data-provisional="1"' in zone_open
|
||||
assert _all_badge_spans(html) == []
|
||||
assert "needs adaptation" not in html
|
||||
|
||||
|
||||
# ─── case 3 : mixed zones — telemetry isolation preserved ───
|
||||
|
||||
def test_imp84_mixed_zones_data_provisional_only_on_provisional_zone():
|
||||
"""In a mixed-zone slide (one provisional + one normal), the silent
|
||||
telemetry attribute must appear ONLY on the provisional zone div, and
|
||||
no visual artifact may surface on either zone."""
|
||||
zones = [
|
||||
{
|
||||
"position": "top",
|
||||
"template_id": "MOCK_template_direct_a",
|
||||
"slot_payload": {},
|
||||
"content_weight": {"score": 1},
|
||||
"min_height_px": 100,
|
||||
"provisional": False,
|
||||
},
|
||||
{
|
||||
"position": "bottom",
|
||||
"template_id": "MOCK_template_restructure_a",
|
||||
"slot_payload": {},
|
||||
"content_weight": {"score": 1},
|
||||
"min_height_px": 100,
|
||||
"provisional": True,
|
||||
},
|
||||
]
|
||||
layout_css = {
|
||||
"cols": "1fr",
|
||||
"rows": "1fr 1fr",
|
||||
"areas": '"top" "bottom"',
|
||||
}
|
||||
html = _render_slide_base(
|
||||
zones, layout_preset="vertical-2", layout_css=layout_css
|
||||
)
|
||||
zone_divs = _all_zone_div_openings(html)
|
||||
assert len(zone_divs) == 2
|
||||
|
||||
top_zone_open = _zone_div_for_position(html, "top")
|
||||
bottom_zone_open = _zone_div_for_position(html, "bottom")
|
||||
assert "data-provisional" not in top_zone_open
|
||||
assert 'data-provisional="1"' in bottom_zone_open
|
||||
|
||||
for tag in zone_divs:
|
||||
assert "zone--provisional" not in tag
|
||||
assert _all_badge_spans(html) == []
|
||||
assert "needs adaptation" not in html
|
||||
|
||||
|
||||
# ─── case 4 : <style> block free of provisional visual selectors ───
|
||||
|
||||
def test_imp84_style_block_has_no_provisional_visual_selectors():
|
||||
"""The provisional visual CSS classes are deleted at source. A future
|
||||
refactor that re-introduces `.zone--provisional` or
|
||||
`.zone__needs-adaptation-badge` selectors into slide_base.html breaks
|
||||
this test rather than silently restoring the visual badge."""
|
||||
zones = [
|
||||
{
|
||||
"position": "single",
|
||||
"template_id": "MOCK_template_restructure_a",
|
||||
"slot_payload": {},
|
||||
"content_weight": {"score": 1},
|
||||
"min_height_px": 100,
|
||||
"provisional": True,
|
||||
}
|
||||
]
|
||||
html = _render_slide_base(zones)
|
||||
assert ".zone--provisional" not in html
|
||||
assert ".zone__needs-adaptation-badge" not in html
|
||||
assert "zone__needs-adaptation-badge" not in html
|
||||
|
||||
|
||||
# ─── case 5 : provisional defaults to false (template fallback) ───
|
||||
|
||||
def test_imp84_provisional_none_falls_back_to_silent_non_provisional():
|
||||
"""When zones[i].provisional is explicitly None (falsy but not False),
|
||||
the template's truthy check must NOT emit `data-provisional`. Pins the
|
||||
template fallback so a refactor cannot silently invert the default."""
|
||||
zones = [
|
||||
{
|
||||
"position": "single",
|
||||
"template_id": "MOCK_template_direct_a",
|
||||
"slot_payload": {},
|
||||
"content_weight": {"score": 1},
|
||||
"min_height_px": 100,
|
||||
"provisional": None,
|
||||
}
|
||||
]
|
||||
html = _render_slide_base(zones)
|
||||
zone_open = _zone_div_for_position(html, "single")
|
||||
assert zone_open != ""
|
||||
assert "data-provisional" not in zone_open
|
||||
assert _all_badge_spans(html) == []
|
||||
@@ -0,0 +1,200 @@
|
||||
"""IMP-39 u6 (issue #68) - synthetic divergence regression.
|
||||
|
||||
Loads the SYNTHETIC fixture under
|
||||
``tests/phase_z2/fixtures/ranking_sort_policy/`` and asserts that the
|
||||
single-source ranking policy
|
||||
(``templates/phase_z2/catalog/ranking_sort_policy.yaml``, u1) resolves
|
||||
the backend - frontend "rank 1" divergence captured in Stage 1
|
||||
root-cause analysis.
|
||||
|
||||
Divergence scenario (Stage 1 root cause):
|
||||
- Pre-policy backend iterates ``judgments_full32`` in raw V4
|
||||
confidence-desc order (``src/phase_z2_pipeline.py`` selector loop
|
||||
behavior before u2). High-confidence ``restructure`` at
|
||||
``v4_full_rank=1`` wins; lower-confidence ``use_as_is`` further
|
||||
down the list is shadowed.
|
||||
- Frontend (``Front/client/src/services/designAgentApi.ts``)
|
||||
re-sorts the same source by ``LABEL_PRIORITY asc + confidence
|
||||
desc`` and surfaces ``use_as_is`` as ``frame_candidates[0]``.
|
||||
- Backend "selected rank 1" and frontend ``frame_candidates[0]``
|
||||
diverge.
|
||||
|
||||
Post-policy (u2 wires ``apply_ranking_sort`` into the selector after
|
||||
the IMP-38 raw-window slice), backend selection order matches the
|
||||
frontend ordering: ``use_as_is`` is rank 1 on both sides.
|
||||
|
||||
Scope (u6, Stage 2 plan):
|
||||
- SYNTHETIC fixture only - sample-agnostic, no MDX 03/04/05
|
||||
references, no real ``frame_id`` / ``template_id`` literals.
|
||||
- Helper-level exercise of ``apply_ranking_sort`` (mirrors the
|
||||
selector's policy step at
|
||||
``src/phase_z2_pipeline.py:1186-1196``).
|
||||
|
||||
Out of scope (other units):
|
||||
- u1 policy yaml shape: covered by ``test_ranking_sort_policy.py``.
|
||||
- u2 selector wiring: integration covered elsewhere.
|
||||
- u3 Step 9 payload forwarding.
|
||||
- u4 frontend mirror.
|
||||
- u7 mdx04 env-toggle e2e.
|
||||
- u8 corpus audit over ``tests/matching/v4_full32_result.yaml``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
|
||||
FIXTURE_PATH = (
|
||||
Path(__file__).parent
|
||||
/ "fixtures"
|
||||
/ "ranking_sort_policy"
|
||||
/ "synthetic_divergence.yaml"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_policy_cache():
|
||||
"""Mirror test_ranking_sort_policy.py isolation - clear the cached policy."""
|
||||
import src.phase_z2_pipeline as pipeline
|
||||
|
||||
pipeline._RANKING_SORT_POLICY_CACHE = None
|
||||
yield
|
||||
pipeline._RANKING_SORT_POLICY_CACHE = None
|
||||
|
||||
|
||||
def _load_fixture() -> dict:
|
||||
with FIXTURE_PATH.open(encoding="utf-8") as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
|
||||
def test_synthetic_fixture_shape_is_intact():
|
||||
fixture = _load_fixture()
|
||||
|
||||
assert fixture["fixture_id"] == "synthetic_divergence"
|
||||
assert fixture["sample_agnostic"] is True
|
||||
raw = fixture["raw_judgments"]
|
||||
assert len(raw) == 4
|
||||
assert {j["label"] for j in raw} == {
|
||||
"use_as_is",
|
||||
"light_edit",
|
||||
"restructure",
|
||||
"reject",
|
||||
}
|
||||
assert len(fixture["expected_legacy_raw_order"]) == len(raw)
|
||||
assert len(fixture["expected_policy_sorted_order"]) == len(raw)
|
||||
div = fixture["divergence_axis"]
|
||||
assert div["pre_policy_rank_1_tag"] != div["post_policy_rank_1_tag"]
|
||||
assert div["post_policy_rank_1_tag"] == div["frontend_candidate_0_tag"]
|
||||
|
||||
|
||||
def test_legacy_raw_order_demonstrates_divergence():
|
||||
"""Pre-policy raw V4 confidence-desc order is the divergence source."""
|
||||
fixture = _load_fixture()
|
||||
raw = fixture["raw_judgments"]
|
||||
|
||||
assert [j["tag"] for j in raw] == fixture["expected_legacy_raw_order"]
|
||||
|
||||
pre_rank_1 = raw[0]
|
||||
assert pre_rank_1["tag"] == fixture["divergence_axis"]["pre_policy_rank_1_tag"]
|
||||
assert pre_rank_1["label"] == "restructure"
|
||||
|
||||
higher_priority_shadowed = next(
|
||||
j for j in raw[1:] if j["label"] == "use_as_is"
|
||||
)
|
||||
assert higher_priority_shadowed["confidence"] < pre_rank_1["confidence"]
|
||||
|
||||
|
||||
def test_apply_ranking_sort_resolves_divergence():
|
||||
"""Post-policy order puts the higher-priority label first."""
|
||||
from src.phase_z2_pipeline import apply_ranking_sort
|
||||
|
||||
fixture = _load_fixture()
|
||||
|
||||
sorted_judgments = apply_ranking_sort(
|
||||
fixture["raw_judgments"],
|
||||
label_key="label",
|
||||
confidence_key="confidence",
|
||||
v4_rank_key="v4_full_rank",
|
||||
)
|
||||
|
||||
assert [j["tag"] for j in sorted_judgments] == fixture[
|
||||
"expected_policy_sorted_order"
|
||||
]
|
||||
assert sorted_judgments[0]["label"] == "use_as_is"
|
||||
assert (
|
||||
sorted_judgments[0]["tag"]
|
||||
== fixture["divergence_axis"]["post_policy_rank_1_tag"]
|
||||
)
|
||||
|
||||
|
||||
def test_backend_rank_1_aligns_with_frontend_candidate_zero():
|
||||
"""Backend selector policy step and frontend candidate ordering agree.
|
||||
|
||||
Mirrors the selector policy step at
|
||||
``src/phase_z2_pipeline.py:1186-1196`` (u2 wiring) and the frontend
|
||||
``frame_candidates[0]`` derivation from ``sorted_candidate_evidence``
|
||||
(``Front/client/src/services/designAgentApi.ts`` u4 wiring). The
|
||||
selector's MVP1 status gate / contract / capacity checks are
|
||||
out of scope - u8 corpus audit exercises the real
|
||||
catalog-registered flow.
|
||||
"""
|
||||
from src.phase_z2_pipeline import (
|
||||
apply_ranking_sort,
|
||||
load_ranking_sort_policy,
|
||||
)
|
||||
|
||||
fixture = _load_fixture()
|
||||
policy = load_ranking_sort_policy()
|
||||
|
||||
sorted_window = apply_ranking_sort(
|
||||
fixture["raw_judgments"],
|
||||
policy=policy,
|
||||
label_key="label",
|
||||
confidence_key="confidence",
|
||||
v4_rank_key="v4_full_rank",
|
||||
)
|
||||
|
||||
backend_rank_1 = sorted_window[0]
|
||||
frontend_candidate_0 = sorted_window[0]
|
||||
|
||||
expected_tag = fixture["divergence_axis"]["frontend_candidate_0_tag"]
|
||||
assert backend_rank_1["tag"] == expected_tag
|
||||
assert frontend_candidate_0["tag"] == expected_tag
|
||||
assert backend_rank_1 is frontend_candidate_0
|
||||
|
||||
|
||||
def test_input_list_is_not_mutated():
|
||||
"""Fixture list reference and order survive ``apply_ranking_sort``."""
|
||||
from src.phase_z2_pipeline import apply_ranking_sort
|
||||
|
||||
fixture = _load_fixture()
|
||||
raw = fixture["raw_judgments"]
|
||||
snapshot_tags = [j["tag"] for j in raw]
|
||||
|
||||
apply_ranking_sort(
|
||||
raw,
|
||||
label_key="label",
|
||||
confidence_key="confidence",
|
||||
v4_rank_key="v4_full_rank",
|
||||
)
|
||||
|
||||
assert [j["tag"] for j in raw] == snapshot_tags
|
||||
|
||||
|
||||
def test_pre_policy_legacy_order_can_be_reproduced():
|
||||
"""Synthetic fixture's legacy order matches raw V4 confidence-desc.
|
||||
|
||||
Sanity check that ``expected_legacy_raw_order`` is consistent with
|
||||
a confidence-desc sort of ``raw_judgments`` ignoring the policy.
|
||||
This keeps the divergence axis honest if the fixture is edited.
|
||||
"""
|
||||
fixture = _load_fixture()
|
||||
raw = fixture["raw_judgments"]
|
||||
|
||||
confidence_desc = sorted(raw, key=lambda j: -j["confidence"])
|
||||
|
||||
assert [j["tag"] for j in confidence_desc] == fixture[
|
||||
"expected_legacy_raw_order"
|
||||
]
|
||||
@@ -0,0 +1,135 @@
|
||||
"""IMP-42 u2 (#71) — post-render HTML invalid path char detector diag tests.
|
||||
|
||||
Stage 1/2 scope-lock §B: rendered partial / base HTML output must fail loud
|
||||
with a typed error when src / href / url(...) attribute values contain
|
||||
invalid path characters that would silently surface downstream as 404 /
|
||||
asset-load failures.
|
||||
|
||||
Three production vectors are covered:
|
||||
- Windows backslash from ``str(Path)`` (e.g. ``assets\\img.png``).
|
||||
- Autoescape entity ``&`` (raw ``&`` in raw path string).
|
||||
- Autoescape entity ``'`` (raw ``'`` in raw path string).
|
||||
|
||||
Assertions cover RULE 0 generality:
|
||||
- error type is ValueError (typed, not bare exception)
|
||||
- error message cites context label + attr type + value snippet
|
||||
- clean rendered HTML (forward slashes only) does not raise
|
||||
- non-attribute backslash (body text) does not raise
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_pipeline import _scan_rendered_html_for_invalid_path_chars
|
||||
|
||||
|
||||
# ─── backslash vector ────────────────────────────────────────────
|
||||
|
||||
def test_backslash_in_src_raises_with_context_and_attr_label():
|
||||
html = '<img src="assets\\img.png">'
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
_scan_rendered_html_for_invalid_path_chars(html, "zone[0] template_id='foo'")
|
||||
msg = str(exc_info.value)
|
||||
assert "zone[0] template_id='foo'" in msg
|
||||
assert "src" in msg
|
||||
assert "assets\\img.png" in msg
|
||||
|
||||
|
||||
def test_backslash_in_href_raises():
|
||||
html = '<link href="styles\\app.css" rel="stylesheet">'
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
_scan_rendered_html_for_invalid_path_chars(html, "ctx")
|
||||
msg = str(exc_info.value)
|
||||
assert "href" in msg
|
||||
assert "styles\\app.css" in msg
|
||||
|
||||
|
||||
def test_backslash_in_url_raises():
|
||||
html = "<style>body { background: url(images\\bg.png); }</style>"
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
_scan_rendered_html_for_invalid_path_chars(html, "ctx")
|
||||
msg = str(exc_info.value)
|
||||
assert "url(...)" in msg
|
||||
assert "images\\bg.png" in msg
|
||||
|
||||
|
||||
def test_backslash_in_url_with_quotes_raises():
|
||||
html = "<style>div { background: url('images\\bg.png'); }</style>"
|
||||
with pytest.raises(ValueError):
|
||||
_scan_rendered_html_for_invalid_path_chars(html, "ctx")
|
||||
|
||||
|
||||
# ─── autoescape entity vectors ───────────────────────────────────
|
||||
|
||||
def test_escaped_ampersand_in_src_raises():
|
||||
html = '<img src="assets/img&v=1.png">'
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
_scan_rendered_html_for_invalid_path_chars(html, "ctx")
|
||||
msg = str(exc_info.value)
|
||||
assert "&" in msg
|
||||
assert "src" in msg
|
||||
|
||||
|
||||
def test_escaped_apostrophe_in_href_raises():
|
||||
html = '<a href="docs/it's-here.pdf">x</a>'
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
_scan_rendered_html_for_invalid_path_chars(html, "ctx")
|
||||
msg = str(exc_info.value)
|
||||
assert "'" in msg
|
||||
assert "href" in msg
|
||||
|
||||
|
||||
def test_escaped_ampersand_in_url_raises():
|
||||
html = "<style>div { background: url('img&.png'); }</style>"
|
||||
with pytest.raises(ValueError):
|
||||
_scan_rendered_html_for_invalid_path_chars(html, "ctx")
|
||||
|
||||
|
||||
# ─── negative cases (must NOT raise) ─────────────────────────────
|
||||
|
||||
def test_clean_forward_slash_src_does_not_raise():
|
||||
html = '<img src="assets/img.png"><link href="styles/app.css" rel="stylesheet">'
|
||||
_scan_rendered_html_for_invalid_path_chars(html, "ctx")
|
||||
|
||||
|
||||
def test_clean_url_does_not_raise():
|
||||
html = "<style>div { background: url('images/bg.png'); }</style>"
|
||||
_scan_rendered_html_for_invalid_path_chars(html, "ctx")
|
||||
|
||||
|
||||
def test_backslash_in_body_text_does_not_raise():
|
||||
# Backslash outside src/href/url is not a path-attr signal.
|
||||
html = "<p>Windows path example: C:\\Users\\foo</p>"
|
||||
_scan_rendered_html_for_invalid_path_chars(html, "ctx")
|
||||
|
||||
|
||||
def test_escaped_entities_in_body_text_do_not_raise():
|
||||
# Body-text autoescape (e.g. legitimate & in copy) is not a path signal.
|
||||
html = "<p>AT&T 'quoted' text</p>"
|
||||
_scan_rendered_html_for_invalid_path_chars(html, "ctx")
|
||||
|
||||
|
||||
def test_empty_html_does_not_raise():
|
||||
_scan_rendered_html_for_invalid_path_chars("", "ctx")
|
||||
|
||||
|
||||
# ─── error message contract ──────────────────────────────────────
|
||||
|
||||
def test_error_message_truncates_long_value_to_snippet():
|
||||
long_path = "a" * 200 + "\\img.png"
|
||||
html = f'<img src="{long_path}">'
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
_scan_rendered_html_for_invalid_path_chars(html, "ctx")
|
||||
msg = str(exc_info.value)
|
||||
assert "..." in msg # truncation marker present
|
||||
|
||||
|
||||
def test_error_message_cites_context_label_verbatim():
|
||||
html = '<img src="x\\y.png">'
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
_scan_rendered_html_for_invalid_path_chars(
|
||||
html, "zones_data[7] template_id='dx_sw_necessity_three_perspectives'"
|
||||
)
|
||||
msg = str(exc_info.value)
|
||||
assert "zones_data[7]" in msg
|
||||
assert "dx_sw_necessity_three_perspectives" in msg
|
||||
@@ -0,0 +1,116 @@
|
||||
"""IMP-42 u1 (#71) — render_slide precondition assertion diag tests.
|
||||
|
||||
Stage 1/2 scope-lock §A: Step 13 `render_slide()` partial render loop must
|
||||
fail loud with a typed error when a zone dict is missing `template_id` or
|
||||
`slot_payload`, instead of silently surfacing as Jinja `TemplateNotFound`
|
||||
or `KeyError` far from the Step 12 emit site.
|
||||
|
||||
Assertions cover RULE 0 generality:
|
||||
- error type is TypeError (typed, not bare AssertionError / KeyError)
|
||||
- error message cites zone index + missing key
|
||||
- empty-zone short-circuit (`__empty__`) still bypasses the precondition,
|
||||
preserving the existing grid-identity behaviour from Codex #10 Catch N.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_pipeline import render_slide
|
||||
|
||||
|
||||
def _layout_css() -> dict:
|
||||
return {"areas": '"primary"', "cols": "1fr", "rows": "1fr"}
|
||||
|
||||
|
||||
def _ok_zone() -> dict:
|
||||
return {"position": "primary", "template_id": "__empty__", "slot_payload": {}}
|
||||
|
||||
|
||||
def _render(zones_data: list[dict]) -> str:
|
||||
return render_slide(
|
||||
slide_title="t",
|
||||
slide_footer=None,
|
||||
zones_data=zones_data,
|
||||
layout_preset="single",
|
||||
layout_css=_layout_css(),
|
||||
gap_px=14,
|
||||
embedded_mode="embedded",
|
||||
)
|
||||
|
||||
|
||||
def test_template_id_missing_raises_typed_error_with_index_and_key():
|
||||
zone = {"position": "primary", "slot_payload": {}}
|
||||
with pytest.raises(TypeError) as exc_info:
|
||||
_render([zone])
|
||||
msg = str(exc_info.value)
|
||||
assert "zones_data[0]" in msg
|
||||
assert "template_id" in msg
|
||||
|
||||
|
||||
def test_template_id_empty_string_raises_typed_error():
|
||||
zone = {"position": "primary", "template_id": "", "slot_payload": {}}
|
||||
with pytest.raises(TypeError) as exc_info:
|
||||
_render([zone])
|
||||
msg = str(exc_info.value)
|
||||
assert "zones_data[0]" in msg
|
||||
assert "template_id" in msg
|
||||
assert "non-empty" in msg
|
||||
|
||||
|
||||
def test_template_id_none_raises_typed_error():
|
||||
zone = {"position": "primary", "template_id": None, "slot_payload": {}}
|
||||
with pytest.raises(TypeError) as exc_info:
|
||||
_render([zone])
|
||||
msg = str(exc_info.value)
|
||||
assert "zones_data[0]" in msg
|
||||
assert "template_id" in msg
|
||||
|
||||
|
||||
def test_template_id_non_string_raises_typed_error():
|
||||
zone = {"position": "primary", "template_id": 42, "slot_payload": {}}
|
||||
with pytest.raises(TypeError) as exc_info:
|
||||
_render([zone])
|
||||
msg = str(exc_info.value)
|
||||
assert "zones_data[0]" in msg
|
||||
assert "template_id" in msg
|
||||
|
||||
|
||||
def test_slot_payload_missing_raises_typed_error_with_index_and_key():
|
||||
zone = {"position": "primary", "template_id": "__placeholder__"}
|
||||
with pytest.raises(TypeError) as exc_info:
|
||||
_render([zone])
|
||||
msg = str(exc_info.value)
|
||||
assert "zones_data[0]" in msg
|
||||
assert "slot_payload" in msg
|
||||
|
||||
|
||||
def test_slot_payload_non_dict_raises_typed_error():
|
||||
zone = {
|
||||
"position": "primary",
|
||||
"template_id": "__placeholder__",
|
||||
"slot_payload": ["not", "a", "dict"],
|
||||
}
|
||||
with pytest.raises(TypeError) as exc_info:
|
||||
_render([zone])
|
||||
msg = str(exc_info.value)
|
||||
assert "zones_data[0]" in msg
|
||||
assert "slot_payload" in msg
|
||||
assert "dict" in msg
|
||||
|
||||
|
||||
def test_second_zone_failure_reports_correct_index():
|
||||
zones = [_ok_zone(), {"position": "secondary", "slot_payload": {}}]
|
||||
with pytest.raises(TypeError) as exc_info:
|
||||
_render(zones)
|
||||
msg = str(exc_info.value)
|
||||
assert "zones_data[1]" in msg
|
||||
assert "template_id" in msg
|
||||
|
||||
|
||||
def test_empty_zone_short_circuit_bypasses_precondition():
|
||||
# __empty__ short-circuit must run before precondition checks so that
|
||||
# legitimate empty zones (no slot_payload required) still render.
|
||||
zones = [{"position": "primary", "template_id": "__empty__"}]
|
||||
html = _render(zones)
|
||||
assert isinstance(html, str)
|
||||
assert len(html) > 0
|
||||
@@ -0,0 +1,92 @@
|
||||
"""IMP-42 u5 (#71) — general 32-frame smoke for diag tools (registry-driven).
|
||||
|
||||
Stage 1/2 RULE 0 lock: u1 (precondition assert), u2 (invalid-path detector),
|
||||
and u3 (backend DIAG) must work GENERALLY across every frame declared in
|
||||
``templates/phase_z2/catalog/frame_contracts.yaml`` — not only the MDX
|
||||
03/04/05 samples that motivated #71.
|
||||
|
||||
The smoke enumerates every top-level frame contract and parametrizes the
|
||||
three diag behaviors against each ``template_id``. AI = 0, no visual_check,
|
||||
no real partial render — payloads are synthetic so the coverage stays
|
||||
sample-agnostic and never depends on frame-specific slot shapes.
|
||||
|
||||
Each parametrized case covers one silent-fail vector from #71 root cause:
|
||||
- u1 precondition: Step 13 partial render must fail loud on missing
|
||||
``slot_payload`` regardless of which frame contract is in play.
|
||||
- u2 invalid path: post-render asset-ref scan must fire on a synthetic
|
||||
backslash ``src`` value when the context cites any frame's id.
|
||||
- u3 DIAG: ``_emit_diag_zones_shape`` must include the frame's
|
||||
``template_id`` in the JSON payload for every contract.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_mapper import load_frame_contracts
|
||||
from src.phase_z2_pipeline import (
|
||||
_emit_diag_zones_shape,
|
||||
_scan_rendered_html_for_invalid_path_chars,
|
||||
render_slide,
|
||||
)
|
||||
|
||||
|
||||
_FRAME_IDS = sorted(load_frame_contracts().keys())
|
||||
|
||||
|
||||
def test_registry_has_expected_frame_count():
|
||||
# Pin the 32-frame floor — additions are auto-covered by parametrize,
|
||||
# while a regression that drops below 32 surfaces here loud.
|
||||
assert len(_FRAME_IDS) >= 32, (
|
||||
f"frame_contracts.yaml expected ≥ 32 entries, got {len(_FRAME_IDS)}: "
|
||||
f"{_FRAME_IDS}"
|
||||
)
|
||||
|
||||
|
||||
def _layout_css() -> dict:
|
||||
return {"areas": '"primary"', "cols": "1fr", "rows": "1fr"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("template_id", _FRAME_IDS)
|
||||
def test_u1_precondition_fires_for_every_frame(template_id):
|
||||
zone = {"position": "primary", "template_id": template_id}
|
||||
with pytest.raises(TypeError) as exc_info:
|
||||
render_slide(
|
||||
slide_title="t",
|
||||
slide_footer=None,
|
||||
zones_data=[zone],
|
||||
layout_preset="single",
|
||||
layout_css=_layout_css(),
|
||||
gap_px=14,
|
||||
embedded_mode="embedded",
|
||||
)
|
||||
msg = str(exc_info.value)
|
||||
assert "zones_data[0]" in msg
|
||||
assert "slot_payload" in msg
|
||||
|
||||
|
||||
@pytest.mark.parametrize("template_id", _FRAME_IDS)
|
||||
def test_u2_invalid_char_detector_fires_for_every_frame_context(template_id):
|
||||
html = '<img src="assets\\img.png">'
|
||||
ctx = f"zones_data[0] template_id={template_id!r}"
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
_scan_rendered_html_for_invalid_path_chars(html, ctx)
|
||||
msg = str(exc_info.value)
|
||||
assert template_id in msg
|
||||
assert "src" in msg
|
||||
|
||||
|
||||
@pytest.mark.parametrize("template_id", _FRAME_IDS)
|
||||
def test_u3_diag_emits_template_id_for_every_frame(template_id, capsys):
|
||||
zones = [
|
||||
{"position": "primary", "template_id": template_id, "slot_payload": {}}
|
||||
]
|
||||
_emit_diag_zones_shape("Step 12 slot_payload emit", zones)
|
||||
line = capsys.readouterr().out.strip()
|
||||
prefix = "[DIAG] phase_z2 Step 12 slot_payload emit "
|
||||
assert line.startswith(prefix), f"missing DIAG prefix on line={line!r}"
|
||||
payload = json.loads(line[len(prefix):])
|
||||
assert payload["zones_count"] == 1
|
||||
assert payload["zones"][0]["template_id"] == template_id
|
||||
assert payload["zones"][0]["slot_keys"] == []
|
||||
@@ -0,0 +1,223 @@
|
||||
"""IMP-42 u3 (#71) — unconditional Step 12 / Step 13 backend DIAG terminal logs.
|
||||
|
||||
Stage 1/2 scope-lock §C-backend: Step 12 slot_payload emit + Step 13
|
||||
render_slide entry must each emit a shape-only `[DIAG]` line to stdout
|
||||
on every slide loop, with no env gate. The line carries enough zone
|
||||
shape (position / template_id / slot_payload key list) to debug the
|
||||
silent 3-hop handoff documented in #71, without leaking raw slot
|
||||
content (RULE 0 sample-agnostic).
|
||||
|
||||
Coverage:
|
||||
- helper emits `[DIAG] phase_z2 <stage_label>` prefix
|
||||
- helper payload is structured JSON with zones_count + per-zone shape
|
||||
- per-zone shape includes i / position / template_id / slot_keys
|
||||
- slot_keys is a sorted key list (never raw values)
|
||||
- slot_keys is null when slot_payload is missing or non-dict
|
||||
- extra_fields are merged into the payload at top level
|
||||
- render_slide() entry call site fires the helper on every invocation
|
||||
- source-slice confirms Step 12 emit site invokes the helper after
|
||||
the slot_payload `_write_step_artifact(...)` call.
|
||||
|
||||
Diag is unconditional — no env-var gate; silence is the bug per Stage 1.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src import phase_z2_pipeline
|
||||
from src.phase_z2_pipeline import (
|
||||
_emit_diag_zones_shape,
|
||||
render_slide,
|
||||
)
|
||||
|
||||
|
||||
# ─── helper unit tests ───────────────────────────────────────────
|
||||
|
||||
|
||||
def _parse_diag_line(line: str, expected_label: str) -> dict:
|
||||
prefix = f"[DIAG] phase_z2 {expected_label} "
|
||||
assert line.startswith(prefix), (
|
||||
f"expected DIAG prefix {prefix!r}, got line={line!r}"
|
||||
)
|
||||
return json.loads(line[len(prefix):])
|
||||
|
||||
|
||||
def test_helper_emits_diag_prefix_and_json(capsys):
|
||||
zones = [{"position": "primary", "template_id": "foo", "slot_payload": {"a": 1, "b": 2}}]
|
||||
_emit_diag_zones_shape("Step 12 slot_payload emit", zones)
|
||||
captured = capsys.readouterr().out.strip().splitlines()
|
||||
assert len(captured) == 1
|
||||
payload = _parse_diag_line(captured[0], "Step 12 slot_payload emit")
|
||||
assert payload["zones_count"] == 1
|
||||
assert payload["zones"][0]["position"] == "primary"
|
||||
assert payload["zones"][0]["template_id"] == "foo"
|
||||
|
||||
|
||||
def test_helper_slot_keys_is_sorted_key_list_not_values(capsys):
|
||||
# Raw values ("secret content") must not leak into the diag line.
|
||||
zones = [{
|
||||
"position": "primary",
|
||||
"template_id": "foo",
|
||||
"slot_payload": {"z_last": "secret content", "a_first": "another secret"},
|
||||
}]
|
||||
_emit_diag_zones_shape("Step 12 slot_payload emit", zones)
|
||||
line = capsys.readouterr().out.strip()
|
||||
assert "secret content" not in line
|
||||
assert "another secret" not in line
|
||||
payload = _parse_diag_line(line, "Step 12 slot_payload emit")
|
||||
assert payload["zones"][0]["slot_keys"] == ["a_first", "z_last"]
|
||||
|
||||
|
||||
def test_helper_slot_keys_null_when_slot_payload_missing(capsys):
|
||||
zones = [{"position": "primary", "template_id": "__empty__"}]
|
||||
_emit_diag_zones_shape("Step 13 render_slide entry", zones)
|
||||
payload = _parse_diag_line(capsys.readouterr().out.strip(), "Step 13 render_slide entry")
|
||||
assert payload["zones"][0]["slot_keys"] is None
|
||||
|
||||
|
||||
def test_helper_slot_keys_null_when_slot_payload_non_dict(capsys):
|
||||
zones = [{"position": "primary", "template_id": "foo", "slot_payload": ["not", "dict"]}]
|
||||
_emit_diag_zones_shape("Step 13 render_slide entry", zones)
|
||||
payload = _parse_diag_line(capsys.readouterr().out.strip(), "Step 13 render_slide entry")
|
||||
assert payload["zones"][0]["slot_keys"] is None
|
||||
|
||||
|
||||
def test_helper_per_zone_index_threading(capsys):
|
||||
zones = [
|
||||
{"position": "top", "template_id": "alpha", "slot_payload": {}},
|
||||
{"position": "bottom_l", "template_id": "beta", "slot_payload": {"k": "v"}},
|
||||
{"position": "bottom_r", "template_id": "gamma", "slot_payload": {"k": "v"}},
|
||||
]
|
||||
_emit_diag_zones_shape("Step 12 slot_payload emit", zones)
|
||||
payload = _parse_diag_line(capsys.readouterr().out.strip(), "Step 12 slot_payload emit")
|
||||
assert payload["zones_count"] == 3
|
||||
assert [z["i"] for z in payload["zones"]] == [0, 1, 2]
|
||||
assert [z["position"] for z in payload["zones"]] == ["top", "bottom_l", "bottom_r"]
|
||||
assert [z["template_id"] for z in payload["zones"]] == ["alpha", "beta", "gamma"]
|
||||
|
||||
|
||||
def test_helper_extra_fields_merged_into_payload(capsys):
|
||||
zones = [{"position": "primary", "template_id": "__empty__"}]
|
||||
_emit_diag_zones_shape(
|
||||
"Step 13 render_slide entry",
|
||||
zones,
|
||||
layout_preset="single",
|
||||
embedded_mode="embedded",
|
||||
)
|
||||
payload = _parse_diag_line(capsys.readouterr().out.strip(), "Step 13 render_slide entry")
|
||||
assert payload["layout_preset"] == "single"
|
||||
assert payload["embedded_mode"] == "embedded"
|
||||
|
||||
|
||||
def test_helper_empty_zones_list_still_emits_line(capsys):
|
||||
# No zones is a valid (degenerate) shape — diag must still fire, never silent.
|
||||
_emit_diag_zones_shape("Step 12 slot_payload emit", [])
|
||||
payload = _parse_diag_line(capsys.readouterr().out.strip(), "Step 12 slot_payload emit")
|
||||
assert payload["zones_count"] == 0
|
||||
assert payload["zones"] == []
|
||||
|
||||
|
||||
# ─── render_slide entry call site integration ────────────────────
|
||||
|
||||
|
||||
def _layout_css() -> dict:
|
||||
return {"areas": '"primary"', "cols": "1fr", "rows": "1fr"}
|
||||
|
||||
|
||||
def test_render_slide_entry_emits_step13_diag_on_every_call(capsys):
|
||||
zones = [{"position": "primary", "template_id": "__empty__"}]
|
||||
render_slide(
|
||||
slide_title="t",
|
||||
slide_footer=None,
|
||||
zones_data=zones,
|
||||
layout_preset="single",
|
||||
layout_css=_layout_css(),
|
||||
gap_px=14,
|
||||
embedded_mode="embedded",
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
step13_lines = [
|
||||
ln for ln in out.splitlines()
|
||||
if ln.startswith("[DIAG] phase_z2 Step 13 render_slide entry ")
|
||||
]
|
||||
assert len(step13_lines) == 1, (
|
||||
f"expected exactly 1 Step 13 DIAG line, got {len(step13_lines)} from out={out!r}"
|
||||
)
|
||||
payload = _parse_diag_line(step13_lines[0], "Step 13 render_slide entry")
|
||||
assert payload["layout_preset"] == "single"
|
||||
assert payload["embedded_mode"] == "embedded"
|
||||
assert payload["zones_count"] == 1
|
||||
|
||||
|
||||
def test_render_slide_fires_step13_diag_before_template_lookup(capsys):
|
||||
# Diag must fire even when the precondition (u1) later raises — the diag
|
||||
# is at entry, so the user sees the zone shape even on failure.
|
||||
zones = [{"position": "primary", "slot_payload": {}}]
|
||||
with pytest.raises(TypeError):
|
||||
render_slide(
|
||||
slide_title="t",
|
||||
slide_footer=None,
|
||||
zones_data=zones,
|
||||
layout_preset="single",
|
||||
layout_css=_layout_css(),
|
||||
gap_px=14,
|
||||
embedded_mode="embedded",
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert "[DIAG] phase_z2 Step 13 render_slide entry " in out
|
||||
|
||||
|
||||
# ─── Step 12 emit call site source-slice ──────────────────────────
|
||||
|
||||
|
||||
def test_step12_emit_call_site_invokes_helper_after_artifact_write():
|
||||
# The Step 12 emit site is buried inside the orchestrator; rather than
|
||||
# spin up the full pipeline, assert by source-slice that the helper is
|
||||
# invoked with the "Step 12 slot_payload emit" label *after* the
|
||||
# _write_step_artifact(... step12 ... "slot_payload" ...) call.
|
||||
src = Path(phase_z2_pipeline.__file__).read_text(encoding="utf-8")
|
||||
artifact_marker = '_write_step_artifact(\n run_dir, 12, "slot_payload"'
|
||||
helper_marker = '_emit_diag_zones_shape("Step 12 slot_payload emit", zones_data)'
|
||||
artifact_pos = src.find(artifact_marker)
|
||||
helper_pos = src.find(helper_marker)
|
||||
assert artifact_pos != -1, "Step 12 slot_payload artifact write not found"
|
||||
assert helper_pos != -1, "Step 12 DIAG helper call not found"
|
||||
assert helper_pos > artifact_pos, (
|
||||
"Step 12 DIAG helper call must appear after the slot_payload artifact write "
|
||||
f"(artifact_pos={artifact_pos}, helper_pos={helper_pos})"
|
||||
)
|
||||
|
||||
|
||||
def test_step13_entry_call_site_invokes_helper_inside_render_slide():
|
||||
src = Path(phase_z2_pipeline.__file__).read_text(encoding="utf-8")
|
||||
render_slide_def = src.find("def render_slide(")
|
||||
assert render_slide_def != -1, "render_slide definition not found"
|
||||
# Bound the search to the function body — find next def or class after it.
|
||||
next_def = src.find("\ndef ", render_slide_def + len("def render_slide("))
|
||||
body = src[render_slide_def:next_def if next_def != -1 else len(src)]
|
||||
assert '_emit_diag_zones_shape(\n "Step 13 render_slide entry"' in body, (
|
||||
"Step 13 DIAG helper call not found inside render_slide()"
|
||||
)
|
||||
|
||||
|
||||
# ─── unconditional contract (no env-gate) ────────────────────────
|
||||
|
||||
|
||||
def test_diag_helper_has_no_env_gate(monkeypatch, capsys):
|
||||
# Stage 1 contract: diag is unconditional. Setting any plausible
|
||||
# "verbose off" env var must not silence the line. We test the most
|
||||
# common gate names a future contributor might be tempted to add.
|
||||
for env_name in (
|
||||
"PHASE_Z_DIAG_VERBOSE",
|
||||
"DIAG_VERBOSE",
|
||||
"VERBOSE",
|
||||
"DEBUG",
|
||||
"PYTHON_NO_DIAG",
|
||||
):
|
||||
monkeypatch.setenv(env_name, "0")
|
||||
_emit_diag_zones_shape("Step 12 slot_payload emit", [])
|
||||
out = capsys.readouterr().out
|
||||
assert "[DIAG] phase_z2 Step 12 slot_payload emit" in out
|
||||
@@ -77,10 +77,14 @@ def test_three_new_salvage_failure_types_route_to_expected_cascade_actions():
|
||||
assert NEXT_ACTION_BY_FAILURE["glue_absorption_insufficient"] == "font_step_compression"
|
||||
assert NEXT_ACTION_BY_FAILURE["font_step_insufficient"] == "layout_adjust"
|
||||
|
||||
# Implementation status (u7): 2 cascade entries IMPLEMENTED, layout_adjust MISSING
|
||||
# Implementation status: 3 cascade entries IMPLEMENTED.
|
||||
# layout_adjust was MISSING pre-IMP-88; IMP-88 u7 (2026-05-24) flipped it
|
||||
# to IMPLEMENTED on the failure-router surface alongside the primary
|
||||
# router surface (u3 plan_layout_adjust + u6 dispatcher branch + u7
|
||||
# cascade entry trigger).
|
||||
assert NEXT_ACTION_IMPLEMENTATION_STATUS["glue_compression"] == "IMPLEMENTED"
|
||||
assert NEXT_ACTION_IMPLEMENTATION_STATUS["font_step_compression"] == "IMPLEMENTED"
|
||||
assert NEXT_ACTION_IMPLEMENTATION_STATUS["layout_adjust"] == "MISSING"
|
||||
assert NEXT_ACTION_IMPLEMENTATION_STATUS["layout_adjust"] == "IMPLEMENTED"
|
||||
|
||||
# Classifier path via salvage_steps[-1].action → failure_type → next action
|
||||
cases = [
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
"""IMP-88 u6 — Step 17 salvage cascade dispatcher tests for the two
|
||||
new branches (`layout_adjust` + `frame_internal_fit_candidate`).
|
||||
|
||||
Stage 2 binding contract (u6):
|
||||
- Extend `_SALVAGE_FAIL_BY_ACTION` to include the two IMP-88 actions so
|
||||
the salvage loop range adapts and the cascade does not exit early at
|
||||
`layout_adjust` / `frame_internal_fit_candidate` (previously terminal
|
||||
in the 3-entry map, now executable).
|
||||
- `layout_adjust` takes a distinct render path: it calls `render_slide`
|
||||
with the NEW preset + remapped zones_data + new layout_css (built via
|
||||
`apply_layout_adjust_layout_css`). No CSS overlay — topology swap only
|
||||
(honors `[[feedback_phase_z_spacing_direction]]`: no common margin
|
||||
shrink, no slide-body shrink).
|
||||
- `frame_internal_fit_candidate` uses the shared CSS-overlay path
|
||||
(same as font_step_compression / glue_compression) because the
|
||||
planner emits a frame-scoped CSS rule via
|
||||
`apply_frame_internal_fit_candidate_css`.
|
||||
|
||||
Test surfaces (8 tests):
|
||||
1. `_SALVAGE_FAIL_BY_ACTION` map registers the two new actions with
|
||||
the failure_type names the failure_router (u2) cascade rows expect.
|
||||
2. `layout_adjust` PASS — out_path promoted with the swapped render,
|
||||
step records new_layout_preset, cascade exits.
|
||||
3. `layout_adjust` infeasible (no sibling for `single` preset) — step
|
||||
records failure_reason without invoking render_slide; cascade
|
||||
advances to frame_internal_fit_candidate.
|
||||
4. `layout_adjust` rerender FAIL — cascade advances to
|
||||
frame_internal_fit_candidate (which then PASSes via patched
|
||||
envelope).
|
||||
5. `frame_internal_fit_candidate` PASS via patched envelope — out_path
|
||||
promoted with CSS-overlay candidate.
|
||||
6. `frame_internal_fit_candidate` no-envelope — step records
|
||||
envelope_present=False; cascade exits via frame_reselect terminal.
|
||||
7. Full 5-step cascade all fail — loop cap (range(len(map))=5)
|
||||
respected; exactly 5 steps recorded; out_path preserved.
|
||||
8. `layout_adjust` uses no CSS-overlay path (css_override field is
|
||||
absent from the layout_adjust step; new_layout_preset is present).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import src.phase_z2_mapper as _pz_mapper
|
||||
import src.phase_z2_pipeline as _pz_pipeline
|
||||
from src.phase_z2_pipeline import _SALVAGE_FAIL_BY_ACTION, _attempt_salvage_chain
|
||||
|
||||
|
||||
_PROJECT_ROOT = _pz_pipeline.PROJECT_ROOT
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project_tmp(tmp_path_factory):
|
||||
"""Temp dir under PROJECT_ROOT so `_attempt_salvage_chain` can call
|
||||
`candidate_path.relative_to(PROJECT_ROOT)` without ValueError on a
|
||||
cross-drive Windows tmp path (default pytest tmp_path lives under
|
||||
%LOCALAPPDATA% which is on a different drive from the project root).
|
||||
Mirrors the fixture pattern in test_phase_z2_step17_salvage_chain.py.
|
||||
"""
|
||||
base = _PROJECT_ROOT / ".orchestrator" / "tmp"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
d = Path(tempfile.mkdtemp(prefix="imp88_u6_", dir=str(base)))
|
||||
try:
|
||||
yield d
|
||||
finally:
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
|
||||
|
||||
# IMP-09 gate-passing layout_css envelope. _attempt_salvage_chain skips
|
||||
# the cascade when dynamic_cols=True or dynamic_rows=False. Mirror of the
|
||||
# fixture in test_phase_z2_step17_salvage_chain.py so this test file
|
||||
# stays consistent with the existing u15 cascade surface.
|
||||
_LAYOUT_CSS_GATE_PASS = {
|
||||
"areas": '"top" "bottom"',
|
||||
"cols": "1fr",
|
||||
"rows": "1fr 1fr",
|
||||
"heights_px": [300, 290],
|
||||
"widths_px": [1180],
|
||||
"ratios": [0.508, 0.491],
|
||||
"width_ratios": [1.0],
|
||||
"dynamic_rows": True,
|
||||
"dynamic_cols": False,
|
||||
}
|
||||
|
||||
|
||||
def _patch_render(monkeypatch):
|
||||
"""Stub render_slide → deterministic HTML envelope. Counter exposes
|
||||
invocation count so tests can assert render_slide was (or was not)
|
||||
called per branch."""
|
||||
counter = {"n": 0}
|
||||
|
||||
def _stub(slide_title, slide_footer, zones_data, layout_preset, layout_css, gap_px=14):
|
||||
counter["n"] += 1
|
||||
return (
|
||||
f"<html><head><meta charset='utf-8'></head>"
|
||||
f"<body><div data-slide-title='{slide_title}' "
|
||||
f"data-rendered-preset='{layout_preset}'></div></body></html>"
|
||||
)
|
||||
|
||||
monkeypatch.setattr(_pz_pipeline, "render_slide", _stub)
|
||||
return counter
|
||||
|
||||
|
||||
def _horizontal_zones() -> list[dict]:
|
||||
"""horizontal-2 zones with content_weight.score so the vertical-2
|
||||
swap path can call _build_cols_dynamic → compute_zone_layout_cols
|
||||
inside apply_layout_adjust_layout_css → build_layout_css."""
|
||||
return [
|
||||
{"position": "top", "template_id": "t-top",
|
||||
"content_weight": {"score": 1.0}},
|
||||
{"position": "bottom", "template_id": "t-bottom",
|
||||
"content_weight": {"score": 1.0}},
|
||||
]
|
||||
|
||||
|
||||
def _ci_image() -> dict:
|
||||
"""Minimal cascade_inputs for the cascade chain — covers the keys
|
||||
every branch reads (fit_analysis is None to deliberately keep the
|
||||
cross_zone branch infeasible when the cascade enters there, since
|
||||
full FitAnalysis assembly is the u15 cascade test's domain not u6's)."""
|
||||
return {
|
||||
"fit_analysis": None, "containers": {}, "min_margin_px": 10,
|
||||
"excess_px": 40.0, "excess_after_glue_px": 40.0,
|
||||
"block_count": 3, "zone_position": "top",
|
||||
"current_font_px": 15.2, "available_lines": 10, "chars_per_line": 40,
|
||||
}
|
||||
|
||||
|
||||
# ── 1. _SALVAGE_FAIL_BY_ACTION map registration ─────────────────────
|
||||
|
||||
|
||||
def test_salvage_fail_map_registers_imp88_actions():
|
||||
"""u6 extends the salvage-action → failure_type map from 3 to 5
|
||||
entries so the loop cap (range(len(map))) covers the IMP-88
|
||||
cascade depth. failure_type names mirror failure_router u2's
|
||||
SALVAGE_FAILURE_TYPE_BY_ACTION rows."""
|
||||
assert _SALVAGE_FAIL_BY_ACTION["layout_adjust"] == "layout_adjust_insufficient"
|
||||
assert _SALVAGE_FAIL_BY_ACTION["frame_internal_fit_candidate"] == "frame_internal_fit_candidate_insufficient"
|
||||
assert len(_SALVAGE_FAIL_BY_ACTION) == 5
|
||||
# u4 image_fit stays OUT of the salvage chain map — u7 handles it as a
|
||||
# Step 17 entry single-pass (not a cascade salvage stage). Guard against
|
||||
# accidental future registration that would change cascade semantics.
|
||||
assert "image_fit" not in _SALVAGE_FAIL_BY_ACTION
|
||||
|
||||
|
||||
# ── 2. layout_adjust PASS branch ────────────────────────────────────
|
||||
|
||||
|
||||
def test_layout_adjust_pass_promotes_final_html(project_tmp, monkeypatch):
|
||||
"""initial_failure_type=font_step_insufficient routes to layout_adjust;
|
||||
plan_layout_adjust swaps horizontal-2 → vertical-2 (rows ↔ cols, swap
|
||||
priority 0). render_slide is invoked with the NEW preset; overflow
|
||||
passes → out_path promoted; step records new_layout_preset; cascade
|
||||
exits with salvage_passed=True. No CSS overlay path was used."""
|
||||
out_path = project_tmp / "final.html"
|
||||
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
|
||||
counter = _patch_render(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
_pz_pipeline, "run_overflow_check",
|
||||
lambda p: {"passed": True, "fail_reasons": []},
|
||||
)
|
||||
|
||||
trace = _attempt_salvage_chain(
|
||||
run_dir=project_tmp, out_path=out_path,
|
||||
slide_title="imp88-u6", slide_footer=None,
|
||||
zones_data=_horizontal_zones(),
|
||||
layout_preset="horizontal-2", layout_css=_LAYOUT_CSS_GATE_PASS,
|
||||
cascade_inputs=_ci_image(),
|
||||
initial_failure_type="font_step_insufficient", gap_px=14,
|
||||
)
|
||||
|
||||
assert trace["salvage_attempted"] is True
|
||||
assert trace["salvage_passed"] is True
|
||||
assert len(trace["salvage_steps"]) == 1
|
||||
step0 = trace["salvage_steps"][0]
|
||||
assert step0["action"] == "layout_adjust"
|
||||
assert step0["passed"] is True
|
||||
assert step0["new_layout_preset"] == "vertical-2"
|
||||
assert step0["plan"]["feasible"] is True
|
||||
# layout_adjust uses the distinct render path — NOT the CSS-overlay path.
|
||||
assert "css_override" not in step0
|
||||
# render_slide was invoked exactly once with the NEW preset.
|
||||
assert counter["n"] == 1
|
||||
promoted = out_path.read_text(encoding="utf-8")
|
||||
assert "ORIGINAL_BEFORE_SALVAGE" not in promoted
|
||||
assert "data-rendered-preset='vertical-2'" in promoted
|
||||
|
||||
|
||||
# ── 3. layout_adjust infeasible (no sibling) ────────────────────────
|
||||
|
||||
|
||||
def test_layout_adjust_infeasible_no_sibling_cascade_advances(project_tmp, monkeypatch):
|
||||
"""`single` preset has no render-ready unit_count=1 sibling (catalog
|
||||
design — single and grid-2x2 have no swap target). layout_adjust
|
||||
returns feasible=False; render_slide is NOT invoked; cascade
|
||||
advances to frame_internal_fit_candidate. Patched get_contract
|
||||
returns no envelope → that branch also infeasible → cascade exits
|
||||
at the frame_reselect terminal action."""
|
||||
out_path = project_tmp / "final.html"
|
||||
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
|
||||
counter = _patch_render(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
_pz_pipeline, "run_overflow_check",
|
||||
lambda p: pytest.fail(
|
||||
"run_overflow_check must not run when no candidate is emitted"
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
_pz_mapper, "get_contract", lambda _tid: None,
|
||||
)
|
||||
|
||||
trace = _attempt_salvage_chain(
|
||||
run_dir=project_tmp, out_path=out_path,
|
||||
slide_title="imp88-u6", slide_footer=None,
|
||||
zones_data=[{"position": "primary", "template_id": "t-only",
|
||||
"content_weight": {"score": 1.0}}],
|
||||
layout_preset="single", layout_css=_LAYOUT_CSS_GATE_PASS,
|
||||
cascade_inputs={**_ci_image(), "zone_position": "primary"},
|
||||
initial_failure_type="font_step_insufficient", gap_px=14,
|
||||
)
|
||||
|
||||
assert trace["salvage_attempted"] is True
|
||||
assert trace["salvage_passed"] is False
|
||||
actions = [s["action"] for s in trace["salvage_steps"]]
|
||||
assert actions == ["layout_adjust", "frame_internal_fit_candidate"]
|
||||
s0, s1 = trace["salvage_steps"]
|
||||
assert s0["plan"]["feasible"] is False
|
||||
assert "no render-ready" in (s0["plan"]["failure_reason"] or "")
|
||||
assert s0["new_layout_preset"] is None
|
||||
assert s1["plan"]["feasible"] is False
|
||||
assert s1["plan"]["envelope_present"] is False
|
||||
# No candidate ever rendered (layout_adjust infeasible → no render call;
|
||||
# frame_internal_fit_candidate infeasible → no render call).
|
||||
assert counter["n"] == 0
|
||||
# frame_reselect is the next routing target after
|
||||
# frame_internal_fit_candidate_insufficient — not in salvage map → terminal.
|
||||
assert trace.get("salvage_terminal_action") == "frame_reselect"
|
||||
# Original final.html unchanged.
|
||||
assert out_path.read_text(encoding="utf-8") == "ORIGINAL_BEFORE_SALVAGE"
|
||||
|
||||
|
||||
# ── 4. layout_adjust rerender FAIL → frame_internal_fit_candidate PASS ──
|
||||
|
||||
|
||||
def test_layout_adjust_fail_cascade_to_frame_internal_fit_pass(project_tmp, monkeypatch):
|
||||
"""layout_adjust feasible but post-swap overflow persists →
|
||||
failure_type=layout_adjust_insufficient → routes to
|
||||
frame_internal_fit_candidate. Patched contract provides an envelope
|
||||
variant that absorbs the excess; that branch PASSes → out_path
|
||||
promoted with the CSS-overlay candidate."""
|
||||
out_path = project_tmp / "final.html"
|
||||
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
|
||||
counter = _patch_render(monkeypatch)
|
||||
|
||||
# First overflow check (after layout_adjust render) FAILS,
|
||||
# second (after frame_internal_fit CSS overlay) PASSes.
|
||||
overflow_results = iter([
|
||||
{"passed": False, "fail_reasons": ["zone overflow persists"]},
|
||||
{"passed": True, "fail_reasons": []},
|
||||
])
|
||||
monkeypatch.setattr(
|
||||
_pz_pipeline, "run_overflow_check",
|
||||
lambda p: next(overflow_results),
|
||||
)
|
||||
|
||||
# Stub contract with a feasible internal_envelope variant covering 40px excess.
|
||||
stub_contract = {
|
||||
"internal_envelope": {
|
||||
"variants": [
|
||||
{"name": "internal_grid_row",
|
||||
"excess_budget_px": 60,
|
||||
"css_overrides": {"padding-top": "0px"}},
|
||||
],
|
||||
},
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
_pz_mapper, "get_contract", lambda _tid: stub_contract,
|
||||
)
|
||||
|
||||
trace = _attempt_salvage_chain(
|
||||
run_dir=project_tmp, out_path=out_path,
|
||||
slide_title="imp88-u6", slide_footer=None,
|
||||
zones_data=_horizontal_zones(),
|
||||
layout_preset="horizontal-2", layout_css=_LAYOUT_CSS_GATE_PASS,
|
||||
cascade_inputs=_ci_image(),
|
||||
initial_failure_type="font_step_insufficient", gap_px=14,
|
||||
)
|
||||
|
||||
assert trace["salvage_passed"] is True
|
||||
assert len(trace["salvage_steps"]) == 2
|
||||
s0, s1 = trace["salvage_steps"]
|
||||
assert s0["action"] == "layout_adjust"
|
||||
assert s0["passed"] is False
|
||||
assert s0["plan"]["feasible"] is True
|
||||
assert s1["action"] == "frame_internal_fit_candidate"
|
||||
assert s1["passed"] is True
|
||||
assert s1["plan"]["selected_variant"] == "internal_grid_row"
|
||||
assert s1["css_override"]
|
||||
assert 'data-template-id="t-top"' in s1["css_override"]
|
||||
# Two render_slide calls (one per dispatched branch).
|
||||
assert counter["n"] == 2
|
||||
assert "ORIGINAL_BEFORE_SALVAGE" not in out_path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# ── 5. frame_internal_fit_candidate PASS (direct entry) ─────────────
|
||||
|
||||
|
||||
def test_frame_internal_fit_candidate_pass_promotes_final_html(project_tmp, monkeypatch):
|
||||
"""initial_failure_type=layout_adjust_insufficient routes directly to
|
||||
frame_internal_fit_candidate. Patched contract envelope variant
|
||||
covers excess; CSS-overlay candidate PASSes → out_path promoted."""
|
||||
out_path = project_tmp / "final.html"
|
||||
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
|
||||
counter = _patch_render(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
_pz_pipeline, "run_overflow_check",
|
||||
lambda p: {"passed": True, "fail_reasons": []},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
_pz_mapper, "get_contract",
|
||||
lambda _tid: {
|
||||
"internal_envelope": {
|
||||
"variants": [
|
||||
{"name": "density_envelope",
|
||||
"excess_budget_px": 80,
|
||||
"css_overrides": {"line-height": "1.4"}},
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
trace = _attempt_salvage_chain(
|
||||
run_dir=project_tmp, out_path=out_path,
|
||||
slide_title="imp88-u6", slide_footer=None,
|
||||
zones_data=_horizontal_zones(),
|
||||
layout_preset="horizontal-2", layout_css=_LAYOUT_CSS_GATE_PASS,
|
||||
cascade_inputs=_ci_image(),
|
||||
initial_failure_type="layout_adjust_insufficient", gap_px=14,
|
||||
)
|
||||
|
||||
assert trace["salvage_passed"] is True
|
||||
assert len(trace["salvage_steps"]) == 1
|
||||
step0 = trace["salvage_steps"][0]
|
||||
assert step0["action"] == "frame_internal_fit_candidate"
|
||||
assert step0["passed"] is True
|
||||
assert step0["plan"]["selected_variant"] == "density_envelope"
|
||||
assert step0["css_override"] and "line-height: 1.4" in step0["css_override"]
|
||||
assert counter["n"] == 1
|
||||
|
||||
|
||||
# ── 6. frame_internal_fit_candidate no envelope → terminal exit ─────
|
||||
|
||||
|
||||
def test_frame_internal_fit_candidate_no_envelope_cascade_terminal(project_tmp, monkeypatch):
|
||||
"""initial=layout_adjust_insufficient routes to
|
||||
frame_internal_fit_candidate. Patched contract has NO
|
||||
internal_envelope → planner returns feasible=False with
|
||||
envelope_present=False; failure_type=
|
||||
frame_internal_fit_candidate_insufficient → routes to
|
||||
frame_reselect (not in salvage map) → terminal exit recorded."""
|
||||
out_path = project_tmp / "final.html"
|
||||
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
|
||||
counter = _patch_render(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
_pz_pipeline, "run_overflow_check",
|
||||
lambda p: pytest.fail("no candidate emitted — overflow_check must not run"),
|
||||
)
|
||||
# Contract present but no internal_envelope → envelope_present=False branch.
|
||||
monkeypatch.setattr(
|
||||
_pz_mapper, "get_contract", lambda _tid: {"some_other_key": "value"},
|
||||
)
|
||||
|
||||
trace = _attempt_salvage_chain(
|
||||
run_dir=project_tmp, out_path=out_path,
|
||||
slide_title="imp88-u6", slide_footer=None,
|
||||
zones_data=_horizontal_zones(),
|
||||
layout_preset="horizontal-2", layout_css=_LAYOUT_CSS_GATE_PASS,
|
||||
cascade_inputs=_ci_image(),
|
||||
initial_failure_type="layout_adjust_insufficient", gap_px=14,
|
||||
)
|
||||
|
||||
assert trace["salvage_passed"] is False
|
||||
assert len(trace["salvage_steps"]) == 1
|
||||
step0 = trace["salvage_steps"][0]
|
||||
assert step0["action"] == "frame_internal_fit_candidate"
|
||||
assert step0["plan"]["feasible"] is False
|
||||
assert step0["plan"]["envelope_present"] is False
|
||||
assert trace["salvage_terminal_action"] == "frame_reselect"
|
||||
assert counter["n"] == 0
|
||||
assert out_path.read_text(encoding="utf-8") == "ORIGINAL_BEFORE_SALVAGE"
|
||||
|
||||
|
||||
# ── 7. Loop cap respected — 5 stages all fail ───────────────────────
|
||||
|
||||
|
||||
def test_full_5_step_cascade_all_fail_loop_cap_respected(project_tmp, monkeypatch):
|
||||
"""Start at donor_slack_insufficient and force every cascade stage to
|
||||
fail: cross_zone (no fit_analysis), glue (excess > envelope), font_step
|
||||
(no headroom), layout_adjust (single → no sibling), frame_internal_fit
|
||||
(no contract). Loop iterates exactly len(_SALVAGE_FAIL_BY_ACTION)=5
|
||||
times → salvage_steps has 5 entries in the exact cascade order."""
|
||||
out_path = project_tmp / "final.html"
|
||||
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
|
||||
counter = _patch_render(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
_pz_pipeline, "run_overflow_check",
|
||||
lambda p: pytest.fail("no CSS emitted in any branch — overflow_check must not run"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
_pz_mapper, "get_contract", lambda _tid: None,
|
||||
)
|
||||
|
||||
trace = _attempt_salvage_chain(
|
||||
run_dir=project_tmp, out_path=out_path,
|
||||
slide_title="imp88-u6", slide_footer=None,
|
||||
# single preset → layout_adjust will be infeasible (no sibling)
|
||||
zones_data=[{"position": "primary", "template_id": "t-only",
|
||||
"content_weight": {"score": 1.0}}],
|
||||
layout_preset="single", layout_css=_LAYOUT_CSS_GATE_PASS,
|
||||
cascade_inputs={
|
||||
"fit_analysis": None, "containers": {}, "min_margin_px": 10,
|
||||
# excess_px=200 > glue envelope at block_count=1 (max ~28) → infeasible
|
||||
"excess_px": 200.0, "excess_after_glue_px": 200.0,
|
||||
"block_count": 1, "zone_position": "primary",
|
||||
# current_font_px cannot absorb 200px even at 8px floor → infeasible
|
||||
"current_font_px": 15.2, "available_lines": 10, "chars_per_line": 40,
|
||||
},
|
||||
initial_failure_type="donor_slack_insufficient", gap_px=14,
|
||||
)
|
||||
|
||||
assert trace["salvage_passed"] is False
|
||||
assert len(trace["salvage_steps"]) == 5
|
||||
actions = [s["action"] for s in trace["salvage_steps"]]
|
||||
assert actions == [
|
||||
"cross_zone_redistribute",
|
||||
"glue_compression",
|
||||
"font_step_compression",
|
||||
"layout_adjust",
|
||||
"frame_internal_fit_candidate",
|
||||
]
|
||||
# All 5 are infeasible — no candidate rendering anywhere.
|
||||
assert counter["n"] == 0
|
||||
# Loop exhausted at cap (no mid-cascade terminal_action since each
|
||||
# next_action stayed in _SALVAGE_FAIL_BY_ACTION through 5 stages).
|
||||
assert "salvage_terminal_action" not in trace
|
||||
assert out_path.read_text(encoding="utf-8") == "ORIGINAL_BEFORE_SALVAGE"
|
||||
|
||||
|
||||
# ── 8. layout_adjust uses the distinct render path (no CSS overlay) ──
|
||||
|
||||
|
||||
def test_layout_adjust_step_has_no_css_overlay_field(project_tmp, monkeypatch):
|
||||
"""layout_adjust's render path is qualitatively different from the
|
||||
CSS-overlay planners (glue / font_step / cross_zone / frame_internal_fit):
|
||||
it calls render_slide with the NEW preset + remapped zones_data + new
|
||||
layout_css. The step dict therefore omits `css_override` and surfaces
|
||||
`new_layout_preset` instead — observability for downstream classifiers."""
|
||||
out_path = project_tmp / "final.html"
|
||||
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
|
||||
_patch_render(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
_pz_pipeline, "run_overflow_check",
|
||||
lambda p: {"passed": True, "fail_reasons": []},
|
||||
)
|
||||
|
||||
trace = _attempt_salvage_chain(
|
||||
run_dir=project_tmp, out_path=out_path,
|
||||
slide_title="imp88-u6", slide_footer=None,
|
||||
zones_data=_horizontal_zones(),
|
||||
layout_preset="horizontal-2", layout_css=_LAYOUT_CSS_GATE_PASS,
|
||||
cascade_inputs=_ci_image(),
|
||||
initial_failure_type="font_step_insufficient", gap_px=14,
|
||||
)
|
||||
|
||||
step0 = trace["salvage_steps"][0]
|
||||
assert step0["action"] == "layout_adjust"
|
||||
# Distinct render path observability contract:
|
||||
assert "css_override" not in step0
|
||||
assert "new_layout_preset" in step0
|
||||
assert "candidate_path" in step0
|
||||
@@ -0,0 +1,542 @@
|
||||
"""IMP-88 u7 — Step 17 entry runtime caller tests.
|
||||
|
||||
Stage 2 binding contract (u7):
|
||||
- `_attempt_step17_image_fit_single_pass` executes the image_fit Step 17
|
||||
entry single-pass: per-event plan_image_fit → apply_image_fit_css →
|
||||
aggregated CSS overlay → single re-render → run_overflow_check.
|
||||
PASS promotes final.html and returns a salvage_steps-shaped entry with
|
||||
post_salvage_overflow; FAIL returns the same shape with failure_reason
|
||||
(NO out_path mutation on FAIL).
|
||||
- image_fit stays OUT of `_SALVAGE_FAIL_BY_ACTION` (u6 guard). The Step 17
|
||||
entry single-pass is NOT a cascade stage — it runs BEFORE the cascade
|
||||
direct-entry block in pipeline §11.7.2.
|
||||
- Honors `[[feedback_phase_z_spacing_direction]]` — img-scoped CSS only,
|
||||
no common margin / slide-body shrink. Honors AI isolation contract
|
||||
(PZ-1) — deterministic data-surface, no AI call.
|
||||
- Step 17/18/19 artifact refresh: the pipeline §11.7.1 wrapper around the
|
||||
helper re-runs classify_visual_runtime_check + route_fit_classification +
|
||||
enrich_retry_trace_with_failure_classification on PASS so Step 18
|
||||
failure_classification + Step 19 next_action_proposal reflect the
|
||||
post-image_fit state (not the stale pre-image_fit state).
|
||||
- direct entry triggers (§11.7.2) for layout_adjust /
|
||||
frame_internal_fit_candidate / image_fit_insufficient route into
|
||||
`_attempt_salvage_chain` with a synthetic initial_failure_type that
|
||||
failure_router u2 NEXT_ACTION_BY_FAILURE maps onto the proposed action.
|
||||
|
||||
Test surfaces (12 tests):
|
||||
1. helper returns triggered=False when no image_events.
|
||||
2. helper returns triggered=False when every image_event is below tol
|
||||
(delta=None or |delta|<=tol).
|
||||
3. helper returns triggered=False when plan_image_fit emits no CSS for
|
||||
any feasible event (rendered_w/h missing — apply returns None).
|
||||
4. helper PASS — out_path promoted, step shape correct (action=image_fit,
|
||||
passed=True, image_fit_event_plans recorded, post_salvage_overflow).
|
||||
5. helper FAIL — out_path NOT promoted, step records failure_reason +
|
||||
no post_salvage_overflow key.
|
||||
6. helper aggregates CSS chunks from multiple events into ONE candidate
|
||||
re-render (render_slide called exactly once).
|
||||
7. helper writes candidate to run_dir as `salvage_image_fit_candidate.html`
|
||||
and step.candidate_path is the project-root-relative form.
|
||||
8. helper passes delta_tol through to plan_image_fit (override threshold
|
||||
filters which events get planned/applied).
|
||||
9. image_fit stays OUT of `_SALVAGE_FAIL_BY_ACTION` (u6 guard re-asserted
|
||||
so u7 does not accidentally register image_fit as a cascade stage).
|
||||
10. helper guards out_path mutation strictly under PASS (FAIL = no write).
|
||||
11. helper exposes every plan_image_fit result through `event_plans`
|
||||
even when no CSS was emitted (telemetry continuity for Step 17/18/19).
|
||||
12. helper honors frame-scoped img CSS only — emitted CSS contains an
|
||||
img selector and does NOT touch shared margins / slide-body / zone gap.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import src.phase_z2_pipeline as _pz_pipeline
|
||||
from src.phase_z2_pipeline import (
|
||||
_SALVAGE_FAIL_BY_ACTION,
|
||||
_attempt_step17_image_fit_single_pass,
|
||||
)
|
||||
|
||||
|
||||
_PROJECT_ROOT = _pz_pipeline.PROJECT_ROOT
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project_tmp(tmp_path_factory):
|
||||
"""Temp dir under PROJECT_ROOT so candidate_path.relative_to(PROJECT_ROOT)
|
||||
does not raise ValueError on a cross-drive Windows tmp path. Mirrors the
|
||||
fixture in test_phase_z2_pipeline_salvage_imp88.py."""
|
||||
base = _PROJECT_ROOT / ".orchestrator" / "tmp"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
d = Path(tempfile.mkdtemp(prefix="imp88_u7_", dir=str(base)))
|
||||
try:
|
||||
yield d
|
||||
finally:
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
|
||||
|
||||
# IMP-09 gate-passing layout_css envelope — kept for parity even though the
|
||||
# image_fit single-pass helper does not consult the gate (gate is internal
|
||||
# to _attempt_salvage_chain). Reserved for §11.7.2 direct-entry tests.
|
||||
_LAYOUT_CSS_GATE_PASS = {
|
||||
"areas": '"top" "bottom"',
|
||||
"cols": "1fr",
|
||||
"rows": "1fr 1fr",
|
||||
"heights_px": [300, 290],
|
||||
"widths_px": [1180],
|
||||
"ratios": [0.508, 0.491],
|
||||
"width_ratios": [1.0],
|
||||
"dynamic_rows": True,
|
||||
"dynamic_cols": False,
|
||||
}
|
||||
|
||||
|
||||
def _stub_render_capture(monkeypatch):
|
||||
"""Stub render_slide → deterministic HTML envelope; counter exposes
|
||||
invocation count + a recorder of the (preset, css overlay observed via
|
||||
candidate_html) tuple per call."""
|
||||
state = {"n": 0, "calls": []}
|
||||
|
||||
def _stub(slide_title, slide_footer, zones_data, layout_preset, layout_css, gap_px=14):
|
||||
state["n"] += 1
|
||||
state["calls"].append({
|
||||
"preset": layout_preset, "zones_count": len(zones_data),
|
||||
"gap_px": gap_px,
|
||||
})
|
||||
return (
|
||||
f"<html><head><meta charset='utf-8'></head>"
|
||||
f"<body><div data-rendered-preset='{layout_preset}'>"
|
||||
f"<img src='zoneA.png'/></div></body></html>"
|
||||
)
|
||||
|
||||
monkeypatch.setattr(_pz_pipeline, "render_slide", _stub)
|
||||
return state
|
||||
|
||||
|
||||
def _zones() -> list[dict]:
|
||||
return [
|
||||
{"position": "top", "template_id": "t-top",
|
||||
"content_weight": {"score": 1.0}},
|
||||
{"position": "bottom", "template_id": "t-bottom",
|
||||
"content_weight": {"score": 1.0}},
|
||||
]
|
||||
|
||||
|
||||
def _image_event(*, src: str, zone_position: str = "top",
|
||||
zone_template_id: str = "t-top",
|
||||
natural_w: int = 1600, natural_h: int = 900,
|
||||
rendered_w: int = 800, rendered_h: int = 600,
|
||||
delta: float | None = 0.20) -> dict:
|
||||
"""Mirror the shape pipeline JS injection emits at lines 3019-3060."""
|
||||
natural_ratio = natural_w / natural_h if natural_h else None
|
||||
rendered_ratio = rendered_w / rendered_h if rendered_h else None
|
||||
return {
|
||||
"src": src,
|
||||
"zone_position": zone_position,
|
||||
"zone_template_id": zone_template_id,
|
||||
"natural_w": natural_w, "natural_h": natural_h,
|
||||
"rendered_w": rendered_w, "rendered_h": rendered_h,
|
||||
"natural_ratio": natural_ratio,
|
||||
"rendered_ratio": rendered_ratio,
|
||||
"delta": delta,
|
||||
}
|
||||
|
||||
|
||||
# ── 1. No image_events → not triggered ──────────────────────────────
|
||||
|
||||
|
||||
def test_helper_not_triggered_when_no_image_events(project_tmp, monkeypatch):
|
||||
out_path = project_tmp / "final.html"
|
||||
out_path.write_text("ORIGINAL_BEFORE_U7", encoding="utf-8")
|
||||
counter = _stub_render_capture(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
_pz_pipeline, "run_overflow_check",
|
||||
lambda p: pytest.fail("must not run overflow_check when not triggered"),
|
||||
)
|
||||
|
||||
res = _attempt_step17_image_fit_single_pass(
|
||||
run_dir=project_tmp, out_path=out_path,
|
||||
slide_title="u7-empty", slide_footer=None,
|
||||
zones_data=_zones(), layout_preset="vertical-2",
|
||||
layout_css=_LAYOUT_CSS_GATE_PASS, image_events=[], gap_px=14,
|
||||
)
|
||||
|
||||
assert res["triggered"] is False
|
||||
assert res["passed"] is False
|
||||
assert res["step"] is None
|
||||
assert res["candidate_html"] is None
|
||||
assert res["candidate_overflow"] is None
|
||||
assert res["event_plans"] == []
|
||||
assert counter["n"] == 0
|
||||
assert out_path.read_text(encoding="utf-8") == "ORIGINAL_BEFORE_U7"
|
||||
|
||||
|
||||
# ── 2. All events sub-tolerance → not triggered ─────────────────────
|
||||
|
||||
|
||||
def test_helper_not_triggered_when_all_events_under_tolerance(project_tmp, monkeypatch):
|
||||
"""delta=None (image not loaded) + |delta|<=tol (no aspect mismatch
|
||||
above threshold) both should produce feasible=False plans, no CSS,
|
||||
and triggered=False at the helper level."""
|
||||
out_path = project_tmp / "final.html"
|
||||
out_path.write_text("ORIGINAL_BEFORE_U7", encoding="utf-8")
|
||||
counter = _stub_render_capture(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
_pz_pipeline, "run_overflow_check",
|
||||
lambda p: pytest.fail("must not run overflow_check when not triggered"),
|
||||
)
|
||||
|
||||
events = [
|
||||
_image_event(src="zoneA.png", delta=None), # not loaded
|
||||
_image_event(src="zoneB.png", delta=0.03), # under tol
|
||||
]
|
||||
res = _attempt_step17_image_fit_single_pass(
|
||||
run_dir=project_tmp, out_path=out_path,
|
||||
slide_title="u7-undertol", slide_footer=None,
|
||||
zones_data=_zones(), layout_preset="vertical-2",
|
||||
layout_css=_LAYOUT_CSS_GATE_PASS, image_events=events, gap_px=14,
|
||||
)
|
||||
|
||||
assert res["triggered"] is False
|
||||
assert len(res["event_plans"]) == 2
|
||||
assert all(p.get("feasible") is False for p in res["event_plans"])
|
||||
assert counter["n"] == 0
|
||||
assert out_path.read_text(encoding="utf-8") == "ORIGINAL_BEFORE_U7"
|
||||
|
||||
|
||||
# ── 3. Feasible but apply_image_fit_css returns None ─────────────────
|
||||
|
||||
|
||||
def test_helper_not_triggered_when_apply_returns_none(project_tmp, monkeypatch):
|
||||
"""rendered_w/h missing on a feasible-shaped event means apply_image_fit_css
|
||||
would emit empty CSS — but plan_image_fit treats missing rendered dims as
|
||||
infeasible (per u4 contract), so this stays triggered=False."""
|
||||
out_path = project_tmp / "final.html"
|
||||
out_path.write_text("ORIGINAL_BEFORE_U7", encoding="utf-8")
|
||||
counter = _stub_render_capture(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
_pz_pipeline, "run_overflow_check",
|
||||
lambda p: pytest.fail("must not run overflow_check when not triggered"),
|
||||
)
|
||||
|
||||
bad_event = _image_event(src="zoneA.png", delta=0.20)
|
||||
bad_event["rendered_w"] = 0 # invalidates apply path → plan infeasible
|
||||
res = _attempt_step17_image_fit_single_pass(
|
||||
run_dir=project_tmp, out_path=out_path,
|
||||
slide_title="u7-noapply", slide_footer=None,
|
||||
zones_data=_zones(), layout_preset="vertical-2",
|
||||
layout_css=_LAYOUT_CSS_GATE_PASS, image_events=[bad_event], gap_px=14,
|
||||
)
|
||||
|
||||
assert res["triggered"] is False
|
||||
assert res["event_plans"][0].get("feasible") is False
|
||||
assert counter["n"] == 0
|
||||
|
||||
|
||||
# ── 4. helper PASS — out_path promoted, step shape correct ──────────
|
||||
|
||||
|
||||
def test_helper_pass_promotes_final_html(project_tmp, monkeypatch):
|
||||
out_path = project_tmp / "final.html"
|
||||
out_path.write_text("ORIGINAL_BEFORE_U7", encoding="utf-8")
|
||||
counter = _stub_render_capture(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
_pz_pipeline, "run_overflow_check",
|
||||
lambda p: {"passed": True, "fail_reasons": []},
|
||||
)
|
||||
|
||||
res = _attempt_step17_image_fit_single_pass(
|
||||
run_dir=project_tmp, out_path=out_path,
|
||||
slide_title="u7-pass", slide_footer=None,
|
||||
zones_data=_zones(), layout_preset="vertical-2",
|
||||
layout_css=_LAYOUT_CSS_GATE_PASS,
|
||||
image_events=[_image_event(src="zoneA.png", delta=0.30)],
|
||||
gap_px=14,
|
||||
)
|
||||
|
||||
assert res["triggered"] is True
|
||||
assert res["passed"] is True
|
||||
assert res["step"]["action"] == "image_fit"
|
||||
assert res["step"]["passed"] is True
|
||||
assert res["step"]["post_salvage_overflow"] == {"passed": True, "fail_reasons": []}
|
||||
assert "failure_reason" not in res["step"]
|
||||
assert res["step"]["image_fit_event_plans"]
|
||||
assert res["step"]["image_fit_event_plans"][0]["feasible"] is True
|
||||
assert counter["n"] == 1
|
||||
# out_path promoted with the candidate HTML (style overlay injected).
|
||||
promoted = out_path.read_text(encoding="utf-8")
|
||||
assert "ORIGINAL_BEFORE_U7" not in promoted
|
||||
assert "data-rendered-preset='vertical-2'" in promoted
|
||||
assert "<style>" in promoted
|
||||
|
||||
|
||||
# ── 5. helper FAIL — out_path NOT promoted, failure_reason recorded ──
|
||||
|
||||
|
||||
def test_helper_fail_leaves_out_path_untouched(project_tmp, monkeypatch):
|
||||
out_path = project_tmp / "final.html"
|
||||
out_path.write_text("ORIGINAL_BEFORE_U7", encoding="utf-8")
|
||||
_stub_render_capture(monkeypatch)
|
||||
fail_payload = {
|
||||
"passed": False,
|
||||
"fail_reasons": ["zone--top (t-top) overflowed by 30px (vert) / 0px (horiz)"],
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
_pz_pipeline, "run_overflow_check", lambda p: fail_payload,
|
||||
)
|
||||
|
||||
res = _attempt_step17_image_fit_single_pass(
|
||||
run_dir=project_tmp, out_path=out_path,
|
||||
slide_title="u7-fail", slide_footer=None,
|
||||
zones_data=_zones(), layout_preset="vertical-2",
|
||||
layout_css=_LAYOUT_CSS_GATE_PASS,
|
||||
image_events=[_image_event(src="zoneA.png", delta=0.30)],
|
||||
gap_px=14,
|
||||
)
|
||||
|
||||
assert res["triggered"] is True
|
||||
assert res["passed"] is False
|
||||
assert res["step"]["passed"] is False
|
||||
assert "post_salvage_overflow" not in res["step"]
|
||||
assert res["step"]["failure_reason"] == fail_payload["fail_reasons"]
|
||||
# out_path stays at the pre-helper value — strict PASS-only promotion.
|
||||
assert out_path.read_text(encoding="utf-8") == "ORIGINAL_BEFORE_U7"
|
||||
|
||||
|
||||
# ── 6. helper aggregates multi-event CSS into one re-render ─────────
|
||||
|
||||
|
||||
def test_helper_aggregates_multi_event_css_into_single_render(project_tmp, monkeypatch):
|
||||
"""Three feasible image events → CSS chunks concatenated → ONE render_slide
|
||||
call (not three). The aggregated style block is injected before </head>."""
|
||||
out_path = project_tmp / "final.html"
|
||||
out_path.write_text("ORIGINAL_BEFORE_U7", encoding="utf-8")
|
||||
counter = _stub_render_capture(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
_pz_pipeline, "run_overflow_check",
|
||||
lambda p: {"passed": True, "fail_reasons": []},
|
||||
)
|
||||
|
||||
events = [
|
||||
_image_event(src="zoneA.png", zone_position="top", delta=0.20),
|
||||
_image_event(src="zoneB.png", zone_position="top", delta=0.25),
|
||||
_image_event(src="zoneC.png", zone_position="bottom",
|
||||
zone_template_id="t-bottom", delta=0.18),
|
||||
]
|
||||
res = _attempt_step17_image_fit_single_pass(
|
||||
run_dir=project_tmp, out_path=out_path,
|
||||
slide_title="u7-multi", slide_footer=None,
|
||||
zones_data=_zones(), layout_preset="vertical-2",
|
||||
layout_css=_LAYOUT_CSS_GATE_PASS, image_events=events, gap_px=14,
|
||||
)
|
||||
|
||||
assert res["triggered"] is True
|
||||
assert res["passed"] is True
|
||||
assert counter["n"] == 1
|
||||
promoted = out_path.read_text(encoding="utf-8")
|
||||
# All three image src selectors should appear in the merged overlay.
|
||||
assert "zoneA.png" in promoted
|
||||
assert "zoneB.png" in promoted
|
||||
assert "zoneC.png" in promoted
|
||||
|
||||
|
||||
# ── 7. candidate_path is project-relative ───────────────────────────
|
||||
|
||||
|
||||
def test_helper_candidate_path_is_project_root_relative(project_tmp, monkeypatch):
|
||||
out_path = project_tmp / "final.html"
|
||||
out_path.write_text("ORIGINAL", encoding="utf-8")
|
||||
_stub_render_capture(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
_pz_pipeline, "run_overflow_check",
|
||||
lambda p: {"passed": False, "fail_reasons": ["nope"]},
|
||||
)
|
||||
|
||||
res = _attempt_step17_image_fit_single_pass(
|
||||
run_dir=project_tmp, out_path=out_path,
|
||||
slide_title="u7-relpath", slide_footer=None,
|
||||
zones_data=_zones(), layout_preset="vertical-2",
|
||||
layout_css=_LAYOUT_CSS_GATE_PASS,
|
||||
image_events=[_image_event(src="zoneA.png", delta=0.20)],
|
||||
gap_px=14,
|
||||
)
|
||||
|
||||
assert res["triggered"] is True
|
||||
cp = res["step"]["candidate_path"]
|
||||
# project-relative path ending with the salvage candidate filename.
|
||||
assert cp.endswith("salvage_image_fit_candidate.html")
|
||||
assert not Path(cp).is_absolute()
|
||||
# Concrete file actually exists on disk.
|
||||
assert (project_tmp / "salvage_image_fit_candidate.html").exists()
|
||||
|
||||
|
||||
# ── 8. delta_tol override threads through ────────────────────────────
|
||||
|
||||
|
||||
def test_helper_passes_delta_tol_through_to_plan_image_fit(project_tmp, monkeypatch):
|
||||
"""An event with |delta|=0.10 is OVER default tol (0.05) but UNDER an
|
||||
override tol of 0.20. With override the helper should see infeasible
|
||||
plans → triggered=False; with default it should see feasible plans."""
|
||||
out_path = project_tmp / "final.html"
|
||||
out_path.write_text("ORIGINAL", encoding="utf-8")
|
||||
_stub_render_capture(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
_pz_pipeline, "run_overflow_check",
|
||||
lambda p: {"passed": True, "fail_reasons": []},
|
||||
)
|
||||
|
||||
ev = _image_event(src="zoneA.png", delta=0.10)
|
||||
# Default tol = IMAGE_ASPECT_DELTA_TOL = 0.05 → plan feasible, triggered.
|
||||
res_default = _attempt_step17_image_fit_single_pass(
|
||||
run_dir=project_tmp, out_path=out_path,
|
||||
slide_title="u7-tol", slide_footer=None,
|
||||
zones_data=_zones(), layout_preset="vertical-2",
|
||||
layout_css=_LAYOUT_CSS_GATE_PASS, image_events=[ev], gap_px=14,
|
||||
)
|
||||
assert res_default["triggered"] is True
|
||||
assert res_default["event_plans"][0].get("feasible") is True
|
||||
|
||||
# Override tol = 0.20 → plan infeasible (delta 0.10 within tol).
|
||||
out_path.write_text("ORIGINAL", encoding="utf-8") # reset
|
||||
res_override = _attempt_step17_image_fit_single_pass(
|
||||
run_dir=project_tmp, out_path=out_path,
|
||||
slide_title="u7-tol", slide_footer=None,
|
||||
zones_data=_zones(), layout_preset="vertical-2",
|
||||
layout_css=_LAYOUT_CSS_GATE_PASS, image_events=[ev], gap_px=14,
|
||||
delta_tol=0.20,
|
||||
)
|
||||
assert res_override["triggered"] is False
|
||||
assert res_override["event_plans"][0].get("feasible") is False
|
||||
|
||||
|
||||
# ── 9. image_fit stays OUT of _SALVAGE_FAIL_BY_ACTION ────────────────
|
||||
|
||||
|
||||
def test_image_fit_stays_out_of_salvage_fail_map():
|
||||
"""u7 entry single-pass is NOT a cascade salvage stage. The u6 guard
|
||||
asserted this; re-assert here so u7's wiring does not accidentally
|
||||
register image_fit into the cascade map."""
|
||||
assert "image_fit" not in _SALVAGE_FAIL_BY_ACTION
|
||||
# Cascade stages stay as u6 left them: 5 entries, image_fit absent.
|
||||
assert len(_SALVAGE_FAIL_BY_ACTION) == 5
|
||||
assert set(_SALVAGE_FAIL_BY_ACTION) == {
|
||||
"cross_zone_redistribute",
|
||||
"glue_compression",
|
||||
"font_step_compression",
|
||||
"layout_adjust",
|
||||
"frame_internal_fit_candidate",
|
||||
}
|
||||
|
||||
|
||||
# ── 10. PASS-only out_path mutation (strict gate) ────────────────────
|
||||
|
||||
|
||||
def test_helper_strictly_promotes_only_on_pass(project_tmp, monkeypatch):
|
||||
"""Defensive — assert the helper does NOT write out_path on FAIL even
|
||||
after rendering a candidate. Mutation must be strictly gated on
|
||||
passed=True from run_overflow_check."""
|
||||
out_path = project_tmp / "final.html"
|
||||
canary = "STRICT_PROMOTION_CANARY_DO_NOT_OVERWRITE"
|
||||
out_path.write_text(canary, encoding="utf-8")
|
||||
_stub_render_capture(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
_pz_pipeline, "run_overflow_check",
|
||||
lambda p: {"passed": False, "fail_reasons": ["persists"]},
|
||||
)
|
||||
|
||||
res = _attempt_step17_image_fit_single_pass(
|
||||
run_dir=project_tmp, out_path=out_path,
|
||||
slide_title="u7-strict", slide_footer=None,
|
||||
zones_data=_zones(), layout_preset="vertical-2",
|
||||
layout_css=_LAYOUT_CSS_GATE_PASS,
|
||||
image_events=[_image_event(src="zoneA.png", delta=0.30)],
|
||||
gap_px=14,
|
||||
)
|
||||
|
||||
assert res["triggered"] is True
|
||||
assert res["passed"] is False
|
||||
# The candidate file IS written (telemetry continuity); out_path is NOT.
|
||||
assert (project_tmp / "salvage_image_fit_candidate.html").exists()
|
||||
assert out_path.read_text(encoding="utf-8") == canary
|
||||
|
||||
|
||||
# ── 11. event_plans telemetry continuity even when no CSS emitted ───
|
||||
|
||||
|
||||
def test_helper_event_plans_recorded_even_when_not_triggered(project_tmp, monkeypatch):
|
||||
"""Step 17 telemetry must surface every plan_image_fit result via
|
||||
event_plans so Step 18 / Step 19 can read planner-side decisions
|
||||
even when the single-pass did not render a candidate."""
|
||||
out_path = project_tmp / "final.html"
|
||||
out_path.write_text("ORIGINAL", encoding="utf-8")
|
||||
_stub_render_capture(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
_pz_pipeline, "run_overflow_check",
|
||||
lambda p: pytest.fail("not triggered → overflow_check must not run"),
|
||||
)
|
||||
|
||||
events = [
|
||||
_image_event(src="zoneA.png", delta=None), # not loaded
|
||||
_image_event(src="zoneB.png", delta=0.02), # under default tol
|
||||
]
|
||||
res = _attempt_step17_image_fit_single_pass(
|
||||
run_dir=project_tmp, out_path=out_path,
|
||||
slide_title="u7-telemetry", slide_footer=None,
|
||||
zones_data=_zones(), layout_preset="vertical-2",
|
||||
layout_css=_LAYOUT_CSS_GATE_PASS, image_events=events, gap_px=14,
|
||||
)
|
||||
|
||||
assert res["triggered"] is False
|
||||
assert res["step"] is None
|
||||
# Every event still surfaces a plan — telemetry continuity invariant.
|
||||
assert len(res["event_plans"]) == 2
|
||||
failure_reasons = [p.get("failure_reason") for p in res["event_plans"]]
|
||||
assert any("not loaded" in (r or "") for r in failure_reasons)
|
||||
assert any("delta_tol" in (r or "") for r in failure_reasons)
|
||||
|
||||
|
||||
# ── 12. Emitted CSS is img-scoped (Phase Z spacing guardrail) ────────
|
||||
|
||||
|
||||
def test_helper_emits_img_scoped_css_only(project_tmp, monkeypatch):
|
||||
"""[[feedback_phase_z_spacing_direction]] — image_fit must NOT shrink
|
||||
common margins / slide-body / zone gap. The emitted CSS overlay must
|
||||
target img selectors only."""
|
||||
out_path = project_tmp / "final.html"
|
||||
out_path.write_text("ORIGINAL", encoding="utf-8")
|
||||
_stub_render_capture(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
_pz_pipeline, "run_overflow_check",
|
||||
lambda p: {"passed": True, "fail_reasons": []},
|
||||
)
|
||||
|
||||
_attempt_step17_image_fit_single_pass(
|
||||
run_dir=project_tmp, out_path=out_path,
|
||||
slide_title="u7-scope", slide_footer=None,
|
||||
zones_data=_zones(), layout_preset="vertical-2",
|
||||
layout_css=_LAYOUT_CSS_GATE_PASS,
|
||||
image_events=[_image_event(src="zoneA.png", delta=0.20)],
|
||||
gap_px=14,
|
||||
)
|
||||
|
||||
candidate = (project_tmp / "salvage_image_fit_candidate.html").read_text(
|
||||
encoding="utf-8",
|
||||
)
|
||||
style_block_start = candidate.find("<style>")
|
||||
style_block_end = candidate.find("</style>", style_block_start)
|
||||
assert style_block_start >= 0 and style_block_end > style_block_start
|
||||
style_body = candidate[style_block_start + len("<style>"):style_block_end]
|
||||
# img-scoped selector present.
|
||||
assert "img" in style_body
|
||||
# Phase Z spacing guardrail — none of these shared-spacing tokens
|
||||
# may appear in the image_fit overlay.
|
||||
assert ".slide-body" not in style_body
|
||||
assert "slide-base" not in style_body
|
||||
assert "--spacing-page" not in style_body
|
||||
assert "--spacing-block" not in style_body
|
||||
assert "grid-gap" not in style_body
|
||||
assert "padding-page" not in style_body
|
||||
@@ -0,0 +1,252 @@
|
||||
"""IMP-88 u5 — plan_frame_internal_fit_candidate / apply tests (Step 17).
|
||||
|
||||
Stage 2 contract (unit u5):
|
||||
- plan_frame_internal_fit_candidate operates ONLY inside the frame
|
||||
contract's declared `internal_envelope` (PHASE-Z-PIPELINE-OVERVIEW.md
|
||||
:333 lock). No internal_envelope → infeasible(envelope_present=False).
|
||||
Envelope present + variant.excess_budget_px >= overflow_zone.excess_y
|
||||
→ feasible with selected_variant + frame-scoped css_overrides.
|
||||
- apply_frame_internal_fit_candidate_css(plan) emits a frame-scoped CSS
|
||||
rule (`.zone[data-template-id="<template_id>"]` selector) from the
|
||||
selected variant's css_overrides. None on infeasible.
|
||||
- Honors feedback_phase_z_spacing_direction — frame-scoped only, no
|
||||
common margin / slide-body / zone gap shrink.
|
||||
- Default contract loader (mapper.get_contract) overridable as kwarg so
|
||||
tests stay free of the catalog cache / pipeline import cycle.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from src.phase_z2_retry import (
|
||||
apply_frame_internal_fit_candidate_css,
|
||||
plan_frame_internal_fit_candidate,
|
||||
)
|
||||
|
||||
|
||||
def _contract(*, variants=None, with_envelope=True) -> dict:
|
||||
"""Build a synthetic frame contract for u5 planner tests."""
|
||||
c: dict = {
|
||||
"template_id": "frame_internal_fit_test",
|
||||
"source_shape": "top_bullets",
|
||||
"cardinality": {"strict": 3},
|
||||
}
|
||||
if with_envelope:
|
||||
c["internal_envelope"] = {
|
||||
"variants": list(variants or []),
|
||||
}
|
||||
return c
|
||||
|
||||
|
||||
def _variant(*, name: str, budget_px: int, css: dict | None = None) -> dict:
|
||||
return {
|
||||
"name": name,
|
||||
"excess_budget_px": budget_px,
|
||||
"css_overrides": dict(css or {"--frame-density": "compact"}),
|
||||
}
|
||||
|
||||
|
||||
# ─── planner: no-envelope infeasible paths ──────────────────────
|
||||
|
||||
|
||||
def test_no_contract_infeasible_with_clear_reason():
|
||||
plan = plan_frame_internal_fit_candidate(
|
||||
frame_template_id="non_existent_frame",
|
||||
frame_contract={}, # caller passed empty dict — explicit no-contract
|
||||
)
|
||||
# Empty dict counts as "contract present but no internal_envelope" → present=False.
|
||||
assert plan["action"] == "frame_internal_fit_candidate"
|
||||
assert plan["feasible"] is False
|
||||
assert plan["envelope_present"] is False
|
||||
assert plan["selected_variant"] is None
|
||||
assert plan["css_overrides"] is None
|
||||
assert "internal_envelope" in plan["failure_reason"]
|
||||
|
||||
|
||||
def test_contract_lookup_none_returns_no_contract_failure():
|
||||
# Simulate mapper.get_contract returning None: caller passes None explicitly
|
||||
# via overriding kwarg path — planner falls back to mapper path then returns
|
||||
# the dedicated "no frame contract registered" failure.
|
||||
# We exercise this directly by passing a sentinel template_id that has no
|
||||
# entry; the planner default-loads via mapper.get_contract.
|
||||
plan = plan_frame_internal_fit_candidate(
|
||||
frame_template_id="__sentinel_unregistered_template__",
|
||||
)
|
||||
assert plan["feasible"] is False
|
||||
assert plan["envelope_present"] is False
|
||||
assert "no frame contract registered" in plan["failure_reason"]
|
||||
assert "__sentinel_unregistered_template__" in plan["failure_reason"]
|
||||
|
||||
|
||||
def test_contract_without_internal_envelope_infeasible():
|
||||
plan = plan_frame_internal_fit_candidate(
|
||||
frame_template_id="f_test",
|
||||
frame_contract=_contract(with_envelope=False),
|
||||
)
|
||||
assert plan["feasible"] is False
|
||||
assert plan["envelope_present"] is False
|
||||
assert "does not declare internal_envelope" in plan["failure_reason"]
|
||||
assert "frame_reselect" in plan["failure_reason"]
|
||||
|
||||
|
||||
def test_envelope_present_but_empty_variants_infeasible():
|
||||
plan = plan_frame_internal_fit_candidate(
|
||||
frame_template_id="f_test",
|
||||
frame_contract=_contract(variants=[]),
|
||||
)
|
||||
assert plan["feasible"] is False
|
||||
assert plan["envelope_present"] is True
|
||||
assert plan["candidates_considered"] == []
|
||||
assert "no variants" in plan["failure_reason"]
|
||||
|
||||
|
||||
# ─── planner: feasible paths ────────────────────────────────────
|
||||
|
||||
|
||||
def test_single_variant_within_budget_is_selected():
|
||||
contract = _contract(variants=[
|
||||
_variant(name="density_compact", budget_px=40,
|
||||
css={"--frame-density": "compact", "font-size": "0.95em"}),
|
||||
])
|
||||
plan = plan_frame_internal_fit_candidate(
|
||||
frame_template_id="f_test",
|
||||
frame_contract=contract,
|
||||
overflow_zone={"excess_y": 24.0},
|
||||
)
|
||||
assert plan["feasible"] is True
|
||||
assert plan["envelope_present"] is True
|
||||
assert plan["selected_variant"] == "density_compact"
|
||||
assert plan["selected_variant_budget_px"] == 40
|
||||
assert plan["excess_y"] == 24
|
||||
assert plan["css_overrides"]["font-size"] == "0.95em"
|
||||
assert plan["css_overrides"]["--frame-density"] == "compact"
|
||||
|
||||
|
||||
def test_greedy_walk_picks_first_variant_that_fits_in_catalog_order():
|
||||
# density_compact only absorbs 10px; line_rhythm 60px; grid_row 200px.
|
||||
# excess_y=45 → density_compact rejected, line_rhythm selected (first fit).
|
||||
contract = _contract(variants=[
|
||||
_variant(name="density_compact", budget_px=10),
|
||||
_variant(name="line_rhythm_tight", budget_px=60),
|
||||
_variant(name="grid_row_collapse", budget_px=200),
|
||||
])
|
||||
plan = plan_frame_internal_fit_candidate(
|
||||
frame_template_id="f_test",
|
||||
frame_contract=contract,
|
||||
overflow_zone={"excess_y": 45.0},
|
||||
)
|
||||
assert plan["feasible"] is True
|
||||
assert plan["selected_variant"] == "line_rhythm_tight"
|
||||
assert plan["selected_variant_budget_px"] == 60
|
||||
assert plan["candidates_considered"] == [
|
||||
"density_compact", "line_rhythm_tight", "grid_row_collapse",
|
||||
]
|
||||
|
||||
|
||||
def test_no_overflow_zone_picks_first_variant():
|
||||
# excess_y default = 0 → every variant qualifies; first catalog entry wins.
|
||||
contract = _contract(variants=[
|
||||
_variant(name="first", budget_px=5),
|
||||
_variant(name="second", budget_px=100),
|
||||
])
|
||||
plan = plan_frame_internal_fit_candidate(
|
||||
frame_template_id="f_test", frame_contract=contract,
|
||||
)
|
||||
assert plan["feasible"] is True
|
||||
assert plan["selected_variant"] == "first"
|
||||
assert plan["excess_y"] == 0
|
||||
|
||||
|
||||
def test_all_variants_below_budget_returns_infeasible_with_excess():
|
||||
contract = _contract(variants=[
|
||||
_variant(name="small", budget_px=10),
|
||||
_variant(name="medium", budget_px=25),
|
||||
])
|
||||
plan = plan_frame_internal_fit_candidate(
|
||||
frame_template_id="f_test", frame_contract=contract,
|
||||
overflow_zone={"excess_y": 80.0},
|
||||
)
|
||||
assert plan["feasible"] is False
|
||||
assert plan["envelope_present"] is True
|
||||
assert plan["excess_y"] == 80
|
||||
assert plan["selected_variant"] is None
|
||||
assert "excess_y=80px" in plan["failure_reason"]
|
||||
assert plan["candidates_considered"] == ["small", "medium"]
|
||||
|
||||
|
||||
def test_excess_y_is_ceil_rounded():
|
||||
# Sub-pixel overflow rounds up so a budget exactly matching the integer
|
||||
# excess covers the case. 23.4 → 24 → variant(budget=24) is selected.
|
||||
contract = _contract(variants=[
|
||||
_variant(name="exact_fit", budget_px=24),
|
||||
])
|
||||
plan = plan_frame_internal_fit_candidate(
|
||||
frame_template_id="f_test", frame_contract=contract,
|
||||
overflow_zone={"excess_y": 23.4},
|
||||
)
|
||||
assert plan["feasible"] is True
|
||||
assert plan["excess_y"] == 24
|
||||
assert plan["selected_variant"] == "exact_fit"
|
||||
|
||||
|
||||
def test_envelope_keys_recorded_for_telemetry():
|
||||
contract = _contract(variants=[_variant(name="v1", budget_px=100)])
|
||||
# Inject an extra envelope key to ensure planner surfaces them.
|
||||
contract["internal_envelope"]["envelope_kind"] = "density_grid"
|
||||
plan = plan_frame_internal_fit_candidate(
|
||||
frame_template_id="f_test", frame_contract=contract,
|
||||
)
|
||||
assert "envelope_kind" in plan["envelope_keys"]
|
||||
assert "variants" in plan["envelope_keys"]
|
||||
|
||||
|
||||
# ─── apply: frame-scoped CSS snippet ────────────────────────────
|
||||
|
||||
|
||||
def test_apply_emits_frame_template_scoped_selector():
|
||||
contract = _contract(variants=[
|
||||
_variant(name="density_compact", budget_px=100, css={
|
||||
"--frame-density": "compact",
|
||||
"font-size": "0.92em",
|
||||
"line-height": "1.35",
|
||||
}),
|
||||
])
|
||||
plan = plan_frame_internal_fit_candidate(
|
||||
frame_template_id="three_parallel_requirements",
|
||||
frame_contract=contract,
|
||||
)
|
||||
css = apply_frame_internal_fit_candidate_css(plan)
|
||||
assert css is not None
|
||||
assert ".zone[data-template-id=\"three_parallel_requirements\"]" in css
|
||||
assert "--frame-density: compact;" in css
|
||||
assert "font-size: 0.92em;" in css
|
||||
assert "line-height: 1.35;" in css
|
||||
|
||||
|
||||
def test_apply_infeasible_returns_none():
|
||||
plan = plan_frame_internal_fit_candidate(
|
||||
frame_template_id="f_test",
|
||||
frame_contract=_contract(with_envelope=False),
|
||||
)
|
||||
assert apply_frame_internal_fit_candidate_css(plan) is None
|
||||
|
||||
|
||||
def test_apply_does_not_shrink_shared_spacing():
|
||||
# feedback_phase_z_spacing_direction: emitted CSS must scope to the frame
|
||||
# only and MUST NOT touch slide-body / outer zone / gap / common margin /
|
||||
# padding tokens. The selector is frame-scoped; body comes from author-
|
||||
# declared envelope css_overrides. We sanity-check that nothing in the
|
||||
# apply helper introduces shared-spacing properties of its own.
|
||||
contract = _contract(variants=[
|
||||
_variant(name="density_compact", budget_px=100,
|
||||
css={"--frame-density": "compact"}),
|
||||
])
|
||||
plan = plan_frame_internal_fit_candidate(
|
||||
frame_template_id="f_test", frame_contract=contract,
|
||||
)
|
||||
css = apply_frame_internal_fit_candidate_css(plan)
|
||||
assert css is not None
|
||||
for forbidden in (".slide-body", ".zone-container", "grid-gap", "gap:",
|
||||
"padding:", "margin:"):
|
||||
assert forbidden not in css, (
|
||||
f"frame_internal_fit CSS leaked shared-spacing token "
|
||||
f"'{forbidden}' — see feedback_phase_z_spacing_direction."
|
||||
)
|
||||
@@ -0,0 +1,198 @@
|
||||
"""IMP-88 u4 — plan_image_fit / apply_image_fit_css tests (Step 17 entry).
|
||||
|
||||
Stage 2 contract (unit u4):
|
||||
- plan_image_fit consumes a single image_event (overflow_metrics.image_
|
||||
events shape: natural_w/h, rendered_w/h, natural_ratio, rendered_ratio,
|
||||
delta, src, zone_position, zone_template_id) and returns:
|
||||
success : {feasible=True, css_overrides={object_fit, max_width_px,
|
||||
max_height_px, width, height}, delta, correction_axis,
|
||||
natural_*, rendered_*}
|
||||
no-op : {feasible=False, failure_reason} when |delta| <= tol or
|
||||
delta is None
|
||||
infeas : {feasible=False, failure_reason} when rendered_w/h missing
|
||||
or non-positive
|
||||
- apply_image_fit_css(plan) returns a frame-scoped CSS rule string for
|
||||
feasible plans (object-fit + max-w/h constraints scoped to the zone +
|
||||
src image), None for infeasible plans.
|
||||
- Honors feedback_phase_z_spacing_direction — image-scoped CSS only, no
|
||||
common margin / frame envelope shrink.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from src.phase_z2_retry import apply_image_fit_css, plan_image_fit
|
||||
|
||||
|
||||
def _image_event(
|
||||
*, delta: float | None = 0.20, src: str = "/images/diagram.png",
|
||||
zone_position: str = "top", zone_template_id: str = "frame_07",
|
||||
natural_w: int = 1200, natural_h: int = 800,
|
||||
rendered_w: int = 600, rendered_h: int = 300,
|
||||
) -> dict:
|
||||
"""image_event shape mirroring runtime overflow_metrics.image_events[i]."""
|
||||
natural_ratio = natural_w / natural_h if natural_h else None
|
||||
rendered_ratio = rendered_w / rendered_h if rendered_h else None
|
||||
return {
|
||||
"src": src,
|
||||
"zone_position": zone_position,
|
||||
"zone_template_id": zone_template_id,
|
||||
"natural_w": natural_w,
|
||||
"natural_h": natural_h,
|
||||
"rendered_w": rendered_w,
|
||||
"rendered_h": rendered_h,
|
||||
"natural_ratio": natural_ratio,
|
||||
"rendered_ratio": rendered_ratio,
|
||||
"delta": delta,
|
||||
"bbox": {"x": 0, "y": 0, "w": rendered_w, "h": rendered_h},
|
||||
}
|
||||
|
||||
|
||||
# ─── planner: success paths ─────────────────────────────────────
|
||||
|
||||
|
||||
def test_positive_delta_emits_width_correction_axis():
|
||||
# natural 1200x800 (1.5), rendered 600x300 (2.0) → delta = +0.5
|
||||
ev = _image_event(
|
||||
delta=0.5,
|
||||
natural_w=1200, natural_h=800,
|
||||
rendered_w=600, rendered_h=300,
|
||||
)
|
||||
plan = plan_image_fit(image_event=ev)
|
||||
assert plan["action"] == "image_fit"
|
||||
assert plan["feasible"] is True
|
||||
assert plan["correction_axis"] == "width"
|
||||
assert plan["delta"] == 0.5
|
||||
assert plan["natural_w"] == 1200
|
||||
assert plan["rendered_w"] == 600
|
||||
overrides = plan["css_overrides"]
|
||||
assert overrides["object_fit"] == "contain"
|
||||
assert overrides["max_width_px"] == 600
|
||||
assert overrides["max_height_px"] == 300
|
||||
assert overrides["width"] == "auto"
|
||||
assert overrides["height"] == "auto"
|
||||
|
||||
|
||||
def test_negative_delta_emits_height_correction_axis():
|
||||
# natural 800x1200 (~0.667), rendered 600x600 (1.0) → delta = +0.333 not -;
|
||||
# use rendered taller than natural for negative delta.
|
||||
ev = _image_event(
|
||||
delta=-0.40,
|
||||
natural_w=1600, natural_h=800,
|
||||
rendered_w=400, rendered_h=400,
|
||||
)
|
||||
plan = plan_image_fit(image_event=ev)
|
||||
assert plan["feasible"] is True
|
||||
assert plan["correction_axis"] == "height"
|
||||
assert plan["css_overrides"]["max_width_px"] == 400
|
||||
assert plan["css_overrides"]["max_height_px"] == 400
|
||||
|
||||
|
||||
def test_planner_passes_through_zone_and_template_metadata():
|
||||
ev = _image_event(
|
||||
delta=0.20,
|
||||
src="/img/policy.png",
|
||||
zone_position="bottom-right",
|
||||
zone_template_id="f23",
|
||||
)
|
||||
plan = plan_image_fit(image_event=ev)
|
||||
assert plan["feasible"] is True
|
||||
assert plan["src"] == "/img/policy.png"
|
||||
assert plan["zone_position"] == "bottom-right"
|
||||
assert plan["zone_template_id"] == "f23"
|
||||
|
||||
|
||||
# ─── planner: infeasible / no-op paths ──────────────────────────
|
||||
|
||||
|
||||
def test_delta_none_infeasible_with_clear_reason():
|
||||
ev = _image_event(delta=None)
|
||||
plan = plan_image_fit(image_event=ev)
|
||||
assert plan["feasible"] is False
|
||||
assert plan["css_overrides"] is None
|
||||
assert "delta is None" in plan["failure_reason"]
|
||||
|
||||
|
||||
def test_delta_within_tolerance_returns_planner_noop():
|
||||
ev = _image_event(delta=0.02)
|
||||
plan = plan_image_fit(image_event=ev)
|
||||
assert plan["feasible"] is False
|
||||
assert "no image_aspect_mismatch to correct" in plan["failure_reason"]
|
||||
# No-op path still records delta + action for telemetry continuity.
|
||||
assert plan["delta"] == 0.02
|
||||
assert plan["action"] == "image_fit"
|
||||
|
||||
|
||||
def test_delta_at_boundary_is_planner_noop():
|
||||
# |delta| == delta_tol is treated as no-op (strict greater-than is the
|
||||
# emission threshold in the classifier).
|
||||
ev = _image_event(delta=0.05)
|
||||
plan = plan_image_fit(image_event=ev, delta_tol=0.05)
|
||||
assert plan["feasible"] is False
|
||||
|
||||
|
||||
def test_rendered_w_zero_infeasible():
|
||||
ev = _image_event(rendered_w=0)
|
||||
plan = plan_image_fit(image_event=ev)
|
||||
assert plan["feasible"] is False
|
||||
assert "rendered_w / rendered_h" in plan["failure_reason"]
|
||||
|
||||
|
||||
def test_rendered_h_missing_infeasible():
|
||||
ev = _image_event()
|
||||
ev.pop("rendered_h", None)
|
||||
plan = plan_image_fit(image_event=ev)
|
||||
assert plan["feasible"] is False
|
||||
assert plan["css_overrides"] is None
|
||||
|
||||
|
||||
def test_custom_delta_tol_widens_noop_band():
|
||||
ev = _image_event(delta=0.10)
|
||||
# default tol=0.05 → feasible, but caller-supplied tol=0.20 → no-op.
|
||||
assert plan_image_fit(image_event=ev)["feasible"] is True
|
||||
assert plan_image_fit(image_event=ev, delta_tol=0.20)["feasible"] is False
|
||||
|
||||
|
||||
# ─── apply: frame-scoped CSS snippet ────────────────────────────
|
||||
|
||||
|
||||
def test_apply_image_fit_css_emits_zone_and_src_scoped_selector():
|
||||
plan = plan_image_fit(image_event=_image_event(
|
||||
delta=0.30,
|
||||
src="/images/process.png",
|
||||
zone_position="top",
|
||||
rendered_w=520, rendered_h=240,
|
||||
))
|
||||
css = apply_image_fit_css(plan)
|
||||
assert css is not None
|
||||
assert ".zone[data-zone-position=\"top\"]" in css
|
||||
assert "img[src=\"/images/process.png\"]" in css
|
||||
assert "object-fit: contain;" in css
|
||||
assert "max-width: 520px;" in css
|
||||
assert "max-height: 240px;" in css
|
||||
assert "width: auto;" in css
|
||||
assert "height: auto;" in css
|
||||
|
||||
|
||||
def test_apply_image_fit_css_without_src_falls_back_to_zone_only_selector():
|
||||
ev = _image_event(delta=0.30, src="")
|
||||
css = apply_image_fit_css(plan_image_fit(image_event=ev))
|
||||
assert css is not None
|
||||
assert ".zone[data-zone-position=\"top\"] img {" in css
|
||||
assert "img[src=" not in css
|
||||
|
||||
|
||||
def test_apply_image_fit_css_infeasible_returns_none():
|
||||
plan = plan_image_fit(image_event=_image_event(delta=None))
|
||||
assert apply_image_fit_css(plan) is None
|
||||
|
||||
|
||||
def test_apply_image_fit_css_does_not_shrink_shared_spacing():
|
||||
# feedback_phase_z_spacing_direction: CSS must scope to image only and
|
||||
# MUST NOT touch slide-body / zone / frame / gap / margin / padding.
|
||||
plan = plan_image_fit(image_event=_image_event(delta=0.30))
|
||||
css = apply_image_fit_css(plan)
|
||||
assert css is not None
|
||||
for forbidden in (".slide-body", ".zone-container", "padding:", "margin:",
|
||||
"gap:", "grid-gap"):
|
||||
assert forbidden not in css, (
|
||||
f"image_fit CSS leaked shared-spacing token '{forbidden}'"
|
||||
)
|
||||
@@ -0,0 +1,168 @@
|
||||
"""IMP-88 u3 — plan_layout_adjust tests (Step 17 retry chain).
|
||||
|
||||
Stage 2 contract (unit u3):
|
||||
- planner returns {feasible, new_layout_preset, new_zones_data,
|
||||
position_remap, candidates_considered, swap_topology_from/to} on success;
|
||||
{feasible=False, failure_reason} on infeasible (no sibling, unknown
|
||||
preset, zone-count mismatch).
|
||||
- apply_layout_adjust_layout_css(plan, gap_px) builds a fresh layout_css
|
||||
via build_layout_css with the swapped preset + remapped zones_data; raw_
|
||||
zone_layout records layout_adjust_applied/from/to provenance. Infeasible
|
||||
plan -> None (dispatcher u6 skips re-render).
|
||||
- Honors feedback_phase_z_spacing_direction — preset swap only, no shared
|
||||
spacing shrink claim.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from src.phase_z2_composition import LAYOUT_PRESETS
|
||||
from src.phase_z2_retry import (
|
||||
apply_layout_adjust_layout_css,
|
||||
plan_layout_adjust,
|
||||
)
|
||||
|
||||
|
||||
def _zones(positions: list[str]) -> list[dict]:
|
||||
"""Minimal zones_data shape for planner consumption."""
|
||||
return [
|
||||
{
|
||||
"position": pos,
|
||||
"template_id": f"frame_{i}",
|
||||
"min_height_px": 120,
|
||||
"content_weight": {"score": 1.0},
|
||||
"slot_payload": {"title": f"zone_{i}"},
|
||||
}
|
||||
for i, pos in enumerate(positions)
|
||||
]
|
||||
|
||||
|
||||
# ─── planner: success paths ─────────────────────────────────────
|
||||
|
||||
|
||||
def test_horizontal_2_swaps_to_vertical_2_orientation_axis():
|
||||
plan = plan_layout_adjust(
|
||||
current_layout_preset="horizontal-2",
|
||||
zones_data=_zones(["top", "bottom"]),
|
||||
)
|
||||
assert plan["action"] == "layout_adjust"
|
||||
assert plan["feasible"] is True
|
||||
assert plan["new_layout_preset"] == "vertical-2"
|
||||
assert plan["unit_count"] == 2
|
||||
assert plan["swap_topology_from"] == "rows"
|
||||
assert plan["swap_topology_to"] == "cols"
|
||||
assert plan["position_remap"] == {"top": "left", "bottom": "right"}
|
||||
new_zd = plan["new_zones_data"]
|
||||
assert [z["position"] for z in new_zd] == ["left", "right"]
|
||||
# Non-position payload preserved through remap.
|
||||
assert new_zd[0]["template_id"] == "frame_0"
|
||||
assert new_zd[1]["slot_payload"] == {"title": "zone_1"}
|
||||
|
||||
|
||||
def test_vertical_2_swaps_back_to_horizontal_2():
|
||||
plan = plan_layout_adjust(
|
||||
current_layout_preset="vertical-2",
|
||||
zones_data=_zones(["left", "right"]),
|
||||
)
|
||||
assert plan["feasible"] is True
|
||||
assert plan["new_layout_preset"] == "horizontal-2"
|
||||
assert plan["position_remap"] == {"left": "top", "right": "bottom"}
|
||||
|
||||
|
||||
def test_T_swaps_to_inverted_T_first_by_topology_priority():
|
||||
plan = plan_layout_adjust(
|
||||
current_layout_preset="top-1-bottom-2",
|
||||
zones_data=_zones(["top", "bottom-left", "bottom-right"]),
|
||||
)
|
||||
assert plan["feasible"] is True
|
||||
# 3-unit siblings: top-2-bottom-1, left-1-right-2, left-2-right-1.
|
||||
# _layout_swap_priority puts T<->inverted-T at priority 1 (before side-T).
|
||||
assert plan["new_layout_preset"] == "top-2-bottom-1"
|
||||
assert plan["candidates_considered"] == [
|
||||
"top-2-bottom-1", "left-1-right-2", "left-2-right-1",
|
||||
]
|
||||
|
||||
|
||||
# ─── planner: infeasible paths ──────────────────────────────────
|
||||
|
||||
|
||||
def test_single_preset_infeasible_no_sibling():
|
||||
plan = plan_layout_adjust(
|
||||
current_layout_preset="single",
|
||||
zones_data=_zones(["primary"]),
|
||||
)
|
||||
assert plan["feasible"] is False
|
||||
assert plan["new_layout_preset"] is None
|
||||
assert plan["unit_count"] == 1
|
||||
assert plan["candidates_considered"] == []
|
||||
assert "no render-ready 8-preset sibling" in plan["failure_reason"]
|
||||
assert "single (1)" in plan["failure_reason"]
|
||||
|
||||
|
||||
def test_grid_2x2_preset_infeasible_no_sibling():
|
||||
plan = plan_layout_adjust(
|
||||
current_layout_preset="grid-2x2",
|
||||
zones_data=_zones(["top-left", "top-right", "bottom-left", "bottom-right"]),
|
||||
)
|
||||
assert plan["feasible"] is False
|
||||
assert plan["candidates_considered"] == []
|
||||
assert "grid-2x2 (4)" in plan["failure_reason"]
|
||||
|
||||
|
||||
def test_unknown_preset_infeasible_with_clear_reason():
|
||||
plan = plan_layout_adjust(
|
||||
current_layout_preset="not-a-real-preset",
|
||||
zones_data=_zones(["top", "bottom"]),
|
||||
)
|
||||
assert plan["feasible"] is False
|
||||
assert plan["new_layout_preset"] is None
|
||||
assert "not in LAYOUT_PRESETS catalog" in plan["failure_reason"]
|
||||
|
||||
|
||||
def test_zone_count_mismatch_infeasible():
|
||||
plan = plan_layout_adjust(
|
||||
current_layout_preset="horizontal-2",
|
||||
zones_data=_zones(["top", "bottom", "extra"]),
|
||||
)
|
||||
assert plan["feasible"] is False
|
||||
assert "length 3" in plan["failure_reason"]
|
||||
assert "horizontal-2" in plan["failure_reason"]
|
||||
|
||||
|
||||
# ─── apply: layout_css construction + provenance ─────────────────
|
||||
|
||||
|
||||
def test_apply_layout_adjust_builds_new_layout_css_with_provenance():
|
||||
plan = plan_layout_adjust(
|
||||
current_layout_preset="horizontal-2",
|
||||
zones_data=_zones(["top", "bottom"]),
|
||||
)
|
||||
layout_css = apply_layout_adjust_layout_css(plan, gap_px=20)
|
||||
assert layout_css is not None
|
||||
# Mirrors build_layout_css(vertical-2, ...) output shape.
|
||||
assert layout_css["areas"] == LAYOUT_PRESETS["vertical-2"]["css_areas"]
|
||||
assert "heights_px" in layout_css and "widths_px" in layout_css
|
||||
raw = layout_css["raw_zone_layout"]
|
||||
assert raw["layout_adjust_applied"] is True
|
||||
assert raw["layout_adjust_from"] == "horizontal-2"
|
||||
assert raw["layout_adjust_to"] == "vertical-2"
|
||||
|
||||
|
||||
def test_apply_layout_adjust_infeasible_returns_none():
|
||||
plan = plan_layout_adjust(
|
||||
current_layout_preset="single",
|
||||
zones_data=_zones(["primary"]),
|
||||
)
|
||||
assert apply_layout_adjust_layout_css(plan, gap_px=20) is None
|
||||
|
||||
|
||||
def test_apply_layout_adjust_T_swap_produces_2d_dynamic_css():
|
||||
plan = plan_layout_adjust(
|
||||
current_layout_preset="top-1-bottom-2",
|
||||
zones_data=_zones(["top", "bottom-left", "bottom-right"]),
|
||||
)
|
||||
layout_css = apply_layout_adjust_layout_css(plan, gap_px=20)
|
||||
assert layout_css is not None
|
||||
# top-2-bottom-1 is a 2-D dynamic preset.
|
||||
assert layout_css["dynamic_rows"] is True
|
||||
assert layout_css["dynamic_cols"] is True
|
||||
assert layout_css["areas"] == LAYOUT_PRESETS["top-2-bottom-1"]["css_areas"]
|
||||
assert layout_css["raw_zone_layout"]["layout_adjust_to"] == "top-2-bottom-1"
|
||||
@@ -198,10 +198,19 @@ def test_case_b_cross_zone_fails_glue_passes_second_promoted(project_tmp, monkey
|
||||
|
||||
|
||||
def test_case_c_all_three_fail_revert_preserved(project_tmp, monkeypatch):
|
||||
"""(c) All three cascade actions are infeasible (no CSS emitted by any
|
||||
planner) → run_overflow_check is never invoked, salvage_passed=False,
|
||||
salvage_steps has three failed entries, and out_path is unchanged
|
||||
(original final.html intact — (b)-revert preserved)."""
|
||||
"""(c) All cascade actions are infeasible (no CSS / no candidate emitted
|
||||
by any planner) → run_overflow_check is never invoked, salvage_passed=
|
||||
False, out_path is unchanged (original final.html intact — (b)-revert
|
||||
preserved).
|
||||
|
||||
IMP-88 u6 extends the cascade depth from 3 to 5 stages (layout_adjust +
|
||||
frame_internal_fit_candidate added). When all stages are infeasible the
|
||||
cascade now runs through all five — the empty zones_data carried by
|
||||
_kwargs() makes plan_layout_adjust infeasible (length mismatch) and the
|
||||
empty resulting template_id makes plan_frame_internal_fit_candidate
|
||||
infeasible (no contract). The (b)-revert contract this test locks
|
||||
(out_path untouched + salvage_passed=False) is unchanged.
|
||||
"""
|
||||
out_path = project_tmp / "final.html"
|
||||
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
|
||||
|
||||
@@ -231,18 +240,24 @@ def test_case_c_all_three_fail_revert_preserved(project_tmp, monkeypatch):
|
||||
|
||||
assert trace["salvage_attempted"] is True
|
||||
assert trace["salvage_passed"] is False
|
||||
assert len(trace["salvage_steps"]) == 3
|
||||
# IMP-88 u6 — cascade depth extended from 3 to 5; see _SALVAGE_FAIL_BY_ACTION.
|
||||
assert len(trace["salvage_steps"]) == 5
|
||||
actions = [s["action"] for s in trace["salvage_steps"]]
|
||||
assert actions == [
|
||||
"cross_zone_redistribute",
|
||||
"glue_compression",
|
||||
"font_step_compression",
|
||||
"layout_adjust",
|
||||
"frame_internal_fit_candidate",
|
||||
]
|
||||
for step in trace["salvage_steps"]:
|
||||
assert step["passed"] is False
|
||||
assert step["css_override"] is None
|
||||
# layout_adjust uses a distinct render path → its step dict has no
|
||||
# css_override key (new_layout_preset is the observability field
|
||||
# instead). All other branches use the shared CSS-overlay path.
|
||||
assert step.get("css_override") is None
|
||||
assert step["failure_reason"]
|
||||
# No CSS emitted anywhere → no render_slide calls either.
|
||||
# No CSS / candidate emitted anywhere → no render_slide calls either.
|
||||
assert render_counter["n"] == 0
|
||||
# (b) revert: out_path is untouched.
|
||||
assert out_path.read_text(encoding="utf-8") == "ORIGINAL_BEFORE_SALVAGE"
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
"""IMP-88 (#88) u1 — Step 17 retry chain router rows + status surface.
|
||||
|
||||
Stage 2 binding contract (unit u1, data-surface only):
|
||||
- NEW row `image_aspect_mismatch → image_fit` in ACTION_BY_CATEGORY.
|
||||
Closes the unmapped classifier emission gap at
|
||||
src/phase_z2_classifier.py:434-447 where image_aspect_mismatch was
|
||||
emitted with proposed_action=None (verified Stage 1).
|
||||
- REMAP `frame_capacity_mismatch → frame_internal_fit_candidate`
|
||||
(previously frame_reselect) per PHASE-Z-PIPELINE-OVERVIEW.md:321.
|
||||
frame_reselect remains a valid downstream action via the
|
||||
failure_router cascade (rerender_still_fails → frame_reselect).
|
||||
- NEW ACTION_RATIONALE rows for image_aspect_mismatch +
|
||||
frame_internal_fit_candidate (rationale text for trace surface).
|
||||
- NEW ACTION_IMPLEMENTATION_STATUS rows for image_fit +
|
||||
frame_internal_fit_candidate. layout_adjust is also registered.
|
||||
u1 initial state was MISSING for all three. u7 completion flips the
|
||||
rows to IMPLEMENTED once the end-to-end path (u3/u4/u5 planners +
|
||||
u6 dispatcher + u7 Step 17 entry) is wired (same convention as
|
||||
IMP-12 u7 cascade rows + IMP-35 u3 details_popup_escalation flip).
|
||||
|
||||
Out of scope for u1 (locked in Stage 2 exit report):
|
||||
- failure_router cascade rows for the three actions → u2.
|
||||
- planner stubs (plan_layout_adjust / plan_image_fit /
|
||||
plan_frame_internal_fit_candidate) → u3 / u4 / u5.
|
||||
- salvage dispatcher branches + Step 17 entry triggers → u6 / u7.
|
||||
|
||||
Post u7 completion (2026-05-24): status assertions in this file reflect
|
||||
the IMPLEMENTED end-state. Test names that previously referenced "_missing"
|
||||
are renamed to "_implemented_after_u7" so the surface contract is honest
|
||||
about the post-u7 state.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from src.phase_z2_router import (
|
||||
ACTION_BY_CATEGORY,
|
||||
ACTION_IMPLEMENTATION_STATUS,
|
||||
ACTION_RATIONALE,
|
||||
route_action,
|
||||
route_fit_classification,
|
||||
)
|
||||
|
||||
|
||||
# ─── ACTION_BY_CATEGORY rows ──────────────────────────────────────
|
||||
|
||||
|
||||
def test_image_aspect_mismatch_maps_to_image_fit():
|
||||
"""u1 — NEW row closes the classifier→router gap.
|
||||
|
||||
Stage 1 verified that route_action('image_aspect_mismatch') returned
|
||||
proposed_action=None with implementation_status='unknown'. u1 must
|
||||
register the row so the classifier emission is routable.
|
||||
"""
|
||||
assert ACTION_BY_CATEGORY["image_aspect_mismatch"] == "image_fit"
|
||||
|
||||
|
||||
def test_frame_capacity_mismatch_remaps_to_frame_internal_fit_candidate():
|
||||
"""u1 — REMAP per PHASE-Z-PIPELINE-OVERVIEW.md:321.
|
||||
|
||||
Spec lock: frame_internal_fit_candidate is the per-zone first-pass
|
||||
salvage inside the declared frame envelope. frame_reselect (V4 top-k
|
||||
alternate frame swap) remains downstream via the failure_router
|
||||
cascade (rerender_still_fails → frame_reselect).
|
||||
"""
|
||||
assert ACTION_BY_CATEGORY["frame_capacity_mismatch"] == "frame_internal_fit_candidate"
|
||||
|
||||
|
||||
def test_existing_action_by_category_rows_unchanged():
|
||||
"""u1 — non-IMP-88 rows must NOT be touched (regression guard).
|
||||
|
||||
Only two edits are allowed in u1: NEW image_aspect_mismatch row and
|
||||
REMAP frame_capacity_mismatch row. Everything else is locked.
|
||||
"""
|
||||
assert ACTION_BY_CATEGORY["minor_overflow"] == "zone_ratio_retry"
|
||||
assert ACTION_BY_CATEGORY["moderate_overflow"] == "layout_adjust"
|
||||
assert ACTION_BY_CATEGORY["structural_minor_overflow"] == "zone_ratio_retry"
|
||||
assert ACTION_BY_CATEGORY["structural_major_overflow"] == "details_popup_escalation"
|
||||
assert ACTION_BY_CATEGORY["tabular_overflow"] == "details_popup_escalation"
|
||||
assert ACTION_BY_CATEGORY["layout_zone_mismatch"] == "layout_adjust"
|
||||
assert ACTION_BY_CATEGORY["hard_visual_fail"] == "abort"
|
||||
|
||||
|
||||
# ─── ACTION_RATIONALE rows ────────────────────────────────────────
|
||||
|
||||
|
||||
def test_image_aspect_mismatch_rationale_present():
|
||||
"""u1 — trace surface must explain *why* image_aspect_mismatch routes
|
||||
onto image_fit (frame-scoped, no global image CSS shrink — honors
|
||||
feedback_phase_z_spacing_direction)."""
|
||||
rationale = ACTION_RATIONALE.get("image_aspect_mismatch", "")
|
||||
assert rationale.strip(), "image_aspect_mismatch rationale must be non-empty"
|
||||
assert "image" in rationale.lower()
|
||||
|
||||
|
||||
def test_frame_capacity_mismatch_rationale_updated_for_internal_fit():
|
||||
"""u1 — rationale text must reflect the new internal-fit-first
|
||||
routing. The text must no longer claim frame_reselect as the primary
|
||||
action for this category (it's now the downstream cascade step)."""
|
||||
rationale = ACTION_RATIONALE.get("frame_capacity_mismatch", "")
|
||||
assert rationale.strip(), "frame_capacity_mismatch rationale must be non-empty"
|
||||
# Mentions the new internal-fit direction.
|
||||
assert "internal" in rationale.lower() or "envelope" in rationale.lower()
|
||||
|
||||
|
||||
# ─── ACTION_IMPLEMENTATION_STATUS rows ────────────────────────────
|
||||
|
||||
|
||||
def test_layout_adjust_status_implemented_after_u7():
|
||||
"""u1 registered layout_adjust row (initial MISSING). After u3
|
||||
(plan_layout_adjust + apply_layout_adjust_layout_css) + u6 (salvage
|
||||
dispatcher branch) + u7 (cascade entry trigger) land the end-to-end
|
||||
deterministic path, the status flips to IMPLEMENTED."""
|
||||
assert ACTION_IMPLEMENTATION_STATUS["layout_adjust"] == "IMPLEMENTED"
|
||||
|
||||
|
||||
def test_image_fit_status_implemented_after_u7():
|
||||
"""u1 registered image_fit row (initial MISSING). After u4 (plan_image_fit
|
||||
+ apply_image_fit_css) + u7 (_attempt_step17_image_fit_single_pass entry)
|
||||
land the end-to-end deterministic path, the status flips to IMPLEMENTED."""
|
||||
assert "image_fit" in ACTION_IMPLEMENTATION_STATUS
|
||||
assert ACTION_IMPLEMENTATION_STATUS["image_fit"] == "IMPLEMENTED"
|
||||
|
||||
|
||||
def test_frame_internal_fit_candidate_status_implemented_after_u7():
|
||||
"""u1 registered frame_internal_fit_candidate row (initial MISSING). After
|
||||
u5 (plan_frame_internal_fit_candidate + apply) + u6 (salvage dispatcher
|
||||
branch) + u7 (cascade entry trigger) land the end-to-end deterministic
|
||||
path, the status flips to IMPLEMENTED."""
|
||||
assert "frame_internal_fit_candidate" in ACTION_IMPLEMENTATION_STATUS
|
||||
assert ACTION_IMPLEMENTATION_STATUS["frame_internal_fit_candidate"] == "IMPLEMENTED"
|
||||
|
||||
|
||||
def test_existing_action_implementation_status_rows_unchanged():
|
||||
"""u1 — non-IMP-88 status rows must NOT regress. zone_ratio_retry,
|
||||
cascade-only salvage actions, details_popup_escalation (IMP-35 u3),
|
||||
and frame_reselect must keep their current statuses."""
|
||||
assert ACTION_IMPLEMENTATION_STATUS["zone_ratio_retry"] == "IMPLEMENTED"
|
||||
assert ACTION_IMPLEMENTATION_STATUS["details_popup_escalation"] == "IMPLEMENTED"
|
||||
assert ACTION_IMPLEMENTATION_STATUS["frame_reselect"] == "PARTIAL"
|
||||
assert ACTION_IMPLEMENTATION_STATUS["adapter_needed"] == "PARTIAL"
|
||||
assert ACTION_IMPLEMENTATION_STATUS["abort"] == "IMPLEMENTED"
|
||||
assert ACTION_IMPLEMENTATION_STATUS["cross_zone_redistribute"] == "IMPLEMENTED"
|
||||
assert ACTION_IMPLEMENTATION_STATUS["glue_compression"] == "IMPLEMENTED"
|
||||
assert ACTION_IMPLEMENTATION_STATUS["font_step_compression"] == "IMPLEMENTED"
|
||||
|
||||
|
||||
# ─── route_action + route_fit_classification integration ──────────
|
||||
|
||||
|
||||
def test_route_action_image_aspect_mismatch_returns_image_fit_implemented():
|
||||
"""u1 — route_action surface composes the new mapping correctly.
|
||||
|
||||
Stage 1 evidence: previously this call returned proposed_action=None
|
||||
and implementation_status='unknown'. After u1 + u4 + u7, the call must
|
||||
return image_fit with status IMPLEMENTED (end-to-end deterministic path
|
||||
via plan_image_fit + apply_image_fit_css + Step 17 single-pass entry)."""
|
||||
routing = route_action("image_aspect_mismatch")
|
||||
assert routing["proposed_action"] == "image_fit"
|
||||
assert routing["implementation_status"] == "IMPLEMENTED"
|
||||
assert routing["mapping_source"] == "spec §4 ACTION_BY_CATEGORY"
|
||||
assert routing["rationale"], "rationale must be carried through route_action"
|
||||
|
||||
|
||||
def test_route_action_frame_capacity_mismatch_returns_frame_internal_fit_candidate_implemented():
|
||||
"""u1 — route_action surface reflects the REMAP. After u1 + u5 + u6 + u7
|
||||
the status is IMPLEMENTED (end-to-end deterministic path via
|
||||
plan_frame_internal_fit_candidate + apply + salvage dispatcher branch +
|
||||
cascade entry trigger)."""
|
||||
routing = route_action("frame_capacity_mismatch")
|
||||
assert routing["proposed_action"] == "frame_internal_fit_candidate"
|
||||
assert routing["implementation_status"] == "IMPLEMENTED"
|
||||
assert routing["mapping_source"] == "spec §4 ACTION_BY_CATEGORY"
|
||||
|
||||
|
||||
def test_route_fit_classification_surfaces_imp88_actions_as_implemented():
|
||||
"""End-to-end: when classifier emits the two IMP-88 categories alongside
|
||||
an already-implemented one, route_fit_classification:
|
||||
- attaches proposed_action onto each row
|
||||
- lists all three actions in proposed_actions_summary
|
||||
- reports an empty missing_actions_pending_impl for the IMP-88 actions
|
||||
(u7 completion flipped image_fit + frame_internal_fit_candidate to
|
||||
IMPLEMENTED alongside layout_adjust)
|
||||
- all three rows count as IMPLEMENTED in the status summary."""
|
||||
fit_classification = {
|
||||
"visual_check_passed": False,
|
||||
"classifications": [
|
||||
{
|
||||
"source": "image_event",
|
||||
"zone_position": "bottom",
|
||||
"category": "image_aspect_mismatch",
|
||||
},
|
||||
{
|
||||
"source": "composition",
|
||||
"zone_position": "top",
|
||||
"category": "frame_capacity_mismatch",
|
||||
},
|
||||
{
|
||||
"source": "clipped_inner",
|
||||
"zone_position": "bottom",
|
||||
"category": "minor_overflow",
|
||||
},
|
||||
],
|
||||
}
|
||||
summary = route_fit_classification(fit_classification)
|
||||
assert summary["router_active"] is True
|
||||
assert summary["routed_count"] == 3
|
||||
assert "image_fit" in summary["proposed_actions_summary"]
|
||||
assert "frame_internal_fit_candidate" in summary["proposed_actions_summary"]
|
||||
assert "zone_ratio_retry" in summary["proposed_actions_summary"]
|
||||
# After u7 completion, both new IMP-88 actions are IMPLEMENTED on the
|
||||
# router-surface — they no longer surface as missing pending impl.
|
||||
assert "image_fit" not in summary["missing_actions_pending_impl"]
|
||||
assert "frame_internal_fit_candidate" not in summary["missing_actions_pending_impl"]
|
||||
# All three (zone_ratio_retry IMPLEMENTED + 2 IMP-88 IMPLEMENTED) count
|
||||
# together. zone_ratio_retry was IMPLEMENTED since A3 cascade.
|
||||
assert summary["implementation_status_summary"].get("IMPLEMENTED", 0) == 3
|
||||
assert summary["implementation_status_summary"].get("MISSING", 0) == 0
|
||||
# Per-row enrichment carries the new proposed actions onto entries.
|
||||
cats = {c["category"]: c for c in fit_classification["classifications"]}
|
||||
assert cats["image_aspect_mismatch"]["proposed_action"] == "image_fit"
|
||||
assert (
|
||||
cats["frame_capacity_mismatch"]["proposed_action"]
|
||||
== "frame_internal_fit_candidate"
|
||||
)
|
||||
@@ -0,0 +1,151 @@
|
||||
"""IMP-90 (#90) u17 — slide_base.html print-mode contract tests.
|
||||
|
||||
Stage 2 plan contract (unit u17):
|
||||
Step 22 user-edit + Export track. The Phase Z2 print path MUST
|
||||
auto-expand <details> popups so the FULL raw_content (MDX 원문 무손실
|
||||
보존) is included when the user prints / exports from the browser.
|
||||
|
||||
u17 introduces two coordinated surfaces in
|
||||
``templates/phase_z2/slide_base.html``:
|
||||
|
||||
1. ``@media print`` CSS block — neutralizes the on-screen-only body
|
||||
centering / box-shadow / 280px popup card clipping so the slide
|
||||
prints at 1280×720 with the expanded popup body in static flow.
|
||||
|
||||
2. ``beforeprint`` / ``afterprint`` JavaScript hook at body level —
|
||||
toggles ``details.open`` to ``true`` before the print snapshot
|
||||
and restores the user's prior open/closed state afterwards. Body
|
||||
level (outside any ``<details>...</details>`` block) preserves
|
||||
the IMP-35 u8 popup-render JS-free invariant
|
||||
(tests/phase_z2/test_slide_base_popup_render.py
|
||||
``test_popup_emits_no_javascript_on_render_path``).
|
||||
|
||||
Invariants locked here:
|
||||
P-1: ``@media print`` block is emitted exactly once in the render.
|
||||
P-2: ``@page`` size matches the 1280×720 slide canvas.
|
||||
P-3: ``.slide`` box-shadow + body padding/min-height neutralized at
|
||||
print time.
|
||||
P-4: ``.zone__popup-summary`` hidden, popup body switches from
|
||||
absolute to static flow with unconstrained height — the popup
|
||||
card chrome (border / shadow / 280px max-height) is unset.
|
||||
P-5: ``beforeprint`` + ``afterprint`` listeners are wired at body
|
||||
level (NOT inside the per-zone details block) so the popup
|
||||
render path stays JS-free.
|
||||
P-6: Restore semantics — the script preserves the user's prior
|
||||
open/closed state via a single ``dataset.imp90PrintRestore`` key
|
||||
(no global state, no event-bus mutation).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from src.phase_z2_pipeline import render_slide
|
||||
|
||||
|
||||
def _layout_css() -> dict:
|
||||
return {"areas": '"primary"', "cols": "1fr", "rows": "1fr"}
|
||||
|
||||
|
||||
def _zone(**overrides) -> dict:
|
||||
base = {
|
||||
"position": "primary",
|
||||
"template_id": "__empty__",
|
||||
"slot_payload": {},
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
def _render() -> str:
|
||||
return render_slide(
|
||||
slide_title="t",
|
||||
slide_footer=None,
|
||||
zones_data=[_zone()],
|
||||
layout_preset="single",
|
||||
layout_css=_layout_css(),
|
||||
gap_px=14,
|
||||
)
|
||||
|
||||
|
||||
# ─── P-1 ─ media print block presence ───────────────────────────────
|
||||
|
||||
|
||||
def test_media_print_block_emitted_once():
|
||||
html = _render()
|
||||
matches = re.findall(r"@media\s+print\s*\{", html)
|
||||
assert len(matches) == 1
|
||||
|
||||
|
||||
# ─── P-2 ─ @page size matches slide canvas ──────────────────────────
|
||||
|
||||
|
||||
def test_page_size_matches_slide_canvas():
|
||||
html = _render()
|
||||
flat = re.sub(r"\s+", " ", html)
|
||||
assert "@page { size: 1280px 720px; margin: 0; }" in flat
|
||||
|
||||
|
||||
# ─── P-3 ─ standalone chrome neutralized at print ───────────────────
|
||||
|
||||
|
||||
def test_slide_box_shadow_neutralized_at_print():
|
||||
html = _render()
|
||||
flat = re.sub(r"\s+", " ", html)
|
||||
print_block = re.search(r"@media\s+print\s*\{(.*?)\}\s*</style>", flat)
|
||||
assert print_block is not None
|
||||
body = print_block.group(1)
|
||||
assert "box-shadow: none !important" in body
|
||||
assert "padding: 0 !important" in body
|
||||
assert "min-height: 0 !important" in body
|
||||
|
||||
|
||||
# ─── P-4 ─ popup body switches to static flow, summary hidden ───────
|
||||
|
||||
|
||||
def test_popup_card_chrome_unset_at_print():
|
||||
html = _render()
|
||||
flat = re.sub(r"\s+", " ", html)
|
||||
print_block = re.search(r"@media\s+print\s*\{(.*?)\}\s*</style>", flat)
|
||||
assert print_block is not None
|
||||
body = print_block.group(1)
|
||||
assert ".zone__popup-summary { display: none !important; }" in body
|
||||
assert "position: static !important" in body
|
||||
assert "max-height: none !important" in body
|
||||
assert "overflow: visible !important" in body
|
||||
|
||||
|
||||
# ─── P-5 ─ beforeprint hook is body-level (NOT inside <details>) ────
|
||||
|
||||
|
||||
def test_beforeprint_and_afterprint_listeners_present():
|
||||
html = _render()
|
||||
assert "addEventListener('beforeprint'" in html
|
||||
assert "addEventListener('afterprint'" in html
|
||||
|
||||
|
||||
def test_print_script_is_outside_any_details_block():
|
||||
"""The IMP-35 u8 popup render path is JS-free. Our print script
|
||||
sits at body level after the slide div, so no <script> appears
|
||||
inside a <details>...</details> popup block."""
|
||||
html = _render(
|
||||
)
|
||||
# No <details> in the no-popup baseline — but the assertion still
|
||||
# holds defensively: locate every <details>...</details> block (if
|
||||
# any) and confirm no <script> tag appears inside.
|
||||
for block in re.findall(r"<details[\s>].*?</details>", html, re.DOTALL):
|
||||
assert "<script" not in block
|
||||
assert "addEventListener" not in block
|
||||
|
||||
|
||||
# ─── P-6 ─ restore semantics ────────────────────────────────────────
|
||||
|
||||
|
||||
def test_restore_uses_single_dataset_key():
|
||||
"""Restore strategy uses one dataset key
|
||||
(``dataset.imp90PrintRestore``) — no global Set/Map, no mutation
|
||||
of any other DOM attribute. Locks the minimal-surface contract."""
|
||||
html = _render()
|
||||
assert "imp90PrintRestore" in html
|
||||
# Restore branch only sets open=false when the prior state was '0'.
|
||||
assert "imp90PrintRestore === '0'" in html
|
||||
assert "d.open = true" in html
|
||||
@@ -40,8 +40,8 @@
|
||||
"04.mdx": {
|
||||
"mdx_file": "04.mdx",
|
||||
"run_id": "89a_baseline_04",
|
||||
"final_html_size_bytes": 27707,
|
||||
"sha256": "2bce45041cdcca6518cd92586c1be9e051a5c98f5a0ad61fdde02604618a1d80",
|
||||
"final_html_size_bytes": 28042,
|
||||
"sha256": "ddb6bf2f8d76ca1f56588a50dd4af5aeb5f45e0a83d5241b83b5932d0c66d41c",
|
||||
"pipeline_exit_code": null
|
||||
},
|
||||
"05.mdx": {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""IMP-#91 u14 — unit tests for the status-board marker updater.
|
||||
|
||||
Exercises ``parse_outcomes`` (nodeid → axis/mdx outcome mapping) and
|
||||
``update_board_text`` (idempotent marker rewrite). u15 will wire the CLI
|
||||
into the GitHub Actions workflow; these tests guard the contract.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
import update_status_board as usb # noqa: E402
|
||||
|
||||
|
||||
SAMPLE_REPORT = {
|
||||
"tests": [
|
||||
{
|
||||
"nodeid": "tests/integration/test_multi_mdx_regression.py::test_normalize_snapshot_matches[01]",
|
||||
"outcome": "passed",
|
||||
},
|
||||
{
|
||||
"nodeid": "tests/integration/test_multi_mdx_regression.py::test_v4_ranking_snapshot_matches[02]",
|
||||
"outcome": "passed",
|
||||
},
|
||||
{
|
||||
"nodeid": "tests/integration/test_multi_mdx_regression.py::test_layout_snapshot_matches[03]",
|
||||
"outcome": "failed",
|
||||
},
|
||||
{
|
||||
"nodeid": "tests/integration/test_multi_mdx_regression.py::test_pipeline_run_produces_step20_status[02]",
|
||||
"outcome": "passed",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def test_parse_outcomes_maps_known_axes_only() -> None:
|
||||
outcomes = usb.parse_outcomes(SAMPLE_REPORT)
|
||||
assert outcomes == {
|
||||
("F0", "01"): "PASS",
|
||||
("F1", "02"): "PASS",
|
||||
("F4", "03"): "FAIL",
|
||||
}
|
||||
|
||||
|
||||
def test_update_board_text_rewrites_markers() -> None:
|
||||
board = "F0/01: <!-- IMP-91:F0:01 -->?<!-- /IMP-91 --> F1/02: <!-- IMP-91:F1:02 -->old<!-- /IMP-91 -->"
|
||||
outcomes = {("F0", "01"): "PASS"}
|
||||
result = usb.update_board_text(board, outcomes)
|
||||
assert "<!-- IMP-91:F0:01 -->PASS<!-- /IMP-91 -->" in result
|
||||
assert "<!-- IMP-91:F1:02 -->?<!-- /IMP-91 -->" in result
|
||||
|
||||
|
||||
def test_update_board_text_is_idempotent() -> None:
|
||||
board = "<!-- IMP-91:F2:05 -->old<!-- /IMP-91 -->"
|
||||
outcomes = {("F2", "05"): "PASS"}
|
||||
once = usb.update_board_text(board, outcomes)
|
||||
twice = usb.update_board_text(once, outcomes)
|
||||
assert once == twice == "<!-- IMP-91:F2:05 -->PASS<!-- /IMP-91 -->"
|
||||
@@ -0,0 +1,107 @@
|
||||
"""IMP-45 (#74) u2 — frontmatter ``slide_overrides`` surfacing.
|
||||
|
||||
Covers ``src.mdx_normalizer.normalize_mdx_content`` and the helper
|
||||
``_extract_slide_overrides``. The Stage 2 plan enumerates four cases:
|
||||
|
||||
1. Present — nested ``slide_overrides.css`` survives normalization verbatim.
|
||||
2. Absent — return dict carries an empty ``slide_overrides`` mapping.
|
||||
3. Non-string ``css`` — dropped (fail-closed against typo'd YAML shapes).
|
||||
4. Title-only frontmatter — no ``slide_overrides`` key in metadata → ``{}``.
|
||||
|
||||
Scope-lock: this unit only adds the new key to the return dict. The four
|
||||
pre-existing return keys (``clean_text``/``title``/``images``/``popups``/
|
||||
``tables``/``sections``) are asserted unchanged at the structural level.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from src.mdx_normalizer import _extract_slide_overrides, normalize_mdx_content
|
||||
|
||||
|
||||
_CSS_BLOCK = "<style>.f29b__col_right { width: 320px; }</style>"
|
||||
|
||||
|
||||
def _mdx_with_frontmatter(fm_body: str, body: str = "# 제목\n\n본문 한 줄.\n") -> str:
|
||||
return f"---\n{fm_body}---\n{body}"
|
||||
|
||||
|
||||
# -- case 1: present (nested css string survives verbatim) ------------------
|
||||
|
||||
|
||||
def test_normalize_surfaces_nested_slide_overrides_css():
|
||||
raw = _mdx_with_frontmatter(
|
||||
"title: 04 sample\n"
|
||||
"slide_overrides:\n"
|
||||
f" css: \"{_CSS_BLOCK}\"\n"
|
||||
)
|
||||
|
||||
result = normalize_mdx_content(raw)
|
||||
|
||||
assert result["slide_overrides"] == {"css": _CSS_BLOCK}
|
||||
# Other axes unaffected by the new key.
|
||||
assert result["title"] == "04 sample"
|
||||
assert "본문" in result["clean_text"]
|
||||
|
||||
|
||||
# -- case 2: absent (no slide_overrides key in frontmatter) -----------------
|
||||
|
||||
|
||||
def test_normalize_returns_empty_slide_overrides_when_key_absent():
|
||||
raw = _mdx_with_frontmatter("title: 03 sample\n")
|
||||
|
||||
result = normalize_mdx_content(raw)
|
||||
|
||||
assert result["slide_overrides"] == {}
|
||||
# Confirm key is always present (callers can rely on .get without default).
|
||||
assert "slide_overrides" in result
|
||||
|
||||
|
||||
# -- case 3: non-string css (fail-closed drop) ------------------------------
|
||||
|
||||
|
||||
def test_normalize_drops_non_string_css_under_slide_overrides():
|
||||
# YAML list under .css should be dropped; sibling unknown keys survive
|
||||
# so the future generalization path (e.g., slide_overrides.js) stays
|
||||
# forward-compatible per the Stage 2 plan.
|
||||
raw = _mdx_with_frontmatter(
|
||||
"title: typo case\n"
|
||||
"slide_overrides:\n"
|
||||
" css:\n"
|
||||
" - .f29b__col_right { width: 320px; }\n"
|
||||
" note: experimental sibling\n"
|
||||
)
|
||||
|
||||
result = normalize_mdx_content(raw)
|
||||
|
||||
assert "css" not in result["slide_overrides"]
|
||||
assert result["slide_overrides"].get("note") == "experimental sibling"
|
||||
|
||||
|
||||
# -- case 4: title-only frontmatter (no slide_overrides at all) -------------
|
||||
|
||||
|
||||
def test_normalize_title_only_frontmatter_yields_empty_slide_overrides():
|
||||
raw = _mdx_with_frontmatter("title: title only\n")
|
||||
|
||||
result = normalize_mdx_content(raw)
|
||||
|
||||
assert result["title"] == "title only"
|
||||
assert result["slide_overrides"] == {}
|
||||
|
||||
|
||||
# -- direct helper coverage (defensive against future return-shape drift) ---
|
||||
|
||||
|
||||
def test_extract_slide_overrides_non_mapping_returns_empty_dict():
|
||||
# Frontmatter parsers can yield odd types if the user writes
|
||||
# ``slide_overrides: 42`` or ``slide_overrides: ".x{}"``. The helper
|
||||
# must coerce to ``{}`` rather than raise.
|
||||
for bad in (None, 42, "literal string", ["css"]):
|
||||
assert _extract_slide_overrides({"slide_overrides": bad}) == {}
|
||||
|
||||
|
||||
def test_extract_slide_overrides_passes_through_unknown_siblings():
|
||||
payload = {"slide_overrides": {"css": ".a{}", "js": "console.log(1)"}}
|
||||
assert _extract_slide_overrides(payload) == {
|
||||
"css": ".a{}",
|
||||
"js": "console.log(1)",
|
||||
}
|
||||
@@ -56,6 +56,8 @@ def _exec_main_block(
|
||||
override_zone_geometries=None,
|
||||
override_section_assignments=None,
|
||||
override_image_overrides=None,
|
||||
override_slide_css=None,
|
||||
reuse_from=None,
|
||||
):
|
||||
captured["mdx_path"] = mdx_path
|
||||
captured["run_id"] = run_id
|
||||
@@ -64,6 +66,8 @@ def _exec_main_block(
|
||||
captured["override_zone_geometries"] = override_zone_geometries
|
||||
captured["override_section_assignments"] = override_section_assignments
|
||||
captured["override_image_overrides"] = override_image_overrides
|
||||
captured["override_slide_css"] = override_slide_css
|
||||
captured["reuse_from"] = reuse_from
|
||||
|
||||
monkeypatch.setattr(_pz2, "run_phase_z2_mvp1", _fake_run)
|
||||
monkeypatch.setattr(sys, "argv", argv)
|
||||
@@ -346,3 +350,136 @@ def test_image_override_does_not_leak_into_sibling_axes(tmp_path, monkeypatch):
|
||||
assert captured["override_frames"] is None
|
||||
assert captured["override_zone_geometries"] is None
|
||||
assert captured["override_section_assignments"] is None
|
||||
|
||||
|
||||
# -- IMP-45 (#74) u5 — slide-level CSS override CLI surface ----------------
|
||||
#
|
||||
# Six focused cases mirror the --override-image pattern above:
|
||||
# 1. neither flag → kwarg None (fall-back to MDX frontmatter at u4)
|
||||
# 2. --override-slide-css inline TEXT → kwarg passes verbatim
|
||||
# 3. --slide-css-file PATH UTF-8 read → kwarg = file contents
|
||||
# 4. both flags set → sys.exit(2) with mutual-exclusion stderr
|
||||
# 5. --slide-css-file missing path → sys.exit(2) with not-found stderr
|
||||
# 6. --slide-css-file non-UTF-8 bytes → sys.exit(2) with utf-8 stderr
|
||||
|
||||
|
||||
def test_no_slide_css_override_forwards_none(tmp_path, monkeypatch):
|
||||
"""Neither --override-slide-css nor --slide-css-file → kwarg = 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_slide_css"] is None
|
||||
|
||||
|
||||
def test_inline_slide_css_override_forwards_verbatim(tmp_path, monkeypatch):
|
||||
"""--override-slide-css TEXT → kwarg = TEXT (verbatim, no `<style>` wrap)."""
|
||||
_redirect_overrides_root(tmp_path, monkeypatch)
|
||||
captured: dict[str, Any] = {}
|
||||
_exec_main_block(
|
||||
captured,
|
||||
[
|
||||
"src.phase_z2_pipeline",
|
||||
"03.mdx",
|
||||
"--override-slide-css",
|
||||
".slide { background: red; }",
|
||||
],
|
||||
monkeypatch,
|
||||
)
|
||||
|
||||
assert captured["override_slide_css"] == ".slide { background: red; }"
|
||||
|
||||
|
||||
def test_slide_css_file_override_reads_utf8(tmp_path, monkeypatch):
|
||||
"""--slide-css-file PATH → kwarg = UTF-8 decoded file contents."""
|
||||
_redirect_overrides_root(tmp_path, monkeypatch)
|
||||
css_payload = ".slide-body { color: #1e293b; } /* 한글 주석 */\n"
|
||||
css_path = tmp_path / "slide_override.css"
|
||||
css_path.write_text(css_payload, encoding="utf-8")
|
||||
captured: dict[str, Any] = {}
|
||||
_exec_main_block(
|
||||
captured,
|
||||
[
|
||||
"src.phase_z2_pipeline",
|
||||
"03.mdx",
|
||||
"--slide-css-file",
|
||||
str(css_path),
|
||||
],
|
||||
monkeypatch,
|
||||
)
|
||||
|
||||
assert captured["override_slide_css"] == css_payload
|
||||
|
||||
|
||||
def test_slide_css_both_flags_set_exits(tmp_path, monkeypatch, capsys):
|
||||
"""--override-slide-css + --slide-css-file → sys.exit(2)."""
|
||||
_redirect_overrides_root(tmp_path, monkeypatch)
|
||||
css_path = tmp_path / "slide_override.css"
|
||||
css_path.write_text(".slide { color: red; }\n", encoding="utf-8")
|
||||
captured: dict[str, Any] = {}
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
_exec_main_block(
|
||||
captured,
|
||||
[
|
||||
"src.phase_z2_pipeline",
|
||||
"03.mdx",
|
||||
"--override-slide-css",
|
||||
".slide { color: blue; }",
|
||||
"--slide-css-file",
|
||||
str(css_path),
|
||||
],
|
||||
monkeypatch,
|
||||
)
|
||||
|
||||
assert excinfo.value.code == 2
|
||||
err = capsys.readouterr().err
|
||||
assert "--override-slide-css and --slide-css-file are mutually exclusive" in err
|
||||
|
||||
|
||||
def test_slide_css_file_missing_path_exits(tmp_path, monkeypatch, capsys):
|
||||
"""--slide-css-file with non-existent PATH → sys.exit(2)."""
|
||||
_redirect_overrides_root(tmp_path, monkeypatch)
|
||||
missing_path = tmp_path / "does_not_exist.css"
|
||||
captured: dict[str, Any] = {}
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
_exec_main_block(
|
||||
captured,
|
||||
[
|
||||
"src.phase_z2_pipeline",
|
||||
"03.mdx",
|
||||
"--slide-css-file",
|
||||
str(missing_path),
|
||||
],
|
||||
monkeypatch,
|
||||
)
|
||||
|
||||
assert excinfo.value.code == 2
|
||||
err = capsys.readouterr().err
|
||||
assert "--slide-css-file path does not exist" in err
|
||||
assert str(missing_path) in err
|
||||
|
||||
|
||||
def test_slide_css_file_non_utf8_exits(tmp_path, monkeypatch, capsys):
|
||||
"""--slide-css-file with non-UTF-8 bytes → sys.exit(2)."""
|
||||
_redirect_overrides_root(tmp_path, monkeypatch)
|
||||
bad_path = tmp_path / "latin1.css"
|
||||
# 0xff is a stand-alone invalid UTF-8 start byte; strict decode raises.
|
||||
bad_path.write_bytes(b".slide { color: \xff; }\n")
|
||||
captured: dict[str, Any] = {}
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
_exec_main_block(
|
||||
captured,
|
||||
[
|
||||
"src.phase_z2_pipeline",
|
||||
"03.mdx",
|
||||
"--slide-css-file",
|
||||
str(bad_path),
|
||||
],
|
||||
monkeypatch,
|
||||
)
|
||||
|
||||
assert excinfo.value.code == 2
|
||||
err = capsys.readouterr().err
|
||||
assert "--slide-css-file must be UTF-8 encoded" in err
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
"""IMP-43 (#72) u1 + u5 — focused tests for the ``--reuse-from`` CLI surface.
|
||||
|
||||
u1 scope (per the Stage 2 Exit Report):
|
||||
|
||||
- argparse flag ``--reuse-from PREV_RUN_ID`` parses without error.
|
||||
- Fail-closed precondition guard runs AFTER the ``user_overrides.json``
|
||||
merge and BEFORE dispatch. With ``--reuse-from`` set, the guard
|
||||
must:
|
||||
* accept frame-only overrides (or no overrides at all);
|
||||
* reject layout / zone-geometry / zone-section / image overrides
|
||||
with ``sys.exit(2)`` whose stderr names every rejected axis.
|
||||
|
||||
u5 scope (added 2026-05-24):
|
||||
|
||||
- ``reuse_from`` is keyword-only on ``run_phase_z2_mvp1`` and defaults
|
||||
to ``None`` so the absent-flag path preserves pre-u5 behaviour.
|
||||
- The CLI dispatch forwards ``args.reuse_from`` verbatim — both
|
||||
``None`` (flag absent) and ``"PREV_RUN_ID"`` (flag present) reach
|
||||
the kwarg unchanged.
|
||||
- The fake ``run_phase_z2_mvp1`` stub below mirrors the production
|
||||
signature so the forwarding lock would fail loudly on any
|
||||
forwarding regression.
|
||||
|
||||
The harness mirrors ``tests/test_phase_z2_cli_overrides.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. 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. Captures the
|
||||
presence of the call (``called=True``) so guard-driven early exits
|
||||
can be distinguished from a successful parse + dispatch."""
|
||||
|
||||
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,
|
||||
override_slide_css=None,
|
||||
reuse_from=None,
|
||||
):
|
||||
captured["called"] = True
|
||||
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
|
||||
captured["override_slide_css"] = override_slide_css
|
||||
captured["reuse_from"] = reuse_from
|
||||
|
||||
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_reuse_from_alone_parses_and_dispatches(tmp_path, monkeypatch):
|
||||
"""``--reuse-from`` with no other overrides must parse cleanly and
|
||||
fall through to dispatch (frame-only / empty override is allowed).
|
||||
u5 (2026-05-24): also asserts the CLI threads ``args.reuse_from``
|
||||
verbatim into the ``reuse_from`` kwarg."""
|
||||
_redirect_overrides_root(tmp_path, monkeypatch)
|
||||
captured: dict[str, Any] = {}
|
||||
_exec_main_block(
|
||||
captured,
|
||||
[
|
||||
"src.phase_z2_pipeline",
|
||||
"03.mdx",
|
||||
"--reuse-from",
|
||||
"03__DX_20260508025134",
|
||||
],
|
||||
monkeypatch,
|
||||
)
|
||||
|
||||
assert captured.get("called") is True
|
||||
# u5 — verbatim threading.
|
||||
assert captured["reuse_from"] == "03__DX_20260508025134"
|
||||
|
||||
|
||||
def test_reuse_from_with_frame_override_dispatches(tmp_path, monkeypatch):
|
||||
"""Frame overrides ARE preserved across Step 0/1/2/5/6 reuse, so
|
||||
``--reuse-from`` + ``--override-frame`` must reach dispatch.
|
||||
u5: forwards both ``reuse_from`` and ``override_frames`` in the
|
||||
same call."""
|
||||
_redirect_overrides_root(tmp_path, monkeypatch)
|
||||
captured: dict[str, Any] = {}
|
||||
_exec_main_block(
|
||||
captured,
|
||||
[
|
||||
"src.phase_z2_pipeline",
|
||||
"03.mdx",
|
||||
"--reuse-from",
|
||||
"03__DX_20260508025134",
|
||||
"--override-frame",
|
||||
"03-1=frame_foo",
|
||||
],
|
||||
monkeypatch,
|
||||
)
|
||||
|
||||
assert captured.get("called") is True
|
||||
assert captured["override_frames"] == {"03-1": "frame_foo"}
|
||||
# u5 — frame override + reuse_from reach the kwarg simultaneously.
|
||||
assert captured["reuse_from"] == "03__DX_20260508025134"
|
||||
|
||||
|
||||
# -- u5 — flag-absent default + signature surface ------------------------
|
||||
|
||||
|
||||
def test_no_reuse_from_threads_none_kwarg(tmp_path, monkeypatch):
|
||||
"""u5 — when ``--reuse-from`` is absent, the kwarg must reach
|
||||
``run_phase_z2_mvp1`` as ``None`` (not omitted, not ``""``). This
|
||||
locks the "default None preserves current behavior" requirement
|
||||
from the Stage 2 plan §u5."""
|
||||
_redirect_overrides_root(tmp_path, monkeypatch)
|
||||
captured: dict[str, Any] = {}
|
||||
_exec_main_block(
|
||||
captured,
|
||||
["src.phase_z2_pipeline", "03.mdx"],
|
||||
monkeypatch,
|
||||
)
|
||||
|
||||
assert captured.get("called") is True
|
||||
assert captured["reuse_from"] is None
|
||||
|
||||
|
||||
def test_run_phase_z2_mvp1_signature_includes_reuse_from():
|
||||
"""Production signature lock — ``reuse_from`` must be a keyword-only
|
||||
parameter with default ``None``. Mirror of the entry-tests
|
||||
invariant; kept here so the CLI-surface test file fails loudly if
|
||||
the production signature drifts away from the dispatch contract."""
|
||||
import inspect
|
||||
|
||||
sig = inspect.signature(_pz2.run_phase_z2_mvp1)
|
||||
assert "reuse_from" in sig.parameters, list(sig.parameters)
|
||||
param = sig.parameters["reuse_from"]
|
||||
assert param.kind is inspect.Parameter.KEYWORD_ONLY, param.kind
|
||||
assert param.default is None, param.default
|
||||
|
||||
|
||||
# -- fail-closed (single-axis rejection) ----------------------------------
|
||||
|
||||
|
||||
def test_reuse_from_with_layout_override_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",
|
||||
"--reuse-from",
|
||||
"03__DX_20260508025134",
|
||||
"--override-layout",
|
||||
"horizontal-2",
|
||||
],
|
||||
monkeypatch,
|
||||
)
|
||||
|
||||
assert excinfo.value.code == 2
|
||||
err = capsys.readouterr().err
|
||||
assert "--reuse-from incompatible with override axes" in err
|
||||
assert "layout" in err
|
||||
assert captured.get("called") is not True
|
||||
|
||||
|
||||
def test_reuse_from_with_zone_geometry_override_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",
|
||||
"--reuse-from",
|
||||
"03__DX_20260508025134",
|
||||
"--override-zone-geometry",
|
||||
"top=0,0,1,0.3",
|
||||
],
|
||||
monkeypatch,
|
||||
)
|
||||
|
||||
assert excinfo.value.code == 2
|
||||
err = capsys.readouterr().err
|
||||
assert "--reuse-from incompatible with override axes" in err
|
||||
assert "zone_geometry" in err
|
||||
assert captured.get("called") is not True
|
||||
|
||||
|
||||
def test_reuse_from_with_zone_section_override_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",
|
||||
"--reuse-from",
|
||||
"03__DX_20260508025134",
|
||||
"--override-section-assignment",
|
||||
"top=03-1",
|
||||
],
|
||||
monkeypatch,
|
||||
)
|
||||
|
||||
assert excinfo.value.code == 2
|
||||
err = capsys.readouterr().err
|
||||
assert "--reuse-from incompatible with override axes" in err
|
||||
assert "zone_section" in err
|
||||
assert captured.get("called") is not True
|
||||
|
||||
|
||||
def test_reuse_from_with_image_override_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",
|
||||
"--reuse-from",
|
||||
"03__DX_20260508025134",
|
||||
"--override-image",
|
||||
"img-abc=10,15,30,25",
|
||||
],
|
||||
monkeypatch,
|
||||
)
|
||||
|
||||
assert excinfo.value.code == 2
|
||||
err = capsys.readouterr().err
|
||||
assert "--reuse-from incompatible with override axes" in err
|
||||
assert "image" in err
|
||||
assert captured.get("called") is not True
|
||||
|
||||
|
||||
# -- fail-closed (multi-axis aggregation) ---------------------------------
|
||||
|
||||
|
||||
def test_reuse_from_with_multiple_rejected_axes_lists_all(
|
||||
tmp_path, monkeypatch, capsys
|
||||
):
|
||||
"""Stderr must enumerate every rejected axis (not stop at first)."""
|
||||
_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",
|
||||
"--reuse-from",
|
||||
"03__DX_20260508025134",
|
||||
"--override-layout",
|
||||
"horizontal-2",
|
||||
"--override-zone-geometry",
|
||||
"top=0,0,1,0.3",
|
||||
"--override-image",
|
||||
"img-abc=10,15,30,25",
|
||||
],
|
||||
monkeypatch,
|
||||
)
|
||||
|
||||
assert excinfo.value.code == 2
|
||||
err = capsys.readouterr().err
|
||||
assert "layout" in err
|
||||
assert "zone_geometry" in err
|
||||
assert "image" in err
|
||||
assert captured.get("called") is not True
|
||||
|
||||
|
||||
# -- guard inactive when --reuse-from absent ------------------------------
|
||||
|
||||
|
||||
def test_no_reuse_from_layout_override_still_dispatches(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
"""Without ``--reuse-from``, the guard must be silent — existing
|
||||
override behaviour is preserved end-to-end."""
|
||||
_redirect_overrides_root(tmp_path, monkeypatch)
|
||||
captured: dict[str, Any] = {}
|
||||
_exec_main_block(
|
||||
captured,
|
||||
[
|
||||
"src.phase_z2_pipeline",
|
||||
"03.mdx",
|
||||
"--override-layout",
|
||||
"horizontal-2",
|
||||
],
|
||||
monkeypatch,
|
||||
)
|
||||
|
||||
assert captured.get("called") is True
|
||||
assert captured["override_layout"] == "horizontal-2"
|
||||
|
||||
|
||||
# -- fail-closed honours persisted overrides ------------------------------
|
||||
|
||||
|
||||
def test_reuse_from_with_persisted_layout_override_exits(
|
||||
tmp_path, monkeypatch, capsys
|
||||
):
|
||||
"""The guard runs AFTER the user_overrides.json merge, so a layout
|
||||
persisted on disk (not on the CLI) must still reject when
|
||||
``--reuse-from`` is set. This locks the Stage 2 placement rule."""
|
||||
_redirect_overrides_root(tmp_path, monkeypatch)
|
||||
# Persist a layout override keyed by the MDX stem ``03``.
|
||||
overrides_dir = tmp_path
|
||||
overrides_dir.mkdir(parents=True, exist_ok=True)
|
||||
(overrides_dir / "03.json").write_text(
|
||||
'{"layout": "vertical-2"}', encoding="utf-8"
|
||||
)
|
||||
captured: dict[str, Any] = {}
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
_exec_main_block(
|
||||
captured,
|
||||
[
|
||||
"src.phase_z2_pipeline",
|
||||
"03.mdx",
|
||||
"--reuse-from",
|
||||
"03__DX_20260508025134",
|
||||
],
|
||||
monkeypatch,
|
||||
)
|
||||
|
||||
assert excinfo.value.code == 2
|
||||
err = capsys.readouterr().err
|
||||
assert "--reuse-from incompatible with override axes" in err
|
||||
assert "layout" in err
|
||||
assert captured.get("called") is not True
|
||||
@@ -676,12 +676,20 @@ def test_u5_zone_without_provisional_key_treated_as_non_provisional():
|
||||
# ─── u5 case 2 : provisional zone renders class + badge + data attr ───
|
||||
|
||||
|
||||
def test_u5_provisional_zone_renders_class_and_badge():
|
||||
"""Opt-in path. zones[i].provisional=True must:
|
||||
1. Append `zone--provisional` class to the zone div.
|
||||
2. Set `data-provisional="1"` data attribute (for downstream selectors).
|
||||
3. Render a `<span class="zone__needs-adaptation-badge">` element with
|
||||
the literal text "needs adaptation" (aria-label included for a11y).
|
||||
def test_imp84_provisional_zone_silent_no_class_no_badge():
|
||||
"""IMP-84 silent-automation inversion of the prior IMP-30 u5 contract.
|
||||
Under the silent contract, zones[i].provisional=True must:
|
||||
1. NOT append `zone--provisional` class to the zone div (no user-visible
|
||||
outline / striped wash).
|
||||
2. Still set `data-provisional="1"` data attribute as silent telemetry
|
||||
for downstream selectors / inspection.
|
||||
3. NOT render any `<span class="zone__needs-adaptation-badge">` element
|
||||
and NOT surface the literal text "needs adaptation" or its
|
||||
aria-label (no user-facing badge).
|
||||
|
||||
Scope: assertions target the zone div body. The CSS <style> block must
|
||||
likewise not carry the removed visual selectors — that surface is pinned
|
||||
in `test_imp84_slide_base_css_strips_provisional_visual_selectors` below.
|
||||
"""
|
||||
zones = [
|
||||
{
|
||||
@@ -694,21 +702,26 @@ def test_u5_provisional_zone_renders_class_and_badge():
|
||||
}
|
||||
]
|
||||
html = _render_slide_base(zones)
|
||||
# zone--provisional class must appear on the zone div for position=single.
|
||||
assert "zone--provisional" in html
|
||||
# data-provisional="1" attribute must be present.
|
||||
assert 'data-provisional="1"' in html
|
||||
# Badge element with the required label text.
|
||||
assert 'class="zone__needs-adaptation-badge"' in html
|
||||
assert "needs adaptation" in html
|
||||
assert 'aria-label="needs user or AI adaptation"' in html
|
||||
zone_divs = _all_zone_div_openings(html)
|
||||
assert len(zone_divs) == 1
|
||||
# No zone--provisional class on the zone div (visual removed).
|
||||
assert "zone--provisional" not in zone_divs[0]
|
||||
# data-provisional="1" attribute still present as silent telemetry.
|
||||
assert 'data-provisional="1"' in zone_divs[0]
|
||||
# No badge <span> element and no badge label text anywhere in the body.
|
||||
assert _all_badge_spans(html) == []
|
||||
assert "needs adaptation" not in html
|
||||
assert 'aria-label="needs user or AI adaptation"' not in html
|
||||
|
||||
|
||||
def test_u5_provisional_badge_appears_inside_provisional_zone_only():
|
||||
"""Mixed-zone slide: one provisional zone + one normal zone. The badge
|
||||
+ class must appear ONLY in the provisional zone, not bleed into the
|
||||
normal one (CSS-level isolation should already prevent this, but the
|
||||
template must not emit the badge for both)."""
|
||||
def test_imp84_provisional_badge_never_rendered_in_mixed_zones():
|
||||
"""IMP-84 silent-automation inversion of the prior IMP-30 u5 mixed-zone
|
||||
contract. Mixed-zone slide: one provisional zone + one normal zone. The
|
||||
silent contract requires that NO badge span and NO `zone--provisional`
|
||||
class be emitted on either zone div. The provisional zone is identifiable
|
||||
only through the silent `data-provisional="1"` telemetry attribute, which
|
||||
must be scoped to the provisional zone alone (no bleed onto the normal
|
||||
zone)."""
|
||||
zones = [
|
||||
{
|
||||
"position": "top",
|
||||
@@ -735,21 +748,21 @@ def test_u5_provisional_badge_appears_inside_provisional_zone_only():
|
||||
html = _render_slide_base(
|
||||
zones, layout_preset="vertical-2", layout_css=layout_css
|
||||
)
|
||||
# Exactly one badge span element should be present in the rendered body
|
||||
# (CSS selector in <style> excluded by the helper).
|
||||
assert len(_all_badge_spans(html)) == 1
|
||||
# zone--provisional must appear on exactly one zone div (CSS selector
|
||||
# in <style> excluded by the helper).
|
||||
# No badge <span> element should be rendered anywhere in the body
|
||||
# (silent-automation policy).
|
||||
assert _all_badge_spans(html) == []
|
||||
# No zone div should carry the zone--provisional class (visual removed).
|
||||
zone_divs = _all_zone_div_openings(html)
|
||||
assert len(zone_divs) == 2
|
||||
provisional_zone_divs = [d for d in zone_divs if "zone--provisional" in d]
|
||||
assert len(provisional_zone_divs) == 1
|
||||
# The provisional class must be associated with the bottom zone.
|
||||
assert all("zone--provisional" not in d for d in zone_divs)
|
||||
# data-provisional="1" telemetry must be present on the bottom (provisional)
|
||||
# zone only — never on the top (non-provisional) zone.
|
||||
bottom_zone_open = _zone_div_for_position(html, "bottom")
|
||||
assert "zone--provisional" in bottom_zone_open
|
||||
assert "zone__needs-adaptation-badge" in bottom_zone_open
|
||||
# The top zone must NOT carry the provisional class.
|
||||
assert 'data-provisional="1"' in bottom_zone_open
|
||||
assert "zone--provisional" not in bottom_zone_open
|
||||
assert "zone__needs-adaptation-badge" not in bottom_zone_open
|
||||
top_zone_open = _zone_div_for_position(html, "top")
|
||||
assert 'data-provisional="1"' not in top_zone_open
|
||||
assert "zone--provisional" not in top_zone_open
|
||||
assert "zone__needs-adaptation-badge" not in top_zone_open
|
||||
|
||||
@@ -779,15 +792,19 @@ def test_u5_zones_data_provisional_field_defaults_false_in_template():
|
||||
assert _all_badge_spans(html) == []
|
||||
|
||||
|
||||
def test_u5_slide_base_css_carries_provisional_marker_styles():
|
||||
"""The provisional visual contract (dashed outline + striped wash + badge)
|
||||
is defined in slide_base.html <style>. Pin that the relevant CSS class
|
||||
selectors exist in the rendered HTML so a refactor that removes them
|
||||
breaks this test rather than silently rendering an unstyled badge.
|
||||
def test_imp84_slide_base_css_strips_provisional_visual_selectors():
|
||||
"""IMP-84 silent-automation inversion of the prior IMP-30 u5 CSS-presence
|
||||
contract. The provisional visual treatment (dashed outline + striped wash
|
||||
+ badge) was deleted from `slide_base.html <style>` by IMP-84 u2. Pin
|
||||
that the CSS class selectors `.zone--provisional` and
|
||||
`.zone__needs-adaptation-badge` no longer appear in the rendered HTML —
|
||||
a refactor that re-introduces them must break this test rather than
|
||||
silently re-surfacing the removed visual signal.
|
||||
|
||||
This is a class-selector existence check; it does not validate the
|
||||
specific color / dash pattern, which is a design decision intentionally
|
||||
left malleable (e.g., palette swap for a different theme)."""
|
||||
Scope: the assertion targets the entire rendered HTML (style block plus
|
||||
body). Since the body badge span is also gone (covered separately above),
|
||||
any occurrence of these strings in the rendered output would only come
|
||||
from a regressed style block."""
|
||||
zones = [
|
||||
{
|
||||
"position": "single",
|
||||
@@ -799,9 +816,9 @@ def test_u5_slide_base_css_carries_provisional_marker_styles():
|
||||
}
|
||||
]
|
||||
html = _render_slide_base(zones)
|
||||
# Style block must define .zone--provisional and the badge selector.
|
||||
assert ".zone--provisional" in html
|
||||
assert ".zone__needs-adaptation-badge" in html
|
||||
# Style block must NOT define .zone--provisional or the badge selector.
|
||||
assert ".zone--provisional" not in html
|
||||
assert ".zone__needs-adaptation-badge" not in html
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,555 @@
|
||||
"""IMP-43 (#72) u4 — focused tests for the --reuse-from entry helpers.
|
||||
|
||||
u4 scope (per the Stage 2 Exit Report):
|
||||
|
||||
- Pure path resolution, file copy, snapshot load+validate, MdxSection +
|
||||
CompositionUnit rehydration, and reuse-marker writing.
|
||||
- Helpers RAISE on missing artifacts / corrupt snapshot / mdx_sha256
|
||||
mismatch — u4b adds the stderr + sys.exit(2) translation and the
|
||||
prev_run_dir == new_run_dir accidental-write guard around them.
|
||||
- The kwarg threading + the in-``run_phase_z2_mvp1`` branch that
|
||||
invokes these helpers land in u5.
|
||||
|
||||
Tested helpers (``src/phase_z2_pipeline.py``):
|
||||
* ``_resolve_reuse_from_prev_run_dir``
|
||||
* ``_copy_reuse_artifacts_from_prev_run``
|
||||
* ``_load_and_validate_reuse_snapshot``
|
||||
* ``_rehydrate_mdx_sections_from_snapshot``
|
||||
* ``_rehydrate_composition_units_from_snapshot``
|
||||
* ``_write_reuse_marker``
|
||||
* ``_RehydratedV4Candidate`` (V4Match-shape duck type)
|
||||
* ``_REUSE_STEP_ARTIFACTS`` / ``REUSE_MARKER_FILENAME`` /
|
||||
``REUSE_MARKER_SCHEMA_VERSION``
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import pytest
|
||||
|
||||
import src.phase_z2_pipeline as _pz2
|
||||
from src.phase_z2_composition import CompositionUnit
|
||||
from src.phase_z2_reuse_snapshot import (
|
||||
SNAPSHOT_FILENAME,
|
||||
SNAPSHOT_VERSION,
|
||||
SnapshotValidationError,
|
||||
build_snapshot,
|
||||
)
|
||||
|
||||
|
||||
# -- synthetic duck-typed inputs (mirror u3 test fixture) -----------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Section:
|
||||
section_id: str
|
||||
section_num: int
|
||||
title: str
|
||||
raw_content: str
|
||||
heading_number: Optional[str] = None
|
||||
v4_alias_keys: list = field(default_factory=list)
|
||||
sub_sections: list = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _V4Candidate:
|
||||
template_id: str
|
||||
frame_id: str
|
||||
frame_number: int
|
||||
confidence: float
|
||||
label: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Unit:
|
||||
source_section_ids: list
|
||||
merge_type: str
|
||||
frame_template_id: str
|
||||
frame_id: str
|
||||
frame_number: int
|
||||
confidence: float
|
||||
label: str
|
||||
phase_z_status: str
|
||||
raw_content: str
|
||||
title: str
|
||||
score: float
|
||||
v4_rank: Optional[int] = 1
|
||||
selection_path: str = "rank_1"
|
||||
fallback_reason: Optional[str] = None
|
||||
rationale: dict = field(default_factory=dict)
|
||||
auto_selectable: bool = True
|
||||
filter_reasons: list = field(default_factory=list)
|
||||
notes: list = field(default_factory=list)
|
||||
v4_candidates: list = field(default_factory=list)
|
||||
provisional: bool = False
|
||||
|
||||
|
||||
def _mdx_text() -> str:
|
||||
return "# Slide\n\n## 03-1 DX status\n\n- bullet one\n- bullet two\n"
|
||||
|
||||
|
||||
def _build_canonical_snapshot(
|
||||
*,
|
||||
mdx_source_text: Optional[str] = None,
|
||||
layout_preset: str = "single",
|
||||
) -> dict:
|
||||
text = mdx_source_text if mdx_source_text is not None else _mdx_text()
|
||||
cand = _V4Candidate(
|
||||
template_id="tpl_a",
|
||||
frame_id="fid_a",
|
||||
frame_number=13,
|
||||
confidence=0.91,
|
||||
label="use_as_is",
|
||||
)
|
||||
section = _Section(
|
||||
section_id="03-1",
|
||||
section_num=1,
|
||||
title="DX status",
|
||||
raw_content="- bullet one\n- bullet two",
|
||||
heading_number="3.1",
|
||||
v4_alias_keys=["03-1.1"],
|
||||
sub_sections=[],
|
||||
)
|
||||
unit = _Unit(
|
||||
source_section_ids=["03-1"],
|
||||
merge_type="single",
|
||||
frame_template_id="tpl_a",
|
||||
frame_id="fid_a",
|
||||
frame_number=13,
|
||||
confidence=0.91,
|
||||
label="use_as_is",
|
||||
phase_z_status="auto_renderable",
|
||||
raw_content="- bullet one\n- bullet two",
|
||||
title="DX status",
|
||||
score=0.91,
|
||||
v4_candidates=[cand],
|
||||
provisional=False,
|
||||
auto_selectable=True,
|
||||
filter_reasons=[],
|
||||
notes=["a note"],
|
||||
rationale={"weight": 1.0},
|
||||
)
|
||||
return build_snapshot(
|
||||
mdx_sha256=hashlib.sha256(text.encode("utf-8")).hexdigest(),
|
||||
slide_title="Slide",
|
||||
slide_footer=None,
|
||||
sections=[section],
|
||||
stage0_adapter_diagnostics={"used": True, "fallback_reason": None},
|
||||
stage0_normalized_assets={"popups": [], "images": [], "tables": []},
|
||||
v4_evidence=[
|
||||
{
|
||||
"section_id": "03-1",
|
||||
"v4_candidates": [
|
||||
{
|
||||
"template_id": "tpl_a",
|
||||
"frame_id": "fid_a",
|
||||
"frame_number": 13,
|
||||
"confidence": 0.91,
|
||||
"label": "use_as_is",
|
||||
}
|
||||
],
|
||||
"candidate_status": "ok",
|
||||
}
|
||||
],
|
||||
layout_preset_pre_override=layout_preset,
|
||||
units=[unit],
|
||||
comp_debug={"v4_fallback_summary": {"fallback_used_count": 0}},
|
||||
v4_fallback_traces={"03-1": {"selection_path": "rank_1"}},
|
||||
ai_preflight={"enabled": False, "skipped": True},
|
||||
)
|
||||
|
||||
|
||||
def _seed_prev_run_dir(prev_run_dir: Path, *, snapshot: dict) -> None:
|
||||
"""Populate ``prev_run_dir`` with the Step 0/1/2/5/6 artifacts plus
|
||||
the reuse snapshot — minimal but valid surface for u4 helpers."""
|
||||
(prev_run_dir / "steps").mkdir(parents=True, exist_ok=True)
|
||||
for fname in _pz2._REUSE_STEP_ARTIFACTS:
|
||||
# JSON-shaped surface — exact shape doesn't matter for u4 (the
|
||||
# copy helper doesn't introspect contents); just must exist.
|
||||
(prev_run_dir / "steps" / fname).write_text(
|
||||
f'{{"name": "{fname}"}}'
|
||||
if fname.endswith(".json")
|
||||
else "raw mdx body bytes",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(prev_run_dir / SNAPSHOT_FILENAME).write_text(
|
||||
json.dumps(snapshot, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
# -- _REUSE_STEP_ARTIFACTS constant ---------------------------------------
|
||||
|
||||
|
||||
def test_reuse_step_artifacts_locks_stage2_boundary():
|
||||
"""Stage 2 boundary lock — Step 0/1/2/5/6 artifacts only.
|
||||
Step 3/4 deliberately absent: step03 / step04 ARE written after
|
||||
Step 6 (around src/phase_z2_pipeline.py:5931 / 5964) before the
|
||||
Step 7 artifact (~6294), but both are emitted with
|
||||
step_status='trace-only' / pipeline_path_connected=False — they
|
||||
are diagnostic projections of the Step 6 debug_zones, not
|
||||
pipeline-path-connected inputs that Step 7+ rehydrate from."""
|
||||
assert _pz2._REUSE_STEP_ARTIFACTS == (
|
||||
"step00_preconditions.json",
|
||||
"step01_mdx_upload.json",
|
||||
"step01_mdx_source.md",
|
||||
"step02_normalized.json",
|
||||
"step05_v4_evidence.json",
|
||||
"step06_composition_plan.json",
|
||||
)
|
||||
|
||||
|
||||
def test_reuse_marker_filename_is_dotfile_at_run_dir_root():
|
||||
assert _pz2.REUSE_MARKER_FILENAME == "_reuse_marker.json"
|
||||
|
||||
|
||||
# -- _resolve_reuse_from_prev_run_dir -------------------------------------
|
||||
|
||||
|
||||
def test_resolve_prev_run_dir_returns_runs_dir_phase_z2_path():
|
||||
rv = _pz2._resolve_reuse_from_prev_run_dir("20260524_120000_phase_z2")
|
||||
expected = _pz2.RUNS_DIR / "20260524_120000_phase_z2" / "phase_z2"
|
||||
assert rv == expected
|
||||
|
||||
|
||||
def test_resolve_prev_run_dir_does_not_check_existence(tmp_path: Path):
|
||||
"""Pure path computation — must NOT touch the filesystem (u4b
|
||||
handles the missing-prev-run case)."""
|
||||
rv = _pz2._resolve_reuse_from_prev_run_dir("never_existed_run_id")
|
||||
assert isinstance(rv, Path)
|
||||
# The path does not actually exist; helper still returned cleanly.
|
||||
assert not rv.exists()
|
||||
|
||||
|
||||
# -- _copy_reuse_artifacts_from_prev_run ----------------------------------
|
||||
|
||||
|
||||
def test_copy_reuse_artifacts_copies_all_step_files(tmp_path: Path):
|
||||
prev = tmp_path / "prev" / "phase_z2"
|
||||
new = tmp_path / "new" / "phase_z2"
|
||||
snap = _build_canonical_snapshot()
|
||||
_seed_prev_run_dir(prev, snapshot=snap)
|
||||
|
||||
copied = _pz2._copy_reuse_artifacts_from_prev_run(prev, new)
|
||||
|
||||
for fname in _pz2._REUSE_STEP_ARTIFACTS:
|
||||
assert (new / "steps" / fname).exists(), f"missing copy: {fname}"
|
||||
assert copied[fname] == f"steps/{fname}"
|
||||
|
||||
|
||||
def test_copy_reuse_artifacts_copies_snapshot_to_run_dir_root(tmp_path: Path):
|
||||
prev = tmp_path / "prev" / "phase_z2"
|
||||
new = tmp_path / "new" / "phase_z2"
|
||||
snap = _build_canonical_snapshot()
|
||||
_seed_prev_run_dir(prev, snapshot=snap)
|
||||
|
||||
copied = _pz2._copy_reuse_artifacts_from_prev_run(prev, new)
|
||||
|
||||
# Snapshot lives at run_dir root (NOT under steps/) per u3 contract.
|
||||
assert (new / SNAPSHOT_FILENAME).exists()
|
||||
assert copied[SNAPSHOT_FILENAME] == SNAPSHOT_FILENAME
|
||||
|
||||
|
||||
def test_copy_reuse_artifacts_creates_steps_subdir_if_absent(tmp_path: Path):
|
||||
prev = tmp_path / "prev" / "phase_z2"
|
||||
new = tmp_path / "new" / "phase_z2"
|
||||
snap = _build_canonical_snapshot()
|
||||
_seed_prev_run_dir(prev, snapshot=snap)
|
||||
|
||||
# new_run_dir / steps does not yet exist
|
||||
assert not (new / "steps").exists()
|
||||
_pz2._copy_reuse_artifacts_from_prev_run(prev, new)
|
||||
assert (new / "steps").is_dir()
|
||||
|
||||
|
||||
def test_copy_reuse_artifacts_missing_step_raises_filenotfound(
|
||||
tmp_path: Path,
|
||||
):
|
||||
prev = tmp_path / "prev" / "phase_z2"
|
||||
new = tmp_path / "new" / "phase_z2"
|
||||
snap = _build_canonical_snapshot()
|
||||
_seed_prev_run_dir(prev, snapshot=snap)
|
||||
# Delete one of the required step artifacts.
|
||||
(prev / "steps" / "step05_v4_evidence.json").unlink()
|
||||
|
||||
with pytest.raises(FileNotFoundError) as ei:
|
||||
_pz2._copy_reuse_artifacts_from_prev_run(prev, new)
|
||||
msg = str(ei.value)
|
||||
assert "step05_v4_evidence.json" in msg
|
||||
assert "prev_run_dir" in msg
|
||||
|
||||
|
||||
def test_copy_reuse_artifacts_missing_snapshot_raises_filenotfound(
|
||||
tmp_path: Path,
|
||||
):
|
||||
prev = tmp_path / "prev" / "phase_z2"
|
||||
new = tmp_path / "new" / "phase_z2"
|
||||
snap = _build_canonical_snapshot()
|
||||
_seed_prev_run_dir(prev, snapshot=snap)
|
||||
(prev / SNAPSHOT_FILENAME).unlink()
|
||||
|
||||
with pytest.raises(FileNotFoundError) as ei:
|
||||
_pz2._copy_reuse_artifacts_from_prev_run(prev, new)
|
||||
assert SNAPSHOT_FILENAME in str(ei.value)
|
||||
|
||||
|
||||
def test_copy_reuse_artifacts_byte_identical_copy(tmp_path: Path):
|
||||
"""Bytes must match exactly — copy, not transform."""
|
||||
prev = tmp_path / "prev" / "phase_z2"
|
||||
new = tmp_path / "new" / "phase_z2"
|
||||
snap = _build_canonical_snapshot()
|
||||
_seed_prev_run_dir(prev, snapshot=snap)
|
||||
|
||||
_pz2._copy_reuse_artifacts_from_prev_run(prev, new)
|
||||
|
||||
for fname in _pz2._REUSE_STEP_ARTIFACTS:
|
||||
assert (
|
||||
(prev / "steps" / fname).read_bytes()
|
||||
== (new / "steps" / fname).read_bytes()
|
||||
)
|
||||
assert (
|
||||
(prev / SNAPSHOT_FILENAME).read_bytes()
|
||||
== (new / SNAPSHOT_FILENAME).read_bytes()
|
||||
)
|
||||
|
||||
|
||||
# -- _load_and_validate_reuse_snapshot ------------------------------------
|
||||
|
||||
|
||||
def test_load_and_validate_returns_snapshot_dict(tmp_path: Path):
|
||||
text = _mdx_text()
|
||||
snap = _build_canonical_snapshot(mdx_source_text=text)
|
||||
(tmp_path / SNAPSHOT_FILENAME).write_text(
|
||||
json.dumps(snap, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
|
||||
loaded = _pz2._load_and_validate_reuse_snapshot(
|
||||
tmp_path, mdx_source_text=text
|
||||
)
|
||||
assert loaded["schema_version"] == SNAPSHOT_VERSION
|
||||
assert loaded["slide_title"]["value"] == "Slide"
|
||||
|
||||
|
||||
def test_load_and_validate_mdx_sha256_mismatch_raises(tmp_path: Path):
|
||||
"""Snapshot was built for ``text_a`` but caller passes ``text_b``;
|
||||
u2 validator raises ``SnapshotValidationError`` (subclass of
|
||||
``ValueError``). u4b translates to exit 2 — here we only assert the
|
||||
raise."""
|
||||
text_a = "# Slide A\n"
|
||||
text_b = "# Slide B (different bytes)\n"
|
||||
snap = _build_canonical_snapshot(mdx_source_text=text_a)
|
||||
(tmp_path / SNAPSHOT_FILENAME).write_text(
|
||||
json.dumps(snap, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
|
||||
with pytest.raises(SnapshotValidationError) as ei:
|
||||
_pz2._load_and_validate_reuse_snapshot(
|
||||
tmp_path, mdx_source_text=text_b
|
||||
)
|
||||
assert "mdx_sha256 mismatch" in str(ei.value)
|
||||
|
||||
|
||||
def test_load_and_validate_corrupt_json_raises(tmp_path: Path):
|
||||
(tmp_path / SNAPSHOT_FILENAME).write_text(
|
||||
"{ not valid json", encoding="utf-8"
|
||||
)
|
||||
with pytest.raises(json.JSONDecodeError):
|
||||
_pz2._load_and_validate_reuse_snapshot(
|
||||
tmp_path, mdx_source_text=_mdx_text()
|
||||
)
|
||||
|
||||
|
||||
def test_load_and_validate_missing_snapshot_file_raises(tmp_path: Path):
|
||||
"""No snapshot at all — bare ``read_text`` raises FileNotFoundError.
|
||||
u4b translates this to exit 2 with a provenance message."""
|
||||
with pytest.raises(FileNotFoundError):
|
||||
_pz2._load_and_validate_reuse_snapshot(
|
||||
tmp_path, mdx_source_text=_mdx_text()
|
||||
)
|
||||
|
||||
|
||||
def test_load_and_validate_schema_version_mismatch_raises(tmp_path: Path):
|
||||
text = _mdx_text()
|
||||
snap = _build_canonical_snapshot(mdx_source_text=text)
|
||||
snap["schema_version"] = SNAPSHOT_VERSION + 1 # force mismatch
|
||||
(tmp_path / SNAPSHOT_FILENAME).write_text(
|
||||
json.dumps(snap, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
with pytest.raises(SnapshotValidationError) as ei:
|
||||
_pz2._load_and_validate_reuse_snapshot(
|
||||
tmp_path, mdx_source_text=text
|
||||
)
|
||||
assert "schema_version" in str(ei.value)
|
||||
|
||||
|
||||
# -- _rehydrate_mdx_sections_from_snapshot --------------------------------
|
||||
|
||||
|
||||
def test_rehydrate_sections_returns_mdxsection_instances():
|
||||
snap = _build_canonical_snapshot()
|
||||
sections = _pz2._rehydrate_mdx_sections_from_snapshot(snap)
|
||||
assert len(sections) == 1
|
||||
assert isinstance(sections[0], _pz2.MdxSection)
|
||||
assert sections[0].section_id == "03-1"
|
||||
assert sections[0].title == "DX status"
|
||||
assert sections[0].raw_content == "- bullet one\n- bullet two"
|
||||
|
||||
|
||||
def test_rehydrate_sections_preserves_heading_number_and_aliases():
|
||||
snap = _build_canonical_snapshot()
|
||||
sections = _pz2._rehydrate_mdx_sections_from_snapshot(snap)
|
||||
assert sections[0].heading_number == "3.1"
|
||||
assert sections[0].v4_alias_keys == ["03-1.1"]
|
||||
assert sections[0].sub_sections == []
|
||||
|
||||
|
||||
# -- _rehydrate_composition_units_from_snapshot ---------------------------
|
||||
|
||||
|
||||
def test_rehydrate_units_returns_composition_unit_instances():
|
||||
snap = _build_canonical_snapshot()
|
||||
units = _pz2._rehydrate_composition_units_from_snapshot(snap)
|
||||
assert len(units) == 1
|
||||
assert isinstance(units[0], CompositionUnit)
|
||||
|
||||
|
||||
def test_rehydrate_units_preserves_core_fields():
|
||||
snap = _build_canonical_snapshot()
|
||||
units = _pz2._rehydrate_composition_units_from_snapshot(snap)
|
||||
u = units[0]
|
||||
assert u.source_section_ids == ["03-1"]
|
||||
assert u.merge_type == "single"
|
||||
assert u.frame_template_id == "tpl_a"
|
||||
assert u.frame_id == "fid_a"
|
||||
assert u.frame_number == 13
|
||||
assert u.confidence == pytest.approx(0.91)
|
||||
assert u.label == "use_as_is"
|
||||
assert u.phase_z_status == "auto_renderable"
|
||||
assert u.title == "DX status"
|
||||
assert u.score == pytest.approx(0.91)
|
||||
|
||||
|
||||
def test_rehydrate_units_preserves_provisional_and_auto_selectable():
|
||||
snap = _build_canonical_snapshot()
|
||||
units = _pz2._rehydrate_composition_units_from_snapshot(snap)
|
||||
assert units[0].provisional is False
|
||||
assert units[0].auto_selectable is True
|
||||
assert units[0].filter_reasons == []
|
||||
assert units[0].notes == ["a note"]
|
||||
assert units[0].rationale == {"weight": 1.0}
|
||||
|
||||
|
||||
def test_rehydrate_units_v4_candidates_expose_attribute_access():
|
||||
"""``_apply_frame_override_to_unit`` reads
|
||||
``cand.template_id`` / ``cand.frame_id`` / etc. off
|
||||
``unit.v4_candidates`` — restored entries MUST expose attribute
|
||||
access, not raw dict access."""
|
||||
snap = _build_canonical_snapshot()
|
||||
units = _pz2._rehydrate_composition_units_from_snapshot(snap)
|
||||
cands = units[0].v4_candidates
|
||||
assert len(cands) == 1
|
||||
c = cands[0]
|
||||
assert isinstance(c, _pz2._RehydratedV4Candidate)
|
||||
assert c.template_id == "tpl_a"
|
||||
assert c.frame_id == "fid_a"
|
||||
assert c.frame_number == 13
|
||||
assert c.confidence == pytest.approx(0.91)
|
||||
assert c.label == "use_as_is"
|
||||
|
||||
|
||||
def test_rehydrate_units_empty_v4_candidates_yields_empty_list():
|
||||
snap = _build_canonical_snapshot()
|
||||
snap["units"]["value"][0]["v4_candidates"] = []
|
||||
units = _pz2._rehydrate_composition_units_from_snapshot(snap)
|
||||
assert units[0].v4_candidates == []
|
||||
|
||||
|
||||
# -- _write_reuse_marker --------------------------------------------------
|
||||
|
||||
|
||||
def test_write_reuse_marker_writes_json_with_prev_run_id(tmp_path: Path):
|
||||
copied = {
|
||||
"step00_preconditions.json": "steps/step00_preconditions.json",
|
||||
SNAPSHOT_FILENAME: SNAPSHOT_FILENAME,
|
||||
}
|
||||
rv = _pz2._write_reuse_marker(
|
||||
tmp_path,
|
||||
prev_run_id="20260524_010101_phase_z2",
|
||||
copied_artifacts=copied,
|
||||
)
|
||||
assert rv == tmp_path / _pz2.REUSE_MARKER_FILENAME
|
||||
marker = json.loads(rv.read_text(encoding="utf-8"))
|
||||
assert marker["schema_version"] == _pz2.REUSE_MARKER_SCHEMA_VERSION
|
||||
assert marker["reuse_from_prev_run_id"] == "20260524_010101_phase_z2"
|
||||
assert marker["snapshot_filename"] == SNAPSHOT_FILENAME
|
||||
|
||||
|
||||
def test_write_reuse_marker_records_copied_artifacts_and_boundary(
|
||||
tmp_path: Path,
|
||||
):
|
||||
copied = {
|
||||
fname: f"steps/{fname}" for fname in _pz2._REUSE_STEP_ARTIFACTS
|
||||
}
|
||||
copied[SNAPSHOT_FILENAME] = SNAPSHOT_FILENAME
|
||||
_pz2._write_reuse_marker(
|
||||
tmp_path,
|
||||
prev_run_id="20260524_010101_phase_z2",
|
||||
copied_artifacts=copied,
|
||||
)
|
||||
marker = json.loads(
|
||||
(tmp_path / _pz2.REUSE_MARKER_FILENAME).read_text(encoding="utf-8")
|
||||
)
|
||||
assert marker["copied_artifacts"] == copied
|
||||
assert marker["boundary_steps"] == list(_pz2._REUSE_STEP_ARTIFACTS)
|
||||
assert marker["resume_at_step"] == 7
|
||||
|
||||
|
||||
# -- module surface anchors -----------------------------------------------
|
||||
|
||||
|
||||
def test_pipeline_exposes_all_u4_helpers():
|
||||
"""u5 wires these into ``run_phase_z2_mvp1`` — they must remain
|
||||
module-level callable surface on ``phase_z2_pipeline``."""
|
||||
for name in (
|
||||
"_resolve_reuse_from_prev_run_dir",
|
||||
"_copy_reuse_artifacts_from_prev_run",
|
||||
"_load_and_validate_reuse_snapshot",
|
||||
"_rehydrate_mdx_sections_from_snapshot",
|
||||
"_rehydrate_composition_units_from_snapshot",
|
||||
"_write_reuse_marker",
|
||||
"_RehydratedV4Candidate",
|
||||
"_REUSE_STEP_ARTIFACTS",
|
||||
"REUSE_MARKER_FILENAME",
|
||||
"REUSE_MARKER_SCHEMA_VERSION",
|
||||
):
|
||||
assert hasattr(_pz2, name), f"u4 surface missing: {name}"
|
||||
|
||||
|
||||
def test_pipeline_run_signature_reuse_from_is_kw_only_optional_none():
|
||||
"""u5 — ``reuse_from`` is now part of ``run_phase_z2_mvp1``'s public
|
||||
signature. The kwarg MUST be keyword-only (after the ``*`` barrier),
|
||||
default to ``None`` (so absent flag preserves the pre-u5 behaviour),
|
||||
and sit alongside the existing override kwargs. The locked
|
||||
``until_u5`` regression has flipped — keep this assertion as the
|
||||
forward-direction lock so future signature drift (e.g. a positional
|
||||
promotion or a default change) trips loudly."""
|
||||
import inspect
|
||||
|
||||
sig = inspect.signature(_pz2.run_phase_z2_mvp1)
|
||||
assert "reuse_from" in sig.parameters, (
|
||||
"u5 must thread reuse_from into run_phase_z2_mvp1 — kwarg missing. "
|
||||
f"current params: {list(sig.parameters)}"
|
||||
)
|
||||
param = sig.parameters["reuse_from"]
|
||||
assert param.kind is inspect.Parameter.KEYWORD_ONLY, (
|
||||
f"reuse_from must be keyword-only (after the ``*`` barrier); "
|
||||
f"got kind={param.kind}"
|
||||
)
|
||||
assert param.default is None, (
|
||||
f"reuse_from must default to None to preserve pre-u5 behaviour; "
|
||||
f"got default={param.default!r}"
|
||||
)
|
||||
@@ -0,0 +1,261 @@
|
||||
"""IMP-43 (#72) u7b — Opt-in sweep equivalence test for full rerun vs
|
||||
``--reuse-from`` across 3 layouts × 3 mdx samples × per-baseline frame pins.
|
||||
|
||||
u7b scope (per the Stage 2 Exit Report):
|
||||
|
||||
* Three mdx samples — ``01.mdx``, ``02.mdx``, ``03.mdx`` (the baseline
|
||||
full run for each must exit 0 to give step13 equivalence something
|
||||
to compare; ``04.mdx`` / ``05.mdx`` are deliberately excluded per
|
||||
the u7a docstring — adapter_needed / EMPTY_SHELL_NO_CONTENT).
|
||||
* Three ``--override-layout`` axes — ``None`` (auto), ``horizontal-2``,
|
||||
``vertical-2``. ``None`` exercises the natural layout for that mdx;
|
||||
the explicit pins exercise the layout-locked branch (Step 7-B
|
||||
``select_layout_preset`` honors ``--override-layout`` per
|
||||
``src/phase_z2_pipeline.py:5210``). The reuse path (C) inherits the
|
||||
locked layout via the Step 6 snapshot ``layout_preset_pre_override``
|
||||
(u2) — it MUST NOT pass ``--override-layout`` itself (u1 fail-closed
|
||||
guard at ``src/phase_z2_pipeline.py:8181-8199`` rejects layout
|
||||
overrides combined with ``--reuse-from``).
|
||||
* "All 32 frames" coverage axis — each test case discovers ALL pinnable
|
||||
``(unit_id, frame_template_id)`` pairs from its baseline ``step06_
|
||||
composition_plan.json`` and uses every pin in (B) and (C). Union of
|
||||
pins across the 9 (mdx, layout) cases approximates the V4 catalog
|
||||
coverage; pure Cartesian 3×3×32 = 288 parametrize combos × 3
|
||||
subprocess runs ≈ 864 pipeline runs is impractical even opt-in.
|
||||
|
||||
Three subprocess pipeline runs per case (same shape as u7a):
|
||||
(A) baseline full run — no frame overrides — reuse seed.
|
||||
(B) full rerun with the discovered frame overrides — independent
|
||||
control path that does NOT touch ``--reuse-from``.
|
||||
(C) ``--reuse-from <seed_id>`` with the same frame overrides — the
|
||||
reuse path.
|
||||
|
||||
Assert: ``step13_render.json`` from (B) and (C) is byte-equal modulo the
|
||||
Stage 2 whitelist (only ``run_id`` substring inside
|
||||
``data.final_html_path`` is normalized — see u7a docstring for the full
|
||||
whitelist rationale).
|
||||
|
||||
Opt-in:
|
||||
* ``@pytest.mark.sweep`` — marker registered in ``pyproject.toml``.
|
||||
Default CI must run ``pytest -m 'not sweep'``; explicit opt-in is
|
||||
``pytest -m sweep tests/test_phase_z2_reuse_from_equivalence_sweep.py``.
|
||||
* If an mdx / layout combo's baseline (A) returns non-zero (e.g., a
|
||||
layout pin incompatible with the mdx's natural unit_count produces
|
||||
a pipeline error), the case is skipped — u7b is a reuse-equivalence
|
||||
test, not a baseline-correctness test (those live elsewhere).
|
||||
|
||||
Persisted ``data/user_overrides/<stem>.json`` isolation:
|
||||
IMP-52 (#80) u2 introduced an MDX-keyed persistence fallback at
|
||||
``src/phase_z2_pipeline.py:8075-8168`` that merges the on-disk file
|
||||
into the subprocess overrides regardless of CLI flags. For mdx stems
|
||||
whose persistence file carries non-frame axes (e.g.,
|
||||
``data/user_overrides/03.json`` holds ``layout`` + ``zone_geometries``),
|
||||
two orthogonality problems break u7b:
|
||||
|
||||
1. (A) and (B) absorb the persisted ``layout`` / ``zone_geometries``
|
||||
independent of the ``layout_pin`` parameter, collapsing the test
|
||||
matrix — the parametrized layout axis stops being a real axis.
|
||||
2. (C) on the reuse path receives the persisted non-frame axes via
|
||||
the same merge, which the u1 fail-closed guard at
|
||||
``src/phase_z2_pipeline.py:8181-8199`` rejects with exit code 2
|
||||
before step13 equivalence can be measured.
|
||||
|
||||
The ``_isolated_persisted_overrides`` context manager renames the
|
||||
persistence file out of the way for the duration of each parametrized
|
||||
case (try/finally restore; crash-resistant via a startup recovery
|
||||
branch). The hidden backup filename starts with ``.`` so
|
||||
``user_overrides_io.validate_key`` (``src/user_overrides_io.py:72``)
|
||||
cannot accidentally re-load it mid-run. The pipeline subprocess does
|
||||
not write the persistence file (writes are gated to the Vite
|
||||
``/api/user-overrides`` endpoint), so the rename is safe across the
|
||||
three subprocess spawns. The real-world reuse-from × persistence
|
||||
interaction (where ``--reuse-from`` should arguably suppress
|
||||
non-frame persistence injection rather than fail closed) is a
|
||||
follow-up issue candidate, surfaced in this unit's unit_executed
|
||||
Gitea comment.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.test_phase_z2_reuse_from_equivalence_unit import (
|
||||
_assert_run_ok,
|
||||
_frame_override_args,
|
||||
_normalize_step13,
|
||||
_read_step_artifact,
|
||||
_spawn_pipeline,
|
||||
)
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
SAMPLES_DIR = REPO_ROOT / "samples" / "mdx_batch"
|
||||
RUNS_DIR = REPO_ROOT / "data" / "runs"
|
||||
OVERRIDES_DIR = REPO_ROOT / "data" / "user_overrides"
|
||||
|
||||
MDX_FILES = ("01.mdx", "02.mdx", "03.mdx")
|
||||
LAYOUT_PINS = (None, "horizontal-2", "vertical-2")
|
||||
|
||||
|
||||
def _unique(prefix: str) -> str:
|
||||
return f"{prefix}_imp43_u7b_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _isolated_persisted_overrides(mdx_name: str):
|
||||
"""Temporarily rename ``data/user_overrides/<stem>.json`` so the
|
||||
three subprocess runs see a clean persistence state.
|
||||
|
||||
Rationale: see module docstring "Persisted ... isolation" section.
|
||||
The pipeline reads the file at
|
||||
``src/phase_z2_pipeline.py:8098`` via ``load(key)`` which resolves
|
||||
to ``DEFAULT_OVERRIDES_ROOT`` (``src/user_overrides_io.py:54``);
|
||||
moving the file out of the way reduces ``load(key) -> {}`` and
|
||||
prevents the merge from injecting persisted axes.
|
||||
|
||||
Crash recovery: a prior run that crashed between rename and
|
||||
restore would leave ``.<stem>.imp43_u7b_isolation.bak`` next to
|
||||
the missing ``<stem>.json``. The recovery branch at startup
|
||||
restores the backup before proceeding so we never lose the
|
||||
original on a second invocation.
|
||||
"""
|
||||
stem = Path(mdx_name).stem
|
||||
src = OVERRIDES_DIR / f"{stem}.json"
|
||||
backup = OVERRIDES_DIR / f".{stem}.imp43_u7b_isolation.bak"
|
||||
if backup.is_file() and not src.is_file():
|
||||
os.replace(backup, src)
|
||||
moved = False
|
||||
if src.is_file():
|
||||
OVERRIDES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
os.replace(src, backup)
|
||||
moved = True
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if moved and backup.is_file():
|
||||
os.replace(backup, src)
|
||||
|
||||
|
||||
def _discover_all_frame_pins(seed_run_id: str) -> list[tuple[str, str]]:
|
||||
"""Discover ALL ``(unit_id, frame_template_id)`` pins from baseline plan.
|
||||
|
||||
Unlike u7a (capped at 2 for fast CI), u7b uses every pin so the sweep
|
||||
naturally exercises the union of frame templates produced across the
|
||||
9 (mdx, layout) cases — the practical realization of the Stage 2
|
||||
plan's "all 32 frames" axis (full Cartesian 3×3×32 would be 288×3 =
|
||||
864 pipeline runs; impractical even opt-in).
|
||||
|
||||
Schema source: ``src/phase_z2_pipeline.py:5530-5560`` — step06 artifact
|
||||
emits ``data.selected_units[*].{source_section_ids, frame_template_id}``;
|
||||
``unit_id = "+".join(source_section_ids)`` per the ``--override-frame``
|
||||
contract documented at ``src/phase_z2_pipeline.py:7827-7832``.
|
||||
"""
|
||||
step06 = _read_step_artifact(seed_run_id, "step06_composition_plan.json")
|
||||
selected_units = step06.get("data", {}).get("selected_units") or []
|
||||
pins: list[tuple[str, str]] = []
|
||||
for u in selected_units:
|
||||
sids = u.get("source_section_ids") or []
|
||||
tpl_id = u.get("frame_template_id")
|
||||
if not isinstance(sids, list) or not sids:
|
||||
continue
|
||||
if not isinstance(tpl_id, str) or not tpl_id:
|
||||
continue
|
||||
unit_id = "+".join(str(s) for s in sids)
|
||||
if unit_id:
|
||||
pins.append((unit_id, tpl_id))
|
||||
return pins
|
||||
|
||||
|
||||
@pytest.mark.sweep
|
||||
@pytest.mark.parametrize("layout_pin", LAYOUT_PINS)
|
||||
@pytest.mark.parametrize("mdx_name", MDX_FILES)
|
||||
def test_full_rerun_vs_reuse_from_step13_equivalence_sweep(
|
||||
mdx_name: str, layout_pin: str | None
|
||||
) -> None:
|
||||
"""Stage 2 §u7b binding contract: across the (mdx × layout) sweep,
|
||||
full rerun (B) with discovered frame overrides and ``--reuse-from``
|
||||
(C) with the same overrides yield byte-equal ``step13_render.json``
|
||||
modulo the u7a whitelist.
|
||||
|
||||
Skip semantics: if baseline (A) fails for a (mdx, layout) combo
|
||||
(e.g., layout pin incompatible with mdx unit_count), the case is
|
||||
skipped — baseline correctness is not the equivalence axis under
|
||||
test here.
|
||||
"""
|
||||
mdx_path = SAMPLES_DIR / mdx_name
|
||||
if not mdx_path.is_file():
|
||||
pytest.skip(f"sample missing: {mdx_path}")
|
||||
|
||||
layout_args: list[str] = (
|
||||
[] if layout_pin is None else ["--override-layout", layout_pin]
|
||||
)
|
||||
|
||||
# Isolate any persisted ``data/user_overrides/<stem>.json`` for this
|
||||
# mdx before spawning the three subprocesses; see module docstring
|
||||
# "Persisted ... isolation" section for the orthogonality and
|
||||
# fail-closed-guard rationale.
|
||||
with _isolated_persisted_overrides(mdx_name):
|
||||
# (A) baseline full run — no frame overrides — reuse seed.
|
||||
seed_id = _unique("seed")
|
||||
cp_a = _spawn_pipeline([str(mdx_path), seed_id, *layout_args])
|
||||
if cp_a.returncode != 0:
|
||||
pytest.skip(
|
||||
f"baseline (A) non-zero for mdx={mdx_name} layout={layout_pin} "
|
||||
f"(returncode={cp_a.returncode}); not a reuse-equivalence axis. "
|
||||
f"stderr tail: {cp_a.stderr[-400:]}"
|
||||
)
|
||||
|
||||
pins = _discover_all_frame_pins(seed_id)
|
||||
if not pins:
|
||||
pytest.skip(
|
||||
f"no pinnable (unit_id, frame_template_id) pairs in baseline "
|
||||
f"step06 for mdx={mdx_name} layout={layout_pin}; nothing to "
|
||||
f"exercise on the override-frame surface"
|
||||
)
|
||||
override_args = _frame_override_args(pins)
|
||||
|
||||
# (B) full rerun with the discovered frame overrides — independent control.
|
||||
full_id = _unique("full")
|
||||
cp_b = _spawn_pipeline([str(mdx_path), full_id, *layout_args, *override_args])
|
||||
_assert_run_ok(
|
||||
f"full rerun (B) mdx={mdx_name} layout={layout_pin} pins={len(pins)}",
|
||||
cp_b,
|
||||
)
|
||||
|
||||
# (C) --reuse-from seed with the same frame overrides — reuse path.
|
||||
# NOTE: must NOT pass --override-layout here — u1 fail-closed guard
|
||||
# rejects layout+reuse combination. Layout is restored from the Step 6
|
||||
# snapshot (u2 layout_preset_pre_override) instead.
|
||||
reuse_id = _unique("reuse")
|
||||
cp_c = _spawn_pipeline([
|
||||
str(mdx_path),
|
||||
reuse_id,
|
||||
"--reuse-from", seed_id,
|
||||
*override_args,
|
||||
])
|
||||
_assert_run_ok(
|
||||
f"reuse rerun (C) mdx={mdx_name} layout={layout_pin} pins={len(pins)}",
|
||||
cp_c,
|
||||
)
|
||||
|
||||
# Step 13 equivalence — apply whitelist + compare byte-for-byte.
|
||||
full_step13 = _read_step_artifact(full_id, "step13_render.json")
|
||||
reuse_step13 = _read_step_artifact(reuse_id, "step13_render.json")
|
||||
full_norm = _normalize_step13(full_step13, full_id)
|
||||
reuse_norm = _normalize_step13(reuse_step13, reuse_id)
|
||||
|
||||
assert full_norm == reuse_norm, (
|
||||
f"step13_render.json equivalence violated for IMP-43 #72 u7b "
|
||||
f"(mdx={mdx_name}, layout={layout_pin}, full={full_id}, "
|
||||
f"reuse={reuse_id}, seed={seed_id}, pins={pins}):\n"
|
||||
f"--- full (normalized) ---\n"
|
||||
f"{json.dumps(full_norm, ensure_ascii=False, indent=2)}\n"
|
||||
f"--- reuse (normalized) ---\n"
|
||||
f"{json.dumps(reuse_norm, ensure_ascii=False, indent=2)}"
|
||||
)
|
||||
@@ -0,0 +1,204 @@
|
||||
"""IMP-43 (#72) u7a — Fast CI equivalence test for full rerun vs ``--reuse-from``.
|
||||
|
||||
u7a scope (per the Stage 2 Exit Report):
|
||||
|
||||
* One mdx (``samples/mdx_batch/02.mdx``), one layout (auto), two
|
||||
``--override-frame`` pins self-discovered from the baseline's
|
||||
``step06_composition_plan.json`` (each pin re-states the unit's
|
||||
own ``frame_template_id`` — semantically a no-op, but it
|
||||
exercises the full ``--override-frame`` CLI surface through both
|
||||
paths, satisfying the "two frames" axis of the Stage 2 plan).
|
||||
* Three subprocess pipeline runs:
|
||||
(A) baseline full run — no overrides — reuse seed
|
||||
(B) full rerun with the two ``--override-frame`` pins — the
|
||||
independent control path that does NOT touch ``--reuse-from``
|
||||
(C) ``--reuse-from <seed_id>`` with the same two
|
||||
``--override-frame`` pins — the reuse path
|
||||
* Assert: ``step13_render.json`` from (B) and (C) is byte-equal modulo
|
||||
the Stage 2 whitelist — only ``run_id`` (as a substring of
|
||||
``data.final_html_path``), ``timestamps``, and ``prev_run_id`` may
|
||||
legitimately differ. ``step13_render.json`` has no timestamps and
|
||||
no ``prev_run_id`` field (the latter surfaces via the separate
|
||||
``_reuse_marker.json`` sidecar instead — out of scope for this
|
||||
step13 equivalence axis), so the only effective normalization
|
||||
target is the ``run_id`` substring inside ``data.final_html_path``.
|
||||
|
||||
Per Stage 2 plan: the sweep equivalence coverage (3 layouts × 3 mdx ×
|
||||
all 32 frames) lives in u7b under ``pytest.mark.sweep`` — u7a stays
|
||||
fast (3 pipeline runs on a single small mdx) so it can run in default
|
||||
CI without an opt-in marker.
|
||||
|
||||
Why mdx02:
|
||||
* ``test_pipeline_smoke_imp85.py::test_non_vp_smoke_runs_clean`` already
|
||||
pins mdx02 as a non-VP exit-0 path (the baseline (A) run must
|
||||
exit 0 for the equivalence axis to even have something to
|
||||
compare against).
|
||||
* mdx04 / mdx05 are deliberately excluded — mdx04 routes zones to
|
||||
``adapter_needed`` per IMP-#85 u1 and mdx05 exits 1 with
|
||||
``EMPTY_SHELL_NO_CONTENT`` per IMP-#87 u3, neither of which gives
|
||||
a stable step13 equivalence surface for a fast CI lock.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
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"
|
||||
RUNS_DIR = REPO_ROOT / "data" / "runs"
|
||||
MDX_FILENAME = "02.mdx"
|
||||
|
||||
|
||||
def _unique_run_id(prefix: str) -> str:
|
||||
return f"{prefix}_imp43_u7a_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
def _spawn_pipeline(extra_args: list[str], timeout: int = 600) -> subprocess.CompletedProcess:
|
||||
"""Spawn ``python -m src.phase_z2_pipeline <args>`` and capture I/O."""
|
||||
return subprocess.run(
|
||||
[sys.executable, "-m", "src.phase_z2_pipeline", *extra_args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
cwd=str(REPO_ROOT),
|
||||
)
|
||||
|
||||
|
||||
def _assert_run_ok(label: str, cp: subprocess.CompletedProcess) -> None:
|
||||
assert cp.returncode == 0, (
|
||||
f"{label} pipeline returncode={cp.returncode}\n"
|
||||
f"--- stderr tail ---\n{cp.stderr[-2000:]}\n"
|
||||
f"--- stdout tail ---\n{cp.stdout[-2000:]}"
|
||||
)
|
||||
|
||||
|
||||
def _read_step_artifact(run_id: str, fname: str) -> dict:
|
||||
p = RUNS_DIR / run_id / "phase_z2" / "steps" / fname
|
||||
assert p.is_file(), f"missing artifact: {p}"
|
||||
return json.loads(p.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _discover_two_frame_pins(seed_run_id: str) -> list[tuple[str, str]]:
|
||||
"""Self-discover two ``(unit_id, frame_template_id)`` pins from the
|
||||
baseline's ``step06_composition_plan.json``.
|
||||
|
||||
Schema source: ``src/phase_z2_pipeline.py`` ~L5530-L5560 — the step06
|
||||
artifact emits ``data.selected_units[*].{source_section_ids,
|
||||
frame_template_id}``. ``unit_id`` is derived as
|
||||
``"+".join(source_section_ids)`` per the
|
||||
``--override-frame UNIT_ID=TEMPLATE_ID`` contract documented at
|
||||
``src/phase_z2_pipeline.py:7827-7832`` and computed by ``_unit_id``
|
||||
at ``src/phase_z2_pipeline.py:2328``. Pinning the unit's own
|
||||
template is a no-op semantically but exercises the
|
||||
``--override-frame`` CLI surface end-to-end in both (B) and (C).
|
||||
"""
|
||||
step06 = _read_step_artifact(seed_run_id, "step06_composition_plan.json")
|
||||
selected_units = step06.get("data", {}).get("selected_units") or []
|
||||
pinnable: list[tuple[str, str]] = []
|
||||
for u in selected_units:
|
||||
sids = u.get("source_section_ids") or []
|
||||
tpl_id = u.get("frame_template_id")
|
||||
if not isinstance(sids, list) or not sids:
|
||||
continue
|
||||
if not isinstance(tpl_id, str) or not tpl_id:
|
||||
continue
|
||||
unit_id = "+".join(str(s) for s in sids)
|
||||
if not unit_id:
|
||||
continue
|
||||
pinnable.append((unit_id, tpl_id))
|
||||
if len(pinnable) >= 2:
|
||||
break
|
||||
assert len(pinnable) >= 2, (
|
||||
f"baseline {seed_run_id} step06_composition_plan.json must expose "
|
||||
f">= 2 (unit_id, frame_template_id) pairs for the u7a two-frames "
|
||||
f"axis; got {pinnable}"
|
||||
)
|
||||
return pinnable
|
||||
|
||||
|
||||
def _frame_override_args(pins: list[tuple[str, str]]) -> list[str]:
|
||||
out: list[str] = []
|
||||
for unit_id, tpl_id in pins:
|
||||
out.extend(["--override-frame", f"{unit_id}={tpl_id}"])
|
||||
return out
|
||||
|
||||
|
||||
def _normalize_step13(payload: dict, run_id: str) -> dict:
|
||||
"""Apply the Stage 2 equivalence whitelist to step13_render.json.
|
||||
|
||||
Whitelist axes (Stage 2 plan §u7a):
|
||||
* ``run_id`` — appears only as a substring of
|
||||
``data.final_html_path`` in the step13 schema
|
||||
(``src/phase_z2_pipeline.py:7174-7192``).
|
||||
* ``timestamps`` — ``_write_step_artifact``
|
||||
(``src/phase_z2_pipeline.py:3826``) does not
|
||||
stamp a timestamp on the payload, so no
|
||||
normalization is needed for this axis.
|
||||
* ``prev_run_id`` — surfaces via ``_reuse_marker.json`` (separate
|
||||
sidecar), NOT via step13_render.json. No
|
||||
normalization needed on the step13 surface.
|
||||
|
||||
Returns a deep copy of ``payload`` with the ``run_id`` substring of
|
||||
``data.final_html_path`` replaced by the sentinel ``<RUN_ID>`` so
|
||||
the (B) and (C) step13 payloads can be compared byte-for-byte.
|
||||
"""
|
||||
normalized = json.loads(json.dumps(payload, ensure_ascii=False))
|
||||
data = normalized.get("data")
|
||||
if isinstance(data, dict):
|
||||
fhp = data.get("final_html_path")
|
||||
if isinstance(fhp, str) and run_id in fhp:
|
||||
data["final_html_path"] = fhp.replace(run_id, "<RUN_ID>")
|
||||
return normalized
|
||||
|
||||
|
||||
def test_full_rerun_vs_reuse_from_step13_equivalence_one_mdx_two_frames() -> None:
|
||||
"""Stage 2 §u7a binding contract: full rerun (B) with two
|
||||
``--override-frame`` pins and ``--reuse-from`` (C) with the same
|
||||
pins yield byte-equal ``step13_render.json`` modulo the whitelist.
|
||||
"""
|
||||
mdx_path = SAMPLES_DIR / MDX_FILENAME
|
||||
assert mdx_path.is_file(), f"sample missing: {mdx_path}"
|
||||
|
||||
# (A) baseline full run — no overrides — reuse seed.
|
||||
seed_id = _unique_run_id("seed")
|
||||
cp_a = _spawn_pipeline([str(mdx_path), seed_id])
|
||||
_assert_run_ok("baseline (A)", cp_a)
|
||||
|
||||
# Self-discover two (unit_id, frame_template_id) pins.
|
||||
pins = _discover_two_frame_pins(seed_id)
|
||||
override_args = _frame_override_args(pins)
|
||||
|
||||
# (B) full rerun with the two frame overrides — independent control.
|
||||
full_id = _unique_run_id("full")
|
||||
cp_b = _spawn_pipeline([str(mdx_path), full_id, *override_args])
|
||||
_assert_run_ok("full rerun (B)", cp_b)
|
||||
|
||||
# (C) --reuse-from seed with the same frame overrides — reuse path.
|
||||
reuse_id = _unique_run_id("reuse")
|
||||
cp_c = _spawn_pipeline([
|
||||
str(mdx_path),
|
||||
reuse_id,
|
||||
"--reuse-from", seed_id,
|
||||
*override_args,
|
||||
])
|
||||
_assert_run_ok("reuse rerun (C)", cp_c)
|
||||
|
||||
# Step 13 equivalence — apply whitelist + compare byte-for-byte.
|
||||
full_step13 = _read_step_artifact(full_id, "step13_render.json")
|
||||
reuse_step13 = _read_step_artifact(reuse_id, "step13_render.json")
|
||||
full_norm = _normalize_step13(full_step13, full_id)
|
||||
reuse_norm = _normalize_step13(reuse_step13, reuse_id)
|
||||
|
||||
assert full_norm == reuse_norm, (
|
||||
"step13_render.json equivalence violated for IMP-43 #72 u7a "
|
||||
f"(full={full_id}, reuse={reuse_id}, seed={seed_id}, pins={pins}):\n"
|
||||
f"--- full (normalized) ---\n"
|
||||
f"{json.dumps(full_norm, ensure_ascii=False, indent=2)}\n"
|
||||
f"--- reuse (normalized) ---\n"
|
||||
f"{json.dumps(reuse_norm, ensure_ascii=False, indent=2)}"
|
||||
)
|
||||
@@ -0,0 +1,748 @@
|
||||
"""IMP-43 (#72) u4b — fail-closed wrapper tests for ``--reuse-from``.
|
||||
|
||||
u4b scope (per the Stage 2 Exit Report):
|
||||
|
||||
- Translate the u4 raise surface (``FileNotFoundError`` /
|
||||
``SnapshotValidationError`` / ``json.JSONDecodeError`` / ``OSError``)
|
||||
into the CLI fail-closed contract: stderr message + ``sys.exit(2)``.
|
||||
- Add the ``prev_run_dir == new_run_dir`` accidental-write guard BEFORE
|
||||
any copy attempt (prev_run_dir must stay read-only).
|
||||
- Add the missing-prev-run-dir surface (clean axis, not raw stack).
|
||||
- Surface ``mdx_sha256 mismatch`` as its OWN axis (distinct from
|
||||
generic snapshot validation failures).
|
||||
|
||||
The signature threading + the in-``run_phase_z2_mvp1`` branch that
|
||||
invokes the wrapper land in u5. u4b adds the wrapper function only.
|
||||
|
||||
Tested surface (``src/phase_z2_pipeline.py``):
|
||||
* ``execute_reuse_from_or_fail_closed``
|
||||
* ``_abort_reuse_from``
|
||||
* ``_paths_equivalent``
|
||||
* ``REUSE_FAIL_CLOSED_AXES`` (closed enum)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
|
||||
import src.phase_z2_pipeline as _pz2
|
||||
from src.phase_z2_reuse_snapshot import (
|
||||
SNAPSHOT_FILENAME,
|
||||
SNAPSHOT_VERSION,
|
||||
build_snapshot,
|
||||
)
|
||||
|
||||
|
||||
# -- synthetic snapshot inputs (mirror u4 test fixture) ------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Section:
|
||||
section_id: str
|
||||
section_num: int
|
||||
title: str
|
||||
raw_content: str
|
||||
heading_number: Optional[str] = None
|
||||
v4_alias_keys: list = field(default_factory=list)
|
||||
sub_sections: list = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _V4Candidate:
|
||||
template_id: str
|
||||
frame_id: str
|
||||
frame_number: int
|
||||
confidence: float
|
||||
label: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Unit:
|
||||
source_section_ids: list
|
||||
merge_type: str
|
||||
frame_template_id: str
|
||||
frame_id: str
|
||||
frame_number: int
|
||||
confidence: float
|
||||
label: str
|
||||
phase_z_status: str
|
||||
raw_content: str
|
||||
title: str
|
||||
score: float
|
||||
v4_rank: Optional[int] = 1
|
||||
selection_path: str = "rank_1"
|
||||
fallback_reason: Optional[str] = None
|
||||
rationale: dict = field(default_factory=dict)
|
||||
auto_selectable: bool = True
|
||||
filter_reasons: list = field(default_factory=list)
|
||||
notes: list = field(default_factory=list)
|
||||
v4_candidates: list = field(default_factory=list)
|
||||
provisional: bool = False
|
||||
|
||||
|
||||
def _mdx_text() -> str:
|
||||
return "# Slide\n\n## 03-1 DX status\n\n- bullet one\n- bullet two\n"
|
||||
|
||||
|
||||
def _build_canonical_snapshot(*, mdx_source_text: Optional[str] = None) -> dict:
|
||||
text = mdx_source_text if mdx_source_text is not None else _mdx_text()
|
||||
cand = _V4Candidate(
|
||||
template_id="tpl_a",
|
||||
frame_id="fid_a",
|
||||
frame_number=13,
|
||||
confidence=0.91,
|
||||
label="use_as_is",
|
||||
)
|
||||
section = _Section(
|
||||
section_id="03-1",
|
||||
section_num=1,
|
||||
title="DX status",
|
||||
raw_content="- bullet one\n- bullet two",
|
||||
heading_number="3.1",
|
||||
v4_alias_keys=["03-1.1"],
|
||||
)
|
||||
unit = _Unit(
|
||||
source_section_ids=["03-1"],
|
||||
merge_type="single",
|
||||
frame_template_id="tpl_a",
|
||||
frame_id="fid_a",
|
||||
frame_number=13,
|
||||
confidence=0.91,
|
||||
label="use_as_is",
|
||||
phase_z_status="auto_renderable",
|
||||
raw_content="- bullet one\n- bullet two",
|
||||
title="DX status",
|
||||
score=0.91,
|
||||
v4_candidates=[cand],
|
||||
)
|
||||
return build_snapshot(
|
||||
mdx_sha256=hashlib.sha256(text.encode("utf-8")).hexdigest(),
|
||||
slide_title="Slide",
|
||||
slide_footer=None,
|
||||
sections=[section],
|
||||
stage0_adapter_diagnostics={"used": True, "fallback_reason": None},
|
||||
stage0_normalized_assets={"popups": [], "images": [], "tables": []},
|
||||
v4_evidence=[],
|
||||
layout_preset_pre_override="single",
|
||||
units=[unit],
|
||||
comp_debug={},
|
||||
v4_fallback_traces={},
|
||||
ai_preflight={"enabled": False, "skipped": True},
|
||||
)
|
||||
|
||||
|
||||
def _seed_prev_run_dir(prev_run_dir: Path, *, snapshot: dict) -> None:
|
||||
(prev_run_dir / "steps").mkdir(parents=True, exist_ok=True)
|
||||
for fname in _pz2._REUSE_STEP_ARTIFACTS:
|
||||
(prev_run_dir / "steps" / fname).write_text(
|
||||
f'{{"name": "{fname}"}}'
|
||||
if fname.endswith(".json")
|
||||
else "raw mdx body bytes",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(prev_run_dir / SNAPSHOT_FILENAME).write_text(
|
||||
json.dumps(snapshot, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
# -- REUSE_FAIL_CLOSED_AXES vocab lock ------------------------------------
|
||||
|
||||
|
||||
def test_fail_closed_axes_is_closed_enum():
|
||||
"""The nine axes are the entire fail-closed vocabulary; if a new
|
||||
axis lands without test coverage update, this lock breaks.
|
||||
|
||||
``reuse_copy_os_error`` / ``snapshot_read_os_error`` were added in
|
||||
the Codex #6 stage_3_edit rewind to cover OSError != FNF that the
|
||||
earlier u4b implementation let escape as a raw traceback.
|
||||
"""
|
||||
assert _pz2.REUSE_FAIL_CLOSED_AXES == frozenset({
|
||||
"prev_run_dir_missing",
|
||||
"prev_run_dir_equals_new_run_dir",
|
||||
"reuse_artifact_missing",
|
||||
"reuse_copy_os_error",
|
||||
"snapshot_missing_after_copy",
|
||||
"snapshot_corrupt_json",
|
||||
"snapshot_read_os_error",
|
||||
"mdx_sha256_mismatch",
|
||||
"snapshot_validation_failed",
|
||||
})
|
||||
|
||||
|
||||
# -- _abort_reuse_from -----------------------------------------------------
|
||||
|
||||
|
||||
def test_abort_reuse_from_exits_with_code_two(capsys):
|
||||
with pytest.raises(SystemExit) as ei:
|
||||
_pz2._abort_reuse_from(
|
||||
axis="prev_run_dir_missing",
|
||||
value="never_existed",
|
||||
path="D:/nope",
|
||||
upstream="--reuse-from CLI argument",
|
||||
)
|
||||
assert ei.value.code == 2
|
||||
|
||||
|
||||
def test_abort_reuse_from_stderr_contains_value_path_upstream(capsys):
|
||||
with pytest.raises(SystemExit):
|
||||
_pz2._abort_reuse_from(
|
||||
axis="prev_run_dir_missing",
|
||||
value="never_existed",
|
||||
path="D:/nope",
|
||||
upstream="--reuse-from CLI argument",
|
||||
)
|
||||
err = capsys.readouterr().err
|
||||
assert "prev_run_dir_missing" in err
|
||||
assert "value:" in err
|
||||
assert "path:" in err
|
||||
assert "upstream:" in err
|
||||
assert "never_existed" in err
|
||||
assert "D:/nope" in err
|
||||
assert "--reuse-from CLI argument" in err
|
||||
|
||||
|
||||
def test_abort_reuse_from_includes_reason_when_exc_passed(capsys):
|
||||
"""The optional ``exc`` field surfaces the underlying type +
|
||||
message so operators can distinguish e.g. JSONDecodeError line/col
|
||||
info from a generic 'snapshot broken'."""
|
||||
try:
|
||||
raise ValueError("schema_version mismatch: expected 1, got 99")
|
||||
except ValueError as exc:
|
||||
with pytest.raises(SystemExit):
|
||||
_pz2._abort_reuse_from(
|
||||
axis="snapshot_validation_failed",
|
||||
value=str(exc),
|
||||
path="D:/some/path",
|
||||
upstream="validate_snapshot",
|
||||
exc=exc,
|
||||
)
|
||||
err = capsys.readouterr().err
|
||||
assert "reason:" in err
|
||||
assert "ValueError" in err
|
||||
assert "schema_version mismatch" in err
|
||||
|
||||
|
||||
def test_abort_reuse_from_rejects_unknown_axis():
|
||||
"""Unknown axis = programmer error, not user error; must trip
|
||||
AssertionError, not silently emit a malformed stderr line."""
|
||||
with pytest.raises(AssertionError):
|
||||
_pz2._abort_reuse_from(
|
||||
axis="totally_made_up_axis",
|
||||
value="x",
|
||||
path="y",
|
||||
upstream="z",
|
||||
)
|
||||
|
||||
|
||||
# -- _paths_equivalent -----------------------------------------------------
|
||||
|
||||
|
||||
def test_paths_equivalent_same_path_returns_true(tmp_path: Path):
|
||||
a = tmp_path / "x" / "y"
|
||||
a.mkdir(parents=True)
|
||||
assert _pz2._paths_equivalent(a, a) is True
|
||||
|
||||
|
||||
def test_paths_equivalent_different_paths_returns_false(tmp_path: Path):
|
||||
a = tmp_path / "alpha"
|
||||
b = tmp_path / "beta"
|
||||
a.mkdir()
|
||||
b.mkdir()
|
||||
assert _pz2._paths_equivalent(a, b) is False
|
||||
|
||||
|
||||
def test_paths_equivalent_handles_nonexistent_paths(tmp_path: Path):
|
||||
"""``Path.resolve(strict=False)`` should still normalize ``..``
|
||||
even when the leaf does not yet exist (new_run_dir before mkdir)."""
|
||||
a = tmp_path / "new_run" / "phase_z2"
|
||||
b = tmp_path / "new_run" / "phase_z2"
|
||||
assert _pz2._paths_equivalent(a, b) is True
|
||||
|
||||
|
||||
# -- execute_reuse_from_or_fail_closed: happy path -----------------------
|
||||
|
||||
|
||||
def test_happy_path_returns_prev_run_dir_copied_snapshot(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
text = _mdx_text()
|
||||
runs_root = tmp_path / "runs"
|
||||
prev_run_id = "prev_run_id_001"
|
||||
prev_run_dir = runs_root / prev_run_id / "phase_z2"
|
||||
new_run_dir = tmp_path / "new" / "phase_z2"
|
||||
|
||||
snap = _build_canonical_snapshot(mdx_source_text=text)
|
||||
_seed_prev_run_dir(prev_run_dir, snapshot=snap)
|
||||
|
||||
monkeypatch.setattr(_pz2, "RUNS_DIR", runs_root)
|
||||
|
||||
rv = _pz2.execute_reuse_from_or_fail_closed(
|
||||
reuse_from=prev_run_id,
|
||||
new_run_dir=new_run_dir,
|
||||
mdx_source_text=text,
|
||||
)
|
||||
prev_dir_ret, copied_ret, snap_ret = rv
|
||||
|
||||
assert prev_dir_ret == prev_run_dir
|
||||
assert SNAPSHOT_FILENAME in copied_ret
|
||||
assert snap_ret["schema_version"] == SNAPSHOT_VERSION
|
||||
# snapshot wrapper survives (value/source_path/upstream_step)
|
||||
assert snap_ret["slide_title"]["value"] == "Slide"
|
||||
|
||||
|
||||
# -- prev_run_dir_missing axis --------------------------------------------
|
||||
|
||||
|
||||
def test_prev_run_dir_missing_aborts(tmp_path: Path, monkeypatch, capsys):
|
||||
runs_root = tmp_path / "runs"
|
||||
runs_root.mkdir()
|
||||
monkeypatch.setattr(_pz2, "RUNS_DIR", runs_root)
|
||||
|
||||
with pytest.raises(SystemExit) as ei:
|
||||
_pz2.execute_reuse_from_or_fail_closed(
|
||||
reuse_from="does_not_exist_anywhere",
|
||||
new_run_dir=tmp_path / "new" / "phase_z2",
|
||||
mdx_source_text=_mdx_text(),
|
||||
)
|
||||
assert ei.value.code == 2
|
||||
err = capsys.readouterr().err
|
||||
assert "prev_run_dir_missing" in err
|
||||
assert "does_not_exist_anywhere" in err
|
||||
|
||||
|
||||
# -- prev_run_dir_equals_new_run_dir axis ---------------------------------
|
||||
|
||||
|
||||
def test_prev_run_dir_equals_new_run_dir_aborts(
|
||||
tmp_path: Path, monkeypatch, capsys
|
||||
):
|
||||
"""Accidental collision: if the new run_id resolves to the same
|
||||
phase_z2 dir as prev_run_id, the copy step would overwrite
|
||||
prev_run_dir in place. u4b must reject BEFORE the copy attempt."""
|
||||
runs_root = tmp_path / "runs"
|
||||
prev_run_id = "shared_run_id"
|
||||
prev_run_dir = runs_root / prev_run_id / "phase_z2"
|
||||
snap = _build_canonical_snapshot()
|
||||
_seed_prev_run_dir(prev_run_dir, snapshot=snap)
|
||||
monkeypatch.setattr(_pz2, "RUNS_DIR", runs_root)
|
||||
|
||||
# new_run_dir resolves to the SAME phase_z2 dir as prev_run_dir.
|
||||
new_run_dir = prev_run_dir
|
||||
|
||||
with pytest.raises(SystemExit) as ei:
|
||||
_pz2.execute_reuse_from_or_fail_closed(
|
||||
reuse_from=prev_run_id,
|
||||
new_run_dir=new_run_dir,
|
||||
mdx_source_text=_mdx_text(),
|
||||
)
|
||||
assert ei.value.code == 2
|
||||
err = capsys.readouterr().err
|
||||
assert "prev_run_dir_equals_new_run_dir" in err
|
||||
|
||||
|
||||
def test_prev_run_dir_equals_new_run_dir_does_not_mutate_prev(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
"""Critical RO guarantee — the abort must fire BEFORE
|
||||
``_copy_reuse_artifacts_from_prev_run`` runs, so the seeded prev
|
||||
artifact bytes survive untouched."""
|
||||
runs_root = tmp_path / "runs"
|
||||
prev_run_id = "shared_run_id"
|
||||
prev_run_dir = runs_root / prev_run_id / "phase_z2"
|
||||
snap = _build_canonical_snapshot()
|
||||
_seed_prev_run_dir(prev_run_dir, snapshot=snap)
|
||||
monkeypatch.setattr(_pz2, "RUNS_DIR", runs_root)
|
||||
|
||||
sentinel_text = '{"name": "step02_normalized.json"}'
|
||||
target = prev_run_dir / "steps" / "step02_normalized.json"
|
||||
assert target.read_text(encoding="utf-8") == sentinel_text
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
_pz2.execute_reuse_from_or_fail_closed(
|
||||
reuse_from=prev_run_id,
|
||||
new_run_dir=prev_run_dir,
|
||||
mdx_source_text=_mdx_text(),
|
||||
)
|
||||
# prev_run_dir bytes still intact.
|
||||
assert target.read_text(encoding="utf-8") == sentinel_text
|
||||
|
||||
|
||||
# -- reuse_artifact_missing axis ------------------------------------------
|
||||
|
||||
|
||||
def test_reuse_artifact_missing_aborts(tmp_path: Path, monkeypatch, capsys):
|
||||
runs_root = tmp_path / "runs"
|
||||
prev_run_id = "prev_run_001"
|
||||
prev_run_dir = runs_root / prev_run_id / "phase_z2"
|
||||
new_run_dir = tmp_path / "new" / "phase_z2"
|
||||
snap = _build_canonical_snapshot()
|
||||
_seed_prev_run_dir(prev_run_dir, snapshot=snap)
|
||||
# Remove one required step file → triggers FileNotFoundError in
|
||||
# _copy_reuse_artifacts_from_prev_run.
|
||||
(prev_run_dir / "steps" / "step05_v4_evidence.json").unlink()
|
||||
monkeypatch.setattr(_pz2, "RUNS_DIR", runs_root)
|
||||
|
||||
with pytest.raises(SystemExit) as ei:
|
||||
_pz2.execute_reuse_from_or_fail_closed(
|
||||
reuse_from=prev_run_id,
|
||||
new_run_dir=new_run_dir,
|
||||
mdx_source_text=_mdx_text(),
|
||||
)
|
||||
assert ei.value.code == 2
|
||||
err = capsys.readouterr().err
|
||||
assert "reuse_artifact_missing" in err
|
||||
assert "step05_v4_evidence.json" in err
|
||||
assert "reason:" in err
|
||||
assert "FileNotFoundError" in err
|
||||
|
||||
|
||||
def test_reuse_artifact_missing_snapshot_sidecar(
|
||||
tmp_path: Path, monkeypatch, capsys
|
||||
):
|
||||
runs_root = tmp_path / "runs"
|
||||
prev_run_id = "prev_run_002"
|
||||
prev_run_dir = runs_root / prev_run_id / "phase_z2"
|
||||
new_run_dir = tmp_path / "new" / "phase_z2"
|
||||
snap = _build_canonical_snapshot()
|
||||
_seed_prev_run_dir(prev_run_dir, snapshot=snap)
|
||||
(prev_run_dir / SNAPSHOT_FILENAME).unlink()
|
||||
monkeypatch.setattr(_pz2, "RUNS_DIR", runs_root)
|
||||
|
||||
with pytest.raises(SystemExit) as ei:
|
||||
_pz2.execute_reuse_from_or_fail_closed(
|
||||
reuse_from=prev_run_id,
|
||||
new_run_dir=new_run_dir,
|
||||
mdx_source_text=_mdx_text(),
|
||||
)
|
||||
assert ei.value.code == 2
|
||||
err = capsys.readouterr().err
|
||||
assert "reuse_artifact_missing" in err
|
||||
assert SNAPSHOT_FILENAME in err
|
||||
|
||||
|
||||
# -- snapshot_corrupt_json axis -------------------------------------------
|
||||
|
||||
|
||||
def test_snapshot_corrupt_json_aborts(tmp_path: Path, monkeypatch, capsys):
|
||||
runs_root = tmp_path / "runs"
|
||||
prev_run_id = "prev_run_corrupt"
|
||||
prev_run_dir = runs_root / prev_run_id / "phase_z2"
|
||||
new_run_dir = tmp_path / "new" / "phase_z2"
|
||||
snap = _build_canonical_snapshot()
|
||||
_seed_prev_run_dir(prev_run_dir, snapshot=snap)
|
||||
# Overwrite the snapshot with invalid JSON; copy will succeed,
|
||||
# validate_snapshot will fail with JSONDecodeError (raised inside
|
||||
# _load_and_validate_reuse_snapshot before validate_snapshot).
|
||||
(prev_run_dir / SNAPSHOT_FILENAME).write_text(
|
||||
"{ not valid json", encoding="utf-8"
|
||||
)
|
||||
monkeypatch.setattr(_pz2, "RUNS_DIR", runs_root)
|
||||
|
||||
with pytest.raises(SystemExit) as ei:
|
||||
_pz2.execute_reuse_from_or_fail_closed(
|
||||
reuse_from=prev_run_id,
|
||||
new_run_dir=new_run_dir,
|
||||
mdx_source_text=_mdx_text(),
|
||||
)
|
||||
assert ei.value.code == 2
|
||||
err = capsys.readouterr().err
|
||||
assert "snapshot_corrupt_json" in err
|
||||
assert SNAPSHOT_FILENAME in err
|
||||
assert "JSONDecodeError" in err
|
||||
|
||||
|
||||
# -- mdx_sha256_mismatch axis (own surface) -------------------------------
|
||||
|
||||
|
||||
def test_mdx_sha256_mismatch_aborts_with_own_axis(
|
||||
tmp_path: Path, monkeypatch, capsys
|
||||
):
|
||||
"""Distinct from generic snapshot_validation_failed — operator
|
||||
must be able to tell 'wrong --mdx-path for this prev_run_id' apart
|
||||
from 'snapshot file is broken'."""
|
||||
runs_root = tmp_path / "runs"
|
||||
prev_run_id = "prev_run_diff_mdx"
|
||||
prev_run_dir = runs_root / prev_run_id / "phase_z2"
|
||||
new_run_dir = tmp_path / "new" / "phase_z2"
|
||||
|
||||
text_a = "# Slide A\n"
|
||||
text_b = "# Slide B (different bytes)\n"
|
||||
snap = _build_canonical_snapshot(mdx_source_text=text_a)
|
||||
_seed_prev_run_dir(prev_run_dir, snapshot=snap)
|
||||
monkeypatch.setattr(_pz2, "RUNS_DIR", runs_root)
|
||||
|
||||
with pytest.raises(SystemExit) as ei:
|
||||
_pz2.execute_reuse_from_or_fail_closed(
|
||||
reuse_from=prev_run_id,
|
||||
new_run_dir=new_run_dir,
|
||||
mdx_source_text=text_b,
|
||||
)
|
||||
assert ei.value.code == 2
|
||||
err = capsys.readouterr().err
|
||||
assert "mdx_sha256_mismatch" in err
|
||||
# Must NOT be reported as generic snapshot_validation_failed —
|
||||
# the mdx-sha case has its own axis.
|
||||
assert "snapshot_validation_failed" not in err
|
||||
assert "mdx_source_text" in err or "mdx_sha256" in err
|
||||
|
||||
|
||||
# -- snapshot_validation_failed axis --------------------------------------
|
||||
|
||||
|
||||
def test_snapshot_validation_failed_schema_version_aborts(
|
||||
tmp_path: Path, monkeypatch, capsys
|
||||
):
|
||||
runs_root = tmp_path / "runs"
|
||||
prev_run_id = "prev_run_schema_mismatch"
|
||||
prev_run_dir = runs_root / prev_run_id / "phase_z2"
|
||||
new_run_dir = tmp_path / "new" / "phase_z2"
|
||||
|
||||
text = _mdx_text()
|
||||
snap = _build_canonical_snapshot(mdx_source_text=text)
|
||||
snap["schema_version"] = SNAPSHOT_VERSION + 1
|
||||
_seed_prev_run_dir(prev_run_dir, snapshot=snap)
|
||||
monkeypatch.setattr(_pz2, "RUNS_DIR", runs_root)
|
||||
|
||||
with pytest.raises(SystemExit) as ei:
|
||||
_pz2.execute_reuse_from_or_fail_closed(
|
||||
reuse_from=prev_run_id,
|
||||
new_run_dir=new_run_dir,
|
||||
mdx_source_text=text,
|
||||
)
|
||||
assert ei.value.code == 2
|
||||
err = capsys.readouterr().err
|
||||
assert "snapshot_validation_failed" in err
|
||||
assert "schema_version" in err
|
||||
# NOT the mdx-sha axis — separate fingerprint.
|
||||
assert "mdx_sha256_mismatch" not in err
|
||||
|
||||
|
||||
def test_snapshot_validation_failed_missing_required_key_aborts(
|
||||
tmp_path: Path, monkeypatch, capsys
|
||||
):
|
||||
runs_root = tmp_path / "runs"
|
||||
prev_run_id = "prev_run_missing_key"
|
||||
prev_run_dir = runs_root / prev_run_id / "phase_z2"
|
||||
new_run_dir = tmp_path / "new" / "phase_z2"
|
||||
|
||||
text = _mdx_text()
|
||||
snap = _build_canonical_snapshot(mdx_source_text=text)
|
||||
del snap["units"]
|
||||
_seed_prev_run_dir(prev_run_dir, snapshot=snap)
|
||||
monkeypatch.setattr(_pz2, "RUNS_DIR", runs_root)
|
||||
|
||||
with pytest.raises(SystemExit) as ei:
|
||||
_pz2.execute_reuse_from_or_fail_closed(
|
||||
reuse_from=prev_run_id,
|
||||
new_run_dir=new_run_dir,
|
||||
mdx_source_text=text,
|
||||
)
|
||||
assert ei.value.code == 2
|
||||
err = capsys.readouterr().err
|
||||
assert "snapshot_validation_failed" in err
|
||||
assert "units" in err
|
||||
|
||||
|
||||
# -- reuse_copy_os_error axis (OSError != FileNotFoundError) -------------
|
||||
|
||||
|
||||
def test_copy_os_error_aborts_with_own_axis(
|
||||
tmp_path: Path, monkeypatch, capsys
|
||||
):
|
||||
"""Codex #6 stage_3_edit fixup — OSError raised inside
|
||||
``_copy_reuse_artifacts_from_prev_run`` (e.g. PermissionError on
|
||||
the destination, OSError(errno.EXDEV) on cross-device copy) must
|
||||
translate to fail-closed (stderr + SystemExit(2)) instead of
|
||||
escaping as a raw traceback.
|
||||
|
||||
Implementation must catch ``FileNotFoundError`` BEFORE the bare
|
||||
``OSError`` handler (FNF is a subclass of OSError), otherwise the
|
||||
missing-artifact case would be mis-bucketed under
|
||||
``reuse_copy_os_error``.
|
||||
"""
|
||||
runs_root = tmp_path / "runs"
|
||||
prev_run_id = "prev_run_perm_denied"
|
||||
prev_run_dir = runs_root / prev_run_id / "phase_z2"
|
||||
new_run_dir = tmp_path / "new" / "phase_z2"
|
||||
snap = _build_canonical_snapshot()
|
||||
_seed_prev_run_dir(prev_run_dir, snapshot=snap)
|
||||
monkeypatch.setattr(_pz2, "RUNS_DIR", runs_root)
|
||||
|
||||
def _raise_perm(src, dst, *args, **kwargs):
|
||||
raise PermissionError(f"simulated permission denied: {dst}")
|
||||
|
||||
monkeypatch.setattr(_pz2.shutil, "copyfile", _raise_perm)
|
||||
|
||||
with pytest.raises(SystemExit) as ei:
|
||||
_pz2.execute_reuse_from_or_fail_closed(
|
||||
reuse_from=prev_run_id,
|
||||
new_run_dir=new_run_dir,
|
||||
mdx_source_text=_mdx_text(),
|
||||
)
|
||||
assert ei.value.code == 2
|
||||
err = capsys.readouterr().err
|
||||
assert "reuse_copy_os_error" in err
|
||||
assert "value:" in err
|
||||
assert "path:" in err
|
||||
assert "upstream:" in err
|
||||
assert "reason:" in err
|
||||
assert "PermissionError" in err
|
||||
assert "simulated permission denied" in err
|
||||
# Must NOT be mis-bucketed as the missing-artifact case.
|
||||
assert "reuse_artifact_missing" not in err
|
||||
|
||||
|
||||
def test_copy_filenotfounderror_still_uses_artifact_missing_axis(
|
||||
tmp_path: Path, monkeypatch, capsys
|
||||
):
|
||||
"""Subclass ordering regression guard — ``FileNotFoundError`` IS an
|
||||
``OSError`` subclass. If the bare-OSError handler ever moves above
|
||||
the FNF handler, the missing-artifact case would be mis-bucketed
|
||||
under ``reuse_copy_os_error``; this test pins the dispatch.
|
||||
"""
|
||||
runs_root = tmp_path / "runs"
|
||||
prev_run_id = "prev_run_fnf_ordering"
|
||||
prev_run_dir = runs_root / prev_run_id / "phase_z2"
|
||||
new_run_dir = tmp_path / "new" / "phase_z2"
|
||||
snap = _build_canonical_snapshot()
|
||||
_seed_prev_run_dir(prev_run_dir, snapshot=snap)
|
||||
(prev_run_dir / "steps" / "step05_v4_evidence.json").unlink()
|
||||
monkeypatch.setattr(_pz2, "RUNS_DIR", runs_root)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
_pz2.execute_reuse_from_or_fail_closed(
|
||||
reuse_from=prev_run_id,
|
||||
new_run_dir=new_run_dir,
|
||||
mdx_source_text=_mdx_text(),
|
||||
)
|
||||
err = capsys.readouterr().err
|
||||
assert "reuse_artifact_missing" in err
|
||||
assert "reuse_copy_os_error" not in err
|
||||
|
||||
|
||||
# -- snapshot_read_os_error axis (OSError != FileNotFoundError) ----------
|
||||
|
||||
|
||||
def test_snapshot_read_os_error_aborts_with_own_axis(
|
||||
tmp_path: Path, monkeypatch, capsys
|
||||
):
|
||||
"""OSError raised inside ``_load_and_validate_reuse_snapshot``
|
||||
(e.g. PermissionError on ``Path.read_text``, IsADirectoryError if
|
||||
the snapshot path resolves to a directory after copy) must
|
||||
translate to fail-closed instead of escaping as a raw traceback.
|
||||
"""
|
||||
runs_root = tmp_path / "runs"
|
||||
prev_run_id = "prev_run_snapshot_perm"
|
||||
prev_run_dir = runs_root / prev_run_id / "phase_z2"
|
||||
new_run_dir = tmp_path / "new" / "phase_z2"
|
||||
snap = _build_canonical_snapshot()
|
||||
_seed_prev_run_dir(prev_run_dir, snapshot=snap)
|
||||
monkeypatch.setattr(_pz2, "RUNS_DIR", runs_root)
|
||||
|
||||
def _raise_perm(*args, **kwargs):
|
||||
raise PermissionError("simulated read denied on snapshot")
|
||||
|
||||
monkeypatch.setattr(
|
||||
_pz2, "_load_and_validate_reuse_snapshot", _raise_perm
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit) as ei:
|
||||
_pz2.execute_reuse_from_or_fail_closed(
|
||||
reuse_from=prev_run_id,
|
||||
new_run_dir=new_run_dir,
|
||||
mdx_source_text=_mdx_text(),
|
||||
)
|
||||
assert ei.value.code == 2
|
||||
err = capsys.readouterr().err
|
||||
assert "snapshot_read_os_error" in err
|
||||
assert "value:" in err
|
||||
assert "path:" in err
|
||||
assert "upstream:" in err
|
||||
assert "reason:" in err
|
||||
assert "PermissionError" in err
|
||||
assert "simulated read denied on snapshot" in err
|
||||
# Must NOT be mis-bucketed as missing-after-copy or corrupt-json.
|
||||
assert "snapshot_missing_after_copy" not in err
|
||||
assert "snapshot_corrupt_json" not in err
|
||||
|
||||
|
||||
def test_snapshot_filenotfounderror_still_uses_missing_after_copy_axis(
|
||||
tmp_path: Path, monkeypatch, capsys
|
||||
):
|
||||
"""Subclass ordering regression guard for the load surface — FNF
|
||||
must keep its own ``snapshot_missing_after_copy`` axis even though
|
||||
the new bare-OSError branch sits below it.
|
||||
"""
|
||||
runs_root = tmp_path / "runs"
|
||||
prev_run_id = "prev_run_load_fnf_ordering"
|
||||
prev_run_dir = runs_root / prev_run_id / "phase_z2"
|
||||
new_run_dir = tmp_path / "new" / "phase_z2"
|
||||
snap = _build_canonical_snapshot()
|
||||
_seed_prev_run_dir(prev_run_dir, snapshot=snap)
|
||||
monkeypatch.setattr(_pz2, "RUNS_DIR", runs_root)
|
||||
|
||||
def _raise_fnf(*args, **kwargs):
|
||||
raise FileNotFoundError("simulated FNF on snapshot read")
|
||||
|
||||
monkeypatch.setattr(
|
||||
_pz2, "_load_and_validate_reuse_snapshot", _raise_fnf
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
_pz2.execute_reuse_from_or_fail_closed(
|
||||
reuse_from=prev_run_id,
|
||||
new_run_dir=new_run_dir,
|
||||
mdx_source_text=_mdx_text(),
|
||||
)
|
||||
err = capsys.readouterr().err
|
||||
assert "snapshot_missing_after_copy" in err
|
||||
assert "snapshot_read_os_error" not in err
|
||||
|
||||
|
||||
# -- module surface anchor ------------------------------------------------
|
||||
|
||||
|
||||
def test_pipeline_exposes_u4b_surface():
|
||||
"""u5 wires ``execute_reuse_from_or_fail_closed`` into the entry
|
||||
point — the public callable + the closed-axis vocabulary must
|
||||
remain module-level attributes."""
|
||||
for name in (
|
||||
"execute_reuse_from_or_fail_closed",
|
||||
"_abort_reuse_from",
|
||||
"_paths_equivalent",
|
||||
"REUSE_FAIL_CLOSED_AXES",
|
||||
):
|
||||
assert hasattr(_pz2, name), f"u4b surface missing: {name}"
|
||||
|
||||
|
||||
def test_pipeline_run_signature_reuse_from_threaded_after_u5():
|
||||
"""u5 has now threaded ``reuse_from`` into ``run_phase_z2_mvp1`` as
|
||||
a keyword-only parameter with default ``None``. The previous
|
||||
``until_u5`` lock has flipped — this forward-direction lock
|
||||
ensures the kwarg never silently drifts (positional promotion,
|
||||
default change to a string, kind change). Mirror of the
|
||||
equivalent lock in test_phase_z2_reuse_from_entry.py and
|
||||
test_phase_z2_cli_reuse_from.py — kept in this file too so the
|
||||
fail-closed regression suite is self-contained."""
|
||||
import inspect
|
||||
|
||||
sig = inspect.signature(_pz2.run_phase_z2_mvp1)
|
||||
assert "reuse_from" in sig.parameters, (
|
||||
"u5 must thread reuse_from into run_phase_z2_mvp1 — kwarg missing. "
|
||||
f"current params: {list(sig.parameters)}"
|
||||
)
|
||||
param = sig.parameters["reuse_from"]
|
||||
assert param.kind is inspect.Parameter.KEYWORD_ONLY, (
|
||||
f"reuse_from must be keyword-only (after the ``*`` barrier); "
|
||||
f"got kind={param.kind}"
|
||||
)
|
||||
assert param.default is None, (
|
||||
f"reuse_from must default to None to preserve pre-u5 behaviour; "
|
||||
f"got default={param.default!r}"
|
||||
)
|
||||
@@ -0,0 +1,493 @@
|
||||
"""IMP-43 (#72) u2 — unit tests for ``src.phase_z2_reuse_snapshot``.
|
||||
|
||||
Scope mirror of the production module (Stage 2 u2):
|
||||
|
||||
* ``build_snapshot`` shape, provenance, JSON round-trip, required keys.
|
||||
* ``serialize_section`` / ``serialize_unit`` field preservation, including
|
||||
the duck-typed ``v4_candidates`` shape (template_id / frame_id /
|
||||
frame_number / confidence / label).
|
||||
* ``validate_snapshot`` fail-closed paths: non-dict input, schema
|
||||
version mismatch, missing/empty/non-string ``mdx_sha256``, sha
|
||||
mismatch, missing required keys, unwrapped wrapper, wrapper missing
|
||||
a provenance field.
|
||||
* Module-level constants exposed for u3 / u4 / u4b consumers.
|
||||
|
||||
The tests use synthetic duck-typed dataclasses so the snapshot module's
|
||||
external surface is exercised without coupling to the production
|
||||
``MdxSection`` / ``CompositionUnit`` / ``V4Match`` dataclass layouts.
|
||||
That mirrors the production module's intentional duck-typing (no
|
||||
imports from ``phase_z2_pipeline`` / ``phase_z2_composition``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_reuse_snapshot import (
|
||||
REQUIRED_TOP_LEVEL_KEYS,
|
||||
SNAPSHOT_FILENAME,
|
||||
SNAPSHOT_VERSION,
|
||||
SnapshotValidationError,
|
||||
build_snapshot,
|
||||
serialize_section,
|
||||
serialize_unit,
|
||||
validate_snapshot,
|
||||
)
|
||||
|
||||
|
||||
# -- synthetic duck-typed inputs ------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Section:
|
||||
section_id: str
|
||||
section_num: int
|
||||
title: str
|
||||
raw_content: str
|
||||
heading_number: Optional[str] = None
|
||||
v4_alias_keys: list = field(default_factory=list)
|
||||
sub_sections: list = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _V4Candidate:
|
||||
template_id: str
|
||||
frame_id: str
|
||||
frame_number: int
|
||||
confidence: float
|
||||
label: str
|
||||
v4_rank: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Unit:
|
||||
source_section_ids: list
|
||||
merge_type: str
|
||||
frame_template_id: str
|
||||
frame_id: str
|
||||
frame_number: int
|
||||
confidence: float
|
||||
label: str
|
||||
phase_z_status: str
|
||||
raw_content: str
|
||||
title: str
|
||||
score: float
|
||||
v4_rank: Optional[int] = 1
|
||||
selection_path: str = "rank_1"
|
||||
fallback_reason: Optional[str] = None
|
||||
rationale: dict = field(default_factory=dict)
|
||||
auto_selectable: bool = True
|
||||
filter_reasons: list = field(default_factory=list)
|
||||
notes: list = field(default_factory=list)
|
||||
v4_candidates: list = field(default_factory=list)
|
||||
provisional: bool = False
|
||||
|
||||
|
||||
def _make_section(**overrides: Any) -> _Section:
|
||||
base = dict(
|
||||
section_id="03-1",
|
||||
section_num=1,
|
||||
title="DX status",
|
||||
raw_content="- bullet one\n- bullet two",
|
||||
)
|
||||
base.update(overrides)
|
||||
return _Section(**base)
|
||||
|
||||
|
||||
def _make_unit(**overrides: Any) -> _Unit:
|
||||
cand = _V4Candidate(
|
||||
template_id="tpl_a",
|
||||
frame_id="fid_a",
|
||||
frame_number=13,
|
||||
confidence=0.91,
|
||||
label="use_as_is",
|
||||
)
|
||||
base: dict[str, Any] = dict(
|
||||
source_section_ids=["03-1"],
|
||||
merge_type="single",
|
||||
frame_template_id="tpl_a",
|
||||
frame_id="fid_a",
|
||||
frame_number=13,
|
||||
confidence=0.91,
|
||||
label="use_as_is",
|
||||
phase_z_status="auto_renderable",
|
||||
raw_content="- bullet one\n- bullet two",
|
||||
title="DX status",
|
||||
score=0.91,
|
||||
v4_candidates=[cand],
|
||||
)
|
||||
base.update(overrides)
|
||||
return _Unit(**base)
|
||||
|
||||
|
||||
def _make_build_kwargs(**overrides: Any) -> dict[str, Any]:
|
||||
kwargs: dict[str, Any] = dict(
|
||||
mdx_sha256="a" * 64,
|
||||
slide_title="Title",
|
||||
slide_footer="Footer",
|
||||
sections=[_make_section()],
|
||||
stage0_adapter_diagnostics={"used": True, "fallback_reason": None},
|
||||
stage0_normalized_assets={"popups": [], "images": [], "tables": []},
|
||||
v4_evidence=[{"section_id": "03-1", "v4_candidates": []}],
|
||||
layout_preset_pre_override="horizontal-2",
|
||||
units=[_make_unit()],
|
||||
comp_debug={"v4_fallback_summary": {"fallback_used_count": 0}},
|
||||
v4_fallback_traces={"03-1": {"selection_path": "rank_1"}},
|
||||
ai_preflight={"enabled": False, "skipped": True},
|
||||
)
|
||||
kwargs.update(overrides)
|
||||
return kwargs
|
||||
|
||||
|
||||
# -- module constants -----------------------------------------------------
|
||||
|
||||
|
||||
def test_snapshot_filename_constant():
|
||||
assert SNAPSHOT_FILENAME == "_reuse_snapshot.json"
|
||||
|
||||
|
||||
def test_snapshot_version_is_positive_int():
|
||||
assert isinstance(SNAPSHOT_VERSION, int)
|
||||
assert SNAPSHOT_VERSION >= 1
|
||||
|
||||
|
||||
def test_required_keys_include_contract_and_payload():
|
||||
# Bare contract / integrity keys.
|
||||
assert "schema_version" in REQUIRED_TOP_LEVEL_KEYS
|
||||
assert "mdx_sha256" in REQUIRED_TOP_LEVEL_KEYS
|
||||
# Payload axes per Stage 2 plan.
|
||||
for k in (
|
||||
"slide_title",
|
||||
"slide_footer",
|
||||
"sections",
|
||||
"stage0_adapter_diagnostics",
|
||||
"stage0_normalized_assets",
|
||||
"v4_evidence",
|
||||
"layout_preset_pre_override",
|
||||
"units",
|
||||
"comp_debug",
|
||||
"v4_fallback_traces",
|
||||
"ai_preflight",
|
||||
):
|
||||
assert k in REQUIRED_TOP_LEVEL_KEYS, f"missing from REQUIRED_TOP_LEVEL_KEYS: {k}"
|
||||
|
||||
|
||||
# -- build_snapshot -------------------------------------------------------
|
||||
|
||||
|
||||
def test_build_snapshot_round_trips_through_json():
|
||||
snap = build_snapshot(**_make_build_kwargs())
|
||||
payload = json.dumps(snap)
|
||||
loaded = json.loads(payload)
|
||||
assert loaded["schema_version"] == SNAPSHOT_VERSION
|
||||
assert loaded["mdx_sha256"] == "a" * 64
|
||||
|
||||
|
||||
def test_build_snapshot_has_all_required_keys():
|
||||
snap = build_snapshot(**_make_build_kwargs())
|
||||
for key in REQUIRED_TOP_LEVEL_KEYS:
|
||||
assert key in snap, f"build_snapshot missing required key: {key}"
|
||||
|
||||
|
||||
def test_build_snapshot_bare_keys_are_unwrapped_scalars():
|
||||
snap = build_snapshot(**_make_build_kwargs())
|
||||
assert snap["schema_version"] == SNAPSHOT_VERSION
|
||||
assert snap["mdx_sha256"] == "a" * 64
|
||||
# bare keys MUST NOT be wrapped — u4b mdx_sha256 check reads directly.
|
||||
assert not isinstance(snap["schema_version"], dict)
|
||||
assert not isinstance(snap["mdx_sha256"], dict)
|
||||
|
||||
|
||||
def test_build_snapshot_provenance_wrapper_shape():
|
||||
snap = build_snapshot(**_make_build_kwargs())
|
||||
bare = {"schema_version", "mdx_sha256"}
|
||||
for key, entry in snap.items():
|
||||
if key in bare:
|
||||
continue
|
||||
assert isinstance(entry, dict), f"{key} is not wrapped"
|
||||
assert set(entry.keys()) == {"value", "source_path", "upstream_step"}, key
|
||||
assert isinstance(entry["source_path"], str) and entry["source_path"]
|
||||
assert isinstance(entry["upstream_step"], str)
|
||||
assert entry["upstream_step"].startswith("step"), entry["upstream_step"]
|
||||
|
||||
|
||||
def test_build_snapshot_upstream_steps_stay_inside_reuse_boundary():
|
||||
"""No ``upstream_step`` may point outside the Step 0/2/5/6 reuse
|
||||
boundary (Stage 1 root_cause). A drift to e.g. ``step09`` would
|
||||
silently invite work outside the reuse window — fail loudly.
|
||||
|
||||
Step 01's contribution is the ``mdx_sha256`` integrity key (a bare
|
||||
contract scalar with no wrapper) so step01 does not need to appear
|
||||
in payload provenance.
|
||||
"""
|
||||
snap = build_snapshot(**_make_build_kwargs())
|
||||
allowed = {"step00", "step02", "step05", "step06"}
|
||||
for key, entry in snap.items():
|
||||
if key in {"schema_version", "mdx_sha256"}:
|
||||
continue
|
||||
assert entry["upstream_step"] in allowed, (
|
||||
f"key {key!r}: upstream_step {entry['upstream_step']!r} outside reuse boundary"
|
||||
)
|
||||
|
||||
|
||||
def test_build_snapshot_units_carry_v4_candidates():
|
||||
snap = build_snapshot(**_make_build_kwargs())
|
||||
units = snap["units"]["value"]
|
||||
assert len(units) == 1
|
||||
assert units[0]["v4_candidates"][0]["template_id"] == "tpl_a"
|
||||
assert units[0]["v4_candidates"][0]["frame_number"] == 13
|
||||
assert units[0]["v4_candidates"][0]["confidence"] == pytest.approx(0.91)
|
||||
|
||||
|
||||
def test_build_snapshot_sections_preserve_alias_keys_and_subsections():
|
||||
sec = _make_section(
|
||||
section_id="04-2",
|
||||
v4_alias_keys=["04-2.1"],
|
||||
sub_sections=[{"id": "04-2-sub-1"}],
|
||||
heading_number="2.1",
|
||||
)
|
||||
snap = build_snapshot(**_make_build_kwargs(sections=[sec]))
|
||||
payload = snap["sections"]["value"]
|
||||
assert payload[0]["section_id"] == "04-2"
|
||||
assert payload[0]["v4_alias_keys"] == ["04-2.1"]
|
||||
assert payload[0]["sub_sections"] == [{"id": "04-2-sub-1"}]
|
||||
assert payload[0]["heading_number"] == "2.1"
|
||||
|
||||
|
||||
def test_build_snapshot_units_provenance_points_at_step06():
|
||||
snap = build_snapshot(**_make_build_kwargs())
|
||||
assert "step06_composition_plan.json" in snap["units"]["source_path"]
|
||||
assert snap["units"]["upstream_step"] == "step06"
|
||||
|
||||
|
||||
def test_build_snapshot_v4_evidence_provenance_points_at_step05():
|
||||
snap = build_snapshot(**_make_build_kwargs())
|
||||
assert "step05_v4_evidence.json" in snap["v4_evidence"]["source_path"]
|
||||
assert snap["v4_evidence"]["upstream_step"] == "step05"
|
||||
|
||||
|
||||
def test_build_snapshot_ai_preflight_provenance_points_at_step00():
|
||||
snap = build_snapshot(**_make_build_kwargs())
|
||||
assert "step00_preconditions.json" in snap["ai_preflight"]["source_path"]
|
||||
assert snap["ai_preflight"]["upstream_step"] == "step00"
|
||||
|
||||
|
||||
def test_build_snapshot_rejects_unjsonable_input():
|
||||
bad_unit = _make_unit()
|
||||
bad_unit.notes.append(object()) # not JSON-safe
|
||||
with pytest.raises(TypeError):
|
||||
build_snapshot(**_make_build_kwargs(units=[bad_unit]))
|
||||
|
||||
|
||||
def test_build_snapshot_handles_none_optional_fields():
|
||||
snap = build_snapshot(
|
||||
**_make_build_kwargs(
|
||||
slide_title=None,
|
||||
slide_footer=None,
|
||||
stage0_adapter_diagnostics=None,
|
||||
stage0_normalized_assets=None,
|
||||
comp_debug=None,
|
||||
v4_fallback_traces=None,
|
||||
ai_preflight=None,
|
||||
)
|
||||
)
|
||||
# None inputs land as None / {} consistently — never raise.
|
||||
assert snap["slide_title"]["value"] is None
|
||||
assert snap["slide_footer"]["value"] is None
|
||||
assert snap["stage0_adapter_diagnostics"]["value"] == {}
|
||||
assert snap["stage0_normalized_assets"]["value"] == {}
|
||||
assert snap["comp_debug"]["value"] == {}
|
||||
assert snap["v4_fallback_traces"]["value"] == {}
|
||||
assert snap["ai_preflight"]["value"] == {}
|
||||
|
||||
|
||||
# -- serializer helpers ---------------------------------------------------
|
||||
|
||||
|
||||
def test_serialize_section_preserves_all_documented_fields():
|
||||
sec = _make_section(
|
||||
heading_number="1.1",
|
||||
v4_alias_keys=["03-1.x"],
|
||||
sub_sections=[{"id": "s"}],
|
||||
)
|
||||
out = serialize_section(sec)
|
||||
assert out["section_id"] == "03-1"
|
||||
assert out["section_num"] == 1
|
||||
assert out["title"] == "DX status"
|
||||
assert out["raw_content"].startswith("- bullet")
|
||||
assert out["heading_number"] == "1.1"
|
||||
assert out["v4_alias_keys"] == ["03-1.x"]
|
||||
assert out["sub_sections"] == [{"id": "s"}]
|
||||
|
||||
|
||||
def test_serialize_section_works_with_missing_optional_attrs():
|
||||
class _Minimal:
|
||||
section_id = "x"
|
||||
section_num = 0
|
||||
title = "t"
|
||||
raw_content = "r"
|
||||
out = serialize_section(_Minimal())
|
||||
assert out["heading_number"] is None
|
||||
assert out["v4_alias_keys"] == []
|
||||
assert out["sub_sections"] == []
|
||||
|
||||
|
||||
def test_serialize_unit_v4_candidates_unwrap_to_named_attrs():
|
||||
unit = _make_unit()
|
||||
out = serialize_unit(unit)
|
||||
cand = out["v4_candidates"][0]
|
||||
assert cand == {
|
||||
"template_id": "tpl_a",
|
||||
"frame_id": "fid_a",
|
||||
"frame_number": 13,
|
||||
"confidence": pytest.approx(0.91),
|
||||
"label": "use_as_is",
|
||||
# u4 follow-up — Step 9 application-plan payload reads
|
||||
# ``c.v4_rank`` off each rehydrated candidate. Snapshot
|
||||
# serializer persists it via ``getattr(c, 'v4_rank', None)`` so
|
||||
# legacy duck types (no v4_rank attr) get None and modern V4Match
|
||||
# instances carry their rank (1/2/3/...).
|
||||
"v4_rank": None,
|
||||
}
|
||||
|
||||
|
||||
def test_serialize_unit_v4_candidates_persist_v4_rank_when_present():
|
||||
"""A v4_candidate with v4_rank=2 (V4Match-shape duck type) round-trips."""
|
||||
ranked_cand = _V4Candidate(
|
||||
template_id="tpl_b",
|
||||
frame_id="fid_b",
|
||||
frame_number=14,
|
||||
confidence=0.82,
|
||||
label="light_edit",
|
||||
v4_rank=2,
|
||||
)
|
||||
unit = _make_unit(v4_candidates=[ranked_cand])
|
||||
out = serialize_unit(unit)
|
||||
assert out["v4_candidates"][0]["v4_rank"] == 2
|
||||
|
||||
|
||||
def test_serialize_unit_handles_empty_v4_candidates():
|
||||
unit = _make_unit(v4_candidates=[])
|
||||
out = serialize_unit(unit)
|
||||
assert out["v4_candidates"] == []
|
||||
|
||||
|
||||
def test_serialize_unit_provisional_default_false():
|
||||
unit = _make_unit()
|
||||
assert serialize_unit(unit)["provisional"] is False
|
||||
|
||||
|
||||
def test_serialize_unit_provisional_true_preserved():
|
||||
unit = _make_unit(provisional=True)
|
||||
assert serialize_unit(unit)["provisional"] is True
|
||||
|
||||
|
||||
def test_serialize_unit_round_trips_through_json():
|
||||
out = serialize_unit(_make_unit())
|
||||
reloaded = json.loads(json.dumps(out))
|
||||
assert reloaded["source_section_ids"] == ["03-1"]
|
||||
assert reloaded["frame_template_id"] == "tpl_a"
|
||||
|
||||
|
||||
# -- validate_snapshot ----------------------------------------------------
|
||||
|
||||
|
||||
def test_validate_snapshot_accepts_well_formed():
|
||||
snap = build_snapshot(**_make_build_kwargs())
|
||||
validate_snapshot(snap, expected_mdx_sha256="a" * 64)
|
||||
|
||||
|
||||
def test_validate_snapshot_rejects_non_dict_input():
|
||||
with pytest.raises(SnapshotValidationError):
|
||||
validate_snapshot("not a dict", expected_mdx_sha256="a" * 64)
|
||||
|
||||
|
||||
def test_validate_snapshot_rejects_version_mismatch():
|
||||
snap = build_snapshot(**_make_build_kwargs())
|
||||
snap["schema_version"] = SNAPSHOT_VERSION + 999
|
||||
with pytest.raises(SnapshotValidationError) as exc:
|
||||
validate_snapshot(snap, expected_mdx_sha256="a" * 64)
|
||||
assert "schema_version" in str(exc.value)
|
||||
|
||||
|
||||
def test_validate_snapshot_rejects_missing_sha():
|
||||
snap = build_snapshot(**_make_build_kwargs())
|
||||
del snap["mdx_sha256"]
|
||||
with pytest.raises(SnapshotValidationError) as exc:
|
||||
validate_snapshot(snap, expected_mdx_sha256="a" * 64)
|
||||
assert "mdx_sha256" in str(exc.value)
|
||||
|
||||
|
||||
def test_validate_snapshot_rejects_empty_sha():
|
||||
snap = build_snapshot(**_make_build_kwargs())
|
||||
snap["mdx_sha256"] = ""
|
||||
with pytest.raises(SnapshotValidationError) as exc:
|
||||
validate_snapshot(snap, expected_mdx_sha256="a" * 64)
|
||||
assert "mdx_sha256" in str(exc.value)
|
||||
|
||||
|
||||
def test_validate_snapshot_rejects_non_string_sha():
|
||||
snap = build_snapshot(**_make_build_kwargs())
|
||||
snap["mdx_sha256"] = 12345
|
||||
with pytest.raises(SnapshotValidationError) as exc:
|
||||
validate_snapshot(snap, expected_mdx_sha256="a" * 64)
|
||||
assert "mdx_sha256" in str(exc.value)
|
||||
|
||||
|
||||
def test_validate_snapshot_rejects_sha_mismatch():
|
||||
snap = build_snapshot(**_make_build_kwargs())
|
||||
with pytest.raises(SnapshotValidationError) as exc:
|
||||
validate_snapshot(snap, expected_mdx_sha256="b" * 64)
|
||||
assert "mdx_sha256 mismatch" in str(exc.value)
|
||||
|
||||
|
||||
def test_validate_snapshot_rejects_missing_required_key():
|
||||
snap = build_snapshot(**_make_build_kwargs())
|
||||
del snap["units"]
|
||||
with pytest.raises(SnapshotValidationError) as exc:
|
||||
validate_snapshot(snap, expected_mdx_sha256="a" * 64)
|
||||
assert "units" in str(exc.value)
|
||||
|
||||
|
||||
def test_validate_snapshot_rejects_unwrapped_payload_key():
|
||||
snap = build_snapshot(**_make_build_kwargs())
|
||||
snap["units"] = "not a dict"
|
||||
with pytest.raises(SnapshotValidationError) as exc:
|
||||
validate_snapshot(snap, expected_mdx_sha256="a" * 64)
|
||||
assert "units" in str(exc.value)
|
||||
|
||||
|
||||
def test_validate_snapshot_rejects_wrapper_missing_value():
|
||||
snap = build_snapshot(**_make_build_kwargs())
|
||||
snap["units"] = {"source_path": "x", "upstream_step": "step06"}
|
||||
with pytest.raises(SnapshotValidationError) as exc:
|
||||
validate_snapshot(snap, expected_mdx_sha256="a" * 64)
|
||||
assert "value" in str(exc.value)
|
||||
|
||||
|
||||
def test_validate_snapshot_rejects_wrapper_missing_source_path():
|
||||
snap = build_snapshot(**_make_build_kwargs())
|
||||
snap["units"] = {"value": [], "upstream_step": "step06"}
|
||||
with pytest.raises(SnapshotValidationError) as exc:
|
||||
validate_snapshot(snap, expected_mdx_sha256="a" * 64)
|
||||
assert "source_path" in str(exc.value)
|
||||
|
||||
|
||||
def test_validate_snapshot_rejects_wrapper_missing_upstream_step():
|
||||
snap = build_snapshot(**_make_build_kwargs())
|
||||
snap["units"] = {"value": [], "source_path": "x"}
|
||||
with pytest.raises(SnapshotValidationError) as exc:
|
||||
validate_snapshot(snap, expected_mdx_sha256="a" * 64)
|
||||
assert "upstream_step" in str(exc.value)
|
||||
|
||||
|
||||
def test_validate_snapshot_error_subclasses_value_error():
|
||||
snap = build_snapshot(**_make_build_kwargs())
|
||||
snap["schema_version"] = 999
|
||||
# u4b will pre-catch SnapshotValidationError, but the broader
|
||||
# `except ValueError` net must still pick this up.
|
||||
with pytest.raises(ValueError):
|
||||
validate_snapshot(snap, expected_mdx_sha256="a" * 64)
|
||||
@@ -0,0 +1,282 @@
|
||||
"""IMP-43 (#72) u3 — focused tests for the Step 6 reuse snapshot writer.
|
||||
|
||||
u3 scope (per the Stage 2 Exit Report):
|
||||
|
||||
- ``_write_reuse_snapshot`` writes ``run_dir/_reuse_snapshot.json`` *after*
|
||||
the Step 6 artifact lands; failure WARNS and CONTINUES (the helper does
|
||||
NOT raise out of the main pipeline run).
|
||||
- The Step 6 artifact data dict records the run_dir-relative sidecar path
|
||||
as ``data.reuse_snapshot_path`` (additive informational field, always
|
||||
set to ``SNAPSHOT_FILENAME`` regardless of write success — u4 will
|
||||
fail-closed on missing / invalid sidecar via u2's ``validate_snapshot``).
|
||||
|
||||
The helper is tested in isolation (no full pipeline run) — pipeline call
|
||||
site presence is asserted structurally so we exercise behaviour without
|
||||
re-running Step 0~6 inside the test process. End-to-end equivalence under
|
||||
``--reuse-from`` is u7a / u7b scope.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import pytest
|
||||
|
||||
import src.phase_z2_pipeline as _pz2
|
||||
from src.phase_z2_reuse_snapshot import (
|
||||
SNAPSHOT_FILENAME,
|
||||
SNAPSHOT_VERSION,
|
||||
SnapshotValidationError,
|
||||
validate_snapshot,
|
||||
)
|
||||
|
||||
|
||||
# -- synthetic duck-typed inputs ------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Section:
|
||||
section_id: str
|
||||
section_num: int
|
||||
title: str
|
||||
raw_content: str
|
||||
heading_number: Optional[str] = None
|
||||
v4_alias_keys: list = field(default_factory=list)
|
||||
sub_sections: list = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _V4Candidate:
|
||||
template_id: str
|
||||
frame_id: str
|
||||
frame_number: int
|
||||
confidence: float
|
||||
label: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Unit:
|
||||
source_section_ids: list
|
||||
merge_type: str
|
||||
frame_template_id: str
|
||||
frame_id: str
|
||||
frame_number: int
|
||||
confidence: float
|
||||
label: str
|
||||
phase_z_status: str
|
||||
raw_content: str
|
||||
title: str
|
||||
score: float
|
||||
v4_rank: Optional[int] = 1
|
||||
selection_path: str = "rank_1"
|
||||
fallback_reason: Optional[str] = None
|
||||
rationale: dict = field(default_factory=dict)
|
||||
auto_selectable: bool = True
|
||||
filter_reasons: list = field(default_factory=list)
|
||||
notes: list = field(default_factory=list)
|
||||
v4_candidates: list = field(default_factory=list)
|
||||
provisional: bool = False
|
||||
|
||||
|
||||
def _make_kwargs(**overrides: Any) -> dict[str, Any]:
|
||||
cand = _V4Candidate(
|
||||
template_id="tpl_a",
|
||||
frame_id="fid_a",
|
||||
frame_number=13,
|
||||
confidence=0.91,
|
||||
label="use_as_is",
|
||||
)
|
||||
section = _Section(
|
||||
section_id="03-1",
|
||||
section_num=1,
|
||||
title="DX status",
|
||||
raw_content="- bullet one\n- bullet two",
|
||||
)
|
||||
unit = _Unit(
|
||||
source_section_ids=["03-1"],
|
||||
merge_type="single",
|
||||
frame_template_id="tpl_a",
|
||||
frame_id="fid_a",
|
||||
frame_number=13,
|
||||
confidence=0.91,
|
||||
label="use_as_is",
|
||||
phase_z_status="auto_renderable",
|
||||
raw_content="- bullet one\n- bullet two",
|
||||
title="DX status",
|
||||
score=0.91,
|
||||
v4_candidates=[cand],
|
||||
)
|
||||
kwargs: dict[str, Any] = dict(
|
||||
mdx_source_text="# Slide\n\n## 03-1 DX status\n\n- bullet one\n- bullet two\n",
|
||||
slide_title="Slide",
|
||||
slide_footer=None,
|
||||
sections=[section],
|
||||
stage0_adapter_diagnostics={"used": True, "fallback_reason": None},
|
||||
stage0_normalized_assets={"popups": [], "images": [], "tables": []},
|
||||
v4_evidence=[
|
||||
{
|
||||
"section_id": "03-1",
|
||||
"v4_candidates": [
|
||||
{
|
||||
"template_id": "tpl_a",
|
||||
"frame_id": "fid_a",
|
||||
"frame_number": 13,
|
||||
"confidence": 0.91,
|
||||
"label": "use_as_is",
|
||||
}
|
||||
],
|
||||
"candidate_status": "ok",
|
||||
}
|
||||
],
|
||||
layout_preset_pre_override="single",
|
||||
units=[unit],
|
||||
comp_debug={"v4_fallback_summary": {"fallback_used_count": 0}},
|
||||
v4_fallback_traces={"03-1": {"selection_path": "rank_1"}},
|
||||
ai_preflight={"enabled": False, "skipped": True},
|
||||
)
|
||||
kwargs.update(overrides)
|
||||
return kwargs
|
||||
|
||||
|
||||
# -- success path ---------------------------------------------------------
|
||||
|
||||
|
||||
def test_writes_snapshot_file_at_run_dir_root(tmp_path: Path):
|
||||
rv = _pz2._write_reuse_snapshot(tmp_path, **_make_kwargs())
|
||||
assert rv == SNAPSHOT_FILENAME
|
||||
fpath = tmp_path / SNAPSHOT_FILENAME
|
||||
assert fpath.exists(), f"snapshot not written at {fpath}"
|
||||
|
||||
|
||||
def test_written_snapshot_validates(tmp_path: Path):
|
||||
kwargs = _make_kwargs()
|
||||
rv = _pz2._write_reuse_snapshot(tmp_path, **kwargs)
|
||||
assert rv == SNAPSHOT_FILENAME
|
||||
snap = json.loads((tmp_path / SNAPSHOT_FILENAME).read_text(encoding="utf-8"))
|
||||
|
||||
# mdx_sha256 is derived from mdx_source_text — recompute to verify
|
||||
# the helper is hashing the UTF-8 bytes of the same source we passed.
|
||||
import hashlib as _hl
|
||||
|
||||
expected_sha = _hl.sha256(
|
||||
kwargs["mdx_source_text"].encode("utf-8")
|
||||
).hexdigest()
|
||||
validate_snapshot(snap, expected_mdx_sha256=expected_sha)
|
||||
|
||||
|
||||
def test_snapshot_has_correct_schema_version(tmp_path: Path):
|
||||
_pz2._write_reuse_snapshot(tmp_path, **_make_kwargs())
|
||||
snap = json.loads((tmp_path / SNAPSHOT_FILENAME).read_text(encoding="utf-8"))
|
||||
assert snap["schema_version"] == SNAPSHOT_VERSION
|
||||
|
||||
|
||||
def test_snapshot_records_layout_preset_pre_override(tmp_path: Path):
|
||||
_pz2._write_reuse_snapshot(
|
||||
tmp_path, **_make_kwargs(layout_preset_pre_override="horizontal-2")
|
||||
)
|
||||
snap = json.loads((tmp_path / SNAPSHOT_FILENAME).read_text(encoding="utf-8"))
|
||||
assert snap["layout_preset_pre_override"]["value"] == "horizontal-2"
|
||||
|
||||
|
||||
def test_snapshot_is_utf8_encoded_with_non_ascii_content(tmp_path: Path):
|
||||
_pz2._write_reuse_snapshot(
|
||||
tmp_path,
|
||||
**_make_kwargs(
|
||||
slide_title="설계 방식의 왜곡",
|
||||
mdx_source_text="# 설계 방식\n\n- 한글 bullet\n",
|
||||
),
|
||||
)
|
||||
# ensure_ascii=False is intentional so Korean text round-trips
|
||||
# readable; if a future refactor drops it the bytes change but the
|
||||
# JSON still parses — we assert the file is decodable AS utf-8 and
|
||||
# the value survives the round trip.
|
||||
raw = (tmp_path / SNAPSHOT_FILENAME).read_text(encoding="utf-8")
|
||||
snap = json.loads(raw)
|
||||
assert snap["slide_title"]["value"] == "설계 방식의 왜곡"
|
||||
|
||||
|
||||
# -- failure path ---------------------------------------------------------
|
||||
|
||||
|
||||
def test_failure_warns_and_returns_none(tmp_path: Path, monkeypatch, capsys):
|
||||
"""When ``build_snapshot`` raises, the helper must NOT propagate the
|
||||
exception — it WARNS on stderr and returns ``None`` so the main
|
||||
pipeline run continues."""
|
||||
|
||||
def _boom(**_kwargs):
|
||||
raise RuntimeError("synthetic build failure")
|
||||
|
||||
monkeypatch.setattr(_pz2, "build_snapshot", _boom)
|
||||
|
||||
rv = _pz2._write_reuse_snapshot(tmp_path, **_make_kwargs())
|
||||
|
||||
assert rv is None
|
||||
captured = capsys.readouterr()
|
||||
assert "reuse-snapshot" in captured.err
|
||||
assert "WARN" in captured.err
|
||||
assert "RuntimeError" in captured.err
|
||||
# File MUST NOT exist on failure (no partial JSON on disk).
|
||||
assert not (tmp_path / SNAPSHOT_FILENAME).exists()
|
||||
|
||||
|
||||
def test_failure_on_unwritable_run_dir_warns_and_returns_none(
|
||||
tmp_path: Path, monkeypatch, capsys
|
||||
):
|
||||
"""Simulate disk write failure: helper warns + returns None, never
|
||||
raises out to the caller (Stage 2 guardrail: optional sidecar)."""
|
||||
nonexistent = tmp_path / "does" / "not" / "exist"
|
||||
# nonexistent.exists() is False — Path.write_text raises FileNotFoundError.
|
||||
|
||||
rv = _pz2._write_reuse_snapshot(nonexistent, **_make_kwargs())
|
||||
|
||||
assert rv is None
|
||||
captured = capsys.readouterr()
|
||||
assert "reuse-snapshot" in captured.err
|
||||
assert "WARN" in captured.err
|
||||
# FileNotFoundError specifically — sanity-check the type surfaces in
|
||||
# the warning so debugging is not blind.
|
||||
assert "FileNotFoundError" in captured.err
|
||||
|
||||
|
||||
# -- pipeline integration anchors -----------------------------------------
|
||||
|
||||
|
||||
def test_pipeline_imports_helper_and_constant():
|
||||
"""The pipeline module must expose the helper for the post-Step-6
|
||||
call site, and the constant must round-trip from the snapshot
|
||||
module (single source of truth)."""
|
||||
assert hasattr(_pz2, "_write_reuse_snapshot")
|
||||
assert callable(_pz2._write_reuse_snapshot)
|
||||
assert _pz2.SNAPSHOT_FILENAME == "_reuse_snapshot.json"
|
||||
|
||||
|
||||
def test_pipeline_call_site_follows_step06_artifact_write():
|
||||
"""Structural guard: the helper must be invoked AFTER the Step 6
|
||||
artifact write in ``run_phase_z2_mvp1`` so the sidecar lands next
|
||||
to ``steps/step06_composition_plan.json`` (Stage 2 spec)."""
|
||||
source = Path(_pz2.__file__).read_text(encoding="utf-8")
|
||||
# Locate the step06 artifact write call site by its locked name arg.
|
||||
step06_marker = '6, "composition_plan"'
|
||||
idx_step06 = source.find(step06_marker)
|
||||
assert idx_step06 != -1, "step06 artifact write call site missing"
|
||||
# The helper call must appear AFTER the step06 marker.
|
||||
idx_helper = source.find("_write_reuse_snapshot(", idx_step06)
|
||||
assert idx_helper != -1, "u3 helper call missing after step06 write"
|
||||
|
||||
|
||||
def test_pipeline_step06_artifact_data_records_snapshot_path():
|
||||
"""Structural guard: the Step 6 artifact data dict must include the
|
||||
``reuse_snapshot_path`` field so a future ``--reuse-from`` consumer
|
||||
can locate the expected sidecar via the canonical step artifact
|
||||
(Stage 2 spec — informational; absence of the file is u4's
|
||||
fail-closed concern)."""
|
||||
source = Path(_pz2.__file__).read_text(encoding="utf-8")
|
||||
step06_marker = '6, "composition_plan"'
|
||||
idx_step06 = source.find(step06_marker)
|
||||
assert idx_step06 != -1
|
||||
# Search a generous window after the marker for the field key.
|
||||
window = source[idx_step06 : idx_step06 + 8000]
|
||||
assert '"reuse_snapshot_path"' in window
|
||||
assert "SNAPSHOT_FILENAME" in window
|
||||
@@ -0,0 +1,101 @@
|
||||
"""IMP-45 (#74) u6 — subprocess smoke for the slide-level CSS override axis.
|
||||
|
||||
End-to-end guard that the ``slide_overrides.css`` frontmatter axis added
|
||||
in u2 propagates through the pipeline (u4) and lands in ``final.html``
|
||||
via :func:`src.slide_css_injector.inject_slide_css` (u3).
|
||||
|
||||
The fixture is the new ``slide_overrides.css`` frontmatter block in
|
||||
``samples/mdx_batch/04.mdx`` (u6 migration of the legacy frontend-only
|
||||
``MDX04_DEFAULT_OVERRIDE_CSS`` constant). The pipeline is spawned via
|
||||
``python -m src.phase_z2_pipeline 04.mdx <run_id>`` so the assertion
|
||||
applies to the on-disk artifact CI / CLI / regression all observe, not
|
||||
to a live iframe view.
|
||||
|
||||
The subprocess returncode is intentionally NOT asserted: mdx04 has known
|
||||
downstream issues (see ``test_pipeline_smoke_imp85.py`` —
|
||||
``test_mdx04_no_longer_emits_imp85_crash_signature``) that are tracked
|
||||
on a separate axis. Step 13 runs before the downstream failure surface,
|
||||
so ``final.html`` is written with the injected slide CSS marker
|
||||
regardless. The test asserts ``final.html`` exists and contains both
|
||||
the IMP-45 sentinel marker and a distinctive CSS substring from the
|
||||
migrated frontmatter block.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
SAMPLES_DIR = REPO_ROOT / "samples" / "mdx_batch"
|
||||
RUNS_DIR = REPO_ROOT / "data" / "runs"
|
||||
|
||||
# IMP-45 (#74) u3 marker sentinel emitted by
|
||||
# :func:`src.slide_css_injector.inject_slide_css` around the injected
|
||||
# ``<style>`` block. Match the ``_IMP45_STYLE_MARKER_OPEN`` constant in
|
||||
# ``src/slide_css_injector.py`` byte-for-byte.
|
||||
IMP45_OPEN_MARKER = "<!--IMP45-SLIDE-CSS:OPEN-->"
|
||||
IMP45_CLOSE_MARKER = "<!--IMP45-SLIDE-CSS:CLOSE-->"
|
||||
|
||||
# Distinctive substring from the migrated frontmatter block in
|
||||
# ``samples/mdx_batch/04.mdx``. ``f29b__cell:nth-child(n+3)`` is unique
|
||||
# to the MDX04 slide-level override CSS and does not appear elsewhere in
|
||||
# the slide_base / partial templates, so its presence in ``final.html``
|
||||
# is sufficient evidence that the frontmatter axis fed the injector.
|
||||
MDX04_DISTINCTIVE_CSS_SUBSTRING = ".f29b__cell:nth-child(n+3)"
|
||||
|
||||
|
||||
def _run_pipeline(mdx_name: str, run_id: str, timeout: int = 240) -> subprocess.CompletedProcess:
|
||||
"""Spawn ``python -m src.phase_z2_pipeline <mdx> <run_id>``."""
|
||||
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}_imp45_slide_css_smoke_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
def test_mdx04_slide_overrides_css_lands_in_final_html() -> None:
|
||||
"""mdx04 ``slide_overrides.css`` frontmatter must reach ``final.html``.
|
||||
|
||||
Contract pins both the IMP-45 marker sentinel and a distinctive CSS
|
||||
substring from the migrated frontmatter block so a regression in
|
||||
either the frontmatter extractor (u2), the kwarg forwarding (u4),
|
||||
or the injector (u3) is caught by this single smoke.
|
||||
"""
|
||||
run_id = _unique_run_id("mdx04")
|
||||
cp = _run_pipeline("04.mdx", run_id)
|
||||
|
||||
final_html_path = RUNS_DIR / run_id / "phase_z2" / "final.html"
|
||||
assert final_html_path.is_file(), (
|
||||
f"final.html not found at {final_html_path}\n"
|
||||
f"--- stderr tail ---\n{cp.stderr[-1500:]}\n"
|
||||
f"--- stdout tail ---\n{cp.stdout[-1500:]}"
|
||||
)
|
||||
html = final_html_path.read_text(encoding="utf-8")
|
||||
|
||||
assert IMP45_OPEN_MARKER in html, (
|
||||
f"IMP-45 open marker missing from mdx04 final.html ({final_html_path}).\n"
|
||||
f"slide_overrides.css frontmatter axis did not reach the injector."
|
||||
)
|
||||
assert IMP45_CLOSE_MARKER in html, (
|
||||
f"IMP-45 close marker missing from mdx04 final.html ({final_html_path}).\n"
|
||||
f"Marker wrap appears unbalanced — check inject_slide_css() output."
|
||||
)
|
||||
assert MDX04_DISTINCTIVE_CSS_SUBSTRING in html, (
|
||||
f"Migrated frontmatter CSS substring "
|
||||
f"{MDX04_DISTINCTIVE_CSS_SUBSTRING!r} missing from mdx04 final.html "
|
||||
f"({final_html_path}). The slide_overrides.css block did not propagate."
|
||||
)
|
||||
@@ -0,0 +1,216 @@
|
||||
"""IMP-56 (#90) u7 — Step 12 ``structure_overrides`` apply unit tests.
|
||||
|
||||
Synthetic — exercises ``_apply_structure_overrides_to_zones`` directly
|
||||
without running the full Phase Z 22-step pipeline. The helper is
|
||||
decoupled from ``MdxSection`` / ``CompositionUnit`` graphs and only
|
||||
consumes a minimal ``[{position, slot_payload}, ...]`` zone list, so a
|
||||
synthetic fixture is sufficient to lock the contract.
|
||||
|
||||
Coverage axes (Stage 2 plan u7 + Stage 1 binding contract) :
|
||||
|
||||
- reorder happy path : ``slot_order`` partial reorder mutates
|
||||
``zone['slot_payload']`` key order in place (caller reference stays
|
||||
valid via clear+update rebuild contract documented at u6).
|
||||
- hide happy path : ``hidden_slots`` pops the listed keys.
|
||||
- stale slot_key : absent slot_keys silently no-op (count toward
|
||||
``skipped_zones`` if the whole override produces no mutation).
|
||||
- SCOPE LOCK : frame-swap-shaped inner keys (``frame_id``,
|
||||
``template_id``) are dropped by the u6 validate gate and therefore
|
||||
never reach the apply path here.
|
||||
- raw_content preservation : per-slot ``list[str]`` line content
|
||||
untouched; out-of-band sentinels (mirror of ``debug_zones`` graph)
|
||||
stay byte-identical.
|
||||
- audit shape : ``applied_zones`` / ``skipped_zones`` / ``per_zone``
|
||||
keys present and counts consistent with the input batch.
|
||||
- empty / ``None`` batch is a no-op (empty audit).
|
||||
|
||||
Fully synthetic per Codex generalization guardrail (MOCK_ prefix).
|
||||
NO real catalog template_id / frame_id, NO ``v4_full32_result.yaml``
|
||||
dependency.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_pipeline import _apply_structure_overrides_to_zones
|
||||
|
||||
|
||||
# ─── Synthetic fixture helpers ──────────────────────────────────────
|
||||
|
||||
|
||||
def _zone(position: str, slot_payload: dict) -> dict:
|
||||
"""Minimal zone dict mirroring the Step 12 ``zones_data[i]`` shape."""
|
||||
return {
|
||||
"position": position,
|
||||
"template_id": "MOCK_T_phase_z2_structure_overrides",
|
||||
"slot_payload": slot_payload,
|
||||
"content_weight": 1.0,
|
||||
"min_height_px": 200,
|
||||
}
|
||||
|
||||
|
||||
# ─── Case 1 : reorder happy path ──────────────────────────────────────
|
||||
|
||||
|
||||
def test_apply_reorders_slot_payload_keys_in_place():
|
||||
payload = {"slot_a": ["A"], "slot_b": ["B"], "slot_c": ["C"]}
|
||||
zones = [_zone("top", payload)]
|
||||
overrides = {"top": {"slot_order": ["slot_c", "slot_a"]}}
|
||||
|
||||
audit = _apply_structure_overrides_to_zones(overrides, zones)
|
||||
|
||||
# Caller reference (payload) stays valid via clear+update rebuild.
|
||||
assert zones[0]["slot_payload"] is payload
|
||||
assert list(payload.keys()) == ["slot_c", "slot_a", "slot_b"]
|
||||
# Per-slot list[str] content untouched (raw_content invariant).
|
||||
assert payload["slot_a"] == ["A"]
|
||||
assert payload["slot_b"] == ["B"]
|
||||
assert payload["slot_c"] == ["C"]
|
||||
assert audit["applied_zones"] == 1
|
||||
assert audit["skipped_zones"] == 0
|
||||
assert audit["per_zone"] == [{"position": "top", "mutated": True}]
|
||||
|
||||
|
||||
# ─── Case 2 : hide happy path ─────────────────────────────────────────
|
||||
|
||||
|
||||
def test_apply_hides_listed_slot_keys():
|
||||
payload = {"slot_a": ["A"], "slot_b": ["B"], "slot_c": ["C"]}
|
||||
zones = [_zone("top", payload)]
|
||||
overrides = {"top": {"hidden_slots": ["slot_b"]}}
|
||||
|
||||
audit = _apply_structure_overrides_to_zones(overrides, zones)
|
||||
|
||||
assert "slot_b" not in payload
|
||||
assert list(payload.keys()) == ["slot_a", "slot_c"]
|
||||
assert audit["applied_zones"] == 1
|
||||
assert audit["per_zone"] == [{"position": "top", "mutated": True}]
|
||||
|
||||
|
||||
# ─── Case 3 : stale slot_key — frame swap / layout regression ─────────
|
||||
|
||||
|
||||
def test_stale_slot_key_silently_no_op():
|
||||
"""Absent slot_keys produce no mutation; the zone counts toward skipped_zones."""
|
||||
payload = {"slot_a": ["A"]}
|
||||
zones = [_zone("top", payload)]
|
||||
overrides = {
|
||||
"top": {
|
||||
"hidden_slots": ["slot_missing"], # absent — no-op
|
||||
"slot_order": ["slot_also_missing"], # absent — no-op
|
||||
},
|
||||
}
|
||||
|
||||
audit = _apply_structure_overrides_to_zones(overrides, zones)
|
||||
|
||||
assert list(payload.keys()) == ["slot_a"]
|
||||
assert audit["applied_zones"] == 0
|
||||
assert audit["skipped_zones"] == 1
|
||||
assert audit["per_zone"] == [{"position": "top", "mutated": False}]
|
||||
|
||||
|
||||
# ─── Case 4 : SCOPE LOCK — frame swap shape dropped at validate ───────
|
||||
|
||||
|
||||
def test_frame_swap_keys_dropped_at_validate_no_mutation():
|
||||
payload = {"slot_a": ["A"], "slot_b": ["B"]}
|
||||
zones = [_zone("top", payload)]
|
||||
# frame_id / template_id / slot_payload as inner keys are the canonical
|
||||
# frame-swap / DOM-rebuild shapes the SCOPE LOCK rejects.
|
||||
overrides = {
|
||||
"top": {
|
||||
"frame_id": "MOCK_OTHER_FRAME",
|
||||
"template_id": "MOCK_OTHER_TEMPLATE",
|
||||
"slot_payload": {"slot_a": ["overwritten"]},
|
||||
},
|
||||
}
|
||||
|
||||
audit = _apply_structure_overrides_to_zones(overrides, zones)
|
||||
|
||||
# No mutation: validate gate drops the whole zone payload (no allowed
|
||||
# inner key remains), so the zone never reaches the apply loop.
|
||||
assert payload == {"slot_a": ["A"], "slot_b": ["B"]}
|
||||
assert audit["applied_zones"] == 0
|
||||
assert audit["skipped_zones"] == 0
|
||||
assert audit["per_zone"] == []
|
||||
|
||||
|
||||
# ─── Case 5 : raw_content preservation invariant ──────────────────────
|
||||
|
||||
|
||||
def test_raw_content_sentinel_untouched():
|
||||
"""Helper must not mutate anything outside zone['slot_payload']
|
||||
AND must not mutate per-slot list[str] line content."""
|
||||
raw_sentinel = ["MOCK_S1", "MOCK_S2"]
|
||||
payload = {"slot_a": ["line 1", "line 2"], "slot_b": ["line 3"]}
|
||||
zones = [_zone("top", payload)]
|
||||
zones[0]["source_section_ids_sentinel"] = raw_sentinel # out-of-band
|
||||
zones[0]["raw_content_sentinel"] = "- line 1\n- line 2\n"
|
||||
|
||||
_apply_structure_overrides_to_zones(
|
||||
{"top": {"slot_order": ["slot_b", "slot_a"]}}, zones,
|
||||
)
|
||||
|
||||
# Out-of-band fields untouched.
|
||||
assert zones[0]["source_section_ids_sentinel"] is raw_sentinel
|
||||
assert zones[0]["source_section_ids_sentinel"] == ["MOCK_S1", "MOCK_S2"]
|
||||
assert zones[0]["raw_content_sentinel"] == "- line 1\n- line 2\n"
|
||||
# Per-slot list[str] line content byte-identical.
|
||||
assert payload["slot_a"] == ["line 1", "line 2"]
|
||||
assert payload["slot_b"] == ["line 3"]
|
||||
|
||||
|
||||
# ─── Case 6 : empty / None / irrelevant batch is no-op ────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"batch",
|
||||
[
|
||||
None,
|
||||
{},
|
||||
{"top": {}}, # empty per-zone
|
||||
{"missing_zone": {"slot_order": ["slot_a"]}}, # stale zone_id
|
||||
],
|
||||
)
|
||||
def test_empty_or_irrelevant_batch_is_noop(batch):
|
||||
payload = {"slot_a": ["A"]}
|
||||
zones = [_zone("top", payload)]
|
||||
audit = _apply_structure_overrides_to_zones(batch, zones)
|
||||
|
||||
assert list(payload.keys()) == ["slot_a"]
|
||||
assert audit["applied_zones"] == 0
|
||||
assert audit["skipped_zones"] == 0
|
||||
assert audit["per_zone"] == []
|
||||
|
||||
|
||||
# ─── Case 7 : zone without slot_payload skipped (defensive) ───────────
|
||||
|
||||
|
||||
def test_zone_without_slot_payload_skipped():
|
||||
zones = [{"position": "top"}] # no slot_payload key (defensive contract)
|
||||
audit = _apply_structure_overrides_to_zones(
|
||||
{"top": {"slot_order": ["slot_a"]}}, zones,
|
||||
)
|
||||
assert audit["per_zone"] == []
|
||||
assert audit["applied_zones"] == 0
|
||||
assert audit["skipped_zones"] == 0
|
||||
|
||||
|
||||
# ─── Case 8 : combined reorder + hide in a single zone ────────────────
|
||||
|
||||
|
||||
def test_combined_reorder_and_hide_in_one_zone():
|
||||
payload = {"slot_a": ["A"], "slot_b": ["B"], "slot_c": ["C"]}
|
||||
zones = [_zone("top", payload)]
|
||||
overrides = {
|
||||
"top": {
|
||||
"hidden_slots": ["slot_b"],
|
||||
"slot_order": ["slot_c", "slot_a"],
|
||||
},
|
||||
}
|
||||
|
||||
audit = _apply_structure_overrides_to_zones(overrides, zones)
|
||||
|
||||
assert list(payload.keys()) == ["slot_c", "slot_a"]
|
||||
assert audit["applied_zones"] == 1
|
||||
assert audit["per_zone"] == [{"position": "top", "mutated": True}]
|
||||
@@ -0,0 +1,158 @@
|
||||
"""IMP-56 (#90) u5 — Step 12 ``text_overrides`` apply unit tests.
|
||||
|
||||
Synthetic — exercises ``_apply_text_overrides_to_zones`` directly without
|
||||
running the full Phase Z 22-step pipeline. The helper is decoupled from
|
||||
``MdxSection`` / ``CompositionUnit`` graphs and only consumes a minimal
|
||||
``[{position, slot_payload}, ...]`` zone list, so a synthetic fixture is
|
||||
sufficient to lock the contract.
|
||||
|
||||
Coverage axes (Stage 2 plan u5 + Stage 1 binding contract) :
|
||||
|
||||
- sanitized batch : malformed text_path / non-string value drops per-row
|
||||
(mirrors ``image_id_stamper.build_image_overrides_style`` u7 tolerance).
|
||||
- stale path : frame swap / layout regression → ``skipped``, not error.
|
||||
- raw_content preservation : helper never touches ``debug_zones`` / unit
|
||||
graph (asserted by zero-mutation on an out-of-band sentinel mapping).
|
||||
- audit shape : ``applied`` / ``skipped`` / ``per_zone`` keys present and
|
||||
counts consistent with the input batch.
|
||||
- empty / ``None`` override input is a no-op (empty audit).
|
||||
|
||||
Fully synthetic per Codex generalization guardrail (MOCK_ prefix).
|
||||
NO real catalog template_id / frame_id, NO ``v4_full32_result.yaml``
|
||||
dependency.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_pipeline import _apply_text_overrides_to_zones
|
||||
|
||||
|
||||
# ─── Synthetic fixture helpers ──────────────────────────────────────
|
||||
|
||||
|
||||
def _zone(position: str, slot_payload: dict) -> dict:
|
||||
"""Minimal zone dict mirroring the Step 12 ``zones_data[i]`` shape."""
|
||||
return {
|
||||
"position": position,
|
||||
"template_id": "MOCK_T_phase_z2_text_overrides",
|
||||
"slot_payload": slot_payload,
|
||||
"content_weight": 1.0,
|
||||
"min_height_px": 200,
|
||||
}
|
||||
|
||||
|
||||
# ─── Case 1 : happy path — list[str] slot mutation ────────────────────
|
||||
|
||||
|
||||
def test_apply_replaces_list_line_in_place():
|
||||
zones = [
|
||||
_zone("top", {"slot_title": ["original headline"], "slot_body": ["a", "b", "c"]}),
|
||||
_zone("bottom", {"slot_caption": ["caption A"]}),
|
||||
]
|
||||
overrides = {
|
||||
"top": {"slot_title.0": "edited headline", "slot_body.1": "edited B"},
|
||||
}
|
||||
|
||||
audit = _apply_text_overrides_to_zones(overrides, zones)
|
||||
|
||||
assert zones[0]["slot_payload"]["slot_title"] == ["edited headline"]
|
||||
assert zones[0]["slot_payload"]["slot_body"] == ["a", "edited B", "c"]
|
||||
# bottom zone untouched (no override entry)
|
||||
assert zones[1]["slot_payload"]["slot_caption"] == ["caption A"]
|
||||
assert audit["applied"] == 2
|
||||
assert audit["skipped"] == 0
|
||||
assert audit["per_zone"] == [{"position": "top", "applied": 2, "skipped": 0}]
|
||||
|
||||
|
||||
# ─── Case 2 : stale text_path — frame swap / layout regression ────────
|
||||
|
||||
|
||||
def test_stale_text_path_skipped_silently():
|
||||
"""Absent slot_key + out-of-range line_index both count as skipped, not errors."""
|
||||
zones = [_zone("top", {"slot_title": ["only one line"]})]
|
||||
overrides = {
|
||||
"top": {
|
||||
"slot_title.0": "ok", # applied
|
||||
"slot_title.99": "out of range", # skipped (idx > len)
|
||||
"slot_missing.0": "stale frame", # skipped (slot absent)
|
||||
},
|
||||
}
|
||||
|
||||
audit = _apply_text_overrides_to_zones(overrides, zones)
|
||||
|
||||
assert zones[0]["slot_payload"]["slot_title"] == ["ok"]
|
||||
assert "slot_missing" not in zones[0]["slot_payload"]
|
||||
assert audit["applied"] == 1
|
||||
assert audit["skipped"] == 2
|
||||
assert audit["per_zone"][0] == {"position": "top", "applied": 1, "skipped": 2}
|
||||
|
||||
|
||||
# ─── Case 3 : malformed input — per-entry tolerance ────────────────────
|
||||
|
||||
|
||||
def test_malformed_entries_dropped_in_validate():
|
||||
"""Non-string value / malformed text_path drop in ``validate_text_overrides``."""
|
||||
zones = [_zone("top", {"slot_title": ["original", "second"]})]
|
||||
overrides = {
|
||||
"top": {
|
||||
"slot_title.0": "good", # kept
|
||||
"slot_title.bad": "ignored", # dropped (non-int idx)
|
||||
"slot_title.1": 123, # dropped (non-str value)
|
||||
"no_dot": "ignored", # dropped (missing '.')
|
||||
},
|
||||
"": {"slot_title.0": "empty zone id dropped"}, # zone_id sanitization
|
||||
123: {"slot_title.0": "non-string zone id dropped"},
|
||||
}
|
||||
|
||||
audit = _apply_text_overrides_to_zones(overrides, zones)
|
||||
|
||||
# only the well-formed entry applied
|
||||
assert zones[0]["slot_payload"]["slot_title"] == ["good", "second"]
|
||||
assert audit["applied"] == 1
|
||||
assert audit["skipped"] == 0
|
||||
|
||||
|
||||
# ─── Case 4 : raw_content preservation invariant ──────────────────────
|
||||
|
||||
|
||||
def test_raw_content_sentinel_untouched():
|
||||
"""Helper must not mutate anything outside ``zone['slot_payload']``.
|
||||
|
||||
Out-of-band fields (mirror of ``debug_zones[i].source_section_ids`` /
|
||||
MdxSection graph) stay byte-identical — Stage 1 binding contract.
|
||||
"""
|
||||
raw_sentinel = ["MOCK_S1", "MOCK_S2"]
|
||||
zones = [_zone("top", {"slot_title": ["original"]})]
|
||||
zones[0]["source_section_ids_sentinel"] = raw_sentinel # out-of-band
|
||||
zones[0]["raw_content_sentinel"] = "- original bullet\n- second\n"
|
||||
|
||||
_apply_text_overrides_to_zones({"top": {"slot_title.0": "edited"}}, zones)
|
||||
|
||||
assert zones[0]["source_section_ids_sentinel"] is raw_sentinel # same object
|
||||
assert zones[0]["source_section_ids_sentinel"] == ["MOCK_S1", "MOCK_S2"]
|
||||
assert zones[0]["raw_content_sentinel"] == "- original bullet\n- second\n"
|
||||
|
||||
|
||||
# ─── Case 5 : empty / None batch is no-op ──────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload", [None, {}, {"top": {}}, {"missing_zone": {"slot.0": "x"}}])
|
||||
def test_empty_or_irrelevant_batch_is_noop(payload):
|
||||
zones = [_zone("top", {"slot_title": ["unchanged"]})]
|
||||
audit = _apply_text_overrides_to_zones(payload, zones)
|
||||
|
||||
assert zones[0]["slot_payload"]["slot_title"] == ["unchanged"]
|
||||
assert audit["applied"] == 0
|
||||
assert audit["skipped"] == 0
|
||||
|
||||
|
||||
# ─── Case 6 : zone without slot_payload skipped (defensive) ───────────
|
||||
|
||||
|
||||
def test_zone_without_slot_payload_skipped():
|
||||
zones = [{"position": "top"}] # no slot_payload key (defensive contract)
|
||||
audit = _apply_text_overrides_to_zones({"top": {"slot.0": "x"}}, zones)
|
||||
assert audit["per_zone"] == []
|
||||
assert audit["applied"] == 0
|
||||
assert audit["skipped"] == 0
|
||||
@@ -0,0 +1,212 @@
|
||||
"""IMP-56 (#90) u9 — Step 13 ``text_path_stamper`` wiring tests.
|
||||
|
||||
Verifies that :func:`src.phase_z2_pipeline.render_slide` stamps each
|
||||
rendered ``text-line`` opening tag with
|
||||
``data-text-path="{slot_key}.{line_index}"`` via the u8 stamper
|
||||
(``src.text_path_stamper.stamp_zone_html``). This is the wiring unit
|
||||
companion to the u8 module-level tests at
|
||||
``tests/test_text_path_stamper.py``.
|
||||
|
||||
Coverage axes (Stage 2 plan u9 + Stage 1 binding contract) :
|
||||
|
||||
- happy path : real ``bim_current_problems_paired`` template emits
|
||||
``text-line`` divs for list-valued slots; the stamper attaches
|
||||
``data-text-path`` with matching ``{slot_key}.{line_index}``.
|
||||
- non-list slots skipped : ``title`` / ``row_*_left_label`` (scalars)
|
||||
do NOT receive ``data-text-path`` attributes.
|
||||
- empty list slots emit no stamps : rows whose body list is empty
|
||||
contribute zero stamps.
|
||||
- deterministic : repeated calls produce byte-identical HTML
|
||||
(no nondeterministic mutation of ``slot_payload`` between renders).
|
||||
- empty zone : the ``__empty__`` template_id short-circuit emits no
|
||||
``data-text-path`` (the stamper short-circuits on empty stamps).
|
||||
|
||||
Fully synthetic slot_payload — no real Phase Z run, no
|
||||
``v4_full32_result.yaml`` dependency. Uses the real
|
||||
``bim_current_problems_paired`` family template only to exercise the
|
||||
genuine Jinja2 + slide_base render path.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_pipeline import render_slide
|
||||
|
||||
|
||||
# ─── Fixture helpers ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _layout_css() -> dict:
|
||||
"""Minimal valid layout_css for a single-zone slide.
|
||||
|
||||
Mirrors tests/phase_z2/test_slide_base_embedded_mode.py shape.
|
||||
"""
|
||||
return {"areas": '"primary"', "cols": "1fr", "rows": "1fr"}
|
||||
|
||||
|
||||
def _paired_slot_payload(
|
||||
*,
|
||||
left_lines: list[str] | None = None,
|
||||
right_lines: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""Build a slot_payload for the bim_current_problems_paired family.
|
||||
|
||||
Only row_1 is populated by default; rows 2-4 stay empty so the
|
||||
template short-circuits to zero text-line divs for them.
|
||||
"""
|
||||
left_lines = left_lines if left_lines is not None else ["L1a", "L1b"]
|
||||
right_lines = right_lines if right_lines is not None else ["R1a"]
|
||||
payload: dict = {
|
||||
"title": "Synthetic Title",
|
||||
"row_1_left_label": "left pill 1",
|
||||
"row_1_left_body": [{"text": t, "indent": 0} for t in left_lines],
|
||||
"row_1_right_label": "right pill 1",
|
||||
"row_1_right_body": [{"text": t, "indent": 0} for t in right_lines],
|
||||
}
|
||||
for r in (2, 3, 4):
|
||||
payload[f"row_{r}_left_label"] = f"left pill {r}"
|
||||
payload[f"row_{r}_left_body"] = []
|
||||
payload[f"row_{r}_right_label"] = f"right pill {r}"
|
||||
payload[f"row_{r}_right_body"] = []
|
||||
return payload
|
||||
|
||||
|
||||
def _zone(template_id: str, slot_payload: dict) -> dict:
|
||||
return {
|
||||
"position": "primary",
|
||||
"template_id": template_id,
|
||||
"slot_payload": slot_payload,
|
||||
}
|
||||
|
||||
|
||||
def _render(zones: list[dict]) -> str:
|
||||
return render_slide(
|
||||
slide_title="t",
|
||||
slide_footer=None,
|
||||
zones_data=zones,
|
||||
layout_preset="single",
|
||||
layout_css=_layout_css(),
|
||||
gap_px=14,
|
||||
embedded_mode="embedded",
|
||||
)
|
||||
|
||||
|
||||
# ─── Case 1 : happy path — list-valued slots stamped ─────────────────
|
||||
|
||||
|
||||
def test_render_slide_stamps_text_path_per_line():
|
||||
"""Each list-valued slot line gets data-text-path={slot}.{index}."""
|
||||
payload = _paired_slot_payload(
|
||||
left_lines=["left line A", "left line B"],
|
||||
right_lines=["right line A"],
|
||||
)
|
||||
html = _render([_zone("bim_current_problems_paired", payload)])
|
||||
|
||||
# left body 2 lines + right body 1 line = 3 stamps in row 1.
|
||||
assert 'data-text-path="row_1_left_body.0"' in html
|
||||
assert 'data-text-path="row_1_left_body.1"' in html
|
||||
assert 'data-text-path="row_1_right_body.0"' in html
|
||||
# row 2-4 are empty → no stamps for those slot_keys.
|
||||
assert "row_2_left_body" not in html
|
||||
assert "row_3_right_body" not in html
|
||||
|
||||
|
||||
def test_stamps_preserve_class_attribute():
|
||||
"""data-text-path injected before existing class attribute, both present."""
|
||||
payload = _paired_slot_payload(left_lines=["only left"], right_lines=[])
|
||||
html = _render([_zone("bim_current_problems_paired", payload)])
|
||||
|
||||
# The original class="text-line..." must survive verbatim alongside
|
||||
# the injected data-text-path attribute on the same opening tag.
|
||||
assert re.search(
|
||||
r'<div\s+data-text-path="row_1_left_body\.0"\s+class="text-line[^"]*">',
|
||||
html,
|
||||
) is not None
|
||||
|
||||
|
||||
# ─── Case 2 : non-list slots are NOT stamped ─────────────────────────
|
||||
|
||||
|
||||
def test_non_list_slots_not_stamped():
|
||||
"""Scalar slot values (title, *_label) get no data-text-path."""
|
||||
payload = _paired_slot_payload(left_lines=["x"], right_lines=["y"])
|
||||
html = _render([_zone("bim_current_problems_paired", payload)])
|
||||
|
||||
# Scalar slots present in slot_payload as strings — must not receive
|
||||
# data-text-path stamps (u8 contract: scalar slots skipped silently
|
||||
# because they render outside text-line divs).
|
||||
assert 'data-text-path="title' not in html
|
||||
assert 'data-text-path="row_1_left_label' not in html
|
||||
assert 'data-text-path="row_1_right_label' not in html
|
||||
|
||||
|
||||
# ─── Case 3 : empty list slots contribute no stamps ──────────────────
|
||||
|
||||
|
||||
def test_empty_list_slots_no_stamps():
|
||||
"""Empty list slot yields zero stamps; template emits zero text-line divs."""
|
||||
payload = _paired_slot_payload(left_lines=[], right_lines=[])
|
||||
html = _render([_zone("bim_current_problems_paired", payload)])
|
||||
|
||||
# No row 1 lines at all (both bodies empty) → no row_1_*_body stamps.
|
||||
assert "data-text-path" not in html
|
||||
|
||||
|
||||
# ─── Case 4 : deterministic — repeated render produces same HTML ─────
|
||||
|
||||
|
||||
def test_render_with_stamp_is_deterministic():
|
||||
"""Same slot_payload → byte-identical HTML across two render_slide calls.
|
||||
|
||||
Guards against the wiring layer accidentally mutating slot_payload
|
||||
between renders (the stamper itself only reads slot_payload; it
|
||||
operates on rendered_partial). Also guards against double-stamping.
|
||||
"""
|
||||
payload_1 = _paired_slot_payload()
|
||||
payload_2 = _paired_slot_payload()
|
||||
html_1 = _render([_zone("bim_current_problems_paired", payload_1)])
|
||||
html_2 = _render([_zone("bim_current_problems_paired", payload_2)])
|
||||
|
||||
assert html_1 == html_2
|
||||
# Counts must match — no double-stamp side effect on shared module state.
|
||||
assert html_1.count("data-text-path=") == html_2.count("data-text-path=")
|
||||
# 2 left + 1 right = 3 stamps for the default fixture.
|
||||
assert html_1.count("data-text-path=") == 3
|
||||
|
||||
|
||||
# ─── Case 5 : __empty__ short-circuit emits no stamps ────────────────
|
||||
|
||||
|
||||
def test_empty_template_short_circuit_no_stamps():
|
||||
"""``template_id=__empty__`` short-circuits before stamping; no stamps."""
|
||||
html = _render([_zone("__empty__", {})])
|
||||
assert "data-text-path" not in html
|
||||
|
||||
|
||||
# ─── Case 6 : slot_payload preserved (raw_content invariant) ─────────
|
||||
|
||||
|
||||
def test_render_does_not_mutate_slot_payload():
|
||||
"""Stamping must not mutate slot_payload list/dict contents.
|
||||
|
||||
The stamper operates on rendered_partial HTML; the source
|
||||
slot_payload should be byte-identical before and after render_slide.
|
||||
Locks the raw_content preservation invariant at the wiring layer.
|
||||
"""
|
||||
payload = _paired_slot_payload(
|
||||
left_lines=["preserved A", "preserved B"],
|
||||
right_lines=["preserved C"],
|
||||
)
|
||||
# Snapshot key list/dict identities and content.
|
||||
snapshot_left = list(payload["row_1_left_body"])
|
||||
snapshot_left_text = [item["text"] for item in snapshot_left]
|
||||
snapshot_right_text = [item["text"] for item in payload["row_1_right_body"]]
|
||||
|
||||
_ = _render([_zone("bim_current_problems_paired", payload)])
|
||||
|
||||
assert [item["text"] for item in payload["row_1_left_body"]] == snapshot_left_text
|
||||
assert [item["text"] for item in payload["row_1_right_body"]] == snapshot_right_text
|
||||
# Scalar slots untouched too.
|
||||
assert payload["title"] == "Synthetic Title"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user