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:
@@ -0,0 +1,62 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
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/domain/services/iperf3_runner.dart';
|
||||||
|
|
||||||
|
/// In-memory [Iperf3Runner] for tests.
|
||||||
|
///
|
||||||
|
/// Implements the interface rather than the concrete service, so it tracks the
|
||||||
|
/// five members the UI actually depends on instead of every public member the
|
||||||
|
/// MethodChannel implementation happens to have.
|
||||||
|
class FakeIperf3Runner implements Iperf3Runner {
|
||||||
|
FakeIperf3Runner({this.onRun});
|
||||||
|
|
||||||
|
/// Supplies the result for each [run], and may [emit] events first.
|
||||||
|
Future<Iperf3Result> Function(TestConfiguration)? onRun;
|
||||||
|
|
||||||
|
final StreamController<Iperf3Event> _controller =
|
||||||
|
StreamController<Iperf3Event>.broadcast();
|
||||||
|
|
||||||
|
/// Configurations passed to [run], in order.
|
||||||
|
final List<TestConfiguration> runCalls = [];
|
||||||
|
|
||||||
|
int stopCallCount = 0;
|
||||||
|
bool disposed = false;
|
||||||
|
bool _isRunning = false;
|
||||||
|
|
||||||
|
int get runCallCount => runCalls.length;
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
runCalls.add(config);
|
||||||
|
_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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,17 @@
|
|||||||
import 'dart:async';
|
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/domain/models/iperf3_event.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';
|
||||||
|
|
||||||
|
import '../../../support/fake_iperf3_runner.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('TestRunViewModel', () {
|
group('TestRunViewModel', () {
|
||||||
test('starts in idle state', () {
|
test('starts in idle state', () {
|
||||||
final vm = TestRunViewModel(iperf3Service: _MockIperf3Service());
|
final vm = TestRunViewModel(iperf3Service: FakeIperf3Runner());
|
||||||
expect(vm.state, equals(TestRunState.idle));
|
expect(vm.state, equals(TestRunState.idle));
|
||||||
expect(vm.result, isNull);
|
expect(vm.result, isNull);
|
||||||
expect(vm.currentConfig, isNull);
|
expect(vm.currentConfig, isNull);
|
||||||
@@ -18,13 +19,13 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('can be reset', () {
|
test('can be reset', () {
|
||||||
final vm = TestRunViewModel(iperf3Service: _MockIperf3Service());
|
final vm = TestRunViewModel(iperf3Service: FakeIperf3Runner());
|
||||||
vm.reset();
|
vm.reset();
|
||||||
expect(vm.state, equals(TestRunState.idle));
|
expect(vm.state, equals(TestRunState.idle));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('invalid config sets error state without running', () async {
|
test('invalid config sets error state without running', () async {
|
||||||
final mock = _MockIperf3Service();
|
final mock = FakeIperf3Runner();
|
||||||
final vm = TestRunViewModel(iperf3Service: mock);
|
final vm = TestRunViewModel(iperf3Service: mock);
|
||||||
const config = TestConfiguration(server: '');
|
const config = TestConfiguration(server: '');
|
||||||
await vm.runTest(config);
|
await vm.runTest(config);
|
||||||
@@ -33,7 +34,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('runTest with valid config calls service', () async {
|
test('runTest with valid config calls service', () async {
|
||||||
final mock = _MockIperf3Service(
|
final mock = FakeIperf3Runner(
|
||||||
onRun: (config) async {
|
onRun: (config) async {
|
||||||
expect(config.server, equals('192.168.1.1'));
|
expect(config.server, equals('192.168.1.1'));
|
||||||
return const Iperf3Result(rawOutput: 'done', exitCode: 0);
|
return const Iperf3Result(rawOutput: 'done', exitCode: 0);
|
||||||
@@ -47,7 +48,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('non-zero exit code lands in error state', () async {
|
test('non-zero exit code lands in error state', () async {
|
||||||
final mock = _MockIperf3Service(
|
final mock = FakeIperf3Runner(
|
||||||
onRun: (_) async =>
|
onRun: (_) async =>
|
||||||
const Iperf3Result(rawOutput: 'boom', exitCode: 1),
|
const Iperf3Result(rawOutput: 'boom', exitCode: 1),
|
||||||
);
|
);
|
||||||
@@ -58,7 +59,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('streamed events accumulate into output during the run', () async {
|
test('streamed events accumulate into output during the run', () async {
|
||||||
final mock = _MockIperf3Service();
|
final mock = FakeIperf3Runner();
|
||||||
mock.onRun = (_) async {
|
mock.onRun = (_) async {
|
||||||
mock.emit(const Iperf3Start(targetHost: '192.168.1.1', targetPort: 5201));
|
mock.emit(const Iperf3Start(targetHost: '192.168.1.1', targetPort: 5201));
|
||||||
mock.emit(const Iperf3Interval(
|
mock.emit(const Iperf3Interval(
|
||||||
@@ -82,7 +83,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('intervals are collected as structured samples', () async {
|
test('intervals are collected as structured samples', () async {
|
||||||
final mock = _MockIperf3Service();
|
final mock = FakeIperf3Runner();
|
||||||
mock.onRun = (_) async {
|
mock.onRun = (_) async {
|
||||||
mock.emit(const Iperf3Interval(
|
mock.emit(const Iperf3Interval(
|
||||||
start: 0, end: 1, bytes: 1000, bitsPerSecond: 8000));
|
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 {
|
test('omitted warm-up intervals are excluded from the series', () async {
|
||||||
final mock = _MockIperf3Service();
|
final mock = FakeIperf3Runner();
|
||||||
mock.onRun = (_) async {
|
mock.onRun = (_) async {
|
||||||
mock.emit(const Iperf3Interval(
|
mock.emit(const Iperf3Interval(
|
||||||
start: 0, end: 1, bytes: 1, bitsPerSecond: 1, omitted: true));
|
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 {
|
test('end event is retained as the summary', () async {
|
||||||
final mock = _MockIperf3Service();
|
final mock = FakeIperf3Runner();
|
||||||
mock.onRun = (_) async {
|
mock.onRun = (_) async {
|
||||||
mock.emit(const Iperf3End(
|
mock.emit(const Iperf3End(
|
||||||
sent: Iperf3Total(
|
sent: Iperf3Total(
|
||||||
@@ -136,7 +137,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('a new run clears the previous interval series', () async {
|
test('a new run clears the previous interval series', () async {
|
||||||
final mock = _MockIperf3Service();
|
final mock = FakeIperf3Runner();
|
||||||
mock.onRun = (_) async {
|
mock.onRun = (_) async {
|
||||||
mock.emit(const Iperf3Interval(
|
mock.emit(const Iperf3Interval(
|
||||||
start: 0, end: 1, bytes: 1000, bitsPerSecond: 8000));
|
start: 0, end: 1, bytes: 1000, bitsPerSecond: 8000));
|
||||||
@@ -153,7 +154,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
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 = FakeIperf3Runner(
|
||||||
onRun: (_) async =>
|
onRun: (_) async =>
|
||||||
const Iperf3Result(rawOutput: 'summary only', exitCode: 0),
|
const Iperf3Result(rawOutput: 'summary only', exitCode: 0),
|
||||||
);
|
);
|
||||||
@@ -163,7 +164,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('output is cleared when a new run starts', () async {
|
test('output is cleared when a new run starts', () async {
|
||||||
final mock = _MockIperf3Service(
|
final mock = FakeIperf3Runner(
|
||||||
onRun: (_) async =>
|
onRun: (_) async =>
|
||||||
const Iperf3Result(rawOutput: 'first', exitCode: 0),
|
const Iperf3Result(rawOutput: 'first', exitCode: 0),
|
||||||
);
|
);
|
||||||
@@ -179,7 +180,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('stopTest forwards to the service only while running', () async {
|
test('stopTest forwards to the service only while running', () async {
|
||||||
final mock = _MockIperf3Service();
|
final mock = FakeIperf3Runner();
|
||||||
final vm = TestRunViewModel(iperf3Service: mock);
|
final vm = TestRunViewModel(iperf3Service: mock);
|
||||||
|
|
||||||
await vm.stopTest();
|
await vm.stopTest();
|
||||||
@@ -198,7 +199,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('wasStopped reflects a user-requested stop', () async {
|
test('wasStopped reflects a user-requested stop', () async {
|
||||||
final mock = _MockIperf3Service(
|
final mock = FakeIperf3Runner(
|
||||||
onRun: (_) async => const Iperf3Result(
|
onRun: (_) async => const Iperf3Result(
|
||||||
rawOutput: 'stopped early',
|
rawOutput: 'stopped early',
|
||||||
exitCode: 0,
|
exitCode: 0,
|
||||||
@@ -213,7 +214,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('wasStopped is false for a run that finished on its own', () async {
|
test('wasStopped is false for a run that finished on its own', () async {
|
||||||
final mock = _MockIperf3Service(
|
final mock = FakeIperf3Runner(
|
||||||
onRun: (_) async =>
|
onRun: (_) async =>
|
||||||
const Iperf3Result(rawOutput: 'done', exitCode: 0),
|
const Iperf3Result(rawOutput: 'done', exitCode: 0),
|
||||||
);
|
);
|
||||||
@@ -223,7 +224,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('a second runTest while running is ignored', () async {
|
test('a second runTest while running is ignored', () async {
|
||||||
final mock = _MockIperf3Service();
|
final mock = FakeIperf3Runner();
|
||||||
final gate = Completer<Iperf3Result>();
|
final gate = Completer<Iperf3Result>();
|
||||||
mock.onRun = (_) => gate.future;
|
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+78
-6
@@ -1,17 +1,89 @@
|
|||||||
// This is a basic Flutter widget test.
|
// Widget-level smoke tests.
|
||||||
//
|
//
|
||||||
// To perform an interaction with a widget in your test, use the WidgetTester
|
// These drive the real UI against a FakeIperf3Runner: no MethodChannel, no
|
||||||
// utility in the flutter_test package. For example, you can send tap and scroll
|
// native library, no device. That is only possible because the presentation
|
||||||
// gestures. You can also use WidgetTester to find child widgets in the widget
|
// layer depends on the Iperf3Runner interface rather than on Iperf3Service.
|
||||||
// tree, read text, and verify that the values of widget properties are correct.
|
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:iperf3gui/domain/models/iperf3_event.dart';
|
||||||
|
import 'package:iperf3gui/domain/models/iperf3_result.dart';
|
||||||
import 'package:iperf3gui/main.dart';
|
import 'package:iperf3gui/main.dart';
|
||||||
|
|
||||||
|
import 'support/fake_iperf3_runner.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('App smoke test', (WidgetTester tester) async {
|
testWidgets('App smoke test', (WidgetTester tester) async {
|
||||||
await tester.pumpWidget(const Iperf3GuiApp());
|
await tester.pumpWidget(Iperf3GuiApp(runner: FakeIperf3Runner()));
|
||||||
expect(find.text('iperf3-gui'), findsOneWidget);
|
expect(find.text('iperf3-gui'), findsOneWidget);
|
||||||
|
expect(find.text('GO'), findsOneWidget);
|
||||||
|
expect(find.text('Ready'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('runs a test and renders streamed results', (tester) async {
|
||||||
|
final runner = FakeIperf3Runner();
|
||||||
|
runner.onRun = (config) async {
|
||||||
|
runner.emit(Iperf3Start(
|
||||||
|
targetHost: config.server,
|
||||||
|
targetPort: config.port,
|
||||||
|
protocol: 'TCP',
|
||||||
|
streamCount: config.parallelStreams,
|
||||||
|
));
|
||||||
|
runner.emit(const Iperf3Interval(
|
||||||
|
start: 0,
|
||||||
|
end: 1,
|
||||||
|
bytes: 98566144,
|
||||||
|
bitsPerSecond: 788410352,
|
||||||
|
));
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
return const Iperf3Result(rawOutput: '', exitCode: 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
await tester.pumpWidget(Iperf3GuiApp(runner: runner));
|
||||||
|
await tester.tap(find.text('GO'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(runner.runCallCount, equals(1));
|
||||||
|
expect(find.text('Test complete'), findsOneWidget);
|
||||||
|
expect(find.textContaining('788 Mbits/sec'), findsWidgets);
|
||||||
|
// The stat row is driven by the parsed interval series.
|
||||||
|
expect(find.text('peak'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows a stop button while running and can stop', (tester) async {
|
||||||
|
final runner = FakeIperf3Runner();
|
||||||
|
final gate = Completer<Iperf3Result>();
|
||||||
|
runner.onRun = (_) => gate.future;
|
||||||
|
|
||||||
|
await tester.pumpWidget(Iperf3GuiApp(runner: runner));
|
||||||
|
await tester.tap(find.text('GO'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text('STOP'), findsOneWidget);
|
||||||
|
expect(find.text('Running…'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('STOP'));
|
||||||
|
await tester.pump();
|
||||||
|
expect(runner.stopCallCount, equals(1));
|
||||||
|
|
||||||
|
gate.complete(const Iperf3Result(
|
||||||
|
rawOutput: '', exitCode: 0, stopped: true));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Stopped'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('rejects an empty server address', (tester) async {
|
||||||
|
final runner = FakeIperf3Runner();
|
||||||
|
await tester.pumpWidget(Iperf3GuiApp(runner: runner));
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField).first, '');
|
||||||
|
await tester.tap(find.text('GO'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(runner.runCallCount, equals(0));
|
||||||
|
expect(find.textContaining('Invalid configuration'), findsOneWidget);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user