diff --git a/lib/ui/features/test_run/iperf3_event_formatter.dart b/lib/ui/features/test_run/iperf3_event_formatter.dart new file mode 100644 index 0000000..898533f --- /dev/null +++ b/lib/ui/features/test_run/iperf3_event_formatter.dart @@ -0,0 +1,95 @@ +import 'package:iperf3gui/domain/models/iperf3_event.dart'; + +/// Renders parsed iperf3 events as the kind of line iperf3 itself would print. +/// +/// The app consumes `--json-stream`, so the familiar text layout has to be +/// rebuilt here. Keeping it in one pure function makes it testable and leaves +/// the structured events untouched for anything else that wants them. +String? formatIperf3Event(Iperf3Event event) { + return switch (event) { + Iperf3Start e => _formatStart(e), + Iperf3Interval e => _formatInterval(e), + Iperf3End e => _formatEnd(e), + Iperf3Error e => 'iperf3 error: ${e.message}', + Iperf3Stopped _ => 'Stopped by user.', + Iperf3LogLine e => e.text.isEmpty ? null : e.text, + }; +} + +String _formatStart(Iperf3Start e) { + final parts = []; + if (e.targetHost != null) { + final port = e.targetPort != null ? ', port ${e.targetPort}' : ''; + parts.add('Connecting to host ${e.targetHost}$port'); + } + final detail = [ + if (e.protocol != null) e.protocol!, + if (e.streamCount != null) + '${e.streamCount} stream${e.streamCount == 1 ? '' : 's'}', + if (e.durationSeconds != null) '${e.durationSeconds}s', + ]; + if (detail.isNotEmpty) parts.add(detail.join(' · ')); + return parts.isEmpty ? '' : parts.join('\n'); +} + +String _formatInterval(Iperf3Interval e) { + final window = '${_seconds(e.start)}-${_seconds(e.end)} sec'; + final line = StringBuffer('[SUM] $window ' + '${formatBytes(e.bytes)} ${formatBitrate(e.bitsPerSecond)}'); + if (e.retransmits != null) { + line.write(' ${e.retransmits} retr'); + } + if (e.omitted) line.write(' (omitted)'); + return line.toString(); +} + +String _formatEnd(Iperf3End e) { + final lines = ['- ' * 20]; + final sent = e.sent; + final received = e.received; + if (sent != null) { + lines.add('[SUM] 0.00-${_seconds(sent.seconds)} sec ' + '${formatBytes(sent.bytes)} ${formatBitrate(sent.bitsPerSecond)}' + '${sent.retransmits != null ? ' ${sent.retransmits} retr' : ''}' + ' sender'); + } + if (received != null) { + lines.add('[SUM] 0.00-${_seconds(received.seconds)} sec ' + '${formatBytes(received.bytes)} ' + '${formatBitrate(received.bitsPerSecond)} receiver'); + } + if (e.hostCpuPercent != null) { + lines.add('CPU: local ${e.hostCpuPercent!.toStringAsFixed(1)}%' + '${e.remoteCpuPercent != null ? ', remote ${e.remoteCpuPercent!.toStringAsFixed(1)}%' : ''}'); + } + return lines.join('\n'); +} + +String _seconds(double value) => value.toStringAsFixed(2); + +/// Byte totals the way iperf3 prints them — `93.8 MBytes`. +String formatBytes(int bytes) { + const units = ['Bytes', 'KBytes', 'MBytes', 'GBytes', 'TBytes']; + var value = bytes.toDouble(); + var unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit++; + } + final precision = value >= 100 ? 0 : (value >= 10 ? 1 : 2); + return '${value.toStringAsFixed(precision)} ${units[unit]}'; +} + +/// Bitrates the way iperf3 prints them — `786 Mbits/sec`. Decimal units, since +/// network rates are powers of ten while transfer totals are powers of two. +String formatBitrate(double bitsPerSecond) { + const units = ['bits/sec', 'Kbits/sec', 'Mbits/sec', 'Gbits/sec']; + var value = bitsPerSecond; + var unit = 0; + while (value >= 1000 && unit < units.length - 1) { + value /= 1000; + unit++; + } + final precision = value >= 100 ? 0 : (value >= 10 ? 1 : 2); + return '${value.toStringAsFixed(precision)} ${units[unit]}'; +} diff --git a/lib/ui/features/test_run/test_run_screen.dart b/lib/ui/features/test_run/test_run_screen.dart index fb2a85d..16aa122 100644 --- a/lib/ui/features/test_run/test_run_screen.dart +++ b/lib/ui/features/test_run/test_run_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:iperf3gui/data/services/iperf3_service.dart'; import 'package:iperf3gui/domain/models/test_configuration.dart'; +import 'package:iperf3gui/ui/features/test_run/iperf3_event_formatter.dart'; import 'package:iperf3gui/ui/features/test_run/test_run_view_model.dart'; /// Main test run screen. @@ -193,6 +194,35 @@ class _TestRunContentState extends State<_TestRunContent> { ), const SizedBox(height: 12), + // Live throughput figures, derived from the parsed interval series + Consumer( + builder: (context, vm, _) { + final intervals = vm.intervals; + if (intervals.isEmpty) return const SizedBox.shrink(); + final rates = + intervals.map((i) => i.bitsPerSecond).toList(growable: false); + final latest = rates.last; + final peak = rates.reduce((a, b) => a > b ? a : b); + final mean = rates.reduce((a, b) => a + b) / rates.length; + final summary = vm.summary?.sent; + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _Stat( + label: summary != null ? 'total' : 'now', + value: formatBitrate(summary?.bitsPerSecond ?? latest), + ), + _Stat(label: 'avg', value: formatBitrate(mean)), + _Stat(label: 'peak', value: formatBitrate(peak)), + _Stat(label: 'samples', value: '${rates.length}'), + ], + ), + ); + }, + ), + // Live output Expanded( child: Container( @@ -229,3 +259,32 @@ class _TestRunContentState extends State<_TestRunContent> { ); } } + + +/// One figure in the throughput row. +class _Stat extends StatelessWidget { + const _Stat({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: theme.textTheme.labelSmall + ?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + Text( + value, + style: theme.textTheme.titleSmall + ?.copyWith(fontFeatures: const [FontFeature.tabularFigures()]), + ), + ], + ); + } +} diff --git a/lib/ui/features/test_run/test_run_view_model.dart b/lib/ui/features/test_run/test_run_view_model.dart index ec267ec..965cb14 100644 --- a/lib/ui/features/test_run/test_run_view_model.dart +++ b/lib/ui/features/test_run/test_run_view_model.dart @@ -2,24 +2,33 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:iperf3gui/data/services/iperf3_service.dart'; +import 'package:iperf3gui/domain/models/iperf3_event.dart'; import 'package:iperf3gui/domain/models/iperf3_result.dart'; import 'package:iperf3gui/domain/models/test_configuration.dart'; +import 'package:iperf3gui/ui/features/test_run/iperf3_event_formatter.dart'; /// State of the test run. enum TestRunState { idle, running, completed, error } /// ViewModel for managing test run state. /// -/// Subscribes to [Iperf3Service.outputLines] so the transcript grows while the -/// test runs rather than appearing all at once at the end. +/// Subscribes to [Iperf3Service.events] so the transcript and the interval +/// series both grow while the test runs, rather than appearing at the end. class TestRunViewModel extends ChangeNotifier { final Iperf3Service iperf3Service; + /// How often to notify listeners while output is arriving. iperf3 can report + /// several times a second across many streams, and each rebuild re-renders + /// the whole transcript — so repaints are coalesced instead of one per event. + static const Duration displayInterval = Duration(milliseconds: 250); + TestRunViewModel({required this.iperf3Service}) { - _outputSubscription = iperf3Service.outputLines.listen(_onOutputLine); + _eventSubscription = iperf3Service.events.listen(_onEvent); } - StreamSubscription? _outputSubscription; + StreamSubscription? _eventSubscription; + Timer? _repaintTimer; + bool _dirty = false; TestRunState _state = TestRunState.idle; TestRunState get state => _state; @@ -30,11 +39,26 @@ class TestRunViewModel extends ChangeNotifier { TestConfiguration? _currentConfig; TestConfiguration? get currentConfig => _currentConfig; - final StringBuffer _liveOutput = StringBuffer(); + final StringBuffer _transcript = StringBuffer(); - /// Output accumulated so far. Grows line by line during a run, and holds the - /// finished transcript once the test completes. - String get output => _liveOutput.toString(); + /// Human-readable output accumulated so far. + String get output => _transcript.toString(); + + final List _intervals = []; + + /// Per-interval throughput samples, in arrival order. Structured, so a chart + /// can be driven from this without re-parsing text. + List get intervals => List.unmodifiable(_intervals); + + Iperf3Start? _start; + + /// Connection details reported at the start of the run. + Iperf3Start? get start => _start; + + Iperf3End? _summary; + + /// Totals reported at the end of the run. + Iperf3End? get summary => _summary; /// Whether a test is currently in flight. bool get isRunning => _state == TestRunState.running; @@ -47,35 +71,32 @@ class TestRunViewModel extends ChangeNotifier { if (_state == TestRunState.running) return; if (!config.isValid) { - _liveOutput - ..clear() - ..writeln('Invalid configuration: check the server address and port.'); + _resetRunData(); + _transcript + .writeln('Invalid configuration: check the server address and port.'); _state = TestRunState.error; - _result = null; notifyListeners(); return; } + _resetRunData(); _state = TestRunState.running; _currentConfig = config; - _result = null; - _liveOutput.clear(); notifyListeners(); try { final result = await iperf3Service.run(config); _result = result; - // Prefer the streamed transcript — it is what the user watched build up. - // Fall back to the service's copy if nothing streamed through. - if (_liveOutput.isEmpty && result.rawOutput.isNotEmpty) { - _liveOutput.write(result.rawOutput); + if (_transcript.isEmpty && result.rawOutput.isNotEmpty) { + _transcript.write(result.rawOutput); } _state = result.exitCode == 0 ? TestRunState.completed : TestRunState.error; } catch (e) { - _liveOutput.writeln('Test failed: $e'); + _transcript.writeln('Test failed: $e'); _state = TestRunState.error; } finally { + _stopRepaintTimer(); notifyListeners(); } } @@ -88,21 +109,63 @@ class TestRunViewModel extends ChangeNotifier { /// Reset to idle state. void reset() { + _resetRunData(); _state = TestRunState.idle; - _result = null; _currentConfig = null; - _liveOutput.clear(); notifyListeners(); } - void _onOutputLine(String line) { - _liveOutput.write(line.endsWith('\n') ? line : '$line\n'); - notifyListeners(); + void _resetRunData() { + _result = null; + _transcript.clear(); + _intervals.clear(); + _start = null; + _summary = null; + _dirty = false; + } + + void _onEvent(Iperf3Event event) { + switch (event) { + case Iperf3Start e: + _start = e; + case Iperf3Interval e: + // Omitted intervals are warm-up (-O) and are not part of the series. + if (!e.omitted) _intervals.add(e); + case Iperf3End e: + _summary = e; + case Iperf3Error _: + case Iperf3Stopped _: + case Iperf3LogLine _: + break; + } + + final line = formatIperf3Event(event); + if (line != null && line.isNotEmpty) { + _transcript.writeln(line); + } + _scheduleRepaint(); + } + + /// Coalesce repaints: mark dirty and let a timer flush at [displayInterval]. + void _scheduleRepaint() { + _dirty = true; + _repaintTimer ??= Timer.periodic(displayInterval, (_) { + if (!_dirty) return; + _dirty = false; + notifyListeners(); + }); + } + + void _stopRepaintTimer() { + _repaintTimer?.cancel(); + _repaintTimer = null; + _dirty = false; } @override void dispose() { - _outputSubscription?.cancel(); + _stopRepaintTimer(); + _eventSubscription?.cancel(); iperf3Service.dispose(); super.dispose(); } diff --git a/test/ui/features/test_run/iperf3_event_formatter_test.dart b/test/ui/features/test_run/iperf3_event_formatter_test.dart new file mode 100644 index 0000000..f5abc90 --- /dev/null +++ b/test/ui/features/test_run/iperf3_event_formatter_test.dart @@ -0,0 +1,87 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:iperf3gui/domain/models/iperf3_event.dart'; +import 'package:iperf3gui/ui/features/test_run/iperf3_event_formatter.dart'; + +void main() { + group('formatBytes', () { + test('scales in binary units the way iperf3 prints totals', () { + expect(formatBytes(0), equals('0.00 Bytes')); + expect(formatBytes(1024), equals('1.00 KBytes')); + expect(formatBytes(98566144), equals('94.0 MBytes')); + expect(formatBytes(1073741824), equals('1.00 GBytes')); + }); + }); + + group('formatBitrate', () { + test('scales in decimal units, as network rates are quoted', () { + expect(formatBitrate(0), equals('0.00 bits/sec')); + expect(formatBitrate(8000), equals('8.00 Kbits/sec')); + expect(formatBitrate(790000000), equals('790 Mbits/sec')); + expect(formatBitrate(2500000000), equals('2.50 Gbits/sec')); + }); + }); + + group('formatIperf3Event', () { + test('renders a start event', () { + const event = Iperf3Start( + targetHost: '192.168.2.182', + targetPort: 5201, + protocol: 'TCP', + streamCount: 1, + durationSeconds: 10, + ); + final text = formatIperf3Event(event)!; + expect(text, contains('Connecting to host 192.168.2.182, port 5201')); + expect(text, contains('TCP')); + expect(text, contains('1 stream')); + }); + + test('renders an interval with retransmits', () { + const event = Iperf3Interval( + start: 0, + end: 1, + bytes: 98566144, + bitsPerSecond: 788410352, + retransmits: 3, + ); + final text = formatIperf3Event(event)!; + expect(text, contains('0.00-1.00 sec')); + expect(text, contains('94.0 MBytes')); + expect(text, contains('788 Mbits/sec')); + expect(text, contains('3 retr')); + }); + + test('marks an omitted interval', () { + const event = Iperf3Interval( + start: 0, end: 1, bytes: 1, bitsPerSecond: 8, omitted: true); + expect(formatIperf3Event(event), contains('omitted')); + }); + + test('renders sender and receiver lines from an end event', () { + const event = Iperf3End( + sent: Iperf3Total( + seconds: 10, bytes: 987654321, bitsPerSecond: 790000000, + retransmits: 7), + received: Iperf3Total( + seconds: 10.01, bytes: 987000000, bitsPerSecond: 789000000), + hostCpuPercent: 12.5, + ); + final text = formatIperf3Event(event)!; + expect(text, contains('sender')); + expect(text, contains('receiver')); + expect(text, contains('7 retr')); + expect(text, contains('CPU: local 12.5%')); + }); + + test('renders errors and stops', () { + expect(formatIperf3Event(const Iperf3Error('boom')), + equals('iperf3 error: boom')); + expect(formatIperf3Event(const Iperf3Stopped()), + equals('Stopped by user.')); + }); + + test('drops an empty log line', () { + expect(formatIperf3Event(const Iperf3LogLine('')), isNull); + }); + }); +} diff --git a/test/ui/features/test_run/test_run_view_model_test.dart b/test/ui/features/test_run/test_run_view_model_test.dart index 6f83d36..025019c 100644 --- a/test/ui/features/test_run/test_run_view_model_test.dart +++ b/test/ui/features/test_run/test_run_view_model_test.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; import 'package:iperf3gui/data/services/iperf3_service.dart'; +import 'package:iperf3gui/domain/models/iperf3_event.dart'; import 'package:iperf3gui/domain/models/iperf3_result.dart'; import 'package:iperf3gui/domain/models/test_configuration.dart'; import 'package:iperf3gui/ui/features/test_run/test_run_view_model.dart'; @@ -56,11 +57,16 @@ void main() { expect(vm.result!.exitCode, equals(1)); }); - test('streamed lines accumulate into output during the run', () async { + test('streamed events accumulate into output during the run', () async { final mock = _MockIperf3Service(); mock.onRun = (_) async { - mock.emit('Connecting to host 192.168.1.1\n'); - mock.emit('[ 5] 0.00-1.00 sec 11.2 MBytes 94.1 Mbits/sec\n'); + mock.emit(const Iperf3Start(targetHost: '192.168.1.1', targetPort: 5201)); + mock.emit(const Iperf3Interval( + start: 0, + end: 1, + bytes: 11744051, + bitsPerSecond: 94100000, + )); // Let the broadcast stream deliver before the run resolves. await Future.delayed(Duration.zero); return const Iperf3Result(rawOutput: 'ignored', exitCode: 0); @@ -69,12 +75,83 @@ void main() { final vm = TestRunViewModel(iperf3Service: mock); await vm.runTest(const TestConfiguration(server: '192.168.1.1')); - expect(vm.output, contains('Connecting to host')); + expect(vm.output, contains('Connecting to host 192.168.1.1')); expect(vm.output, contains('94.1 Mbits/sec')); // The streamed transcript wins over the service's own copy. expect(vm.output, isNot(contains('ignored'))); }); + test('intervals are collected as structured samples', () async { + final mock = _MockIperf3Service(); + mock.onRun = (_) async { + mock.emit(const Iperf3Interval( + start: 0, end: 1, bytes: 1000, bitsPerSecond: 8000)); + mock.emit(const Iperf3Interval( + start: 1, end: 2, bytes: 2000, bitsPerSecond: 16000)); + await Future.delayed(Duration.zero); + return const Iperf3Result(rawOutput: '', exitCode: 0); + }; + + final vm = TestRunViewModel(iperf3Service: mock); + await vm.runTest(const TestConfiguration(server: '192.168.1.1')); + + expect(vm.intervals, hasLength(2)); + expect(vm.intervals[1].bitsPerSecond, equals(16000)); + }); + + test('omitted warm-up intervals are excluded from the series', () async { + final mock = _MockIperf3Service(); + mock.onRun = (_) async { + mock.emit(const Iperf3Interval( + start: 0, end: 1, bytes: 1, bitsPerSecond: 1, omitted: true)); + mock.emit(const Iperf3Interval( + start: 1, end: 2, bytes: 2000, bitsPerSecond: 16000)); + await Future.delayed(Duration.zero); + return const Iperf3Result(rawOutput: '', exitCode: 0); + }; + + final vm = TestRunViewModel(iperf3Service: mock); + await vm.runTest(const TestConfiguration(server: '192.168.1.1')); + + expect(vm.intervals, hasLength(1)); + expect(vm.intervals.single.bitsPerSecond, equals(16000)); + }); + + test('end event is retained as the summary', () async { + final mock = _MockIperf3Service(); + mock.onRun = (_) async { + mock.emit(const Iperf3End( + sent: Iperf3Total( + seconds: 10, bytes: 987654321, bitsPerSecond: 790000000), + )); + await Future.delayed(Duration.zero); + return const Iperf3Result(rawOutput: '', exitCode: 0); + }; + + final vm = TestRunViewModel(iperf3Service: mock); + await vm.runTest(const TestConfiguration(server: '192.168.1.1')); + + expect(vm.summary?.sent?.bitsPerSecond, equals(790000000)); + expect(vm.output, contains('790 Mbits/sec')); + }); + + test('a new run clears the previous interval series', () async { + final mock = _MockIperf3Service(); + mock.onRun = (_) async { + mock.emit(const Iperf3Interval( + start: 0, end: 1, bytes: 1000, bitsPerSecond: 8000)); + await Future.delayed(Duration.zero); + return const Iperf3Result(rawOutput: '', exitCode: 0); + }; + + final vm = TestRunViewModel(iperf3Service: mock); + await vm.runTest(const TestConfiguration(server: '192.168.1.1')); + expect(vm.intervals, hasLength(1)); + + await vm.runTest(const TestConfiguration(server: '192.168.1.1')); + expect(vm.intervals, hasLength(1), reason: 'not appended to the old run'); + }); + test('falls back to result output when nothing streamed', () async { final mock = _MockIperf3Service( onRun: (_) async => @@ -166,19 +243,19 @@ class _MockIperf3Service implements Iperf3Service { Future Function(TestConfiguration)? onRun; - final StreamController _controller = - StreamController.broadcast(); + final StreamController _controller = + StreamController.broadcast(); int runCallCount = 0; int stopCallCount = 0; bool disposed = false; bool _isRunning = false; - /// Push a line as if the native layer had emitted it. - void emit(String line) => _controller.add(line); + /// Push an event as if the native layer had reported it. + void emit(Iperf3Event event) => _controller.add(event); @override - Stream get outputLines => _controller.stream; + Stream get events => _controller.stream; @override bool get isRunning => _isRunning;