import 'dart:async'; import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; import 'package:dmc_app/transport/link_transport.dart'; import 'package:dmc_app/transport/udp/mlrs_network_controller.dart'; import 'package:dmc_app/transport/udp/udp_transport.dart'; /// Test-Doppel fuer [MlrsNetworkController] (Doku Kommunikationsschicht v2, /// Abschnitt 4): simuliert Netzanfrage- und Socket-Ergebnisse rein in Dart, /// ohne einen echten Android-Platform-Channel/nativen Socket zu brauchen. /// Der eigentliche `DatagramSocket` lebt seit der Umstellung auf /// socket-genaue Bindung (statt Prozessbindung) nativ in /// MlrsNetworkPlugin.kt - dieses Fake deckt daher nur noch die /// Orchestrierung in UdpTransport ab, nicht mehr echtes UDP-Verhalten. class FakeMlrsNetworkController implements MlrsNetworkController { final _eventsController = StreamController.broadcast(); final _packetsController = StreamController.broadcast(); final List requestedSsidPrefixes = []; final List requestedPreferredSsids = []; final List<({Uint8List data, String host, int port})> sentPackets = []; final List rememberedSsids = []; int releaseCount = 0; int openSocketCount = 0; int closeSocketCount = 0; bool? staConcurrencyResult; int nextBoundPort = 14550; int? nextFallbackFromPort; @override Stream get events => _eventsController.stream; @override Stream get incomingPackets => _packetsController.stream; void emit(MlrsNetworkEvent event) => _eventsController.add(event); void emitPacket(MlrsSocketPacket packet) => _packetsController.add(packet); @override Future requestNetwork(String ssidPrefix, {String? preferredSsid}) async { requestedSsidPrefixes.add(ssidPrefix); requestedPreferredSsids.add(preferredSsid); } @override Future releaseNetwork() async { releaseCount++; } @override Future openSocket(int port) async { openSocketCount++; return UdpSocketBindResult( boundPort: nextBoundPort, fallbackFromPort: nextFallbackFromPort, ); } @override Future closeSocket() async { closeSocketCount++; } @override Future send(Uint8List data, String host, int port) async { sentPackets.add((data: data, host: host, port: port)); } @override Future isStaConcurrencySupported() async => staConcurrencyResult; @override void dispose() { _eventsController.close(); _packetsController.close(); } } void main() { // Echter Timer-Tick statt nur eines Microtasks: laesst die // async/await-Kette in UdpTransport.connect() (getSsidPrefix -> // requestNetwork -> Listener-Aufbau) vollstaendig ablaufen, bevor der Test // ein Netzwerk-Ereignis einspeist. Future pump() => Future.delayed(const Duration(milliseconds: 10)); Future connectWithFake( FakeMlrsNetworkController fake, { String? host, int port = 0, String ssidPrefix = 'mLRS-', String? rememberedSsid, MlrsNetworkEvent event = const MlrsNetworkAvailable(), }) async { final transport = UdpTransport( getHost: () async => host, getPort: () async => port, getSsidPrefix: () async => ssidPrefix, getRememberedSsid: () async => rememberedSsid, rememberSsid: (ssid) async => fake.rememberedSsids.add(ssid), networkController: fake, ); final connectFuture = transport.connect(); await pump(); fake.emit(event); await connectFuture; return transport; } test('connect() requests the network with the configured SSID prefix', () async { final fake = FakeMlrsNetworkController(); final transport = await connectWithFake(fake, ssidPrefix: 'mLRS-1234'); expect(fake.requestedSsidPrefixes, ['mLRS-1234']); transport.dispose(); }); test('connect() opens the native socket and reports the bound port', () async { final fake = FakeMlrsNetworkController()..nextBoundPort = 14550; final transport = await connectWithFake(fake); expect(transport.currentState, LinkState.connected); expect(transport.boundPort, 14550); expect(fake.openSocketCount, 1); transport.dispose(); }); test('connect() fails with networkUnavailable when the request is denied', () async { final fake = FakeMlrsNetworkController(); final transport = UdpTransport( getHost: () async => null, getPort: () async => 0, getSsidPrefix: () async => 'mLRS-', getRememberedSsid: () async => null, rememberSsid: (_) async {}, networkController: fake, ); final connectFuture = transport.connect(); await pump(); fake.emit(const MlrsNetworkUnavailable()); await expectLater( connectFuture, throwsA(isA()), ); expect(transport.lastErrorReason, LinkErrorReason.networkUnavailable); expect(transport.currentState, LinkState.error); expect(fake.openSocketCount, 0); transport.dispose(); }); test('losing the network while connected closes the socket and reports ' 'networkLost', () async { final fake = FakeMlrsNetworkController(); final transport = await connectWithFake(fake); fake.emit(const MlrsNetworkLost()); await pump(); expect(transport.currentState, LinkState.error); expect(transport.lastErrorReason, LinkErrorReason.networkLost); expect(transport.boundPort, isNull); expect(fake.closeSocketCount, 1); transport.dispose(); }); test('uses the reported gateway address as initial peer when no host is ' 'configured', () async { final fake = FakeMlrsNetworkController(); final transport = await connectWithFake( fake, event: const MlrsNetworkAvailable(gatewayAddress: '192.168.4.1'), ); expect(transport.peerDescription, startsWith('192.168.4.1')); transport.dispose(); }); test( 'falls back to the default mLRS bridge IP when neither a host is ' 'configured nor a gateway address is reported', () async { final fake = FakeMlrsNetworkController(); final transport = await connectWithFake(fake); expect(transport.peerDescription, startsWith('192.168.4.55')); transport.dispose(); }, ); test('a configured host overrides the reported gateway address', () async { final fake = FakeMlrsNetworkController(); final transport = await connectWithFake( fake, host: '10.0.0.5', event: const MlrsNetworkAvailable(gatewayAddress: '192.168.4.1'), ); expect(transport.peerDescription, startsWith('10.0.0.5')); transport.dispose(); }); test('incoming packets arrive via incoming and learn the peer', () async { final fake = FakeMlrsNetworkController(); final transport = await connectWithFake(fake); final received = []; transport.incoming.listen(received.add); fake.emitPacket(MlrsSocketPacket( address: '192.168.4.100', port: 5555, data: Uint8List.fromList([1, 2, 3]), )); await pump(); expect(received, [ Uint8List.fromList([1, 2, 3]), ]); expect(transport.peerDescription, '192.168.4.100:5555'); transport.dispose(); }); test('send() before the socket is open throws LinkConnectException', () async { final fake = FakeMlrsNetworkController(); final transport = UdpTransport( getHost: () async => null, getPort: () async => 0, getSsidPrefix: () async => 'mLRS-', getRememberedSsid: () async => null, rememberSsid: (_) async {}, networkController: fake, ); expect( () => transport.send(Uint8List.fromList([1])), throwsA(isA()), ); transport.dispose(); }); test( 'send() works with the default mLRS bridge IP even before any packet ' 'has been received', () async { final fake = FakeMlrsNetworkController(); final transport = await connectWithFake(fake); await transport.send(Uint8List.fromList([1, 2, 3])); expect(fake.sentPackets, hasLength(1)); expect(fake.sentPackets.single.host, '192.168.4.55'); transport.dispose(); }, ); test('send() forwards bytes to the learned peer via the native socket', () async { final fake = FakeMlrsNetworkController(); final transport = await connectWithFake(fake); fake.emitPacket(MlrsSocketPacket( address: '192.168.4.100', port: 5555, data: Uint8List.fromList([9]), )); await pump(); await transport.send(Uint8List.fromList([7, 7, 7])); expect(fake.sentPackets, hasLength(1)); expect(fake.sentPackets.single.data, Uint8List.fromList([7, 7, 7])); expect(fake.sentPackets.single.host, '192.168.4.100'); expect(fake.sentPackets.single.port, 5555); transport.dispose(); }); test('disconnect() closes the socket and releases the network request', () async { final fake = FakeMlrsNetworkController(); final transport = await connectWithFake(fake); await transport.disconnect(); expect(transport.currentState, LinkState.disconnected); expect(transport.boundPort, isNull); expect(fake.closeSocketCount, 1); expect(fake.releaseCount, greaterThanOrEqualTo(1)); transport.dispose(); }); test('reports a fallback port when the configured one was occupied natively', () async { final fake = FakeMlrsNetworkController() ..nextBoundPort = 51000 ..nextFallbackFromPort = 14550; final transport = await connectWithFake(fake, port: 14550); expect(transport.currentState, LinkState.connected); expect(transport.boundPort, 51000); expect(transport.lastErrorMessage, contains('in use')); transport.dispose(); }); test('reports isStaConcurrencySupported() once connected', () async { final fake = FakeMlrsNetworkController()..staConcurrencyResult = true; final transport = await connectWithFake(fake); expect(transport.staConcurrencySupported, isTrue); transport.dispose(); }); test('passes the remembered SSID to requestNetwork for a silent reconnect', () async { final fake = FakeMlrsNetworkController(); final transport = await connectWithFake( fake, rememberedSsid: 'mLRS-1234', ); expect(fake.requestedPreferredSsids, ['mLRS-1234']); transport.dispose(); }); test('remembers the connected SSID reported by the platform', () async { final fake = FakeMlrsNetworkController(); final transport = await connectWithFake( fake, event: const MlrsNetworkAvailable(ssid: 'mLRS-5678'), ); expect(transport.connectedSsid, 'mLRS-5678'); expect(fake.rememberedSsids, ['mLRS-5678']); transport.dispose(); }); test('connectedSsid is cleared on disconnect and network loss', () async { final fake = FakeMlrsNetworkController(); final transport = await connectWithFake( fake, event: const MlrsNetworkAvailable(ssid: 'mLRS-5678'), ); expect(transport.connectedSsid, 'mLRS-5678'); await transport.disconnect(); expect(transport.connectedSsid, isNull); transport.dispose(); }); }