@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.
- package/.firebase/hosting.ZWRpdGlvbnMvZnJlZS9zcmM.cache +599 -534
- package/editions/free/src/app.bundle.js +1 -1
- package/editions/free/src/assets/blockicons/microbitbuttona.svg +33 -0
- package/editions/free/src/assets/blockicons/microbitbuttonab.svg +33 -0
- package/editions/free/src/assets/blockicons/microbitbuttonb.svg +33 -0
- package/editions/free/src/assets/blockicons/microbitdisplayclear.svg +29 -0
- package/editions/free/src/assets/blockicons/microbitdisplayhappy.svg +36 -0
- package/editions/free/src/assets/blockicons/microbitdisplayheart.svg +42 -0
- package/editions/free/src/assets/blockicons/microbitdisplaytext.svg +29 -0
- package/editions/free/src/assets/blockicons/microbitgesturejumped.svg +40 -0
- package/editions/free/src/assets/blockicons/microbitgesturemoved.svg +41 -0
- package/editions/free/src/assets/blockicons/microbitgestureshaken.svg +45 -0
- package/editions/free/src/assets/blockicons/microbittiltany.svg +45 -0
- package/editions/free/src/assets/blockicons/microbittiltbackward.svg +39 -0
- package/editions/free/src/assets/blockicons/microbittiltforward.svg +39 -0
- package/editions/free/src/assets/blockicons/microbittiltleft.svg +39 -0
- package/editions/free/src/assets/blockicons/microbittiltright.svg +39 -0
- package/editions/free/src/assets/categories/MicroBitLooksOff.svg +17 -0
- package/editions/free/src/assets/categories/MicroBitLooksOn.svg +17 -0
- package/editions/free/src/assets/categories/MicroBitStartOff.svg +17 -0
- package/editions/free/src/assets/categories/MicroBitStartOn.svg +17 -0
- package/editions/free/src/assets/ui/viewOnCompact.png +0 -0
- package/editions/free/src/assets/ui/viewOnTargetCompact.png +0 -0
- package/editions/free/src/css/editor.css +334 -1
- package/editions/free/src/css/editorleftpanel.css +86 -26
- package/editions/free/src/css/editorstage.css +62 -0
- package/editions/free/src/css/paintlook.css +23 -0
- package/editions/free/src/localizations/bg.json +10 -1
- package/editions/free/src/localizations/ca.json +10 -1
- package/editions/free/src/localizations/cs.json +10 -1
- package/editions/free/src/localizations/cy.json +10 -1
- package/editions/free/src/localizations/da.json +10 -1
- package/editions/free/src/localizations/de.json +10 -1
- package/editions/free/src/localizations/el.json +10 -1
- package/editions/free/src/localizations/en.json +10 -1
- package/editions/free/src/localizations/es.json +10 -1
- package/editions/free/src/localizations/fi.json +10 -1
- package/editions/free/src/localizations/fr.json +10 -1
- package/editions/free/src/localizations/it.json +10 -1
- package/editions/free/src/localizations/ja.json +10 -1
- package/editions/free/src/localizations/ko.json +10 -1
- package/editions/free/src/localizations/nl.json +10 -1
- package/editions/free/src/localizations/no.json +10 -1
- package/editions/free/src/localizations/pl.json +10 -1
- package/editions/free/src/localizations/pt-br.json +10 -1
- package/editions/free/src/localizations/pt.json +10 -1
- package/editions/free/src/localizations/sv.json +10 -1
- package/editions/free/src/localizations/th.json +10 -1
- package/editions/free/src/localizations/tr.json +10 -1
- package/editions/free/src/localizations/uk.json +10 -1
- package/editions/free/src/localizations/zh-cn.json +10 -1
- package/editions/free/src/localizations/zh-tw.json +10 -1
- package/editions/free/src/media.json +15 -0
- package/editions/free/src/pnglibrary/BeeSprite1.png +0 -0
- package/editions/free/src/svglibrary/BeeSprite1.svg +4 -0
- package/editions/free/src/svglibrary/BeeSprite2.svg +4 -0
- package/editions/free/src/svglibrary/BeeSprite3.svg +4 -0
- package/package.json +1 -1
- package/tests/e2e/accessibility.e2e.test.js +18 -2
- package/tests/e2e/animated-sprite-paint-disabled.e2e.test.js +270 -0
- package/tests/e2e/background-paint-active-selection.e2e.test.js +162 -0
- package/tests/e2e/blocks-jr-hidden-blocks.e2e.test.js +262 -0
- package/tests/e2e/chromium-79-smoke.test.js +235 -0
- package/tests/e2e/delete-last-sprite-mode.e2e.test.js +162 -0
- package/tests/e2e/duplicate-page.e2e.test.js +414 -0
- package/tests/e2e/marty-connection-ui.e2e.test.js +56 -0
- package/tests/e2e/marty-script-activation-paths.e2e.test.js +522 -0
- package/tests/e2e/microbit-blocks.test.js +162 -0
- package/tests/e2e/microbit-extension-project-reload.e2e.test.js +309 -0
- package/tests/e2e/microbit-web-bluetooth.test.js +131 -0
- package/tests/e2e/page-background-loading.test.js +152 -0
- package/tests/e2e/paint-editor-cancel.e2e.test.js +274 -0
- package/tests/e2e/project-load-progress-loop.e2e.test.js +310 -0
- package/tests/e2e/project-save-stall.e2e.test.js +230 -0
- package/tests/e2e/sprite-watermark-grayscale.e2e.test.js +269 -0
- package/tests/e2e/top-toolbar-tooltips.e2e.test.js +152 -0
- 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
|
+
});
|