import 'dart:async'; import 'dart:io'; 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-Ergebnisse ueber [emit], ohne einen /// echten Android-Platform-Channel zu brauchen. Zeichnet Aufrufe auf, damit /// Tests den Bindungsfenster-Ablauf (bind -> Socket -> unbind) pruefen /// koennen. class FakeMlrsNetworkController implements MlrsNetworkController { final _eventsController = StreamController.broadcast(); final List requestedSsidPrefixes = []; int releaseCount = 0; int bindCount = 0; int unbindCount = 0; bool bindResult = true; bool? staConcurrencyResult; @override Stream get events => _eventsController.stream; void emit(MlrsNetworkEvent event) => _eventsController.add(event); @override Future requestNetwork(String ssidPrefix) async { requestedSsidPrefixes.add(ssidPrefix); } @override Future releaseNetwork() async { releaseCount++; } @override Future bindProcessToNetwork() async { bindCount++; return bindResult; } @override Future unbindProcessFromNetwork() async { unbindCount++; } @override Future isStaConcurrencySupported() async => staConcurrencyResult; @override void dispose() { _eventsController.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-', MlrsNetworkEvent event = const MlrsNetworkAvailable(), }) async { final transport = UdpTransport( getHost: () async => host, getPort: () async => port, getSsidPrefix: () async => ssidPrefix, 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() binds process to network, creates the socket, then unbinds', () async { final fake = FakeMlrsNetworkController(); final transport = await connectWithFake(fake); expect(transport.currentState, LinkState.connected); expect(transport.boundPort, isNotNull); expect(fake.bindCount, 1); expect(fake.unbindCount, 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-', 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); 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); 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('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 sender = await RawDatagramSocket.bind(InternetAddress.loopbackIPv4, 0); final received = []; transport.incoming.listen(received.add); sender.send( Uint8List.fromList([1, 2, 3]), InternetAddress.loopbackIPv4, transport.boundPort!, ); await Future.delayed(const Duration(milliseconds: 50)); expect(received, [ Uint8List.fromList([1, 2, 3]), ]); expect(transport.peerDescription, contains(sender.port.toString())); sender.close(); transport.dispose(); }); test('send() without a known peer throws LinkConnectException', () async { final fake = FakeMlrsNetworkController(); final transport = await connectWithFake(fake); expect( () => transport.send(Uint8List.fromList([1])), throwsA(isA()), ); transport.dispose(); }); test('send() sends bytes to the learned peer', () async { final fake = FakeMlrsNetworkController(); final transport = await connectWithFake(fake); final peer = await RawDatagramSocket.bind(InternetAddress.loopbackIPv4, 0); // Erst "sprechen" lassen, damit der Transport die Gegenstelle lernt // (Doku Kommunikationsschicht v2 Abschnitt 3: "Gegenstelle aus dem // Verkehr lernen"). peer.send( Uint8List.fromList([9]), InternetAddress.loopbackIPv4, transport.boundPort!, ); await Future.delayed(const Duration(milliseconds: 50)); final receivedByPeer = []; peer.listen((event) { if (event == RawSocketEvent.read) { final datagram = peer.receive(); if (datagram != null) receivedByPeer.add(datagram.data); } }); await transport.send(Uint8List.fromList([7, 7, 7])); await Future.delayed(const Duration(milliseconds: 50)); expect(receivedByPeer, [ Uint8List.fromList([7, 7, 7]), ]); peer.close(); 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.releaseCount, greaterThanOrEqualTo(1)); transport.dispose(); }); test( 'falls back to an ephemeral port when the configured one is occupied', () async { final occupied = await RawDatagramSocket.bind(InternetAddress.anyIPv4, 0); final fake = FakeMlrsNetworkController(); final transport = await connectWithFake(fake, port: occupied.port); expect(transport.currentState, LinkState.connected); expect(transport.boundPort, isNot(occupied.port)); expect(transport.lastErrorMessage, contains('in use')); occupied.close(); transport.dispose(); }); test('reports isStaConcurrencySupported() once connected', () async { final fake = FakeMlrsNetworkController()..staConcurrencyResult = true; final transport = await connectWithFake(fake); expect(transport.staConcurrencySupported, isTrue); transport.dispose(); }); }