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