Skip to content

Commit 0ec34e9

Browse files
committed
test: add regression safety tests for aiwidget lifecycle and async flows
1 parent e3e79a9 commit 0ec34e9

1 file changed

Lines changed: 280 additions & 0 deletions

File tree

js/widgets/__tests__/aiwidget.test.js

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,4 +216,284 @@ describe("AIWidget Instance", () => {
216216
undefined
217217
);
218218
});
219+
220+
it("should debounce generate blocks action and prevent duplicate saves", () => {
221+
jest.useFakeTimers();
222+
let generateButtonHandler;
223+
mockActivity.storage = {
224+
groq_api_key: "test-key"
225+
};
226+
global.window.widgetWindows.windowFor = jest.fn(() => ({
227+
clear: jest.fn(),
228+
show: jest.fn(),
229+
destroy: jest.fn(),
230+
sendToCenter: jest.fn(),
231+
isMaximized: jest.fn(() => false),
232+
getWidgetFrame: jest.fn(() => ({
233+
getBoundingClientRect: jest.fn(() => ({ width: 800, height: 600 }))
234+
})),
235+
getWidgetBody: jest.fn(() => {
236+
const div = document.createElement("div");
237+
div.getBoundingClientRect = jest.fn(() => ({
238+
width: 800,
239+
height: 600
240+
}));
241+
return div;
242+
}),
243+
addButton: jest.fn(iconName => {
244+
const button = { onclick: null };
245+
if (iconName === "export-chunk.svg") {
246+
generateButtonHandler = button;
247+
}
248+
return button;
249+
})
250+
}));
251+
aiWidget = new AIWidget();
252+
aiWidget._saveSample = jest.fn();
253+
aiWidget.init(mockActivity);
254+
generateButtonHandler.onclick();
255+
generateButtonHandler.onclick();
256+
generateButtonHandler.onclick();
257+
expect(aiWidget._saveSample).toHaveBeenCalledTimes(1);
258+
jest.advanceTimersByTime(1000);
259+
generateButtonHandler.onclick();
260+
expect(aiWidget._saveSample).toHaveBeenCalledTimes(2);
261+
jest.useRealTimers();
262+
});
263+
264+
it("should clean up analysers and animation frames on widget close", () => {
265+
const cancelAnimationFrameMock = jest.fn();
266+
global.cancelAnimationFrame = cancelAnimationFrameMock;
267+
const disposeMock = jest.fn();
268+
global.Tone.Analyser = jest.fn(() => ({
269+
dispose: disposeMock
270+
}));
271+
global.instruments = [
272+
{
273+
piano: {
274+
disconnect: jest.fn(),
275+
connect: jest.fn()
276+
}
277+
}
278+
];
279+
let widgetInstance;
280+
global.window.widgetWindows.windowFor = jest.fn(() => {
281+
widgetInstance = {
282+
clear: jest.fn(),
283+
show: jest.fn(),
284+
destroy: jest.fn(),
285+
sendToCenter: jest.fn(),
286+
isMaximized: jest.fn(() => false),
287+
getWidgetFrame: jest.fn(() => ({
288+
getBoundingClientRect: jest.fn(() => ({
289+
width: 800,
290+
height: 600
291+
}))
292+
})),
293+
getWidgetBody: jest.fn(() => {
294+
const div = document.createElement("div");
295+
div.getBoundingClientRect = jest.fn(() => ({
296+
width: 800,
297+
height: 600
298+
}));
299+
return div;
300+
}),
301+
addButton: jest.fn(() => ({ onclick: null })),
302+
onclose: null
303+
};
304+
return widgetInstance;
305+
});
306+
aiWidget = new AIWidget();
307+
aiWidget.drawVisualIDs = {
308+
one: 11,
309+
two: 22
310+
};
311+
aiWidget.pitchAnalysers = {
312+
0: {
313+
dispose: disposeMock
314+
}
315+
};
316+
aiWidget.init(mockActivity);
317+
aiWidget.drawVisualIDs = {
318+
one: 11,
319+
two: 22
320+
};
321+
aiWidget.pitchAnalysers = {
322+
0: {
323+
dispose: disposeMock
324+
}
325+
};
326+
widgetInstance.onclose();
327+
expect(cancelAnimationFrameMock).toHaveBeenCalledWith(11);
328+
expect(cancelAnimationFrameMock).toHaveBeenCalledWith(22);
329+
expect(disposeMock).toHaveBeenCalled();
330+
expect(widgetInstance.destroy).toHaveBeenCalled();
331+
expect(aiWidget.pitchAnalysers).toEqual({});
332+
});
333+
334+
it("should handle synth initialization failures gracefully", async () => {
335+
const resumeMock = jest.fn(() => Promise.resolve());
336+
const initMock = jest.fn(() => Promise.reject(new Error("init failed")));
337+
global.ABCJS = {
338+
renderAbc: jest.fn(() => [
339+
{
340+
millisecondsPerMeasure: jest.fn(() => 1000)
341+
}
342+
]),
343+
synth: {
344+
supportsAudio: jest.fn(() => true),
345+
CreateSynth: jest.fn(() => ({
346+
init: initMock,
347+
prime: jest.fn(),
348+
start: jest.fn(),
349+
stop: jest.fn()
350+
}))
351+
}
352+
};
353+
aiWidget = new AIWidget();
354+
mockActivity.logo.synth = {
355+
tone: {
356+
context: {
357+
resume: resumeMock
358+
}
359+
}
360+
};
361+
aiWidget.activity = mockActivity;
362+
await aiWidget._playABCSong();
363+
expect(mockActivity.errorMsg).toHaveBeenCalledWith(
364+
expect.stringContaining("Synth error: init failed")
365+
);
366+
});
367+
368+
it("should prevent submission when API key is missing", () => {
369+
global.alert = jest.fn();
370+
aiWidget = new AIWidget();
371+
mockActivity.storage = {};
372+
const widgetBody = document.createElement("div");
373+
const widgetWindowMock = {
374+
clear: jest.fn(),
375+
show: jest.fn(),
376+
destroy: jest.fn(),
377+
sendToCenter: jest.fn(),
378+
isMaximized: jest.fn(() => false),
379+
getWidgetFrame: jest.fn(() => ({
380+
getBoundingClientRect: jest.fn(() => ({
381+
width: 800,
382+
height: 600
383+
}))
384+
})),
385+
getWidgetBody: jest.fn(() => widgetBody),
386+
addButton: jest.fn(() => ({ onclick: null }))
387+
};
388+
aiWidget.widgetWindow = widgetWindowMock;
389+
aiWidget.activity = mockActivity;
390+
aiWidget.makeCanvas(800, 400);
391+
const input = widgetBody.querySelector(".inputField");
392+
const submitButton = widgetBody.querySelector(".submitButton");
393+
input.value = "generate melody";
394+
global.fetch = jest.fn();
395+
submitButton.onclick();
396+
expect(global.alert).toHaveBeenCalled();
397+
expect(global.fetch).not.toHaveBeenCalled();
398+
});
399+
400+
it("should display API error messages from Groq responses", async () => {
401+
global.fetch = jest.fn(() =>
402+
Promise.resolve({
403+
json: () =>
404+
Promise.resolve({
405+
error: {
406+
message: "invalid api key"
407+
}
408+
})
409+
})
410+
);
411+
aiWidget = new AIWidget();
412+
mockActivity.storage = {
413+
groq_api_key: "test-key"
414+
};
415+
const widgetBody = document.createElement("div");
416+
aiWidget.widgetWindow = {
417+
getWidgetBody: jest.fn(() => widgetBody)
418+
};
419+
aiWidget.activity = mockActivity;
420+
aiWidget.makeCanvas(800, 400);
421+
const input = widgetBody.querySelector(".inputField");
422+
const submitButton = widgetBody.querySelector(".submitButton");
423+
const textarea = widgetBody.querySelector(".samplerTextarea");
424+
input.value = "generate melody";
425+
submitButton.onclick();
426+
await new Promise(resolve => setTimeout(resolve, 0));
427+
expect(textarea.value).toContain("Groq API Error: invalid api key");
428+
expect(submitButton.disabled).toBe(false);
429+
});
430+
431+
it("should handle malformed AI responses without choices", async () => {
432+
global.fetch = jest.fn(() =>
433+
Promise.resolve({
434+
json: () => Promise.resolve({})
435+
})
436+
);
437+
aiWidget = new AIWidget();
438+
mockActivity.storage = {
439+
groq_api_key: "test-key"
440+
};
441+
const widgetBody = document.createElement("div");
442+
aiWidget.widgetWindow = {
443+
getWidgetBody: jest.fn(() => widgetBody)
444+
};
445+
aiWidget.activity = mockActivity;
446+
aiWidget.makeCanvas(800, 400);
447+
const input = widgetBody.querySelector(".inputField");
448+
const submitButton = widgetBody.querySelector(".submitButton");
449+
const textarea = widgetBody.querySelector(".samplerTextarea");
450+
input.value = "generate melody";
451+
submitButton.onclick();
452+
await new Promise(resolve => setTimeout(resolve, 0));
453+
expect(textarea.value).toContain("Error: Unexpected response format from AI.");
454+
expect(submitButton.disabled).toBe(false);
455+
});
456+
457+
it("should show an error when audio is not supported", async () => {
458+
global.ABCJS = {
459+
renderAbc: jest.fn(() => [
460+
{
461+
millisecondsPerMeasure: jest.fn(() => 1000)
462+
}
463+
]),
464+
synth: {
465+
supportsAudio: jest.fn(() => false)
466+
}
467+
};
468+
aiWidget = new AIWidget();
469+
aiWidget.activity = mockActivity;
470+
await aiWidget._playABCSong();
471+
expect(mockActivity.errorMsg).toHaveBeenCalledWith("Audio not supported in this browser.");
472+
});
473+
474+
it("should remove old interface containers before rerendering", () => {
475+
aiWidget = new AIWidget();
476+
const widgetBody = document.createElement("div");
477+
const oldContainer1 = document.createElement("div");
478+
oldContainer1.className = "ai-interface-container";
479+
const oldContainer2 = document.createElement("div");
480+
oldContainer2.className = "ai-interface-container";
481+
widgetBody.appendChild(oldContainer1);
482+
widgetBody.appendChild(oldContainer2);
483+
const makeCanvasSpy = jest.spyOn(aiWidget, "makeCanvas").mockImplementation(() => {});
484+
aiWidget.widgetWindow = {
485+
getWidgetBody: jest.fn(() => widgetBody),
486+
getWidgetFrame: jest.fn(() => ({
487+
getBoundingClientRect: jest.fn(() => ({
488+
height: 600
489+
}))
490+
})),
491+
isMaximized: jest.fn(() => false)
492+
};
493+
aiWidget.reconnectSynthsToAnalyser = jest.fn();
494+
aiWidget._scale();
495+
expect(widgetBody.getElementsByClassName("ai-interface-container").length).toBe(0);
496+
expect(makeCanvasSpy).toHaveBeenCalled();
497+
expect(aiWidget.reconnectSynthsToAnalyser).toHaveBeenCalled();
498+
});
219499
});

0 commit comments

Comments
 (0)