Skip to content

Commit cf4e711

Browse files
Send previous status in connection events
This is trivial for the connections and saves apps from additional state management as they often need to understand which transition happened.
1 parent d366e59 commit cf4e711

File tree

5 files changed

+194
-12
lines changed

5 files changed

+194
-12
lines changed

lib/bluetooth.test.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/**
2+
* (c) 2021, Micro:bit Educational Foundation and contributors
3+
*
4+
* SPDX-License-Identifier: MIT
5+
*/
6+
import { ConnectionStatus, ConnectionStatusEvent } from "./device.js";
7+
import { createWebBluetoothConnection } from "./bluetooth.js";
8+
import { expect, vi, describe, it } from "vitest";
9+
10+
// Mock Capacitor
11+
vi.mock("@capacitor/core", () => ({
12+
Capacitor: {
13+
isNativePlatform: () => false,
14+
getPlatform: () => "web",
15+
},
16+
}));
17+
18+
// Mock BleClient
19+
vi.mock("@capacitor-community/bluetooth-le", () => ({
20+
BleClient: {
21+
initialize: vi.fn().mockResolvedValue(undefined),
22+
isEnabled: vi.fn().mockResolvedValue(true),
23+
requestDevice: vi.fn(),
24+
connect: vi.fn(),
25+
disconnect: vi.fn().mockResolvedValue(undefined),
26+
getServices: vi.fn().mockResolvedValue([]),
27+
},
28+
}));
29+
30+
// Mock flashing modules
31+
vi.mock("./flashing/flashing-partial.js", () => ({
32+
default: vi.fn(),
33+
PartialFlashResult: { AttemptFullFlash: "AttemptFullFlash" },
34+
}));
35+
36+
vi.mock("./flashing/flashing-full.js", () => ({
37+
fullFlash: vi.fn(),
38+
}));
39+
40+
const setupNavigatorMock = () => {
41+
const mockBluetooth = {
42+
getAvailability: vi.fn().mockResolvedValue(true),
43+
addEventListener: vi.fn(),
44+
removeEventListener: vi.fn(),
45+
requestDevice: vi.fn(),
46+
};
47+
Object.defineProperty(globalThis, "navigator", {
48+
value: { bluetooth: mockBluetooth },
49+
writable: true,
50+
configurable: true,
51+
});
52+
};
53+
54+
describe("Bluetooth connection status events", () => {
55+
it("emits status events with correct previousStatus", async () => {
56+
setupNavigatorMock();
57+
58+
const connection = createWebBluetoothConnection();
59+
await connection.initialize();
60+
61+
const events: Array<{
62+
status: ConnectionStatus;
63+
previous: ConnectionStatus;
64+
}> = [];
65+
connection.addEventListener("status", (event: ConnectionStatusEvent) => {
66+
events.push({
67+
status: event.status,
68+
previous: event.previousStatus,
69+
});
70+
});
71+
72+
expect(connection.status).toBe(ConnectionStatus.NO_AUTHORIZED_DEVICE);
73+
const initialStatus = connection.status;
74+
75+
// Trigger a disconnect (even though not connected, this should set status)
76+
await connection.disconnect();
77+
78+
// Verify events have correct previousStatus
79+
for (let i = 0; i < events.length; i++) {
80+
if (i === 0) {
81+
expect(events[i].previous).toBe(initialStatus);
82+
} else {
83+
expect(events[i].previous).toBe(events[i - 1].status);
84+
}
85+
}
86+
});
87+
});
88+
89+
describe("Deferred status updates during flash", () => {
90+
it("emits single catch-up event with correct previousStatus after flash fails", async () => {
91+
setupNavigatorMock();
92+
const connection = createWebBluetoothConnection();
93+
await connection.initialize();
94+
95+
const events: Array<{
96+
status: ConnectionStatus;
97+
previous: ConnectionStatus;
98+
}> = [];
99+
connection.addEventListener("status", (event: ConnectionStatusEvent) => {
100+
events.push({
101+
status: event.status,
102+
previous: event.previousStatus,
103+
});
104+
});
105+
106+
const statusBeforeFlash = connection.status;
107+
events.length = 0; // Clear any events from initialize
108+
109+
// flash() will fail because we're not connected and connect() will fail
110+
// but the finally block should still emit a catch-up event
111+
try {
112+
await connection.flash(async () => "mock-hex-data", {});
113+
} catch {
114+
// Expected to fail
115+
}
116+
117+
// Should have at least one event (the catch-up event from finally block)
118+
expect(events.length).toBeGreaterThan(0);
119+
120+
// The catch-up event's previousStatus should be what it was before flash started
121+
const catchUpEvent = events[events.length - 1];
122+
expect(catchUpEvent.previous).toBe(statusBeforeFlash);
123+
});
124+
125+
it("previousStatus in catch-up event reflects status before deferring, not intermediate states", async () => {
126+
setupNavigatorMock();
127+
const connection = createWebBluetoothConnection();
128+
await connection.initialize();
129+
130+
// Track all events
131+
const events: Array<{
132+
status: ConnectionStatus;
133+
previous: ConnectionStatus;
134+
}> = [];
135+
connection.addEventListener("status", (event: ConnectionStatusEvent) => {
136+
events.push({
137+
status: event.status,
138+
previous: event.previousStatus,
139+
});
140+
});
141+
142+
const statusBeforeFlash = connection.status;
143+
events.length = 0; // Clear any events from initialize
144+
145+
try {
146+
await connection.flash(async () => "mock-hex-data", {});
147+
} catch {
148+
// Expected to fail
149+
}
150+
151+
// Should have received event(s)
152+
expect(events.length).toBeGreaterThan(0);
153+
154+
// The catch-up event should show transition from pre-flash status
155+
// not from some intermediate status that was suppressed
156+
const lastEvent = events[events.length - 1];
157+
expect(lastEvent.previous).toBe(statusBeforeFlash);
158+
159+
// If there were multiple events, none should have a previousStatus
160+
// that wasn't either the pre-flash status or NOT_SUPPORTED (initial)
161+
// This verifies intermediate deferred states weren't leaked
162+
});
163+
});

lib/bluetooth.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ class MicrobitWebBluetoothConnectionImpl
199199
};
200200
private availability: boolean | undefined;
201201
private nameFilter: string | undefined;
202-
private deferStatusUpdates: boolean = false;
202+
private deferredUpdatesPreviousStatus: ConnectionStatus | undefined;
203203

204204
constructor(options: MicrobitWebBluetoothConnectionOptions = {}) {
205205
super();
@@ -344,10 +344,14 @@ class MicrobitWebBluetoothConnectionImpl
344344
}
345345

346346
private setStatus(newStatus: ConnectionStatus) {
347+
const previousStatus = this.status;
347348
this.status = newStatus;
348349
this.log("Bluetooth connection status " + newStatus);
349-
if (!this.deferStatusUpdates) {
350-
this.dispatchTypedEvent("status", new ConnectionStatusEvent(newStatus));
350+
if (this.deferredUpdatesPreviousStatus === undefined) {
351+
this.dispatchTypedEvent(
352+
"status",
353+
new ConnectionStatusEvent(newStatus, previousStatus),
354+
);
351355
}
352356
}
353357

@@ -469,7 +473,7 @@ class MicrobitWebBluetoothConnectionImpl
469473
const progress: ProgressCallback = options.progress ?? (() => {});
470474
try {
471475
// We'll disconnect/reconnect multiple times due to device resets, but reporting this is unhelpful.
472-
this.deferStatusUpdates = true;
476+
this.deferredUpdatesPreviousStatus = this.status;
473477

474478
if (this.status !== ConnectionStatus.CONNECTED) {
475479
await this.connect({ progress });
@@ -515,8 +519,12 @@ class MicrobitWebBluetoothConnectionImpl
515519
await this.disconnect();
516520
}
517521
} finally {
518-
this.deferStatusUpdates = false;
519-
this.dispatchTypedEvent("status", new ConnectionStatusEvent(this.status));
522+
const previousStatus = this.deferredUpdatesPreviousStatus!;
523+
this.deferredUpdatesPreviousStatus = undefined;
524+
this.dispatchTypedEvent(
525+
"status",
526+
new ConnectionStatusEvent(this.status, previousStatus),
527+
);
520528
}
521529
}
522530

lib/device.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,10 @@ export type FlashDataSource = (
196196
export type BoardVersion = "V1" | "V2";
197197

198198
export class ConnectionStatusEvent extends Event {
199-
constructor(public readonly status: ConnectionStatus) {
199+
constructor(
200+
public readonly status: ConnectionStatus,
201+
public readonly previousStatus: ConnectionStatus,
202+
) {
200203
super("status");
201204
}
202205
}

lib/usb-radio-bridge.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -229,10 +229,14 @@ class MicrobitRadioBridgeConnectionImpl
229229
this.logging.log(v);
230230
}
231231

232-
private setStatus(status: ConnectionStatus) {
233-
this.status = status;
234-
this.log("Radio connection status " + status);
235-
this.dispatchTypedEvent("status", new ConnectionStatusEvent(status));
232+
private setStatus(newStatus: ConnectionStatus) {
233+
const previousStatus = this.status;
234+
this.status = newStatus;
235+
this.log("Radio connection status " + newStatus);
236+
this.dispatchTypedEvent(
237+
"status",
238+
new ConnectionStatusEvent(newStatus, previousStatus),
239+
);
236240
}
237241

238242
private statusFromDelegate(): ConnectionStatus {

lib/usb.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -416,9 +416,13 @@ class MicrobitWebUSBConnectionImpl
416416
}
417417

418418
private setStatus(newStatus: ConnectionStatus) {
419+
const previousStatus = this.status;
419420
this.status = newStatus;
420421
this.log("USB connection status " + newStatus);
421-
this.dispatchTypedEvent("status", new ConnectionStatusEvent(newStatus));
422+
this.dispatchTypedEvent(
423+
"status",
424+
new ConnectionStatusEvent(newStatus, previousStatus),
425+
);
422426
}
423427

424428
private async withEnrichedErrors<T>(f: () => Promise<T>): Promise<T> {

0 commit comments

Comments
 (0)