test: drive the real UI against an in-memory runner

FakeIperf3Runner implements Iperf3Runner, so the widget tests exercise the
actual widget tree — GO, STOP, the status line, the stat row, validation —
with no MethodChannel and no native library. That was impossible while the
screen constructed its own Iperf3Service.

The view model tests share the same fake instead of each file carrying a
double that had to mirror the concrete service.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 19:56:03 +09:00
parent 314a3ea059
commit 95acf5ea38
3 changed files with 158 additions and 69 deletions
@@ -1,16 +1,17 @@
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';
import '../../../support/fake_iperf3_runner.dart';
void main() {
group('TestRunViewModel', () {
test('starts in idle state', () {
final vm = TestRunViewModel(iperf3Service: _MockIperf3Service());
final vm = TestRunViewModel(iperf3Service: FakeIperf3Runner());
expect(vm.state, equals(TestRunState.idle));
expect(vm.result, isNull);
expect(vm.currentConfig, isNull);
@@ -18,13 +19,13 @@ void main() {
});
test('can be reset', () {
final vm = TestRunViewModel(iperf3Service: _MockIperf3Service());
final vm = TestRunViewModel(iperf3Service: FakeIperf3Runner());
vm.reset();
expect(vm.state, equals(TestRunState.idle));
});
test('invalid config sets error state without running', () async {
final mock = _MockIperf3Service();
final mock = FakeIperf3Runner();
final vm = TestRunViewModel(iperf3Service: mock);
const config = TestConfiguration(server: '');
await vm.runTest(config);
@@ -33,7 +34,7 @@ void main() {
});
test('runTest with valid config calls service', () async {
final mock = _MockIperf3Service(
final mock = FakeIperf3Runner(
onRun: (config) async {
expect(config.server, equals('192.168.1.1'));
return const Iperf3Result(rawOutput: 'done', exitCode: 0);
@@ -47,7 +48,7 @@ void main() {
});
test('non-zero exit code lands in error state', () async {
final mock = _MockIperf3Service(
final mock = FakeIperf3Runner(
onRun: (_) async =>
const Iperf3Result(rawOutput: 'boom', exitCode: 1),
);
@@ -58,7 +59,7 @@ void main() {
});
test('streamed events accumulate into output during the run', () async {
final mock = _MockIperf3Service();
final mock = FakeIperf3Runner();
mock.onRun = (_) async {
mock.emit(const Iperf3Start(targetHost: '192.168.1.1', targetPort: 5201));
mock.emit(const Iperf3Interval(
@@ -82,7 +83,7 @@ void main() {
});
test('intervals are collected as structured samples', () async {
final mock = _MockIperf3Service();
final mock = FakeIperf3Runner();
mock.onRun = (_) async {
mock.emit(const Iperf3Interval(
start: 0, end: 1, bytes: 1000, bitsPerSecond: 8000));
@@ -100,7 +101,7 @@ void main() {
});
test('omitted warm-up intervals are excluded from the series', () async {
final mock = _MockIperf3Service();
final mock = FakeIperf3Runner();
mock.onRun = (_) async {
mock.emit(const Iperf3Interval(
start: 0, end: 1, bytes: 1, bitsPerSecond: 1, omitted: true));
@@ -118,7 +119,7 @@ void main() {
});
test('end event is retained as the summary', () async {
final mock = _MockIperf3Service();
final mock = FakeIperf3Runner();
mock.onRun = (_) async {
mock.emit(const Iperf3End(
sent: Iperf3Total(
@@ -136,7 +137,7 @@ void main() {
});
test('a new run clears the previous interval series', () async {
final mock = _MockIperf3Service();
final mock = FakeIperf3Runner();
mock.onRun = (_) async {
mock.emit(const Iperf3Interval(
start: 0, end: 1, bytes: 1000, bitsPerSecond: 8000));
@@ -153,7 +154,7 @@ void main() {
});
test('falls back to result output when nothing streamed', () async {
final mock = _MockIperf3Service(
final mock = FakeIperf3Runner(
onRun: (_) async =>
const Iperf3Result(rawOutput: 'summary only', exitCode: 0),
);
@@ -163,7 +164,7 @@ void main() {
});
test('output is cleared when a new run starts', () async {
final mock = _MockIperf3Service(
final mock = FakeIperf3Runner(
onRun: (_) async =>
const Iperf3Result(rawOutput: 'first', exitCode: 0),
);
@@ -179,7 +180,7 @@ void main() {
});
test('stopTest forwards to the service only while running', () async {
final mock = _MockIperf3Service();
final mock = FakeIperf3Runner();
final vm = TestRunViewModel(iperf3Service: mock);
await vm.stopTest();
@@ -198,7 +199,7 @@ void main() {
});
test('wasStopped reflects a user-requested stop', () async {
final mock = _MockIperf3Service(
final mock = FakeIperf3Runner(
onRun: (_) async => const Iperf3Result(
rawOutput: 'stopped early',
exitCode: 0,
@@ -213,7 +214,7 @@ void main() {
});
test('wasStopped is false for a run that finished on its own', () async {
final mock = _MockIperf3Service(
final mock = FakeIperf3Runner(
onRun: (_) async =>
const Iperf3Result(rawOutput: 'done', exitCode: 0),
);
@@ -223,7 +224,7 @@ void main() {
});
test('a second runTest while running is ignored', () async {
final mock = _MockIperf3Service();
final mock = FakeIperf3Runner();
final gate = Completer<Iperf3Result>();
mock.onRun = (_) => gate.future;
@@ -237,49 +238,3 @@ void main() {
});
});
}
class _MockIperf3Service implements Iperf3Service {
_MockIperf3Service({this.onRun});
Future<Iperf3Result> Function(TestConfiguration)? onRun;
final StreamController<Iperf3Event> _controller =
StreamController<Iperf3Event>.broadcast();
int runCallCount = 0;
int stopCallCount = 0;
bool disposed = false;
bool _isRunning = false;
/// Push an event as if the native layer had reported it.
void emit(Iperf3Event event) => _controller.add(event);
@override
Stream<Iperf3Event> get events => _controller.stream;
@override
bool get isRunning => _isRunning;
@override
Future<Iperf3Result> run(TestConfiguration config) async {
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();
}
}