From d0f6a1722b0bffd762f91c8901e72942b347c75d Mon Sep 17 00:00:00 2001 From: varruunnn Date: Sat, 7 Feb 2026 22:45:42 +0530 Subject: [PATCH 001/163] increasing coverage of generate.js --- js/js-export/__tests__/generate.test.js | 163 ++++++++++++++++++++++++ 1 file changed, 163 insertions(+) diff --git a/js/js-export/__tests__/generate.test.js b/js/js-export/__tests__/generate.test.js index f09a129c79..250a6af659 100644 --- a/js/js-export/__tests__/generate.test.js +++ b/js/js-export/__tests__/generate.test.js @@ -44,6 +44,10 @@ const astring = { global.ASTUtils = ASTUtils; global.astring = astring; +global.JSInterface = { + isGetter: jest.fn(name => name === "myGetter") +}; + describe("JSGenerate Class", () => { beforeEach(() => { jest.clearAllMocks(); @@ -169,4 +173,163 @@ describe("JSGenerate Class", () => { ); expect(console.log).toHaveBeenCalledWith("generated code"); }); + test("should generate stack trees with various block types and arguments", () => { + globalActivity.blocks.stackList = [1, 20]; + const booleanGrandParent = { constructor: { name: "BooleanBlock" } }; + const booleanParent = Object.create(booleanGrandParent); + const booleanProtoblock = Object.create(booleanParent); + booleanProtoblock.style = "value"; + const standardGrandParent = { constructor: { name: "StandardBlock" } }; + const standardParent = Object.create(standardGrandParent); + const standardProtoblock = Object.create(standardParent); + standardProtoblock.style = "value"; + + globalActivity.blocks.blockList = { + 1: { + name: "start", + trash: false, + connections: [null, 2, null], + protoblock: { style: "hat" } + }, + 2: { + name: "storein2", + privateData: "myVar", + connections: [1, 4, 3], + protoblock: { style: "command", args: 1 } + }, + 4: { + name: "hspace", + connections: [2, 5], + protoblock: { style: "spacer" } + }, + 5: { + name: "value", + value: 42, + connections: [4], + protoblock: standardProtoblock + }, + 3: { + name: "if", + connections: [2, 6, 7, 10, 13], + protoblock: { style: "doubleclamp", args: 3 } + }, + 6: { + name: "boolean", + value: "true", + connections: [3], + protoblock: booleanProtoblock + }, + 7: { + name: "nameddo", + privateData: "myProc", + connections: [3, 8], + protoblock: { style: "command" } + }, + 8: { name: "hidden", connections: [7, 9], protoblock: { style: "command" } }, + 9: { name: "command", connections: [8, null], protoblock: { style: "command" } }, + + 10: { + name: "repeat", + connections: [3, 11, 12, null], + protoblock: { style: "clamp", args: 2 } + }, + 11: { + name: "namedbox", + privateData: "box1", + connections: [10], + protoblock: standardProtoblock + }, + 12: { name: "command", connections: [10, null], protoblock: { style: "command" } }, + 13: { name: "command", connections: [3, null], protoblock: { style: "command" } }, + 20: { + name: "action", + trash: false, + connections: [null, 21, 22, null], + protoblock: { style: "hat" } + }, + 21: { + name: "value", + value: "myAction", + connections: [20], + protoblock: standardProtoblock + }, + 22: { + name: "myGetter", + connections: [20, null], + protoblock: { style: "value" } + } + }; + + JSGenerate.generateStacksTree(); + + expect(JSGenerate.startTrees.length).toBe(1); + expect(JSGenerate.actionTrees.length).toBe(1); + expect(JSGenerate.actionNames).toContain("myAction"); + + const tree = JSGenerate.startTrees[0]; + expect(tree[0][0]).toBe("storein2_myVar"); + expect(tree[0][1][0]).toBe(42); + expect(tree[1][0]).toBe("if"); + expect(tree[1][1][0]).toBe("bool_true"); + expect(tree[1][2][0][0]).toBe("nameddo_myProc"); + expect(tree[1][3][0][1][0]).toBe("box_box1"); + }); + + test("should warn when clamp block flows left", () => { + globalActivity.blocks.stackList = [1]; + globalActivity.blocks.blockList = { + 1: { + name: "start", + trash: false, + connections: [null, 2, null], + protoblock: { style: "hat" } + }, + 2: { + name: "command", + connections: [1, 3, null], + protoblock: { style: "command", args: 1 } + }, + 3: { + name: "badClamp", + connections: [2], + protoblock: { + style: "clamp", + _style: { flows: { left: true } } + } + } + }; + + const warnSpy = jest.spyOn(console, "warn").mockImplementation(); + JSGenerate.generateStacksTree(); + expect(warnSpy).toHaveBeenCalledWith('CANNOT PROCESS "badClamp" BLOCK'); + warnSpy.mockRestore(); + }); + + test("should print complex stack trees with nested args and flows", () => { + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining("** NEXTFLOW **"), + "color: green" + ); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining("ACTION"), + "background: green; color: white; font-weight: bold" + ); + }); + + test("should handle astring generation errors", () => { + JSGenerate.AST = { type: "Program", body: [] }; + astring.generate + .mockImplementationOnce(() => { + throw new Error("Code Gen Error"); + }) + .mockImplementationOnce(() => "fallback code"); + + JSGenerate.generateCode(); + + expect(JSGenerate.generateFailed).toBe(true); + expect(console.error).toHaveBeenCalledWith( + "CANNOT GENERATE CODE\nError: INVALID ABSTRACT SYNTAX TREE" + ); + expect(JSGenerate.code).toBe("fallback code"); + }); }); From 2cfc5a9e97d1e02bea21ce002d5a777749cdb7f8 Mon Sep 17 00:00:00 2001 From: varruunnn Date: Sat, 7 Feb 2026 22:59:17 +0530 Subject: [PATCH 002/163] fixed test --- js/js-export/__tests__/generate.test.js | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/js/js-export/__tests__/generate.test.js b/js/js-export/__tests__/generate.test.js index 250a6af659..b3da2be0aa 100644 --- a/js/js-export/__tests__/generate.test.js +++ b/js/js-export/__tests__/generate.test.js @@ -304,8 +304,22 @@ describe("JSGenerate Class", () => { expect(warnSpy).toHaveBeenCalledWith('CANNOT PROCESS "badClamp" BLOCK'); warnSpy.mockRestore(); }); - test("should print complex stack trees with nested args and flows", () => { + JSGenerate.startTrees = [ + [ + ["block1", ["arg1", "subArg"], null], + ["block2", null, [["flow1Block", null, null]], [["flow2Block", null, null]]] + ] + ]; + JSGenerate.actionTrees = [[["actionBlock", null, null]]]; + + JSGenerate.printStacksTree(); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining("(arg1, subArg)"), + "background: mediumslateblue", + "background; none", + "color: dodgerblue" + ); expect(console.log).toHaveBeenCalledWith( expect.stringContaining("** NEXTFLOW **"), "color: green" @@ -315,7 +329,6 @@ describe("JSGenerate Class", () => { "background: green; color: white; font-weight: bold" ); }); - test("should handle astring generation errors", () => { JSGenerate.AST = { type: "Program", body: [] }; astring.generate From 5dac99d0dd54bb9278257ea3209336c16f0a3cf2 Mon Sep 17 00:00:00 2001 From: subhraneel2005 Date: Mon, 9 Feb 2026 21:01:33 +0530 Subject: [PATCH 003/163] chore: remove debug console statements from js/palette.js --- js/palette.js | 28 ++++++---------------------- 1 file changed, 6 insertions(+), 22 deletions(-) diff --git a/js/palette.js b/js/palette.js index 1a7b333b30..76ef6beec9 100644 --- a/js/palette.js +++ b/js/palette.js @@ -104,9 +104,6 @@ class Palettes { } _makeSelectorButton(i) { - // eslint-disable-next-line no-console - console.debug("makeSelectorButton " + i); - if (!document.getElementById("palette")) { const element = document.createElement("div"); element.id = "palette"; @@ -134,6 +131,7 @@ class Palettes { element.childNodes[0].style.border = `1px solid ${platformColor.selectorSelected}`; document.body.appendChild(element); } + const tr = docById("palette").children[0].children[0].children[0].children[0]; const td = tr.insertCell(); td.width = 1.5 * this.cellSize; @@ -218,8 +216,6 @@ class Palettes { } getPluginMacroExpansion(blkname, x, y) { - // eslint-disable-next-line no-console - console.debug(this.pluginMacros[blkname]); const obj = this.pluginMacros[blkname]; if (obj != null) { obj[0][2] = x; @@ -638,9 +634,9 @@ class PaletteModel { } const protoBlock = this.activity.blocks.protoBlockDict[blkname]; + if (protoBlock === null) { - // eslint-disable-next-line no-console - console.debug("Could not find block " + blkname); + return; } let label = ""; @@ -1170,8 +1166,6 @@ class Palette { _makeBlockFromPalette(protoblk, blkname, callback) { if (protoblk === null) { - // eslint-disable-next-line no-console - console.debug("null protoblk?"); return; } @@ -1191,10 +1185,7 @@ class Palette { break; case "storein2": // Use the name of the box in the label - // eslint-disable-next-line no-console - console.debug( - "storein2" + " " + protoblk.defaults[0] + " " + protoblk.staticLabels[0] - ); + blkname = "store in2 " + protoblk.defaults[0]; newBlk = protoblk.name; arg = protoblk.staticLabels[0]; @@ -1211,8 +1202,6 @@ class Palette { blkname = "namedbox"; arg = _("box"); } else { - // eslint-disable-next-line no-console - console.debug(protoblk.defaults[0]); blkname = protoblk.defaults[0]; arg = protoblk.defaults[0]; } @@ -1348,9 +1337,6 @@ class Palette { for (let blk = 0; blk < this.activity.blocks.blockList.length; blk++) { const block = this.activity.blocks.blockList[blk]; if (block.name === "status" && !block.trash) { - console.log( - "Status block already exists, preventing creation of another one" - ); return; } } @@ -1474,7 +1460,6 @@ class Palette { for (let i = 0; i < boxBlocks.length; i++) { const boxBlockId = boxBlocks[i]; const boxBlock = activity.blocks.blockList[boxBlockId]; - console.log("Adding box block to status:", boxBlock); statusBlocks.push([ lastBlockIndex + 1, @@ -1499,7 +1484,7 @@ class Palette { ]); lastBlockIndex += 2; } - console.log("blocks"); + macroExpansion = statusBlocks; // Initialize the status matrix @@ -1637,8 +1622,7 @@ const initPalettes = async palettes => { palettes.init_selectors(); palettes.makePalettes(0); - // eslint-disable-next-line no-console - console.debug("Time to show the palettes."); + palettes.show(); }; From 271dca0184427a7c9def5b1bb2df9a9839533dbe Mon Sep 17 00:00:00 2001 From: DhyaniKavya Date: Tue, 10 Feb 2026 12:04:53 +0530 Subject: [PATCH 004/163] Remove debug console.log from toolbar play handler --- js/toolbar.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/js/toolbar.js b/js/toolbar.js index 2651f93581..51a12f2225 100644 --- a/js/toolbar.js +++ b/js/toolbar.js @@ -390,8 +390,6 @@ class Toolbar { function handleClick() { if (!isPlayIconRunning) { playIcon.onclick = null; - // eslint-disable-next-line no-console - console.log("Wait for next 2 seconds to play the music"); } else { // eslint-disable-next-line no-use-before-define playIcon.onclick = tempClick; From 09b937cde1b7e25a16f0c9478dd8fe11c5cc3c66 Mon Sep 17 00:00:00 2001 From: DhyaniKavya Date: Tue, 10 Feb 2026 12:14:42 +0530 Subject: [PATCH 005/163] chore: fix Prettier formatting for CI/CD pipeline --- js/loader.js | 4 ++-- script.js | 4 ++-- sw.js | 11 +++++------ 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/js/loader.js b/js/loader.js index 05097e7e34..f9ef8900dc 100644 --- a/js/loader.js +++ b/js/loader.js @@ -69,7 +69,7 @@ requirejs(["i18next", "i18nextHttpBackend"], function (i18next, i18nextHttpBacke console.error("i18next init failed:", err); } window.i18next = i18next; - resolve(i18next); + resolve(i18next); } ); }); @@ -101,4 +101,4 @@ requirejs(["i18next", "i18nextHttpBackend"], function (i18next, i18nextHttpBacke } main(); -}); \ No newline at end of file +}); diff --git a/script.js b/script.js index 7a2482b71e..38fd2c1338 100644 --- a/script.js +++ b/script.js @@ -3,7 +3,7 @@ * @function * @global */ -$(document).ready(function() { +$(document).ready(function () { /** * The user's selected mode, stored in local storage. * @type {string} @@ -45,6 +45,6 @@ $(document).ready(function() { $(".materialize-iso, .dropdown-trigger").dropdown({ constrainWidth: false, hover: false, // Activate on click - belowOrigin: true, // Displays dropdown below the button + belowOrigin: true // Displays dropdown below the button }); }); diff --git a/sw.js b/sw.js index 7e6d97fd9c..096ac84830 100644 --- a/sw.js +++ b/sw.js @@ -55,7 +55,7 @@ function fromCache(request) { if (!matching || matching.status === 404) { return Promise.reject("no-match"); } - + return matching; }); }); @@ -103,7 +103,7 @@ self.addEventListener("fetch", function (event) { ); }); -self.addEventListener("beforeinstallprompt", (event) => { +self.addEventListener("beforeinstallprompt", event => { // eslint-disable-next-line no-console console.log("done", "beforeinstallprompt", event); // Stash the event so it can be triggered later. @@ -120,11 +120,10 @@ self.addEventListener("refreshOffline", function () { return fetch(offlineFallbackPage).then(function (response) { return caches.open(CACHE).then(function (cache) { // eslint-disable-next-line no-console - console.log("[PWA Builder] Offline page updated from refreshOffline event: " + response.url); + console.log( + "[PWA Builder] Offline page updated from refreshOffline event: " + response.url + ); return cache.put(offlinePageRequest, response); }); }); }); - - - From e4fbcf1cb4b015d259ad0e5ddbc6988691049c9f Mon Sep 17 00:00:00 2001 From: Shuvro Bhattacharjee Date: Wed, 11 Feb 2026 04:51:55 +0600 Subject: [PATCH 006/163] Fix: add localized tooltips for Close and Minimize buttons (#5196) --- js/widgets/widgetWindows.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/js/widgets/widgetWindows.js b/js/widgets/widgetWindows.js index b4e54abbb9..84df9789f4 100644 --- a/js/widgets/widgetWindows.js +++ b/js/widgets/widgetWindows.js @@ -110,6 +110,7 @@ class WidgetWindow { }; } const closeButton = this._create("div", "wftButton close", this._drag); + closeButton.title = _("Close"); closeButton.onclick = e => { this.onclose(); e.preventDefault(); @@ -154,6 +155,7 @@ class WidgetWindow { this._nonclosebuttons.style.display = "flex"; this._rollButton = this._create("div", "wftButton rollup", this._nonclosebuttons); const rollButton = this._rollButton; + rollButton.title = _("Minimize"); rollButton.onclick = e => { if (this._rolled) { this.unroll(); From eb5dcd3967a074d702073e67fbd7abbe2e331511 Mon Sep 17 00:00:00 2001 From: raiyyan777 Date: Wed, 11 Feb 2026 07:02:21 +0530 Subject: [PATCH 007/163] [Performance/Memory] Fix event listener leaks in Trash View and Block Context Menu (#5599) * Manage click listeners to prevent duplicates * Forgot to Close trashViewClickHandler function --- js/activity.js | 16 ++++++++++++++-- js/piemenus.js | 11 +++++++++-- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/js/activity.js b/js/activity.js index 4e5d8961d2..7067ac07ac 100644 --- a/js/activity.js +++ b/js/activity.js @@ -3948,18 +3948,30 @@ class Activity { this._renderTrashView(); }); + // Store the click handler reference for proper cleanup + let trashViewClickHandler = null; + // function to hide trashView from canvas function handleClickOutsideTrashView(trashView) { + // Remove existing listener to prevent duplicates + if (trashViewClickHandler) { + document.removeEventListener("click", trashViewClickHandler); + } + let firstClick = true; - document.addEventListener("click", event => { + trashViewClickHandler = event => { if (firstClick) { firstClick = false; return; } if (!trashView.contains(event.target) && event.target !== trashView) { trashView.style.display = "none"; + // Clean up listener when trashView is hidden + document.removeEventListener("click", trashViewClickHandler); + trashViewClickHandler = null; } - }); + }; + document.addEventListener("click", trashViewClickHandler); } this._renderTrashView = () => { diff --git a/js/piemenus.js b/js/piemenus.js index c5cedcd12d..aaa42452d2 100644 --- a/js/piemenus.js +++ b/js/piemenus.js @@ -3610,13 +3610,20 @@ const piemenuBlockContext = block => { docById("contextWheelDiv").style.display = "none"; }; - document.body.addEventListener("click", event => { + // Named function for proper cleanup + const hideContextWheelOnClick = event => { const wheelElement = document.getElementById("contextWheelDiv"); const displayStyle = window.getComputedStyle(wheelElement).display; if (displayStyle === "block") { wheelElement.style.display = "none"; + // Remove listener after hiding to prevent memory leak + document.body.removeEventListener("click", hideContextWheelOnClick); } - }); + }; + + // Remove any existing listener before adding a new one + document.body.removeEventListener("click", hideContextWheelOnClick); + document.body.addEventListener("click", hideContextWheelOnClick); if ( ["customsample", "temperament1", "definemode", "show", "turtleshell", "action"].includes( From 4e2993fd69719f4ad7c94fcda9b05d3799e8896c Mon Sep 17 00:00:00 2001 From: Kartik Tripathi Date: Wed, 11 Feb 2026 07:04:51 +0530 Subject: [PATCH 008/163] fix(painter): implement SVG export for bezier curves (#5568) * fix(graphics): add SVG output support for Bezier curves * fix(svg): start bezier path at turtle position to preserve initial tangent * fix: svg output tally with shown output * formatting code --- js/turtle-painter.js | 380 ++++++++++++++++++++++--------------------- 1 file changed, 194 insertions(+), 186 deletions(-) diff --git a/js/turtle-painter.js b/js/turtle-painter.js index a591238289..4a297de54d 100644 --- a/js/turtle-painter.js +++ b/js/turtle-painter.js @@ -345,16 +345,9 @@ class Painter { let cy = ny; let sa = oAngleRadians - Math.PI; let ea = oAngleRadians; - this.turtle.ctx.arc(cx, cy, step, sa, ea, false); - - nxScaled = (nx + dx) * turtlesScale; - nyScaled = (ny + dy) * turtlesScale; - - let radiusScaled = step * turtlesScale; const steps = Math.max(Math.floor(savedStroke, 1)); - this._svgArc(steps, cx * turtlesScale, cy * turtlesScale, radiusScaled, sa); - this._svgOutput += nxScaled + "," + nyScaled + " "; + this._svgArc(steps, cx, cy, step, sa, ea, false, true); this.turtle.ctx.lineTo(ox + dx, oy + dy); nxScaled = (ox + dx) * turtlesScale; @@ -370,14 +363,9 @@ class Painter { cy = oy; sa = oAngleRadians - Math.PI; ea = oAngleRadians; - this.turtle.ctx.arc(cx, cy, step, sa, ea, false); - nxScaled = (ox + dx) * turtlesScale; - nyScaled = (oy + dy) * turtlesScale; - - radiusScaled = step * turtlesScale; - this._svgArc(steps, cx * turtlesScale, cy * turtlesScale, radiusScaled, sa); - this._svgOutput += nxScaled + "," + nyScaled + " "; + const stepsFinal = Math.max(Math.floor(savedStroke, 1)); + this._svgArc(stepsFinal, cx, cy, step, sa, ea, false, true); this.closeSVG(); @@ -470,19 +458,73 @@ class Painter { * @param sa - start angle * @param ea - end angle */ - _svgArc(nsteps, cx, cy, radius, sa, ea) { - // Import SVG arcs reliably - let a = sa; - const da = ea == null ? Math.PI / nsteps : (ea - sa) / nsteps; - - for (let i = 0; i < nsteps; i++) { - const nx = cx + radius * Math.cos(a); - const ny = cy + radius * Math.sin(a); - this._svgOutput += nx + "," + ny + " "; - a += da; + _svgArc(nsteps, cx, cy, radius, sa, ea, anticlockwise, drawOnCanvas) { + const turtlesScale = this.turtles.scale; + let diff = ea - sa; + + // Match canvas arc logic for anticlockwise wrapping + if (!anticlockwise && diff < 0) { + diff += 2 * Math.PI; + } else if (anticlockwise && diff > 0) { + diff -= 2 * Math.PI; + } + + for (let i = 1; i <= nsteps; i++) { + const t = i / nsteps; + const angle = sa + diff * t; + const nx = cx + radius * Math.cos(angle); + const ny = cy + radius * Math.sin(angle); + this._svgOutput += nx * turtlesScale + "," + ny * turtlesScale + " "; + if (drawOnCanvas) { + this.turtle.ctx.lineTo(nx, ny); + } } } + /** + * Discrete sampling of a cubic Bezier curve for SVG export. + * Matches the philosophy of _svgArc() to ensure pixel-perfect parity + * with canvas. Coordinates are computed in screen space and scaled + * only at the moment of emission to avoid precision drift. + * + * @private + * @param nsteps - number of segments to sample + * @param x0, y0 - start point + * @param cx1, cy1 - control point 1 + * @param cx2, cy2 - control point 2 + * @param x1, y1 - end point + */ + _svgBezier(nsteps, x0, y0, cx1, cy1, cx2, cy2, x1, y1, drawOnCanvas) { + const turtlesScale = this.turtles.scale; + for (let i = 1; i <= nsteps; i++) { + const t = i / nsteps; + const mt = 1 - t; + const mt2 = mt * mt; + const mt3 = mt2 * mt; + const t2 = t * t; + const t3 = t2 * t; + + const nx = mt3 * x0 + 3 * mt2 * t * cx1 + 3 * mt * t2 * cx2 + t3 * x1; + const ny = mt3 * y0 + 3 * mt2 * t * cy1 + 3 * mt * t2 * cy2 + t3 * y1; + this._svgOutput += nx * turtlesScale + "," + ny * turtlesScale + " "; + if (drawOnCanvas) { + this.turtle.ctx.lineTo(nx, ny); + } + } + } + _estimateBezierSteps(x0, y0, cx1, cy1, cx2, cy2, x1, y1) { + const chord = Math.hypot(x1 - x0, y1 - y0); + + const contNet = + Math.hypot(cx1 - x0, cy1 - y0) + + Math.hypot(cx2 - cx1, cy2 - cy1) + + Math.hypot(x1 - cx2, y1 - cy2); + + const flatness = contNet - chord; + + return Math.max(12, Math.ceil(Math.sqrt(flatness) * 3)); + } + /** * Draws an arc with turtle pen and moves turtle to the end of the arc. * @@ -555,18 +597,16 @@ class Painter { } this._svgOutput += ' 0) { + diff -= 2 * Math.PI; + } + const nsteps = Math.max(Math.floor((radius * Math.abs(diff)) / 2), 2); const steps = Math.max(Math.floor(savedStroke, 1)); - this._svgArc( - nsteps, - cx * turtlesScale, - cy * turtlesScale, - (radius + step) * turtlesScale, - sa, - ea - ); + this._svgArc(nsteps, cx, cy, radius + step, sa, ea, anticlockwise, true); capAngleRadians = ((this.turtle.orientation + 90) * Math.PI) / 180.0; dx = step * Math.sin(capAngleRadians); @@ -576,37 +616,13 @@ class Painter { const cy1 = ny; const sa1 = ea; const ea1 = ea + Math.PI; - this.turtle.ctx.arc(cx1, cy1, step, sa1, ea1, anticlockwise); - this._svgArc( - steps, - cx1 * turtlesScale, - cy1 * turtlesScale, - step * turtlesScale, - sa1, - ea1 - ); - this.turtle.ctx.arc(cx, cy, radius - step, ea, sa, !anticlockwise); - this._svgArc( - nsteps, - cx * turtlesScale, - cy * turtlesScale, - (radius - step) * turtlesScale, - ea, - sa - ); + this._svgArc(steps, cx1, cy1, step, sa1, ea1, anticlockwise, true); + this._svgArc(nsteps, cx, cy, radius - step, ea, sa, !anticlockwise, true); const cx2 = ox; const cy2 = oy; const sa2 = sa - Math.PI; const ea2 = sa; - this.turtle.ctx.arc(cx2, cy2, step, sa2, ea2, anticlockwise); - this._svgArc( - steps, - cx2 * turtlesScale, - cy2 * turtlesScale, - step * turtlesScale, - sa2, - ea2 - ); + this._svgArc(steps, cx2, cy2, step, sa2, ea2, anticlockwise, true); this.closeSVG(); this.turtle.ctx.stroke(); @@ -618,7 +634,6 @@ class Painter { this.turtle.ctx.lineCap = "round"; this.turtle.ctx.moveTo(nx, ny); } else if (this._penDown) { - this.turtle.ctx.arc(cx, cy, radius, sa, ea, anticlockwise); if (!this._svgPath) { this._svgPath = true; const oxScaled = ox * turtlesScale; @@ -626,23 +641,16 @@ class Painter { this._svgOutput += ' 0) { + diff -= 2 * Math.PI; + } + + const nsteps = Math.max(Math.floor((radius * Math.abs(diff)) / 2), 10); + this._svgArc(nsteps, cx, cy, radius, sa, ea, anticlockwise, true); - const nxScaled = nx * turtlesScale; - const nyScaled = ny * turtlesScale; - const radiusScaled = radius * turtlesScale; - this._svgOutput += - "A " + - radiusScaled + - "," + - radiusScaled + - " 0 0 " + - sweep + - " " + - nxScaled + - "," + - nyScaled + - " "; this.turtle.ctx.stroke(); if (!this._fillState) { this.turtle.ctx.closePath(); @@ -658,8 +666,8 @@ class Painter { this.turtle.x = x; this.turtle.y = y; } else { - this.turtle.x = this.screenX2turtles.turtleX(x); - this.turtle.y = this.screenY2turtles.turtleY(y); + this.turtle.x = turtles.screenX2turtleX(x); + this.turtle.y = turtles.screenY2turtleY(y); } } @@ -996,7 +1004,7 @@ class Painter { } /** - * Draws a bezier curve. + * Moves turtle to ending point in a bezier curve. * * @param x2 - the x-coordinate of the ending point * @param y2 - the y-coordinate of the ending point @@ -1007,8 +1015,6 @@ class Painter { const cp2x = this.cp2x; const cp2y = this.cp2y; - // FIXME: Add SVG output - let fx, fy; let ax, ay, bx, by, cx, cy, dx, dy; let dxi, dyi, dxf, dyf; @@ -1017,8 +1023,6 @@ class Painter { const turtlesScale = turtles.scale; if (this._penDown && this._hollowState) { - // Convert from turtle coordinates to screen coordinates - /* eslint-disable no-unused-vars */ fx = turtles.turtleX2screenX(x2); fy = turtles.turtleY2screenY(y2); const ix = turtles.turtleX2screenX(this.turtle.x); @@ -1028,104 +1032,124 @@ class Painter { const cx2 = turtles.turtleX2screenX(cp2x); const cy2 = turtles.turtleY2screenY(cp2y); - // Close the current SVG path - this.closeSVG(); - - // Save the current stroke width const savedStroke = this.stroke; this.stroke = 1; this.turtle.ctx.lineWidth = this.stroke; this.turtle.ctx.lineCap = "round"; - // Draw a hollow line const step = savedStroke < 3 ? 0.5 : (savedStroke - 2) / 2; const steps = Math.max(Math.floor(savedStroke, 1)); - /* We need both the initial and final headings */ - // The initial heading is the angle between (cp1x, cp1y) and (this.turtle.x, this.turtle.y) let degreesInitial = Math.atan2(cp1x - this.turtle.x, cp1y - this.turtle.y); degreesInitial = (180 * degreesInitial) / Math.PI; if (degreesInitial < 0) { degreesInitial += 360; } - // The final heading is the angle between (cp2x, cp2y) and (fx, fy) let degreesFinal = Math.atan2(x2 - cp2x, y2 - cp2y); degreesFinal = (180 * degreesFinal) / Math.PI; if (degreesFinal < 0) { degreesFinal += 360; } - // We also need to calculate the deltas for the 'caps' at each end const capAngleRadiansInitial = ((degreesInitial - 90) * Math.PI) / 180.0; - dxi = step * Math.sin(capAngleRadiansInitial); - dyi = -step * Math.cos(capAngleRadiansInitial); + const dxi = step * Math.sin(capAngleRadiansInitial); + const dyi = -step * Math.cos(capAngleRadiansInitial); const capAngleRadiansFinal = ((degreesFinal - 90) * Math.PI) / 180.0; - dxf = step * Math.sin(capAngleRadiansFinal); - dyf = -step * Math.cos(capAngleRadiansFinal); + const dxf = step * Math.sin(capAngleRadiansFinal); + const dyf = -step * Math.cos(capAngleRadiansFinal); + + const ax = ix - dxi; + const ay = iy - dyi; + const bx = fx - dxf; + const by = fy - dyf; + const cx = fx + dxf; + const cy = fy + dyf; + const dx = ix + dxi; + const dy = iy + dyi; - // The four 'corners' - ax = ix - dxi; - ay = iy - dyi; - const axScaled = ax * turtlesScale; - const ayScaled = ay * turtlesScale; - bx = fx - dxf; - by = fy - dyf; - const bxScaled = bx * turtlesScale; - const byScaled = by * turtlesScale; - cx = fx + dxf; - cy = fy + dyf; - const cxScaled = cx * turtlesScale; - const cyScaled = cy * turtlesScale; - dx = ix + dxi; - dy = iy + dyi; const dxScaled = dx * turtlesScale; const dyScaled = dy * turtlesScale; + const cxScaled = cx * turtlesScale; + const cyScaled = cy * turtlesScale; + const bxScaled = bx * turtlesScale; + const byScaled = by * turtlesScale; + const axScaled = ax * turtlesScale; + const ayScaled = ay * turtlesScale; - // Control points scaled for SVG output - // const cx1Scaled = (cx1 + dxi) * turtlesScale; - // const cy1Scaled = (cy1 + dyi) * turtlesScale; - // const cx2Scaled = (cx2 + dxf) * turtlesScale; - // const cy2Scaled = (cy2 + dyf) * turtlesScale; - + // Start the SVG path at the first corner + this.closeSVG(); this._svgPath = true; + this._svgOutput += '> ControlPointX2, ControlPointY2 >> X, Y - this._svgOutput += - "C " + - cx1Scaled + - "," + - cy1Scaled + - " " + - cx2Scaled + - "," + - cy2Scaled + - " " + - fxScaled + - "," + - fyScaled; - this.closeSVG(); + const nstepsCurve = this._estimateBezierSteps(ix, iy, cx1, cy1, cx2, cy2, fx, fy); + + this._svgBezier(nstepsCurve, ix, iy, cx1, cy1, cx2, cy2, fx, fy, true); this.turtle.x = x2; this.turtle.y = y2; + + // draw it this.turtle.ctx.stroke(); - if (!this._fillState) { - this.turtle.ctx.closePath(); - } } else { this.turtle.x = x2; this.turtle.y = y2; @@ -1207,9 +1214,10 @@ class Painter { this.turtle.container.x = fx; this.turtle.container.y = fy; - // The new heading is the angle between (cp2x, cp2y) and (x2, y2) + // compute heading using turtle-space tangent (MusicBlocks convention: North=0) let degrees = Math.atan2(x2 - cp2x, y2 - cp2y); degrees = (180 * degrees) / Math.PI; + this.doSetHeading(degrees); } From 1b6b7bc0a56108ae2fab92d1f08f7cdab9ec51b2 Mon Sep 17 00:00:00 2001 From: Kartik Tripathi Date: Wed, 11 Feb 2026 07:12:07 +0530 Subject: [PATCH 009/163] fix(svg): replace fragile string-split block SVG parsing with DOM parsing (#5605) --- js/activity.js | 65 +++++++++++++++++++++++++++++++------------------- 1 file changed, 40 insertions(+), 25 deletions(-) diff --git a/js/activity.js b/js/activity.js index 7067ac07ac..6b0c6a474b 100644 --- a/js/activity.js +++ b/js/activity.js @@ -1331,32 +1331,47 @@ class Activity { if (!SPECIALINPUTS.includes(this.blocks.blockList[i].name)) { svg += extractSVGInner(rawSVG); } else { - // Keep existing fragile logic for now - parts = rawSVG.split("><"); - - for (let p = 1; p < parts.length; p++) { - // FIXME: This is fragile. - if (p === 1) { - svg += "<" + parts[p] + "><"; - } else if (p === 2) { - // skip filter - } else if (p === 3) { - svg += parts[p].replace("filter:url(#dropshadow);", "") + "><"; - } else if (p === 5) { - // Add block value to SVG between tspans - if (typeof this.blocks.blockList[i].value === "string") { - svg += parts[p] + ">" + _(this.blocks.blockList[i].value) + "<"; - } else { - svg += parts[p] + ">" + this.blocks.blockList[i].value + "<"; - } - } else if (p === parts.length - 2) { - svg += parts[p] + ">"; - } else if (p === parts.length - 1) { - // skip final - } else { - svg += parts[p] + "><"; - } + // Safer SVG manipulation using DOM instead of string splitting + const parser = new DOMParser(); + const doc = parser.parseFromString(rawSVG, "image/svg+xml"); + + // remove dropshadow filter if present + const filtered = doc.querySelector('[style*="filter:url(#dropshadow)"]'); + if (filtered) { + filtered.style.filter = ""; + } + + // Find correct tspan to inject value (matches previous behaviour) + let target = null; + + // 1) Prefer empty tspan (most block SVGs reserve this for value) + target = Array.from(doc.querySelectorAll("text tspan")).find( + t => !t.textContent || t.textContent.trim() === "" + ); + + // 2) Otherwise fallback to last tspan + if (!target) { + const tspans = doc.querySelectorAll("text tspan"); + if (tspans.length) target = tspans[tspans.length - 1]; + } + + // 3) Final fallback to text node + if (!target) { + target = doc.querySelector("text"); + } + + if (target) { + const val = this.blocks.blockList[i].value; + target.textContent = typeof val === "string" ? _(val) : val; } + + // serialize without outer wrapper (matches previous behavior) + let serialized = new XMLSerializer().serializeToString(doc.documentElement); + + // remove outer svg tags because original code skipped them + serialized = serialized.replace(/^]*>/, "").replace(/<\/svg>$/, ""); + + svg += serialized; } svg += ""; From f979aa7611f3e2e2ab6e6472cef2d3c36253d1f0 Mon Sep 17 00:00:00 2001 From: Kartik Tripathi Date: Wed, 11 Feb 2026 07:15:11 +0530 Subject: [PATCH 010/163] Use strict equality for safe comparisons without altering null semantics (#5606) * fix: replace loose equality operators with strict equality in SaveInterface.js and block.js * fix: use strict equality where behavior is unchanged --- js/SaveInterface.js | 14 +++++++------- js/block.js | 18 +++++++++--------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/js/SaveInterface.js b/js/SaveInterface.js index 93635aee04..14f6700633 100644 --- a/js/SaveInterface.js +++ b/js/SaveInterface.js @@ -166,17 +166,17 @@ class SaveInterface { defaultfilename = this.activity.PlanetInterface.getCurrentProjectName(); } - if (fileExt(defaultfilename) != extension) { + if (fileExt(defaultfilename) !== extension) { defaultfilename += "." + extension; } - if (window.isElectron == true) { + if (window.isElectron === true) { filename = defaultfilename; } else { filename = prompt("Filename:", defaultfilename); } } else { - if (fileExt(defaultfilename) != extension) { + if (fileExt(defaultfilename) !== extension) { defaultfilename += "." + extension; } filename = defaultfilename; @@ -190,7 +190,7 @@ class SaveInterface { return; } - if (fileExt(filename) != extension) { + if (fileExt(filename) !== extension) { filename += "." + extension; } @@ -590,7 +590,7 @@ class SaveInterface { filename = activity.PlanetInterface.getCurrentProjectName(); } - if (fileExt(filename) != lyext) { + if (fileExt(filename) !== lyext) { filename += "." + lyext; } @@ -618,7 +618,7 @@ class SaveInterface { // Load custom author saved in local storage. const customAuthorData = activity.storage.getItem("customAuthor"); - if (customAuthorData != undefined) { + if (customAuthorData !== undefined) { docById("author").value = JSON.parse(customAuthorData); } else { //.TRANS: default project author when saving as Lilypond @@ -664,7 +664,7 @@ class SaveInterface { const MIDICheck = docById("MIDICheck").checked; const guitarCheck = docById("guitarCheck").checked; - if (filename != null) { + if (filename !== null) { if (fileExt(filename) !== "ly") { filename += ".ly"; } diff --git a/js/block.js b/js/block.js index 2b957e8bb7..2fe11baf31 100644 --- a/js/block.js +++ b/js/block.js @@ -1412,7 +1412,7 @@ class Block { } else if (this.name === "grid") { label = _(this.value); } else { - if (this.value !== null) { + if (this.value != null) { label = this.value.toString(); } else { label = "???"; @@ -1469,7 +1469,7 @@ class Block { const postProcess = that => { that.loadComplete = true; - if (that.postProcess !== null) { + if (that.postProcess != null) { that.postProcess(that.postProcessArg); that.postProcess = null; } @@ -2128,7 +2128,7 @@ class Block { */ _doOpenMedia(thisBlock) { const that = this; - const fileChooser = that.name == "media" ? docById("myMedia") : docById("audio"); + const fileChooser = that.name === "media" ? docById("myMedia") : docById("audio"); const __readerAction = () => { window.scroll(0, 0); @@ -2865,7 +2865,7 @@ class Block { } // We might be able to check which button was clicked. if ("nativeEvent" in event) { - if ("button" in event.nativeEvent && event.nativeEvent.button == 2) { + if ("button" in event.nativeEvent && event.nativeEvent.button === 2) { that.blocks.stageClick = true; _getStatic("wheelDiv").style.display = "none"; that.blocks.activeBlock = thisBlock; @@ -3043,7 +3043,7 @@ class Block { // Do not allow a stack of blocks to be dragged if the stack contains a silence block. let block = that.blocks.blockList[that.connections[1]]; - while (block != undefined) { + while (block != null) { if (block?.name === "rest2") { this.activity.errorMsg(_("Silence block cannot be removed."), block); return; @@ -3565,7 +3565,7 @@ class Block { selectedCustom = customLabels[0]; } - if (this.value !== null) { + if (this.value != null) { selectedNote = this.value; } else { selectedNote = getTemperament(selectedCustom)["0"][1]; @@ -4024,7 +4024,7 @@ class Block { for (let i = 0; i < this.blocks.blockList.length; i++) { if ( this.blocks.blockList[i].name === "settemperament" && - this.blocks.blockList[i].connections[0] !== null + this.blocks.blockList[i].connections[0] != null ) { const index = this.blocks.blockList[i].connections[1]; temperament = this.blocks.blockList[index].value; @@ -4377,7 +4377,7 @@ class Block { const cblk1 = this.connections[0]; let cblk2; - if (cblk1 !== null) { + if (cblk1 != null) { cblk2 = this.blocks.blockList[cblk1].connections[0]; } else { cblk2 = null; @@ -4389,7 +4389,7 @@ class Block { cblk2 !== null && newValue < 0 && (this.blocks.blockList[cblk1].name === "newnote" || - this.blocks.blockList[cblk2].name == "newnote") + this.blocks.blockList[cblk2].name === "newnote") ) { this.label.value = 0; this.value = 0; From ddb815aefd9dab1c224bfead9c29836eb101db34 Mon Sep 17 00:00:00 2001 From: Ashutosh Singh Date: Thu, 12 Feb 2026 00:35:58 +0530 Subject: [PATCH 011/163] fix: resolve initialization crashes and loading screen issues (#5642) * fix: resolve initialization crashes and loading screen issues - Restore missing createArtwork method in turtles.js that was causing TypeError during turtle initialization - Add guard clause in doSearch to prevent crash when searchWidget is undefined - Add robust null checks in showContents to prevent crashes on missing DOM elements and ensure loading screen is properly hidden - Add RequireJS shim for constraints.js to depend on interface.js, fixing 'JSInterface is not defined' ReferenceError - Add safe fallback for DEFAULTVOLUME in turtle-singer.js to handle module loading order issues These fixes address race conditions and missing methods that were preventing the application from fully loading and causing the 'Combining math and music...' loading screen to persist. * style: format files with prettier * fix(playback): resolve turtle cache infinite loop and missing bpm constant * style: fix formatting in js files --- js/activity.js | 44 +++++- js/loader.js | 351 ++++++++++++++++++++------------------------ js/logoconstants.js | 60 ++++---- js/turtle-singer.js | 21 ++- js/turtles.js | 50 +++++++ 5 files changed, 299 insertions(+), 227 deletions(-) diff --git a/js/activity.js b/js/activity.js index 6b0c6a474b..308e9dccbb 100644 --- a/js/activity.js +++ b/js/activity.js @@ -2871,6 +2871,12 @@ class Activity { this.searchSuggestions = []; this.deprecatedBlockNames = []; + // Guard: blocks may not be initialized yet during early loading + if (!this.blocks || !this.blocks.protoBlockDict) { + console.debug("prepSearchWidget: blocks not yet initialized, skipping"); + return; + } + for (const i in this.blocks.protoBlockDict) { const block = this.blocks.protoBlockDict[i]; const blockLabel = block.staticLabels.join(" "); @@ -3057,6 +3063,12 @@ class Activity { * Uses JQuery to add autocompleted search suggestions */ this.doSearch = () => { + // Guard: ensure searchWidget exists before proceeding + if (!this.searchWidget) { + console.debug("doSearch: searchWidget not yet initialized, skipping"); + return; + } + const $j = window.jQuery; if (this.searchSuggestions.length === 0) { this.prepSearchWidget(); @@ -5186,14 +5198,32 @@ class Activity { document.getElementById("loadingText").textContent = _("Loading Complete!"); setTimeout(() => { - document.getElementById("loadingText").textContent = null; - document.getElementById("loading-image-container").style.display = "none"; - document.getElementById("bottom-right-logo").style.display = "none"; - document.getElementById("palette").style.display = "block"; + const loadingText = document.getElementById("loadingText"); + if (loadingText) loadingText.textContent = null; + + const loadingImageContainer = document.getElementById("loading-image-container"); + if (loadingImageContainer) loadingImageContainer.style.display = "none"; + + // Try hiding load-container instead if it exists + const loadContainer = document.getElementById("load-container"); + if (loadContainer) loadContainer.style.display = "none"; + + const bottomRightLogo = document.getElementById("bottom-right-logo"); + if (bottomRightLogo) bottomRightLogo.style.display = "none"; + + const palette = document.getElementById("palette"); + if (palette) palette.style.display = "block"; + // document.getElementById('canvas').style.display = 'none'; - document.getElementById("hideContents").style.display = "block"; - document.getElementById("buttoncontainerBOTTOM").style.display = "block"; - document.getElementById("buttoncontainerTOP").style.display = "block"; + + const hideContents = document.getElementById("hideContents"); + if (hideContents) hideContents.style.display = "block"; + + const btnBottom = document.getElementById("buttoncontainerBOTTOM"); + if (btnBottom) btnBottom.style.display = "block"; + + const btnTop = document.getElementById("buttoncontainerTOP"); + if (btnTop) btnTop.style.display = "block"; }, 500); }; diff --git a/js/loader.js b/js/loader.js index 215cb021b4..2561c3342b 100644 --- a/js/loader.js +++ b/js/loader.js @@ -13,7 +13,7 @@ requirejs.config({ baseUrl: "./", - urlArgs: window.location.protocol === "file:" ? "" : "v=999999_fix5", + urlArgs: window.location.protocol === "file:" ? "" : "v=999999_fix7", waitSeconds: 60, shim: { "easeljs.min": { @@ -87,7 +87,12 @@ requirejs.config({ exports: "Synth" }, "activity/logo": { - deps: ["activity/turtles", "activity/notation", "utils/synthutils"], + deps: [ + "activity/turtles", + "activity/notation", + "utils/synthutils", + "activity/logoconstants" + ], exports: "Logo" }, "activity/activity": { @@ -109,6 +114,12 @@ requirejs.config({ }, "highlight": { exports: "hljs" + }, + "activity/js-export/constraints": { + deps: ["activity/js-export/interface"] + }, + "activity/js-export/generate": { + deps: ["activity/js-export/ASTutils"] } }, paths: { @@ -149,228 +160,192 @@ requirejs.config({ packages: [] }); -requirejs( - ["i18next", "i18nextHttpBackend", "jquery", "materialize", "jquery-ui"], - function (i18next, i18nextHttpBackend, $, M) { - if (typeof M !== "undefined") { - window.M = M; - } +requirejs(["i18next", "i18nextHttpBackend"], function (i18next, i18nextHttpBackend) { + // Use globally-loaded jQuery and Materialize (avoids AMD conflicts) + var $ = window.jQuery; + // Materialize v0.100.2 (bundled) uses 'Materialize' as global, not 'M' + var M = window.Materialize || window.M; - // Define essential globals for core modules - window._THIS_IS_MUSIC_BLOCKS_ = true; - window._THIS_IS_TURTLE_BLOCKS_ = false; + // Ensure both M and Materialize are available for compatibility + if (typeof M !== "undefined") { + window.M = M; + window.Materialize = M; + } - // Load highlight optionally - requirejs( - ["highlight"], - function (hljs) { - if (hljs) { - window.hljs = hljs; - hljs.highlightAll(); - } - }, - function (err) { - console.warn("Highlight.js failed to load, moving on...", err); - } - ); + // Define essential globals for core modules + window._THIS_IS_MUSIC_BLOCKS_ = true; + window._THIS_IS_TURTLE_BLOCKS_ = false; - function updateContent() { - if (!i18next.isInitialized) return; - const elements = document.querySelectorAll("[data-i18n]"); - elements.forEach(element => { - const key = element.getAttribute("data-i18n"); - element.textContent = i18next.t(key); - }); + // Load highlight optionally + requirejs( + ["highlight"], + function (hljs) { + if (hljs) { + window.hljs = hljs; + hljs.highlightAll(); + } + }, + function (err) { + console.warn("Highlight.js failed to load, moving on...", err); } + ); - function initializeI18next() { - return new Promise(resolve => { - i18next.use(i18nextHttpBackend).init( - { - lng: "en", - fallbackLng: "en", - keySeparator: false, - nsSeparator: false, - interpolation: { - escapeValue: false - }, - backend: { - loadPath: "locales/{{lng}}.json?v=" + Date.now() - } + function updateContent() { + if (!i18next.isInitialized) return; + const elements = document.querySelectorAll("[data-i18n]"); + elements.forEach(element => { + const key = element.getAttribute("data-i18n"); + element.textContent = i18next.t(key); + }); + } + + function initializeI18next() { + return new Promise(resolve => { + i18next.use(i18nextHttpBackend).init( + { + lng: "en", + fallbackLng: "en", + keySeparator: false, + nsSeparator: false, + interpolation: { + escapeValue: false }, - function (err) { - if (err) { - console.error("i18next init failed:", err); - } - window.i18next = i18next; - resolve(i18next); + backend: { + loadPath: "locales/{{lng}}.json?v=" + Date.now() } - ); - }); - } - - async function main() { - try { - await initializeI18next(); - - if (typeof M !== "undefined" && M.AutoInit) { - M.AutoInit(); - } - - const lang = "en"; - i18next.changeLanguage(lang, function (err) { + }, + function (err) { if (err) { - console.error("Error changing language:", err); + console.error("i18next init failed:", err); } - updateContent(); - }); - - if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", updateContent); - } else { - updateContent(); + window.i18next = i18next; + resolve(i18next); } + ); + }); + } - i18next.on("languageChanged", updateContent); + async function main() { + try { + await initializeI18next(); - // Two-phase bootstrap: load core modules first, then application modules - const waitForGlobals = async (retryCount = 0) => { - if (typeof window.createjs === "undefined" && retryCount < 50) { - await new Promise(resolve => setTimeout(resolve, 100)); - return waitForGlobals(retryCount + 1); - } - }; + if (typeof M !== "undefined" && M.AutoInit) { + M.AutoInit(); + } - await waitForGlobals(); + const lang = "en"; + i18next.changeLanguage(lang, function (err) { + if (err) { + console.error("Error changing language:", err); + } + updateContent(); + }); - const PRELOADED_MODULES = [ - { name: "easeljs.min", export: () => window.createjs }, - { name: "tweenjs.min", export: () => window.createjs }, - { name: "preloadjs.min", export: () => window.createjs }, - { name: "libgif", export: () => window.SuperGif }, - { name: "activity/gif-animator", export: () => window.GIFAnimator }, - { name: "utils/platformstyle", export: null }, - { name: "utils/utils", export: () => window._ }, - { name: "utils/musicutils", export: null }, - { name: "utils/synthutils", export: () => window.Synth }, - { name: "utils/mathutils", export: () => window.MathUtility }, - { name: "activity/artwork", export: null }, - { name: "activity/turtledefs", export: () => window.createDefaultStack }, - { name: "activity/block", export: () => window.Block }, - { name: "activity/blocks", export: () => window.Blocks }, - { name: "activity/turtle-singer", export: () => window.Singer }, - { name: "activity/turtle-painter", export: () => window.Painter }, - { name: "activity/turtle", export: () => window.Turtle }, - { name: "activity/turtles", export: () => window.Turtles }, - { name: "activity/notation", export: () => window.Notation }, - { name: "activity/trash", export: () => window.Trashcan }, - { name: "activity/palette", export: () => window.Palettes }, - { name: "activity/protoblocks", export: () => window.ProtoBlock }, - { name: "activity/logo", export: () => window.Logo } - ]; + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", updateContent); + } else { + updateContent(); + } - PRELOADED_MODULES.forEach(mod => { - if (!requirejs.defined(mod.name)) { - define(mod.name, [], function () { - return mod.export ? mod.export() : undefined; - }); - } - }); + i18next.on("languageChanged", updateContent); - const CORE_BOOTSTRAP_MODULES = [ - "easeljs.min", - "tweenjs.min", - "preloadjs.min", - "utils/platformstyle", - "utils/utils", - "activity/turtledefs", - "activity/block", - "activity/blocks", - "activity/turtle-singer", - "activity/turtle-painter", - "activity/turtle", - "activity/turtles", - "utils/synthutils", - "activity/notation", - "activity/logo" - ]; + // Two-phase bootstrap: load core modules first, then application modules + const waitForGlobals = async (retryCount = 0) => { + if (typeof window.createjs === "undefined" && retryCount < 50) { + await new Promise(resolve => setTimeout(resolve, 100)); + return waitForGlobals(retryCount + 1); + } + }; - requirejs( - CORE_BOOTSTRAP_MODULES, - function () { - // Verify critical globals are initialized - const verificationErrors = []; + await waitForGlobals(); - if (typeof window.createjs === "undefined") { - verificationErrors.push("createjs (EaselJS/TweenJS) not found"); - } + // Only pre-define modules that are loaded via script tags in index.html + // These modules are already available as globals before RequireJS loads them + const PRELOADED_SCRIPTS = [ + { name: "easeljs.min", export: () => window.createjs }, + { name: "tweenjs.min", export: () => window.createjs } + ]; - if ( - typeof window.createDefaultStack === "undefined" && - typeof arguments[5] === "undefined" - ) { - verificationErrors.push("createDefaultStack not initialized"); - } + PRELOADED_SCRIPTS.forEach(mod => { + if (!requirejs.defined(mod.name) && mod.export && mod.export()) { + define(mod.name, [], function () { + return mod.export(); + }); + } + }); - if ( - typeof window.Logo === "undefined" && - typeof arguments[14] === "undefined" - ) { - verificationErrors.push("Logo not initialized"); - } + // Note: Other modules like activity/*, utils/* are loaded by RequireJS + // from their file paths as configured in requirejs.config(). + // Do NOT pre-define them here as that prevents RequireJS from loading the actual files. - if ( - typeof window.Blocks === "undefined" && - typeof arguments[7] === "undefined" - ) { - verificationErrors.push("Blocks not initialized"); - } + const CORE_BOOTSTRAP_MODULES = [ + "easeljs.min", + "tweenjs.min", + "preloadjs.min", + "utils/platformstyle", + "utils/utils", + "activity/turtledefs", + "activity/block", + "activity/blocks", + "activity/turtle-singer", + "activity/turtle-painter", + "activity/turtle", + "activity/turtles", + "utils/synthutils", + "activity/notation", + "activity/logo" + ]; - if ( - typeof window.Turtles === "undefined" && - typeof arguments[11] === "undefined" - ) { - verificationErrors.push("Turtles not initialized"); - } + requirejs( + CORE_BOOTSTRAP_MODULES, + function () { + // Give scripts a moment to finish executing and set globals + setTimeout(function () { + // Log verification status for debugging (non-blocking) + const verificationStatus = { + createjs: typeof window.createjs !== "undefined", + createDefaultStack: typeof window.createDefaultStack !== "undefined", + Logo: typeof window.Logo !== "undefined", + Blocks: typeof window.Blocks !== "undefined", + Turtles: typeof window.Turtles !== "undefined" + }; + console.log("Core module verification:", verificationStatus); - if (verificationErrors.length > 0) { + // Check critical dependencies (only createjs is truly critical) + if (typeof window.createjs === "undefined") { console.error( - "FATAL: Core bootstrap verification failed:", - verificationErrors - ); - alert( - "Failed to initialize Music Blocks core modules. Please refresh the page.\n\nMissing: " + - verificationErrors.join(", ") - ); - throw new Error( - "Core bootstrap failed: " + verificationErrors.join(", ") + "FATAL: createjs (EaselJS/TweenJS) not found. Cannot proceed." ); + alert("Failed to load EaselJS. Please refresh the page."); + return; } + // Proceed with activity loading requirejs( ["activity/activity"], function () { // Activity loaded successfully + console.log("Activity module loaded successfully"); }, function (err) { console.error("Failed to load activity/activity:", err); alert("Failed to load Music Blocks. Please refresh the page."); } ); - }, - function (err) { - console.error("Core bootstrap failed:", err); - alert( - "Failed to initialize Music Blocks core. Please refresh the page.\n\nError: " + - (err.message || err) - ); - } - ); - } catch (e) { - console.error("Error in main bootstrap:", e); - } + }, 100); // Small delay to allow globals to be set + }, + function (err) { + console.error("Core bootstrap failed:", err); + alert( + "Failed to initialize Music Blocks core. Please refresh the page.\n\nError: " + + (err.message || err) + ); + } + ); + } catch (e) { + console.error("Error in main bootstrap:", e); } - - main().catch(err => console.error("Main execution failed:", err)); } -); + + main().catch(err => console.error("Main execution failed:", err)); +}); diff --git a/js/logoconstants.js b/js/logoconstants.js index 01da22e4f6..1e439c26c8 100644 --- a/js/logoconstants.js +++ b/js/logoconstants.js @@ -50,33 +50,37 @@ const NOTATIONROUNDDOWN = 4; const NOTATIONINSIDECHORD = 5; // deprecated const NOTATIONSTACCATO = 6; +const exportsObj = { + DEFAULTVOLUME, + PREVIEWVOLUME, + DEFAULTDELAY, + OSCVOLUMEADJUSTMENT, + TONEBPM, + TARGETBPM, + TURTLESTEP, + NOTEDIV, + NOMICERRORMSG, + NANERRORMSG, + NOSTRINGERRORMSG, + NOBOXERRORMSG, + NOACTIONERRORMSG, + NOINPUTERRORMSG, + NOSQRTERRORMSG, + ZERODIVIDEERRORMSG, + EMPTYHEAPERRORMSG, + POSNUMBER, + INVALIDPITCH, + NOTATIONNOTE, + NOTATIONDURATION, + NOTATIONDOTCOUNT, + NOTATIONTUPLETVALUE, + NOTATIONROUNDDOWN, + NOTATIONINSIDECHORD, + NOTATIONSTACCATO +}; + if (typeof module !== "undefined" && module.exports) { - module.exports = { - DEFAULTVOLUME, - PREVIEWVOLUME, - DEFAULTDELAY, - OSCVOLUMEADJUSTMENT, - TONEBPM, - TARGETBPM, - TURTLESTEP, - NOTEDIV, - NOMICERRORMSG, - NANERRORMSG, - NOSTRINGERRORMSG, - NOBOXERRORMSG, - NOACTIONERRORMSG, - NOINPUTERRORMSG, - NOSQRTERRORMSG, - ZERODIVIDEERRORMSG, - EMPTYHEAPERRORMSG, - POSNUMBER, - INVALIDPITCH, - NOTATIONNOTE, - NOTATIONDURATION, - NOTATIONDOTCOUNT, - NOTATIONTUPLETVALUE, - NOTATIONROUNDDOWN, - NOTATIONINSIDECHORD, - NOTATIONSTACCATO - }; + module.exports = exportsObj; +} else if (typeof window !== "undefined") { + Object.assign(window, exportsObj); } diff --git a/js/turtle-singer.js b/js/turtle-singer.js index d0975e165d..4e6eb13dd5 100644 --- a/js/turtle-singer.js +++ b/js/turtle-singer.js @@ -135,7 +135,9 @@ class Singer { this.instrumentNames = []; this.inCrescendo = []; this.crescendoDelta = []; - this.crescendoInitialVolume = { DEFAULTVOICE: [DEFAULTVOLUME] }; + this.crescendoInitialVolume = { + DEFAULTVOICE: [typeof DEFAULTVOLUME !== "undefined" ? DEFAULTVOLUME : 50] + }; this.intervals = []; // relative interval (based on scale degree) this.semitoneIntervals = []; // absolute interval (based on semitones) this.chordIntervals = []; // combination of scale degree and semitones @@ -212,9 +214,15 @@ class Singer { // ========= Class variables ============================================== // Parameters used by notes - static masterBPM = TARGETBPM; - static defaultBPMFactor = TONEBPM / TARGETBPM; - static masterVolume = [DEFAULTVOLUME]; + // Note: These globals (TARGETBPM, TONEBPM, DEFAULTVOLUME) are defined in logo.js. + // We use safe defaults here because this file may load before logo.js due to + // the RequireJS dependency chain. + static masterBPM = typeof TARGETBPM !== "undefined" ? TARGETBPM : 120; + static defaultBPMFactor = + typeof TONEBPM !== "undefined" && typeof TARGETBPM !== "undefined" + ? TONEBPM / TARGETBPM + : 1; + static masterVolume = [typeof DEFAULTVOLUME !== "undefined" ? DEFAULTVOLUME : 50]; // ========= Deprecated =================================================== @@ -2498,3 +2506,8 @@ class Singer { if (typeof module !== "undefined" && module.exports) { module.exports = Singer; } + +// Export to global scope for browser (RequireJS shim) +if (typeof window !== "undefined") { + window.Singer = Singer; +} diff --git a/js/turtles.js b/js/turtles.js index cfc5ab1cdb..8e2749062e 100644 --- a/js/turtles.js +++ b/js/turtles.js @@ -513,6 +513,37 @@ Turtles.TurtlesModel = class { turtle.container.y = this.turtleY2screenY(turtle.y); } + /** + * Creates the artwork (visual representation) for a turtle. + * + * @param {Object} turtle - Turtle object + * @param {Number} i - Color index + * @param {Boolean} useTurtleArtwork - Whether to use turtle or metronome artwork + * @returns {void} + */ + createArtwork(turtle, i, useTurtleArtwork) { + const artwork = useTurtleArtwork ? TURTLESVG : METRONOMESVG; + const fillColor = FILLCOLORS[i % FILLCOLORS.length]; + const strokeColor = STROKECOLORS[i % STROKECOLORS.length]; + + const svgData = artwork + .replace(/fill_color/g, fillColor) + .replace(/stroke_color/g, strokeColor); + + const img = new Image(); + img.onload = () => { + const bitmap = new createjs.Bitmap(img); + bitmap.regX = 27; + bitmap.regY = 27; + turtle.container.addChild(bitmap); + turtle._bitmap = bitmap; + turtle._createCache(); + turtle.updateCache(); + this.activity.refreshCanvas(); + }; + img.src = "data:image/svg+xml;base64," + window.btoa(unescape(encodeURIComponent(svgData))); + } + /** * Creates sensor area for Turtle body. * @@ -711,6 +742,25 @@ Turtles.TurtlesView = class { this.makeBackground(); } + /** + * Makes background for canvas: updates the canvas background color. + * + * @param {Boolean} setCollapsed - whether to set the background in collapsed state + * @returns {void} + */ + makeBackground(setCollapsed) { + // Update the canvas background color + const canvas = this.canvas; + if (canvas) { + canvas.style.backgroundColor = this._backgroundColor; + } + + // Also update body background if available + if (typeof document !== "undefined") { + document.body.style.backgroundColor = this._backgroundColor; + } + } + /** * @returns {Boolean} - whether canvas is collapsed */ From 517b91fb20fabfbf1d0774abacd03c64b057dbe4 Mon Sep 17 00:00:00 2001 From: raiyyan777 Date: Thu, 12 Feb 2026 04:02:41 +0530 Subject: [PATCH 012/163] refactor: remove unused test utility functions from synthutils.js (#5575) * Remove tuner test helper functions Remove the testTuner and testSpecificFrequency methods from the Synth() class. The helper functions of these tests create an audio context, oscillator, and gain node to play the test frequencies for verification of the manual tuner. This is more for ad-hoc tests rather than runtime. * Add tuner AudioContext tests; remove debug logs --- js/utils/__tests__/synthutils.test.js | 346 ++++++++++++++++++++++++++ js/utils/musicutils.js | 3 - js/utils/synthutils.js | 73 ------ 3 files changed, 346 insertions(+), 76 deletions(-) diff --git a/js/utils/__tests__/synthutils.test.js b/js/utils/__tests__/synthutils.test.js index f948dfc69f..4a12fc85fd 100644 --- a/js/utils/__tests__/synthutils.test.js +++ b/js/utils/__tests__/synthutils.test.js @@ -1050,3 +1050,349 @@ describe("Utility Functions (logic-only)", () => { }); }); }); + +describe("Tuner Utilities (Audio Test Functions)", () => { + let mockAudioContext; + let mockOscillator; + let mockGainNode; + let originalAudioContext; + let originalConsoleLog; + let originalConsoleError; + + beforeEach(() => { + // Save original AudioContext if it exists + originalAudioContext = global.AudioContext; + originalConsoleLog = console.log; + originalConsoleError = console.error; + + // Mock console methods + console.log = jest.fn(); + console.error = jest.fn(); + + // Mock GainNode + mockGainNode = { + connect: jest.fn(), + gain: { value: 0 } + }; + + // Mock Oscillator + mockOscillator = { + connect: jest.fn(), + frequency: { + setValueAtTime: jest.fn() + }, + start: jest.fn(), + stop: jest.fn() + }; + + // Mock AudioContext + mockAudioContext = { + createOscillator: jest.fn(() => mockOscillator), + createGain: jest.fn(() => mockGainNode), + destination: {}, + currentTime: 0 + }; + + global.AudioContext = jest.fn(() => mockAudioContext); + global.window = { AudioContext: global.AudioContext }; + + // Use fake timers for setTimeout + jest.useFakeTimers(); + }); + + afterEach(() => { + // Restore original values + global.AudioContext = originalAudioContext; + global.window = originalAudioContext ? { AudioContext: originalAudioContext } : {}; + console.log = originalConsoleLog; + console.error = originalConsoleError; + + // Clear all timers + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + describe("testTuner", () => { + it("should verify tuner accuracy with predefined test frequencies", () => { + const testTuner = () => { + if (!window.AudioContext) { + console.error("Web Audio API not supported"); + return; + } + + const audioContext = new AudioContext(); + const oscillator = audioContext.createOscillator(); + const gainNode = audioContext.createGain(); + + oscillator.connect(gainNode); + gainNode.connect(audioContext.destination); + gainNode.gain.value = 0.1; + + const testCases = [ + { freq: 440, expected: "A4" }, + { freq: 442, expected: "A4" }, + { freq: 438, expected: "A4" }, + { freq: 261.63, expected: "C4" }, + { freq: 329.63, expected: "E4" } + ]; + + let currentTest = 0; + + const runTest = () => { + if (currentTest >= testCases.length) { + oscillator.stop(); + console.log("Tuner tests completed"); + return; + } + + const test = testCases[currentTest]; + console.log(`Testing frequency: ${test.freq}Hz (Expected: ${test.expected})`); + + oscillator.frequency.setValueAtTime(test.freq, audioContext.currentTime); + + currentTest++; + setTimeout(runTest, 2000); + }; + + oscillator.start(); + runTest(); + }; + + testTuner(); + + // Verify AudioContext setup + expect(global.AudioContext).toHaveBeenCalled(); + expect(mockAudioContext.createOscillator).toHaveBeenCalled(); + expect(mockAudioContext.createGain).toHaveBeenCalled(); + + // Verify connections + expect(mockOscillator.connect).toHaveBeenCalledWith(mockGainNode); + expect(mockGainNode.connect).toHaveBeenCalledWith(mockAudioContext.destination); + expect(mockGainNode.gain.value).toBe(0.1); + + // Verify oscillator started + expect(mockOscillator.start).toHaveBeenCalled(); + + // Verify first test case (440 Hz - A4) + expect(console.log).toHaveBeenCalledWith("Testing frequency: 440Hz (Expected: A4)"); + expect(mockOscillator.frequency.setValueAtTime).toHaveBeenCalledWith( + 440, + mockAudioContext.currentTime + ); + + // Advance to second test case (442 Hz - A4 sharp) + jest.advanceTimersByTime(2000); + expect(console.log).toHaveBeenCalledWith("Testing frequency: 442Hz (Expected: A4)"); + expect(mockOscillator.frequency.setValueAtTime).toHaveBeenCalledWith( + 442, + mockAudioContext.currentTime + ); + + // Advance to third test case (438 Hz - A4 flat) + jest.advanceTimersByTime(2000); + expect(console.log).toHaveBeenCalledWith("Testing frequency: 438Hz (Expected: A4)"); + expect(mockOscillator.frequency.setValueAtTime).toHaveBeenCalledWith( + 438, + mockAudioContext.currentTime + ); + + // Advance to fourth test case (261.63 Hz - C4) + jest.advanceTimersByTime(2000); + expect(console.log).toHaveBeenCalledWith("Testing frequency: 261.63Hz (Expected: C4)"); + expect(mockOscillator.frequency.setValueAtTime).toHaveBeenCalledWith( + 261.63, + mockAudioContext.currentTime + ); + + // Advance to fifth test case (329.63 Hz - E4) + jest.advanceTimersByTime(2000); + expect(console.log).toHaveBeenCalledWith("Testing frequency: 329.63Hz (Expected: E4)"); + expect(mockOscillator.frequency.setValueAtTime).toHaveBeenCalledWith( + 329.63, + mockAudioContext.currentTime + ); + + // Complete test - should stop oscillator + jest.advanceTimersByTime(2000); + expect(mockOscillator.stop).toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith("Tuner tests completed"); + + // Verify all 5 frequencies were tested + expect(mockOscillator.frequency.setValueAtTime).toHaveBeenCalledTimes(5); + }); + + it("should handle missing AudioContext gracefully", () => { + global.AudioContext = undefined; + global.window = {}; + + const testTuner = () => { + if (!window.AudioContext) { + console.error("Web Audio API not supported"); + return; + } + + const audioContext = new AudioContext(); + const oscillator = audioContext.createOscillator(); + oscillator.start(); + }; + + testTuner(); + + expect(console.error).toHaveBeenCalledWith("Web Audio API not supported"); + expect(mockAudioContext.createOscillator).not.toHaveBeenCalled(); + }); + }); + + describe("testSpecificFrequency", () => { + it("should test a specific frequency for 3 seconds", () => { + const testSpecificFrequency = frequency => { + if (!window.AudioContext) { + console.error("Web Audio API not supported"); + return; + } + + const audioContext = new AudioContext(); + const oscillator = audioContext.createOscillator(); + const gainNode = audioContext.createGain(); + + oscillator.connect(gainNode); + gainNode.connect(audioContext.destination); + gainNode.gain.value = 0.1; + + oscillator.frequency.setValueAtTime(frequency, audioContext.currentTime); + oscillator.start(); + + console.log(`Testing frequency: ${frequency}Hz`); + + setTimeout(() => { + oscillator.stop(); + console.log("Test completed"); + }, 3000); + }; + + testSpecificFrequency(440); + + // Verify AudioContext setup + expect(global.AudioContext).toHaveBeenCalled(); + expect(mockAudioContext.createOscillator).toHaveBeenCalled(); + expect(mockAudioContext.createGain).toHaveBeenCalled(); + + // Verify connections + expect(mockOscillator.connect).toHaveBeenCalledWith(mockGainNode); + expect(mockGainNode.connect).toHaveBeenCalledWith(mockAudioContext.destination); + expect(mockGainNode.gain.value).toBe(0.1); + + // Verify frequency was set + expect(mockOscillator.frequency.setValueAtTime).toHaveBeenCalledWith( + 440, + mockAudioContext.currentTime + ); + + // Verify oscillator started + expect(mockOscillator.start).toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith("Testing frequency: 440Hz"); + + // Verify oscillator hasn't stopped yet + expect(mockOscillator.stop).not.toHaveBeenCalled(); + + // Advance time to 3 seconds + jest.advanceTimersByTime(3000); + + // Verify oscillator stopped and completion message + expect(mockOscillator.stop).toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith("Test completed"); + }); + + it("should work with different test frequencies", () => { + const testSpecificFrequency = frequency => { + if (!window.AudioContext) { + console.error("Web Audio API not supported"); + return; + } + + const audioContext = new AudioContext(); + const oscillator = audioContext.createOscillator(); + const gainNode = audioContext.createGain(); + + oscillator.connect(gainNode); + gainNode.connect(audioContext.destination); + gainNode.gain.value = 0.1; + + oscillator.frequency.setValueAtTime(frequency, audioContext.currentTime); + oscillator.start(); + + console.log(`Testing frequency: ${frequency}Hz`); + + setTimeout(() => { + oscillator.stop(); + console.log("Test completed"); + }, 3000); + }; + + // Test C4 (middle C) + testSpecificFrequency(261.63); + + expect(mockOscillator.frequency.setValueAtTime).toHaveBeenCalledWith( + 261.63, + mockAudioContext.currentTime + ); + expect(console.log).toHaveBeenCalledWith("Testing frequency: 261.63Hz"); + + jest.advanceTimersByTime(3000); + expect(mockOscillator.stop).toHaveBeenCalled(); + }); + + it("should handle missing AudioContext gracefully", () => { + global.AudioContext = undefined; + global.window = {}; + + const testSpecificFrequency = frequency => { + if (!window.AudioContext) { + console.error("Web Audio API not supported"); + return; + } + + const audioContext = new AudioContext(); + const oscillator = audioContext.createOscillator(); + oscillator.start(); + }; + + testSpecificFrequency(440); + + expect(console.error).toHaveBeenCalledWith("Web Audio API not supported"); + expect(mockAudioContext.createOscillator).not.toHaveBeenCalled(); + }); + + it("should verify gain value is set to low volume (0.1)", () => { + const testSpecificFrequency = frequency => { + if (!window.AudioContext) { + console.error("Web Audio API not supported"); + return; + } + + const audioContext = new AudioContext(); + const oscillator = audioContext.createOscillator(); + const gainNode = audioContext.createGain(); + + oscillator.connect(gainNode); + gainNode.connect(audioContext.destination); + gainNode.gain.value = 0.1; + + oscillator.frequency.setValueAtTime(frequency, audioContext.currentTime); + oscillator.start(); + + console.log(`Testing frequency: ${frequency}Hz`); + + setTimeout(() => { + oscillator.stop(); + console.log("Test completed"); + }, 3000); + }; + + testSpecificFrequency(440); + + // Verify low volume was set for safe testing + expect(mockGainNode.gain.value).toBe(0.1); + }); + }); +}); diff --git a/js/utils/musicutils.js b/js/utils/musicutils.js index 79808aea05..7a51052663 100644 --- a/js/utils/musicutils.js +++ b/js/utils/musicutils.js @@ -2555,7 +2555,6 @@ const getDrumName = name => { } } - // console.debug(name + ' not found in DRUMNAMES'); return null; }; @@ -2698,7 +2697,6 @@ const getNoiseName = name => { } } - // console.debug(name + " not found in NOISENAMES"); return DEFAULTNOISE; }; @@ -2771,7 +2769,6 @@ const getVoiceName = name => { } } - // console.debug(name + " not found in VOICENAMES"); return DEFAULTVOICE; }; diff --git a/js/utils/synthutils.js b/js/utils/synthutils.js index 81042ba3c3..4ce87ba6a4 100644 --- a/js/utils/synthutils.js +++ b/js/utils/synthutils.js @@ -3295,79 +3295,6 @@ function Synth() { return pitch > 0 ? pitch : 440; }; - // Test function to verify tuner accuracy - this.testTuner = () => { - if (!window.AudioContext) { - console.error("Web Audio API not supported"); - return; - } - - const audioContext = new AudioContext(); - const oscillator = audioContext.createOscillator(); - const gainNode = audioContext.createGain(); - - oscillator.connect(gainNode); - gainNode.connect(audioContext.destination); - gainNode.gain.value = 0.1; // Low volume - - // Test frequencies - const testCases = [ - { freq: 440, expected: "A4" }, // A4 (in tune) - { freq: 442, expected: "A4" }, // A4 (sharp) - { freq: 438, expected: "A4" }, // A4 (flat) - { freq: 261.63, expected: "C4" }, // C4 (in tune) - { freq: 329.63, expected: "E4" } // E4 (in tune) - ]; - - let currentTest = 0; - - const runTest = () => { - if (currentTest >= testCases.length) { - oscillator.stop(); - console.log("Tuner tests completed"); - return; - } - - const test = testCases[currentTest]; - console.log(`Testing frequency: ${test.freq}Hz (Expected: ${test.expected})`); - - oscillator.frequency.setValueAtTime(test.freq, audioContext.currentTime); - - currentTest++; - setTimeout(runTest, 2000); // Test each frequency for 2 seconds - }; - - oscillator.start(); - runTest(); - }; - - // Function to test specific frequencies - this.testSpecificFrequency = frequency => { - if (!window.AudioContext) { - console.error("Web Audio API not supported"); - return; - } - - const audioContext = new AudioContext(); - const oscillator = audioContext.createOscillator(); - const gainNode = audioContext.createGain(); - - oscillator.connect(gainNode); - gainNode.connect(audioContext.destination); - gainNode.gain.value = 0.1; // Low volume - - oscillator.frequency.setValueAtTime(frequency, audioContext.currentTime); - oscillator.start(); - - console.log(`Testing frequency: ${frequency}Hz`); - - // Stop after 3 seconds - setTimeout(() => { - oscillator.stop(); - console.log("Test completed"); - }, 3000); - }; - /** * Creates and displays the cents adjustment interface * @returns {void} From 05a98defeec38fcd63ffc2428d91024922323af0 Mon Sep 17 00:00:00 2001 From: Mahesh Gali Date: Thu, 12 Feb 2026 04:05:34 +0530 Subject: [PATCH 013/163] docs: add documentation for rationalSum helper (#5603) --- js/utils/utils.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/js/utils/utils.js b/js/utils/utils.js index 331bf0feeb..b8c6364c58 100644 --- a/js/utils/utils.js +++ b/js/utils/utils.js @@ -1365,6 +1365,10 @@ const LCD = (a, b) => { /** * Adds two rational numbers represented as arrays [numerator, denominator]. + * + * This helper is used internally where rational arithmetic is required + * to avoid floating-point precision issues (e.g., turtle singer logic). + * * @param {Array} a - The first rational number. * @param {Array} b - The second rational number. * @returns {Array} The sum of the two rational numbers in the form [numerator, denominator]. From 26be0e3cf14fdd5b1b09d484a63909ba4cf4b2e3 Mon Sep 17 00:00:00 2001 From: Harihara Vardhan <140606048+zealot-zew@users.noreply.github.com> Date: Thu, 12 Feb 2026 04:11:28 +0530 Subject: [PATCH 014/163] Synchronize Stop Button State with Space Key Playback (#5512) This PR fixes a UI state inconsistency where the Stop button failed to update its visual state (turning red) when music playback was initiated using the Space key. --- js/activity.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/js/activity.js b/js/activity.js index 308e9dccbb..9683d64bd9 100644 --- a/js/activity.js +++ b/js/activity.js @@ -3409,6 +3409,10 @@ class Activity { this._doHardStopButton(); } else if (!disableKeys && !hasOpenWidget) { event.preventDefault(); + const stopbtn = document.getElementById("stop"); + if (stopbtn) { + stopbtn.style.color = platformColor.stopIconcolor; + } this._doFastButton(); } } else if (!disableKeys) { From 943f215ad645de60ecc6bc41bd56b071d6077752 Mon Sep 17 00:00:00 2001 From: raiyyan777 Date: Thu, 12 Feb 2026 04:14:59 +0530 Subject: [PATCH 015/163] Use named event handlers for cleanup (#5601) --- js/activity.js | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/js/activity.js b/js/activity.js index 9683d64bd9..da200d3027 100644 --- a/js/activity.js +++ b/js/activity.js @@ -7016,15 +7016,16 @@ class Activity { // Throttle rendering when user is inactive and no music is playing this._initIdleWatcher(); + // Named event handlers for proper cleanup let mouseEvents = 0; - document.addEventListener("mousemove", () => { + this.handleMouseMove = () => { mouseEvents++; if (mouseEvents % 4 === 0) { that.__tick(); } - }); + }; - document.addEventListener("click", e => { + this.handleDocumentClick = e => { if (!this.hasMouseMoved) { if (this.selectionModeOn) { this.deselectSelectedBlocks(); @@ -7032,7 +7033,11 @@ class Activity { this._hideHelpfulSearchWidget(e); } } - }); + }; + + // Use managed addEventListener for automatic cleanup + this.addEventListener(document, "mousemove", this.handleMouseMove); + this.addEventListener(document, "click", this.handleDocumentClick); this._createMsgContainer( "#ffffff", @@ -7670,11 +7675,15 @@ class Activity { document.addEventListener("DOMMouseScroll", scrollEvent, false); */ + // Named event handler for proper cleanup const activity = this; - document.onkeydown = () => { + this.handleKeyDown = event => { activity.__keyPressed(event); }; + // Use managed addEventListener instead of onkeydown assignment + this.addEventListener(document, "keydown", this.handleKeyDown); + if (this.planet !== undefined) { this.planet.planet.setAnalyzeProject(doAnalyzeProject); } From 39022d1267350ba5d8b727dc3b7f1d6b31845e84 Mon Sep 17 00:00:00 2001 From: 7se7en72025 Date: Thu, 12 Feb 2026 06:36:52 +0530 Subject: [PATCH 016/163] Enhance AST2BlockList error test assertions --- js/js-export/__tests__/ast2blocklist.test.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/js/js-export/__tests__/ast2blocklist.test.js b/js/js-export/__tests__/ast2blocklist.test.js index 86924bba4c..2b578d9455 100644 --- a/js/js-export/__tests__/ast2blocklist.test.js +++ b/js/js-export/__tests__/ast2blocklist.test.js @@ -58,8 +58,11 @@ describe("AST2BlockList Class", () => { try { AST2BlockList.toBlockList(AST, config); } catch (e) { - //TODO: error message should isolate to smallest scope + // Verify error message provides context about unsupported statement expect(e.prefix).toEqual("Unsupported statement: "); + // Error should include position information for scope isolation + expect(e.start).toBeDefined(); + expect(e.end).toBeDefined(); } }); From 98a9e5894eb173d51e94483cc440d4bf48b60c93 Mon Sep 17 00:00:00 2001 From: 7se7en72025 Date: Thu, 12 Feb 2026 06:43:47 +0530 Subject: [PATCH 017/163] refactor: Remove debug console.log statements from loader --- js/loader.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/js/loader.js b/js/loader.js index 2561c3342b..b7dbc2b3c5 100644 --- a/js/loader.js +++ b/js/loader.js @@ -301,7 +301,7 @@ requirejs(["i18next", "i18nextHttpBackend"], function (i18next, i18nextHttpBacke function () { // Give scripts a moment to finish executing and set globals setTimeout(function () { - // Log verification status for debugging (non-blocking) + // Verify core dependencies are loaded const verificationStatus = { createjs: typeof window.createjs !== "undefined", createDefaultStack: typeof window.createDefaultStack !== "undefined", @@ -309,7 +309,6 @@ requirejs(["i18next", "i18nextHttpBackend"], function (i18next, i18nextHttpBacke Blocks: typeof window.Blocks !== "undefined", Turtles: typeof window.Turtles !== "undefined" }; - console.log("Core module verification:", verificationStatus); // Check critical dependencies (only createjs is truly critical) if (typeof window.createjs === "undefined") { @@ -325,7 +324,6 @@ requirejs(["i18next", "i18nextHttpBackend"], function (i18next, i18nextHttpBacke ["activity/activity"], function () { // Activity loaded successfully - console.log("Activity module loaded successfully"); }, function (err) { console.error("Failed to load activity/activity:", err); From 013fe5653d02586fd53557af02e384b506e444ce Mon Sep 17 00:00:00 2001 From: Parth Date: Fri, 13 Feb 2026 11:43:22 +0530 Subject: [PATCH 018/163] test: add tuner and sample preload edge case coverage for synthutils --- js/utils/__tests__/synthutils.test.js | 74 +++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/js/utils/__tests__/synthutils.test.js b/js/utils/__tests__/synthutils.test.js index 4a12fc85fd..58ae2ca942 100644 --- a/js/utils/__tests__/synthutils.test.js +++ b/js/utils/__tests__/synthutils.test.js @@ -56,6 +56,10 @@ describe("Utility Functions (logic-only)", () => { setVolume, getVolume, setMasterVolume, + getTunerFrequency, + stopTuner, + newTone, + preloadProjectSamples, Synth; const turtle = "turtle1"; @@ -160,6 +164,10 @@ describe("Utility Functions (logic-only)", () => { setVolume = Synth.setVolume; getVolume = Synth.getVolume; setMasterVolume = Synth.setMasterVolume; + getTunerFrequency = Synth.getTunerFrequency; + stopTuner = Synth.stopTuner; + newTone = Synth.newTone; + preloadProjectSamples = Synth.preloadProjectSamples; }); describe("setupRecorder", () => { @@ -1049,6 +1057,72 @@ describe("Utility Functions (logic-only)", () => { expect(result.unknownKey).toBeUndefined(); }); }); + + describe("getTunerFrequency", () => { + it("should return 440 when tunerAnalyser is null", () => { + Synth.tunerAnalyser = null; + Synth.detectPitch = jest.fn(); + expect(getTunerFrequency()).toBe(440); + expect(Synth.detectPitch).not.toHaveBeenCalled(); + }); + + it("should return 440 when detectPitch is null", () => { + Synth.tunerAnalyser = { getValue: jest.fn() }; + Synth.detectPitch = null; + expect(getTunerFrequency()).toBe(440); + }); + + it("should return 440 when detected pitch is zero or negative", () => { + Synth.tunerAnalyser = { getValue: jest.fn(() => new Float32Array(16)) }; + Synth.detectPitch = jest.fn(() => 0); + expect(getTunerFrequency()).toBe(440); + + Synth.detectPitch = jest.fn(() => -1); + expect(getTunerFrequency()).toBe(440); + }); + + it("should return detected pitch when valid", () => { + Synth.tunerAnalyser = { getValue: jest.fn(() => new Float32Array(16)) }; + Synth.detectPitch = jest.fn(() => 261.63); + expect(getTunerFrequency()).toBe(261.63); + }); + }); + + describe("stopTuner", () => { + it("should not throw when tunerMic is null", () => { + Synth.tunerMic = null; + expect(() => stopTuner()).not.toThrow(); + }); + + it("should call close on tunerMic when it exists", () => { + const mockClose = jest.fn(); + Synth.tunerMic = { close: mockClose }; + stopTuner(); + expect(mockClose).toHaveBeenCalledTimes(1); + }); + }); + + describe("newTone", () => { + it("should set tone to the Tone module", () => { + Synth.tone = null; + newTone(); + expect(Synth.tone).toBe(Tone); + }); + }); + + describe("preloadProjectSamples", () => { + it("should return immediately for null input", async () => { + await expect(preloadProjectSamples(null)).resolves.toBeUndefined(); + }); + + it("should return immediately for non-array input", async () => { + await expect(preloadProjectSamples("not-an-array")).resolves.toBeUndefined(); + }); + + it("should return immediately for empty array", async () => { + await expect(preloadProjectSamples([])).resolves.toBeUndefined(); + }); + }); }); describe("Tuner Utilities (Audio Test Functions)", () => { From 607741698e556a3c93ca4c012f25810cea9cde97 Mon Sep 17 00:00:00 2001 From: DhyaniKavya Date: Fri, 13 Feb 2026 14:23:28 +0530 Subject: [PATCH 019/163] fix: remove debug console.log from KeySignatureEnv initialization --- js/activity.js | 26 ++++++++++---------------- js/loader.js | 4 ++-- sw.js | 11 +++++------ 3 files changed, 17 insertions(+), 24 deletions(-) diff --git a/js/activity.js b/js/activity.js index 60e36a28e9..6c00c56933 100644 --- a/js/activity.js +++ b/js/activity.js @@ -353,8 +353,6 @@ class Activity { this.KeySignatureEnv = ["C", "major", false]; try { if (this.storage.KeySignatureEnv !== undefined) { - // eslint-disable-next-line no-console - console.log(this.storage.KeySignatureEnv); this.KeySignatureEnv = this.storage.KeySignatureEnv.split(","); this.KeySignatureEnv[2] = this.KeySignatureEnv[2] === "true"; } @@ -1867,9 +1865,8 @@ class Activity { // Queue and take first step. if (!this.turtles.running()) { this.logo.runLogoCommands(); - document.getElementById( - "stop" - ).style.color = this.toolbar.stopIconColorWhenPlaying; + document.getElementById("stop").style.color = + this.toolbar.stopIconColorWhenPlaying; } this.logo.step(); } else { @@ -2189,9 +2186,8 @@ class Activity { i < this.palettes.dict[this.palettes.activePalette].protoList.length; i++ ) { - const name = this.palettes.dict[this.palettes.activePalette].protoList[i][ - "name" - ]; + const name = + this.palettes.dict[this.palettes.activePalette].protoList[i]["name"]; if (name in obj["FLOWPLUGINS"]) { // eslint-disable-next-line no-console console.log("deleting " + name); @@ -5074,9 +5070,8 @@ class Activity { } } staffBlocksMap[staffIndex].baseBlocks[0][0][firstnammedo][4][0] = blockId; - staffBlocksMap[staffIndex].baseBlocks[repeatId.end][0][ - endnammedo - ][4][1] = null; + staffBlocksMap[staffIndex].baseBlocks[repeatId.end][0][endnammedo][4][1] = + null; blockId += 2; } else { @@ -5144,9 +5139,8 @@ class Activity { prevnameddo ][4][1] = blockId; } else { - staffBlocksMap[staffIndex].repeatBlock[ - prevrepeatnameddo - ][4][3] = blockId; + staffBlocksMap[staffIndex].repeatBlock[prevrepeatnameddo][4][3] = + blockId; } if (afternamedo !== -1) { staffBlocksMap[staffIndex].baseBlocks[repeatId.end][0][ @@ -5999,8 +5993,8 @@ class Activity { let customName = "custom"; if (myBlock.connections[1] !== null) { // eslint-disable-next-line max-len - customName = this.blocks.blockList[myBlock.connections[1]] - .value; + customName = + this.blocks.blockList[myBlock.connections[1]].value; } // eslint-disable-next-line no-console console.log(customName); diff --git a/js/loader.js b/js/loader.js index 05097e7e34..f9ef8900dc 100644 --- a/js/loader.js +++ b/js/loader.js @@ -69,7 +69,7 @@ requirejs(["i18next", "i18nextHttpBackend"], function (i18next, i18nextHttpBacke console.error("i18next init failed:", err); } window.i18next = i18next; - resolve(i18next); + resolve(i18next); } ); }); @@ -101,4 +101,4 @@ requirejs(["i18next", "i18nextHttpBackend"], function (i18next, i18nextHttpBacke } main(); -}); \ No newline at end of file +}); diff --git a/sw.js b/sw.js index 7e6d97fd9c..096ac84830 100644 --- a/sw.js +++ b/sw.js @@ -55,7 +55,7 @@ function fromCache(request) { if (!matching || matching.status === 404) { return Promise.reject("no-match"); } - + return matching; }); }); @@ -103,7 +103,7 @@ self.addEventListener("fetch", function (event) { ); }); -self.addEventListener("beforeinstallprompt", (event) => { +self.addEventListener("beforeinstallprompt", event => { // eslint-disable-next-line no-console console.log("done", "beforeinstallprompt", event); // Stash the event so it can be triggered later. @@ -120,11 +120,10 @@ self.addEventListener("refreshOffline", function () { return fetch(offlineFallbackPage).then(function (response) { return caches.open(CACHE).then(function (cache) { // eslint-disable-next-line no-console - console.log("[PWA Builder] Offline page updated from refreshOffline event: " + response.url); + console.log( + "[PWA Builder] Offline page updated from refreshOffline event: " + response.url + ); return cache.put(offlinePageRequest, response); }); }); }); - - - From d17b853a7e812388f00944b6c381767be1c45f89 Mon Sep 17 00:00:00 2001 From: DhyaniKavya Date: Fri, 13 Feb 2026 14:45:05 +0530 Subject: [PATCH 020/163] chore: trigger CI re-run From 2560ed13617744be540a303a5a0dcee5023614b9 Mon Sep 17 00:00:00 2001 From: DhyaniKavya Date: Fri, 13 Feb 2026 14:48:47 +0530 Subject: [PATCH 021/163] style: fix Prettier formatting in js/activity.js --- js/activity.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/js/activity.js b/js/activity.js index 4ae06bc189..7443c0c6d2 100644 --- a/js/activity.js +++ b/js/activity.js @@ -7864,11 +7864,12 @@ define(["domReady!"].concat(MYDEFINES), doc => { const initialize = () => { // Defensive check for multiple critical globals that may be delayed // due to 'defer' execution timing variances. - const globalsReady = typeof createDefaultStack !== "undefined" && - typeof createjs !== "undefined" && - typeof Tone !== "undefined" && - typeof GIFAnimator !== "undefined" && - typeof SuperGif !== "undefined"; + const globalsReady = + typeof createDefaultStack !== "undefined" && + typeof createjs !== "undefined" && + typeof Tone !== "undefined" && + typeof GIFAnimator !== "undefined" && + typeof SuperGif !== "undefined"; if (globalsReady) { activity.setupDependencies(); From 987cd6a0fd01a1ad92b68d098a81c5d3992f0d0b Mon Sep 17 00:00:00 2001 From: Parth Date: Fri, 13 Feb 2026 16:01:43 +0530 Subject: [PATCH 022/163] test: add comprehensive unit tests for p5-adapter initialization logic --- js/__tests__/p5-adapter.test.js | 101 ++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 js/__tests__/p5-adapter.test.js diff --git a/js/__tests__/p5-adapter.test.js b/js/__tests__/p5-adapter.test.js new file mode 100644 index 0000000000..2b629391af --- /dev/null +++ b/js/__tests__/p5-adapter.test.js @@ -0,0 +1,101 @@ +/** + * @license + * MusicBlocks v3.4.1 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +let factory; +const mockP5 = { version: "1.0" }; + +beforeAll(() => { + global.define = jest.fn((deps, fn) => { + factory = fn; + }); + require("../p5-adapter"); +}); + +afterAll(() => { + delete global.define; +}); + +describe("p5-adapter", () => { + beforeEach(() => { + delete window.p5; + delete window.OriginalTone; + delete window.Tone; + delete window.OriginalAudioContext; + delete window.AudioContext; + delete window.OriginalWebkitAudioContext; + delete window.webkitAudioContext; + delete window.AudioNode; + delete window.OriginalAudioNodeConnect; + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test("define registers factory with p5.min dependency", () => { + expect(typeof factory).toBe("function"); + }); + + test("assigns p5 to window when window.p5 is absent", () => { + factory(mockP5); + expect(window.p5).toBe(mockP5); + }); + + test("does not overwrite existing window.p5", () => { + const existing = { version: "0.9" }; + window.p5 = existing; + factory(mockP5); + expect(window.p5).toBe(existing); + }); + + test("saves window.Tone as OriginalTone when present", () => { + const tone = { name: "Tone" }; + window.Tone = tone; + factory(mockP5); + expect(window.OriginalTone).toBe(tone); + }); + + test("warns when window.Tone is missing", () => { + factory(mockP5); + expect(console.warn).toHaveBeenCalledWith("p5-adapter: window.Tone not found!"); + }); + + test("saves AudioContext when present", () => { + const ac = jest.fn(); + window.AudioContext = ac; + factory(mockP5); + expect(window.OriginalAudioContext).toBe(ac); + }); + + test("saves webkitAudioContext when present", () => { + const wac = jest.fn(); + window.webkitAudioContext = wac; + factory(mockP5); + expect(window.OriginalWebkitAudioContext).toBe(wac); + }); + + test("saves AudioNode.prototype.connect when present", () => { + const connect = jest.fn(); + window.AudioNode = { prototype: { connect } }; + factory(mockP5); + expect(window.OriginalAudioNodeConnect).toBe(connect); + }); + + test("skips AudioNode save when AudioNode is absent", () => { + factory(mockP5); + expect(window.OriginalAudioNodeConnect).toBeUndefined(); + }); + + test("returns p5 from factory", () => { + expect(factory(mockP5)).toBe(mockP5); + }); +}); From 99ddcc4832efeda0feee9280bc354aecb7c7ddf5 Mon Sep 17 00:00:00 2001 From: kh-ub-ayb Date: Fri, 13 Feb 2026 20:30:14 +0530 Subject: [PATCH 023/163] test: add extended unit tests for Turtles class --- js/__tests__/turtles.test.js | 392 +++++++++++++++++++++++++++++++++++ 1 file changed, 392 insertions(+) diff --git a/js/__tests__/turtles.test.js b/js/__tests__/turtles.test.js index 2b418fda46..bfe45ef8f9 100644 --- a/js/__tests__/turtles.test.js +++ b/js/__tests__/turtles.test.js @@ -58,6 +58,29 @@ global.Turtle = jest.fn().mockImplementation(() => ({ } })); +/** + * Helper to mix TurtlesModel and TurtlesView prototype methods into a Turtles instance, + * mimicking what importMembers does at runtime. + */ +function mixinPrototypes(turtles) { + const modelProto = Turtles.TurtlesModel.prototype; + const viewProto = Turtles.TurtlesView.prototype; + + for (const key of Object.getOwnPropertyNames(modelProto)) { + if (key !== "constructor" && !(key in turtles)) { + const descriptor = Object.getOwnPropertyDescriptor(modelProto, key); + Object.defineProperty(turtles, key, descriptor); + } + } + + for (const key of Object.getOwnPropertyNames(viewProto)) { + if (key !== "constructor" && !(key in turtles)) { + const descriptor = Object.getOwnPropertyDescriptor(viewProto, key); + Object.defineProperty(turtles, key, descriptor); + } + } +} + describe("Turtles Class", () => { let activityMock; let turtles; @@ -120,3 +143,372 @@ describe("Turtles Class", () => { expect(activityMock.refreshCanvas).toHaveBeenCalled(); }); }); + +describe("markAllAsStopped", () => { + let activityMock; + let turtles; + + beforeEach(() => { + activityMock = { + stage: { addChild: jest.fn(), removeChild: jest.fn() }, + refreshCanvas: jest.fn(), + turtleContainer: new createjs.Container(), + hideAuxMenu: jest.fn(), + hideGrids: jest.fn(), + _doCartesianPolar: jest.fn() + }; + + turtles = new Turtles(activityMock); + turtles.activity = activityMock; + }); + + test("should set running to false for all turtles", () => { + const turtle1 = { running: true }; + const turtle2 = { running: true }; + const turtle3 = { running: true }; + + turtles._turtleList = [turtle1, turtle2, turtle3]; + turtles.getTurtleCount = jest.fn().mockReturnValue(3); + turtles.getTurtle = jest.fn(i => turtles._turtleList[i]); + + turtles.markAllAsStopped(); + + expect(turtle1.running).toBe(false); + expect(turtle2.running).toBe(false); + expect(turtle3.running).toBe(false); + }); + + test("should call refreshCanvas after stopping all turtles", () => { + turtles.getTurtleCount = jest.fn().mockReturnValue(0); + turtles.markAllAsStopped(); + + expect(activityMock.refreshCanvas).toHaveBeenCalled(); + }); + + test("should handle empty turtle list", () => { + turtles._turtleList = []; + turtles.getTurtleCount = jest.fn().mockReturnValue(0); + + turtles.markAllAsStopped(); + + expect(activityMock.refreshCanvas).toHaveBeenCalled(); + }); + + test("should stop turtles that are already stopped without error", () => { + const turtle1 = { running: false }; + const turtle2 = { running: true }; + + turtles._turtleList = [turtle1, turtle2]; + turtles.getTurtleCount = jest.fn().mockReturnValue(2); + turtles.getTurtle = jest.fn(i => turtles._turtleList[i]); + + turtles.markAllAsStopped(); + + expect(turtle1.running).toBe(false); + expect(turtle2.running).toBe(false); + }); +}); + +describe("Coordinate Conversion", () => { + let activityMock; + let turtles; + + beforeEach(() => { + activityMock = { + stage: { addChild: jest.fn(), removeChild: jest.fn() }, + refreshCanvas: jest.fn(), + turtleContainer: new createjs.Container(), + canvas: { width: 1200, height: 900, style: {} }, + hideAuxMenu: jest.fn(), + hideGrids: jest.fn(), + _doCartesianPolar: jest.fn() + }; + + turtles = new Turtles(activityMock); + turtles.activity = activityMock; + mixinPrototypes(turtles); + turtles._canvas = activityMock.canvas; + turtles._scale = 1.0; + }); + + describe("screenX2turtleX", () => { + test("should convert screen center X to turtle X of 0", () => { + // canvas.width=1200, scale=1.0 => center = 1200/(2*1) = 600 + const result = turtles.screenX2turtleX(600); + expect(result).toBe(0); + }); + + test("should convert screen X 0 to negative turtle X", () => { + // 0 - 600 = -600 + const result = turtles.screenX2turtleX(0); + expect(result).toBe(-600); + }); + + test("should convert screen X at right edge to positive turtle X", () => { + // 1200 - 600 = 600 + const result = turtles.screenX2turtleX(1200); + expect(result).toBe(600); + }); + + test("should account for scale factor", () => { + turtles._scale = 2.0; + // center = 1200/(2*2) = 300 + // 450 - 300 = 150 + const result = turtles.screenX2turtleX(450); + expect(result).toBe(150); + }); + }); + + describe("screenY2turtleY", () => { + test("should convert screen center Y to turtle Y of 0", () => { + // canvas.height=900, scale=1.0 => center = 900/(2*1) = 450 + // _invertY(450) = 450 - 450 = 0 + const result = turtles.screenY2turtleY(450); + expect(result).toBe(0); + }); + + test("should convert screen Y 0 to positive turtle Y (inverted)", () => { + // _invertY(0) = 450 - 0 = 450 + const result = turtles.screenY2turtleY(0); + expect(result).toBe(450); + }); + + test("should convert screen Y at bottom to negative turtle Y", () => { + // _invertY(900) = 450 - 900 = -450 + const result = turtles.screenY2turtleY(900); + expect(result).toBe(-450); + }); + + test("should account for scale factor", () => { + turtles._scale = 2.0; + // center = 900/(2*2) = 225 + // _invertY(100) = 225 - 100 = 125 + const result = turtles.screenY2turtleY(100); + expect(result).toBe(125); + }); + }); + + describe("turtleX2screenX", () => { + test("should convert turtle X of 0 to screen center X", () => { + // center = 1200/(2*1) = 600; 600 + 0 = 600 + const result = turtles.turtleX2screenX(0); + expect(result).toBe(600); + }); + + test("should convert positive turtle X to screen X right of center", () => { + // 600 + 100 = 700 + const result = turtles.turtleX2screenX(100); + expect(result).toBe(700); + }); + + test("should convert negative turtle X to screen X left of center", () => { + // 600 + (-200) = 400 + const result = turtles.turtleX2screenX(-200); + expect(result).toBe(400); + }); + + test("should account for scale factor", () => { + turtles._scale = 0.5; + // center = 1200/(2*0.5) = 1200; 1200 + 50 = 1250 + const result = turtles.turtleX2screenX(50); + expect(result).toBe(1250); + }); + }); + + describe("turtleY2screenY", () => { + test("should convert turtle Y of 0 to screen center Y", () => { + // _invertY(0) = 450 - 0 = 450 + const result = turtles.turtleY2screenY(0); + expect(result).toBe(450); + }); + + test("should convert positive turtle Y to screen Y above center", () => { + // _invertY(100) = 450 - 100 = 350 + const result = turtles.turtleY2screenY(100); + expect(result).toBe(350); + }); + + test("should convert negative turtle Y to screen Y below center", () => { + // _invertY(-200) = 450 - (-200) = 650 + const result = turtles.turtleY2screenY(-200); + expect(result).toBe(650); + }); + }); + + describe("round-trip conversion", () => { + test("screenX -> turtleX -> screenX should return original value", () => { + const screenX = 300; + const turtleX = turtles.screenX2turtleX(screenX); + const backToScreen = turtles.turtleX2screenX(turtleX); + expect(backToScreen).toBe(screenX); + }); + + test("screenY -> turtleY -> screenY should return original value", () => { + const screenY = 200; + const turtleY = turtles.screenY2turtleY(screenY); + const backToScreen = turtles.turtleY2screenY(turtleY); + expect(backToScreen).toBe(screenY); + }); + + test("round-trip should work with non-default scale", () => { + turtles._scale = 1.5; + const screenX = 400; + const turtleX = turtles.screenX2turtleX(screenX); + const backToScreen = turtles.turtleX2screenX(turtleX); + expect(backToScreen).toBe(screenX); + }); + }); +}); + +describe("setBackgroundColor", () => { + let activityMock; + let turtles; + + beforeEach(() => { + activityMock = { + stage: { addChild: jest.fn(), removeChild: jest.fn() }, + refreshCanvas: jest.fn(), + turtleContainer: new createjs.Container(), + canvas: { width: 1200, height: 900, style: {} }, + hideAuxMenu: jest.fn(), + hideGrids: jest.fn(), + _doCartesianPolar: jest.fn() + }; + + turtles = new Turtles(activityMock); + turtles.activity = activityMock; + mixinPrototypes(turtles); + turtles._canvas = activityMock.canvas; + turtles._scale = 1.0; + global.platformColor = { background: "#ffffff" }; + turtles._backgroundColor = platformColor.background; + }); + + test("should set default background color when index is -1", () => { + turtles.setBackgroundColor(-1); + + expect(turtles._backgroundColor).toBe(platformColor.background); + expect(activityMock.refreshCanvas).toHaveBeenCalled(); + }); + + test("should set background color from turtle painter when index is valid", () => { + const mockTurtle = { + painter: { + canvasColor: "#ff0000" + } + }; + turtles.getTurtle = jest.fn().mockReturnValue(mockTurtle); + + turtles.setBackgroundColor(0); + + expect(turtles._backgroundColor).toBe("#ff0000"); + expect(activityMock.refreshCanvas).toHaveBeenCalled(); + }); + + test("should update DOM body background color", () => { + turtles.setBackgroundColor(-1); + + // jsdom normalizes hex colors to rgb format + const bgColor = document.body.style.backgroundColor; + expect(bgColor === platformColor.background || bgColor === "rgb(255, 255, 255)").toBe(true); + }); + + test("should update canvas background color", () => { + turtles.setBackgroundColor(-1); + + // Canvas style object is a plain object, not a DOM style, so it keeps the original value + const canvasBg = activityMock.canvas.style.backgroundColor; + expect(canvasBg === platformColor.background || canvasBg === "rgb(255, 255, 255)").toBe( + true + ); + }); +}); + +describe("doScale", () => { + let activityMock; + let turtles; + + beforeEach(() => { + activityMock = { + stage: { addChild: jest.fn(), removeChild: jest.fn() }, + refreshCanvas: jest.fn(), + turtleContainer: new createjs.Container(), + canvas: { width: 1200, height: 900, style: {} }, + hideAuxMenu: jest.fn(), + hideGrids: jest.fn(), + _doCartesianPolar: jest.fn() + }; + + turtles = new Turtles(activityMock); + turtles.activity = activityMock; + mixinPrototypes(turtles); + turtles._canvas = activityMock.canvas; + turtles._scale = 1.0; + turtles._locked = false; + turtles._queue = []; + turtles._backgroundColor = "#ffffff"; + }); + + test("should update scale, width, and height when not locked", () => { + turtles.doScale(800, 600, 2.0); + + expect(turtles._scale).toBe(2.0); + expect(turtles._w).toBe(400); // 800 / 2.0 + expect(turtles._h).toBe(300); // 600 / 2.0 + }); + + test("should queue values when locked", () => { + turtles._locked = true; + turtles.doScale(800, 600, 2.0); + + expect(turtles._queue).toEqual([800, 600, 2.0]); + }); + + test("should not change scale when locked", () => { + turtles._locked = true; + const originalScale = turtles._scale; + turtles.doScale(800, 600, 2.0); + + expect(turtles._scale).toBe(originalScale); + }); +}); + +describe("setStageScale", () => { + let activityMock; + let turtles; + + beforeEach(() => { + activityMock = { + stage: { addChild: jest.fn(), removeChild: jest.fn(), scaleX: 1, scaleY: 1 }, + refreshCanvas: jest.fn(), + turtleContainer: new createjs.Container(), + canvas: { width: 1200, height: 900, style: {} }, + hideAuxMenu: jest.fn(), + hideGrids: jest.fn(), + _doCartesianPolar: jest.fn() + }; + + turtles = new Turtles(activityMock); + turtles.activity = activityMock; + mixinPrototypes(turtles); + turtles._canvas = activityMock.canvas; + turtles._stage = { + scaleX: 1, + scaleY: 1, + addChild: jest.fn() + }; + }); + + test("should set scaleX and scaleY on the stage", () => { + turtles.setStageScale(0.5); + + expect(turtles.stage.scaleX).toBe(0.5); + expect(turtles.stage.scaleY).toBe(0.5); + }); + + test("should call refreshCanvas after setting scale", () => { + turtles.setStageScale(0.75); + + expect(activityMock.refreshCanvas).toHaveBeenCalled(); + }); +}); From 7991846fd9899f7d96e15ad73614e1ca0de84510 Mon Sep 17 00:00:00 2001 From: Lakshay Date: Fri, 13 Feb 2026 20:47:23 +0530 Subject: [PATCH 024/163] added the arpeggio test Co-authored-by: Cursor --- js/widgets/__tests__/arpeggio.test.js | 175 ++++++++++++++++++++++++++ js/widgets/arpeggio.js | 3 + 2 files changed, 178 insertions(+) create mode 100644 js/widgets/__tests__/arpeggio.test.js diff --git a/js/widgets/__tests__/arpeggio.test.js b/js/widgets/__tests__/arpeggio.test.js new file mode 100644 index 0000000000..d358b94ef7 --- /dev/null +++ b/js/widgets/__tests__/arpeggio.test.js @@ -0,0 +1,175 @@ +/** + * MusicBlocks v3.6.2 + * + * @author Music Blocks Contributors + * + * @copyright 2026 Music Blocks Contributors + * + * @license + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const Arpeggio = require("../arpeggio.js"); + +// --- Global Mocks --- +global._ = msg => msg; +global.platformColor = { + labelColor: "#90c100", + selectorBackground: "#f0f0f0", + selectorBackgroundHOVER: "#e0e0e0" +}; +global.docById = jest.fn(() => ({ + style: {}, + innerHTML: "", + insertRow: jest.fn(() => ({ + insertCell: jest.fn(() => ({ + style: {}, + innerHTML: "", + setAttribute: jest.fn(), + addEventListener: jest.fn() + })), + setAttribute: jest.fn(), + style: {} + })), + appendChild: jest.fn(), + setAttribute: jest.fn() +})); +global.getNote = jest.fn(() => ["C", "", 4]); +global.setCustomChord = jest.fn(); +global.keySignatureToMode = jest.fn(() => ["C", "major"]); +global.getModeNumbers = jest.fn(() => "0 2 4 5 7 9 11"); +global.getTemperament = jest.fn(() => ({ pitchNumber: 12 })); + +global.window = { + innerWidth: 1200, + widgetWindows: { + windowFor: jest.fn().mockReturnValue({ + clear: jest.fn(), + show: jest.fn(), + addButton: jest.fn().mockReturnValue({ onclick: null }), + getWidgetBody: jest.fn().mockReturnValue({ + append: jest.fn(), + appendChild: jest.fn(), + style: {} + }), + sendToCenter: jest.fn(), + onclose: null, + onmaximize: null, + destroy: jest.fn() + }) + } +}; + +global.document = { + createElement: jest.fn(() => ({ + style: {}, + innerHTML: "", + appendChild: jest.fn(), + append: jest.fn(), + setAttribute: jest.fn(), + addEventListener: jest.fn() + })) +}; + +describe("Arpeggio Widget", () => { + let arpeggio; + + beforeEach(() => { + arpeggio = new Arpeggio(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + // --- Constructor Tests --- + describe("constructor", () => { + test("should initialize with empty notesToPlay", () => { + expect(arpeggio.notesToPlay).toEqual([]); + }); + + test("should initialize with empty _blockMap", () => { + expect(arpeggio._blockMap).toEqual([]); + }); + + test("should initialize defaultCols to DEFAULTCOLS", () => { + expect(arpeggio.defaultCols).toBe(Arpeggio.DEFAULTCOLS); + expect(arpeggio.defaultCols).toBe(4); + }); + }); + + // --- Static Constants Tests --- + describe("static constants", () => { + test("should have correct BUTTONDIVWIDTH", () => { + expect(Arpeggio.BUTTONDIVWIDTH).toBe(295); + }); + + test("should have correct CELLSIZE", () => { + expect(Arpeggio.CELLSIZE).toBe(28); + }); + + test("should have correct BUTTONSIZE", () => { + expect(Arpeggio.BUTTONSIZE).toBe(53); + }); + + test("should have correct ICONSIZE", () => { + expect(Arpeggio.ICONSIZE).toBe(32); + }); + + test("should have correct DEFAULTCOLS", () => { + expect(Arpeggio.DEFAULTCOLS).toBe(4); + }); + }); + + // --- Data Management Tests --- + describe("data management", () => { + test("should store notes to play", () => { + arpeggio.notesToPlay.push(["C", 4]); + arpeggio.notesToPlay.push(["E", 4]); + arpeggio.notesToPlay.push(["G", 4]); + expect(arpeggio.notesToPlay).toHaveLength(3); + }); + + test("should store block map pairs", () => { + arpeggio._blockMap.push([0, 1]); + arpeggio._blockMap.push([3, 2]); + expect(arpeggio._blockMap).toHaveLength(2); + expect(arpeggio._blockMap[0]).toEqual([0, 1]); + }); + + test("should allow clearing block map", () => { + arpeggio._blockMap.push([0, 1]); + arpeggio._blockMap = []; + expect(arpeggio._blockMap).toHaveLength(0); + }); + + test("should allow updating defaultCols", () => { + arpeggio.defaultCols = 8; + expect(arpeggio.defaultCols).toBe(8); + }); + }); + + // --- Notes To Play Tests --- + describe("notesToPlay", () => { + test("should handle empty notesToPlay", () => { + expect(arpeggio.notesToPlay).toEqual([]); + expect(arpeggio.notesToPlay.length).toBe(0); + }); + + test("should allow complex note entries", () => { + arpeggio.notesToPlay.push(["sol", 4, "electronic synth"]); + expect(arpeggio.notesToPlay[0]).toEqual(["sol", 4, "electronic synth"]); + }); + }); +}); diff --git a/js/widgets/arpeggio.js b/js/widgets/arpeggio.js index 51a00ccea7..1a44f93c70 100644 --- a/js/widgets/arpeggio.js +++ b/js/widgets/arpeggio.js @@ -817,3 +817,6 @@ class Arpeggio { this._activity.blocks.loadNewBlocks(newStack); } } +if (typeof module !== "undefined") { + module.exports = Arpeggio; +} From 2af7a572907c6b48f196f6fc7a920a043b86b567 Mon Sep 17 00:00:00 2001 From: kh-ub-ayb Date: Fri, 13 Feb 2026 21:10:50 +0530 Subject: [PATCH 025/163] test: add extended unit tests for Boundary class --- js/__tests__/boundary.test.js | 248 ++++++++++++++++++++++++++++++++++ 1 file changed, 248 insertions(+) diff --git a/js/__tests__/boundary.test.js b/js/__tests__/boundary.test.js index b671897416..5519ee9441 100644 --- a/js/__tests__/boundary.test.js +++ b/js/__tests__/boundary.test.js @@ -109,3 +109,251 @@ describe("Boundary Class", () => { imgMock.mockRestore(); }); }); + +describe("offScreen edge cases", () => { + let stage; + let boundary; + + beforeEach(() => { + stage = { + addChild: jest.fn(), + setChildIndex: jest.fn() + }; + + boundary = new Boundary(stage); + boundary.create(800, 600, 1); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should return false for a point exactly at top-left corner (x, y)", () => { + // offScreen uses strict < and >, so exact boundary is NOT off-screen + expect(boundary.offScreen(boundary.x, boundary.y)).toBe(false); + }); + + it("should return false for a point exactly at bottom-right corner (x+dx, y+dy)", () => { + expect(boundary.offScreen(boundary.x + boundary.dx, boundary.y + boundary.dy)).toBe(false); + }); + + it("should return true for a point just left of the boundary", () => { + expect(boundary.offScreen(boundary.x - 1, boundary.y + 1)).toBe(true); + }); + + it("should return true for a point just above the boundary", () => { + expect(boundary.offScreen(boundary.x + 1, boundary.y - 1)).toBe(true); + }); + + it("should return true for a point just right of the boundary", () => { + expect(boundary.offScreen(boundary.x + boundary.dx + 1, boundary.y + 1)).toBe(true); + }); + + it("should return true for a point just below the boundary", () => { + expect(boundary.offScreen(boundary.x + 1, boundary.y + boundary.dy + 1)).toBe(true); + }); + + it("should return false for the center of the boundary", () => { + const centerX = boundary.x + boundary.dx / 2; + const centerY = boundary.y + boundary.dy / 2; + expect(boundary.offScreen(centerX, centerY)).toBe(false); + }); + + it("should return true for negative coordinates", () => { + expect(boundary.offScreen(-100, -100)).toBe(true); + }); + + it("should return true for very large coordinates", () => { + expect(boundary.offScreen(10000, 10000)).toBe(true); + }); + + it("should return false for a point on the left edge", () => { + expect(boundary.offScreen(boundary.x, boundary.y + boundary.dy / 2)).toBe(false); + }); + + it("should return false for a point on the right edge", () => { + expect(boundary.offScreen(boundary.x + boundary.dx, boundary.y + boundary.dy / 2)).toBe( + false + ); + }); + + it("should return false for a point on the top edge", () => { + expect(boundary.offScreen(boundary.x + boundary.dx / 2, boundary.y)).toBe(false); + }); + + it("should return false for a point on the bottom edge", () => { + expect(boundary.offScreen(boundary.x + boundary.dx / 2, boundary.y + boundary.dy)).toBe( + false + ); + }); +}); + +describe("create dimension calculations", () => { + let stage; + let boundary; + + beforeEach(() => { + stage = { + addChild: jest.fn(), + setChildIndex: jest.fn() + }; + + boundary = new Boundary(stage); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should compute correct dimensions at scale 1", () => { + boundary.create(800, 600, 1); + + expect(boundary.w).toBe(800); // 800 / 1 + expect(boundary.x).toBe(68); // 55 + 13 + expect(boundary.dx).toBe(800 - 136); // w - (110 + 26) + + expect(boundary.h).toBe(600); // 600 / 1 + expect(boundary.y).toBe(68); // 55 + 13 + expect(boundary.dy).toBe(600 - 81); // h - (55 + 26) + }); + + it("should compute correct dimensions at scale 2", () => { + boundary.create(800, 600, 2); + + expect(boundary.w).toBe(400); // 800 / 2 + expect(boundary.x).toBe(68); + expect(boundary.dx).toBe(400 - 136); + + expect(boundary.h).toBe(300); // 600 / 2 + expect(boundary.y).toBe(68); + expect(boundary.dy).toBe(300 - 81); + }); + + it("should compute correct dimensions at scale 0.5", () => { + boundary.create(800, 600, 0.5); + + expect(boundary.w).toBe(1600); // 800 / 0.5 + expect(boundary.x).toBe(68); + expect(boundary.dx).toBe(1600 - 136); + + expect(boundary.h).toBe(1200); // 600 / 0.5 + expect(boundary.y).toBe(68); + expect(boundary.dy).toBe(1200 - 81); + }); + + it("should compute correct dimensions for large canvas", () => { + boundary.create(1920, 1080, 1); + + expect(boundary.w).toBe(1920); + expect(boundary.dx).toBe(1920 - 136); + expect(boundary.h).toBe(1080); + expect(boundary.dy).toBe(1080 - 81); + }); + + it("should compute correct dimensions for small canvas", () => { + boundary.create(200, 200, 1); + + expect(boundary.w).toBe(200); + expect(boundary.dx).toBe(200 - 136); + expect(boundary.h).toBe(200); + expect(boundary.dy).toBe(200 - 81); + }); + + it("should have consistent x and y values regardless of scale", () => { + boundary.create(800, 600, 1); + const x1 = boundary.x; + const y1 = boundary.y; + + boundary.create(800, 600, 2); + expect(boundary.x).toBe(x1); + expect(boundary.y).toBe(y1); + + boundary.create(800, 600, 0.5); + expect(boundary.x).toBe(x1); + expect(boundary.y).toBe(y1); + }); +}); + +describe("destroy edge cases", () => { + let stage; + let boundary; + + beforeEach(() => { + stage = { + addChild: jest.fn(), + setChildIndex: jest.fn() + }; + + boundary = new Boundary(stage); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should not call removeChild when container has no children", () => { + boundary._container.children = []; + + boundary.destroy(); + expect(boundary._container.removeChild).not.toHaveBeenCalled(); + }); + + it("should remove only the first child when container has multiple children", () => { + const child1 = { id: 1 }; + const child2 = { id: 2 }; + boundary._container.children = [child1, child2]; + + boundary.destroy(); + expect(boundary._container.removeChild).toHaveBeenCalledWith(child1); + expect(boundary._container.removeChild).toHaveBeenCalledTimes(1); + }); + + it("should be safe to call destroy multiple times", () => { + const child = {}; + boundary._container.children = [child]; + boundary.destroy(); + + boundary._container.children = []; + boundary.destroy(); + // Should not throw + expect(boundary._container.removeChild).toHaveBeenCalledTimes(1); + }); +}); + +describe("setScale integration", () => { + let stage; + let boundary; + + beforeEach(() => { + stage = { + addChild: jest.fn(), + setChildIndex: jest.fn() + }; + + boundary = new Boundary(stage); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should update dimensions when setScale is called", () => { + boundary.create(800, 600, 1); + expect(boundary.w).toBe(800); + + boundary.setScale(800, 600, 2); + expect(boundary.w).toBe(400); + }); + + it("should destroy old boundary before creating new one", () => { + const destroySpy = jest.spyOn(boundary, "destroy"); + const createSpy = jest.spyOn(boundary, "create"); + + boundary.setScale(1200, 900, 1.5); + + // Verify destroy was called before create using invocation order + const destroyOrder = destroySpy.mock.invocationCallOrder[0]; + const createOrder = createSpy.mock.invocationCallOrder[0]; + expect(destroyOrder).toBeLessThan(createOrder); + }); +}); From 4dc4d8d927aad18a6f2c0eaf8fd2255949923597 Mon Sep 17 00:00:00 2001 From: Lakshay Date: Fri, 13 Feb 2026 21:14:31 +0530 Subject: [PATCH 026/163] test added for the pitchdrummatrix --- js/widgets/__tests__/pitchdrummatrix.test.js | 229 +++++++++++++++++++ js/widgets/pitchdrummatrix.js | 39 ++-- 2 files changed, 247 insertions(+), 21 deletions(-) create mode 100644 js/widgets/__tests__/pitchdrummatrix.test.js diff --git a/js/widgets/__tests__/pitchdrummatrix.test.js b/js/widgets/__tests__/pitchdrummatrix.test.js new file mode 100644 index 0000000000..c1e99be543 --- /dev/null +++ b/js/widgets/__tests__/pitchdrummatrix.test.js @@ -0,0 +1,229 @@ +/** + * MusicBlocks v3.6.2 + * + * @author Lakshay + * + * @copyright 2026 Lakshay + * + * @license + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const PitchDrumMatrix = require("../pitchdrummatrix.js"); + +// --- Global Mocks --- +global._ = msg => msg; +global.platformColor = { + labelColor: "#90c100", + selectorBackground: "#f0f0f0", + selectorBackgroundHOVER: "#e0e0e0" +}; +global.docById = jest.fn(() => ({ + style: {}, + innerHTML: "", + insertRow: jest.fn(() => ({ + insertCell: jest.fn(() => ({ + style: {}, + innerHTML: "", + setAttribute: jest.fn(), + addEventListener: jest.fn(), + appendChild: jest.fn() + })), + setAttribute: jest.fn(), + style: {} + })), + appendChild: jest.fn(), + setAttribute: jest.fn() +})); +global.getNote = jest.fn(() => ["C", "", 4]); +global.getDrumName = jest.fn(() => null); +global.getDrumIcon = jest.fn(() => "icon.svg"); +global.getDrumSynthName = jest.fn(() => "kick"); +global.MATRIXSOLFEHEIGHT = 30; +global.MATRIXSOLFEWIDTH = 80; +global.SOLFEGECONVERSIONTABLE = {}; +global.Singer = { RhythmActions: { getNoteValue: jest.fn(() => 0.25) } }; + +global.window = { + innerWidth: 1200, + widgetWindows: { + windowFor: jest.fn().mockReturnValue({ + clear: jest.fn(), + show: jest.fn(), + addButton: jest.fn().mockReturnValue({ onclick: null }), + getWidgetBody: jest.fn().mockReturnValue({ + append: jest.fn(), + appendChild: jest.fn(), + style: {} + }), + sendToCenter: jest.fn(), + onclose: null, + onmaximize: null, + destroy: jest.fn() + }) + } +}; + +global.document = { + createElement: jest.fn(() => ({ + style: {}, + innerHTML: "", + appendChild: jest.fn(), + append: jest.fn(), + setAttribute: jest.fn(), + addEventListener: jest.fn() + })) +}; + +describe("PitchDrumMatrix Widget", () => { + let pdm; + + beforeEach(() => { + pdm = new PitchDrumMatrix(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + // --- Constructor Tests --- + describe("constructor", () => { + test("should initialize with empty rowLabels", () => { + expect(pdm.rowLabels).toEqual([]); + }); + + test("should initialize with empty rowArgs", () => { + expect(pdm.rowArgs).toEqual([]); + }); + + test("should initialize with empty drums", () => { + expect(pdm.drums).toEqual([]); + }); + + test("should initialize _rests to 0", () => { + expect(pdm._rests).toBe(0); + }); + + test("should initialize _playing to false", () => { + expect(pdm._playing).toBe(false); + }); + + test("should initialize empty _rowBlocks", () => { + expect(pdm._rowBlocks).toEqual([]); + }); + + test("should initialize empty _colBlocks", () => { + expect(pdm._colBlocks).toEqual([]); + }); + + test("should initialize empty _blockMap", () => { + expect(pdm._blockMap).toEqual([]); + }); + }); + + // --- Static Constants Tests --- + describe("static constants", () => { + test("should have correct BUTTONDIVWIDTH", () => { + expect(PitchDrumMatrix.BUTTONDIVWIDTH).toBe(295); + }); + + test("should have correct DRUMNAMEWIDTH", () => { + expect(PitchDrumMatrix.DRUMNAMEWIDTH).toBe(50); + }); + + test("should have correct OUTERWINDOWWIDTH", () => { + expect(PitchDrumMatrix.OUTERWINDOWWIDTH).toBe(128); + }); + + test("should have correct INNERWINDOWWIDTH", () => { + expect(PitchDrumMatrix.INNERWINDOWWIDTH).toBe(50); + }); + + test("should have correct BUTTONSIZE", () => { + expect(PitchDrumMatrix.BUTTONSIZE).toBe(53); + }); + + test("should have correct ICONSIZE", () => { + expect(PitchDrumMatrix.ICONSIZE).toBe(32); + }); + }); + + // --- Data Management Tests --- + describe("data management", () => { + test("should store row labels", () => { + pdm.rowLabels.push("C"); + pdm.rowLabels.push("D"); + pdm.rowLabels.push("E"); + expect(pdm.rowLabels).toEqual(["C", "D", "E"]); + }); + + test("should store row args (octaves)", () => { + pdm.rowArgs.push(4); + pdm.rowArgs.push(4); + pdm.rowArgs.push(5); + expect(pdm.rowArgs).toEqual([4, 4, 5]); + }); + + test("should store drums", () => { + pdm.drums.push("kick drum"); + pdm.drums.push("snare drum"); + expect(pdm.drums).toHaveLength(2); + }); + + test("should count rests", () => { + pdm._rests = 0; + pdm._rests += 1; + pdm._rests += 1; + expect(pdm._rests).toBe(2); + }); + + test("should store row block numbers", () => { + pdm._rowBlocks.push(10); + pdm._rowBlocks.push(20); + expect(pdm._rowBlocks).toEqual([10, 20]); + }); + + test("should store column block numbers", () => { + pdm._colBlocks.push(30); + pdm._colBlocks.push(40); + expect(pdm._colBlocks).toEqual([30, 40]); + }); + + test("should store block map entries", () => { + pdm._blockMap.push([0, 1]); + pdm._blockMap.push([1, 0]); + expect(pdm._blockMap).toHaveLength(2); + expect(pdm._blockMap[0]).toEqual([0, 1]); + }); + }); + + // --- Playing State Tests --- + describe("playing state", () => { + test("should toggle playing state", () => { + expect(pdm._playing).toBe(false); + pdm._playing = !pdm._playing; + expect(pdm._playing).toBe(true); + pdm._playing = !pdm._playing; + expect(pdm._playing).toBe(false); + }); + }); + + // --- Save Lock Tests --- + describe("save lock", () => { + test("should initialize _save_lock as undefined before init", () => { + // _save_lock is set in init, not constructor + expect(pdm._save_lock).toBeUndefined(); + }); + }); +}); diff --git a/js/widgets/pitchdrummatrix.js b/js/widgets/pitchdrummatrix.js index 48c1d0256f..520e592e69 100644 --- a/js/widgets/pitchdrummatrix.js +++ b/js/widgets/pitchdrummatrix.js @@ -160,28 +160,22 @@ class PitchDrumMatrix { * @private */ this._save_lock = false; - widgetWindow.addButton( - "export-chunk.svg", - PitchDrumMatrix.ICONSIZE, - _("Save") - ).onclick = () => { - // Debounce button - if (!this._get_save_lock()) { - this._save_lock = true; - this._save(); - setTimeout(() => { - this._save_lock = false; - }, 1000); - } - }; + widgetWindow.addButton("export-chunk.svg", PitchDrumMatrix.ICONSIZE, _("Save")).onclick = + () => { + // Debounce button + if (!this._get_save_lock()) { + this._save_lock = true; + this._save(); + setTimeout(() => { + this._save_lock = false; + }, 1000); + } + }; - widgetWindow.addButton( - "erase-button.svg", - PitchDrumMatrix.ICONSIZE, - _("Clear") - ).onclick = () => { - this._clear(); - }; + widgetWindow.addButton("erase-button.svg", PitchDrumMatrix.ICONSIZE, _("Clear")).onclick = + () => { + this._clear(); + }; /** * The container for the pitch/drum matrix. @@ -1068,3 +1062,6 @@ class PitchDrumMatrix { this.activity.blocks.loadNewBlocks(newStack); } } +if (typeof module !== "undefined") { + module.exports = PitchDrumMatrix; +} From a8105a0cf196b33335e52d4ea8f6b8ae69fe591c Mon Sep 17 00:00:00 2001 From: kh-ub-ayb Date: Fri, 13 Feb 2026 21:27:06 +0530 Subject: [PATCH 027/163] test: add extended unit tests for Trash class --- js/__tests__/trash.test.js | 242 +++++++++++++++++++++++++++++++++++++ 1 file changed, 242 insertions(+) diff --git a/js/__tests__/trash.test.js b/js/__tests__/trash.test.js index 3f283deb53..2b5bf6e20f 100644 --- a/js/__tests__/trash.test.js +++ b/js/__tests__/trash.test.js @@ -160,3 +160,245 @@ describe("Trashcan Class", () => { expect(trashcan.overTrashcan(300, 300)).toBe(false); }); }); + +describe("overTrashcan edge cases", () => { + let trashcan; + + beforeEach(() => { + jest.clearAllMocks(); + trashcan = new Trashcan(mockActivity); + trashcan._container.x = 100; + trashcan._container.y = 200; + }); + + it("should return true for a point exactly at the top-left corner", () => { + expect(trashcan.overTrashcan(100, 200)).toBe(true); + }); + + it("should return true for a point exactly at the top-right corner", () => { + // TRASHWIDTH is 120, so top-right is (100 + 120, 200) + expect(trashcan.overTrashcan(220, 200)).toBe(true); + }); + + it("should return false for a point just left of the left edge", () => { + expect(trashcan.overTrashcan(99, 200)).toBe(false); + }); + + it("should return false for a point just above the top edge", () => { + expect(trashcan.overTrashcan(150, 199)).toBe(false); + }); + + it("should return false for a point just right of the right edge", () => { + // x > tx + TRASHWIDTH (100 + 120 = 220), so 221 is out + expect(trashcan.overTrashcan(221, 200)).toBe(false); + }); + + it("should return true for a point far below the trashcan (no lower y bound)", () => { + // overTrashcan has no lower y bound check + expect(trashcan.overTrashcan(150, 10000)).toBe(true); + }); + + it("should return true for a point exactly on the left edge", () => { + expect(trashcan.overTrashcan(100, 250)).toBe(true); + }); + + it("should return true for a point exactly on the right edge", () => { + expect(trashcan.overTrashcan(220, 250)).toBe(true); + }); + + it("should return false for negative x coordinates", () => { + expect(trashcan.overTrashcan(-50, 250)).toBe(false); + }); + + it("should return false for negative y coordinates", () => { + expect(trashcan.overTrashcan(150, -50)).toBe(false); + }); + + it("should return true for the center of the trashcan area", () => { + // center x = 100 + 60 = 160, y = 200 + 60 = 260 + expect(trashcan.overTrashcan(160, 260)).toBe(true); + }); + + it("should return false when x is at left boundary but y is above", () => { + expect(trashcan.overTrashcan(100, 199)).toBe(false); + }); +}); + +describe("shouldResize edge cases", () => { + let trashcan; + + beforeEach(() => { + jest.clearAllMocks(); + trashcan = new Trashcan(mockActivity); + }); + + it("should return false when both dimensions match container position", () => { + trashcan._container.x = 500; + trashcan._container.y = 400; + expect(trashcan.shouldResize(500, 400)).toBe(false); + }); + + it("should return true when only x differs", () => { + trashcan._container.x = 500; + trashcan._container.y = 400; + expect(trashcan.shouldResize(600, 400)).toBe(true); + }); + + it("should return true when only y differs", () => { + trashcan._container.x = 500; + trashcan._container.y = 400; + expect(trashcan.shouldResize(500, 300)).toBe(true); + }); + + it("should return true when both dimensions differ", () => { + trashcan._container.x = 500; + trashcan._container.y = 400; + expect(trashcan.shouldResize(600, 300)).toBe(true); + }); + + it("should return false with zero positions matching", () => { + trashcan._container.x = 0; + trashcan._container.y = 0; + expect(trashcan.shouldResize(0, 0)).toBe(false); + }); + + it("should return true with zero vs non-zero", () => { + trashcan._container.x = 0; + trashcan._container.y = 0; + expect(trashcan.shouldResize(100, 0)).toBe(true); + }); +}); + +describe("stopHighlightAnimation", () => { + let trashcan; + + beforeEach(() => { + jest.clearAllMocks(); + trashcan = new Trashcan(mockActivity); + }); + + it("should do nothing if not in animation", () => { + trashcan._inAnimation = false; + const clearSpy = jest.spyOn(global, "clearInterval"); + + trashcan.stopHighlightAnimation(); + + expect(clearSpy).not.toHaveBeenCalled(); + clearSpy.mockRestore(); + }); + + it("should clear interval and reset state when in animation", () => { + trashcan._inAnimation = true; + trashcan._animationInterval = 42; + trashcan._animationLevel = 100; + trashcan._highlightPower = 128; + trashcan.isVisible = true; + const clearSpy = jest.spyOn(global, "clearInterval"); + + trashcan.stopHighlightAnimation(); + + expect(clearSpy).toHaveBeenCalledWith(42); + expect(trashcan._inAnimation).toBe(false); + expect(trashcan.isVisible).toBe(false); + expect(trashcan._animationLevel).toBe(0); + expect(trashcan._highlightPower).toBe(255); + clearSpy.mockRestore(); + }); + + it("should be safe to call multiple times", () => { + trashcan._inAnimation = true; + trashcan._animationInterval = 42; + + trashcan.stopHighlightAnimation(); + expect(trashcan._inAnimation).toBe(false); + + // Second call should be a no-op since _inAnimation is now false + trashcan.stopHighlightAnimation(); + expect(trashcan._inAnimation).toBe(false); + }); + + it("should reset animation level and highlight power to defaults", () => { + trashcan._inAnimation = true; + trashcan._animationLevel = 500; + trashcan._highlightPower = 0; + + trashcan.stopHighlightAnimation(); + + expect(trashcan._animationLevel).toBe(0); + expect(trashcan._highlightPower).toBe(255); + }); +}); + +describe("scale and container positioning", () => { + let trashcan; + + beforeEach(() => { + jest.clearAllMocks(); + trashcan = new Trashcan(mockActivity); + }); + + it("should have default scale of 1", () => { + expect(trashcan._scale).toBe(1); + }); + + it("should update scale via resizeEvent", () => { + trashcan.resizeEvent(2); + expect(trashcan._scale).toBe(2); + }); + + it("should update container position based on scale", () => { + // window.innerWidth = 1024, window.innerHeight = 768 (jsdom defaults) + trashcan._scale = 1; + trashcan.updateContainerPosition(); + + const expectedX = + window.innerWidth / trashcan._scale - Trashcan.TRASHWIDTH - 2 * trashcan._iconsize; + const expectedY = + window.innerHeight / trashcan._scale - + Trashcan.TRASHHEIGHT - + (5 / 4) * trashcan._iconsize; + + expect(trashcan._container.x).toBe(expectedX); + expect(trashcan._container.y).toBe(expectedY); + }); + + it("should compute different positions at different scales", () => { + trashcan._scale = 1; + trashcan.updateContainerPosition(); + const x1 = trashcan._container.x; + const y1 = trashcan._container.y; + + trashcan._scale = 2; + trashcan.updateContainerPosition(); + const x2 = trashcan._container.x; + const y2 = trashcan._container.y; + + // At scale 2, window dimensions are halved, so positions should be different + expect(x2).not.toBe(x1); + expect(y2).not.toBe(y1); + }); + + it("should have static TRASHWIDTH and TRASHHEIGHT constants", () => { + expect(Trashcan.TRASHWIDTH).toBe(120); + expect(Trashcan.TRASHHEIGHT).toBe(120); + }); + + it("should set iconsize based on trash bitmap bounds", () => { + // _makeTrash sets _iconsize from bitmap getBounds().width (mocked as 100) + expect(trashcan._iconsize).toBe(100); + }); + + it("should initialize _borderHighlightBitmap during construction", () => { + // resizeEvent(1) in constructor triggers _makeBorderHighlight + expect(trashcan._borderHighlightBitmap).not.toBeNull(); + }); + + it("should have _isHighlightInitialized set after construction", () => { + // resizeEvent(1) in constructor triggers _makeBorderHighlight which sets this + expect(trashcan._isHighlightInitialized).toBe(true); + }); + + it("should initialize animationTime as 500", () => { + expect(trashcan.animationTime).toBe(500); + }); +}); From 3230405f65e070ca15c8a962c66c4ab0b12ec35d Mon Sep 17 00:00:00 2001 From: Lakshay Date: Fri, 13 Feb 2026 22:09:13 +0530 Subject: [PATCH 028/163] pitchstircase widget tests added --- js/widgets/__tests__/pitchstaircase.test.js | 198 ++++++++++++++++++++ js/widgets/pitchstaircase.js | 3 + 2 files changed, 201 insertions(+) create mode 100644 js/widgets/__tests__/pitchstaircase.test.js diff --git a/js/widgets/__tests__/pitchstaircase.test.js b/js/widgets/__tests__/pitchstaircase.test.js new file mode 100644 index 0000000000..aa87f4aec2 --- /dev/null +++ b/js/widgets/__tests__/pitchstaircase.test.js @@ -0,0 +1,198 @@ +/** + * MusicBlocks v3.6.2 + * + * @author Lakshay + * + * @copyright 2026 Lakshay + * + * @license + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const PitchStaircase = require("../pitchstaircase.js"); + +// --- Global Mocks --- +global._ = msg => msg; +global.platformColor = { + labelColor: "#90c100", + selectorBackground: "#f0f0f0", + selectorBackgroundHOVER: "#e0e0e0" +}; +global.SYNTHSVG = "SVGWIDTH XSCALE STOKEWIDTH"; +global.DEFAULTVOICE = "electronic synth"; +global.frequencyToPitch = jest.fn(f => ["A", "", 4]); +global.base64Encode = jest.fn(s => s); + +global.window = { + innerWidth: 1200, + btoa: jest.fn(s => s), + widgetWindows: { + windowFor: jest.fn().mockReturnValue({ + clear: jest.fn(), + show: jest.fn(), + addButton: jest.fn().mockReturnValue({ onclick: null }), + addInputButton: jest.fn().mockReturnValue({ + value: "3", + addEventListener: jest.fn() + }), + getWidgetBody: jest.fn().mockReturnValue({ + appendChild: jest.fn(), + append: jest.fn(), + style: {} + }), + sendToCenter: jest.fn(), + onclose: null, + onmaximize: null, + destroy: jest.fn() + }) + } +}; + +global.document = { + createElement: jest.fn(() => ({ + style: {}, + innerHTML: "", + appendChild: jest.fn(), + append: jest.fn(), + setAttribute: jest.fn(), + addEventListener: jest.fn(), + insertRow: jest.fn(() => ({ + insertCell: jest.fn(() => ({ + style: {}, + innerHTML: "", + appendChild: jest.fn(), + setAttribute: jest.fn(), + addEventListener: jest.fn(), + className: "" + })) + })) + })) +}; + +describe("PitchStaircase Widget", () => { + let psc; + + beforeEach(() => { + psc = new PitchStaircase(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + // --- Constructor Tests --- + describe("constructor", () => { + test("should initialize with empty Stairs", () => { + expect(psc.Stairs).toEqual([]); + }); + + test("should initialize with empty stairPitchBlocks", () => { + expect(psc.stairPitchBlocks).toEqual([]); + }); + + test("should initialize with empty _stepTables", () => { + expect(psc._stepTables).toEqual([]); + }); + + test("should initialize _musicRatio1 as null", () => { + expect(psc._musicRatio1).toBeNull(); + }); + + test("should initialize _musicRatio2 as null", () => { + expect(psc._musicRatio2).toBeNull(); + }); + }); + + // --- Static Constants Tests --- + describe("static constants", () => { + test("should have correct BUTTONDIVWIDTH", () => { + expect(PitchStaircase.BUTTONDIVWIDTH).toBe(476); + }); + + test("should have correct OUTERWINDOWWIDTH", () => { + expect(PitchStaircase.OUTERWINDOWWIDTH).toBe(685); + }); + + test("should have correct INNERWINDOWWIDTH", () => { + expect(PitchStaircase.INNERWINDOWWIDTH).toBe(600); + }); + + test("should have correct BUTTONSIZE", () => { + expect(PitchStaircase.BUTTONSIZE).toBe(53); + }); + + test("should have correct ICONSIZE", () => { + expect(PitchStaircase.ICONSIZE).toBe(32); + }); + + test("should have correct DEFAULTFREQUENCY", () => { + expect(PitchStaircase.DEFAULTFREQUENCY).toBe(220.0); + }); + }); + + // --- Stairs Data Tests --- + describe("stairs data management", () => { + test("should allow adding stairs", () => { + psc.Stairs.push(["A", "", 220.0]); + psc.Stairs.push(["B", "", 246.94]); + psc.Stairs.push(["C", "", 261.63]); + expect(psc.Stairs).toHaveLength(3); + }); + + test("should allow accessing stair properties", () => { + psc.Stairs.push(["A", "", 220.0]); + expect(psc.Stairs[0][0]).toBe("A"); + expect(psc.Stairs[0][1]).toBe(""); + expect(psc.Stairs[0][2]).toBe(220.0); + }); + + test("should allow removing stairs", () => { + psc.Stairs.push(["A", "", 220.0]); + psc.Stairs.push(["B", "", 246.94]); + psc.Stairs.splice(0, 1); + expect(psc.Stairs).toHaveLength(1); + expect(psc.Stairs[0][0]).toBe("B"); + }); + + test("should track stairPitchBlocks", () => { + psc.stairPitchBlocks.push(10); + psc.stairPitchBlocks.push(20); + expect(psc.stairPitchBlocks).toEqual([10, 20]); + }); + + test("should allow clearing stairs", () => { + psc.Stairs.push(["A", "", 220.0]); + psc.Stairs = []; + expect(psc.Stairs).toHaveLength(0); + }); + }); + + // --- Frequency Tests --- + describe("frequency handling", () => { + test("should store frequencies in stairs", () => { + psc.Stairs.push(["A", "", 220.0]); + psc.Stairs.push(["A", "", 440.0]); + expect(psc.Stairs[0][2]).toBe(220.0); + expect(psc.Stairs[1][2]).toBe(440.0); + }); + + test("should maintain frequency order when sorted", () => { + psc.Stairs.push(["C", "", 261.63]); + psc.Stairs.push(["A", "", 220.0]); + psc.Stairs.sort((a, b) => a[2] - b[2]); + expect(psc.Stairs[0][2]).toBe(220.0); + expect(psc.Stairs[1][2]).toBe(261.63); + }); + }); +}); diff --git a/js/widgets/pitchstaircase.js b/js/widgets/pitchstaircase.js index a8642b61c8..68d66b0faf 100644 --- a/js/widgets/pitchstaircase.js +++ b/js/widgets/pitchstaircase.js @@ -683,3 +683,6 @@ class PitchStaircase { this._makeStairs(true); } } +if (typeof module !== "undefined") { + module.exports = PitchStaircase; +} From 0ff617f979668a5c82a1fd13d3fefcd165370b10 Mon Sep 17 00:00:00 2001 From: kh-ub-ayb Date: Fri, 13 Feb 2026 21:35:54 +0530 Subject: [PATCH 029/163] test: add extended unit tests for turtle-painter --- js/__tests__/turtle-painter.test.js | 491 +++++++++++++++++++++++++++- 1 file changed, 484 insertions(+), 7 deletions(-) diff --git a/js/__tests__/turtle-painter.test.js b/js/__tests__/turtle-painter.test.js index 44d10f4573..79db68b2b7 100644 --- a/js/__tests__/turtle-painter.test.js +++ b/js/__tests__/turtle-painter.test.js @@ -19,12 +19,18 @@ const Painter = require("../turtle-painter"); global.WRAP = true; -const mockTurtle = { + +// Mock external color functions +global.getcolor = jest.fn(() => [50, 100, "rgba(255,0,49,1)"]); +global.getMunsellColor = jest.fn(() => "rgba(128,64,32,1)"); +global.hex2rgb = jest.fn(hex => "rgba(255,0,49,1)"); + +const createMockTurtle = () => ({ turtles: { - screenX2turtleX: jest.fn(), - screenY2turtleY: jest.fn(), - turtleX2screenX: jest.fn(), - turtleY2screenY: jest.fn(), + screenX2turtleX: jest.fn(x => x), + screenY2turtleY: jest.fn(y => y), + turtleX2screenX: jest.fn(x => x), + turtleY2screenY: jest.fn(y => y), scale: 1 }, activity: { refreshCanvas: jest.fn() }, @@ -37,24 +43,32 @@ const mockTurtle = { moveTo: jest.fn(), lineTo: jest.fn(), arc: jest.fn(), - canvas: { width: 800, height: 600 } + fill: jest.fn(), + canvas: { width: 800, height: 600 }, + strokeStyle: "", + fillStyle: "", + lineWidth: 1, + lineCap: "" }, penstrokes: { image: null }, orientation: 0, updateCache: jest.fn(), blinking: jest.fn().mockReturnValue(false) -}; +}); describe("Painter Class", () => { let painter; + let mockTurtle; beforeEach(() => { jest.spyOn(window, "requestAnimationFrame").mockImplementation(cb => cb()); + mockTurtle = createMockTurtle(); painter = new Painter(mockTurtle); }); afterEach(() => { window.requestAnimationFrame.mockRestore(); + jest.clearAllMocks(); }); describe("Constructor", () => { @@ -63,6 +77,35 @@ describe("Painter Class", () => { expect(painter._stroke).toBe(5); expect(painter._penDown).toBe(true); }); + + test("should initialize value to DEFAULTVALUE (50)", () => { + expect(painter._value).toBe(50); + }); + + test("should initialize chroma to DEFAULTCHROMA (100)", () => { + expect(painter._chroma).toBe(100); + }); + + test("should initialize font to sans-serif", () => { + expect(painter._font).toBe("sans-serif"); + }); + + test("should initialize canvas color and alpha", () => { + expect(painter._canvasColor).toBe("rgba(255,0,49,1)"); + expect(painter._canvasAlpha).toBe(1.0); + }); + + test("should initialize fill and hollow states to false", () => { + expect(painter._fillState).toBe(false); + expect(painter._hollowState).toBe(false); + }); + + test("should initialize bezier control points", () => { + expect(painter.cp1x).toBe(0); + expect(painter.cp1y).toBe(100); + expect(painter.cp2x).toBe(100); + expect(painter.cp2y).toBe(100); + }); }); describe("Setters and Getters", () => { @@ -75,6 +118,42 @@ describe("Painter Class", () => { painter.stroke = 8; expect(painter.stroke).toBe(8); }); + + test("should set and get value", () => { + painter.value = 75; + expect(painter.value).toBe(75); + }); + + test("should set and get chroma", () => { + painter.chroma = 50; + expect(painter.chroma).toBe(50); + }); + + test("should get font", () => { + expect(painter.font).toBe("sans-serif"); + }); + + test("should set and get canvasColor", () => { + painter.canvasColor = "rgba(0,255,0,1)"; + expect(painter.canvasColor).toBe("rgba(0,255,0,1)"); + }); + + test("should set and get canvasAlpha", () => { + painter.canvasAlpha = 0.5; + expect(painter.canvasAlpha).toBe(0.5); + }); + + test("should set and get penState", () => { + painter.penState = false; + expect(painter.penState).toBe(false); + painter.penState = true; + expect(painter.penState).toBe(true); + }); + + test("should set and get svgOutput", () => { + painter.svgOutput = "test"; + expect(painter.svgOutput).toBe("test"); + }); }); describe("Actions", () => { @@ -89,3 +168,401 @@ describe("Painter Class", () => { }); }); }); + +describe("Pen operations", () => { + let painter; + let mockTurtle; + + beforeEach(() => { + jest.spyOn(window, "requestAnimationFrame").mockImplementation(cb => cb()); + mockTurtle = createMockTurtle(); + painter = new Painter(mockTurtle); + }); + + afterEach(() => { + window.requestAnimationFrame.mockRestore(); + jest.clearAllMocks(); + }); + + test("doSetPensize should update stroke and lineWidth", () => { + painter.doSetPensize(10); + expect(painter.stroke).toBe(10); + expect(mockTurtle.ctx.lineWidth).toBe(10); + }); + + test("doSetPensize should handle zero", () => { + painter.doSetPensize(0); + expect(painter.stroke).toBe(0); + expect(mockTurtle.ctx.lineWidth).toBe(0); + }); + + test("doSetPensize should handle large values", () => { + painter.doSetPensize(100); + expect(painter.stroke).toBe(100); + expect(mockTurtle.ctx.lineWidth).toBe(100); + }); + + test("doSetColor should set color and call getcolor", () => { + painter.doSetColor(50); + expect(painter.color).toBe(50); + expect(getcolor).toHaveBeenCalledWith(50); + }); + + test("doSetColor should update value, chroma, and canvas color from getcolor results", () => { + getcolor.mockReturnValue([75, 80, "rgba(100,200,50,1)"]); + painter.doSetColor(30); + expect(painter.value).toBe(75); + expect(painter.chroma).toBe(80); + }); + + test("doSetChroma should update chroma and call getMunsellColor", () => { + painter.doSetChroma(75); + expect(painter.chroma).toBe(75); + expect(getMunsellColor).toHaveBeenCalledWith(painter.color, painter.value, 75); + }); + + test("doSetValue should update value and call getMunsellColor", () => { + painter.doSetValue(30); + expect(painter.value).toBe(30); + expect(getMunsellColor).toHaveBeenCalledWith(painter.color, 30, painter.chroma); + }); + + test("doSetHue should update color and call getMunsellColor", () => { + painter.doSetHue(120); + expect(painter.color).toBe(120); + expect(getMunsellColor).toHaveBeenCalledWith(120, painter.value, painter.chroma); + }); + + test("doSetPenAlpha should update canvas alpha", () => { + painter.doSetPenAlpha(0.5); + expect(painter.canvasAlpha).toBe(0.5); + }); + + test("doSetPenAlpha should handle zero transparency", () => { + painter.doSetPenAlpha(0); + expect(painter.canvasAlpha).toBe(0); + }); + + test("doSetPenAlpha should handle full opacity", () => { + painter.doSetPenAlpha(1); + expect(painter.canvasAlpha).toBe(1); + }); + + test("doPenUp should set penDown to false", () => { + painter.doPenUp(); + expect(painter._penDown).toBe(false); + expect(painter.penState).toBe(false); + }); + + test("doPenDown should set penDown to true", () => { + painter.doPenUp(); + painter.doPenDown(); + expect(painter._penDown).toBe(true); + expect(painter.penState).toBe(true); + }); + + test("pen toggle should work repeatedly", () => { + painter.doPenUp(); + expect(painter.penState).toBe(false); + painter.doPenDown(); + expect(painter.penState).toBe(true); + painter.doPenUp(); + expect(painter.penState).toBe(false); + }); +}); + +describe("Drawing - doForward", () => { + let painter; + let mockTurtle; + + beforeEach(() => { + jest.spyOn(window, "requestAnimationFrame").mockImplementation(cb => cb()); + mockTurtle = createMockTurtle(); + painter = new Painter(mockTurtle); + }); + + afterEach(() => { + window.requestAnimationFrame.mockRestore(); + jest.clearAllMocks(); + }); + + test("should call beginPath and moveTo when not in fill state", () => { + painter.doForward(10); + expect(mockTurtle.ctx.beginPath).toHaveBeenCalled(); + expect(mockTurtle.ctx.moveTo).toHaveBeenCalled(); + }); + + test("should not call beginPath when in fill state", () => { + painter.doStartFill(); + mockTurtle.ctx.beginPath.mockClear(); + painter.doForward(10); + // beginPath is called inside doForward only if !fillState + // Since fillState is true, beginPath should not be called again from doForward + // (it was called once in doStartFill) + }); + + test("should set lineWidth and lineCap when pen is down and not filling", () => { + painter.doSetPensize(8); + painter.doForward(10); + expect(mockTurtle.ctx.lineWidth).toBe(8); + expect(mockTurtle.ctx.lineCap).toBe("round"); + }); + + test("forward with zero steps should still call refresh", () => { + painter.doForward(0); + expect(mockTurtle.activity.refreshCanvas).toHaveBeenCalled(); + }); + + test("forward with negative steps should work", () => { + painter.doForward(-10); + expect(mockTurtle.activity.refreshCanvas).toHaveBeenCalled(); + }); + + test("pen up should still move turtle position", () => { + painter.doPenUp(); + painter.doForward(10); + // Should still process the movement (refreshCanvas called) + expect(mockTurtle.activity.refreshCanvas).toHaveBeenCalled(); + }); +}); + +describe("Drawing - doArc", () => { + let painter; + let mockTurtle; + + beforeEach(() => { + jest.spyOn(window, "requestAnimationFrame").mockImplementation(cb => cb()); + mockTurtle = createMockTurtle(); + painter = new Painter(mockTurtle); + }); + + afterEach(() => { + window.requestAnimationFrame.mockRestore(); + jest.clearAllMocks(); + }); + + test("should call _doArcPart for a 90-degree arc", () => { + const arcSpy = jest.spyOn(painter, "_doArcPart"); + painter.doArc(90, 100); + expect(arcSpy).toHaveBeenCalledWith(90, 100); + expect(arcSpy).toHaveBeenCalledTimes(1); + }); + + test("should break 180-degree arc into two 90-degree parts", () => { + const arcSpy = jest.spyOn(painter, "_doArcPart"); + painter.doArc(180, 100); + expect(arcSpy).toHaveBeenCalledTimes(2); + expect(arcSpy).toHaveBeenCalledWith(90, 100); + }); + + test("should break 360-degree arc into four 90-degree parts", () => { + const arcSpy = jest.spyOn(painter, "_doArcPart"); + painter.doArc(360, 100); + expect(arcSpy).toHaveBeenCalledTimes(4); + }); + + test("should handle arc with remainder (e.g., 135 = 90 + 45)", () => { + const arcSpy = jest.spyOn(painter, "_doArcPart"); + painter.doArc(135, 100); + expect(arcSpy).toHaveBeenCalledTimes(2); + expect(arcSpy).toHaveBeenNthCalledWith(1, 90, 100); + expect(arcSpy).toHaveBeenNthCalledWith(2, 45, 100); + }); + + test("should handle negative angle by using factor -1", () => { + const arcSpy = jest.spyOn(painter, "_doArcPart"); + painter.doArc(-90, 100); + expect(arcSpy).toHaveBeenCalledWith(-90, 100); + }); + + test("should convert negative radius to positive", () => { + const arcSpy = jest.spyOn(painter, "_doArcPart"); + painter.doArc(90, -50); + expect(arcSpy).toHaveBeenCalledWith(90, 50); + }); + + test("should handle small angle (less than 90)", () => { + const arcSpy = jest.spyOn(painter, "_doArcPart"); + painter.doArc(45, 100); + expect(arcSpy).toHaveBeenCalledTimes(1); + expect(arcSpy).toHaveBeenCalledWith(45, 100); + }); + + test("should handle zero angle", () => { + const arcSpy = jest.spyOn(painter, "_doArcPart"); + painter.doArc(0, 100); + expect(arcSpy).not.toHaveBeenCalled(); + }); +}); + +describe("Heading and orientation", () => { + let painter; + let mockTurtle; + + beforeEach(() => { + jest.spyOn(window, "requestAnimationFrame").mockImplementation(cb => cb()); + mockTurtle = createMockTurtle(); + painter = new Painter(mockTurtle); + }); + + afterEach(() => { + window.requestAnimationFrame.mockRestore(); + jest.clearAllMocks(); + }); + + test("doSetHeading should set orientation to given degrees", () => { + painter.doSetHeading(45); + expect(mockTurtle.orientation).toBe(45); + }); + + test("doSetHeading should normalize negative degrees", () => { + painter.doSetHeading(-90); + expect(mockTurtle.orientation).toBe(270); + }); + + test("doSetHeading should normalize degrees above 360", () => { + painter.doSetHeading(450); + expect(mockTurtle.orientation).toBe(90); + }); + + test("doSetHeading should set container rotation", () => { + painter.doSetHeading(180); + expect(mockTurtle.container.rotation).toBe(180); + }); + + test("doSetHeading should update cache when not blinking", () => { + painter.doSetHeading(90); + expect(mockTurtle.updateCache).toHaveBeenCalled(); + }); + + test("doSetHeading should not update cache when blinking", () => { + mockTurtle.blinking.mockReturnValue(true); + painter.doSetHeading(90); + expect(mockTurtle.updateCache).not.toHaveBeenCalled(); + }); + + test("doRight should add degrees to current orientation", () => { + mockTurtle.orientation = 45; + painter.doRight(90); + expect(mockTurtle.orientation).toBe(135); + }); + + test("doRight should handle negative degrees (turn left)", () => { + mockTurtle.orientation = 45; + painter.doRight(-90); + expect(mockTurtle.orientation).toBe(315); + }); + + test("doRight should wrap around 360", () => { + mockTurtle.orientation = 350; + painter.doRight(20); + expect(mockTurtle.orientation).toBe(10); + }); + + test("doRight with 0 degrees should not change orientation", () => { + mockTurtle.orientation = 90; + painter.doRight(0); + expect(mockTurtle.orientation).toBe(90); + }); + + test("doRight with full rotation should return to same orientation", () => { + mockTurtle.orientation = 45; + painter.doRight(360); + expect(mockTurtle.orientation).toBe(45); + }); +}); + +describe("Fill and hollow state", () => { + let painter; + let mockTurtle; + + beforeEach(() => { + jest.spyOn(window, "requestAnimationFrame").mockImplementation(cb => cb()); + mockTurtle = createMockTurtle(); + painter = new Painter(mockTurtle); + }); + + afterEach(() => { + window.requestAnimationFrame.mockRestore(); + jest.clearAllMocks(); + }); + + test("doStartFill should set fillState to true and call beginPath", () => { + painter.doStartFill(); + expect(painter._fillState).toBe(true); + expect(mockTurtle.ctx.beginPath).toHaveBeenCalled(); + }); + + test("doEndFill should set fillState to false and call fill/closePath", () => { + painter.doStartFill(); + painter.doEndFill(); + expect(painter._fillState).toBe(false); + expect(mockTurtle.ctx.fill).toHaveBeenCalled(); + expect(mockTurtle.ctx.closePath).toHaveBeenCalled(); + }); + + test("doStartHollowLine should set hollowState to true", () => { + painter.doStartHollowLine(); + expect(painter._hollowState).toBe(true); + }); + + test("doEndHollowLine should set hollowState to false", () => { + painter.doStartHollowLine(); + painter.doEndHollowLine(); + expect(painter._hollowState).toBe(false); + }); +}); + +describe("Bezier control points", () => { + let painter; + let mockTurtle; + + beforeEach(() => { + jest.spyOn(window, "requestAnimationFrame").mockImplementation(cb => cb()); + mockTurtle = createMockTurtle(); + painter = new Painter(mockTurtle); + }); + + afterEach(() => { + window.requestAnimationFrame.mockRestore(); + jest.clearAllMocks(); + }); + + test("setControlPoint1 should set cp1x and cp1y", () => { + painter.setControlPoint1([50, 75]); + expect(painter.cp1x).toBe(50); + expect(painter.cp1y).toBe(75); + }); + + test("setControlPoint2 should set cp2x and cp2y", () => { + painter.setControlPoint2([200, 150]); + expect(painter.cp2x).toBe(200); + expect(painter.cp2y).toBe(150); + }); +}); + +describe("Font setting", () => { + let painter; + let mockTurtle; + + beforeEach(() => { + jest.spyOn(window, "requestAnimationFrame").mockImplementation(cb => cb()); + mockTurtle = createMockTurtle(); + painter = new Painter(mockTurtle); + }); + + afterEach(() => { + window.requestAnimationFrame.mockRestore(); + jest.clearAllMocks(); + }); + + test("doSetFont should update font", () => { + painter.doSetFont("monospace"); + expect(painter._font).toBe("monospace"); + }); + + test("font getter should return current font", () => { + painter.doSetFont("serif"); + expect(painter.font).toBe("serif"); + }); +}); From b57b7adc522eb7cc70eed78f9ff53bdc51f1e0ed Mon Sep 17 00:00:00 2001 From: Lakshay Date: Fri, 13 Feb 2026 22:50:47 +0530 Subject: [PATCH 030/163] added the unit test for phasemaker --- js/widgets/__tests__/phrasemaker.test.js | 369 +++++++++++++++++++++++ js/widgets/phrasemaker.js | 3 + 2 files changed, 372 insertions(+) create mode 100644 js/widgets/__tests__/phrasemaker.test.js diff --git a/js/widgets/__tests__/phrasemaker.test.js b/js/widgets/__tests__/phrasemaker.test.js new file mode 100644 index 0000000000..10e312f4f0 --- /dev/null +++ b/js/widgets/__tests__/phrasemaker.test.js @@ -0,0 +1,369 @@ +/** + * MusicBlocks v3.6.2 + * + * @author Lakshay + * + * @copyright 2026 Lakshay + * + * @license + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const PhraseMaker = require("../phrasemaker.js"); + +// --- Global Mocks --- +global._ = msg => msg; +global.last = arr => arr[arr.length - 1]; +global.LCD = (a, b) => (a * b) / gcd(a, b); +function gcd(a, b) { + return b === 0 ? a : gcd(b, a % b); +} +global.DEFAULTVOICE = "electronic synth"; +global.DEFAULTDRUM = "kick drum"; +global.DEFAULTVOLUME = 50; +global.PREVIEWVOLUME = 50; +global.SHARP = "♯"; +global.FLAT = "♭"; +global.MATRIXSOLFEHEIGHT = 30; +global.MATRIXSOLFEWIDTH = 80; +global.EIGHTHNOTEWIDTH = 24; +global.DRUMS = []; +global.NOTESYMBOLS = {}; +global.SOLFEGECONVERSIONTABLE = {}; +global.platformColor = { + labelColor: "#90c100", + selectorBackground: "#f0f0f0", + selectorBackgroundHOVER: "#e0e0e0", + paletteColors: {} +}; + +global.toFraction = jest.fn(n => [1, n]); +global.getDrumName = jest.fn(() => null); +global.getDrumIcon = jest.fn(() => ""); +global.getDrumSynthName = jest.fn(() => "kick"); +global.noteIsSolfege = jest.fn(() => false); +global.isCustomTemperament = jest.fn(() => false); +global.i18nSolfege = jest.fn(s => s); +global.getNote = jest.fn(() => ["C", "", 4]); +global.noteToFrequency = jest.fn(() => 440); +global.calcNoteValueToDisplay = jest.fn(() => ["1/4", "♩"]); +global.delayExecution = jest.fn(ms => new Promise(r => setTimeout(r, ms))); +global.getTemperament = jest.fn(() => ({ pitchNumber: 12 })); +global.docBySelector = jest.fn(() => []); +global.Singer = { RhythmActions: { getNoteValue: jest.fn(() => 0.25) } }; + +global.docById = jest.fn(() => ({ + style: {}, + innerHTML: "", + insertRow: jest.fn(() => ({ + insertCell: jest.fn(() => ({ + style: {}, + appendChild: jest.fn(), + setAttribute: jest.fn(), + addEventListener: jest.fn(), + innerHTML: "" + })), + style: {}, + setAttribute: jest.fn() + })), + appendChild: jest.fn(), + querySelectorAll: jest.fn(() => []), + setAttribute: jest.fn(), + addEventListener: jest.fn(), + getBoundingClientRect: jest.fn(() => ({ width: 800, height: 600 })) +})); + +global.window = { + innerWidth: 1200, + innerHeight: 800, + btoa: jest.fn(s => s), + widgetWindows: { + windowFor: jest.fn().mockReturnValue({ + clear: jest.fn(), + show: jest.fn(), + addButton: jest.fn().mockReturnValue({ + onclick: null, + innerHTML: "", + style: {} + }), + addInputButton: jest.fn().mockReturnValue({ + value: "", + addEventListener: jest.fn() + }), + getWidgetBody: jest.fn().mockReturnValue({ + appendChild: jest.fn(), + append: jest.fn(), + style: {}, + insertRow: jest.fn(() => ({ + insertCell: jest.fn(() => ({ + appendChild: jest.fn(), + setAttribute: jest.fn(), + style: {}, + innerHTML: "" + })) + })) + }), + getWidgetFrame: jest.fn().mockReturnValue({ + getBoundingClientRect: jest.fn(() => ({ width: 800, height: 600 })) + }), + sendToCenter: jest.fn(), + updateTitle: jest.fn(), + onclose: null, + onmaximize: null, + destroy: jest.fn() + }) + } +}; + +global.document = { + createElement: jest.fn(() => ({ + style: {}, + innerHTML: "", + appendChild: jest.fn(), + append: jest.fn(), + setAttribute: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + insertAdjacentHTML: jest.fn(), + getContext: jest.fn(() => ({ + clearRect: jest.fn(), + beginPath: jest.fn(), + fill: jest.fn(), + closePath: jest.fn() + })), + querySelectorAll: jest.fn(() => []), + insertRow: jest.fn(() => ({ + insertCell: jest.fn(() => ({ style: {}, innerHTML: "" })) + })) + })), + getElementById: jest.fn(() => ({ + style: {}, + innerHTML: "", + querySelectorAll: jest.fn(() => []) + })), + createTextNode: jest.fn(t => t) +}; + +describe("PhraseMaker Widget", () => { + let phraseMaker; + let mockDeps; + + beforeEach(() => { + jest.useFakeTimers(); + + mockDeps = { + platformColor: global.platformColor, + docById: global.docById, + _: global._, + wheelnav: jest.fn(), + slicePath: jest.fn(), + DEFAULTVOICE: "electronic synth" + }; + + phraseMaker = new PhraseMaker(mockDeps); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.clearAllMocks(); + }); + + describe("constructor", () => { + test("should initialize with empty rowLabels", () => { + expect(phraseMaker.rowLabels).toEqual([]); + }); + + test("should initialize with empty rowArgs", () => { + expect(phraseMaker.rowArgs).toEqual([]); + }); + + test("should initialize with isInitial true", () => { + expect(phraseMaker.isInitial).toBe(true); + }); + + test("should initialize with sorted false", () => { + expect(phraseMaker.sorted).toBe(false); + }); + + test("should initialize with empty _notesToPlay", () => { + expect(phraseMaker._notesToPlay).toEqual([]); + }); + + test("should initialize _noteBlocks as false", () => { + expect(phraseMaker._noteBlocks).toBe(false); + }); + + test("should initialize empty arrays for row/col blocks", () => { + expect(phraseMaker._rowBlocks).toEqual([]); + expect(phraseMaker._colBlocks).toEqual([]); + }); + + test("should initialize empty blockMap", () => { + expect(phraseMaker._blockMap).toEqual({}); + }); + + test("should initialize lyricsON as false", () => { + expect(phraseMaker.lyricsON).toBe(false); + }); + + test("should accept deps via constructor", () => { + expect(phraseMaker.platformColor).toBe(global.platformColor); + expect(phraseMaker._).toBe(global._); + }); + + test("should use default instrumentName", () => { + expect(phraseMaker._instrumentName).toBe("electronic synth"); + }); + + test("should initialize paramsEffects with all effects disabled", () => { + expect(phraseMaker.paramsEffects.doVibrato).toBe(false); + expect(phraseMaker.paramsEffects.doDistortion).toBe(false); + expect(phraseMaker.paramsEffects.doTremolo).toBe(false); + expect(phraseMaker.paramsEffects.doPhaser).toBe(false); + expect(phraseMaker.paramsEffects.doChorus).toBe(false); + }); + + test("should initialize with zero effects values", () => { + expect(phraseMaker.paramsEffects.vibratoIntensity).toBe(0); + expect(phraseMaker.paramsEffects.distortionAmount).toBe(0); + expect(phraseMaker.paramsEffects.tremoloFrequency).toBe(0); + }); + }); + + describe("data management", () => { + test("should store row labels when pushed", () => { + phraseMaker.rowLabels.push("sol"); + phraseMaker.rowLabels.push("mi"); + expect(phraseMaker.rowLabels).toEqual(["sol", "mi"]); + }); + + test("should store row args when pushed", () => { + phraseMaker.rowArgs.push(4); + phraseMaker.rowArgs.push(5); + expect(phraseMaker.rowArgs).toEqual([4, 5]); + }); + + test("should track _rowBlocks", () => { + phraseMaker._rowBlocks.push(10); + phraseMaker._rowBlocks.push(20); + expect(phraseMaker._rowBlocks).toHaveLength(2); + }); + + test("should track _colBlocks", () => { + phraseMaker._colBlocks.push([1, 0]); + phraseMaker._colBlocks.push([2, 1]); + expect(phraseMaker._colBlocks).toHaveLength(2); + }); + + test("should store blockMap entries", () => { + phraseMaker._blockMap["0,0"] = true; + phraseMaker._blockMap["1,2"] = true; + expect(Object.keys(phraseMaker._blockMap)).toHaveLength(2); + }); + + test("should track lyrics", () => { + phraseMaker._lyrics.push("do"); + phraseMaker._lyrics.push("re"); + expect(phraseMaker._lyrics).toEqual(["do", "re"]); + }); + + test("should track _notesCounter", () => { + phraseMaker._notesCounter = 5; + expect(phraseMaker._notesCounter).toBe(5); + }); + }); + + describe("state management", () => { + test("should toggle _stopOrCloseClicked", () => { + expect(phraseMaker._stopOrCloseClicked).toBe(false); + phraseMaker._stopOrCloseClicked = true; + expect(phraseMaker._stopOrCloseClicked).toBe(true); + }); + + test("should track sorted state", () => { + expect(phraseMaker.sorted).toBe(false); + phraseMaker.sorted = true; + expect(phraseMaker.sorted).toBe(true); + }); + + test("should update _matrixHasTuplets", () => { + expect(phraseMaker._matrixHasTuplets).toBe(false); + phraseMaker._matrixHasTuplets = true; + expect(phraseMaker._matrixHasTuplets).toBe(true); + }); + }); + + describe("effects parameters", () => { + test("should allow updating vibrato parameters", () => { + phraseMaker.paramsEffects.doVibrato = true; + phraseMaker.paramsEffects.vibratoIntensity = 5; + phraseMaker.paramsEffects.vibratoFrequency = 10; + expect(phraseMaker.paramsEffects.doVibrato).toBe(true); + expect(phraseMaker.paramsEffects.vibratoIntensity).toBe(5); + expect(phraseMaker.paramsEffects.vibratoFrequency).toBe(10); + }); + + test("should allow updating distortion parameters", () => { + phraseMaker.paramsEffects.doDistortion = true; + phraseMaker.paramsEffects.distortionAmount = 40; + expect(phraseMaker.paramsEffects.doDistortion).toBe(true); + expect(phraseMaker.paramsEffects.distortionAmount).toBe(40); + }); + + test("should allow updating tremolo parameters", () => { + phraseMaker.paramsEffects.doTremolo = true; + phraseMaker.paramsEffects.tremoloFrequency = 5; + phraseMaker.paramsEffects.tremoloDepth = 50; + expect(phraseMaker.paramsEffects.doTremolo).toBe(true); + expect(phraseMaker.paramsEffects.tremoloDepth).toBe(50); + }); + + test("should allow updating chorus parameters", () => { + phraseMaker.paramsEffects.doChorus = true; + phraseMaker.paramsEffects.chorusRate = 0.5; + phraseMaker.paramsEffects.delayTime = 3.5; + phraseMaker.paramsEffects.chorusDepth = 70; + expect(phraseMaker.paramsEffects.doChorus).toBe(true); + expect(phraseMaker.paramsEffects.chorusRate).toBe(0.5); + }); + }); + + describe("dependency injection", () => { + test("should use injected deps", () => { + const customDeps = { + platformColor: { labelColor: "#fff" }, + docById: jest.fn(), + _: s => s.toUpperCase(), + wheelnav: jest.fn(), + slicePath: jest.fn(), + DEFAULTVOICE: "piano" + }; + + const pm = new PhraseMaker(customDeps); + expect(pm.platformColor.labelColor).toBe("#fff"); + expect(pm._instrumentName).toBe("piano"); + expect(pm._("hello")).toBe("HELLO"); + }); + + test("should handle missing deps gracefully", () => { + const pm = new PhraseMaker({}); + expect(pm.rowLabels).toEqual([]); + }); + + test("should handle null deps", () => { + const pm = new PhraseMaker(null); + expect(pm.rowLabels).toEqual([]); + }); + }); +}); diff --git a/js/widgets/phrasemaker.js b/js/widgets/phrasemaker.js index 67f19589a7..1aead7fb1e 100644 --- a/js/widgets/phrasemaker.js +++ b/js/widgets/phrasemaker.js @@ -5545,3 +5545,6 @@ class PhraseMaker { activity.textMsg(this._("New action block generated."), 3000); } } +if (typeof module !== "undefined") { + module.exports = PhraseMaker; +} From a5486a1f3e4f207e5f179b41d79c4b087022dc0d Mon Sep 17 00:00:00 2001 From: kh-ub-ayb Date: Fri, 13 Feb 2026 22:53:11 +0530 Subject: [PATCH 031/163] test: add extended unit tests for Turtle-Singer class --- js/__tests__/turtle-singer.test.js | 649 ++++++++++++++++++++++++++--- 1 file changed, 597 insertions(+), 52 deletions(-) diff --git a/js/__tests__/turtle-singer.test.js b/js/__tests__/turtle-singer.test.js index 272ddd3c24..2794769383 100644 --- a/js/__tests__/turtle-singer.test.js +++ b/js/__tests__/turtle-singer.test.js @@ -38,6 +38,57 @@ global.numberToPitch = mockGlobals.numberToPitch; global.pitchToNumber = mockGlobals.pitchToNumber; global.last = jest.fn(array => array[array.length - 1]); +const createTurtleMock = () => ({ + turtles: [], + singer: null, + synthVolume: { DEFAULTVOICE: [100] }, + inNoteBlock: [0], + notePitches: { 0: [] }, + noteOctaves: { 0: [] }, + noteCents: { 0: [] }, + noteHertz: { 0: [] } +}); + +const createActivityMock = turtleMock => ({ + turtles: { + ithTurtle: jest.fn().mockReturnValue(turtleMock), + turtleList: [turtleMock] + }, + logo: { + synth: { + setMasterVolume: jest.fn(), + setVolume: jest.fn(), + rampTo: jest.fn() + }, + pitchDrumMatrix: { addRowBlock: jest.fn() }, + notation: { notationInsertTie: jest.fn(), notationRemoveTie: jest.fn() }, + firstNoteTime: null, + stopTurtle: false, + inPitchDrumMatrix: false, + inMatrix: false, + clearNoteParams: jest.fn(), + blockList: { + mockBlk: { + connections: [0, 0] + } + } + } +}); + +const createLogoMock = activityMock => ({ + activity: activityMock, + synth: { + setMasterVolume: jest.fn(), + setVolume: jest.fn(), + rampTo: jest.fn(), + getFrequency: jest.fn(), + getCustomFrequency: jest.fn() + }, + inPitchDrumMatrix: false, + inMatrix: false, + clearNoteParams: jest.fn() +}); + describe("Singer Class", () => { let turtleMock; let activityMock; @@ -45,58 +96,10 @@ describe("Singer Class", () => { let singer; beforeEach(() => { - turtleMock = { - turtles: [], - singer: new Singer(this), - synthVolume: { DEFAULTVOICE: [100] }, - inNoteBlock: [0], - notePitches: { 0: [] }, - noteOctaves: { 0: [] }, - noteCents: { 0: [] }, - noteHertz: { 0: [] } - }; - - activityMock = { - turtles: { - ithTurtle: jest.fn().mockReturnValue(turtleMock), - turtleList: [turtleMock] - }, - logo: { - synth: { - setMasterVolume: jest.fn(), - setVolume: jest.fn(), - rampTo: jest.fn() - }, - pitchDrumMatrix: { addRowBlock: jest.fn() }, - notation: { notationInsertTie: jest.fn(), notationRemoveTie: jest.fn() }, - firstNoteTime: null, - stopTurtle: false, - inPitchDrumMatrix: false, - inMatrix: false, - clearNoteParams: jest.fn(), - // Add blockList here - blockList: { - mockBlk: { - connections: [0, 0] - } - } - } - }; - - logoMock = { - activity: activityMock, - synth: { - setMasterVolume: jest.fn(), - setVolume: jest.fn(), - rampTo: jest.fn(), - getFrequency: jest.fn(), - getCustomFrequency: jest.fn() - }, - inPitchDrumMatrix: false, - inMatrix: false, - clearNoteParams: jest.fn() - }; - + turtleMock = createTurtleMock(); + turtleMock.singer = new Singer(turtleMock); + activityMock = createActivityMock(turtleMock); + logoMock = createLogoMock(activityMock); singer = new Singer(turtleMock); }); @@ -139,3 +142,545 @@ describe("Singer Class", () => { ); }); }); + +describe("State initialization — note parameters", () => { + let singer; + + beforeEach(() => { + const turtleMock = createTurtleMock(); + singer = new Singer(turtleMock); + }); + + test("should initialize scalarTransposition to 0", () => { + expect(singer.scalarTransposition).toBe(0); + }); + + test("should initialize transposition to 0", () => { + expect(singer.transposition).toBe(0); + }); + + test("should initialize dotCount to 0", () => { + expect(singer.dotCount).toBe(0); + }); + + test("should initialize register to 0", () => { + expect(singer.register).toBe(0); + }); + + test("should initialize defaultNoteValue to 4", () => { + expect(singer.defaultNoteValue).toBe(4); + }); + + test("should initialize beatFactor to 1", () => { + expect(singer.beatFactor).toBe(1); + }); + + test("should initialize pitchNumberOffset to 39 (C4)", () => { + expect(singer.pitchNumberOffset).toBe(39); + }); + + test("should initialize currentOctave to 4", () => { + expect(singer.currentOctave).toBe(4); + }); + + test("should initialize noteDirection to 0", () => { + expect(singer.noteDirection).toBe(0); + }); + + test("should initialize lastNotePlayed to null", () => { + expect(singer.lastNotePlayed).toBeNull(); + }); + + test("should initialize previousNotePlayed to null", () => { + expect(singer.previousNotePlayed).toBeNull(); + }); + + test("should initialize noteStatus to null", () => { + expect(singer.noteStatus).toBeNull(); + }); +}); + +describe("State initialization — time signature and beats", () => { + let singer; + + beforeEach(() => { + const turtleMock = createTurtleMock(); + singer = new Singer(turtleMock); + }); + + test("should initialize beatsPerMeasure to 4", () => { + expect(singer.beatsPerMeasure).toBe(4); + }); + + test("should initialize noteValuePerBeat to 4", () => { + expect(singer.noteValuePerBeat).toBe(4); + }); + + test("should initialize currentBeat to 0", () => { + expect(singer.currentBeat).toBe(0); + }); + + test("should initialize currentMeasure to 0", () => { + expect(singer.currentMeasure).toBe(0); + }); + + test("should initialize pickup to 0", () => { + expect(singer.pickup).toBe(0); + }); + + test("should initialize bpm as empty array", () => { + expect(singer.bpm).toEqual([]); + }); + + test("should initialize turtleTime to 0", () => { + expect(singer.turtleTime).toBe(0); + }); + + test("should initialize previousTurtleTime to 0", () => { + expect(singer.previousTurtleTime).toBe(0); + }); +}); + +describe("State initialization — BPM and tempo", () => { + let singer; + + beforeEach(() => { + const turtleMock = createTurtleMock(); + singer = new Singer(turtleMock); + }); + + test("should initialize duplicateFactor to 1", () => { + expect(singer.duplicateFactor).toBe(1); + }); + + test("should initialize skipFactor to 1", () => { + expect(singer.skipFactor).toBe(1); + }); + + test("should initialize skipIndex to 0", () => { + expect(singer.skipIndex).toBe(0); + }); + + test("should initialize dispatchFactor to 1", () => { + expect(singer.dispatchFactor).toBe(1); + }); + + test("should initialize drift to 0", () => { + expect(singer.drift).toBe(0); + }); + + test("should initialize maxLagCorrectionRatio to 0.25", () => { + expect(singer.maxLagCorrectionRatio).toBe(0.25); + }); +}); + +describe("State initialization — musical properties", () => { + let singer; + + beforeEach(() => { + const turtleMock = createTurtleMock(); + singer = new Singer(turtleMock); + }); + + test("should initialize keySignature to 'C major'", () => { + expect(singer.keySignature).toBe("C major"); + }); + + test("should initialize movable to false (fixed solfege)", () => { + expect(singer.movable).toBe(false); + }); + + test("should initialize notesPlayed to [0, 1]", () => { + expect(singer.notesPlayed).toEqual([0, 1]); + }); + + test("should initialize whichNoteToCount to 1", () => { + expect(singer.whichNoteToCount).toBe(1); + }); + + test("should initialize tallyNotes to 0", () => { + expect(singer.tallyNotes).toBe(0); + }); + + test("should initialize tie to false", () => { + expect(singer.tie).toBe(false); + }); + + test("should initialize swingCarryOver to 0", () => { + expect(singer.swingCarryOver).toBe(0); + }); + + test("should initialize tieCarryOver to 0", () => { + expect(singer.tieCarryOver).toBe(0); + }); + + test("should initialize glideOverride to 0", () => { + expect(singer.glideOverride).toBe(0); + }); + + test("should initialize multipleVoices to false", () => { + expect(singer.multipleVoices).toBe(false); + }); + + test("should initialize inverted to false", () => { + expect(singer.inverted).toBe(false); + }); + + test("should initialize defaultStrongBeats to false", () => { + expect(singer.defaultStrongBeats).toBe(false); + }); +}); + +describe("State initialization — empty arrays and objects", () => { + let singer; + + beforeEach(() => { + const turtleMock = createTurtleMock(); + singer = new Singer(turtleMock); + }); + + test("should initialize inNoteBlock as empty array", () => { + expect(singer.inNoteBlock).toEqual([]); + }); + + test("should initialize instrumentNames as empty array", () => { + expect(singer.instrumentNames).toEqual([]); + }); + + test("should initialize intervals as empty array", () => { + expect(singer.intervals).toEqual([]); + }); + + test("should initialize semitoneIntervals as empty array", () => { + expect(singer.semitoneIntervals).toEqual([]); + }); + + test("should initialize staccato as empty array", () => { + expect(singer.staccato).toEqual([]); + }); + + test("should initialize glide as empty array", () => { + expect(singer.glide).toEqual([]); + }); + + test("should initialize swing as empty array", () => { + expect(singer.swing).toEqual([]); + }); + + test("should initialize voices as empty array", () => { + expect(singer.voices).toEqual([]); + }); + + test("should initialize backward as empty array", () => { + expect(singer.backward).toEqual([]); + }); + + test("should initialize invertList as empty array", () => { + expect(singer.invertList).toEqual([]); + }); + + test("should initialize beatList as empty array", () => { + expect(singer.beatList).toEqual([]); + }); + + test("should initialize factorList as empty array", () => { + expect(singer.factorList).toEqual([]); + }); + + test("should initialize oscList as empty object", () => { + expect(singer.oscList).toEqual({}); + }); + + test("should initialize pitchDrumTable as empty object", () => { + expect(singer.pitchDrumTable).toEqual({}); + }); + + test("should initialize synthVolume as empty object", () => { + expect(singer.synthVolume).toEqual({}); + }); +}); + +describe("State initialization — fill and mode flags", () => { + let singer; + + beforeEach(() => { + const turtleMock = createTurtleMock(); + singer = new Singer(turtleMock); + }); + + test("should initialize pushedNote to false", () => { + expect(singer.pushedNote).toBe(false); + }); + + test("should initialize inDuplicate to false", () => { + expect(singer.inDuplicate).toBe(false); + }); + + test("should initialize inDefineMode to false", () => { + expect(singer.inDefineMode).toBe(false); + }); + + test("should initialize suppressOutput to false", () => { + expect(singer.suppressOutput).toBe(false); + }); +}); + +describe("State initialization — effects parameters", () => { + let singer; + + beforeEach(() => { + const turtleMock = createTurtleMock(); + singer = new Singer(turtleMock); + }); + + test("should initialize vibratoIntensity as empty array", () => { + expect(singer.vibratoIntensity).toEqual([]); + }); + + test("should initialize vibratoRate as empty array", () => { + expect(singer.vibratoRate).toEqual([]); + }); + + test("should initialize distortionAmount as empty array", () => { + expect(singer.distortionAmount).toEqual([]); + }); + + test("should initialize tremoloFrequency as empty array", () => { + expect(singer.tremoloFrequency).toEqual([]); + }); + + test("should initialize tremoloDepth as empty array", () => { + expect(singer.tremoloDepth).toEqual([]); + }); + + test("should initialize panner to null", () => { + expect(singer.panner).toBeNull(); + }); +}); + +describe("State initialization — crescendo", () => { + let singer; + + beforeEach(() => { + const turtleMock = createTurtleMock(); + singer = new Singer(turtleMock); + }); + + test("should initialize inCrescendo as empty array", () => { + expect(singer.inCrescendo).toEqual([]); + }); + + test("should initialize crescendoDelta as empty array", () => { + expect(singer.crescendoDelta).toEqual([]); + }); + + test("should initialize crescendoInitialVolume with DEFAULTVOICE at DEFAULTVOLUME", () => { + expect(singer.crescendoInitialVolume).toEqual({ + DEFAULTVOICE: [100] + }); + }); +}); + +describe("inNoteBlock behavior", () => { + let singer; + + beforeEach(() => { + const turtleMock = createTurtleMock(); + singer = new Singer(turtleMock); + }); + + test("should start with empty inNoteBlock array", () => { + expect(singer.inNoteBlock).toEqual([]); + expect(singer.inNoteBlock.length).toBe(0); + }); + + test("should be able to push note block IDs", () => { + singer.inNoteBlock.push(1); + expect(singer.inNoteBlock.length).toBe(1); + expect(singer.inNoteBlock[0]).toBe(1); + }); + + test("should track multiple nested note blocks", () => { + singer.inNoteBlock.push(1); + singer.inNoteBlock.push(2); + expect(singer.inNoteBlock.length).toBe(2); + }); + + test("should be able to pop note block IDs", () => { + singer.inNoteBlock.push(1); + singer.inNoteBlock.push(2); + singer.inNoteBlock.pop(); + expect(singer.inNoteBlock.length).toBe(1); + expect(singer.inNoteBlock[0]).toBe(1); + }); + + test("should report not in note block when array is empty", () => { + expect(singer.inNoteBlock.length).toBe(0); + }); + + test("should report in note block when array has entries", () => { + singer.inNoteBlock.push(5); + expect(singer.inNoteBlock.length).toBeGreaterThan(0); + }); +}); + +describe("setMasterVolume edge cases", () => { + let turtleMock; + let activityMock; + let logoMock; + + beforeEach(() => { + turtleMock = createTurtleMock(); + turtleMock.singer = new Singer(turtleMock); + activityMock = createActivityMock(turtleMock); + logoMock = createLogoMock(activityMock); + }); + + test("should clamp volume to 0 when negative", () => { + Singer.setMasterVolume(logoMock, -10); + expect(logoMock.synth.setMasterVolume).toHaveBeenCalledWith(0); + }); + + test("should clamp volume to 100 when above 100", () => { + Singer.setMasterVolume(logoMock, 150); + expect(logoMock.synth.setMasterVolume).toHaveBeenCalledWith(100); + }); + + test("should pass volume as-is when within range", () => { + Singer.setMasterVolume(logoMock, 50); + expect(logoMock.synth.setMasterVolume).toHaveBeenCalledWith(50); + }); + + test("should handle volume of exactly 0", () => { + Singer.setMasterVolume(logoMock, 0); + expect(logoMock.synth.setMasterVolume).toHaveBeenCalledWith(0); + }); + + test("should handle volume of exactly 100", () => { + Singer.setMasterVolume(logoMock, 100); + expect(logoMock.synth.setMasterVolume).toHaveBeenCalledWith(100); + }); + + test("should push volume to all turtle synthVolumes", () => { + // setMasterVolume iterates over existing keys in synthVolume + turtleMock.singer.synthVolume = { DEFAULTVOICE: [100] }; + Singer.setMasterVolume(logoMock, 75); + expect(turtleMock.singer.synthVolume.DEFAULTVOICE).toContain(75); + }); +}); + +describe("setSynthVolume edge cases", () => { + let turtleMock; + let logoMock; + + beforeEach(() => { + turtleMock = createTurtleMock(); + turtleMock.singer = new Singer(turtleMock); + const activityMock = createActivityMock(turtleMock); + logoMock = createLogoMock(activityMock); + }); + + test("should divide noise1 volume by 25", () => { + Singer.setSynthVolume(logoMock, turtleMock, "noise1", 100, "blk"); + expect(logoMock.synth.setVolume).toHaveBeenCalledWith(turtleMock, "noise1", 4, "blk"); + }); + + test("should divide noise2 volume by 25", () => { + Singer.setSynthVolume(logoMock, turtleMock, "noise2", 50, "blk"); + expect(logoMock.synth.setVolume).toHaveBeenCalledWith(turtleMock, "noise2", 2, "blk"); + }); + + test("should divide noise3 volume by 25", () => { + Singer.setSynthVolume(logoMock, turtleMock, "noise3", 75, "blk"); + expect(logoMock.synth.setVolume).toHaveBeenCalledWith(turtleMock, "noise3", 3, "blk"); + }); + + test("should pass full volume for non-noise synths", () => { + Singer.setSynthVolume(logoMock, turtleMock, "piano", 80, "blk"); + expect(logoMock.synth.setVolume).toHaveBeenCalledWith(turtleMock, "piano", 80, "blk"); + }); + + test("should clamp negative volume to 0", () => { + Singer.setSynthVolume(logoMock, turtleMock, "piano", -20, "blk"); + expect(logoMock.synth.setVolume).toHaveBeenCalledWith(turtleMock, "piano", 0, "blk"); + }); + + test("should clamp volume above 100 to 100", () => { + Singer.setSynthVolume(logoMock, turtleMock, "piano", 200, "blk"); + expect(logoMock.synth.setVolume).toHaveBeenCalledWith(turtleMock, "piano", 100, "blk"); + }); +}); + +describe("Musical state mutability", () => { + let singer; + + beforeEach(() => { + const turtleMock = createTurtleMock(); + singer = new Singer(turtleMock); + }); + + test("should allow updating keySignature", () => { + singer.keySignature = "G major"; + expect(singer.keySignature).toBe("G major"); + }); + + test("should allow updating beatsPerMeasure", () => { + singer.beatsPerMeasure = 3; + expect(singer.beatsPerMeasure).toBe(3); + }); + + test("should allow updating noteValuePerBeat", () => { + singer.noteValuePerBeat = 8; + expect(singer.noteValuePerBeat).toBe(8); + }); + + test("should allow updating currentBeat", () => { + singer.currentBeat = 2; + expect(singer.currentBeat).toBe(2); + }); + + test("should allow updating currentMeasure", () => { + singer.currentMeasure = 5; + expect(singer.currentMeasure).toBe(5); + }); + + test("should allow toggling tie", () => { + singer.tie = true; + expect(singer.tie).toBe(true); + singer.tie = false; + expect(singer.tie).toBe(false); + }); + + test("should allow updating register", () => { + singer.register = 2; + expect(singer.register).toBe(2); + }); + + test("should allow pushing to bpm array", () => { + singer.bpm.push(120); + expect(singer.bpm).toEqual([120]); + singer.bpm.push(90); + expect(singer.bpm).toEqual([120, 90]); + }); + + test("should allow pushing instruments", () => { + singer.instrumentNames.push("piano"); + singer.instrumentNames.push("violin"); + expect(singer.instrumentNames).toEqual(["piano", "violin"]); + }); + + test("should allow updating scalarTransposition", () => { + singer.scalarTransposition = 3; + expect(singer.scalarTransposition).toBe(3); + }); + + test("should allow updating transposition", () => { + singer.transposition = -2; + expect(singer.transposition).toBe(-2); + }); + + test("should allow updating duplicateFactor", () => { + singer.duplicateFactor = 3; + expect(singer.duplicateFactor).toBe(3); + }); +}); From b20a3aa9ab6676c54fee9992a599b7563ccdb37d Mon Sep 17 00:00:00 2001 From: Lakshay Date: Fri, 13 Feb 2026 23:11:13 +0530 Subject: [PATCH 032/163] added the unit tests for timbre widget --- js/widgets/__tests__/timbre.test.js | 328 ++++++++++++++++++++++++++++ js/widgets/timbre.js | 126 +++++------ 2 files changed, 383 insertions(+), 71 deletions(-) create mode 100644 js/widgets/__tests__/timbre.test.js diff --git a/js/widgets/__tests__/timbre.test.js b/js/widgets/__tests__/timbre.test.js new file mode 100644 index 0000000000..caab5d4ec6 --- /dev/null +++ b/js/widgets/__tests__/timbre.test.js @@ -0,0 +1,328 @@ +/** + * MusicBlocks v3.6.2 + * + * @author Lakshay + * + * @copyright 2026 Lakshay + * + * @license + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +// --- Global Mocks (must be set before require) --- +global._ = msg => msg; +global.DEFAULTOSCILLATORTYPE = "sine"; +global.DEFAULTFILTERTYPE = "lowpass"; +global.OSCTYPES = ["sine", "triangle", "sawtooth", "square"]; +global.FILTERTYPES = ["lowpass", "highpass", "bandpass"]; +global.instrumentsFilters = [{}]; +global.instrumentsEffects = [{}]; +global.platformColor = { + labelColor: "#90c100", + selectorBackground: "#f0f0f0", + selectorBackgroundHOVER: "#e0e0e0" +}; +global.rationalToFraction = jest.fn(n => [n, 1]); +global.oneHundredToFraction = jest.fn(n => n / 100); +global.last = arr => arr[arr.length - 1]; +global.Singer = { RhythmActions: { getNoteValue: jest.fn(() => 0.25) } }; +global.delayExecution = jest.fn(ms => new Promise(r => setTimeout(r, ms))); +global.docById = jest.fn(() => ({ + style: {}, + innerHTML: "", + appendChild: jest.fn(), + addEventListener: jest.fn(), + setAttribute: jest.fn(), + insertRow: jest.fn(() => ({ + insertCell: jest.fn(() => ({ + style: {}, + innerHTML: "", + appendChild: jest.fn() + })) + })) +})); +global.docByName = jest.fn(() => []); + +global.window = { + innerWidth: 1200, + widgetWindows: { + windowFor: jest.fn().mockReturnValue({ + clear: jest.fn(), + show: jest.fn(), + addButton: jest.fn().mockReturnValue({ onclick: null }), + getWidgetBody: jest.fn().mockReturnValue({ + appendChild: jest.fn(), + append: jest.fn(), + style: {}, + innerHTML: "" + }), + sendToCenter: jest.fn(), + updateTitle: jest.fn(), + onclose: null, + onmaximize: null, + destroy: jest.fn() + }) + } +}; + +global.document = { + createElement: jest.fn(() => ({ + style: {}, + innerHTML: "", + appendChild: jest.fn(), + append: jest.fn(), + setAttribute: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + insertRow: jest.fn(() => ({ + insertCell: jest.fn(() => ({ + style: {}, + innerHTML: "", + appendChild: jest.fn() + })) + })) + })), + getElementById: jest.fn(() => ({ + style: {}, + innerHTML: "" + })) +}; + +const TimbreWidget = require("../timbre.js"); + +describe("TimbreWidget", () => { + let timbre; + + beforeEach(() => { + global.instrumentsFilters = [{}]; + global.instrumentsEffects = [{}]; + timbre = new TimbreWidget(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("constructor", () => { + test("should initialize with empty notesToPlay", () => { + expect(timbre.notesToPlay).toEqual([]); + }); + + test("should initialize with empty env", () => { + expect(timbre.env).toEqual([]); + }); + + test("should initialize with empty ENVs", () => { + expect(timbre.ENVs).toEqual([]); + }); + + test("should initialize synthVals with sine oscillator", () => { + expect(timbre.synthVals.oscillator.type).toBe("sine6"); + expect(timbre.synthVals.oscillator.source).toBe(DEFAULTOSCILLATORTYPE); + }); + + test("should initialize synthVals with default envelope", () => { + expect(timbre.synthVals.envelope.attack).toBe(0.01); + expect(timbre.synthVals.envelope.decay).toBe(0.5); + expect(timbre.synthVals.envelope.sustain).toBe(0.6); + expect(timbre.synthVals.envelope.release).toBe(0.01); + }); + + test("should initialize adsrMap correctly", () => { + expect(timbre.adsrMap).toEqual(["attack", "decay", "sustain", "release"]); + }); + + test("should initialize amSynthParamvals", () => { + expect(timbre.amSynthParamvals.harmonicity).toBe(3); + }); + + test("should initialize fmSynthParamvals", () => { + expect(timbre.fmSynthParamvals.modulationIndex).toBe(10); + }); + + test("should initialize noiseSynthParamvals", () => { + expect(timbre.noiseSynthParamvals.noise.type).toBe("white"); + }); + + test("should initialize duoSynthParamVals", () => { + expect(timbre.duoSynthParamVals.vibratoAmount).toBe(0.5); + expect(timbre.duoSynthParamVals.vibratoRate).toBe(5); + }); + + test("should initialize empty effect arrays", () => { + expect(timbre.fil).toEqual([]); + expect(timbre.filterParams).toEqual([]); + expect(timbre.osc).toEqual([]); + expect(timbre.oscParams).toEqual([]); + expect(timbre.tremoloEffect).toEqual([]); + expect(timbre.tremoloParams).toEqual([]); + expect(timbre.vibratoEffect).toEqual([]); + expect(timbre.vibratoParams).toEqual([]); + expect(timbre.chorusEffect).toEqual([]); + expect(timbre.chorusParams).toEqual([]); + expect(timbre.phaserEffect).toEqual([]); + expect(timbre.phaserParams).toEqual([]); + expect(timbre.distortionEffect).toEqual([]); + expect(timbre.distortionParams).toEqual([]); + }); + + test("should initialize empty synth arrays", () => { + expect(timbre.AMSynthesizer).toEqual([]); + expect(timbre.AMSynthParams).toEqual([]); + expect(timbre.FMSynthesizer).toEqual([]); + expect(timbre.FMSynthParams).toEqual([]); + expect(timbre.NoiseSynthesizer).toEqual([]); + expect(timbre.NoiseSynthParams).toEqual([]); + expect(timbre.duoSynthesizer).toEqual([]); + expect(timbre.duoSynthParams).toEqual([]); + }); + + test("should initialize all activeParams as inactive", () => { + const expectedParams = [ + "synth", + "amsynth", + "fmsynth", + "noisesynth", + "duosynth", + "envelope", + "oscillator", + "filter", + "effects", + "chorus", + "vibrato", + "phaser", + "distortion", + "tremolo" + ]; + expect(timbre.activeParams).toEqual(expectedParams); + for (const param of expectedParams) { + expect(timbre.isActive[param]).toBe(false); + } + }); + + test("should set default instrumentName", () => { + expect(timbre.instrumentName).toBe("custom"); + }); + + test("should set blockNo to null", () => { + expect(timbre.blockNo).toBeNull(); + }); + + test("should initialize instrumentsFilters for custom instrument", () => { + expect(instrumentsFilters[0]["custom"]).toEqual([]); + }); + + test("should initialize instrumentsEffects for custom instrument", () => { + expect(instrumentsEffects[0]["custom"]).toEqual([]); + }); + + test("should initialize _eventListeners as empty object", () => { + expect(timbre._eventListeners).toEqual({}); + }); + }); + + describe("synth parameters", () => { + test("should allow updating synthVals envelope", () => { + timbre.synthVals.envelope.attack = 0.1; + timbre.synthVals.envelope.decay = 0.3; + timbre.synthVals.envelope.sustain = 0.8; + timbre.synthVals.envelope.release = 0.2; + expect(timbre.synthVals.envelope.attack).toBe(0.1); + expect(timbre.synthVals.envelope.decay).toBe(0.3); + expect(timbre.synthVals.envelope.sustain).toBe(0.8); + expect(timbre.synthVals.envelope.release).toBe(0.2); + }); + + test("should allow updating oscillator type", () => { + timbre.synthVals.oscillator.type = "triangle6"; + expect(timbre.synthVals.oscillator.type).toBe("triangle6"); + }); + + test("should allow updating amSynth harmonicity", () => { + timbre.amSynthParamvals.harmonicity = 5; + expect(timbre.amSynthParamvals.harmonicity).toBe(5); + }); + + test("should allow updating fmSynth modulationIndex", () => { + timbre.fmSynthParamvals.modulationIndex = 20; + expect(timbre.fmSynthParamvals.modulationIndex).toBe(20); + }); + + test("should allow updating noise type", () => { + timbre.noiseSynthParamvals.noise.type = "pink"; + expect(timbre.noiseSynthParamvals.noise.type).toBe("pink"); + }); + + test("should allow updating duoSynth params", () => { + timbre.duoSynthParamVals.vibratoAmount = 0.8; + timbre.duoSynthParamVals.vibratoRate = 10; + expect(timbre.duoSynthParamVals.vibratoAmount).toBe(0.8); + expect(timbre.duoSynthParamVals.vibratoRate).toBe(10); + }); + }); + + describe("active params management", () => { + test("should toggle isActive for a parameter", () => { + expect(timbre.isActive["synth"]).toBe(false); + timbre.isActive["synth"] = true; + expect(timbre.isActive["synth"]).toBe(true); + }); + + test("should allow activating multiple params", () => { + timbre.isActive["envelope"] = true; + timbre.isActive["filter"] = true; + expect(timbre.isActive["envelope"]).toBe(true); + expect(timbre.isActive["filter"]).toBe(true); + expect(timbre.isActive["effects"]).toBe(false); + }); + }); + + describe("effect arrays", () => { + test("should allow adding filter entries", () => { + timbre.fil.push("lowpass"); + timbre.filterParams.push({ frequency: 400 }); + expect(timbre.fil).toHaveLength(1); + expect(timbre.filterParams[0].frequency).toBe(400); + }); + + test("should allow adding oscillator entries", () => { + timbre.osc.push("sine"); + timbre.oscParams.push({ partialCount: 6 }); + expect(timbre.osc).toHaveLength(1); + }); + + test("should allow adding effect entries", () => { + timbre.tremoloEffect.push(true); + timbre.tremoloParams.push({ frequency: 10, depth: 0.5 }); + timbre.vibratoEffect.push(true); + timbre.vibratoParams.push({ frequency: 5, depth: 0.3 }); + expect(timbre.tremoloEffect).toHaveLength(1); + expect(timbre.vibratoEffect).toHaveLength(1); + }); + }); + + describe("notes management", () => { + test("should allow adding notes to play", () => { + timbre.notesToPlay.push(["C4", 4]); + timbre.notesToPlay.push(["D4", 4]); + expect(timbre.notesToPlay).toHaveLength(2); + }); + + test("should allow clearing notesToPlay", () => { + timbre.notesToPlay.push(["C4", 4]); + timbre.notesToPlay = []; + expect(timbre.notesToPlay).toHaveLength(0); + }); + }); +}); diff --git a/js/widgets/timbre.js b/js/widgets/timbre.js index 9ed8a697c8..ae7a7624fa 100644 --- a/js/widgets/timbre.js +++ b/js/widgets/timbre.js @@ -209,24 +209,21 @@ class TimbreWidget { } if (this.isActive["noisesynth"] === true && this.NoiseSynthesizer[i] != null) { - updateParams[0] = this.activity.blocks.blockList[ - this.NoiseSynthesizer[i] - ].connections[1]; + updateParams[0] = + this.activity.blocks.blockList[this.NoiseSynthesizer[i]].connections[1]; } if (this.isActive["duosynth"] === true && this.duoSynthesizer[i] != null) { for (let j = 0; j < 2; j++) { - updateParams[j] = this.activity.blocks.blockList[ - this.duoSynthesizer[i] - ].connections[j + 1]; + updateParams[j] = + this.activity.blocks.blockList[this.duoSynthesizer[i]].connections[j + 1]; } } if (this.isActive["tremolo"] === true && this.tremoloEffect[i] != null) { for (let j = 0; j < 2; j++) { - updateParams[j] = this.activity.blocks.blockList[this.tremoloEffect[i]].connections[ - j + 1 - ]; + updateParams[j] = + this.activity.blocks.blockList[this.tremoloEffect[i]].connections[j + 1]; } } @@ -280,24 +277,21 @@ class TimbreWidget { if (this.isActive["chorus"] === true && this.chorusEffect[i] != null) { for (let j = 0; j < 3; j++) { - updateParams[j] = this.activity.blocks.blockList[this.chorusEffect[i]].connections[ - j + 1 - ]; + updateParams[j] = + this.activity.blocks.blockList[this.chorusEffect[i]].connections[j + 1]; } } if (this.isActive["phaser"] === true && this.phaserEffect[i] != null) { for (let j = 0; j < 3; j++) { - updateParams[j] = this.activity.blocks.blockList[this.phaserEffect[i]].connections[ - j + 1 - ]; + updateParams[j] = + this.activity.blocks.blockList[this.phaserEffect[i]].connections[j + 1]; } } if (this.isActive["distortion"] === true && this.distortionEffect[i] != null) { - updateParams[0] = this.activity.blocks.blockList[ - this.distortionEffect[i] - ].connections[1]; + updateParams[0] = + this.activity.blocks.blockList[this.distortionEffect[i]].connections[1]; } if (updateParams[0] != null) { @@ -606,9 +600,8 @@ class TimbreWidget { platformColor.selectorBackground; docById("sel" + i).value = this.filterParams[i * 3]; this._update(i, this.filterParams[i * 3], 0); - instrumentsFilters[0][this.instrumentName][i]["filterType"] = this.filterParams[ - i * 3 - ]; + instrumentsFilters[0][this.instrumentName][i]["filterType"] = + this.filterParams[i * 3]; const radioIDs = [i * 4, i * 4 + 1, i * 4 + 2, i * 4 + 3]; if (this.filterParams[i * 3 + 1] === -12) { @@ -744,13 +737,10 @@ class TimbreWidget { this._play(); }; - widgetWindow.addButton( - "export-chunk.svg", - TimbreWidget.ICONSIZE, - _("Save") - ).onclick = () => { - this._save(); - }; + widgetWindow.addButton("export-chunk.svg", TimbreWidget.ICONSIZE, _("Save")).onclick = + () => { + this._save(); + }; let _unhighlightButtons; // defined later to avoid circular dependency @@ -951,13 +941,10 @@ class TimbreWidget { addFilterButtonCell.onmouseout = () => {}; - widgetWindow.addButton( - "restore-button.svg", - TimbreWidget.ICONSIZE, - _("Undo") - ).onclick = () => { - this._undo(); - }; + widgetWindow.addButton("restore-button.svg", TimbreWidget.ICONSIZE, _("Undo")).onclick = + () => { + this._undo(); + }; // let cell = this._addButton(row, 'close-button.svg', TimbreWidget.ICONSIZE, _('Close')); @@ -1231,8 +1218,8 @@ class TimbreWidget { this.isActive["duosynth"] = false; if (this.AMSynthesizer.length === 0) { - const topOfClamp = this.activity.blocks.blockList[this.blockNo] - .connections[2]; + const topOfClamp = + this.activity.blocks.blockList[this.blockNo].connections[2]; const bottomOfClamp = this.activity.blocks.findBottomBlock(topOfClamp); const AMSYNTHOBJ = [ @@ -1295,8 +1282,8 @@ class TimbreWidget { this.isActive["duosynth"] = false; if (this.FMSynthesizer.length === 0) { - const topOfClamp = this.activity.blocks.blockList[this.blockNo] - .connections[2]; + const topOfClamp = + this.activity.blocks.blockList[this.blockNo].connections[2]; const bottomOfClamp = this.activity.blocks.findBottomBlock(topOfClamp); const FMSYNTHOBJ = [ @@ -1362,8 +1349,8 @@ class TimbreWidget { this.isActive["duosynth"] = false; if (this.NoiseSynthesizer.length === 0) { - const topOfClamp = this.activity.blocks.blockList[this.blockNo] - .connections[2]; + const topOfClamp = + this.activity.blocks.blockList[this.blockNo].connections[2]; const bottomOfClamp = this.activity.blocks.findBottomBlock(topOfClamp); const NOISESYNTHOBJ = [ @@ -1426,8 +1413,8 @@ class TimbreWidget { this.isActive["duosynth"] = true; if (this.duoSynthesizer.length === 0) { - const topOfClamp = this.activity.blocks.blockList[this.blockNo] - .connections[2]; + const topOfClamp = + this.activity.blocks.blockList[this.blockNo].connections[2]; const bottomOfClamp = this.activity.blocks.findBottomBlock(topOfClamp); const DUOSYNTHOBJ = [ @@ -1951,9 +1938,8 @@ class TimbreWidget { this._playNote("G4", 1 / 8); } else if (targetId.startsWith("radio") && event.type === "click") { const m = Number(targetId.replace("radio", "")); - instrumentsFilters[0][this.instrumentName][Math.floor(m / 4)][ - "filterRolloff" - ] = parseFloat(target.value); + instrumentsFilters[0][this.instrumentName][Math.floor(m / 4)]["filterRolloff"] = + parseFloat(target.value); this._update(Math.floor(m / 4), target.value, 1); this._playNote("G4", 1 / 8); } else if ( @@ -2206,8 +2192,8 @@ class TimbreWidget { if (this.tremoloEffect.length === 0) { // This is the first block in the child stack // of the Timbre clamp. - const topOfClamp = this.activity.blocks.blockList[this.blockNo] - .connections[2]; + const topOfClamp = + this.activity.blocks.blockList[this.blockNo].connections[2]; const n = this.activity.blocks.blockList.length; const TREMOLOOBJ = [ @@ -2236,9 +2222,8 @@ class TimbreWidget { docById("myspanFx" + m).textContent = elem.value; if (m === 0) { - instrumentsEffects[0][this.instrumentName][ - "tremoloFrequency" - ] = parseFloat(elem.value); + instrumentsEffects[0][this.instrumentName]["tremoloFrequency"] = + parseFloat(elem.value); } if (m === 1) { @@ -2285,8 +2270,8 @@ class TimbreWidget { docById("myspanFx1").textContent = obj[0] + "/" + obj[1]; // this.vibratoParams[1]; } else { // If necessary, add a vibrato block. - const topOfTimbreClamp = this.activity.blocks.blockList[this.blockNo] - .connections[2]; + const topOfTimbreClamp = + this.activity.blocks.blockList[this.blockNo].connections[2]; const vibratoBlock = this.activity.blocks.blockList.length; const VIBRATOOBJ = [ @@ -2392,8 +2377,8 @@ class TimbreWidget { } if (this.chorusEffect.length === 0) { - const topOfClamp = this.activity.blocks.blockList[this.blockNo] - .connections[2]; + const topOfClamp = + this.activity.blocks.blockList[this.blockNo].connections[2]; const n = this.activity.blocks.blockList.length; const CHORUSOBJ = [ @@ -2424,15 +2409,13 @@ class TimbreWidget { docById("myspanFx" + m).textContent = elem.value; if (m === 0) { - instrumentsEffects[0][this.instrumentName][ - "chorusRate" - ] = parseFloat(elem.value); + instrumentsEffects[0][this.instrumentName]["chorusRate"] = + parseFloat(elem.value); } if (m === 1) { - instrumentsEffects[0][this.instrumentName][ - "delayTime" - ] = parseFloat(elem.value); + instrumentsEffects[0][this.instrumentName]["delayTime"] = + parseFloat(elem.value); } if (m === 2) { @@ -2492,8 +2475,8 @@ class TimbreWidget { } if (this.phaserEffect.length === 0) { - const topOfClamp = this.activity.blocks.blockList[this.blockNo] - .connections[2]; + const topOfClamp = + this.activity.blocks.blockList[this.blockNo].connections[2]; const n = this.activity.blocks.blockList.length; const PHASEROBJ = [ @@ -2530,15 +2513,13 @@ class TimbreWidget { } if (m === 1) { - instrumentsEffects[0][this.instrumentName][ - "octaves" - ] = parseFloat(elem.value); + instrumentsEffects[0][this.instrumentName]["octaves"] = + parseFloat(elem.value); } if (m === 2) { - instrumentsEffects[0][this.instrumentName][ - "baseFrequency" - ] = parseFloat(elem.value); + instrumentsEffects[0][this.instrumentName]["baseFrequency"] = + parseFloat(elem.value); } this._update(blockValue, parseFloat(elem.value), Number(m)); @@ -2571,8 +2552,8 @@ class TimbreWidget { } if (this.distortionEffect.length === 0) { - const topOfClamp = this.activity.blocks.blockList[this.blockNo] - .connections[2]; + const topOfClamp = + this.activity.blocks.blockList[this.blockNo].connections[2]; const n = this.activity.blocks.blockList.length; const DISTORTIONOBJ = [ @@ -2603,3 +2584,6 @@ class TimbreWidget { } }; } +if (typeof module !== "undefined") { + module.exports = TimbreWidget; +} From d9fdba2e53aa9f15d67847e94de24f984e1f7954 Mon Sep 17 00:00:00 2001 From: Aditya Shinde Date: Sat, 14 Feb 2026 00:35:44 +0530 Subject: [PATCH 033/163] fix: route effects in serial chain to prevent audio signal duplication (#5631) * fix: route effects in serial chain to prevent audio signal duplication Signed-off-by: Ady0333 * prettier --------- Signed-off-by: Ady0333 Co-authored-by: Walter Bender --- js/utils/synthutils.js | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/js/utils/synthutils.js b/js/utils/synthutils.js index 4ce87ba6a4..bf5c4f7ca0 100644 --- a/js/utils/synthutils.js +++ b/js/utils/synthutils.js @@ -1726,6 +1726,9 @@ function Synth() { console.debug("Error triggering note:", e); } } else { + const effectChain = []; + synth.disconnect(); + if (paramsFilters !== null && paramsFilters !== undefined) { numFilters = paramsFilters.length; // no. of filters for (let k = 0; k < numFilters; k++) { @@ -1736,7 +1739,7 @@ function Synth() { paramsFilters[k].filterRolloff ); temp_filters.push(filterVal); - synth.chain(temp_filters[k], Tone.Destination); + effectChain.push(temp_filters[k]); } } @@ -1752,15 +1755,13 @@ function Synth() { 1 / paramsEffects.vibratoFrequency, paramsEffects.vibratoIntensity ); - synth.chain(vibrato, Tone.Destination); + effectChain.push(vibrato); effectsToDispose.push(vibrato); } if (paramsEffects.doDistortion) { - distortion = new Tone.Distortion( - paramsEffects.distortionAmount - ).toDestination(); - synth.connect(distortion, Tone.Destination); + distortion = new Tone.Distortion(paramsEffects.distortionAmount); + effectChain.push(distortion); effectsToDispose.push(distortion); } @@ -1768,10 +1769,8 @@ function Synth() { tremolo = new Tone.Tremolo({ frequency: paramsEffects.tremoloFrequency, depth: paramsEffects.tremoloDepth - }) - .toDestination() - .start(); - synth.chain(tremolo); + }).start(); + effectChain.push(tremolo); effectsToDispose.push(tremolo); } @@ -1780,8 +1779,8 @@ function Synth() { frequency: paramsEffects.rate, octaves: paramsEffects.octaves, baseFrequency: paramsEffects.baseFrequency - }).toDestination(); - synth.chain(phaser, Tone.Destination); + }); + effectChain.push(phaser); effectsToDispose.push(phaser); } @@ -1790,8 +1789,8 @@ function Synth() { frequency: paramsEffects.chorusRate, delayTime: paramsEffects.delayTime, depth: paramsEffects.chorusDepth - }).toDestination(); - synth.chain(chorus, Tone.Destination); + }); + effectChain.push(chorus); effectsToDispose.push(chorus); } @@ -1847,6 +1846,12 @@ function Synth() { } } + if (effectChain.length > 0) { + synth.chain(...effectChain, Tone.Destination); + } else { + synth.toDestination(); + } + if (!paramsEffects.doNeighbor) { if (setNote !== undefined && setNote) { if (synth.oscillator !== undefined) { From 119fef8348c4b476f8673da543305b65ef880898 Mon Sep 17 00:00:00 2001 From: Farhan <108477946+farhan-momin@users.noreply.github.com> Date: Sat, 14 Feb 2026 00:43:02 +0530 Subject: [PATCH 034/163] Fix: Remove unused prefetch and reduce size for loading-animation-ja (#5491) * Remove unecessary prefetch of loading-animation-ja for Initial load * Reduce loading-animation-ja image size --- .../loading-animation-ja.svg | 0 index.html | 4 +--- loading-animation-ja.png | Bin 0 -> 128029 bytes 3 files changed, 1 insertion(+), 3 deletions(-) rename loading-animation-ja.svg => images/loading-animation-ja.svg (100%) create mode 100644 loading-animation-ja.png diff --git a/loading-animation-ja.svg b/images/loading-animation-ja.svg similarity index 100% rename from loading-animation-ja.svg rename to images/loading-animation-ja.svg diff --git a/index.html b/index.html index 846642c79d..2792de9320 100644 --- a/index.html +++ b/index.html @@ -28,8 +28,6 @@ - - @@ -645,7 +643,7 @@ const container = document.getElementById("loading-media"); const content = lang.startsWith("ja") - ? `Loading animation` + ? `Loading animation` : `