Files
meshcore-open/test/screens/usb_flow_test.dart
T
Enot (ded) Skelly d529ce9228 fix foreground service and add notification nav
wraps MaterialApp in WithForegroundService to keep alive when swiped away

persists last connected device and clears on manual disconnect to allow
reconnect after kill

added lifecycle tracking to iOS and keep android notification alive with
heartbeat

add notification navigation

change screen tests to be less brittle

address PR commnets
2026-04-13 08:09:22 -07:00

586 lines
18 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/connector/meshcore_connector.dart';
import 'package:meshcore_open/l10n/app_localizations.dart';
import 'package:meshcore_open/utils/usb_port_labels.dart';
// ---------------------------------------------------------------------------
// Pure helpers extracted from UsbScreen logic.
// ---------------------------------------------------------------------------
/// Mirrors `_UsbScreenState._buildStatusBar` text selection.
///
/// [isLoadingPorts] corresponds to the screen's `_isLoadingPorts` flag.
String usbStatusText({
required bool isLoadingPorts,
required bool isUsbTransportConnected,
required MeshCoreConnectionState state,
required MeshCoreTransportType transport,
String? activeUsbPortDisplayLabel,
// L10n strings passed directly so we don't need BuildContext.
required String searching,
required String Function(String) connectedTo,
required String disconnecting,
required String connecting,
required String notConnected,
}) {
if (isLoadingPorts) return searching;
if (isUsbTransportConnected) {
switch (state) {
case MeshCoreConnectionState.connected:
return connectedTo(activeUsbPortDisplayLabel ?? 'USB');
case MeshCoreConnectionState.disconnecting:
return disconnecting;
default:
return notConnected;
}
}
if (state == MeshCoreConnectionState.connecting &&
transport == MeshCoreTransportType.usb) {
return connecting;
}
return notConnected;
}
/// Mirrors `_UsbScreenState._friendlyErrorMessage`.
///
/// Uses string keys instead of l10n objects so this is a pure function.
String usbFriendlyErrorKey(Object error) {
if (error is PlatformException) {
switch (error.code) {
case 'usb_permission_denied':
return 'permissionDenied';
case 'usb_device_missing':
case 'usb_device_detached':
return 'deviceMissing';
case 'usb_invalid_port':
return 'invalidPort';
case 'usb_busy':
return 'busy';
case 'usb_not_connected':
return 'notConnected';
case 'usb_open_failed':
case 'usb_driver_missing':
return 'openFailed';
case 'usb_connect_failed':
return 'connectFailed';
}
}
if (error is UnsupportedError) return 'unsupported';
if (error is StateError) {
final msg = error.message;
if (msg.contains('already active')) return 'alreadyActive';
if (msg.contains('No USB serial device selected')) {
return 'noDeviceSelected';
}
if (msg.contains('not open') || msg.contains('closed')) {
return 'portClosed';
}
if (msg.contains('Timed out')) return 'connectTimedOut';
if (msg.contains('Failed to open')) return 'openFailed';
}
if (error is TimeoutException) return 'connectTimedOut';
return 'unknown';
}
/// Mirrors the guard in `_UsbScreenState._connectPort`:
/// returns true only when the connector is disconnected.
bool shouldAllowUsbConnect(MeshCoreConnectionState state) =>
state == MeshCoreConnectionState.disconnected;
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
void main() {
// -- Port name helpers (normalizeUsbPortName / friendlyUsbPortName) -------
group('USB port name parsing', () {
test('normalizeUsbPortName extracts raw port before separator', () {
expect(normalizeUsbPortName('COM6 - USB Serial Device (COM6)'), 'COM6');
});
test('normalizeUsbPortName returns input when no separator', () {
expect(normalizeUsbPortName('/dev/ttyUSB0'), '/dev/ttyUSB0');
});
test('normalizeUsbPortName trims whitespace', () {
expect(normalizeUsbPortName(' COM3 '), 'COM3');
});
test('friendlyUsbPortName extracts description field', () {
expect(
friendlyUsbPortName('COM6 - USB Serial Device (COM6) - HWID'),
'USB Serial Device (COM6)',
);
});
test(
'friendlyUsbPortName falls back to raw name if description is n/a',
() {
expect(friendlyUsbPortName('COM6 - n/a'), 'COM6');
},
);
test('friendlyUsbPortName falls back when only one part', () {
expect(friendlyUsbPortName('/dev/ttyUSB0'), '/dev/ttyUSB0');
});
});
// -- Connect guard --------------------------------------------------------
group('USB connect guard', () {
test('allows connect when disconnected', () {
expect(
shouldAllowUsbConnect(MeshCoreConnectionState.disconnected),
isTrue,
);
});
test('blocks connect when connected', () {
expect(shouldAllowUsbConnect(MeshCoreConnectionState.connected), isFalse);
});
test('blocks connect when connecting', () {
expect(
shouldAllowUsbConnect(MeshCoreConnectionState.connecting),
isFalse,
);
});
test('blocks connect when scanning', () {
expect(shouldAllowUsbConnect(MeshCoreConnectionState.scanning), isFalse);
});
test('blocks connect when disconnecting', () {
expect(
shouldAllowUsbConnect(MeshCoreConnectionState.disconnecting),
isFalse,
);
});
});
// -- Status text ----------------------------------------------------------
group('USB status text', () {
String status({
bool isLoadingPorts = false,
bool isUsbTransportConnected = false,
MeshCoreConnectionState state = MeshCoreConnectionState.disconnected,
MeshCoreTransportType transport = MeshCoreTransportType.usb,
String? activeUsbPortDisplayLabel,
}) => usbStatusText(
isLoadingPorts: isLoadingPorts,
isUsbTransportConnected: isUsbTransportConnected,
state: state,
transport: transport,
activeUsbPortDisplayLabel: activeUsbPortDisplayLabel,
searching: 'SEARCHING',
connectedTo: (label) => 'CONNECTED:$label',
disconnecting: 'DISCONNECTING',
connecting: 'CONNECTING',
notConnected: 'NOT_CONNECTED',
);
test('loading ports shows searching', () {
expect(status(isLoadingPorts: true), 'SEARCHING');
});
test('connected USB with label', () {
expect(
status(
isUsbTransportConnected: true,
state: MeshCoreConnectionState.connected,
activeUsbPortDisplayLabel: 'COM6 - Device',
),
'CONNECTED:COM6 - Device',
);
});
test('connected USB with null label falls back to USB', () {
expect(
status(
isUsbTransportConnected: true,
state: MeshCoreConnectionState.connected,
),
'CONNECTED:USB',
);
});
test('USB transport connected but disconnecting', () {
expect(
status(
isUsbTransportConnected: true,
state: MeshCoreConnectionState.disconnecting,
),
'DISCONNECTING',
);
});
test('USB transport connected but scanning falls to default', () {
expect(
status(
isUsbTransportConnected: true,
state: MeshCoreConnectionState.scanning,
),
'NOT_CONNECTED',
);
});
test('connecting over USB shows connecting', () {
expect(status(state: MeshCoreConnectionState.connecting), 'CONNECTING');
});
test('connecting over bluetooth falls through to not-connected', () {
expect(
status(
state: MeshCoreConnectionState.connecting,
transport: MeshCoreTransportType.bluetooth,
),
'NOT_CONNECTED',
);
});
test('disconnected shows not-connected', () {
expect(status(), 'NOT_CONNECTED');
});
});
// -- Error mapping --------------------------------------------------------
group('USB friendly error mapping', () {
test('PlatformException usb_permission_denied', () {
expect(
usbFriendlyErrorKey(PlatformException(code: 'usb_permission_denied')),
'permissionDenied',
);
});
test('PlatformException usb_device_missing', () {
expect(
usbFriendlyErrorKey(PlatformException(code: 'usb_device_missing')),
'deviceMissing',
);
});
test('PlatformException usb_device_detached', () {
expect(
usbFriendlyErrorKey(PlatformException(code: 'usb_device_detached')),
'deviceMissing',
);
});
test('PlatformException usb_invalid_port', () {
expect(
usbFriendlyErrorKey(PlatformException(code: 'usb_invalid_port')),
'invalidPort',
);
});
test('PlatformException usb_busy', () {
expect(usbFriendlyErrorKey(PlatformException(code: 'usb_busy')), 'busy');
});
test('PlatformException usb_not_connected', () {
expect(
usbFriendlyErrorKey(PlatformException(code: 'usb_not_connected')),
'notConnected',
);
});
test('PlatformException usb_open_failed', () {
expect(
usbFriendlyErrorKey(PlatformException(code: 'usb_open_failed')),
'openFailed',
);
});
test('PlatformException usb_driver_missing', () {
expect(
usbFriendlyErrorKey(PlatformException(code: 'usb_driver_missing')),
'openFailed',
);
});
test('PlatformException usb_connect_failed', () {
expect(
usbFriendlyErrorKey(PlatformException(code: 'usb_connect_failed')),
'connectFailed',
);
});
test('PlatformException with unknown code falls through', () {
expect(
usbFriendlyErrorKey(PlatformException(code: 'usb_whatever')),
'unknown',
);
});
test('UnsupportedError → unsupported', () {
expect(usbFriendlyErrorKey(UnsupportedError('nope')), 'unsupported');
});
test('StateError "already active" → alreadyActive', () {
expect(
usbFriendlyErrorKey(StateError('already active')),
'alreadyActive',
);
});
test('StateError "No USB serial device selected" → noDeviceSelected', () {
expect(
usbFriendlyErrorKey(StateError('No USB serial device selected')),
'noDeviceSelected',
);
});
test('StateError "not open" → portClosed', () {
expect(usbFriendlyErrorKey(StateError('port not open')), 'portClosed');
});
test('StateError "closed" → portClosed', () {
expect(
usbFriendlyErrorKey(StateError('connection closed')),
'portClosed',
);
});
test('StateError "Timed out" → connectTimedOut', () {
expect(
usbFriendlyErrorKey(StateError('Timed out waiting')),
'connectTimedOut',
);
});
test('StateError "Failed to open" → openFailed', () {
expect(
usbFriendlyErrorKey(StateError('Failed to open device')),
'openFailed',
);
});
test('TimeoutException → connectTimedOut', () {
expect(usbFriendlyErrorKey(TimeoutException('slow')), 'connectTimedOut');
});
test('generic error → unknown', () {
expect(usbFriendlyErrorKey(Exception('boom')), 'unknown');
});
});
// -- Localized strings resolve correctly ----------------------------------
testWidgets('English USB localizations resolve without error', (
tester,
) async {
late AppLocalizations l10n;
await tester.pumpWidget(
MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder: (context) {
l10n = AppLocalizations.of(context);
return const SizedBox.shrink();
},
),
),
);
await tester.pumpAndSettle();
expect(l10n.usbScreenTitle, isNotEmpty);
expect(l10n.usbScreenStatus, 'Select a USB device');
expect(l10n.usbStatus_notConnected, isNotEmpty);
expect(l10n.usbStatus_connecting, isNotEmpty);
expect(l10n.usbStatus_searching, isNotEmpty);
expect(l10n.usbErrorPermissionDenied, isNotEmpty);
expect(l10n.usbErrorDeviceMissing, isNotEmpty);
expect(l10n.usbErrorInvalidPort, isNotEmpty);
expect(l10n.usbErrorBusy, isNotEmpty);
expect(l10n.usbErrorNotConnected, isNotEmpty);
expect(l10n.usbErrorOpenFailed, isNotEmpty);
expect(l10n.usbErrorConnectFailed, isNotEmpty);
expect(l10n.usbErrorUnsupported, isNotEmpty);
expect(l10n.usbErrorAlreadyActive, isNotEmpty);
expect(l10n.usbErrorNoDeviceSelected, isNotEmpty);
expect(l10n.usbErrorPortClosed, isNotEmpty);
expect(l10n.usbErrorConnectTimedOut, isNotEmpty);
expect(l10n.scanner_connectedTo('device'), contains('device'));
expect(l10n.scanner_disconnecting, isNotEmpty);
});
// -- Isolated widget: status bar Row with FittedBox overflow --------------
testWidgets('USB status bar with long text does not overflow at 320px', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(320, 100));
addTearDown(() => tester.binding.setSurfaceSize(null));
const longText =
'Connected to /dev/bus/usb/001/002 - KD3CGK mesh-utility.org very long label';
const statusColor = Colors.green;
// Exact widget tree from _buildStatusBar in UsbScreen.
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
color: statusColor.withValues(alpha: 0.1),
child: Row(
children: [
const Icon(Icons.circle, size: 12, color: statusColor),
const SizedBox(width: 8),
Expanded(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
longText,
style: const TextStyle(
color: statusColor,
fontWeight: FontWeight.w500,
),
),
),
),
],
),
),
),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.text(longText), findsOneWidget);
});
// -- Isolated widget: bottom nav FittedBox overflow -----------------------
testWidgets('Bottom nav row with multiple FABs does not overflow at 320px', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(320, 200));
addTearDown(() => tester.binding.setSurfaceSize(null));
// Mirrors the bottomNavigationBar structure from ScannerScreen / UsbScreen
// with all possible buttons visible.
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: const SizedBox.expand(),
bottomNavigationBar: SafeArea(
top: false,
minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerRight,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
FloatingActionButton.extended(
onPressed: () {},
heroTag: 'usb',
icon: const Icon(Icons.usb),
label: const Text('USB'),
),
const SizedBox(width: 12),
FloatingActionButton.extended(
onPressed: () {},
heroTag: 'tcp',
icon: const Icon(Icons.lan),
label: const Text('TCP'),
),
const SizedBox(width: 12),
FloatingActionButton.extended(
onPressed: () {},
heroTag: 'ble',
icon: const Icon(Icons.bluetooth_searching),
label: const Text('Scan'),
),
],
),
),
),
),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.text('USB'), findsOneWidget);
expect(find.text('TCP'), findsOneWidget);
expect(find.text('Scan'), findsOneWidget);
});
// -- describeWebUsbPort ---------------------------------------------------
group('describeWebUsbPort', () {
test('null vendor and product returns requestPortLabel', () {
expect(
describeWebUsbPort(vendorId: null, productId: null),
'Choose USB Device',
);
});
test('known VID:PID uses knownUsbNames', () {
expect(
describeWebUsbPort(
vendorId: 0x1A86,
productId: 0x7523,
knownUsbNames: {'1a86:7523': 'CH340 Serial'},
),
'CH340 Serial (VID:1A86 PID:7523)',
);
});
test('unknown VID:PID uses fallback device name', () {
expect(
describeWebUsbPort(
vendorId: 0x1234,
productId: 0x5678,
fallbackDeviceName: 'My Device',
),
'My Device (VID:1234 PID:5678)',
);
});
});
// -- buildUsbDisplayLabel -------------------------------------------------
group('buildUsbDisplayLabel', () {
test('appends device name when present', () {
expect(
buildUsbDisplayLabel(
basePortLabel: 'COM6',
deviceName: 'MeshCore Node',
),
'COM6 - MeshCore Node',
);
});
test('returns base label when device name is null', () {
expect(
buildUsbDisplayLabel(basePortLabel: 'COM6', deviceName: null),
'COM6',
);
});
test('returns base label when device name is whitespace', () {
expect(
buildUsbDisplayLabel(basePortLabel: 'COM6', deviceName: ' '),
'COM6',
);
});
});
}