diff --git a/js/widgets/statistics.js b/js/widgets/statistics.js
index 494c582c88..0c4b6d3943 100644
--- a/js/widgets/statistics.js
+++ b/js/widgets/statistics.js
@@ -116,3 +116,7 @@ class StatsWindow {
ornaments used: ${stats["ornaments"]}`;
}
}
+/* istanbul ignore next */
+if (typeof module !== "undefined") {
+ module.exports = StatsWindow;
+}
diff --git a/js/widgets/statistics.test.js b/js/widgets/statistics.test.js
new file mode 100644
index 0000000000..443a561dac
--- /dev/null
+++ b/js/widgets/statistics.test.js
@@ -0,0 +1,174 @@
+/**
+ * MusicBlocks v3.6.2
+ *
+ * @author Divyam Agarwal
+ *
+ * @copyright 2026 Divyam Agarwal
+ *
+ * @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 StatsWindow = require("./statistics.js");
+
+describe("StatsWindow", () => {
+ let activity, widgetWin, body, capturedCb;
+
+ beforeEach(() => {
+ capturedCb = null;
+ body = document.createElement("div");
+
+ activity = {
+ blocks: {
+ showBlocks: jest.fn(),
+ hideBlocks: jest.fn(),
+ activeBlock: null
+ },
+ logo: { statsWindow: null },
+ loading: false,
+ showBlocksAfterRun: true
+ };
+
+ widgetWin = {
+ clear: jest.fn(),
+ show: jest.fn(),
+ destroy: jest.fn(),
+ sendToCenter: jest.fn(),
+ onclose: null,
+ onmaximize: null,
+ getWidgetBody: jest.fn().mockReturnValue(body),
+ getWidgetFrame: jest
+ .fn()
+ .mockReturnValue({ getBoundingClientRect: () => ({ height: 500 }) }),
+ isMaximized: jest.fn().mockReturnValue(false)
+ };
+
+ window.widgetWindows = {
+ windowFor: jest.fn().mockReturnValue(widgetWin)
+ };
+
+ global.docById = jest.fn().mockReturnValue({ getContext: jest.fn().mockReturnValue({}) });
+
+ global.analyzeProject = jest.fn().mockReturnValue({});
+ global.runAnalytics = jest.fn();
+ global.scoreToChartData = jest.fn().mockReturnValue({});
+ global.getChartOptions = jest.fn().mockImplementation(cb => {
+ capturedCb = cb;
+ return {};
+ });
+
+ global.Chart = jest.fn().mockImplementation(() => ({
+ Radar: jest.fn().mockReturnValue({
+ toBase64Image: jest.fn().mockReturnValue("data:image/png;base64,fakedata")
+ })
+ }));
+ });
+
+ test("constructor initialises widget and calls doAnalytics", () => {
+ const sw = new StatsWindow(activity);
+
+ expect(window.widgetWindows.windowFor).toHaveBeenCalledWith(sw, "stats", "stats");
+ expect(widgetWin.clear).toHaveBeenCalled();
+ expect(widgetWin.show).toHaveBeenCalled();
+ expect(widgetWin.sendToCenter).toHaveBeenCalled();
+ expect(sw.isOpen).toBe(true);
+ expect(global.analyzeProject).toHaveBeenCalledWith(activity);
+ expect(global.runAnalytics).toHaveBeenCalledWith(activity);
+ });
+
+ test("onclose cleans up and marks window closed", () => {
+ const sw = new StatsWindow(activity);
+ widgetWin.onclose();
+
+ expect(sw.isOpen).toBe(false);
+ expect(activity.blocks.showBlocks).toHaveBeenCalled();
+ expect(widgetWin.destroy).toHaveBeenCalled();
+ expect(activity.logo.statsWindow).toBeNull();
+ });
+
+ test("onmaximize sets flex layout when maximized", () => {
+ const sw = new StatsWindow(activity);
+ global.analyzeProject.mockClear();
+
+ widgetWin.isMaximized.mockReturnValue(true);
+ widgetWin.onmaximize();
+
+ expect(body.style.display).toBe("flex");
+ expect(body.style.justifyContent).toBe("space-between");
+ expect(body.style.padding).toBe("0px 2vw");
+ expect(global.analyzeProject).toHaveBeenCalled();
+ });
+
+ test("onmaximize resets padding when not maximized", () => {
+ const sw = new StatsWindow(activity);
+ widgetWin.isMaximized.mockReturnValue(false);
+ widgetWin.onmaximize();
+
+ expect(body.style.padding).toBe("0px 0px");
+ });
+
+ test("chart callback sets img width to 200 when not maximized", () => {
+ widgetWin.isMaximized.mockReturnValue(false);
+ const sw = new StatsWindow(activity);
+
+ expect(capturedCb).not.toBeNull();
+ capturedCb();
+
+ const imgs = body.querySelectorAll("img");
+ expect(imgs.length).toBeGreaterThanOrEqual(1);
+ expect(imgs[imgs.length - 1].width).toBe(200);
+ expect(activity.blocks.hideBlocks).toHaveBeenCalled();
+ expect(activity.showBlocksAfterRun).toBe(false);
+ expect(document.body.style.cursor).toBe("default");
+ });
+
+ test("chart callback uses frame height for img width when maximized", () => {
+ widgetWin.isMaximized.mockReturnValue(true);
+ const sw = new StatsWindow(activity);
+
+ capturedCb();
+
+ const imgs = body.querySelectorAll("img");
+ expect(imgs[imgs.length - 1].width).toBe(420); // 500 - 80
+ });
+
+ test("displayInfo renders all stats fields with correct Hz", () => {
+ const sw = new StatsWindow(activity);
+
+ sw.displayInfo({
+ duples: 5,
+ triplets: 2,
+ quintuplets: 0,
+ pitchNames: new Set(["A", "C#", "E"]),
+ numberOfNotes: 20,
+ lowestNote: ["A4", 60, 440],
+ highestNote: ["C5", 72, 523.25],
+ rests: 4,
+ ornaments: 1
+ });
+
+ const html = sw.jsonObject.innerHTML;
+ expect(html).toContain("duples: 5");
+ expect(html).toContain("triplets: 2");
+ expect(html).toContain("quintuplets: 0");
+ expect(html).toContain("pitch names: A, C#, E");
+ expect(html).toContain("number of notes: 20");
+ expect(html).toContain("lowest note: A4");
+ expect(html).toContain("441Hz"); // 440 + 0.5 rounded
+ expect(html).toContain("highest note: C5");
+ expect(html).toContain("524Hz"); // 523.25 + 0.5 rounded
+ expect(html).toContain("rests used: 4");
+ expect(html).toContain("ornaments used: 1");
+ });
+});