Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97b7833a1b | ||
|
|
6e9e3ee1fb | ||
|
|
2afedfc780 | ||
|
|
5484077a53 | ||
|
|
ed391af2e8 | ||
|
|
b9747c2f4a | ||
|
|
f0d4494409 | ||
|
|
4da22adb43 | ||
|
|
943957562f | ||
|
|
ec7471ed59 | ||
|
|
4e281a20d8 | ||
|
|
9062931863 | ||
|
|
b4be6c1cd0 | ||
|
|
8648a468d9 | ||
|
|
028042aaa9 | ||
|
|
2e3747c5ab | ||
|
|
e0c39f1bc1 | ||
|
|
5deeb97cf6 | ||
|
|
c59864eb9a | ||
|
|
6aa7564509 | ||
|
|
b1bbe27c38 | ||
|
|
896f273ffa | ||
|
|
842a46144c | ||
|
|
c53722ad0b | ||
|
|
cacc5b30db | ||
|
|
d9d338416a | ||
|
|
f3ef4d917c | ||
|
|
7c93031f9b | ||
|
|
c1df656312 | ||
|
|
6f1c7367e0 | ||
|
|
bd8bcf748b | ||
|
|
9388e25e76 | ||
|
|
ee97f4fc78 | ||
|
|
79f9ea5c92 | ||
|
|
2ef02f5f18 | ||
|
|
1186ad8ae2 | ||
|
|
f358604fb3 | ||
|
|
90503cadd6 |
@@ -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>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { motion } from 'framer-motion';
|
||||
import type { Zone, InternalRegion, UserSelection, FrameCandidate, SlidePlan } from '../types/designAgent';
|
||||
import { getSectionsForZone } from '../utils/slidePlanUtils';
|
||||
import { buildBadgeTitle } from '../services/applicationMode';
|
||||
|
||||
interface FramePanelProps {
|
||||
slidePlan: SlidePlan | null;
|
||||
@@ -19,19 +20,18 @@ interface FramePanelProps {
|
||||
onNoDesignToggle: () => void;
|
||||
}
|
||||
|
||||
// ─── IMP-41 u3 — application_mode consequence tooltip map (issue #70) ────────
|
||||
// Keyed by application_mode VALUE (backend authoritative), NOT V4 label.
|
||||
// Source = src/phase_z2_pipeline.py APPLICATION_MODE_BY_V4_LABEL (:107-112)
|
||||
// emitted via Step 9 unit.application_candidates[] and forwarded by
|
||||
// designAgentApi.ts (IMP-41 u2). When applicationMode is absent (legacy
|
||||
// fixtures pre-IMP-32, or candidate filtered out at Step 9) the tooltip
|
||||
// falls back to the raw V4 label string per Stage 2 contract.
|
||||
const APPLICATION_MODE_TOOLTIP_KR: Record<string, string> = {
|
||||
direct_insert: "코드 직접 적용",
|
||||
same_frame_with_adjustment: "AI 보강 필요",
|
||||
layout_or_region_change: "AI restructure 필요",
|
||||
exclude: "render path 제외",
|
||||
};
|
||||
// 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,
|
||||
@@ -60,24 +60,11 @@ export default function FramePanel({
|
||||
return userSelection.overrides.zone_frames[targetRegion.id] || targetRegion.frame_match_strategy.frame_id;
|
||||
}, [selectedZone, selectedRegion, userSelection.overrides.zone_frames]);
|
||||
|
||||
// IMP-47B u11 — reject-click confirm guard. Per #76 policy: 사용자가 reject
|
||||
// 카드 명시 클릭 → backend `--override-frame` 전달 + reject frame 유지 + AI 재구성.
|
||||
// The window.confirm makes the AI-rebuild intent explicit (deselecting an
|
||||
// already-applied reject frame does not prompt). Pure UX gate — no state
|
||||
// mutation here; the parent `onFrameSelect` still owns the override apply.
|
||||
const handleFrameSelect = React.useCallback(
|
||||
(candidate: FrameCandidate) => {
|
||||
const isReject = candidate.label === "reject";
|
||||
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) {
|
||||
@@ -269,35 +256,29 @@ export default function FramePanel({
|
||||
</span>
|
||||
)}
|
||||
{/* V4 label badge */}
|
||||
{candidate.label && (() => {
|
||||
// IMP-41 u3 — applicationMode-keyed Korean consequence
|
||||
// tooltip with legacy fallback. applicationMode is
|
||||
// forwarded by designAgentApi.ts (u2) from Step 9
|
||||
// unit.application_candidates[]; undefined when the
|
||||
// backend did not emit a mapping for this candidate.
|
||||
const consequence = candidate.applicationMode
|
||||
? APPLICATION_MODE_TOOLTIP_KR[candidate.applicationMode]
|
||||
: undefined;
|
||||
const badgeTitle = consequence
|
||||
? `${consequence} (${candidate.applicationMode})`
|
||||
: `V4 label: ${candidate.label}`;
|
||||
return (
|
||||
<span
|
||||
className={`text-[8px] font-black uppercase tracking-tight px-1.5 py-0.5 rounded ${
|
||||
candidate.label === "use_as_is"
|
||||
? "bg-emerald-100 text-emerald-700"
|
||||
: candidate.label === "light_edit"
|
||||
? "bg-blue-100 text-blue-700"
|
||||
: candidate.label === "restructure"
|
||||
? "bg-amber-100 text-amber-700"
|
||||
: "bg-red-100 text-red-700"
|
||||
}`}
|
||||
title={badgeTitle}
|
||||
>
|
||||
{candidate.label}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
{/* IMP-41 u5 — tooltip delegated to pure helper
|
||||
`buildBadgeTitle` (services/applicationMode.ts).
|
||||
applicationMode is forwarded by designAgentApi.ts
|
||||
(u4) from Step 9 unit.application_candidates[];
|
||||
helper falls back to the raw V4 label when the
|
||||
mode is undefined or unknown. Badge color mapping
|
||||
is intentionally untouched per Stage 2 scope. */}
|
||||
{candidate.label && (
|
||||
<span
|
||||
className={`text-[8px] font-black uppercase tracking-tight px-1.5 py-0.5 rounded ${
|
||||
candidate.label === "use_as_is"
|
||||
? "bg-emerald-100 text-emerald-700"
|
||||
: candidate.label === "light_edit"
|
||||
? "bg-blue-100 text-blue-700"
|
||||
: candidate.label === "restructure"
|
||||
? "bg-amber-100 text-amber-700"
|
||||
: "bg-red-100 text-red-700"
|
||||
}`}
|
||||
title={buildBadgeTitle(candidate.label, candidate.applicationMode)}
|
||||
>
|
||||
{candidate.label}
|
||||
</span>
|
||||
)}
|
||||
{/* IMP-29 u3 — route hint chip (skip when direct_render = default). */}
|
||||
{showRouteChip && (
|
||||
<span
|
||||
|
||||
@@ -21,6 +21,19 @@ import type {
|
||||
UserSelection,
|
||||
NormalizedContent,
|
||||
} from "../types/designAgent";
|
||||
import {
|
||||
IMAGE_RESIZE_MIN_SIZE_PERCENT,
|
||||
clampImagePercentGeometry,
|
||||
clampZoneMove,
|
||||
crossedDragThreshold,
|
||||
type ImageDragDirection,
|
||||
} from "./slideCanvasDragMath";
|
||||
import type {
|
||||
ImageOverridesOverride,
|
||||
StructureOverridesOverride,
|
||||
StructureOverridePerZone,
|
||||
} from "../services/userOverridesApi";
|
||||
import StructureEditOverlay from "./StructureEditOverlay";
|
||||
|
||||
interface SlideCanvasProps {
|
||||
slidePlan: SlidePlan | null;
|
||||
@@ -28,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 만 표시. */
|
||||
@@ -51,16 +60,134 @@ interface SlideCanvasProps {
|
||||
onZoneResize?: (
|
||||
geometries: Record<string, { x: number; y: number; w: number; h: number }>
|
||||
) => void;
|
||||
/** IMP-51 (#79) u8 — persisted slide-absolute image geometries
|
||||
* (image_id → {x,y,w,h} as percent of 1280×720, range 0–100). Mirrors
|
||||
* the u3 typed-client `ImageOverride` contract and the u7 stamper that
|
||||
* emits CSS `left/top/width/height: {value}%`. Forward-compat optional;
|
||||
* u11 wires this from `userSelection.overrides.image_overrides`. When
|
||||
* present, SlideCanvas displays the persisted geometry instead of the
|
||||
* iframe-measured baseline. */
|
||||
imageOverrides?: ImageOverridesOverride;
|
||||
/** IMP-51 (#79) u8 — emitted when the user drags or resizes a stamped
|
||||
* user-content image. Geometry is slide-absolute percent (0–100 of
|
||||
* 1280×720), matching the persisted axis schema (u3 typed client) and
|
||||
* the u7 CSS injection that writes the values directly into
|
||||
* `left/top/width/height: {value}%`. u10 wires this to a persistence
|
||||
* handler that updates `image_overrides` on user_overrides.json. */
|
||||
onImageResize?: (
|
||||
imageId: string,
|
||||
geometry: { x: number; y: number; w: number; h: number }
|
||||
) => void;
|
||||
/** 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,
|
||||
@@ -70,6 +197,11 @@ export default function SlideCanvas({
|
||||
onSlideClick,
|
||||
onSectionDrop,
|
||||
onZoneResize,
|
||||
imageOverrides,
|
||||
onImageResize,
|
||||
onTextEdit,
|
||||
structureOverrides,
|
||||
onStructureEdit,
|
||||
}: SlideCanvasProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [scale, setScale] = useState(1);
|
||||
@@ -91,10 +223,36 @@ export default function SlideCanvas({
|
||||
// Step B : section drag-drop drop target. 사용자가 LeftMdxPanel 의 section 카드
|
||||
// 를 drag 해서 zone 에 drop 시 그 zone 에 section 할당. dragOver 시 강조 표시.
|
||||
const [dragOverZoneId, setDragOverZoneId] = useState<string | null>(null);
|
||||
// IMP-51 (#79) u8 — measured user-content image bboxes inside iframe
|
||||
// (slide-absolute percent of 1280×720, range 0–100). key = data-image-id
|
||||
// stamped by u4 (`src/image_id_stamper.py`). Populated in the iframe
|
||||
// onLoad measure block alongside measuredZones / measuredSlideBody.
|
||||
// Units intentionally match the persisted `image_overrides` axis (u3
|
||||
// typed client) and the u7 CSS injection so the overlay math has a
|
||||
// single coord space across measured/persisted/emitted values. Used as
|
||||
// the baseline geometry when no persisted override exists for that id;
|
||||
// `imageOverrides` prop (u11-fed) wins when present.
|
||||
const [measuredImages, setMeasuredImages] = useState<
|
||||
Record<string, { x: number; y: number; w: number; h: number }>
|
||||
>({});
|
||||
// IMP-51 (#79) u8 — currently selected user-content image id (= the one
|
||||
// whose drag/resize handles are shown). Set by the click-listener
|
||||
// installed inside the iframe contentDocument when edit mode is active.
|
||||
// Reset on finalHtmlUrl change and on edit-mode exit so stale ids never
|
||||
// leak across runs.
|
||||
const [selectedImageId, setSelectedImageId] = useState<string | null>(null);
|
||||
// HTML 편집 모드 — 글벗 패턴 (designMode + contentEditable + outline CSS) 차용.
|
||||
// 활성 시 iframe 안 텍스트 element 직접 클릭하여 수정 가능. backend 반영은 별 작업.
|
||||
// pendingLayout 과 배타적 (충돌 방지).
|
||||
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 에 글벗 패턴 적용 / 해제.
|
||||
@@ -118,7 +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;
|
||||
if (isEditMode) {
|
||||
// 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 }> = [];
|
||||
|
||||
// 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)) {
|
||||
@@ -130,6 +303,14 @@ export default function SlideCanvas({
|
||||
onContentEdit?.();
|
||||
};
|
||||
doc.addEventListener("input", inputHandler);
|
||||
|
||||
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) => {
|
||||
@@ -137,23 +318,103 @@ export default function SlideCanvas({
|
||||
});
|
||||
}
|
||||
|
||||
// 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]'
|
||||
);
|
||||
imgEls.forEach((imgEl) => {
|
||||
const imgId = imgEl.dataset.imageId;
|
||||
if (!imgId) return;
|
||||
const handler = (ev: Event) => {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
setSelectedImageId(imgId);
|
||||
};
|
||||
const prevCursor = imgEl.style.cursor;
|
||||
const prevOutline = imgEl.style.outline;
|
||||
imgEl.style.cursor = "pointer";
|
||||
imgEl.style.outline = "1px dashed rgba(16, 185, 129, 0.55)";
|
||||
imgEl.addEventListener("click", handler);
|
||||
imageClickBindings.push({ el: imgEl, handler, prevCursor, prevOutline });
|
||||
});
|
||||
} else {
|
||||
setSelectedImageId(null);
|
||||
}
|
||||
|
||||
return () => {
|
||||
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(() => {
|
||||
setMeasuredZones({});
|
||||
setMeasuredSlideBody(null);
|
||||
// IMP-51 (#79) u8 — image measurements + selection are per-render;
|
||||
// drop both so the new iframe's onLoad starts clean.
|
||||
setMeasuredImages({});
|
||||
setSelectedImageId(null);
|
||||
}, [finalHtmlUrl]);
|
||||
|
||||
// 16:9 비율 유지하며 컨테이너에 통째로 fit (스크롤 X).
|
||||
@@ -251,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
|
||||
@@ -294,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 가 더 이상
|
||||
@@ -305,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 기준 정규화.
|
||||
@@ -351,6 +631,33 @@ export default function SlideCanvas({
|
||||
h: r.height / SLIDE_H,
|
||||
});
|
||||
}
|
||||
|
||||
// ── IMP-51 (#79) u8 — user-content image bbox 측정 ──
|
||||
// u4 stamper 가 부착한 data-image-id 가 있는 img 만 잡음
|
||||
// (decorative / frame img 제외). 측정 결과는 1280×720 기준
|
||||
// 슬라이드-절대 percent (0–100) — image_overrides axis (u3
|
||||
// 타입 + u7 CSS `left/top/width/height: {value}%` 주입) 와
|
||||
// 동일한 좌표계라서 측정 / 영구 저장 / emit 가 1:1 매칭됨.
|
||||
const imageEls = doc.querySelectorAll<HTMLImageElement>(
|
||||
'.slide img[data-image-role="user-content"][data-image-id]'
|
||||
);
|
||||
const measuredImg: Record<
|
||||
string,
|
||||
{ x: number; y: number; w: number; h: number }
|
||||
> = {};
|
||||
imageEls.forEach((imgEl) => {
|
||||
const id = imgEl.dataset.imageId;
|
||||
if (!id) return;
|
||||
const r = imgEl.getBoundingClientRect();
|
||||
if (r.width <= 0 || r.height <= 0) return;
|
||||
measuredImg[id] = {
|
||||
x: (r.left / SLIDE_W) * 100,
|
||||
y: (r.top / SLIDE_H) * 100,
|
||||
w: (r.width / SLIDE_W) * 100,
|
||||
h: (r.height / SLIDE_H) * 100,
|
||||
};
|
||||
});
|
||||
setMeasuredImages(measuredImg);
|
||||
} catch (err) {
|
||||
console.warn("[SlideCanvas] iframe inject/measure 실패:", err);
|
||||
}
|
||||
@@ -465,10 +772,11 @@ export default function SlideCanvas({
|
||||
const makeResizeHandler = (
|
||||
direction: ResizeDir
|
||||
) => (ev: React.MouseEvent<HTMLDivElement>) => {
|
||||
// resize 는 pendingLayout 모드에서만 — 첫 초안 (normal) 과 편집 모드에서는
|
||||
// frame HTML 이 reflow 못 해서 의미 없음. layout 변경 후 빈 layout 에서만
|
||||
// zone 자유 배치.
|
||||
if (!isPendingLayout || !onZoneResize) return;
|
||||
// resize 는 pendingLayout OR 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();
|
||||
@@ -485,6 +793,12 @@ export default function SlideCanvas({
|
||||
const affectsTop = direction === "top" || direction === "nw" || direction === "ne";
|
||||
const affectsBottom = direction === "bottom" || direction === "sw" || direction === "se";
|
||||
|
||||
// 2026-05-22 demo hot-fix — iframe 이 마우스 가로채서 mouseup leak 일어남
|
||||
// (편집 모드에서 iframe pointerEvents=auto). drag 동안 iframe 강제 none.
|
||||
const iframeEl = iframeRef.current;
|
||||
const prevIframePE = iframeEl ? iframeEl.style.pointerEvents : "";
|
||||
if (iframeEl) iframeEl.style.pointerEvents = "none";
|
||||
|
||||
const onMove = (mv: MouseEvent) => {
|
||||
const dx = (mv.clientX - startMouseX) / slideBodyWidthPx;
|
||||
const dy = (mv.clientY - startMouseY) / slideBodyHeightPx;
|
||||
@@ -511,6 +825,7 @@ export default function SlideCanvas({
|
||||
const onUp = () => {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
if (iframeEl) iframeEl.style.pointerEvents = prevIframePE;
|
||||
};
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
@@ -532,7 +847,10 @@ export default function SlideCanvas({
|
||||
ev: React.MouseEvent<HTMLDivElement>
|
||||
) => {
|
||||
ev.stopPropagation();
|
||||
const canDrag = !!(isPendingLayout && 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 };
|
||||
@@ -543,25 +861,26 @@ export default function SlideCanvas({
|
||||
? H_SCALED * measuredSlideBody!.h
|
||||
: 1;
|
||||
let dragged = false;
|
||||
const dragThresholdPx = 5;
|
||||
|
||||
// 2026-05-22 demo hot-fix — same iframe pointer-events fix as makeResizeHandler.
|
||||
const iframeEl = iframeRef.current;
|
||||
const prevIframePE = iframeEl ? iframeEl.style.pointerEvents : "";
|
||||
if (iframeEl) iframeEl.style.pointerEvents = "none";
|
||||
|
||||
const onMove = (mv: MouseEvent) => {
|
||||
if (!canDrag) return;
|
||||
const dxPx = mv.clientX - startMouseX;
|
||||
const dyPx = mv.clientY - startMouseY;
|
||||
if (!dragged && Math.hypot(dxPx, dyPx) > dragThresholdPx) {
|
||||
if (!dragged && crossedDragThreshold(dxPx, dyPx)) {
|
||||
dragged = true;
|
||||
}
|
||||
if (dragged) {
|
||||
const dx = dxPx / slideBodyWidthPx;
|
||||
const dy = dyPx / slideBodyHeightPx;
|
||||
const newX = Math.max(
|
||||
0,
|
||||
Math.min(1 - startGeom.w, startGeom.x + dx)
|
||||
);
|
||||
const newY = Math.max(
|
||||
0,
|
||||
Math.min(1 - startGeom.h, startGeom.y + dy)
|
||||
const { x: newX, y: newY } = clampZoneMove(
|
||||
startGeom,
|
||||
dxPx,
|
||||
dyPx,
|
||||
slideBodyWidthPx,
|
||||
slideBodyHeightPx
|
||||
);
|
||||
onZoneResize!({
|
||||
[zone.zone_id]: {
|
||||
@@ -576,6 +895,7 @@ export default function SlideCanvas({
|
||||
const onUp = () => {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
if (iframeEl) iframeEl.style.pointerEvents = prevIframePE;
|
||||
if (!dragged) {
|
||||
// 단순 click 으로 처리 — onZoneClick.
|
||||
onZoneClick?.(zone.id);
|
||||
@@ -671,6 +991,8 @@ export default function SlideCanvas({
|
||||
} ${
|
||||
isDragOver
|
||||
? "border-4 border-emerald-500 bg-emerald-100/30 shadow-[0_0_0_4px_rgba(16,185,129,0.3)]"
|
||||
: isSelected && isEditMode
|
||||
? "border-2 border-emerald-500 bg-emerald-500/10 shadow-[0_0_0_2px_rgba(16,185,129,0.25)]"
|
||||
: isSelected && !isEditMode
|
||||
? "border-2 border-blue-500 bg-blue-500/10 shadow-[0_0_0_2px_rgba(59,130,246,0.2)]"
|
||||
: !isEditMode
|
||||
@@ -747,11 +1069,14 @@ export default function SlideCanvas({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step C : zone resize handles — 8 방향. pendingLayout 모드만 활성
|
||||
(frame html 의 fixed px 디자인 한계로 첫 초안 / 편집 모드 resize 의미 X).
|
||||
{/* Step C : zone resize handles — 8 방향. pendingLayout OR 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 && onZoneResize && (
|
||||
{(isPendingLayout || editGates.zoneGestures) && onZoneResize && (
|
||||
<>
|
||||
{/* top edge */}
|
||||
<div
|
||||
@@ -819,9 +1144,291 @@ export default function SlideCanvas({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* IMP-54 u1: edit-mode body-drag gesture surfaces.
|
||||
wrapper sets pointerEvents:none in edit mode (see above) to
|
||||
preserve iframe text-edit clicks (A8 guardrail), so the
|
||||
wrapper-level handleZoneMouseDown is unreachable in edit mode.
|
||||
These 4 perimeter strips + top-left grip provide a separate
|
||||
pointer-event surface routing into handleZoneMouseDown.
|
||||
zIndex 25 sits BELOW the 8 resize handles (z-30) so resize
|
||||
gesture wins in overlap regions, and ABOVE the iframe so the
|
||||
strips intercept the perimeter while the un-covered iframe
|
||||
interior keeps text-edit reachability intact.
|
||||
pendingLayout mode already has wrapper pointerEvents:auto,
|
||||
so these surfaces are only needed in edit mode.
|
||||
IMP-90 u12: image-zone-mode-only — text / structure 모드는
|
||||
zone drag 안 함 (editGates.zoneGestures = false 두 모드 모두). */}
|
||||
{editGates.zoneGestures && !isPendingLayout && onZoneResize && (
|
||||
<>
|
||||
<div
|
||||
onMouseDown={handleZoneMouseDown}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="absolute top-0 left-0 right-0 h-2 cursor-grab active:cursor-grabbing hover:bg-emerald-500/20 transition"
|
||||
style={{ pointerEvents: "auto", zIndex: 25 }}
|
||||
title="zone 이동 — 드래그"
|
||||
/>
|
||||
<div
|
||||
onMouseDown={handleZoneMouseDown}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="absolute bottom-0 left-0 right-0 h-2 cursor-grab active:cursor-grabbing hover:bg-emerald-500/20 transition"
|
||||
style={{ pointerEvents: "auto", zIndex: 25 }}
|
||||
title="zone 이동 — 드래그"
|
||||
/>
|
||||
<div
|
||||
onMouseDown={handleZoneMouseDown}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="absolute top-0 left-0 bottom-0 w-2 cursor-grab active:cursor-grabbing hover:bg-emerald-500/20 transition"
|
||||
style={{ pointerEvents: "auto", zIndex: 25 }}
|
||||
title="zone 이동 — 드래그"
|
||||
/>
|
||||
<div
|
||||
onMouseDown={handleZoneMouseDown}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="absolute top-0 right-0 bottom-0 w-2 cursor-grab active:cursor-grabbing hover:bg-emerald-500/20 transition"
|
||||
style={{ pointerEvents: "auto", zIndex: 25 }}
|
||||
title="zone 이동 — 드래그"
|
||||
/>
|
||||
{/* visible grip affordance — placed below the section label
|
||||
(top-1 left-1 container) so the two don't overlap. */}
|
||||
<div
|
||||
onMouseDown={handleZoneMouseDown}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="absolute top-7 left-1 w-3 h-3 bg-emerald-500/70 border border-emerald-700 rounded-full cursor-grab active:cursor-grabbing shadow hover:scale-125 transition"
|
||||
style={{ pointerEvents: "auto", zIndex: 25 }}
|
||||
title="zone 이동 — 드래그하여 위치 변경"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* IMP-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`
|
||||
(iframe-measured baseline). pendingLayout suppresses the image
|
||||
overlay so zone editing and image editing never compete for the
|
||||
same pointer events.
|
||||
|
||||
For every stamped user-content image we render a transparent
|
||||
wrapper at the image's slide-absolute coords. Wrapper picks up
|
||||
the body-drag gesture (move the image without resizing). When
|
||||
the image is the `selectedImageId` we additionally render 8
|
||||
resize handles. Aspect ratio is LOCKED on corner drags by
|
||||
default; holding Shift during the drag unlocks it (matches the
|
||||
issue contract "corner_resize_ratio_default_locked_shift_unlock").
|
||||
|
||||
Coordinate space: slide-absolute percent (0–100) throughout —
|
||||
measured / persisted / emitted values share the same units as
|
||||
the u7 CSS injector (`left/top/width/height: {value}%`) and the
|
||||
u3 typed-client `ImageOverride` contract. CSS values are
|
||||
written verbatim ({geom.x}%, no scale factor) and pixel deltas
|
||||
from MouseEvent are converted to percent via
|
||||
`(dx_px / W_SCALED) * 100` so the round-trip drag → save →
|
||||
re-render produces identical geometry. IMP-51 (#79) u9 moved
|
||||
the resize / move math to `clampImagePercentGeometry` in
|
||||
`slideCanvasDragMath.ts` so the boundary contract Codex #16
|
||||
verified is exercised directly by vitest (mirror of how IMP-54
|
||||
u3 split the zone math out of SlideCanvas). */}
|
||||
{/* 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];
|
||||
const measured = measuredImages[imageId];
|
||||
// override 우선; 없으면 measured baseline. 둘 다 없으면 skip.
|
||||
const geom = persisted ?? measured;
|
||||
if (!geom) return null;
|
||||
const isSelected = selectedImageId === imageId;
|
||||
|
||||
const beginDrag = (
|
||||
ev: React.MouseEvent<HTMLDivElement>,
|
||||
direction: ImageDragDirection
|
||||
) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
setSelectedImageId(imageId);
|
||||
const startMouseX = ev.clientX;
|
||||
const startMouseY = ev.clientY;
|
||||
const startGeom = { ...geom };
|
||||
|
||||
// 2026-05-22 demo hot-fix parity — iframe 이 마우스 가로
|
||||
// 채서 mouseup leak 일어남 (편집 모드에서 pe=auto).
|
||||
const iframeEl = iframeRef.current;
|
||||
const prevIframePE = iframeEl ? iframeEl.style.pointerEvents : "";
|
||||
if (iframeEl) iframeEl.style.pointerEvents = "none";
|
||||
|
||||
const isCorner =
|
||||
direction === "nw" ||
|
||||
direction === "ne" ||
|
||||
direction === "sw" ||
|
||||
direction === "se";
|
||||
|
||||
const onMove = (mv: MouseEvent) => {
|
||||
// Convert pixel delta on the on-screen scaled slide
|
||||
// back into percent-of-slide so all downstream math
|
||||
// shares the persisted axis's coord space. W_SCALED /
|
||||
// H_SCALED already include the wrapper scale factor,
|
||||
// so dividing then multiplying by 100 gives a stable
|
||||
// value regardless of viewport zoom.
|
||||
const dx = ((mv.clientX - startMouseX) / W_SCALED) * 100;
|
||||
const dy = ((mv.clientY - startMouseY) / H_SCALED) * 100;
|
||||
// IMP-51 (#79) u9 — boundary contract lives in the
|
||||
// pure helper so vitest can verify it directly.
|
||||
// Aspect lock is default on for corner handles and
|
||||
// released when Shift is held.
|
||||
const aspectLocked = isCorner && !mv.shiftKey;
|
||||
const next = clampImagePercentGeometry(
|
||||
startGeom,
|
||||
dx,
|
||||
dy,
|
||||
direction,
|
||||
aspectLocked,
|
||||
IMAGE_RESIZE_MIN_SIZE_PERCENT,
|
||||
);
|
||||
onImageResize(imageId, next);
|
||||
};
|
||||
const onUp = () => {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
if (iframeEl) iframeEl.style.pointerEvents = prevIframePE;
|
||||
};
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`img-overlay-${imageId}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-image-overlay-id={imageId}
|
||||
onMouseDown={(ev) => beginDrag(ev, "move")}
|
||||
className={`absolute z-30 ${
|
||||
isSelected
|
||||
? "border-2 border-emerald-500 bg-emerald-500/5 shadow-[0_0_0_2px_rgba(16,185,129,0.25)]"
|
||||
: "border border-dashed border-emerald-400/60 hover:border-emerald-500"
|
||||
} cursor-grab active:cursor-grabbing`}
|
||||
style={{
|
||||
left: `${geom.x}%`,
|
||||
top: `${geom.y}%`,
|
||||
width: `${geom.w}%`,
|
||||
height: `${geom.h}%`,
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
title={
|
||||
isSelected
|
||||
? "이미지 이동 — 드래그 / 모서리 핸들 = 크기 (Shift = 비율 해제)"
|
||||
: "클릭하여 선택"
|
||||
}
|
||||
>
|
||||
<span className="absolute top-1 left-1 text-[9px] font-black uppercase tracking-tighter px-1.5 py-0.5 rounded bg-emerald-600/90 text-white shadow pointer-events-none">
|
||||
IMG
|
||||
</span>
|
||||
|
||||
{isSelected && (
|
||||
<>
|
||||
{/* edges */}
|
||||
<div
|
||||
onMouseDown={(ev) => beginDrag(ev, "top")}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="absolute -top-1 left-1/4 w-1/2 h-2 bg-emerald-500/70 hover:bg-emerald-500 rounded cursor-ns-resize z-40 transition shadow"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
title="상단"
|
||||
/>
|
||||
<div
|
||||
onMouseDown={(ev) => beginDrag(ev, "bottom")}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="absolute -bottom-1 left-1/4 w-1/2 h-2 bg-emerald-500/70 hover:bg-emerald-500 rounded cursor-ns-resize z-40 transition shadow"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
title="하단"
|
||||
/>
|
||||
<div
|
||||
onMouseDown={(ev) => beginDrag(ev, "left")}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="absolute top-1/4 -left-1 h-1/2 w-2 bg-emerald-500/70 hover:bg-emerald-500 rounded cursor-ew-resize z-40 transition shadow"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
title="좌측"
|
||||
/>
|
||||
<div
|
||||
onMouseDown={(ev) => beginDrag(ev, "right")}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="absolute top-1/4 -right-1 h-1/2 w-2 bg-emerald-500/70 hover:bg-emerald-500 rounded cursor-ew-resize z-40 transition shadow"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
title="우측"
|
||||
/>
|
||||
{/* corners — aspect locked by default, Shift unlocks */}
|
||||
<div
|
||||
onMouseDown={(ev) => beginDrag(ev, "nw")}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="absolute -top-1 -left-1 w-3 h-3 bg-white border-2 border-emerald-500 rounded-sm cursor-nwse-resize z-40 hover:scale-125 transition shadow"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
title="좌상단 (Shift = 비율 해제)"
|
||||
/>
|
||||
<div
|
||||
onMouseDown={(ev) => beginDrag(ev, "ne")}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="absolute -top-1 -right-1 w-3 h-3 bg-white border-2 border-emerald-500 rounded-sm cursor-nesw-resize z-40 hover:scale-125 transition shadow"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
title="우상단 (Shift = 비율 해제)"
|
||||
/>
|
||||
<div
|
||||
onMouseDown={(ev) => beginDrag(ev, "sw")}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="absolute -bottom-1 -left-1 w-3 h-3 bg-white border-2 border-emerald-500 rounded-sm cursor-nesw-resize z-40 hover:scale-125 transition shadow"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
title="좌하단 (Shift = 비율 해제)"
|
||||
/>
|
||||
<div
|
||||
onMouseDown={(ev) => beginDrag(ev, "se")}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="absolute -bottom-1 -right-1 w-4 h-4 bg-white border-2 border-emerald-500 rounded-sm cursor-nwse-resize z-40 hover:scale-125 transition shadow"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
title="우하단 (Shift = 비율 해제)"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
// IMP-54 u4 — vitest coverage for the pure drag-math helpers extracted in u3
|
||||
// (`Front/client/src/components/slideCanvasDragMath.ts`).
|
||||
//
|
||||
// Stage 2 contract (`Stage 2 Exit Report → implementation_units → u4`):
|
||||
// • Threshold pass/fail at 5 px (strict `Math.hypot > 5`).
|
||||
// • Clamp negative delta to 0 on both axes.
|
||||
// • Clamp max-edge delta to `1 - startGeom.w` (x) and `1 - startGeom.h` (y).
|
||||
//
|
||||
// The helpers are pure (no React, no DOM) so we drive them directly with
|
||||
// numeric inputs — no fake timers, no fetch stubs, no component mount.
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
DRAG_THRESHOLD_PX,
|
||||
IMAGE_RESIZE_MIN_SIZE_PERCENT,
|
||||
clampImagePercentGeometry,
|
||||
clampZoneMove,
|
||||
crossedDragThreshold,
|
||||
type ImagePercentGeom,
|
||||
type ZoneFracGeom,
|
||||
} from "./slideCanvasDragMath";
|
||||
|
||||
describe("DRAG_THRESHOLD_PX", () => {
|
||||
it("is 5", () => {
|
||||
expect(DRAG_THRESHOLD_PX).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("crossedDragThreshold", () => {
|
||||
it("returns false for zero movement (still a click)", () => {
|
||||
expect(crossedDragThreshold(0, 0)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false just below threshold — 3,4 → hypot 5 with strict >", () => {
|
||||
expect(crossedDragThreshold(3, 4)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false at exactly the threshold along each axis", () => {
|
||||
// strict inequality: Math.hypot(5, 0) === 5, not > 5
|
||||
expect(crossedDragThreshold(5, 0)).toBe(false);
|
||||
expect(crossedDragThreshold(0, 5)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true once distance exceeds threshold", () => {
|
||||
expect(crossedDragThreshold(4, 4)).toBe(true); // hypot ≈ 5.6568
|
||||
expect(crossedDragThreshold(6, 0)).toBe(true);
|
||||
expect(crossedDragThreshold(0, 6)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats negative deltas symmetrically (Euclidean distance)", () => {
|
||||
expect(crossedDragThreshold(-3, -4)).toBe(false);
|
||||
expect(crossedDragThreshold(-4, -4)).toBe(true);
|
||||
expect(crossedDragThreshold(-6, 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clampZoneMove", () => {
|
||||
// 1000 × 1000 slide body so 1 px == 0.001 frac — keeps the arithmetic
|
||||
// exact and the boundary deltas (1000 px) round-trip back to `1 - w/h`.
|
||||
const W = 1000;
|
||||
const H = 1000;
|
||||
const baseGeom: ZoneFracGeom = { x: 0.1, y: 0.2, w: 0.3, h: 0.4 };
|
||||
|
||||
it("applies in-bounds delta as startGeom + (dPx / slideBodySize)", () => {
|
||||
expect(clampZoneMove(baseGeom, 100, 50, W, H)).toEqual({
|
||||
x: 0.2,
|
||||
y: 0.25,
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps negative delta to 0 on both axes", () => {
|
||||
expect(clampZoneMove(baseGeom, -1000, -1000, W, H)).toEqual({
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps max-edge delta to (1 - w) on x and (1 - h) on y", () => {
|
||||
expect(clampZoneMove(baseGeom, 1000, 1000, W, H)).toEqual({
|
||||
x: 1 - baseGeom.w, // 0.7
|
||||
y: 1 - baseGeom.h, // 0.6
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps the two axes independently (negative x, in-bounds y)", () => {
|
||||
expect(clampZoneMove(baseGeom, -1000, 50, W, H)).toEqual({
|
||||
x: 0,
|
||||
y: 0.25,
|
||||
});
|
||||
});
|
||||
|
||||
it("honours non-square slide bodies via per-axis division", () => {
|
||||
// dxPx 100 / 500 = 0.2 fr; dyPx 100 / 250 = 0.4 fr (hits the y boundary).
|
||||
// x is checked with toBeCloseTo because 0.1 + 0.2 is the canonical IEEE-754
|
||||
// floating-point trap (0.30000000000000004) — the clamp logic is correct,
|
||||
// it just inherits JS number precision. y stays exact since it clamps to
|
||||
// the boundary `1 - h`.
|
||||
const result = clampZoneMove(baseGeom, 100, 100, 500, 250);
|
||||
expect(result.x).toBeCloseTo(0.3, 10);
|
||||
expect(result.y).toBe(1 - baseGeom.h); // 0.6
|
||||
});
|
||||
|
||||
it("returns only { x, y } — width / height are preserved by the caller", () => {
|
||||
const out = clampZoneMove(baseGeom, 0, 0, W, H);
|
||||
expect(out).toEqual({ x: 0.1, y: 0.2 });
|
||||
expect("w" in out).toBe(false);
|
||||
expect("h" in out).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// IMP-51 (#79) u9 — image overlay resize / move math.
|
||||
// Boundary contract (must match the inline u8 math Codex #16 verified):
|
||||
// • slide-bound invariant — x+w ≤ 100 ∧ y+h ≤ 100 for ALL valid inputs,
|
||||
// including small-near-edge geoms where the existing minSize floor
|
||||
// would otherwise have pushed past the slide bound.
|
||||
// • aspect-locked corner — baseAspect = startGeom.w / startGeom.h is
|
||||
// preserved exactly; the wFloor uses `min(minSize, maxW, maxH*baseAspect)`
|
||||
// so a floor application never violates either axis.
|
||||
// The two concrete Codex #15 reproductions are encoded explicitly below
|
||||
// so a future regression on the boundary math fails this suite directly.
|
||||
describe("IMAGE_RESIZE_MIN_SIZE_PERCENT", () => {
|
||||
it("is 2 (percent of slide bbox)", () => {
|
||||
expect(IMAGE_RESIZE_MIN_SIZE_PERCENT).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clampImagePercentGeometry", () => {
|
||||
const baseGeom: ImagePercentGeom = { x: 10, y: 10, w: 20, h: 10 };
|
||||
|
||||
describe("direction = 'move'", () => {
|
||||
it("translates and clamps both axes; preserves w/h", () => {
|
||||
expect(
|
||||
clampImagePercentGeometry(baseGeom, 5, 7, "move", false),
|
||||
).toEqual({ x: 15, y: 17, w: 20, h: 10 });
|
||||
});
|
||||
|
||||
it("clamps negative deltas to (0, 0)", () => {
|
||||
expect(
|
||||
clampImagePercentGeometry(baseGeom, -1000, -1000, "move", false),
|
||||
).toEqual({ x: 0, y: 0, w: 20, h: 10 });
|
||||
});
|
||||
|
||||
it("clamps max-edge deltas to (100 - w, 100 - h)", () => {
|
||||
expect(
|
||||
clampImagePercentGeometry(baseGeom, 1000, 1000, "move", false),
|
||||
).toEqual({ x: 80, y: 90, w: 20, h: 10 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge resize — independent per-axis clamp", () => {
|
||||
it("right edge clamps width to 100 - startGeom.x", () => {
|
||||
const out = clampImagePercentGeometry(baseGeom, 1000, 0, "right", false);
|
||||
expect(out).toEqual({ x: 10, y: 10, w: 90, h: 10 });
|
||||
expect(out.x + out.w).toBeLessThanOrEqual(100);
|
||||
});
|
||||
|
||||
it("left drag dx=-100 emits {x:0,y:10,w:30,h:10} (Codex regression)", () => {
|
||||
// From Codex #15 / #16 verification — ordinary left drag past the
|
||||
// slide edge should pin x at 0 and grow w by the original x amount.
|
||||
expect(
|
||||
clampImagePercentGeometry(baseGeom, -100, 0, "left", false),
|
||||
).toEqual({ x: 0, y: 10, w: 30, h: 10 });
|
||||
});
|
||||
|
||||
it("near-edge right resize keeps x + w ≤ 100 (Codex #15 reproduction)", () => {
|
||||
// Pre-fix: minSize=2 floor applied AFTER span clamp would emit
|
||||
// {x:99, w:2} so x+w=101. Post-fix: floor caps at maxW=1.
|
||||
const start: ImagePercentGeom = { x: 99, y: 10, w: 0.5, h: 10 };
|
||||
const out = clampImagePercentGeometry(start, 1, 0, "right", false);
|
||||
expect(out).toEqual({ x: 99, y: 10, w: 1, h: 10 });
|
||||
expect(out.x + out.w).toBe(100);
|
||||
});
|
||||
|
||||
it("top/bottom edges are symmetric to left/right", () => {
|
||||
const bottom = clampImagePercentGeometry(baseGeom, 0, 1000, "bottom", false);
|
||||
expect(bottom).toEqual({ x: 10, y: 10, w: 20, h: 90 });
|
||||
const top = clampImagePercentGeometry(baseGeom, 0, -100, "top", false);
|
||||
expect(top).toEqual({ x: 10, y: 0, w: 20, h: 20 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("corner resize — aspect locked (default Shift-off)", () => {
|
||||
it("NW drag dx=-100,dy=-100 emits {x:0,y:5,w:30,h:15} (Codex regression)", () => {
|
||||
// From Codex #16 verification — aspect-locked NW past the slide
|
||||
// edge: rightEdge=30, bottomEdge=20, baseAspect=2. Independent
|
||||
// clamps give x=0,w=30,y=0,h=20. Aspect block then picks the
|
||||
// limiting axis: newH = 30/2 = 15 (≤20). Re-anchor: y = 20 - 15 = 5.
|
||||
expect(
|
||||
clampImagePercentGeometry(baseGeom, -100, -100, "nw", true),
|
||||
).toEqual({ x: 0, y: 5, w: 30, h: 15 });
|
||||
});
|
||||
|
||||
it("tiny near-corner NE resize stays within bounds (Codex #15 reproduction)", () => {
|
||||
// Pre-fix: dual-axis minSize floor would emit w=2, h=2 with
|
||||
// re-anchor pushing x+w past 100. Post-fix: wFloor caps at
|
||||
// min(2, maxW=1, maxH*baseAspect=1) = 1, so newW=1, newH=1.
|
||||
const start: ImagePercentGeom = { x: 99, y: 99, w: 0.5, h: 0.5 };
|
||||
const out = clampImagePercentGeometry(start, 1, -1, "ne", true);
|
||||
expect(out).toEqual({ x: 99, y: 98.5, w: 1, h: 1 });
|
||||
expect(out.x + out.w).toBeLessThanOrEqual(100);
|
||||
expect(out.y + out.h).toBeLessThanOrEqual(100);
|
||||
});
|
||||
|
||||
it("preserves baseAspect exactly when the floor is hit", () => {
|
||||
// 2:1 aspect ratio (w=20, h=10); large negative drag past edges
|
||||
// hits wFloor. newW/newH ratio must equal baseAspect.
|
||||
const out = clampImagePercentGeometry(
|
||||
baseGeom, -1000, -1000, "nw", true,
|
||||
);
|
||||
expect(out.w / out.h).toBeCloseTo(baseGeom.w / baseGeom.h, 10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("corner resize — Shift unlock (independent edges)", () => {
|
||||
it("SE without aspect lock degenerates to right + bottom edges", () => {
|
||||
const corner = clampImagePercentGeometry(baseGeom, 1000, 1000, "se", false);
|
||||
const sides = clampImagePercentGeometry(
|
||||
clampImagePercentGeometry(baseGeom, 1000, 0, "right", false),
|
||||
0, 1000, "bottom", false,
|
||||
);
|
||||
expect(corner).toEqual(sides);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
// IMP-54 u3 — pure drag math extracted from SlideCanvas.tsx
|
||||
// `handleZoneMouseDown` (`Front/client/src/components/SlideCanvas.tsx:537-598`).
|
||||
//
|
||||
// Resize math (`makeResizeHandler` at SlideCanvas.tsx:465-523) is intentionally
|
||||
// NOT touched — it has its own independent geometry model (per-side
|
||||
// `affectsLeft/Right/Top/Bottom`, `minSize`, `1 - startGeom.x/y` cap) that
|
||||
// must not regress.
|
||||
//
|
||||
// Two responsibilities live here:
|
||||
//
|
||||
// 1. Drag-vs-click classification — a pointer must travel more than
|
||||
// `DRAG_THRESHOLD_PX` (Euclidean distance from the mousedown origin)
|
||||
// before mousedown→mousemove is treated as a drag. Below the
|
||||
// threshold the gesture stays a click, which the caller surfaces as
|
||||
// `onZoneClick(zone.id)` in `onUp`.
|
||||
//
|
||||
// 2. Pixel-delta → slide-body fraction conversion plus clamp to keep the
|
||||
// moved zone fully inside the slide body. Width/height are preserved
|
||||
// verbatim by this helper — only `x` and `y` move.
|
||||
//
|
||||
// Both helpers are pure (no React, no DOM, no side effects) so vitest can
|
||||
// drive them directly. The numeric contract is the inline behavior that
|
||||
// existed before the extraction; this file is a relocation, not a behavior
|
||||
// change.
|
||||
|
||||
export const DRAG_THRESHOLD_PX = 5;
|
||||
|
||||
// IMP-51 (#79) u9 — image overlay resize / move math extracted from
|
||||
// SlideCanvas.tsx `beginDrag` onMove (lines 1092–1219 of the u8 patch).
|
||||
// Slide-absolute percent coordinate space (0–100 on both axes), matching
|
||||
// the persisted `image_overrides` axis (`src/user_overrides_io.py` u1
|
||||
// KNOWN_AXES) and the typed client `ImageOverride` shape (`userOverridesApi.ts`
|
||||
// u3). The math is the contract Codex #16 verified post-u8 — this file
|
||||
// is a relocation, not a behavior change. SlideCanvas calls it from a
|
||||
// single hook so future tweaks need to update one place + the vitest
|
||||
// suite alongside.
|
||||
export const IMAGE_RESIZE_MIN_SIZE_PERCENT = 2;
|
||||
|
||||
/** Image overlay geometry in slide-absolute percent (each component ∈ [0, 100]).
|
||||
* Mirrors `ImageOverride` from `services/userOverridesApi.ts` (u3) so this
|
||||
* shape moves end-to-end through stamper → overlay → persisted axis. */
|
||||
export interface ImagePercentGeom {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export type ImageDragDirection =
|
||||
| "move"
|
||||
| "left"
|
||||
| "right"
|
||||
| "top"
|
||||
| "bottom"
|
||||
| "nw"
|
||||
| "ne"
|
||||
| "sw"
|
||||
| "se";
|
||||
|
||||
/** Apply a percent-space drag delta to `startGeom` per `direction` and clamp.
|
||||
*
|
||||
* Contract (must match the inline u8 math Codex #16 verified):
|
||||
* • `direction === "move"` → translate only; w/h preserved verbatim;
|
||||
* x/y clamped to `[0, 100 - w]` and `[0, 100 - h]`.
|
||||
* • Edge handle (`left|right|top|bottom`) → one axis only; opposite
|
||||
* edge pinned so x+w ≤ 100 and y+h ≤ 100 hold.
|
||||
* • Corner handle (`nw|ne|sw|se`) with `aspectLocked=false` → two
|
||||
* independent edges (same per-edge clamp as above).
|
||||
* • Corner handle with `aspectLocked=true` → preserves
|
||||
* `baseAspect = startGeom.w / startGeom.h`; the pinned-opposite-corner
|
||||
* stays fixed; the floored axis is `w` and `h` is re-derived so the
|
||||
* aspect ratio is exact even at the minSize floor.
|
||||
*
|
||||
* `minSize` is best-effort: when the available span (e.g. `100 - startGeom.x`
|
||||
* for `affectsRight`) is below `minSize`, the floor caps at the span itself
|
||||
* so the slide-bound invariant (x+w ≤ 100 ∧ y+h ≤ 100) is never violated.
|
||||
* Pure / deterministic / no DOM access — vitest drives it directly. */
|
||||
export function clampImagePercentGeometry(
|
||||
startGeom: ImagePercentGeom,
|
||||
dxPercent: number,
|
||||
dyPercent: number,
|
||||
direction: ImageDragDirection,
|
||||
aspectLocked: boolean,
|
||||
minSize: number = IMAGE_RESIZE_MIN_SIZE_PERCENT,
|
||||
): ImagePercentGeom {
|
||||
if (direction === "move") {
|
||||
const x = Math.max(0, Math.min(100 - startGeom.w, startGeom.x + dxPercent));
|
||||
const y = Math.max(0, Math.min(100 - startGeom.h, startGeom.y + dyPercent));
|
||||
return { x, y, w: startGeom.w, h: startGeom.h };
|
||||
}
|
||||
|
||||
const affectsLeft =
|
||||
direction === "left" || direction === "nw" || direction === "sw";
|
||||
const affectsRight =
|
||||
direction === "right" || direction === "ne" || direction === "se";
|
||||
const affectsTop =
|
||||
direction === "top" || direction === "nw" || direction === "ne";
|
||||
const affectsBottom =
|
||||
direction === "bottom" || direction === "sw" || direction === "se";
|
||||
const isCorner =
|
||||
direction === "nw" ||
|
||||
direction === "ne" ||
|
||||
direction === "sw" ||
|
||||
direction === "se";
|
||||
|
||||
const rightEdge = startGeom.x + startGeom.w;
|
||||
const bottomEdge = startGeom.y + startGeom.h;
|
||||
let x = startGeom.x;
|
||||
let y = startGeom.y;
|
||||
let w = startGeom.w;
|
||||
let h = startGeom.h;
|
||||
|
||||
if (affectsRight) {
|
||||
const maxW = 100 - startGeom.x;
|
||||
const floor = Math.min(minSize, maxW);
|
||||
w = Math.max(floor, Math.min(maxW, startGeom.w + dxPercent));
|
||||
}
|
||||
if (affectsBottom) {
|
||||
const maxH = 100 - startGeom.y;
|
||||
const floor = Math.min(minSize, maxH);
|
||||
h = Math.max(floor, Math.min(maxH, startGeom.h + dyPercent));
|
||||
}
|
||||
if (affectsLeft) {
|
||||
const floor = Math.min(minSize, rightEdge);
|
||||
x = Math.max(0, Math.min(rightEdge - floor, startGeom.x + dxPercent));
|
||||
w = rightEdge - x;
|
||||
}
|
||||
if (affectsTop) {
|
||||
const floor = Math.min(minSize, bottomEdge);
|
||||
y = Math.max(0, Math.min(bottomEdge - floor, startGeom.y + dyPercent));
|
||||
h = bottomEdge - y;
|
||||
}
|
||||
|
||||
if (isCorner && aspectLocked) {
|
||||
const baseAspect =
|
||||
startGeom.w > 0 && startGeom.h > 0 ? startGeom.w / startGeom.h : 1;
|
||||
if (baseAspect > 0) {
|
||||
const maxW = affectsLeft ? rightEdge : 100 - startGeom.x;
|
||||
const maxH = affectsTop ? bottomEdge : 100 - startGeom.y;
|
||||
let newW = w;
|
||||
let newH = newW / baseAspect;
|
||||
if (newH > maxH) {
|
||||
newH = maxH;
|
||||
newW = newH * baseAspect;
|
||||
}
|
||||
if (newW > maxW) {
|
||||
newW = maxW;
|
||||
newH = newW / baseAspect;
|
||||
}
|
||||
const wFloor = Math.min(minSize, maxW, maxH * baseAspect);
|
||||
if (newW < wFloor) {
|
||||
newW = wFloor;
|
||||
newH = newW / baseAspect;
|
||||
}
|
||||
w = newW;
|
||||
h = newH;
|
||||
x = affectsLeft ? rightEdge - w : startGeom.x;
|
||||
y = affectsTop ? bottomEdge - h : startGeom.y;
|
||||
}
|
||||
}
|
||||
|
||||
return { x, y, w, h };
|
||||
}
|
||||
|
||||
/** Returns true once the pointer has travelled far enough from the mousedown
|
||||
* origin to be treated as a drag rather than a click. */
|
||||
export function crossedDragThreshold(dxPx: number, dyPx: number): boolean {
|
||||
return Math.hypot(dxPx, dyPx) > DRAG_THRESHOLD_PX;
|
||||
}
|
||||
|
||||
/** Zone geometry in slide-body fraction space (each component ∈ [0, 1]).
|
||||
* Mirrors the shape the SlideCanvas pipeline already uses for
|
||||
* `localGeom` / `overrideGeom` / `onZoneResize` payloads. */
|
||||
export interface ZoneFracGeom {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
/** Convert a pixel-space drag delta into a slide-body fraction delta, apply
|
||||
* it to `startGeom.{x, y}`, and clamp so the zone never escapes the slide
|
||||
* body (`x ∈ [0, 1 - w]`, `y ∈ [0, 1 - h]`). `w` and `h` are not modified.
|
||||
*
|
||||
* The caller (`SlideCanvas.tsx` `handleZoneMouseDown` onMove) guarantees
|
||||
* `slideBodyWidthPx > 0` and `slideBodyHeightPx > 0` via the
|
||||
* `measuredSlideBody` precondition, so this helper does not re-guard
|
||||
* divide-by-zero. */
|
||||
export function clampZoneMove(
|
||||
startGeom: ZoneFracGeom,
|
||||
dxPx: number,
|
||||
dyPx: number,
|
||||
slideBodyWidthPx: number,
|
||||
slideBodyHeightPx: number,
|
||||
): { x: number; y: number } {
|
||||
const dx = dxPx / slideBodyWidthPx;
|
||||
const dy = dyPx / slideBodyHeightPx;
|
||||
const x = Math.max(0, Math.min(1 - startGeom.w, startGeom.x + dx));
|
||||
const y = Math.max(0, Math.min(1 - startGeom.h, startGeom.y + dy));
|
||||
return { x, y };
|
||||
}
|
||||
+396
-102
@@ -2,7 +2,7 @@
|
||||
* Home - 메인 페이지 (Zone-Centric 슬라이드 빌더)
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo, useEffect } from "react";
|
||||
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { DesignAgentState, LayoutPresetId, Zone } from "../types/designAgent";
|
||||
import {
|
||||
@@ -15,6 +15,13 @@ import {
|
||||
getSelectedRegion,
|
||||
moveSectionToZone,
|
||||
saveZoneSizes,
|
||||
saveImageOverride,
|
||||
saveTextOverride,
|
||||
saveStructureOverride,
|
||||
deriveUserOverridesKey,
|
||||
applyPersistedNonFrameOverrides,
|
||||
remapPersistedFramesToZoneFrames,
|
||||
validateZoneGeometriesAgainstLayout,
|
||||
} from "../utils/slidePlanUtils";
|
||||
import {
|
||||
parseMdxFile,
|
||||
@@ -25,13 +32,20 @@ import {
|
||||
type RunMeta,
|
||||
type PipelineOverrides,
|
||||
} from "../services/designAgentApi";
|
||||
import {
|
||||
flushUserOverrides,
|
||||
getUserOverrides,
|
||||
saveUserOverrides,
|
||||
type UserOverrides,
|
||||
} from "../services/userOverridesApi";
|
||||
|
||||
import LeftMdxPanel from "../components/LeftMdxPanel";
|
||||
import SlideCanvas from "../components/SlideCanvas";
|
||||
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";
|
||||
@@ -63,6 +77,14 @@ export default function Home() {
|
||||
// section drag drop + frame 선택). null 이면 평소 모드 (final.html 표시).
|
||||
const [pendingLayout, setPendingLayout] = useState<LayoutPresetId | null>(null);
|
||||
|
||||
// IMP-52 u6 — restore-on-reopen: persisted user_overrides.json fetched at
|
||||
// handleFileUpload time. layout / zone_geometries / zone_sections are
|
||||
// seeded into userSelection immediately (so handleGenerate forwards them
|
||||
// as CLI args). frames are stashed here because their on-disk key
|
||||
// (unit_id = section_ids joined by "+") only maps to region.id after
|
||||
// loadRun rebuilds the slidePlan — see handleGenerate post-loadRun.
|
||||
const persistedOverridesRef = useRef<Partial<UserOverrides>>({});
|
||||
|
||||
// pendingLayout 활성 시 effective slidePlan = pendingZones 가 swap 된 plan.
|
||||
// 그 외 = default state.slidePlan. 모든 zone / region lookup (handleFrameSelect /
|
||||
// getSelectedZone / SlideCanvas) 이 일관되게 이 effectiveSlidePlan 사용.
|
||||
@@ -136,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: {
|
||||
@@ -144,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,
|
||||
@@ -158,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);
|
||||
}, []);
|
||||
|
||||
@@ -180,7 +256,19 @@ export default function Home() {
|
||||
|
||||
try {
|
||||
const content = await parseMdxFile(file);
|
||||
setState((p) => ({ ...p, normalizedContent: content, isLoading: false }));
|
||||
// IMP-52 u6 — restore-on-reopen. Key = MDX stem (matches backend
|
||||
// u2 fallback's Path(args.mdx_path).stem). getUserOverrides returns
|
||||
// {} on miss / corrupt / network failure (u5 contract) so the upload
|
||||
// path never fails on a fresh MDX.
|
||||
const overridesKey = deriveUserOverridesKey(file.name);
|
||||
const persisted = await getUserOverrides(overridesKey);
|
||||
persistedOverridesRef.current = persisted;
|
||||
setState((p) => ({
|
||||
...p,
|
||||
normalizedContent: content,
|
||||
userSelection: applyPersistedNonFrameOverrides(p.userSelection, persisted),
|
||||
isLoading: false,
|
||||
}));
|
||||
toast.success(`"${file.name}" 분석 완료 — 하단 버튼으로 슬라이드 생성하세요.`);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -195,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)}`);
|
||||
@@ -257,9 +329,11 @@ export default function Home() {
|
||||
const overrides: PipelineOverrides = {};
|
||||
const sourcePlan = effectiveSlidePlan;
|
||||
if (sourcePlan && state.slidePlan) {
|
||||
const defaultLayout = state.slidePlan.layout_preset;
|
||||
// 2026-05-22 demo hot-fix — 이전 비교 가드 (default !== override) 제거.
|
||||
// restore loop 이 default = override 로 sync 시 override 안 보내고 backend
|
||||
// default fallback 발생. user 가 명시한 layout 이 있으면 무조건 보냄.
|
||||
const overrideLayout = state.userSelection.overrides.layout_preset;
|
||||
if (overrideLayout && overrideLayout !== defaultLayout) {
|
||||
if (overrideLayout) {
|
||||
overrides.layout = overrideLayout;
|
||||
}
|
||||
const frames: Record<string, string> = {};
|
||||
@@ -297,37 +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;
|
||||
}
|
||||
}
|
||||
|
||||
// IMP-08 B-3 : zoneSections forward only when the user diverged from
|
||||
// the auto plan. Codex Stage 3 R3 B3 fix : `createInitialUserSelection`
|
||||
// seeds `zone_sections` with the default placement, so a literal copy
|
||||
// would pollute backend assignment-source provenance even on a fresh
|
||||
// re-render. Diff against `sourcePlan.zones[].section_ids` per zone and
|
||||
// only emit zones whose section list differs.
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -349,6 +455,20 @@ export default function Home() {
|
||||
toast.info(`Phase Z 파이프라인 실행 중... ${overrideSummary}`);
|
||||
|
||||
try {
|
||||
// IMP-52 u10 — Force-commit any pending debounced PUTs before backend
|
||||
// reads user_overrides.json on pipeline entry. Without this, a user
|
||||
// who changes an override (300ms debounce window) and immediately
|
||||
// clicks Generate would race the PUT against /api/run; the u2
|
||||
// fallback could then load a stale persisted document.
|
||||
await flushUserOverrides();
|
||||
// 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) {
|
||||
@@ -362,20 +482,44 @@ export default function Home() {
|
||||
}
|
||||
|
||||
const { normalizedContent, slidePlan, runMeta } = await loadRun(result.run_id);
|
||||
setState((p) => ({
|
||||
...p,
|
||||
normalizedContent,
|
||||
// IMP-52 u6 — post-loadRun frame remap. persistedOverridesRef holds
|
||||
// the user_overrides.json read at handleFileUpload time. Frames there
|
||||
// are keyed by unit_id (section_ids joined by "+"); the in-memory
|
||||
// zone_frames is keyed by region.id. Remap against the new slidePlan
|
||||
// zones so SlideCanvas's override-vs-default preview indicator shows
|
||||
// the user's persisted choice without forcing them to re-click.
|
||||
const restoredZoneFrames = remapPersistedFramesToZoneFrames(
|
||||
slidePlan,
|
||||
userSelection: createInitialUserSelection(slidePlan),
|
||||
isLoading: false,
|
||||
}));
|
||||
persistedOverridesRef.current.frames as Record<string, string> | undefined,
|
||||
);
|
||||
setState((p) => {
|
||||
// IMP-52 u6 — restore-on-reopen: re-layer the persisted non-frame
|
||||
// axes (layout / zone_geometries / zone_sections) onto the post-load
|
||||
// `base`. `createInitialUserSelection` rebuilds from slidePlan and
|
||||
// drops anything the backend fallback could not round-trip through
|
||||
// a CLI arg — `zone_geometries` in particular has no slidePlan
|
||||
// representation, so without this merge the user would see their
|
||||
// resized zones revert on every Generate.
|
||||
const base = applyPersistedNonFrameOverrides(
|
||||
createInitialUserSelection(slidePlan),
|
||||
persistedOverridesRef.current,
|
||||
);
|
||||
return {
|
||||
...p,
|
||||
normalizedContent,
|
||||
slidePlan,
|
||||
userSelection: {
|
||||
...base,
|
||||
overrides: {
|
||||
...base.overrides,
|
||||
zone_frames: { ...base.overrides.zone_frames, ...restoredZoneFrames },
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
};
|
||||
});
|
||||
setRunMeta(runMeta);
|
||||
toast.success(`run "${result.run_id}" 완료 — ${runMeta.status}`);
|
||||
// IMP-47B u11 — surface Step 12 AI repair failure axes (error /
|
||||
// coverage_violated / unsupported_kind) as a human_review notification.
|
||||
// Auto-pipeline first ([[feedback_auto_pipeline_first]]): no review_queue
|
||||
// insertion — just an explicit error toast directing the user to pick
|
||||
// another frame or edit manually. Helper returns null on success path.
|
||||
const aiReviewMsg = formatAiRepairHumanReviewMessage(runMeta.ai_repair_status);
|
||||
if (aiReviewMsg) toast.error(aiReviewMsg);
|
||||
} catch (err) {
|
||||
@@ -391,10 +535,40 @@ export default function Home() {
|
||||
const handleSectionDrop = useCallback((sectionId: string, zoneId: string) => {
|
||||
setState((p) => {
|
||||
const newSelection = moveSectionToZone(p.userSelection, sectionId, zoneId);
|
||||
return {
|
||||
...p,
|
||||
userSelection: selectZone(newSelection, zoneId) // 이동된 존 자동 선택
|
||||
const 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
|
||||
// replaces this axis atomically while preserving the foreign axes.
|
||||
// p.uploadedFile gate skips persistence before any MDX is loaded —
|
||||
// the demo-mode initial render path would otherwise PUT to the empty
|
||||
// key. saveUserOverrides is debounced (300ms) and per-key coalesced.
|
||||
// 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 };
|
||||
});
|
||||
setRightTab("frame");
|
||||
setHasPendingChanges(true);
|
||||
@@ -420,10 +594,18 @@ export default function Home() {
|
||||
|
||||
// ── Layout 선택 ──
|
||||
const handleLayoutSelect = useCallback((layoutId: string) => {
|
||||
setState((p) => ({
|
||||
...p,
|
||||
userSelection: applyLayout(p.userSelection, layoutId as LayoutPresetId)
|
||||
}));
|
||||
setState((p) => {
|
||||
const newSelection = applyLayout(p.userSelection, layoutId as LayoutPresetId);
|
||||
// IMP-52 u7 — persist the selected layout preset id. The on-disk
|
||||
// `layout` axis is a single string; `applyLayout` validates the
|
||||
// preset id before mutating the selection, so the value here is
|
||||
// already the LayoutPresetId we want to round-trip.
|
||||
if (p.uploadedFile) {
|
||||
const key = deriveUserOverridesKey(p.uploadedFile.name);
|
||||
void saveUserOverrides(key, { layout: layoutId });
|
||||
}
|
||||
return { ...p, userSelection: newSelection };
|
||||
});
|
||||
setHasPendingChanges(true);
|
||||
}, []);
|
||||
|
||||
@@ -436,22 +618,64 @@ export default function Home() {
|
||||
}, []);
|
||||
|
||||
const handleZoneResize = useCallback((geometries: Record<string, { x: number; y: number; w: number; h: number }>) => {
|
||||
setState((p) => ({
|
||||
...p,
|
||||
userSelection: {
|
||||
...p.userSelection,
|
||||
overrides: {
|
||||
...p.userSelection.overrides,
|
||||
zone_geometries: {
|
||||
...p.userSelection.overrides.zone_geometries,
|
||||
...geometries
|
||||
}
|
||||
}
|
||||
setState((p) => {
|
||||
const mergedGeometries = {
|
||||
...p.userSelection.overrides.zone_geometries,
|
||||
...geometries,
|
||||
};
|
||||
// IMP-52 u7 — persist the merged zone_geometries snapshot. Resize
|
||||
// gestures fire repeatedly during a drag; the 300ms u5 debounce
|
||||
// collapses them into a single PUT at gesture-end, so we don't
|
||||
// need to gate on resize-finished here.
|
||||
if (p.uploadedFile) {
|
||||
const key = deriveUserOverridesKey(p.uploadedFile.name);
|
||||
void saveUserOverrides(key, { zone_geometries: mergedGeometries });
|
||||
}
|
||||
}));
|
||||
return {
|
||||
...p,
|
||||
userSelection: {
|
||||
...p.userSelection,
|
||||
overrides: {
|
||||
...p.userSelection.overrides,
|
||||
zone_geometries: mergedGeometries,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
setHasPendingChanges(true);
|
||||
}, []);
|
||||
|
||||
// IMP-51 (#79) u10 — wire SlideCanvas's user-content image drag/resize
|
||||
// emit into the 5th persisted axis. Mirrors handleZoneResize exactly:
|
||||
// • merge the single (imageId → {x,y,w,h}) tick onto the prior
|
||||
// in-memory `image_overrides` map via the u11 `saveImageOverride`
|
||||
// helper so the immutable update path is shared with the test suite,
|
||||
// • forward the full merged snapshot through `saveUserOverrides`
|
||||
// (the u3 typed client) under the `image_overrides` key — the 300ms
|
||||
// debounce defined alongside `zone_geometries` collapses the
|
||||
// per-mousemove emits into one PUT at gesture-end,
|
||||
// • flip `hasPendingChanges` so the "선택대로 재생성하기" CTA appears.
|
||||
// Coordinates are slide-absolute percent (0–100) from u8/u9 — passed
|
||||
// through unchanged so the on-disk schema matches the SlideCanvas
|
||||
// overlay, the stamper selector (u4), and the render-time CSS
|
||||
// injector (u7) without any per-zone transform.
|
||||
const handleImageResize = useCallback(
|
||||
(imageId: string, geometry: { x: number; y: number; w: number; h: number }) => {
|
||||
setState((p) => {
|
||||
const nextSelection = saveImageOverride(p.userSelection, imageId, geometry);
|
||||
if (p.uploadedFile) {
|
||||
const key = deriveUserOverridesKey(p.uploadedFile.name);
|
||||
void saveUserOverrides(key, {
|
||||
image_overrides: nextSelection.overrides.image_overrides,
|
||||
});
|
||||
}
|
||||
return { ...p, userSelection: nextSelection };
|
||||
});
|
||||
setHasPendingChanges(true);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// 편집 모드 텍스트 변경 시 hasPendingChanges 활성. useCallback 으로 reference 안정화 —
|
||||
// SlideCanvas 의 useEffect 가 매번 rerun 안 하도록 (resize drag 매 mousemove 마다
|
||||
// re-render 시 useEffect retrigger → iframe contentEditable 재설정 = 매우 느림).
|
||||
@@ -459,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 로 들어가
|
||||
@@ -470,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);
|
||||
@@ -491,10 +749,40 @@ export default function Home() {
|
||||
return;
|
||||
}
|
||||
|
||||
setState((p) => ({
|
||||
...p,
|
||||
userSelection: applyFrame(p.userSelection, region.id, frameId)
|
||||
}));
|
||||
setState((p) => {
|
||||
const newSelection = applyFrame(p.userSelection, region.id, frameId);
|
||||
// IMP-52 u7 — persist frames keyed by `unit_id`. The on-disk schema
|
||||
// uses `unit_id = zone.section_ids.join("+")` (the same convention
|
||||
// handleGenerate uses when forwarding `overrides.frames` to the
|
||||
// backend CLI). `zone_frames` is keyed by region.id, so we walk
|
||||
// the effectiveSlidePlan zones to translate. Only true user
|
||||
// overrides are persisted — `createInitialUserSelection` pre-fills
|
||||
// `zone_frames[region.id]` with `region.frame_match_strategy.frame_id`
|
||||
// (backend default) for every region, so we mirror handleGenerate's
|
||||
// `overrideFrameId !== defaultFrameId` gate to avoid leaking defaults
|
||||
// into user_overrides.json. Zones with no sections are skipped.
|
||||
if (p.uploadedFile && effectiveSlidePlan) {
|
||||
const framesByUnitId: Record<string, string> = {};
|
||||
for (const z of effectiveSlidePlan.zones) {
|
||||
const r = z.internal_regions[0];
|
||||
if (!r) continue;
|
||||
if (!Array.isArray(z.section_ids) || z.section_ids.length === 0) continue;
|
||||
const unitId = z.section_ids.join("+");
|
||||
const overrideId = newSelection.overrides.zone_frames?.[r.id];
|
||||
const defaultFrameId = r.frame_match_strategy.frame_id;
|
||||
if (
|
||||
typeof overrideId === "string" &&
|
||||
overrideId.length > 0 &&
|
||||
overrideId !== defaultFrameId
|
||||
) {
|
||||
framesByUnitId[unitId] = overrideId;
|
||||
}
|
||||
}
|
||||
const key = deriveUserOverridesKey(p.uploadedFile.name);
|
||||
void saveUserOverrides(key, { frames: framesByUnitId });
|
||||
}
|
||||
return { ...p, userSelection: newSelection };
|
||||
});
|
||||
setHasPendingChanges(true);
|
||||
}, [effectiveSlidePlan, state.userSelection]);
|
||||
|
||||
@@ -611,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}
|
||||
@@ -627,6 +914,11 @@ export default function Home() {
|
||||
onSectionDrop={handleSectionDrop}
|
||||
onLayoutResize={handleLayoutResize}
|
||||
onZoneResize={handleZoneResize}
|
||||
imageOverrides={state.userSelection.overrides.image_overrides}
|
||||
onImageResize={handleImageResize}
|
||||
onTextEdit={handleTextEdit}
|
||||
structureOverrides={state.userSelection.overrides.structure_overrides}
|
||||
onStructureEdit={handleStructureEdit}
|
||||
/>
|
||||
</main>
|
||||
|
||||
@@ -669,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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// ─── IMP-41 u2 — application_mode helper (issue #70) ────────────────────────
|
||||
// Pure deterministic helpers for forwarding backend Step 9
|
||||
// `unit.application_candidates[]` to the FramePanel V4-label badge tooltip.
|
||||
//
|
||||
// Keyed by backend `application_mode` VALUE (NOT V4 label) — preserves the
|
||||
// AI-isolation contract: tooltip text is a read-only display of backend
|
||||
// authority, never re-derived on the frontend from V4 label.
|
||||
//
|
||||
// Source of truth = src/phase_z2_pipeline.py APPLICATION_MODE_BY_V4_LABEL
|
||||
// (:107-112) emitted via _application_candidates_for_unit() (:3071-3092)
|
||||
// onto unit.application_candidates[] in step09_application_plan.json.
|
||||
|
||||
/** Backend application_mode enumeration (verbatim from APPLICATION_MODE_BY_V4_LABEL). */
|
||||
export type ApplicationMode =
|
||||
| 'direct_insert'
|
||||
| 'same_frame_with_adjustment'
|
||||
| 'layout_or_region_change'
|
||||
| 'exclude';
|
||||
|
||||
/** Korean consequence phrases per issue #70 spec item #2. Keyed by mode VALUE. */
|
||||
export const APPLICATION_MODE_TOOLTIP_KR: Record<ApplicationMode, string> = {
|
||||
direct_insert: '코드 직접 적용',
|
||||
same_frame_with_adjustment: 'AI 보강 필요',
|
||||
layout_or_region_change: 'AI restructure 필요',
|
||||
exclude: 'render path 제외',
|
||||
};
|
||||
|
||||
/**
|
||||
* Compose the V4-label badge tooltip title. When `applicationMode` resolves
|
||||
* to a known mode the title shows the Korean consequence + raw mode token;
|
||||
* otherwise (undefined or unknown — legacy fixtures pre-IMP-32) it falls
|
||||
* back to the raw V4 label string per Stage 2 contract.
|
||||
*/
|
||||
export function buildBadgeTitle(
|
||||
label: string,
|
||||
applicationMode: string | undefined,
|
||||
): string {
|
||||
const consequence = applicationMode
|
||||
? APPLICATION_MODE_TOOLTIP_KR[applicationMode as ApplicationMode]
|
||||
: undefined;
|
||||
return consequence
|
||||
? `${consequence} (${applicationMode})`
|
||||
: `V4 label: ${label}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Map<template_id, applicationCandidate> from a Step 9
|
||||
* `unit.application_candidates[]` array. Entries with a non-string or empty
|
||||
* `template_id` are skipped. First occurrence wins on duplicate keys.
|
||||
* Pure — does NOT sort, slice, or filter by label/confidence.
|
||||
*/
|
||||
export function mergeApplicationCandidates(
|
||||
applicationCandidates: unknown,
|
||||
): Map<string, any> {
|
||||
const out = new Map<string, any>();
|
||||
if (!Array.isArray(applicationCandidates)) return out;
|
||||
for (const ac of applicationCandidates) {
|
||||
const key = (ac as any)?.template_id;
|
||||
if (typeof key === 'string' && key.length > 0 && !out.has(key)) {
|
||||
out.set(key, ac);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
MOCK_FRAME_CANDIDATES_SECTION1,
|
||||
} from "../data/mockDesignAgentData";
|
||||
|
||||
import { mergeApplicationCandidates } from "./applicationMode";
|
||||
|
||||
/** 네트워크 지연 시뮬레이션 */
|
||||
const simulateDelay = (ms: number = 800) =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms));
|
||||
@@ -223,10 +225,6 @@ export interface FilteredSectionReason {
|
||||
position?: string | null;
|
||||
}
|
||||
|
||||
// IMP-47B u11 — verbatim mirror of step20_slide_status.ai_repair_status (u8 schema).
|
||||
// Surfaces Step 12 AI repair outcomes so the frontend can render a
|
||||
// human_review notification when AI proposal validation, coverage, or call
|
||||
// itself failed. Enum / field names kept verbatim — no frontend redefinition.
|
||||
export interface AiRepairStatus {
|
||||
status: "ok" | "applied" | "unsupported_kind" | "coverage_violated" | "error" | string;
|
||||
counts: {
|
||||
@@ -237,6 +235,15 @@ export interface AiRepairStatus {
|
||||
unsupported_kind: number;
|
||||
error: number;
|
||||
};
|
||||
// IMP-92 u3 — per-kind operational error aggregates plumbed from Step 12
|
||||
// (u2 classify_operational_error). Optional for backward compatibility
|
||||
// with pre-u3 payloads — u5 formatter treats absence as silent.
|
||||
api_error_kinds?: {
|
||||
quota: number;
|
||||
billing: number;
|
||||
auth: number;
|
||||
other: number;
|
||||
};
|
||||
unsupported_kind_records: Array<{
|
||||
unit_index?: number | null;
|
||||
source_section_ids: string[];
|
||||
@@ -246,6 +253,8 @@ export interface AiRepairStatus {
|
||||
unit_index?: number | null;
|
||||
source_section_ids: string[];
|
||||
error: string;
|
||||
// IMP-92 u3 — per-record operational error kind (quota|billing|auth|other|null).
|
||||
api_error_kind?: string | null;
|
||||
}>;
|
||||
coverage_status: string;
|
||||
dropped_section_ids: string[];
|
||||
@@ -266,38 +275,35 @@ export interface RunMeta {
|
||||
layout_candidates: string[]; // step07 layout_candidates list
|
||||
region_layout_candidates_by_zone: Record<string, string[]>; // step08 placeholder
|
||||
display_strategy_candidates_by_zone: Record<string, string[]>; // step08 placeholder
|
||||
/** IMP-47B u11 — Step 12 AI repair outcome (u8 surfacing). null when
|
||||
* step20 omits the field (legacy runs / pipeline aborted before Step 12). */
|
||||
ai_repair_status: AiRepairStatus | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* IMP-47B u11 — Build the human_review notification text when Step 12 AI repair
|
||||
* reports a failure axis. Returns null when no notification is needed (success,
|
||||
* no AI invocation, or human_review_required=false). Pure function — no DOM, no
|
||||
* toast side-effect — so it can be unit-tested without React Testing Library.
|
||||
*
|
||||
* Failure axes mapped to user-facing text (verbatim policy from
|
||||
* IMP-47B #76 guardrail: "AI 호출 실패 / proposal validation 실패 / coverage 미달
|
||||
* → frontend 에 명확한 notification").
|
||||
*/
|
||||
// IMP-92 u5 — Operational-only AI repair message formatter.
|
||||
//
|
||||
// Per the #84 operational-vs-non-operational replacement-plan contract, this
|
||||
// returns a user-visible toast string ONLY when ai_repair_status carries one
|
||||
// of the three actionable Anthropic API error kinds plumbed by u3
|
||||
// (quota / billing / auth). Non-operational AI failures (validation,
|
||||
// coverage_violated, unsupported_kind, or generic "other" API errors) return
|
||||
// null so the auto-pipeline stays silent per feedback_auto_pipeline_first.
|
||||
// Messages mirror the issue body copy contract exactly (429/402/401 →
|
||||
// quota/billing/auth Korean strings).
|
||||
export function formatAiRepairHumanReviewMessage(
|
||||
ai: AiRepairStatus | null | undefined,
|
||||
): string | null {
|
||||
if (!ai || !ai.human_review_required) return null;
|
||||
if (ai.status === "error") {
|
||||
const n = ai.counts?.error ?? ai.error_records?.length ?? 0;
|
||||
return `AI 재구성 호출 실패 (${n}건) — 다른 frame 선택 또는 수동 편집 필요`;
|
||||
if (!ai) return null;
|
||||
const kinds = ai.api_error_kinds;
|
||||
if (!kinds) return null;
|
||||
if (kinds.quota > 0) {
|
||||
return `API quota 부족 — 충전 필요 (${kinds.quota}건)`;
|
||||
}
|
||||
if (ai.status === "coverage_violated") {
|
||||
const dropped = (ai.dropped_section_ids || []).join(", ");
|
||||
return `AI 재구성 후 콘텐츠 누락 (dropped: ${dropped || "?"}) — 다른 frame 선택 또는 수동 편집 필요`;
|
||||
if (kinds.billing > 0) {
|
||||
return `API billing 문제 — 결제 정보 확인 (${kinds.billing}건)`;
|
||||
}
|
||||
if (ai.status === "unsupported_kind") {
|
||||
const n = ai.counts?.unsupported_kind ?? ai.unsupported_kind_records?.length ?? 0;
|
||||
return `AI 제안 형식 미지원 (${n}건) — 다른 frame 선택 또는 수동 편집 필요`;
|
||||
if (kinds.auth > 0) {
|
||||
return `API key 무효 — .env 확인 (${kinds.auth}건)`;
|
||||
}
|
||||
return `AI 재구성 human_review 필요 (status: ${ai.status})`;
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface LoadRunResult {
|
||||
@@ -339,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) {
|
||||
@@ -559,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,
|
||||
@@ -570,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;
|
||||
@@ -580,31 +602,73 @@ 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-41 u2 — application_candidates enrichment (issue #70) ───────────
|
||||
|
||||
// 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
|
||||
// application_mode / auto_applicable / delegated_to derived from
|
||||
// APPLICATION_MODE_BY_V4_LABEL (:107-112). Enrichment ONLY — does NOT alter
|
||||
// candidate source priority, sorting, or TOP_N_FRAMES slicing.
|
||||
const applicationCandidates: any[] = Array.isArray(unit.application_candidates)
|
||||
? unit.application_candidates
|
||||
: [];
|
||||
const applicationModeMap = new Map<string, any>();
|
||||
applicationCandidates.forEach((ac: any) => {
|
||||
const key = ac?.template_id;
|
||||
if (typeof key === "string" && key.length > 0) {
|
||||
applicationModeMap.set(key, ac);
|
||||
}
|
||||
});
|
||||
// APPLICATION_MODE_BY_V4_LABEL (:107-112). Indexing delegated to the pure
|
||||
// helper `mergeApplicationCandidates` (services/applicationMode.ts) keyed
|
||||
// by template_id. Enrichment ONLY — does NOT alter candidate source
|
||||
// priority, sorting, or TOP_N_FRAMES slicing.
|
||||
const applicationModeMap = mergeApplicationCandidates(unit.application_candidates);
|
||||
const frameCandidates: FrameCandidate[] = v4Source
|
||||
.slice(0, TOP_N_FRAMES)
|
||||
.map((c: any) => {
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
// IMP-52 u5 — typed frontend client for `/api/user-overrides/:key` (GET + PUT).
|
||||
//
|
||||
// The on-disk schema (KNOWN_AXES) and endpoint contract are owned by:
|
||||
// • src/user_overrides_io.py (Python — backend pipeline fallback, u1/u2)
|
||||
// • Front/vite.config.ts (handleGet/PutUserOverrides, u3/u4)
|
||||
// This module is the typed view used by Home.tsx restore-on-reopen (u6) and
|
||||
// the four mutation handlers (u7). It does NOT own the schema — any change
|
||||
// to KNOWN_AXES must land in u1/u4 first, then reflect here.
|
||||
//
|
||||
// IMP-51 (#79) u3 — added `image_overrides` (5th axis). `image_id` → percent-
|
||||
// of-slide {x,y,w,h}. Mirrors src/user_overrides_io.py KNOWN_AXES (u1) and
|
||||
// Front/vite.config.ts KNOWN_USER_OVERRIDES_AXES (u2). Backend stamper +
|
||||
// render-time CSS injection ride on u4~u7; the SlideCanvas drag/resize
|
||||
// handles that drive this axis ride on u8~u11.
|
||||
//
|
||||
// Contract (Stage 2 unit u5 summary):
|
||||
// • Typed `getUserOverrides(key)` → returns `Partial<UserOverrides>` from
|
||||
// the GET endpoint. Missing / corrupt / non-object payloads degrade to
|
||||
// `{}` so the frontend reopen flow never crashes on a fresh MDX.
|
||||
// • Typed `saveUserOverrides(key, partial)` → schedules a 300ms-debounced
|
||||
// PUT carrying ONLY the axes the user has mutated since the last flush.
|
||||
// Per-axis coalescing: a later call overwrites the same axis in the
|
||||
// pending payload; axes the user did not mutate are NOT sent (the
|
||||
// server-side merge in u4 preserves them on disk).
|
||||
// • Per-key debounce buckets — rapid edits to MDX "03" do not delay the
|
||||
// flush for MDX "04".
|
||||
// • Explicit clear sentinel: `partial[axis] = null` forwards to the PUT
|
||||
// body verbatim so u4 `mergeUserOverrides` can `delete` the axis on disk.
|
||||
// • `flushUserOverrides()` / `flushUserOverrides(key)` force an immediate
|
||||
// PUT (used by tests + Home.tsx Generate flow to ensure outstanding
|
||||
// writes commit before pipeline run).
|
||||
|
||||
const ENDPOINT_BASE = "/api/user-overrides";
|
||||
const DEBOUNCE_MS = 300;
|
||||
|
||||
// ── Schema (mirror of backend KNOWN_AXES; see header comment) ───────────────
|
||||
|
||||
/** unit_id → template_id. unit_id = source_section_ids joined by "+". */
|
||||
export type FramesOverride = Record<string, string>;
|
||||
|
||||
/** zone_id → 0-1 normalized geometry inside slide-body. */
|
||||
export type ZoneGeometryOverride = {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
};
|
||||
export type ZoneGeometriesOverride = Record<string, ZoneGeometryOverride>;
|
||||
|
||||
/** zone_id → ordered list of section_ids assigned to that zone. */
|
||||
export type ZoneSectionsOverride = Record<string, string[]>;
|
||||
|
||||
/**
|
||||
* IMP-51 #79 u3 — image_id → percent-of-slide geometry. Matches the user-
|
||||
* content image selector `.slide img[data-image-role="user-content"]`
|
||||
* (stamper in u4) and the render-time CSS injection map (u7). Coordinates
|
||||
* are slide-absolute percent (0–100) so SlideCanvas drag handles (u8~u11)
|
||||
* map 1:1 with the persisted axis without per-zone transforms.
|
||||
*/
|
||||
export type ImageOverride = {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
};
|
||||
export type ImageOverridesOverride = Record<string, ImageOverride>;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
frames: FramesOverride;
|
||||
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). */
|
||||
export type UserOverridesPartial = {
|
||||
[K in keyof UserOverrides]?: UserOverrides[K] | null;
|
||||
};
|
||||
|
||||
// ── Per-key debounce buckets ────────────────────────────────────────────────
|
||||
|
||||
type PendingBucket = {
|
||||
partial: UserOverridesPartial;
|
||||
timer: ReturnType<typeof setTimeout> | null;
|
||||
waiters: Array<{
|
||||
resolve: (merged: Partial<UserOverrides>) => void;
|
||||
reject: (err: unknown) => void;
|
||||
}>;
|
||||
};
|
||||
|
||||
const buckets = new Map<string, PendingBucket>();
|
||||
|
||||
function getBucket(key: string): PendingBucket {
|
||||
let b = buckets.get(key);
|
||||
if (!b) {
|
||||
b = { partial: {}, timer: null, waiters: [] };
|
||||
buckets.set(key, b);
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
// ── GET ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch the persisted user_overrides for `key` (MDX stem). Returns `{}` on
|
||||
* any failure mode (network error, 4xx/5xx, non-object body) so the caller
|
||||
* can use it unconditionally during MDX reopen without branching on
|
||||
* error paths.
|
||||
*/
|
||||
export async function getUserOverrides(
|
||||
key: string,
|
||||
): Promise<Partial<UserOverrides>> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${ENDPOINT_BASE}/${encodeURIComponent(key)}`, {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
if (!res.ok) return {};
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = await res.json();
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
return {};
|
||||
}
|
||||
return parsed as Partial<UserOverrides>;
|
||||
}
|
||||
|
||||
// ── PUT (debounced) ─────────────────────────────────────────────────────────
|
||||
|
||||
async function flushBucket(
|
||||
key: string,
|
||||
bucket: PendingBucket,
|
||||
): Promise<void> {
|
||||
const payload = bucket.partial;
|
||||
const waiters = bucket.waiters;
|
||||
bucket.partial = {};
|
||||
bucket.timer = null;
|
||||
bucket.waiters = [];
|
||||
|
||||
let merged: Partial<UserOverrides> = {};
|
||||
try {
|
||||
const res = await fetch(`${ENDPOINT_BASE}/${encodeURIComponent(key)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (res.ok) {
|
||||
try {
|
||||
const parsed = (await res.json()) as unknown;
|
||||
if (
|
||||
typeof parsed === "object" &&
|
||||
parsed !== null &&
|
||||
!Array.isArray(parsed)
|
||||
) {
|
||||
merged = parsed as Partial<UserOverrides>;
|
||||
}
|
||||
} catch {
|
||||
// server returned 200 with non-JSON body → treat as empty merged
|
||||
}
|
||||
} else {
|
||||
const err = new Error(`PUT ${ENDPOINT_BASE}/${key} → ${res.status}`);
|
||||
waiters.forEach((w) => w.reject(err));
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
waiters.forEach((w) => w.reject(err));
|
||||
return;
|
||||
}
|
||||
waiters.forEach((w) => w.resolve(merged));
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a debounced PUT to persist the mutated axes. Resolves with the
|
||||
* server-side merged document when the debounced PUT eventually fires.
|
||||
* Multiple rapid calls for the same `key` coalesce into a single PUT;
|
||||
* a later call's value for a given axis overrides an earlier pending value.
|
||||
* Calls for different `key`s are isolated.
|
||||
*/
|
||||
export function saveUserOverrides(
|
||||
key: string,
|
||||
partial: UserOverridesPartial,
|
||||
): Promise<Partial<UserOverrides>> {
|
||||
const bucket = getBucket(key);
|
||||
// Per-axis coalescing — later mutations replace earlier pending values.
|
||||
for (const axis of Object.keys(partial) as Array<keyof UserOverridesPartial>) {
|
||||
bucket.partial[axis] = partial[axis] as never;
|
||||
}
|
||||
const p = new Promise<Partial<UserOverrides>>((resolve, reject) => {
|
||||
bucket.waiters.push({ resolve, reject });
|
||||
});
|
||||
if (bucket.timer !== null) clearTimeout(bucket.timer);
|
||||
bucket.timer = setTimeout(() => {
|
||||
void flushBucket(key, bucket);
|
||||
}, DEBOUNCE_MS);
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-flush pending debounced writes. With no arg, flushes ALL pending
|
||||
* keys (used before pipeline runs so the backend reads the latest file).
|
||||
* With a key, flushes only that key's bucket.
|
||||
*
|
||||
* Resolves after every flushed bucket's PUT completes. Per-bucket errors
|
||||
* are swallowed at the flush level — the original caller's
|
||||
* saveUserOverrides() promise still rejects to its owner via the waiter.
|
||||
*/
|
||||
export async function flushUserOverrides(key?: string): Promise<void> {
|
||||
const targets: Array<[string, PendingBucket]> = [];
|
||||
if (key !== undefined) {
|
||||
const b = buckets.get(key);
|
||||
if (b && b.timer !== null) targets.push([key, b]);
|
||||
} else {
|
||||
buckets.forEach((b, k) => {
|
||||
if (b.timer !== null) targets.push([k, b]);
|
||||
});
|
||||
}
|
||||
const flushPromises = targets.map(([k, b]) => {
|
||||
if (b.timer !== null) {
|
||||
clearTimeout(b.timer);
|
||||
b.timer = null;
|
||||
}
|
||||
return flushBucket(k, b);
|
||||
});
|
||||
await Promise.all(flushPromises);
|
||||
}
|
||||
|
||||
/** Test-only — clears all pending buckets without firing PUTs. */
|
||||
export function __resetUserOverridesBuckets_FOR_TEST(): void {
|
||||
buckets.forEach((b) => {
|
||||
if (b.timer !== null) clearTimeout(b.timer);
|
||||
});
|
||||
buckets.clear();
|
||||
}
|
||||
@@ -206,6 +206,36 @@ export interface UserSelection {
|
||||
zone_sections: Record<string, string[]>; // zoneId -> sectionIds[]
|
||||
zone_sizes: Record<string, number[]>; // layoutGroupId -> [size1, size2, ...]
|
||||
zone_geometries: Record<string, { x: number; y: number; w: number; h: number }>; // zone_id -> geometry
|
||||
// IMP-51 (#79) u11 — image_id → slide-absolute percent geometry (0–100
|
||||
// on each axis). image_id is stamped by `src/image_id_stamper.py` (u4)
|
||||
// on user-content `<img>` tags; the same key is consumed by the u7 CSS
|
||||
// injector and the SlideCanvas u8 overlay. Shape mirrors the on-disk
|
||||
// `image_overrides` axis (KNOWN_AXES, src/user_overrides_io.py u1) and
|
||||
// the typed-client `ImageOverridesOverride` (services/userOverridesApi.ts u3).
|
||||
image_overrides: Record<string, { x: number; y: number; w: number; h: number }>;
|
||||
// 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,4 +1,206 @@
|
||||
import type { UserSelection, SlidePlan, Zone, InternalRegion, LayoutPresetId } from "../types/designAgent";
|
||||
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
|
||||
// the u5 service) onto the in-memory `UserSelection`. They live here rather
|
||||
// than inline in Home.tsx so vitest can drive them in a node environment
|
||||
// without booting React or pulling in the radix-ui / lucide UI deps that
|
||||
// Home.tsx requires. Home.tsx wires these into:
|
||||
// • handleFileUpload (pre-Generate layout / zone_geometries / zone_sections
|
||||
// seed so handleGenerate's CLI-args build picks them up)
|
||||
// • handleGenerate post-loadRun (frame remap unit_id → region.id over the
|
||||
// freshly built slidePlan)
|
||||
// The on-disk schema and clear-sentinel semantics are owned by:
|
||||
// • src/user_overrides_io.py (KNOWN_AXES, u1)
|
||||
// • Front/vite.config.ts mergeUserOverrides (u4)
|
||||
// • Front/client/src/services/userOverridesApi.ts (UserOverrides type, u5)
|
||||
// Any KNOWN_AXES drift must land in those files first.
|
||||
|
||||
/**
|
||||
* Derive the `/api/user-overrides/:key` MDX-stem key from a filename.
|
||||
* Strips a trailing `.mdx` (case-insensitive). The key matches the Python
|
||||
* `Path(args.mdx_path).stem` derivation used by the backend fallback (u2),
|
||||
* so the same persisted file is read from both ends without translation.
|
||||
*/
|
||||
export function deriveUserOverridesKey(filename: string): string {
|
||||
return filename.replace(/\.mdx$/i, "");
|
||||
}
|
||||
|
||||
const LAYOUT_PRESET_IDS = new Set<string>([
|
||||
"single",
|
||||
"horizontal-2",
|
||||
"vertical-2",
|
||||
"top-1-bottom-2",
|
||||
"top-2-bottom-1",
|
||||
"left-1-right-2",
|
||||
"left-2-right-1",
|
||||
"grid-2x2",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Layer the three non-frame axes from a persisted `user_overrides.json`
|
||||
* payload onto an existing `UserSelection`. Foreign / unrecognized payload
|
||||
* shapes are silently ignored — the u5 GET path already returns `{}` on
|
||||
* corrupt files, but we revalidate here so hand-edited files or future
|
||||
* forward-compat axes cannot poison the in-memory state.
|
||||
*
|
||||
* Frames are NOT layered here because the on-disk key (`unit_id` =
|
||||
* section_ids joined by `+`) only resolves after the slidePlan zones are
|
||||
* known. Use `remapPersistedFramesToZoneFrames` in the post-loadRun step.
|
||||
*/
|
||||
export function applyPersistedNonFrameOverrides(
|
||||
selection: UserSelection,
|
||||
persisted: Partial<UserOverrides> | null | undefined,
|
||||
): UserSelection {
|
||||
if (!persisted || typeof persisted !== "object") return selection;
|
||||
const next = { ...selection.overrides };
|
||||
if (typeof persisted.layout === "string" && LAYOUT_PRESET_IDS.has(persisted.layout)) {
|
||||
next.layout_preset = persisted.layout as LayoutPresetId;
|
||||
}
|
||||
if (
|
||||
persisted.zone_geometries &&
|
||||
typeof persisted.zone_geometries === "object" &&
|
||||
!Array.isArray(persisted.zone_geometries)
|
||||
) {
|
||||
next.zone_geometries = { ...persisted.zone_geometries };
|
||||
}
|
||||
if (
|
||||
persisted.zone_sections &&
|
||||
typeof persisted.zone_sections === "object" &&
|
||||
!Array.isArray(persisted.zone_sections)
|
||||
) {
|
||||
next.zone_sections = { ...persisted.zone_sections };
|
||||
}
|
||||
// IMP-51 (#79) u11 — layer the 5th persisted axis (`image_overrides`) by
|
||||
// the same array / non-object guard the zone_geometries branch uses. The
|
||||
// u3 typed client (services/userOverridesApi.ts) shape and the on-disk
|
||||
// KNOWN_AXES entry (src/user_overrides_io.py u1) are both flat dicts
|
||||
// (image_id → {x,y,w,h} percent-of-slide), so a shallow copy is enough.
|
||||
if (
|
||||
persisted.image_overrides &&
|
||||
typeof persisted.image_overrides === "object" &&
|
||||
!Array.isArray(persisted.image_overrides)
|
||||
) {
|
||||
next.image_overrides = { ...persisted.image_overrides };
|
||||
}
|
||||
// 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
|
||||
* slidePlan zones. `unit_id` follows handleGenerate's convention:
|
||||
* `zone.section_ids.join("+")`. Persisted entries whose unit_id no longer
|
||||
* matches any zone (e.g. user changed zone_sections between sessions) are
|
||||
* silently dropped.
|
||||
*/
|
||||
export function remapPersistedFramesToZoneFrames(
|
||||
slidePlan: SlidePlan | null | undefined,
|
||||
framesByUnitId: Record<string, string> | null | undefined,
|
||||
): Record<string, string> {
|
||||
if (!slidePlan || !framesByUnitId || typeof framesByUnitId !== "object") {
|
||||
return {};
|
||||
}
|
||||
const out: Record<string, string> = {};
|
||||
for (const zone of slidePlan.zones) {
|
||||
const region = zone.internal_regions[0];
|
||||
if (!region) continue;
|
||||
if (!Array.isArray(zone.section_ids) || zone.section_ids.length === 0) continue;
|
||||
const unitId = zone.section_ids.join("+");
|
||||
const templateId = framesByUnitId[unitId];
|
||||
if (typeof templateId === "string" && templateId.length > 0) {
|
||||
out[region.id] = templateId;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase Z 초기 선택 상태 생성
|
||||
@@ -39,13 +241,31 @@ export function createInitialUserSelection(slidePlan?: SlidePlan | null): UserSe
|
||||
zone_sections: initialSections,
|
||||
zone_sizes: {},
|
||||
zone_geometries: {},
|
||||
// IMP-51 (#79) u11 — image_overrides axis starts empty; entries land
|
||||
// here via `saveImageOverride` (SlideCanvas drag/resize handler) and
|
||||
// are seeded on reopen via `applyPersistedNonFrameOverrides`.
|
||||
image_overrides: {},
|
||||
// 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function saveZoneGeometry(
|
||||
selection: UserSelection,
|
||||
zoneId: string,
|
||||
selection: UserSelection,
|
||||
zoneId: string,
|
||||
geometry: { x: number; y: number; w: number; h: number }
|
||||
): UserSelection {
|
||||
return {
|
||||
@@ -60,6 +280,86 @@ export function saveZoneGeometry(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* IMP-51 (#79) u11 — record a single `image_id` → slide-absolute percent
|
||||
* geometry on the in-memory selection. Mirrors `saveZoneGeometry` but on
|
||||
* the 5th persisted axis (`image_overrides`); the SlideCanvas drag/resize
|
||||
* handler (u8) emits one entry per pointer move, and u10's Home wiring
|
||||
* funnels each emit through this helper before scheduling the debounced
|
||||
* PUT. Pure / immutable — returns a fresh `UserSelection`; the input is
|
||||
* never mutated. Existing entries for the same `imageId` are replaced.
|
||||
*/
|
||||
export function saveImageOverride(
|
||||
selection: UserSelection,
|
||||
imageId: string,
|
||||
geometry: { x: number; y: number; w: number; h: number },
|
||||
): UserSelection {
|
||||
return {
|
||||
...selection,
|
||||
overrides: {
|
||||
...selection.overrides,
|
||||
image_overrides: {
|
||||
...selection.overrides.image_overrides,
|
||||
[imageId]: geometry,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
@@ -174,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,123 @@
|
||||
// IMP-41 u3 — Vitest coverage for application_mode helper (issue #70).
|
||||
//
|
||||
// Scope (Stage 2 unit u3 contract):
|
||||
// 1) buildBadgeTitle: composite output for each known mode + legacy fallback
|
||||
// (undefined applicationMode) + unknown fallback (string not in
|
||||
// APPLICATION_MODE_TOOLTIP_KR).
|
||||
// 2) mergeApplicationCandidates: array → Map<template_id, candidate>
|
||||
// semantics, including skip-missing-key and empty-input.
|
||||
//
|
||||
// Pure helper unit test — no React, no DOM, no fetch. Aligns with the
|
||||
// AI-isolation contract: assertions key by backend application_mode VALUE,
|
||||
// never by V4 label.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
buildBadgeTitle,
|
||||
mergeApplicationCandidates,
|
||||
APPLICATION_MODE_TOOLTIP_KR,
|
||||
} from "../src/services/applicationMode";
|
||||
|
||||
describe("buildBadgeTitle (IMP-41 u3)", () => {
|
||||
it("returns composite '<consequence> (<mode>)' for direct_insert", () => {
|
||||
expect(buildBadgeTitle("use_as_is", "direct_insert")).toBe(
|
||||
`${APPLICATION_MODE_TOOLTIP_KR.direct_insert} (direct_insert)`,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns composite output for same_frame_with_adjustment", () => {
|
||||
expect(
|
||||
buildBadgeTitle("light_edit", "same_frame_with_adjustment"),
|
||||
).toBe(
|
||||
`${APPLICATION_MODE_TOOLTIP_KR.same_frame_with_adjustment} (same_frame_with_adjustment)`,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns composite output for layout_or_region_change", () => {
|
||||
expect(
|
||||
buildBadgeTitle("restructure", "layout_or_region_change"),
|
||||
).toBe(
|
||||
`${APPLICATION_MODE_TOOLTIP_KR.layout_or_region_change} (layout_or_region_change)`,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns composite output for exclude", () => {
|
||||
expect(buildBadgeTitle("reject", "exclude")).toBe(
|
||||
`${APPLICATION_MODE_TOOLTIP_KR.exclude} (exclude)`,
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to 'V4 label: <label>' when applicationMode is undefined (legacy fixtures pre-IMP-32)", () => {
|
||||
expect(buildBadgeTitle("use_as_is", undefined)).toBe("V4 label: use_as_is");
|
||||
});
|
||||
|
||||
it("falls back to 'V4 label: <label>' when applicationMode is an unknown string", () => {
|
||||
expect(buildBadgeTitle("light_edit", "some_future_mode")).toBe(
|
||||
"V4 label: light_edit",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeApplicationCandidates (IMP-41 u3)", () => {
|
||||
it("returns empty Map when input is undefined", () => {
|
||||
const result = mergeApplicationCandidates(undefined);
|
||||
expect(result).toBeInstanceOf(Map);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it("returns empty Map when input is null", () => {
|
||||
const result = mergeApplicationCandidates(null);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it("returns empty Map when input is not an array", () => {
|
||||
expect(mergeApplicationCandidates({ template_id: "f01" }).size).toBe(0);
|
||||
expect(mergeApplicationCandidates("f01").size).toBe(0);
|
||||
expect(mergeApplicationCandidates(42).size).toBe(0);
|
||||
});
|
||||
|
||||
it("returns empty Map when input is an empty array", () => {
|
||||
expect(mergeApplicationCandidates([]).size).toBe(0);
|
||||
});
|
||||
|
||||
it("keys entries by template_id and preserves the candidate payload", () => {
|
||||
const ac1 = {
|
||||
template_id: "f01",
|
||||
label: "use_as_is",
|
||||
application_mode: "direct_insert",
|
||||
auto_applicable: true,
|
||||
delegated_to: null,
|
||||
};
|
||||
const ac2 = {
|
||||
template_id: "f17",
|
||||
label: "light_edit",
|
||||
application_mode: "same_frame_with_adjustment",
|
||||
auto_applicable: false,
|
||||
delegated_to: "step10_contract_check",
|
||||
};
|
||||
const result = mergeApplicationCandidates([ac1, ac2]);
|
||||
expect(result.size).toBe(2);
|
||||
expect(result.get("f01")).toBe(ac1);
|
||||
expect(result.get("f17")).toBe(ac2);
|
||||
});
|
||||
|
||||
it("skips entries with missing or non-string template_id", () => {
|
||||
const result = mergeApplicationCandidates([
|
||||
{ label: "use_as_is" }, // missing template_id
|
||||
{ template_id: "", label: "light_edit" }, // empty string
|
||||
{ template_id: 17, label: "restructure" }, // non-string
|
||||
{ template_id: "f29", label: "reject" }, // valid
|
||||
]);
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.has("f29")).toBe(true);
|
||||
expect(result.has("")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the first occurrence on duplicate template_id keys (deterministic)", () => {
|
||||
const first = { template_id: "f01", label: "use_as_is" };
|
||||
const second = { template_id: "f01", label: "reject" };
|
||||
const result = mergeApplicationCandidates([first, second]);
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.get("f01")).toBe(first);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
// IMP-92 u5 — Frontend AI repair operational-only formatter test surface.
|
||||
//
|
||||
// Scope (Stage 2 unit u5 contract):
|
||||
// 1) formatAiRepairHumanReviewMessage(...) surfaces a user-facing toast
|
||||
// ONLY on the three operational Anthropic API error kinds (quota /
|
||||
// billing / auth) classified by Step 12 u2
|
||||
// (classify_operational_error) and aggregated through u3
|
||||
// ai_repair_status.api_error_kinds.
|
||||
// 2) Non-operational AI failures (validation / coverage_violated /
|
||||
// unsupported_kind / generic "other") return null so the
|
||||
// auto-pipeline stays silent per feedback_auto_pipeline_first and
|
||||
// the #84 operational-vs-non-operational replacement-plan contract.
|
||||
// 3) Replaces the prior IMP-47B u11 surface — previously rendered toasts
|
||||
// for error / coverage_violated / unsupported_kind. After IMP-92 the
|
||||
// ONLY operational reaches the user; non-operational stays silent.
|
||||
//
|
||||
// Pure-function unit test (no React Testing Library required — vitest is
|
||||
// already in devDependencies; @testing-library/* is NOT installed). The
|
||||
// Home.tsx wiring is a 2-line site (`Home.tsx:438`) that calls this helper
|
||||
// after `setRunMeta(...)`; covering the helper covers the user-visible
|
||||
// message text directly without DOM rendering.
|
||||
//
|
||||
// The test file path is preserved from IMP-47B u11 (Stage 2 plan
|
||||
// `Front/client/tests/imp47b_human_review_toast.test.tsx`); the assertions
|
||||
// inside reflect the IMP-92 u5 operational-only contract.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
formatAiRepairHumanReviewMessage,
|
||||
type AiRepairStatus,
|
||||
} from "../src/services/designAgentApi";
|
||||
|
||||
const baseCounts = {
|
||||
total: 0,
|
||||
applied: 0,
|
||||
no_proposal: 0,
|
||||
no_zone_match: 0,
|
||||
unsupported_kind: 0,
|
||||
error: 0,
|
||||
};
|
||||
|
||||
const zeroKinds = { quota: 0, billing: 0, auth: 0, other: 0 };
|
||||
|
||||
describe("formatAiRepairHumanReviewMessage (IMP-92 u5 — operational-only)", () => {
|
||||
it("returns null when ai_repair_status is null / undefined", () => {
|
||||
expect(formatAiRepairHumanReviewMessage(null)).toBeNull();
|
||||
expect(formatAiRepairHumanReviewMessage(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null on success / no-AI path (no operational kind present)", () => {
|
||||
const ok: AiRepairStatus = {
|
||||
status: "ok",
|
||||
counts: { ...baseCounts },
|
||||
api_error_kinds: { ...zeroKinds },
|
||||
unsupported_kind_records: [],
|
||||
error_records: [],
|
||||
coverage_status: "ok",
|
||||
dropped_section_ids: [],
|
||||
human_review_required: false,
|
||||
};
|
||||
expect(formatAiRepairHumanReviewMessage(ok)).toBeNull();
|
||||
|
||||
const applied: AiRepairStatus = {
|
||||
...ok,
|
||||
status: "applied",
|
||||
counts: { ...baseCounts, total: 1, applied: 1 },
|
||||
};
|
||||
expect(formatAiRepairHumanReviewMessage(applied)).toBeNull();
|
||||
});
|
||||
|
||||
it("surfaces quota operational alert (Anthropic 429 / RateLimitError)", () => {
|
||||
const ai: AiRepairStatus = {
|
||||
status: "error",
|
||||
counts: { ...baseCounts, total: 2, error: 2 },
|
||||
api_error_kinds: { quota: 2, billing: 0, auth: 0, other: 0 },
|
||||
unsupported_kind_records: [],
|
||||
error_records: [
|
||||
{
|
||||
unit_index: 0,
|
||||
source_section_ids: ["03-1"],
|
||||
error: "RateLimitError: rate_limit_exceeded",
|
||||
api_error_kind: "quota",
|
||||
},
|
||||
{
|
||||
unit_index: 1,
|
||||
source_section_ids: ["03-2"],
|
||||
error: "RateLimitError: rate_limit_exceeded",
|
||||
api_error_kind: "quota",
|
||||
},
|
||||
],
|
||||
coverage_status: "ok",
|
||||
dropped_section_ids: [],
|
||||
human_review_required: true,
|
||||
};
|
||||
const msg = formatAiRepairHumanReviewMessage(ai);
|
||||
expect(msg).not.toBeNull();
|
||||
expect(msg).toContain("API quota");
|
||||
expect(msg).toContain("충전 필요");
|
||||
expect(msg).toContain("2");
|
||||
});
|
||||
|
||||
it("surfaces billing operational alert (Anthropic 402 / PermissionDeniedError)", () => {
|
||||
const ai: AiRepairStatus = {
|
||||
status: "error",
|
||||
counts: { ...baseCounts, total: 1, error: 1 },
|
||||
api_error_kinds: { quota: 0, billing: 1, auth: 0, other: 0 },
|
||||
unsupported_kind_records: [],
|
||||
error_records: [
|
||||
{
|
||||
unit_index: 0,
|
||||
source_section_ids: ["03-1"],
|
||||
error: "PermissionDeniedError: insufficient credits",
|
||||
api_error_kind: "billing",
|
||||
},
|
||||
],
|
||||
coverage_status: "ok",
|
||||
dropped_section_ids: [],
|
||||
human_review_required: true,
|
||||
};
|
||||
const msg = formatAiRepairHumanReviewMessage(ai);
|
||||
expect(msg).not.toBeNull();
|
||||
expect(msg).toContain("API billing");
|
||||
expect(msg).toContain("결제 정보 확인");
|
||||
expect(msg).toContain("1");
|
||||
});
|
||||
|
||||
it("surfaces auth operational alert (Anthropic 401 / AuthenticationError)", () => {
|
||||
const ai: AiRepairStatus = {
|
||||
status: "error",
|
||||
counts: { ...baseCounts, total: 1, error: 1 },
|
||||
api_error_kinds: { quota: 0, billing: 0, auth: 1, other: 0 },
|
||||
unsupported_kind_records: [],
|
||||
error_records: [
|
||||
{
|
||||
unit_index: 0,
|
||||
source_section_ids: ["03-1"],
|
||||
error: "AuthenticationError: invalid x-api-key",
|
||||
api_error_kind: "auth",
|
||||
},
|
||||
],
|
||||
coverage_status: "ok",
|
||||
dropped_section_ids: [],
|
||||
human_review_required: true,
|
||||
};
|
||||
const msg = formatAiRepairHumanReviewMessage(ai);
|
||||
expect(msg).not.toBeNull();
|
||||
expect(msg).toContain("API key 무효");
|
||||
expect(msg).toContain(".env");
|
||||
expect(msg).toContain("1");
|
||||
});
|
||||
|
||||
it("returns null on generic non-operational 'other' API error (silent)", () => {
|
||||
const ai: AiRepairStatus = {
|
||||
status: "error",
|
||||
counts: { ...baseCounts, total: 1, error: 1 },
|
||||
api_error_kinds: { quota: 0, billing: 0, auth: 0, other: 1 },
|
||||
unsupported_kind_records: [],
|
||||
error_records: [
|
||||
{
|
||||
unit_index: 0,
|
||||
source_section_ids: ["03-1"],
|
||||
error: "ValidationError: proposal failed schema",
|
||||
api_error_kind: "other",
|
||||
},
|
||||
],
|
||||
coverage_status: "ok",
|
||||
dropped_section_ids: [],
|
||||
human_review_required: true,
|
||||
};
|
||||
expect(formatAiRepairHumanReviewMessage(ai)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null on coverage_violated (non-operational, silent)", () => {
|
||||
const ai: AiRepairStatus = {
|
||||
status: "coverage_violated",
|
||||
counts: { ...baseCounts, total: 1, applied: 1 },
|
||||
api_error_kinds: { ...zeroKinds },
|
||||
unsupported_kind_records: [],
|
||||
error_records: [],
|
||||
coverage_status: "violated",
|
||||
dropped_section_ids: ["03-2"],
|
||||
human_review_required: true,
|
||||
};
|
||||
expect(formatAiRepairHumanReviewMessage(ai)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null on unsupported_kind (non-operational, silent)", () => {
|
||||
const ai: AiRepairStatus = {
|
||||
status: "unsupported_kind",
|
||||
counts: { ...baseCounts, total: 1, unsupported_kind: 1 },
|
||||
api_error_kinds: { ...zeroKinds },
|
||||
unsupported_kind_records: [
|
||||
{
|
||||
unit_index: 0,
|
||||
source_section_ids: ["03-1"],
|
||||
apply_status: "unsupported_kind_for_reject_route:builder_options_patch",
|
||||
},
|
||||
],
|
||||
error_records: [],
|
||||
coverage_status: "ok",
|
||||
dropped_section_ids: [],
|
||||
human_review_required: true,
|
||||
};
|
||||
expect(formatAiRepairHumanReviewMessage(ai)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null on legacy ai_repair_status without api_error_kinds (pre-u3 runs)", () => {
|
||||
// Backward-compat: payloads emitted before u3 plumbing landed don't
|
||||
// carry api_error_kinds. Operational-only contract treats the absence
|
||||
// as "no operational signal" → silent (no toast).
|
||||
const legacy: AiRepairStatus = {
|
||||
status: "error",
|
||||
counts: { ...baseCounts, total: 1, error: 1 },
|
||||
// api_error_kinds intentionally omitted
|
||||
unsupported_kind_records: [],
|
||||
error_records: [
|
||||
{ unit_index: 0, source_section_ids: ["03-1"], error: "timeout" },
|
||||
],
|
||||
coverage_status: "ok",
|
||||
dropped_section_ids: [],
|
||||
human_review_required: true,
|
||||
};
|
||||
expect(formatAiRepairHumanReviewMessage(legacy)).toBeNull();
|
||||
});
|
||||
|
||||
it("prioritises quota when multiple operational kinds co-occur", () => {
|
||||
// Defensive: a run that accumulated quota + billing errors across
|
||||
// multiple AI repair attempts surfaces the quota line first (the
|
||||
// most-frequently actionable per the issue body ordering).
|
||||
const ai: AiRepairStatus = {
|
||||
status: "error",
|
||||
counts: { ...baseCounts, total: 2, error: 2 },
|
||||
api_error_kinds: { quota: 1, billing: 1, auth: 0, other: 0 },
|
||||
unsupported_kind_records: [],
|
||||
error_records: [
|
||||
{
|
||||
unit_index: 0,
|
||||
source_section_ids: ["03-1"],
|
||||
error: "RateLimitError",
|
||||
api_error_kind: "quota",
|
||||
},
|
||||
{
|
||||
unit_index: 1,
|
||||
source_section_ids: ["03-2"],
|
||||
error: "PermissionDeniedError",
|
||||
api_error_kind: "billing",
|
||||
},
|
||||
],
|
||||
coverage_status: "ok",
|
||||
dropped_section_ids: [],
|
||||
human_review_required: true,
|
||||
};
|
||||
const msg = formatAiRepairHumanReviewMessage(ai);
|
||||
expect(msg).not.toBeNull();
|
||||
expect(msg).toContain("API quota");
|
||||
});
|
||||
});
|
||||
@@ -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*\)/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,851 @@
|
||||
// IMP-52 u3/u4 — vitest coverage for the vite `/api/user-overrides/:key`
|
||||
// GET and PUT endpoints and their supporting helpers.
|
||||
//
|
||||
// Scope:
|
||||
// u3 (read path):
|
||||
// 1) isValidUserOverridesKey: accept MDX-stem keys (03, 03__DX_BIM,
|
||||
// a-b.c), reject empty / leading-dot / `..` / `/` / `\` /
|
||||
// disallowed chars. Mirrors src/user_overrides_io.validate_key so
|
||||
// backend (u2) and frontend endpoint (u3) agree on every key.
|
||||
// 2) userOverridesPath: returns <root>/data/user_overrides/<key>.json.
|
||||
// 3) handleGetUserOverrides: method != GET → false (next chained for
|
||||
// PUT); invalid key → 400; missing file → 200 {}; corrupt JSON /
|
||||
// non-object root → 200 {} (graceful degrade per u1 load contract);
|
||||
// valid object JSON → 200 with parsed payload echoed back.
|
||||
//
|
||||
// u4 (write path):
|
||||
// 4) mergeUserOverrides: only KNOWN_USER_OVERRIDES_AXES mutated;
|
||||
// foreign top-level keys preserved; null clears axis; non-axis
|
||||
// partial keys dropped (allowlist).
|
||||
// 5) atomicWriteUserOverrides: tmp + rename; parent dir auto-created.
|
||||
// 6) handlePutUserOverrides: method != PUT → false (next chained);
|
||||
// invalid key → 400; invalid JSON → 400; non-object body → 400;
|
||||
// success → 200 with merged result; partial-merge preserves axes
|
||||
// not in payload; foreign-key preserve on disk; allowlist drops
|
||||
// unknown payload keys; explicit null clears; corrupt existing →
|
||||
// recover to clean state.
|
||||
//
|
||||
// Tests exercise the pure handlers with mock req/res — no real vite server.
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
KNOWN_USER_OVERRIDES_AXES,
|
||||
USER_OVERRIDES_KEY_RE,
|
||||
atomicWriteUserOverrides,
|
||||
handleGetUserOverrides,
|
||||
handlePutUserOverrides,
|
||||
isValidUserOverridesKey,
|
||||
mergeUserOverrides,
|
||||
userOverridesPath,
|
||||
} from "../../vite.config";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mock res helper — captures writeHead(status, headers) + end(body) so the
|
||||
// handler can be invoked synchronously without spawning a TCP socket.
|
||||
// ---------------------------------------------------------------------------
|
||||
function makeMockRes() {
|
||||
const state = {
|
||||
statusCode: 0,
|
||||
headers: {} as Record<string, string>,
|
||||
body: "",
|
||||
ended: false,
|
||||
};
|
||||
return {
|
||||
state,
|
||||
res: {
|
||||
writeHead(status: number, headers?: Record<string, string>) {
|
||||
state.statusCode = status;
|
||||
if (headers) state.headers = headers;
|
||||
},
|
||||
end(body?: string) {
|
||||
state.body = body ?? "";
|
||||
state.ended = true;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("USER_OVERRIDES_KEY_RE (IMP-52 u3)", () => {
|
||||
it("matches Python validate_key regex literally", () => {
|
||||
// The pattern locked in src/user_overrides_io.py:_KEY_RE — any drift here
|
||||
// means backend pipeline fallback (u2) and the vite endpoint disagree on
|
||||
// which keys are routable, which is the single failure mode that would
|
||||
// silently lose persisted overrides.
|
||||
expect(USER_OVERRIDES_KEY_RE.source).toBe(
|
||||
"^[A-Za-z0-9_][A-Za-z0-9_.\\-]*$",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidUserOverridesKey (IMP-52 u3)", () => {
|
||||
it("accepts MDX-stem-style keys actually used in samples/mdx/", () => {
|
||||
// 03 / 04 / 05 are the wired sample MDXs (vite.config.ts:SAMPLE_MDX_MAP).
|
||||
expect(isValidUserOverridesKey("03")).toBe(true);
|
||||
expect(isValidUserOverridesKey("04")).toBe(true);
|
||||
expect(isValidUserOverridesKey("05")).toBe(true);
|
||||
// Stage 1 EVIDENCE references 03__DX_BIM... — must round-trip.
|
||||
expect(isValidUserOverridesKey("03__DX_BIM")).toBe(true);
|
||||
expect(isValidUserOverridesKey("a-b.c")).toBe(true);
|
||||
expect(isValidUserOverridesKey("a")).toBe(true);
|
||||
expect(isValidUserOverridesKey("_leading_underscore")).toBe(true);
|
||||
expect(isValidUserOverridesKey("9starts_with_digit")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects empty and whitespace-only keys", () => {
|
||||
expect(isValidUserOverridesKey("")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects path-traversal substrings", () => {
|
||||
// `..` rejected explicitly even if the rest of the regex would allow it
|
||||
// — `a..b` would otherwise pass the char class.
|
||||
expect(isValidUserOverridesKey("..")).toBe(false);
|
||||
expect(isValidUserOverridesKey("a..b")).toBe(false);
|
||||
expect(isValidUserOverridesKey("../escape")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects path separators", () => {
|
||||
expect(isValidUserOverridesKey("a/b")).toBe(false);
|
||||
expect(isValidUserOverridesKey("a\\b")).toBe(false);
|
||||
expect(isValidUserOverridesKey("/")).toBe(false);
|
||||
expect(isValidUserOverridesKey("\\")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects keys starting with a non-word character", () => {
|
||||
expect(isValidUserOverridesKey(".hidden")).toBe(false);
|
||||
expect(isValidUserOverridesKey("-leading-dash")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects characters outside [A-Za-z0-9_.-]", () => {
|
||||
expect(isValidUserOverridesKey("a b")).toBe(false);
|
||||
expect(isValidUserOverridesKey("a:b")).toBe(false);
|
||||
expect(isValidUserOverridesKey("a*b")).toBe(false);
|
||||
expect(isValidUserOverridesKey("a%2Fb")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("userOverridesPath (IMP-52 u3)", () => {
|
||||
it("resolves <root>/data/user_overrides/<key>.json regardless of OS sep", () => {
|
||||
const root = path.join("X:", "design_agent");
|
||||
const got = userOverridesPath(root, "03");
|
||||
expect(got).toBe(path.join(root, "data", "user_overrides", "03.json"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleGetUserOverrides (IMP-52 u3)", () => {
|
||||
let tmpRoot: string;
|
||||
let overridesDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "imp52-u3-"));
|
||||
overridesDir = path.join(tmpRoot, "data", "user_overrides");
|
||||
fs.mkdirSync(overridesDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns false (next chained) when method != GET", () => {
|
||||
const { res, state } = makeMockRes();
|
||||
const handled = handleGetUserOverrides(
|
||||
{ method: "PUT", url: "/03" },
|
||||
res,
|
||||
tmpRoot,
|
||||
);
|
||||
expect(handled).toBe(false);
|
||||
// Crucial for u4: PUT must reach its own middleware unobstructed.
|
||||
expect(state.ended).toBe(false);
|
||||
expect(state.statusCode).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 400 on invalid key (path traversal)", () => {
|
||||
const { res, state } = makeMockRes();
|
||||
const handled = handleGetUserOverrides(
|
||||
{ method: "GET", url: "/../escape" },
|
||||
res,
|
||||
tmpRoot,
|
||||
);
|
||||
expect(handled).toBe(true);
|
||||
expect(state.statusCode).toBe(400);
|
||||
expect(JSON.parse(state.body)).toEqual({ error: "invalid key" });
|
||||
});
|
||||
|
||||
it("returns 400 on invalid key (missing key segment)", () => {
|
||||
const { res, state } = makeMockRes();
|
||||
const handled = handleGetUserOverrides(
|
||||
{ method: "GET", url: "/" },
|
||||
res,
|
||||
tmpRoot,
|
||||
);
|
||||
expect(handled).toBe(true);
|
||||
expect(state.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 200 {} on missing file (graceful degrade)", () => {
|
||||
const { res, state } = makeMockRes();
|
||||
const handled = handleGetUserOverrides(
|
||||
{ method: "GET", url: "/03" },
|
||||
res,
|
||||
tmpRoot,
|
||||
);
|
||||
expect(handled).toBe(true);
|
||||
expect(state.statusCode).toBe(200);
|
||||
expect(state.body).toBe("{}");
|
||||
});
|
||||
|
||||
it("returns 200 {} on corrupt JSON (graceful degrade)", () => {
|
||||
fs.writeFileSync(path.join(overridesDir, "03.json"), "{not json", "utf-8");
|
||||
const { res, state } = makeMockRes();
|
||||
const handled = handleGetUserOverrides(
|
||||
{ method: "GET", url: "/03" },
|
||||
res,
|
||||
tmpRoot,
|
||||
);
|
||||
expect(handled).toBe(true);
|
||||
expect(state.statusCode).toBe(200);
|
||||
expect(state.body).toBe("{}");
|
||||
});
|
||||
|
||||
it("returns 200 {} when JSON root is not an object", () => {
|
||||
// Mirrors u1 load() which treats non-object roots as corrupt — covers
|
||||
// both arrays and primitives so the frontend never receives a shape
|
||||
// the typed service (u5) can't deserialize.
|
||||
fs.writeFileSync(
|
||||
path.join(overridesDir, "arr.json"),
|
||||
JSON.stringify([1, 2, 3]),
|
||||
"utf-8",
|
||||
);
|
||||
const { res, state } = makeMockRes();
|
||||
const handled = handleGetUserOverrides(
|
||||
{ method: "GET", url: "/arr" },
|
||||
res,
|
||||
tmpRoot,
|
||||
);
|
||||
expect(handled).toBe(true);
|
||||
expect(state.statusCode).toBe(200);
|
||||
expect(state.body).toBe("{}");
|
||||
|
||||
fs.writeFileSync(path.join(overridesDir, "num.json"), "42", "utf-8");
|
||||
const { res: res2, state: state2 } = makeMockRes();
|
||||
handleGetUserOverrides({ method: "GET", url: "/num" }, res2, tmpRoot);
|
||||
expect(state2.statusCode).toBe(200);
|
||||
expect(state2.body).toBe("{}");
|
||||
});
|
||||
|
||||
it("returns 200 with parsed JSON object on hit", () => {
|
||||
const payload = {
|
||||
layout: "two_zone_split",
|
||||
frames: { "03-1+03-2": "frame_07" },
|
||||
zone_geometries: {
|
||||
top: { x: 0.05, y: 0.1, w: 0.9, h: 0.3 },
|
||||
},
|
||||
zone_sections: { top: ["03-1", "03-2"] },
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(overridesDir, "03.json"),
|
||||
JSON.stringify(payload),
|
||||
"utf-8",
|
||||
);
|
||||
const { res, state } = makeMockRes();
|
||||
const handled = handleGetUserOverrides(
|
||||
{ method: "GET", url: "/03" },
|
||||
res,
|
||||
tmpRoot,
|
||||
);
|
||||
expect(handled).toBe(true);
|
||||
expect(state.statusCode).toBe(200);
|
||||
expect(state.headers["Content-Type"]).toBe(
|
||||
"application/json; charset=utf-8",
|
||||
);
|
||||
expect(JSON.parse(state.body)).toEqual(payload);
|
||||
});
|
||||
|
||||
it("preserves foreign top-level keys in the response", () => {
|
||||
// Forward-compat with future axes (e.g., zone_sizes, image_overrides).
|
||||
// u1 save() preserves them on the disk side; u3 GET must surface them
|
||||
// so the frontend service (u5) can decide whether to act on them.
|
||||
const payload = {
|
||||
layout: "single_zone",
|
||||
zone_sizes: { top: 0.42 }, // not part of KNOWN_AXES yet
|
||||
custom_extension: { foo: "bar" },
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(overridesDir, "future.json"),
|
||||
JSON.stringify(payload),
|
||||
"utf-8",
|
||||
);
|
||||
const { res, state } = makeMockRes();
|
||||
handleGetUserOverrides({ method: "GET", url: "/future" }, res, tmpRoot);
|
||||
expect(state.statusCode).toBe(200);
|
||||
expect(JSON.parse(state.body)).toEqual(payload);
|
||||
});
|
||||
|
||||
it("strips the leading slash and ignores query string when keying", () => {
|
||||
fs.writeFileSync(
|
||||
path.join(overridesDir, "03.json"),
|
||||
JSON.stringify({ layout: "x" }),
|
||||
"utf-8",
|
||||
);
|
||||
const { res, state } = makeMockRes();
|
||||
handleGetUserOverrides(
|
||||
{ method: "GET", url: "/03?ts=1747884800" },
|
||||
res,
|
||||
tmpRoot,
|
||||
);
|
||||
expect(state.statusCode).toBe(200);
|
||||
expect(JSON.parse(state.body)).toEqual({ layout: "x" });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IMP-52 u4 — PUT endpoint coverage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("KNOWN_USER_OVERRIDES_AXES (IMP-52 u4 + 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)", () => {
|
||||
it("only mutates KNOWN_AXES present in partial", () => {
|
||||
const existing = {
|
||||
layout: "old",
|
||||
frames: { "03-1": "frame_01" },
|
||||
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } },
|
||||
zone_sections: { top: ["03-1"] },
|
||||
};
|
||||
const merged = mergeUserOverrides(existing, { layout: "new" });
|
||||
expect(merged.layout).toBe("new");
|
||||
// axes not in partial are preserved
|
||||
expect(merged.frames).toEqual({ "03-1": "frame_01" });
|
||||
expect(merged.zone_geometries).toEqual({
|
||||
top: { x: 0, y: 0, w: 1, h: 0.5 },
|
||||
});
|
||||
expect(merged.zone_sections).toEqual({ top: ["03-1"] });
|
||||
});
|
||||
|
||||
it("preserves foreign top-level keys in existing", () => {
|
||||
// Forward-compat: future axes (zone_sizes, schema_version, etc.) on
|
||||
// disk must survive PUT writes that only touch the 5 in-scope axes.
|
||||
// `image_overrides` is no longer a foreign key after IMP-51 #79 u2 —
|
||||
// it joined KNOWN_USER_OVERRIDES_AXES — so we probe with axes that
|
||||
// are still NOT in the allowlist.
|
||||
const existing = {
|
||||
layout: "old",
|
||||
zone_sizes: { top: 0.42 },
|
||||
schema_version: 2,
|
||||
};
|
||||
const merged = mergeUserOverrides(existing, { layout: "new" });
|
||||
expect(merged.zone_sizes).toEqual({ top: 0.42 });
|
||||
expect(merged.schema_version).toBe(2);
|
||||
});
|
||||
|
||||
it("clears axis when partial value is null (explicit clear)", () => {
|
||||
const existing = { layout: "x", frames: { "03-1": "f01" } };
|
||||
const merged = mergeUserOverrides(existing, { layout: null });
|
||||
expect("layout" in merged).toBe(false);
|
||||
expect(merged.frames).toEqual({ "03-1": "f01" });
|
||||
});
|
||||
|
||||
it("drops non-axis keys in partial (allowlist)", () => {
|
||||
// PUT payload may carry junk fields (typo, malicious key); allowlist
|
||||
// ensures only the 5 axes can be written to disk.
|
||||
const merged = mergeUserOverrides(
|
||||
{},
|
||||
{ layout: "x", random_key: "evil", __proto__: "x" } as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
);
|
||||
expect(merged.layout).toBe("x");
|
||||
expect("random_key" in merged).toBe(false);
|
||||
});
|
||||
|
||||
it("merges all 5 axes when present in partial", () => {
|
||||
const merged = mergeUserOverrides(
|
||||
{},
|
||||
{
|
||||
layout: "two_zone_split",
|
||||
frames: { "03-1+03-2": "frame_07" },
|
||||
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } },
|
||||
zone_sections: { top: ["03-1", "03-2"] },
|
||||
image_overrides: { "img-1": { x: 0.1, y: 0.2, w: 0.3, h: 0.25 } },
|
||||
},
|
||||
);
|
||||
expect(Object.keys(merged).sort()).toEqual([
|
||||
"frames",
|
||||
"image_overrides",
|
||||
"layout",
|
||||
"zone_geometries",
|
||||
"zone_sections",
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves image_overrides when absent from partial (5th axis IMP-51 #79 u2)", () => {
|
||||
// Sibling axis of layout/frames/zone_geometries/zone_sections: a PUT
|
||||
// that touches only layout must NOT erase the image_overrides map
|
||||
// already on disk. Mirrors the partial-merge invariant for the 4
|
||||
// pre-existing axes.
|
||||
const existing = {
|
||||
layout: "old",
|
||||
image_overrides: { "img-1": { x: 0.1, y: 0.2, w: 0.3, h: 0.25 } },
|
||||
};
|
||||
const merged = mergeUserOverrides(existing, { layout: "new" });
|
||||
expect(merged.image_overrides).toEqual({
|
||||
"img-1": { x: 0.1, y: 0.2, w: 0.3, h: 0.25 },
|
||||
});
|
||||
expect(merged.layout).toBe("new");
|
||||
});
|
||||
|
||||
it("clears image_overrides when partial value is null (explicit clear)", () => {
|
||||
// Same null-sentinel contract as the 4 sibling axes — `null` removes
|
||||
// the axis from disk so the next render reverts to baseline (no
|
||||
// user image position/size override).
|
||||
const existing = {
|
||||
layout: "x",
|
||||
image_overrides: { "img-1": { x: 0.1, y: 0.2, w: 0.3, h: 0.25 } },
|
||||
};
|
||||
const merged = mergeUserOverrides(existing, { image_overrides: null });
|
||||
expect("image_overrides" in merged).toBe(false);
|
||||
expect(merged.layout).toBe("x");
|
||||
});
|
||||
|
||||
it("does not mutate the existing input", () => {
|
||||
const existing = { layout: "old", frames: { a: "b" } };
|
||||
const snapshot = JSON.parse(JSON.stringify(existing));
|
||||
mergeUserOverrides(existing, { layout: "new", layout_evil: "x" } as Record<
|
||||
string,
|
||||
unknown
|
||||
>);
|
||||
expect(existing).toEqual(snapshot);
|
||||
});
|
||||
});
|
||||
|
||||
describe("atomicWriteUserOverrides (IMP-52 u4)", () => {
|
||||
let tmpRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "imp52-u4-aw-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("creates parent dir if missing and writes JSON content", () => {
|
||||
const filePath = path.join(tmpRoot, "data", "user_overrides", "03.json");
|
||||
expect(fs.existsSync(path.dirname(filePath))).toBe(false);
|
||||
atomicWriteUserOverrides(filePath, { layout: "x" });
|
||||
expect(fs.existsSync(filePath)).toBe(true);
|
||||
expect(JSON.parse(fs.readFileSync(filePath, "utf-8"))).toEqual({
|
||||
layout: "x",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves no .tmp residue after a successful write", () => {
|
||||
const filePath = path.join(tmpRoot, "data", "user_overrides", "03.json");
|
||||
atomicWriteUserOverrides(filePath, { layout: "x" });
|
||||
const dirContents = fs.readdirSync(path.dirname(filePath));
|
||||
expect(dirContents).toEqual(["03.json"]);
|
||||
});
|
||||
|
||||
it("overwrites an existing file atomically", () => {
|
||||
const filePath = path.join(tmpRoot, "data", "user_overrides", "03.json");
|
||||
atomicWriteUserOverrides(filePath, { layout: "v1" });
|
||||
atomicWriteUserOverrides(filePath, { layout: "v2" });
|
||||
expect(JSON.parse(fs.readFileSync(filePath, "utf-8"))).toEqual({
|
||||
layout: "v2",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// req mock — EventEmitter with method/url + a `send(body)` helper that
|
||||
// emits the data chunk and then `end`, mirroring the node IncomingMessage
|
||||
// flow used by vite's dev middlewares.
|
||||
function makeMockReq(opts: {
|
||||
method?: string;
|
||||
url?: string;
|
||||
}): EventEmitter & { method?: string; url?: string; send: (body: string) => void } {
|
||||
const ee = new EventEmitter() as EventEmitter & {
|
||||
method?: string;
|
||||
url?: string;
|
||||
send: (body: string) => void;
|
||||
};
|
||||
ee.method = opts.method;
|
||||
ee.url = opts.url;
|
||||
ee.send = (body: string) => {
|
||||
if (body.length > 0) ee.emit("data", Buffer.from(body, "utf-8"));
|
||||
ee.emit("end");
|
||||
};
|
||||
return ee;
|
||||
}
|
||||
|
||||
describe("handlePutUserOverrides (IMP-52 u4)", () => {
|
||||
let tmpRoot: string;
|
||||
let overridesDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "imp52-u4-"));
|
||||
overridesDir = path.join(tmpRoot, "data", "user_overrides");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns false (next chained) when method != PUT", () => {
|
||||
const req = makeMockReq({ method: "GET", url: "/03" });
|
||||
const { res, state } = makeMockRes();
|
||||
const handled = handlePutUserOverrides(req, res, tmpRoot);
|
||||
expect(handled).toBe(false);
|
||||
expect(state.ended).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 400 on invalid key", () => {
|
||||
const req = makeMockReq({ method: "PUT", url: "/../escape" });
|
||||
const { res, state } = makeMockRes();
|
||||
const handled = handlePutUserOverrides(req, res, tmpRoot);
|
||||
expect(handled).toBe(true);
|
||||
expect(state.statusCode).toBe(400);
|
||||
expect(JSON.parse(state.body)).toEqual({ error: "invalid key" });
|
||||
});
|
||||
|
||||
it("returns 400 on invalid JSON body", () => {
|
||||
const req = makeMockReq({ method: "PUT", url: "/03" });
|
||||
const { res, state } = makeMockRes();
|
||||
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
||||
req.send("{not json");
|
||||
expect(state.statusCode).toBe(400);
|
||||
expect(JSON.parse(state.body)).toEqual({ error: "invalid JSON" });
|
||||
// file MUST NOT have been created on parse failure
|
||||
expect(fs.existsSync(path.join(overridesDir, "03.json"))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 400 when JSON body is an array", () => {
|
||||
const req = makeMockReq({ method: "PUT", url: "/03" });
|
||||
const { res, state } = makeMockRes();
|
||||
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
||||
req.send(JSON.stringify([1, 2, 3]));
|
||||
expect(state.statusCode).toBe(400);
|
||||
expect(JSON.parse(state.body)).toEqual({
|
||||
error: "body must be a JSON object",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 400 when JSON body is a primitive", () => {
|
||||
const req = makeMockReq({ method: "PUT", url: "/03" });
|
||||
const { res, state } = makeMockRes();
|
||||
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
||||
req.send("42");
|
||||
expect(state.statusCode).toBe(400);
|
||||
expect(JSON.parse(state.body)).toEqual({
|
||||
error: "body must be a JSON object",
|
||||
});
|
||||
});
|
||||
|
||||
it("creates the override file on first PUT and returns merged body", () => {
|
||||
const req = makeMockReq({ method: "PUT", url: "/03" });
|
||||
const { res, state } = makeMockRes();
|
||||
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
||||
|
||||
const payload = { layout: "two_zone_split" };
|
||||
req.send(JSON.stringify(payload));
|
||||
|
||||
expect(state.statusCode).toBe(200);
|
||||
expect(state.headers["Content-Type"]).toBe(
|
||||
"application/json; charset=utf-8",
|
||||
);
|
||||
expect(JSON.parse(state.body)).toEqual({ layout: "two_zone_split" });
|
||||
|
||||
const filePath = path.join(overridesDir, "03.json");
|
||||
expect(fs.existsSync(filePath)).toBe(true);
|
||||
expect(JSON.parse(fs.readFileSync(filePath, "utf-8"))).toEqual({
|
||||
layout: "two_zone_split",
|
||||
});
|
||||
});
|
||||
|
||||
it("partial-merges: axes absent from payload are preserved on disk", () => {
|
||||
fs.mkdirSync(overridesDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(overridesDir, "03.json"),
|
||||
JSON.stringify({
|
||||
layout: "old",
|
||||
frames: { "03-1": "frame_01" },
|
||||
zone_sections: { top: ["03-1"] },
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const req = makeMockReq({ method: "PUT", url: "/03" });
|
||||
const { res, state } = makeMockRes();
|
||||
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
||||
req.send(JSON.stringify({ layout: "new" }));
|
||||
|
||||
expect(state.statusCode).toBe(200);
|
||||
const onDisk = JSON.parse(
|
||||
fs.readFileSync(path.join(overridesDir, "03.json"), "utf-8"),
|
||||
);
|
||||
expect(onDisk).toEqual({
|
||||
layout: "new",
|
||||
frames: { "03-1": "frame_01" },
|
||||
zone_sections: { top: ["03-1"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves foreign top-level keys on disk (forward-compat)", () => {
|
||||
// `image_overrides` is no longer a foreign key after IMP-51 #79 u2;
|
||||
// probe with axes that are still NOT in KNOWN_USER_OVERRIDES_AXES.
|
||||
fs.mkdirSync(overridesDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(overridesDir, "future.json"),
|
||||
JSON.stringify({
|
||||
layout: "old",
|
||||
zone_sizes: { top: 0.42 },
|
||||
schema_version: 2,
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const req = makeMockReq({ method: "PUT", url: "/future" });
|
||||
const { res } = makeMockRes();
|
||||
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
||||
req.send(JSON.stringify({ layout: "new" }));
|
||||
|
||||
const onDisk = JSON.parse(
|
||||
fs.readFileSync(path.join(overridesDir, "future.json"), "utf-8"),
|
||||
);
|
||||
expect(onDisk.zone_sizes).toEqual({ top: 0.42 });
|
||||
expect(onDisk.schema_version).toBe(2);
|
||||
expect(onDisk.layout).toBe("new");
|
||||
});
|
||||
|
||||
it("persists image_overrides partial-merge and preserves sibling axes (IMP-51 #79 u2)", () => {
|
||||
// 5th axis end-to-end PUT round-trip: writing only image_overrides
|
||||
// must NOT touch the 4 sibling axes already on disk. Mirrors the
|
||||
// existing partial-merge test for layout above.
|
||||
fs.mkdirSync(overridesDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(overridesDir, "03.json"),
|
||||
JSON.stringify({
|
||||
layout: "two_zone_split",
|
||||
frames: { "03-1": "frame_01" },
|
||||
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } },
|
||||
zone_sections: { top: ["03-1"] },
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const req = makeMockReq({ method: "PUT", url: "/03" });
|
||||
const { res, state } = makeMockRes();
|
||||
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
||||
req.send(
|
||||
JSON.stringify({
|
||||
image_overrides: { "img-1": { x: 0.1, y: 0.2, w: 0.3, h: 0.25 } },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(state.statusCode).toBe(200);
|
||||
const onDisk = JSON.parse(
|
||||
fs.readFileSync(path.join(overridesDir, "03.json"), "utf-8"),
|
||||
);
|
||||
expect(onDisk).toEqual({
|
||||
layout: "two_zone_split",
|
||||
frames: { "03-1": "frame_01" },
|
||||
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } },
|
||||
zone_sections: { top: ["03-1"] },
|
||||
image_overrides: { "img-1": { x: 0.1, y: 0.2, w: 0.3, h: 0.25 } },
|
||||
});
|
||||
});
|
||||
|
||||
it("drops non-axis payload keys (allowlist enforced at write)", () => {
|
||||
fs.mkdirSync(overridesDir, { recursive: true });
|
||||
const req = makeMockReq({ method: "PUT", url: "/03" });
|
||||
const { res, state } = makeMockRes();
|
||||
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
||||
|
||||
req.send(
|
||||
JSON.stringify({
|
||||
layout: "two_zone_split",
|
||||
random_evil_key: "should not persist",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(state.statusCode).toBe(200);
|
||||
const onDisk = JSON.parse(
|
||||
fs.readFileSync(path.join(overridesDir, "03.json"), "utf-8"),
|
||||
);
|
||||
expect(onDisk).toEqual({ layout: "two_zone_split" });
|
||||
expect("random_evil_key" in onDisk).toBe(false);
|
||||
});
|
||||
|
||||
it("clears an axis when payload sets it to null", () => {
|
||||
fs.mkdirSync(overridesDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(overridesDir, "03.json"),
|
||||
JSON.stringify({ layout: "old", frames: { "03-1": "f01" } }),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const req = makeMockReq({ method: "PUT", url: "/03" });
|
||||
const { res } = makeMockRes();
|
||||
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
||||
req.send(JSON.stringify({ layout: null }));
|
||||
|
||||
const onDisk = JSON.parse(
|
||||
fs.readFileSync(path.join(overridesDir, "03.json"), "utf-8"),
|
||||
);
|
||||
expect("layout" in onDisk).toBe(false);
|
||||
expect(onDisk.frames).toEqual({ "03-1": "f01" });
|
||||
});
|
||||
|
||||
it("recovers from corrupt existing file (graceful degrade)", () => {
|
||||
fs.mkdirSync(overridesDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(overridesDir, "03.json"),
|
||||
"{this is not JSON",
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const req = makeMockReq({ method: "PUT", url: "/03" });
|
||||
const { res, state } = makeMockRes();
|
||||
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
||||
req.send(JSON.stringify({ layout: "recovered" }));
|
||||
|
||||
expect(state.statusCode).toBe(200);
|
||||
const onDisk = JSON.parse(
|
||||
fs.readFileSync(path.join(overridesDir, "03.json"), "utf-8"),
|
||||
);
|
||||
expect(onDisk).toEqual({ layout: "recovered" });
|
||||
});
|
||||
|
||||
it("treats array-rooted existing file as empty (graceful degrade)", () => {
|
||||
fs.mkdirSync(overridesDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(overridesDir, "03.json"),
|
||||
JSON.stringify(["not", "an", "object"]),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const req = makeMockReq({ method: "PUT", url: "/03" });
|
||||
const { res, state } = makeMockRes();
|
||||
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
||||
req.send(JSON.stringify({ layout: "recovered" }));
|
||||
|
||||
expect(state.statusCode).toBe(200);
|
||||
const onDisk = JSON.parse(
|
||||
fs.readFileSync(path.join(overridesDir, "03.json"), "utf-8"),
|
||||
);
|
||||
expect(onDisk).toEqual({ layout: "recovered" });
|
||||
});
|
||||
|
||||
it("strips the leading slash and ignores query string when keying", () => {
|
||||
const req = makeMockReq({
|
||||
method: "PUT",
|
||||
url: "/03?ts=1747884800",
|
||||
});
|
||||
const { res, state } = makeMockRes();
|
||||
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
||||
req.send(JSON.stringify({ layout: "x" }));
|
||||
|
||||
expect(state.statusCode).toBe(200);
|
||||
expect(fs.existsSync(path.join(overridesDir, "03.json"))).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts an empty body as a no-op partial (no axes mutated)", () => {
|
||||
fs.mkdirSync(overridesDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(overridesDir, "03.json"),
|
||||
JSON.stringify({ layout: "kept" }),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const req = makeMockReq({ method: "PUT", url: "/03" });
|
||||
const { res, state } = makeMockRes();
|
||||
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
||||
req.send("");
|
||||
|
||||
expect(state.statusCode).toBe(200);
|
||||
const onDisk = JSON.parse(
|
||||
fs.readFileSync(path.join(overridesDir, "03.json"), "utf-8"),
|
||||
);
|
||||
expect(onDisk).toEqual({ layout: "kept" });
|
||||
});
|
||||
|
||||
it("accepts a chunked PUT body (concatenates data events)", () => {
|
||||
const req = makeMockReq({ method: "PUT", url: "/03" });
|
||||
const { res, state } = makeMockRes();
|
||||
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
||||
|
||||
const body = JSON.stringify({
|
||||
layout: "two_zone_split",
|
||||
frames: { "03-1": "frame_01" },
|
||||
});
|
||||
// Emit in two halves to simulate a fragmented HTTP body.
|
||||
const half = Math.floor(body.length / 2);
|
||||
req.emit("data", Buffer.from(body.slice(0, half), "utf-8"));
|
||||
req.emit("data", Buffer.from(body.slice(half), "utf-8"));
|
||||
req.emit("end");
|
||||
|
||||
expect(state.statusCode).toBe(200);
|
||||
expect(JSON.parse(state.body)).toEqual({
|
||||
layout: "two_zone_split",
|
||||
frames: { "03-1": "frame_01" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,706 @@
|
||||
// IMP-52 u6 — vitest coverage for restore-on-reopen helpers used by
|
||||
// `Home.tsx` to layer persisted `user_overrides.json` payloads onto the
|
||||
// in-memory `UserSelection` and `slidePlan`.
|
||||
//
|
||||
// Scope (Stage 2 unit u6 contract):
|
||||
// 1) deriveUserOverridesKey(filename) — MDX-stem key derivation that
|
||||
// matches backend u2 fallback's `Path(args.mdx_path).stem`. Strips
|
||||
// `.mdx` case-insensitively; preserves everything else.
|
||||
// 2) applyPersistedNonFrameOverrides(selection, persisted) — layers
|
||||
// layout / zone_geometries / zone_sections onto an existing selection.
|
||||
// Frames are NOT layered here (unit_id key requires slidePlan).
|
||||
// Foreign / unrecognized payloads degrade silently (no throw, no
|
||||
// partial mutation).
|
||||
// 3) remapPersistedFramesToZoneFrames(slidePlan, framesByUnitId) —
|
||||
// remaps frames (unit_id → template_id) to zone_frames (region.id →
|
||||
// template_id). Stale unit_ids (no matching zone) drop silently;
|
||||
// zones without internal_regions[0] or without section_ids are
|
||||
// skipped without throwing.
|
||||
//
|
||||
// All helpers are pure; tests run in vitest's default node environment
|
||||
// without RTL / jsdom. Home.tsx wiring sites (handleFileUpload pre-Generate
|
||||
// seed + handleGenerate post-loadRun frame remap) are 1-line call sites that
|
||||
// these helpers cover end-to-end.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type {
|
||||
LayoutPresetId,
|
||||
SlidePlan,
|
||||
UserSelection,
|
||||
Zone,
|
||||
} from "../src/types/designAgent";
|
||||
import {
|
||||
applyPersistedNonFrameOverrides,
|
||||
createInitialUserSelection,
|
||||
deriveUserOverridesKey,
|
||||
remapPersistedFramesToZoneFrames,
|
||||
saveImageOverride,
|
||||
saveTextOverride,
|
||||
saveStructureOverride,
|
||||
} from "../src/utils/slidePlanUtils";
|
||||
|
||||
// ─── Fixtures ───────────────────────────────────────────────────────────────
|
||||
|
||||
function makeSelection(overrides?: Partial<UserSelection["overrides"]>): UserSelection {
|
||||
return {
|
||||
selectedSectionId: null,
|
||||
selectedZoneId: null,
|
||||
selectedRegionId: null,
|
||||
overrides: {
|
||||
layout_preset: undefined,
|
||||
zone_frames: {},
|
||||
zone_sections: {},
|
||||
zone_sizes: {},
|
||||
zone_geometries: {},
|
||||
// IMP-51 (#79) u11 — keep the fixture in sync with the 5th persisted
|
||||
// axis declared on `UserSelection.overrides`. Empty by default so the
|
||||
// existing IMP-52 cases remain unchanged in shape.
|
||||
image_overrides: {},
|
||||
// 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeZone(
|
||||
partial: { id: string; zone_id: string; section_ids: string[]; region_id?: string },
|
||||
): Zone {
|
||||
return {
|
||||
id: partial.id,
|
||||
zone_id: partial.zone_id,
|
||||
section_ids: partial.section_ids,
|
||||
position: { x: 0, y: 0, width: 1, height: 1 },
|
||||
internal_regions: [
|
||||
{
|
||||
id: partial.region_id ?? `${partial.id}-r0`,
|
||||
region_id: "region-single",
|
||||
role: "primary",
|
||||
content_type: "text_block",
|
||||
ratio_estimate: 1,
|
||||
content_unit_ids: [],
|
||||
frame_match_strategy: {
|
||||
kind: "frame_match",
|
||||
frame_id: null,
|
||||
display_strategy: "inline_full",
|
||||
},
|
||||
frame_candidates: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function makeSlidePlan(zones: Zone[], layout: LayoutPresetId = "single"): SlidePlan {
|
||||
return {
|
||||
id: "plan-1",
|
||||
title: "test plan",
|
||||
layout_preset: layout,
|
||||
zones,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── deriveUserOverridesKey ─────────────────────────────────────────────────
|
||||
|
||||
describe("deriveUserOverridesKey (IMP-52 u6)", () => {
|
||||
it("strips trailing .mdx", () => {
|
||||
expect(deriveUserOverridesKey("03__DX_BIM_value_chain.mdx")).toBe(
|
||||
"03__DX_BIM_value_chain",
|
||||
);
|
||||
});
|
||||
|
||||
it("strips .MDX case-insensitively", () => {
|
||||
expect(deriveUserOverridesKey("04_demo.MDX")).toBe("04_demo");
|
||||
expect(deriveUserOverridesKey("05_intro.Mdx")).toBe("05_intro");
|
||||
});
|
||||
|
||||
it("returns the filename unchanged when no .mdx suffix", () => {
|
||||
expect(deriveUserOverridesKey("03__DX_BIM_value_chain")).toBe(
|
||||
"03__DX_BIM_value_chain",
|
||||
);
|
||||
expect(deriveUserOverridesKey("notes.txt")).toBe("notes.txt");
|
||||
});
|
||||
|
||||
it("only strips the final .mdx, preserves dots inside the stem", () => {
|
||||
expect(deriveUserOverridesKey("05.2_layer.mdx")).toBe("05.2_layer");
|
||||
});
|
||||
|
||||
it("returns empty string for empty input", () => {
|
||||
expect(deriveUserOverridesKey("")).toBe("");
|
||||
});
|
||||
|
||||
it("matches backend Path(args.mdx_path).stem for the canonical demo MDXs", () => {
|
||||
// These are the three canonical samples loaded by /api/sample-mdx; the
|
||||
// key on both ends must agree so a write from frontend (PUT) is found
|
||||
// by backend (u2 fallback on next pipeline run).
|
||||
expect(deriveUserOverridesKey("03_demo.mdx")).toBe("03_demo");
|
||||
expect(deriveUserOverridesKey("04_demo.mdx")).toBe("04_demo");
|
||||
expect(deriveUserOverridesKey("05_demo.mdx")).toBe("05_demo");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── applyPersistedNonFrameOverrides ────────────────────────────────────────
|
||||
|
||||
describe("applyPersistedNonFrameOverrides (IMP-52 u6)", () => {
|
||||
it("layers layout / zone_geometries / zone_sections", () => {
|
||||
const sel = makeSelection();
|
||||
const persisted = {
|
||||
layout: "horizontal-2",
|
||||
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.4 } },
|
||||
zone_sections: { top: ["03-1"], bottom: ["03-2"] },
|
||||
} as const;
|
||||
const next = applyPersistedNonFrameOverrides(sel, persisted);
|
||||
expect(next.overrides.layout_preset).toBe("horizontal-2");
|
||||
expect(next.overrides.zone_geometries).toEqual({
|
||||
top: { x: 0, y: 0, w: 1, h: 0.4 },
|
||||
});
|
||||
expect(next.overrides.zone_sections).toEqual({
|
||||
top: ["03-1"],
|
||||
bottom: ["03-2"],
|
||||
});
|
||||
});
|
||||
|
||||
it("does NOT layer frames (frames need post-loadRun remap)", () => {
|
||||
const sel = makeSelection({ zone_frames: { "r-existing": "tpl-existing" } });
|
||||
const persisted = {
|
||||
frames: { "03-1+03-2": "tpl-persisted" },
|
||||
};
|
||||
const next = applyPersistedNonFrameOverrides(sel, persisted);
|
||||
// zone_frames is untouched here; the post-loadRun remap step owns it.
|
||||
expect(next.overrides.zone_frames).toEqual({ "r-existing": "tpl-existing" });
|
||||
});
|
||||
|
||||
it("rejects layout values outside the 8 known preset ids", () => {
|
||||
const sel = makeSelection({ layout_preset: "single" });
|
||||
const next = applyPersistedNonFrameOverrides(sel, {
|
||||
layout: "rogue-layout" as unknown as string,
|
||||
});
|
||||
// Stays at the original — preset whitelist guards against hand-edited
|
||||
// files or future schema drift.
|
||||
expect(next.overrides.layout_preset).toBe("single");
|
||||
});
|
||||
|
||||
it("ignores zone_geometries when the payload axis is an array", () => {
|
||||
const sel = makeSelection({ zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } } });
|
||||
const next = applyPersistedNonFrameOverrides(sel, {
|
||||
zone_geometries: [] as unknown as Record<string, { x: number; y: number; w: number; h: number }>,
|
||||
});
|
||||
expect(next.overrides.zone_geometries).toEqual({
|
||||
top: { x: 0, y: 0, w: 1, h: 0.5 },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the selection unchanged when persisted is null / undefined / non-object", () => {
|
||||
const sel = makeSelection({ layout_preset: "single" });
|
||||
expect(applyPersistedNonFrameOverrides(sel, null)).toEqual(sel);
|
||||
expect(applyPersistedNonFrameOverrides(sel, undefined)).toEqual(sel);
|
||||
});
|
||||
|
||||
it("returns the selection unchanged when persisted is empty {}", () => {
|
||||
const sel = makeSelection({ layout_preset: "single" });
|
||||
const next = applyPersistedNonFrameOverrides(sel, {});
|
||||
expect(next.overrides.layout_preset).toBe("single");
|
||||
expect(next.overrides.zone_geometries).toEqual({});
|
||||
expect(next.overrides.zone_sections).toEqual({});
|
||||
});
|
||||
|
||||
it("returns a NEW selection object (no mutation of input)", () => {
|
||||
const sel = makeSelection();
|
||||
const next = applyPersistedNonFrameOverrides(sel, { layout: "vertical-2" });
|
||||
expect(next).not.toBe(sel);
|
||||
expect(next.overrides).not.toBe(sel.overrides);
|
||||
// Input still pristine.
|
||||
expect(sel.overrides.layout_preset).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── remapPersistedFramesToZoneFrames ───────────────────────────────────────
|
||||
|
||||
describe("remapPersistedFramesToZoneFrames (IMP-52 u6)", () => {
|
||||
it("maps unit_id (section_ids joined by +) to region.id", () => {
|
||||
const plan = makeSlidePlan([
|
||||
makeZone({ id: "z-top", zone_id: "top", section_ids: ["03-1"], region_id: "r-top" }),
|
||||
makeZone({ id: "z-bot", zone_id: "bottom", section_ids: ["03-2", "03-3"], region_id: "r-bot" }),
|
||||
]);
|
||||
const remapped = remapPersistedFramesToZoneFrames(plan, {
|
||||
"03-1": "tpl-a",
|
||||
"03-2+03-3": "tpl-b",
|
||||
});
|
||||
expect(remapped).toEqual({
|
||||
"r-top": "tpl-a",
|
||||
"r-bot": "tpl-b",
|
||||
});
|
||||
});
|
||||
|
||||
it("silently drops persisted entries whose unit_id matches no zone", () => {
|
||||
const plan = makeSlidePlan([
|
||||
makeZone({ id: "z-top", zone_id: "top", section_ids: ["03-1"], region_id: "r-top" }),
|
||||
]);
|
||||
const remapped = remapPersistedFramesToZoneFrames(plan, {
|
||||
"03-1": "tpl-a",
|
||||
"stale-section-id": "tpl-stale", // user changed zone_sections between sessions
|
||||
});
|
||||
expect(remapped).toEqual({ "r-top": "tpl-a" });
|
||||
});
|
||||
|
||||
it("returns {} when slidePlan is null / undefined", () => {
|
||||
expect(remapPersistedFramesToZoneFrames(null, { "03-1": "tpl-a" })).toEqual({});
|
||||
expect(remapPersistedFramesToZoneFrames(undefined, { "03-1": "tpl-a" })).toEqual({});
|
||||
});
|
||||
|
||||
it("returns {} when framesByUnitId is null / undefined / {}", () => {
|
||||
const plan = makeSlidePlan([
|
||||
makeZone({ id: "z-top", zone_id: "top", section_ids: ["03-1"], region_id: "r-top" }),
|
||||
]);
|
||||
expect(remapPersistedFramesToZoneFrames(plan, null)).toEqual({});
|
||||
expect(remapPersistedFramesToZoneFrames(plan, undefined)).toEqual({});
|
||||
expect(remapPersistedFramesToZoneFrames(plan, {})).toEqual({});
|
||||
});
|
||||
|
||||
it("skips zones with empty section_ids (no unit_id to derive)", () => {
|
||||
const plan = makeSlidePlan([
|
||||
makeZone({ id: "z-empty", zone_id: "empty", section_ids: [], region_id: "r-empty" }),
|
||||
makeZone({ id: "z-top", zone_id: "top", section_ids: ["03-1"], region_id: "r-top" }),
|
||||
]);
|
||||
const remapped = remapPersistedFramesToZoneFrames(plan, {
|
||||
"": "tpl-should-not-match-empty-join",
|
||||
"03-1": "tpl-a",
|
||||
});
|
||||
expect(remapped).toEqual({ "r-top": "tpl-a" });
|
||||
});
|
||||
|
||||
it("skips zones without internal_regions[0]", () => {
|
||||
const plan: SlidePlan = {
|
||||
id: "plan-x",
|
||||
title: "no regions",
|
||||
layout_preset: "single",
|
||||
zones: [
|
||||
{
|
||||
id: "z-bare",
|
||||
zone_id: "bare",
|
||||
section_ids: ["03-1"],
|
||||
position: { x: 0, y: 0, width: 1, height: 1 },
|
||||
internal_regions: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(remapPersistedFramesToZoneFrames(plan, { "03-1": "tpl-a" })).toEqual({});
|
||||
});
|
||||
|
||||
it("ignores persisted entries with empty / non-string template_id", () => {
|
||||
const plan = makeSlidePlan([
|
||||
makeZone({ id: "z-top", zone_id: "top", section_ids: ["03-1"], region_id: "r-top" }),
|
||||
]);
|
||||
const remapped = remapPersistedFramesToZoneFrames(plan, {
|
||||
"03-1": "" as unknown as string,
|
||||
});
|
||||
expect(remapped).toEqual({});
|
||||
});
|
||||
|
||||
it("preserves the user-selected template even when slidePlan layout would imply a different default", () => {
|
||||
// Backend u2 fallback should already have applied the user's frame
|
||||
// override via CLI args, but if the plan's default frame_match_strategy
|
||||
// disagrees, the post-loadRun remap still surfaces the user's choice
|
||||
// for the SlideCanvas override-vs-default preview indicator.
|
||||
const plan = makeSlidePlan([
|
||||
makeZone({ id: "z-top", zone_id: "top", section_ids: ["03-1"], region_id: "r-top" }),
|
||||
]);
|
||||
const remapped = remapPersistedFramesToZoneFrames(plan, {
|
||||
"03-1": "user-chosen-tpl",
|
||||
});
|
||||
expect(remapped["r-top"]).toBe("user-chosen-tpl");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── IMP-51 (#79) u11 — image_overrides axis ────────────────────────────────
|
||||
// New 5th persisted axis. The on-disk schema (KNOWN_AXES,
|
||||
// src/user_overrides_io.py u1), the typed client
|
||||
// (services/userOverridesApi.ts u3 ImageOverridesOverride), the Vite
|
||||
// allowlist (vite.config.ts u2), and the backend CLI flag (--override-image
|
||||
// in src/phase_z2_pipeline.py u5) all expect `image_id` → percent-of-slide
|
||||
// geometry. u11 owns the in-memory mirror on `UserSelection.overrides`
|
||||
// (declared in types/designAgent.ts) plus the three pure helpers that
|
||||
// Home.tsx (u10) wires:
|
||||
// • applyPersistedNonFrameOverrides — restore-on-reopen layer.
|
||||
// • createInitialUserSelection — fresh-slide initializer.
|
||||
// • saveImageOverride — single-image record helper invoked by the
|
||||
// SlideCanvas u8 drag/resize handler.
|
||||
|
||||
describe("image_overrides axis — applyPersistedNonFrameOverrides (IMP-51 u11)", () => {
|
||||
it("layers a flat image_overrides dict onto the selection", () => {
|
||||
const sel = makeSelection();
|
||||
const persisted = {
|
||||
image_overrides: {
|
||||
"img-abc1234567": { x: 10, y: 15, w: 30.5, h: 25 },
|
||||
"img-deadbeef00": { x: 50, y: 50, w: 40, h: 40 },
|
||||
},
|
||||
};
|
||||
const next = applyPersistedNonFrameOverrides(sel, persisted);
|
||||
expect(next.overrides.image_overrides).toEqual({
|
||||
"img-abc1234567": { x: 10, y: 15, w: 30.5, h: 25 },
|
||||
"img-deadbeef00": { x: 50, y: 50, w: 40, h: 40 },
|
||||
});
|
||||
// Untouched axes stay at their fixture defaults so the round-trip is
|
||||
// safe to interleave with the other four axes.
|
||||
expect(next.overrides.zone_geometries).toEqual({});
|
||||
expect(next.overrides.zone_sections).toEqual({});
|
||||
expect(next.overrides.layout_preset).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores image_overrides when the payload axis is an array", () => {
|
||||
const sel = makeSelection({
|
||||
image_overrides: { "img-existing00": { x: 1, y: 2, w: 30, h: 40 } },
|
||||
});
|
||||
const next = applyPersistedNonFrameOverrides(sel, {
|
||||
image_overrides: [] as unknown as Record<
|
||||
string,
|
||||
{ x: number; y: number; w: number; h: number }
|
||||
>,
|
||||
});
|
||||
// Same guard the zone_geometries branch uses — array payloads from a
|
||||
// hand-edited file are rejected and the prior in-memory value stays.
|
||||
expect(next.overrides.image_overrides).toEqual({
|
||||
"img-existing00": { x: 1, y: 2, w: 30, h: 40 },
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores image_overrides when the payload axis is null", () => {
|
||||
const sel = makeSelection({
|
||||
image_overrides: { "img-existing00": { x: 0, y: 0, w: 100, h: 100 } },
|
||||
});
|
||||
const next = applyPersistedNonFrameOverrides(sel, {
|
||||
image_overrides: null,
|
||||
});
|
||||
expect(next.overrides.image_overrides).toEqual({
|
||||
"img-existing00": { x: 0, y: 0, w: 100, h: 100 },
|
||||
});
|
||||
});
|
||||
|
||||
it("layers image_overrides alongside the four IMP-52 axes in one call", () => {
|
||||
const sel = makeSelection();
|
||||
const next = applyPersistedNonFrameOverrides(sel, {
|
||||
layout: "horizontal-2",
|
||||
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.4 } },
|
||||
zone_sections: { top: ["03-1"] },
|
||||
image_overrides: { "img-abc1234567": { x: 25, y: 25, w: 50, h: 50 } },
|
||||
});
|
||||
expect(next.overrides.layout_preset).toBe("horizontal-2");
|
||||
expect(next.overrides.zone_geometries).toEqual({
|
||||
top: { x: 0, y: 0, w: 1, h: 0.4 },
|
||||
});
|
||||
expect(next.overrides.zone_sections).toEqual({ top: ["03-1"] });
|
||||
expect(next.overrides.image_overrides).toEqual({
|
||||
"img-abc1234567": { x: 25, y: 25, w: 50, h: 50 },
|
||||
});
|
||||
});
|
||||
|
||||
it("seeds an empty image_overrides on a fresh selection (createInitialUserSelection)", () => {
|
||||
const sel = createInitialUserSelection();
|
||||
expect(sel.overrides.image_overrides).toEqual({});
|
||||
// Mirrors the shape Home.tsx receives before any user interaction —
|
||||
// SlideCanvas u8 expects the axis to exist (not undefined) so its
|
||||
// `Object.entries(measured + persisted)` merge never crashes.
|
||||
});
|
||||
});
|
||||
|
||||
describe("image_overrides axis — saveImageOverride (IMP-51 u11)", () => {
|
||||
const ID_A = "img-abc1234567";
|
||||
const ID_B = "img-deadbeef00";
|
||||
|
||||
it("adds a new image_id entry on an empty axis", () => {
|
||||
const sel = makeSelection();
|
||||
const next = saveImageOverride(sel, ID_A, { x: 10, y: 15, w: 30.5, h: 25 });
|
||||
expect(next.overrides.image_overrides).toEqual({
|
||||
[ID_A]: { x: 10, y: 15, w: 30.5, h: 25 },
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces an existing entry under the same image_id (most recent drag wins)", () => {
|
||||
const sel = makeSelection({
|
||||
image_overrides: { [ID_A]: { x: 0, y: 0, w: 20, h: 20 } },
|
||||
});
|
||||
const next = saveImageOverride(sel, ID_A, { x: 50, y: 50, w: 30, h: 30 });
|
||||
expect(next.overrides.image_overrides).toEqual({
|
||||
[ID_A]: { x: 50, y: 50, w: 30, h: 30 },
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves sibling image_id entries when adding a new one", () => {
|
||||
const sel = makeSelection({
|
||||
image_overrides: { [ID_A]: { x: 10, y: 10, w: 20, h: 20 } },
|
||||
});
|
||||
const next = saveImageOverride(sel, ID_B, { x: 60, y: 60, w: 30, h: 30 });
|
||||
expect(next.overrides.image_overrides).toEqual({
|
||||
[ID_A]: { x: 10, y: 10, w: 20, h: 20 },
|
||||
[ID_B]: { x: 60, y: 60, w: 30, h: 30 },
|
||||
});
|
||||
});
|
||||
|
||||
it("does NOT touch the other four override axes", () => {
|
||||
const sel = makeSelection({
|
||||
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } },
|
||||
zone_sections: { top: ["03-1"] },
|
||||
zone_frames: { "r-top": "tpl-a" },
|
||||
layout_preset: "horizontal-2",
|
||||
});
|
||||
const next = saveImageOverride(sel, ID_A, { x: 10, y: 10, w: 20, h: 20 });
|
||||
expect(next.overrides.zone_geometries).toEqual({
|
||||
top: { x: 0, y: 0, w: 1, h: 0.5 },
|
||||
});
|
||||
expect(next.overrides.zone_sections).toEqual({ top: ["03-1"] });
|
||||
expect(next.overrides.zone_frames).toEqual({ "r-top": "tpl-a" });
|
||||
expect(next.overrides.layout_preset).toBe("horizontal-2");
|
||||
});
|
||||
|
||||
it("returns a NEW selection object (no input mutation)", () => {
|
||||
const sel = makeSelection({
|
||||
image_overrides: { [ID_A]: { x: 0, y: 0, w: 10, h: 10 } },
|
||||
});
|
||||
const before = { ...sel.overrides.image_overrides };
|
||||
const next = saveImageOverride(sel, ID_B, { x: 30, y: 30, w: 20, h: 20 });
|
||||
expect(next).not.toBe(sel);
|
||||
expect(next.overrides).not.toBe(sel.overrides);
|
||||
expect(next.overrides.image_overrides).not.toBe(sel.overrides.image_overrides);
|
||||
// Input still pristine.
|
||||
expect(sel.overrides.image_overrides).toEqual(before);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 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({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,625 @@
|
||||
// IMP-52 u5 — vitest coverage for the typed frontend client at
|
||||
// `Front/client/src/services/userOverridesApi.ts`.
|
||||
//
|
||||
// Scope (Stage 2 unit u5 contract):
|
||||
// 1) getUserOverrides:
|
||||
// • 200 with object body → typed payload echoed.
|
||||
// • 200 with array / primitive / non-JSON body → {} (graceful).
|
||||
// • 4xx / 5xx → {}.
|
||||
// • fetch reject (network) → {} (no throw to caller).
|
||||
// 2) saveUserOverrides:
|
||||
// • Single call: PUT fires after exactly 300 ms with the mutated-axis
|
||||
// partial as body (NOT a full snapshot of UserOverrides).
|
||||
// • Rapid coalescing: N calls in <300 ms window collapse to ONE PUT
|
||||
// carrying the union of mutated axes.
|
||||
// • Per-axis later-wins: later call's value replaces earlier pending
|
||||
// value for the same axis; axes the user did not touch stay absent.
|
||||
// • null sentinel: forwarded verbatim so u4 mergeUserOverrides can
|
||||
// `delete` the axis on disk.
|
||||
// • Per-key isolation: rapid edits to "03" do not delay flush of "04".
|
||||
// • Promise resolves with the server-side merged document.
|
||||
// • Promise rejects on 4xx/5xx and on fetch reject.
|
||||
// 3) flushUserOverrides:
|
||||
// • No arg → flushes all pending buckets immediately (no 300 ms wait).
|
||||
// • Specific key → flushes only that bucket; other buckets stay
|
||||
// pending.
|
||||
// • No-op when no buckets are pending.
|
||||
//
|
||||
// All tests mock `fetch` and use `vi.useFakeTimers()` to make the 300 ms
|
||||
// debounce deterministic — no real wall-clock waits.
|
||||
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
type Mock,
|
||||
} from "vitest";
|
||||
import {
|
||||
__resetUserOverridesBuckets_FOR_TEST,
|
||||
flushUserOverrides,
|
||||
getUserOverrides,
|
||||
saveUserOverrides,
|
||||
type UserOverridesPartial,
|
||||
} from "../src/services/userOverridesApi";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fetch mock — minimal Response stub with the two methods the service uses
|
||||
// (.ok / .status / .json()). We track the call log so debounce + coalescing
|
||||
// can be asserted by counting PUTs and inspecting their bodies.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type MockResponse = {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
json: () => Promise<unknown>;
|
||||
};
|
||||
|
||||
function mockResponse(body: unknown, ok = true, status = 200): MockResponse {
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
json: async () => body,
|
||||
};
|
||||
}
|
||||
|
||||
let fetchMock: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
vi.useFakeTimers();
|
||||
__resetUserOverridesBuckets_FOR_TEST();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
__resetUserOverridesBuckets_FOR_TEST();
|
||||
});
|
||||
|
||||
// Microtask-flushing helper. vi.advanceTimersByTime fires timers, but the
|
||||
// promise chain inside flushBucket (await fetch → await res.json() → resolve
|
||||
// waiters) needs the microtask queue to drain before assertions run.
|
||||
async function drainMicrotasks(): Promise<void> {
|
||||
// Multiple ticks because each `await` in flushBucket adds another tick.
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
function lastPutBody(): unknown {
|
||||
const lastCall = fetchMock.mock.calls.at(-1);
|
||||
if (!lastCall) throw new Error("fetch was not called");
|
||||
const init = lastCall[1] as RequestInit | undefined;
|
||||
if (!init?.body) throw new Error("fetch was called without a body");
|
||||
return JSON.parse(String(init.body));
|
||||
}
|
||||
|
||||
function putCallsCount(): number {
|
||||
return fetchMock.mock.calls.filter(
|
||||
(call) => (call[1] as RequestInit | undefined)?.method === "PUT",
|
||||
).length;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// getUserOverrides
|
||||
// ============================================================================
|
||||
|
||||
describe("getUserOverrides (IMP-52 u5)", () => {
|
||||
it("issues GET against /api/user-overrides/<key>", async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockResponse({ layout: "x" }));
|
||||
await getUserOverrides("03");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("/api/user-overrides/03");
|
||||
expect((init as RequestInit).method).toBe("GET");
|
||||
});
|
||||
|
||||
it("returns the parsed object on 200 with object body", async () => {
|
||||
const payload = {
|
||||
layout: "two_zone_split",
|
||||
frames: { "03-1+03-2": "frame_07" },
|
||||
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } },
|
||||
zone_sections: { top: ["03-1", "03-2"] },
|
||||
};
|
||||
fetchMock.mockResolvedValueOnce(mockResponse(payload));
|
||||
const got = await getUserOverrides("03");
|
||||
expect(got).toEqual(payload);
|
||||
});
|
||||
|
||||
it("returns {} when JSON root is an array (mirrors u3 graceful degrade)", async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockResponse([1, 2, 3]));
|
||||
expect(await getUserOverrides("03")).toEqual({});
|
||||
});
|
||||
|
||||
it("returns {} when JSON root is a primitive", async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockResponse(42));
|
||||
expect(await getUserOverrides("03")).toEqual({});
|
||||
});
|
||||
|
||||
it("returns {} when JSON root is null", async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockResponse(null));
|
||||
expect(await getUserOverrides("03")).toEqual({});
|
||||
});
|
||||
|
||||
it("returns {} on 4xx (invalid key path from u3)", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockResponse({ error: "invalid key" }, false, 400),
|
||||
);
|
||||
expect(await getUserOverrides("..")).toEqual({});
|
||||
});
|
||||
|
||||
it("returns {} on 5xx", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockResponse({ error: "boom" }, false, 500),
|
||||
);
|
||||
expect(await getUserOverrides("03")).toEqual({});
|
||||
});
|
||||
|
||||
it("returns {} when response.json() throws (non-JSON body)", async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => {
|
||||
throw new SyntaxError("Unexpected token");
|
||||
},
|
||||
});
|
||||
expect(await getUserOverrides("03")).toEqual({});
|
||||
});
|
||||
|
||||
it("returns {} when fetch rejects (network error) — does NOT throw", async () => {
|
||||
fetchMock.mockRejectedValueOnce(new Error("network down"));
|
||||
await expect(getUserOverrides("03")).resolves.toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// saveUserOverrides — debounce + coalescing
|
||||
// ============================================================================
|
||||
|
||||
describe("saveUserOverrides (IMP-52 u5) — debounce", () => {
|
||||
it("does NOT fire fetch before 300 ms have elapsed", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({ layout: "two_zone_split" }));
|
||||
void saveUserOverrides("03", { layout: "two_zone_split" });
|
||||
|
||||
vi.advanceTimersByTime(299);
|
||||
await drainMicrotasks();
|
||||
expect(putCallsCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("fires exactly one PUT at the 300 ms boundary", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({ layout: "two_zone_split" }));
|
||||
void saveUserOverrides("03", { layout: "two_zone_split" });
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
expect(putCallsCount()).toBe(1);
|
||||
|
||||
const lastCall = fetchMock.mock.calls.at(-1)!;
|
||||
expect(lastCall[0]).toBe("/api/user-overrides/03");
|
||||
expect((lastCall[1] as RequestInit).method).toBe("PUT");
|
||||
expect((lastCall[1] as RequestInit).headers).toMatchObject({
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
expect(lastPutBody()).toEqual({ layout: "two_zone_split" });
|
||||
});
|
||||
|
||||
it("PUT body contains ONLY the mutated axis (not a full snapshot)", async () => {
|
||||
// The frontend handler only knows the axis it just mutated; the server
|
||||
// is responsible for partial-merge against axes already on disk.
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03", {
|
||||
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } },
|
||||
});
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
|
||||
const body = lastPutBody() as Record<string, unknown>;
|
||||
expect(Object.keys(body)).toEqual(["zone_geometries"]);
|
||||
expect("layout" in body).toBe(false);
|
||||
expect("frames" in body).toBe(false);
|
||||
expect("zone_sections" in body).toBe(false);
|
||||
});
|
||||
|
||||
it("coalesces N rapid calls into a SINGLE PUT after the debounce", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03", { layout: "old" });
|
||||
vi.advanceTimersByTime(100);
|
||||
void saveUserOverrides("03", { frames: { "03-1": "frame_01" } });
|
||||
vi.advanceTimersByTime(100);
|
||||
void saveUserOverrides("03", { zone_sections: { top: ["03-1"] } });
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
// After 300 ms total (but the timer was reset each call to start the
|
||||
// 300 ms window over), so we need one more 300 ms to fire.
|
||||
expect(putCallsCount()).toBe(0);
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
expect(putCallsCount()).toBe(1);
|
||||
|
||||
// All three axes accumulated.
|
||||
const body = lastPutBody() as Record<string, unknown>;
|
||||
expect(body).toEqual({
|
||||
layout: "old",
|
||||
frames: { "03-1": "frame_01" },
|
||||
zone_sections: { top: ["03-1"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("per-axis later-wins: same axis mutated twice keeps the LAST value", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03", { layout: "first" });
|
||||
void saveUserOverrides("03", { layout: "second" });
|
||||
void saveUserOverrides("03", { layout: "final" });
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
expect(putCallsCount()).toBe(1);
|
||||
expect(lastPutBody()).toEqual({ layout: "final" });
|
||||
});
|
||||
|
||||
it("forwards null sentinel verbatim (explicit clear)", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03", { layout: null });
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
expect(lastPutBody()).toEqual({ layout: null });
|
||||
});
|
||||
|
||||
it("null can override a prior non-null pending value for the same axis", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03", { layout: "two_zone_split" });
|
||||
void saveUserOverrides("03", { layout: null });
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
expect(lastPutBody()).toEqual({ layout: null });
|
||||
});
|
||||
|
||||
it("resolves the caller promise with the server-merged document", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockResponse({
|
||||
layout: "two_zone_split",
|
||||
// server's view includes axes preserved on disk that the partial
|
||||
// PUT did NOT carry — confirms we surface the full merged state.
|
||||
frames: { "03-1": "frame_01" },
|
||||
}),
|
||||
);
|
||||
const p = saveUserOverrides("03", { layout: "two_zone_split" });
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
|
||||
await expect(p).resolves.toEqual({
|
||||
layout: "two_zone_split",
|
||||
frames: { "03-1": "frame_01" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects all coalesced waiters on 5xx response", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockResponse({ error: "write failed" }, false, 500),
|
||||
);
|
||||
const p1 = saveUserOverrides("03", { layout: "x" });
|
||||
const p2 = saveUserOverrides("03", { frames: { "03-1": "f01" } });
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
|
||||
await expect(p1).rejects.toThrow(/500/);
|
||||
await expect(p2).rejects.toThrow(/500/);
|
||||
});
|
||||
|
||||
it("rejects waiters on fetch network error", async () => {
|
||||
fetchMock.mockRejectedValueOnce(new Error("ECONNRESET"));
|
||||
const p = saveUserOverrides("03", { layout: "x" });
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
|
||||
await expect(p).rejects.toThrow("ECONNRESET");
|
||||
});
|
||||
|
||||
it("after a successful flush, a new save starts a fresh debounce window", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03", { layout: "first" });
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
expect(putCallsCount()).toBe(1);
|
||||
expect(lastPutBody()).toEqual({ layout: "first" });
|
||||
|
||||
void saveUserOverrides("03", { layout: "second" });
|
||||
vi.advanceTimersByTime(299);
|
||||
await drainMicrotasks();
|
||||
expect(putCallsCount()).toBe(1); // not fired yet
|
||||
vi.advanceTimersByTime(1);
|
||||
await drainMicrotasks();
|
||||
expect(putCallsCount()).toBe(2);
|
||||
expect(lastPutBody()).toEqual({ layout: "second" });
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// saveUserOverrides — per-key isolation
|
||||
// ============================================================================
|
||||
|
||||
describe("saveUserOverrides (IMP-52 u5) — per-key isolation", () => {
|
||||
it("rapid edits to key A do not delay key B's flush", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
// Schedule a save on "03"
|
||||
void saveUserOverrides("03", { layout: "x" });
|
||||
// Schedule a save on "04" at t=0
|
||||
void saveUserOverrides("04", { layout: "y" });
|
||||
|
||||
vi.advanceTimersByTime(150);
|
||||
// Keep extending "03"'s window
|
||||
void saveUserOverrides("03", { layout: "x2" });
|
||||
|
||||
// "04" should still fire at t=300 (untouched after first call)
|
||||
vi.advanceTimersByTime(150); // t=300
|
||||
await drainMicrotasks();
|
||||
|
||||
const puts = fetchMock.mock.calls.filter(
|
||||
(c) => (c[1] as RequestInit).method === "PUT",
|
||||
);
|
||||
expect(puts.length).toBe(1);
|
||||
expect(puts[0][0]).toBe("/api/user-overrides/04");
|
||||
expect(JSON.parse(String((puts[0][1] as RequestInit).body))).toEqual({
|
||||
layout: "y",
|
||||
});
|
||||
});
|
||||
|
||||
it("each key's PUT carries only that key's mutated axes", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03", { layout: "for-03" });
|
||||
void saveUserOverrides("04", { frames: { "04-1": "frame_05" } });
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
|
||||
const puts = fetchMock.mock.calls.filter(
|
||||
(c) => (c[1] as RequestInit).method === "PUT",
|
||||
);
|
||||
expect(puts.length).toBe(2);
|
||||
|
||||
const byUrl = new Map(
|
||||
puts.map((c) => [
|
||||
c[0],
|
||||
JSON.parse(String((c[1] as RequestInit).body)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
]),
|
||||
);
|
||||
expect(byUrl.get("/api/user-overrides/03")).toEqual({ layout: "for-03" });
|
||||
expect(byUrl.get("/api/user-overrides/04")).toEqual({
|
||||
frames: { "04-1": "frame_05" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// flushUserOverrides
|
||||
// ============================================================================
|
||||
|
||||
describe("flushUserOverrides (IMP-52 u5)", () => {
|
||||
it("with no arg, flushes ALL pending buckets immediately (no 300 ms wait)", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03", { layout: "x" });
|
||||
void saveUserOverrides("04", { layout: "y" });
|
||||
|
||||
expect(putCallsCount()).toBe(0);
|
||||
const flushP = flushUserOverrides();
|
||||
await drainMicrotasks();
|
||||
await flushP;
|
||||
|
||||
expect(putCallsCount()).toBe(2);
|
||||
});
|
||||
|
||||
it("with a key arg, flushes only that bucket; others stay pending", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03", { layout: "x" });
|
||||
void saveUserOverrides("04", { layout: "y" });
|
||||
|
||||
await flushUserOverrides("03");
|
||||
await drainMicrotasks();
|
||||
|
||||
const puts = fetchMock.mock.calls.filter(
|
||||
(c) => (c[1] as RequestInit).method === "PUT",
|
||||
);
|
||||
expect(puts.length).toBe(1);
|
||||
expect(puts[0][0]).toBe("/api/user-overrides/03");
|
||||
|
||||
// "04" should still fire at the regular 300 ms boundary.
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
expect(putCallsCount()).toBe(2);
|
||||
});
|
||||
|
||||
it("is a no-op when no buckets are pending", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
await flushUserOverrides();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resolves the original saveUserOverrides promise via the in-flight PUT", async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockResponse({ layout: "flushed" }));
|
||||
const savePromise = saveUserOverrides("03", { layout: "flushed" });
|
||||
const flushPromise = flushUserOverrides();
|
||||
await drainMicrotasks();
|
||||
await flushPromise;
|
||||
await expect(savePromise).resolves.toEqual({ layout: "flushed" });
|
||||
});
|
||||
|
||||
it("propagates PUT failure as caller rejection (flush itself swallows)", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockResponse({ error: "boom" }, false, 500),
|
||||
);
|
||||
const savePromise = saveUserOverrides("03", { layout: "x" });
|
||||
// flush itself should not throw — the original waiter takes the rejection.
|
||||
const flushPromise = flushUserOverrides();
|
||||
await drainMicrotasks();
|
||||
await expect(flushPromise).resolves.toBeUndefined();
|
||||
await expect(savePromise).rejects.toThrow(/500/);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// type-level export sanity check (compile-time evidence; runtime no-op)
|
||||
// ============================================================================
|
||||
|
||||
describe("UserOverridesPartial type (IMP-52 u5)", () => {
|
||||
it("permits per-axis null sentinels and partial keys", () => {
|
||||
// Compile-time only — if any of these stops being a valid assignment,
|
||||
// the test suite fails at build with a TS error before this assertion
|
||||
// runs. The expect() is a placebo to keep vitest happy.
|
||||
const a: UserOverridesPartial = { layout: "x" };
|
||||
const b: UserOverridesPartial = { layout: null };
|
||||
const c: UserOverridesPartial = { frames: { unit: "tmpl" } };
|
||||
const d: UserOverridesPartial = {};
|
||||
const e: UserOverridesPartial = {
|
||||
image_overrides: { "img-1": { x: 10, y: 20, w: 30, h: 25 } },
|
||||
};
|
||||
const f: UserOverridesPartial = { image_overrides: null };
|
||||
expect([a, b, c, d, e, f]).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// IMP-51 #79 u3 — image_overrides axis (5th axis) parity coverage
|
||||
//
|
||||
// Same debounce / coalescing / clear / per-key isolation guarantees as the
|
||||
// 4 sibling axes (layout / frames / zone_geometries / zone_sections), but
|
||||
// asserted explicitly so a regression in the type or the runtime allowlist
|
||||
// fails here instead of in a downstream u8~u11 handler.
|
||||
// ============================================================================
|
||||
|
||||
describe("saveUserOverrides (IMP-51 #79 u3) — image_overrides axis", () => {
|
||||
it("PUT body carries only image_overrides when that is the sole mutated axis", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03", {
|
||||
image_overrides: { "img-1": { x: 10, y: 20, w: 30, h: 25 } },
|
||||
});
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
|
||||
const body = lastPutBody() as Record<string, unknown>;
|
||||
expect(Object.keys(body)).toEqual(["image_overrides"]);
|
||||
expect(body.image_overrides).toEqual({
|
||||
"img-1": { x: 10, y: 20, w: 30, h: 25 },
|
||||
});
|
||||
expect("layout" in body).toBe(false);
|
||||
expect("frames" in body).toBe(false);
|
||||
expect("zone_geometries" in body).toBe(false);
|
||||
expect("zone_sections" in body).toBe(false);
|
||||
});
|
||||
|
||||
it("per-axis later-wins: same image_id mutated twice keeps the LAST value", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03", {
|
||||
image_overrides: { "img-1": { x: 0, y: 0, w: 50, h: 50 } },
|
||||
});
|
||||
void saveUserOverrides("03", {
|
||||
image_overrides: { "img-1": { x: 25, y: 25, w: 30, h: 30 } },
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
expect(putCallsCount()).toBe(1);
|
||||
expect(lastPutBody()).toEqual({
|
||||
image_overrides: { "img-1": { x: 25, y: 25, w: 30, h: 30 } },
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards null sentinel verbatim (clear all image_overrides on disk)", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03", { image_overrides: null });
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
expect(lastPutBody()).toEqual({ image_overrides: null });
|
||||
});
|
||||
|
||||
it("coalesces with sibling axes in a single PUT", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03", { layout: "two_zone_split" });
|
||||
void saveUserOverrides("03", {
|
||||
image_overrides: { "img-1": { x: 10, y: 20, w: 30, h: 25 } },
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
expect(putCallsCount()).toBe(1);
|
||||
expect(lastPutBody()).toEqual({
|
||||
layout: "two_zone_split",
|
||||
image_overrides: { "img-1": { x: 10, y: 20, w: 30, h: 25 } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,802 @@
|
||||
// IMP-52 u10 — Frontend write-side regression coverage.
|
||||
//
|
||||
// Stage 2 unit u10 contract:
|
||||
// 1) All 4 in-scope mutation handlers persist their axis.
|
||||
// 2) zone_sizes is NOT persisted (handleLayoutResize stays in-memory).
|
||||
// 3) Write-before-Generate ordering — flushUserOverrides forces pending
|
||||
// PUTs to commit before the pipeline run begins.
|
||||
// 4) Restore-on-reopen end-to-end — getUserOverrides → non-frame layering
|
||||
// and post-loadRun frame remap compose into a single restored state.
|
||||
//
|
||||
// React Testing Library is NOT installed in this repo (devDependencies has
|
||||
// vitest only). Home.tsx's mutation handlers live inside `useCallback`
|
||||
// closures so they cannot be invoked from a test without mounting the
|
||||
// component. We cover them with two complementary tactics:
|
||||
// • Source-pattern grep on Home.tsx that pins the exact wiring shape per
|
||||
// handler. A regression that drops or rewires a `saveUserOverrides`
|
||||
// call fails here loudly.
|
||||
// • End-to-end mocked-fetch tests on the `userOverridesApi` flow with the
|
||||
// payload shapes that Home.tsx produces — proves the contract the
|
||||
// handlers depend on still holds.
|
||||
//
|
||||
// File extension is `.ts` (no JSX). All tests run in vitest's default node
|
||||
// environment; fetch is stubbed with vi.stubGlobal and timers are faked so
|
||||
// the 300ms debounce in `saveUserOverrides` is deterministic.
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
type Mock,
|
||||
} from "vitest";
|
||||
import {
|
||||
__resetUserOverridesBuckets_FOR_TEST,
|
||||
flushUserOverrides,
|
||||
getUserOverrides,
|
||||
saveUserOverrides,
|
||||
type UserOverridesPartial,
|
||||
} from "../src/services/userOverridesApi";
|
||||
import {
|
||||
applyPersistedNonFrameOverrides,
|
||||
createInitialUserSelection,
|
||||
deriveUserOverridesKey,
|
||||
remapPersistedFramesToZoneFrames,
|
||||
} from "../src/utils/slidePlanUtils";
|
||||
import type { SlidePlan, Zone } from "../src/types/designAgent";
|
||||
|
||||
// ─── Source-pattern regression ─────────────────────────────────────────────
|
||||
// Without RTL we can't dispatch a click and read `fetch.mock.calls`. Instead
|
||||
// we read Home.tsx as text and assert each in-scope handler closure contains
|
||||
// the exact wiring that Stage 2 u7 specified. This is brittle in a good way:
|
||||
// if a handler is renamed or its `saveUserOverrides` call is moved/removed,
|
||||
// the assertion fires with a clear "X handler does not persist Y axis"
|
||||
// message instead of silently regressing in prod.
|
||||
|
||||
const HOME_TSX_PATH = path.resolve(
|
||||
__dirname,
|
||||
"..",
|
||||
"src",
|
||||
"pages",
|
||||
"Home.tsx",
|
||||
);
|
||||
const HOME_TSX = fs.readFileSync(HOME_TSX_PATH, "utf-8");
|
||||
|
||||
/**
|
||||
* Slice the `const <name> = useCallback(...)` block out of Home.tsx. The
|
||||
* handlers are well-formed and end either at the next `const handle...`
|
||||
* declaration or at the next top-level `const ` at 2-space indent.
|
||||
*/
|
||||
function sliceHandler(source: string, name: string): string {
|
||||
const start = source.indexOf(`const ${name} = useCallback(`);
|
||||
if (start === -1) {
|
||||
throw new Error(`handler "${name}" not found in Home.tsx`);
|
||||
}
|
||||
// Find the next handler / top-level const after `start`.
|
||||
const nextHandler = source.indexOf("\n const handle", start + 1);
|
||||
const nextConst = source.indexOf("\n const ", start + 1);
|
||||
const candidates = [nextHandler, nextConst].filter((i) => i > start);
|
||||
const end = candidates.length > 0 ? Math.min(...candidates) : source.length;
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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");
|
||||
// gate
|
||||
expect(block).toMatch(/if\s*\(\s*p\.uploadedFile\s*\)/);
|
||||
// axis key + value source
|
||||
expect(block).toMatch(
|
||||
/saveUserOverrides\([\s\S]*?zone_sections:\s*finalSelection\.overrides\.zone_sections/,
|
||||
);
|
||||
// key derivation
|
||||
expect(block).toMatch(/deriveUserOverridesKey\(p\.uploadedFile\.name\)/);
|
||||
});
|
||||
|
||||
it("handleLayoutSelect persists `layout` axis behind uploadedFile gate", () => {
|
||||
const block = sliceHandler(HOME_TSX, "handleLayoutSelect");
|
||||
expect(block).toMatch(/if\s*\(\s*p\.uploadedFile\s*\)/);
|
||||
expect(block).toMatch(
|
||||
/saveUserOverrides\([\s\S]*?layout:\s*layoutId\s*\}/,
|
||||
);
|
||||
expect(block).toMatch(/deriveUserOverridesKey\(p\.uploadedFile\.name\)/);
|
||||
});
|
||||
|
||||
it("handleZoneResize persists merged zone_geometries behind uploadedFile gate", () => {
|
||||
const block = sliceHandler(HOME_TSX, "handleZoneResize");
|
||||
expect(block).toMatch(/if\s*\(\s*p\.uploadedFile\s*\)/);
|
||||
// merged geometry (not the partial delta) is persisted so the on-disk
|
||||
// axis is a complete snapshot of all currently-resized zones.
|
||||
expect(block).toMatch(
|
||||
/saveUserOverrides\([\s\S]*?zone_geometries:\s*mergedGeometries/,
|
||||
);
|
||||
expect(block).toMatch(/deriveUserOverridesKey\(p\.uploadedFile\.name\)/);
|
||||
});
|
||||
|
||||
it("handleFrameSelect persists frames-by-unit_id with default-frame gate", () => {
|
||||
const block = sliceHandler(HOME_TSX, "handleFrameSelect");
|
||||
expect(block).toMatch(/if\s*\(\s*p\.uploadedFile\s*&&\s*effectiveSlidePlan\s*\)/);
|
||||
// unit_id derivation matches handleGenerate's CLI-forwarding contract
|
||||
expect(block).toMatch(/z\.section_ids\.join\(\s*"\+"\s*\)/);
|
||||
// default-frame gate (rewind fix from Codex #17 / Claude #18)
|
||||
expect(block).toMatch(/overrideId\s*!==\s*defaultFrameId/);
|
||||
// axis key
|
||||
expect(block).toMatch(
|
||||
/saveUserOverrides\([\s\S]*?frames:\s*framesByUnitId/,
|
||||
);
|
||||
});
|
||||
|
||||
it("handleLayoutResize does NOT call saveUserOverrides (zone_sizes excluded)", () => {
|
||||
const block = sliceHandler(HOME_TSX, "handleLayoutResize");
|
||||
expect(block).not.toMatch(/saveUserOverrides/);
|
||||
// Sanity: handleLayoutResize still writes zone_sizes in-memory.
|
||||
expect(block).toMatch(/saveZoneSizes/);
|
||||
});
|
||||
|
||||
it("handleGenerate does NOT call saveUserOverrides (read-only re: persistence layer)", () => {
|
||||
const block = sliceHandler(HOME_TSX, "handleGenerate");
|
||||
// handleGenerate forwards overrides through runPipeline → /api/run, not
|
||||
// through /api/user-overrides. The persistence layer is owned by the
|
||||
// four mutation handlers; Generate must not introduce a competing
|
||||
// write path that could clobber a partially-edited bucket.
|
||||
expect(block).not.toMatch(/saveUserOverrides\(/);
|
||||
});
|
||||
|
||||
it("no handler in Home.tsx persists the zone_sizes axis", () => {
|
||||
// Top-level regression: searching the whole file rules out a future
|
||||
// accidental wiring inside a new handler we forgot to enumerate above.
|
||||
expect(HOME_TSX).not.toMatch(
|
||||
/saveUserOverrides\([\s\S]{0,200}?zone_sizes\s*:/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Payload-shape contract via mocked fetch ───────────────────────────────
|
||||
// Drive `saveUserOverrides` with the exact payload shapes each in-scope
|
||||
// handler produces in Home.tsx. Asserts that (a) the PUT body matches what
|
||||
// the on-disk schema (u1 / u4) accepts and (b) the partial-axis contract
|
||||
// holds — only the mutated axis is sent, never a full snapshot.
|
||||
|
||||
type MockResponse = {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
json: () => Promise<unknown>;
|
||||
};
|
||||
|
||||
function mockResponse(body: unknown, ok = true, status = 200): MockResponse {
|
||||
return { ok, status, json: async () => body };
|
||||
}
|
||||
|
||||
let fetchMock: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
vi.useFakeTimers();
|
||||
__resetUserOverridesBuckets_FOR_TEST();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
__resetUserOverridesBuckets_FOR_TEST();
|
||||
});
|
||||
|
||||
async function drainMicrotasks(): Promise<void> {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
function lastPutBody(): unknown {
|
||||
const lastCall = fetchMock.mock.calls.at(-1);
|
||||
if (!lastCall) throw new Error("fetch was not called");
|
||||
const init = lastCall[1] as RequestInit | undefined;
|
||||
if (!init?.body) throw new Error("fetch called without a body");
|
||||
return JSON.parse(String(init.body));
|
||||
}
|
||||
|
||||
describe("save payload contract per axis (IMP-52 u10)", () => {
|
||||
it("section-drop payload: PUT body carries only zone_sections", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
// Shape produced by handleSectionDrop after moveSectionToZone.
|
||||
const payload: UserOverridesPartial = {
|
||||
zone_sections: {
|
||||
top: ["03-1", "03-2"],
|
||||
bottom: ["03-3"],
|
||||
},
|
||||
};
|
||||
void saveUserOverrides("03_demo", payload);
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const body = lastPutBody() as Record<string, unknown>;
|
||||
expect(Object.keys(body)).toEqual(["zone_sections"]);
|
||||
expect(body.zone_sections).toEqual(payload.zone_sections);
|
||||
});
|
||||
|
||||
it("layout-select payload: PUT body carries only `layout` (string)", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03_demo", { layout: "two-column" });
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
const body = lastPutBody() as Record<string, unknown>;
|
||||
expect(Object.keys(body)).toEqual(["layout"]);
|
||||
expect(body.layout).toBe("two-column");
|
||||
});
|
||||
|
||||
it("zone-resize payload: PUT body carries only zone_geometries (merged snapshot)", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
const merged = {
|
||||
top: { x: 0, y: 0, w: 1, h: 0.42 },
|
||||
bottom_l: { x: 0, y: 0.42, w: 0.5, h: 0.58 },
|
||||
bottom_r: { x: 0.5, y: 0.42, w: 0.5, h: 0.58 },
|
||||
};
|
||||
void saveUserOverrides("03_demo", { zone_geometries: merged });
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
const body = lastPutBody() as Record<string, unknown>;
|
||||
expect(Object.keys(body)).toEqual(["zone_geometries"]);
|
||||
expect(body.zone_geometries).toEqual(merged);
|
||||
});
|
||||
|
||||
it("frame-select payload: PUT body carries only frames (unit_id → template_id)", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
// Shape produced by handleFrameSelect after the default-frame gate:
|
||||
// only zones the user explicitly chose a non-default frame for.
|
||||
const framesByUnitId = {
|
||||
"03-1": "process_product_two_way",
|
||||
"03-2+03-3": "three_parallel_requirements",
|
||||
};
|
||||
void saveUserOverrides("03_demo", { frames: framesByUnitId });
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
const body = lastPutBody() as Record<string, unknown>;
|
||||
expect(Object.keys(body)).toEqual(["frames"]);
|
||||
expect(body.frames).toEqual(framesByUnitId);
|
||||
});
|
||||
|
||||
it("frame-select payload with empty framesByUnitId still PUTs (replaces axis with {})", async () => {
|
||||
// When the user reverts the last frame override back to the backend
|
||||
// default, handleFrameSelect computes `framesByUnitId = {}`. The PUT
|
||||
// path still fires so the on-disk `frames` axis is cleared to the empty
|
||||
// object via u4's partial-merge replace semantics. Foreign axes
|
||||
// (layout / zone_geometries / zone_sections) remain on disk.
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03_demo", { frames: {} });
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
expect(lastPutBody()).toEqual({ frames: {} });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── zone_sizes axis is not part of the on-disk schema ─────────────────────
|
||||
|
||||
describe("zone_sizes axis exclusion (IMP-52 u10)", () => {
|
||||
it("UserOverridesPartial type does not include zone_sizes at compile time", () => {
|
||||
// Compile-time check: this assignment must be a TS error. The runtime
|
||||
// assertion below is a placebo; the meaningful evidence is that the
|
||||
// suite *builds*. If a future schema bump adds zone_sizes to
|
||||
// UserOverrides, this comment serves as the migration touchpoint.
|
||||
// @ts-expect-error — zone_sizes is intentionally not part of UserOverridesPartial
|
||||
const _bad: UserOverridesPartial = { zone_sizes: { layout_group_1: [0.5, 0.5] } };
|
||||
void _bad;
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it("Home.tsx never imports a write helper that would persist zone_sizes", () => {
|
||||
// handleLayoutResize delegates to saveZoneSizes (in-memory), not
|
||||
// saveUserOverrides. Cross-check the import line and the handler body.
|
||||
expect(HOME_TSX).toMatch(/import\s*\{[^}]*\bsaveZoneSizes\b[^}]*\}\s*from\s*"\.\.\/utils\/slidePlanUtils"/);
|
||||
const block = sliceHandler(HOME_TSX, "handleLayoutResize");
|
||||
expect(block).toMatch(/saveZoneSizes\(/);
|
||||
expect(block).not.toMatch(/saveUserOverrides/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Write-before-Generate ordering ────────────────────────────────────────
|
||||
// The four mutation handlers schedule debounced PUTs (300ms). If the user
|
||||
// hits Generate before the debounce fires, the persistence layer must not
|
||||
// drop the pending writes. `flushUserOverrides` is the contract: callers can
|
||||
// force-commit pending buckets before pipeline kickoff so the backend u2
|
||||
// fallback reads the latest file.
|
||||
|
||||
describe("write-before-Generate ordering (IMP-52 u10)", () => {
|
||||
// The service-level tests below prove the `flushUserOverrides` contract in
|
||||
// isolation. The two source-pattern checks here pin the *real* Generate
|
||||
// call site so a future refactor that drops the flush — re-exposing the
|
||||
// 300ms debounce race against `runPipeline` / the u2 backend fallback —
|
||||
// fails loudly. Without React Testing Library we cannot dispatch a click
|
||||
// on the Generate button, so we read Home.tsx as text and assert (a) the
|
||||
// import names `flushUserOverrides`, (b) the `handleGenerate` closure
|
||||
// awaits the flush before it awaits `runPipeline`.
|
||||
|
||||
it("Home.tsx imports flushUserOverrides from userOverridesApi", () => {
|
||||
expect(HOME_TSX).toMatch(
|
||||
/import\s*\{[^}]*\bflushUserOverrides\b[^}]*\}\s*from\s*"\.\.\/services\/userOverridesApi"/,
|
||||
);
|
||||
});
|
||||
|
||||
it("handleGenerate awaits flushUserOverrides before awaiting runPipeline", () => {
|
||||
const block = sliceHandler(HOME_TSX, "handleGenerate");
|
||||
expect(block).toMatch(/await\s+flushUserOverrides\s*\(\s*\)/);
|
||||
expect(block).toMatch(/await\s+runPipeline\s*\(/);
|
||||
const flushIdx = block.search(/await\s+flushUserOverrides\s*\(/);
|
||||
const runIdx = block.search(/await\s+runPipeline\s*\(/);
|
||||
expect(flushIdx).toBeGreaterThan(-1);
|
||||
expect(runIdx).toBeGreaterThan(-1);
|
||||
expect(flushIdx).toBeLessThan(runIdx);
|
||||
});
|
||||
|
||||
it("flushUserOverrides commits a pending PUT before its 300ms debounce fires", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({ layout: "two-column" }));
|
||||
const savePromise = saveUserOverrides("03_demo", { layout: "two-column" });
|
||||
|
||||
// Without flush, the PUT would not fire for another 300ms.
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
const flushPromise = flushUserOverrides();
|
||||
await drainMicrotasks();
|
||||
await flushPromise;
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("/api/user-overrides/03_demo");
|
||||
expect((init as RequestInit).method).toBe("PUT");
|
||||
|
||||
// Caller's promise resolves with the server-merged document — so a
|
||||
// pre-Generate `await flushUserOverrides()` can be paired with
|
||||
// `await savePromise` for stronger ordering if needed.
|
||||
await expect(savePromise).resolves.toEqual({ layout: "two-column" });
|
||||
});
|
||||
|
||||
it("flushUserOverrides (no arg) flushes pending writes across multiple MDX keys", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03_demo", { layout: "two-column" });
|
||||
void saveUserOverrides("04_demo", { frames: { "04-1": "tpl_a" } });
|
||||
void saveUserOverrides("05_demo", {
|
||||
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } },
|
||||
});
|
||||
|
||||
await flushUserOverrides();
|
||||
await drainMicrotasks();
|
||||
|
||||
const putUrls = fetchMock.mock.calls
|
||||
.filter((c) => (c[1] as RequestInit).method === "PUT")
|
||||
.map((c) => c[0]);
|
||||
expect(putUrls).toEqual(
|
||||
expect.arrayContaining([
|
||||
"/api/user-overrides/03_demo",
|
||||
"/api/user-overrides/04_demo",
|
||||
"/api/user-overrides/05_demo",
|
||||
]),
|
||||
);
|
||||
expect(putUrls).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("flushUserOverrides is a no-op when no writes are pending", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
await flushUserOverrides();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("post-flush, a new save schedules a fresh 300ms debounce window", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03_demo", { layout: "two-column" });
|
||||
await flushUserOverrides();
|
||||
await drainMicrotasks();
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(
|
||||
(c) => (c[1] as RequestInit).method === "PUT",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
|
||||
// Second save after Generate completes — must not piggy-back on the
|
||||
// already-flushed bucket; must re-arm a fresh debounce.
|
||||
void saveUserOverrides("03_demo", { layout: "hero-detail" });
|
||||
vi.advanceTimersByTime(299);
|
||||
await drainMicrotasks();
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(
|
||||
(c) => (c[1] as RequestInit).method === "PUT",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
vi.advanceTimersByTime(1);
|
||||
await drainMicrotasks();
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(
|
||||
(c) => (c[1] as RequestInit).method === "PUT",
|
||||
),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Restore-on-reopen — end-to-end compose ────────────────────────────────
|
||||
// u6 covers the helpers in isolation. This test wires them together with a
|
||||
// mocked GET response in the order Home.tsx invokes them at file-upload
|
||||
// time (key derive → fetch persisted → layer non-frame axes pre-loadRun →
|
||||
// remap frames post-loadRun) to pin the integration contract.
|
||||
|
||||
function makeZone(partial: {
|
||||
id: string;
|
||||
zone_id: string;
|
||||
section_ids: string[];
|
||||
default_frame_id?: string | null;
|
||||
}): Zone {
|
||||
return {
|
||||
id: partial.id,
|
||||
zone_id: partial.zone_id,
|
||||
section_ids: partial.section_ids,
|
||||
position: { x: 0, y: 0, width: 1, height: 1 },
|
||||
internal_regions: [
|
||||
{
|
||||
id: `${partial.id}-r0`,
|
||||
region_id: "region-single",
|
||||
role: "primary",
|
||||
content_type: "text_block",
|
||||
ratio_estimate: 1,
|
||||
content_unit_ids: [],
|
||||
frame_match_strategy: {
|
||||
kind: "frame_match",
|
||||
frame_id: partial.default_frame_id ?? null,
|
||||
display_strategy: "inline_full",
|
||||
},
|
||||
frame_candidates: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("restore-on-reopen end-to-end (IMP-52 u10)", () => {
|
||||
it("getUserOverrides → non-frame layer + post-load frame remap composes a restored selection", async () => {
|
||||
// GET returns the persisted file for "03_demo". The `layout` value
|
||||
// must be a real LayoutPresetId — applyPersistedNonFrameOverrides
|
||||
// validates against the 8-preset whitelist (slidePlanUtils.ts:30).
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockResponse({
|
||||
layout: "horizontal-2",
|
||||
frames: { "03-1": "process_product_two_way" },
|
||||
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.42 } },
|
||||
zone_sections: { top: ["03-1"], bottom: ["03-2", "03-3"] },
|
||||
}),
|
||||
);
|
||||
|
||||
const key = deriveUserOverridesKey("03_demo.mdx");
|
||||
expect(key).toBe("03_demo");
|
||||
|
||||
// Step 1: Home.tsx fetches at handleFileUpload time.
|
||||
const persisted = await getUserOverrides(key);
|
||||
expect(persisted.layout).toBe("horizontal-2");
|
||||
|
||||
// Step 2: pre-loadRun layering applies layout / zone_geometries /
|
||||
// zone_sections onto a fresh selection. Frames are deferred because
|
||||
// the unit_id key cannot be remapped without a slidePlan yet.
|
||||
const seededSelection = applyPersistedNonFrameOverrides(
|
||||
createInitialUserSelection(null),
|
||||
persisted,
|
||||
);
|
||||
expect(seededSelection.overrides.layout_preset).toBe("horizontal-2");
|
||||
expect(seededSelection.overrides.zone_geometries).toEqual({
|
||||
top: { x: 0, y: 0, w: 1, h: 0.42 },
|
||||
});
|
||||
expect(seededSelection.overrides.zone_sections).toEqual({
|
||||
top: ["03-1"],
|
||||
bottom: ["03-2", "03-3"],
|
||||
});
|
||||
// Frames must NOT have been layered at this stage.
|
||||
expect(seededSelection.overrides.zone_frames).toEqual({});
|
||||
|
||||
// Step 3: post-loadRun, Home.tsx has a slidePlan. Remap unit_id-keyed
|
||||
// frames to region.id-keyed frames against the rebuilt plan.
|
||||
const plan: SlidePlan = {
|
||||
id: "plan-3",
|
||||
title: "demo",
|
||||
layout_preset: "horizontal-2",
|
||||
zones: [
|
||||
makeZone({
|
||||
id: "z-top",
|
||||
zone_id: "top",
|
||||
section_ids: ["03-1"],
|
||||
default_frame_id: "some_default_frame",
|
||||
}),
|
||||
makeZone({
|
||||
id: "z-bot",
|
||||
zone_id: "bottom",
|
||||
section_ids: ["03-2", "03-3"],
|
||||
default_frame_id: null,
|
||||
}),
|
||||
],
|
||||
};
|
||||
const remapped = remapPersistedFramesToZoneFrames(
|
||||
plan,
|
||||
persisted.frames,
|
||||
);
|
||||
expect(remapped).toEqual({
|
||||
"z-top-r0": "process_product_two_way",
|
||||
});
|
||||
|
||||
// Step 4: post-loadRun merge — Home.tsx layers `remapped` onto
|
||||
// `createInitialUserSelection(slidePlan)` so the SlideCanvas
|
||||
// override-vs-default preview indicator surfaces the restored choice.
|
||||
const finalSelection = {
|
||||
...applyPersistedNonFrameOverrides(
|
||||
createInitialUserSelection(plan),
|
||||
persisted,
|
||||
),
|
||||
};
|
||||
finalSelection.overrides = {
|
||||
...finalSelection.overrides,
|
||||
zone_frames: { ...finalSelection.overrides.zone_frames, ...remapped },
|
||||
};
|
||||
expect(finalSelection.overrides.zone_frames["z-top-r0"]).toBe(
|
||||
"process_product_two_way",
|
||||
);
|
||||
expect(finalSelection.overrides.layout_preset).toBe("horizontal-2");
|
||||
expect(finalSelection.overrides.zone_sections).toEqual({
|
||||
top: ["03-1"],
|
||||
bottom: ["03-2", "03-3"],
|
||||
});
|
||||
});
|
||||
|
||||
it("missing persisted file (GET returns {}) leaves the selection at backend defaults", async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockResponse({}));
|
||||
const persisted = await getUserOverrides(deriveUserOverridesKey("new_file.mdx"));
|
||||
expect(persisted).toEqual({});
|
||||
|
||||
const plan: SlidePlan = {
|
||||
id: "plan-x",
|
||||
title: "fresh",
|
||||
layout_preset: "single",
|
||||
zones: [
|
||||
makeZone({ id: "z-only", zone_id: "main", section_ids: ["x-1"] }),
|
||||
],
|
||||
};
|
||||
const seeded = applyPersistedNonFrameOverrides(
|
||||
createInitialUserSelection(plan),
|
||||
persisted,
|
||||
);
|
||||
// No override applied → layout_preset, geometries, sections all from
|
||||
// the slidePlan defaults; remap yields {} so no frames layered.
|
||||
expect(seeded.overrides.layout_preset).toBe("single");
|
||||
expect(seeded.overrides.zone_geometries).toEqual({});
|
||||
expect(remapPersistedFramesToZoneFrames(plan, persisted.frames)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 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));
|
||||
});
|
||||
});
|
||||
+580
-1
@@ -204,20 +204,545 @@ function vitePluginStorageProxy(): Plugin {
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// IMP-52 u3/u4 — user_overrides.json persistence (MDX-stem keyed store).
|
||||
//
|
||||
// On-disk layout: <DESIGN_AGENT_ROOT>/data/user_overrides/<key>.json. Mirrors
|
||||
// the Python contract in src/user_overrides_io.py — same validate_key regex,
|
||||
// same graceful-degrade (corrupt → {}) so backend pipeline entry fallback
|
||||
// (u2) and the vite endpoints (u3 GET, u4 PUT) agree on every file.
|
||||
//
|
||||
// Helpers are named exports so vitest can drive handleGetUserOverrides /
|
||||
// handlePutUserOverrides with mock req/res without booting a real dev
|
||||
// server. vite still consumes the default `defineConfig` export below.
|
||||
// =============================================================================
|
||||
|
||||
export const USER_OVERRIDES_KEY_RE = /^[A-Za-z0-9_][A-Za-z0-9_.\-]*$/;
|
||||
|
||||
// The 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];
|
||||
|
||||
// 1MB cap on PUT bodies. Override files in practice are < 10KB (5 axes,
|
||||
// each a small dict). The cap is a safety net against runaway client
|
||||
// loops, not a real schema constraint.
|
||||
const USER_OVERRIDES_PUT_MAX_BYTES = 1_000_000;
|
||||
|
||||
export function isValidUserOverridesKey(key: string): boolean {
|
||||
if (!key) return false;
|
||||
if (key.includes("..")) return false;
|
||||
if (key.includes("/") || key.includes("\\")) return false;
|
||||
return USER_OVERRIDES_KEY_RE.test(key);
|
||||
}
|
||||
|
||||
export function userOverridesPath(root: string, key: string): string {
|
||||
return path.join(root, "data", "user_overrides", `${key}.json`);
|
||||
}
|
||||
|
||||
// Minimal req/res shapes — node IncomingMessage / ServerResponse have many
|
||||
// fields the handler does not touch, so we accept a structural subset for
|
||||
// testability.
|
||||
type GetReqLike = { method?: string; url?: string };
|
||||
type PutReqLike = {
|
||||
method?: string;
|
||||
url?: string;
|
||||
on(event: "data" | "end" | "error", cb: (...args: any[]) => void): unknown;
|
||||
};
|
||||
type ResLike = {
|
||||
writeHead: (status: number, headers?: Record<string, string>) => void;
|
||||
end: (body?: string) => void;
|
||||
};
|
||||
|
||||
// IMP-52 u3 — GET /api/user-overrides/:key handler. Returns true when the
|
||||
// handler took over the response, false when the caller should `next()`.
|
||||
// Invariants:
|
||||
// • method != GET → false (chain continues; u4 PUT may handle)
|
||||
// • invalid key → 400 {"error":"invalid key"}
|
||||
// • file missing → 200 {}
|
||||
// • file unreadable/corrupt → 200 {} (graceful degrade, mirrors u1 load)
|
||||
// • non-object JSON root → 200 {} (mirrors u1 load)
|
||||
// • valid object JSON → 200 with parsed JSON body
|
||||
export function handleGetUserOverrides(
|
||||
req: GetReqLike,
|
||||
res: ResLike,
|
||||
root: string,
|
||||
): boolean {
|
||||
if (req.method !== "GET") return false;
|
||||
|
||||
const url = req.url || "";
|
||||
const key = url.split("?")[0].replace(/^\//, "");
|
||||
|
||||
if (!isValidUserOverridesKey(key)) {
|
||||
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ error: "invalid key" }));
|
||||
return true;
|
||||
}
|
||||
|
||||
const filePath = userOverridesPath(root, key);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end("{}");
|
||||
return true;
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, "utf-8");
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end("{}");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end("{}");
|
||||
return true;
|
||||
}
|
||||
|
||||
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify(parsed));
|
||||
return true;
|
||||
}
|
||||
|
||||
// IMP-52 u4 — pure merge function. Mirrors src/user_overrides_io.save():
|
||||
// • Only KNOWN_USER_OVERRIDES_AXES present in `partial` are mutated.
|
||||
// • Axes absent from `partial` are preserved verbatim from `existing`.
|
||||
// • Foreign top-level keys in `existing` (future axes like zone_sizes)
|
||||
// are preserved verbatim — allowlist guards what the PUT writes, NOT
|
||||
// what the file already holds.
|
||||
// • `partial[axis] = null` is the explicit clear sentinel (remove key).
|
||||
// • Any non-axis keys in `partial` are silently dropped (allowlist).
|
||||
export function mergeUserOverrides(
|
||||
existing: Record<string, unknown>,
|
||||
partial: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const merged: Record<string, unknown> = { ...existing };
|
||||
for (const axis of KNOWN_USER_OVERRIDES_AXES) {
|
||||
if (!(axis in partial)) continue;
|
||||
const value = partial[axis];
|
||||
if (value === null) {
|
||||
delete merged[axis];
|
||||
} else {
|
||||
merged[axis] = value;
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
// IMP-52 u4 — atomic file write via tmp + rename. Mirrors the
|
||||
// `_atomic_write_json` semantics in src/user_overrides_io.py so a
|
||||
// crashed/interrupted PUT cannot leave a half-written .json on disk
|
||||
// (the next GET / pipeline-entry read would otherwise return {} via
|
||||
// graceful degrade, silently losing the user's prior overrides).
|
||||
export function atomicWriteUserOverrides(
|
||||
filePath: string,
|
||||
data: Record<string, unknown>,
|
||||
): void {
|
||||
const dir = path.dirname(filePath);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
const tmpName = path.join(
|
||||
dir,
|
||||
`.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`,
|
||||
);
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
tmpName,
|
||||
JSON.stringify(data, null, 2) + "\n",
|
||||
"utf-8",
|
||||
);
|
||||
fs.renameSync(tmpName, filePath);
|
||||
} catch (err) {
|
||||
try {
|
||||
fs.unlinkSync(tmpName);
|
||||
} catch {
|
||||
// best-effort cleanup; the rename source may not exist on early failure
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// IMP-52 u4 — PUT /api/user-overrides/:key handler. Returns true when the
|
||||
// handler took over the response, false when the caller should `next()`.
|
||||
// Invariants:
|
||||
// • method != PUT → false (chain continues; GET runs first)
|
||||
// • invalid key → 400 {"error":"invalid key"}
|
||||
// • body > 1MB → 413 {"error":"payload too large"}
|
||||
// • invalid JSON → 400 {"error":"invalid JSON"}
|
||||
// • non-object JSON root → 400 {"error":"body must be a JSON object"}
|
||||
// • write failure → 500 {"error":"write failed: ..."}
|
||||
// • success → 200 with merged JSON body
|
||||
//
|
||||
// Existing-file read uses the same graceful-degrade rules as GET (corrupt
|
||||
// JSON / non-object root → treat as empty {}) so a PUT cannot fail solely
|
||||
// because a prior file is unparseable — the new payload replaces it.
|
||||
export function handlePutUserOverrides(
|
||||
req: PutReqLike,
|
||||
res: ResLike,
|
||||
root: string,
|
||||
): boolean {
|
||||
if (req.method !== "PUT") return false;
|
||||
|
||||
const url = req.url || "";
|
||||
const key = url.split("?")[0].replace(/^\//, "");
|
||||
|
||||
if (!isValidUserOverridesKey(key)) {
|
||||
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ error: "invalid key" }));
|
||||
return true;
|
||||
}
|
||||
|
||||
let body = "";
|
||||
let aborted = false;
|
||||
|
||||
req.on("data", (chunk: Buffer | string) => {
|
||||
if (aborted) return;
|
||||
body += typeof chunk === "string" ? chunk : chunk.toString();
|
||||
if (body.length > USER_OVERRIDES_PUT_MAX_BYTES) {
|
||||
aborted = true;
|
||||
res.writeHead(413, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
});
|
||||
res.end(JSON.stringify({ error: "payload too large" }));
|
||||
}
|
||||
});
|
||||
|
||||
req.on("end", () => {
|
||||
if (aborted) return;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = body.length > 0 ? JSON.parse(body) : {};
|
||||
} catch {
|
||||
res.writeHead(400, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
});
|
||||
res.end(JSON.stringify({ error: "invalid JSON" }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof parsed !== "object" ||
|
||||
parsed === null ||
|
||||
Array.isArray(parsed)
|
||||
) {
|
||||
res.writeHead(400, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
});
|
||||
res.end(JSON.stringify({ error: "body must be a JSON object" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const partial = parsed as Record<string, unknown>;
|
||||
const filePath = userOverridesPath(root, key);
|
||||
|
||||
// Load existing — corrupt / non-object → {} so the PUT still succeeds
|
||||
// and recovers the file to a clean state. Mirrors u1 load() graceful
|
||||
// degrade.
|
||||
let existing: Record<string, unknown> = {};
|
||||
if (fs.existsSync(filePath)) {
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, "utf-8");
|
||||
const ex = JSON.parse(raw);
|
||||
if (
|
||||
typeof ex === "object" &&
|
||||
ex !== null &&
|
||||
!Array.isArray(ex)
|
||||
) {
|
||||
existing = ex as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
// corrupt → treat as empty
|
||||
}
|
||||
}
|
||||
|
||||
const merged = mergeUserOverrides(existing, partial);
|
||||
|
||||
try {
|
||||
atomicWriteUserOverrides(filePath, merged);
|
||||
} catch (err) {
|
||||
res.writeHead(500, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
});
|
||||
res.end(JSON.stringify({ error: `write failed: ${String(err)}` }));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
});
|
||||
res.end(JSON.stringify(merged));
|
||||
});
|
||||
|
||||
req.on("error", () => {
|
||||
if (aborted) return;
|
||||
aborted = true;
|
||||
res.writeHead(500, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
});
|
||||
res.end(JSON.stringify({ error: "request error" }));
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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 업로드 → 파이프라인 실행 → 결과 노출
|
||||
//
|
||||
// Endpoints (vite dev middleware) :
|
||||
// POST /api/run multipart/JSON body {filename, content} → run_id
|
||||
// GET /data/runs/{run_id}/{path} → {DESIGN_AGENT_ROOT}/data/runs/{run_id}/phase_z2/{path}
|
||||
// GET /api/user-overrides/{key} → data/user_overrides/{key}.json (IMP-52 u3)
|
||||
// PUT /api/user-overrides/{key} → partial-merge save (IMP-52 u4)
|
||||
// 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");
|
||||
|
||||
@@ -245,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);
|
||||
@@ -256,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(
|
||||
@@ -340,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))}`
|
||||
);
|
||||
@@ -464,6 +1009,40 @@ function vitePluginPhaseZApi(): Plugin {
|
||||
fs.createReadStream(previewPath).pipe(res);
|
||||
});
|
||||
|
||||
// ── GET / PUT /api/user-overrides/{key} → data/user_overrides/{key}.json ──
|
||||
// IMP-52 u3 (GET) + u4 (PUT) — MDX-stem keyed user overrides. Logic
|
||||
// lives in the pure helpers (handleGetUserOverrides / handlePutUserOverrides)
|
||||
// so vitest can exercise them without booting vite. Both handlers
|
||||
// return false when the HTTP method does not match, so they chain
|
||||
// cleanly: GET first, then PUT, then next() for everything else
|
||||
// (e.g., OPTIONS / preflight handled by upstream middleware).
|
||||
server.middlewares.use("/api/user-overrides", (req, res, next) => {
|
||||
if (handleGetUserOverrides(req, res, DESIGN_AGENT_ROOT)) return;
|
||||
if (handlePutUserOverrides(req, res, DESIGN_AGENT_ROOT)) return;
|
||||
next();
|
||||
});
|
||||
|
||||
// ── 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,66 @@ 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).
|
||||
|
||||
---
|
||||
|
||||
## 9. IMP-95 (V4 evidence → B4 `_select_frame` integration) sub-axis markers
|
||||
|
||||
> Sub-axis carve-out of section 3 item (j) for IMP-95. Pair-comment markers
|
||||
> `<!-- IMP-95:<axis> -->VALUE<!-- /IMP-95 -->`. Closing tag `<!-- /IMP-95 -->`
|
||||
> is intentionally distinct from IMP-91's `<!-- /IMP-91 -->` so the IMP-91
|
||||
> updater (`scripts/update_status_board.py`) cannot rewrite IMP-95 cells.
|
||||
> Allowed values: `pending` (not implemented), `trace-only` (default-OFF flag
|
||||
> `PHASE_Z_B4_V4_EVIDENCE`, additive telemetry only — no render-path change),
|
||||
> `guarded` (default-OFF regression harness landed and runs locally), `active`
|
||||
> (default-ON — not the current IMP-95 target).
|
||||
|
||||
| sub-axis | status |
|
||||
|---|---|
|
||||
| j1 V4-aware selector under `accepted_content_types ⊇` (u2) | <!-- IMP-95:j1 -->trace-only<!-- /IMP-95 --> |
|
||||
| j2 `plan_placement` v4_candidates kwarg + selection_trace (u3) | <!-- IMP-95:j2 -->trace-only<!-- /IMP-95 --> |
|
||||
| j3 Step 11 `placement_trace` hoist (u4) | <!-- IMP-95:j3 -->trace-only<!-- /IMP-95 --> |
|
||||
| j4 Gatekeeper `v4_short_circuit` telemetry (u5) | <!-- IMP-95:j4 -->trace-only<!-- /IMP-95 --> |
|
||||
| j5 `partial_exists` precheck (u6) | <!-- IMP-95:j5 -->trace-only<!-- /IMP-95 --> |
|
||||
| j6 Flag-OFF SHA parity regression on mdx 01/02/04/05 (u8) | <!-- IMP-95:j6 -->guarded<!-- /IMP-95 --> |
|
||||
| j7 Flag-ON adapter_needed monotone regression (u9) | <!-- IMP-95:j7 -->guarded<!-- /IMP-95 --> |
|
||||
| j8 Flag-ON `placement_trace` field presence regression (u10) | <!-- IMP-95:j8 -->guarded<!-- /IMP-95 --> |
|
||||
|
||||
---
|
||||
|
||||
## 사용 방법
|
||||
|
||||
- 새 작업 들어오면 → 본 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,299 @@
|
||||
"""Catalog ↔ partial ↔ builder invariant audit CLI (IMP-#85 u3a / u3b).
|
||||
|
||||
Offline audit of `templates/phase_z2/catalog/frame_contracts.yaml` against
|
||||
the on-disk frame partials and the runtime `PAYLOAD_BUILDERS` registry.
|
||||
|
||||
Reports diff surface so first-fix iteration sees the entire catalog drift,
|
||||
not just the first failure (matches the boot-time invariant's aggregation
|
||||
behavior in `_check_catalog_builder_invariant`).
|
||||
|
||||
Invariants (scope-locked per Stage 2):
|
||||
I1 partial existence — `templates/phase_z2/families/{template_id}.html`
|
||||
must exist for live (non-VP) contracts.
|
||||
I2 builder declared — live contracts must declare a non-empty
|
||||
`payload.builder`.
|
||||
I3 builder registered — declared builders must be members of
|
||||
`src.phase_z2_mapper.PAYLOAD_BUILDERS`.
|
||||
I4 slot_payload refs — every key generated by the contract's builder
|
||||
must appear as a `slot_payload.<key>` reference in
|
||||
the partial. Direction A only (dead generated key).
|
||||
Skipped when the partial uses dynamic bracket
|
||||
access (`slot_payload[...]`) — those refs cannot be
|
||||
resolved statically; the relevant generated keys
|
||||
are presumed reachable via the dynamic form.
|
||||
|
||||
`visual_pending: true` contracts are skipped for I1–I4 (data-driven from
|
||||
catalog, no hard-coded frame allow-list; matches u2 invariant scope).
|
||||
|
||||
Exit codes:
|
||||
0 — all invariants pass on live (non-VP) contracts.
|
||||
1 — one or more violations reported.
|
||||
|
||||
Usage::
|
||||
|
||||
python scripts/audit_frame_invariants.py
|
||||
python scripts/audit_frame_invariants.py --catalog <path> --partials-dir <path>
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
import yaml
|
||||
|
||||
DEFAULT_CATALOG_PATH = (
|
||||
REPO_ROOT / "templates" / "phase_z2" / "catalog" / "frame_contracts.yaml"
|
||||
)
|
||||
DEFAULT_PARTIALS_DIR = REPO_ROOT / "templates" / "phase_z2" / "families"
|
||||
|
||||
|
||||
def _format_path(path: Path) -> str:
|
||||
try:
|
||||
return str(path.relative_to(REPO_ROOT))
|
||||
except ValueError:
|
||||
return str(path)
|
||||
|
||||
|
||||
def _is_visual_pending(contract: dict) -> bool:
|
||||
return contract.get("visual_pending") is True
|
||||
|
||||
|
||||
def _iter_live_contracts(catalog: dict) -> Iterable[tuple[str, dict]]:
|
||||
for template_id, contract in catalog.items():
|
||||
if not isinstance(contract, dict):
|
||||
continue
|
||||
if _is_visual_pending(contract):
|
||||
continue
|
||||
yield template_id, contract
|
||||
|
||||
|
||||
def check_i1_partial_existence(
|
||||
catalog: dict, partials_dir: Path
|
||||
) -> list[str]:
|
||||
"""I1 — Live contracts must have `families/{template_id}.html` on disk."""
|
||||
violations: list[str] = []
|
||||
for template_id, _contract in _iter_live_contracts(catalog):
|
||||
partial_path = partials_dir / f"{template_id}.html"
|
||||
if not partial_path.is_file():
|
||||
violations.append(
|
||||
f"I1 partial-missing: contract '{template_id}' has no "
|
||||
f"partial file at {_format_path(partial_path)}."
|
||||
)
|
||||
return violations
|
||||
|
||||
|
||||
def check_i2_builder_declared(catalog: dict) -> list[str]:
|
||||
"""I2 — Live contracts must declare a non-empty `payload.builder`."""
|
||||
violations: list[str] = []
|
||||
for template_id, contract in _iter_live_contracts(catalog):
|
||||
payload = contract.get("payload") or {}
|
||||
if not isinstance(payload, dict):
|
||||
violations.append(
|
||||
f"I2 builder-undeclared: contract '{template_id}' has "
|
||||
f"non-dict payload (type={type(payload).__name__})."
|
||||
)
|
||||
continue
|
||||
builder_name = payload.get("builder")
|
||||
if not builder_name:
|
||||
violations.append(
|
||||
f"I2 builder-undeclared: contract '{template_id}' is "
|
||||
f"missing payload.builder."
|
||||
)
|
||||
return violations
|
||||
|
||||
|
||||
def check_i3_builder_registered(
|
||||
catalog: dict, registered_builders: set[str]
|
||||
) -> list[str]:
|
||||
"""I3 — Declared builders must be members of PAYLOAD_BUILDERS registry."""
|
||||
violations: list[str] = []
|
||||
for template_id, contract in _iter_live_contracts(catalog):
|
||||
payload = contract.get("payload") or {}
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
builder_name = payload.get("builder")
|
||||
if not builder_name:
|
||||
continue
|
||||
if builder_name not in registered_builders:
|
||||
violations.append(
|
||||
f"I3 builder-unregistered: contract '{template_id}' "
|
||||
f"references payload.builder='{builder_name}' not in "
|
||||
f"PAYLOAD_BUILDERS."
|
||||
)
|
||||
return violations
|
||||
|
||||
|
||||
_SLOT_PAYLOAD_DOT_RE = re.compile(r"slot_payload\.([A-Za-z_][A-Za-z0-9_]*)")
|
||||
_SLOT_PAYLOAD_BRACKET_RE = re.compile(r"slot_payload\s*\[")
|
||||
|
||||
|
||||
def extract_static_slot_refs(partial_text: str) -> set[str]:
|
||||
"""Return the set of `slot_payload.<key>` dot-access references."""
|
||||
return set(_SLOT_PAYLOAD_DOT_RE.findall(partial_text))
|
||||
|
||||
|
||||
def partial_uses_dynamic_slot_access(partial_text: str) -> bool:
|
||||
"""True if the partial dereferences `slot_payload[...]` (dynamic key)."""
|
||||
return bool(_SLOT_PAYLOAD_BRACKET_RE.search(partial_text))
|
||||
|
||||
|
||||
def expected_payload_keys(contract: dict) -> set[str]:
|
||||
"""Statically compute the set of payload keys the contract's builder produces.
|
||||
|
||||
Mirrors `src.phase_z2_mapper`'s registered builders (IMP-#85 u3b). Returns
|
||||
an empty set when the builder is unknown — I3 already flags that drift.
|
||||
"""
|
||||
payload = contract.get("payload") or {}
|
||||
if not isinstance(payload, dict):
|
||||
return set()
|
||||
keys: set[str] = set()
|
||||
title_spec = payload.get("title")
|
||||
if isinstance(title_spec, dict) and title_spec.get("source"):
|
||||
keys.add("title")
|
||||
|
||||
builder = payload.get("builder")
|
||||
options = payload.get("builder_options") or {}
|
||||
if not isinstance(options, dict):
|
||||
options = {}
|
||||
|
||||
if builder == "items_with_role":
|
||||
array_root = options.get("array_root")
|
||||
if array_root:
|
||||
keys.add(array_root)
|
||||
elif builder == "process_product_pair":
|
||||
for col in options.get("columns") or []:
|
||||
if not isinstance(col, dict):
|
||||
continue
|
||||
if col.get("title_to"):
|
||||
keys.add(col["title_to"])
|
||||
if col.get("body_to"):
|
||||
keys.add(col["body_to"])
|
||||
elif builder == "quadrant_flat_slots":
|
||||
pad_to = int(options.get("pad_to", 4))
|
||||
label_key = options.get("label_key_pattern", "quadrant_{n}_label")
|
||||
body_key = options.get("body_key_pattern", "quadrant_{n}_body")
|
||||
for n in range(1, pad_to + 1):
|
||||
keys.add(label_key.format(n=n))
|
||||
keys.add(body_key.format(n=n))
|
||||
elif builder == "cycle_intersect_3":
|
||||
pad_to = int(options.get("pad_to", 3))
|
||||
label_key = options.get("label_key_pattern", "circle_{n}_label")
|
||||
for n in range(1, pad_to + 1):
|
||||
keys.add(label_key.format(n=n))
|
||||
keys.add("intersection")
|
||||
elif builder == "compare_table_2col":
|
||||
keys.update({"col_a_label", "col_b_label", "rows"})
|
||||
elif builder == "paired_rows_4x2_slots":
|
||||
label_key = options.get("label_key_pattern", "row_{r}_{side}_label")
|
||||
body_key = options.get("body_key_pattern", "row_{r}_{side}_body")
|
||||
rows = int(options.get("rows", 4))
|
||||
sides = options.get("sides", ["left", "right"]) or []
|
||||
for r in range(1, rows + 1):
|
||||
for side in sides:
|
||||
keys.add(label_key.format(r=r, side=side))
|
||||
keys.add(body_key.format(r=r, side=side))
|
||||
return keys
|
||||
|
||||
|
||||
def check_i4_slot_payload_refs(
|
||||
catalog: dict,
|
||||
partials_dir: Path,
|
||||
registered_builders: set[str],
|
||||
) -> list[str]:
|
||||
"""I4 — every generated payload key must be referenced by the partial.
|
||||
|
||||
Direction A only (dead key). Skipped when the partial uses dynamic
|
||||
bracket access (`slot_payload[...]`) — generated keys are presumed
|
||||
reached via the dynamic form and cannot be resolved statically.
|
||||
|
||||
Contracts already failing I1 (missing partial) or I3 (unregistered
|
||||
builder) are skipped so the same drift is not double-reported.
|
||||
"""
|
||||
violations: list[str] = []
|
||||
for template_id, contract in _iter_live_contracts(catalog):
|
||||
payload = contract.get("payload") or {}
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
builder_name = payload.get("builder")
|
||||
if not builder_name or builder_name not in registered_builders:
|
||||
continue
|
||||
partial_path = partials_dir / f"{template_id}.html"
|
||||
if not partial_path.is_file():
|
||||
continue
|
||||
partial_text = partial_path.read_text(encoding="utf-8")
|
||||
if partial_uses_dynamic_slot_access(partial_text):
|
||||
continue
|
||||
static_refs = extract_static_slot_refs(partial_text)
|
||||
expected = expected_payload_keys(contract)
|
||||
orphans = sorted(expected - static_refs)
|
||||
for key in orphans:
|
||||
violations.append(
|
||||
f"I4 generated-key-orphan: contract '{template_id}' builder "
|
||||
f"'{builder_name}' produces payload key '{key}' but partial "
|
||||
f"never references slot_payload.{key}."
|
||||
)
|
||||
return violations
|
||||
|
||||
|
||||
def run_audit(
|
||||
catalog_path: Path = DEFAULT_CATALOG_PATH,
|
||||
partials_dir: Path = DEFAULT_PARTIALS_DIR,
|
||||
) -> list[str]:
|
||||
"""Load catalog + registry and aggregate I1-I4 violations.
|
||||
|
||||
Registry is imported here (not at module import) so the script can be
|
||||
inspected without triggering the boot-time catalog invariant.
|
||||
"""
|
||||
from src.phase_z2_mapper import PAYLOAD_BUILDERS
|
||||
|
||||
catalog = yaml.safe_load(catalog_path.read_text(encoding="utf-8")) or {}
|
||||
registered = set(PAYLOAD_BUILDERS.keys())
|
||||
|
||||
violations: list[str] = []
|
||||
violations.extend(check_i1_partial_existence(catalog, partials_dir))
|
||||
violations.extend(check_i2_builder_declared(catalog))
|
||||
violations.extend(check_i3_builder_registered(catalog, registered))
|
||||
violations.extend(check_i4_slot_payload_refs(catalog, partials_dir, registered))
|
||||
return violations
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Audit Phase Z-2 catalog ↔ partials ↔ builder registry."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--catalog",
|
||||
type=Path,
|
||||
default=DEFAULT_CATALOG_PATH,
|
||||
help="Path to frame_contracts.yaml",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--partials-dir",
|
||||
type=Path,
|
||||
default=DEFAULT_PARTIALS_DIR,
|
||||
help="Directory containing families/{template_id}.html partials",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
violations = run_audit(args.catalog, args.partials_dir)
|
||||
if not violations:
|
||||
print("audit_frame_invariants: PASS (I1-I4 clean on live contracts).")
|
||||
return 0
|
||||
|
||||
print(
|
||||
f"audit_frame_invariants: FAIL ({len(violations)} violation(s)):"
|
||||
)
|
||||
for v in violations:
|
||||
print(f" - {v}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,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())
|
||||
+1
-1
@@ -17,7 +17,7 @@ class Settings(BaseSettings):
|
||||
# IMP-33 u1 — AI fallback policy. Fallback-path only; normal path AI=0.
|
||||
# Defaults locked by Stage 2 plan; do NOT inline literals downstream.
|
||||
ai_fallback_enabled: bool = False
|
||||
ai_fallback_model: str = "claude-opus-4-6-20250415"
|
||||
ai_fallback_model: str = "claude-opus-4-7"
|
||||
ai_fallback_timeout_s: float = 60.0
|
||||
ai_fallback_max_retries: int = 3
|
||||
ai_fallback_backoff_base_s: float = 1.0
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
"""IMP-51 (#79) u4 — user-content image stamper for Phase Z final.html.
|
||||
|
||||
Annotates user-content ``<img>`` elements with a stable id + role
|
||||
attribute so the frontend SlideCanvas (u8~u11) can attach drag/resize
|
||||
handles and the backend CSS injector (u7) can re-apply persisted geometry
|
||||
on the next render.
|
||||
|
||||
DOM selector contract (single point of truth shared across the axis) :
|
||||
|
||||
.slide img[data-image-role="user-content"]
|
||||
|
||||
This selector is mirrored verbatim in :
|
||||
|
||||
- ``Front/client/src/components/SlideCanvas.tsx`` (u8 handle attach target)
|
||||
- ``Front/client/src/services/userOverridesApi.ts`` (u3 doc reference)
|
||||
- ``src/phase_z2_pipeline.py`` u7 hook (CSS injector — pending unit)
|
||||
|
||||
Decorative imgs (frame backgrounds, figma assets, dx-figures, decorative
|
||||
icons) are NOT stamped, so they are NOT matched by the selector and remain
|
||||
unaffected. The allowlist that decides "what counts as user-content" is
|
||||
passed in by the caller (typically ``stage0_normalized_assets["images"]``);
|
||||
this module does not encode the source-of-truth itself.
|
||||
|
||||
Stable id contract :
|
||||
|
||||
image_id = "img-" + sha1(src)[:10]
|
||||
|
||||
Deterministic across renders so persisted ``image_overrides`` entries
|
||||
(keyed on ``image_id`` per ``src/user_overrides_io.py`` u1) re-apply
|
||||
automatically. Duplicate srcs in the same slide get an ordinal suffix
|
||||
("-1", "-2", ...) appended in DOM order; the first occurrence has no
|
||||
suffix.
|
||||
|
||||
Forward-compat : current Phase Z final.html emits zero user-content
|
||||
``<img>`` elements (``stage0_normalized_assets["images"]`` is empty across
|
||||
all recent verify runs). ``stamp_user_content_images(html, sources=())``
|
||||
is a pure no-op in that case — returns ``(html, [])`` without scanning.
|
||||
|
||||
Guardrails :
|
||||
|
||||
- No-hardcoding : the allowlist is caller-supplied, never inferred from
|
||||
sample filenames or path heuristics.
|
||||
- Idempotent : stamping a previously-stamped tag is a no-op (the
|
||||
``data-image-role`` probe short-circuits before re-injecting).
|
||||
- AI-isolation : this module is pure deterministic Python; no LLM calls.
|
||||
- Carve-out (IMP-46 #62) : brand-new module, does not touch the
|
||||
#76 commit ``1186ad8`` cache region.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from typing import Iterable
|
||||
|
||||
USER_CONTENT_IMAGE_SELECTOR: str = '.slide img[data-image-role="user-content"]'
|
||||
|
||||
IMAGE_ROLE_ATTR: str = "data-image-role"
|
||||
IMAGE_ROLE_VALUE: str = "user-content"
|
||||
IMAGE_ID_ATTR: str = "data-image-id"
|
||||
|
||||
# Matches a single ``<img ...>`` tag. Permissive on attribute order and
|
||||
# whitespace; captures the inner attribute string + an optional XHTML
|
||||
# self-close slash. Phase Z renders well-formed Jinja2 output (no inline
|
||||
# ``<`` in attribute values), so a regex is safe here without pulling in
|
||||
# an HTML parser.
|
||||
_IMG_TAG_RE = re.compile(
|
||||
r"<img\b([^>]*?)(/?)>",
|
||||
flags=re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
# Matches the ``src="..."`` or ``src='...'`` attribute. Group 1 = double,
|
||||
# group 2 = single. Quote style is preserved by callers that re-emit the
|
||||
# tag verbatim.
|
||||
_SRC_ATTR_RE = re.compile(
|
||||
r"""\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)')""",
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
# Probe for an existing ``data-image-role`` attribute (any value, any
|
||||
# quote) so re-stamping is idempotent.
|
||||
_ROLE_ATTR_RE = re.compile(r"""\bdata-image-role\s*=""", flags=re.IGNORECASE)
|
||||
|
||||
|
||||
def stable_image_id(src: str, ordinal: int = 0) -> str:
|
||||
"""Return the deterministic ``image_id`` for ``src``.
|
||||
|
||||
``ordinal`` disambiguates repeated occurrences of the same ``src`` in
|
||||
the same slide (0 = first occurrence, no suffix; 1 → ``-1``; ...).
|
||||
"""
|
||||
if not isinstance(src, str):
|
||||
raise TypeError(f"src must be a string, got {type(src).__name__}: {src!r}")
|
||||
if ordinal < 0:
|
||||
raise ValueError(f"ordinal must be >= 0, got {ordinal}")
|
||||
digest = hashlib.sha1(src.encode("utf-8")).hexdigest()[:10]
|
||||
base = f"img-{digest}"
|
||||
return base if ordinal == 0 else f"{base}-{ordinal}"
|
||||
|
||||
|
||||
def stamp_user_content_images(
|
||||
html: str,
|
||||
sources: Iterable[str] = (),
|
||||
) -> tuple[str, list[str]]:
|
||||
"""Stamp user-content ``<img>`` tags in ``html`` with role + stable id.
|
||||
|
||||
``sources`` is the allowlist of ``src`` attribute values that count as
|
||||
user-content (typically ``stage0_normalized_assets["images"]``). Any
|
||||
``<img>`` whose ``src`` value is in ``sources`` is rewritten to include
|
||||
``data-image-role="user-content"`` and ``data-image-id="<stable_id>"``.
|
||||
Other ``<img>`` tags (decorative, figma, frame-internal) are left
|
||||
unchanged byte-for-byte.
|
||||
|
||||
Returns ``(modified_html, stamped_image_ids)`` where the id list is
|
||||
in DOM (left-to-right) order. The list may contain duplicates only
|
||||
via the ordinal-suffix path (``img-<hash>``, ``img-<hash>-1``, ...);
|
||||
ordering is what the caller persists as the canonical key sequence.
|
||||
|
||||
Forward-compat : empty / all-non-string ``sources`` → pure no-op
|
||||
(``html`` returned unchanged, empty list). This is the current Phase
|
||||
Z state since ``stage0_normalized_assets["images"]`` is empty.
|
||||
"""
|
||||
allow = {s for s in sources if isinstance(s, str) and s}
|
||||
if not allow:
|
||||
return html, []
|
||||
|
||||
stamped: list[str] = []
|
||||
seen_ordinal: dict[str, int] = {}
|
||||
|
||||
def _replace(match: re.Match[str]) -> str:
|
||||
attrs = match.group(1) or ""
|
||||
self_close = match.group(2) or ""
|
||||
src_match = _SRC_ATTR_RE.search(attrs)
|
||||
if src_match is None:
|
||||
return match.group(0)
|
||||
src = src_match.group(1) if src_match.group(1) is not None else src_match.group(2)
|
||||
if src not in allow:
|
||||
return match.group(0)
|
||||
if _ROLE_ATTR_RE.search(attrs):
|
||||
return match.group(0)
|
||||
ordinal = seen_ordinal.get(src, 0)
|
||||
seen_ordinal[src] = ordinal + 1
|
||||
image_id = stable_image_id(src, ordinal=ordinal)
|
||||
stamped.append(image_id)
|
||||
injected = (
|
||||
f' {IMAGE_ROLE_ATTR}="{IMAGE_ROLE_VALUE}"'
|
||||
f' {IMAGE_ID_ATTR}="{image_id}"'
|
||||
)
|
||||
return f"<img{injected}{attrs}{self_close}>"
|
||||
|
||||
new_html = _IMG_TAG_RE.sub(_replace, html)
|
||||
return new_html, stamped
|
||||
|
||||
|
||||
# ─── IMP-51 (#79) u7 — render-time CSS injection ──────────────────────────
|
||||
|
||||
# Marker comments wrap the injected ``<style>`` block so re-injection on a
|
||||
# previously-injected document is idempotent (the wrapper is found by a
|
||||
# simple substring probe and the inner CSS is replaced in place).
|
||||
_IMP51_STYLE_MARKER_OPEN: str = "<!-- IMP-51 image_overrides start -->"
|
||||
_IMP51_STYLE_MARKER_CLOSE: str = "<!-- IMP-51 image_overrides end -->"
|
||||
|
||||
_IMP51_STYLE_BLOCK_RE = re.compile(
|
||||
re.escape(_IMP51_STYLE_MARKER_OPEN) + r".*?" + re.escape(_IMP51_STYLE_MARKER_CLOSE),
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
_HEAD_CLOSE_RE = re.compile(r"</head\s*>", flags=re.IGNORECASE)
|
||||
_BODY_OPEN_RE = re.compile(r"<body\b[^>]*>", flags=re.IGNORECASE)
|
||||
|
||||
|
||||
def build_image_overrides_style(
|
||||
image_overrides: dict,
|
||||
stamped_ids: Iterable[str],
|
||||
) -> str:
|
||||
"""Build CSS rule text for persisted ``image_overrides``.
|
||||
|
||||
For every ``image_id`` that appears in BOTH ``stamped_ids`` (the DOM
|
||||
order of stamps returned by :func:`stamp_user_content_images`) AND
|
||||
``image_overrides`` (the persisted geometry mapping from ``u1``
|
||||
``user_overrides_io``), emit one absolute-position rule of the form ::
|
||||
|
||||
.slide img[data-image-role="user-content"][data-image-id="<id>"] {
|
||||
position: absolute;
|
||||
left: <x>%; top: <y>%;
|
||||
width: <w>%; height: <h>%;
|
||||
}
|
||||
|
||||
Coordinates are ``%`` of the slide bounding box (slide-absolute, per
|
||||
Stage 2 scope-lock). ``.slide`` already declares ``position: relative``
|
||||
in ``templates/phase_z2/slide_base.html`` so the absolute coordinates
|
||||
resolve against the slide frame.
|
||||
|
||||
Rules are emitted in ``stamped_ids`` order so the output is
|
||||
byte-deterministic across renders (critical for diff-based verifiers).
|
||||
Override entries for ids NOT in ``stamped_ids`` are silently dropped —
|
||||
those keys cannot be produced via the SlideCanvas pathway (the
|
||||
frontend only knows the ids actually present in the DOM). Per-entry
|
||||
malformed geometries (non-dict / missing axis / non-coercible value)
|
||||
are dropped silently; the whole batch is never rejected.
|
||||
|
||||
Returns ``""`` when no rules are emitted so the caller can skip
|
||||
``<style>`` injection entirely (forward-compat no-op when Phase Z
|
||||
final.html still emits zero user-content imgs).
|
||||
"""
|
||||
if not image_overrides:
|
||||
return ""
|
||||
rules: list[str] = []
|
||||
for iid in stamped_ids:
|
||||
geom = image_overrides.get(iid)
|
||||
if not isinstance(geom, dict):
|
||||
continue
|
||||
try:
|
||||
x = float(geom["x"])
|
||||
y = float(geom["y"])
|
||||
w = float(geom["w"])
|
||||
h = float(geom["h"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
rules.append(
|
||||
f'.slide img[{IMAGE_ROLE_ATTR}="{IMAGE_ROLE_VALUE}"]'
|
||||
f'[{IMAGE_ID_ATTR}="{iid}"] {{ '
|
||||
f"position: absolute; "
|
||||
f"left: {x}%; top: {y}%; "
|
||||
f"width: {w}%; height: {h}%; "
|
||||
f"}}"
|
||||
)
|
||||
return "\n".join(rules)
|
||||
|
||||
|
||||
def inject_image_overrides_style(html: str, css: str) -> str:
|
||||
"""Inject a marker-wrapped ``<style>`` block carrying ``css`` into ``html``.
|
||||
|
||||
Empty ``css`` → ``html`` returned unchanged (no DOM mutation). This
|
||||
preserves the byte-for-byte identity of forward-compat renders where
|
||||
no overrides apply.
|
||||
|
||||
When a previously-injected marker block is present, its inner CSS is
|
||||
replaced in place (idempotent re-injection — second call with the
|
||||
same overrides produces an identical document).
|
||||
|
||||
Injection precedence when no existing marker is found :
|
||||
|
||||
1. Before the first ``</head>`` (case-insensitive)
|
||||
2. Immediately after the first ``<body ...>`` open tag
|
||||
3. At the start of the document
|
||||
|
||||
Phase Z ``slide_base.html`` always emits ``</head>`` so path 1 wins
|
||||
for production renders; paths 2/3 are defensive fallbacks for
|
||||
unusual fragment inputs (tests, partials).
|
||||
"""
|
||||
if not css:
|
||||
return html
|
||||
block = (
|
||||
f"{_IMP51_STYLE_MARKER_OPEN}\n"
|
||||
f"<style>\n{css}\n</style>\n"
|
||||
f"{_IMP51_STYLE_MARKER_CLOSE}"
|
||||
)
|
||||
if _IMP51_STYLE_MARKER_OPEN in html:
|
||||
return _IMP51_STYLE_BLOCK_RE.sub(lambda _m: block, html, count=1)
|
||||
head_close = _HEAD_CLOSE_RE.search(html)
|
||||
if head_close is not None:
|
||||
idx = head_close.start()
|
||||
return html[:idx] + block + "\n" + html[idx:]
|
||||
body_open = _BODY_OPEN_RE.search(html)
|
||||
if body_open is not None:
|
||||
idx = body_open.end()
|
||||
return html[:idx] + "\n" + block + html[idx:]
|
||||
return block + "\n" + html
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -31,6 +31,55 @@ _TRANSIENT_ERRORS: tuple[type[BaseException], ...] = (
|
||||
# Output cap is an Anthropic API requirement, not a policy knob (u1).
|
||||
_MAX_OUTPUT_TOKENS = 4096
|
||||
|
||||
# IMP-92 u2 — Anthropic SDK exception → operational error kind classifier.
|
||||
# Stamped onto Step 12 AI repair records (api_error_kind) so the frontend
|
||||
# operational alert formatter can surface quota / billing / auth to users
|
||||
# while keeping non-operational ("other") failures silent. The classifier
|
||||
# is type-based (not string parsing) and the four kinds are the only
|
||||
# values frontend operational formatter is allowed to render.
|
||||
_OPERATIONAL_ERROR_KIND_QUOTA = "quota"
|
||||
_OPERATIONAL_ERROR_KIND_BILLING = "billing"
|
||||
_OPERATIONAL_ERROR_KIND_AUTH = "auth"
|
||||
_OPERATIONAL_ERROR_KIND_OTHER = "other"
|
||||
|
||||
|
||||
def classify_operational_error(exc: BaseException) -> str:
|
||||
"""Return the operational error kind for an Anthropic SDK exception.
|
||||
|
||||
Dispatch combines SDK exception type with the HTTP status code so the
|
||||
issue body's explicit operational contract (429 quota / 402 billing /
|
||||
401 auth) is honoured even when the SDK surfaces a 402 as the generic
|
||||
``anthropic.APIStatusError`` rather than a typed subclass:
|
||||
|
||||
* ``anthropic.RateLimitError`` OR HTTP 429 → ``"quota"``
|
||||
* ``anthropic.PermissionDeniedError`` OR HTTP 402 → ``"billing"``
|
||||
(Anthropic Payment Required surfaces as 402; PermissionDenied/403
|
||||
is the SDK-typed billing/permission surface)
|
||||
* ``anthropic.AuthenticationError`` OR HTTP 401 → ``"auth"``
|
||||
* everything else → ``"other"`` (silent on UI)
|
||||
|
||||
The frontend formatter renders quota / billing / auth and returns
|
||||
``None`` for ``"other"`` so non-operational AI failures stay silent
|
||||
per the #84 replacement-plan contract.
|
||||
"""
|
||||
if isinstance(exc, anthropic.RateLimitError):
|
||||
return _OPERATIONAL_ERROR_KIND_QUOTA
|
||||
if isinstance(exc, anthropic.PermissionDeniedError):
|
||||
return _OPERATIONAL_ERROR_KIND_BILLING
|
||||
if isinstance(exc, anthropic.AuthenticationError):
|
||||
return _OPERATIONAL_ERROR_KIND_AUTH
|
||||
if isinstance(exc, anthropic.APIStatusError):
|
||||
status_code = getattr(exc, "status_code", None)
|
||||
if status_code is None:
|
||||
status_code = getattr(getattr(exc, "response", None), "status_code", None)
|
||||
if status_code == 429:
|
||||
return _OPERATIONAL_ERROR_KIND_QUOTA
|
||||
if status_code == 402:
|
||||
return _OPERATIONAL_ERROR_KIND_BILLING
|
||||
if status_code == 401:
|
||||
return _OPERATIONAL_ERROR_KIND_AUTH
|
||||
return _OPERATIONAL_ERROR_KIND_OTHER
|
||||
|
||||
|
||||
class AiFallbackBudgetExceeded(RuntimeError):
|
||||
"""Per-run AI call budget (u1 ai_fallback_budget_per_run) exhausted."""
|
||||
|
||||
@@ -50,6 +50,7 @@ def route_ai_fallback(
|
||||
internal_region: dict[str, Any],
|
||||
mdx_text: str,
|
||||
client: AiFallbackClient | None = None,
|
||||
fingerprints: dict | None = None,
|
||||
) -> AiFallbackProposal | None:
|
||||
"""Route a fallback request through cache → prompt → client → validate.
|
||||
|
||||
@@ -57,13 +58,18 @@ def route_ai_fallback(
|
||||
not ``ai_adaptation_required`` — both gates short-circuit BEFORE any
|
||||
prompt/client work, so the normal-path AI call count stays at 0
|
||||
(PZ-1).
|
||||
|
||||
``fingerprints`` is forwarded into ``read_proposal`` so that
|
||||
contract / partial / catalog SHA mismatches invalidate stale cache
|
||||
entries (IMP-46 #62 Axis R). When ``None`` the cache layer skips
|
||||
fingerprint comparison (legacy behaviour).
|
||||
"""
|
||||
if not settings.ai_fallback_enabled:
|
||||
return None
|
||||
route = v4_result.get("route") or v4_result.get("imp05_route_hint")
|
||||
if route != V4_ROUTE_AI_ADAPTATION:
|
||||
return None
|
||||
cached = read_proposal(cache_key)
|
||||
cached = read_proposal(cache_key, fingerprints=fingerprints)
|
||||
if cached is not None:
|
||||
validate_proposal(
|
||||
cached,
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""IMP-46 u1 — Frame transformation cache signature builder.
|
||||
|
||||
Deterministic SHA256 over the 8 declared structural axes:
|
||||
frame_id, v4_label, cardinality, source_shape,
|
||||
h3_count, char_count_bucket, layout_preset, zone_position
|
||||
|
||||
Guardrails:
|
||||
* No sample/section identifiers in the signature surface (no-hardcoding lock).
|
||||
* source_shape constrained to the bullet/paragraph/table/mixed enum.
|
||||
* char_count_bucket is the *bucket label*; numeric counts must be projected
|
||||
via :func:`bucket_char_count` before being fed to :func:`build_signature`.
|
||||
* Schema version is embedded in the hashed payload so a future axis change
|
||||
breaks the digest by design (cache invalidation on schema bump).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from enum import Enum
|
||||
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
class SourceShape(str, Enum):
|
||||
BULLET = "bullet"
|
||||
PARAGRAPH = "paragraph"
|
||||
TABLE = "table"
|
||||
MIXED = "mixed"
|
||||
|
||||
|
||||
_CHAR_COUNT_BUCKETS: tuple[tuple[int, str], ...] = (
|
||||
(50, "0-50"),
|
||||
(150, "51-150"),
|
||||
(400, "151-400"),
|
||||
(1000, "401-1000"),
|
||||
)
|
||||
_CHAR_COUNT_BUCKET_OVERFLOW = "1001+"
|
||||
CHAR_COUNT_BUCKET_LABELS: tuple[str, ...] = tuple(
|
||||
label for _, label in _CHAR_COUNT_BUCKETS
|
||||
) + (_CHAR_COUNT_BUCKET_OVERFLOW,)
|
||||
|
||||
|
||||
def bucket_char_count(char_count: int) -> str:
|
||||
"""Project a non-negative character count to its fixed bucket label."""
|
||||
if isinstance(char_count, bool) or not isinstance(char_count, int):
|
||||
raise TypeError("char_count must be a non-negative int")
|
||||
if char_count < 0:
|
||||
raise ValueError("char_count must be non-negative")
|
||||
for upper, label in _CHAR_COUNT_BUCKETS:
|
||||
if char_count <= upper:
|
||||
return label
|
||||
return _CHAR_COUNT_BUCKET_OVERFLOW
|
||||
|
||||
|
||||
def build_signature(
|
||||
*,
|
||||
frame_id: str,
|
||||
v4_label: str,
|
||||
cardinality: int | None,
|
||||
source_shape: SourceShape | str,
|
||||
h3_count: int,
|
||||
char_count_bucket: str,
|
||||
layout_preset: str,
|
||||
zone_position: str,
|
||||
) -> str:
|
||||
"""Return a deterministic SHA256 hex digest over the 8 declared axes."""
|
||||
if isinstance(source_shape, SourceShape):
|
||||
source_shape_value = source_shape.value
|
||||
elif isinstance(source_shape, str):
|
||||
source_shape_value = SourceShape(source_shape).value
|
||||
else:
|
||||
raise TypeError("source_shape must be SourceShape or str")
|
||||
if char_count_bucket not in CHAR_COUNT_BUCKET_LABELS:
|
||||
raise ValueError(
|
||||
f"char_count_bucket={char_count_bucket!r} is not a known bucket "
|
||||
f"label (expected one of {CHAR_COUNT_BUCKET_LABELS})"
|
||||
)
|
||||
payload = {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"frame_id": frame_id,
|
||||
"v4_label": v4_label,
|
||||
"cardinality": cardinality,
|
||||
"source_shape": source_shape_value,
|
||||
"h3_count": h3_count,
|
||||
"char_count_bucket": char_count_bucket,
|
||||
"layout_preset": layout_preset,
|
||||
"zone_position": zone_position,
|
||||
}
|
||||
encoded = json.dumps(payload, sort_keys=True, ensure_ascii=False).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
@@ -56,6 +56,7 @@ import hashlib
|
||||
import json
|
||||
from typing import Any, Callable, Iterable
|
||||
|
||||
from src.phase_z2_ai_fallback.client import classify_operational_error
|
||||
from src.phase_z2_ai_fallback.router import route_ai_fallback
|
||||
from src.phase_z2_ai_fallback.signature import bucket_char_count, build_signature
|
||||
|
||||
@@ -96,6 +97,7 @@ def gather_step12_ai_repair_proposals(
|
||||
"skip_reason": str | None,
|
||||
"proposal": dict | None,
|
||||
"error": str | None,
|
||||
"api_error_kind": str | None, # IMP-92 u2 (quota|billing|auth|other)
|
||||
"cache_key": str | None, # IMP-46 u4
|
||||
"fingerprints": dict | None, # IMP-46 u4
|
||||
}
|
||||
@@ -130,6 +132,7 @@ def gather_step12_ai_repair_proposals(
|
||||
"skip_reason": None,
|
||||
"proposal": None,
|
||||
"error": None,
|
||||
"api_error_kind": None,
|
||||
"cache_key": None,
|
||||
"fingerprints": None,
|
||||
}
|
||||
@@ -200,10 +203,12 @@ def gather_step12_ai_repair_proposals(
|
||||
figma_partial_json=figma_partial_json,
|
||||
internal_region=internal_region,
|
||||
mdx_text=mdx_text,
|
||||
fingerprints=fingerprints,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — record + continue, no AI re-raise
|
||||
record["ai_called"] = True
|
||||
record["error"] = f"{type(exc).__name__}: {exc}"
|
||||
record["api_error_kind"] = classify_operational_error(exc)
|
||||
records.append(record)
|
||||
continue
|
||||
if proposal is None:
|
||||
|
||||
@@ -73,6 +73,247 @@ STEP17_AI_REPAIR_BLOCKED_REASON = (
|
||||
)
|
||||
|
||||
|
||||
# IMP-35 (#64) u4 — POPUP cascade AI split-decision contract (API gated).
|
||||
#
|
||||
# Step 17 POPUP escalation needs an AI hook to decide *what content* stays in
|
||||
# the body (summary/subset) vs. moves into the <details> popup (full MDX).
|
||||
# That hook is the AI split-decision contract. u4 ships the contract surface
|
||||
# (function signature + record schema + cascade_stage + route_for_label +
|
||||
# skip_reason) WITHOUT enabling the Anthropic API. The deterministic POPUP
|
||||
# gate executor (u5) runs ahead of this contract and stamps
|
||||
# popup_escalation_plan + has_popup; u4's hook is a forward-compatible
|
||||
# placeholder so downstream wiring (u5 executor / future IMP activating the
|
||||
# API) can rely on a stable schema. ``api_gated=True`` on every record makes
|
||||
# the gate state machine-readable; ``ai_called`` stays False everywhere.
|
||||
#
|
||||
# Per feedback_ai_isolation_contract: AI = fallback path only. The contract
|
||||
# function MUST NOT import route_ai_fallback, the u4 client (despite name
|
||||
# collision — u4 here is the IMP-35 unit, not the Step 12 client module),
|
||||
# or any anthropic SDK symbol. Structural import guards in the test surface
|
||||
# already enforce this and continue to hold after this change.
|
||||
STEP17_POPUP_SPLIT_DECISION_API_GATED_REASON = (
|
||||
"step17_popup_split_decision_api_gated"
|
||||
)
|
||||
|
||||
|
||||
# IMP-35 (#64) u5 — deterministic POPUP gate executor (cascade-terminal).
|
||||
#
|
||||
# Runs AFTER the DETERMINISTIC stage exhausts and BEFORE the AI_REPAIR
|
||||
# cascade stage (canonical OVERFLOW_CASCADE_ORDER). Per unit:
|
||||
#
|
||||
# 1. Idempotency (q2): if a unit carries ``has_popup=True`` already,
|
||||
# ``run_step17_popup_gate`` short-circuits with
|
||||
# ``gate_status="idempotent_short_circuit"``. No duplicate plan,
|
||||
# no re-routing. Re-running Step 17 on already-escalated units is
|
||||
# safe — the gate emits a deterministic record per unit but does
|
||||
# NOT re-stamp the plan or flip the marker. The persistence of
|
||||
# ``has_popup`` and ``popup_escalation_plan`` on the unit itself
|
||||
# (see step 4 below) is what makes the second call observe the
|
||||
# stamp from the first call and short-circuit correctly.
|
||||
# 2. Classification: ``classification_for_unit(unit)`` returns the
|
||||
# fit_classifier row associated with this unit (or ``None`` if the
|
||||
# unit has no overflow on this run).
|
||||
# 3. Plan: ``plan_for_classification(cls)`` is the router u3 stub
|
||||
# (``src.phase_z2_router.plan_details_popup_escalation``). Only
|
||||
# the categories in ``POPUP_ESCALATION_CATEGORIES`` of the router
|
||||
# surface (currently ``structural_major_overflow`` and
|
||||
# ``tabular_overflow``) emit a feasible plan; anything else falls
|
||||
# through to ``gate_status="infeasible_category"`` so the gate
|
||||
# never silently escalates the wrong overflow shape.
|
||||
# 4. Feasible plan → record stamps ``popup_escalation_plan`` and
|
||||
# flips ``has_popup=True`` in the returned record AND persists
|
||||
# the same two fields on the unit via ``setattr`` (``unit.has_popup``
|
||||
# and ``unit.popup_escalation_plan``). The unit-side persistence
|
||||
# is the q2 idempotency contract: a second call to
|
||||
# ``run_step17_popup_gate`` over the same unit reads
|
||||
# ``unit.has_popup=True`` at step 1 and short-circuits before
|
||||
# classification / plan callable invocation. The marker is also
|
||||
# what u6 composition binding and u7 render wiring read from the
|
||||
# unit downstream.
|
||||
#
|
||||
# AI isolation contract: NO Anthropic call inside this gate. The
|
||||
# deterministic split between popup body (full MDX) and preview
|
||||
# (summary/subset) is composed downstream from container px budgets
|
||||
# (q3 — preview_chars derives from container px telemetry already on
|
||||
# the retry_trace). The u4 AI hook (``gather_step17_popup_split_decisions``)
|
||||
# sits at the same cascade stage but is API-gated (``api_gated=True``)
|
||||
# and never invoked from this deterministic path. ``ai_called=False`` on
|
||||
# every record this gate emits.
|
||||
#
|
||||
# cascade_stage="popup" on every record so Step 17 retry-trace consumers
|
||||
# can multiplex DETERMINISTIC / POPUP / AI_REPAIR records without
|
||||
# ambiguity. The schema mirrors :func:`gather_step17_popup_split_decisions`
|
||||
# (unit_index / source_section_ids / frame_template_id / label /
|
||||
# route_hint / provisional) PLUS u5-specific fields:
|
||||
# ``gate_status`` / ``popup_escalation_plan`` / ``has_popup`` /
|
||||
# ``skip_reason`` (only set for non-escalated gate_status values).
|
||||
STEP17_POPUP_GATE_ESCALATED_REASON = "step17_popup_gate_escalated"
|
||||
STEP17_POPUP_GATE_IDEMPOTENT_SHORT_CIRCUIT_REASON = (
|
||||
"step17_popup_gate_idempotent_short_circuit"
|
||||
)
|
||||
STEP17_POPUP_GATE_INFEASIBLE_CATEGORY_REASON = (
|
||||
"step17_popup_gate_infeasible_category"
|
||||
)
|
||||
STEP17_POPUP_GATE_NO_CLASSIFICATION_REASON = (
|
||||
"step17_popup_gate_no_classification_for_unit"
|
||||
)
|
||||
|
||||
|
||||
def run_step17_popup_gate(
|
||||
units: Iterable[Any],
|
||||
*,
|
||||
classification_for_unit: Callable[[Any], dict | None],
|
||||
route_for_label: Callable[[str | None], str | None],
|
||||
plan_for_classification: Callable[[dict], dict],
|
||||
) -> list[dict]:
|
||||
"""Deterministic POPUP gate executor for Step 17 cascade (IMP-35 u5).
|
||||
|
||||
See module-level block comment (immediately above) for the full
|
||||
contract — idempotency (q2), classification source, router u3 stub
|
||||
coupling, AI isolation, and cascade_stage multiplexing.
|
||||
|
||||
Args:
|
||||
units: provisional / non-provisional Step 17 units. The gate is
|
||||
agnostic to provisional state; the marker ``has_popup`` flows
|
||||
from this function regardless.
|
||||
classification_for_unit: maps a unit to its fit_classifier
|
||||
classification row (or ``None`` if the unit has no overflow).
|
||||
Tests inject a fake dict / lookup; the pipeline composes
|
||||
this from ``fit_classification.classifications`` matched by
|
||||
``zone_position``.
|
||||
route_for_label: same callable shape as
|
||||
:func:`gather_step17_ai_repair_proposals` /
|
||||
:func:`gather_step17_popup_split_decisions`. The route hint
|
||||
is stamped on every record for downstream consumers.
|
||||
plan_for_classification: the router u3 stub
|
||||
(``src.phase_z2_router.plan_details_popup_escalation``).
|
||||
Injected as a callable so this module stays decoupled from
|
||||
the router surface and tests can stub the plan output.
|
||||
|
||||
Returns:
|
||||
list[dict] — one record per unit. Records carry
|
||||
``cascade_stage="popup"`` and ``ai_called=False`` everywhere.
|
||||
Feasible-escalation records also carry
|
||||
``popup_escalation_plan`` (the router u3 plan dict) and
|
||||
``has_popup=True``. Non-escalation records carry a
|
||||
``skip_reason`` enum.
|
||||
"""
|
||||
records: list[dict] = []
|
||||
for index, unit in enumerate(units):
|
||||
label = getattr(unit, "label", None)
|
||||
already_escalated = bool(getattr(unit, "has_popup", False))
|
||||
record: dict = {
|
||||
"unit_index": index,
|
||||
"source_section_ids": list(
|
||||
getattr(unit, "source_section_ids", []) or []
|
||||
),
|
||||
"frame_template_id": getattr(unit, "frame_template_id", None),
|
||||
"label": label,
|
||||
"route_hint": route_for_label(label),
|
||||
"provisional": bool(getattr(unit, "provisional", False)),
|
||||
"cascade_stage": OverflowCascadeStage.POPUP.value,
|
||||
"ai_called": False,
|
||||
"has_popup": already_escalated,
|
||||
"popup_escalation_plan": None,
|
||||
"gate_status": None,
|
||||
"skip_reason": None,
|
||||
}
|
||||
if already_escalated:
|
||||
# q2 idempotency — short-circuit. The previously stamped
|
||||
# popup_escalation_plan stays on the unit (carried by u6/u7
|
||||
# composition); this gate does NOT re-emit it.
|
||||
record["gate_status"] = "idempotent_short_circuit"
|
||||
record["skip_reason"] = (
|
||||
STEP17_POPUP_GATE_IDEMPOTENT_SHORT_CIRCUIT_REASON
|
||||
)
|
||||
records.append(record)
|
||||
continue
|
||||
classification = classification_for_unit(unit)
|
||||
if not classification:
|
||||
record["gate_status"] = "no_classification"
|
||||
record["skip_reason"] = STEP17_POPUP_GATE_NO_CLASSIFICATION_REASON
|
||||
records.append(record)
|
||||
continue
|
||||
plan = plan_for_classification(classification)
|
||||
record["popup_escalation_plan"] = plan
|
||||
if plan and plan.get("feasible"):
|
||||
record["gate_status"] = "escalated"
|
||||
record["has_popup"] = True
|
||||
record["skip_reason"] = None
|
||||
# q2 idempotency persistence — stamp the marker AND the plan
|
||||
# on the unit itself so a second run of the gate over the
|
||||
# same unit observes ``unit.has_popup=True`` at the top of
|
||||
# the loop and short-circuits before re-invoking the
|
||||
# classification / plan callables. The unit-side persistence
|
||||
# is also what u6 composition binding and u7 render wiring
|
||||
# read downstream.
|
||||
setattr(unit, "has_popup", True)
|
||||
setattr(unit, "popup_escalation_plan", plan)
|
||||
else:
|
||||
# Plan rejected by router (wrong category). Defensive guard —
|
||||
# the gate must not silently escalate the wrong overflow
|
||||
# shape (see router u3 plan_details_popup_escalation defensive
|
||||
# guard).
|
||||
record["gate_status"] = "infeasible_category"
|
||||
record["skip_reason"] = (
|
||||
STEP17_POPUP_GATE_INFEASIBLE_CATEGORY_REASON
|
||||
)
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
def gather_step17_popup_split_decisions(
|
||||
units: Iterable[Any],
|
||||
*,
|
||||
route_for_label: Callable[[str | None], str | None],
|
||||
) -> list[dict]:
|
||||
"""Return one API-gated split-decision record per unit (POPUP cascade).
|
||||
|
||||
Schema mirrors :func:`gather_step17_ai_repair_proposals` so a Step 17
|
||||
artifact consumer can multiplex DETERMINISTIC / POPUP / AI_REPAIR records
|
||||
onto the same retry trace. POPUP-specific fields:
|
||||
|
||||
* ``cascade_stage`` — always ``"popup"``.
|
||||
* ``api_gated`` — always ``True`` at u4. Future IMP activating the
|
||||
Anthropic API for popup splitting will flip this to ``False`` for
|
||||
units that traversed the deterministic POPUP gate (u5) without
|
||||
resolving via summary-only.
|
||||
* ``ai_called`` — always ``False`` at u4 (contract surface only).
|
||||
* ``skip_reason`` — always
|
||||
:data:`STEP17_POPUP_SPLIT_DECISION_API_GATED_REASON`.
|
||||
* ``split_decision`` — always ``None`` at u4. Once activated, this will
|
||||
carry the AI-proposed ``{"body_preview": ..., "popup_full": ...}``
|
||||
pair; u5 deterministic gate fills the same field deterministically
|
||||
from container px budgets (preview_chars) and never invokes AI.
|
||||
|
||||
Per IMP-35 u4 binding contract: the API stays gated. No Anthropic call,
|
||||
no route_ai_fallback import, no client instantiation. Structural import
|
||||
tests in :mod:`tests.phase_z2_ai_fallback.test_step17` continue to lock
|
||||
these guarantees.
|
||||
"""
|
||||
records: list[dict] = []
|
||||
for index, unit in enumerate(units):
|
||||
label = getattr(unit, "label", None)
|
||||
record: dict = {
|
||||
"unit_index": index,
|
||||
"source_section_ids": list(
|
||||
getattr(unit, "source_section_ids", []) or []
|
||||
),
|
||||
"frame_template_id": getattr(unit, "frame_template_id", None),
|
||||
"label": label,
|
||||
"route_hint": route_for_label(label),
|
||||
"provisional": bool(getattr(unit, "provisional", False)),
|
||||
"cascade_stage": OverflowCascadeStage.POPUP.value,
|
||||
"ai_called": False,
|
||||
"api_gated": True,
|
||||
"skip_reason": STEP17_POPUP_SPLIT_DECISION_API_GATED_REASON,
|
||||
"split_decision": None,
|
||||
"error": None,
|
||||
}
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
def gather_step17_ai_repair_proposals(
|
||||
units: Iterable[Any],
|
||||
*,
|
||||
|
||||
@@ -315,6 +315,321 @@ def select_display_strategy_candidates(
|
||||
return [s for s in order if s in eligible]
|
||||
|
||||
|
||||
# ─── IMP-35 (#64) u6 — Composition popup binding (yaml strategy -> zone payload) ─
|
||||
#
|
||||
# Stage 2 binding contract (unit u6):
|
||||
# Step 17 POPUP gate (u5 in src/phase_z2_ai_fallback/step17.py) stamps
|
||||
# ``unit.has_popup=True`` AND ``unit.popup_escalation_plan=<plan>`` on
|
||||
# composition units whose overflow category routes to
|
||||
# ``details_popup_escalation``. u6 is the composition-side binding that
|
||||
# translates the unit-side marker into a deterministic zone payload
|
||||
# structure that u7 (pipeline composer -> render_slide wiring) reads to
|
||||
# emit the ``<details>/<summary>`` markup u8 will add to slide_base.html.
|
||||
#
|
||||
# Inputs (unit-side, all duck-typed via getattr):
|
||||
# has_popup — bool (False default; u5 sets True on
|
||||
# feasible escalation only)
|
||||
# popup_escalation_plan — dict | None (u3 router plan from
|
||||
# plan_details_popup_escalation; carries
|
||||
# feasible / category / rationale /
|
||||
# needs_split_decision)
|
||||
# raw_content — str (the source MDX content; popup body
|
||||
# source per CLAUDE.md 자세히보기 원칙)
|
||||
#
|
||||
# Outputs (zone payload binding dict):
|
||||
# display_strategy — catalog strategy id read from
|
||||
# display_strategies.yaml (NOT hardcoded).
|
||||
# ``inline_full`` when has_popup=False.
|
||||
# ``inline_preview_with_details`` when
|
||||
# has_popup=True (preview = excerpt from
|
||||
# container px budget downstream; popup body
|
||||
# preserves the FULL original).
|
||||
# popup_body_source — str | None — the FULL raw_content. u7 passes
|
||||
# this verbatim to the renderer; the popup
|
||||
# body is the MDX 원문 (자세히보기 원칙),
|
||||
# never summarized in the body branch.
|
||||
# None when has_popup=False.
|
||||
# detail_trigger — dict | None — placement + label read from
|
||||
# the catalog strategy entry's
|
||||
# ``detail_trigger``. None when has_popup=False.
|
||||
# preserves_original — bool — echoed from the catalog entry.
|
||||
# MUST be True for popup-binding strategies
|
||||
# (absolute user lock — 오답노트 #5 /
|
||||
# IMPROVEMENT-REDESIGN.md §3.6 line 110).
|
||||
# has_popup — bool — echoed for downstream multiplex.
|
||||
# popup_escalation_plan — dict | None — echoed verbatim (u5 plan).
|
||||
# Provides traceability into the router
|
||||
# category + rationale for downstream debug.
|
||||
# strategy_meta — dict — full catalog entry (description /
|
||||
# applies_to / forbidden_for / detail_trigger)
|
||||
# so downstream traces can self-explain without
|
||||
# re-reading the yaml.
|
||||
#
|
||||
# Guardrails honored:
|
||||
# - feedback_ai_isolation_contract — NO AI call. Reads catalog + unit
|
||||
# state only. The deterministic POPUP gate (u5) already established
|
||||
# the marker; this function is pure composition-side binding.
|
||||
# - feedback_no_hardcoding — strategy id is the ONLY name reference, and
|
||||
# it is the catalog key (yaml is source of truth). detail_trigger
|
||||
# placement / label come from the catalog entry, not literals.
|
||||
# - MDX 원문 무손실 보존 — popup_body_source = full raw_content.
|
||||
# u6 NEVER trims or summarizes; the body preview (excerpt from
|
||||
# container px budget) is composed by u7 downstream.
|
||||
# - Phase Z spacing 방향 — u6 binds a strategy that EXPANDS capacity
|
||||
# (popup escalation) instead of shrinking common margins.
|
||||
|
||||
# Strategy id used when the unit carries no popup escalation marker.
|
||||
# Catalog read — yaml is source of truth.
|
||||
POPUP_BINDING_NO_POPUP_STRATEGY_ID = "inline_full"
|
||||
|
||||
# Strategy id used when the unit carries has_popup=True (deterministic
|
||||
# choice — the preview body is a px-budget excerpt of the original, the
|
||||
# popup body holds the FULL original per CLAUDE.md 자세히보기 원칙).
|
||||
# u5 q3 — preview_chars deterministic from container px telemetry; that
|
||||
# is an excerpt-from-original pattern, which matches
|
||||
# ``inline_preview_with_details``. ``details_only`` (summary-only body)
|
||||
# is the alternative future axis when an AI/summarizer is available.
|
||||
POPUP_BINDING_ESCALATED_STRATEGY_ID = "inline_preview_with_details"
|
||||
|
||||
|
||||
def bind_popup_display_strategy(unit) -> dict:
|
||||
"""Bind catalog popup display strategy to a zone payload (IMP-35 u6).
|
||||
|
||||
Reads the unit-side ``has_popup`` + ``popup_escalation_plan`` markers
|
||||
stamped by Step 17 POPUP gate (u5) and produces a zone payload dict
|
||||
that u7 wires into the renderer. The catalog
|
||||
(``display_strategies.yaml``) is the source of truth for both the
|
||||
strategy id and the detail_trigger placement / label — no hardcoded
|
||||
string literals.
|
||||
|
||||
Args:
|
||||
unit: a CompositionUnit (or any duck-typed object exposing
|
||||
``has_popup`` / ``popup_escalation_plan`` / ``raw_content``).
|
||||
``has_popup`` defaults to False when the attribute is absent
|
||||
(units that never went through the Step 17 POPUP gate).
|
||||
|
||||
Returns:
|
||||
zone payload binding dict (see module-level u6 contract block
|
||||
immediately above for the full schema).
|
||||
|
||||
Raises:
|
||||
RuntimeError: if the chosen catalog strategy id is missing from
|
||||
the loaded ``DISPLAY_STRATEGIES`` mapping. Defensive guard —
|
||||
yaml drift would otherwise cause downstream KeyError on a
|
||||
stale string literal. The constants
|
||||
``POPUP_BINDING_NO_POPUP_STRATEGY_ID`` /
|
||||
``POPUP_BINDING_ESCALATED_STRATEGY_ID`` must always resolve
|
||||
against the catalog at import time.
|
||||
"""
|
||||
has_popup = bool(getattr(unit, "has_popup", False))
|
||||
plan = getattr(unit, "popup_escalation_plan", None)
|
||||
raw_content = getattr(unit, "raw_content", "") or ""
|
||||
|
||||
strategy_id = (
|
||||
POPUP_BINDING_ESCALATED_STRATEGY_ID
|
||||
if has_popup
|
||||
else POPUP_BINDING_NO_POPUP_STRATEGY_ID
|
||||
)
|
||||
meta = DISPLAY_STRATEGIES.get(strategy_id)
|
||||
if meta is None:
|
||||
raise RuntimeError(
|
||||
f"bind_popup_display_strategy: catalog drift — strategy id "
|
||||
f"{strategy_id!r} is missing from display_strategies.yaml. "
|
||||
f"Loaded keys: {sorted(DISPLAY_STRATEGIES)}."
|
||||
)
|
||||
|
||||
if not has_popup:
|
||||
return {
|
||||
"display_strategy": strategy_id,
|
||||
"popup_body_source": None,
|
||||
"detail_trigger": None,
|
||||
"preserves_original": bool(meta.get("preserves_original")),
|
||||
"has_popup": False,
|
||||
"popup_escalation_plan": None,
|
||||
"strategy_meta": meta,
|
||||
}
|
||||
|
||||
# has_popup=True path. preserves_original MUST be True per the catalog
|
||||
# absolute user lock — defensive guard against yaml drift.
|
||||
if not meta.get("preserves_original"):
|
||||
raise RuntimeError(
|
||||
f"bind_popup_display_strategy: catalog invariant violated — "
|
||||
f"popup-binding strategy {strategy_id!r} has preserves_original="
|
||||
f"{meta.get('preserves_original')!r}; MDX 원문 무손실 보존 "
|
||||
f"requires preserves_original=True (오답노트 #5 / "
|
||||
f"IMPROVEMENT-REDESIGN.md §3.6 line 110)."
|
||||
)
|
||||
trigger_meta = meta.get("detail_trigger") or {}
|
||||
return {
|
||||
"display_strategy": strategy_id,
|
||||
# MDX 원문 무손실 보존 — popup body = full raw_content (verbatim).
|
||||
"popup_body_source": raw_content,
|
||||
"detail_trigger": {
|
||||
"placement": trigger_meta.get("placement"),
|
||||
"label": trigger_meta.get("label"),
|
||||
},
|
||||
"preserves_original": True,
|
||||
"has_popup": True,
|
||||
"popup_escalation_plan": plan,
|
||||
"strategy_meta": meta,
|
||||
}
|
||||
|
||||
|
||||
# ─── IMP-35 (#64) u7 — Pipeline composer -> render_slide wiring ──
|
||||
#
|
||||
# Stage 2 wiring contract (unit u7):
|
||||
# u6 (``bind_popup_display_strategy``) produced the deterministic zone
|
||||
# binding from the unit-side marker stamped by Step 17 POPUP gate (u5).
|
||||
# u7 wires that binding into the pipeline composer's zones_data so the
|
||||
# render_slide call site (and downstream slide_base.html consumer u8)
|
||||
# sees three uniform render-context field names per zone:
|
||||
#
|
||||
# has_popup : bool — escalation marker echo
|
||||
# popup_html : str — popup body source (full ``raw_content`` per u6;
|
||||
# u8 wraps it in ``<details>/<summary>``).
|
||||
# ``None`` when has_popup=False.
|
||||
# preview_text : str — px-budgeted excerpt of ``raw_content`` shown in
|
||||
# the body / inline_preview slot. NEVER trims
|
||||
# inside a line — line-boundary cut only — and
|
||||
# the popup body retains the FULL original
|
||||
# (MDX 원문 무손실 보존). ``None`` when
|
||||
# has_popup=False.
|
||||
#
|
||||
# The full u6 binding is also echoed on the zone dict under
|
||||
# ``popup_binding`` so downstream debug / catalog-aware consumers can
|
||||
# self-explain without re-reading the yaml.
|
||||
#
|
||||
# Why the preview is a deterministic line-budget cut (u5 q3 resolution):
|
||||
# The popup body holds the FULL original verbatim, so the preview loses
|
||||
# no information — it just truncates at a deterministic boundary that
|
||||
# fits the container height telemetry. Container telemetry source is the
|
||||
# per-unit ``min_height_px`` (frame visual_hints), which is what the
|
||||
# pipeline composer already knows at the zones_data append site.
|
||||
#
|
||||
# We never re-summarize, never AI-call, never reorder. Char-budget cut
|
||||
# would risk splitting CJK words mid-character — line-boundary cut is
|
||||
# the closest deterministic surface to ``raw_content`` semantics
|
||||
# (MDX paragraph / bullet boundaries).
|
||||
#
|
||||
# Guardrails honored:
|
||||
# - feedback_ai_isolation_contract — pure deterministic helper. No
|
||||
# anthropic import, no AI fallback router path.
|
||||
# - MDX 원문 무손실 보존 — preview is a CUT, never a rewrite; popup body
|
||||
# stays equal to ``raw_content``.
|
||||
# - feedback_no_hardcoding — line metric is parametric (line_height_px
|
||||
# defaults to slide_base.html body line metric ~18 px = 11 px font *
|
||||
# 1.6 line-height + ~0.4 px ascent guard). u9 will surface the literal
|
||||
# value source.
|
||||
|
||||
# Line height in px used to convert a container-height budget into a
|
||||
# line-count budget. Matches slide_base.html ``--font-body`` (11 px) at
|
||||
# the ``.text-line`` line-height (1.6). Default — NOT a hardcoded magic
|
||||
# constant: ``compute_popup_preview_text`` accepts an override so the
|
||||
# downstream renderer (u8) or per-frame contracts can pass a tighter
|
||||
# value if a frame uses a smaller body font.
|
||||
POPUP_PREVIEW_DEFAULT_LINE_HEIGHT_PX = 18.0
|
||||
|
||||
|
||||
def compute_popup_preview_text(
|
||||
raw_content: str,
|
||||
container_height_px: float,
|
||||
*,
|
||||
line_height_px: float = POPUP_PREVIEW_DEFAULT_LINE_HEIGHT_PX,
|
||||
) -> str:
|
||||
"""Px-budgeted preview excerpt of ``raw_content`` (IMP-35 u7).
|
||||
|
||||
Deterministic line-boundary cut — returns the leading lines of
|
||||
``raw_content`` that fit within ``container_height_px`` at the slide
|
||||
body line metric. Never trims inside a line (no mid-CJK-word cut);
|
||||
the popup body (u6 ``popup_body_source``) retains the FULL original
|
||||
verbatim so this excerpt loses no information.
|
||||
|
||||
Args:
|
||||
raw_content: the unit's source MDX content; the popup body
|
||||
source per CLAUDE.md 자세히보기 원칙.
|
||||
container_height_px: container height telemetry. The pipeline
|
||||
composer passes ``min_height_px`` (frame visual_hints) at
|
||||
the zones_data append site. Non-positive values fall back
|
||||
to returning the full content unchanged (popup gate would
|
||||
not have fired without a real container budget anyway).
|
||||
line_height_px: px per body line. Default matches slide_base.html
|
||||
``.text-line`` (11 px font * 1.6 line-height + guard).
|
||||
Overridable for tighter-font frames.
|
||||
|
||||
Returns:
|
||||
The leading lines that fit the budget, joined verbatim. If the
|
||||
content already fits, returns ``raw_content`` unchanged.
|
||||
"""
|
||||
if not raw_content:
|
||||
return ""
|
||||
if container_height_px <= 0 or line_height_px <= 0:
|
||||
# No budget signal — return the full content unchanged. u5 POPUP
|
||||
# gate would not have fired without a real container budget, so
|
||||
# this branch is only reachable for non-popup units (where the
|
||||
# preview is anyway unused — see compose_zone_popup_payload).
|
||||
return raw_content
|
||||
max_lines = int(container_height_px // line_height_px)
|
||||
if max_lines < 1:
|
||||
max_lines = 1
|
||||
lines = raw_content.splitlines(keepends=False)
|
||||
if len(lines) <= max_lines:
|
||||
return raw_content
|
||||
# Re-join with "\n" — splitlines drops the terminator so a verbatim
|
||||
# round-trip of the leading lines is "\n".join(...). Preserves the
|
||||
# exact head of raw_content up to the chosen line boundary.
|
||||
return "\n".join(lines[:max_lines])
|
||||
|
||||
|
||||
def compose_zone_popup_payload(unit, container_height_px: float) -> dict:
|
||||
"""Compose the per-zone popup render-context payload (IMP-35 u7).
|
||||
|
||||
Reads u6 ``bind_popup_display_strategy(unit)`` and surfaces the three
|
||||
uniform render-context field names the pipeline composer attaches to
|
||||
each zone in ``zones_data``. The full u6 binding is also echoed
|
||||
under ``popup_binding`` so downstream debug / u8 / u9 consumers can
|
||||
self-explain without re-reading the yaml.
|
||||
|
||||
Args:
|
||||
unit: a CompositionUnit (or any duck-typed object exposing
|
||||
``has_popup`` / ``popup_escalation_plan`` / ``raw_content``).
|
||||
container_height_px: container height telemetry. The pipeline
|
||||
composer passes ``min_height_px`` at the zones_data append
|
||||
site. The non-popup branch ignores the value (preview_text
|
||||
is always None when has_popup=False).
|
||||
|
||||
Returns:
|
||||
Dict with the four wiring keys (``has_popup``, ``popup_html``,
|
||||
``preview_text``, ``popup_binding``). Spreadable into a zone
|
||||
dict via ``zones_data.append({..., **payload})``.
|
||||
"""
|
||||
binding = bind_popup_display_strategy(unit)
|
||||
has_popup = bool(binding.get("has_popup"))
|
||||
if not has_popup:
|
||||
return {
|
||||
"has_popup": False,
|
||||
"popup_html": None,
|
||||
"preview_text": None,
|
||||
"popup_binding": binding,
|
||||
}
|
||||
raw_content = getattr(unit, "raw_content", "") or ""
|
||||
popup_html = binding.get("popup_body_source")
|
||||
preview_text = compute_popup_preview_text(raw_content, container_height_px)
|
||||
return {
|
||||
"has_popup": True,
|
||||
# popup body = FULL raw_content (u6 popup_body_source). u8 wraps
|
||||
# this in <details>/<summary> markup on slide_base.html.
|
||||
"popup_html": popup_html,
|
||||
# body preview = px-budgeted line-boundary cut of raw_content.
|
||||
# NEVER trims inside a line; popup body holds the FULL original
|
||||
# so this excerpt loses no information.
|
||||
"preview_text": preview_text,
|
||||
# Full u6 binding echo — downstream debug surfaces (catalog
|
||||
# detail_trigger placement, popup_escalation_plan category /
|
||||
# rationale) without re-reading yaml.
|
||||
"popup_binding": binding,
|
||||
}
|
||||
|
||||
|
||||
# ─── CompositionUnit ────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
@@ -925,3 +1240,341 @@ def plan_composition(sections, v4_lookup_fn, v4_label_to_status: dict,
|
||||
}
|
||||
|
||||
return units, preset, debug
|
||||
|
||||
|
||||
# ─── IMP-48 — Re-split All-Reject Merges (#77, Stage 2 / u1~u3) ─────
|
||||
|
||||
def resplit_all_reject_merges(
|
||||
units: list[CompositionUnit],
|
||||
sections,
|
||||
v4_lookup_fn,
|
||||
v4_label_to_status: dict,
|
||||
allowed_statuses: set[str],
|
||||
*,
|
||||
capacity_fit_fn=None,
|
||||
v4_candidates_lookup_fn=None,
|
||||
section_assignment_override: bool = False,
|
||||
) -> tuple[list[CompositionUnit], dict]:
|
||||
"""Re-split merged composition units whose rank-1 V4 label is ``reject``.
|
||||
|
||||
IMP-48 (#77) — Step 6 post-pass that decomposes a merged unit
|
||||
(``parent_merged`` / ``parent_merged_inferred``) carrying ``label=reject``
|
||||
into per-section singles, so child sections with non-reject rank-1 V4
|
||||
evidence can flow through the normal use_as_is / light_edit / restructure
|
||||
paths instead of being handed to IMP-47B (#76) as a single blob.
|
||||
|
||||
Stage 2 / u3 slice (current revision) :
|
||||
u1 contract (detection scan + override skip + idempotent single-
|
||||
exclusion) + u2 per-section Branch-1 rebuild (each rebuilt single
|
||||
carries ``merge_type="single"`` + the section's OWN rank-1 V4
|
||||
evidence via ``v4_lookup_fn`` + the section's original
|
||||
``raw_content`` from ``sections``) are both preserved. u3 adds the
|
||||
gating + swap path :
|
||||
|
||||
1. **Coverage equality** — every child section in
|
||||
``source_section_ids`` MUST rebuild successfully. Any
|
||||
``section_not_found`` / ``no_v4_match`` rebuild result short-
|
||||
circuits that merged unit to ``reason="incomplete_rebuild"``.
|
||||
2. **Beneficial split** — at least one rebuilt single MUST have
|
||||
``label != "reject"`` (Stage 2 Q2 Codex YES — "≥1 section
|
||||
gains non-reject frame"). Otherwise that merged unit short-
|
||||
circuits to ``reason="no_beneficial_split"`` and IMP-47B (#76)
|
||||
handles the merge directly.
|
||||
3. **Layout cap (≤ 4 units)** — projected post-split unit count
|
||||
(across ALL detected merges that would split) MUST be ≤ 4.
|
||||
Otherwise EVERY would-be split is aborted with
|
||||
``reason="layout_cap_exceeded"`` (Stage 2 Q2 default — keep
|
||||
merged, no partial split; v0 ``select_layout_preset`` supports
|
||||
1~4 units max).
|
||||
4. **Telemetry** — every single produced by an APPLIED split has
|
||||
``selection_path="resplit_from_merge"`` (Stage 1 Q3 YES,
|
||||
additive field reuse — no schema add).
|
||||
5. **Audit payload** — ``audit["applied"]`` reflects whether ANY
|
||||
merge actually split. ``audit["split_units"]`` /
|
||||
``audit["skipped_units"]`` capture per-merge decisions.
|
||||
``audit["post_split_unit_count"]`` reflects the returned list
|
||||
length. ``audit["post_split_layout_preset"]`` is filled via
|
||||
``select_layout_preset(out_units)`` when ``applied=True``,
|
||||
None otherwise (u5 also re-derives in pipeline scope).
|
||||
|
||||
``out_units`` is the post-resplit unit list (merged removed +
|
||||
singles inserted, in original ordering). When no merge splits,
|
||||
``out_units`` is byte-identical to input ``units`` and
|
||||
``applied=False`` — the audit's ``skipped_reason`` becomes
|
||||
``"no_split_applied"``.
|
||||
|
||||
Detection signal (★ no-hardcoding, AI=0) :
|
||||
``merge_type ∈ {"parent_merged", "parent_merged_inferred"}``
|
||||
AND ``label == "reject"``
|
||||
AND ``len(source_section_ids) >= 2``
|
||||
|
||||
Signal uses only ``merge_type`` + ``label`` + section count — never
|
||||
section_id, template_id, MDX filename, or sample identifier.
|
||||
|
||||
Override skip (Stage 2 Q1 — kwarg per Codex YES) :
|
||||
``section_assignment_override=True`` makes the helper a no-op. User-
|
||||
driven ``zoneSections`` (#6 IMP-06) is the ground truth and must not
|
||||
be second-guessed by an automatic re-split.
|
||||
|
||||
Idempotency (max_retry=1, Stage 2 lock) :
|
||||
u2's rebuilt units carry ``merge_type="single"``, which is excluded
|
||||
from the detection filter by construction. A second pass through
|
||||
this helper finds nothing — no inner loop, no recursion.
|
||||
|
||||
Frame-swap guardrail (★ feedback_ai_isolation_contract) :
|
||||
u2 rebuilds each child section's single from its OWN rank-1 V4
|
||||
evidence via ``v4_lookup_fn``. The merged unit's parent /
|
||||
representative ``template_id`` is discarded along with the merge
|
||||
itself — no swap of one section's frame onto another section.
|
||||
|
||||
Args:
|
||||
units: composition units from ``plan_composition()``.
|
||||
sections: original section list (forwarded to u2 for per-section
|
||||
``raw_content`` lookup — merged units carry the joined string,
|
||||
not the individual child source).
|
||||
v4_lookup_fn: ``(section_id) -> V4Match | None`` (rank-1). Forwarded
|
||||
to u2 — identical evidence source as ``plan_composition``.
|
||||
v4_label_to_status: V4 label → Phase Z status mapping (forwarded).
|
||||
allowed_statuses: auto-renderable status set (forwarded).
|
||||
capacity_fit_fn: optional capacity fit injector (forwarded to u2).
|
||||
v4_candidates_lookup_fn: optional Step 6-A candidates fn (forwarded).
|
||||
section_assignment_override: True iff user supplied
|
||||
``zoneSections`` / ``section_assignment_plan`` (IMP-06 chain).
|
||||
|
||||
Returns:
|
||||
``(out_units, audit)`` :
|
||||
``out_units`` = post-resplit units (u1: identical to input).
|
||||
``audit`` = ``imp48_resplit`` payload following Stage 1 schema::
|
||||
|
||||
{
|
||||
"applied": bool, # u1: always False
|
||||
"split_units": [...], # u3 fills with per-section singles
|
||||
"skipped_units": [...], # u3 fills with kept-merged + reason
|
||||
"post_split_unit_count": int,
|
||||
"post_split_layout_preset": Optional[str],
|
||||
"skipped_reason": str, # u1: contract-stage reason
|
||||
"detected_units": [...], # u1: u2's rebuild targets
|
||||
}
|
||||
"""
|
||||
# ``allowed_statuses`` is forwarded for signature symmetry with
|
||||
# ``plan_composition`` but unused inside the helper — Stage 2 / Codex YES
|
||||
# fixed the beneficial-split threshold to ``single.label != "reject"``
|
||||
# (Stage 1 contract "non-reject rank-1"). Future axes may widen the
|
||||
# threshold using ``allowed_statuses``; until then the parameter is
|
||||
# explicitly deleted to silence lint without losing the public contract.
|
||||
del allowed_statuses
|
||||
|
||||
audit: dict = {
|
||||
"applied": False,
|
||||
"split_units": [],
|
||||
"skipped_units": [],
|
||||
"post_split_unit_count": len(units),
|
||||
"post_split_layout_preset": None,
|
||||
"detected_units": [],
|
||||
"rebuild_attempts": [],
|
||||
}
|
||||
|
||||
if section_assignment_override:
|
||||
audit["skipped_reason"] = "section_assignment_override"
|
||||
return units, audit
|
||||
|
||||
detected = [
|
||||
u for u in units
|
||||
if u.merge_type in {"parent_merged", "parent_merged_inferred"}
|
||||
and u.label == "reject"
|
||||
and len(u.source_section_ids) >= 2
|
||||
]
|
||||
audit["detected_units"] = [
|
||||
{
|
||||
"source_section_ids": list(u.source_section_ids),
|
||||
"merge_type": u.merge_type,
|
||||
"template_id": u.frame_template_id,
|
||||
"label": u.label,
|
||||
}
|
||||
for u in detected
|
||||
]
|
||||
if not detected:
|
||||
audit["skipped_reason"] = "no_detection"
|
||||
return units, audit
|
||||
|
||||
# u2 — per-section Branch-1 rebuild for each detected merged-reject unit.
|
||||
# Mirrors ``collect_candidates`` Branch 1 (single per section). Each rebuilt
|
||||
# single carries the section's OWN rank-1 V4 evidence — the merged unit's
|
||||
# parent/representative template_id is discarded along with the merge.
|
||||
# ★ feedback_ai_isolation_contract : no frame swap (each section's own V4).
|
||||
# ★ MDX_raw_content_invariant : raw_content taken from sections list.
|
||||
# ★ idempotency : merge_type="single" excludes singles
|
||||
# from re-detection on any later pass.
|
||||
section_by_id = {s.section_id: s for s in sections}
|
||||
|
||||
def _v4_cands(section_id: str) -> list:
|
||||
return v4_candidates_lookup_fn(section_id) if v4_candidates_lookup_fn else []
|
||||
|
||||
rebuild_attempts: list[dict] = []
|
||||
for merged_unit in detected:
|
||||
section_singles: list[dict] = []
|
||||
for sid in merged_unit.source_section_ids:
|
||||
section = section_by_id.get(sid)
|
||||
if section is None:
|
||||
section_singles.append({
|
||||
"section_id": sid,
|
||||
"build_result": "section_not_found",
|
||||
"unit": None,
|
||||
})
|
||||
continue
|
||||
match = v4_lookup_fn(sid)
|
||||
if match is None:
|
||||
section_singles.append({
|
||||
"section_id": sid,
|
||||
"build_result": "no_v4_match",
|
||||
"unit": None,
|
||||
})
|
||||
continue
|
||||
single = CompositionUnit(
|
||||
source_section_ids=[sid],
|
||||
merge_type="single",
|
||||
frame_template_id=match.template_id,
|
||||
frame_id=match.frame_id,
|
||||
frame_number=match.frame_number,
|
||||
confidence=match.confidence,
|
||||
label=match.label,
|
||||
phase_z_status=v4_label_to_status.get(match.label, "unknown"),
|
||||
v4_rank=getattr(match, "v4_rank", None),
|
||||
selection_path=getattr(match, "selection_path", "rank_1"),
|
||||
fallback_reason=getattr(match, "fallback_reason", None),
|
||||
raw_content=section.raw_content,
|
||||
title=section.title,
|
||||
v4_candidates=_v4_cands(sid),
|
||||
provisional=getattr(match, "provisional", False),
|
||||
)
|
||||
_apply_capacity_fit(single, capacity_fit_fn)
|
||||
score_candidate(single)
|
||||
section_singles.append({
|
||||
"section_id": sid,
|
||||
"build_result": "ok",
|
||||
"unit": single,
|
||||
})
|
||||
rebuild_attempts.append({
|
||||
"merged_source_section_ids": list(merged_unit.source_section_ids),
|
||||
"merged_merge_type": merged_unit.merge_type,
|
||||
"merged_template_id": merged_unit.frame_template_id,
|
||||
"section_singles": section_singles,
|
||||
})
|
||||
|
||||
audit["rebuild_attempts"] = rebuild_attempts
|
||||
|
||||
# u3 — gating + swap path.
|
||||
# Per-merge decision: split | skip(reason). Then a cumulative layout-cap
|
||||
# check aborts ALL would-be splits if projected post-split count > 4
|
||||
# (Stage 2 Q2 default — keep merged, no partial split; v0
|
||||
# ``select_layout_preset`` supports 1~4 units max).
|
||||
plans: list[dict] = []
|
||||
for merged_unit, attempt in zip(detected, rebuild_attempts):
|
||||
required_sids = set(merged_unit.source_section_ids)
|
||||
built_sids = {
|
||||
entry["section_id"]
|
||||
for entry in attempt["section_singles"]
|
||||
if entry["build_result"] == "ok"
|
||||
}
|
||||
if built_sids != required_sids:
|
||||
# Some sections failed to rebuild — coverage equality violated.
|
||||
# IMP-47B (#76) will handle the merged unit directly.
|
||||
plans.append({
|
||||
"merged": merged_unit,
|
||||
"decision": "skip",
|
||||
"reason": "incomplete_rebuild",
|
||||
"missing": sorted(required_sids - built_sids),
|
||||
})
|
||||
continue
|
||||
built_units = [
|
||||
entry["unit"]
|
||||
for entry in attempt["section_singles"]
|
||||
if entry["build_result"] == "ok"
|
||||
]
|
||||
non_reject_count = sum(1 for u in built_units if u.label != "reject")
|
||||
if non_reject_count == 0:
|
||||
# No child section gains a non-reject frame — split is not
|
||||
# beneficial. IMP-47B (#76) handles the merge directly.
|
||||
plans.append({
|
||||
"merged": merged_unit,
|
||||
"decision": "skip",
|
||||
"reason": "no_beneficial_split",
|
||||
})
|
||||
continue
|
||||
plans.append({
|
||||
"merged": merged_unit,
|
||||
"decision": "split",
|
||||
"singles": built_units,
|
||||
"non_reject_count": non_reject_count,
|
||||
})
|
||||
|
||||
# Cumulative layout-cap projection across all would-be splits.
|
||||
projected_count = len(units)
|
||||
for plan in plans:
|
||||
if plan["decision"] == "split":
|
||||
projected_count += len(plan["singles"]) - 1
|
||||
if projected_count > 4:
|
||||
for plan in plans:
|
||||
if plan["decision"] == "split":
|
||||
plan["decision"] = "skip"
|
||||
plan["reason"] = "layout_cap_exceeded"
|
||||
plan["projected_count"] = projected_count
|
||||
|
||||
# Build out_units by walking the input list once. Identity match by
|
||||
# ``id(unit)`` keeps the swap deterministic and preserves order.
|
||||
plan_by_unit_id = {id(plan["merged"]): plan for plan in plans}
|
||||
out_units: list[CompositionUnit] = []
|
||||
applied = False
|
||||
for unit in units:
|
||||
plan = plan_by_unit_id.get(id(unit))
|
||||
if plan is None:
|
||||
out_units.append(unit)
|
||||
continue
|
||||
if plan["decision"] == "split":
|
||||
applied = True
|
||||
for single in plan["singles"]:
|
||||
# ★ Stage 1 Q3 YES — additive telemetry tag, no schema add.
|
||||
# Overrides the v4 match's selection_path for split-produced
|
||||
# singles only; non-resplit code paths are unaffected.
|
||||
single.selection_path = "resplit_from_merge"
|
||||
out_units.extend(plan["singles"])
|
||||
audit["split_units"].append({
|
||||
"merged_source_section_ids": list(plan["merged"].source_section_ids),
|
||||
"merged_template_id": plan["merged"].frame_template_id,
|
||||
"non_reject_count": plan["non_reject_count"],
|
||||
"split_singles": [
|
||||
{
|
||||
"section_id": s.source_section_ids[0],
|
||||
"template_id": s.frame_template_id,
|
||||
"label": s.label,
|
||||
"phase_z_status": s.phase_z_status,
|
||||
}
|
||||
for s in plan["singles"]
|
||||
],
|
||||
})
|
||||
else: # skip
|
||||
out_units.append(unit)
|
||||
skip_entry: dict = {
|
||||
"merged_source_section_ids": list(plan["merged"].source_section_ids),
|
||||
"merged_template_id": plan["merged"].frame_template_id,
|
||||
"reason": plan["reason"],
|
||||
}
|
||||
if plan["reason"] == "incomplete_rebuild":
|
||||
skip_entry["missing_section_ids"] = list(plan["missing"])
|
||||
if plan["reason"] == "layout_cap_exceeded":
|
||||
skip_entry["projected_post_split_count"] = plan["projected_count"]
|
||||
audit["skipped_units"].append(skip_entry)
|
||||
|
||||
audit["applied"] = applied
|
||||
audit["post_split_unit_count"] = len(out_units)
|
||||
if applied:
|
||||
# ``select_layout_preset`` is deterministic on unit count (v0).
|
||||
# u5 (pipeline) re-derives layout preset over the same out_units list;
|
||||
# both values stay consistent by construction.
|
||||
audit["post_split_layout_preset"] = select_layout_preset(out_units)
|
||||
audit.pop("skipped_reason", None)
|
||||
else:
|
||||
audit["post_split_layout_preset"] = None
|
||||
audit["skipped_reason"] = "no_split_applied"
|
||||
|
||||
return out_units, audit
|
||||
|
||||
+175
-14
@@ -27,15 +27,48 @@ 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)
|
||||
↓ 그래도 안 되면
|
||||
details_popup_escalation (가장 invasive — content popup, 마지막 resort)
|
||||
```
|
||||
|
||||
`details_popup_escalation` 은 본 매핑에 *없음* — tabular_overflow / structural_major_overflow /
|
||||
frame_reselect 실패 이후 단계에서 다룸 (별 step).
|
||||
IMP-35 (#64) u2 — cascade terminal landed. `frame_reselect_insufficient`
|
||||
(post-frame remeasure failure, classifier path locked in u1) now routes onto
|
||||
`details_popup_escalation`. The status table records the popup action as
|
||||
MISSING here; the actual executor stub + MISSING→IMPLEMENTED flip lives in
|
||||
`src/phase_z2_router.py` (u3 surface), so this module advertises the cascade
|
||||
terminal without claiming an implementation it does not own.
|
||||
|
||||
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
|
||||
@@ -74,6 +107,35 @@ FAILURE_TYPE_DESCRIPTIONS: dict[str, str] = {
|
||||
"font_step_compression salvage step failed — FONT_SIZE_STEPS exhausted "
|
||||
"down to the floor without resolving overflow (or text_metrics missing)"
|
||||
),
|
||||
"frame_reselect_insufficient": (
|
||||
"frame_reselect salvage step failed — V4 top-k alternate frame swap "
|
||||
"re-rendered + post-frame remeasure (run_overflow_check) still fails. "
|
||||
"IMP-35 (#64) u1 contract: emitted from salvage_steps[-1].action == "
|
||||
"'frame_reselect' AND passed=False AND post_salvage_overflow present. "
|
||||
"Routes to details_popup_escalation in u2 (cascade terminal)."
|
||||
),
|
||||
# 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)."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -86,6 +148,23 @@ SALVAGE_FAILURE_TYPE_BY_ACTION: dict[str, str] = {
|
||||
"cross_zone_redistribute": "cross_zone_redistribute_insufficient",
|
||||
"glue_compression": "glue_absorption_insufficient",
|
||||
"font_step_compression": "font_step_insufficient",
|
||||
# IMP-35 (#64) u1: post-frame remeasure failure. frame_reselect salvage step
|
||||
# writes a salvage_steps entry with action='frame_reselect', passed=False,
|
||||
# and post_salvage_overflow populated by run_overflow_check on the swapped
|
||||
# frame's HTML. classifier reads that entry; u2 adds the NEXT_ACTION row
|
||||
# that routes this onto details_popup_escalation.
|
||||
"frame_reselect": "frame_reselect_insufficient",
|
||||
# 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",
|
||||
}
|
||||
|
||||
|
||||
@@ -98,7 +177,24 @@ NEXT_ACTION_BY_FAILURE: dict[str, str] = {
|
||||
"glue_absorption_insufficient": "font_step_compression",
|
||||
"font_step_insufficient": "layout_adjust",
|
||||
"rerender_still_fails": "frame_reselect",
|
||||
# IMP-35 (#64) u2 — cascade terminal. frame_reselect salvage exhausted
|
||||
# (post-frame remeasure failed; classifier path gated on
|
||||
# post_salvage_overflow per u1/q4) escalates onto details_popup_escalation.
|
||||
# Popup body holds full MDX source; preview shows summary/subset
|
||||
# (CLAUDE.md 자세히보기 원칙). Executor + MISSING→IMPLEMENTED flip lands
|
||||
# in u3 (src/phase_z2_router.py); this module owns the cascade mapping
|
||||
# only.
|
||||
"frame_reselect_insufficient": "details_popup_escalation",
|
||||
"not_attempted": "none",
|
||||
# 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] = {
|
||||
@@ -127,9 +223,31 @@ NEXT_ACTION_RATIONALE: dict[str, str] = {
|
||||
"frame/zone 조합 자체 부적합, V4 top-k 의 다른 frame 평가 (frame_reselect). "
|
||||
"popup 직행은 아직 빠름 (tabular / structural_major 가 아닌 한)"
|
||||
),
|
||||
"frame_reselect_insufficient": (
|
||||
"V4 top-k frame swap + 명시적 post-frame remeasure 까지 했는데도 overflow "
|
||||
"잔존 → cascade terminal 인 details_popup_escalation 으로 escalate. "
|
||||
"본문 = summary/subset, popup = MDX 원문 (자세히보기 원칙). "
|
||||
"AI repair 진입 전 deterministic 마지막 단계."
|
||||
),
|
||||
"not_attempted": (
|
||||
"retry 시도 자체가 없었음 (visual ok 등) — escalation 불필요"
|
||||
),
|
||||
# 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 들의 *현재 코드* 구현 상태
|
||||
@@ -143,8 +261,32 @@ 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
|
||||
# src/phase_z2_router.py (u3). Keeping this entry as MISSING until u3
|
||||
# lands prevents premature "popup ready" claims from the failure-router
|
||||
# surface.
|
||||
"details_popup_escalation": "MISSING",
|
||||
# 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",
|
||||
}
|
||||
|
||||
@@ -170,21 +312,40 @@ def classify_retry_failure(retry_trace: dict) -> Optional[dict]:
|
||||
# case 0.7 : salvage chain attempted and ended in a salvage-level failure.
|
||||
# zone_ratio_retry 가 먼저 실패한 뒤 _attempt_salvage_chain 이 가동된 path —
|
||||
# 마지막 salvage step 의 action 으로 failure_type 을 분류한다. u3 가 routing.
|
||||
#
|
||||
# IMP-35 (#64) u1 — q4 explicit remeasure contract: the frame_reselect
|
||||
# branch is gated on post_salvage_overflow being present on the salvage
|
||||
# step. A bare passed=False flag with no remeasure payload is *not*
|
||||
# sufficient to emit frame_reselect_insufficient (which routes to
|
||||
# details_popup_escalation in u2). When the gate fails, the classifier
|
||||
# falls through to lower-priority cases so the salvage trace surfaces as
|
||||
# an unmatched defensive fallback instead of a spurious popup escalation.
|
||||
salvage_steps = retry_trace.get("salvage_steps") or []
|
||||
if salvage_steps:
|
||||
last = salvage_steps[-1] or {}
|
||||
if not last.get("passed"):
|
||||
action = (last.get("action") or "").lower()
|
||||
ftype = SALVAGE_FAILURE_TYPE_BY_ACTION.get(action)
|
||||
if ftype is not None:
|
||||
reason = last.get("failure_reason") or ""
|
||||
return {
|
||||
"failure_type": ftype,
|
||||
"classification_rule": (
|
||||
f"salvage_steps[-1].action == {action!r} "
|
||||
f"AND passed=False. raw failure_reason: {reason!r}"
|
||||
),
|
||||
}
|
||||
frame_reselect_blocked = (
|
||||
action == "frame_reselect"
|
||||
and not last.get("post_salvage_overflow")
|
||||
)
|
||||
if not frame_reselect_blocked:
|
||||
ftype = SALVAGE_FAILURE_TYPE_BY_ACTION.get(action)
|
||||
if ftype is not None:
|
||||
reason = last.get("failure_reason") or ""
|
||||
rule_suffix = (
|
||||
" AND post_salvage_overflow present"
|
||||
if action == "frame_reselect"
|
||||
else ""
|
||||
)
|
||||
return {
|
||||
"failure_type": ftype,
|
||||
"classification_rule": (
|
||||
f"salvage_steps[-1].action == {action!r} "
|
||||
f"AND passed=False{rule_suffix}. "
|
||||
f"raw failure_reason: {reason!r}"
|
||||
),
|
||||
}
|
||||
|
||||
# case 1 : retry 시도 자체 안 됨 (router_active=False 또는 다른 action)
|
||||
if not retry_trace.get("retry_attempted"):
|
||||
|
||||
+93
-7
@@ -42,6 +42,22 @@ class FitError(Exception):
|
||||
"""
|
||||
|
||||
|
||||
class BuilderMissingError(FitError):
|
||||
"""Contract.payload.builder ↔ PAYLOAD_BUILDERS registry mismatch.
|
||||
|
||||
FitError subclass — pipeline 의 기존 `except FitError` 경로가 그대로
|
||||
adapter_needed 로 라우팅 (mdx04 hard crash 차단, IMP-#85 u1).
|
||||
"""
|
||||
|
||||
|
||||
class CatalogInvariantError(Exception):
|
||||
"""Catalog ↔ runtime registry drift detected at load time.
|
||||
|
||||
Boot-time invariant violation (IMP-#85 u2). Distinct from FitError:
|
||||
runtime fallback 대상이 아니라 catalog wiring 결함 (fail-fast).
|
||||
"""
|
||||
|
||||
|
||||
# ─── Catalog loading ──────────────────────────────────────────────
|
||||
|
||||
_CATALOG_CACHE: dict | None = None
|
||||
@@ -50,7 +66,9 @@ _CATALOG_CACHE: dict | None = None
|
||||
def load_frame_contracts() -> dict:
|
||||
global _CATALOG_CACHE
|
||||
if _CATALOG_CACHE is None:
|
||||
_CATALOG_CACHE = yaml.safe_load(CATALOG_PATH.read_text(encoding="utf-8")) or {}
|
||||
catalog = yaml.safe_load(CATALOG_PATH.read_text(encoding="utf-8")) or {}
|
||||
_check_catalog_builder_invariant(catalog)
|
||||
_CATALOG_CACHE = catalog
|
||||
return _CATALOG_CACHE
|
||||
|
||||
|
||||
@@ -561,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"]
|
||||
@@ -577,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)
|
||||
|
||||
@@ -686,6 +728,50 @@ PAYLOAD_BUILDERS: dict[str, Callable] = {
|
||||
}
|
||||
|
||||
|
||||
# ─── Catalog builder invariant (IMP-#85 u2) ──────────────────────
|
||||
|
||||
def _check_catalog_builder_invariant(catalog: dict) -> None:
|
||||
"""Every non-`visual_pending` contract must declare a registered builder.
|
||||
|
||||
`visual_pending: true` contracts are scaffolding records whose builders
|
||||
are tracked as VP backlog (별 axis IMP-04b / #42) — skipped here so the
|
||||
catalog can keep declaring them without breaking boot.
|
||||
|
||||
Violations are aggregated and raised together so first-fix iteration sees
|
||||
the full drift surface, not just the first row.
|
||||
|
||||
Raises:
|
||||
CatalogInvariantError — when one or more live (non-VP) contracts
|
||||
either omit `payload.builder` or reference a name absent from
|
||||
`PAYLOAD_BUILDERS`.
|
||||
"""
|
||||
violations: list[str] = []
|
||||
for template_id, contract in catalog.items():
|
||||
if not isinstance(contract, dict):
|
||||
continue
|
||||
if contract.get("visual_pending") is True:
|
||||
continue
|
||||
payload = contract.get("payload") or {}
|
||||
builder_name = payload.get("builder") if isinstance(payload, dict) else None
|
||||
if not builder_name:
|
||||
violations.append(
|
||||
f"Contract '{template_id}' (non-VP) missing payload.builder."
|
||||
)
|
||||
continue
|
||||
if builder_name not in PAYLOAD_BUILDERS:
|
||||
violations.append(
|
||||
f"Contract '{template_id}' (non-VP) references payload.builder="
|
||||
f"'{builder_name}' not in PAYLOAD_BUILDERS registry."
|
||||
)
|
||||
if violations:
|
||||
raise CatalogInvariantError(
|
||||
f"Catalog builder invariant violated "
|
||||
f"({len(violations)} non-VP contract(s)):\n - "
|
||||
+ "\n - ".join(violations)
|
||||
+ f"\nRegistered builders: {sorted(PAYLOAD_BUILDERS.keys())}"
|
||||
)
|
||||
|
||||
|
||||
# ─── Generic mapper (single dispatch via builder) ────────────────
|
||||
|
||||
def _check_cardinality(contract: dict, units: list, section) -> None:
|
||||
@@ -843,13 +929,13 @@ def map_with_contract(section, contract: dict) -> dict:
|
||||
payload_spec = contract["payload"]
|
||||
builder_name = payload_spec.get("builder")
|
||||
if not builder_name:
|
||||
raise ValueError(
|
||||
raise BuilderMissingError(
|
||||
f"Contract '{contract['template_id']}' missing payload.builder. "
|
||||
f"available: {sorted(PAYLOAD_BUILDERS.keys())}"
|
||||
)
|
||||
builder = PAYLOAD_BUILDERS.get(builder_name)
|
||||
if builder is None:
|
||||
raise ValueError(
|
||||
raise BuilderMissingError(
|
||||
f"Contract '{contract['template_id']}' references payload.builder="
|
||||
f"'{builder_name}' but PAYLOAD_BUILDERS has no such entry. "
|
||||
f"available: {sorted(PAYLOAD_BUILDERS.keys())}"
|
||||
|
||||
+3491
-661
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}"
|
||||
)
|
||||
+159
-5
@@ -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,20 +62,51 @@ 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":
|
||||
"위 매핑 모두 미적용 — 마지막 fallback (현재 코드는 sys.exit 으로 abort)",
|
||||
}
|
||||
|
||||
# 각 action 의 *현재 코드* 구현 상태 (2026-04-29 기준; IMP-12 u7 cascade 2026-05-18)
|
||||
# 각 action 의 *현재 코드* 구현 상태 (2026-04-29 기준; IMP-12 u7 cascade 2026-05-18;
|
||||
# IMP-35 u3 popup-stub 2026-05-23)
|
||||
# A2 단계에서 이 매핑이 *어디까지 자동 처리되고 어디서 막히는지* trace 확보용
|
||||
ACTION_IMPLEMENTATION_STATUS: dict[str, str] = {
|
||||
"zone_ratio_retry": "IMPLEMENTED", # A3 (2026-04-29) phase_z2_retry.plan_zone_ratio_retry + pipeline orchestration
|
||||
"layout_adjust": "MISSING",
|
||||
"details_popup_escalation": "MISSING", # CLAUDE.md 의 <details> 원칙은 있음, runtime 미구현
|
||||
# IMP-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
|
||||
# in `src/phase_z2_ai_fallback/step17.py`; u5 wires the Step 17 POPUP
|
||||
# gate executor in `src/phase_z2_pipeline.py`. Router-level mapping is
|
||||
# decoupled from orchestrator wiring (same precedent as the IMP-12 u7
|
||||
# cascade actions below): IMPLEMENTED here reflects deterministic
|
||||
# *surface availability* (importable stub), not whether a given pipeline
|
||||
# run has invoked it. The failure_router companion surface
|
||||
# (NEXT_ACTION_IMPLEMENTATION_STATUS in phase_z2_failure_router.py) keeps
|
||||
# `details_popup_escalation` as MISSING until u5 lands the pipeline gate.
|
||||
"details_popup_escalation": "IMPLEMENTED",
|
||||
"frame_reselect": "PARTIAL", # IMP-05 pre-render rank-2/3 fallback implemented; post-render rerender trace-only
|
||||
"adapter_needed": "PARTIAL", # composition v0.1.1 의 mapper FitError catch
|
||||
"abort": "IMPLEMENTED", # sys.exit(1) — pipeline 의 현재 default
|
||||
@@ -185,3 +230,112 @@ def route_fit_classification(fit_classification: dict) -> dict:
|
||||
"MISSING 이면 그 action 은 실행 X 이고 기존 abort/status 흐름 (sys.exit(1)) 으로 종료."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ─── IMP-35 (#64) u3 — details_popup_escalation deterministic stub ─
|
||||
# Surface contract for the cascade-terminal popup escalation. This stub
|
||||
# does NOT mutate HTML / CSS / MDX content; it emits the canonical plan
|
||||
# marker that the Step 17 POPUP gate (u5) and the AI split-decision hook
|
||||
# (u4) consume. Keeping the executor surface here (next to the primary
|
||||
# ACTION_BY_CATEGORY mapping) lets the router report IMPLEMENTED for
|
||||
# `details_popup_escalation` while u4/u5 are still landing.
|
||||
#
|
||||
# Contract (locked in Stage 2 IMPLEMENTATION_UNITS u3):
|
||||
# - Inputs: classification dict (a single fit_classifier output row).
|
||||
# The category MUST be one of the two ACTION_BY_CATEGORY
|
||||
# rows that map onto `details_popup_escalation` —
|
||||
# `structural_major_overflow` or `tabular_overflow`.
|
||||
# Other categories raise the stub's defensive guard (so
|
||||
# callers do not silently popup-escalate the wrong category).
|
||||
# - Output: popup_escalation_plan dict with `feasible=True`,
|
||||
# `stub=True`, the source category, the canonical
|
||||
# ACTION_RATIONALE entry, and `needs_split_decision=True`
|
||||
# to flag that u4 (AI hook) must run before u5 renders.
|
||||
# - No side effects (no AI call, no MDX read, no HTML mutation).
|
||||
#
|
||||
# Guardrails honored:
|
||||
# - feedback_ai_isolation_contract: stub is deterministic-with-data;
|
||||
# no AI call inside the router surface.
|
||||
# - Phase Z spacing 방향: stub does not shrink common margins; it
|
||||
# expands capacity by routing content to popup downstream.
|
||||
# - 자세히보기 원칙 (CLAUDE.md): plan carries the marker that u5 uses
|
||||
# to put MDX 원문 in popup body and a summary/subset in preview.
|
||||
# - 1 turn = 1 unit: this is router-surface only. u4/u5 own the
|
||||
# downstream wiring on their respective files.
|
||||
|
||||
|
||||
# Categories that legitimately escalate onto details_popup_escalation
|
||||
# per the ACTION_BY_CATEGORY mapping above. Kept as a derived constant
|
||||
# so the router cannot drift away from the single source of truth.
|
||||
POPUP_ESCALATION_CATEGORIES: frozenset[str] = frozenset(
|
||||
category
|
||||
for category, action in ACTION_BY_CATEGORY.items()
|
||||
if action == "details_popup_escalation"
|
||||
)
|
||||
|
||||
|
||||
def plan_details_popup_escalation(classification: dict) -> dict:
|
||||
"""Cascade-terminal popup escalation plan stub (IMP-35 u3).
|
||||
|
||||
Returns a deterministic popup_escalation_plan marker. The actual
|
||||
content split (popup_html / preview_text / has_popup payload) is
|
||||
composed downstream: u4 binds the AI split-decision contract on
|
||||
`src/phase_z2_ai_fallback/step17.py`; u5 wires the Step 17 POPUP
|
||||
gate executor on `src/phase_z2_pipeline.py`.
|
||||
|
||||
Args:
|
||||
classification: a single fit_classifier classification dict.
|
||||
Must contain a `category` key. Only the categories that
|
||||
map onto `details_popup_escalation` in ACTION_BY_CATEGORY
|
||||
(currently `structural_major_overflow` and `tabular_overflow`)
|
||||
are accepted; any other category produces an
|
||||
`feasible=False` plan with `failure_reason` so the caller
|
||||
never silently popup-escalates the wrong overflow shape.
|
||||
|
||||
Returns:
|
||||
popup_escalation_plan dict with at least:
|
||||
action : "details_popup_escalation"
|
||||
feasible : True/False (True for accepted categories)
|
||||
stub : True (marks u3 surface; u4/u5 fill in)
|
||||
category : echoed from input
|
||||
rationale : canonical ACTION_RATIONALE entry
|
||||
needs_split_decision : True (u4 AI hook must run before u5 renders)
|
||||
mapping_source : "IMP-35 u3 plan_details_popup_escalation stub"
|
||||
note : downstream-wiring pointer text
|
||||
"""
|
||||
category = (classification or {}).get("category")
|
||||
base = {
|
||||
"action": "details_popup_escalation",
|
||||
"stub": True,
|
||||
"category": category,
|
||||
"mapping_source": "IMP-35 u3 plan_details_popup_escalation stub",
|
||||
}
|
||||
if category not in POPUP_ESCALATION_CATEGORIES:
|
||||
return {
|
||||
**base,
|
||||
"feasible": False,
|
||||
"needs_split_decision": False,
|
||||
"rationale": "",
|
||||
"failure_reason": (
|
||||
f"category {category!r} does not map onto details_popup_escalation "
|
||||
f"in ACTION_BY_CATEGORY. Accepted categories: "
|
||||
f"{sorted(POPUP_ESCALATION_CATEGORIES)}. Defensive guard — "
|
||||
f"router must not silently popup-escalate the wrong overflow shape."
|
||||
),
|
||||
"note": (
|
||||
"u3 stub — caller passed a category that should not popup-escalate. "
|
||||
"Honour the ACTION_BY_CATEGORY mapping at the router entry point."
|
||||
),
|
||||
}
|
||||
return {
|
||||
**base,
|
||||
"feasible": True,
|
||||
"needs_split_decision": True,
|
||||
"rationale": ACTION_RATIONALE.get(category, ""),
|
||||
"note": (
|
||||
"u3 stub — actual content split planning lands in u4 "
|
||||
"(AI split-decision contract on src/phase_z2_ai_fallback/step17.py) "
|
||||
"and u5 (Step 17 POPUP gate executor on src/phase_z2_pipeline.py). "
|
||||
"popup body = MDX 원문, preview = summary/subset (자세히보기 원칙)."
|
||||
),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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)
|
||||
@@ -0,0 +1,206 @@
|
||||
"""IMP-52 (#80) u1 — user_overrides.json persistence layer (backend IO).
|
||||
|
||||
Persists the CLI-wired override axes per MDX so a subsequent render
|
||||
auto-restores user choices without re-clicking. Source of truth = MDX-keyed
|
||||
file (stem of the MDX path), NOT ``data/runs/<run_id>/`` which mints a fresh
|
||||
run_id per ``/api/run`` invocation.
|
||||
|
||||
Schema (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}},
|
||||
"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
|
||||
stamper (IMP-51 u4) and matched via the selector
|
||||
``.slide img[data-image-role="user-content"]``. Coordinates are
|
||||
percent-of-slide (zone-agnostic, slide-absolute) to match the SlideCanvas
|
||||
edit-mode handle conventions in IMP-51 u8~u11.
|
||||
|
||||
``unit_id`` is the convention already used by ``--override-frame`` :
|
||||
``"+".join(source_section_ids)`` (e.g., ``"03-1"`` or ``"03-1+03-2"``).
|
||||
|
||||
Behavior :
|
||||
- ``load(key)`` — file missing or corrupt → ``{}`` (warning to stderr on corrupt).
|
||||
- ``save(key, partial)`` — merges only the supplied axes onto the existing
|
||||
file, preserving (a) unknown top-level keys (foreign-key preserve) and
|
||||
(b) axes not present in the partial payload. Atomic write via tmp+rename.
|
||||
- ``override_path(key, root=None)`` — resolves the persistence path under
|
||||
``data/user_overrides/<key>.json``.
|
||||
|
||||
Guardrails (refs : ``user_overrides_io`` Stage 2 lock) :
|
||||
- Deterministic code, no AI fallback.
|
||||
- ``key`` validation rejects path traversal / separators / dot-prefix.
|
||||
- ``save`` is a deep-shallow merge — per-axis dict mutation does not delete
|
||||
prior keys unless caller passes ``None`` for that axis (explicit clear).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
# Persistence root — MDX-keyed, decoupled from data/runs/<run_id>/.
|
||||
# Resolved at call time so tests can monkeypatch via ``root=`` parameter.
|
||||
_PKG_ROOT = Path(__file__).resolve().parent.parent
|
||||
DEFAULT_OVERRIDES_ROOT = _PKG_ROOT / "data" / "user_overrides"
|
||||
|
||||
# The 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
|
||||
# alphanumerics, underscore, hyphen, and dot in the middle (sample stems
|
||||
# are e.g. ``01``, ``03``, ``03__DX...``). Reject leading dot, path
|
||||
# separators, and traversal.
|
||||
_KEY_RE = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_.\-]*$")
|
||||
|
||||
|
||||
class InvalidOverrideKey(ValueError):
|
||||
"""Raised when ``key`` is not a safe MDX stem."""
|
||||
|
||||
|
||||
def validate_key(key: str) -> str:
|
||||
"""Validate that ``key`` is a safe MDX stem; return it unchanged.
|
||||
|
||||
Rejects empty strings, path separators (``/`` ``\\``), traversal
|
||||
(``..``), and leading dot. Callers should pass ``Path(mdx_path).stem``.
|
||||
"""
|
||||
if not isinstance(key, str) or not key:
|
||||
raise InvalidOverrideKey(f"key must be a non-empty string, got: {key!r}")
|
||||
if not _KEY_RE.match(key):
|
||||
raise InvalidOverrideKey(
|
||||
f"key must match {_KEY_RE.pattern!r} (alphanumerics, '_', '-', '.'; "
|
||||
f"no leading dot, no separators); got: {key!r}"
|
||||
)
|
||||
if ".." in key:
|
||||
raise InvalidOverrideKey(f"key must not contain '..'; got: {key!r}")
|
||||
return key
|
||||
|
||||
|
||||
def override_path(key: str, root: Optional[Path] = None) -> Path:
|
||||
"""Resolve the on-disk path for ``key``'s override file."""
|
||||
validate_key(key)
|
||||
base = Path(root) if root is not None else DEFAULT_OVERRIDES_ROOT
|
||||
return base / f"{key}.json"
|
||||
|
||||
|
||||
def load(key: str, root: Optional[Path] = None) -> dict[str, Any]:
|
||||
"""Load persisted overrides for ``key``.
|
||||
|
||||
Missing file → ``{}``. Corrupt JSON → warning to stderr + ``{}``.
|
||||
Returns the raw mapping (including any foreign keys); callers should
|
||||
pick the KNOWN_AXES they care about.
|
||||
"""
|
||||
path = override_path(key, root=root)
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
print(
|
||||
f"[user_overrides_io] warning: failed to read {path} ({exc}); "
|
||||
f"treating as empty.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return {}
|
||||
if not isinstance(data, dict):
|
||||
print(
|
||||
f"[user_overrides_io] warning: {path} is not a JSON object "
|
||||
f"(got {type(data).__name__}); treating as empty.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return {}
|
||||
return data
|
||||
|
||||
|
||||
def save(key: str, partial: dict[str, Any], root: Optional[Path] = None) -> Path:
|
||||
"""Merge ``partial`` onto the persisted overrides for ``key`` and write atomically.
|
||||
|
||||
Merge semantics :
|
||||
- Only keys present in ``partial`` are mutated. Other axes (including
|
||||
foreign keys outside KNOWN_AXES) are preserved verbatim.
|
||||
- For each axis present in ``partial``, the new value REPLACES the prior
|
||||
value (no per-zone deep-merge). Callers that want to add a single
|
||||
zone must read → mutate → save with the full updated axis dict.
|
||||
- Pass ``None`` for an axis to clear it (remove the key from the file).
|
||||
"""
|
||||
if not isinstance(partial, dict):
|
||||
raise TypeError(
|
||||
f"partial must be a dict, got {type(partial).__name__}: {partial!r}"
|
||||
)
|
||||
path = override_path(key, root=root)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
current = load(key, root=root)
|
||||
for axis_key, axis_value in partial.items():
|
||||
if axis_value is None:
|
||||
current.pop(axis_key, None)
|
||||
else:
|
||||
current[axis_key] = axis_value
|
||||
_atomic_write_json(path, current)
|
||||
return path
|
||||
|
||||
|
||||
def _atomic_write_json(path: Path, data: dict[str, Any]) -> None:
|
||||
"""Write ``data`` to ``path`` atomically via tmp file + os.replace."""
|
||||
fd, tmp_name = tempfile.mkstemp(
|
||||
prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent)
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2, sort_keys=True)
|
||||
f.write("\n")
|
||||
os.replace(tmp_name, path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp_name)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
@@ -23,6 +23,10 @@ three_parallel_requirements:
|
||||
frame_id: 1171281190
|
||||
family: three_parallel
|
||||
|
||||
# IMP-36 (Gitea #65 u2) partial-backed contract axis bools.
|
||||
rotation_eligible: true # P1: aspect-ratio container-query rotation
|
||||
body_fit_pattern2: true # P2: cqh/clamp/--max-body-lines body fit
|
||||
|
||||
source_shape: top_bullets
|
||||
cardinality:
|
||||
strict: 3
|
||||
@@ -79,6 +83,10 @@ process_product_two_way:
|
||||
frame_id: 1171281210
|
||||
family: two_column_h3
|
||||
|
||||
# IMP-36 (Gitea #65 u2) partial-backed contract axis bools.
|
||||
rotation_eligible: false # P1: 2-col compare table — rotation 부적합 opt-out
|
||||
body_fit_pattern2: false # P2: 미적용 (Stage 2 selection)
|
||||
|
||||
source_shape: h3_subsections
|
||||
cardinality:
|
||||
strict: 2 # F29 frame = 2 visual columns. ≠2 → fallback.
|
||||
@@ -122,7 +130,7 @@ process_product_two_way:
|
||||
body_parser: column_with_transform # 첫 top-bullet AS-IS/TO-BE 표 인식
|
||||
- title_to: banner_right
|
||||
body_to: product
|
||||
body_parser: column_plain # 모든 section = 일반 text_lines
|
||||
body_parser: column_with_transform # IMP-36 (Gitea #65 u2) P3 parity — process column 과 동일 transform 인식 (좌/우 대칭)
|
||||
|
||||
|
||||
bim_issues_quadrant_four:
|
||||
@@ -130,6 +138,10 @@ bim_issues_quadrant_four:
|
||||
frame_id: 1171281193
|
||||
family: bim_issues_quadrant
|
||||
|
||||
# IMP-36 (Gitea #65 u2) partial-backed contract axis bools.
|
||||
rotation_eligible: false # P1: 4-quadrant 고정 grid — rotation 부적합
|
||||
body_fit_pattern2: false # P2: 미적용 (Stage 2 selection)
|
||||
|
||||
source_shape: top_bullets
|
||||
# F16 정책 = pad_to=4 + truncate>4 (legacy 와 동일). cardinality strict 화는 본 transition 범위 외.
|
||||
# 향후 normal path 안정 후 strict 적용 + 위반 시 fallback path (FitError) 검토.
|
||||
@@ -193,6 +205,10 @@ three_persona_benefits:
|
||||
frame_id: 1171281191
|
||||
family: cards
|
||||
|
||||
# IMP-36 (Gitea #65 u2) partial-backed contract axis bools.
|
||||
rotation_eligible: true # P1: aspect-ratio container-query rotation
|
||||
body_fit_pattern2: true # P2: cqh/clamp/--max-body-lines body fit
|
||||
|
||||
source_shape: top_bullets
|
||||
cardinality:
|
||||
strict: 3 # 3 persona = strict.
|
||||
@@ -258,6 +274,10 @@ construction_goals_three_circle_intersection:
|
||||
frame_id: 1171281189
|
||||
family: diagram
|
||||
|
||||
# IMP-36 (Gitea #65 u2) partial-backed contract axis bools.
|
||||
rotation_eligible: false # P1: 3-circle SVG diagram — rotation 부적합 (좌표 의존)
|
||||
body_fit_pattern2: false # P2: 미적용 (Stage 2 selection)
|
||||
|
||||
source_shape: top_bullets
|
||||
cardinality:
|
||||
strict: 3 # 3 메인 원 — strict.
|
||||
@@ -328,6 +348,10 @@ construction_bim_three_usage:
|
||||
frame_id: 1171281182
|
||||
family: cards
|
||||
|
||||
# IMP-36 (Gitea #65 u2) partial-backed contract axis bools.
|
||||
rotation_eligible: false # P1: 3 stacked rows — rotation 부적합 (수평 row 구조)
|
||||
body_fit_pattern2: false # P2: 미적용 (Stage 2 selection)
|
||||
|
||||
source_shape: top_bullets
|
||||
cardinality:
|
||||
strict: 3
|
||||
@@ -397,6 +421,10 @@ bim_dx_comparison_table:
|
||||
frame_id: 1171281195
|
||||
family: table
|
||||
|
||||
# IMP-36 (Gitea #65 u2) partial-backed contract axis bools.
|
||||
rotation_eligible: false # P1: 2-col compare table — rotation 부적합 opt-out (issue body 명시)
|
||||
body_fit_pattern2: false # P2: 미적용 (Stage 2 selection)
|
||||
|
||||
source_shape: top_bullets
|
||||
# NOTE (Codex round 43 §F1-c) : top-level `cardinality.strict: 2` = *column 수*
|
||||
# (col_a / col_b). data row 수 는 별 — `sub_zones.rows.cardinality` 의 `{min:1, max:12}`.
|
||||
@@ -446,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"
|
||||
@@ -462,6 +492,10 @@ dx_sw_necessity_three_perspectives:
|
||||
frame_id: 1171281198
|
||||
family: cards
|
||||
|
||||
# IMP-36 (Gitea #65 u2) partial-backed contract axis bools.
|
||||
rotation_eligible: true # P1: aspect-ratio container-query rotation
|
||||
body_fit_pattern2: true # P2: cqh/clamp/--max-body-lines body fit
|
||||
|
||||
source_shape: top_bullets
|
||||
cardinality:
|
||||
strict: 3 # 3 perspective columns
|
||||
@@ -528,6 +562,10 @@ info_management_what_how_when:
|
||||
frame_id: 1171281179
|
||||
family: cards
|
||||
|
||||
# IMP-36 (Gitea #65 u2) partial-backed contract axis bools.
|
||||
rotation_eligible: true # P1: aspect-ratio container-query rotation
|
||||
body_fit_pattern2: true # P2: cqh/clamp/--max-body-lines body fit
|
||||
|
||||
source_shape: top_bullets
|
||||
cardinality:
|
||||
strict: 3 # 3 sections (What / How / When)
|
||||
@@ -587,6 +625,10 @@ sw_reality_three_emphasis:
|
||||
frame_id: 1171281209
|
||||
family: cards
|
||||
|
||||
# IMP-36 (Gitea #65 u2) partial-backed contract axis bools.
|
||||
rotation_eligible: false # P1: 미적용 (Stage 2 selection — future eligibility TBD)
|
||||
body_fit_pattern2: false # P2: 미적용 (Stage 2 selection)
|
||||
|
||||
source_shape: top_bullets
|
||||
cardinality:
|
||||
strict: 3
|
||||
@@ -644,6 +686,10 @@ bim_current_problems_paired:
|
||||
frame_id: 1171281194
|
||||
family: cards
|
||||
|
||||
# IMP-36 (Gitea #65 u2) partial-backed contract axis bools.
|
||||
rotation_eligible: false # P1: 4x2 paired rows — rotation 부적합 (2-axis row×side 구조)
|
||||
body_fit_pattern2: false # P2: 미적용 (Stage 2 selection)
|
||||
|
||||
source_shape: top_bullets # mapper split_source allow-list 정합 (Codex round 60)
|
||||
layout_variant: paired_rows_4x2_alternating_pills # runtime projection model
|
||||
cardinality:
|
||||
@@ -735,6 +781,10 @@ app_sw_package_vs_solution:
|
||||
frame_id: 1171281203
|
||||
family: table
|
||||
|
||||
# IMP-36 (Gitea #65 u2) partial-backed contract axis bools.
|
||||
rotation_eligible: false # P1: 2-col compare table — rotation 부적합 opt-out
|
||||
body_fit_pattern2: true # P2: cqh/clamp/--max-body-lines body fit (Stage 1 canonical source)
|
||||
|
||||
source_shape: h3_subsections # F29 와 동일 — 2 h3 subsection = 2 column.
|
||||
cardinality:
|
||||
strict: 2 # 2 column (Package / Solution) — NOT row count.
|
||||
@@ -783,6 +833,10 @@ pre_construction_model_info_stacked:
|
||||
frame_id: 1171281180
|
||||
family: list
|
||||
|
||||
# IMP-36 (Gitea #65 u2) partial-backed contract axis bools.
|
||||
rotation_eligible: false # P1: 5-color cycle pill list — rotation 부적합
|
||||
body_fit_pattern2: false # P2: 미적용 (Stage 2 selection)
|
||||
|
||||
source_shape: top_bullets
|
||||
cardinality:
|
||||
min: 4 # analysis.md min 4 / Figma 5-color cycle design floor.
|
||||
@@ -1733,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).
|
||||
|
||||
|
||||
@@ -1795,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,45 @@
|
||||
# IMP-38 V4 max_rank 정책 — separate yaml (catalog 오염 방지)
|
||||
#
|
||||
# 도입 배경:
|
||||
# 기존 `lookup_v4_match_with_fallback(max_rank=3)` hardcoded → rank 4~32 의 등록 frame 도달 못함
|
||||
# mdx05-2 같이 V4 rank 1~9 가 catalog 미등록 + rank 10~ 등록 case → chain_exhausted → unit 생성 X
|
||||
#
|
||||
# 4 round 합의 (IMP-38 #67):
|
||||
# - Codex #1: frame_contracts.yaml 오염 회피 → 별 yaml 파일 (이 파일)
|
||||
# - Codex #2: 3 변수 분리 (configured / judgments / catalog count)
|
||||
# - Codex #3: effective_extended_ceiling = min(configured, len(judgments_full32))
|
||||
#
|
||||
# 적용 path: src/phase_z2_mapper.py 의 load_v4_fallback_policy() loader
|
||||
# + src/phase_z2_pipeline.py 의 lookup_v4_match_with_fallback() 동적 max_rank logic
|
||||
|
||||
policy_type: dynamic_usable_count_based
|
||||
|
||||
# usable_threshold N:
|
||||
# rank 1~default_max_rank 중 "usable" predicate 충족 frame 수 >= N → default_max_rank 유지
|
||||
# < N → extended_max_rank 로 확장
|
||||
usable_threshold: 1
|
||||
|
||||
# default_max_rank:
|
||||
# normal case (usable_count >= threshold) 의 fallback chain 길이
|
||||
# mdx03 같이 rank 1 use_as_is 매칭 잘 되는 case 보호
|
||||
default_max_rank: 3
|
||||
|
||||
# extended_max_rank:
|
||||
# usable_count < threshold case 의 확장 ceiling
|
||||
# mdx05-2 같이 rank 1~9 미등록 case 처리
|
||||
# ★ 실제 effective_extended_ceiling = min(extended_max_rank, len(judgments_full32))
|
||||
# (Codex #2 정정: yaml ceiling 무력화 방지 + V4 schema 범위 초과 방지)
|
||||
extended_max_rank: 32
|
||||
|
||||
# usable predicate (3-tier):
|
||||
# (a) phase_z_status in MVP1_ALLOWED_STATUSES (matched_zone / adapt_matched_zone)
|
||||
# (b) get_contract(template_id) is not None (catalog 등록)
|
||||
# (c) capacity_fit ok (raw_content 제공 시만 — optional)
|
||||
|
||||
# 의미 신뢰 vs catalog presence trade-off:
|
||||
# N=1 = 가장 보수 (rank 1 usable 시 확장 X — mdx03 정상 case 보호)
|
||||
# default_max_rank=3 = 의미 신뢰 범위 (V4 rank 1~3)
|
||||
# extended_max_rank=32 = catalog presence fallback (rank 4~32)
|
||||
|
||||
# graceful fallback (yaml 없을 시):
|
||||
# loader 가 default {default_max_rank: 3, extended_max_rank: 3} 로 fall through (backward compat)
|
||||
@@ -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>
|
||||
@@ -18,11 +18,18 @@ builder/parser 0.
|
||||
- figma_to_html (1171281198) = source/evidence — 386-line index.html + assets/.
|
||||
- Phase Z = runtime — 본 commit adds catalog + partial + smoke fixture.
|
||||
|
||||
PROMOTED — CSS :
|
||||
- 3 column header bg : dark green (`#296B55` family, Figma green theme)
|
||||
- header text white bold + green accent
|
||||
- title gradient (#000 → #883700, F13/F14/F12/F11/F18 zone-title family)
|
||||
- card border + bullet markers (green family)
|
||||
PROMOTED — CSS (verbatim from figma_to_html_agent/blocks/1171281198/index.html) :
|
||||
- 3 column header bg : two-stop vertical adaptation of upstream horizontal
|
||||
banner gradient end-stops — start `rgb(15, 50, 30)` (upstream :54, stop 0%),
|
||||
end `rgb(60, 52, 34)` (upstream :64, stop 100%). Adaptation surface =
|
||||
direction (90deg → 180deg) + stop count (11 → 2); colors verbatim.
|
||||
- header text white bold + green accent (white #fff verbatim, see upstream
|
||||
:202 `color: #ffffff`)
|
||||
- title gradient (#000 → #883700, F13/F14/F12/F11/F18 zone-title family;
|
||||
shared zone-title token, not from this frame)
|
||||
- card border + bullet markers : `#1d4d3e` (upstream :208 `.card-title-1`
|
||||
`-webkit-text-stroke: 1.5px #1d4d3e`). Replaces earlier eyeballed
|
||||
`#296B55` approximation (IMP-49 #78 u1).
|
||||
|
||||
NOT PROMOTED (P1 case-by-case, compact zone fit) :
|
||||
- 상단 dark green banner (Figma 의 큰 visual 영역, MDX 의 *title 만* 핵심)
|
||||
@@ -58,6 +65,12 @@ slots :
|
||||
gap: 6px;
|
||||
font-family: 'Noto Sans KR', 'Pretendard', sans-serif;
|
||||
word-break: keep-all;
|
||||
/* IMP-36 (Gitea #65 u6) P1 root — enable container queries on .f20b.
|
||||
container-type: size unlocks cqh/cqi/cqw + aspect-ratio matching against
|
||||
this element. container-name: f20b-root namespaces the @container rule
|
||||
below (partial-fidelity lock per IMP-49 #78 — no cross-frame borrowing). */
|
||||
container-type: size;
|
||||
container-name: f20b-root;
|
||||
}
|
||||
.f20b__title {
|
||||
font-size: var(--font-zone-title);
|
||||
@@ -80,7 +93,7 @@ slots :
|
||||
}
|
||||
.f20b__col {
|
||||
display: flex; flex-direction: column;
|
||||
border: 2px solid #296B55; /* PROMOTED — green family from Figma */
|
||||
border: 2px solid #1d4d3e; /* PROMOTED — verbatim from upstream :208 (.card-title-1 -webkit-text-stroke) */
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
@@ -89,7 +102,7 @@ slots :
|
||||
|
||||
/* header bar (top of each card, dark green per Figma) */
|
||||
.f20b__header {
|
||||
background: linear-gradient(180deg, #296B55 0%, #123328 100%); /* PROMOTED — Figma green theme */
|
||||
background: linear-gradient(180deg, rgb(15, 50, 30) 0%, rgb(60, 52, 34) 100%); /* PROMOTED — verbatim end-stops from upstream :54 (0%) and :64 (100%); 11-stop horizontal banner adapted to 2-stop vertical card header */
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: var(--font-sub-title);
|
||||
@@ -121,18 +134,47 @@ slots :
|
||||
content: "\2713"; /* ✓ check mark — green theme */
|
||||
position: absolute;
|
||||
left: 0; top: 0;
|
||||
color: #296B55; /* PROMOTED — green family */
|
||||
color: #1d4d3e; /* PROMOTED — verbatim from upstream :208 (.card-title-1 -webkit-text-stroke) */
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* IMP-36 (Gitea #65 u6) P2 body fit — line-height clamp scales with the
|
||||
per-column bullet count via the inline `--max-body-lines` Jinja style on
|
||||
each `.f20b__body`. 60cqh ≈ 3-col card body share of `.f20b` root height
|
||||
(zone-title ~15cqh + card header ~10cqh + remaining ~75cqh, split 60cqh
|
||||
for text lines + reserve for padding/gap). font-size unchanged
|
||||
(guardrail #6 — Stage 2 spec). Fallback 3 = file-header default
|
||||
"body 3-5 bullets per column". */
|
||||
.f20b__body .text-line {
|
||||
line-height: clamp(1.15em, calc(60cqh / var(--max-body-lines, 3)), 1.6em);
|
||||
}
|
||||
|
||||
/* IMP-36 (Gitea #65 u6) P1 rotation rule — when the surrounding zone is
|
||||
narrow (aspect-ratio < 1.5), collapse the 3-column grid to a single
|
||||
column. The threshold matches the IMP-36 Stage 2 canonical (vertical-2 /
|
||||
세로형). Card header / body / bullet styles remain unchanged. */
|
||||
@container f20b-root (aspect-ratio < 1.5) {
|
||||
.f20b__cols { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- IMP-49 #78 u2 — namespace scope note :
|
||||
`.f20b__*` is an AUTHORING-ORDINAL namespace (ordinal "20b" in this catalog's
|
||||
authoring sequence), NOT the Figma frame_id 1171281198. The structural link
|
||||
to the source frame is the `data-frame-id="1171281198"` attribute on the
|
||||
root <div> below. Cross-frame `.fNb__` class reuse is forbidden — class names
|
||||
MUST stay within their owning partial (see [[feedback_partial_figma_audit]]).
|
||||
Selector names and catalog references (frame_contracts.yaml :492,:497,:502)
|
||||
are intentionally unchanged in this unit. -->
|
||||
|
||||
<div class="f20b" data-frame-id="1171281198" data-template-id="dx_sw_necessity_three_perspectives">
|
||||
<div class="f20b__title">{{ slot_payload.title }}</div>
|
||||
<div class="f20b__cols">
|
||||
{# 3 columns — quadrant_flat_slots produces perspective_N_label / perspective_N_body for N=1..3 #}
|
||||
<div class="f20b__col">
|
||||
<div class="f20b__header">{{ slot_payload.perspective_1_label | safe }}</div>
|
||||
<div class="f20b__body">
|
||||
{# IMP-36 (Gitea #65 u6) P2 — inline `--max-body-lines` per column; code composes (Phase Z guardrail #7), defensive fallback to 3 matches the CSS default. #}
|
||||
<div class="f20b__body" style="--max-body-lines: {{ (slot_payload.perspective_1_body | length) if slot_payload.perspective_1_body else 3 }};">
|
||||
{% if slot_payload.perspective_1_body %}
|
||||
{% for line in slot_payload.perspective_1_body %}<div class="text-line text-line--bullet{% if line.indent > 0 %} text-line--indent-{{ line.indent }}{% endif %}">{{ line.text | safe }}</div>{% endfor %}
|
||||
{% endif %}
|
||||
@@ -140,7 +182,7 @@ slots :
|
||||
</div>
|
||||
<div class="f20b__col">
|
||||
<div class="f20b__header">{{ slot_payload.perspective_2_label | safe }}</div>
|
||||
<div class="f20b__body">
|
||||
<div class="f20b__body" style="--max-body-lines: {{ (slot_payload.perspective_2_body | length) if slot_payload.perspective_2_body else 3 }};">
|
||||
{% if slot_payload.perspective_2_body %}
|
||||
{% for line in slot_payload.perspective_2_body %}<div class="text-line text-line--bullet{% if line.indent > 0 %} text-line--indent-{{ line.indent }}{% endif %}">{{ line.text | safe }}</div>{% endfor %}
|
||||
{% endif %}
|
||||
@@ -148,7 +190,7 @@ slots :
|
||||
</div>
|
||||
<div class="f20b__col">
|
||||
<div class="f20b__header">{{ slot_payload.perspective_3_label | safe }}</div>
|
||||
<div class="f20b__body">
|
||||
<div class="f20b__body" style="--max-body-lines: {{ (slot_payload.perspective_3_body | length) if slot_payload.perspective_3_body else 3 }};">
|
||||
{% if slot_payload.perspective_3_body %}
|
||||
{% for line in slot_payload.perspective_3_body %}<div class="text-line text-line--bullet{% if line.indent > 0 %} text-line--indent-{{ line.indent }}{% endif %}">{{ line.text | safe }}</div>{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
@@ -48,6 +48,12 @@ slots : title, section_1/2/3_label, section_1/2/3_body
|
||||
gap: 6px;
|
||||
font-family: 'Noto Sans KR', 'Pretendard', sans-serif;
|
||||
word-break: keep-all;
|
||||
/* IMP-36 (Gitea #65 u7) P1 — container-type: size unlocks cqh/cqi/cqw +
|
||||
aspect-ratio measurement on .f8b. container-name: f8b-root namespaces
|
||||
the rotation rule below (IMP-49 partial-fidelity lock — no cross-frame
|
||||
.fNb__ class borrowing). */
|
||||
container-type: size;
|
||||
container-name: f8b-root;
|
||||
}
|
||||
.f8b__title {
|
||||
font-size: var(--font-zone-title);
|
||||
@@ -110,6 +116,16 @@ slots : title, section_1/2/3_label, section_1/2/3_body
|
||||
position: relative;
|
||||
padding-left: 14px;
|
||||
}
|
||||
/* IMP-36 (Gitea #65 u7) P2 body fit — line-height cqh/clamp against the
|
||||
bullet count rendered per column. font-size 미변경 (사용자 룰 + Stage 2
|
||||
guardrail #6). 60cqh = approx body region share of .f8b after title +
|
||||
section header (title ≈ 15cqh + per-col header ≈ 12cqh → body ≈ 60cqh).
|
||||
fallback = 4 (file header L42 watch threshold "body 5+ bullets per
|
||||
column" → typical < 5). Additive cascade override; does not mutate the
|
||||
pre-existing .f8b__body .text-line block above. */
|
||||
.f8b__body .text-line {
|
||||
line-height: clamp(1.15em, calc(60cqh / var(--max-body-lines, 4)), 1.6em);
|
||||
}
|
||||
.f8b__body .text-line--bullet::before {
|
||||
content: "•";
|
||||
position: absolute;
|
||||
@@ -119,6 +135,14 @@ slots : title, section_1/2/3_label, section_1/2/3_body
|
||||
.f8b__col:nth-child(1) .f8b__body .text-line--bullet::before { color: #2563eb; }
|
||||
.f8b__col:nth-child(2) .f8b__body .text-line--bullet::before { color: #ea580c; }
|
||||
.f8b__col:nth-child(3) .f8b__body .text-line--bullet::before { color: #16a34a; }
|
||||
|
||||
/* IMP-36 (Gitea #65 u7) P1 rotation rule — when zone aspect-ratio narrows
|
||||
below 1.5 (vertical-2 narrow / 임의 세로형 geometry), flip the 3-column
|
||||
grid to single column. Card header / body / bullet styles unchanged —
|
||||
only grid-template-columns flips from 1fr 1fr 1fr to 1fr. */
|
||||
@container f8b-root (aspect-ratio < 1.5) {
|
||||
.f8b__cols { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="f8b" data-frame-id="1171281179" data-template-id="info_management_what_how_when">
|
||||
@@ -126,7 +150,7 @@ slots : title, section_1/2/3_label, section_1/2/3_body
|
||||
<div class="f8b__cols">
|
||||
<div class="f8b__col">
|
||||
<div class="f8b__header">{{ slot_payload.section_1_label | safe }}</div>
|
||||
<div class="f8b__body">
|
||||
<div class="f8b__body" style="--max-body-lines: {{ (slot_payload.section_1_body | length) if slot_payload.section_1_body else 4 }};">
|
||||
{% if slot_payload.section_1_body %}
|
||||
{% for line in slot_payload.section_1_body %}<div class="text-line text-line--bullet{% if line.indent > 0 %} text-line--indent-{{ line.indent }}{% endif %}">{{ line.text | safe }}</div>{% endfor %}
|
||||
{% endif %}
|
||||
@@ -134,7 +158,7 @@ slots : title, section_1/2/3_label, section_1/2/3_body
|
||||
</div>
|
||||
<div class="f8b__col">
|
||||
<div class="f8b__header">{{ slot_payload.section_2_label | safe }}</div>
|
||||
<div class="f8b__body">
|
||||
<div class="f8b__body" style="--max-body-lines: {{ (slot_payload.section_2_body | length) if slot_payload.section_2_body else 4 }};">
|
||||
{% if slot_payload.section_2_body %}
|
||||
{% for line in slot_payload.section_2_body %}<div class="text-line text-line--bullet{% if line.indent > 0 %} text-line--indent-{{ line.indent }}{% endif %}">{{ line.text | safe }}</div>{% endfor %}
|
||||
{% endif %}
|
||||
@@ -142,7 +166,7 @@ slots : title, section_1/2/3_label, section_1/2/3_body
|
||||
</div>
|
||||
<div class="f8b__col">
|
||||
<div class="f8b__header">{{ slot_payload.section_3_label | safe }}</div>
|
||||
<div class="f8b__body">
|
||||
<div class="f8b__body" style="--max-body-lines: {{ (slot_payload.section_3_body | length) if slot_payload.section_3_body else 4 }};">
|
||||
{% if slot_payload.section_3_body %}
|
||||
{% for line in slot_payload.section_3_body %}<div class="text-line text-line--bullet{% if line.indent > 0 %} text-line--indent-{{ line.indent }}{% endif %}">{{ line.text | safe }}</div>{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
@@ -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>
|
||||
@@ -34,6 +34,12 @@ slots: title, pillars[].{label, color_class, sections[].{heading, bullets[]}}
|
||||
gap: 4px;
|
||||
font-family: 'Noto Sans KR', 'Pretendard', sans-serif;
|
||||
word-break: keep-all;
|
||||
/* IMP-36 (Gitea #65 u4) P1 — partial-side container query root.
|
||||
container-type: size 로 aspect-ratio 측정 가능 (cqh / cqi / cqw 도
|
||||
동일 root 기준). container-name: f13b-root 는 frame_contracts.yaml
|
||||
rotation_eligible: true 와 짝. */
|
||||
container-type: size;
|
||||
container-name: f13b-root;
|
||||
}
|
||||
.f13b__title {
|
||||
font-size: var(--font-zone-title);
|
||||
@@ -126,6 +132,22 @@ slots: title, pillars[].{label, color_class, sections[].{heading, bullets[]}}
|
||||
}
|
||||
/* desc 안 .text-line 색 override */
|
||||
.f13b__desc .text-line { color: #3E3523; }
|
||||
|
||||
/* IMP-36 (Gitea #65 u4) P2 — body fit via cqh + clamp + --max-body-lines.
|
||||
section 의 text_lines 개수가 늘면 line-height 가 비례로 축소. font-size
|
||||
미변경 (사용자 룰). --max-body-lines fallback = 4 (section 평균 줄 수).
|
||||
20cqh = 한 section 이 차지하는 .f13b 컨테이너 비율 근사치 (3 section /
|
||||
col, body 영역 ≈ 80cqh → 25cqh/section 중 line 영역 ≈ 20cqh). */
|
||||
.f13b__desc {
|
||||
line-height: clamp(1.2em, calc(20cqh / var(--max-body-lines, 4)), 1.6em);
|
||||
}
|
||||
|
||||
/* IMP-36 (Gitea #65 u4) P1 — aspect-ratio < 1.5 rotation rule. zone 의
|
||||
가로:세로 비가 1.5 미만으로 좁아지면 (vertical-2 narrow / 또는 임의
|
||||
세로형 geometry) 3-col grid 가 1-col stack 으로 회전. */
|
||||
@container f13b-root (aspect-ratio < 1.5) {
|
||||
.f13b__cols { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="f13b" data-frame-id="1171281190" data-template-id="three_parallel_requirements">
|
||||
@@ -144,7 +166,7 @@ slots: title, pillars[].{label, color_class, sections[].{heading, bullets[]}}
|
||||
<div class="f13b__section">
|
||||
<div class="f13b__heading">{{ section.heading | safe }}</div>
|
||||
{% if section.text_lines %}
|
||||
<div class="f13b__desc">
|
||||
<div class="f13b__desc" style="--max-body-lines: {{ section.text_lines | length }};">
|
||||
{% for line in section.text_lines %}<div class="text-line text-line--bullet{% if line.indent > 0 %} text-line--indent-{{ line.indent }}{% endif %}">{{ line.text | safe }}</div>{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -70,6 +70,13 @@ Asset path runtime resolution :
|
||||
gap: 6px;
|
||||
font-family: 'Noto Sans KR', 'Pretendard', sans-serif;
|
||||
word-break: keep-all;
|
||||
/* IMP-36 (Gitea #65 u5) P1 — partial-side container query root.
|
||||
container-type: size 로 aspect-ratio 측정 가능 (cqh / cqi / cqw 도
|
||||
동일 root 기준). container-name: f14b-root 는 frame_contracts.yaml
|
||||
rotation_eligible: true 와 짝. Circle badge (.f14b__badge aspect-ratio
|
||||
1/1) 는 별도 element — 본 root 의 aspect-ratio 측정 대상 아님. */
|
||||
container-type: size;
|
||||
container-name: f14b-root;
|
||||
}
|
||||
.f14b__title {
|
||||
font-size: var(--font-zone-title);
|
||||
@@ -176,6 +183,22 @@ Asset path runtime resolution :
|
||||
position: relative;
|
||||
padding-left: 14px;
|
||||
}
|
||||
/* IMP-36 (Gitea #65 u5) P2 — body fit via cqh + clamp + --max-body-lines.
|
||||
persona.body 의 bullet 개수가 늘면 line-height 가 비례로 축소. font-size
|
||||
미변경 (사용자 룰). --max-body-lines fallback = 7 (Figma 원본 frame 평균
|
||||
bullet 수, file header L8 참조). 60cqh = .f14b__body 영역 비율 근사치
|
||||
(title ≈ 15cqh + badge ≈ 18cqh + photo ≈ 7cqh → body ≈ 60cqh). 본 clamp
|
||||
은 .text-line 의 var(--lh-body) 를 override (cascade 우선순위). */
|
||||
.f14b__body .text-line {
|
||||
line-height: clamp(1.15em, calc(60cqh / var(--max-body-lines, 7)), 1.6em);
|
||||
}
|
||||
/* IMP-36 (Gitea #65 u5) P1 — aspect-ratio < 1.5 rotation rule. zone 의
|
||||
가로:세로 비가 1.5 미만으로 좁아지면 3-col grid 가 1-col stack 으로
|
||||
회전. Circle badge (.f14b__badge aspect-ratio 1/1) 는 col 내부 element
|
||||
이므로 회전 후에도 원형 유지. */
|
||||
@container f14b-root (aspect-ratio < 1.5) {
|
||||
.f14b__cols { grid-template-columns: 1fr; }
|
||||
}
|
||||
.f14b__body .text-line--bullet::before {
|
||||
content: "\2713";
|
||||
position: absolute;
|
||||
@@ -226,7 +249,7 @@ Asset path runtime resolution :
|
||||
</div>
|
||||
|
||||
{# body — bullets with CSS check marker #}
|
||||
<div class="f14b__body">
|
||||
<div class="f14b__body" style="--max-body-lines: {{ (persona.body | length) if persona.body else 7 }};">
|
||||
{% if persona.body %}
|
||||
{% for line in persona.body %}<div class="text-line text-line--bullet{% if line.indent > 0 %} text-line--indent-{{ line.indent }}{% endif %}">{{ line.text | safe }}</div>{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
@@ -20,6 +20,16 @@
|
||||
# applies_to: list[str] (content types that can use this strategy)
|
||||
# forbidden_for: list[str] (content types that MUST NOT use this strategy)
|
||||
# preserves_original: bool (true = original content kept somewhere — popup/detail)
|
||||
# preview_chars: int | null (IMP-35 u9 — soft char budget for the inline body
|
||||
# shown alongside the popup trigger; null when the
|
||||
# strategy has no popup. The popup body itself
|
||||
# ALWAYS holds the FULL original — preview_chars
|
||||
# governs only the inline preview/summary surface.)
|
||||
# popup_target_slot: str | null
|
||||
# (IMP-35 u9 — frame Layer B slot identifier the
|
||||
# popup trigger anchors to. null when the strategy
|
||||
# has no popup. See CLAUDE.md "위계 + 용어" →
|
||||
# "Frame Slot" / "Layer B" for the slot vocabulary.)
|
||||
|
||||
|
||||
inline_full:
|
||||
@@ -27,6 +37,9 @@ inline_full:
|
||||
applies_to: [text_block, table, image, details, decorative_element]
|
||||
forbidden_for: []
|
||||
preserves_original: true # all content is inline, original = inline
|
||||
# IMP-35 u9 — inline_full has no popup → both popup-wiring fields are null.
|
||||
preview_chars: null
|
||||
popup_target_slot: null
|
||||
|
||||
|
||||
inline_preview_with_details:
|
||||
@@ -34,6 +47,9 @@ inline_preview_with_details:
|
||||
applies_to: [text_block, table, details]
|
||||
forbidden_for: [decorative_element]
|
||||
preserves_original: true # User lock — original content kept in popup
|
||||
# IMP-35 u9 — partial preview body inline; popup body holds FULL original.
|
||||
preview_chars: 240
|
||||
popup_target_slot: primary
|
||||
detail_trigger:
|
||||
placement: top-right # 본문 흐름 방해 X / 보조 동작 위치 / 안정 (user 2026-05-07)
|
||||
label: details # identifier — display text 는 partial/UI 별 axis
|
||||
@@ -45,6 +61,11 @@ details_only:
|
||||
applies_to: [text_block, table, details]
|
||||
forbidden_for: [decorative_element]
|
||||
preserves_original: true # User lock — full content in popup
|
||||
# IMP-35 u9 — summary-only inline surface (smaller char budget); popup body
|
||||
# holds FULL original. preview_chars > 0 because details_only still emits a
|
||||
# short summary line — it is NOT a "no body" surface (that is `dropped`).
|
||||
preview_chars: 80
|
||||
popup_target_slot: primary
|
||||
detail_trigger:
|
||||
placement: top-right # user lock — popup 진입 일관 위치
|
||||
label: details
|
||||
@@ -60,3 +81,6 @@ dropped:
|
||||
applies_to: [decorative_element]
|
||||
forbidden_for: [text_block, table, image, details]
|
||||
preserves_original: false # decorative only — no original to preserve
|
||||
# IMP-35 u9 — dropped has no popup and no body surface → both fields null.
|
||||
preview_chars: null
|
||||
popup_target_slot: null
|
||||
|
||||
@@ -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 강화.
|
||||
@@ -290,6 +258,103 @@
|
||||
font-family: monospace;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
/* ── IMP-35 u8 : popup details/summary (Step 17 POPUP gate escalation) ──
|
||||
When the Step 17 POPUP gate escalates a unit (zone.has_popup=True),
|
||||
slide_base renders a JS-free <details>/<summary> wrapper in the zone.
|
||||
The body of the frame stays as zone.partial_html (the FIT-version of
|
||||
content); the popup body holds the FULL original raw_content (MDX 원문
|
||||
무손실 보존 — 오답노트 #5 / IMPROVEMENT-REDESIGN.md §3.6 line 110).
|
||||
Placement (default top-right) is read from
|
||||
zone.popup_binding.detail_trigger.placement
|
||||
(templates/phase_z2/regions/display_strategies.yaml). HTML-native
|
||||
<details> per CLAUDE.md 자세히보기 contract — no JavaScript. */
|
||||
.zone__popup-details {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
font-family: 'Pretendard', sans-serif;
|
||||
}
|
||||
.zone__popup-details--top-right {
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
}
|
||||
.zone__popup-details--top-left {
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
}
|
||||
.zone__popup-details--bottom-right {
|
||||
bottom: 4px;
|
||||
right: 4px;
|
||||
}
|
||||
.zone__popup-details--bottom-left {
|
||||
bottom: 4px;
|
||||
left: 4px;
|
||||
}
|
||||
.zone__popup-summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
background: rgba(30, 41, 59, 0.85);
|
||||
color: #fff;
|
||||
border-radius: 2px;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1.2;
|
||||
user-select: none;
|
||||
}
|
||||
.zone__popup-summary::-webkit-details-marker { display: none; }
|
||||
.zone__popup-summary::marker { content: ""; }
|
||||
.zone__popup-body {
|
||||
position: absolute;
|
||||
top: 22px;
|
||||
right: 0;
|
||||
width: 360px;
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
padding: 8px 10px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--color-border, #e2e8f0);
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||
white-space: pre-wrap;
|
||||
word-break: keep-all;
|
||||
font-size: 10px;
|
||||
line-height: 1.5;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
/* ── 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>
|
||||
@@ -301,9 +366,18 @@
|
||||
<div class="slide-body">
|
||||
<div class="layout-{{ layout_preset }}">
|
||||
{% for zone in zones %}
|
||||
<div class="zone{% if zone.provisional %} zone--provisional{% endif %}" data-zone-position="{{ zone.position }}" data-template-id="{{ zone.template_id }}"{% if zone.provisional %} data-provisional="1"{% endif %} style="grid-area: {{ zone.position }};">
|
||||
{% 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 {} %}
|
||||
{% set _popup_placement = _popup_trigger.placement or 'top-right' %}
|
||||
{% set _popup_label = _popup_trigger.label or 'details' %}
|
||||
{% set _popup_strategy = (zone.popup_binding.display_strategy if zone.popup_binding else 'inline_preview_with_details') %}
|
||||
<details class="zone__popup-details zone__popup-details--{{ _popup_placement }}" data-display-strategy="{{ _popup_strategy }}" data-popup-placement="{{ _popup_placement }}">
|
||||
<summary class="zone__popup-summary">{{ _popup_label }}</summary>
|
||||
<div class="zone__popup-body">{{ zone.popup_html }}</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -314,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,112 @@
|
||||
"""IMP-#85 u7 — pytest env isolation for src.config defaults.
|
||||
|
||||
This conftest.py runs BEFORE any test module is imported by pytest.
|
||||
Setting ``os.environ["AI_FALLBACK_*"]`` here overrides values that the
|
||||
live ``.env`` file would otherwise inject through ``pydantic-settings``
|
||||
(priority: init args > os.environ > env_file). The ``src.config``
|
||||
module-level ``settings = Settings()`` singleton is therefore built
|
||||
against the test-clean environment when src.config is first imported
|
||||
during test collection.
|
||||
|
||||
Scope (per Stage 2 plan u7):
|
||||
* Restore the default-OFF contract for ``ai_fallback_enabled`` so
|
||||
``tests/test_phase_z2_ai_fallback_config.py`` and
|
||||
``tests/test_imp47b_step12_ai_wiring.py`` (which lock the
|
||||
flag-off short-circuit) match the source-of-truth default in
|
||||
``src/config.py``.
|
||||
* Restore the default-OFF contract for ``ai_fallback_auto_cache``.
|
||||
|
||||
Out of scope:
|
||||
* Touching ``ANTHROPIC_API_KEY`` / ``KEI_API_URL`` / ``LOG_LEVEL``.
|
||||
* Resetting the ``src.config.settings`` singleton mid-session.
|
||||
Tests that need to flip ``settings.ai_fallback_enabled`` at
|
||||
runtime mutate the singleton directly (mirrors the production
|
||||
``--auto-cache`` CLI path in ``src/phase_z2_pipeline.py``).
|
||||
|
||||
IMP-35 baseline-red invariance carve-out
|
||||
========================================
|
||||
The IMP-35 baseline-red invariance gate at
|
||||
``tests/phase_z2/test_imp35_baseline_red_invariance.py`` spawns a child
|
||||
pytest subprocess that targets ONLY the two baseline-area files:
|
||||
|
||||
tests/test_imp47b_step12_ai_wiring.py
|
||||
tests/test_phase_z2_ai_fallback_config.py
|
||||
|
||||
That gate's binding contract (Stage 2 u11 lock) is that those four
|
||||
registered known-red tests STAY RED until a follow-up issue
|
||||
deregisters them. If this conftest blindly forces
|
||||
``AI_FALLBACK_ENABLED=false`` in the gate's subprocess, the
|
||||
``test_ai_fallback_master_flag_default_off`` registered red flips
|
||||
green and the invariance gate trips — a real cross-issue contract
|
||||
conflict (see Codex #8 Stage 3 verification of IMP-#85 u7).
|
||||
|
||||
The carve-out below detects that exact subprocess signature
|
||||
(positional ``.py`` targets are entirely baseline-area files) and
|
||||
skips env isolation, leaving the gate's child process in its native
|
||||
``.env``-loaded state. Every other pytest invocation — full-suite
|
||||
``pytest -q tests``, the IMP-#85 smoke targets, single-file dev runs
|
||||
on non-baseline files — still gets the default-OFF isolation.
|
||||
|
||||
Per ``feedback_demo_env_toggle_policy``: demo activation belongs in
|
||||
``.env`` only. The override below is test-scoped (lives under
|
||||
``tests/``) and never propagates into ``src/`` or ``vite.config``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# File suffixes (basenames) of the IMP-35 baseline-red area files.
|
||||
# The IMP-35 gate spawns its subprocess with these as the sole positional
|
||||
# pytest targets. Suffix matching is used so the detection is robust
|
||||
# across Windows/POSIX path separators and absolute/relative cwd.
|
||||
_IMP35_BASELINE_AREA_FILE_SUFFIXES: tuple[str, ...] = (
|
||||
"test_imp47b_step12_ai_wiring.py",
|
||||
"test_phase_z2_ai_fallback_config.py",
|
||||
)
|
||||
|
||||
|
||||
def _is_imp35_baseline_subprocess() -> bool:
|
||||
"""True iff the current pytest argv targets ONLY IMP-35 baseline-area files.
|
||||
|
||||
The IMP-35 baseline-red invariance gate
|
||||
(``tests/phase_z2/test_imp35_baseline_red_invariance.py``) runs:
|
||||
|
||||
python -m pytest -q --tb=no -p no:cacheprovider \\
|
||||
tests/test_imp47b_step12_ai_wiring.py \\
|
||||
tests/test_phase_z2_ai_fallback_config.py
|
||||
|
||||
The two trailing positional ``.py`` arguments are the signature.
|
||||
We compare on basename suffix so the check is path-separator and
|
||||
cwd agnostic.
|
||||
|
||||
Returning True here suppresses the ``AI_FALLBACK_*`` env override
|
||||
so the baseline-red registry contract (Stage 2 u11 lock) holds for
|
||||
the gate's child process while every other invocation
|
||||
(full-suite, IMP-#85 smokes, mixed-target dev runs) still gets the
|
||||
default-OFF isolation.
|
||||
"""
|
||||
file_targets = [arg for arg in sys.argv[1:] if arg.endswith(".py")]
|
||||
if not file_targets:
|
||||
return False
|
||||
return all(
|
||||
any(
|
||||
arg.replace("\\", "/").endswith(suffix)
|
||||
for suffix in _IMP35_BASELINE_AREA_FILE_SUFFIXES
|
||||
)
|
||||
for arg in file_targets
|
||||
)
|
||||
|
||||
|
||||
if _is_imp35_baseline_subprocess():
|
||||
# Drop any inherited AI_FALLBACK_* values so the gate's child process
|
||||
# falls back to the live ``.env`` (AI_FALLBACK_ENABLED=true) — the
|
||||
# exact precondition under which the four registered baseline-red
|
||||
# tests are red. ``pop`` is no-op when the key is absent, so a
|
||||
# developer running the gate manually with a clean environment is
|
||||
# unaffected.
|
||||
os.environ.pop("AI_FALLBACK_ENABLED", None)
|
||||
os.environ.pop("AI_FALLBACK_AUTO_CACHE", None)
|
||||
else:
|
||||
os.environ["AI_FALLBACK_ENABLED"] = "false"
|
||||
os.environ["AI_FALLBACK_AUTO_CACHE"] = "false"
|
||||
@@ -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)"
|
||||
)
|
||||
@@ -2,11 +2,27 @@
|
||||
|
||||
Stage 1 finding: line 564 previously referenced a non-existent ID ("IMP-31").
|
||||
The legitimate slot is IMP-17 (Gitea #17, carve-out — AI fallback only, normal path 밖).
|
||||
Line 565 (IMP-29 frontend zone-level override) must remain untouched.
|
||||
The reject anchor previously referenced IMP-29 (frontend zone-level override); it has
|
||||
since been superseded by IMP-47B u1 (2026-05-21) which corrects the reject disposition
|
||||
to AI re-construction over the rank-1 reject frame.
|
||||
|
||||
Anchor re-pin (2026-05-20, IMP-30 u1 follow-up): V4Match.provisional field added at
|
||||
src/phase_z2_pipeline.py:179-184 shifted the route-hint table down by six lines.
|
||||
Pinned line numbers updated from 564/565 → 570/571 to track the actual anchor location.
|
||||
Pinned line numbers were updated 564/565 → 570/571.
|
||||
|
||||
Anchor re-pin (2026-05-22, IMP-36 u1 / Gitea #65 Stage 2): IMP-47B supersession at
|
||||
src/phase_z2_pipeline.py:579-582 expanded the reject hint comment by four lines, which
|
||||
shifted only the post-comment table downward. The restructure anchor itself moved from
|
||||
570 → 578 because additional comment context was inserted between the table header and
|
||||
the restructure line. Re-pinned 570 → 578 (restructure / IMP-17) and 571 → 579
|
||||
(reject / IMP-47B supersession of the prior IMP-29 reference).
|
||||
|
||||
Anchor re-pin (2026-05-23, IMP-35 u1/u5/u7 / Gitea #64 Stage 3): IMP-35 added a
|
||||
single-line ``compose_zone_popup_payload`` import (u7) plus a 7-line
|
||||
``run_step17_popup_gate`` import block (u5) ahead of the route-hint table, totaling
|
||||
+8 lines of pre-anchor additions. The post-import body shifted uniformly downward;
|
||||
the restructure anchor moved 578 → 586 and the reject anchor moved 579 → 587.
|
||||
Re-pinned 578 → 586 (restructure / IMP-17) and 579 → 587 (reject / IMP-47B).
|
||||
|
||||
Run: pytest -q tests/orchestrator_unit/test_imp17_comment_anchor.py
|
||||
"""
|
||||
@@ -20,14 +36,17 @@ def _lines() -> list[str]:
|
||||
return PIPELINE.read_text(encoding="utf-8").splitlines()
|
||||
|
||||
|
||||
def test_line_570_references_imp17_not_imp31():
|
||||
line = _lines()[569] # 1-indexed line 570
|
||||
assert "restructure" in line, f"line 570 anchor drifted: {line!r}"
|
||||
assert "IMP-17" in line, f"line 570 must reference IMP-17 (carve-out): {line!r}"
|
||||
assert "IMP-31" not in line, f"line 570 must not reference non-existent IMP-31: {line!r}"
|
||||
def test_line_586_references_imp17_not_imp31():
|
||||
line = _lines()[585] # 1-indexed line 586
|
||||
assert "restructure" in line, f"line 586 anchor drifted: {line!r}"
|
||||
assert "IMP-17" in line, f"line 586 must reference IMP-17 (carve-out): {line!r}"
|
||||
assert "IMP-31" not in line, f"line 586 must not reference non-existent IMP-31: {line!r}"
|
||||
|
||||
|
||||
def test_line_571_still_references_imp29():
|
||||
line = _lines()[570] # 1-indexed line 571
|
||||
assert "reject" in line, f"line 571 anchor drifted: {line!r}"
|
||||
assert "IMP-29" in line, f"line 571 must still reference IMP-29 frontend override: {line!r}"
|
||||
def test_line_587_references_imp47b_supersession():
|
||||
line = _lines()[586] # 1-indexed line 587
|
||||
assert "reject" in line, f"line 587 anchor drifted: {line!r}"
|
||||
assert "IMP-47B" in line, (
|
||||
f"line 587 must reference IMP-47B (supersedes prior IMP-29 reject disposition): "
|
||||
f"{line!r}"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# IMP-#85 u5 fixture — non-VP contract whose payload.builder is absent from
|
||||
# `PAYLOAD_BUILDERS`. Drives the u2 boot invariant + audit I3 negative paths.
|
||||
#
|
||||
# Scope (Stage 2 lock): regression coverage only. Not a runtime catalog entry.
|
||||
# Frame id is in the 9999xxx range so any accidental cross-reference is obvious.
|
||||
|
||||
imp85_u5_missing_builder_frame:
|
||||
template_id: imp85_u5_missing_builder_frame
|
||||
frame_id: 9999001
|
||||
family: imp85_u5_fixture
|
||||
source_shape: top_bullets
|
||||
cardinality:
|
||||
strict: 3
|
||||
payload:
|
||||
title:
|
||||
source: section.title
|
||||
builder: definitely_not_a_registered_builder_imp85_u5
|
||||
@@ -0,0 +1,23 @@
|
||||
# IMP-#85 u5 fixture — non-VP contract whose `items_with_role` builder produces
|
||||
# a `slot_payload.<array_root>` key the partial never references. Drives the
|
||||
# audit I4 (generated-key-orphan) negative path.
|
||||
#
|
||||
# Scope (Stage 2 lock): regression coverage only. The corresponding partial is
|
||||
# written into a tmp dir by the test (it must NOT use `slot_payload[...]`
|
||||
# bracket access, otherwise I4 suppresses correctly and the assertion fails).
|
||||
|
||||
imp85_u5_undeclared_slot_frame:
|
||||
template_id: imp85_u5_undeclared_slot_frame
|
||||
frame_id: 9999002
|
||||
family: imp85_u5_fixture
|
||||
source_shape: top_bullets
|
||||
cardinality:
|
||||
strict: 3
|
||||
payload:
|
||||
title:
|
||||
source: section.title
|
||||
builder: items_with_role
|
||||
builder_options:
|
||||
item_parser: pillar_item
|
||||
array_root: orphan_array_root_imp85_u5
|
||||
role_field: color_class
|
||||
@@ -0,0 +1,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
|
||||
@@ -0,0 +1,157 @@
|
||||
"""IMP-89 89-a u3 — BLOCKED exit unit tests for Layer A render path.
|
||||
|
||||
Stage 2 plan (u3): when PHASE_Z_B4_MAPPER_SOURCE=ON and the Layer A render
|
||||
path cannot resolve a covering frame, the runtime MUST sys.exit(1) instead of
|
||||
silently degrading to adapter_needed or to the legacy V4 rank-1 mapper input.
|
||||
|
||||
Locked semantics (Stage 1 Q2 lock; IMP-87 honesty gate pattern):
|
||||
flag OFF → legacy adapter_needed path
|
||||
(silent fallback preserved)
|
||||
flag ON + B4 no-cover → BLOCKED (sys.exit 1)
|
||||
flag ON + FitError on B4-selected → BLOCKED (sys.exit 1)
|
||||
flag ON + matches_mapper + FitError → BLOCKED (explicit no-silent
|
||||
fallback even when V4 rank-1
|
||||
equals B4 pick)
|
||||
|
||||
These tests target the `_b4_mapper_source_blocked_exit()` helper directly
|
||||
plus contract-level assertions of its stderr output. The runtime call-sites
|
||||
inside `run_phase_z2_mvp1` are guarded by `_b4_mapper_source_enabled()`
|
||||
checks; u3 changes ZERO behavior under the default-OFF path.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_pipeline import (
|
||||
_b4_mapper_source_blocked_exit,
|
||||
_b4_mapper_source_enabled,
|
||||
)
|
||||
|
||||
FLAG = "PHASE_Z_B4_MAPPER_SOURCE"
|
||||
|
||||
|
||||
def test_blocked_exit_no_cover_exits_with_code_1(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""b4_no_cover reason → SystemExit(1), no silent fallback."""
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
_b4_mapper_source_blocked_exit(
|
||||
"b4_no_cover",
|
||||
position="top",
|
||||
context={
|
||||
"unit": "source_section_ids=['01-1'] merge_type=raw",
|
||||
"v4_rank1": "F13",
|
||||
"b4_pick": None,
|
||||
},
|
||||
)
|
||||
assert exc.value.code == 1
|
||||
|
||||
|
||||
def test_blocked_exit_fit_error_exits_with_code_1(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""b4_selected_fit_error reason → SystemExit(1)."""
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
_b4_mapper_source_blocked_exit(
|
||||
"b4_selected_fit_error",
|
||||
position="bottom_l",
|
||||
context={
|
||||
"template": "F29 (B4 selected)",
|
||||
"unit": "source_section_ids=['02-2']",
|
||||
"v4_rank1": "F13",
|
||||
"fit_error": "slot 'title' missing",
|
||||
},
|
||||
)
|
||||
assert exc.value.code == 1
|
||||
|
||||
|
||||
def test_blocked_exit_stderr_carries_reason_and_position(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""Header line surfaces the locked reason enum + zone position."""
|
||||
with pytest.raises(SystemExit):
|
||||
_b4_mapper_source_blocked_exit(
|
||||
"b4_no_cover",
|
||||
position="bottom_r",
|
||||
context={"v4_rank1": "F13"},
|
||||
)
|
||||
err = capsys.readouterr().err
|
||||
assert "[Phase Z-2 IMP-89 89-a u3] BLOCKED" in err
|
||||
assert "b4_no_cover" in err
|
||||
assert "zone--bottom_r" in err
|
||||
|
||||
|
||||
def test_blocked_exit_stderr_carries_honesty_policy_line(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""Policy banner names PHASE_Z_B4_MAPPER_SOURCE + IMP-87 honesty pattern."""
|
||||
with pytest.raises(SystemExit):
|
||||
_b4_mapper_source_blocked_exit(
|
||||
"b4_selected_fit_error",
|
||||
position="top",
|
||||
context={"fit_error": "x"},
|
||||
)
|
||||
err = capsys.readouterr().err
|
||||
assert "PHASE_Z_B4_MAPPER_SOURCE=ON" in err
|
||||
assert "NO silent fallback" in err
|
||||
assert "IMP-87 honesty gate pattern" in err
|
||||
|
||||
|
||||
def test_blocked_exit_stderr_carries_all_context_fields(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""Each context dict entry surfaces on its own stderr line."""
|
||||
with pytest.raises(SystemExit):
|
||||
_b4_mapper_source_blocked_exit(
|
||||
"b4_selected_fit_error",
|
||||
position="top",
|
||||
context={
|
||||
"template": "F29 (B4 selected)",
|
||||
"unit": "source_section_ids=['02-2']",
|
||||
"v4_rank1": "F13",
|
||||
"fit_error": "slot 'title' missing",
|
||||
},
|
||||
)
|
||||
err = capsys.readouterr().err
|
||||
assert "template" in err
|
||||
assert "F29 (B4 selected)" in err
|
||||
assert "unit" in err
|
||||
assert "source_section_ids=['02-2']" in err
|
||||
assert "v4_rank1" in err
|
||||
assert "F13" in err
|
||||
assert "fit_error" in err
|
||||
assert "slot 'title' missing" in err
|
||||
|
||||
|
||||
def test_blocked_exit_ignores_flag_state(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""Helper is unconditional — flag-gating is the call-site's responsibility.
|
||||
|
||||
The runtime checks `_b4_mapper_source_enabled()` BEFORE invoking this
|
||||
helper, so once invoked the helper always exits. This keeps the helper
|
||||
behavior orthogonal to env state and makes the call-sites the
|
||||
single-source-of-truth for ON/OFF policy.
|
||||
"""
|
||||
monkeypatch.delenv(FLAG, raising=False)
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
_b4_mapper_source_blocked_exit(
|
||||
"b4_no_cover",
|
||||
position="top",
|
||||
context={"v4_rank1": "F13"},
|
||||
)
|
||||
assert exc.value.code == 1
|
||||
|
||||
|
||||
def test_default_off_flag_state_does_not_invoke_blocked_helper(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Under default-OFF, `_b4_mapper_source_enabled()` is False, which is
|
||||
the precondition the runtime checks before calling the helper. This test
|
||||
locks the contract that the flag reader returns False by default — any
|
||||
accidental flip would break the byte-identity guarantee of the legacy
|
||||
adapter_needed path.
|
||||
"""
|
||||
monkeypatch.delenv(FLAG, raising=False)
|
||||
assert _b4_mapper_source_enabled() is False
|
||||
@@ -0,0 +1,426 @@
|
||||
"""IMP-89 89-a u5 — slot_payload byte-equivalence when B4 matches mapper.
|
||||
|
||||
Stage 2 u5 contract (verbatim)::
|
||||
|
||||
slot_payload byte-equivalent (PHASE_Z_B4_MAPPER_SOURCE ON + matches_mapper=True)
|
||||
vs OFF, across mdx 01-05
|
||||
|
||||
Why this is load-bearing
|
||||
========================
|
||||
|
||||
u4 freezes the FULL pipeline ``final.html`` SHA under flag OFF. u5 isolates
|
||||
the *mapper-input* axis: when B4 ``PlacementPlan.selected_template_id``
|
||||
equals the legacy mapper input (``unit.frame_template_id`` — V4 rank-1),
|
||||
the selector at ``src/phase_z2_pipeline.py:223-242`` returns the same
|
||||
template id under either flag state. The mapper is a pure function of
|
||||
``(MdxSection, template_id)`` (deterministic dispatch via
|
||||
``map_with_contract`` → named ``PAYLOAD_BUILDERS`` — verified at
|
||||
``src/phase_z2_mapper.py:894-919``), so identical inputs → identical
|
||||
``slot_payload`` dicts → identical JSON-canonical bytes.
|
||||
|
||||
This is the *cross-axis* proof complementing u4:
|
||||
|
||||
* u4 = on-disk ``final.html`` SHA parity, default-OFF only (legacy
|
||||
preservation guard).
|
||||
* u5 = ``slot_payload`` byte equivalence, *flag ON ↔ flag OFF* (Layer A
|
||||
render-active behavior-preserving proof under matches_mapper).
|
||||
|
||||
The negative case (``test_slot_payload_diverges_when_b4_mismatches_under_flag_on``)
|
||||
locks the fact that ``slot_payload`` actually *depends* on the
|
||||
``template_id`` selector output — without it, the equivalence test could
|
||||
trivially pass even if the selector were a no-op.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_mapper import (
|
||||
FitError,
|
||||
get_contract,
|
||||
load_frame_contracts,
|
||||
map_with_contract,
|
||||
)
|
||||
from src.phase_z2_pipeline import (
|
||||
_b4_mapper_source_enabled,
|
||||
_select_mapper_template_id,
|
||||
extract_content_objects,
|
||||
parse_mdx,
|
||||
)
|
||||
from src.phase_z2_placement_planner import plan_placement
|
||||
|
||||
|
||||
@dataclass
|
||||
class _StubPlan:
|
||||
"""Minimal placement-plan stand-in for selector unit checks.
|
||||
|
||||
``_select_mapper_template_id`` reads ONLY ``selected_template_id``
|
||||
(verified at ``src/phase_z2_pipeline.py:240-242``). Constructing the
|
||||
real ``PlacementPlan`` with placeholder slot/region lists would force
|
||||
the test to track schema drift on fields the selector never touches.
|
||||
"""
|
||||
|
||||
selected_template_id: Optional[str]
|
||||
|
||||
FLAG = "PHASE_Z_B4_MAPPER_SOURCE"
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
_SAMPLES_DIR = _REPO_ROOT / "samples" / "mdx_batch"
|
||||
_MDX_BATCH = ("01.mdx", "02.mdx", "03.mdx", "04.mdx", "05.mdx")
|
||||
|
||||
|
||||
def _canonical_bytes(payload: dict) -> bytes:
|
||||
"""Stable JSON canonical encoding for byte-level dict comparison.
|
||||
|
||||
``sort_keys`` removes dict-ordering noise; ``ensure_ascii=False`` keeps
|
||||
Korean text from being mangled into ``\\uXXXX`` escapes (which would
|
||||
still compare equal but would silently mask any encoding regression in
|
||||
the mapper).
|
||||
"""
|
||||
return json.dumps(payload, sort_keys=True, ensure_ascii=False).encode(
|
||||
"utf-8"
|
||||
)
|
||||
|
||||
|
||||
def _matches_mapper_cases() -> list[tuple[str, str, object, str]]:
|
||||
"""Enumerate (mdx_file, section_id, section, template_id) tuples where
|
||||
the matches_mapper scenario is reachable.
|
||||
|
||||
"matches_mapper=True" in production is the predicate
|
||||
``placement_plan.selected_template_id == unit.frame_template_id``. To
|
||||
cover it at the unit-test level without driving the full Type B
|
||||
coordinator, we treat each B4-selected template as the *simulated*
|
||||
legacy mapper input — i.e. we force matches_mapper=True by construction
|
||||
via ``mapper_template_id := plan.selected_template_id``.
|
||||
|
||||
Only sections where (a) B4 finds a covering frame AND (b) the mapper
|
||||
accepts that frame (no FitError) are byte-equivalence-eligible. Under
|
||||
flag ON the BLOCKED u3 path would otherwise fire — that axis is
|
||||
covered by ``test_b4_mapper_source_blocked.py`` and is out of scope
|
||||
here.
|
||||
"""
|
||||
frame_contracts = list(load_frame_contracts().values())
|
||||
cases: list[tuple[str, str, object, str]] = []
|
||||
for mdx_file in _MDX_BATCH:
|
||||
mdx_path = _SAMPLES_DIR / mdx_file
|
||||
_title, sections, _footer = parse_mdx(mdx_path)
|
||||
for section in sections:
|
||||
content_objects = extract_content_objects(
|
||||
section, source_shape=None
|
||||
)
|
||||
plan = plan_placement(
|
||||
content_objects=content_objects,
|
||||
frame_contracts=frame_contracts,
|
||||
section_id=section.section_id,
|
||||
)
|
||||
template_id = plan.selected_template_id
|
||||
if template_id is None:
|
||||
continue
|
||||
contract = get_contract(template_id)
|
||||
if contract is None:
|
||||
continue
|
||||
try:
|
||||
map_with_contract(section, contract)
|
||||
except FitError:
|
||||
continue
|
||||
cases.append((mdx_file, section.section_id, section, template_id))
|
||||
return cases
|
||||
|
||||
|
||||
# Frozen at collection time so a parametrize zero-iteration cannot silently
|
||||
# pass the byte-equivalence assertion (additional coverage lock below).
|
||||
_MATCHES_CASES = _matches_mapper_cases()
|
||||
|
||||
|
||||
def _slot_payload_via_selector(
|
||||
section, plan, mapper_input: str
|
||||
) -> tuple[dict, str]:
|
||||
"""Compose ``_select_mapper_template_id → map_mdx_to_slots`` once.
|
||||
|
||||
Mirrors the exact runtime path at
|
||||
``src/phase_z2_pipeline.py:4771-4797`` minus the BLOCKED u3 gate
|
||||
(which is out of scope for u5 byte equivalence — covered by u3).
|
||||
Returns ``(slot_payload, resolved_template_id)`` so per-case asserts
|
||||
can verify *both* axes (input + output) match.
|
||||
"""
|
||||
resolved = _select_mapper_template_id(plan, mapper_input)
|
||||
assert resolved is not None, (
|
||||
"u5 fixture invariant violated: resolved template_id is None even "
|
||||
"though the case was pre-filtered for B4 cover. Re-check "
|
||||
"_matches_mapper_cases()."
|
||||
)
|
||||
contract = get_contract(resolved)
|
||||
assert contract is not None, (
|
||||
f"u5 fixture invariant violated: no contract for resolved="
|
||||
f"{resolved!r} (case was pre-filtered for catalog membership)."
|
||||
)
|
||||
return map_with_contract(section, contract), resolved
|
||||
|
||||
|
||||
# ─── algebraic precondition (no pipeline / no mapper run) ──────────────
|
||||
|
||||
|
||||
def test_selector_returns_same_value_under_flag_flip_when_matches_mapper(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Pure-function property: when ``plan.selected_template_id == T`` the
|
||||
selector returns ``T`` under either flag state.
|
||||
|
||||
This is the algebra that makes the end-to-end byte equivalence below
|
||||
hold mathematically. If this property breaks, every parametrized
|
||||
equivalence assertion would also break — this test localizes the
|
||||
failure to the selector helper itself.
|
||||
"""
|
||||
plan = _StubPlan(selected_template_id="F13")
|
||||
legacy_input = "F13" # matches_mapper=True by construction
|
||||
|
||||
monkeypatch.setenv(FLAG, "1")
|
||||
assert _b4_mapper_source_enabled() is True
|
||||
on_value = _select_mapper_template_id(plan, legacy_input)
|
||||
|
||||
monkeypatch.delenv(FLAG, raising=False)
|
||||
assert _b4_mapper_source_enabled() is False
|
||||
off_value = _select_mapper_template_id(plan, legacy_input)
|
||||
|
||||
assert on_value == off_value == "F13"
|
||||
|
||||
|
||||
# ─── end-to-end byte equivalence (parametrized over real mdx data) ────
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.parametrize(
|
||||
("mdx_file", "section_id", "section", "template_id"),
|
||||
_MATCHES_CASES,
|
||||
ids=lambda case: (
|
||||
case if isinstance(case, str) else getattr(case, "section_id", "_")
|
||||
),
|
||||
)
|
||||
def test_slot_payload_byte_equivalent_when_matches_mapper(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
mdx_file: str,
|
||||
section_id: str,
|
||||
section,
|
||||
template_id: str,
|
||||
) -> None:
|
||||
"""Per-section byte equivalence proof under matches_mapper=True.
|
||||
|
||||
Recomputes ``PlacementPlan`` from scratch inside the test (fixture
|
||||
enumeration cached only the section + B4 pick) and asserts that the
|
||||
mapper output is JSON-canonical-byte-identical between flag ON and
|
||||
flag OFF, given the same mapper input.
|
||||
"""
|
||||
frame_contracts = list(load_frame_contracts().values())
|
||||
content_objects = extract_content_objects(section, source_shape=None)
|
||||
plan = plan_placement(
|
||||
content_objects=content_objects,
|
||||
frame_contracts=frame_contracts,
|
||||
section_id=section.section_id,
|
||||
)
|
||||
assert plan.selected_template_id == template_id, (
|
||||
f"u5 invariant: B4 selection drifted between enumeration and "
|
||||
f"test execution for {mdx_file} {section_id}: enumerated="
|
||||
f"{template_id!r} live={plan.selected_template_id!r}"
|
||||
)
|
||||
|
||||
# Under matches_mapper=True the legacy mapper input equals plan pick.
|
||||
legacy_mapper_input = template_id
|
||||
|
||||
monkeypatch.delenv(FLAG, raising=False)
|
||||
plan_snapshot_off = asdict(plan) # type: ignore[call-overload]
|
||||
payload_off, resolved_off = _slot_payload_via_selector(
|
||||
section, plan, legacy_mapper_input
|
||||
)
|
||||
plan_after_off = asdict(plan) # type: ignore[call-overload]
|
||||
|
||||
monkeypatch.setenv(FLAG, "1")
|
||||
payload_on, resolved_on = _slot_payload_via_selector(
|
||||
section, plan, legacy_mapper_input
|
||||
)
|
||||
plan_after_on = asdict(plan) # type: ignore[call-overload]
|
||||
|
||||
assert resolved_off == resolved_on == template_id, (
|
||||
f"selector returned different template_id under matches_mapper for "
|
||||
f"{mdx_file} {section_id}: off={resolved_off!r} on={resolved_on!r}"
|
||||
)
|
||||
assert _canonical_bytes(payload_off) == _canonical_bytes(payload_on), (
|
||||
f"slot_payload byte equivalence broken for {mdx_file} {section_id} "
|
||||
f"(template_id={template_id}): mapper output diverged between "
|
||||
f"flag OFF and flag ON despite identical mapper input. This means "
|
||||
f"either map_with_contract gained nondeterminism or a hidden "
|
||||
f"selector-side effect crept in."
|
||||
)
|
||||
assert plan_snapshot_off == plan_after_off == plan_after_on, (
|
||||
f"PlacementPlan mutated by selector / mapper call for {mdx_file} "
|
||||
f"{section_id} — u5 byte equivalence relies on the selector being "
|
||||
f"a pure read of plan.selected_template_id."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_matches_mapper_corpus_coverage_is_non_empty() -> None:
|
||||
"""Lock: the parametrized equivalence test above must have iterated at
|
||||
least once.
|
||||
|
||||
Without this guard a pytest parametrize zero-iteration (e.g. all
|
||||
sections rejected by B4 or all FitError-raising) would let the byte
|
||||
equivalence test silently pass with zero work. mdx 01-05 is rich
|
||||
enough that at least one matches_mapper case is always reachable.
|
||||
"""
|
||||
assert _MATCHES_CASES, (
|
||||
"u5 byte equivalence had zero matches_mapper cases — every section "
|
||||
"across mdx 01-05 was either B4-uncovered or raised FitError. "
|
||||
"Either the corpus shrank, B4 algorithm regressed, or the mapper "
|
||||
"now rejects every B4 pick. Investigate before re-locking."
|
||||
)
|
||||
seen_files = {case[0] for case in _MATCHES_CASES}
|
||||
assert len(seen_files) >= 1, (
|
||||
f"u5 coverage too narrow: {seen_files} — at least one mdx file "
|
||||
f"must yield a matches_mapper case for the equivalence proof to "
|
||||
f"be load-bearing."
|
||||
)
|
||||
|
||||
|
||||
# ─── negative case — bytes MUST diverge when B4 mismatches ─────────────
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_slot_payload_diverges_when_b4_mismatches_under_flag_on(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Anti-vacuous proof: when B4 picks a template DIFFERENT from the
|
||||
legacy mapper input AND flag ON, the resulting ``slot_payload``
|
||||
differs from the flag-OFF case.
|
||||
|
||||
Without this assertion the equivalence test would pass even if the
|
||||
selector were a no-op that always returned the legacy input — i.e.
|
||||
the equivalence test would be load-bearing in the wrong direction.
|
||||
This test proves the mapper output genuinely depends on the selector's
|
||||
template_id choice, so equivalence under matches_mapper is a real
|
||||
behavioral guarantee rather than a tautology.
|
||||
|
||||
Strategy: find a section where the mapper accepts *both* the B4 pick
|
||||
AND a distinct alternative template (a frame the mapper also covers
|
||||
with a different builder/source_shape). Compare slot_payload bytes
|
||||
across the two — they MUST differ.
|
||||
"""
|
||||
frame_contracts = list(load_frame_contracts().values())
|
||||
diverging_case: tuple | None = None
|
||||
|
||||
for mdx_file in _MDX_BATCH:
|
||||
mdx_path = _SAMPLES_DIR / mdx_file
|
||||
_title, sections, _footer = parse_mdx(mdx_path)
|
||||
for section in sections:
|
||||
content_objects = extract_content_objects(
|
||||
section, source_shape=None
|
||||
)
|
||||
plan = plan_placement(
|
||||
content_objects=content_objects,
|
||||
frame_contracts=frame_contracts,
|
||||
section_id=section.section_id,
|
||||
)
|
||||
b4_pick = plan.selected_template_id
|
||||
if b4_pick is None:
|
||||
continue
|
||||
b4_contract = get_contract(b4_pick)
|
||||
if b4_contract is None:
|
||||
continue
|
||||
try:
|
||||
b4_payload = map_with_contract(section, b4_contract)
|
||||
except FitError:
|
||||
continue
|
||||
# Hunt for a *different* template the mapper also accepts on
|
||||
# this same section. Iterate the catalog in declaration order
|
||||
# so the search is deterministic.
|
||||
for alt in frame_contracts:
|
||||
alt_id = alt.get("template_id")
|
||||
if not alt_id or alt_id == b4_pick:
|
||||
continue
|
||||
try:
|
||||
alt_payload = map_with_contract(section, alt)
|
||||
except FitError:
|
||||
continue
|
||||
if _canonical_bytes(b4_payload) != _canonical_bytes(
|
||||
alt_payload
|
||||
):
|
||||
diverging_case = (
|
||||
mdx_file,
|
||||
section.section_id,
|
||||
b4_pick,
|
||||
alt_id,
|
||||
b4_payload,
|
||||
alt_payload,
|
||||
)
|
||||
break
|
||||
if diverging_case is not None:
|
||||
break
|
||||
if diverging_case is not None:
|
||||
break
|
||||
|
||||
assert diverging_case is not None, (
|
||||
"Could not find a section across mdx 01-05 where the mapper "
|
||||
"accepts two distinct templates with divergent slot_payload. "
|
||||
"Without such a case the equivalence test above is tautological."
|
||||
)
|
||||
|
||||
(
|
||||
mdx_file,
|
||||
section_id,
|
||||
b4_pick,
|
||||
alt_id,
|
||||
b4_payload,
|
||||
alt_payload,
|
||||
) = diverging_case
|
||||
|
||||
# Now drive the selector path under flag ON with B4 picking ``b4_pick``
|
||||
# while the legacy mapper input is ``alt_id`` — i.e. B4 mismatches the
|
||||
# legacy input. Flag ON → selector returns b4_pick → mapper produces
|
||||
# b4_payload. Flag OFF → selector returns alt_id → mapper produces
|
||||
# alt_payload. The two MUST differ.
|
||||
plan = _StubPlan(selected_template_id=b4_pick)
|
||||
|
||||
mdx_path = _SAMPLES_DIR / mdx_file
|
||||
_title, sections, _footer = parse_mdx(mdx_path)
|
||||
section = next(s for s in sections if s.section_id == section_id)
|
||||
|
||||
monkeypatch.setenv(FLAG, "1")
|
||||
on_payload, on_resolved = _slot_payload_via_selector(
|
||||
section, plan, alt_id
|
||||
)
|
||||
monkeypatch.delenv(FLAG, raising=False)
|
||||
off_payload, off_resolved = _slot_payload_via_selector(
|
||||
section, plan, alt_id
|
||||
)
|
||||
|
||||
assert on_resolved == b4_pick
|
||||
assert off_resolved == alt_id
|
||||
assert _canonical_bytes(on_payload) != _canonical_bytes(off_payload), (
|
||||
f"Negative case failed: selector flip from {alt_id} (OFF) to "
|
||||
f"{b4_pick} (ON) produced byte-identical slot_payload for "
|
||||
f"{mdx_file} {section_id}. The mapper appears to ignore "
|
||||
f"template_id, which would make the equivalence test tautological."
|
||||
)
|
||||
|
||||
|
||||
# ─── selector default-state lock (mirror of u4 sanity check) ───────────
|
||||
|
||||
|
||||
def test_selector_default_state_returns_legacy_under_b4_mismatch(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Final sanity: even when B4 would pick something different, the
|
||||
flag-OFF default selector returns the legacy mapper input verbatim.
|
||||
|
||||
This is the property that makes u4 SHA parity hold and the negative
|
||||
test above meaningful. Repeated here at the u5 axis so a single test
|
||||
file change cannot accidentally hide the regression signal across
|
||||
both u4 and u5.
|
||||
"""
|
||||
plan = _StubPlan(selected_template_id="F29")
|
||||
monkeypatch.delenv(FLAG, raising=False)
|
||||
assert _b4_mapper_source_enabled() is False
|
||||
assert _select_mapper_template_id(plan, "F13") == "F13"
|
||||
@@ -0,0 +1,54 @@
|
||||
"""IMP-89 89-a u1 — PHASE_Z_B4_MAPPER_SOURCE flag reader unit tests.
|
||||
|
||||
Stage 2 plan (u1): adds an env flag reader helper (default OFF) distinct
|
||||
from PHASE_Z_B4_GATEKEEPER. u1 only locks reader semantics — u2 wires it
|
||||
into the slot_payload source-of-truth switch and u3 layers BLOCKED exits
|
||||
for B4 no-cover and B4-selected FitError under flag ON.
|
||||
|
||||
Truthy contract (mirrors PHASE_Z_B4_GATEKEEPER /
|
||||
PHASE_Z_B4_SOURCE_SHAPE_ENABLED at src/phase_z2_pipeline.py:4625,4662):
|
||||
case-insensitive + leading/trailing whitespace stripped; truthy set
|
||||
= {'1', 'true', 'yes'}. Everything else (including '0', '', 'no',
|
||||
'false', missing env var) is OFF.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_pipeline import _b4_mapper_source_enabled
|
||||
|
||||
FLAG = "PHASE_Z_B4_MAPPER_SOURCE"
|
||||
|
||||
|
||||
def test_default_off_when_env_unset(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv(FLAG, raising=False)
|
||||
assert _b4_mapper_source_enabled() is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["1", "true", "yes", "TRUE", "Yes", " true ", " 1\t"])
|
||||
def test_truthy_values_enable_flag(
|
||||
monkeypatch: pytest.MonkeyPatch, value: str
|
||||
) -> None:
|
||||
monkeypatch.setenv(FLAG, value)
|
||||
assert _b4_mapper_source_enabled() is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["", "0", "no", "false", "off", "2", "on", "y"])
|
||||
def test_non_truthy_values_keep_flag_off(
|
||||
monkeypatch: pytest.MonkeyPatch, value: str
|
||||
) -> None:
|
||||
monkeypatch.setenv(FLAG, value)
|
||||
assert _b4_mapper_source_enabled() is False
|
||||
|
||||
|
||||
def test_flag_distinct_from_gatekeeper(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""PHASE_Z_B4_GATEKEEPER ON must not flip the mapper-source flag.
|
||||
|
||||
Locks Stage 2 design decision (Stage 1 Q1 resolution): the new flag
|
||||
governs slot_payload source-of-truth; PHASE_Z_B4_GATEKEEPER retains
|
||||
its mismatch render-skip semantics. They must be independently
|
||||
toggleable.
|
||||
"""
|
||||
monkeypatch.setenv("PHASE_Z_B4_GATEKEEPER", "1")
|
||||
monkeypatch.delenv(FLAG, raising=False)
|
||||
assert _b4_mapper_source_enabled() is False
|
||||
@@ -0,0 +1,96 @@
|
||||
"""IMP-89 89-a u2 — slot_payload source-of-truth switch unit tests.
|
||||
|
||||
Stage 2 plan (u2): wires the u1 PHASE_Z_B4_MAPPER_SOURCE flag into the
|
||||
single slot_payload construction site at src/phase_z2_pipeline.py:4702
|
||||
via the _select_mapper_template_id() selector helper.
|
||||
|
||||
Locked semantics (Stage 1 Q1 / Stage 2 u2):
|
||||
flag ON → mapper input = placement_plan.selected_template_id (B4)
|
||||
flag OFF → mapper input = unit.frame_template_id (legacy mapper-only)
|
||||
|
||||
u3 will add BLOCKED exits for (selected_template_id is None OR FitError
|
||||
on B4-selected) under flag ON — NO silent fallback. u4 guards default-OFF
|
||||
final.html SHA parity for mdx 01-05.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_pipeline import _select_mapper_template_id
|
||||
|
||||
FLAG = "PHASE_Z_B4_MAPPER_SOURCE"
|
||||
|
||||
|
||||
@dataclass
|
||||
class _StubPlan:
|
||||
"""Minimal PlacementPlan stand-in — only selected_template_id is read."""
|
||||
|
||||
selected_template_id: Optional[str]
|
||||
|
||||
|
||||
def test_flag_off_returns_unit_frame_template_id(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Default-OFF preserves legacy mapper input (V4 rank-1)."""
|
||||
monkeypatch.delenv(FLAG, raising=False)
|
||||
plan = _StubPlan(selected_template_id="B4_PICK")
|
||||
assert _select_mapper_template_id(plan, "V4_PICK") == "V4_PICK"
|
||||
|
||||
|
||||
def test_flag_on_returns_placement_plan_selected_template_id(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Flag ON routes mapper input to B4 PlacementPlan."""
|
||||
monkeypatch.setenv(FLAG, "1")
|
||||
plan = _StubPlan(selected_template_id="B4_PICK")
|
||||
assert _select_mapper_template_id(plan, "V4_PICK") == "B4_PICK"
|
||||
|
||||
|
||||
def test_flag_on_with_matching_b4_returns_same_value(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""When B4-selected == mapper, switch is behavior-preserving."""
|
||||
monkeypatch.setenv(FLAG, "true")
|
||||
plan = _StubPlan(selected_template_id="F13")
|
||||
assert _select_mapper_template_id(plan, "F13") == "F13"
|
||||
|
||||
|
||||
def test_flag_on_with_no_b4_cover_returns_none(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Flag ON + B4 no-cover surfaces None — u3 will BLOCK on this signal."""
|
||||
monkeypatch.setenv(FLAG, "yes")
|
||||
plan = _StubPlan(selected_template_id=None)
|
||||
assert _select_mapper_template_id(plan, "V4_PICK") is None
|
||||
|
||||
|
||||
def test_flag_off_with_no_b4_cover_still_returns_legacy(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Default-OFF ignores B4 None — legacy mapper input always honored."""
|
||||
monkeypatch.delenv(FLAG, raising=False)
|
||||
plan = _StubPlan(selected_template_id=None)
|
||||
assert _select_mapper_template_id(plan, "V4_PICK") == "V4_PICK"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("non_truthy", ["", "0", "no", "false", "off", "2"])
|
||||
def test_non_truthy_env_values_keep_legacy_source(
|
||||
monkeypatch: pytest.MonkeyPatch, non_truthy: str
|
||||
) -> None:
|
||||
"""Non-truthy env values mirror u1 flag-reader contract — legacy source."""
|
||||
monkeypatch.setenv(FLAG, non_truthy)
|
||||
plan = _StubPlan(selected_template_id="B4_PICK")
|
||||
assert _select_mapper_template_id(plan, "V4_PICK") == "V4_PICK"
|
||||
|
||||
|
||||
def test_gatekeeper_flag_does_not_flip_mapper_source(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""PHASE_Z_B4_GATEKEEPER ON alone must NOT route mapper to B4 (Stage 1 Q1)."""
|
||||
monkeypatch.setenv("PHASE_Z_B4_GATEKEEPER", "1")
|
||||
monkeypatch.delenv(FLAG, raising=False)
|
||||
plan = _StubPlan(selected_template_id="B4_PICK")
|
||||
assert _select_mapper_template_id(plan, "V4_PICK") == "V4_PICK"
|
||||
@@ -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,333 @@
|
||||
"""IMP-35 (#64) u6 — Composition popup binding tests.
|
||||
|
||||
Stage 2 binding contract (unit u6):
|
||||
``bind_popup_display_strategy`` in ``src/phase_z2_composition.py`` is
|
||||
the composition-side binding that translates the unit-side marker
|
||||
(``has_popup`` + ``popup_escalation_plan``) stamped by the Step 17
|
||||
POPUP gate (u5 in ``src/phase_z2_ai_fallback/step17.py``) into a
|
||||
deterministic zone payload structure that u7 wires into the renderer.
|
||||
|
||||
Key invariants this file locks:
|
||||
1. Strategy id is the catalog key (yaml is source of truth) — no
|
||||
hardcoded literal string drift between code and
|
||||
``display_strategies.yaml``.
|
||||
2. ``has_popup=False`` units bind to ``inline_full`` (no popup).
|
||||
3. ``has_popup=True`` units bind to ``inline_preview_with_details``
|
||||
(preview = excerpt from container px budget downstream; popup
|
||||
body holds the FULL original per CLAUDE.md 자세히보기 원칙).
|
||||
4. ``popup_body_source`` is the FULL ``raw_content``, verbatim —
|
||||
u6 NEVER trims or summarizes (MDX 원문 무손실 보존, 오답노트 #5,
|
||||
IMPROVEMENT-REDESIGN.md §3.6 line 110).
|
||||
5. ``detail_trigger.placement`` / ``label`` come from the catalog
|
||||
entry's ``detail_trigger`` block, not from code constants.
|
||||
6. The popup-binding strategy MUST have ``preserves_original=True``
|
||||
in the catalog (defensive yaml-drift guard).
|
||||
7. No AI call. ``bind_popup_display_strategy`` is pure composition-
|
||||
side binding — feedback_ai_isolation_contract.
|
||||
|
||||
Cross-references:
|
||||
- u3 router stub (``plan_details_popup_escalation``):
|
||||
tests/phase_z2/test_phase_z2_router_popup.py
|
||||
- u4 api_gated split-decision contract:
|
||||
tests/phase_z2_ai_fallback/test_step17.py
|
||||
- u5 Step 17 POPUP gate (stamps the marker u6 reads):
|
||||
tests/phase_z2/test_phase_z2_step17_popup_gate.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_composition import (
|
||||
DISPLAY_STRATEGIES,
|
||||
POPUP_BINDING_ESCALATED_STRATEGY_ID,
|
||||
POPUP_BINDING_NO_POPUP_STRATEGY_ID,
|
||||
bind_popup_display_strategy,
|
||||
)
|
||||
|
||||
|
||||
# ─── Synthetic stubs ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class _StubUnit:
|
||||
"""Minimal duck-typed CompositionUnit for u6 binding tests.
|
||||
|
||||
Mirrors only the fields ``bind_popup_display_strategy`` reads via
|
||||
getattr — keeps the test independent of the full CompositionUnit
|
||||
dataclass evolution (e.g., IMP-30 / IMP-48 axis additions).
|
||||
"""
|
||||
|
||||
raw_content: str = "MOCK_ORIGINAL_CONTENT"
|
||||
has_popup: bool = False
|
||||
popup_escalation_plan: Optional[dict] = None
|
||||
|
||||
|
||||
def _stub_popup_plan(category: str = "structural_major_overflow") -> dict:
|
||||
"""Mirror the shape ``plan_details_popup_escalation`` returns on a
|
||||
feasible escalation. u6 echoes this verbatim — no field is consumed
|
||||
here other than as a traceable payload."""
|
||||
return {
|
||||
"action": "details_popup_escalation",
|
||||
"stub": True,
|
||||
"feasible": True,
|
||||
"category": category,
|
||||
"needs_split_decision": True,
|
||||
"rationale": "MOCK_RATIONALE",
|
||||
"mapping_source": "IMP-35 u3 plan_details_popup_escalation stub",
|
||||
}
|
||||
|
||||
|
||||
# ─── Catalog constants are catalog keys (no hardcoded drift) ─────────
|
||||
|
||||
|
||||
def test_popup_binding_strategy_ids_are_catalog_keys():
|
||||
"""u6 — both constants used by the binder must resolve against the
|
||||
yaml catalog. Defensive guard against catalog rename / removal."""
|
||||
assert POPUP_BINDING_NO_POPUP_STRATEGY_ID in DISPLAY_STRATEGIES
|
||||
assert POPUP_BINDING_ESCALATED_STRATEGY_ID in DISPLAY_STRATEGIES
|
||||
|
||||
|
||||
def test_popup_binding_escalated_strategy_preserves_original_in_catalog():
|
||||
"""u6 — the escalated-path strategy MUST preserve original content
|
||||
in the catalog (yaml lock — MDX 원문 무손실 보존). If yaml drift ever
|
||||
flips this to False, the binder must surface the violation; this
|
||||
test locks the catalog side of that invariant."""
|
||||
meta = DISPLAY_STRATEGIES[POPUP_BINDING_ESCALATED_STRATEGY_ID]
|
||||
assert meta.get("preserves_original") is True, (
|
||||
"Catalog entry for the popup-binding strategy must declare "
|
||||
"preserves_original=True (오답노트 #5 / IMPROVEMENT-REDESIGN.md §3.6)."
|
||||
)
|
||||
|
||||
|
||||
def test_popup_binding_escalated_strategy_has_detail_trigger_in_catalog():
|
||||
"""u6 — the escalated-path strategy MUST declare a detail_trigger
|
||||
block with placement + label in the catalog. The binder reads from
|
||||
the yaml — no code-side string literal drift."""
|
||||
meta = DISPLAY_STRATEGIES[POPUP_BINDING_ESCALATED_STRATEGY_ID]
|
||||
trigger = meta.get("detail_trigger")
|
||||
assert isinstance(trigger, dict)
|
||||
assert trigger.get("placement"), (
|
||||
"Catalog detail_trigger.placement must be non-empty so the binder "
|
||||
"can stamp a deterministic trigger position on the zone payload."
|
||||
)
|
||||
assert trigger.get("label"), (
|
||||
"Catalog detail_trigger.label must be non-empty so the binder "
|
||||
"can stamp a deterministic trigger identifier on the zone payload."
|
||||
)
|
||||
|
||||
|
||||
# ─── has_popup=False path ────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_bind_returns_inline_full_when_unit_has_no_popup_marker():
|
||||
"""u6 — units that never went through the Step 17 POPUP gate carry
|
||||
has_popup=False. The binder returns the catalog ``inline_full``
|
||||
strategy with no popup body / no detail trigger."""
|
||||
unit = _StubUnit(raw_content="MOCK_BODY", has_popup=False)
|
||||
payload = bind_popup_display_strategy(unit)
|
||||
assert payload["display_strategy"] == POPUP_BINDING_NO_POPUP_STRATEGY_ID
|
||||
assert payload["popup_body_source"] is None
|
||||
assert payload["detail_trigger"] is None
|
||||
assert payload["has_popup"] is False
|
||||
assert payload["popup_escalation_plan"] is None
|
||||
# preserves_original mirrors the catalog inline_full entry.
|
||||
expected_preserves = bool(
|
||||
DISPLAY_STRATEGIES[POPUP_BINDING_NO_POPUP_STRATEGY_ID].get(
|
||||
"preserves_original"
|
||||
)
|
||||
)
|
||||
assert payload["preserves_original"] is expected_preserves
|
||||
|
||||
|
||||
def test_bind_default_when_unit_has_no_has_popup_attr_at_all():
|
||||
"""u6 — defensive default. Units that lack the ``has_popup`` attr
|
||||
entirely (e.g., third-party duck-typed stubs that don't carry the
|
||||
Step 17 marker) bind to the no-popup path. The getattr() default
|
||||
branch must hold."""
|
||||
|
||||
class _BareUnit:
|
||||
raw_content = "MOCK_BODY"
|
||||
|
||||
payload = bind_popup_display_strategy(_BareUnit())
|
||||
assert payload["display_strategy"] == POPUP_BINDING_NO_POPUP_STRATEGY_ID
|
||||
assert payload["has_popup"] is False
|
||||
assert payload["popup_body_source"] is None
|
||||
|
||||
|
||||
# ─── has_popup=True path ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_bind_returns_inline_preview_with_details_when_has_popup_true():
|
||||
"""u6 — feasible POPUP gate escalation flips the binder onto the
|
||||
``inline_preview_with_details`` strategy (preview = px-budget
|
||||
excerpt downstream; popup body holds FULL original)."""
|
||||
plan = _stub_popup_plan()
|
||||
unit = _StubUnit(
|
||||
raw_content="MOCK_BODY",
|
||||
has_popup=True,
|
||||
popup_escalation_plan=plan,
|
||||
)
|
||||
payload = bind_popup_display_strategy(unit)
|
||||
assert payload["display_strategy"] == POPUP_BINDING_ESCALATED_STRATEGY_ID
|
||||
assert payload["has_popup"] is True
|
||||
assert payload["popup_escalation_plan"] is plan
|
||||
|
||||
|
||||
def test_bind_popup_body_source_is_full_raw_content_verbatim():
|
||||
"""u6 — popup body MUST be the FULL raw_content, byte-for-byte.
|
||||
The binder NEVER trims or summarizes (MDX 원문 무손실 보존 —
|
||||
오답노트 #5 / IMPROVEMENT-REDESIGN.md §3.6 line 110). u7 composes
|
||||
the body preview from container px telemetry downstream."""
|
||||
full_text = (
|
||||
"## MOCK_SECTION_TITLE\n\n"
|
||||
"- bullet one with **bold** marker\n"
|
||||
"- bullet two with *italic* marker\n"
|
||||
"- bullet three trailing\n"
|
||||
"\n"
|
||||
"| col_a | col_b |\n| --- | --- |\n| MOCK | DATA |\n"
|
||||
)
|
||||
unit = _StubUnit(
|
||||
raw_content=full_text,
|
||||
has_popup=True,
|
||||
popup_escalation_plan=_stub_popup_plan(),
|
||||
)
|
||||
payload = bind_popup_display_strategy(unit)
|
||||
assert payload["popup_body_source"] == full_text
|
||||
# Verbatim guarantee — no length-trimming side channel.
|
||||
assert len(payload["popup_body_source"]) == len(full_text)
|
||||
|
||||
|
||||
def test_bind_detail_trigger_placement_and_label_come_from_catalog():
|
||||
"""u6 — detail_trigger.placement / label MUST be read from the yaml
|
||||
catalog entry's detail_trigger block, not from code constants. This
|
||||
test compares the binder output against a fresh catalog read so a
|
||||
catalog rename (e.g., placement: top-right → top-left) propagates
|
||||
automatically."""
|
||||
catalog_trigger = (
|
||||
DISPLAY_STRATEGIES[POPUP_BINDING_ESCALATED_STRATEGY_ID]
|
||||
.get("detail_trigger") or {}
|
||||
)
|
||||
unit = _StubUnit(
|
||||
has_popup=True,
|
||||
popup_escalation_plan=_stub_popup_plan(),
|
||||
)
|
||||
payload = bind_popup_display_strategy(unit)
|
||||
assert payload["detail_trigger"] == {
|
||||
"placement": catalog_trigger.get("placement"),
|
||||
"label": catalog_trigger.get("label"),
|
||||
}
|
||||
|
||||
|
||||
def test_bind_preserves_original_is_true_on_popup_path():
|
||||
"""u6 — the popup-binding strategy MUST surface preserves_original=
|
||||
True so downstream consumers can rely on the absolute user lock
|
||||
(오답노트 #5). The binder mirrors the catalog value (which the
|
||||
catalog-side test already locks)."""
|
||||
unit = _StubUnit(
|
||||
has_popup=True,
|
||||
popup_escalation_plan=_stub_popup_plan(),
|
||||
)
|
||||
payload = bind_popup_display_strategy(unit)
|
||||
assert payload["preserves_original"] is True
|
||||
|
||||
|
||||
def test_bind_strategy_meta_is_the_full_catalog_entry():
|
||||
"""u6 — strategy_meta echoes the full catalog entry so downstream
|
||||
debug traces can self-explain without re-reading the yaml. Tests
|
||||
that the binder does not strip / re-shape the catalog dict."""
|
||||
expected_meta = DISPLAY_STRATEGIES[POPUP_BINDING_ESCALATED_STRATEGY_ID]
|
||||
unit = _StubUnit(
|
||||
has_popup=True,
|
||||
popup_escalation_plan=_stub_popup_plan(),
|
||||
)
|
||||
payload = bind_popup_display_strategy(unit)
|
||||
assert payload["strategy_meta"] is expected_meta
|
||||
|
||||
|
||||
def test_bind_popup_escalation_plan_is_echoed_verbatim():
|
||||
"""u6 — the popup_escalation_plan from u5 is echoed verbatim onto
|
||||
the zone payload so downstream debug surfaces can trace WHICH router
|
||||
category triggered the escalation (structural_major_overflow vs
|
||||
tabular_overflow). Object identity is preserved (no dict copy)."""
|
||||
plan = _stub_popup_plan("tabular_overflow")
|
||||
unit = _StubUnit(
|
||||
has_popup=True,
|
||||
popup_escalation_plan=plan,
|
||||
)
|
||||
payload = bind_popup_display_strategy(unit)
|
||||
assert payload["popup_escalation_plan"] is plan
|
||||
assert payload["popup_escalation_plan"]["category"] == "tabular_overflow"
|
||||
|
||||
|
||||
# ─── Defensive guards ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_bind_raises_when_strategy_id_missing_from_catalog(monkeypatch):
|
||||
"""u6 defensive guard — if catalog drift removes the escalated
|
||||
strategy id, the binder must raise RuntimeError rather than silently
|
||||
falling back to a wrong strategy. Locks the "yaml is source of
|
||||
truth" invariant against accidental rename."""
|
||||
drifted_catalog = {
|
||||
k: v for k, v in DISPLAY_STRATEGIES.items()
|
||||
if k != POPUP_BINDING_ESCALATED_STRATEGY_ID
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"src.phase_z2_composition.DISPLAY_STRATEGIES",
|
||||
drifted_catalog,
|
||||
)
|
||||
unit = _StubUnit(
|
||||
has_popup=True,
|
||||
popup_escalation_plan=_stub_popup_plan(),
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="catalog drift"):
|
||||
bind_popup_display_strategy(unit)
|
||||
|
||||
|
||||
def test_bind_raises_when_escalated_strategy_loses_preserves_original(
|
||||
monkeypatch,
|
||||
):
|
||||
"""u6 defensive guard — if the catalog entry for the escalated
|
||||
strategy ever flips preserves_original to False (yaml drift), the
|
||||
binder must raise RuntimeError. The absolute user lock — MDX 원문
|
||||
무손실 보존 — must NOT silently degrade through the binding layer."""
|
||||
drifted_meta = {
|
||||
**DISPLAY_STRATEGIES[POPUP_BINDING_ESCALATED_STRATEGY_ID],
|
||||
"preserves_original": False,
|
||||
}
|
||||
drifted_catalog = {
|
||||
**DISPLAY_STRATEGIES,
|
||||
POPUP_BINDING_ESCALATED_STRATEGY_ID: drifted_meta,
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"src.phase_z2_composition.DISPLAY_STRATEGIES",
|
||||
drifted_catalog,
|
||||
)
|
||||
unit = _StubUnit(
|
||||
has_popup=True,
|
||||
popup_escalation_plan=_stub_popup_plan(),
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="preserves_original"):
|
||||
bind_popup_display_strategy(unit)
|
||||
|
||||
|
||||
# ─── AI isolation contract (structural import lock) ─────────────────
|
||||
|
||||
|
||||
def test_composition_module_does_not_import_anthropic_or_route_ai_fallback():
|
||||
"""u6 — bind_popup_display_strategy MUST stay AI-free. Structural
|
||||
guard — composition module is allowed to consult the catalog and
|
||||
unit state, never the Anthropic SDK / route_ai_fallback path. This
|
||||
mirrors the import-isolation pattern locked by u5 tests in
|
||||
tests/phase_z2_ai_fallback/test_step17.py."""
|
||||
import src.phase_z2_composition as composition_module
|
||||
|
||||
source = composition_module.__file__
|
||||
assert source is not None
|
||||
with open(source, encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
assert "import anthropic" not in text
|
||||
assert "from anthropic" not in text
|
||||
assert "route_ai_fallback" not in text
|
||||
@@ -99,3 +99,63 @@ def test_fr_default_single_returns_full_body():
|
||||
per_zone = _compute_per_zone_geometry(layout_css, debug_zones, GRID_GAP)
|
||||
assert per_zone[0]["zone_height_px"] == SLIDE_BODY_HEIGHT
|
||||
assert per_zone[0]["zone_width_px"] == SLIDE_BODY_WIDTH
|
||||
|
||||
|
||||
def _placeholder_zone(position: str) -> dict:
|
||||
# IMP-86 u3 — mirror the u1 mapper-FitError placeholder zones_data
|
||||
# record shape (`src/phase_z2_pipeline.py:4459-4469`) used to keep the
|
||||
# failed unit's preset position in zones_data so build_layout_css /
|
||||
# _compute_per_zone_geometry observe len(zones_data) == active preset's
|
||||
# css_areas rows (R). content_weight.score == 0 ensures the placeholder
|
||||
# does not steal weight from the surviving normal zone.
|
||||
return {
|
||||
"position": position,
|
||||
"template_id": "__empty__",
|
||||
"slot_payload": {},
|
||||
"content_weight": {"score": 0},
|
||||
"min_height_px": 100,
|
||||
"assignment_source": "imp86_u1_adapter_needed",
|
||||
"section_assignment_override": False,
|
||||
"provisional": False,
|
||||
}
|
||||
|
||||
|
||||
def test_horizontal_2_normal_plus_placeholder_preserves_R2_cardinality():
|
||||
"""IMP-86 u3 — horizontal-2 with one mapper-success zone + one
|
||||
mapper-FitError placeholder (per IMP-86 u1) must keep heights_px /
|
||||
debug_zones / per_zone cardinality locked at R=2.
|
||||
|
||||
Reproduces the bug scenario in the issue body (mdx03 reject override
|
||||
where 03-2 hits adapter_needed) at the helper level: if the FitError
|
||||
path forgets to append a placeholder, heights_px length 1 vs R=2
|
||||
raises ValueError at `_compute_per_zone_geometry`. With the u1
|
||||
placeholder, all three artifacts (heights_px, debug_zones, per_zone)
|
||||
stay at length 2 and the geometry helper succeeds.
|
||||
"""
|
||||
zones = [_zone("top", 1.0), _placeholder_zone("bottom")]
|
||||
layout_css = build_layout_css("horizontal-2", zones)
|
||||
debug_zones = [{"position": "top"}, {"position": "bottom"}]
|
||||
|
||||
# heights_px length is locked to R=2 (parsed from preset css_areas).
|
||||
assert layout_css["areas"] == '"top" "bottom"'
|
||||
assert len(layout_css["heights_px"]) == 2
|
||||
# widths_px length is locked to C=1.
|
||||
assert len(layout_css["widths_px"]) == 1
|
||||
|
||||
# Placeholder zone (score=0) gets its min_height_px (100) and the
|
||||
# surviving normal zone absorbs the remaining body height after gap.
|
||||
assert layout_css["heights_px"][1] == 100
|
||||
assert (
|
||||
layout_css["heights_px"][0] + layout_css["heights_px"][1] + GRID_GAP
|
||||
== SLIDE_BODY_HEIGHT
|
||||
)
|
||||
|
||||
per_zone = _compute_per_zone_geometry(layout_css, debug_zones, GRID_GAP)
|
||||
# per_zone cardinality matches debug_zones (which matches R=2).
|
||||
assert len(per_zone) == 2
|
||||
assert [pz["position"] for pz in per_zone] == ["top", "bottom"]
|
||||
assert per_zone[0]["zone_height_px"] == layout_css["heights_px"][0]
|
||||
assert per_zone[1]["zone_height_px"] == layout_css["heights_px"][1]
|
||||
# Both zones share the single column => width == SLIDE_BODY_WIDTH.
|
||||
assert per_zone[0]["zone_width_px"] == SLIDE_BODY_WIDTH
|
||||
assert per_zone[1]["zone_width_px"] == SLIDE_BODY_WIDTH
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"""IMP-35 (#64) u9 — display_strategies.yaml popup-wiring catalog tests.
|
||||
|
||||
Stage 2 binding contract (unit u9):
|
||||
``templates/phase_z2/regions/display_strategies.yaml`` is the source of
|
||||
truth for the popup-wiring axis. u9 adds two strategy-level fields:
|
||||
|
||||
preview_chars : int | null
|
||||
Soft char budget for the inline body shown alongside the popup
|
||||
trigger. ``null`` when the strategy has no popup (``inline_full``,
|
||||
``dropped``). For popup-bearing strategies the value is the soft
|
||||
budget for the INLINE preview / summary surface only — the popup
|
||||
body itself ALWAYS holds the FULL original (MDX 원문 무손실 보존,
|
||||
오답노트 #5 / IMPROVEMENT-REDESIGN.md §3.6 line 110).
|
||||
|
||||
popup_target_slot : str | null
|
||||
Frame Layer B slot identifier the popup trigger anchors to.
|
||||
``null`` when the strategy has no popup. See CLAUDE.md
|
||||
"위계 + 용어" → "Frame Slot" / "Layer B" for the slot vocabulary.
|
||||
|
||||
Invariants this file locks (catalog side only — u9 is "data only"):
|
||||
|
||||
1. Both fields exist on every catalog entry (no missing keys).
|
||||
2. ``preview_chars`` is ``int >= 0`` for popup-bearing strategies
|
||||
(``inline_preview_with_details``, ``details_only``) and ``None`` for
|
||||
non-popup strategies (``inline_full``, ``dropped``).
|
||||
3. ``popup_target_slot`` is a non-empty ``str`` for popup-bearing
|
||||
strategies and ``None`` for non-popup strategies.
|
||||
4. The two fields are mutually consistent — both null OR both populated
|
||||
within a single strategy entry (no half-wired strategy).
|
||||
5. The popup-bearing strategies still preserve original content
|
||||
(popup body = full original; preview_chars governs only the inline
|
||||
surface, never the popup body).
|
||||
|
||||
Cross-references:
|
||||
- u6 binder (consumes ``DISPLAY_STRATEGIES`` via catalog key):
|
||||
src/phase_z2_composition.py:bind_popup_display_strategy
|
||||
- u6 binding tests (existing — must still pass with u9 fields added):
|
||||
tests/phase_z2/test_composition_popup_strategy.py
|
||||
- u7 preview text helper (line-budget cut; the char-budget axis u9
|
||||
introduces is forward config the future wiring will honor):
|
||||
src/phase_z2_composition.py:compute_popup_preview_text
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_composition import (
|
||||
DISPLAY_STRATEGIES,
|
||||
POPUP_BINDING_ESCALATED_STRATEGY_ID,
|
||||
POPUP_BINDING_NO_POPUP_STRATEGY_ID,
|
||||
)
|
||||
|
||||
|
||||
# Catalog keys grouped by popup capability. Sourced from the loaded
|
||||
# DISPLAY_STRATEGIES so a yaml-side rename surfaces immediately (no
|
||||
# hardcoded duplicate of catalog keys outside the binder constants).
|
||||
_POPUP_BEARING_STRATEGY_IDS = (
|
||||
"inline_preview_with_details",
|
||||
"details_only",
|
||||
)
|
||||
_NON_POPUP_STRATEGY_IDS = (
|
||||
"inline_full",
|
||||
"dropped",
|
||||
)
|
||||
|
||||
|
||||
def test_all_strategies_declare_preview_chars_field():
|
||||
"""Every catalog entry MUST declare ``preview_chars`` (int or null).
|
||||
Missing key = yaml drift; the binder + future wiring need a present
|
||||
field to read deterministically."""
|
||||
for name, meta in DISPLAY_STRATEGIES.items():
|
||||
assert "preview_chars" in meta, (
|
||||
f"display_strategies.yaml entry {name!r} is missing the u9 "
|
||||
f"`preview_chars` field. Every entry must declare it (int >= 0 "
|
||||
f"for popup-bearing strategies, null otherwise)."
|
||||
)
|
||||
|
||||
|
||||
def test_all_strategies_declare_popup_target_slot_field():
|
||||
"""Every catalog entry MUST declare ``popup_target_slot`` (str or
|
||||
null). Missing key = yaml drift."""
|
||||
for name, meta in DISPLAY_STRATEGIES.items():
|
||||
assert "popup_target_slot" in meta, (
|
||||
f"display_strategies.yaml entry {name!r} is missing the u9 "
|
||||
f"`popup_target_slot` field. Every entry must declare it "
|
||||
f"(non-empty str for popup-bearing strategies, null otherwise)."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("strategy_id", _POPUP_BEARING_STRATEGY_IDS)
|
||||
def test_popup_bearing_strategies_have_nonnegative_int_preview_chars(strategy_id):
|
||||
"""Popup-bearing strategies declare ``preview_chars`` as ``int >= 0``.
|
||||
The popup body itself always holds the FULL original (user lock), so
|
||||
this budget governs only the INLINE preview / summary surface."""
|
||||
meta = DISPLAY_STRATEGIES[strategy_id]
|
||||
value = meta.get("preview_chars")
|
||||
assert isinstance(value, int) and not isinstance(value, bool), (
|
||||
f"display_strategies.yaml {strategy_id!r} preview_chars must be an "
|
||||
f"int (got {type(value).__name__}={value!r}). The future wiring "
|
||||
f"reads it as a deterministic budget — bool / float / str would "
|
||||
f"silently break downstream comparisons."
|
||||
)
|
||||
assert value >= 0, (
|
||||
f"display_strategies.yaml {strategy_id!r} preview_chars must be "
|
||||
f">= 0 (got {value!r}). Negative budgets are not a valid surface."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("strategy_id", _POPUP_BEARING_STRATEGY_IDS)
|
||||
def test_popup_bearing_strategies_have_nonempty_string_popup_target_slot(strategy_id):
|
||||
"""Popup-bearing strategies declare ``popup_target_slot`` as a
|
||||
non-empty ``str`` — the frame Layer B slot identifier the popup
|
||||
trigger anchors to."""
|
||||
meta = DISPLAY_STRATEGIES[strategy_id]
|
||||
value = meta.get("popup_target_slot")
|
||||
assert isinstance(value, str), (
|
||||
f"display_strategies.yaml {strategy_id!r} popup_target_slot must "
|
||||
f"be a str (got {type(value).__name__}={value!r})."
|
||||
)
|
||||
assert value, (
|
||||
f"display_strategies.yaml {strategy_id!r} popup_target_slot must "
|
||||
f"be a non-empty string identifying a frame Layer B slot."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("strategy_id", _NON_POPUP_STRATEGY_IDS)
|
||||
def test_non_popup_strategies_have_null_preview_chars(strategy_id):
|
||||
"""Non-popup strategies (``inline_full`` / ``dropped``) declare
|
||||
``preview_chars`` as null — they have no popup-side budget axis."""
|
||||
meta = DISPLAY_STRATEGIES[strategy_id]
|
||||
assert meta.get("preview_chars") is None, (
|
||||
f"display_strategies.yaml {strategy_id!r} has no popup; "
|
||||
f"preview_chars must be null (got {meta.get('preview_chars')!r})."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("strategy_id", _NON_POPUP_STRATEGY_IDS)
|
||||
def test_non_popup_strategies_have_null_popup_target_slot(strategy_id):
|
||||
"""Non-popup strategies declare ``popup_target_slot`` as null —
|
||||
nothing for the popup trigger to anchor to."""
|
||||
meta = DISPLAY_STRATEGIES[strategy_id]
|
||||
assert meta.get("popup_target_slot") is None, (
|
||||
f"display_strategies.yaml {strategy_id!r} has no popup; "
|
||||
f"popup_target_slot must be null (got {meta.get('popup_target_slot')!r})."
|
||||
)
|
||||
|
||||
|
||||
def test_popup_wiring_fields_are_mutually_consistent_per_strategy():
|
||||
"""For every catalog entry, ``preview_chars`` and ``popup_target_slot``
|
||||
must be either BOTH null OR BOTH populated. A half-wired strategy
|
||||
(one null, one populated) is a yaml-drift bug — surfaces here."""
|
||||
for name, meta in DISPLAY_STRATEGIES.items():
|
||||
preview = meta.get("preview_chars")
|
||||
slot = meta.get("popup_target_slot")
|
||||
both_null = preview is None and slot is None
|
||||
both_set = preview is not None and slot is not None
|
||||
assert both_null or both_set, (
|
||||
f"display_strategies.yaml {name!r} has inconsistent popup "
|
||||
f"wiring fields — preview_chars={preview!r}, "
|
||||
f"popup_target_slot={slot!r}. Must be both null OR both set."
|
||||
)
|
||||
|
||||
|
||||
def test_binder_constants_point_to_popup_bearing_strategies():
|
||||
"""The u6 binder constants must continue to resolve against the
|
||||
catalog entries that carry u9 popup-wiring fields. Cross-axis lock
|
||||
between the binder (u6) and the catalog (u9) — drift on either side
|
||||
breaks the popup path silently."""
|
||||
assert POPUP_BINDING_ESCALATED_STRATEGY_ID in _POPUP_BEARING_STRATEGY_IDS, (
|
||||
f"u6 binder POPUP_BINDING_ESCALATED_STRATEGY_ID points to "
|
||||
f"{POPUP_BINDING_ESCALATED_STRATEGY_ID!r} which is NOT a popup-"
|
||||
f"bearing strategy per the u9 catalog axis."
|
||||
)
|
||||
assert POPUP_BINDING_NO_POPUP_STRATEGY_ID in _NON_POPUP_STRATEGY_IDS, (
|
||||
f"u6 binder POPUP_BINDING_NO_POPUP_STRATEGY_ID points to "
|
||||
f"{POPUP_BINDING_NO_POPUP_STRATEGY_ID!r} which IS popup-bearing "
|
||||
f"per the u9 catalog axis — wiring would be miscategorised."
|
||||
)
|
||||
|
||||
|
||||
def test_popup_bearing_strategies_still_preserve_original():
|
||||
"""u9 does not alter the existing absolute user lock: popup-bearing
|
||||
strategies have ``preserves_original=True`` (popup body == full
|
||||
original). u9 only adds inline-surface budget fields — must NOT
|
||||
silently degrade the existing invariant."""
|
||||
for strategy_id in _POPUP_BEARING_STRATEGY_IDS:
|
||||
meta = DISPLAY_STRATEGIES[strategy_id]
|
||||
assert meta.get("preserves_original") is True, (
|
||||
f"display_strategies.yaml {strategy_id!r} must preserve "
|
||||
f"original content even after u9 — preview_chars governs "
|
||||
f"the inline surface only, never the popup body."
|
||||
)
|
||||
@@ -0,0 +1,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,339 @@
|
||||
"""IMP-35 (#64) u11 — baseline-red invariance gate.
|
||||
|
||||
Stage 2 binding contract (unit u11):
|
||||
IMP-35 inherits a four-test red baseline from prior phases that is
|
||||
explicitly OUT OF SCOPE for this issue:
|
||||
|
||||
1. tests/test_imp47b_step12_ai_wiring.py
|
||||
::test_mixed_units_classified_by_route_and_provisional_flag
|
||||
2. tests/test_imp47b_step12_ai_wiring.py
|
||||
::test_reject_provisional_unit_reaches_router_short_circuit
|
||||
3. tests/test_imp47b_step12_ai_wiring.py
|
||||
::test_step12_ai_repair_artifact_writes_json_serialisable_records
|
||||
4. tests/test_phase_z2_ai_fallback_config.py
|
||||
::test_ai_fallback_master_flag_default_off
|
||||
|
||||
u11 does NOT fix these. u11 LOCKS the count + identity of the
|
||||
baseline-red set so that IMP-35 cannot silently grow the red surface
|
||||
while the issue is in-flight. A follow-up issue (Stage 2 plan
|
||||
`follow_up_candidates`) tracks the actual repair.
|
||||
|
||||
Invariance semantics:
|
||||
- The exact four baseline-red node ids resolve to real, collectible
|
||||
pytest items (a rename / delete is caught up front; the gate cannot
|
||||
be defeated by silently removing the failing test).
|
||||
- Running pytest on the BROADER baseline-area files
|
||||
(``tests/test_imp47b_step12_ai_wiring.py`` +
|
||||
``tests/test_phase_z2_ai_fallback_config.py``) yields EXACTLY four
|
||||
FAILED node ids and zero ERROR node ids; the FAILED set is exactly
|
||||
the documented baseline-red set.
|
||||
- A NEW red introduced by IMP-35 in the baseline area flips the
|
||||
FAILED count above four AND/OR introduces an extra FAILED node id
|
||||
that is not in the baseline set; either branch fails this gate.
|
||||
|
||||
AI isolation contract (`feedback_ai_isolation_contract`):
|
||||
The invariance gate runs pytest in a child process and parses stdout.
|
||||
It must NOT import the Anthropic SDK and must NOT route through
|
||||
``route_ai_fallback``. The structural import test below locks this.
|
||||
|
||||
Stage 2 plan source: Stage 2 exit report u11 — "u11 acknowledges the
|
||||
current four red baseline tests as pre-existing and adds an invariance
|
||||
gate so IMP-35 cannot worsen them."
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# === BASELINE-RED REGISTRY (frozen by Stage 2 u11 contract) ===
|
||||
#
|
||||
# Order is informational only; the gate compares as a set. Each entry
|
||||
# is a fully-qualified pytest node id resolvable from the repo root.
|
||||
IMP35_BASELINE_RED_NODE_IDS: tuple[str, ...] = (
|
||||
"tests/test_imp47b_step12_ai_wiring.py"
|
||||
"::test_mixed_units_classified_by_route_and_provisional_flag",
|
||||
"tests/test_imp47b_step12_ai_wiring.py"
|
||||
"::test_reject_provisional_unit_reaches_router_short_circuit",
|
||||
"tests/test_imp47b_step12_ai_wiring.py"
|
||||
"::test_step12_ai_repair_artifact_writes_json_serialisable_records",
|
||||
"tests/test_phase_z2_ai_fallback_config.py"
|
||||
"::test_ai_fallback_master_flag_default_off",
|
||||
)
|
||||
|
||||
# Files that own the baseline-red set. The "no-new-red in baseline area"
|
||||
# axis runs pytest on this set and checks that ONLY the registry above
|
||||
# fails.
|
||||
IMP35_BASELINE_RED_AREA_FILES: tuple[str, ...] = (
|
||||
"tests/test_imp47b_step12_ai_wiring.py",
|
||||
"tests/test_phase_z2_ai_fallback_config.py",
|
||||
)
|
||||
|
||||
|
||||
# === Repo root resolution (subprocess CWD anchor) ===
|
||||
|
||||
# tests/phase_z2/<this file>.py -> parents[2] = repo root.
|
||||
_REPO_ROOT: Path = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
# === pytest stdout parsers ===
|
||||
|
||||
# Matches lines like:
|
||||
# FAILED tests/test_imp47b_step12_ai_wiring.py::test_xxx
|
||||
# and:
|
||||
# FAILED tests/test_imp47b_step12_ai_wiring.py::test_xxx - AssertionError: ...
|
||||
# The capture group is the bare node id (no trailing failure detail).
|
||||
_FAILED_LINE_RE = re.compile(r"^FAILED\s+(\S+?)(?:\s+-\s+.*)?$", re.MULTILINE)
|
||||
|
||||
# Matches lines like:
|
||||
# ERROR tests/test_xxx.py::test_yyy
|
||||
_ERROR_LINE_RE = re.compile(r"^ERROR\s+(\S+?)(?:\s+-\s+.*)?$", re.MULTILINE)
|
||||
|
||||
# Matches the pytest tail summary line (sub-second timing field varies):
|
||||
# 4 failed, 6 passed in 2.27s
|
||||
_TAIL_SUMMARY_RE = re.compile(
|
||||
r"^(?P<body>.*?)\s+in\s+\d+(?:\.\d+)?s\s*$", re.MULTILINE
|
||||
)
|
||||
|
||||
|
||||
def _run_pytest_collect_only(node_ids: tuple[str, ...]) -> subprocess.CompletedProcess:
|
||||
"""Run ``pytest --collect-only -q`` against the supplied node ids.
|
||||
|
||||
Used to confirm the baseline-red registry resolves to real, currently
|
||||
collectible tests. If a test is renamed / moved / deleted out from
|
||||
under the registry, pytest's collection failure is the signal.
|
||||
"""
|
||||
return subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pytest",
|
||||
"--collect-only",
|
||||
"-q",
|
||||
*node_ids,
|
||||
],
|
||||
cwd=_REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def _run_pytest_quiet(targets: tuple[str, ...]) -> subprocess.CompletedProcess:
|
||||
"""Run ``pytest -q --tb=no -p no:cacheprovider`` against ``targets``.
|
||||
|
||||
``-p no:cacheprovider`` keeps the gate hermetic across reruns; the
|
||||
parent pytest invocation that triggers this child process must not
|
||||
poison or be poisoned by the child's cache state.
|
||||
"""
|
||||
return subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pytest",
|
||||
"-q",
|
||||
"--tb=no",
|
||||
"-p",
|
||||
"no:cacheprovider",
|
||||
*targets,
|
||||
],
|
||||
cwd=_REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def _parse_failed_node_ids(stdout: str) -> set[str]:
|
||||
"""Extract the set of FAILED node ids from pytest's ``--tb=no -q`` stdout."""
|
||||
return {match.group(1) for match in _FAILED_LINE_RE.finditer(stdout)}
|
||||
|
||||
|
||||
def _parse_error_node_ids(stdout: str) -> set[str]:
|
||||
"""Extract the set of ERROR node ids from pytest's ``--tb=no -q`` stdout."""
|
||||
return {match.group(1) for match in _ERROR_LINE_RE.finditer(stdout)}
|
||||
|
||||
|
||||
# === Tests ===
|
||||
|
||||
|
||||
def test_imp35_baseline_red_registry_has_exactly_four_node_ids() -> None:
|
||||
"""The baseline-red registry is a frozen four-tuple (Stage 2 u11 lock)."""
|
||||
assert len(IMP35_BASELINE_RED_NODE_IDS) == 4
|
||||
assert len(set(IMP35_BASELINE_RED_NODE_IDS)) == 4, (
|
||||
"IMP-35 baseline-red registry must not contain duplicate node ids; "
|
||||
"duplicates would silently weaken the invariance gate."
|
||||
)
|
||||
|
||||
|
||||
def test_imp35_baseline_red_registry_node_ids_are_well_formed() -> None:
|
||||
"""Each baseline-red node id must look like ``tests/<file>.py::<test>``."""
|
||||
for node_id in IMP35_BASELINE_RED_NODE_IDS:
|
||||
assert node_id.startswith("tests/"), (
|
||||
f"IMP-35 baseline-red registry node id {node_id!r} must live "
|
||||
"under tests/ — registry entries point at repo-rooted node ids."
|
||||
)
|
||||
assert ".py::" in node_id, (
|
||||
f"IMP-35 baseline-red registry node id {node_id!r} must use the "
|
||||
"<file>.py::<test_name> pytest node id grammar."
|
||||
)
|
||||
|
||||
|
||||
def test_imp35_baseline_red_registry_files_match_area_inventory() -> None:
|
||||
"""Registry node ids must all live in declared baseline-area files.
|
||||
|
||||
Locks the cross-axis link between :data:`IMP35_BASELINE_RED_NODE_IDS`
|
||||
and :data:`IMP35_BASELINE_RED_AREA_FILES` — adding a registry entry
|
||||
without expanding the area sweep (or vice versa) is the kind of
|
||||
half-wiring that would silently let the gate miss new reds.
|
||||
"""
|
||||
declared_files = set(IMP35_BASELINE_RED_AREA_FILES)
|
||||
for node_id in IMP35_BASELINE_RED_NODE_IDS:
|
||||
file_part, _, _ = node_id.partition("::")
|
||||
assert file_part in declared_files, (
|
||||
f"IMP-35 baseline-red registry entry {node_id!r} references "
|
||||
f"{file_part!r}, which is not in IMP35_BASELINE_RED_AREA_FILES. "
|
||||
"Update both lists together or the area sweep will miss new reds."
|
||||
)
|
||||
|
||||
|
||||
def test_imp35_baseline_red_node_ids_resolve_to_collectible_tests() -> None:
|
||||
"""``pytest --collect-only`` must resolve every baseline-red node id.
|
||||
|
||||
A failure here means a baseline-red test was renamed / deleted /
|
||||
moved out from under the gate; the registry must be updated in the
|
||||
same commit (or, if the test was fixed, the follow-up issue must
|
||||
deregister it).
|
||||
"""
|
||||
result = _run_pytest_collect_only(IMP35_BASELINE_RED_NODE_IDS)
|
||||
# ``pytest --collect-only`` exits 0 on full collection, 2/4/5 on
|
||||
# collection errors. Exit code 5 = no tests collected ("not found").
|
||||
assert result.returncode in (0,), (
|
||||
"pytest --collect-only failed for the IMP-35 baseline-red "
|
||||
f"registry (rc={result.returncode}).\n"
|
||||
f"STDOUT:\n{result.stdout}\n"
|
||||
f"STDERR:\n{result.stderr}"
|
||||
)
|
||||
|
||||
|
||||
def test_imp35_baseline_red_invariance_gate_failed_set_matches_registry() -> None:
|
||||
"""Running pytest on the baseline area must FAIL EXACTLY the registry.
|
||||
|
||||
This is the core invariance contract. If IMP-35 work breaks a 5th
|
||||
test in the baseline area, the FAILED set diverges from the registry
|
||||
and this gate trips. If IMP-35 accidentally fixes one of the four,
|
||||
the FAILED set shrinks below four and this gate also trips — at
|
||||
which point the registry is removed from the failing test (the
|
||||
follow-up issue deregisters it) and the gate is re-locked.
|
||||
"""
|
||||
result = _run_pytest_quiet(IMP35_BASELINE_RED_AREA_FILES)
|
||||
|
||||
# The baseline area is currently red: pytest MUST exit non-zero. A
|
||||
# zero return code here would mean the baseline magically went green
|
||||
# (or the parser missed the failures); both branches require human
|
||||
# review before the registry is updated.
|
||||
assert result.returncode != 0, (
|
||||
"IMP-35 baseline-red area is expected to fail (4 known reds). "
|
||||
"A clean pytest exit means either the baseline was unexpectedly "
|
||||
"fixed (deregister via follow-up issue) or the gate's subprocess "
|
||||
"did not reach the failing tests.\n"
|
||||
f"STDOUT:\n{result.stdout}\n"
|
||||
f"STDERR:\n{result.stderr}"
|
||||
)
|
||||
|
||||
failed_ids = _parse_failed_node_ids(result.stdout)
|
||||
error_ids = _parse_error_node_ids(result.stdout)
|
||||
expected = set(IMP35_BASELINE_RED_NODE_IDS)
|
||||
|
||||
assert error_ids == set(), (
|
||||
"IMP-35 baseline-red invariance gate found ERROR-state tests "
|
||||
f"in the baseline area (expected zero): {sorted(error_ids)}.\n"
|
||||
f"STDOUT:\n{result.stdout}"
|
||||
)
|
||||
|
||||
assert failed_ids == expected, (
|
||||
"IMP-35 baseline-red invariance gate detected drift between the "
|
||||
"registered baseline-red set and the actual pytest FAILED set.\n"
|
||||
f" registered (expected): {sorted(expected)}\n"
|
||||
f" actual (observed): {sorted(failed_ids)}\n"
|
||||
f" unexpected new reds: {sorted(failed_ids - expected)}\n"
|
||||
f" unexpectedly green: {sorted(expected - failed_ids)}\n"
|
||||
"If new reds appear above, IMP-35 has silently grown the red "
|
||||
"surface (u11 contract violation). If reds are unexpectedly "
|
||||
"green, the follow-up issue must deregister them.\n"
|
||||
f"STDOUT:\n{result.stdout}"
|
||||
)
|
||||
|
||||
|
||||
def test_imp35_baseline_red_invariance_gate_failed_count_is_exactly_four() -> None:
|
||||
"""Count-only assertion: the baseline area has exactly four FAILED nodes.
|
||||
|
||||
Complements the identity check above. Even if a parser bug or
|
||||
output-format change ever weakens the identity check, the bare count
|
||||
still catches the "did a new red sneak in?" failure mode.
|
||||
"""
|
||||
result = _run_pytest_quiet(IMP35_BASELINE_RED_AREA_FILES)
|
||||
failed_ids = _parse_failed_node_ids(result.stdout)
|
||||
assert len(failed_ids) == 4, (
|
||||
"IMP-35 baseline-red invariance gate expected exactly 4 FAILED "
|
||||
f"node ids in the baseline area; observed {len(failed_ids)}: "
|
||||
f"{sorted(failed_ids)}.\n"
|
||||
f"STDOUT:\n{result.stdout}"
|
||||
)
|
||||
|
||||
|
||||
def test_imp35_baseline_red_invariance_module_has_no_ai_imports() -> None:
|
||||
"""AI isolation contract — u11 invariance gate must stay pure stdlib.
|
||||
|
||||
Mirrors the structural import lock used by u6 / u7 / u10. The gate
|
||||
is deterministic-with-data (subprocess pytest + regex parse); any
|
||||
Anthropic SDK import or route through the AI fallback router would
|
||||
violate the ``feedback_ai_isolation_contract`` lock.
|
||||
|
||||
The check is AST-based so the assertion bodies (which reference
|
||||
forbidden tokens by name) do not self-trigger a string-substring
|
||||
false positive.
|
||||
"""
|
||||
forbidden_module_prefix = "anthropic"
|
||||
forbidden_attr_substring = "route_ai_fallback"
|
||||
|
||||
module_source = Path(__file__).read_text(encoding="utf-8")
|
||||
tree = ast.parse(module_source)
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
root = alias.name.split(".", 1)[0]
|
||||
assert root != forbidden_module_prefix, (
|
||||
"IMP-35 u11 invariance gate must not import the "
|
||||
f"Anthropic SDK (found ``import {alias.name}``)."
|
||||
)
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
if node.module is None:
|
||||
continue
|
||||
root = node.module.split(".", 1)[0]
|
||||
assert root != forbidden_module_prefix, (
|
||||
"IMP-35 u11 invariance gate must not import from the "
|
||||
f"Anthropic SDK (found ``from {node.module} import ...``)."
|
||||
)
|
||||
for alias in node.names:
|
||||
assert forbidden_attr_substring not in alias.name, (
|
||||
"IMP-35 u11 invariance gate must not route through the "
|
||||
"AI fallback router (found "
|
||||
f"``from {node.module} import {alias.name}``)."
|
||||
)
|
||||
elif isinstance(node, ast.Call):
|
||||
func = node.func
|
||||
if isinstance(func, ast.Name):
|
||||
assert forbidden_attr_substring not in func.id, (
|
||||
"IMP-35 u11 invariance gate must not call into the "
|
||||
f"AI fallback router (found call to ``{func.id}``)."
|
||||
)
|
||||
elif isinstance(func, ast.Attribute):
|
||||
assert forbidden_attr_substring not in func.attr, (
|
||||
"IMP-35 u11 invariance gate must not call into the "
|
||||
f"AI fallback router (found call to ``.{func.attr}``)."
|
||||
)
|
||||
@@ -0,0 +1,166 @@
|
||||
"""IMP-36 (Gitea #65) — P1/P2 fit/rotation generalization static checks.
|
||||
|
||||
Coupled with u2 (frame_contracts.yaml two-bool axis + F29 P3 parity).
|
||||
Asserts:
|
||||
(1) contract-level axis booleans on the 13 partial-backed contracts and
|
||||
their absence on the 19 builder-only contracts;
|
||||
(2) F29 P3 parity (both columns declare column_with_transform);
|
||||
(3) partial-side CSS signatures —
|
||||
P1 (rotation_eligible=true) → ``container-name: f<N>b-root`` +
|
||||
``container-type: size`` + ``@container <name> (aspect-ratio < 1.5)``.
|
||||
P2 (body_fit_pattern2=true) → ``--max-body-lines`` + ``cqh`` + ``clamp(``
|
||||
in the body line-height clamp.
|
||||
|
||||
Per Stage 2 plan the partial-side P1/P2 assertions for F13/F14/F20/F8 begin
|
||||
passing only after u4-u7 land. F23 (Stage 1 canonical P2 source) already
|
||||
satisfies P2 at u3 time. F23 explicitly stays P1=false (no rotation rule)
|
||||
per the in-file lock at templates/phase_z2/families/app_sw_package_vs_solution.html
|
||||
line 64.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
CONTRACTS_PATH = ROOT / "templates" / "phase_z2" / "catalog" / "frame_contracts.yaml"
|
||||
FAMILIES_DIR = ROOT / "templates" / "phase_z2" / "families"
|
||||
|
||||
|
||||
EXPECTED_P1_TRUE = {
|
||||
"three_parallel_requirements",
|
||||
"three_persona_benefits",
|
||||
"dx_sw_necessity_three_perspectives",
|
||||
"info_management_what_how_when",
|
||||
}
|
||||
EXPECTED_P1_FALSE = {
|
||||
"app_sw_package_vs_solution",
|
||||
"bim_current_problems_paired",
|
||||
"bim_dx_comparison_table",
|
||||
"bim_issues_quadrant_four",
|
||||
"construction_bim_three_usage",
|
||||
"construction_goals_three_circle_intersection",
|
||||
"pre_construction_model_info_stacked",
|
||||
"process_product_two_way",
|
||||
"sw_reality_three_emphasis",
|
||||
}
|
||||
EXPECTED_P2_TRUE = EXPECTED_P1_TRUE | {"app_sw_package_vs_solution"}
|
||||
EXPECTED_P2_FALSE = EXPECTED_P1_FALSE - {"app_sw_package_vs_solution"}
|
||||
|
||||
# P1 container-name convention = f<frame_id>b-root, declared in Stage 2 plan.
|
||||
CONTAINER_NAMES = {
|
||||
"three_parallel_requirements": "f13b-root",
|
||||
"three_persona_benefits": "f14b-root",
|
||||
"dx_sw_necessity_three_perspectives": "f20b-root",
|
||||
"info_management_what_how_when": "f8b-root",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def contracts() -> dict:
|
||||
return yaml.safe_load(CONTRACTS_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def partial_files() -> set[str]:
|
||||
return {p.stem for p in FAMILIES_DIR.glob("*.html")}
|
||||
|
||||
|
||||
# ─── contract metadata axis ────────────────────────────────────────────────
|
||||
def test_partial_backed_thirteen_carry_both_flags(contracts, partial_files):
|
||||
partial_backed = {k for k in contracts if k in partial_files}
|
||||
assert len(partial_backed) == 13, sorted(partial_backed)
|
||||
missing = [
|
||||
tid
|
||||
for tid in partial_backed
|
||||
if "rotation_eligible" not in contracts[tid]
|
||||
or "body_fit_pattern2" not in contracts[tid]
|
||||
]
|
||||
assert missing == [], missing
|
||||
bad_type = [
|
||||
tid
|
||||
for tid in partial_backed
|
||||
if not isinstance(contracts[tid]["rotation_eligible"], bool)
|
||||
or not isinstance(contracts[tid]["body_fit_pattern2"], bool)
|
||||
]
|
||||
assert bad_type == [], bad_type
|
||||
|
||||
|
||||
def test_builder_only_nineteen_carry_neither_flag(contracts, partial_files):
|
||||
builder_only = {k for k in contracts if k not in partial_files}
|
||||
assert len(builder_only) == 19, sorted(builder_only)
|
||||
leaked = [
|
||||
tid
|
||||
for tid in builder_only
|
||||
if "rotation_eligible" in contracts[tid] or "body_fit_pattern2" in contracts[tid]
|
||||
]
|
||||
assert leaked == [], leaked
|
||||
|
||||
|
||||
def test_rotation_eligible_true_set(contracts):
|
||||
actual = {k for k, v in contracts.items() if v.get("rotation_eligible") is True}
|
||||
assert actual == EXPECTED_P1_TRUE
|
||||
|
||||
|
||||
def test_rotation_eligible_false_set(contracts):
|
||||
actual = {k for k, v in contracts.items() if v.get("rotation_eligible") is False}
|
||||
assert actual == EXPECTED_P1_FALSE
|
||||
|
||||
|
||||
def test_body_fit_pattern2_true_set(contracts):
|
||||
actual = {k for k, v in contracts.items() if v.get("body_fit_pattern2") is True}
|
||||
assert actual == EXPECTED_P2_TRUE
|
||||
|
||||
|
||||
def test_body_fit_pattern2_false_set(contracts):
|
||||
actual = {k for k, v in contracts.items() if v.get("body_fit_pattern2") is False}
|
||||
assert actual == EXPECTED_P2_FALSE
|
||||
|
||||
|
||||
def test_f29_columns_both_with_transform(contracts):
|
||||
"""P3 parity — F29 (process_product_two_way) columns[*].body_parser symmetry."""
|
||||
cols = contracts["process_product_two_way"]["payload"]["builder_options"]["columns"]
|
||||
parsers = [c.get("body_parser") for c in cols]
|
||||
assert parsers == ["column_with_transform", "column_with_transform"], parsers
|
||||
|
||||
|
||||
# ─── partial-side CSS axis (u4-u7 progressively satisfy) ───────────────────
|
||||
@pytest.mark.parametrize("tid", sorted(EXPECTED_P1_TRUE))
|
||||
def test_p1_partial_declares_aspect_ratio_rotation(tid):
|
||||
"""P1=true partials declare ``container-name``/``container-type`` and an
|
||||
``@container <name> (aspect-ratio < 1.5)`` rotation rule. Satisfied by
|
||||
F13/F14/F20/F8 in u4-u7."""
|
||||
css = (FAMILIES_DIR / f"{tid}.html").read_text(encoding="utf-8")
|
||||
name = CONTAINER_NAMES[tid]
|
||||
assert f"container-name: {name}" in css, tid
|
||||
assert "container-type: size" in css, tid
|
||||
assert f"@container {name} (aspect-ratio < 1.5)" in css, tid
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tid", sorted(EXPECTED_P1_FALSE))
|
||||
def test_p1_false_partial_has_no_rotation_rule(tid):
|
||||
"""P1=false partials must not declare ``aspect-ratio < 1.5`` rotation
|
||||
rule. F23 may still keep its own container-name for P2 cqh — only the
|
||||
rotation rule signature is forbidden here."""
|
||||
css = (FAMILIES_DIR / f"{tid}.html").read_text(encoding="utf-8")
|
||||
assert "aspect-ratio < 1.5" not in css, tid
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tid", sorted(EXPECTED_P2_TRUE))
|
||||
def test_p2_partial_uses_cqh_clamp_max_body_lines(tid):
|
||||
"""P2=true partials declare ``--max-body-lines`` + ``cqh`` + ``clamp(``
|
||||
body line-height clamp. Satisfied by F23 today; F13/F14/F20/F8 land u4-u7."""
|
||||
css = (FAMILIES_DIR / f"{tid}.html").read_text(encoding="utf-8")
|
||||
assert "--max-body-lines" in css, tid
|
||||
assert "cqh" in css, tid
|
||||
assert "clamp(" in css, tid
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tid", sorted(EXPECTED_P2_FALSE))
|
||||
def test_p2_false_partial_has_no_max_body_lines(tid):
|
||||
"""P2=false partials must not declare ``--max-body-lines``."""
|
||||
css = (FAMILIES_DIR / f"{tid}.html").read_text(encoding="utf-8")
|
||||
assert "--max-body-lines" not in css, tid
|
||||
@@ -0,0 +1,299 @@
|
||||
"""IMP-36 (Gitea #65 u8) — Selenium self-fire for the P1/P2 generalization.
|
||||
|
||||
For each of the four P1+P2 partials (F13 ``three_parallel_requirements``,
|
||||
F14 ``three_persona_benefits``, F20 ``dx_sw_necessity_three_perspectives``,
|
||||
F8 ``info_management_what_how_when``), the partial's ``<style>`` block is
|
||||
rendered with a minimal structural skeleton inside a fixed-size outer div
|
||||
at two aspect ratios — wide (1200x675, aspect 1.78) and tall (600x600,
|
||||
aspect 1.0) — and verified live in headless Chrome:
|
||||
|
||||
* P1 (container-query rotation): grid-template-columns goes from 3 tracks
|
||||
(wide, aspect >= 1.5) to 1 track (tall, aspect < 1.5).
|
||||
* P2 (cqh/clamp line-height): computed line-height on the body text element
|
||||
differs between wide and tall because ``cqh`` scales with container height.
|
||||
* P2 invariant (Stage 2 guardrail #6 / IMP-36 contract): the additive P2
|
||||
rule body declares ``line-height: clamp(...)`` only — no ``font-size``
|
||||
mutation. Enforced by static text scan of each partial.
|
||||
|
||||
OVERFLOW_CASCADE_ORDER must remain a 4-tuple — the Step 17 cascade contract
|
||||
is not altered by IMP-36 (P1/P2 are CSS-only self-fire; no new Python stage
|
||||
is introduced — "no new Python surface" per Stage 2 plan).
|
||||
|
||||
Chromedriver resolution mirrors the pipeline order (``PROJECT_ROOT/
|
||||
chromedriver{,.exe}`` -> PATH -> Selenium Manager). When no driver resolves
|
||||
the suite skips; under ``PHASE_Z_REQUIRE_SELENIUM=1`` the skip becomes a
|
||||
strict xfail so CI cannot silently lose coverage.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_ai_fallback.step17 import OVERFLOW_CASCADE_ORDER
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
FAMILIES = PROJECT_ROOT / "templates" / "phase_z2" / "families"
|
||||
|
||||
|
||||
# ─── chromedriver guard (mirrors test_phase_z2_step14_image_check) ───
|
||||
|
||||
def _selenium_manager_resolvable() -> bool:
|
||||
try:
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.chrome.options import Options as _Opts
|
||||
except Exception:
|
||||
return False
|
||||
opts = _Opts()
|
||||
for arg in ("--headless=new", "--no-sandbox", "--disable-dev-shm-usage"):
|
||||
opts.add_argument(arg)
|
||||
try:
|
||||
drv = webdriver.Chrome(options=opts)
|
||||
except Exception:
|
||||
return False
|
||||
try:
|
||||
drv.quit()
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
def _chromedriver_resolvable() -> bool:
|
||||
for candidate in (PROJECT_ROOT / "chromedriver", PROJECT_ROOT / "chromedriver.exe"):
|
||||
if candidate.is_file():
|
||||
return True
|
||||
if shutil.which("chromedriver") or shutil.which("chromedriver.exe"):
|
||||
return True
|
||||
return _selenium_manager_resolvable()
|
||||
|
||||
|
||||
_REQUIRE_SELENIUM = os.environ.get("PHASE_Z_REQUIRE_SELENIUM") == "1"
|
||||
_DRIVER_AVAILABLE = _chromedriver_resolvable()
|
||||
|
||||
if not _DRIVER_AVAILABLE:
|
||||
if _REQUIRE_SELENIUM:
|
||||
pytestmark = pytest.mark.xfail(
|
||||
strict=True,
|
||||
reason="PHASE_Z_REQUIRE_SELENIUM=1 but chromedriver is unresolvable",
|
||||
)
|
||||
else:
|
||||
pytestmark = pytest.mark.skip(
|
||||
reason=(
|
||||
"chromedriver unresolvable (PROJECT_ROOT/chromedriver{,.exe} + PATH + Selenium Manager); "
|
||||
"set PHASE_Z_REQUIRE_SELENIUM=1 to make this a hard failure"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ─── frame harness table ─────────────────────────────────────────────
|
||||
# stem = partial filename (no .html)
|
||||
# root = top-level container-query class (target of container-type:size)
|
||||
# cols = grid class that rotates 3->1 under aspect < 1.5
|
||||
# col_inner = minimal markup for one column with one body text element.
|
||||
# The inline --max-body-lines value is chosen so the P2 clamp
|
||||
# does not saturate at both aspects (otherwise wide and tall
|
||||
# would compute identical line-height). F13 uses 20cqh / N so
|
||||
# N=8 splits the clamp band; F14/F20/F8 use 60cqh / N so N=20
|
||||
# splits theirs.
|
||||
# text_sel = CSS selector for the body text element to measure
|
||||
# p2_re = regex for the IMP-36 P2 rule body (must contain line-height
|
||||
# clamp and must NOT contain font-size)
|
||||
#
|
||||
# Font-size invariance is asserted uniformly for all four frames — IMP-36 P2
|
||||
# mutates line-height / --max-body-lines only (Stage 2 guardrail #6).
|
||||
FRAMES = [
|
||||
{
|
||||
"stem": "three_parallel_requirements",
|
||||
"root": "f13b",
|
||||
"cols": "f13b__cols",
|
||||
"col_inner": (
|
||||
'<div class="f13b__col"><div class="f13b__body">'
|
||||
'<div class="f13b__section"><div class="f13b__desc" '
|
||||
'style="--max-body-lines: 8;">'
|
||||
'<div class="text-line">line a</div>'
|
||||
'<div class="text-line">line b</div>'
|
||||
"</div></div></div></div>"
|
||||
),
|
||||
"text_sel": ".f13b__desc",
|
||||
"p2_re": r"\.f13b__desc\s*\{\s*line-height:\s*clamp\([^}]*\}",
|
||||
},
|
||||
{
|
||||
"stem": "three_persona_benefits",
|
||||
"root": "f14b",
|
||||
"cols": "f14b__cols",
|
||||
"col_inner": (
|
||||
'<div class="f14b__col"><div class="f14b__body" '
|
||||
'style="--max-body-lines: 20;">'
|
||||
'<div class="text-line">line a</div>'
|
||||
'<div class="text-line">line b</div>'
|
||||
"</div></div>"
|
||||
),
|
||||
"text_sel": ".f14b__body .text-line",
|
||||
"p2_re": r"\.f14b__body\s+\.text-line\s*\{\s*line-height:\s*clamp\([^}]*\}",
|
||||
},
|
||||
{
|
||||
"stem": "dx_sw_necessity_three_perspectives",
|
||||
"root": "f20b",
|
||||
"cols": "f20b__cols",
|
||||
"col_inner": (
|
||||
'<div class="f20b__col"><div class="f20b__body" '
|
||||
'style="--max-body-lines: 20;">'
|
||||
'<div class="text-line">line a</div>'
|
||||
'<div class="text-line">line b</div>'
|
||||
"</div></div>"
|
||||
),
|
||||
"text_sel": ".f20b__body .text-line",
|
||||
"p2_re": r"\.f20b__body\s+\.text-line\s*\{\s*line-height:\s*clamp\([^}]*\}",
|
||||
},
|
||||
{
|
||||
"stem": "info_management_what_how_when",
|
||||
"root": "f8b",
|
||||
"cols": "f8b__cols",
|
||||
"col_inner": (
|
||||
'<div class="f8b__col"><div class="f8b__body" '
|
||||
'style="--max-body-lines: 20;">'
|
||||
'<div class="text-line">line a</div>'
|
||||
'<div class="text-line">line b</div>'
|
||||
"</div></div>"
|
||||
),
|
||||
"text_sel": ".f8b__body .text-line",
|
||||
"p2_re": r"\.f8b__body\s+\.text-line\s*\{\s*line-height:\s*clamp\([^}]*\}",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _read_style_block(partial: Path) -> str:
|
||||
text = partial.read_text(encoding="utf-8")
|
||||
m = re.search(r"<style>(.*?)</style>", text, flags=re.DOTALL)
|
||||
assert m, f"<style> block missing in {partial}"
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def _harness_html(frame: dict, outer_w: int, outer_h: int) -> str:
|
||||
style = _read_style_block(FAMILIES / f"{frame['stem']}.html")
|
||||
cols_html = (
|
||||
f'<div class="{frame["cols"]}">' + (frame["col_inner"] * 3) + "</div>"
|
||||
)
|
||||
return (
|
||||
"<!doctype html><html><head><meta charset='utf-8'><style>"
|
||||
":root{"
|
||||
" --font-body:10px; --font-sub-title:12px; --font-zone-title:13px;"
|
||||
" --font-caption:10px;"
|
||||
" --lh-body:1.4; --lh-sub-title:1.3; --lh-zone-title:1.3;"
|
||||
"}"
|
||||
"html,body{margin:0;padding:0;font-size:10px;}"
|
||||
f".outer{{width:{outer_w}px;height:{outer_h}px;}}"
|
||||
f".outer > .{frame['root']}{{width:100%;height:100%;}}"
|
||||
f"{style}</style></head><body>"
|
||||
f'<div class="outer"><div class="{frame["root"]}">'
|
||||
f"{cols_html}"
|
||||
"</div></div></body></html>"
|
||||
)
|
||||
|
||||
|
||||
def _new_driver():
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.chrome.options import Options as _Opts
|
||||
opts = _Opts()
|
||||
for arg in ("--headless=new", "--no-sandbox", "--disable-dev-shm-usage"):
|
||||
opts.add_argument(arg)
|
||||
drv_path = None
|
||||
for cand in (PROJECT_ROOT / "chromedriver", PROJECT_ROOT / "chromedriver.exe"):
|
||||
if cand.is_file():
|
||||
drv_path = str(cand)
|
||||
break
|
||||
if drv_path is None:
|
||||
which = shutil.which("chromedriver") or shutil.which("chromedriver.exe")
|
||||
if which:
|
||||
drv_path = which
|
||||
if drv_path:
|
||||
from selenium.webdriver.chrome.service import Service
|
||||
return webdriver.Chrome(service=Service(executable_path=drv_path), options=opts)
|
||||
return webdriver.Chrome(options=opts)
|
||||
|
||||
|
||||
def _measure(drv, frame: dict, html_path: Path) -> dict:
|
||||
drv.get(html_path.resolve().as_uri())
|
||||
cols_tpl = drv.execute_script(
|
||||
"return getComputedStyle(document.querySelector(arguments[0])).gridTemplateColumns;",
|
||||
f".{frame['cols']}",
|
||||
)
|
||||
lh = drv.execute_script(
|
||||
"var el = document.querySelector(arguments[0]); "
|
||||
"return el ? getComputedStyle(el).lineHeight : null;",
|
||||
frame["text_sel"],
|
||||
)
|
||||
fs = drv.execute_script(
|
||||
"var el = document.querySelector(arguments[0]); "
|
||||
"return el ? getComputedStyle(el).fontSize : null;",
|
||||
frame["text_sel"],
|
||||
)
|
||||
tracks = [t for t in (cols_tpl or "").split() if t]
|
||||
return {"cols": cols_tpl, "tracks": len(tracks), "lh": lh, "fs": fs}
|
||||
|
||||
|
||||
# ─── live (Selenium) parametrized check ──────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize("frame", FRAMES, ids=[f["stem"] for f in FRAMES])
|
||||
def test_p1_rotation_and_p2_lineheight_self_fire(tmp_path: Path, frame: dict) -> None:
|
||||
"""P1: 3-track grid rotates to 1-track when aspect < 1.5.
|
||||
P2: line-height differs between aspects (cqh-driven clamp evaluates
|
||||
differently as container height changes).
|
||||
Font-size invariance is asserted uniformly for all four frames — IMP-36
|
||||
P2 mutates line-height / --max-body-lines only (Stage 2 guardrail #6)."""
|
||||
wide_path = tmp_path / f"{frame['stem']}_wide.html"
|
||||
tall_path = tmp_path / f"{frame['stem']}_tall.html"
|
||||
wide_path.write_text(_harness_html(frame, 1200, 600), encoding="utf-8")
|
||||
tall_path.write_text(_harness_html(frame, 400, 400), encoding="utf-8")
|
||||
|
||||
drv = _new_driver()
|
||||
try:
|
||||
drv.set_window_size(1400, 900)
|
||||
wide = _measure(drv, frame, wide_path)
|
||||
tall = _measure(drv, frame, tall_path)
|
||||
finally:
|
||||
try:
|
||||
drv.quit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
assert wide["tracks"] == 3, (frame["stem"], wide)
|
||||
assert tall["tracks"] == 1, (frame["stem"], tall)
|
||||
assert wide["lh"] is not None and tall["lh"] is not None, (frame["stem"], wide, tall)
|
||||
assert wide["lh"] != tall["lh"], (frame["stem"], wide, tall)
|
||||
assert wide["fs"] == tall["fs"], (frame["stem"], wide, tall)
|
||||
|
||||
|
||||
# ─── static (no-Selenium) guards ─────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize("frame", FRAMES, ids=[f["stem"] for f in FRAMES])
|
||||
def test_p2_rule_declares_line_height_only(frame: dict) -> None:
|
||||
"""IMP-36 P2 invariant — the additive P2 rule body must contain
|
||||
``line-height: clamp(`` and must NOT declare ``font-size``."""
|
||||
body = (FAMILIES / f"{frame['stem']}.html").read_text(encoding="utf-8")
|
||||
m = re.search(frame["p2_re"], body, flags=re.DOTALL)
|
||||
assert m, f"{frame['stem']}: P2 clamp rule not located via /{frame['p2_re']}/"
|
||||
rule_body = m.group(0)
|
||||
assert "line-height:" in rule_body, f"{frame['stem']}: P2 rule missing line-height: {rule_body!r}"
|
||||
assert "clamp(" in rule_body, f"{frame['stem']}: P2 rule missing clamp(: {rule_body!r}"
|
||||
assert "font-size" not in rule_body, (
|
||||
f"{frame['stem']}: P2 rule must not declare font-size — got {rule_body!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_overflow_cascade_order_is_four_tuple() -> None:
|
||||
"""IMP-36 must not alter the Step 17 cascade contract. P1/P2 are CSS-only
|
||||
self-fire (no new Python stage); the 4-tuple stays intact."""
|
||||
assert isinstance(OVERFLOW_CASCADE_ORDER, tuple)
|
||||
assert len(OVERFLOW_CASCADE_ORDER) == 4
|
||||
assert [stage.value for stage in OVERFLOW_CASCADE_ORDER] == [
|
||||
"deterministic",
|
||||
"popup",
|
||||
"ai_repair",
|
||||
"user_override",
|
||||
]
|
||||
@@ -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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user