import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:intl/intl.dart'; import 'package:sm_mobile/core/app_controller.dart'; import 'package:sm_mobile/l10n/strings.dart'; import 'package:sm_mobile/services/diagnostics.dart'; import 'package:sm_mobile/services/metrics_format.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'; import 'package:sm_mobile/ui/diagnostics_page.dart'; import 'support.dart'; String decimal(double v, int digits) => NumberFormat.decimalPatternDigits( locale: 'cs', decimalDigits: digits, ).format(v); void main() { group('metric formatting', () { // Values as LinuxMonitor reports them (observed on Ubuntu 24.04.5). const sample = { 'cpu': '1.0%', 'ram': '15.7% (308 / 1967 MiB)', 'disk': '17% (4521312 / 30298176 KiB)', 'uptime': '166790.65', 'load': '0.19 0.11 0.04', 'rtt': '12 ms', }; test('parses the monitor output into numbers', () { final v = MetricValues.parse(sample); expect(v.cpu, 1.0); expect(v.ramPercent, 15.7); expect(v.ramUsedBytes, 308 * 1024 * 1024); expect(v.ramTotalBytes, 1967 * 1024 * 1024); expect(v.diskPercent, 17); expect(v.diskUsedBytes, 4521312 * 1024); expect(v.diskTotalBytes, 30298176 * 1024); expect(v.uptimeSeconds, 166790.65); expect(v.load, [0.19, 0.11, 0.04]); }); test('unexpected output stays unparsed instead of guessed', () { final v = MetricValues.parse(const { 'cpu': 'n/a', 'ram': 'weird', 'uptime': '-5', 'load': '0.1 x 0.2', }); expect(v.cpu, isNull); expect(v.ramPercent, isNull); expect(v.ramUsedBytes, isNull); expect(v.uptimeSeconds, isNull); expect(v.load, isNull); expect(MetricValues.parse(const {}).diskPercent, isNull); }); test('uptime splits into days, hours and minutes', () { expect(splitUptime(166790.65), (days: 1, hours: 22, minutes: 19)); expect(splitUptime(59), (days: 0, hours: 0, minutes: 0)); expect(splitUptime(3600), (days: 0, hours: 1, minutes: 0)); expect(splitUptime(86400 * 400 + 61), (days: 400, hours: 0, minutes: 1)); }); test('uptime is localized with correct plural forms', () async { final cs = await AppLocalizations.delegate.load(const Locale('cs')); final de = await AppLocalizations.delegate.load(const Locale('de')); final en = await AppLocalizations.delegate.load(const Locale('en')); String text(AppLocalizations l, double s) { final u = splitUptime(s); return [ if (u.days > 0) l.uptimeDays(u.days), if (u.days > 0 || u.hours > 0) l.uptimeHours(u.hours), l.uptimeMinutes(u.minutes), ].join(' '); } expect(text(cs, 166790.65), '1 den 22 hodin 19 minut'); expect( text(cs, 3 * 86400 + 2 * 3600 + 4 * 60), '3 dny 2 hodiny 4 minuty', ); expect(text(cs, 5 * 86400 + 60), '5 dní 0 hodin 1 minuta'); expect(text(en, 166790.65), '1 day 22 hours 19 minutes'); expect(text(de, 166790.65), '1 Tag 22 Stunden 19 Minuten'); }); test('sizes use binary units consistently', () { expect(formatBytes(512, decimal), '512 B'); expect(formatBytes(308 * 1024 * 1024, decimal), '308 MiB'); expect(formatBytes(1967 * 1024 * 1024, decimal), '1,9 GiB'); expect(formatBytes(4521312 * 1024, decimal), '4,3 GiB'); expect(formatBytes(30298176 * 1024, decimal), '28,9 GiB'); expect(formatBytes(3 * 1024 * 1024 * 1024 * 1024, decimal), '3,0 TiB'); }); }); group('diagnostics page (simulated network, no sockets)', () { late List started; late Map> streams; late bool cancelled; NetTools tools({ List a = const ['192.0.2.5'], List aaaa = const ['2001:db8::5'], Object? lookupError, String? Function(TraceMethod)? unsupported, }) { started = []; streams = {}; cancelled = false; return NetTools( lookup: (host, type) async { if (lookupError != null) throw lookupError; final v4 = [for (final x in a) InternetAddress(x)]; final v6 = [for (final x in aaaa) InternetAddress(x)]; return switch (type) { InternetAddressType.IPv4 => v4, InternetAddressType.IPv6 => v6, _ => [...v6, ...v4], }; }, tcp: (address, settings) { started.add(settings); final c = StreamController(); streams[address] = c; return PingRun(c.stream, () async { cancelled = true; unawaited(c.close()); }); }, ping: (address, settings) { started.add(settings); final c = StreamController(); streams[address] = c; return IcmpRun(c.stream, () async { cancelled = true; unawaited(c.close()); }); }, traceroute: (address, settings) { started.add(settings); final c = StreamController(); streams[address] = c; return IcmpRun(c.stream, () async { cancelled = true; unawaited(c.close()); }); }, reverse: (a) async => a.address == '198.51.100.1' ? 'router.example' : null, unsupported: unsupported ?? (_) => null, ); } Future pump(WidgetTester tester, NetTools tools) async { tester.view.physicalSize = const Size(900, 4000); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); final c = AppController(MemoryStore()); await tester.runAsync(() async { await c.initialize(); await c.acceptLicense(); await c.openVault('diagnostics ui test', create: true); }); await tester.pumpWidget( MaterialApp( locale: const Locale('en'), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: Scaffold(body: DiagnosticsPage(c, tools: tools)), ), ); await tester.pumpAndSettle(); return c; } Sample tcpSample(int seq, String address, double? rtt, Outcome outcome) => Sample( seq: seq, time: DateTime(2026, 10, 16, 12, 0, seq), family: address.contains(':') ? Family.v6 : Family.v4, address: address, port: 443, rttMs: rtt, outcome: outcome, ); // Measurements animate a progress bar, so the page never settles while // one runs; pump a few frames instead. Future step(WidgetTester tester) async { for (var i = 0; i < 5; i++) { await tester.pump(const Duration(milliseconds: 50)); } } Future finish(WidgetTester tester, AppController c) async { await tester.pumpWidget(const SizedBox()); c.dispose(); } testWidgets('TCP ping: count presets, every attempt listed, statistics', ( tester, ) async { final c = await pump(tester, tools()); await tester.enterText(find.byType(TextField).at(0), 'server.test'); await tester.enterText(find.byType(TextField).at(1), '443'); await tester.tap(find.text('IPv4')); await tester.tap(find.byType(DropdownButtonFormField)); await tester.pumpAndSettle(); await tester.tap(find.text('10').last); await tester.pumpAndSettle(); await tester.tap(find.text('Run test')); await step(tester); final s = started.single as TcpPingSettings; expect((s.port, s.count), (443, 10)); final c4 = streams[InternetAddress('192.0.2.5')]! as StreamController; c4 ..add(tcpSample(1, '192.0.2.5', 10, Outcome.ok)) ..add(tcpSample(2, '192.0.2.5', null, Outcome.timeout)) ..add(tcpSample(3, '192.0.2.5', 1.5, Outcome.refused)) ..add(tcpSample(4, '192.0.2.5', 30, Outcome.ok)); await step(tester); expect(find.text('Timeout'), findsOneWidget); expect(find.textContaining('Refused (TCP reset)'), findsOneWidget); expect(find.text('10.0 ms'), findsWidgets); Text value(String label) => tester.widget( find .descendant( of: find .ancestor(of: find.text(label), matching: find.byType(Column)) .first, matching: find.byType(Text), ) .last, ); expect(value('Attempts').data, '4'); expect(value('Successful').data, '2'); expect(value('Failed').data, '2'); expect(value('Success').data, '50.0 %'); expect(value('Average').data, '20.0 ms'); expect(value('Jitter').data, '20.0 ms'); expect(value('P95').data, '—'); await tester.tap(find.text('Stop')); await step(tester); expect(cancelled, true); expect(find.text('Run test'), findsOneWidget); await finish(tester, c); }); testWidgets('both IP versions run independently; a missing AAAA is shown', ( tester, ) async { var c = await pump(tester, tools()); await tester.enterText(find.byType(TextField).at(0), 'dual.test'); await tester.tap(find.text('4 + 6')); await tester.tap(find.text('Run test')); await step(tester); expect(started, hasLength(2)); expect(streams.keys.map((a) => a.address).toSet(), { '192.0.2.5', '2001:db8::5', }); (streams[InternetAddress('2001:db8::5')]! as StreamController) .add(tcpSample(1, '2001:db8::5', null, Outcome.timeout)); (streams[InternetAddress('192.0.2.5')]! as StreamController).add( tcpSample(1, '192.0.2.5', 12, Outcome.ok), ); await tester.pump(); expect(find.text('IPv4 and IPv6 overview'), findsOneWidget); expect( find.text('0.0 % (0/1)'), findsOneWidget, reason: 'IPv6 failure is visible', ); expect(find.text('100.0 % (1/1)'), findsOneWidget); await finish(tester, c); c = await pump(tester, tools(aaaa: const [])); await tester.enterText(find.byType(TextField).at(0), 'only-a.test'); await tester.tap(find.text('4 + 6')); await tester.tap(find.text('Run test')); await step(tester); expect(started, hasLength(1)); expect( find.textContaining('No address of this IP version'), findsOneWidget, ); await finish(tester, c); }); testWidgets('ICMP ping options, address actually used, stop on demand', ( tester, ) async { final c = await pump(tester, tools()); await tester.tap(find.text('ICMP ping')); await tester.pump(); expect(find.text('TCP port'), findsNothing, reason: 'ICMP has no port'); await tester.enterText(find.byType(TextField).first, 'server.test'); await tester.tap(find.byType(DropdownButtonFormField)); await tester.pumpAndSettle(); await tester.tap(find.text('Continuous until stopped').last); await tester.pumpAndSettle(); await tester.tap(find.text('Test options')); await tester.pumpAndSettle(); await tester.enterText( find.widgetWithText(TextField, 'Payload size (B)'), '100', ); await tester.enterText( find.widgetWithText(TextField, 'TTL / hop limit'), '7', ); await tester.tap(find.text("Don't fragment (DF)")); await tester.tap(find.text('Run test')); await step(tester); final s = started.single as PingSettings; expect((s.count, s.size, s.ttl, s.dontFragment), (0, 100, 7, true)); // Automatic mode: the resolver preferred IPv6, and the page says so. expect(find.textContaining('IPv6 / ICMPv6 echo'), findsOneWidget); final from = InternetAddress('2001:db8::5'); (streams[from]! as StreamController) ..add(ProbeEvent(1, ProbeStatus.reply, from: from, rttMs: 10)) ..add(const ProbeEvent(2, ProbeStatus.timeout)) ..add(ProbeEvent(3, ProbeStatus.reply, from: from, rttMs: 30)); await step(tester); expect(find.text('Timeout'), findsOneWidget); await tester.tap(find.text('Stop')); await step(tester); expect(cancelled, true); expect(find.textContaining('Stopped by user'), findsOneWidget); await finish(tester, c); }); testWidgets('platform errors are explained, not hidden', (tester) async { final c = await pump( tester, tools(a: const ['192.0.2.9'], aaaa: const []), ); await tester.tap(find.text('ICMP ping')); await tester.pump(); await tester.enterText(find.byType(TextField).first, '192.0.2.9'); await tester.tap(find.text('Run test')); await step(tester); final s = streams[InternetAddress('192.0.2.9')]! as StreamController; s.addError(const IcmpUnavailable('icmpNotPermitted', 13)); await s.close(); await step(tester); expect( find.textContaining('does not allow an unprivileged ICMP'), findsWidgets, ); await finish(tester, c); }); testWidgets('traceroute: hops, *, names, statistics, compare and export', ( tester, ) async { final c = await pump( tester, tools(a: const ['192.0.2.9'], aaaa: const []), ); await tester.tap(find.text('Traceroute')); await tester.pump(); await tester.enterText(find.byType(TextField).first, '192.0.2.9'); await tester.tap(find.text('UDP')); await tester.tap(find.text('Run test')); await step(tester); final settings = started.single as TraceSettings; expect( (settings.method, settings.maxHops, settings.probes), (TraceMethod.udp, 30, 3), ); final router = InternetAddress('198.51.100.1'); final target = InternetAddress('192.0.2.9'); final s = streams[target]! as StreamController; s ..add( ProbeEvent( 1, ProbeStatus.timeExceeded, ttl: 1, from: router, rttMs: 1.5, ), ) ..add(const ProbeEvent(2, ProbeStatus.timeout, ttl: 1)) ..add( ProbeEvent( 3, ProbeStatus.timeExceeded, ttl: 1, from: router, rttMs: 2.5, ), ) ..add(const ProbeEvent(1, ProbeStatus.timeout, ttl: 2)) ..add( ProbeEvent( 1, ProbeStatus.reply, ttl: 3, from: target, rttMs: 12, icmpCode: 3, note: 'portUnreachable', ), ); await s.close(); await step(tester); expect(find.text('router.example (198.51.100.1)'), findsOneWidget); expect(find.text('1.50 ms * 2.50 ms'), findsOneWidget); expect( find.textContaining('min / avg / max: 1.50 / 2.00 / 2.50 ms'), findsOneWidget, ); expect(find.textContaining('Destination reached'), findsOneWidget); expect(find.textContaining('Protocol: UDP'), findsOneWidget); // A second run can be compared with the first. await tester.tap(find.text('Run test')); await step(tester); final again = streams[target]! as StreamController; again.add( ProbeEvent( 1, ProbeStatus.timeExceeded, ttl: 1, from: InternetAddress('198.51.100.7'), rttMs: 3, ), ); await again.close(); await step(tester); await tester.tap(find.text('Compare with the previous measurement')); await tester.pumpAndSettle(); expect(find.textContaining('Route changed'), findsOneWidget); await finish(tester, c); }); testWidgets('an unsupported traceroute method cannot be chosen', ( tester, ) async { final c = await pump( tester, tools( unsupported: (m) => m == TraceMethod.tcp ? 'tcpTraceUnsupported' : null, ), ); await tester.tap(find.text('Traceroute')); await tester.pump(); expect( find.textContaining('TCP: Not available on Android'), findsOneWidget, ); await tester.tap(find.text('TCP')); await tester.pump(); final selected = tester .widget>( find.byType(SegmentedButton), ) .selected; expect(selected, {TraceMethod.icmp}); await finish(tester, c); }); testWidgets('DNS failure and invalid options never start a measurement', ( tester, ) async { final c = await pump( tester, tools(lookupError: const SocketException('Failed host lookup')), ); await tester.enterText( find.byType(TextField).first, 'nonexistent.invalid', ); await tester.tap(find.text('Run test')); await step(tester); expect(started, isEmpty); expect(find.textContaining('DNS lookup failed'), findsOneWidget); await tester.enterText(find.byType(TextField).at(1), '70000'); await tester.tap(find.text('Run test')); await step(tester); expect(started, isEmpty); expect(find.text('Invalid value in the test options.'), findsOneWidget); await finish(tester, c); }); }); test('real sockets: TCP failure is reported as the TCP stage', () async { final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); final port = server.port; final ok = await Diagnostics().probe( '127.0.0.1', port, type: InternetAddressType.IPv4, ); expect(ok.connected!.address, '127.0.0.1'); expect(ok.dnsMs, isNotNull); await server.close(); await expectLater( Diagnostics().probe('127.0.0.1', port, type: InternetAddressType.IPv4), throwsA( isA().having((e) => e.stage, 'stage', 'tcp').having( (e) => e.addresses, 'addresses', ['127.0.0.1'], ), ), ); }); }