Skip to content

Commit 67834d7

Browse files
committed
[red] Reject stale connected board identity
Signed-off-by: Viwat Vchirawongkwin <viwat.v@chula.ac.th>
1 parent 05d7092 commit 67834d7

3 files changed

Lines changed: 197 additions & 1 deletion

File tree

‎app/test/support/shell_harness.dart‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ FakeConnection fakeConnection({
113113
/// radio, NO BleLink. The chrome (pill/toolbar/nav) keeps reading the scripted
114114
/// [connection] via the [connectionProvider] override, so every existing shell
115115
/// assertion holds unchanged.
116-
Future<void> pumpShell(
116+
Future<PbleConnectionManager> pumpShell(
117117
WidgetTester tester, {
118118
required FakeConnection connection,
119119
ShellSurface surface = ipadLandscape,
@@ -153,4 +153,5 @@ Future<void> pumpShell(
153153
),
154154
);
155155
await tester.pumpAndSettle();
156+
return manager;
156157
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// SPDX-License-Identifier: MIT
2+
// Part of PyBLE (https://pyble.dev) — see /LICENSE.
3+
4+
import 'dart:async';
5+
6+
import 'package:flutter_test/flutter_test.dart';
7+
8+
import 'package:pyble/connect/connect.dart';
9+
import 'package:pyble/pble/pble.dart';
10+
11+
import '../support/fake_readiness_seam.dart';
12+
13+
class _DelayedInfoConnection extends FakeConnection {
14+
_DelayedInfoConnection() : super(initial: ConnState.ready);
15+
16+
final Completer<DeviceInfo> _info = Completer<DeviceInfo>();
17+
int deviceInfoCalls = 0;
18+
19+
@override
20+
Future<DeviceInfo> deviceInfo() {
21+
deviceInfoCalls += 1;
22+
return _info.future;
23+
}
24+
25+
void completeInfo(DeviceInfo info) => _info.complete(info);
26+
}
27+
28+
DeviceInfo _info(String deviceId, String agentVersion) => DeviceInfo(
29+
chip: 'esp32-s3',
30+
mpyVersion: '1.28.0',
31+
freeMem: 48000,
32+
fsRoot: '/',
33+
deviceId: deviceId,
34+
agentVersion: agentVersion,
35+
);
36+
37+
void main() {
38+
test(
39+
'late DeviceInfo from a detached board cannot replace current identity',
40+
() async {
41+
final _DelayedInfoConnection first = _DelayedInfoConnection();
42+
final _DelayedInfoConnection second = _DelayedInfoConnection();
43+
final FakeSeamReadiness readiness = FakeSeamReadiness();
44+
final Map<String, _DelayedInfoConnection> boards =
45+
<String, _DelayedInfoConnection>{'board-a': first, 'board-b': second};
46+
final PbleConnectionManager manager = PbleConnectionManager(
47+
scanner: FakeScanner(),
48+
readiness: readiness,
49+
connectionFactory: (String id) async => boards[id]!,
50+
);
51+
final ConnectController controller = ConnectController(manager);
52+
addTearDown(() async {
53+
controller.dispose();
54+
await manager.dispose();
55+
await readiness.dispose();
56+
});
57+
final List<String> publishedIds = <String>[];
58+
controller.addListener(() {
59+
final String? id = controller.state.deviceInfo?.deviceId;
60+
if (id != null) publishedIds.add(id);
61+
});
62+
63+
await manager.connect('board-a');
64+
await pumpEventQueue();
65+
expect(first.deviceInfoCalls, 1);
66+
67+
await manager.disconnect();
68+
await manager.connect('board-b');
69+
await pumpEventQueue();
70+
expect(controller.state.deviceInfo, isNull);
71+
72+
second.completeInfo(_info('BBBB', '0.7.0'));
73+
await pumpEventQueue();
74+
first.completeInfo(_info('AAAA', '0.6.0'));
75+
await pumpEventQueue();
76+
77+
expect(
78+
second.deviceInfoCalls,
79+
1,
80+
reason: 'the replacement session must not wait for the old request',
81+
);
82+
expect(controller.state.deviceInfo?.deviceId, 'BBBB');
83+
expect(controller.state.deviceInfo?.agentVersion, '0.7.0');
84+
expect(
85+
publishedIds,
86+
isNot(contains('AAAA')),
87+
reason: 'prior-session identity must never flash in the live UI',
88+
);
89+
},
90+
);
91+
}

‎app/test/widget/app_shell_test.dart‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
// HAND-OFF provenance: lib/app → app-architect; lib/pble → app-protocol-engineer.
2323

2424
import 'package:flutter/material.dart';
25+
import 'package:flutter/rendering.dart';
2526
import 'package:flutter_test/flutter_test.dart';
2627

2728
import 'package:pyble/app/app.dart';
@@ -231,6 +232,7 @@ void main() {
231232
);
232233

233234
final AppLocalizations l10n = _l10n(tester);
235+
expect(_pillLabel('Ready'), findsOneWidget);
234236
expect(
235237
find.descendant(
236238
of: find.byKey(_pillKey),
@@ -283,6 +285,108 @@ void main() {
283285
expect(find.byTooltip('Disconnect'), findsOneWidget);
284286
},
285287
);
288+
289+
testWidgets(
290+
'Connect-to-IDE transition retains identity in ready and running',
291+
(tester) async {
292+
final FakeConnection connection = fakeConnection(
293+
initial: ConnState.connecting,
294+
info: fakeDeviceInfo(deviceId: '5646', agentVersion: '0.6.0'),
295+
);
296+
final PbleConnectionManager manager = await pumpShell(
297+
tester,
298+
connection: connection,
299+
surface: ipadLandscape,
300+
);
301+
302+
final AppLocalizations l10n = _l10n(tester);
303+
expect(
304+
find.text(l10n.connectEmptyTitle),
305+
findsOneWidget,
306+
reason: 'the pre-connection Connect surface starts mounted',
307+
);
308+
309+
await manager.connect('shell-transition-board');
310+
connection.emit(ConnState.ready);
311+
await tester.pumpAndSettle();
312+
313+
expect(find.text(l10n.connectEmptyTitle), findsNothing);
314+
expect(find.text(l10n.connectDeviceInfoTitle), findsNothing);
315+
expect(_pillLabel('Ready'), findsOneWidget);
316+
expect(
317+
find.text(l10n.connStatusBoardInfoSummary('5646', '0.6.0')),
318+
findsOneWidget,
319+
);
320+
expect(
321+
find.bySemanticsLabel(
322+
l10n.connStatusBoardInfoSemanticLabel('Ready', '5646', '0.6.0'),
323+
),
324+
findsOneWidget,
325+
);
326+
327+
connection.emit(ConnState.running);
328+
await tester.pumpAndSettle();
329+
330+
expect(_pillLabel('Running'), findsOneWidget);
331+
expect(
332+
find.text(l10n.connStatusBoardInfoSummary('5646', '0.6.0')),
333+
findsOneWidget,
334+
);
335+
expect(
336+
find.bySemanticsLabel(
337+
l10n.connStatusBoardInfoSemanticLabel('Running', '5646', '0.6.0'),
338+
),
339+
findsOneWidget,
340+
);
341+
},
342+
);
343+
344+
testWidgets('tight 2x layout ellipsizes identity but not state', (
345+
tester,
346+
) async {
347+
tester.platformDispatcher.textScaleFactorTestValue = 2;
348+
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
349+
const String longId = 'BOARD-IDENTITY-1234567890';
350+
const String longVersion = '0.6.0-development-metadata';
351+
352+
await pumpShell(
353+
tester,
354+
connection: fakeConnection(
355+
initial: ConnState.ready,
356+
info: fakeDeviceInfo(deviceId: longId, agentVersion: longVersion),
357+
),
358+
surface: phonePortrait,
359+
);
360+
361+
final AppLocalizations l10n = _l10n(tester);
362+
final Finder stateFinder = _pillLabel('Ready');
363+
final Finder summaryFinder = find.descendant(
364+
of: find.byKey(_pillKey),
365+
matching: find.text(
366+
l10n.connStatusBoardInfoSummary(longId, longVersion),
367+
),
368+
);
369+
expect(stateFinder, findsOneWidget);
370+
expect(summaryFinder, findsOneWidget);
371+
expect(
372+
tester.renderObject<RenderParagraph>(stateFinder).didExceedMaxLines,
373+
isFalse,
374+
reason: 'the full state word is the non-negotiable minimum signal',
375+
);
376+
expect(
377+
tester.renderObject<RenderParagraph>(summaryFinder).didExceedMaxLines,
378+
isTrue,
379+
reason: 'only the appended identity may ellipsize under pressure',
380+
);
381+
expect(
382+
find.bySemanticsLabel(
383+
l10n.connStatusBoardInfoSemanticLabel('Ready', longId, longVersion),
384+
),
385+
findsOneWidget,
386+
reason: 'visual ellipsis must not truncate accessibility metadata',
387+
);
388+
expect(tester.takeException(), isNull);
389+
});
286390
});
287391

288392
group('FR-UI-1 collapsible landscape Files sidebar', () {

0 commit comments

Comments
 (0)