feat(ui): render the event stream and show live throughput figures
Consuming JSON means the familiar iperf3 text layout has to be rebuilt, so a pure formatter turns each event back into a line — binary units for transfer totals, decimal units for bitrates, matching how iperf3 quotes each. The view model keeps the interval series and the end summary as data alongside the transcript, and a stat row shows current/total, mean, peak and sample count off that series. A chart can be driven from the same list without re-parsing text. Repaints are coalesced on a 250ms timer rather than one per event. Reports can now arrive several times a second across many streams, and each rebuild re-renders the whole transcript, so notifying per event made the cost of drawing scale with the report rate — which competes with the test itself for CPU and skews the measurement. Warm-up intervals (-O) are marked omitted by iperf3 and are kept out of the series so they cannot drag the mean down. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,95 @@
|
|||||||
|
import 'package:iperf3gui/domain/models/iperf3_event.dart';
|
||||||
|
|
||||||
|
/// Renders parsed iperf3 events as the kind of line iperf3 itself would print.
|
||||||
|
///
|
||||||
|
/// The app consumes `--json-stream`, so the familiar text layout has to be
|
||||||
|
/// rebuilt here. Keeping it in one pure function makes it testable and leaves
|
||||||
|
/// the structured events untouched for anything else that wants them.
|
||||||
|
String? formatIperf3Event(Iperf3Event event) {
|
||||||
|
return switch (event) {
|
||||||
|
Iperf3Start e => _formatStart(e),
|
||||||
|
Iperf3Interval e => _formatInterval(e),
|
||||||
|
Iperf3End e => _formatEnd(e),
|
||||||
|
Iperf3Error e => 'iperf3 error: ${e.message}',
|
||||||
|
Iperf3Stopped _ => 'Stopped by user.',
|
||||||
|
Iperf3LogLine e => e.text.isEmpty ? null : e.text,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatStart(Iperf3Start e) {
|
||||||
|
final parts = <String>[];
|
||||||
|
if (e.targetHost != null) {
|
||||||
|
final port = e.targetPort != null ? ', port ${e.targetPort}' : '';
|
||||||
|
parts.add('Connecting to host ${e.targetHost}$port');
|
||||||
|
}
|
||||||
|
final detail = <String>[
|
||||||
|
if (e.protocol != null) e.protocol!,
|
||||||
|
if (e.streamCount != null)
|
||||||
|
'${e.streamCount} stream${e.streamCount == 1 ? '' : 's'}',
|
||||||
|
if (e.durationSeconds != null) '${e.durationSeconds}s',
|
||||||
|
];
|
||||||
|
if (detail.isNotEmpty) parts.add(detail.join(' · '));
|
||||||
|
return parts.isEmpty ? '' : parts.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatInterval(Iperf3Interval e) {
|
||||||
|
final window = '${_seconds(e.start)}-${_seconds(e.end)} sec';
|
||||||
|
final line = StringBuffer('[SUM] $window '
|
||||||
|
'${formatBytes(e.bytes)} ${formatBitrate(e.bitsPerSecond)}');
|
||||||
|
if (e.retransmits != null) {
|
||||||
|
line.write(' ${e.retransmits} retr');
|
||||||
|
}
|
||||||
|
if (e.omitted) line.write(' (omitted)');
|
||||||
|
return line.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatEnd(Iperf3End e) {
|
||||||
|
final lines = <String>['- ' * 20];
|
||||||
|
final sent = e.sent;
|
||||||
|
final received = e.received;
|
||||||
|
if (sent != null) {
|
||||||
|
lines.add('[SUM] 0.00-${_seconds(sent.seconds)} sec '
|
||||||
|
'${formatBytes(sent.bytes)} ${formatBitrate(sent.bitsPerSecond)}'
|
||||||
|
'${sent.retransmits != null ? ' ${sent.retransmits} retr' : ''}'
|
||||||
|
' sender');
|
||||||
|
}
|
||||||
|
if (received != null) {
|
||||||
|
lines.add('[SUM] 0.00-${_seconds(received.seconds)} sec '
|
||||||
|
'${formatBytes(received.bytes)} '
|
||||||
|
'${formatBitrate(received.bitsPerSecond)} receiver');
|
||||||
|
}
|
||||||
|
if (e.hostCpuPercent != null) {
|
||||||
|
lines.add('CPU: local ${e.hostCpuPercent!.toStringAsFixed(1)}%'
|
||||||
|
'${e.remoteCpuPercent != null ? ', remote ${e.remoteCpuPercent!.toStringAsFixed(1)}%' : ''}');
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
String _seconds(double value) => value.toStringAsFixed(2);
|
||||||
|
|
||||||
|
/// Byte totals the way iperf3 prints them — `93.8 MBytes`.
|
||||||
|
String formatBytes(int bytes) {
|
||||||
|
const units = ['Bytes', 'KBytes', 'MBytes', 'GBytes', 'TBytes'];
|
||||||
|
var value = bytes.toDouble();
|
||||||
|
var unit = 0;
|
||||||
|
while (value >= 1024 && unit < units.length - 1) {
|
||||||
|
value /= 1024;
|
||||||
|
unit++;
|
||||||
|
}
|
||||||
|
final precision = value >= 100 ? 0 : (value >= 10 ? 1 : 2);
|
||||||
|
return '${value.toStringAsFixed(precision)} ${units[unit]}';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bitrates the way iperf3 prints them — `786 Mbits/sec`. Decimal units, since
|
||||||
|
/// network rates are powers of ten while transfer totals are powers of two.
|
||||||
|
String formatBitrate(double bitsPerSecond) {
|
||||||
|
const units = ['bits/sec', 'Kbits/sec', 'Mbits/sec', 'Gbits/sec'];
|
||||||
|
var value = bitsPerSecond;
|
||||||
|
var unit = 0;
|
||||||
|
while (value >= 1000 && unit < units.length - 1) {
|
||||||
|
value /= 1000;
|
||||||
|
unit++;
|
||||||
|
}
|
||||||
|
final precision = value >= 100 ? 0 : (value >= 10 ? 1 : 2);
|
||||||
|
return '${value.toStringAsFixed(precision)} ${units[unit]}';
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:iperf3gui/data/services/iperf3_service.dart';
|
import 'package:iperf3gui/data/services/iperf3_service.dart';
|
||||||
import 'package:iperf3gui/domain/models/test_configuration.dart';
|
import 'package:iperf3gui/domain/models/test_configuration.dart';
|
||||||
|
import 'package:iperf3gui/ui/features/test_run/iperf3_event_formatter.dart';
|
||||||
import 'package:iperf3gui/ui/features/test_run/test_run_view_model.dart';
|
import 'package:iperf3gui/ui/features/test_run/test_run_view_model.dart';
|
||||||
|
|
||||||
/// Main test run screen.
|
/// Main test run screen.
|
||||||
@@ -193,6 +194,35 @@ class _TestRunContentState extends State<_TestRunContent> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Live throughput figures, derived from the parsed interval series
|
||||||
|
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;
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
_Stat(label: 'avg', value: formatBitrate(mean)),
|
||||||
|
_Stat(label: 'peak', value: formatBitrate(peak)),
|
||||||
|
_Stat(label: 'samples', value: '${rates.length}'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
// Live output
|
// Live output
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -229,3 +259,32 @@ class _TestRunContentState extends State<_TestRunContent> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// One figure in the throughput row.
|
||||||
|
class _Stat extends StatelessWidget {
|
||||||
|
const _Stat({required this.label, required this.value});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: theme.textTheme.labelSmall
|
||||||
|
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: theme.textTheme.titleSmall
|
||||||
|
?.copyWith(fontFeatures: const [FontFeature.tabularFigures()]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,24 +2,33 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:iperf3gui/data/services/iperf3_service.dart';
|
import 'package:iperf3gui/data/services/iperf3_service.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/test_configuration.dart';
|
import 'package:iperf3gui/domain/models/test_configuration.dart';
|
||||||
|
import 'package:iperf3gui/ui/features/test_run/iperf3_event_formatter.dart';
|
||||||
|
|
||||||
/// State of the test run.
|
/// State of the test run.
|
||||||
enum TestRunState { idle, running, completed, error }
|
enum TestRunState { idle, running, completed, error }
|
||||||
|
|
||||||
/// ViewModel for managing test run state.
|
/// ViewModel for managing test run state.
|
||||||
///
|
///
|
||||||
/// Subscribes to [Iperf3Service.outputLines] so the transcript grows while the
|
/// Subscribes to [Iperf3Service.events] so the transcript and the interval
|
||||||
/// test runs rather than appearing all at once at the end.
|
/// series both grow while the test runs, rather than appearing at the end.
|
||||||
class TestRunViewModel extends ChangeNotifier {
|
class TestRunViewModel extends ChangeNotifier {
|
||||||
final Iperf3Service iperf3Service;
|
final Iperf3Service iperf3Service;
|
||||||
|
|
||||||
|
/// How often to notify listeners while output is arriving. iperf3 can report
|
||||||
|
/// several times a second across many streams, and each rebuild re-renders
|
||||||
|
/// the whole transcript — so repaints are coalesced instead of one per event.
|
||||||
|
static const Duration displayInterval = Duration(milliseconds: 250);
|
||||||
|
|
||||||
TestRunViewModel({required this.iperf3Service}) {
|
TestRunViewModel({required this.iperf3Service}) {
|
||||||
_outputSubscription = iperf3Service.outputLines.listen(_onOutputLine);
|
_eventSubscription = iperf3Service.events.listen(_onEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
StreamSubscription<String>? _outputSubscription;
|
StreamSubscription<Iperf3Event>? _eventSubscription;
|
||||||
|
Timer? _repaintTimer;
|
||||||
|
bool _dirty = false;
|
||||||
|
|
||||||
TestRunState _state = TestRunState.idle;
|
TestRunState _state = TestRunState.idle;
|
||||||
TestRunState get state => _state;
|
TestRunState get state => _state;
|
||||||
@@ -30,11 +39,26 @@ class TestRunViewModel extends ChangeNotifier {
|
|||||||
TestConfiguration? _currentConfig;
|
TestConfiguration? _currentConfig;
|
||||||
TestConfiguration? get currentConfig => _currentConfig;
|
TestConfiguration? get currentConfig => _currentConfig;
|
||||||
|
|
||||||
final StringBuffer _liveOutput = StringBuffer();
|
final StringBuffer _transcript = StringBuffer();
|
||||||
|
|
||||||
/// Output accumulated so far. Grows line by line during a run, and holds the
|
/// Human-readable output accumulated so far.
|
||||||
/// finished transcript once the test completes.
|
String get output => _transcript.toString();
|
||||||
String get output => _liveOutput.toString();
|
|
||||||
|
final List<Iperf3Interval> _intervals = [];
|
||||||
|
|
||||||
|
/// 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;
|
||||||
|
|
||||||
/// 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;
|
||||||
@@ -47,35 +71,32 @@ class TestRunViewModel extends ChangeNotifier {
|
|||||||
if (_state == TestRunState.running) return;
|
if (_state == TestRunState.running) return;
|
||||||
|
|
||||||
if (!config.isValid) {
|
if (!config.isValid) {
|
||||||
_liveOutput
|
_resetRunData();
|
||||||
..clear()
|
_transcript
|
||||||
..writeln('Invalid configuration: check the server address and port.');
|
.writeln('Invalid configuration: check the server address and port.');
|
||||||
_state = TestRunState.error;
|
_state = TestRunState.error;
|
||||||
_result = null;
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_resetRunData();
|
||||||
_state = TestRunState.running;
|
_state = TestRunState.running;
|
||||||
_currentConfig = config;
|
_currentConfig = config;
|
||||||
_result = null;
|
|
||||||
_liveOutput.clear();
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final result = await iperf3Service.run(config);
|
final result = await iperf3Service.run(config);
|
||||||
_result = result;
|
_result = result;
|
||||||
// Prefer the streamed transcript — it is what the user watched build up.
|
if (_transcript.isEmpty && result.rawOutput.isNotEmpty) {
|
||||||
// Fall back to the service's copy if nothing streamed through.
|
_transcript.write(result.rawOutput);
|
||||||
if (_liveOutput.isEmpty && result.rawOutput.isNotEmpty) {
|
|
||||||
_liveOutput.write(result.rawOutput);
|
|
||||||
}
|
}
|
||||||
_state =
|
_state =
|
||||||
result.exitCode == 0 ? TestRunState.completed : TestRunState.error;
|
result.exitCode == 0 ? TestRunState.completed : TestRunState.error;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_liveOutput.writeln('Test failed: $e');
|
_transcript.writeln('Test failed: $e');
|
||||||
_state = TestRunState.error;
|
_state = TestRunState.error;
|
||||||
} finally {
|
} finally {
|
||||||
|
_stopRepaintTimer();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -88,21 +109,63 @@ class TestRunViewModel extends ChangeNotifier {
|
|||||||
|
|
||||||
/// Reset to idle state.
|
/// Reset to idle state.
|
||||||
void reset() {
|
void reset() {
|
||||||
|
_resetRunData();
|
||||||
_state = TestRunState.idle;
|
_state = TestRunState.idle;
|
||||||
_result = null;
|
|
||||||
_currentConfig = null;
|
_currentConfig = null;
|
||||||
_liveOutput.clear();
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onOutputLine(String line) {
|
void _resetRunData() {
|
||||||
_liveOutput.write(line.endsWith('\n') ? line : '$line\n');
|
_result = null;
|
||||||
|
_transcript.clear();
|
||||||
|
_intervals.clear();
|
||||||
|
_start = null;
|
||||||
|
_summary = null;
|
||||||
|
_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;
|
||||||
|
}
|
||||||
|
|
||||||
|
final line = formatIperf3Event(event);
|
||||||
|
if (line != null && line.isNotEmpty) {
|
||||||
|
_transcript.writeln(line);
|
||||||
|
}
|
||||||
|
_scheduleRepaint();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Coalesce repaints: mark dirty and let a timer flush at [displayInterval].
|
||||||
|
void _scheduleRepaint() {
|
||||||
|
_dirty = true;
|
||||||
|
_repaintTimer ??= Timer.periodic(displayInterval, (_) {
|
||||||
|
if (!_dirty) return;
|
||||||
|
_dirty = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _stopRepaintTimer() {
|
||||||
|
_repaintTimer?.cancel();
|
||||||
|
_repaintTimer = null;
|
||||||
|
_dirty = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_outputSubscription?.cancel();
|
_stopRepaintTimer();
|
||||||
|
_eventSubscription?.cancel();
|
||||||
iperf3Service.dispose();
|
iperf3Service.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:iperf3gui/domain/models/iperf3_event.dart';
|
||||||
|
import 'package:iperf3gui/ui/features/test_run/iperf3_event_formatter.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('formatBytes', () {
|
||||||
|
test('scales in binary units the way iperf3 prints totals', () {
|
||||||
|
expect(formatBytes(0), equals('0.00 Bytes'));
|
||||||
|
expect(formatBytes(1024), equals('1.00 KBytes'));
|
||||||
|
expect(formatBytes(98566144), equals('94.0 MBytes'));
|
||||||
|
expect(formatBytes(1073741824), equals('1.00 GBytes'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('formatBitrate', () {
|
||||||
|
test('scales in decimal units, as network rates are quoted', () {
|
||||||
|
expect(formatBitrate(0), equals('0.00 bits/sec'));
|
||||||
|
expect(formatBitrate(8000), equals('8.00 Kbits/sec'));
|
||||||
|
expect(formatBitrate(790000000), equals('790 Mbits/sec'));
|
||||||
|
expect(formatBitrate(2500000000), equals('2.50 Gbits/sec'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('formatIperf3Event', () {
|
||||||
|
test('renders a start event', () {
|
||||||
|
const event = Iperf3Start(
|
||||||
|
targetHost: '192.168.2.182',
|
||||||
|
targetPort: 5201,
|
||||||
|
protocol: 'TCP',
|
||||||
|
streamCount: 1,
|
||||||
|
durationSeconds: 10,
|
||||||
|
);
|
||||||
|
final text = formatIperf3Event(event)!;
|
||||||
|
expect(text, contains('Connecting to host 192.168.2.182, port 5201'));
|
||||||
|
expect(text, contains('TCP'));
|
||||||
|
expect(text, contains('1 stream'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders an interval with retransmits', () {
|
||||||
|
const event = Iperf3Interval(
|
||||||
|
start: 0,
|
||||||
|
end: 1,
|
||||||
|
bytes: 98566144,
|
||||||
|
bitsPerSecond: 788410352,
|
||||||
|
retransmits: 3,
|
||||||
|
);
|
||||||
|
final text = formatIperf3Event(event)!;
|
||||||
|
expect(text, contains('0.00-1.00 sec'));
|
||||||
|
expect(text, contains('94.0 MBytes'));
|
||||||
|
expect(text, contains('788 Mbits/sec'));
|
||||||
|
expect(text, contains('3 retr'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('marks an omitted interval', () {
|
||||||
|
const event = Iperf3Interval(
|
||||||
|
start: 0, end: 1, bytes: 1, bitsPerSecond: 8, omitted: true);
|
||||||
|
expect(formatIperf3Event(event), contains('omitted'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders sender and receiver lines from an end event', () {
|
||||||
|
const event = Iperf3End(
|
||||||
|
sent: Iperf3Total(
|
||||||
|
seconds: 10, bytes: 987654321, bitsPerSecond: 790000000,
|
||||||
|
retransmits: 7),
|
||||||
|
received: Iperf3Total(
|
||||||
|
seconds: 10.01, bytes: 987000000, bitsPerSecond: 789000000),
|
||||||
|
hostCpuPercent: 12.5,
|
||||||
|
);
|
||||||
|
final text = formatIperf3Event(event)!;
|
||||||
|
expect(text, contains('sender'));
|
||||||
|
expect(text, contains('receiver'));
|
||||||
|
expect(text, contains('7 retr'));
|
||||||
|
expect(text, contains('CPU: local 12.5%'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders errors and stops', () {
|
||||||
|
expect(formatIperf3Event(const Iperf3Error('boom')),
|
||||||
|
equals('iperf3 error: boom'));
|
||||||
|
expect(formatIperf3Event(const Iperf3Stopped()),
|
||||||
|
equals('Stopped by user.'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('drops an empty log line', () {
|
||||||
|
expect(formatIperf3Event(const Iperf3LogLine('')), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:iperf3gui/data/services/iperf3_service.dart';
|
import 'package:iperf3gui/data/services/iperf3_service.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/test_configuration.dart';
|
import 'package:iperf3gui/domain/models/test_configuration.dart';
|
||||||
import 'package:iperf3gui/ui/features/test_run/test_run_view_model.dart';
|
import 'package:iperf3gui/ui/features/test_run/test_run_view_model.dart';
|
||||||
@@ -56,11 +57,16 @@ void main() {
|
|||||||
expect(vm.result!.exitCode, equals(1));
|
expect(vm.result!.exitCode, equals(1));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('streamed lines accumulate into output during the run', () async {
|
test('streamed events accumulate into output during the run', () async {
|
||||||
final mock = _MockIperf3Service();
|
final mock = _MockIperf3Service();
|
||||||
mock.onRun = (_) async {
|
mock.onRun = (_) async {
|
||||||
mock.emit('Connecting to host 192.168.1.1\n');
|
mock.emit(const Iperf3Start(targetHost: '192.168.1.1', targetPort: 5201));
|
||||||
mock.emit('[ 5] 0.00-1.00 sec 11.2 MBytes 94.1 Mbits/sec\n');
|
mock.emit(const Iperf3Interval(
|
||||||
|
start: 0,
|
||||||
|
end: 1,
|
||||||
|
bytes: 11744051,
|
||||||
|
bitsPerSecond: 94100000,
|
||||||
|
));
|
||||||
// Let the broadcast stream deliver before the run resolves.
|
// Let the broadcast stream deliver before the run resolves.
|
||||||
await Future<void>.delayed(Duration.zero);
|
await Future<void>.delayed(Duration.zero);
|
||||||
return const Iperf3Result(rawOutput: 'ignored', exitCode: 0);
|
return const Iperf3Result(rawOutput: 'ignored', exitCode: 0);
|
||||||
@@ -69,12 +75,83 @@ 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.output, contains('Connecting to host'));
|
expect(vm.output, contains('Connecting to host 192.168.1.1'));
|
||||||
expect(vm.output, contains('94.1 Mbits/sec'));
|
expect(vm.output, contains('94.1 Mbits/sec'));
|
||||||
// The streamed transcript wins over the service's own copy.
|
// The streamed transcript wins over the service's own copy.
|
||||||
expect(vm.output, isNot(contains('ignored')));
|
expect(vm.output, isNot(contains('ignored')));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('intervals are collected as structured samples', () async {
|
||||||
|
final mock = _MockIperf3Service();
|
||||||
|
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<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 = _MockIperf3Service();
|
||||||
|
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 = _MockIperf3Service();
|
||||||
|
mock.onRun = (_) async {
|
||||||
|
mock.emit(const Iperf3End(
|
||||||
|
sent: Iperf3Total(
|
||||||
|
seconds: 10, bytes: 987654321, bitsPerSecond: 790000000),
|
||||||
|
));
|
||||||
|
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.summary?.sent?.bitsPerSecond, equals(790000000));
|
||||||
|
expect(vm.output, contains('790 Mbits/sec'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a new run clears the previous interval series', () async {
|
||||||
|
final mock = _MockIperf3Service();
|
||||||
|
mock.onRun = (_) async {
|
||||||
|
mock.emit(const Iperf3Interval(
|
||||||
|
start: 0, end: 1, bytes: 1000, bitsPerSecond: 8000));
|
||||||
|
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));
|
||||||
|
|
||||||
|
await vm.runTest(const TestConfiguration(server: '192.168.1.1'));
|
||||||
|
expect(vm.intervals, hasLength(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 {
|
||||||
final mock = _MockIperf3Service(
|
final mock = _MockIperf3Service(
|
||||||
onRun: (_) async =>
|
onRun: (_) async =>
|
||||||
@@ -166,19 +243,19 @@ class _MockIperf3Service implements Iperf3Service {
|
|||||||
|
|
||||||
Future<Iperf3Result> Function(TestConfiguration)? onRun;
|
Future<Iperf3Result> Function(TestConfiguration)? onRun;
|
||||||
|
|
||||||
final StreamController<String> _controller =
|
final StreamController<Iperf3Event> _controller =
|
||||||
StreamController<String>.broadcast();
|
StreamController<Iperf3Event>.broadcast();
|
||||||
|
|
||||||
int runCallCount = 0;
|
int runCallCount = 0;
|
||||||
int stopCallCount = 0;
|
int stopCallCount = 0;
|
||||||
bool disposed = false;
|
bool disposed = false;
|
||||||
bool _isRunning = false;
|
bool _isRunning = false;
|
||||||
|
|
||||||
/// Push a line as if the native layer had emitted it.
|
/// Push an event as if the native layer had reported it.
|
||||||
void emit(String line) => _controller.add(line);
|
void emit(Iperf3Event event) => _controller.add(event);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Stream<String> get outputLines => _controller.stream;
|
Stream<Iperf3Event> get events => _controller.stream;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool get isRunning => _isRunning;
|
bool get isRunning => _isRunning;
|
||||||
|
|||||||
Reference in New Issue
Block a user