diff --git a/test/support/fake_iperf3_runner.dart b/test/support/fake_iperf3_runner.dart new file mode 100644 index 0000000..c0c636c --- /dev/null +++ b/test/support/fake_iperf3_runner.dart @@ -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 Function(TestConfiguration)? onRun; + + final StreamController _controller = + StreamController.broadcast(); + + /// Configurations passed to [run], in order. + final List 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 get events => _controller.stream; + + @override + bool get isRunning => _isRunning; + + @override + Future 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 stop() async { + stopCallCount++; + } + + @override + Future dispose() async { + disposed = true; + await _controller.close(); + } +} diff --git a/test/ui/features/test_run/test_run_view_model_test.dart b/test/ui/features/test_run/test_run_view_model_test.dart index 025019c..e087e56 100644 --- a/test/ui/features/test_run/test_run_view_model_test.dart +++ b/test/ui/features/test_run/test_run_view_model_test.dart @@ -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(); mock.onRun = (_) => gate.future; @@ -237,49 +238,3 @@ void main() { }); }); } - -class _MockIperf3Service implements Iperf3Service { - _MockIperf3Service({this.onRun}); - - Future Function(TestConfiguration)? onRun; - - final StreamController _controller = - StreamController.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 get events => _controller.stream; - - @override - bool get isRunning => _isRunning; - - @override - Future 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 stop() async { - stopCallCount++; - } - - @override - Future dispose() async { - disposed = true; - await _controller.close(); - } -} diff --git a/test/widget_test.dart b/test/widget_test.dart index af0aa47..d9f16f9 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -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 -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. +// These drive the real UI against a FakeIperf3Runner: no MethodChannel, no +// native library, no device. That is only possible because the presentation +// layer depends on the Iperf3Runner interface rather than on Iperf3Service. +import 'dart:async'; + +import 'package:flutter/material.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 'support/fake_iperf3_runner.dart'; + void main() { 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('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.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(); + 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); }); }