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.
427 lines
13 KiB
Dart
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();
|
|
});
|
|
}
|