import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:sm_mobile/services/net/icmp_probe.dart'; import 'package:sm_mobile/services/net/measure.dart'; import 'package:sm_mobile/services/net/tcp_ping.dart'; Sample sample(int seq, double? rtt, [Outcome? outcome]) => Sample( seq: seq, time: DateTime.utc(2026, 10, 16, 12, 0, seq), family: Family.v4, address: '192.0.2.1', port: 443, rttMs: rtt, outcome: outcome ?? (rtt == null ? Outcome.timeout : Outcome.ok), ); void main() { group('statistics on known values', () { test('min, max, mean, median, population stddev, jitter', () { final s = RttStats.of([10, 20, 30, 40], attempts: 5); expect(s.attempts, 5); expect(s.successes, 4); expect(s.failures, 1); expect(s.successPercent, 80); expect(s.lossPercent, 20); expect(s.min, 10); expect(s.max, 40); expect(s.mean, 25); expect(s.median, 20, reason: 'nearest rank: ceil(0.5 × 4) = 2nd'); expect(s.stddev, closeTo(11.1803398875, 1e-9)); expect(s.jitter, 10); expect(s.p95, isNull, reason: 'fewer than 20 samples'); }); test('jitter uses consecutive successful samples in order', () { expect(RttStats.of([10, 30, 20], attempts: 3).jitter, 15); expect(RttStats.of([5], attempts: 1).jitter, isNull); }); test('P95 with 20 samples is the 19th smallest', () { final values = [for (var i = 1; i <= 20; i++) i.toDouble()]; final s = RttStats.of(values.reversed.toList(), attempts: 20); expect(s.p95, 19); expect(s.median, 10); }); test('no successful attempt: no invented RTT', () { final s = RttStats.of(const [], attempts: 3); expect( (s.min, s.max, s.mean, s.median, s.stddev, s.jitter), (null, null, null, null, null, null), ); expect(s.lossPercent, 100); expect(RttStats.empty.successPercent, 0); }); test('accumulator equals the exact statistics', () { final a = StatsAccumulator(); final rtts = [12.5, 8.0, null, 15.25, 9.75, null, 11.0]; for (var i = 0; i < rtts.length; i++) { a.add(sample(i + 1, rtts[i])); } final exact = RttStats.of([ for (final r in rtts) ?r, ], attempts: rtts.length); final s = a.stats(); expect(s.attempts, 7); expect(s.successes, 5); expect(s.mean, closeTo(exact.mean!, 1e-12)); expect(s.stddev, closeTo(exact.stddev!, 1e-12)); expect(s.jitter, closeTo(exact.jitter!, 1e-12)); expect((s.min, s.max, s.median), (exact.min, exact.max, exact.median)); expect(s.elapsed, const Duration(seconds: 6)); }); test('refused and failed attempts never enter RTT statistics', () { final a = StatsAccumulator() ..add(sample(1, 5)) ..add(sample(2, 2, Outcome.refused)) ..add(sample(3, null, Outcome.unreachable)); final s = a.stats(); expect((s.attempts, s.successes, s.min, s.max), (3, 1, 5.0, 5.0)); }); test('long measurements stay bounded in memory', () { final log = SampleLog(100); final a = StatsAccumulator(); for (var i = 0; i < 25000; i++) { final s = sample(i, (i % 50).toDouble()); log.add(s); a.add(s); } expect(log.length, 100); expect(log.dropped, 24900); final s = a.stats(); expect(s.attempts, 25000); expect(s.windowed, true); expect(s.min, 0); expect(s.max, 49); }); }); group('resolution per IP version', () { Future> Function(String, InternetAddressType) records( List a, List aaaa, { Object? error, }) => (host, type) async { if (error != null) throw error; final v4 = [for (final x in a) InternetAddress(x)]; final v6 = [for (final x in aaaa) InternetAddress(x)]; return switch (type) { InternetAddressType.IPv4 => v4.isEmpty ? throw const SocketException( 'no data', osError: OSError('no data', 7), ) : v4, InternetAddressType.IPv6 => v6, _ => [...v6, ...v4], }; }; test('dual mode with both records gives two independent targets', () async { final r = await resolveTarget( 'both.test', IpMode.dual, lookup: records(['192.0.2.1'], ['2001:db8::1']), ); expect(r.of(Family.v4)!.addresses.single.address, '192.0.2.1'); expect(r.of(Family.v6)!.addresses.single.address, '2001:db8::1'); }); test('only A or only AAAA: the missing version is named', () async { final onlyA = await resolveTarget( 'a.test', IpMode.dual, lookup: records(['192.0.2.1'], []), ); expect(onlyA.of(Family.v6)!.missing, 'noAddressFamily'); expect(onlyA.of(Family.v4)!.available, true); final onlyAaaa = await resolveTarget( 'aaaa.test', IpMode.dual, lookup: records([], ['2001:db8::2']), ); expect(onlyAaaa.of(Family.v4)!.missing, 'noAddressFamily'); await expectLater( resolveTarget( 'aaaa.test', IpMode.v4, lookup: records([], ['2001:db8::2']), ), throwsA(isA()), ); }); test( 'automatic mode uses the resolver preference and reports it', () async { final r = await resolveTarget( 'both.test', IpMode.auto, lookup: records(['192.0.2.1'], ['2001:db8::1']), ); expect(r.preferred!.address, '2001:db8::1'); expect(r.families.map((f) => f.family), [Family.v4, Family.v6]); }, ); test('broken DNS fails; literals skip DNS', () async { await expectLater( resolveTarget( 'x.test', IpMode.dual, lookup: records([], [], error: const SocketException('down')), ), throwsA(isA()), ); final literal = await resolveTarget('[2001:db8::9]', IpMode.dual); expect(literal.of(Family.v6)!.addresses.single.address, '2001:db8::9'); expect(literal.of(Family.v4)!.missing, 'literalOtherFamily'); }); }); group('TCP connect ping with real sockets', () { test('success, refused (not timeout) and count/stop', () async { final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); server.listen((s) => s.destroy()); final ok = await tcpPing( InternetAddress.loopbackIPv4, TcpPingSettings( port: server.port, count: 3, interval: const Duration(milliseconds: 10), ), ).samples.toList(); expect(ok.map((s) => s.seq), [1, 2, 3]); expect(ok.every((s) => s.ok && s.port == server.port), true); final port = server.port; await server.close(); final refused = await tcpPing( InternetAddress.loopbackIPv4, TcpPingSettings(port: port, count: 2, interval: Duration.zero), ).samples.toList(); expect(refused.map((s) => s.outcome), [Outcome.refused, Outcome.refused]); // Continuous until stopped. final run = tcpPing( InternetAddress.loopbackIPv4, TcpPingSettings( port: port, count: 0, interval: const Duration(milliseconds: 20), ), ); final got = []; final done = Completer(); run.samples.listen(got.add, onDone: done.complete); await Future.delayed(const Duration(milliseconds: 250)); await run.cancel(); await done.future.timeout(const Duration(seconds: 2)); final n = got.length; expect(n, greaterThan(3)); await Future.delayed(const Duration(milliseconds: 100)); expect(got.length, n, reason: 'nothing after stop'); }); test('a connect that never completes is a timeout', () async { final never = Completer(); final samples = await tcpPing( InternetAddress('192.0.2.1'), const TcpPingSettings( port: 9, count: 1, timeout: Duration(milliseconds: 50), ), connect: (a, p) async => ConnectionTask.fromSocket(never.future, () {}), ).samples.toList(); expect(samples.single.outcome, Outcome.timeout); expect(samples.single.rttMs, isNull); }); test('errno classification', () { (Outcome, String?) of(int code) => classifyConnectError( SocketException('x', osError: OSError('x', code)), timedOut: false, ); expect(of(61).$1, Outcome.refused); expect(of(111).$1, Outcome.refused); expect(of(60).$1, Outcome.timeout); expect(of(65).$1, Outcome.unreachable); expect(of(101).$1, Outcome.unreachable); expect(of(13).$1, Outcome.error); }); }); group('reports', () { Series series() { final s = Series(Family.v4, InternetAddress('192.0.2.1')); s.add(sample(1, 10)); s.add(sample(2, null)); s.add(sample(3, 20)); return s; } test('CSV, JSON and text contain every attempt and the statistics', () { final csv = pingReport( ReportFormat.csv, kind: MeasureKind.tcp, host: 'host,"x"', mode: IpMode.v4, settings: {'port': 443}, series: [series()], ); final lines = csv.trim().split('\n'); expect( lines.first, 'ip_version,address,port,seq,time_utc,outcome,rtt_ms,detail', ); expect(lines, hasLength(4)); expect( lines[2], 'IPv4,192.0.2.1,443,2,2026-10-16T12:00:02.000Z,timeout,,', ); final json = jsonDecode( pingReport( ReportFormat.json, kind: MeasureKind.icmp, host: 'h', mode: IpMode.dual, settings: const PingSettings().toJson(), series: [series()], ), ); expect(json['method'], 'ICMP echo'); final stats = json['series'][0]['statistics']; expect(stats['attempts'], 3); expect(stats['meanMs'], 15); expect(json['series'][0]['samples'], hasLength(3)); final text = pingReport( ReportFormat.txt, kind: MeasureKind.tcp, host: 'h', mode: IpMode.v4, settings: const {}, series: [series()], ); expect(text, contains('#2')); expect(text, contains('attempts 3, ok 2, failed 1')); expect( text, contains('rtt min/avg/median/max 10.000/15.000/10.000/20.000'), ); }); }); test('traceroute method availability is honest per platform', () { // The test host is macOS (Darwin): every method is available. if (Platform.isMacOS) { for (final m in TraceMethod.values) { expect(IcmpProbe.unsupported(m), isNull); } } if (Platform.isLinux || Platform.isAndroid) { expect(IcmpProbe.unsupported(TraceMethod.tcp), 'tcpTraceUnsupported'); } }); }