Files
dmc/app/test/transport/udp_transport_test.dart
Constantin Leue 221c0552d5 Fix event-listener gap in UdpTransport.connect() dropping late network events
Between cancelling the initial "available" subscription and attaching
the network-loss listener, no listener was attached to the broadcast
event stream for a brief window. Events landing in that gap (notably
a late SSID update, which real hardware without STA concurrency often
fires very close after "available") were silently lost since broadcast
streams don't buffer for late subscribers. Now uses one continuous
subscription for the whole connection lifetime instead.
2026-08-06 22:23:44 +02:00

427 lines
13 KiB
Dart

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<MlrsNetworkEvent>.broadcast();
final _packetsController = StreamController<MlrsSocketPacket>.broadcast();
final List<String> requestedSsidPrefixes = [];
final List<String?> requestedPreferredSsids = [];
final List<({Uint8List data, String host, int port})> sentPackets = [];
final List<String> rememberedSsids = [];
int releaseCount = 0;
int openSocketCount = 0;
int closeSocketCount = 0;
bool? staConcurrencyResult;
int nextBoundPort = 14550;
int? nextFallbackFromPort;
@override
Stream<MlrsNetworkEvent> get events => _eventsController.stream;
@override
Stream<MlrsSocketPacket> get incomingPackets => _packetsController.stream;
void emit(MlrsNetworkEvent event) => _eventsController.add(event);
void emitPacket(MlrsSocketPacket packet) => _packetsController.add(packet);
@override
Future<void> requestNetwork(String ssidPrefix, {String? preferredSsid}) async {
requestedSsidPrefixes.add(ssidPrefix);
requestedPreferredSsids.add(preferredSsid);
}
@override
Future<void> releaseNetwork() async {
releaseCount++;
}
@override
Future<UdpSocketBindResult> openSocket(int port) async {
openSocketCount++;
return UdpSocketBindResult(
boundPort: nextBoundPort,
fallbackFromPort: nextFallbackFromPort,
);
}
@override
Future<void> closeSocket() async {
closeSocketCount++;
}
@override
Future<void> send(Uint8List data, String host, int port) async {
sentPackets.add((data: data, host: host, port: port));
}
@override
Future<bool?> 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<void> pump() => Future<void>.delayed(const Duration(milliseconds: 10));
Future<UdpTransport> 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<LinkConnectException>()),
);
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(
'uses the reported subnet broadcast address as initial peer when no '
'host is configured and no gateway address is reported',
() async {
final fake = FakeMlrsNetworkController();
final transport = await connectWithFake(
fake,
event: const MlrsNetworkAvailable(broadcastAddress: '192.168.4.255'),
);
expect(transport.peerDescription, startsWith('192.168.4.255'));
transport.dispose();
},
);
test(
'falls back to the default mLRS bridge IP when neither a host, a '
'gateway address, nor a broadcast 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 = <Uint8List>[];
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<LinkConnectException>()),
);
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(
'a late-arriving SSID update immediately after "available" is not lost '
'(regression: connect() used to briefly detach its event listener '
'between the "available" and "network loss" subscriptions, dropping '
'any event that landed exactly in that gap - real hardware without '
'STA concurrency often fires the SSID update this close together)',
() async {
final fake = FakeMlrsNetworkController();
final transport = UdpTransport(
getHost: () async => null,
getPort: () async => 0,
getSsidPrefix: () async => 'mLRS-',
getRememberedSsid: () async => null,
rememberSsid: (ssid) async => fake.rememberedSsids.add(ssid),
networkController: fake,
);
final connectFuture = transport.connect();
await Future<void>.delayed(const Duration(milliseconds: 10));
// Android liefert die SSID bei WifiNetworkSpecifier-Netzen oft nicht
// im "available"-Ereignis selbst, sondern in einem unmittelbar
// (synchron im selben Event-Loop-Takt) nachfolgenden "ssid"-Ereignis -
// ohne SSID im ersten Ereignis nachgebildet, beide direkt
// hintereinander emittiert.
fake.emit(const MlrsNetworkAvailable());
fake.emit(const MlrsNetworkSsidUpdated('mLRS-5287 AP UDP'));
await connectFuture;
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(transport.connectedSsid, 'mLRS-5287 AP UDP');
expect(fake.rememberedSsids, contains('mLRS-5287 AP UDP'));
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();
});
}