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>
This commit is contained in:
2026-08-26 20:24:44 +09:00
parent 838c883cc0
commit 9485049b57
5 changed files with 283 additions and 87 deletions
+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;
}
}
+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) {
+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 {