feat(#90): IMP-56 u20 BottomActions wiring to /api/connect + /api/export (replace placeholder toasts + standalone HTML download + cel mirror connect; pure builders exported for vitest)
Some checks failed
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 20s
Some checks failed
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 20s
Stage 2 final unit for Step 22 (user edit + export). u20 wires the previously
placeholder bottom-action footer to the u18 /api/connect and u19 /api/export
middlewares living in Front/vite.config.ts:
- BottomActions.tsx
• drops the dead `serializeSlidePlan` import (TS2305 blocker since u14;
project-wide `tsc --noEmit` now exits 0)
• exports three pure builders for vitest (no jsdom / RTL devDep needed):
buildConnectRequest(run_id, slug) -> POST /api/connect {run_id, slug}
buildExportRequest(run_id) -> POST /api/export {run_id}
buildDownloadFilename(run_id) -> "<run_id>.html"
• handleExport: POST -> blob -> a[download] click chain; toast on
success / failure / network error.
• handleConnect: derives slug via deriveUserOverridesKey(uploadedFile.name)
and PUTs to u18 cel mirror; reports assets_copied count.
• both buttons disable when runMeta is null so the UI cannot fire
requests with an undefined run_id.
- Home.tsx
• mounts <BottomActions/> in the footer with
{slidePlan, runMeta, uploadedFile, isLoading, onGenerate}.
• removes 2 of 3 placeholder `toast.info('… 준비 중입니다.')` buttons
(LeftMdxPanel MDX-edit placeholder remains — out of u20 scope).
• adds handleTextEdit (u15 wire to text_overrides axis) and
handleStructureEdit (u15 wire to structure_overrides axis) to satisfy
the SlideCanvas props introduced earlier in the u-series.
- imp90_bottom_actions.test.ts (new)
• 11 vitest specs locking the builder URL + JSON shape against u18/u19
middleware contracts. Verified 11/11 pass.
Stage 4 verification (all PASS):
• u20 vitest: 11/11
• u18/u19 endpoint vitest: 31/31
• npx tsc --noEmit: exit 0 (carry-forward TS2305 resolved)
• backend pytest (u1~u9 + u17 print mode, 9 files): 185/185
Out of scope:
• LeftMdxPanel.tsx:333 MDX-edit placeholder toast (separate unit)
• #1 / #72 / #74 / #79 / #80 / #81 / #93 closed dependencies (no re-impl)
• AI-generated HTML structure (Phase Z regression guard)
• frame swap via structure_overrides (locked to slot_order + hidden_slots)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 { Sparkles, Download, Link2, Loader2 } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { SlidePlan, UserSelection } from "../types/designAgent";
|
import type { SlidePlan } from "../types/designAgent";
|
||||||
import { serializeSlidePlan } from "../utils/slidePlanUtils";
|
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 {
|
interface BottomActionsProps {
|
||||||
slidePlan: SlidePlan | null;
|
slidePlan: SlidePlan | null;
|
||||||
userSelection: UserSelection;
|
runMeta: RunMeta | null;
|
||||||
|
uploadedFile: File | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
onGenerate: () => void;
|
onGenerate: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BottomActions({
|
export default function BottomActions({
|
||||||
slidePlan,
|
slidePlan,
|
||||||
userSelection,
|
runMeta,
|
||||||
|
uploadedFile,
|
||||||
isLoading,
|
isLoading,
|
||||||
onGenerate,
|
onGenerate,
|
||||||
}: BottomActionsProps) {
|
}: BottomActionsProps) {
|
||||||
const handleDownload = () => {
|
const [isConnecting, setIsConnecting] = useState(false);
|
||||||
if (!slidePlan) {
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
toast.error("슬라이드 플랜이 없습니다. 먼저 생성하기를 눌러주세요.");
|
|
||||||
|
const runReady = !!runMeta && !!slidePlan;
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
if (!runMeta) {
|
||||||
|
toast.error("Run 산출물이 없습니다. 먼저 생성하기를 눌러주세요.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setIsExporting(true);
|
||||||
const json = serializeSlidePlan(slidePlan, userSelection);
|
try {
|
||||||
console.log("[Download] SlidePlan JSON:", json);
|
const exportReq = buildExportRequest(runMeta.run_id);
|
||||||
|
const resp = await fetch(exportReq.url, {
|
||||||
// JSON 파일 다운로드
|
method: "POST",
|
||||||
const blob = new Blob([json], { type: "application/json" });
|
headers: { "Content-Type": "application/json" },
|
||||||
const url = URL.createObjectURL(blob);
|
body: exportReq.body,
|
||||||
const a = document.createElement("a");
|
});
|
||||||
a.href = url;
|
if (!resp.ok) {
|
||||||
a.download = `slide-plan-${Date.now()}.json`;
|
const text = await resp.text();
|
||||||
a.click();
|
toast.error(`Export 실패 (${resp.status}): ${text.slice(0, 160)}`);
|
||||||
URL.revokeObjectURL(url);
|
return;
|
||||||
|
}
|
||||||
toast.success("SlidePlan JSON이 다운로드되었습니다.");
|
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 = () => {
|
const handleConnect = async () => {
|
||||||
toast.info("연동하기 기능은 파이프라인 연결 후 활성화됩니다.");
|
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 (
|
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-3">
|
||||||
<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>
|
|
||||||
{/* 생성하기 */}
|
|
||||||
<Button
|
<Button
|
||||||
onClick={onGenerate}
|
onClick={onGenerate}
|
||||||
disabled={isLoading}
|
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"
|
size="default"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{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>
|
||||||
|
|
||||||
{/* 다운로드 */}
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleDownload}
|
onClick={handleExport}
|
||||||
disabled={!slidePlan || isLoading}
|
disabled={!runReady || isExporting || isLoading}
|
||||||
className="gap-2 min-w-[120px]"
|
className="gap-2 h-9 text-[11px] font-bold uppercase tracking-widest border-slate-200"
|
||||||
size="default"
|
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>
|
||||||
|
|
||||||
{/* 연동하기 */}
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleConnect}
|
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"
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import {
|
|||||||
moveSectionToZone,
|
moveSectionToZone,
|
||||||
saveZoneSizes,
|
saveZoneSizes,
|
||||||
saveImageOverride,
|
saveImageOverride,
|
||||||
|
saveTextOverride,
|
||||||
|
saveStructureOverride,
|
||||||
deriveUserOverridesKey,
|
deriveUserOverridesKey,
|
||||||
applyPersistedNonFrameOverrides,
|
applyPersistedNonFrameOverrides,
|
||||||
remapPersistedFramesToZoneFrames,
|
remapPersistedFramesToZoneFrames,
|
||||||
@@ -41,8 +43,9 @@ import LeftMdxPanel from "../components/LeftMdxPanel";
|
|||||||
import SlideCanvas from "../components/SlideCanvas";
|
import SlideCanvas from "../components/SlideCanvas";
|
||||||
import LayoutPanel from "../components/LayoutPanel";
|
import LayoutPanel from "../components/LayoutPanel";
|
||||||
import FramePanel from "../components/FramePanel";
|
import FramePanel from "../components/FramePanel";
|
||||||
|
import BottomActions from "../components/BottomActions";
|
||||||
import {
|
import {
|
||||||
Sparkles, Download, Link2, Loader2,
|
Sparkles, Loader2,
|
||||||
CheckCircle2, HelpCircle,
|
CheckCircle2, HelpCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -680,6 +683,51 @@ export default function Home() {
|
|||||||
setHasPendingChanges(true);
|
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.
|
// pending mode 일 때 effectiveSlidePlan = pendingZones 가 swap 된 plan.
|
||||||
// 그 외 = state.slidePlan. 모든 zone / region lookup 이 일관되게 이걸 사용 →
|
// 그 외 = state.slidePlan. 모든 zone / region lookup 이 일관되게 이걸 사용 →
|
||||||
// pending mode 의 region.id ("pending-region-N") 가 zone_frames key 로 들어가
|
// pending mode 의 region.id ("pending-region-N") 가 zone_frames key 로 들어가
|
||||||
@@ -868,6 +916,9 @@ export default function Home() {
|
|||||||
onZoneResize={handleZoneResize}
|
onZoneResize={handleZoneResize}
|
||||||
imageOverrides={state.userSelection.overrides.image_overrides}
|
imageOverrides={state.userSelection.overrides.image_overrides}
|
||||||
onImageResize={handleImageResize}
|
onImageResize={handleImageResize}
|
||||||
|
onTextEdit={handleTextEdit}
|
||||||
|
structureOverrides={state.userSelection.overrides.structure_overrides}
|
||||||
|
onStructureEdit={handleStructureEdit}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -910,11 +961,13 @@ export default function Home() {
|
|||||||
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-tighter">Phase Z Engine Active</span>
|
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-tighter">Phase Z Engine Active</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<BottomActions
|
||||||
<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>
|
slidePlan={state.slidePlan}
|
||||||
<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>
|
runMeta={runMeta}
|
||||||
<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>
|
uploadedFile={state.uploadedFile}
|
||||||
</div>
|
isLoading={state.isLoading}
|
||||||
|
onGenerate={handleGenerate}
|
||||||
|
/>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
90
Front/client/tests/imp90_bottom_actions.test.ts
Normal file
90
Front/client/tests/imp90_bottom_actions.test.ts
Normal file
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user