Compare commits

..

4 Commits

Author SHA1 Message Date
mikkeli b76098c7e6 Merge branch 'refactor/core-independent-of-ui'
CI / Analyze & Test (push) Failing after 30s
Make the core work independently of the UI: Iperf3Session accumulates a run in
the domain layer, so the view model holds no run data and the screen derives no
numbers. Replacing the presentation layer now means rewriting rendering, not
re-deriving results.

Adds lib/main_headless.dart, which runs a full test through Iperf3Service and
Iperf3Session with no widget tree, as a standing check on that claim — and as
the accurate way to measure: 30.2% local CPU headless against 79.9% through the
UI on a comparable 4-stream run.

Verified on an XQ-AS72: headless run clean at 859 Mbit/s mean, matching the
server's own 859 Mbits/sec receiver figure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 20:25:24 +09:00
mikkeli 6e35d724dc 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 <noreply@anthropic.com>
2026-08-26 20:25:06 +09:00
mikkeli fc15897541 feat: add a headless entry point that runs a test with no UI
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 <noreply@anthropic.com>
2026-08-26 20:24:44 +09:00
mikkeli 9485049b57 refactor: move run accumulation out of the view model into the domain
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 <noreply@anthropic.com>
2026-08-26 20:24:44 +09:00
8 changed files with 432 additions and 90 deletions
+24 -1
View File
@@ -7,11 +7,13 @@ A Flutter-based Android GUI wrapper for iperf3, the popular network performance
``` ```
lib/ # Flutter/Dart application layer lib/ # Flutter/Dart application layer
├── main.dart # Composition root: picks the Iperf3Runner impl ├── 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 ├── domain/ # Pure Dart — no Flutter import
│ ├── models/ │ ├── models/
│ │ ├── test_configuration.dart # Server, port, duration, interval, parallel streams │ │ ├── test_configuration.dart # Server, port, duration, interval, parallel streams
│ │ ├── iperf3_result.dart # Raw output, exit code, stopped flag │ │ ├── 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/ │ └── services/
│ └── iperf3_runner.dart # The contract the UI depends on │ └── iperf3_runner.dart # The contract the UI depends on
├── data/ # Transport — flutter/services only ├── 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 device — and what keeps a test double at five stable members instead of
mirroring the transport's whole surface. 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 `test/architecture_test.dart` enforces all of this by reading the actual
imports under `lib/`, including a check that no fourth layer directory has imports under `lib/`, including a check that no fourth layer directory has
appeared unnoticed. Change the layering there when the layering should change; appeared unnoticed. Change the layering there when the layering should change;
+114
View File
@@ -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<Iperf3Interval> _intervals = [];
/// Throughput samples in arrival order, warm-up intervals excluded.
List<Iperf3Interval> get intervals => List.unmodifiable(_intervals);
Iperf3End? _summary;
/// Sent/received totals, once the run has finished.
Iperf3End? get summary => _summary;
final List<String> _errors = [];
/// Errors reported during the run, in order.
List<String> 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<double>(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<int>(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<int>().toList();
if (reported.isEmpty) return null;
return reported.fold<int>(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;
}
}
+105
View File
@@ -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<void> 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');
+14 -12
View File
@@ -195,29 +195,31 @@ class _TestRunContentState extends State<_TestRunContent> {
), ),
const SizedBox(height: 12), 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<TestRunViewModel>( Consumer<TestRunViewModel>(
builder: (context, vm, _) { builder: (context, vm, _) {
final intervals = vm.intervals; final session = vm.session;
if (intervals.isEmpty) return const SizedBox.shrink(); final latest = session.latestBitsPerSecond;
final rates = final mean = session.meanBitsPerSecond;
intervals.map((i) => i.bitsPerSecond).toList(growable: false); final peak = session.peakBitsPerSecond;
final latest = rates.last; if (latest == null || mean == null || peak == null) {
final peak = rates.reduce((a, b) => a > b ? a : b); return const SizedBox.shrink();
final mean = rates.reduce((a, b) => a + b) / rates.length; }
final summary = vm.summary?.sent; final total = session.summary?.sent;
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.only(bottom: 12),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
_Stat( _Stat(
label: summary != null ? 'total' : 'now', label: total != null ? 'total' : 'now',
value: formatBitrate(summary?.bitsPerSecond ?? latest), value: formatBitrate(total?.bitsPerSecond ?? latest),
), ),
_Stat(label: 'avg', value: formatBitrate(mean)), _Stat(label: 'avg', value: formatBitrate(mean)),
_Stat(label: 'peak', value: formatBitrate(peak)), _Stat(label: 'peak', value: formatBitrate(peak)),
_Stat(label: 'samples', value: '${rates.length}'), _Stat(
label: 'samples', value: '${session.sampleCount}'),
], ],
), ),
); );
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:iperf3gui/domain/models/iperf3_event.dart'; import 'package:iperf3gui/domain/models/iperf3_event.dart';
import 'package:iperf3gui/domain/models/iperf3_result.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/models/test_configuration.dart';
import 'package:iperf3gui/domain/services/iperf3_runner.dart'; import 'package:iperf3gui/domain/services/iperf3_runner.dart';
import 'package:iperf3gui/ui/features/test_run/iperf3_event_formatter.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. /// ViewModel for managing test run state.
/// ///
/// Subscribes to [Iperf3Runner.events] so the transcript and the interval /// Presentation only. What happened during a run is accumulated by
/// series both grow while the test runs, rather than appearing at the end. /// [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 { class TestRunViewModel extends ChangeNotifier {
final Iperf3Runner iperf3Service; final Iperf3Runner iperf3Service;
@@ -41,24 +44,14 @@ class TestRunViewModel extends ChangeNotifier {
final StringBuffer _transcript = StringBuffer(); 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(); String get output => _transcript.toString();
final List<Iperf3Interval> _intervals = []; final Iperf3Session _session = Iperf3Session();
/// Per-interval throughput samples, in arrival order. Structured, so a chart /// The accumulated run: interval series, totals, errors and their statistics.
/// can be driven from this without re-parsing text. Iperf3Session get session => _session;
List<Iperf3Interval> 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. /// Whether a test is currently in flight.
bool get isRunning => _state == TestRunState.running; bool get isRunning => _state == TestRunState.running;
@@ -118,26 +111,12 @@ class TestRunViewModel extends ChangeNotifier {
void _resetRunData() { void _resetRunData() {
_result = null; _result = null;
_transcript.clear(); _transcript.clear();
_intervals.clear(); _session.clear();
_start = null;
_summary = null;
_dirty = false; _dirty = false;
} }
void _onEvent(Iperf3Event event) { void _onEvent(Iperf3Event event) {
switch (event) { _session.add(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); final line = formatIperf3Event(event);
if (line != null && line.isNotEmpty) { if (line != null && line.isNotEmpty) {
+20 -2
View File
@@ -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', () { test('every layer directory is actually covered by these rules', () {
// Guards against a new top-level layer appearing and silently escaping // Guards against a new top-level layer appearing and silently escaping
// every rule above. // every rule above.
@@ -102,8 +118,9 @@ void main() {
/// Every `(file, import)` pair under [directory] whose import satisfies [test]. /// Every `(file, import)` pair under [directory] whose import satisfies [test].
List<({String file, String import})> _importsMatching( List<({String file, String import})> _importsMatching(
String directory, String directory,
bool Function(String import) test, bool Function(String import) test, {
) { Set<String>? onlyFiles,
}) {
final results = <({String file, String import})>[]; final results = <({String file, String import})>[];
final dir = Directory(directory); final dir = Directory(directory);
if (!dir.existsSync()) return results; if (!dir.existsSync()) return results;
@@ -111,6 +128,7 @@ List<({String file, String import})> _importsMatching(
final pattern = RegExp(r'''^\s*import\s+['"]([^'"]+)['"]''', multiLine: true); final pattern = RegExp(r'''^\s*import\s+['"]([^'"]+)['"]''', multiLine: true);
for (final entity in dir.listSync(recursive: true)) { for (final entity in dir.listSync(recursive: true)) {
if (entity is! File || !entity.path.endsWith('.dart')) continue; if (entity is! File || !entity.path.endsWith('.dart')) continue;
if (onlyFiles != null && !onlyFiles.contains(entity.path)) continue;
final source = entity.readAsStringSync(); final source = entity.readAsStringSync();
for (final match in pattern.allMatches(source)) { for (final match in pattern.allMatches(source)) {
final import = match.group(1)!; final import = match.group(1)!;
+135
View File
@@ -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);
});
});
}
@@ -82,48 +82,13 @@ void main() {
expect(vm.output, isNot(contains('ignored'))); 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(); final mock = FakeIperf3Runner();
mock.onRun = (_) async { mock.onRun = (_) async {
mock.emit(const Iperf3Interval( mock.emit(const Iperf3Interval(
start: 0, end: 1, bytes: 1000, bitsPerSecond: 8000)); start: 0, end: 1, bytes: 1000, bitsPerSecond: 8000));
mock.emit(const Iperf3Interval(
start: 1, end: 2, bytes: 2000, bitsPerSecond: 16000));
await Future<void>.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<void>.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( mock.emit(const Iperf3End(
sent: Iperf3Total( sent: Iperf3Total(seconds: 1, bytes: 1000, bitsPerSecond: 8000),
seconds: 10, bytes: 987654321, bitsPerSecond: 790000000),
)); ));
await Future<void>.delayed(Duration.zero); await Future<void>.delayed(Duration.zero);
return const Iperf3Result(rawOutput: '', exitCode: 0); return const Iperf3Result(rawOutput: '', exitCode: 0);
@@ -132,11 +97,11 @@ void main() {
final vm = TestRunViewModel(iperf3Service: mock); final vm = TestRunViewModel(iperf3Service: mock);
await vm.runTest(const TestConfiguration(server: '192.168.1.1')); await vm.runTest(const TestConfiguration(server: '192.168.1.1'));
expect(vm.summary?.sent?.bitsPerSecond, equals(790000000)); expect(vm.session.sampleCount, equals(1));
expect(vm.output, contains('790 Mbits/sec')); 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(); final mock = FakeIperf3Runner();
mock.onRun = (_) async { mock.onRun = (_) async {
mock.emit(const Iperf3Interval( mock.emit(const Iperf3Interval(
@@ -147,10 +112,11 @@ void main() {
final vm = TestRunViewModel(iperf3Service: mock); final vm = TestRunViewModel(iperf3Service: mock);
await vm.runTest(const TestConfiguration(server: '192.168.1.1')); 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')); 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 { test('falls back to result output when nothing streamed', () async {