jam-buddy / apps /web /__tests__ /conversation.test.ts
salgadev's picture
Sync from GitHub 6feaf31d
b2e4883 verified
Raw
History Blame Contribute Delete
3.98 kB
/**
* Voice conversation FSM tests.
*
* Pure transition tests β€” no DOM, no audio. Verify each state transition
* produces the expected next state and side-effects.
*/
import { describe, it, expect } from "vitest";
import { transition } from "../lib/voice/conversation";
import type { ParseResult } from "@patterntalk/shared-types";
describe("voice FSM β€” happy path", () => {
it("idle β†’ listening β†’ parsing β†’ generating β†’ ready on a clean prompt", () => {
let s = transition("idle", {}, { type: "START_LISTENING" });
expect(s.next).toBe("listening");
expect(s.sideEffects.map((e) => e.type)).toContain("START_MIC");
s = transition(s.next, s.context, { type: "TRANSCRIPT_FINAL", transcript: "d-beat at 180" });
expect(s.next).toBe("parsing");
const parseOk: ParseResult = {
ok: true,
confidence: 0.95,
request: {
patternId: "d-beat",
patternName: "D-Beat",
bars: 4,
tempoSource: "prompt",
tempoDefault: 120,
timeSignature: { numerator: 4, denominator: 4 },
tempo: 180,
},
};
s = transition(s.next, s.context, { type: "PARSE_OK", result: parseOk });
expect(s.next).toBe("generating");
s = transition(s.next, s.context, {
type: "GENERATION_OK",
sampleUrl: "http://localhost:8001/cache/abc.wav",
});
expect(s.next).toBe("ready");
expect(s.context.parsed?.patternId).toBe("d-beat");
expect(s.context.sampleUrl).toBe("http://localhost:8001/cache/abc.wav");
});
});
describe("voice FSM β€” low confidence β†’ confirmation", () => {
it("parsing β†’ awaiting-confirmation when PARSE_LOW_CONFIDENCE fires", () => {
const lowConf: ParseResult = {
ok: false,
confidence: 0.4,
heardAs: "tupatupa",
candidates: [
{
request: {
patternId: "skank",
patternName: "Skank Beat",
bars: 4,
tempoSource: "default",
tempoDefault: 120,
timeSignature: { numerator: 4, denominator: 4 },
},
confidence: 0.4,
reason: "phonetic match",
},
],
};
const s = transition("parsing", {}, { type: "PARSE_LOW_CONFIDENCE", result: lowConf });
expect(s.next).toBe("awaiting-confirmation");
const speak = s.sideEffects.find((e) => e.type === "SPEAK");
expect(speak).toBeDefined();
if (speak && speak.type === "SPEAK") {
expect(speak.text).toContain("Skank Beat");
}
});
});
describe("voice FSM β€” error states", () => {
it("any state β†’ error on ERROR event", () => {
const s = transition("listening", {}, {
type: "ERROR",
kind: "mic-denied",
message: "Microphone access denied. Enable it in browser settings.",
});
expect(s.next).toBe("error");
expect(s.context.error?.kind).toBe("mic-denied");
});
it("GENERATION_FAIL routes to error state", () => {
const s = transition("generating", {}, {
type: "GENERATION_FAIL",
message: "Sample generation timed out. MIDI is still ready.",
});
expect(s.next).toBe("error");
expect(s.context.error?.message).toContain("timed out");
});
});
describe("voice FSM β€” RESET", () => {
it("RESET from any state returns to idle", () => {
const s1 = transition("ready", { parsed: { patternId: "x", bars: 4, tempoSource: "default", tempoDefault: 120, timeSignature: { numerator: 4, denominator: 4 } } }, { type: "RESET" });
expect(s1.next).toBe("idle");
expect(s1.context).toEqual({});
});
});
describe("voice FSM β€” ignores stray events", () => {
it("TRANSCRIPT_FINAL while idle is ignored", () => {
const s = transition("idle", {}, { type: "TRANSCRIPT_FINAL", transcript: "hi" });
expect(s.next).toBe("idle");
expect(s.sideEffects).toHaveLength(0);
});
it("PLAY_START while idle is ignored", () => {
const s = transition("idle", {}, { type: "PLAY_START" });
expect(s.next).toBe("idle");
});
});