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
@@ -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) {