Skip to content

Commit 8bffbee

Browse files
committed
test: add unit tests for AIWidget
1 parent f488f0b commit 8bffbee

File tree

1 file changed

+303
-0
lines changed

1 file changed

+303
-0
lines changed
Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
/**
2+
* MusicBlocks
3+
*
4+
* @author kh-ub-ayb
5+
*
6+
* @copyright 2026 kh-ub-ayb
7+
*
8+
* @license
9+
* This program is free software: you can redistribute it and/or modify
10+
* it under the terms of the GNU Affero General Public License as published by
11+
* the Free Software Foundation, either version 3 of the License, or
12+
* (at your option) any later version.
13+
*
14+
* This program is distributed in the hope that it will be useful,
15+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
16+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17+
* GNU Affero General Public License for more details.
18+
*
19+
* You should have received a copy of the GNU Affero General Public License
20+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
21+
*/
22+
23+
const fs = require("fs");
24+
const path = require("path");
25+
26+
// Load the AIWidget class by reading the source and evaluating it
27+
// We inject a global setter so tests can access the closure's midiBuffer
28+
const source = fs.readFileSync(path.resolve(__dirname, "../aiwidget.js"), "utf-8");
29+
new Function(
30+
source.replace(
31+
"let midiBuffer;",
32+
"let midiBuffer; global.mockMidiBuffer = function(m) { midiBuffer = m; };"
33+
) + "\nif (typeof global !== 'undefined') { global.AIWidget = AIWidget; }"
34+
)();
35+
36+
// Mock globals
37+
global._ = str => str;
38+
global.docById = jest.fn(id => document.createElement("div"));
39+
global.DOUBLEFLAT = "doubleflat";
40+
global.FLAT = "flat";
41+
global.NATURAL = "natural";
42+
global.SHARP = "sharp";
43+
global.DOUBLESHARP = "doublesharp";
44+
global.CUSTOMSAMPLES = [];
45+
global.wheelnav = jest.fn();
46+
global.getVoiceSynthName = jest.fn(() => "mockSynth");
47+
global.Singer = jest.fn();
48+
global.DRUMS = {};
49+
global.Tone = { Analyser: jest.fn() };
50+
global.instruments = { 0: { "electronic synth": { connect: jest.fn() } } };
51+
global.slicePath = jest.fn();
52+
global.platformColor = jest.fn();
53+
global.ABCJS = {
54+
renderAbc: jest.fn(() => [{ millisecondsPerMeasure: () => 1000 }]),
55+
synth: {
56+
supportsAudio: jest.fn(() => true),
57+
CreateSynth: jest.fn().mockImplementation(() => ({
58+
init: jest.fn().mockResolvedValue(),
59+
prime: jest.fn().mockResolvedValue(),
60+
start: jest.fn(),
61+
stop: jest.fn()
62+
}))
63+
},
64+
parseOnly: jest.fn(() => [])
65+
};
66+
67+
describe("AIWidget", () => {
68+
let mockActivity;
69+
let mockWidgetWindow;
70+
71+
beforeEach(() => {
72+
document.body.innerHTML = "";
73+
jest.clearAllMocks();
74+
75+
mockWidgetWindow = {
76+
clear: jest.fn(),
77+
show: jest.fn(),
78+
onclose: null,
79+
onmaximize: null,
80+
addButton: jest.fn(() => {
81+
const btn = document.createElement("button");
82+
return btn;
83+
}),
84+
getWidgetBody: jest.fn(() => {
85+
const body = document.createElement("div");
86+
body.getBoundingClientRect = () => ({ width: 800, height: 600 });
87+
return body;
88+
}),
89+
getWidgetFrame: jest.fn(() => {
90+
const frame = document.createElement("div");
91+
frame.getBoundingClientRect = () => ({ width: 800, height: 600 });
92+
return frame;
93+
}),
94+
sendToCenter: jest.fn(),
95+
isMaximized: jest.fn(() => false),
96+
destroy: jest.fn()
97+
};
98+
99+
window.widgetWindows = {
100+
windowFor: jest.fn(() => mockWidgetWindow)
101+
};
102+
103+
mockActivity = {
104+
logo: {
105+
synth: {
106+
loadSynth: jest.fn(),
107+
trigger: jest.fn()
108+
}
109+
},
110+
textMsg: jest.fn(),
111+
errorMsg: jest.fn(),
112+
blocks: {
113+
loadNewBlocks: jest.fn()
114+
}
115+
};
116+
117+
const canvas = document.createElement("canvas");
118+
document.body.appendChild(canvas);
119+
120+
const audioError = document.createElement("div");
121+
audioError.className = "audio-error";
122+
document.body.appendChild(audioError);
123+
124+
const stopAudio = document.createElement("button");
125+
stopAudio.className = "stop-audio";
126+
document.body.appendChild(stopAudio);
127+
});
128+
129+
describe("Constructor and Properties", () => {
130+
test("Initializes basic properties", () => {
131+
const aiwidget = new AIWidget();
132+
133+
expect(aiwidget.sampleName).toBe("electronic synth");
134+
expect(aiwidget.sampleData).toBe("");
135+
expect(aiwidget.samplePitch).toBe("sol");
136+
expect(aiwidget.sampleOctave).toBe("4");
137+
expect(aiwidget.pitchCenter).toBe(9);
138+
expect(aiwidget.accidentalCenter).toBe(2);
139+
expect(aiwidget.octaveCenter).toBe(4);
140+
expect(aiwidget.sampleLength).toBe(1000);
141+
expect(aiwidget.pitchAnalysers).toEqual({});
142+
});
143+
});
144+
145+
describe("State Modifiers", () => {
146+
let aiwidget;
147+
148+
beforeEach(() => {
149+
aiwidget = new AIWidget();
150+
});
151+
152+
test("_usePitch updates pitchCenter", () => {
153+
aiwidget._usePitch("sol");
154+
expect(aiwidget.pitchCenter).toBe(4);
155+
156+
aiwidget._usePitch("unknown");
157+
expect(aiwidget.pitchCenter).toBe(0); // Fallback to 0 if not found
158+
});
159+
160+
test("_useAccidental updates accidentalCenter", () => {
161+
aiwidget._useAccidental(global.FLAT);
162+
expect(aiwidget.accidentalCenter).toBe(1);
163+
164+
aiwidget._useAccidental("unknown");
165+
expect(aiwidget.accidentalCenter).toBe(2); // Fallback to center (natural)
166+
});
167+
168+
test("_useOctave sets octave number", () => {
169+
aiwidget._useOctave("5");
170+
expect(aiwidget.octaveCenter).toBe(5);
171+
});
172+
});
173+
174+
describe("Init Method", () => {
175+
test("Sets up widget window and basic dependencies", () => {
176+
const aiwidget = new AIWidget();
177+
178+
// Spy on makeCanvas, reconnectSynthsToAnalyser, and pause
179+
jest.spyOn(aiwidget, "makeCanvas").mockImplementation(() => {});
180+
jest.spyOn(aiwidget, "reconnectSynthsToAnalyser").mockImplementation(() => {});
181+
jest.spyOn(aiwidget, "pause").mockImplementation(() => {});
182+
183+
aiwidget.init(mockActivity);
184+
185+
expect(window.widgetWindows.windowFor).toHaveBeenCalledWith(aiwidget, "AI");
186+
expect(mockWidgetWindow.clear).toHaveBeenCalled();
187+
expect(mockWidgetWindow.show).toHaveBeenCalled();
188+
expect(mockWidgetWindow.sendToCenter).toHaveBeenCalledTimes(2);
189+
expect(aiwidget.activity).toBe(mockActivity);
190+
191+
// Play and Export buttons logic
192+
expect(mockWidgetWindow.addButton).toHaveBeenCalledTimes(2);
193+
expect(aiwidget.reconnectSynthsToAnalyser).toHaveBeenCalled();
194+
});
195+
});
196+
197+
describe("Methods and Helpers", () => {
198+
let aiwidget;
199+
200+
beforeEach(() => {
201+
aiwidget = new AIWidget();
202+
aiwidget.activity = mockActivity;
203+
});
204+
205+
test("getSampleLength checks sample size threshold", () => {
206+
aiwidget.sampleData = "A".repeat(1333334);
207+
aiwidget.getSampleLength();
208+
expect(mockActivity.errorMsg).toHaveBeenCalledWith(
209+
"Warning: Sample is bigger than 1MB.",
210+
undefined
211+
);
212+
213+
mockActivity.errorMsg.mockClear();
214+
aiwidget.sampleData = "A".repeat(1000);
215+
aiwidget.getSampleLength();
216+
expect(mockActivity.errorMsg).not.toHaveBeenCalled();
217+
});
218+
219+
test("showSampleTypeError triggers error", () => {
220+
aiwidget.showSampleTypeError();
221+
expect(mockActivity.errorMsg).toHaveBeenCalledWith(
222+
"Upload failed: Sample is not a .wav file.",
223+
undefined
224+
);
225+
});
226+
227+
test("_saveSample calls inner __save method logic", () => {
228+
jest.spyOn(aiwidget, "__save").mockImplementation(() => {});
229+
aiwidget._saveSample();
230+
expect(aiwidget.__save).toHaveBeenCalled();
231+
});
232+
233+
test("_addSample pushes unique names to CUSTOMSAMPLES array", () => {
234+
global.CUSTOMSAMPLES = [];
235+
236+
aiwidget.sampleName = "test_sample_1";
237+
aiwidget.sampleData = "data1";
238+
aiwidget._addSample();
239+
240+
expect(global.CUSTOMSAMPLES.length).toBe(1);
241+
expect(global.CUSTOMSAMPLES[0]).toEqual(["test_sample_1", "data1"]);
242+
243+
// Try adding same again, should skip
244+
aiwidget._addSample();
245+
expect(global.CUSTOMSAMPLES.length).toBe(1);
246+
});
247+
248+
test("reconnectSynthsToAnalyser initializes Analyser and connects synth", () => {
249+
aiwidget.originalSampleName = "test_sample_orig";
250+
global.instruments = {
251+
0: {
252+
"electronic synth": { connect: jest.fn() },
253+
"customsample_test_sample_orig": { connect: jest.fn() }
254+
}
255+
};
256+
257+
aiwidget.reconnectSynthsToAnalyser();
258+
259+
expect(global.Tone.Analyser).toHaveBeenCalledTimes(2);
260+
expect(global.instruments[0]["electronic synth"].connect).toHaveBeenCalled();
261+
expect(
262+
global.instruments[0]["customsample_test_sample_orig"].connect
263+
).toHaveBeenCalled();
264+
});
265+
266+
test("_playSample triggers synth with sample configuration", () => {
267+
aiwidget.sampleName = "valid_sample";
268+
aiwidget.originalSampleName = "original_name";
269+
aiwidget.sampleLength = 2000;
270+
jest.spyOn(aiwidget, "reconnectSynthsToAnalyser").mockImplementation(() => {});
271+
272+
aiwidget._playSample();
273+
274+
expect(aiwidget.reconnectSynthsToAnalyser).toHaveBeenCalled();
275+
expect(mockActivity.logo.synth.trigger).toHaveBeenCalledWith(
276+
0,
277+
[220],
278+
2,
279+
"customsample_original_name",
280+
null,
281+
null,
282+
false
283+
);
284+
});
285+
286+
test("pause stops the midiBuffer", () => {
287+
const stopMock = jest.fn();
288+
global.mockMidiBuffer({ stop: stopMock });
289+
aiwidget.pause();
290+
expect(stopMock).toHaveBeenCalled();
291+
});
292+
293+
test("resume sets isMoving true and updates playBtn", () => {
294+
aiwidget.playBtn = document.createElement("button");
295+
aiwidget.isMoving = false;
296+
297+
aiwidget.resume();
298+
299+
expect(aiwidget.isMoving).toBe(true);
300+
expect(aiwidget.playBtn.innerHTML).toContain("pause-button.svg");
301+
});
302+
});
303+
});

0 commit comments

Comments
 (0)