@robotical/martyblocksjr 4.1.2 → 4.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/.firebase/hosting.ZWRpdGlvbnMvZnJlZS9zcmM.cache +599 -534
  2. package/editions/free/src/app.bundle.js +1 -1
  3. package/editions/free/src/assets/blockicons/microbitbuttona.svg +33 -0
  4. package/editions/free/src/assets/blockicons/microbitbuttonab.svg +33 -0
  5. package/editions/free/src/assets/blockicons/microbitbuttonb.svg +33 -0
  6. package/editions/free/src/assets/blockicons/microbitdisplayclear.svg +29 -0
  7. package/editions/free/src/assets/blockicons/microbitdisplayhappy.svg +36 -0
  8. package/editions/free/src/assets/blockicons/microbitdisplayheart.svg +42 -0
  9. package/editions/free/src/assets/blockicons/microbitdisplaytext.svg +29 -0
  10. package/editions/free/src/assets/blockicons/microbitgesturejumped.svg +40 -0
  11. package/editions/free/src/assets/blockicons/microbitgesturemoved.svg +41 -0
  12. package/editions/free/src/assets/blockicons/microbitgestureshaken.svg +45 -0
  13. package/editions/free/src/assets/blockicons/microbittiltany.svg +45 -0
  14. package/editions/free/src/assets/blockicons/microbittiltbackward.svg +39 -0
  15. package/editions/free/src/assets/blockicons/microbittiltforward.svg +39 -0
  16. package/editions/free/src/assets/blockicons/microbittiltleft.svg +39 -0
  17. package/editions/free/src/assets/blockicons/microbittiltright.svg +39 -0
  18. package/editions/free/src/assets/categories/MicroBitLooksOff.svg +17 -0
  19. package/editions/free/src/assets/categories/MicroBitLooksOn.svg +17 -0
  20. package/editions/free/src/assets/categories/MicroBitStartOff.svg +17 -0
  21. package/editions/free/src/assets/categories/MicroBitStartOn.svg +17 -0
  22. package/editions/free/src/assets/ui/viewOnCompact.png +0 -0
  23. package/editions/free/src/assets/ui/viewOnTargetCompact.png +0 -0
  24. package/editions/free/src/css/editor.css +334 -1
  25. package/editions/free/src/css/editorleftpanel.css +86 -26
  26. package/editions/free/src/css/editorstage.css +62 -0
  27. package/editions/free/src/css/paintlook.css +23 -0
  28. package/editions/free/src/localizations/bg.json +10 -1
  29. package/editions/free/src/localizations/ca.json +10 -1
  30. package/editions/free/src/localizations/cs.json +10 -1
  31. package/editions/free/src/localizations/cy.json +10 -1
  32. package/editions/free/src/localizations/da.json +10 -1
  33. package/editions/free/src/localizations/de.json +10 -1
  34. package/editions/free/src/localizations/el.json +10 -1
  35. package/editions/free/src/localizations/en.json +10 -1
  36. package/editions/free/src/localizations/es.json +10 -1
  37. package/editions/free/src/localizations/fi.json +10 -1
  38. package/editions/free/src/localizations/fr.json +10 -1
  39. package/editions/free/src/localizations/it.json +10 -1
  40. package/editions/free/src/localizations/ja.json +10 -1
  41. package/editions/free/src/localizations/ko.json +10 -1
  42. package/editions/free/src/localizations/nl.json +10 -1
  43. package/editions/free/src/localizations/no.json +10 -1
  44. package/editions/free/src/localizations/pl.json +10 -1
  45. package/editions/free/src/localizations/pt-br.json +10 -1
  46. package/editions/free/src/localizations/pt.json +10 -1
  47. package/editions/free/src/localizations/sv.json +10 -1
  48. package/editions/free/src/localizations/th.json +10 -1
  49. package/editions/free/src/localizations/tr.json +10 -1
  50. package/editions/free/src/localizations/uk.json +10 -1
  51. package/editions/free/src/localizations/zh-cn.json +10 -1
  52. package/editions/free/src/localizations/zh-tw.json +10 -1
  53. package/editions/free/src/media.json +15 -0
  54. package/editions/free/src/pnglibrary/BeeSprite1.png +0 -0
  55. package/editions/free/src/svglibrary/BeeSprite1.svg +4 -0
  56. package/editions/free/src/svglibrary/BeeSprite2.svg +4 -0
  57. package/editions/free/src/svglibrary/BeeSprite3.svg +4 -0
  58. package/package.json +1 -1
  59. package/tests/e2e/accessibility.e2e.test.js +18 -2
  60. package/tests/e2e/animated-sprite-paint-disabled.e2e.test.js +270 -0
  61. package/tests/e2e/background-paint-active-selection.e2e.test.js +162 -0
  62. package/tests/e2e/blocks-jr-hidden-blocks.e2e.test.js +262 -0
  63. package/tests/e2e/chromium-79-smoke.test.js +235 -0
  64. package/tests/e2e/delete-last-sprite-mode.e2e.test.js +162 -0
  65. package/tests/e2e/duplicate-page.e2e.test.js +414 -0
  66. package/tests/e2e/marty-connection-ui.e2e.test.js +56 -0
  67. package/tests/e2e/marty-script-activation-paths.e2e.test.js +522 -0
  68. package/tests/e2e/microbit-blocks.test.js +162 -0
  69. package/tests/e2e/microbit-extension-project-reload.e2e.test.js +309 -0
  70. package/tests/e2e/microbit-web-bluetooth.test.js +131 -0
  71. package/tests/e2e/page-background-loading.test.js +152 -0
  72. package/tests/e2e/paint-editor-cancel.e2e.test.js +274 -0
  73. package/tests/e2e/project-load-progress-loop.e2e.test.js +310 -0
  74. package/tests/e2e/project-save-stall.e2e.test.js +230 -0
  75. package/tests/e2e/sprite-watermark-grayscale.e2e.test.js +269 -0
  76. package/tests/e2e/top-toolbar-tooltips.e2e.test.js +152 -0
  77. package/vitest.config.js +2 -3
@@ -0,0 +1,309 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
2
+ import puppeteer from "puppeteer";
3
+ import { spawn } from "child_process";
4
+ import http from "http";
5
+
6
+ const PORT = 3013;
7
+ const HOST = `http://localhost:${PORT}`;
8
+ const MICROBIT_BLOCK_TYPE = "microbitdisplayclear";
9
+
10
+ let server;
11
+
12
+ async function waitForServer(maxAttempts = 20) {
13
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
14
+ const ok = await new Promise((resolve) => {
15
+ const req = http.get(`${HOST}/`, (res) => {
16
+ res.destroy();
17
+ resolve(true);
18
+ });
19
+ req.on("error", () => resolve(false));
20
+ });
21
+ if (ok) {
22
+ return;
23
+ }
24
+ await new Promise((resolve) => setTimeout(resolve, 500));
25
+ }
26
+ throw new Error("Local server did not start in time");
27
+ }
28
+
29
+ beforeAll(async () => {
30
+ server = spawn("python3", ["-m", "http.server", `${PORT}`, "--directory", "editions/free/src"], {
31
+ stdio: "ignore",
32
+ });
33
+ await waitForServer();
34
+ }, 30_000);
35
+
36
+ afterAll(() => {
37
+ if (server) {
38
+ server.kill();
39
+ }
40
+ });
41
+
42
+ async function openPage(pathname) {
43
+ const browser = await puppeteer.launch({
44
+ headless: true,
45
+ args: ["--no-sandbox"],
46
+ });
47
+ const page = await browser.newPage();
48
+ const errors = [];
49
+
50
+ page.on("pageerror", (err) => errors.push(err.message || String(err)));
51
+ page.on("console", (msg) => {
52
+ if (msg.type() === "error") {
53
+ errors.push(msg.text());
54
+ }
55
+ });
56
+
57
+ await page.goto(`${HOST}${pathname}`, { waitUntil: "networkidle2", timeout: 30_000 });
58
+
59
+ return { browser, page, errors };
60
+ }
61
+
62
+ async function createProjectFromHome(page) {
63
+ await page.waitForSelector("#newproject .card-action-open", { timeout: 30_000 });
64
+ await Promise.all([
65
+ page.waitForNavigation({ waitUntil: "networkidle2", timeout: 30_000 }),
66
+ page.click("#newproject .card-action-open"),
67
+ ]);
68
+ expect(page.url()).toContain("/editor.html");
69
+ expect(page.url()).toContain("mode=edit");
70
+ await waitForEditorReady(page);
71
+ return page.evaluate(() => window.ScratchJr.currentProject);
72
+ }
73
+
74
+ async function waitForEditorReady(page) {
75
+ await page.waitForFunction(
76
+ () => {
77
+ const backdrop = document.getElementById("backdrop");
78
+ return Boolean(
79
+ window.ScratchJr
80
+ && window.ScratchJr.stage
81
+ && window.ScratchJr.stage.currentPage
82
+ && document.getElementById("addExtensionButton")
83
+ && document.getElementById("martyMode")
84
+ && backdrop
85
+ && window.getComputedStyle(backdrop).display === "none"
86
+ && !window.ScratchJr.onHold
87
+ && window.ScratchJr.getActiveScript()
88
+ );
89
+ },
90
+ { timeout: 30_000 }
91
+ );
92
+ }
93
+
94
+ async function waitForProjectStateLoaded(page) {
95
+ await page.waitForFunction(
96
+ () => Boolean(
97
+ window.ScratchJr
98
+ && window.ScratchJr.stage
99
+ && window.ScratchJr.stage.currentPage
100
+ && window.ScratchJr.getActiveScript()
101
+ && document.querySelector("#selectorsright .category-selector-button")
102
+ ),
103
+ { timeout: 30_000 }
104
+ );
105
+ }
106
+
107
+ async function enableMartyMode(page) {
108
+ const alreadyEnabled = await page.evaluate(() => window.ScratchJr.isMartyModeEnabled);
109
+ if (!alreadyEnabled) {
110
+ await page.click("#martyMode");
111
+ }
112
+ await page.waitForFunction(
113
+ () => window.ScratchJr.isMartyModeEnabled
114
+ && window.ScratchJr.stage.currentPage.currentSpriteName
115
+ && window.ScratchJr.stage.currentPage.currentSpriteName.indexOf(window.ScratchJr.BIRDS_EYE_SPRITE_NAME) > -1
116
+ && window.ScratchJr.getActiveScript(),
117
+ { timeout: 30_000 }
118
+ );
119
+ }
120
+
121
+ async function enableMicroBitExtension(page) {
122
+ await page.$eval("#addExtensionButton", (button) => {
123
+ if (!button.disabled || window.getComputedStyle(button).display !== "none") {
124
+ throw new Error("Expected the extensions entry point to remain hidden and disabled");
125
+ }
126
+ button.onclick();
127
+ });
128
+ await page.waitForSelector("#extensionsLibrary.fade.in", { timeout: 30_000 });
129
+ await page.click("#microBitExtensionCard");
130
+ await page.waitForFunction(
131
+ () => {
132
+ const microBitButton = document.getElementById("microBitConnectionButton");
133
+ const library = document.getElementById("extensionsLibrary");
134
+ return window.ScratchJr.isMicroBitExtensionEnabled
135
+ && microBitButton
136
+ && window.getComputedStyle(microBitButton).display !== "none"
137
+ && document.querySelector("#selectorsright #microbit-start")
138
+ && document.querySelector("#selectorsright #microbit-looks")
139
+ && library
140
+ && !library.classList.contains("in");
141
+ },
142
+ { timeout: 30_000 }
143
+ );
144
+ }
145
+
146
+ async function insertMicroBitBlock(page) {
147
+ await page.click("#microbit-looks");
148
+ await page.waitForFunction(
149
+ (blockType) => Boolean(document.querySelector(`#palette [data-blocktype="${blockType}"]`)),
150
+ { timeout: 30_000 },
151
+ MICROBIT_BLOCK_TYPE
152
+ );
153
+
154
+ await page.evaluate((blockType) => {
155
+ const blockElement = document.querySelector(`#palette [data-blocktype="${blockType}"]`);
156
+ window.Palette.insertBlockFromKeyboard(blockElement, new Event("microbit-reload-test"));
157
+ }, MICROBIT_BLOCK_TYPE);
158
+
159
+ await page.waitForFunction(
160
+ (blockType) => window.ScratchJr.getBlocks().some((block) => block.blocktype === blockType),
161
+ { timeout: 30_000 },
162
+ MICROBIT_BLOCK_TYPE
163
+ );
164
+ }
165
+
166
+ async function saveProjectAndWaitForBlock(page, projectId) {
167
+ await page.evaluate(() => new Promise((resolve) => {
168
+ window.ScratchJr.changed = true;
169
+ window.ScratchJr.saveProject(null, resolve);
170
+ }));
171
+
172
+ await waitForPersistedProject(page, projectId, (project) => projectHasBlock(project, MICROBIT_BLOCK_TYPE));
173
+ }
174
+
175
+ async function waitForPersistedProject(page, projectId, predicate) {
176
+ const startedAt = Date.now();
177
+ let lastProject = null;
178
+
179
+ while (Date.now() - startedAt < 15_000) {
180
+ lastProject = await readPersistedProject(page, projectId);
181
+ if (lastProject && predicate(lastProject)) {
182
+ return lastProject;
183
+ }
184
+ await new Promise((resolve) => setTimeout(resolve, 250));
185
+ }
186
+
187
+ throw new Error(`Timed out waiting for saved project state. Last project: ${JSON.stringify(lastProject)}`);
188
+ }
189
+
190
+ async function readPersistedProject(page, projectId) {
191
+ return page.evaluate((id) => new Promise((resolve) => {
192
+ window.OS.query(
193
+ { stmt: "select * from projects where id = ?", values: [Number(id)] },
194
+ (result) => {
195
+ const rows = JSON.parse(result);
196
+ if (!rows.length) {
197
+ resolve(null);
198
+ return;
199
+ }
200
+ const row = Object.fromEntries(Object.entries(rows[0]).map(([key, value]) => [key.toLowerCase(), value]));
201
+ resolve(typeof row.json === "string" ? JSON.parse(row.json) : row.json);
202
+ }
203
+ );
204
+ }), projectId);
205
+ }
206
+
207
+ function projectHasBlock(project, blockType) {
208
+ if (!project || !Array.isArray(project.pages)) {
209
+ return false;
210
+ }
211
+ return project.pages.some((pageId) => {
212
+ const page = project[pageId];
213
+ return page && Array.isArray(page.sprites) && page.sprites.some((spriteId) => {
214
+ const sprite = page[spriteId];
215
+ return sprite && Array.isArray(sprite.scripts) && sprite.scripts.some((strip) => {
216
+ return Array.isArray(strip) && strip.some((block) => Array.isArray(block) && block[0] === blockType);
217
+ });
218
+ });
219
+ });
220
+ }
221
+
222
+ async function hardReload(page) {
223
+ const client = await page.target().createCDPSession();
224
+ await client.send("Network.enable");
225
+ await client.send("Network.setCacheDisabled", { cacheDisabled: true });
226
+ try {
227
+ await page.reload({ waitUntil: "networkidle2", timeout: 30_000 });
228
+ } finally {
229
+ await client.send("Network.setCacheDisabled", { cacheDisabled: false });
230
+ await client.detach();
231
+ }
232
+ }
233
+
234
+ async function getLoadedProjectState(page) {
235
+ return page.evaluate((blockType) => {
236
+ const activeBlocks = window.ScratchJr.getBlocks().map((block) => block.blocktype);
237
+ const currentSpriteName = window.ScratchJr.stage.currentPage.currentSpriteName || "";
238
+ return {
239
+ currentProject: window.ScratchJr.currentProject,
240
+ isMartyModeEnabled: window.ScratchJr.isMartyModeEnabled,
241
+ isMicroBitExtensionEnabled: window.ScratchJr.isMicroBitExtensionEnabled,
242
+ activeSpriteIsMartyBirdsEye: currentSpriteName.indexOf(window.ScratchJr.BIRDS_EYE_SPRITE_NAME) > -1,
243
+ activeBlocks,
244
+ hasMicroBitBlockInActiveScript: activeBlocks.indexOf(blockType) > -1,
245
+ rightCategoryIds: Array.from(document.querySelectorAll("#selectorsright .category-selector-button")).map(
246
+ (button) => button.id
247
+ ),
248
+ microBitButtonVisible:
249
+ window.getComputedStyle(document.getElementById("microBitConnectionButton")).display !== "none",
250
+ extensionEnabledClass: document.getElementById("connectionButtonsArea").classList.contains("extensionEnabled"),
251
+ };
252
+ }, MICROBIT_BLOCK_TYPE);
253
+ }
254
+
255
+ function expectLoadedMicroBitMartyProjectStates(states, projectId) {
256
+ const expectedState = {
257
+ currentProject: projectId,
258
+ isMartyModeEnabled: true,
259
+ activeSpriteIsMartyBirdsEye: true,
260
+ isMicroBitExtensionEnabled: true,
261
+ microBitButtonVisible: true,
262
+ extensionEnabledClass: true,
263
+ hasMicroBitBlockInActiveScript: true,
264
+ rightCategoryIds: ["cog-start", "cog-looks", "cog-sound", "microbit-start", "microbit-looks"],
265
+ };
266
+ expect(states).toMatchObject({
267
+ beforeReload: expectedState,
268
+ afterPlainReload: expectedState,
269
+ afterHardReload: expectedState,
270
+ });
271
+ }
272
+
273
+ describe("micro:bit extension project reload", () => {
274
+ it(
275
+ "keeps the saved micro:bit extension, active Marty mode, and block after plain and hard refreshes",
276
+ async () => {
277
+ const { browser, page, errors } = await openPage("/home.html?place=home");
278
+
279
+ try {
280
+ const projectId = await createProjectFromHome(page);
281
+
282
+ await enableMartyMode(page);
283
+ await enableMicroBitExtension(page);
284
+ await insertMicroBitBlock(page);
285
+ await saveProjectAndWaitForBlock(page, projectId);
286
+
287
+ const beforeReload = await getLoadedProjectState(page);
288
+ await page.reload({ waitUntil: "networkidle2", timeout: 30_000 });
289
+ await waitForEditorReady(page);
290
+ const afterPlainReload = await getLoadedProjectState(page);
291
+
292
+ await hardReload(page);
293
+ await waitForProjectStateLoaded(page);
294
+ const afterHardReload = await getLoadedProjectState(page);
295
+
296
+ expectLoadedMicroBitMartyProjectStates({
297
+ beforeReload,
298
+ afterPlainReload,
299
+ afterHardReload,
300
+ }, projectId);
301
+
302
+ expect(errors).toEqual([]);
303
+ } finally {
304
+ await browser.close();
305
+ }
306
+ },
307
+ 120_000
308
+ );
309
+ });
@@ -0,0 +1,131 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import MicroBitWebBluetooth from '@/microbit/MicroBitWebBluetooth.js';
3
+
4
+ describe('MicroBitWebBluetooth', () => {
5
+ beforeEach(() => {
6
+ vi.useFakeTimers();
7
+ vi.stubGlobal('window', {
8
+ setTimeout,
9
+ clearTimeout
10
+ });
11
+ });
12
+
13
+ afterEach(() => {
14
+ vi.runOnlyPendingTimers();
15
+ vi.useRealTimers();
16
+ vi.unstubAllGlobals();
17
+ });
18
+
19
+ it('parses incoming sensor packets using the Scratch micro:bit protocol layout', () => {
20
+ const microBit = new MicroBitWebBluetooth();
21
+ const listener = vi.fn();
22
+ microBit.addSensorListener(listener);
23
+
24
+ microBit.handleSensorData(new DataView(Uint8Array.from([
25
+ 0xff, 0x38, // tiltX: -200
26
+ 0x00, 0xc8, // tiltY: 200
27
+ 1, 0, // button A/B
28
+ 1, 0, 1, // touch pins 0/1/2
29
+ 5 // moved + shaken gesture bits
30
+ ]).buffer));
31
+
32
+ expect(microBit.sensors).toEqual({
33
+ tiltX: -200,
34
+ tiltY: 200,
35
+ buttonA: 1,
36
+ buttonB: 0,
37
+ touchPins: [1, 0, 1],
38
+ gestureState: 5
39
+ });
40
+ expect(listener).toHaveBeenCalledWith(
41
+ microBit.sensors,
42
+ {
43
+ tiltX: 0,
44
+ tiltY: 0,
45
+ buttonA: 0,
46
+ buttonB: 0,
47
+ touchPins: [0, 0, 0],
48
+ gestureState: 0
49
+ }
50
+ );
51
+ expect(microBit.receiveTimeoutId).not.toBe(null);
52
+ });
53
+
54
+ it('sends display commands with the expected command byte prefix', async () => {
55
+ const microBit = new MicroBitWebBluetooth();
56
+ const writeValueWithResponse = vi.fn(() => Promise.resolve());
57
+ microBit.server = {connected: true};
58
+ microBit.txCharacteristic = {writeValueWithResponse};
59
+
60
+ const result = microBit.displayMatrix(new Uint8Array([10, 31, 31, 14, 4]));
61
+
62
+ expect(microBit.busy).toBe(true);
63
+ await expect(result).resolves.toBe(true);
64
+ expect(writeValueWithResponse).toHaveBeenCalledWith(new Uint8Array([0x82, 10, 31, 31, 14, 4]));
65
+ expect(microBit.busy).toBe(false);
66
+ expect(microBit.busyTimeoutId).toBe(null);
67
+ });
68
+
69
+ it('cleans up direct disconnects without leaking rejected stopNotifications calls', async () => {
70
+ const microBit = new MicroBitWebBluetooth();
71
+ const disconnectListener = vi.fn();
72
+ const removeEventListener = vi.fn();
73
+ const stopNotifications = vi.fn(() => Promise.reject(new Error('already disconnected')));
74
+ microBit.addDisconnectListener(disconnectListener);
75
+ microBit.device = {removeEventListener};
76
+ microBit.server = {
77
+ connected: true,
78
+ disconnect: vi.fn()
79
+ };
80
+ microBit.rxCharacteristic = {
81
+ removeEventListener,
82
+ stopNotifications
83
+ };
84
+ microBit.txCharacteristic = {};
85
+ microBit.resetReceiveTimeout();
86
+
87
+ microBit.disconnect();
88
+ await Promise.resolve();
89
+
90
+ expect(stopNotifications).toHaveBeenCalled();
91
+ expect(microBit.server).toBe(null);
92
+ expect(microBit.rxCharacteristic).toBe(null);
93
+ expect(microBit.txCharacteristic).toBe(null);
94
+ expect(microBit.receiveTimeoutId).toBe(null);
95
+ expect(disconnectListener).toHaveBeenCalledTimes(1);
96
+ });
97
+
98
+ it('cleans timers and listeners immediately when the browser reports a GATT disconnect', () => {
99
+ const microBit = new MicroBitWebBluetooth();
100
+ const disconnectListener = vi.fn();
101
+ const removeRxListener = vi.fn();
102
+ const removeDeviceListener = vi.fn();
103
+ microBit.addDisconnectListener(disconnectListener);
104
+ microBit.device = {removeEventListener: removeDeviceListener};
105
+ microBit.server = {connected: false};
106
+ microBit.rxCharacteristic = {removeEventListener: removeRxListener};
107
+ microBit.txCharacteristic = {};
108
+ microBit.resetReceiveTimeout();
109
+ microBit.busy = true;
110
+ microBit.busyTimeoutId = window.setTimeout(() => {}, 5000);
111
+
112
+ microBit.handleGattServerDisconnected();
113
+ vi.advanceTimersByTime(5000);
114
+
115
+ expect(removeRxListener).toHaveBeenCalledWith(
116
+ 'characteristicvaluechanged',
117
+ microBit.handleCharacteristicValueChanged
118
+ );
119
+ expect(removeDeviceListener).toHaveBeenCalledWith(
120
+ 'gattserverdisconnected',
121
+ microBit.handleGattServerDisconnected
122
+ );
123
+ expect(microBit.server).toBe(null);
124
+ expect(microBit.rxCharacteristic).toBe(null);
125
+ expect(microBit.txCharacteristic).toBe(null);
126
+ expect(microBit.receiveTimeoutId).toBe(null);
127
+ expect(microBit.busyTimeoutId).toBe(null);
128
+ expect(microBit.busy).toBe(false);
129
+ expect(disconnectListener).toHaveBeenCalledTimes(1);
130
+ });
131
+ });
@@ -0,0 +1,152 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ const { mockProject, mockGn, mockSetProps } = vi.hoisted(() => ({
4
+ mockProject: {
5
+ setProgress: vi.fn(),
6
+ getMediaLoadRatio: vi.fn(() => 72)
7
+ },
8
+ mockGn: vi.fn(id => {
9
+ if (id === 'backdrop') {
10
+ return { className: 'modal-backdrop fade in' };
11
+ }
12
+ return null;
13
+ }),
14
+ mockSetProps: vi.fn((target, props) => Object.assign(target, props))
15
+ }));
16
+
17
+ vi.mock('@/editor/ScratchJr', () => ({
18
+ default: {
19
+ BIRDS_EYE_SPRITE_NAME: "Marty Bird's Eye",
20
+ getSprite: vi.fn(),
21
+ inFullscreen: false,
22
+ isMartyModeEnabled: false,
23
+ onHold: false,
24
+ stage: {
25
+ currentPage: {},
26
+ pages: [],
27
+ pagesdiv: {},
28
+ stagecolor: '#fff'
29
+ },
30
+ stagecolor: '#fff',
31
+ storyStart: vi.fn()
32
+ }
33
+ }));
34
+ vi.mock('@/editor/ui/Project', () => ({ default: mockProject }));
35
+ vi.mock('@/editor/ui/Thumbs', () => ({ default: {} }));
36
+ vi.mock('@/editor/ui/UI', () => ({ default: {} }));
37
+ vi.mock('@/editor/engine/Sprite', () => ({ default: vi.fn() }));
38
+ vi.mock('@/editor/ui/Palette', () => ({ default: {} }));
39
+ vi.mock('@/editor/blocks/BlockSpecs', () => ({ default: {} }));
40
+ vi.mock('@/tablet/OS', () => ({ default: {} }));
41
+ vi.mock('@/tablet/IO', () => ({ default: {} }));
42
+ vi.mock('@/tablet/MediaLib', () => ({ default: { keys: {}, path: '' } }));
43
+ vi.mock('@/editor/ui/Undo', () => ({ default: {} }));
44
+ vi.mock('@/geom/Matrix', () => ({ default: vi.fn() }));
45
+ vi.mock('@/geom/Vector', () => ({ default: vi.fn() }));
46
+ vi.mock('@/utils/lib', () => ({
47
+ DEGTOR: Math.PI / 180,
48
+ getIdFor: vi.fn(name => name),
49
+ gn: mockGn,
50
+ isTablet: false,
51
+ newDiv: vi.fn(),
52
+ newHTML: vi.fn(),
53
+ setCanvasSizeScaledToWindowDocumentHeight: vi.fn(),
54
+ setProps: mockSetProps
55
+ }));
56
+
57
+ describe('Page background loading', () => {
58
+ let Page;
59
+ let nextImage;
60
+ let createdImage;
61
+
62
+ beforeEach(async () => {
63
+ vi.useFakeTimers();
64
+ vi.clearAllMocks();
65
+ nextImage = null;
66
+ createdImage = null;
67
+ global.document = {
68
+ createElement: vi.fn(tag => {
69
+ expect(tag).toBe('img');
70
+ createdImage = nextImage || createImage();
71
+ return createdImage;
72
+ })
73
+ };
74
+ ({ default: Page } = await import('@/editor/engine/Page.js'));
75
+ });
76
+
77
+ afterEach(() => {
78
+ vi.runOnlyPendingTimers();
79
+ vi.useRealTimers();
80
+ delete global.document;
81
+ });
82
+
83
+ it('finishes once when a background image emits an error', () => {
84
+ const page = createPage();
85
+ const done = vi.fn();
86
+
87
+ Page.prototype.setBackgroundImage.call(page, 'missing-background.svg', done);
88
+ createdImage.onerror();
89
+ createdImage.onload();
90
+ vi.runAllTimers();
91
+
92
+ expect(page.clearBackground).toHaveBeenCalledTimes(1);
93
+ expect(done).toHaveBeenCalledTimes(1);
94
+ expect(mockProject.setProgress).not.toHaveBeenCalled();
95
+ });
96
+
97
+ it('finishes when a cached broken image is already complete', () => {
98
+ nextImage = createImage({ complete: true, naturalWidth: 0, naturalHeight: 0 });
99
+ const page = createPage();
100
+ const done = vi.fn();
101
+
102
+ Page.prototype.setBackgroundImage.call(page, 'cached-broken-background.svg', done);
103
+
104
+ expect(page.clearBackground).toHaveBeenCalledTimes(1);
105
+ expect(done).toHaveBeenCalledTimes(1);
106
+ expect(mockProject.setProgress).not.toHaveBeenCalled();
107
+ });
108
+
109
+ it('updates progress and finishes once when a background image loads', () => {
110
+ const page = createPage();
111
+ const done = vi.fn();
112
+
113
+ Page.prototype.setBackgroundImage.call(page, 'background.svg', done);
114
+ createdImage.onload();
115
+ createdImage.onerror();
116
+ vi.runAllTimers();
117
+
118
+ expect(page.clearBackground).not.toHaveBeenCalled();
119
+ expect(mockProject.getMediaLoadRatio).toHaveBeenCalledWith(70);
120
+ expect(mockProject.setProgress).toHaveBeenCalledWith(72);
121
+ expect(done).toHaveBeenCalledTimes(1);
122
+ });
123
+
124
+ function createPage() {
125
+ return {
126
+ bkg: {
127
+ appendChild: vi.fn(),
128
+ img: null,
129
+ originalImg: null
130
+ },
131
+ clearBackground: vi.fn()
132
+ };
133
+ }
134
+
135
+ function createImage({ complete = false, naturalWidth = 32, naturalHeight = 32 } = {}) {
136
+ return {
137
+ complete,
138
+ naturalHeight,
139
+ naturalWidth,
140
+ onerror: null,
141
+ onload: null,
142
+ style: {},
143
+ cloneNode: vi.fn(() => ({ cloned: true })),
144
+ get src() {
145
+ return this._src;
146
+ },
147
+ set src(value) {
148
+ this._src = value;
149
+ }
150
+ };
151
+ }
152
+ });