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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<TestRunViewModel>(
|
||||
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}'),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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<Iperf3Interval> _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<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;
|
||||
/// 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) {
|
||||
|
||||
Reference in New Issue
Block a user