Skip to content

Commit 9dc79fc

Browse files
committed
Add tests to sound
1 parent 208fb28 commit 9dc79fc

File tree

7 files changed

+415
-111
lines changed

7 files changed

+415
-111
lines changed

src/bundles/sound/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@
88
},
99
"devDependencies": {
1010
"@sourceacademy/modules-buildtools": "workspace:^",
11-
"typescript": "^5.8.2"
11+
"@sourceacademy/modules-lib": "workspace:^",
12+
"playwright": "^1.54.1",
13+
"typescript": "^5.8.2",
14+
"vitest": "^3.2.3"
1215
},
1316
"type": "module",
1417
"exports": {
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
// @vitest-environment jsdom
2+
import { stringify } from 'js-slang/dist/utils/stringify';
3+
import {
4+
afterEach,
5+
beforeEach,
6+
describe,
7+
expect,
8+
test,
9+
vi,
10+
type Mock
11+
} from 'vitest';
12+
import * as funcs from '../functions';
13+
import { mockAudioContext, mockMediaRecorder } from './utils';
14+
15+
const mockStream = {} as MediaStream;
16+
const mockedGetUserMedia: Mock<typeof navigator.mediaDevices.getUserMedia> = vi.fn()
17+
.mockResolvedValue(mockStream);
18+
19+
vi.spyOn(global, 'navigator', 'get').mockReturnValue({
20+
get mediaDevices() {
21+
return {
22+
getUserMedia: mockedGetUserMedia
23+
};
24+
}
25+
} as any);
26+
27+
Object.defineProperty(global, 'AudioContext', {
28+
value: () => mockAudioContext
29+
});
30+
31+
Object.defineProperty(global, 'MediaRecorder', {
32+
value: () => mockMediaRecorder
33+
});
34+
35+
beforeEach(() => {
36+
funcs.globalVars.recordedSound = null;
37+
funcs.globalVars.stream = null;
38+
funcs.globalVars.isPlaying = false;
39+
funcs.globalVars.audioplayer = null;
40+
});
41+
42+
describe(funcs.init_record, () => {
43+
test('sets stream correctly when permission is accepted', async () => {
44+
expect(funcs.init_record()).toEqual('obtaining recording permission');
45+
await expect.poll(() => funcs.globalVars.stream).toBe(mockStream);
46+
});
47+
48+
test('sets stream to false when permission is rejected', async () => {
49+
mockedGetUserMedia.mockRejectedValueOnce('');
50+
expect(funcs.init_record()).toEqual('obtaining recording permission');
51+
await expect.poll(() => funcs.globalVars.stream).toEqual(false);
52+
});
53+
});
54+
55+
describe('Recording functions', () => {
56+
beforeEach(() => {
57+
vi.useFakeTimers();
58+
});
59+
60+
afterEach(() => {
61+
vi.runOnlyPendingTimers();
62+
vi.useRealTimers();
63+
});
64+
65+
describe(funcs.record, () => {
66+
test('throws error if called without init_record', () => {
67+
expect(() => funcs.record(0)).toThrowError('Call init_record(); to obtain permission to use microphone');
68+
});
69+
70+
test('throws error if called concurrently with another sound', () => {
71+
funcs.play_wave(() => 0, 10);
72+
expect(() => funcs.record(1)).toThrowError('record: Cannot record while another sound is playing!');
73+
});
74+
75+
test(`${funcs.record.name} works`, async () => {
76+
funcs.init_record();
77+
await expect.poll(() => funcs.globalVars.stream).toBe(mockStream);
78+
79+
const stop = funcs.record(1);
80+
await vi.advanceTimersByTimeAsync(1200); // Mock waiting for the buffer duration
81+
expect(mockAudioContext.bufferSource.start).toHaveBeenCalledOnce(); // Assert that the recording signal was played
82+
mockAudioContext.close(); // End the recording signal playing
83+
84+
await vi.advanceTimersByTimeAsync(100); // Advance past the end of the recording signal
85+
expect(mockMediaRecorder.start).toHaveBeenCalledOnce(); // Assert that recording started
86+
const soundPromise = stop(); // Call stop to stop recording
87+
88+
expect(mockMediaRecorder.stop).toHaveBeenCalledOnce();
89+
expect(mockAudioContext.bufferSource.start).toHaveBeenCalledTimes(2); // Assert that the recording signal was played again
90+
mockAudioContext.close(); // End the recording signal playing
91+
92+
// Resolving the promise before processing is done throws an error
93+
expect(soundPromise).toThrowError('recording still being processed');
94+
expect(stringify(soundPromise)).toEqual('<SoundPromise>');
95+
const mockRecordedSound = funcs.silence_sound(0);
96+
97+
// Resolving the promise after processing is done doesn't throw an error
98+
funcs.globalVars.recordedSound = mockRecordedSound;
99+
expect(soundPromise()).toBe(mockRecordedSound);
100+
});
101+
});
102+
103+
describe(funcs.record_for, () => {
104+
test('throws error if called without init_record', () => {
105+
expect(() => funcs.record_for(0, 0)).toThrowError('Call init_record(); to obtain permission to use microphone');
106+
});
107+
108+
test('throws error if called concurrently with another sound', () => {
109+
funcs.play_wave(() => 0, 10);
110+
expect(() => funcs.record_for(1, 1)).toThrowError('record_for: Cannot record while another sound is playing!');
111+
});
112+
113+
test(`${funcs.record_for.name} works`, async () => {
114+
funcs.init_record();
115+
await expect.poll(() => funcs.globalVars.stream).toBe(mockStream);
116+
117+
const promise = funcs.record_for(5, 1);
118+
expect(stringify(promise)).toEqual('<SoundPromise>');
119+
await vi.advanceTimersByTimeAsync(200); // Advance time by the pre-record buffer duration (in ms)
120+
expect(mockAudioContext.bufferSource.start).toHaveBeenCalledOnce(); // Assert that the recording signal was played
121+
mockAudioContext.close(); // End the recording signal playing
122+
123+
await vi.advanceTimersByTimeAsync(1100); // Advance time by recording signal and buffer duration
124+
expect(mockMediaRecorder.start).toHaveBeenCalledOnce(); // Assert that recording began
125+
126+
await vi.advanceTimersByTimeAsync(5000); // Advance time by recording duration
127+
expect(mockMediaRecorder.stop).toHaveBeenCalledOnce(); // Assert that recording stopped
128+
expect(mockAudioContext.bufferSource.start).toHaveBeenCalledTimes(2); // Assert that the recording signal was played again
129+
mockAudioContext.close(); // End the recording signal playing
130+
});
131+
});
132+
});

src/bundles/sound/src/__tests__/sound.test.ts

Lines changed: 71 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1-
import { describe, expect, it } from 'vitest';
1+
import { afterEach, beforeEach, describe, expect, it, test } from 'vitest';
22
import * as funcs from '../functions';
3-
import type { Sound } from '../types';
3+
import type { Sound, Wave } from '../types';
4+
import { mockAudioContext } from './utils';
5+
6+
Object.defineProperty(global, 'AudioContext', {
7+
value: () => mockAudioContext
8+
});
49

510
describe(funcs.make_sound, () => {
611
it('Should error gracefully when duration is negative', () => {
@@ -18,37 +23,72 @@ describe(funcs.make_sound, () => {
1823
});
1924
});
2025

21-
describe(funcs.play, () => {
22-
it('Should error gracefully when duration is negative', () => {
23-
const sound: Sound = [() => 0, -1];
24-
expect(() => funcs.play(sound))
25-
.toThrow('play: duration of sound is negative');
26+
describe('Concurrent playback functions', () => {
27+
beforeEach(() => {
28+
funcs.globalVars.audioplayer = null;
2629
});
2730

28-
it('Should not error when duration is zero', () => {
29-
const sound = funcs.make_sound(() => 0, 0);
30-
expect(() => funcs.play(sound)).not.toThrow();
31+
afterEach(() => {
32+
funcs.globalVars.isPlaying = false;
3133
});
3234

33-
it('Should throw error when given not a sound', () => {
34-
expect(() => funcs.play(0 as any)).toThrow('play is expecting sound, but encountered 0');
35+
describe(funcs.play, () => {
36+
it('Should error gracefully when duration is negative', () => {
37+
const sound: Sound = [() => 0, -1];
38+
expect(() => funcs.play(sound))
39+
.toThrow('play: duration of sound is negative');
40+
});
41+
42+
it('Should not error when duration is zero', () => {
43+
const sound = funcs.make_sound(() => 0, 0);
44+
expect(() => funcs.play(sound)).not.toThrow();
45+
});
46+
47+
it('Should throw error when given not a sound', () => {
48+
expect(() => funcs.play(0 as any)).toThrow('play is expecting sound, but encountered 0');
49+
});
50+
51+
test.only('Concurrently playing two sounds should error', () => {
52+
console.log(AudioContext);
53+
const sound = funcs.silence_sound(10);
54+
expect(() => funcs.play(sound)).not.toThrow();
55+
expect(() => funcs.play(sound)).toThrowError('play: Previous sound still playing');
56+
});
3557
});
36-
});
3758

38-
describe(funcs.play_wave, () => {
39-
it('Should error gracefully when duration is negative', () => {
40-
expect(() => funcs.play_wave(() => 0, -1))
41-
.toThrow('play_wave: Sound duration must be greater than or equal to 0');
59+
describe(funcs.play_wave, () => {
60+
it('Should error gracefully when duration is negative', () => {
61+
expect(() => funcs.play_wave(() => 0, -1))
62+
.toThrow('play_wave: Sound duration must be greater than or equal to 0');
63+
});
64+
65+
it('Should error gracefully when duration is not a number', () => {
66+
expect(() => funcs.play_wave(() => 0, true as any))
67+
.toThrow('play_wave expects a number for duration, got true');
68+
});
69+
70+
it('Should error gracefully when wave is not a function', () => {
71+
expect(() => funcs.play_wave(true as any, 0))
72+
.toThrow('play_wave expects a wave, got true');
73+
});
74+
75+
test('Concurrently playing two sounds should error', () => {
76+
const wave: Wave = () => 0;
77+
expect(() => funcs.play_wave(wave, 10)).not.toThrow();
78+
expect(() => funcs.play_wave(wave, 10)).toThrowError('play: Previous sound still playing');
79+
});
4280
});
4381

44-
it('Should error gracefully when duration is not a number', () => {
45-
expect(() => funcs.play_wave(() => 0, true as any))
46-
.toThrow('play_wave expects a number for duration, got true');
47-
});
82+
describe(funcs.stop, () => {
83+
test('Calling stop without ever calling any playback functions should not throw an error', () => {
84+
expect(funcs.stop).not.toThrowError();
85+
});
4886

49-
it('Should error gracefully when wave is not a function', () => {
50-
expect(() => funcs.play_wave(true as any, 0))
51-
.toThrow('play_wave expects a wave, got true');
87+
it('sets isPlaying to false', () => {
88+
funcs.globalVars.isPlaying = true;
89+
funcs.stop();
90+
expect(funcs.globalVars.isPlaying).toEqual(false);
91+
});
5292
});
5393
});
5494

@@ -67,6 +107,13 @@ describe(funcs.play_in_tab, () => {
67107
it('Should throw error when given not a sound', () => {
68108
expect(() => funcs.play_in_tab(0 as any)).toThrow('play_in_tab is expecting sound, but encountered 0');
69109
});
110+
111+
test('Multiple calls does not cause an error', () => {
112+
const sound = funcs.silence_sound(10);
113+
expect(() => funcs.play_in_tab(sound)).not.toThrow();
114+
expect(() => funcs.play_in_tab(sound)).not.toThrow();
115+
expect(() => funcs.play_in_tab(sound)).not.toThrow();
116+
});
70117
});
71118

72119
function evaluateSound(sound: Sound) {
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { vi, type Mock } from 'vitest';
2+
3+
interface MockMediaRecorder {
4+
stop: () => void;
5+
start: Mock<() => void>;
6+
onstop?: () => void;
7+
ondataavailable?: () => void;
8+
}
9+
10+
interface MockAudioBufferSource {
11+
buffer: MockAudioBuffer | null;
12+
node: AudioNode | null;
13+
isPlaying: boolean;
14+
connect: (node: AudioNode) => void;
15+
disconnect: (node: AudioNode) => void;
16+
start: () => void;
17+
stop: () => void;
18+
onended?: () => void;
19+
}
20+
21+
interface MockAudioBuffer {
22+
getChannelData: (channel: number) => Float32Array<ArrayBuffer>;
23+
}
24+
25+
interface MockAudioContext {
26+
bufferSource: MockAudioBufferSource;
27+
createBufferSource: () => MockAudioBufferSource;
28+
createBuffer: (channels: number, length: number, sampleRate: number) => MockAudioBuffer;
29+
decodeAudioData: (buffer: ArrayBuffer) => Promise<MockAudioBuffer>;
30+
close: () => void;
31+
}
32+
33+
export const mockBufferSource: MockAudioBufferSource = {
34+
isPlaying: false,
35+
buffer: null,
36+
node: null,
37+
connect(node) {
38+
this.node = node;
39+
},
40+
disconnect: () => {},
41+
start() {
42+
this.isPlaying = true;
43+
},
44+
stop() {
45+
this.isPlaying = false;
46+
this.onended?.();
47+
}
48+
};
49+
vi.spyOn(mockBufferSource, 'start');
50+
51+
export const mockAudioContext: MockAudioContext = {
52+
bufferSource: mockBufferSource,
53+
createBufferSource: () => mockBufferSource,
54+
createBuffer: (channels, length) => ({
55+
getChannelData: () => new Float32Array<ArrayBuffer>(new ArrayBuffer(4 * length)),
56+
}),
57+
close() {
58+
return this.bufferSource.stop();
59+
},
60+
decodeAudioData() {
61+
return Promise.resolve(this.bufferSource.buffer!);
62+
},
63+
};
64+
65+
export const mockMediaRecorder: MockMediaRecorder = {
66+
start: vi.fn(),
67+
stop() {
68+
this.onstop?.();
69+
}
70+
};
71+
vi.spyOn(mockMediaRecorder, 'stop');

0 commit comments

Comments
 (0)