From 9485049b57d6f7b76b1caf4f17a2e0c4084ad23f Mon Sep 17 00:00:00 2001 From: Mikkeli Date: Wed, 26 Aug 2026 20:24:44 +0900 Subject: [PATCH 1/3] refactor: move run accumulation out of the view model into the domain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The view model was doing two jobs. Alongside presentation state it held what happened during a run — the interval series, the rule that iperf3's omitted warm-up intervals do not count, the retained end summary — and the screen computed mean and peak inline in a widget builder. All of that is the part that has to be correct, and all of it would have been rewritten by anyone replacing the UI. Iperf3Session accumulates a run in the domain layer: samples, totals, errors, whether it was stopped, and the statistics over them. The view model now feeds it events and keeps only a formatted transcript and the repaint schedule; the screen reads every displayed number off the session and derives none. Two details the session now owns rather than leaving to a caller. Omitted intervals are excluded, so a warm-up cannot drag the mean down. And totalRetransmits stays null when no interval reported a count — UDP does not measure retransmits, and "not measured" must not render as zero. The aggregation tests move with the logic, into a pure-Dart test with no widgets or bindings involved. Co-Authored-By: Claude Opus 5 --- lib/domain/models/iperf3_session.dart | 114 +++++++++++++++ lib/ui/features/test_run/test_run_screen.dart | 26 ++-- .../test_run/test_run_view_model.dart | 45 ++---- test/domain/models/iperf3_session_test.dart | 135 ++++++++++++++++++ .../test_run/test_run_view_model_test.dart | 50 ++----- 5 files changed, 283 insertions(+), 87 deletions(-) create mode 100644 lib/domain/models/iperf3_session.dart create mode 100644 test/domain/models/iperf3_session_test.dart 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/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/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 { From fc158975416a841f8f50bf4b5a659ff2d4936faa Mon Sep 17 00:00:00 2001 From: Mikkeli Date: Wed, 26 Aug 2026 20:24:44 +0900 Subject: [PATCH 2/3] feat: add a headless entry point that runs a test with no UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Separation that nothing exercises is separation that quietly stops being true, so this 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, this stops working. An architecture test additionally forbids it importing lib/ui. It is also the accurate way to measure. A 4-stream run against a local server reports 30.2% local CPU here, against 79.9% for a comparable run with the transcript repainting alongside — the UI was costing roughly fifty points of CPU on a phone already near CPU-bound at gigabit rates. Throughput figures taken through the UI are therefore pessimistic, and this is the path to use when the number matters. Parameters come from --dart-define so a run needs no code edit: flutter run -t lib/main_headless.dart --dart-define=IPERF_SERVER=… Co-Authored-By: Claude Opus 5 --- lib/main_headless.dart | 105 ++++++++++++++++++++++++++++++++++++ test/architecture_test.dart | 22 +++++++- 2 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 lib/main_headless.dart 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/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)!; From 6e35d724dcaa1bde9c1890f533ab03e0f5c865f5 Mon Sep 17 00:00:00 2001 From: Mikkeli Date: Wed, 26 Aug 2026 20:25:06 +0900 Subject: [PATCH 3/3] docs: record that the core runs without the UI, and the CPU cost of the UI States where run data lives now that Iperf3Session owns it, so the next person does not put accumulation back in the view model, and documents the headless entry point as the way to check that claim rather than trust it. Includes the measurement that motivates using it: 30.2% local CPU headless versus 79.9% through the UI on a comparable 4-stream run. That gap is worth recording, because it means throughput read off the app's own screen is pessimistic at high rates and should not be compared against a headless or desktop figure without noting which path produced it. Co-Authored-By: Claude Opus 5 --- docs/README.md | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) 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;