From ca2b54b3f04068bb4b1542d4677f38d7faa15798 Mon Sep 17 00:00:00 2001 From: allofmeng <165001210+allofmeng@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:09:42 +0800 Subject: [PATCH 1/3] fix(ble): stop treating a missing GATT attribute as a dead link (gh-656) characteristicNotFound and serviceNotFound were in _goneDeviceCodes, so any read of an attribute the peripheral does not expose made the transport emit disconnected and drain the queue. The Solo Barista (LSJ-001) routes to EurekaScale but has no 0x180F battery service, so the optional battery read at the end of onConnect dropped the scale microseconds after it connected. Both codes are ambiguous: a live peripheral returns them for an attribute that is not in its GATT database, a dead link returns them from a stale cache. They now log, rethrow the original UniversalBleException, and hand off to _probeAndDeclareIfDead, which asks the OS for the real link state before declaring the link dead. EurekaScale also gates the battery read on the discovered service list. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RRSCR8Q7HWcHPwH8qvPap6 --- doc/AI_BLE_NOTES.md | 12 +++- .../device/impl/eureka/eureka_scale.dart | 4 +- .../services/ble/universal_ble_transport.dart | 20 ++++++- .../ble/universal_ble_transport_mtu_test.dart | 58 ++++++++++++++++++- 4 files changed, 89 insertions(+), 5 deletions(-) diff --git a/doc/AI_BLE_NOTES.md b/doc/AI_BLE_NOTES.md index 068eceb08..c474a1466 100644 --- a/doc/AI_BLE_NOTES.md +++ b/doc/AI_BLE_NOTES.md @@ -144,10 +144,20 @@ cancellation. ## Gone-Device Error Handling `UniversalBleTransport._handleGattError()` catches `UniversalBleException` with gone-device codes: -`characteristicNotFound`, `deviceNotFound`, `serviceNotFound`, `connectionTerminated`, `deviceDisconnected`, `unknownError`. +`deviceNotFound`, `connectionTerminated`, `deviceDisconnected`, `unknownError`. On hit: emits `disconnected`, drains the queue with typed `deviceDisconnected`, and throws `DeviceNotConnectedException`. +`characteristicNotFound` and `serviceNotFound` are ambiguous and are handled separately. A live peripheral +returns them when the attribute simply is not in its GATT database, and a dead link returns them from a stale +cache. Treating them as gone-device broke the Solo Barista (LSJ-001), which the matcher routes to `EurekaScale` +but which has no 0x180F battery service: the optional battery read at the end of `onConnect()` failed with +`characteristicNotFound`, the transport emitted `disconnected`, and the scale dropped one tick after connecting +(log signature: `GATT read(...2a19...) failed - device gone`, then `scale connection update: disconnected`). +These two codes now log, rethrow the original `UniversalBleException`, and hand off to +`_probeAndDeclareIfDead()`, which asks the OS for the real link state and only then declares the link dead. +Device implementations should still gate optional reads on `discoverServices()` rather than relying on the probe. + The `isBenignFrameworkError()` filter in `crashlytics_error_filter.dart` suppresses these from `FlutterError.onError` — but scale-level catches at the write helper are defense-in-depth. ## Faulted Queue Recovery diff --git a/lib/src/models/device/impl/eureka/eureka_scale.dart b/lib/src/models/device/impl/eureka/eureka_scale.dart index f76aa6312..31045835e 100644 --- a/lib/src/models/device/impl/eureka/eureka_scale.dart +++ b/lib/src/models/device/impl/eureka/eureka_scale.dart @@ -89,7 +89,9 @@ class EurekaScale implements Scale { ); } await _registerNotifications(); - _readBattery(); + if (batteryService.matchesAny(services)) { + _readBattery(); + } _connectionStateController.add(ConnectionState.connected); } catch (e) { _log.warning('Connect failed: $e'); diff --git a/lib/src/services/ble/universal_ble_transport.dart b/lib/src/services/ble/universal_ble_transport.dart index 71075f614..27bb8c608 100644 --- a/lib/src/services/ble/universal_ble_transport.dart +++ b/lib/src/services/ble/universal_ble_transport.dart @@ -256,18 +256,34 @@ class UniversalBleTransport extends BLETransport { } static const _goneDeviceCodes = { - UniversalBleErrorCode.characteristicNotFound, UniversalBleErrorCode.deviceNotFound, - UniversalBleErrorCode.serviceNotFound, UniversalBleErrorCode.connectionTerminated, UniversalBleErrorCode.deviceDisconnected, }; + static const _attributeMissingCodes = { + UniversalBleErrorCode.characteristicNotFound, + UniversalBleErrorCode.serviceNotFound, + }; + Never _handleGattError( UniversalBleException e, String operation, String path, ) { + if (_attributeMissingCodes.contains(e.code)) { + _log.warning( + 'GATT $operation($path) failed — attribute not in GATT database: ' + '${e.code}', + ); + unawaited( + _probeAndDeclareIfDead( + 'GATT $operation($path) attribute missing', + _connectionGeneration, + ), + ); + throw e; + } if (_goneDeviceCodes.contains(e.code)) { _log.warning('GATT $operation($path) failed — device gone: ${e.code}'); _connectionStateSubject.add(device.ConnectionState.disconnected); diff --git a/test/services/ble/universal_ble_transport_mtu_test.dart b/test/services/ble/universal_ble_transport_mtu_test.dart index 96803af1c..523351563 100644 --- a/test/services/ble/universal_ble_transport_mtu_test.dart +++ b/test/services/ble/universal_ble_transport_mtu_test.dart @@ -9,6 +9,7 @@ class _MtuRecordingBlePlatform extends UniversalBlePlatform { final List<(String, int)> mtuRequests = []; bool throwOnRequestMtu = false; + UniversalBleErrorCode? readErrorCode; bool systemConnected = true; bool attached = false; int connectCalls = 0; @@ -92,7 +93,16 @@ class _MtuRecordingBlePlatform extends UniversalBlePlatform { String service, String characteristic, { Duration? timeout, - }) async => Uint8List(0); + }) async { + final code = readErrorCode; + if (code != null) { + throw UniversalBleException( + code: code, + message: 'simulated read failure', + ); + } + return Uint8List(0); + } @override Future writeValue( @@ -162,6 +172,52 @@ void main() { requestLargeMtuNonAndroid: flag, ); + test('missing characteristic on a live link does not disconnect', () async { + final value = transport(android: false, linux: false); + final states = []; + final subscription = value.connectionState.listen(states.add); + await value.connect(); + platform.readErrorCode = UniversalBleErrorCode.characteristicNotFound; + + await expectLater( + value.read( + '0000180f-0000-1000-8000-00805f9b34fb', + '00002a19-0000-1000-8000-00805f9b34fb', + ), + throwsA(isA()), + ); + await Future.delayed(const Duration(milliseconds: 50)); + + expect(states, isNot(contains(device.ConnectionState.disconnected))); + await subscription.cancel(); + await value.dispose(); + }); + + test( + 'missing characteristic after the link died reports disconnected', + () async { + final value = transport(android: false, linux: false); + final states = []; + final subscription = value.connectionState.listen(states.add); + await value.connect(); + platform.readErrorCode = UniversalBleErrorCode.characteristicNotFound; + platform.systemConnected = false; + + await expectLater( + value.read( + '0000180f-0000-1000-8000-00805f9b34fb', + '00002a19-0000-1000-8000-00805f9b34fb', + ), + throwsA(isA()), + ); + await Future.delayed(const Duration(milliseconds: 50)); + + expect(states, contains(device.ConnectionState.disconnected)); + await subscription.cancel(); + await value.dispose(); + }, + ); + test('Android requests 517 once with the flag off or on', () async { for (final flag in [false, true]) { final value = transport(android: true, linux: false, flag: flag); From 691742dd3da1d767755ce17e232be77de2338a5f Mon Sep 17 00:00:00 2001 From: allofmeng <165001210+allofmeng@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:12:14 +0800 Subject: [PATCH 2/3] fix(ble): wrap missing-GATT-attribute errors in a domain exception Review feedback on #657: - `_handleGattError()` no longer rethrows the third-party `UniversalBleException` for `characteristicNotFound` / `serviceNotFound`. It throws `GattAttributeUnavailableException`, which extends `DeviceNotConnectedException` so the lowest-level scale write helpers keep catching it: for a write, a stale-GATT `characteristicNotFound` may still mean a dead link, and the asynchronous OS probe cannot change the exception already handed to the caller. - Transport regression tests now run over both `characteristicNotFound` and `serviceNotFound`, plus a case asserting the error is catchable as `DeviceNotConnectedException`. - New `test/unit/models/eureka_scale_test.dart` covers the Solo Barista path with a fake transport: discovery without 0x180F registers notifications, skips the battery read, and stays connected; discovery with 0x180F still reads the battery level. - `doc/AI_BLE_NOTES.md` documents the domain exception and why it subclasses `DeviceNotConnectedException`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013888xMCNLWCuQFW1RkgYka --- doc/AI_BLE_NOTES.md | 6 +- lib/src/models/errors.dart | 15 ++ .../services/ble/universal_ble_transport.dart | 2 +- test/controllers/de1_controller_test.dart | 38 ++-- test/controllers/shot_sequencer_test.dart | 11 +- .../workflow_device_sync_test.dart | 51 +++-- .../ble/universal_ble_transport_mtu_test.dart | 69 ++++--- .../universal_ble_discovery_service_test.dart | 8 +- .../generate_simulation_assets_test.dart | 180 +++++++++--------- .../remembered_devices_controller_test.dart | 33 ++-- .../firmware_mmr_exclusion_test.dart | 54 +++--- test/unit/models/eureka_scale_test.dart | 132 +++++++++++++ ...universal_ble_transport_recovery_test.dart | 16 +- 13 files changed, 410 insertions(+), 205 deletions(-) create mode 100644 test/unit/models/eureka_scale_test.dart diff --git a/doc/AI_BLE_NOTES.md b/doc/AI_BLE_NOTES.md index c474a1466..11c157c3b 100644 --- a/doc/AI_BLE_NOTES.md +++ b/doc/AI_BLE_NOTES.md @@ -154,8 +154,12 @@ cache. Treating them as gone-device broke the Solo Barista (LSJ-001), which the but which has no 0x180F battery service: the optional battery read at the end of `onConnect()` failed with `characteristicNotFound`, the transport emitted `disconnected`, and the scale dropped one tick after connecting (log signature: `GATT read(...2a19...) failed - device gone`, then `scale connection update: disconnected`). -These two codes now log, rethrow the original `UniversalBleException`, and hand off to +These two codes now log, throw the domain `GattAttributeUnavailableException`, and hand off to `_probeAndDeclareIfDead()`, which asks the OS for the real link state and only then declares the link dead. +`GattAttributeUnavailableException` extends `DeviceNotConnectedException`, so the lowest-level scale write +helpers that already catch `DeviceNotConnectedException` keep swallowing it: for a write, a stale-GATT +`characteristicNotFound` may still mean a dead link, and the asynchronous probe cannot retroactively change +the exception the caller already received. Device implementations should still gate optional reads on `discoverServices()` rather than relying on the probe. The `isBenignFrameworkError()` filter in `crashlytics_error_filter.dart` suppresses these from `FlutterError.onError` — but scale-level catches at the write helper are defense-in-depth. diff --git a/lib/src/models/errors.dart b/lib/src/models/errors.dart index f01dda17b..0d0265f1c 100644 --- a/lib/src/models/errors.dart +++ b/lib/src/models/errors.dart @@ -23,6 +23,21 @@ class DeviceNotConnectedException implements Exception { 'DeviceNotConnectedException: ${kind.name} not connected'; } +class GattAttributeUnavailableException extends DeviceNotConnectedException { + final String operation; + final String path; + + const GattAttributeUnavailableException({ + required this.operation, + required this.path, + }) : super(DeviceKind.unknown); + + @override + String toString() => + 'GattAttributeUnavailableException: $operation($path) not in the ' + 'GATT database'; +} + class DeviceIdentityMismatchException implements Exception { final String expected; final int actualModelValue; diff --git a/lib/src/services/ble/universal_ble_transport.dart b/lib/src/services/ble/universal_ble_transport.dart index 27bb8c608..3782d1e6a 100644 --- a/lib/src/services/ble/universal_ble_transport.dart +++ b/lib/src/services/ble/universal_ble_transport.dart @@ -282,7 +282,7 @@ class UniversalBleTransport extends BLETransport { _connectionGeneration, ), ); - throw e; + throw GattAttributeUnavailableException(operation: operation, path: path); } if (_goneDeviceCodes.contains(e.code)) { _log.warning('GATT $operation($path) failed — device gone: ${e.code}'); diff --git a/test/controllers/de1_controller_test.dart b/test/controllers/de1_controller_test.dart index c40e7f36b..ded60c449 100644 --- a/test/controllers/de1_controller_test.dart +++ b/test/controllers/de1_controller_test.dart @@ -124,25 +124,31 @@ void main() { }); group('initial shot settings', () { - test('missing initial settings do not block initialization', () async { - final deviceController = DeviceController([MockDeviceDiscoveryService()]); - await deviceController.initialize(); - final de1Controller = De1Controller(controller: deviceController); - final testDe1 = TestDe1(); + test( + 'missing initial settings do not block initialization', + () async { + final deviceController = DeviceController([ + MockDeviceDiscoveryService(), + ]); + await deviceController.initialize(); + final de1Controller = De1Controller(controller: deviceController); + final testDe1 = TestDe1(); - await de1Controller.connectToDe1(testDe1); + await de1Controller.connectToDe1(testDe1); - expect( - await de1Controller.initSettled - .firstWhere((generation) => generation != null) - .timeout(const Duration(seconds: 3)), - isNotNull, - ); - expect(de1Controller.connectedDe1OrNull, same(testDe1)); + expect( + await de1Controller.initSettled + .firstWhere((generation) => generation != null) + .timeout(const Duration(seconds: 3)), + isNotNull, + ); + expect(de1Controller.connectedDe1OrNull, same(testDe1)); - testDe1.dispose(); - de1Controller.dispose(); - }, timeout: const Timeout(Duration(seconds: 5))); + testDe1.dispose(); + de1Controller.dispose(); + }, + timeout: const Timeout(Duration(seconds: 5)), + ); }); group('shot-settings debounce race (comms-harden #5)', () { diff --git a/test/controllers/shot_sequencer_test.dart b/test/controllers/shot_sequencer_test.dart index b6843bf86..07052e4d0 100644 --- a/test/controllers/shot_sequencer_test.dart +++ b/test/controllers/shot_sequencer_test.dart @@ -1406,12 +1406,11 @@ void main() { [0.0, 0.0, 0.0, 18.0], reason: 'pre-tare frames are 0; real weight only after the pour tare', ); - expect(recorded.map((s) => s.scale?.weightFlow).toList(), [ - 0.0, - 0.0, - 0.0, - 2.0, - ], reason: 'flow off the un-tared cup must not leak either'); + expect( + recorded.map((s) => s.scale?.weightFlow).toList(), + [0.0, 0.0, 0.0, 2.0], + reason: 'flow off the un-tared cup must not leak either', + ); shotSequencer.dispose(); }); diff --git a/test/controllers/workflow_device_sync_test.dart b/test/controllers/workflow_device_sync_test.dart index d745e21ac..6892b7dd3 100644 --- a/test/controllers/workflow_device_sync_test.dart +++ b/test/controllers/workflow_device_sync_test.dart @@ -398,9 +398,11 @@ void main() { applyProfile('A'); await Future.delayed(const Duration(milliseconds: 10)); - expect(gated.setProfileCalls.map((p) => p.title), [ - 'A', - ], reason: 'first change starts an upload immediately'); + expect( + gated.setProfileCalls.map((p) => p.title), + ['A'], + reason: 'first change starts an upload immediately', + ); applyProfile('B'); applyProfile('C'); @@ -413,10 +415,11 @@ void main() { gated.completeNext(); await Future.delayed(const Duration(milliseconds: 10)); - expect(gated.setProfileCalls.map((p) => p.title), [ - 'A', - 'C', - ], reason: 'B was superseded by C before its upload started'); + expect( + gated.setProfileCalls.map((p) => p.title), + ['A', 'C'], + reason: 'B was superseded by C before its upload started', + ); gated.completeNext(); await Future.delayed(const Duration(milliseconds: 10)); @@ -449,9 +452,11 @@ void main() { ); await Future.delayed(const Duration(milliseconds: 40)); - expect(flaky.setProfileCalls.map((p) => p.title), [ - 'Cleaning', - ], reason: 'retry must fire without any further workflow change'); + expect( + flaky.setProfileCalls.map((p) => p.title), + ['Cleaning'], + reason: 'retry must fire without any further workflow change', + ); }, ); @@ -468,9 +473,11 @@ void main() { applyProfile('B'); await Future.delayed(const Duration(milliseconds: 100)); - expect(flaky.setProfileCalls.map((p) => p.title), [ - 'B', - ], reason: 'superseded profile A must never be uploaded'); + expect( + flaky.setProfileCalls.map((p) => p.title), + ['B'], + reason: 'superseded profile A must never be uploaded', + ); }); test('backoff walks the delay list until the upload lands', () async { @@ -568,11 +575,11 @@ void main() { gated.completeNext(); await Future.delayed(const Duration(milliseconds: 10)); - expect(gated.setProfileCalls.map((p) => p.title), [ - 'P1', - 'P2', - 'P1', - ], reason: 'device must converge to the workflow profile, not P2'); + expect( + gated.setProfileCalls.map((p) => p.title), + ['P1', 'P2', 'P1'], + reason: 'device must converge to the workflow profile, not P2', + ); gated.completeNext(); await Future.delayed(const Duration(milliseconds: 10)); @@ -873,9 +880,11 @@ void main() { expect(steamEvents, hasLength(steamEventCountAfterBInit)); expect(hotWaterEvents, hasLength(hotWaterEventCountAfterBInit)); expect(rinseEvents, hasLength(rinseEventCountAfterBInit)); - expect(de1B.setProfileCalls.map((p) => p.title), [ - 'Persisted', - ], reason: 'B should receive the profile via initSettled'); + expect( + de1B.setProfileCalls.map((p) => p.title), + ['Persisted'], + reason: 'B should receive the profile via initSettled', + ); }); test('stale init does not emit B\'s generation', () async { diff --git a/test/services/ble/universal_ble_transport_mtu_test.dart b/test/services/ble/universal_ble_transport_mtu_test.dart index 523351563..57cefcd25 100644 --- a/test/services/ble/universal_ble_transport_mtu_test.dart +++ b/test/services/ble/universal_ble_transport_mtu_test.dart @@ -2,6 +2,7 @@ import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; import 'package:reaprime/src/models/device/device.dart' as device; +import 'package:reaprime/src/models/errors.dart'; import 'package:reaprime/src/services/ble/universal_ble_transport.dart'; import 'package:universal_ble/universal_ble.dart'; @@ -172,35 +173,37 @@ void main() { requestLargeMtuNonAndroid: flag, ); - test('missing characteristic on a live link does not disconnect', () async { - final value = transport(android: false, linux: false); - final states = []; - final subscription = value.connectionState.listen(states.add); - await value.connect(); - platform.readErrorCode = UniversalBleErrorCode.characteristicNotFound; + for (final code in [ + UniversalBleErrorCode.characteristicNotFound, + UniversalBleErrorCode.serviceNotFound, + ]) { + test('$code on a live link does not disconnect', () async { + final value = transport(android: false, linux: false); + final states = []; + final subscription = value.connectionState.listen(states.add); + await value.connect(); + platform.readErrorCode = code; - await expectLater( - value.read( - '0000180f-0000-1000-8000-00805f9b34fb', - '00002a19-0000-1000-8000-00805f9b34fb', - ), - throwsA(isA()), - ); - await Future.delayed(const Duration(milliseconds: 50)); + await expectLater( + value.read( + '0000180f-0000-1000-8000-00805f9b34fb', + '00002a19-0000-1000-8000-00805f9b34fb', + ), + throwsA(isA()), + ); + await Future.delayed(const Duration(milliseconds: 50)); - expect(states, isNot(contains(device.ConnectionState.disconnected))); - await subscription.cancel(); - await value.dispose(); - }); + expect(states, isNot(contains(device.ConnectionState.disconnected))); + await subscription.cancel(); + await value.dispose(); + }); - test( - 'missing characteristic after the link died reports disconnected', - () async { + test('$code after the link died reports disconnected', () async { final value = transport(android: false, linux: false); final states = []; final subscription = value.connectionState.listen(states.add); await value.connect(); - platform.readErrorCode = UniversalBleErrorCode.characteristicNotFound; + platform.readErrorCode = code; platform.systemConnected = false; await expectLater( @@ -208,15 +211,31 @@ void main() { '0000180f-0000-1000-8000-00805f9b34fb', '00002a19-0000-1000-8000-00805f9b34fb', ), - throwsA(isA()), + throwsA(isA()), ); await Future.delayed(const Duration(milliseconds: 50)); expect(states, contains(device.ConnectionState.disconnected)); await subscription.cancel(); await value.dispose(); - }, - ); + }); + } + + test('attribute-missing errors are catchable as not-connected', () async { + final value = transport(android: false, linux: false); + await value.connect(); + platform.readErrorCode = UniversalBleErrorCode.characteristicNotFound; + + await expectLater( + value.read( + '0000180f-0000-1000-8000-00805f9b34fb', + '00002a19-0000-1000-8000-00805f9b34fb', + ), + throwsA(isA()), + ); + await Future.delayed(const Duration(milliseconds: 50)); + await value.dispose(); + }); test('Android requests 517 once with the flag off or on', () async { for (final flag in [false, true]) { diff --git a/test/services/universal_ble_discovery_service_test.dart b/test/services/universal_ble_discovery_service_test.dart index 51c9f30db..d1eca9d31 100644 --- a/test/services/universal_ble_discovery_service_test.dart +++ b/test/services/universal_ble_discovery_service_test.dart @@ -410,9 +410,11 @@ void main() { final prefixes = platform.startScanCalls .map((c) => c.filter?.withNamePrefix ?? const []) .toList(); - expect(prefixes.first, [ - 'Decent Scale', - ], reason: 'the raced watch start settles before the burst starts'); + expect( + prefixes.first, + ['Decent Scale'], + reason: 'the raced watch start settles before the burst starts', + ); expect( prefixes[1], isEmpty, diff --git a/test/tools/generate_simulation_assets_test.dart b/test/tools/generate_simulation_assets_test.dart index e9a47de9f..c0b870723 100644 --- a/test/tools/generate_simulation_assets_test.dart +++ b/test/tools/generate_simulation_assets_test.dart @@ -206,101 +206,105 @@ void main() { ); }); - test('regenerate bundled simulation shots', () { - Directory(outputDir).createSync(recursive: true); + test( + 'regenerate bundled simulation shots', + () { + Directory(outputDir).createSync(recursive: true); - (String, double) convert(File source) { - final stem = source.uri.pathSegments.last.replaceAll('.shot', ''); - final content = source.readAsStringSync(); - final map = TclParser.parse(content); - final parsed = TclShotParser.parse(content); - final framed = _withFrames( - parsed.shot.measurements, - map['espresso_state_change'], - ); - final resampled = _resampleTo10Hz(framed); - final originalDuration = _durationSeconds(resampled); - final shot = parsed.shot.copyWith( - measurements: _extendTo(resampled, _targetDurationSeconds), - ); - - final json = shot.toJson(); - json['id'] = 'sim-$stem'; - final workflow = json['workflow']; - if (workflow is Map) { - workflow['id'] = 'sim-$stem-workflow'; - final first = shot.measurements.first.machine; - final durationSeconds = - shot.measurements.last.machine.timestamp - .difference(first.timestamp) - .inMilliseconds / - 1000.0; - final replayStep = ProfileStepPressure( - name: 'Replay', - transition: TransitionType.fast, - volume: 0, - seconds: durationSeconds, - temperature: first.targetGroupTemperature > 0 - ? first.targetGroupTemperature - : 90, - sensor: TemperatureSensor.coffee, - pressure: first.targetPressure > 0 ? first.targetPressure : 9, + (String, double) convert(File source) { + final stem = source.uri.pathSegments.last.replaceAll('.shot', ''); + final content = source.readAsStringSync(); + final map = TclParser.parse(content); + final parsed = TclShotParser.parse(content); + final framed = _withFrames( + parsed.shot.measurements, + map['espresso_state_change'], + ); + final resampled = _resampleTo10Hz(framed); + final originalDuration = _durationSeconds(resampled); + final shot = parsed.shot.copyWith( + measurements: _extendTo(resampled, _targetDurationSeconds), ); - final profile = workflow['profile']; - if (profile is Map) { - profile['steps'] = [replayStep.toJson()]; + + final json = shot.toJson(); + json['id'] = 'sim-$stem'; + final workflow = json['workflow']; + if (workflow is Map) { + workflow['id'] = 'sim-$stem-workflow'; + final first = shot.measurements.first.machine; + final durationSeconds = + shot.measurements.last.machine.timestamp + .difference(first.timestamp) + .inMilliseconds / + 1000.0; + final replayStep = ProfileStepPressure( + name: 'Replay', + transition: TransitionType.fast, + volume: 0, + seconds: durationSeconds, + temperature: first.targetGroupTemperature > 0 + ? first.targetGroupTemperature + : 90, + sensor: TemperatureSensor.coffee, + pressure: first.targetPressure > 0 ? first.targetPressure : 9, + ); + final profile = workflow['profile']; + if (profile is Map) { + profile['steps'] = [replayStep.toJson()]; + } } - } - final roundTripped = ShotRecord.fromJson( - jsonDecode(jsonEncode(json)) as Map, - ); - expect(roundTripped.measurements, isNotEmpty); + final roundTripped = ShotRecord.fromJson( + jsonDecode(jsonEncode(json)) as Map, + ); + expect(roundTripped.measurements, isNotEmpty); - // Compact (unindented) — these are generated, machine-read data files; - // indentation would roughly double the bundled size. - File('$outputDir/$stem.json').writeAsStringSync(jsonEncode(json)); - return ('$stem.json', originalDuration); - } + // Compact (unindented) — these are generated, machine-read data files; + // indentation would roughly double the bundled size. + File('$outputDir/$stem.json').writeAsStringSync(jsonEncode(json)); + return ('$stem.json', originalDuration); + } - List shotsIn(String dir) => Directory(dir).existsSync() - ? (Directory(dir) - .listSync() - .whereType() - .where((f) => f.path.endsWith('.shot')) - .toList() - ..sort((a, b) => a.path.compareTo(b.path))) - : []; + List shotsIn(String dir) => Directory(dir).existsSync() + ? (Directory(dir) + .listSync() + .whereType() + .where((f) => f.path.endsWith('.shot')) + .toList() + ..sort((a, b) => a.path.compareTo(b.path))) + : []; - final fallbackSources = shotsIn(sourceDir); - expect(fallbackSources, isNotEmpty, reason: 'no fallback .shot sources'); - final fallback = fallbackSources.map((s) { - final (file, original) = convert(s); - return {'file': file, 'originalDurationSeconds': original}; - }).toList(); + final fallbackSources = shotsIn(sourceDir); + expect(fallbackSources, isNotEmpty, reason: 'no fallback .shot sources'); + final fallback = fallbackSources.map((s) { + final (file, original) = convert(s); + return {'file': file, 'originalDurationSeconds': original}; + }).toList(); - final profiles = >[]; - for (final source in shotsIn(profileDir)) { - final stem = source.uri.pathSegments.last.replaceAll('.shot', ''); - final bundledProfile = File('assets/defaultProfiles/$stem.json'); - final title = bundledProfile.existsSync() - ? (jsonDecode(bundledProfile.readAsStringSync()) - as Map)['title'] - as String - : TclShotParser.parse(source.readAsStringSync()).shot.workflow.name; - final (file, original) = convert(source); - profiles.add({ - 'file': file, - 'profileTitle': title, - 'profileFile': '$stem.json', - 'originalDurationSeconds': original, - }); - } + final profiles = >[]; + for (final source in shotsIn(profileDir)) { + final stem = source.uri.pathSegments.last.replaceAll('.shot', ''); + final bundledProfile = File('assets/defaultProfiles/$stem.json'); + final title = bundledProfile.existsSync() + ? (jsonDecode(bundledProfile.readAsStringSync()) + as Map)['title'] + as String + : TclShotParser.parse(source.readAsStringSync()).shot.workflow.name; + final (file, original) = convert(source); + profiles.add({ + 'file': file, + 'profileTitle': title, + 'profileFile': '$stem.json', + 'originalDurationSeconds': original, + }); + } - File('$outputDir/manifest.json').writeAsStringSync( - const JsonEncoder.withIndent( - ' ', - ).convert({'fallback': fallback, 'profiles': profiles}), - ); - }, skip: regenerate ? false : 'set REGEN_SIM_ASSETS=1 to rebuild assets'); + File('$outputDir/manifest.json').writeAsStringSync( + const JsonEncoder.withIndent( + ' ', + ).convert({'fallback': fallback, 'profiles': profiles}), + ); + }, + skip: regenerate ? false : 'set REGEN_SIM_ASSETS=1 to rebuild assets', + ); } diff --git a/test/unit/controllers/remembered_devices_controller_test.dart b/test/unit/controllers/remembered_devices_controller_test.dart index c6d654644..356a54001 100644 --- a/test/unit/controllers/remembered_devices_controller_test.dart +++ b/test/unit/controllers/remembered_devices_controller_test.dart @@ -81,9 +81,11 @@ void main() { scale.add(null); await Future.delayed(Duration.zero); - expect(controller.remembered.map((d) => d.id), [ - 's', - ], reason: 'disconnect keeps it remembered'); + expect( + controller.remembered.map((d) => d.id), + ['s'], + reason: 'disconnect keeps it remembered', + ); }); test('registry restores from settings on init', () async { @@ -191,10 +193,11 @@ void main() { ); await Future.delayed(Duration.zero); - expect(controller.remembered.map((d) => d.id).toSet(), { - 'wifi:hds.local', - 'AA:BB:CC:DD:EE:FF', - }, reason: 'same name, distinct ids → distinct entries'); + expect( + controller.remembered.map((d) => d.id).toSet(), + {'wifi:hds.local', 'AA:BB:CC:DD:EE:FF'}, + reason: 'same name, distinct ids → distinct entries', + ); }, ); @@ -233,9 +236,11 @@ void main() { throwsA(isA()), reason: 'the awaitable forget path must not swallow a persist failure', ); - expect(controller.remembered.map((d) => d.id), [ - 'a', - ], reason: 'a failed persist rolls back the removal (memory matches disk)'); + expect( + controller.remembered.map((d) => d.id), + ['a'], + reason: 'a failed persist rolls back the removal (memory matches disk)', + ); }); test( @@ -267,9 +272,11 @@ void main() { controller = build(); await controller.initialize(); - expect(controller.remembered.map((d) => d.id), [ - 'a', - ], reason: 'an unreadable record must not abort the whole load'); + expect( + controller.remembered.map((d) => d.id), + ['a'], + reason: 'an unreadable record must not abort the whole load', + ); }, ); diff --git a/test/unit/models/device/impl/de1/unified_de1/firmware_mmr_exclusion_test.dart b/test/unit/models/device/impl/de1/unified_de1/firmware_mmr_exclusion_test.dart index ef0afb5b7..8d3947c21 100644 --- a/test/unit/models/device/impl/de1/unified_de1/firmware_mmr_exclusion_test.dart +++ b/test/unit/models/device/impl/de1/unified_de1/firmware_mmr_exclusion_test.dart @@ -158,33 +158,37 @@ void main() { timeout: const Timeout(Duration(seconds: 10)), ); - test('MMR issued during firmware follows final firmware traffic', () async { - transport.queueFirmwareMapResponse([0, 0, 0, 1, 0xff, 0xff, 0xff]); - transport.queueFirmwareMapResponse([0, 0, 0, 1, 0xff, 0xff, 0xfd]); - final eraseRequest = transport.nextWrite(Endpoint.fwMapRequest.uuid); - final update = de1.updateFirmware( - Uint8List.fromList(List.filled(16, 0xab)), - onProgress: (_) {}, - ); - await eraseRequest; + test( + 'MMR issued during firmware follows final firmware traffic', + () async { + transport.queueFirmwareMapResponse([0, 0, 0, 1, 0xff, 0xff, 0xff]); + transport.queueFirmwareMapResponse([0, 0, 0, 1, 0xff, 0xff, 0xfd]); + final eraseRequest = transport.nextWrite(Endpoint.fwMapRequest.uuid); + final update = de1.updateFirmware( + Uint8List.fromList(List.filled(16, 0xab)), + onProgress: (_) {}, + ); + await eraseRequest; - transport.queueMmrResponseInt(MMRItem.targetSteamFlow, 100); - final read = de1.getSteamFlow(); - await update; - await read; + transport.queueMmrResponseInt(MMRItem.targetSteamFlow, 100); + final read = de1.getSteamFlow(); + await update; + await read; - final characteristics = transport.writes - .map((write) => write.characteristicUUID) - .toList(); - expect( - characteristics.indexOf(Endpoint.readFromMMR.uuid), - greaterThan(characteristics.lastIndexOf(Endpoint.fwMapRequest.uuid)), - ); - expect( - characteristics.indexOf(Endpoint.readFromMMR.uuid), - greaterThan(characteristics.lastIndexOf(Endpoint.writeToMMR.uuid)), - ); - }, timeout: const Timeout(Duration(seconds: 15))); + final characteristics = transport.writes + .map((write) => write.characteristicUUID) + .toList(); + expect( + characteristics.indexOf(Endpoint.readFromMMR.uuid), + greaterThan(characteristics.lastIndexOf(Endpoint.fwMapRequest.uuid)), + ); + expect( + characteristics.indexOf(Endpoint.readFromMMR.uuid), + greaterThan(characteristics.lastIndexOf(Endpoint.writeToMMR.uuid)), + ); + }, + timeout: const Timeout(Duration(seconds: 15)), + ); } class _BarrierBleTransport extends BarrierBleTransport { diff --git a/test/unit/models/eureka_scale_test.dart b/test/unit/models/eureka_scale_test.dart new file mode 100644 index 000000000..8a70fd95c --- /dev/null +++ b/test/unit/models/eureka_scale_test.dart @@ -0,0 +1,132 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:reaprime/src/models/device/device.dart'; +import 'package:reaprime/src/models/device/impl/eureka/eureka_scale.dart'; +import 'package:reaprime/src/models/device/transport/ble_transport.dart'; +import 'package:rxdart/rxdart.dart'; + +class _MockEurekaBleTransport extends BLETransport { + _MockEurekaBleTransport({required this.serviceUUIDs}); + + final List serviceUUIDs; + final BehaviorSubject _connectionState = + BehaviorSubject.seeded(ConnectionState.discovered); + final List<(String, String)> reads = []; + final List<(String, String)> subscriptions = []; + + @override + String get id => 'AA:BB:CC:DD:EE:FF'; + + @override + String get name => 'Solo Barista'; + + @override + Stream get connectionState => _connectionState.stream; + + @override + Future getConnectionState() async => _connectionState.value; + + @override + Future connect() async { + _connectionState.add(ConnectionState.connected); + } + + @override + Future disconnect() async { + _connectionState.add(ConnectionState.disconnected); + } + + @override + Future> discoverServices() async => serviceUUIDs; + + @override + Future read( + String serviceUUID, + String characteristicUUID, { + Duration? timeout, + }) async { + reads.add((serviceUUID, characteristicUUID)); + return Uint8List.fromList([50]); + } + + @override + Future subscribe( + String serviceUUID, + String characteristicUUID, + void Function(Uint8List) callback, + ) async { + subscriptions.add((serviceUUID, characteristicUUID)); + } + + @override + Future write( + String serviceUUID, + String characteristicUUID, + Uint8List data, { + bool withResponse = true, + Duration? timeout, + }) async {} + + @override + Future setTransportPriority(bool prioritized) async {} + + @override + Future dispose() async { + await _connectionState.close(); + } +} + +void main() { + test( + 'scale without a battery service connects and skips the battery read', + () async { + final transport = _MockEurekaBleTransport( + serviceUUIDs: [EurekaScale.serviceIdentifier.long], + ); + final scale = EurekaScale(transport: transport); + final states = []; + final subscription = scale.connectionState.listen(states.add); + + await scale.onConnect(); + await Future.delayed(const Duration(milliseconds: 20)); + + expect( + transport.subscriptions, + contains(( + EurekaScale.serviceIdentifier.long, + EurekaScale.dataCharacteristic.long, + )), + ); + expect(transport.reads, isEmpty); + expect(states.last, ConnectionState.connected); + + await subscription.cancel(); + await transport.dispose(); + }, + ); + + test('scale with a battery service still reads the battery level', () async { + final transport = _MockEurekaBleTransport( + serviceUUIDs: [ + EurekaScale.serviceIdentifier.long, + EurekaScale.batteryService.long, + ], + ); + final scale = EurekaScale(transport: transport); + + await scale.onConnect(); + await Future.delayed(const Duration(milliseconds: 20)); + + expect( + transport.reads, + contains(( + EurekaScale.batteryService.long, + EurekaScale.batteryCharacteristic.long, + )), + ); + + await transport.dispose(); + }); +} diff --git a/test/universal_ble_transport_recovery_test.dart b/test/universal_ble_transport_recovery_test.dart index 5ed361ad7..221f720d4 100644 --- a/test/universal_ble_transport_recovery_test.dart +++ b/test/universal_ble_transport_recovery_test.dart @@ -647,12 +647,16 @@ void main() { await pump(); for (var i = 0; i < chars.length; i++) { - expect(newReceived[i], [ - (i + 1) * 10, - ], reason: 'new callback for ${chars[i]} must receive the push'); - expect(oldReceived[i], [ - i + 1, - ], reason: 'old callback for ${chars[i]} must NOT receive the push'); + expect( + newReceived[i], + [(i + 1) * 10], + reason: 'new callback for ${chars[i]} must receive the push', + ); + expect( + oldReceived[i], + [i + 1], + reason: 'old callback for ${chars[i]} must NOT receive the push', + ); } }); From d3c0c8b15f367eb1760c3bbdf07a0fed8e29820a Mon Sep 17 00:00:00 2001 From: allofmeng <165001210+allofmeng@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:42:35 +0800 Subject: [PATCH 3/3] style(test): revert formatter churn in unrelated test files The previous commits reformatted eight test files untouched by this fix. That formatting came from an older local Dart formatter and disagrees with the stable Flutter the CI format gate runs, so `dart format --set-exit-if-changed` over the PR diff failed. Restore those files to their base state; the fix's own files are unaffected. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015J7EV8fcsjBNH15axyAiwk --- test/controllers/de1_controller_test.dart | 38 ++-- test/controllers/shot_sequencer_test.dart | 11 +- .../workflow_device_sync_test.dart | 51 ++--- .../universal_ble_discovery_service_test.dart | 8 +- .../generate_simulation_assets_test.dart | 180 +++++++++--------- .../remembered_devices_controller_test.dart | 33 ++-- .../firmware_mmr_exclusion_test.dart | 54 +++--- ...universal_ble_transport_recovery_test.dart | 16 +- 8 files changed, 178 insertions(+), 213 deletions(-) diff --git a/test/controllers/de1_controller_test.dart b/test/controllers/de1_controller_test.dart index ded60c449..c40e7f36b 100644 --- a/test/controllers/de1_controller_test.dart +++ b/test/controllers/de1_controller_test.dart @@ -124,31 +124,25 @@ void main() { }); group('initial shot settings', () { - test( - 'missing initial settings do not block initialization', - () async { - final deviceController = DeviceController([ - MockDeviceDiscoveryService(), - ]); - await deviceController.initialize(); - final de1Controller = De1Controller(controller: deviceController); - final testDe1 = TestDe1(); + test('missing initial settings do not block initialization', () async { + final deviceController = DeviceController([MockDeviceDiscoveryService()]); + await deviceController.initialize(); + final de1Controller = De1Controller(controller: deviceController); + final testDe1 = TestDe1(); - await de1Controller.connectToDe1(testDe1); + await de1Controller.connectToDe1(testDe1); - expect( - await de1Controller.initSettled - .firstWhere((generation) => generation != null) - .timeout(const Duration(seconds: 3)), - isNotNull, - ); - expect(de1Controller.connectedDe1OrNull, same(testDe1)); + expect( + await de1Controller.initSettled + .firstWhere((generation) => generation != null) + .timeout(const Duration(seconds: 3)), + isNotNull, + ); + expect(de1Controller.connectedDe1OrNull, same(testDe1)); - testDe1.dispose(); - de1Controller.dispose(); - }, - timeout: const Timeout(Duration(seconds: 5)), - ); + testDe1.dispose(); + de1Controller.dispose(); + }, timeout: const Timeout(Duration(seconds: 5))); }); group('shot-settings debounce race (comms-harden #5)', () { diff --git a/test/controllers/shot_sequencer_test.dart b/test/controllers/shot_sequencer_test.dart index 07052e4d0..b6843bf86 100644 --- a/test/controllers/shot_sequencer_test.dart +++ b/test/controllers/shot_sequencer_test.dart @@ -1406,11 +1406,12 @@ void main() { [0.0, 0.0, 0.0, 18.0], reason: 'pre-tare frames are 0; real weight only after the pour tare', ); - expect( - recorded.map((s) => s.scale?.weightFlow).toList(), - [0.0, 0.0, 0.0, 2.0], - reason: 'flow off the un-tared cup must not leak either', - ); + expect(recorded.map((s) => s.scale?.weightFlow).toList(), [ + 0.0, + 0.0, + 0.0, + 2.0, + ], reason: 'flow off the un-tared cup must not leak either'); shotSequencer.dispose(); }); diff --git a/test/controllers/workflow_device_sync_test.dart b/test/controllers/workflow_device_sync_test.dart index 6892b7dd3..d745e21ac 100644 --- a/test/controllers/workflow_device_sync_test.dart +++ b/test/controllers/workflow_device_sync_test.dart @@ -398,11 +398,9 @@ void main() { applyProfile('A'); await Future.delayed(const Duration(milliseconds: 10)); - expect( - gated.setProfileCalls.map((p) => p.title), - ['A'], - reason: 'first change starts an upload immediately', - ); + expect(gated.setProfileCalls.map((p) => p.title), [ + 'A', + ], reason: 'first change starts an upload immediately'); applyProfile('B'); applyProfile('C'); @@ -415,11 +413,10 @@ void main() { gated.completeNext(); await Future.delayed(const Duration(milliseconds: 10)); - expect( - gated.setProfileCalls.map((p) => p.title), - ['A', 'C'], - reason: 'B was superseded by C before its upload started', - ); + expect(gated.setProfileCalls.map((p) => p.title), [ + 'A', + 'C', + ], reason: 'B was superseded by C before its upload started'); gated.completeNext(); await Future.delayed(const Duration(milliseconds: 10)); @@ -452,11 +449,9 @@ void main() { ); await Future.delayed(const Duration(milliseconds: 40)); - expect( - flaky.setProfileCalls.map((p) => p.title), - ['Cleaning'], - reason: 'retry must fire without any further workflow change', - ); + expect(flaky.setProfileCalls.map((p) => p.title), [ + 'Cleaning', + ], reason: 'retry must fire without any further workflow change'); }, ); @@ -473,11 +468,9 @@ void main() { applyProfile('B'); await Future.delayed(const Duration(milliseconds: 100)); - expect( - flaky.setProfileCalls.map((p) => p.title), - ['B'], - reason: 'superseded profile A must never be uploaded', - ); + expect(flaky.setProfileCalls.map((p) => p.title), [ + 'B', + ], reason: 'superseded profile A must never be uploaded'); }); test('backoff walks the delay list until the upload lands', () async { @@ -575,11 +568,11 @@ void main() { gated.completeNext(); await Future.delayed(const Duration(milliseconds: 10)); - expect( - gated.setProfileCalls.map((p) => p.title), - ['P1', 'P2', 'P1'], - reason: 'device must converge to the workflow profile, not P2', - ); + expect(gated.setProfileCalls.map((p) => p.title), [ + 'P1', + 'P2', + 'P1', + ], reason: 'device must converge to the workflow profile, not P2'); gated.completeNext(); await Future.delayed(const Duration(milliseconds: 10)); @@ -880,11 +873,9 @@ void main() { expect(steamEvents, hasLength(steamEventCountAfterBInit)); expect(hotWaterEvents, hasLength(hotWaterEventCountAfterBInit)); expect(rinseEvents, hasLength(rinseEventCountAfterBInit)); - expect( - de1B.setProfileCalls.map((p) => p.title), - ['Persisted'], - reason: 'B should receive the profile via initSettled', - ); + expect(de1B.setProfileCalls.map((p) => p.title), [ + 'Persisted', + ], reason: 'B should receive the profile via initSettled'); }); test('stale init does not emit B\'s generation', () async { diff --git a/test/services/universal_ble_discovery_service_test.dart b/test/services/universal_ble_discovery_service_test.dart index d1eca9d31..51c9f30db 100644 --- a/test/services/universal_ble_discovery_service_test.dart +++ b/test/services/universal_ble_discovery_service_test.dart @@ -410,11 +410,9 @@ void main() { final prefixes = platform.startScanCalls .map((c) => c.filter?.withNamePrefix ?? const []) .toList(); - expect( - prefixes.first, - ['Decent Scale'], - reason: 'the raced watch start settles before the burst starts', - ); + expect(prefixes.first, [ + 'Decent Scale', + ], reason: 'the raced watch start settles before the burst starts'); expect( prefixes[1], isEmpty, diff --git a/test/tools/generate_simulation_assets_test.dart b/test/tools/generate_simulation_assets_test.dart index c0b870723..e9a47de9f 100644 --- a/test/tools/generate_simulation_assets_test.dart +++ b/test/tools/generate_simulation_assets_test.dart @@ -206,105 +206,101 @@ void main() { ); }); - test( - 'regenerate bundled simulation shots', - () { - Directory(outputDir).createSync(recursive: true); + test('regenerate bundled simulation shots', () { + Directory(outputDir).createSync(recursive: true); - (String, double) convert(File source) { - final stem = source.uri.pathSegments.last.replaceAll('.shot', ''); - final content = source.readAsStringSync(); - final map = TclParser.parse(content); - final parsed = TclShotParser.parse(content); - final framed = _withFrames( - parsed.shot.measurements, - map['espresso_state_change'], - ); - final resampled = _resampleTo10Hz(framed); - final originalDuration = _durationSeconds(resampled); - final shot = parsed.shot.copyWith( - measurements: _extendTo(resampled, _targetDurationSeconds), - ); + (String, double) convert(File source) { + final stem = source.uri.pathSegments.last.replaceAll('.shot', ''); + final content = source.readAsStringSync(); + final map = TclParser.parse(content); + final parsed = TclShotParser.parse(content); + final framed = _withFrames( + parsed.shot.measurements, + map['espresso_state_change'], + ); + final resampled = _resampleTo10Hz(framed); + final originalDuration = _durationSeconds(resampled); + final shot = parsed.shot.copyWith( + measurements: _extendTo(resampled, _targetDurationSeconds), + ); - final json = shot.toJson(); - json['id'] = 'sim-$stem'; - final workflow = json['workflow']; - if (workflow is Map) { - workflow['id'] = 'sim-$stem-workflow'; - final first = shot.measurements.first.machine; - final durationSeconds = - shot.measurements.last.machine.timestamp - .difference(first.timestamp) - .inMilliseconds / - 1000.0; - final replayStep = ProfileStepPressure( - name: 'Replay', - transition: TransitionType.fast, - volume: 0, - seconds: durationSeconds, - temperature: first.targetGroupTemperature > 0 - ? first.targetGroupTemperature - : 90, - sensor: TemperatureSensor.coffee, - pressure: first.targetPressure > 0 ? first.targetPressure : 9, - ); - final profile = workflow['profile']; - if (profile is Map) { - profile['steps'] = [replayStep.toJson()]; - } + final json = shot.toJson(); + json['id'] = 'sim-$stem'; + final workflow = json['workflow']; + if (workflow is Map) { + workflow['id'] = 'sim-$stem-workflow'; + final first = shot.measurements.first.machine; + final durationSeconds = + shot.measurements.last.machine.timestamp + .difference(first.timestamp) + .inMilliseconds / + 1000.0; + final replayStep = ProfileStepPressure( + name: 'Replay', + transition: TransitionType.fast, + volume: 0, + seconds: durationSeconds, + temperature: first.targetGroupTemperature > 0 + ? first.targetGroupTemperature + : 90, + sensor: TemperatureSensor.coffee, + pressure: first.targetPressure > 0 ? first.targetPressure : 9, + ); + final profile = workflow['profile']; + if (profile is Map) { + profile['steps'] = [replayStep.toJson()]; } + } - final roundTripped = ShotRecord.fromJson( - jsonDecode(jsonEncode(json)) as Map, - ); - expect(roundTripped.measurements, isNotEmpty); + final roundTripped = ShotRecord.fromJson( + jsonDecode(jsonEncode(json)) as Map, + ); + expect(roundTripped.measurements, isNotEmpty); - // Compact (unindented) — these are generated, machine-read data files; - // indentation would roughly double the bundled size. - File('$outputDir/$stem.json').writeAsStringSync(jsonEncode(json)); - return ('$stem.json', originalDuration); - } + // Compact (unindented) — these are generated, machine-read data files; + // indentation would roughly double the bundled size. + File('$outputDir/$stem.json').writeAsStringSync(jsonEncode(json)); + return ('$stem.json', originalDuration); + } - List shotsIn(String dir) => Directory(dir).existsSync() - ? (Directory(dir) - .listSync() - .whereType() - .where((f) => f.path.endsWith('.shot')) - .toList() - ..sort((a, b) => a.path.compareTo(b.path))) - : []; + List shotsIn(String dir) => Directory(dir).existsSync() + ? (Directory(dir) + .listSync() + .whereType() + .where((f) => f.path.endsWith('.shot')) + .toList() + ..sort((a, b) => a.path.compareTo(b.path))) + : []; - final fallbackSources = shotsIn(sourceDir); - expect(fallbackSources, isNotEmpty, reason: 'no fallback .shot sources'); - final fallback = fallbackSources.map((s) { - final (file, original) = convert(s); - return {'file': file, 'originalDurationSeconds': original}; - }).toList(); + final fallbackSources = shotsIn(sourceDir); + expect(fallbackSources, isNotEmpty, reason: 'no fallback .shot sources'); + final fallback = fallbackSources.map((s) { + final (file, original) = convert(s); + return {'file': file, 'originalDurationSeconds': original}; + }).toList(); - final profiles = >[]; - for (final source in shotsIn(profileDir)) { - final stem = source.uri.pathSegments.last.replaceAll('.shot', ''); - final bundledProfile = File('assets/defaultProfiles/$stem.json'); - final title = bundledProfile.existsSync() - ? (jsonDecode(bundledProfile.readAsStringSync()) - as Map)['title'] - as String - : TclShotParser.parse(source.readAsStringSync()).shot.workflow.name; - final (file, original) = convert(source); - profiles.add({ - 'file': file, - 'profileTitle': title, - 'profileFile': '$stem.json', - 'originalDurationSeconds': original, - }); - } + final profiles = >[]; + for (final source in shotsIn(profileDir)) { + final stem = source.uri.pathSegments.last.replaceAll('.shot', ''); + final bundledProfile = File('assets/defaultProfiles/$stem.json'); + final title = bundledProfile.existsSync() + ? (jsonDecode(bundledProfile.readAsStringSync()) + as Map)['title'] + as String + : TclShotParser.parse(source.readAsStringSync()).shot.workflow.name; + final (file, original) = convert(source); + profiles.add({ + 'file': file, + 'profileTitle': title, + 'profileFile': '$stem.json', + 'originalDurationSeconds': original, + }); + } - File('$outputDir/manifest.json').writeAsStringSync( - const JsonEncoder.withIndent( - ' ', - ).convert({'fallback': fallback, 'profiles': profiles}), - ); - }, - skip: regenerate ? false : 'set REGEN_SIM_ASSETS=1 to rebuild assets', - ); + File('$outputDir/manifest.json').writeAsStringSync( + const JsonEncoder.withIndent( + ' ', + ).convert({'fallback': fallback, 'profiles': profiles}), + ); + }, skip: regenerate ? false : 'set REGEN_SIM_ASSETS=1 to rebuild assets'); } diff --git a/test/unit/controllers/remembered_devices_controller_test.dart b/test/unit/controllers/remembered_devices_controller_test.dart index 356a54001..c6d654644 100644 --- a/test/unit/controllers/remembered_devices_controller_test.dart +++ b/test/unit/controllers/remembered_devices_controller_test.dart @@ -81,11 +81,9 @@ void main() { scale.add(null); await Future.delayed(Duration.zero); - expect( - controller.remembered.map((d) => d.id), - ['s'], - reason: 'disconnect keeps it remembered', - ); + expect(controller.remembered.map((d) => d.id), [ + 's', + ], reason: 'disconnect keeps it remembered'); }); test('registry restores from settings on init', () async { @@ -193,11 +191,10 @@ void main() { ); await Future.delayed(Duration.zero); - expect( - controller.remembered.map((d) => d.id).toSet(), - {'wifi:hds.local', 'AA:BB:CC:DD:EE:FF'}, - reason: 'same name, distinct ids → distinct entries', - ); + expect(controller.remembered.map((d) => d.id).toSet(), { + 'wifi:hds.local', + 'AA:BB:CC:DD:EE:FF', + }, reason: 'same name, distinct ids → distinct entries'); }, ); @@ -236,11 +233,9 @@ void main() { throwsA(isA()), reason: 'the awaitable forget path must not swallow a persist failure', ); - expect( - controller.remembered.map((d) => d.id), - ['a'], - reason: 'a failed persist rolls back the removal (memory matches disk)', - ); + expect(controller.remembered.map((d) => d.id), [ + 'a', + ], reason: 'a failed persist rolls back the removal (memory matches disk)'); }); test( @@ -272,11 +267,9 @@ void main() { controller = build(); await controller.initialize(); - expect( - controller.remembered.map((d) => d.id), - ['a'], - reason: 'an unreadable record must not abort the whole load', - ); + expect(controller.remembered.map((d) => d.id), [ + 'a', + ], reason: 'an unreadable record must not abort the whole load'); }, ); diff --git a/test/unit/models/device/impl/de1/unified_de1/firmware_mmr_exclusion_test.dart b/test/unit/models/device/impl/de1/unified_de1/firmware_mmr_exclusion_test.dart index 8d3947c21..ef0afb5b7 100644 --- a/test/unit/models/device/impl/de1/unified_de1/firmware_mmr_exclusion_test.dart +++ b/test/unit/models/device/impl/de1/unified_de1/firmware_mmr_exclusion_test.dart @@ -158,37 +158,33 @@ void main() { timeout: const Timeout(Duration(seconds: 10)), ); - test( - 'MMR issued during firmware follows final firmware traffic', - () async { - transport.queueFirmwareMapResponse([0, 0, 0, 1, 0xff, 0xff, 0xff]); - transport.queueFirmwareMapResponse([0, 0, 0, 1, 0xff, 0xff, 0xfd]); - final eraseRequest = transport.nextWrite(Endpoint.fwMapRequest.uuid); - final update = de1.updateFirmware( - Uint8List.fromList(List.filled(16, 0xab)), - onProgress: (_) {}, - ); - await eraseRequest; + test('MMR issued during firmware follows final firmware traffic', () async { + transport.queueFirmwareMapResponse([0, 0, 0, 1, 0xff, 0xff, 0xff]); + transport.queueFirmwareMapResponse([0, 0, 0, 1, 0xff, 0xff, 0xfd]); + final eraseRequest = transport.nextWrite(Endpoint.fwMapRequest.uuid); + final update = de1.updateFirmware( + Uint8List.fromList(List.filled(16, 0xab)), + onProgress: (_) {}, + ); + await eraseRequest; - transport.queueMmrResponseInt(MMRItem.targetSteamFlow, 100); - final read = de1.getSteamFlow(); - await update; - await read; + transport.queueMmrResponseInt(MMRItem.targetSteamFlow, 100); + final read = de1.getSteamFlow(); + await update; + await read; - final characteristics = transport.writes - .map((write) => write.characteristicUUID) - .toList(); - expect( - characteristics.indexOf(Endpoint.readFromMMR.uuid), - greaterThan(characteristics.lastIndexOf(Endpoint.fwMapRequest.uuid)), - ); - expect( - characteristics.indexOf(Endpoint.readFromMMR.uuid), - greaterThan(characteristics.lastIndexOf(Endpoint.writeToMMR.uuid)), - ); - }, - timeout: const Timeout(Duration(seconds: 15)), - ); + final characteristics = transport.writes + .map((write) => write.characteristicUUID) + .toList(); + expect( + characteristics.indexOf(Endpoint.readFromMMR.uuid), + greaterThan(characteristics.lastIndexOf(Endpoint.fwMapRequest.uuid)), + ); + expect( + characteristics.indexOf(Endpoint.readFromMMR.uuid), + greaterThan(characteristics.lastIndexOf(Endpoint.writeToMMR.uuid)), + ); + }, timeout: const Timeout(Duration(seconds: 15))); } class _BarrierBleTransport extends BarrierBleTransport { diff --git a/test/universal_ble_transport_recovery_test.dart b/test/universal_ble_transport_recovery_test.dart index 221f720d4..5ed361ad7 100644 --- a/test/universal_ble_transport_recovery_test.dart +++ b/test/universal_ble_transport_recovery_test.dart @@ -647,16 +647,12 @@ void main() { await pump(); for (var i = 0; i < chars.length; i++) { - expect( - newReceived[i], - [(i + 1) * 10], - reason: 'new callback for ${chars[i]} must receive the push', - ); - expect( - oldReceived[i], - [i + 1], - reason: 'old callback for ${chars[i]} must NOT receive the push', - ); + expect(newReceived[i], [ + (i + 1) * 10, + ], reason: 'new callback for ${chars[i]} must receive the push'); + expect(oldReceived[i], [ + i + 1, + ], reason: 'old callback for ${chars[i]} must NOT receive the push'); } });