@robotical/martyblocksjr 4.2.2 → 4.2.4

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 (47) hide show
  1. package/editions/free/src/app.bundle.js +1 -1
  2. package/editions/free/src/css/editor.css +5 -0
  3. package/editions/free/src/css/editorleftpanel.css +12 -6
  4. package/editions/free/src/inapp/about.html +55 -26
  5. package/editions/free/src/inapp/blocks.html +16 -459
  6. package/editions/free/src/inapp/images/interface.png +0 -0
  7. package/editions/free/src/inapp/images/paint.png +0 -0
  8. package/editions/free/src/inapp/interface.html +136 -123
  9. package/editions/free/src/inapp/style/about.css +333 -21
  10. package/editions/free/src/inapp/style/blocks.css +423 -82
  11. package/editions/free/src/inapp/style/interface.css +178 -453
  12. package/editions/free/src/localizations/bg.json +36 -2
  13. package/editions/free/src/localizations/ca.json +36 -2
  14. package/editions/free/src/localizations/cs.json +36 -2
  15. package/editions/free/src/localizations/cy.json +36 -2
  16. package/editions/free/src/localizations/da.json +36 -2
  17. package/editions/free/src/localizations/de.json +36 -2
  18. package/editions/free/src/localizations/el.json +36 -2
  19. package/editions/free/src/localizations/en.json +114 -7
  20. package/editions/free/src/localizations/es.json +36 -2
  21. package/editions/free/src/localizations/fi.json +36 -2
  22. package/editions/free/src/localizations/fr.json +36 -2
  23. package/editions/free/src/localizations/it.json +36 -2
  24. package/editions/free/src/localizations/ja.json +36 -2
  25. package/editions/free/src/localizations/ko.json +36 -2
  26. package/editions/free/src/localizations/nl.json +36 -2
  27. package/editions/free/src/localizations/no.json +36 -2
  28. package/editions/free/src/localizations/pl.json +36 -2
  29. package/editions/free/src/localizations/pt-br.json +36 -2
  30. package/editions/free/src/localizations/pt.json +36 -2
  31. package/editions/free/src/localizations/sv.json +36 -2
  32. package/editions/free/src/localizations/th.json +36 -2
  33. package/editions/free/src/localizations/tr.json +36 -2
  34. package/editions/free/src/localizations/uk.json +36 -2
  35. package/editions/free/src/localizations/zh-cn.json +36 -2
  36. package/editions/free/src/localizations/zh-tw.json +36 -2
  37. package/package.json +1 -1
  38. package/scripts/a11y-structure.mjs +9 -2
  39. package/tests/BlockGuideRegistry.test.js +60 -0
  40. package/tests/MartyManager.test.js +88 -0
  41. package/tests/e2e/accessibility.e2e.test.js +153 -10
  42. package/tests/e2e/blocks-jr-hidden-blocks.e2e.test.js +84 -0
  43. package/tests/e2e/chromium-79-smoke.test.js +12 -17
  44. package/tests/e2e/marty-connection-ui.e2e.test.js +177 -12
  45. package/tests/e2e/microbit-extension-project-reload.e2e.test.js +1 -6
  46. package/tests/e2e/project-load-progress-loop.e2e.test.js +1 -6
  47. package/tests/e2e/sidebar-scrollbars.e2e.test.js +179 -0
@@ -138,9 +138,11 @@ async function getFocusStyles(page, selector) {
138
138
  }
139
139
 
140
140
  function expectVisibleFocusIndicator(focusStyles) {
141
- expect(focusStyles.outlineStyle).not.toBe('none');
142
- expect(focusStyles.outlineWidth).toBeGreaterThan(0);
143
- expect(focusStyles.outlineColor).not.toBe('rgba(0, 0, 0, 0)');
141
+ const hasVisibleOutline = focusStyles.outlineStyle !== 'none' &&
142
+ focusStyles.outlineWidth > 0 &&
143
+ focusStyles.outlineColor !== 'rgba(0, 0, 0, 0)';
144
+ const hasVisibleBoxShadow = focusStyles.boxShadow && focusStyles.boxShadow !== 'none';
145
+ expect(hasVisibleOutline || hasVisibleBoxShadow).toBe(true);
144
146
  }
145
147
 
146
148
  async function expectNamedControls(page, selectors) {
@@ -231,7 +233,7 @@ describe('Accessibility shell audit', () => {
231
233
  await new Promise((resolve) => setTimeout(resolve, 1200));
232
234
  await page.click('#booktab');
233
235
  await page.waitForSelector('iframe#htmlcontents', { timeout: 30_000 });
234
- expect(await page.$eval('iframe#htmlcontents', (element) => element.getAttribute('title'))).toBe('About ScratchJr');
236
+ expect(await page.$eval('iframe#htmlcontents', (element) => element.getAttribute('title'))).toBe('About Blocks Jr');
235
237
 
236
238
  expectNoAxeViolations(await runAxe(page));
237
239
  expect(errors).toEqual([]);
@@ -250,11 +252,25 @@ describe('Accessibility shell audit', () => {
250
252
  });
251
253
 
252
254
  const guideCases = [
255
+ {
256
+ path: 'inapp/about.html',
257
+ title: 'About Blocks Jr',
258
+ focusSelector: '#skip-link',
259
+ buttonSelectors: [],
260
+ decorativeImageSelector: '.about-hero-art img'
261
+ },
253
262
  {
254
263
  path: 'inapp/interface.html',
255
264
  title: 'Interface Guide',
256
265
  focusSelector: '#interface-button-save',
257
- buttonSelectors: ['#interface-button-save', '#interface-button-stage', '#interface-button-green-flag'],
266
+ buttonSelectors: [
267
+ '#interface-button-save',
268
+ '#interface-button-extensions',
269
+ '#interface-button-stage',
270
+ '#interface-button-green-flag',
271
+ '#interface-button-duplicate-page',
272
+ '#interface-button-zoom'
273
+ ],
258
274
  decorativeImageSelector: '.ipad-project-view'
259
275
  },
260
276
  {
@@ -267,9 +283,13 @@ describe('Accessibility shell audit', () => {
267
283
  {
268
284
  path: 'inapp/blocks.html',
269
285
  title: 'Blocks Guide',
270
- focusSelector: '#skip-link',
271
- buttonSelectors: [],
272
- decorativeImageSelector: '.block-image'
286
+ focusSelector: '#blocks-guide-tab-marty',
287
+ buttonSelectors: [
288
+ '#blocks-guide-tab-marty',
289
+ '#blocks-guide-tab-sprite',
290
+ '#blocks-guide-tab-cog'
291
+ ],
292
+ decorativeImageSelector: '.block-guide-icon'
273
293
  }
274
294
  ];
275
295
 
@@ -279,12 +299,14 @@ describe('Accessibility shell audit', () => {
279
299
  await page.goto(`${HOST}/${guideCase.path}`, { waitUntil: 'domcontentloaded', timeout: 30_000 });
280
300
  await waitForTitle(page, guideCase.title);
281
301
  await page.waitForSelector('#skip-link');
282
- await page.waitForSelector('#content[role="main"]');
302
+ await page.waitForSelector('#content');
283
303
 
284
304
  expect((await page.title()).includes(guideCase.title)).toBe(true);
285
305
  expect(await page.$eval('html', (element) => element.lang)).toBe('en');
286
306
  expect(await page.$eval('#skip-link', (element) => element.getAttribute('href'))).toBe('#content');
287
- expect(await page.$eval('#content', (element) => element.getAttribute('role'))).toBe('main');
307
+ expect(await page.$eval('#content', (element) => {
308
+ return element.tagName.toLowerCase() === 'main' || element.getAttribute('role') === 'main';
309
+ })).toBe(true);
288
310
 
289
311
  if (guideCase.buttonSelectors.length > 0) {
290
312
  await expectNamedControls(page, guideCase.buttonSelectors);
@@ -298,6 +320,127 @@ describe('Accessibility shell audit', () => {
298
320
  await tabToSelector(page, guideCase.focusSelector);
299
321
  expectVisibleFocusIndicator(await getFocusStyles(page, guideCase.focusSelector));
300
322
 
323
+ if (guideCase.path === 'inapp/interface.html') {
324
+ const buttonIds = await page.$$eval('.interface-button', (buttons) => {
325
+ return buttons.map((button) => button.id);
326
+ });
327
+ expect(buttonIds).toHaveLength(24);
328
+ expect(await page.$$eval('.interface-connector', (connectors) => connectors.length)).toBe(24);
329
+ expect(await page.$eval('#interface-key-header', (element) => element.textContent)).toBe('1 | Save');
330
+
331
+ for (let index = 0; index < buttonIds.length; index += 1) {
332
+ const selector = `#${buttonIds[index]}`;
333
+ await page.click(selector);
334
+ const selectedFeature = await page.evaluate((buttonSelector) => {
335
+ const button = document.querySelector(buttonSelector);
336
+ const connector = document.querySelector(
337
+ `.interface-connector[data-guide-key="${button.getAttribute('data-guide-key')}"]`
338
+ );
339
+ const number = button.querySelector('span');
340
+ return {
341
+ ariaCurrent: button.getAttribute('aria-current'),
342
+ ariaLabel: button.getAttribute('aria-label'),
343
+ buttonBackground: window.getComputedStyle(button).backgroundColor,
344
+ connectorIsSelected: connector.classList.contains('interface-connector-selected'),
345
+ connectorLineLength: connector.querySelector('line').getTotalLength(),
346
+ description: document.querySelector('#interface-key-description').textContent,
347
+ header: document.querySelector('#interface-key-header').textContent,
348
+ numberColor: window.getComputedStyle(number).color,
349
+ numberText: number.textContent,
350
+ selectedConnectorCount: document.querySelectorAll(
351
+ '.interface-connector-selected'
352
+ ).length,
353
+ selectedCount: document.querySelectorAll('.interface-button[aria-current="page"]').length
354
+ };
355
+ }, selector);
356
+
357
+ expect(selectedFeature.header).toBe(selectedFeature.ariaLabel);
358
+ expect(selectedFeature.header.startsWith(`${index + 1} | `)).toBe(true);
359
+ expect(selectedFeature.header).not.toContain('String missing:');
360
+ expect(selectedFeature.description.trim().length).toBeGreaterThan(0);
361
+ expect(selectedFeature.description).not.toContain('String missing:');
362
+ expect(selectedFeature.ariaCurrent).toBe('page');
363
+ expect(selectedFeature.numberText).toBe(String(index + 1));
364
+ expect(selectedFeature.numberColor).not.toBe(selectedFeature.buttonBackground);
365
+ expect(selectedFeature.connectorIsSelected).toBe(true);
366
+ expect(selectedFeature.connectorLineLength).toBeGreaterThan(0);
367
+ expect(selectedFeature.selectedConnectorCount).toBe(1);
368
+ expect(selectedFeature.selectedCount).toBe(1);
369
+ }
370
+
371
+ await page.click('#interface-button-extensions');
372
+ expect(await page.$eval('#interface-key-header', (element) => element.textContent)).toBe('3 | Extensions');
373
+ expect(await page.$eval('#interface-key-description', (element) => element.textContent)).toContain('micro:bit');
374
+ }
375
+
376
+ if (guideCase.path === 'inapp/blocks.html') {
377
+ const inventory = await page.evaluate(() => {
378
+ const modeCounts = {};
379
+ document.querySelectorAll('[data-guide-mode-panel]').forEach((panel) => {
380
+ modeCounts[panel.getAttribute('data-guide-mode-panel')] =
381
+ panel.querySelectorAll('.block-guide-card').length;
382
+ });
383
+ return {
384
+ activeMode: document.querySelector('[role="tab"][aria-selected="true"]')
385
+ .getAttribute('data-guide-mode'),
386
+ extensionCount: document.querySelectorAll(
387
+ '#blocks-guide-extension .block-guide-card'
388
+ ).length,
389
+ modeCounts,
390
+ missingCopy: document.body.textContent.includes('String missing:'),
391
+ tabCount: document.querySelectorAll('[role="tab"]').length
392
+ };
393
+ });
394
+ expect(inventory).toEqual({
395
+ activeMode: 'marty',
396
+ extensionCount: 8,
397
+ modeCounts: {
398
+ marty: 44,
399
+ sprite: 31,
400
+ cog: 19
401
+ },
402
+ missingCopy: false,
403
+ tabCount: 3
404
+ });
405
+
406
+ await page.click('#blocks-guide-tab-sprite');
407
+ expect(await page.$eval('#blocks-guide-panel-sprite', (panel) => panel.hidden)).toBe(false);
408
+ expect(await page.$eval('#blocks-guide-panel-marty', (panel) => panel.hidden)).toBe(true);
409
+
410
+ await page.focus('#blocks-guide-tab-sprite');
411
+ await page.keyboard.press('ArrowRight');
412
+ expect(await page.$eval('#blocks-guide-tab-cog', (tab) => tab.getAttribute('aria-selected'))).toBe('true');
413
+ expect(await page.$eval('#blocks-guide-panel-cog', (panel) => panel.hidden)).toBe(false);
414
+
415
+ for (const modeId of ['marty', 'sprite', 'cog']) {
416
+ await page.evaluate(() => {
417
+ window.scrollTo(0, document.documentElement.scrollHeight);
418
+ });
419
+ await page.click(`#blocks-guide-tab-${modeId}`);
420
+ const selectedMode = await page.evaluate((expectedMode) => {
421
+ const tab = document.querySelector(`[data-guide-mode="${expectedMode}"]`);
422
+ const panel = document.querySelector(`[data-guide-mode-panel="${expectedMode}"]`);
423
+ const firstCard = panel.querySelector('.block-guide-card');
424
+ const tabsShell = document.querySelector('.blocks-guide-tabs-shell');
425
+ const cardRect = firstCard.getBoundingClientRect();
426
+ const tabsRect = tabsShell.getBoundingClientRect();
427
+ return {
428
+ activeMode: document.querySelector('[role="tab"][aria-selected="true"]')
429
+ .getAttribute('data-guide-mode'),
430
+ firstCardVisible: cardRect.top < window.innerHeight && cardRect.bottom > tabsRect.bottom,
431
+ panelHidden: panel.hidden,
432
+ tabSelected: tab.getAttribute('aria-selected')
433
+ };
434
+ }, modeId);
435
+ expect(selectedMode).toEqual({
436
+ activeMode: modeId,
437
+ firstCardVisible: true,
438
+ panelHidden: false,
439
+ tabSelected: 'true'
440
+ });
441
+ }
442
+ }
443
+
301
444
  await activateSkipLink(page, 'content');
302
445
 
303
446
  expectNoAxeViolations(await runAxe(page));
@@ -164,6 +164,14 @@ const getSpriteScriptState = async (page, spriteId) =>
164
164
  };
165
165
  }, spriteId);
166
166
 
167
+ const getMartySensorPaletteState = page =>
168
+ page.evaluate(() => ({
169
+ colour: Boolean(document.getElementById("martycoloursensed_block")),
170
+ obstacle: Boolean(document.getElementById("martyobstaclesensed_block")),
171
+ light: Boolean(document.getElementById("martylightsensed_block")),
172
+ noise: Boolean(document.getElementById("martynoisesensed_block"))
173
+ }));
174
+
167
175
  describe("Blocks Jr hidden block drop regression", () => {
168
176
  beforeAll(async () => {
169
177
  server = spawn("python3", ["-m", "http.server", String(PORT), "--directory", "editions/free/src"], {
@@ -259,4 +267,80 @@ describe("Blocks Jr hidden block drop regression", () => {
259
267
  await browser.close();
260
268
  }
261
269
  }, 60000);
270
+
271
+ it("always shows standard Marty sensors and gates optional sensor blocks", async () => {
272
+ const { browser, page, errors } = await openEditor();
273
+
274
+ try {
275
+ await enableMartyMode(page);
276
+ await page.click("#marty-start");
277
+ await page.waitForSelector("#martycoloursensed_block", { visible: true });
278
+ await page.waitForSelector("#martyobstaclesensed_block", { visible: true });
279
+
280
+ const unpairedState = await getMartySensorPaletteState(page);
281
+ expect(unpairedState).toEqual({
282
+ colour: true,
283
+ obstacle: true,
284
+ light: false,
285
+ noise: false
286
+ });
287
+
288
+ await page.evaluate(() => {
289
+ window.sensorPolicyTestManager = window.martyManager;
290
+ window.martyManager = undefined;
291
+ window.Palette.selectCategory(window.Palette.numcat);
292
+ });
293
+ const managerUnavailableState = await getMartySensorPaletteState(page);
294
+ expect(managerUnavailableState).toEqual({
295
+ colour: true,
296
+ obstacle: true,
297
+ light: false,
298
+ noise: false
299
+ });
300
+
301
+ await page.evaluate(() => {
302
+ window.martyManager = window.sensorPolicyTestManager;
303
+ delete window.sensorPolicyTestManager;
304
+ });
305
+
306
+ await page.evaluate(() => {
307
+ window.martyManager.addMarty({ id: "sensor-policy-test" });
308
+ window.martyManager.setMartySensorAvailability("sensor-policy-test", {
309
+ colour: false,
310
+ obstacle: false,
311
+ light: true,
312
+ noise: false
313
+ });
314
+ });
315
+ await page.waitForSelector("#martylightsensed_block", { visible: true });
316
+
317
+ const lightSensorState = await getMartySensorPaletteState(page);
318
+ expect(lightSensorState).toEqual({
319
+ colour: true,
320
+ obstacle: true,
321
+ light: true,
322
+ noise: false
323
+ });
324
+
325
+ await page.evaluate(() => {
326
+ window.martyManager.setMartySensorAvailability("sensor-policy-test", {
327
+ light: false,
328
+ noise: true
329
+ });
330
+ });
331
+ await page.waitForSelector("#martynoisesensed_block", { visible: true });
332
+ await page.waitForFunction(() => !document.getElementById("martylightsensed_block"));
333
+
334
+ const noiseSensorState = await getMartySensorPaletteState(page);
335
+ expect(noiseSensorState).toEqual({
336
+ colour: true,
337
+ obstacle: true,
338
+ light: false,
339
+ noise: true
340
+ });
341
+ expect(errors).toEqual([]);
342
+ } finally {
343
+ await browser.close();
344
+ }
345
+ }, 60000);
262
346
  });
@@ -108,13 +108,8 @@ async function assertTutorialOpened(page, tutorialId) {
108
108
  expect(tutorialState.tutorialTitle.length).toBeGreaterThan(0);
109
109
  }
110
110
 
111
- async function openHiddenExtensionsLibrary(page) {
112
- await page.$eval("#addExtensionButton", (button) => {
113
- if (!button.disabled || window.getComputedStyle(button).display !== "none") {
114
- throw new Error("Expected the extensions entry point to remain hidden and disabled");
115
- }
116
- button.onclick();
117
- });
111
+ async function openExtensionsLibrary(page) {
112
+ await page.click("#addExtensionButton");
118
113
  }
119
114
 
120
115
  describe("Chromium 79 smoke test", () => {
@@ -132,7 +127,7 @@ describe("Chromium 79 smoke test", () => {
132
127
  );
133
128
 
134
129
  it(
135
- "keeps the extension entry hidden and reveals micro:bit controls only while loaded",
130
+ "keeps the extension entry visible and reveals micro:bit controls only while loaded",
136
131
  async () => {
137
132
  const { browser, page, errors } = await openPage("/editor.html?mode=edit");
138
133
 
@@ -157,9 +152,9 @@ describe("Chromium 79 smoke test", () => {
157
152
  });
158
153
 
159
154
  expect(initialState).toEqual({
160
- addButtonDisplay: "none",
161
- addButtonAriaHidden: "true",
162
- addButtonDisabled: true,
155
+ addButtonDisplay: "flex",
156
+ addButtonAriaHidden: "false",
157
+ addButtonDisabled: false,
163
158
  microBitButtonDisplay: "none",
164
159
  microBitButtonAriaHidden: "true",
165
160
  microBitButtonDisabled: true,
@@ -167,7 +162,7 @@ describe("Chromium 79 smoke test", () => {
167
162
  extensionEnabled: false,
168
163
  });
169
164
 
170
- await openHiddenExtensionsLibrary(page);
165
+ await openExtensionsLibrary(page);
171
166
  await page.waitForSelector("#extensionsLibrary.fade.in", { timeout: 30_000 });
172
167
  const libraryState = await page.evaluate(() => ({
173
168
  title: document.getElementById("extensionsLibraryTitle").textContent.trim(),
@@ -221,9 +216,9 @@ describe("Chromium 79 smoke test", () => {
221
216
  });
222
217
 
223
218
  expect(enabledState).toEqual({
224
- addButtonDisplay: "none",
225
- addButtonAriaHidden: "true",
226
- addButtonDisabled: true,
219
+ addButtonDisplay: "flex",
220
+ addButtonAriaHidden: "false",
221
+ addButtonDisabled: false,
227
222
  microBitButtonDisplay: "flex",
228
223
  microBitButtonAriaHidden: "false",
229
224
  microBitButtonDisabled: false,
@@ -290,7 +285,7 @@ describe("Chromium 79 smoke test", () => {
290
285
  });
291
286
  expect(blocksAfterInsert).toContain("microbitdisplayclear");
292
287
 
293
- await openHiddenExtensionsLibrary(page);
288
+ await openExtensionsLibrary(page);
294
289
  await page.waitForSelector("#extensionsLibrary.fade.in", { timeout: 30_000 });
295
290
  const loadedLibraryState = await page.evaluate(() => ({
296
291
  microBitCardAction: document.getElementById("microBitExtensionCardAction").textContent.trim(),
@@ -339,7 +334,7 @@ describe("Chromium 79 smoke test", () => {
339
334
  extensionEnabled: document.getElementById("connectionButtonsArea").classList.contains("extensionEnabled"),
340
335
  }));
341
336
  expect(unloadedState).toEqual({
342
- addButtonDisplay: "none",
337
+ addButtonDisplay: "flex",
343
338
  microBitButtonDisplay: "none",
344
339
  extensionsLibraryOpen: false,
345
340
  microBitCardAction: "+",
@@ -1,5 +1,9 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
2
 
3
+ const { newHTMLMock } = vi.hoisted(() => ({
4
+ newHTMLMock: vi.fn()
5
+ }));
6
+
3
7
  vi.mock('@/editor/ScratchJr', () => ({
4
8
  default: {
5
9
  isMartyModeEnabled: false,
@@ -42,7 +46,7 @@ vi.mock('@/utils/lib', () => ({
42
46
  gn: vi.fn(),
43
47
  CSSTransition: vi.fn(),
44
48
  localx: vi.fn(),
45
- newHTML: vi.fn(),
49
+ newHTML: newHTMLMock,
46
50
  newButton: vi.fn(),
47
51
  scaleMultiplier: 1,
48
52
  fullscreenScaleMultiplier: 1,
@@ -75,18 +79,36 @@ vi.mock('@/html-svgs/sprite_toggle_on', () => ({ spriteToggleOn: '<svg></svg>' }
75
79
  vi.mock('@/html-svgs/battery-svg', () => ({ batterySvg: () => '<svg id="battery"></svg>' }));
76
80
  vi.mock('@/html-svgs/signal-svg', () => ({ signalSvg: () => '<svg id="signal"></svg>' }));
77
81
  vi.mock('@/utils/raft-subscription-helpers', () => ({
78
- createRaftConnectionIssueDetectedHelper: () => ({
79
- subscribe: vi.fn(),
80
- unsubscribe: vi.fn()
81
- }),
82
- createRaftConnectionIssueResolvedHelper: () => ({
83
- subscribe: vi.fn(),
84
- unsubscribe: vi.fn()
82
+ createRaftConnectionIssueDetectedHelper: raft => ({
83
+ subscribe: vi.fn(callback => {
84
+ raft.__connectionIssueDetectedCallback = callback;
85
+ }),
86
+ unsubscribe: vi.fn(() => {
87
+ raft.__connectionIssueDetectedUnsubscribed = true;
88
+ })
85
89
  }),
86
- raftVerifiedSubscriptionHelper: () => ({
87
- subscribe: vi.fn(),
88
- unsubscribe: vi.fn()
90
+ createRaftConnectionIssueResolvedHelper: raft => ({
91
+ subscribe: vi.fn(callback => {
92
+ raft.__connectionIssueResolvedCallback = callback;
93
+ }),
94
+ unsubscribe: vi.fn(() => {
95
+ raft.__connectionIssueResolvedUnsubscribed = true;
96
+ })
89
97
  }),
98
+ raftVerifiedSubscriptionHelper: raft => {
99
+ let callback;
100
+ return {
101
+ subscribe: vi.fn(nextCallback => {
102
+ callback = nextCallback;
103
+ raft.__verifiedCallbacks = raft.__verifiedCallbacks || [];
104
+ raft.__verifiedCallbacks.push(callback);
105
+ }),
106
+ unsubscribe: vi.fn(() => {
107
+ raft.__verifiedUnsubscribeCount = (raft.__verifiedUnsubscribeCount || 0) + 1;
108
+ raft.__verifiedCallbacks = (raft.__verifiedCallbacks || []).filter(item => item !== callback);
109
+ })
110
+ };
111
+ },
90
112
  raftDisconnectedSubscriptionHelper: raft => {
91
113
  const helper = {
92
114
  subscribe: vi.fn(callback => {
@@ -128,6 +150,11 @@ describe('Marty connection UI', () => {
128
150
  removeMarty: vi.fn(),
129
151
  setMartySensorAvailability: vi.fn()
130
152
  },
153
+ cogManager: {
154
+ addCog: vi.fn(),
155
+ wireCogWithBlocks: vi.fn(),
156
+ removeCog: vi.fn()
157
+ },
131
158
  microBitManager: {
132
159
  addMicroBit: vi.fn(),
133
160
  wireMicroBitWithBlocks: vi.fn(),
@@ -139,6 +166,13 @@ describe('Marty connection UI', () => {
139
166
  import('@/editor/ui/UI.js'),
140
167
  import('@/editor/ScratchJr')
141
168
  ]);
169
+ newHTMLMock.mockImplementation((type, className, parent) => {
170
+ const element = new FakeElement(className ? [className] : []);
171
+ if (parent) {
172
+ parent.appendChild(element);
173
+ }
174
+ return element;
175
+ });
142
176
  UI = uiModule.default;
143
177
  ScratchJr = scratchJrModule.default;
144
178
  ScratchJr.isMartyModeEnabled = false;
@@ -249,6 +283,102 @@ describe('Marty connection UI', () => {
249
283
  expect(button.querySelector('.iconButtonContainer').classList.contains('connectedButtonContainer')).toBe(true);
250
284
  });
251
285
 
286
+ it('uses one verified subscription instance and cleans it up after setup', () => {
287
+ const button = createConnectionButton();
288
+ const raft = createCogRaft();
289
+ const setupConnectionButton = vi.fn();
290
+
291
+ UI.setupConnectionButtonWhenVerified(button, raft, setupConnectionButton);
292
+
293
+ expect(raft.__verifiedCallbacks).toHaveLength(1);
294
+ const verifiedCallback = raft.__verifiedCallbacks[0];
295
+ raft.isVerified = true;
296
+ verifiedCallback();
297
+ verifiedCallback();
298
+
299
+ expect(setupConnectionButton).toHaveBeenCalledTimes(1);
300
+ expect(setupConnectionButton).toHaveBeenCalledWith(button, raft);
301
+ expect(raft.__verifiedUnsubscribeCount).toBe(1);
302
+ expect(raft.__verifiedCallbacks).toHaveLength(0);
303
+ });
304
+
305
+ it('sets up an already verified Cog even when its verified event was missed', () => {
306
+ const button = createConnectionButton();
307
+ const raft = createCogRaft();
308
+ const setupConnectionButton = vi.fn();
309
+ raft.isVerified = true;
310
+
311
+ UI.setupConnectionButtonWhenVerified(button, raft, setupConnectionButton);
312
+
313
+ expect(setupConnectionButton).toHaveBeenCalledWith(button, raft);
314
+ expect(raft.__verifiedUnsubscribeCount).toBe(1);
315
+ expect(raft.__verifiedCallbacks).toHaveLength(0);
316
+ });
317
+
318
+ it('reconciles an existing verified Cog after the host application manager is injected', () => {
319
+ const cogButton = createConnectionButton();
320
+ const martyButton = createConnectionButton();
321
+ const raft = createCogRaft();
322
+ raft.isVerified = true;
323
+ const setupCogSpy = vi.spyOn(UI, 'setupCogConnectionButton').mockImplementation(() => {});
324
+ window.applicationManager = undefined;
325
+
326
+ expect(UI.reconcileConnectionButtonsWhenApplicationManagerReady(cogButton, martyButton)).toBe(false);
327
+ window.applicationManager = {
328
+ getTheCurrentlySelectedDeviceOrFirstOfItsKind: vi.fn(type => type === 'Cog' ? raft : undefined)
329
+ };
330
+ vi.advanceTimersByTime(100);
331
+
332
+ expect(setupCogSpy).toHaveBeenCalledWith(cogButton, raft);
333
+ expect(raft.__verifiedUnsubscribeCount).toBe(1);
334
+ setupCogSpy.mockRestore();
335
+ });
336
+
337
+ it('disconnects and clears a stale Cog when the connection issue countdown expires', () => {
338
+ const button = createConnectionButton();
339
+ const raft = createCogRaft();
340
+ window.applicationManager.disconnectGeneric = vi.fn((disconnectedRaft, onDisconnected) => {
341
+ onDisconnected();
342
+ });
343
+
344
+ UI.setupCogConnectionButton(button, raft);
345
+ raft.__connectionIssueDetectedCallback();
346
+
347
+ expect(button.querySelector('.connIssueOverlay').textContent).toBe('A11Y_CONNECTION_LOST 60');
348
+ expect(button.style.pointerEvents).toBe('none');
349
+
350
+ vi.advanceTimersByTime(59000);
351
+ expect(window.applicationManager.disconnectGeneric).not.toHaveBeenCalled();
352
+ expect(button.querySelector('.connIssueOverlay').textContent).toBe('A11Y_CONNECTION_LOST 1');
353
+
354
+ vi.advanceTimersByTime(1000);
355
+
356
+ expect(window.applicationManager.disconnectGeneric).toHaveBeenCalledWith(raft, expect.any(Function), true);
357
+ expect(window.cogManager.removeCog).toHaveBeenCalledWith(raft);
358
+ expect(button.querySelector('.connIssueOverlay')).toBeNull();
359
+ expect(button.style.pointerEvents).toBe('auto');
360
+ expect(button.classList.contains('connectButtonConnected')).toBe(false);
361
+ expect(raft.__connectionIssueDetectedUnsubscribed).toBe(true);
362
+ expect(raft.__connectionIssueResolvedUnsubscribed).toBe(true);
363
+ });
364
+
365
+ it('cancels the Cog countdown when the connection issue resolves', () => {
366
+ const button = createConnectionButton();
367
+ const raft = createCogRaft();
368
+
369
+ UI.setupCogConnectionButton(button, raft);
370
+ raft.__connectionIssueDetectedCallback();
371
+ vi.advanceTimersByTime(30000);
372
+ raft.__connectionIssueResolvedCallback();
373
+ vi.advanceTimersByTime(31000);
374
+
375
+ expect(window.applicationManager.disconnectGeneric).not.toHaveBeenCalled();
376
+ expect(button.querySelector('.connIssueOverlay')).toBeNull();
377
+ expect(button.style.pointerEvents).toBe('auto');
378
+
379
+ raft.__disconnectCallback();
380
+ });
381
+
252
382
  it('uses a friendly message when the micro:bit chooser is cancelled', () => {
253
383
  const message = UI.getMicroBitConnectionErrorMessage(
254
384
  new DOMException('User cancelled the requestDevice() chooser.', 'NotFoundError')
@@ -293,6 +423,19 @@ function createMartyRaft() {
293
423
  };
294
424
  }
295
425
 
426
+ function createCogRaft() {
427
+ return {
428
+ id: 'cog-1',
429
+ isVerified: false,
430
+ hasVerifiedConnection() {
431
+ return this.isVerified;
432
+ },
433
+ getFriendlyName: () => 'Cog One',
434
+ getBatteryStrength: () => 70,
435
+ getRSSI: () => -45
436
+ };
437
+ }
438
+
296
439
  function createConnectionButton() {
297
440
  const button = new FakeElement();
298
441
  button.children['.iconButtonContainer'] = new FakeElement(['iconButtonContainer', 'notConnectedButtonContainer']);
@@ -325,6 +468,7 @@ class FakeElement {
325
468
  constructor(classes = []) {
326
469
  this.classList = new FakeClassList(classes);
327
470
  this.children = {};
471
+ this.childNodes = [];
328
472
  this.style = {};
329
473
  this.attributes = {};
330
474
  this.textContent = '';
@@ -333,7 +477,28 @@ class FakeElement {
333
477
  }
334
478
 
335
479
  querySelector(selector) {
336
- return this.children[selector] || null;
480
+ if (this.children[selector]) {
481
+ return this.children[selector];
482
+ }
483
+ if (selector.startsWith('.')) {
484
+ const className = selector.slice(1);
485
+ return this.childNodes.find(child => child.classList.contains(className)) || null;
486
+ }
487
+ return null;
488
+ }
489
+
490
+ appendChild(child) {
491
+ if (!this.childNodes.includes(child)) {
492
+ this.childNodes.push(child);
493
+ }
494
+ child.parentNode = this;
495
+ return child;
496
+ }
497
+
498
+ removeChild(child) {
499
+ this.childNodes = this.childNodes.filter(item => item !== child);
500
+ child.parentNode = null;
501
+ return child;
337
502
  }
338
503
 
339
504
  setAttribute(name, value) {
@@ -119,12 +119,7 @@ async function enableMartyMode(page) {
119
119
  }
120
120
 
121
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
- });
122
+ await page.click("#addExtensionButton");
128
123
  await page.waitForSelector("#extensionsLibrary.fade.in", { timeout: 30_000 });
129
124
  await page.click("#microBitExtensionCard");
130
125
  await page.waitForFunction(
@@ -110,12 +110,7 @@ async function enableMartyMode(page) {
110
110
  }
111
111
 
112
112
  async function enableMicroBitExtension(page) {
113
- await page.$eval("#addExtensionButton", (button) => {
114
- if (!button.disabled || window.getComputedStyle(button).display !== "none") {
115
- throw new Error("Expected the extensions entry point to remain hidden and disabled");
116
- }
117
- button.onclick();
118
- });
113
+ await page.click("#addExtensionButton");
119
114
  await page.waitForSelector("#extensionsLibrary.fade.in", { timeout: 30_000 });
120
115
  await page.click("#microBitExtensionCard");
121
116
  await page.waitForFunction(