evOo-local/apps/web/src/lib/oo-sound.test.ts
cynarAI 5ae8f15931 feat(web): Ingo UX — Proaktivität, Oo-Gesicht, dezentes Audio (#76)
* feat(web): Ingo UX — proactivity signals, Oo face richness, soft audio

Priority S: Heute Brief buckets (Needs you ≤3 / Doing / Done), roster doing-verbs
and Braucht-dich chips, sidebar badge on Heute, need-you ping on waiting states.

Priority A/B: Web Audio cues (app-start shimmer, interact tick, success-resolve,
need-you ouh), reaction vocals with cooldown, optional eOo cold-open voice mark.
Oo eyes gain iris/specular detail and asymmetric blink; hands reduced to rest
except punctuation poses; speaking mouth animates.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix(web): biome format and unused import in HeuteBrief

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix(web): a11y and import order for Heute badge

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* docs+motion: Motion God craft — pose stagger, audio bus, ducking

Add MOTION-EVOO-CHARACTER.md (Brand Soul brief): eyes lead, mouth ~80%,
hands punctuation, 40–80 ms eye→mouth lag, 220–320 ms springs, SFX LUFS
and duck-under-TTS/PTT, nod mh/Mhh, reduced-motion rules.

Implement motion-tokens, staggered mouthPose in EvooMascot, shared
oo-audio-bus with voice-lane ducking, mint→gold success, hm nod vocal.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix(web): align conversation roster with bot-only sidebar groups after rebase

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-09-22 14:39:40 +02:00

106 lines
2.6 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { type OoSoundCue, ooSound } from "./oo-sound";
import { readOoSoundEnabled, writeOoSoundEnabled } from "./oo-sound-pref";
describe("oo-sound-pref", () => {
const storage = new Map<string, string>();
beforeEach(() => {
storage.clear();
const localStorage = {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => {
storage.set(key, value);
},
removeItem: (key: string) => {
storage.delete(key);
},
};
vi.stubGlobal("localStorage", localStorage);
vi.stubGlobal("window", { localStorage });
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("defaults sound to enabled", () => {
expect(readOoSoundEnabled()).toBe(true);
});
it("persists disabled preference", () => {
writeOoSoundEnabled(false);
expect(readOoSoundEnabled()).toBe(false);
writeOoSoundEnabled(true);
expect(readOoSoundEnabled()).toBe(true);
});
});
describe("ooSound", () => {
class FakeContext {
state: AudioContextState = "running";
currentTime = 0;
destination = {};
createGain() {
return {
gain: { value: 0, setValueAtTime: vi.fn(), exponentialRampToValueAtTime: vi.fn() },
connect: vi.fn(),
};
}
createOscillator() {
return {
type: "sine",
frequency: { setValueAtTime: vi.fn() },
connect: vi.fn(),
start: vi.fn(),
stop: vi.fn(),
};
}
resume = vi.fn(async () => undefined);
}
beforeEach(() => {
vi.stubGlobal("AudioContext", FakeContext);
vi.stubGlobal("window", {
matchMedia: () => ({
matches: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}),
});
vi.stubGlobal("document", { visibilityState: "visible" });
writeOoSoundEnabled(true);
ooSound.setEnabled(true);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("plays procedural cues when enabled", () => {
const cues: OoSoundCue[] = [
"app-start",
"interact",
"success-resolve",
"need-you",
"work-complete",
"listening-start",
"listening-stop",
"speaking-start",
"speaking-done",
"attention",
"message-sent",
"handoff",
"group-spark",
];
for (const cue of cues) {
expect(() => ooSound.play(cue)).not.toThrow();
}
});
it("skips playback when disabled", () => {
ooSound.setEnabled(false);
expect(() => ooSound.play("work-complete")).not.toThrow();
});
});