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,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: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/test_configuration.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));
|
||||
});
|
||||
|
||||
test('streamed lines accumulate into output during the run', () async {
|
||||
test('streamed events accumulate into output during the run', () async {
|
||||
final mock = _MockIperf3Service();
|
||||
mock.onRun = (_) async {
|
||||
mock.emit('Connecting to host 192.168.1.1\n');
|
||||
mock.emit('[ 5] 0.00-1.00 sec 11.2 MBytes 94.1 Mbits/sec\n');
|
||||
mock.emit(const Iperf3Start(targetHost: '192.168.1.1', targetPort: 5201));
|
||||
mock.emit(const Iperf3Interval(
|
||||
start: 0,
|
||||
end: 1,
|
||||
bytes: 11744051,
|
||||
bitsPerSecond: 94100000,
|
||||
));
|
||||
// Let the broadcast stream deliver before the run resolves.
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
return const Iperf3Result(rawOutput: 'ignored', exitCode: 0);
|
||||
@@ -69,12 +75,83 @@ void main() {
|
||||
final vm = TestRunViewModel(iperf3Service: mock);
|
||||
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'));
|
||||
// The streamed transcript wins over the service's own copy.
|
||||
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 {
|
||||
final mock = _MockIperf3Service(
|
||||
onRun: (_) async =>
|
||||
@@ -166,19 +243,19 @@ class _MockIperf3Service implements Iperf3Service {
|
||||
|
||||
Future<Iperf3Result> Function(TestConfiguration)? onRun;
|
||||
|
||||
final StreamController<String> _controller =
|
||||
StreamController<String>.broadcast();
|
||||
final StreamController<Iperf3Event> _controller =
|
||||
StreamController<Iperf3Event>.broadcast();
|
||||
|
||||
int runCallCount = 0;
|
||||
int stopCallCount = 0;
|
||||
bool disposed = false;
|
||||
bool _isRunning = false;
|
||||
|
||||
/// Push a line as if the native layer had emitted it.
|
||||
void emit(String line) => _controller.add(line);
|
||||
/// Push an event as if the native layer had reported it.
|
||||
void emit(Iperf3Event event) => _controller.add(event);
|
||||
|
||||
@override
|
||||
Stream<String> get outputLines => _controller.stream;
|
||||
Stream<Iperf3Event> get events => _controller.stream;
|
||||
|
||||
@override
|
||||
bool get isRunning => _isRunning;
|
||||
|
||||
Reference in New Issue
Block a user