diff --git a/docs/README.md b/docs/README.md index 3079c9d..230f1a7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,11 +7,13 @@ A Flutter-based Android GUI wrapper for iperf3, the popular network performance ``` lib/ # Flutter/Dart application layer ├── main.dart # Composition root: picks the Iperf3Runner impl +├── main_headless.dart # Runs a test with no widgets (see Layering) ├── domain/ # Pure Dart — no Flutter import │ ├── models/ │ │ ├── test_configuration.dart # Server, port, duration, interval, parallel streams │ │ ├── iperf3_result.dart # Raw output, exit code, stopped flag -│ │ └── iperf3_event.dart # Parsed --json-stream reports +│ │ ├── iperf3_event.dart # Parsed --json-stream reports +│ │ └── iperf3_session.dart # One run accumulated: samples, totals, stats │ └── services/ │ └── iperf3_runner.dart # The contract the UI depends on ├── data/ # Transport — flutter/services only @@ -69,6 +71,27 @@ tree against an in-memory runner with no MethodChannel, native library or device — and what keeps a test double at five stable members instead of mirroring the transport's whole surface. +**The core does not need the UI.** What happened during a run is accumulated by +`Iperf3Session` in the domain layer — samples, totals, errors, and the mean, +peak and retransmit figures over them. The view model holds no run data of its +own and the screen derives no numbers; both read the session. Replacing the +presentation layer therefore means rewriting rendering, not re-deriving results. + +`lib/main_headless.dart` is the standing proof: it runs a full test through +`Iperf3Service` and `Iperf3Session` with `WidgetsFlutterBinding` but no +`runApp` — no widget tree, no render surface. If the run path ever starts +needing the UI, it stops working. + +```bash +flutter run -t lib/main_headless.dart \ + --dart-define=IPERF_SERVER=192.168.2.182 --dart-define=IPERF_STREAMS=4 +``` + +Use it when a figure has to be trustworthy. A 4-stream local run reports +**30.2% local CPU headless against 79.9% through the UI** — the phone is near +CPU-bound at gigabit rates, so a repainting transcript measurably depresses +throughput. Numbers taken through the UI are pessimistic. + `test/architecture_test.dart` enforces all of this by reading the actual imports under `lib/`, including a check that no fourth layer directory has appeared unnoticed. Change the layering there when the layering should change; diff --git a/lib/domain/models/iperf3_session.dart b/lib/domain/models/iperf3_session.dart new file mode 100644 index 0000000..258eea0 --- /dev/null +++ b/lib/domain/models/iperf3_session.dart @@ -0,0 +1,114 @@ +import 'package:iperf3gui/domain/models/iperf3_event.dart'; + +/// The accumulated state of one iperf3 run. +/// +/// Fed the event stream one report at a time, this is the answer to "what +/// happened during this test" — the interval series, the end totals, any +/// errors, and the statistics derived from them. +/// +/// It lives in the domain layer on purpose: it is what has to be *correct*, +/// independent of how anything chooses to draw it. A different UI, a headless +/// run or a future export path all consume the same object, and none of them +/// re-derive a mean or re-decide whether a warm-up interval counts. +class Iperf3Session { + Iperf3Start? _start; + + /// Connection details, once the run has reported them. + Iperf3Start? get start => _start; + + final List _intervals = []; + + /// Throughput samples in arrival order, warm-up intervals excluded. + List get intervals => List.unmodifiable(_intervals); + + Iperf3End? _summary; + + /// Sent/received totals, once the run has finished. + Iperf3End? get summary => _summary; + + final List _errors = []; + + /// Errors reported during the run, in order. + List get errors => List.unmodifiable(_errors); + + bool _stopped = false; + + /// Whether the run ended because it was asked to stop. + bool get stopped => _stopped; + + /// Whether iperf3 reported any error. + bool get hasError => _errors.isNotEmpty; + + /// Whether anything at all has been recorded. + bool get isEmpty => + _start == null && _intervals.isEmpty && _summary == null && !hasError; + + /// How many throughput samples have been recorded. + int get sampleCount => _intervals.length; + + /// Throughput of the most recent sample, or null before the first one. + double? get latestBitsPerSecond => + _intervals.isEmpty ? null : _intervals.last.bitsPerSecond; + + /// Mean throughput across the samples, or null before the first one. + /// + /// This is the mean of the per-interval rates, which is not identical to the + /// run's overall rate when intervals differ in length — prefer + /// [summary]'s totals for the headline figure once it is available. + double? get meanBitsPerSecond { + if (_intervals.isEmpty) return null; + final total = _intervals.fold(0, (sum, i) => sum + i.bitsPerSecond); + return total / _intervals.length; + } + + /// Highest sampled throughput, or null before the first sample. + double? get peakBitsPerSecond { + if (_intervals.isEmpty) return null; + return _intervals + .map((i) => i.bitsPerSecond) + .reduce((a, b) => a > b ? a : b); + } + + /// Total bytes across the recorded samples. + int get sampledBytes => _intervals.fold(0, (sum, i) => sum + i.bytes); + + /// Total retransmits across the samples, or null if none were reported. + /// + /// UDP tests report no retransmit counts, so null means "not measured" + /// rather than zero. + int? get totalRetransmits { + final reported = + _intervals.map((i) => i.retransmits).whereType().toList(); + if (reported.isEmpty) return null; + return reported.fold(0, (sum, r) => sum + r); + } + + /// Record one report. + void add(Iperf3Event event) { + switch (event) { + case Iperf3Start e: + _start = e; + case Iperf3Interval e: + // Warm-up intervals (-O) are marked omitted by iperf3 and must not + // drag the statistics down. + if (!e.omitted) _intervals.add(e); + case Iperf3End e: + _summary = e; + case Iperf3Error e: + _errors.add(e.message); + case Iperf3Stopped _: + _stopped = true; + case Iperf3LogLine _: + break; + } + } + + /// Forget everything, ready for another run. + void clear() { + _start = null; + _intervals.clear(); + _summary = null; + _errors.clear(); + _stopped = false; + } +} diff --git a/lib/main_headless.dart b/lib/main_headless.dart new file mode 100644 index 0000000..143aedf --- /dev/null +++ b/lib/main_headless.dart @@ -0,0 +1,105 @@ +// Headless entry point: runs one iperf3 test with no widgets. +// +// This exists to keep the core honest. If anything in the run path ever starts +// depending on the UI, this stops compiling or stops working — a claim about +// separation that is checked rather than asserted. +// +// It is also the low-noise way to measure. The UI competes with the test for +// CPU (a gigabit run can put the phone near CPU-bound), so figures taken here +// are cleaner than figures taken with a transcript repainting alongside. +// +// flutter run -t lib/main_headless.dart \ +// --dart-define=IPERF_SERVER=192.168.2.182 \ +// --dart-define=IPERF_STREAMS=4 +// +// Nothing below imports lib/ui, and test/architecture_test.dart enforces that. + +import 'package:flutter/widgets.dart' show WidgetsFlutterBinding; +import 'package:flutter/foundation.dart' show debugPrint; + +import 'package:iperf3gui/data/services/iperf3_service.dart'; +import 'package:iperf3gui/domain/models/iperf3_event.dart'; +import 'package:iperf3gui/domain/models/iperf3_session.dart'; +import 'package:iperf3gui/domain/models/test_configuration.dart'; +import 'package:iperf3gui/domain/services/iperf3_runner.dart'; + +/// Test parameters, supplied at build time with --dart-define. +const String _server = String.fromEnvironment('IPERF_SERVER', + defaultValue: 'iperf.es.net'); +const int _port = int.fromEnvironment('IPERF_PORT', defaultValue: 5201); +const int _seconds = int.fromEnvironment('IPERF_SECONDS', defaultValue: 10); +const int _streams = int.fromEnvironment('IPERF_STREAMS', defaultValue: 1); + +Future main() async { + // Required for the MethodChannel. Notably NOT runApp() — there is no widget + // tree, no render surface and no Flutter UI thread work in this process. + WidgetsFlutterBinding.ensureInitialized(); + + const config = TestConfiguration( + server: _server, + port: _port, + duration: Duration(seconds: _seconds), + parallelStreams: _streams, + ); + + final Iperf3Runner runner = Iperf3Service(); + final session = Iperf3Session(); + + final subscription = runner.events.listen((event) { + session.add(event); + if (event is Iperf3Interval && !event.omitted) { + _out('interval ${event.start.toStringAsFixed(2)}-' + '${event.end.toStringAsFixed(2)}s ' + '${_mbits(event.bitsPerSecond)} Mbit/s' + '${event.retransmits != null ? ' retr=${event.retransmits}' : ''}'); + } else if (event is Iperf3Error) { + _out('error: ${event.message}'); + } + }); + + _out('running iperf3 -c ${config.server} -p ${config.port} ' + '-t ${config.duration.inSeconds} -P ${config.parallelStreams}'); + + final result = await runner.run(config); + await subscription.cancel(); + await runner.dispose(); + + _report(result.exitCode, session); +} + +void _report(int exitCode, Iperf3Session session) { + _out('--- result ---'); + _out('exit code: $exitCode'); + _out('samples: ${session.sampleCount}'); + + final mean = session.meanBitsPerSecond; + final peak = session.peakBitsPerSecond; + if (mean != null) _out('mean: ${_mbits(mean)} Mbit/s'); + if (peak != null) _out('peak: ${_mbits(peak)} Mbit/s'); + + final retr = session.totalRetransmits; + _out('retransmits: ${retr ?? 'not measured'}'); + + final sent = session.summary?.sent; + final received = session.summary?.received; + if (sent != null) { + _out('sent: ${_mbits(sent.bitsPerSecond)} Mbit/s ' + 'over ${sent.seconds.toStringAsFixed(2)}s'); + } + if (received != null) { + _out('received: ${_mbits(received.bitsPerSecond)} Mbit/s ' + 'over ${received.seconds.toStringAsFixed(2)}s'); + } + final cpu = session.summary?.hostCpuPercent; + if (cpu != null) _out('local cpu: ${cpu.toStringAsFixed(1)}%'); + + if (session.stopped) _out('ended early: stopped'); + for (final error in session.errors) { + _out('error: $error'); + } +} + +String _mbits(double bitsPerSecond) => + (bitsPerSecond / 1e6).toStringAsFixed(1); + +void _out(String line) => debugPrint('[iperf3-headless] $line'); diff --git a/lib/ui/features/test_run/test_run_screen.dart b/lib/ui/features/test_run/test_run_screen.dart index 5eda417..1b5e6aa 100644 --- a/lib/ui/features/test_run/test_run_screen.dart +++ b/lib/ui/features/test_run/test_run_screen.dart @@ -195,29 +195,31 @@ class _TestRunContentState extends State<_TestRunContent> { ), const SizedBox(height: 12), - // Live throughput figures, derived from the parsed interval series + // Live throughput figures. Every number here is read off the + // session; none are computed in the widget tree. 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; + final session = vm.session; + final latest = session.latestBitsPerSecond; + final mean = session.meanBitsPerSecond; + final peak = session.peakBitsPerSecond; + if (latest == null || mean == null || peak == null) { + return const SizedBox.shrink(); + } + final total = session.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), + label: total != null ? 'total' : 'now', + value: formatBitrate(total?.bitsPerSecond ?? latest), ), _Stat(label: 'avg', value: formatBitrate(mean)), _Stat(label: 'peak', value: formatBitrate(peak)), - _Stat(label: 'samples', value: '${rates.length}'), + _Stat( + label: 'samples', value: '${session.sampleCount}'), ], ), ); 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 83b2f0e..1186124 100644 --- a/lib/ui/features/test_run/test_run_view_model.dart +++ b/lib/ui/features/test_run/test_run_view_model.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:iperf3gui/domain/models/iperf3_event.dart'; import 'package:iperf3gui/domain/models/iperf3_result.dart'; +import 'package:iperf3gui/domain/models/iperf3_session.dart'; import 'package:iperf3gui/domain/models/test_configuration.dart'; import 'package:iperf3gui/domain/services/iperf3_runner.dart'; import 'package:iperf3gui/ui/features/test_run/iperf3_event_formatter.dart'; @@ -12,8 +13,10 @@ enum TestRunState { idle, running, completed, error } /// ViewModel for managing test run state. /// -/// Subscribes to [Iperf3Runner.events] so the transcript and the interval -/// series both grow while the test runs, rather than appearing at the end. +/// Presentation only. What happened during a run is accumulated by +/// [Iperf3Session] in the domain layer; this class subscribes to +/// [Iperf3Runner.events], feeds that session, keeps a formatted transcript for +/// display, and decides when to repaint. class TestRunViewModel extends ChangeNotifier { final Iperf3Runner iperf3Service; @@ -41,24 +44,14 @@ class TestRunViewModel extends ChangeNotifier { final StringBuffer _transcript = StringBuffer(); - /// Human-readable output accumulated so far. + /// Human-readable output accumulated so far. Display only — the structured + /// record of the run is [session]. String get output => _transcript.toString(); - final List _intervals = []; + final Iperf3Session _session = Iperf3Session(); - /// 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; + /// The accumulated run: interval series, totals, errors and their statistics. + Iperf3Session get session => _session; /// Whether a test is currently in flight. bool get isRunning => _state == TestRunState.running; @@ -118,26 +111,12 @@ class TestRunViewModel extends ChangeNotifier { void _resetRunData() { _result = null; _transcript.clear(); - _intervals.clear(); - _start = null; - _summary = null; + _session.clear(); _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; - } + _session.add(event); final line = formatIperf3Event(event); if (line != null && line.isNotEmpty) { diff --git a/test/architecture_test.dart b/test/architecture_test.dart index 20725d8..0c9846f 100644 --- a/test/architecture_test.dart +++ b/test/architecture_test.dart @@ -82,6 +82,22 @@ void main() { ); }); + test('the headless entry point does not depend on ui', () { + // main_headless.dart is the standing proof that the core runs without a + // widget tree; an import from lib/ui would quietly end that. + final offenders = _importsMatching( + 'lib', + (import) => import.contains('/ui/'), + onlyFiles: {'lib/main_headless.dart'}, + ); + expect( + offenders, + isEmpty, + reason: 'The headless entry point must run the core with no UI.\n' + '${_render(offenders)}', + ); + }); + test('every layer directory is actually covered by these rules', () { // Guards against a new top-level layer appearing and silently escaping // every rule above. @@ -102,8 +118,9 @@ void main() { /// Every `(file, import)` pair under [directory] whose import satisfies [test]. List<({String file, String import})> _importsMatching( String directory, - bool Function(String import) test, -) { + bool Function(String import) test, { + Set? onlyFiles, +}) { final results = <({String file, String import})>[]; final dir = Directory(directory); if (!dir.existsSync()) return results; @@ -111,6 +128,7 @@ List<({String file, String import})> _importsMatching( final pattern = RegExp(r'''^\s*import\s+['"]([^'"]+)['"]''', multiLine: true); for (final entity in dir.listSync(recursive: true)) { if (entity is! File || !entity.path.endsWith('.dart')) continue; + if (onlyFiles != null && !onlyFiles.contains(entity.path)) continue; final source = entity.readAsStringSync(); for (final match in pattern.allMatches(source)) { final import = match.group(1)!; diff --git a/test/domain/models/iperf3_session_test.dart b/test/domain/models/iperf3_session_test.dart new file mode 100644 index 0000000..b459583 --- /dev/null +++ b/test/domain/models/iperf3_session_test.dart @@ -0,0 +1,135 @@ +// Pure-Dart tests for the run aggregate. No widgets, no bindings, no channel — +// this is the part that has to be correct regardless of what draws it. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:iperf3gui/domain/models/iperf3_event.dart'; +import 'package:iperf3gui/domain/models/iperf3_session.dart'; + +void main() { + Iperf3Interval sample(double start, double bits, + {int bytes = 1000, int? retransmits, bool omitted = false}) => + Iperf3Interval( + start: start, + end: start + 1, + bytes: bytes, + bitsPerSecond: bits, + retransmits: retransmits, + omitted: omitted, + ); + + group('Iperf3Session', () { + test('starts empty', () { + final session = Iperf3Session(); + expect(session.isEmpty, isTrue); + expect(session.sampleCount, isZero); + expect(session.intervals, isEmpty); + expect(session.start, isNull); + expect(session.summary, isNull); + expect(session.errors, isEmpty); + expect(session.stopped, isFalse); + expect(session.hasError, isFalse); + }); + + test('statistics are null before any sample', () { + final session = Iperf3Session(); + expect(session.latestBitsPerSecond, isNull); + expect(session.meanBitsPerSecond, isNull); + expect(session.peakBitsPerSecond, isNull); + expect(session.totalRetransmits, isNull); + }); + + test('records start, intervals and summary', () { + final session = Iperf3Session() + ..add(const Iperf3Start(targetHost: 'host', targetPort: 5201)) + ..add(sample(0, 100)) + ..add(const Iperf3End( + sent: Iperf3Total(seconds: 1, bytes: 10, bitsPerSecond: 80), + )); + expect(session.isEmpty, isFalse); + expect(session.start!.targetHost, equals('host')); + expect(session.sampleCount, equals(1)); + expect(session.summary!.sent!.bitsPerSecond, equals(80)); + }); + + test('excludes omitted warm-up intervals from the series', () { + final session = Iperf3Session() + ..add(sample(0, 10, omitted: true)) + ..add(sample(1, 100)) + ..add(sample(2, 200)); + expect(session.sampleCount, equals(2)); + expect(session.meanBitsPerSecond, equals(150), + reason: 'the omitted 10 would have pulled the mean to ~103'); + }); + + test('computes mean, peak and latest over the samples', () { + final session = Iperf3Session() + ..add(sample(0, 100)) + ..add(sample(1, 400)) + ..add(sample(2, 250)); + expect(session.latestBitsPerSecond, equals(250)); + expect(session.peakBitsPerSecond, equals(400)); + expect(session.meanBitsPerSecond, equals(250)); + }); + + test('sums sampled bytes', () { + final session = Iperf3Session() + ..add(sample(0, 1, bytes: 1500)) + ..add(sample(1, 1, bytes: 2500)); + expect(session.sampledBytes, equals(4000)); + }); + + test('sums retransmits when reported', () { + final session = Iperf3Session() + ..add(sample(0, 1, retransmits: 2)) + ..add(sample(1, 1, retransmits: 5)); + expect(session.totalRetransmits, equals(7)); + }); + + test('retransmits stay null when never reported, not zero', () { + // UDP reports no retransmit counts; "not measured" must not read as 0. + final session = Iperf3Session() + ..add(sample(0, 1)) + ..add(sample(1, 1)); + expect(session.totalRetransmits, isNull); + }); + + test('collects errors in order and flags them', () { + final session = Iperf3Session() + ..add(const Iperf3Error('first')) + ..add(const Iperf3Error('second')); + expect(session.hasError, isTrue); + expect(session.errors, equals(['first', 'second'])); + expect(session.isEmpty, isFalse); + }); + + test('records a stop', () { + final session = Iperf3Session()..add(const Iperf3Stopped()); + expect(session.stopped, isTrue); + }); + + test('ignores log lines', () { + final session = Iperf3Session()..add(const Iperf3LogLine('noise')); + expect(session.isEmpty, isTrue); + }); + + test('clear returns it to the empty state', () { + final session = Iperf3Session() + ..add(const Iperf3Start(targetHost: 'host')) + ..add(sample(0, 100)) + ..add(const Iperf3Error('boom')) + ..add(const Iperf3Stopped()) + ..add(const Iperf3End()) + ..clear(); + expect(session.isEmpty, isTrue); + expect(session.stopped, isFalse); + expect(session.hasError, isFalse); + expect(session.peakBitsPerSecond, isNull); + }); + + test('exposed collections are unmodifiable', () { + final session = Iperf3Session()..add(sample(0, 1)); + expect(() => session.intervals.add(sample(1, 1)), throwsUnsupportedError); + expect(() => session.errors.add('x'), throwsUnsupportedError); + }); + }); +} 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 e087e56..74a3c34 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 @@ -82,48 +82,13 @@ void main() { expect(vm.output, isNot(contains('ignored'))); }); - test('intervals are collected as structured samples', () async { + test('events are fed into the session', () async { final mock = FakeIperf3Runner(); 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 = FakeIperf3Runner(); - 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 = FakeIperf3Runner(); - mock.onRun = (_) async { mock.emit(const Iperf3End( - sent: Iperf3Total( - seconds: 10, bytes: 987654321, bitsPerSecond: 790000000), + sent: Iperf3Total(seconds: 1, bytes: 1000, bitsPerSecond: 8000), )); await Future.delayed(Duration.zero); return const Iperf3Result(rawOutput: '', exitCode: 0); @@ -132,11 +97,11 @@ void main() { 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')); + expect(vm.session.sampleCount, equals(1)); + expect(vm.session.summary, isNotNull); }); - test('a new run clears the previous interval series', () async { + test('a new run clears the previous session', () async { final mock = FakeIperf3Runner(); mock.onRun = (_) async { mock.emit(const Iperf3Interval( @@ -147,10 +112,11 @@ void main() { final vm = TestRunViewModel(iperf3Service: mock); await vm.runTest(const TestConfiguration(server: '192.168.1.1')); - expect(vm.intervals, hasLength(1)); + expect(vm.session.sampleCount, equals(1)); await vm.runTest(const TestConfiguration(server: '192.168.1.1')); - expect(vm.intervals, hasLength(1), reason: 'not appended to the old run'); + expect(vm.session.sampleCount, equals(1), + reason: 'not appended to the old run'); }); test('falls back to result output when nothing streamed', () async {