import { test, expect } from "@playwright/test"; import { normalizeStoryScriptRows } from "@/lib/video-canvas/story-script"; import { validateVideoReferences } from "@/lib/video-canvas/reference-input"; import { preserveGeneratedVideo } from "@/lib/video-canvas/archive-result"; test("three requested shots retain their content and durations without cloned filler", () => { const rows = [{ sceneDescription: "杯子全景", duration: "3s" }, { sceneDescription: "杯柄特写", duration: "2s" }, { sceneDescription: "咖啡液面", duration: "3s" }]; const result = normalizeStoryScriptRows(rows); expect(result).toHaveLength(3); expect(result.map(row => row.sceneDescription)).toEqual(rows.map(row => row.sceneDescription)); expect(result.map(row => row.duration)).toEqual(["3s", "2s", "3s"]); }); test("empty model rows do not create invented shots", () => { expect(normalizeStoryScriptRows([null, {}, { shot: "01", duration: "3s" }, [], ["", ""], ["01"], ["01", "3s"]])).toEqual([]); }); test("same first and last frame survive while only probing the URL once", async () => { const seen: string[] = []; const images = ["https://example.com/one.png", "https://example.com/one.png"]; const out = await validateVideoReferences(images, async url => { seen.push(url); return "reachable"; }); expect(out.images).toEqual(images); expect(seen).toHaveLength(1); }); test("invalid references never silently turn into text-only generation", async () => { for (const input of [["not a url"], [null], "https://example.com/a.png", ["https://user:pass@example.com/a.png"]]) { let calls = 0; expect((await validateVideoReferences(input, async () => { calls++; return "reachable"; })).status).toBe(400); expect(calls).toBe(0); } }); test("temporary network errors are distinguished from dead images", async () => { const images = ["https://example.com/a.png"]; expect((await validateVideoReferences(images, async () => "unknown")).status).toBe(503); expect((await validateVideoReferences(images, async () => { throw new Error("network"); })).status).toBe(503); expect((await validateVideoReferences(images, async () => "unreachable")).status).toBe(400); }); test("archive success replaces URL and preserves generation metadata", async () => { const out = await preserveGeneratedVideo("https://provider.example/video", { duration: 8, model: "veo" }, async () => "https://storage.example/file.mp4"); expect(out).toMatchObject({ url: "https://storage.example/file.mp4", metadata: { storageStatus: "archived", duration: 8, model: "veo", temporaryVideoUrl: "https://provider.example/video" } }); }); test("archive failure preserves the generated output and cannot mark it permanently saved", async () => { for (const archive of [async () => { throw new Error("storage unavailable"); }, async (url: string) => url]) { const out = await preserveGeneratedVideo("https://provider.example/video", { permanentVideoUrl: "stale" }, archive); expect(out.url).toBe("https://provider.example/video"); expect(out.metadata.storageStatus).toBe("temporary"); expect(out.metadata.permanentVideoUrl).toBeUndefined(); } }); import { canvasGenerationCount, canvasImageRequest, canvasImageResultUrls } from '@/lib/video-canvas/generation-contract'; import { angleRequestParameters, anglePresets } from '@/lib/video-canvas/angle-settings'; import { fetchCanvasDownloadAsset } from '@/lib/canvas/photoshop-safe-download'; test('image composer transmits resolution and all references without injecting demo content',()=>{ for(const resolution of ['1K','2K','4K']) { const request=canvasImageRequest({model:'configured-image',ratio:'9:16',resolution,count:'4张'},[]); expect(request).toMatchObject({model:'configured-image',aspectRatio:'9:16',imageSize:resolution,imageCount:4,referenceImages:[]}); expect(request.referenceImage).toBeUndefined(); } expect(canvasImageRequest({ratio:'1:1',resolution:'2K',count:'2'},['https://a.test/1','https://a.test/1','https://a.test/2'])).toMatchObject({imageCount:2,referenceImage:'https://a.test/1',referenceImages:['https://a.test/2']}); }); test('all returned image results survive, while failures never expose fallback images',()=>{ const data={images:[{originalUrl:'a',thumbnailUrl:'thumb'},{originalUrl:'b'},{thumbnailUrl:'c'},{}]}; expect(canvasImageResultUrls({success:true,data})).toEqual(['a','b','c']); expect(canvasImageResultUrls({success:false,data})).toEqual([]); expect(['1个','2个','4个','Infinity','100','0'].map(canvasGenerationCount)).toEqual([1,2,4,1,1,1]); }); test('angle controls convert degrees and percent zoom into provider units',()=>{ expect(angleRequestParameters({horizontalAngle:-90,verticalAngle:-45,zoom:50})).toEqual({horizontalAngle:270,verticalAngle:-30,zoom:5}); expect(angleRequestParameters(anglePresets['全景俯拍'])).toEqual({horizontalAngle:0,verticalAngle:90,zoom:0}); expect(anglePresets['鱼眼视角'].prompt).toContain('鱼眼'); expect(()=>angleRequestParameters({horizontalAngle:NaN,verticalAngle:0,zoom:50})).toThrow(); }); test('audio downloads preserve original bytes and extension instead of passing through an image decoder',async()=>{ const original=globalThis.fetch; const bytes=Buffer.from('RIFF00000000WAVEfmt synthetic QA audio'); globalThis.fetch=async()=>new Response(bytes,{headers:{'content-type':'audio/wav'}}); try { const result=await fetchCanvasDownloadAsset({file:{url:'https://cdn.example/audio.wav',name:'配音'},index:0,requestOrigin:'https://canvas.example',cookie:'',retryCount:1}); expect(result.ok).toBe(true); if(result.ok){expect(result.bytes).toEqual(bytes);expect(result.filename).toMatch(/\.wav$/);expect(result.contentType).toBe('audio/wav');} } finally {globalThis.fetch=original;} });