forked from
tokono.ma/diffuse
A music player that connects to your cloud/distributed storage.
1import { describe, it } from "@std/testing/bdd";
2import { expect } from "@std/expect";
3
4import { testWeb } from "@tests/common/index.ts";
5
6describe("components/engine/audio", () => {
7 it("has default volume of 0.75", async () => {
8 const result = await testWeb(async () => {
9 const mod = await import("~/components/engine/audio/element.js");
10 const engine = new mod.CLASS();
11 document.body.append(engine);
12 return engine.volume();
13 });
14
15 expect(result).toBe(0.75);
16 });
17
18 it("adjustVolume updates the global volume signal", async () => {
19 const result = await testWeb(async () => {
20 const mod = await import("~/components/engine/audio/element.js");
21 const engine = new mod.CLASS();
22 document.body.append(engine);
23 engine.adjustVolume({ volume: 0.5 });
24 return engine.volume();
25 });
26
27 expect(result).toBe(0.5);
28 });
29
30 it("adjustVolume clamps to the provided value", async () => {
31 const result = await testWeb(async () => {
32 const mod = await import("~/components/engine/audio/element.js");
33 const engine = new mod.CLASS();
34 document.body.append(engine);
35 engine.adjustVolume({ volume: 1.0 });
36 return engine.volume();
37 });
38
39 expect(result).toBe(1.0);
40 });
41
42 it("isPlaying returns false with no items", async () => {
43 const result = await testWeb(async () => {
44 const mod = await import("~/components/engine/audio/element.js");
45 const engine = new mod.CLASS();
46 document.body.append(engine);
47 return engine.isPlaying();
48 });
49
50 expect(result).toBe(false);
51 });
52
53 it("supply with URL items updates the items signal", async () => {
54 const result = await testWeb(async () => {
55 const mod = await import("~/components/engine/audio/element.js");
56 const { trackA } = await import("~/testing/sample/tracks.js");
57 const engine = new mod.CLASS();
58 document.body.append(engine);
59
60 engine.supply({
61 audio: [
62 {
63 id: "audio-a",
64 url: "/testing/sample/audio.mp3",
65 isPreload: false,
66 track: trackA,
67 },
68 {
69 id: "audio-b",
70 url: "/testing/sample/audio.mp3",
71 isPreload: false,
72 track: trackA,
73 },
74 ],
75 });
76
77 return engine.items().map((i) => i.id);
78 });
79
80 expect(result).toEqual(["audio-a", "audio-b"]);
81 });
82
83 it("supply with same IDs does not update items signal", async () => {
84 const result = await testWeb(async () => {
85 const mod = await import("~/components/engine/audio/element.js");
86 const { trackA } = await import("~/testing/sample/tracks.js");
87 const engine = new mod.CLASS();
88 document.body.append(engine);
89
90 const item = {
91 id: "audio-a",
92 url: "/testing/sample/audio.mp3",
93 isPreload: false,
94 track: trackA,
95 };
96
97 engine.supply({ audio: [item] });
98 const itemsAfterFirst = engine.items();
99
100 engine.supply({ audio: [item] });
101 const itemsAfterSecond = engine.items();
102
103 // Same reference means the signal was not updated
104 return itemsAfterFirst === itemsAfterSecond;
105 });
106
107 expect(result).toBe(true);
108 });
109
110 it("supply replaces items when IDs change", async () => {
111 const result = await testWeb(async () => {
112 const mod = await import("~/components/engine/audio/element.js");
113 const { trackA } = await import("~/testing/sample/tracks.js");
114 const engine = new mod.CLASS();
115 document.body.append(engine);
116
117 engine.supply({
118 audio: [{
119 id: "audio-a",
120 url: "/testing/sample/audio.mp3",
121 isPreload: false,
122 track: trackA,
123 }],
124 });
125
126 engine.supply({
127 audio: [{
128 id: "audio-b",
129 url: "/testing/sample/audio.mp3",
130 isPreload: false,
131 track: trackA,
132 }],
133 });
134
135 return engine.items().map((i) => i.id);
136 });
137
138 expect(result).toEqual(["audio-b"]);
139 });
140
141 it("supply with isPreload change triggers items update", async () => {
142 const result = await testWeb(async () => {
143 const mod = await import("~/components/engine/audio/element.js");
144 const { trackA } = await import("~/testing/sample/tracks.js");
145 const engine = new mod.CLASS();
146 document.body.append(engine);
147
148 engine.supply({
149 audio: [{
150 id: "audio-a",
151 url: "/testing/sample/audio.mp3",
152 isPreload: true,
153 track: trackA,
154 }],
155 });
156
157 engine.supply({
158 audio: [{
159 id: "audio-a",
160 url: "/testing/sample/audio.mp3",
161 isPreload: false,
162 track: trackA,
163 }],
164 });
165
166 return engine.items()[0]?.isPreload;
167 });
168
169 expect(result).toBe(false);
170 });
171
172 it("persists volume to localStorage", async () => {
173 const stored = await testWeb(async () => {
174 const mod = await import("~/components/engine/audio/element.js");
175 const engine = new mod.CLASS();
176 document.body.append(engine);
177 engine.adjustVolume({ volume: 0.3 });
178
179 for (let i = 0; i < localStorage.length; i++) {
180 const key = localStorage.key(i)!;
181 if (key.includes("engine/audio") && key.endsWith("/volume")) {
182 return localStorage.getItem(key);
183 }
184 }
185 return null;
186 });
187
188 expect(stored).toBe("0.3");
189 });
190
191 it("restores volume from localStorage on connect", async () => {
192 const result = await testWeb(async () => {
193 const mod = await import("~/components/engine/audio/element.js");
194
195 // Set volume with first engine instance
196 const engine1 = new mod.CLASS();
197 document.body.append(engine1);
198 engine1.adjustVolume({ volume: 0.4 });
199
200 // Second instance reads from localStorage
201 const engine2 = new mod.CLASS();
202 document.body.append(engine2);
203 return engine2.volume();
204 });
205
206 expect(result).toBe(0.4);
207 });
208
209 // Sample audio tests
210
211 it("state returns undefined for unknown audio id", async () => {
212 const result = await testWeb(async () => {
213 const mod = await import("~/components/engine/audio/element.js");
214 const { trackA } = await import("~/testing/sample/tracks.js");
215 const engine = new mod.CLASS();
216 document.body.append(engine);
217
218 engine.supply({
219 audio: [{
220 id: "audio-a",
221 url: "/testing/sample/audio.mp3",
222 isPreload: false,
223 track: trackA,
224 }],
225 });
226
227 return engine.state("no-such-id");
228 });
229
230 expect(result).toBeUndefined();
231 });
232
233 it("state has initial loadingState of loading before audio loads", async () => {
234 const result = await testWeb(async () => {
235 const mod = await import("~/components/engine/audio/element.js");
236 const { trackA } = await import("~/testing/sample/tracks.js");
237 const engine = new mod.CLASS();
238 document.body.append(engine);
239
240 engine.supply({
241 audio: [{
242 id: "audio-a",
243 url: "/testing/sample/audio.mp3",
244 isPreload: false,
245 track: trackA,
246 }],
247 });
248
249 const st = engine.state("audio-a");
250 return {
251 loadingState: st?.loadingState(),
252 currentTime: st?.currentTime(),
253 isPlaying: st?.isPlaying(),
254 hasEnded: st?.hasEnded(),
255 };
256 });
257
258 expect(result.loadingState).toBe("loading");
259 expect(result.currentTime).toBe(0);
260 expect(result.isPlaying).toBe(false);
261 expect(result.hasEnded).toBe(false);
262 });
263
264 it("loadingState becomes loaded after audio loads", async () => {
265 const result = await testWeb(async () => {
266 const mod = await import("~/components/engine/audio/element.js");
267 const { trackA } = await import("~/testing/sample/tracks.js");
268 const engine = new mod.CLASS();
269 document.body.append(engine);
270
271 engine.supply({
272 audio: [{
273 id: "audio-a",
274 url: "/testing/sample/audio.mp3",
275 isPreload: false,
276 track: trackA,
277 }],
278 });
279
280 const audioEl = engine.querySelector(
281 'de-audio-item[id="audio-a"]:not([preload]) audio',
282 ) as HTMLAudioElement;
283
284 await new Promise<void>((resolve, reject) => {
285 const st = engine.state("audio-a");
286 if (st?.loadingState() === "loaded") { resolve(); return; }
287 const timer = setTimeout(
288 () => reject(new Error("timeout waiting for audio load")),
289 10000,
290 );
291 const done = () => { clearTimeout(timer); resolve(); };
292 audioEl.addEventListener("canplay", done, { once: true });
293 audioEl.addEventListener("suspend", done, { once: true });
294 audioEl.addEventListener(
295 "error",
296 () => { clearTimeout(timer); reject(new Error("audio load error")); },
297 { once: true },
298 );
299 });
300
301 return engine.state("audio-a")?.loadingState();
302 });
303
304 expect(result).toBe("loaded");
305 });
306
307 it("duration is positive after audio loads", async () => {
308 const result = await testWeb(async () => {
309 const mod = await import("~/components/engine/audio/element.js");
310 const { trackA } = await import("~/testing/sample/tracks.js");
311 const engine = new mod.CLASS();
312 document.body.append(engine);
313
314 engine.supply({
315 audio: [{
316 id: "audio-a",
317 url: "/testing/sample/audio.mp3",
318 isPreload: false,
319 track: trackA,
320 }],
321 });
322
323 const audioEl = engine.querySelector(
324 'de-audio-item[id="audio-a"]:not([preload]) audio',
325 ) as HTMLAudioElement;
326
327 await new Promise<void>((resolve, reject) => {
328 if (audioEl.duration > 0 && isFinite(audioEl.duration)) {
329 resolve();
330 return;
331 }
332 const timer = setTimeout(
333 () => reject(new Error("timeout waiting for duration")),
334 10000,
335 );
336 const done = () => { clearTimeout(timer); resolve(); };
337 audioEl.addEventListener("durationchange", done, { once: true });
338 audioEl.addEventListener(
339 "error",
340 () => { clearTimeout(timer); reject(new Error("audio load error")); },
341 { once: true },
342 );
343 });
344
345 return engine.state("audio-a")?.duration();
346 });
347
348 expect(result).toBeGreaterThan(0);
349 });
350
351 it("currentTime is 0 before seek", async () => {
352 const result = await testWeb(async () => {
353 const mod = await import("~/components/engine/audio/element.js");
354 const { trackA } = await import("~/testing/sample/tracks.js");
355 const engine = new mod.CLASS();
356 document.body.append(engine);
357
358 engine.supply({
359 audio: [{
360 id: "audio-a",
361 url: "/testing/sample/audio.mp3",
362 isPreload: false,
363 track: trackA,
364 }],
365 });
366
367 const audioEl = engine.querySelector(
368 'de-audio-item[id="audio-a"]:not([preload]) audio',
369 ) as HTMLAudioElement;
370
371 await new Promise<void>((resolve, reject) => {
372 const st = engine.state("audio-a");
373 if (st?.loadingState() === "loaded") { resolve(); return; }
374 const timer = setTimeout(
375 () => reject(new Error("timeout")),
376 10000,
377 );
378 const done = () => { clearTimeout(timer); resolve(); };
379 audioEl.addEventListener("canplay", done, { once: true });
380 audioEl.addEventListener("suspend", done, { once: true });
381 audioEl.addEventListener(
382 "error",
383 () => { clearTimeout(timer); reject(new Error("audio load error")); },
384 { once: true },
385 );
386 });
387
388 return engine.state("audio-a")?.currentTime();
389 });
390
391 expect(result).toBe(0);
392 });
393
394 it("seek by currentTime updates audio element currentTime", async () => {
395 const result = await testWeb(async () => {
396 const mod = await import("~/components/engine/audio/element.js");
397 const { trackA } = await import("~/testing/sample/tracks.js");
398 const engine = new mod.CLASS();
399 document.body.append(engine);
400
401 engine.supply({
402 audio: [{
403 id: "audio-a",
404 url: "/testing/sample/audio.mp3",
405 isPreload: false,
406 track: trackA,
407 }],
408 });
409
410 const audioEl = engine.querySelector(
411 'de-audio-item[id="audio-a"]:not([preload]) audio',
412 ) as HTMLAudioElement;
413
414 await new Promise<void>((resolve, reject) => {
415 const st = engine.state("audio-a");
416 if (st?.loadingState() === "loaded") { resolve(); return; }
417 const timer = setTimeout(() => reject(new Error("timeout")), 10000);
418 const done = () => { clearTimeout(timer); resolve(); };
419 audioEl.addEventListener("canplay", done, { once: true });
420 audioEl.addEventListener("suspend", done, { once: true });
421 audioEl.addEventListener(
422 "error",
423 () => { clearTimeout(timer); reject(new Error("audio load error")); },
424 { once: true },
425 );
426 });
427
428 engine.seek({ audioId: "audio-a", currentTime: 1 });
429
430 return audioEl.currentTime;
431 });
432
433 expect(result).toBe(1);
434 });
435
436 it("seek by percentage updates audio element currentTime proportionally", async () => {
437 const result = await testWeb(async () => {
438 const mod = await import("~/components/engine/audio/element.js");
439 const { trackA } = await import("~/testing/sample/tracks.js");
440 const engine = new mod.CLASS();
441 document.body.append(engine);
442
443 engine.supply({
444 audio: [{
445 id: "audio-a",
446 url: "/testing/sample/audio.mp3",
447 isPreload: false,
448 track: trackA,
449 }],
450 });
451
452 const audioEl = engine.querySelector(
453 'de-audio-item[id="audio-a"]:not([preload]) audio',
454 ) as HTMLAudioElement;
455
456 await new Promise<void>((resolve, reject) => {
457 if (audioEl.duration > 0 && isFinite(audioEl.duration)) { resolve(); return; }
458 const timer = setTimeout(() => reject(new Error("timeout")), 10000);
459 const done = () => { clearTimeout(timer); resolve(); };
460 audioEl.addEventListener("durationchange", done, { once: true });
461 audioEl.addEventListener(
462 "error",
463 () => { clearTimeout(timer); reject(new Error("audio load error")); },
464 { once: true },
465 );
466 });
467
468 engine.seek({ audioId: "audio-a", percentage: 0.5 });
469
470 return {
471 currentTime: audioEl.currentTime,
472 expected: 0.5 * audioEl.duration,
473 };
474 });
475
476 expect(result.currentTime).toBe(result.expected);
477 });
478
479 it("play sets isPlaying to true", async () => {
480 const result = await testWeb(async () => {
481 const mod = await import("~/components/engine/audio/element.js");
482 const { trackA } = await import("~/testing/sample/tracks.js");
483 const engine = new mod.CLASS();
484 document.body.append(engine);
485
486 engine.supply({
487 audio: [{
488 id: "audio-a",
489 url: "/testing/sample/audio.mp3",
490 isPreload: false,
491 track: trackA,
492 }],
493 });
494
495 const audioEl = engine.querySelector(
496 'de-audio-item[id="audio-a"]:not([preload]) audio',
497 ) as HTMLAudioElement;
498
499 await new Promise<void>((resolve, reject) => {
500 const st = engine.state("audio-a");
501 if (st?.loadingState() === "loaded") { resolve(); return; }
502 const timer = setTimeout(() => reject(new Error("timeout")), 10000);
503 const done = () => { clearTimeout(timer); resolve(); };
504 audioEl.addEventListener("canplay", done, { once: true });
505 audioEl.addEventListener("suspend", done, { once: true });
506 audioEl.addEventListener(
507 "error",
508 () => { clearTimeout(timer); reject(new Error("audio load error")); },
509 { once: true },
510 );
511 });
512
513 // play() sets isPlaying optimistically before the audio.play() promise settles
514 engine.play({ audioId: "audio-a", volume: 0.5 });
515
516 return engine.state("audio-a")?.isPlaying();
517 });
518
519 expect(result).toBe(true);
520 });
521
522 it("pause resets isPlaying to false", async () => {
523 const result = await testWeb(async () => {
524 const mod = await import("~/components/engine/audio/element.js");
525 const { trackA } = await import("~/testing/sample/tracks.js");
526 const engine = new mod.CLASS();
527 document.body.append(engine);
528
529 engine.supply({
530 audio: [{
531 id: "audio-a",
532 url: "/testing/sample/audio.mp3",
533 isPreload: false,
534 track: trackA,
535 }],
536 });
537
538 const audioEl = engine.querySelector(
539 'de-audio-item[id="audio-a"]:not([preload]) audio',
540 ) as HTMLAudioElement;
541
542 await new Promise<void>((resolve, reject) => {
543 const st = engine.state("audio-a");
544 if (st?.loadingState() === "loaded") { resolve(); return; }
545 const timer = setTimeout(() => reject(new Error("timeout")), 10000);
546 const done = () => { clearTimeout(timer); resolve(); };
547 audioEl.addEventListener("canplay", done, { once: true });
548 audioEl.addEventListener("suspend", done, { once: true });
549 audioEl.addEventListener(
550 "error",
551 () => { clearTimeout(timer); reject(new Error("audio load error")); },
552 { once: true },
553 );
554 });
555
556 engine.play({ audioId: "audio-a", volume: 0.5 });
557 engine.pause({ audioId: "audio-a" });
558
559 // Wait for pause event to propagate
560 await new Promise<void>((resolve) => setTimeout(resolve, 50));
561
562 return engine.state("audio-a")?.isPlaying();
563 });
564
565 expect(result).toBe(false);
566 });
567});