@@ -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