fix(app): stream native output and release run state on every exit path

_isRunning was never cleared on the timeout path, so a single timed-out run
locked the app out of starting another until it was restarted. It is now
cleared in a finally block covering all three exits.

Output was buffered inside the service and only handed over once the run
finished, so the UI showed nothing until the end despite the native layer
streaming line by line. The service republishes each line on outputLines and
the view model appends to a transcript as they arrive.

The channel handler was re-registered on every run and dereferenced
_runCompleter with `!`, which threw once a late callback arrived after the
field had been nulled. It is registered once in the constructor and reads the
completer through a local.

Also drops the duplicated argument list in favour of
TestConfiguration.toCliArgs(), so the flags the UI sends have one definition.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 19:03:22 +09:00
parent f410a7e0d3
commit 3b01b0c4a6
3 changed files with 300 additions and 84 deletions
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:iperf3gui/data/services/iperf3_service.dart';
import 'package:iperf3gui/domain/models/iperf3_result.dart';
@@ -7,29 +9,26 @@ import 'package:iperf3gui/ui/features/test_run/test_run_view_model.dart';
void main() {
group('TestRunViewModel', () {
test('starts in idle state', () {
final vm = TestRunViewModel(
iperf3Service: _MockIperf3Service(),
);
final vm = TestRunViewModel(iperf3Service: _MockIperf3Service());
expect(vm.state, equals(TestRunState.idle));
expect(vm.result, isNull);
expect(vm.currentConfig, isNull);
expect(vm.output, isEmpty);
});
test('can be reset', () {
final vm = TestRunViewModel(
iperf3Service: _MockIperf3Service(),
);
final vm = TestRunViewModel(iperf3Service: _MockIperf3Service());
vm.reset();
expect(vm.state, equals(TestRunState.idle));
});
test('invalid config sets error state without running', () async {
final vm = TestRunViewModel(
iperf3Service: _MockIperf3Service(),
);
final mock = _MockIperf3Service();
final vm = TestRunViewModel(iperf3Service: mock);
const config = TestConfiguration(server: '');
await vm.runTest(config);
expect(vm.state, equals(TestRunState.error));
expect(mock.runCallCount, equals(0));
});
test('runTest with valid config calls service', () async {
@@ -45,17 +44,140 @@ void main() {
expect(vm.state, equals(TestRunState.completed));
expect(vm.result, isNotNull);
});
test('non-zero exit code lands in error state', () async {
final mock = _MockIperf3Service(
onRun: (_) async =>
const Iperf3Result(rawOutput: 'boom', exitCode: 1),
);
final vm = TestRunViewModel(iperf3Service: mock);
await vm.runTest(const TestConfiguration(server: '192.168.1.1'));
expect(vm.state, equals(TestRunState.error));
expect(vm.result!.exitCode, equals(1));
});
test('streamed lines 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');
// Let the broadcast stream deliver before the run resolves.
await Future<void>.delayed(Duration.zero);
return const Iperf3Result(rawOutput: 'ignored', exitCode: 0);
};
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('94.1 Mbits/sec'));
// The streamed transcript wins over the service's own copy.
expect(vm.output, isNot(contains('ignored')));
});
test('falls back to result output when nothing streamed', () async {
final mock = _MockIperf3Service(
onRun: (_) async =>
const Iperf3Result(rawOutput: 'summary only', exitCode: 0),
);
final vm = TestRunViewModel(iperf3Service: mock);
await vm.runTest(const TestConfiguration(server: '192.168.1.1'));
expect(vm.output, contains('summary only'));
});
test('output is cleared when a new run starts', () async {
final mock = _MockIperf3Service(
onRun: (_) async =>
const Iperf3Result(rawOutput: 'first', exitCode: 0),
);
final vm = TestRunViewModel(iperf3Service: mock);
await vm.runTest(const TestConfiguration(server: '192.168.1.1'));
expect(vm.output, contains('first'));
mock.onRun = (_) async =>
const Iperf3Result(rawOutput: 'second', exitCode: 0);
await vm.runTest(const TestConfiguration(server: '192.168.1.1'));
expect(vm.output, isNot(contains('first')));
expect(vm.output, contains('second'));
});
test('stopTest forwards to the service only while running', () async {
final mock = _MockIperf3Service();
final vm = TestRunViewModel(iperf3Service: mock);
await vm.stopTest();
expect(mock.stopCallCount, equals(0), reason: 'idle: nothing to stop');
final gate = Completer<Iperf3Result>();
mock.onRun = (_) => gate.future;
final run = vm.runTest(const TestConfiguration(server: '192.168.1.1'));
expect(vm.isRunning, isTrue);
await vm.stopTest();
expect(mock.stopCallCount, equals(1));
gate.complete(const Iperf3Result(rawOutput: 'stopped', exitCode: 0));
await run;
});
test('a second runTest while running is ignored', () async {
final mock = _MockIperf3Service();
final gate = Completer<Iperf3Result>();
mock.onRun = (_) => gate.future;
final vm = TestRunViewModel(iperf3Service: mock);
final run = vm.runTest(const TestConfiguration(server: '192.168.1.1'));
await vm.runTest(const TestConfiguration(server: '192.168.1.1'));
expect(mock.runCallCount, equals(1));
gate.complete(const Iperf3Result(rawOutput: 'done', exitCode: 0));
await run;
});
});
}
class _MockIperf3Service implements Iperf3Service {
_MockIperf3Service({this.onRun});
final Future<Iperf3Result> Function(TestConfiguration)? onRun;
Future<Iperf3Result> Function(TestConfiguration)? onRun;
final StreamController<String> _controller =
StreamController<String>.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);
@override
Stream<String> get outputLines => _controller.stream;
@override
bool get isRunning => _isRunning;
@override
Future<Iperf3Result> run(TestConfiguration config) async {
if (onRun != null) return onRun!(config);
return const Iperf3Result(rawOutput: '', exitCode: 0);
runCallCount++;
_isRunning = true;
try {
if (onRun != null) return await onRun!(config);
return const Iperf3Result(rawOutput: '', exitCode: 0);
} finally {
_isRunning = false;
}
}
@override
Future<void> stop() async {
stopCallCount++;
}
@override
Future<void> dispose() async {
disposed = true;
await _controller.close();
}
}