Skip to content

Commit a3670c8

Browse files
committed
test: add unit tests for statistics.js with 100% coverage
1 parent 75bb85d commit a3670c8

File tree

2 files changed

+211
-0
lines changed

2 files changed

+211
-0
lines changed

js/widgets/statistics.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,3 +116,7 @@ class StatsWindow {
116116
<li>ornaments used: ${stats["ornaments"]}</li>`;
117117
}
118118
}
119+
/* istanbul ignore next */
120+
if (typeof module !== "undefined") {
121+
module.exports = StatsWindow;
122+
}

js/widgets/statistics.test.js

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
/**
2+
* MusicBlocks v3.6.2
3+
*
4+
* @author Divyam Agarwal
5+
*
6+
* @copyright 2026 Divyam Agarwal
7+
*
8+
* @license
9+
* This program is free software: you can redistribute it and/or modify
10+
* it under the terms of the GNU Affero General Public License as published by
11+
* the Free Software Foundation, either version 3 of the License, or
12+
* (at your option) any later version.
13+
*
14+
* This program is distributed in the hope that it will be useful,
15+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
16+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17+
* GNU Affero General Public License for more details.
18+
*
19+
* You should have received a copy of the GNU Affero General Public License
20+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
21+
*/
22+
23+
const StatsWindow = require("./statistics.js");
24+
25+
describe("StatsWindow", () => {
26+
let mockActivity;
27+
let mockWidgetWindow;
28+
let mockWidgetBody;
29+
let capturedCallback;
30+
31+
beforeEach(() => {
32+
capturedCallback = null;
33+
34+
mockWidgetBody = document.createElement("div");
35+
36+
// 1. Mock the Activity object
37+
mockActivity = {
38+
blocks: { showBlocks: jest.fn(), hideBlocks: jest.fn(), activeBlock: null },
39+
logo: { statsWindow: null },
40+
loading: false,
41+
showBlocksAfterRun: true
42+
};
43+
44+
// 2. Mock the Widget Window
45+
mockWidgetWindow = {
46+
clear: jest.fn(),
47+
show: jest.fn(),
48+
destroy: jest.fn(),
49+
sendToCenter: jest.fn(),
50+
onclose: null,
51+
onmaximize: null,
52+
getWidgetBody: jest.fn().mockReturnValue(mockWidgetBody),
53+
getWidgetFrame: jest
54+
.fn()
55+
.mockReturnValue({ getBoundingClientRect: () => ({ height: 500 }) }),
56+
isMaximized: jest.fn().mockReturnValue(false)
57+
};
58+
59+
// 3. Mock window.widgetWindows
60+
window.widgetWindows = {
61+
windowFor: jest.fn().mockReturnValue(mockWidgetWindow)
62+
};
63+
64+
// 4. Mock docById to return a fake Canvas with getContext
65+
global.docById = jest.fn().mockReturnValue({
66+
getContext: jest.fn().mockReturnValue({})
67+
});
68+
69+
// 5. Mock external analysis functions (global scope)
70+
global.analyzeProject = jest.fn().mockReturnValue({});
71+
global.runAnalytics = jest.fn();
72+
global.scoreToChartData = jest.fn().mockReturnValue({});
73+
global.getChartOptions = jest.fn().mockImplementation(cb => {
74+
capturedCallback = cb;
75+
return {};
76+
});
77+
78+
// 6. Mock the Chart.js library — Radar returns an object with toBase64Image
79+
global.Chart = jest.fn().mockImplementation(() => ({
80+
Radar: jest.fn().mockReturnValue({
81+
toBase64Image: jest.fn().mockReturnValue("data:image/png;base64,fakedata")
82+
})
83+
}));
84+
});
85+
86+
test("constructor initialises the widget and calls doAnalytics", () => {
87+
const sw = new StatsWindow(mockActivity);
88+
89+
expect(window.widgetWindows.windowFor).toHaveBeenCalledWith(sw, "stats", "stats");
90+
expect(mockWidgetWindow.clear).toHaveBeenCalled();
91+
expect(mockWidgetWindow.show).toHaveBeenCalled();
92+
expect(mockWidgetWindow.sendToCenter).toHaveBeenCalled();
93+
expect(sw.isOpen).toBe(true);
94+
// doAnalytics was called during construction
95+
expect(global.analyzeProject).toHaveBeenCalledWith(mockActivity);
96+
expect(global.runAnalytics).toHaveBeenCalledWith(mockActivity);
97+
});
98+
99+
test("onclose sets isOpen to false and cleans up", () => {
100+
const sw = new StatsWindow(mockActivity);
101+
102+
// Trigger the onclose callback
103+
mockWidgetWindow.onclose();
104+
105+
expect(sw.isOpen).toBe(false);
106+
expect(mockActivity.blocks.showBlocks).toHaveBeenCalled();
107+
expect(mockWidgetWindow.destroy).toHaveBeenCalled();
108+
expect(mockActivity.logo.statsWindow).toBeNull();
109+
});
110+
111+
test("onmaximize when maximized sets flex layout and re-runs analytics", () => {
112+
const sw = new StatsWindow(mockActivity);
113+
114+
// Reset call counts from constructor
115+
global.analyzeProject.mockClear();
116+
global.runAnalytics.mockClear();
117+
118+
// Set isMaximized to return true
119+
mockWidgetWindow.isMaximized.mockReturnValue(true);
120+
121+
// Trigger onmaximize
122+
mockWidgetWindow.onmaximize();
123+
124+
expect(mockWidgetBody.style.display).toBe("flex");
125+
expect(mockWidgetBody.style.justifyContent).toBe("space-between");
126+
expect(mockWidgetBody.style.padding).toBe("0px 2vw");
127+
// doAnalytics is re-called
128+
expect(global.analyzeProject).toHaveBeenCalled();
129+
});
130+
131+
test("onmaximize when not maximized resets padding", () => {
132+
const sw = new StatsWindow(mockActivity);
133+
134+
mockWidgetWindow.isMaximized.mockReturnValue(false);
135+
mockWidgetWindow.onmaximize();
136+
137+
expect(mockWidgetBody.style.padding).toBe("0px 0px");
138+
});
139+
140+
test("__callback in doAnalytics sets image width to 200 when not maximized", () => {
141+
mockWidgetWindow.isMaximized.mockReturnValue(false);
142+
const sw = new StatsWindow(mockActivity);
143+
144+
// capturedCallback was captured by our getChartOptions mock
145+
expect(capturedCallback).not.toBeNull();
146+
capturedCallback();
147+
148+
// An <img> should have been appended to the widget body
149+
const imgs = mockWidgetBody.querySelectorAll("img");
150+
expect(imgs.length).toBeGreaterThanOrEqual(1);
151+
const img = imgs[imgs.length - 1];
152+
expect(img.width).toBe(200);
153+
154+
expect(mockActivity.blocks.hideBlocks).toHaveBeenCalled();
155+
expect(mockActivity.showBlocksAfterRun).toBe(false);
156+
expect(document.body.style.cursor).toBe("default");
157+
});
158+
159+
test("__callback in doAnalytics sets image width from frame height when maximized", () => {
160+
mockWidgetWindow.isMaximized.mockReturnValue(true);
161+
const sw = new StatsWindow(mockActivity);
162+
163+
expect(capturedCallback).not.toBeNull();
164+
capturedCallback();
165+
166+
const imgs = mockWidgetBody.querySelectorAll("img");
167+
expect(imgs.length).toBeGreaterThanOrEqual(1);
168+
const img = imgs[imgs.length - 1];
169+
// height (500) - 80 = 420
170+
expect(img.width).toBe(420);
171+
});
172+
173+
test("displayInfo correctly formats all note statistics and Hz calculations", () => {
174+
const sw = new StatsWindow(mockActivity);
175+
176+
const mockStats = {
177+
duples: 5,
178+
triplets: 2,
179+
quintuplets: 0,
180+
pitchNames: new Set(["A", "C#", "E"]),
181+
numberOfNotes: 20,
182+
lowestNote: ["A4", 60, 440],
183+
highestNote: ["C5", 72, 523.25],
184+
rests: 4,
185+
ornaments: 1
186+
};
187+
188+
sw.displayInfo(mockStats);
189+
190+
const html = sw.jsonObject.innerHTML;
191+
192+
// Hz calculations: 440 + 0.5 = 440.5 → "441Hz"; 523.25 + 0.5 = 523.75 → "524Hz"
193+
expect(html).toContain("441Hz");
194+
expect(html).toContain("524Hz");
195+
196+
// All stat fields
197+
expect(html).toContain("duples: 5");
198+
expect(html).toContain("triplets: 2");
199+
expect(html).toContain("quintuplets: 0");
200+
expect(html).toContain("pitch names: A, C#, E");
201+
expect(html).toContain("number of notes: 20");
202+
expect(html).toContain("lowest note: A4");
203+
expect(html).toContain("highest note: C5");
204+
expect(html).toContain("rests used: 4");
205+
expect(html).toContain("ornaments used: 1");
206+
});
207+
});

0 commit comments

Comments
 (0)