import 'dart:async'; import 'package:abawo_bt_app/model/shifter_types.dart'; import 'package:abawo_bt_app/service/firmware_update_service.dart'; import 'package:anyhow/anyhow.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { group('FirmwareUpdateService', () { test('completes happy path with START, data frames, and FINISH', () async { final transport = _FakeFirmwareUpdateTransport(); final service = FirmwareUpdateService( transport: transport, defaultWindowSize: 4, defaultAckTimeout: const Duration(milliseconds: 100), ); final image = List.generate(130, (index) => index & 0xFF); final result = await service.startUpdate( imageBytes: image, sessionId: 7, ); expect(result.isOk(), isTrue); expect(transport.controlWrites.length, 2); expect( transport.controlWrites.first.first, universalShifterDfuOpcodeStart); expect(transport.controlWrites.last, [universalShifterDfuOpcodeFinish]); expect(transport.dataWrites.length, greaterThanOrEqualTo(3)); expect(service.currentProgress.state, DfuUpdateState.completed); expect(service.currentProgress.sentBytes, image.length); await service.dispose(); await transport.dispose(); }); test('rewinds to ack+1 and retransmits after ACK stall', () async { final transport = _FakeFirmwareUpdateTransport(dropFirstSequence: 1); final service = FirmwareUpdateService( transport: transport, defaultWindowSize: 3, defaultAckTimeout: const Duration(milliseconds: 100), maxNoProgressRetries: 4, ); final image = List.generate(190, (index) => index & 0xFF); final result = await service.startUpdate( imageBytes: image, sessionId: 9, ); expect(result.isOk(), isTrue); expect(transport.dataWrites.length, greaterThan(4)); expect(transport.sequenceWriteCount(1), greaterThan(1)); expect(service.currentProgress.state, DfuUpdateState.completed); await service.dispose(); await transport.dispose(); }); test('cancel sends ABORT and reports aborted state', () async { final firstFrameSent = Completer(); final transport = _FakeFirmwareUpdateTransport( onDataWrite: (frame) { if (!firstFrameSent.isCompleted) { firstFrameSent.complete(); } }, suppressDataAcks: true, ); final service = FirmwareUpdateService( transport: transport, defaultWindowSize: 1, defaultAckTimeout: const Duration(milliseconds: 500), ); final future = service.startUpdate( imageBytes: List.generate(90, (index) => index & 0xFF), sessionId: 11, ); await firstFrameSent.future.timeout(const Duration(seconds: 1)); await service.cancelUpdate(); final result = await future; expect(result.isErr(), isTrue); expect(result.unwrapErr().toString(), contains('canceled')); expect(transport.controlWrites.last, [universalShifterDfuOpcodeAbort]); expect(service.currentProgress.state, DfuUpdateState.aborted); await service.dispose(); await transport.dispose(); }); }); } class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport { _FakeFirmwareUpdateTransport({ this.dropFirstSequence, this.onDataWrite, this.suppressDataAcks = false, }); final int? dropFirstSequence; final void Function(List frame)? onDataWrite; final bool suppressDataAcks; final StreamController> _ackController = StreamController>.broadcast(); final List> controlWrites = >[]; final List> dataWrites = >[]; final Set _droppedOnce = {}; int _lastAck = 0xFF; int _expectedSequence = 0; @override Future> runPreflight({ required int requestedMtu, }) async { return Ok( DfuPreflightResult.ready( requestedMtu: requestedMtu, negotiatedMtu: 128, ), ); } @override Stream> subscribeToAck() => _ackController.stream; @override Future> writeControl(List payload) async { controlWrites.add(List.from(payload, growable: false)); final opcode = payload.isEmpty ? -1 : payload.first; if (opcode == universalShifterDfuOpcodeStart) { _lastAck = 0xFF; _expectedSequence = 0; scheduleMicrotask(() { _ackController.add([0xFF]); }); } if (opcode == universalShifterDfuOpcodeAbort) { _lastAck = 0xFF; _expectedSequence = 0; } return Ok(null); } @override Future> writeDataFrame(List frame) async { dataWrites.add(List.from(frame, growable: false)); onDataWrite?.call(frame); if (suppressDataAcks) { return Ok(null); } final sequence = frame.first; final shouldDrop = dropFirstSequence != null && sequence == dropFirstSequence && !_droppedOnce.contains(sequence); if (shouldDrop) { _droppedOnce.add(sequence); scheduleMicrotask(() { _ackController.add([_lastAck]); }); return Ok(null); } if (sequence == _expectedSequence) { _lastAck = sequence; _expectedSequence = (_expectedSequence + 1) & 0xFF; } scheduleMicrotask(() { _ackController.add([_lastAck]); }); return Ok(null); } int sequenceWriteCount(int sequence) { var count = 0; for (final frame in dataWrites) { if (frame.first == sequence) { count += 1; } } return count; } Future dispose() async { await _ackController.close(); } }