返回 DeepSeek-Reasonix
sound.test.ts
根目录 / desktop / frontend / src / __tests__ / sound.test.ts
1 // Run: tsx src/__tests__/sound.test.ts
2
3 import {
4 DEFAULT_NOTIFICATION_VOLUME,
5 NOTIFICATION_VOLUME_STORAGE_KEY,
6 attentionChimeEventKey,
7 clearAttentionChimeKeys,
8 getNotificationVolume,
9 notificationWavGain,
10 notificationVolumeToGain,
11 normalizeNotificationVolume,
12 playAttentionChime,
13 playSuccessChime,
14 setAttentionPreference,
15 setNotificationVolume,
16 setSuccessPreference,
17 shouldPlayAttentionChimeForEvent,
18 } from "../lib/sound";
19
20 let passed = 0;
21 let failed = 0;
22
23 function eq(a: unknown, b: unknown, label: string) {
24 if (a === b) {
25 process.stdout.write(` PASS ${label}\n`);
26 passed += 1;
27 } else {
28 process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
29 failed += 1;
30 }
31 }
32
33 console.log("\nsound notifications");
34
35 const storedValues = new Map<string, string>();
36 Object.defineProperty(globalThis, "localStorage", {
37 configurable: true,
38 value: {
39 getItem(key: string) { return storedValues.get(key) ?? null; },
40 setItem(key: string, value: string) { storedValues.set(key, value); },
41 removeItem(key: string) { storedValues.delete(key); },
42 clear() { storedValues.clear(); },
43 },
44 });
45
46 {
47 eq(getNotificationVolume(), DEFAULT_NOTIFICATION_VOLUME, "legacy installs without a saved volume upgrade to the 70% default");
48 eq(setNotificationVolume(85), 85, "notification volume saves a user-selected percentage");
49 eq(storedValues.get(NOTIFICATION_VOLUME_STORAGE_KEY), "85", "notification volume persists in its dedicated storage key");
50 eq(getNotificationVolume(), 85, "saved notification volume round-trips");
51 eq(setNotificationVolume(140), 100, "notification volume clamps values above 100%");
52 eq(setNotificationVolume(-10), 0, "notification volume clamps values below 0%");
53 storedValues.set(NOTIFICATION_VOLUME_STORAGE_KEY, "not-a-number");
54 eq(getNotificationVolume(), DEFAULT_NOTIFICATION_VOLUME, "invalid persisted volume recovers to the default");
55 eq(normalizeNotificationVolume(72.6), 73, "notification volume normalizes fractional values to an integer percentage");
56 eq(notificationVolumeToGain(70), 0.7, "notification volume converts percentages to Web Audio gain");
57 const normalizedWavGains = [
58 notificationWavGain("positive", 1),
59 notificationWavGain("correct", 1),
60 notificationWavGain("start", 1),
61 notificationWavGain("back", 1),
62 ];
63 eq(normalizedWavGains.join(","), "0.62,0.85,1,0.7", "bundled WAVs use their measured loudness-normalization trims");
64 eq(Math.max(...normalizedWavGains), 1, "WAV normalization never boosts a source above its recorded peak");
65 eq(notificationWavGain("start", 0.7), 0.7, "the master volume scales a normalized WAV after its source trim");
66 }
67
68 {
69 const synthPeaks: number[] = [];
70 class FakeAudioContext {
71 destination = {};
72 createOscillator() {
73 return {
74 type: "sine",
75 frequency: { setValueAtTime() {} },
76 connect() {},
77 start() {},
78 stop() {},
79 };
80 }
81 createGain() {
82 return {
83 gain: {
84 setValueAtTime() {},
85 linearRampToValueAtTime(value: number) { synthPeaks.push(value); },
86 exponentialRampToValueAtTime() {},
87 },
88 connect() {},
89 };
90 }
91 close() {}
92 }
93 Object.defineProperty(globalThis, "AudioContext", { configurable: true, value: FakeAudioContext });
94 const maxPeak = () => Math.round(Math.max(...synthPeaks) * 1000) / 1000;
95
96 setSuccessPreference("synth");
97 setAttentionPreference("synth");
98 setNotificationVolume(DEFAULT_NOTIFICATION_VOLUME);
99 playSuccessChime();
100 eq(maxPeak(), 0.245, "the 70% upgrade default raises the synthesized success peak above the legacy level");
101 synthPeaks.length = 0;
102 playAttentionChime();
103 eq(maxPeak(), 0.28, "the 70% upgrade default keeps attention at least as audible as success");
104 synthPeaks.length = 0;
105 setNotificationVolume(0);
106 playSuccessChime();
107 playAttentionChime();
108 eq(synthPeaks.length, 0, "0% prevents synthesized notifications from creating audio nodes");
109 }
110
111 {
112 eq(attentionChimeEventKey({ kind: "approval_request", tabId: "tab-a", approval: { id: "approval-1" } }), "approval:tab-a:approval-1", "approval request builds a tab-scoped chime key");
113 eq(attentionChimeEventKey({ kind: "ask_request", tabId: "tab-a", ask: { id: "ask-1" } }), "ask:tab-a:ask-1", "ask request builds a tab-scoped chime key");
114 eq(attentionChimeEventKey({ kind: "approval_request", approval: { id: "approval-1" } }), "approval::approval-1", "legacy approval events without a tab still build a stable key");
115 eq(attentionChimeEventKey({ kind: "turn_done" }), undefined, "non-attention events do not build chime keys");
116 }
117
118 {
119 const seen = new Set<string>();
120 eq(shouldPlayAttentionChimeForEvent({ kind: "approval_request", tabId: "tab-a", approval: { id: "1" } }, seen), true, "first approval event plays");
121 eq(shouldPlayAttentionChimeForEvent({ kind: "approval_request", tabId: "tab-a", approval: { id: "1" } }, seen), false, "replayed approval event for the same tab is deduped");
122 eq(shouldPlayAttentionChimeForEvent({ kind: "approval_request", tabId: "tab-b", approval: { id: "1" } }, seen), true, "same approval id from another tab still plays");
123 eq(shouldPlayAttentionChimeForEvent({ kind: "ask_request", tabId: "tab-a", ask: { id: "1" } }, seen), true, "ask id sharing an approval id still plays");
124 eq(shouldPlayAttentionChimeForEvent({ kind: "approval_request" }, seen), false, "malformed approval event does not play");
125 }
126
127 {
128 // The dedupe set stays bounded: after many unique prompts it self-prunes
129 // while still deduping recently seen ids.
130 const seen = new Set<string>();
131 for (let i = 0; i < 2000; i++) {
132 shouldPlayAttentionChimeForEvent({ kind: "approval_request", tabId: "t", approval: { id: String(i) } }, seen);
133 }
134 eq(seen.size <= 1024, true, "dedupe set stays bounded after 2000 unique prompts");
135 eq(shouldPlayAttentionChimeForEvent({ kind: "approval_request", tabId: "t", approval: { id: "1999" } }, seen), false, "most recent prompt id is still deduped after pruning");
136 }
137
138 {
139 // Runtime rebuild clears dedupe keys so reissued ids chime again.
140 const seen = new Set<string>();
141 shouldPlayAttentionChimeForEvent({ kind: "approval_request", tabId: "tab-a", approval: { id: "1" } }, seen);
142 shouldPlayAttentionChimeForEvent({ kind: "ask_request", tabId: "tab-b", ask: { id: "1" } }, seen);
143 clearAttentionChimeKeys(seen, "tab-a");
144 eq(shouldPlayAttentionChimeForEvent({ kind: "approval_request", tabId: "tab-a", approval: { id: "1" } }, seen), true, "rebuilt tab's reissued approval id chimes again");
145 eq(shouldPlayAttentionChimeForEvent({ kind: "ask_request", tabId: "tab-b", ask: { id: "1" } }, seen), false, "other tab's keys survive a scoped clear");
146 clearAttentionChimeKeys(seen);
147 eq(shouldPlayAttentionChimeForEvent({ kind: "ask_request", tabId: "tab-b", ask: { id: "1" } }, seen), true, "tab-less ready clears every key");
148 }
149
150 if (failed) {
151 console.error(`sound notifications: ${failed} failed, ${passed} passed`);
152 process.exit(1);
153 }
154
155 console.log(`sound notifications: ${passed} passed`);
156
156 lines TYPESCRIPT