import test, { mock } from "node:test"; import assert from "node:assert/strict"; // Minimal browser stand-ins: Audio plays instantly and reports onended. class FakeAudio { constructor(url) { this.url = url; FakeAudio.last = this; } play() { setTimeout(() => this.onended?.(), 0); return Promise.resolve(); } pause() { this.paused = true; } } // run.js loads every test file into one process, so the real globals must come back // after each test here - the integration tests later use the real fetch. const originalFetch = globalThis.fetch; const originalAudio = globalThis.Audio; function useStubs(fetchStub) { globalThis.Audio = FakeAudio; globalThis.fetch = fetchStub; } function restoreStubs() { globalThis.fetch = originalFetch; globalThis.Audio = originalAudio; } const { speakWithPiper, isPiperSpeaking, stopPiperSpeech } = await import("../src/piper_speech.js"); const flush = () => new Promise((resolve) => setTimeout(resolve, 5)); test("when Piper is unavailable the browser voice takes over", async () => { useStubs(async () => new Response("{}", { status: 503 })); try { let fallbackCalls = 0; assert.equal(speakWithPiper("Hej", { language: "sv", fallback: () => { fallbackCalls += 1; return true; } }), true); await flush(); assert.equal(fallbackCalls, 1); assert.equal(isPiperSpeaking(), false); } finally { restoreStubs(); } }); test("a slow generation is abandoned after the timeout and falls back", async () => { mock.timers.enable({ apis: ["setTimeout"] }); try { useStubs((_url, { signal }) => new Promise((_resolve, reject) => signal.addEventListener("abort", () => reject(new Error("aborted"))))); let fellBack = false; speakWithPiper("Hej", { language: "sv", fallback: () => { fellBack = true; return true; } }); mock.timers.tick(2499); await Promise.resolve(); assert.equal(fellBack, false, "still waiting just before the timeout"); mock.timers.tick(1); for (let i = 0; i < 5; i += 1) await Promise.resolve(); assert.equal(fellBack, true); } finally { mock.timers.reset(); stopPiperSpeech(); restoreStubs(); } }); test("a generated line plays and reports onEnd, and a non-interrupting line is refused while busy", async () => { useStubs(async () => new Response(new Blob(["RIFF"], { type: "audio/wav" }), { status: 200 })); try { let ended = false; assert.equal(speakWithPiper("Heat ett", { language: "sv", volume: 0.4, onEnd: () => { ended = true; }, fallback: () => true }), true); assert.equal(speakWithPiper("Queued", { language: "sv", fallback: () => true }), false, "busy and not interrupting"); await flush(); assert.equal(FakeAudio.last.volume, 0.4); assert.equal(ended, true); assert.equal(isPiperSpeaking(), false); } finally { restoreStubs(); } }); test("an interrupting line stops the one in progress", async () => { useStubs(() => new Promise(() => {})); try { let firstFellBack = false; speakWithPiper("First", { language: "sv", fallback: () => { firstFellBack = true; return true; } }); assert.equal(speakWithPiper("Second", { language: "sv", interrupt: true, fallback: () => true }), true); await flush(); assert.equal(firstFellBack, false, "a cancelled line must not fall back and talk over the new one"); } finally { stopPiperSpeech(); restoreStubs(); } });