| |
| |
| |
| |
| |
| |
|
|
| 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"); |
| }); |
| }); |
|
|