|
| 1 | +// @vitest-environment jsdom |
| 2 | +import { describe, expect, it, vi } from "vitest"; |
| 3 | +import { studioFrame, type FrameToolDeps, type StudioFrameResult } from "./frameTools"; |
| 4 | +import type { ToolFailure, ToolResult } from "../toolResult"; |
| 5 | + |
| 6 | +function frameDeps(overrides: Partial<FrameToolDeps> = {}): FrameToolDeps { |
| 7 | + return { |
| 8 | + getProjectId: () => "demo", |
| 9 | + getCompositionPath: () => "index.html", |
| 10 | + readPlayhead: () => ({ currentTime: 2.4, duration: 10, isPlaying: false }), |
| 11 | + requestSeek: () => undefined, |
| 12 | + probeFrame: async () => ({ ok: true, status: 200 }), |
| 13 | + wait: async () => undefined, |
| 14 | + ...overrides, |
| 15 | + }; |
| 16 | +} |
| 17 | + |
| 18 | +function expectOk<T>(result: ToolResult<T>): { ok: true } & T { |
| 19 | + if (!result.ok) throw new Error(`expected ok, got ${JSON.stringify(result)}`); |
| 20 | + return result; |
| 21 | +} |
| 22 | + |
| 23 | +function expectFailure(result: ToolResult<unknown>): ToolFailure { |
| 24 | + if (result.ok) throw new Error(`expected failure, got ${JSON.stringify(result)}`); |
| 25 | + return result; |
| 26 | +} |
| 27 | + |
| 28 | +describe("studioFrame", () => { |
| 29 | + it("returns a URL for the composition at the playhead", async () => { |
| 30 | + const result = await studioFrame(frameDeps()); |
| 31 | + |
| 32 | + const ok = expectOk<StudioFrameResult>(result); |
| 33 | + expect(ok.time).toBe(2.4); |
| 34 | + expect(ok.compositionPath).toBe("index.html"); |
| 35 | + expect(ok.url).toContain("/thumbnail/"); |
| 36 | + expect(ok.url).toContain("t=2.400"); |
| 37 | + expect(ok.url).toContain("format=png"); |
| 38 | + }); |
| 39 | + |
| 40 | + it("seeks first when given a time", async () => { |
| 41 | + const requestSeek = vi.fn(); |
| 42 | + |
| 43 | + await studioFrame(frameDeps({ requestSeek }), { time: 5 }); |
| 44 | + |
| 45 | + expect(requestSeek).toHaveBeenCalledWith(5); |
| 46 | + }); |
| 47 | + |
| 48 | + it("captures where the playhead LANDED, not what was asked for", async () => { |
| 49 | + // The player clamps. Reporting the request would attach the wrong time to |
| 50 | + // the frame, and an agent judging motion would draw the wrong conclusion. |
| 51 | + const result = await studioFrame( |
| 52 | + frameDeps({ readPlayhead: () => ({ currentTime: 10, duration: 10, isPlaying: false }) }), |
| 53 | + { time: 999 }, |
| 54 | + ); |
| 55 | + |
| 56 | + const ok = expectOk<StudioFrameResult>(result); |
| 57 | + expect(ok.time).toBe(10); |
| 58 | + expect(ok.url).toContain("t=10.000"); |
| 59 | + }); |
| 60 | + |
| 61 | + it("waits before capturing, so a just-made edit is in the frame", async () => { |
| 62 | + // The render cache is cleared by a file watcher with a write-stability |
| 63 | + // threshold. Capturing faster than that renders the PRE-edit composition. |
| 64 | + const wait = vi.fn(async () => undefined); |
| 65 | + const order: string[] = []; |
| 66 | + |
| 67 | + await studioFrame( |
| 68 | + frameDeps({ |
| 69 | + wait: async (ms) => { |
| 70 | + order.push(`wait:${ms}`); |
| 71 | + await wait(); |
| 72 | + }, |
| 73 | + probeFrame: async () => { |
| 74 | + order.push("probe"); |
| 75 | + return { ok: true, status: 200 }; |
| 76 | + }, |
| 77 | + }), |
| 78 | + ); |
| 79 | + |
| 80 | + expect(order).toEqual(["wait:150", "probe"]); |
| 81 | + }); |
| 82 | + |
| 83 | + it("honours a caller-supplied settle time and reports it", async () => { |
| 84 | + const result = await studioFrame(frameDeps(), { settleMs: 800 }); |
| 85 | + |
| 86 | + expect(expectOk<StudioFrameResult>(result).settledMs).toBe(800); |
| 87 | + }); |
| 88 | + |
| 89 | + it("clamps an absurd settle time rather than hanging", async () => { |
| 90 | + const result = await studioFrame(frameDeps(), { settleMs: 10 * 60 * 1000 }); |
| 91 | + |
| 92 | + expect(expectOk<StudioFrameResult>(result).settledMs).toBe(5000); |
| 93 | + }); |
| 94 | + |
| 95 | + it("falls back to the default for a nonsense settle time", async () => { |
| 96 | + for (const settleMs of [-1, Number.NaN]) { |
| 97 | + const result = await studioFrame(frameDeps(), { settleMs }); |
| 98 | + expect(expectOk<StudioFrameResult>(result).settledMs).toBe(150); |
| 99 | + } |
| 100 | + }); |
| 101 | + |
| 102 | + it("skips the wait entirely when asked for zero", async () => { |
| 103 | + const wait = vi.fn(async () => undefined); |
| 104 | + |
| 105 | + await studioFrame(frameDeps({ wait }), { settleMs: 0 }); |
| 106 | + |
| 107 | + expect(wait).not.toHaveBeenCalled(); |
| 108 | + }); |
| 109 | + |
| 110 | + it("reports a renderer failure instead of handing back a dead URL", async () => { |
| 111 | + const result = expectFailure( |
| 112 | + await studioFrame(frameDeps({ probeFrame: async () => ({ ok: false, status: 500 }) })), |
| 113 | + ); |
| 114 | + |
| 115 | + expect(result.kind).toBe("failed"); |
| 116 | + expect(result.reason).toContain("500"); |
| 117 | + expect(result.hint).toBeDefined(); |
| 118 | + }); |
| 119 | + |
| 120 | + it("fails when no project is open, before touching the renderer", async () => { |
| 121 | + const probeFrame = vi.fn(); |
| 122 | + |
| 123 | + const result = expectFailure( |
| 124 | + await studioFrame(frameDeps({ getProjectId: () => null, probeFrame })), |
| 125 | + ); |
| 126 | + |
| 127 | + expect(result.kind).toBe("blocked"); |
| 128 | + expect(probeFrame).not.toHaveBeenCalled(); |
| 129 | + }); |
| 130 | + |
| 131 | + it("rejects a negative or non-finite time without seeking", async () => { |
| 132 | + const requestSeek = vi.fn(); |
| 133 | + |
| 134 | + for (const time of [-1, Number.NaN, Number.POSITIVE_INFINITY]) { |
| 135 | + const result = expectFailure(await studioFrame(frameDeps({ requestSeek }), { time })); |
| 136 | + expect(result.kind).toBe("invalid"); |
| 137 | + } |
| 138 | + expect(requestSeek).not.toHaveBeenCalled(); |
| 139 | + }); |
| 140 | + |
| 141 | + it("captures the master composition when no path is active", async () => { |
| 142 | + const result = await studioFrame(frameDeps({ getCompositionPath: () => null })); |
| 143 | + |
| 144 | + expect(expectOk<StudioFrameResult>(result).compositionPath).toBe("index.html"); |
| 145 | + }); |
| 146 | +}); |
0 commit comments