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:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user