@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,522 @@
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 = 3020;
7
+ const HOST = `http://localhost:${PORT}`;
8
+
9
+ let server;
10
+ let sharedBrowser;
11
+
12
+ const waitForServer = () =>
13
+ new Promise((resolve, reject) => {
14
+ const started = Date.now();
15
+
16
+ const check = () => {
17
+ http.get(HOST, response => {
18
+ response.resume();
19
+ resolve();
20
+ }).on("error", error => {
21
+ if (Date.now() - started > 10000) {
22
+ reject(error);
23
+ } else {
24
+ setTimeout(check, 100);
25
+ }
26
+ });
27
+ };
28
+
29
+ check();
30
+ });
31
+
32
+ const openPage = async pathname => {
33
+ const browser = await sharedBrowser.createIncognitoBrowserContext();
34
+ const page = await browser.newPage();
35
+ const errors = [];
36
+
37
+ page.on("pageerror", error => errors.push(error.message));
38
+ page.on("console", message => {
39
+ if (message.type() === "error") {
40
+ errors.push(message.text());
41
+ }
42
+ });
43
+
44
+ await page.goto(`${HOST}${pathname}`, {
45
+ waitUntil: "networkidle2",
46
+ timeout: 30000
47
+ });
48
+
49
+ return { browser, page, errors };
50
+ };
51
+
52
+ const waitForEditorReady = async page => {
53
+ await page.waitForFunction(() => {
54
+ const backdrop = document.getElementById("backdrop");
55
+ return window.ScratchJr &&
56
+ ScratchJr.stage &&
57
+ ScratchJr.stage.currentPage &&
58
+ document.getElementById("martyMode") &&
59
+ document.getElementById("emptypage") &&
60
+ backdrop &&
61
+ window.getComputedStyle(backdrop).display === "none" &&
62
+ !ScratchJr.onHold &&
63
+ ScratchJr.getActiveScript();
64
+ }, { timeout: 30000 });
65
+ };
66
+
67
+ const createProjectFromHome = async page => {
68
+ await page.waitForSelector("#newproject .card-action-open", { timeout: 30000 });
69
+ await Promise.all([
70
+ page.waitForNavigation({ waitUntil: "networkidle2", timeout: 30000 }),
71
+ page.click("#newproject .card-action-open")
72
+ ]);
73
+ await waitForEditorReady(page);
74
+ return page.evaluate(() => ScratchJr.currentProject);
75
+ };
76
+
77
+ const getActiveScriptState = async page =>
78
+ page.evaluate(() => {
79
+ const script = ScratchJr.getActiveScript();
80
+ const blocks = script.owner.getBlocks();
81
+ return {
82
+ pageId: ScratchJr.stage.currentPage.id,
83
+ pageIds: ScratchJr.stage.getPagesID(),
84
+ isMartyModeEnabled: ScratchJr.isMartyModeEnabled,
85
+ spriteId: ScratchJr.stage.currentPage.currentSpriteName,
86
+ scriptId: script.id,
87
+ scriptVisibility: window.getComputedStyle(script).visibility,
88
+ blockTypes: blocks.map(block => block.blocktype),
89
+ visibleBlockTypes: blocks
90
+ .filter(block => window.getComputedStyle(block.div).visibility !== "hidden")
91
+ .map(block => block.blocktype)
92
+ };
93
+ });
94
+
95
+ const setMartyMode = async (page, enabled) => {
96
+ const currentMode = await page.evaluate(() => ScratchJr.isMartyModeEnabled);
97
+ if (currentMode !== enabled) {
98
+ await page.click("#martyMode");
99
+ }
100
+
101
+ await page.waitForFunction(expected => {
102
+ if (ScratchJr.isMartyModeEnabled !== expected || !ScratchJr.getActiveScript()) {
103
+ return false;
104
+ }
105
+ const spriteId = ScratchJr.stage.currentPage.currentSpriteName || "";
106
+ const isBirdsEye = spriteId.indexOf(ScratchJr.BIRDS_EYE_SPRITE_NAME) > -1;
107
+ return expected ? isBirdsEye : !isBirdsEye;
108
+ }, {}, enabled);
109
+ };
110
+
111
+ const reactivateCurrentMartyScript = async page => {
112
+ const pageId = await page.evaluate(() => ScratchJr.stage.currentPage.id);
113
+ await setMartyMode(page, false);
114
+ await setMartyMode(page, true);
115
+ await page.waitForFunction(expectedPageId => {
116
+ const script = ScratchJr.getActiveScript();
117
+ return ScratchJr.stage.currentPage.id === expectedPageId &&
118
+ script &&
119
+ window.getComputedStyle(script).visibility === "visible";
120
+ }, {}, pageId);
121
+ };
122
+
123
+ const createSecondMartyPage = async page => {
124
+ await setMartyMode(page, true);
125
+ const pageOne = await getActiveScriptState(page);
126
+ const pageCount = pageOne.pageIds.length;
127
+
128
+ await page.click("#emptypage");
129
+ await page.waitForFunction((firstPageId, count) =>
130
+ ScratchJr.stage.pages.length === count + 1 &&
131
+ ScratchJr.stage.currentPage.id !== firstPageId &&
132
+ ScratchJr.isMartyModeEnabled &&
133
+ ScratchJr.stage.currentPage.currentSpriteName &&
134
+ ScratchJr.stage.currentPage.currentSpriteName.indexOf(ScratchJr.BIRDS_EYE_SPRITE_NAME) > -1 &&
135
+ !ScratchJr.onHold &&
136
+ ScratchJr.getActiveScript(),
137
+ {}, pageOne.pageId, pageCount);
138
+
139
+ return {
140
+ pageOne,
141
+ pageTwo: await getActiveScriptState(page)
142
+ };
143
+ };
144
+
145
+ const selectPage = async (page, pageId) => {
146
+ await page.click(`.pagethumb[data-owner="${pageId}"]`);
147
+ await page.waitForFunction(id =>
148
+ ScratchJr.stage.currentPage.id === id && ScratchJr.getActiveScript(),
149
+ {}, pageId);
150
+ };
151
+
152
+ const dragBlockToCanvas = async (page, categorySelector, blockSelector, blockType) => {
153
+ const blockCount = await page.evaluate(() => ScratchJr.getBlocks().length);
154
+ await page.click(categorySelector);
155
+ await page.waitForSelector(blockSelector, { visible: true });
156
+
157
+ const source = await page.$eval(blockSelector, node => {
158
+ const rect = node.getBoundingClientRect();
159
+ return {
160
+ x: rect.left + rect.width / 2,
161
+ y: rect.top + rect.height / 2
162
+ };
163
+ });
164
+ const target = await page.$eval("#scripts", node => {
165
+ const rect = node.getBoundingClientRect();
166
+ return {
167
+ x: rect.left + Math.min(220, rect.width / 2),
168
+ y: rect.top + Math.min(170, rect.height / 2)
169
+ };
170
+ });
171
+
172
+ await page.mouse.move(source.x, source.y);
173
+ await page.mouse.down();
174
+ await page.mouse.move(target.x, target.y, { steps: 12 });
175
+ await page.mouse.up();
176
+
177
+ await page.waitForFunction((before, type) =>
178
+ ScratchJr.getBlocks().length > before &&
179
+ ScratchJr.getBlocks().some(block => block.blocktype === type),
180
+ {}, blockCount, blockType);
181
+
182
+ return getActiveScriptState(page);
183
+ };
184
+
185
+ const dragMartyBlock = page =>
186
+ dragBlockToCanvas(page, "#marty-motion", "#martyStepForward_block", "martyStepForward");
187
+
188
+ const dragSpriteBlock = page =>
189
+ dragBlockToCanvas(page, "#sprite-motion", "#forward_block", "forward");
190
+
191
+ const expectVisibleBlock = (state, blockType, pathName) => {
192
+ expect(state.blockTypes, `${pathName}: ${JSON.stringify(state)}`).toContain(blockType);
193
+ expect(state.scriptVisibility, `${pathName}: ${JSON.stringify(state)}`).toBe("visible");
194
+ expect(state.visibleBlockTypes, `${pathName}: ${JSON.stringify(state)}`).toContain(blockType);
195
+ };
196
+
197
+ const readPersistedProject = async (page, projectId) =>
198
+ page.evaluate(id => new Promise(resolve => {
199
+ window.OS.query(
200
+ { stmt: "select * from projects where id = ?", values: [Number(id)] },
201
+ result => {
202
+ const rows = JSON.parse(result);
203
+ if (!rows.length) {
204
+ resolve(null);
205
+ return;
206
+ }
207
+ const row = Object.fromEntries(Object.entries(rows[0])
208
+ .map(([key, value]) => [key.toLowerCase(), value]));
209
+ resolve(typeof row.json === "string" ? JSON.parse(row.json) : row.json);
210
+ }
211
+ );
212
+ }), projectId);
213
+
214
+ const saveProject = async (page, projectId, currentPageId) => {
215
+ await page.evaluate(() => new Promise(resolve => {
216
+ ScratchJr.changed = true;
217
+ ScratchJr.saveProject(null, resolve);
218
+ }));
219
+
220
+ const started = Date.now();
221
+ while (Date.now() - started < 15000) {
222
+ const project = await readPersistedProject(page, projectId);
223
+ if (project && project.currentPage === currentPageId && project.pages.length === 2) {
224
+ return;
225
+ }
226
+ await new Promise(resolve => setTimeout(resolve, 250));
227
+ }
228
+ throw new Error("Timed out waiting for the multi-page Marty project to persist");
229
+ };
230
+
231
+ describe("Marty script activation paths", () => {
232
+ beforeAll(async () => {
233
+ server = spawn("python3", ["-m", "http.server", String(PORT), "--directory", "editions/free/src"], {
234
+ stdio: "ignore"
235
+ });
236
+ await waitForServer();
237
+ sharedBrowser = await puppeteer.launch({
238
+ headless: true,
239
+ args: ["--no-sandbox"]
240
+ });
241
+ }, 30000);
242
+
243
+ afterAll(async () => {
244
+ if (sharedBrowser) {
245
+ await sharedBrowser.close();
246
+ }
247
+ if (server) {
248
+ server.kill();
249
+ }
250
+ });
251
+
252
+ it("activates the script after duplicating a page in Marty mode", async () => {
253
+ const { browser, page, errors } = await openPage("/editor.html?mode=edit");
254
+ try {
255
+ await waitForEditorReady(page);
256
+ await setMartyMode(page, true);
257
+ const source = await getActiveScriptState(page);
258
+
259
+ await page.evaluate(pageId => ScratchJr.stage.duplicatePage(pageId), source.pageId);
260
+ await page.waitForFunction((pageId, count) =>
261
+ ScratchJr.stage.pages.length === count + 1 &&
262
+ ScratchJr.stage.currentPage.id !== pageId &&
263
+ !ScratchJr.stage.duplicatingPage &&
264
+ !ScratchJr.onHold &&
265
+ ScratchJr.getActiveScript(),
266
+ {}, source.pageId, source.pageIds.length);
267
+
268
+ expectVisibleBlock(await dragMartyBlock(page), "martyStepForward", "duplicate page");
269
+ expect(errors).toEqual([]);
270
+ } finally {
271
+ await browser.close();
272
+ }
273
+ }, 60000);
274
+
275
+ it("activates the remaining page script after deleting the current page", async () => {
276
+ const { browser, page, errors } = await openPage("/editor.html?mode=edit");
277
+ try {
278
+ await waitForEditorReady(page);
279
+ const { pageOne, pageTwo } = await createSecondMartyPage(page);
280
+
281
+ await page.evaluate(pageId => ScratchJr.stage.deletePage(pageId), pageTwo.pageId);
282
+ await page.waitForFunction(pageId =>
283
+ ScratchJr.stage.pages.length === 1 &&
284
+ ScratchJr.stage.currentPage.id === pageId &&
285
+ ScratchJr.getActiveScript(),
286
+ {}, pageOne.pageId);
287
+
288
+ expectVisibleBlock(await dragMartyBlock(page), "martyStepForward", "delete current page");
289
+ expect(errors).toEqual([]);
290
+ } finally {
291
+ await browser.close();
292
+ }
293
+ }, 60000);
294
+
295
+ it("activates scripts after undoing and redoing page navigation", async () => {
296
+ const { browser, page, errors } = await openPage("/editor.html?mode=edit");
297
+ try {
298
+ await waitForEditorReady(page);
299
+ const { pageOne, pageTwo } = await createSecondMartyPage(page);
300
+ await selectPage(page, pageOne.pageId);
301
+ await reactivateCurrentMartyScript(page);
302
+
303
+ await page.click("#id_undo");
304
+ await page.waitForFunction(pageId => ScratchJr.stage.currentPage.id === pageId,
305
+ {}, pageTwo.pageId);
306
+ const undoState = await getActiveScriptState(page);
307
+
308
+ await reactivateCurrentMartyScript(page);
309
+ await page.click("#id_redo");
310
+ await page.waitForFunction(pageId => ScratchJr.stage.currentPage.id === pageId,
311
+ {}, pageOne.pageId);
312
+ const redoState = await dragMartyBlock(page);
313
+
314
+ expect({
315
+ undo: undoState.scriptVisibility,
316
+ redo: redoState.scriptVisibility
317
+ }).toEqual({ undo: "visible", redo: "visible" });
318
+ expectVisibleBlock(redoState, "martyStepForward", "redo page navigation");
319
+ expect(errors).toEqual([]);
320
+ } finally {
321
+ await browser.close();
322
+ }
323
+ }, 60000);
324
+
325
+ it("activates scripts after undoing and redoing page ordering", async () => {
326
+ const { browser, page, errors } = await openPage("/editor.html?mode=edit");
327
+ try {
328
+ await waitForEditorReady(page);
329
+ const { pageOne, pageTwo } = await createSecondMartyPage(page);
330
+
331
+ await page.evaluate(pageId => {
332
+ const thumb = document.querySelector(`.pagethumb[data-owner="${pageId}"]`);
333
+ thumb.dispatchEvent(new KeyboardEvent("keydown", {
334
+ key: "ArrowLeft",
335
+ shiftKey: true,
336
+ bubbles: true,
337
+ cancelable: true
338
+ }));
339
+ }, pageTwo.pageId);
340
+ await page.waitForFunction(pageId => ScratchJr.stage.getPagesID()[0] === pageId,
341
+ {}, pageTwo.pageId);
342
+
343
+ await page.click("#id_undo");
344
+ await page.waitForFunction((firstId, currentId) =>
345
+ ScratchJr.stage.getPagesID()[0] === firstId &&
346
+ ScratchJr.stage.currentPage.id === currentId,
347
+ {}, pageOne.pageId, pageTwo.pageId);
348
+ const undoState = await getActiveScriptState(page);
349
+
350
+ await reactivateCurrentMartyScript(page);
351
+ await page.click("#id_redo");
352
+ await page.waitForFunction((firstId, currentId) =>
353
+ ScratchJr.stage.getPagesID()[0] === firstId &&
354
+ ScratchJr.stage.currentPage.id === currentId,
355
+ {}, pageTwo.pageId, pageTwo.pageId);
356
+ const redoState = await dragMartyBlock(page);
357
+
358
+ expect({
359
+ undo: undoState.scriptVisibility,
360
+ redo: redoState.scriptVisibility
361
+ }).toEqual({ undo: "visible", redo: "visible" });
362
+ expectVisibleBlock(redoState, "martyStepForward", "redo page ordering");
363
+ expect(errors).toEqual([]);
364
+ } finally {
365
+ await browser.close();
366
+ }
367
+ }, 60000);
368
+
369
+ it("activates scripts when undoing and redoing a page deletion", async () => {
370
+ const { browser, page, errors } = await openPage("/editor.html?mode=edit");
371
+ try {
372
+ await waitForEditorReady(page);
373
+ const { pageOne, pageTwo } = await createSecondMartyPage(page);
374
+
375
+ await page.evaluate(pageId => ScratchJr.stage.deletePage(pageId), pageTwo.pageId);
376
+ await page.waitForFunction(pageId =>
377
+ ScratchJr.stage.pages.length === 1 && ScratchJr.stage.currentPage.id === pageId,
378
+ {}, pageOne.pageId);
379
+ await reactivateCurrentMartyScript(page);
380
+
381
+ await page.click("#id_undo");
382
+ await page.waitForFunction(() =>
383
+ ScratchJr.stage.pages.length === 2 &&
384
+ document.getElementById("id_redo").className.indexOf("enable") > -1 &&
385
+ ScratchJr.getActiveScript());
386
+ await page.waitFor(1000);
387
+ const undoState = await getActiveScriptState(page);
388
+
389
+ await page.click("#id_redo");
390
+ await page.waitForFunction(pageId =>
391
+ ScratchJr.stage.pages.length === 1 &&
392
+ ScratchJr.stage.currentPage.id === pageId &&
393
+ ScratchJr.getActiveScript(),
394
+ {}, pageOne.pageId);
395
+ await page.waitFor(500);
396
+ const redoState = await (await page.evaluate(() => ScratchJr.isMartyModeEnabled) ?
397
+ dragMartyBlock(page) : dragSpriteBlock(page));
398
+
399
+ expect({
400
+ undo: undoState.scriptVisibility,
401
+ redo: redoState.scriptVisibility
402
+ }).toEqual({
403
+ undo: "visible",
404
+ redo: "visible"
405
+ });
406
+ expectVisibleBlock(
407
+ redoState,
408
+ redoState.isMartyModeEnabled ? "martyStepForward" : "forward",
409
+ "redo page deletion"
410
+ );
411
+ expect(errors).toEqual([]);
412
+ } finally {
413
+ await browser.close();
414
+ }
415
+ }, 60000);
416
+
417
+ it("activates the destination script after a go-to-page transition", async () => {
418
+ const { browser, page, errors } = await openPage("/editor.html?mode=edit");
419
+ try {
420
+ await waitForEditorReady(page);
421
+ const { pageOne } = await createSecondMartyPage(page);
422
+
423
+ await page.evaluate(() => ScratchJr.stage.gotoPage(1));
424
+ await page.waitForFunction(pageId => ScratchJr.stage.currentPage.id === pageId,
425
+ {}, pageOne.pageId);
426
+
427
+ expectVisibleBlock(await dragMartyBlock(page), "martyStepForward", "go to page");
428
+ expect(errors).toEqual([]);
429
+ } finally {
430
+ await browser.close();
431
+ }
432
+ }, 60000);
433
+
434
+ it("activates the destination script after fullscreen page navigation", async () => {
435
+ const { browser, page, errors } = await openPage("/editor.html?mode=edit");
436
+ try {
437
+ await waitForEditorReady(page);
438
+ const { pageOne } = await createSecondMartyPage(page);
439
+
440
+ await page.click("#full");
441
+ await page.waitForFunction(() => ScratchJr.inFullscreen);
442
+ await page.$eval("#prevpage", button => button.click());
443
+ await page.waitForFunction(pageId => ScratchJr.stage.currentPage.id === pageId,
444
+ {}, pageOne.pageId);
445
+ await page.$eval("#full", button => button.click());
446
+ await page.waitForFunction(() => !ScratchJr.inFullscreen);
447
+
448
+ expectVisibleBlock(await dragMartyBlock(page), "martyStepForward", "fullscreen navigation");
449
+ expect(errors).toEqual([]);
450
+ } finally {
451
+ await browser.close();
452
+ }
453
+ }, 60000);
454
+
455
+ it("activates the saved current script after reloading a multi-page Marty project", async () => {
456
+ const { browser, page, errors } = await openPage("/home.html?place=home");
457
+ try {
458
+ const projectId = await createProjectFromHome(page);
459
+ const { pageOne } = await createSecondMartyPage(page);
460
+ await selectPage(page, pageOne.pageId);
461
+ await reactivateCurrentMartyScript(page);
462
+ expectVisibleBlock(await dragMartyBlock(page), "martyStepForward", "pre-save state");
463
+ await saveProject(page, projectId, pageOne.pageId);
464
+
465
+ await page.reload({ waitUntil: "networkidle2", timeout: 30000 });
466
+ await waitForEditorReady(page);
467
+ await page.waitForFunction(pageId =>
468
+ ScratchJr.stage.currentPage.id === pageId && ScratchJr.isMartyModeEnabled,
469
+ {}, pageOne.pageId);
470
+
471
+ expectVisibleBlock(await dragMartyBlock(page), "martyStepForward", "project reload");
472
+ expect(errors).toEqual([]);
473
+ } finally {
474
+ await browser.close();
475
+ }
476
+ }, 120000);
477
+
478
+ it("reactivates the original script after duplicate-page error recovery", async () => {
479
+ const { browser, page, errors } = await openPage("/editor.html?mode=edit");
480
+ try {
481
+ await waitForEditorReady(page);
482
+ await setMartyMode(page, false);
483
+ const source = await getActiveScriptState(page);
484
+
485
+ const recovery = await page.evaluate(pageId => {
486
+ const pages = ScratchJr.stage.pages;
487
+ const originalPush = pages.push;
488
+ pages.push = function () {
489
+ throw new Error("injected duplicate-page failure");
490
+ };
491
+ try {
492
+ ScratchJr.stage.duplicatePage(pageId);
493
+ return { caught: false };
494
+ } catch (error) {
495
+ return {
496
+ caught: true,
497
+ message: error.message,
498
+ currentPageId: ScratchJr.stage.currentPage.id
499
+ };
500
+ } finally {
501
+ pages.push = originalPush;
502
+ }
503
+ }, source.pageId);
504
+
505
+ expect(recovery).toMatchObject({
506
+ caught: true,
507
+ message: "injected duplicate-page failure",
508
+ currentPageId: source.pageId
509
+ });
510
+ await page.waitForFunction(pageId =>
511
+ ScratchJr.stage.currentPage.id === pageId &&
512
+ !ScratchJr.stage.duplicatingPage &&
513
+ !ScratchJr.onHold,
514
+ {}, source.pageId);
515
+
516
+ expectVisibleBlock(await dragSpriteBlock(page), "forward", "duplicate error recovery");
517
+ expect(errors).toEqual([]);
518
+ } finally {
519
+ await browser.close();
520
+ }
521
+ }, 60000);
522
+ });
@@ -0,0 +1,162 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ const { mockPrims } = vi.hoisted(() => ({
4
+ mockPrims: {
5
+ OnMicroBitEvent: vi.fn()
6
+ }
7
+ }));
8
+
9
+ vi.mock('@/editor/engine/Prims', () => ({
10
+ default: mockPrims
11
+ }));
12
+
13
+ vi.mock('../../src/editor/engine/Prims', () => ({
14
+ default: mockPrims
15
+ }));
16
+
17
+ describe('MicroBitBlocks', () => {
18
+ let MicroBitBlocks;
19
+ let Prims;
20
+ let sensorListener;
21
+ let blocks;
22
+
23
+ beforeEach(async () => {
24
+ vi.clearAllMocks();
25
+ sensorListener = null;
26
+ blocks = null;
27
+ const [blocksModule, primsModule] = await Promise.all([
28
+ import('@/microbit/MicroBitBlocks.js'),
29
+ import('@/editor/engine/Prims')
30
+ ]);
31
+ MicroBitBlocks = blocksModule.default;
32
+ Prims = primsModule.default;
33
+ });
34
+
35
+ afterEach(() => {
36
+ if (blocks) {
37
+ blocks.destroy();
38
+ }
39
+ vi.useRealTimers();
40
+ });
41
+
42
+ it('emits individual button events after the combo window', () => {
43
+ vi.useFakeTimers();
44
+ blocks = new MicroBitBlocks(createMicroBit());
45
+
46
+ sensorListener(
47
+ createSensors({buttonA: 1}),
48
+ createSensors({buttonA: 0})
49
+ );
50
+
51
+ expect(Prims.OnMicroBitEvent).not.toHaveBeenCalled();
52
+
53
+ vi.advanceTimersByTime(120);
54
+
55
+ expect(Prims.OnMicroBitEvent).toHaveBeenCalledWith('microbitbuttona');
56
+ expect(Prims.OnMicroBitEvent).not.toHaveBeenCalledWith('microbitbuttonab');
57
+ });
58
+
59
+ it('emits A+B button events when both buttons are pressed together', () => {
60
+ blocks = new MicroBitBlocks(createMicroBit());
61
+
62
+ sensorListener(
63
+ createSensors({buttonA: 1, buttonB: 1}),
64
+ createSensors({buttonA: 0, buttonB: 0})
65
+ );
66
+
67
+ expect(Prims.OnMicroBitEvent).toHaveBeenCalledWith('microbitbuttonab');
68
+ expect(Prims.OnMicroBitEvent).not.toHaveBeenCalledWith('microbitbuttona');
69
+ expect(Prims.OnMicroBitEvent).not.toHaveBeenCalledWith('microbitbuttonb');
70
+ });
71
+
72
+ it('promotes a near-simultaneous second button press to A+B', () => {
73
+ vi.useFakeTimers();
74
+ blocks = new MicroBitBlocks(createMicroBit());
75
+
76
+ sensorListener(
77
+ createSensors({buttonA: 1}),
78
+ createSensors({buttonA: 0})
79
+ );
80
+ vi.advanceTimersByTime(60);
81
+ sensorListener(
82
+ createSensors({buttonA: 1, buttonB: 1}),
83
+ createSensors({buttonA: 1, buttonB: 0})
84
+ );
85
+ vi.advanceTimersByTime(120);
86
+
87
+ expect(Prims.OnMicroBitEvent).toHaveBeenCalledTimes(1);
88
+ expect(Prims.OnMicroBitEvent).toHaveBeenCalledWith('microbitbuttonab');
89
+ });
90
+
91
+ it('emits gesture events on gesture bit transitions', () => {
92
+ blocks = new MicroBitBlocks(createMicroBit());
93
+
94
+ sensorListener(
95
+ createSensors({gestureState: 1}),
96
+ createSensors({gestureState: 0})
97
+ );
98
+
99
+ expect(Prims.OnMicroBitEvent).toHaveBeenCalledWith('microbitgestureshaken');
100
+ });
101
+
102
+ it('emits tilt events on every tilted sensor update', () => {
103
+ blocks = new MicroBitBlocks(createMicroBit());
104
+
105
+ sensorListener(
106
+ createSensors({tiltX: -180}),
107
+ createSensors({tiltX: 0})
108
+ );
109
+ sensorListener(
110
+ createSensors({tiltX: -180}),
111
+ createSensors({tiltX: -180})
112
+ );
113
+
114
+ expect(Prims.OnMicroBitEvent).toHaveBeenCalledWith('microbittiltany');
115
+ expect(Prims.OnMicroBitEvent).toHaveBeenCalledWith('microbittiltleft');
116
+ expect(Prims.OnMicroBitEvent).toHaveBeenCalledTimes(4);
117
+ });
118
+
119
+ it('sends matrix rows for display symbols', () => {
120
+ const microBit = createMicroBit();
121
+ blocks = new MicroBitBlocks(microBit);
122
+
123
+ blocks.displaySymbol('microbitdisplayheart');
124
+
125
+ expect(microBit.displayMatrix).toHaveBeenCalledWith(new Uint8Array([10, 31, 31, 14, 4]));
126
+ });
127
+
128
+ it('sends matrix rows for custom display patterns', () => {
129
+ const microBit = createMicroBit();
130
+ blocks = new MicroBitBlocks(microBit);
131
+
132
+ blocks.displayPattern('10000/01000/00100/00010/00001');
133
+
134
+ expect(microBit.displayMatrix).toHaveBeenCalledWith(new Uint8Array([1, 2, 4, 8, 16]));
135
+ });
136
+
137
+ function createMicroBit(sensors = createSensors()) {
138
+ return {
139
+ sensors,
140
+ addSensorListener: vi.fn(callback => {
141
+ sensorListener = callback;
142
+ return vi.fn();
143
+ }),
144
+ isConnected: () => true,
145
+ displayMatrix: vi.fn(),
146
+ displayText: vi.fn(),
147
+ clearDisplay: vi.fn()
148
+ };
149
+ }
150
+
151
+ function createSensors(overrides = {}) {
152
+ return {
153
+ tiltX: 0,
154
+ tiltY: 0,
155
+ buttonA: 0,
156
+ buttonB: 0,
157
+ touchPins: [0, 0, 0],
158
+ gestureState: 0,
159
+ ...overrides
160
+ };
161
+ }
162
+ });