diff --git a/docs/README.md b/docs/README.md index 9728d37..3079c9d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,19 +6,23 @@ A Flutter-based Android GUI wrapper for iperf3, the popular network performance ``` lib/ # Flutter/Dart application layer -├── main.dart # App entry point (MaterialApp) -├── domain/ # Business logic (platform-independent) +├── main.dart # Composition root: picks the Iperf3Runner impl +├── domain/ # Pure Dart — no Flutter import │ ├── models/ │ │ ├── test_configuration.dart # Server, port, duration, interval, parallel streams -│ │ └── iperf3_result.dart # Raw output + exit code -├── data/ +│ │ ├── iperf3_result.dart # Raw output, exit code, stopped flag +│ │ └── iperf3_event.dart # Parsed --json-stream reports │ └── services/ -│ └── iperf3_service.dart # Dart → MethodChannel bridge (async with Completer) -└── ui/ # Presentation layer +│ └── iperf3_runner.dart # The contract the UI depends on +├── data/ # Transport — flutter/services only +│ └── services/ +│ └── iperf3_service.dart # Iperf3Runner over MethodChannel → JNI +└── ui/ # Presentation └── features/ └── test_run/ - ├── test_run_screen.dart # Server/port inputs + GO button - └── test_run_view_model.dart # State machine: idle → running → completed/error + ├── test_run_screen.dart # Inputs, GO/STOP, live transcript + ├── test_run_view_model.dart # idle → running → completed/error + └── iperf3_event_formatter.dart # Events → iperf3-style text android/app/src/main/ ├── kotlin/ # Kotlin plugin layer @@ -48,6 +52,28 @@ User taps GO → On completion: Dart Completer resolves with Iperf3Result ``` +## Layering + +Dependencies point inward: `ui → domain ← data`, never the reverse. + +| Layer | May import | Holds | +|---|---|---| +| `domain` | nothing else in this package, no Flutter | models and the `Iperf3Runner` contract | +| `data` | `domain`, `package:flutter/services.dart` | the MethodChannel/JNI implementation | +| `ui` | `domain` | widgets, view model, formatting | +| `main.dart` | everything | the single place that picks an implementation | + +The UI depends on `Iperf3Runner`, not on `Iperf3Service`, so presentation names +no transport. That is what lets `test/widget_test.dart` drive the real widget +tree against an in-memory runner with no MethodChannel, native library or +device — and what keeps a test double at five stable members instead of +mirroring the transport's whole surface. + +`test/architecture_test.dart` enforces all of this by reading the actual +imports under `lib/`, including a check that no fourth layer directory has +appeared unnoticed. Change the layering there when the layering should change; +don't work around it. + ## Key Design Decisions ### JNI Over Process Execution diff --git a/lib/data/services/iperf3_service.dart b/lib/data/services/iperf3_service.dart index a9b3036..ff36b89 100644 --- a/lib/data/services/iperf3_service.dart +++ b/lib/data/services/iperf3_service.dart @@ -1,12 +1,13 @@ import 'dart:async'; +import 'dart:developer' as developer; -import 'package:flutter/foundation.dart'; import 'package:flutter/services.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/domain/services/iperf3_runner.dart'; -/// Service that runs iperf3 via JNI (native library). +/// [Iperf3Runner] backed by the native iperf3 library over a MethodChannel. /// /// On Android, iperf3 is compiled into the app as a shared library (.so). /// This avoids SELinux restrictions on executing ELF binaries from assets. @@ -16,13 +17,17 @@ import 'package:iperf3gui/domain/models/test_configuration.dart'; /// they are parsed and published on [events] while the test runs. [run] /// resolves once the native layer reports completion, carrying the raw JSON /// lines as the result's output. -class Iperf3Service { +class Iperf3Service implements Iperf3Runner { Iperf3Service() { _channel.setMethodCallHandler(_handleNativeCall); } - static const MethodChannel _channel = - MethodChannel('com.novoyuuparosk.iperf3gui/iperf'); + /// A channel's handler is keyed by channel name in the binary messenger, so + /// it is process-global: constructing a second instance would take the + /// handler away from the first. The app composes exactly one, in main(). + final MethodChannel _channel = const MethodChannel(_channelName); + + static const String _channelName = 'com.novoyuuparosk.iperf3gui/iperf'; /// Exit code reported when the native layer signalled an error. static const int exitCodeNativeError = 1; @@ -41,6 +46,7 @@ class Iperf3Service { StreamController.broadcast(); /// Live reports, one event per native report. Use this to render progress. + @override Stream get events => _eventController.stream; /// Raw JSON lines received during the current (or most recent) run. @@ -51,6 +57,7 @@ class Iperf3Service { /// Whether iperf3 is currently running. bool _isRunning = false; + @override bool get isRunning => _isRunning; /// Whether the native layer reported an error during this run. @@ -60,17 +67,19 @@ class Iperf3Service { bool _stopRequested = false; /// Cancel any running test. + @override Future stop() async { if (!_isRunning) return; _stopRequested = true; try { await _channel.invokeMethod('stopIperf'); } catch (e) { - debugPrint('Iperf3Service: error stopping: $e'); + _log('Iperf3Service: error stopping: $e'); } } /// Release the stream controller. Call from the owner's dispose(). + @override Future dispose() async { _channel.setMethodCallHandler(null); await _eventController.close(); @@ -79,6 +88,7 @@ class Iperf3Service { /// Run iperf3 with the given configuration. /// /// Returns an [Iperf3Result] with the full output and an exit code. + @override Future run(TestConfiguration config) async { if (_isRunning) { return const Iperf3Result( @@ -93,7 +103,7 @@ class Iperf3Service { _rawLines.clear(); final args = config.toCliArgs(); - debugPrint('Iperf3Service: running: ${args.join(" ")}'); + _log('Iperf3Service: running: ${args.join(" ")}'); final completer = Completer(); _runCompleter = completer; @@ -101,8 +111,8 @@ class Iperf3Service { try { await _channel.invokeMethod('runIperf', {'args': args}); } catch (e, stackTrace) { - debugPrint('Iperf3Service exception: $e'); - debugPrint('Iperf3Service stack: $stackTrace'); + _log('Iperf3Service exception: $e'); + _log('Iperf3Service stack: $stackTrace'); _finishRun(); return Iperf3Result( rawOutput: 'Error starting iperf3: $e', @@ -116,11 +126,11 @@ class Iperf3Service { return await completer.future.timeout( config.duration + const Duration(seconds: 45), onTimeout: () { - debugPrint('Iperf3Service: timeout, stopping test'); + _log('Iperf3Service: timeout, stopping test'); // Ask the native layer to wind down, then release local state so a // stuck run can never lock the UI out of starting another test. _channel.invokeMethod('stopIperf').catchError((Object e) { - debugPrint('Iperf3Service: error stopping after timeout: $e'); + _log('Iperf3Service: error stopping after timeout: $e'); return null; }); _publish(const Iperf3Error('Timed out waiting for iperf3 to finish.')); @@ -131,7 +141,7 @@ class Iperf3Service { }, ); } catch (e) { - debugPrint('Iperf3Service: error waiting for completion: $e'); + _log('Iperf3Service: error waiting for completion: $e'); return Iperf3Result( rawOutput: 'Test failed: $e', exitCode: exitCodeStartFailed, @@ -163,7 +173,7 @@ class Iperf3Service { _publish(Iperf3Error(error)); } case 'onComplete': - debugPrint('Iperf3Service: test completed'); + _log('Iperf3Service: test completed'); final completer = _runCompleter; if (completer != null && !completer.isCompleted) { completer.complete( @@ -177,7 +187,7 @@ class Iperf3Service { ); } default: - debugPrint('Iperf3Service: unhandled native call ${call.method}'); + _log('Iperf3Service: unhandled native call ${call.method}'); } } @@ -197,3 +207,6 @@ class Iperf3Service { _runCompleter = null; } } + +void _log(String message) => + developer.log(message, name: 'Iperf3Service'); diff --git a/lib/domain/services/iperf3_runner.dart b/lib/domain/services/iperf3_runner.dart new file mode 100644 index 0000000..c21438d --- /dev/null +++ b/lib/domain/services/iperf3_runner.dart @@ -0,0 +1,36 @@ +import 'package:iperf3gui/domain/models/iperf3_event.dart'; +import 'package:iperf3gui/domain/models/iperf3_result.dart'; +import 'package:iperf3gui/domain/models/test_configuration.dart'; + +/// Runs iperf3 tests and reports what happens. +/// +/// This is the whole contract the UI depends on. It is deliberately pure Dart — +/// no Flutter import — so the presentation layer never names a transport. The +/// Android build supplies a MethodChannel-and-JNI implementation +/// (`Iperf3Service`); a test, a desktop build or a headless tool can supply +/// its own without touching anything above this line. +abstract interface class Iperf3Runner { + /// Reports parsed from the running test, one event per iperf3 report. + /// + /// A broadcast stream: it may have no listener, and late listeners miss + /// earlier events rather than replaying them. + Stream get events; + + /// Whether a test is in flight right now. + bool get isRunning; + + /// Run a test to completion and resolve with its outcome. + /// + /// Resolves rather than throws for an iperf3-level failure — the failure is + /// carried in [Iperf3Result.exitCode]. Implementations must always resolve, + /// including on internal error, so a caller can never be left awaiting. + Future run(TestConfiguration config); + + /// Ask a running test to stop early. A no-op when nothing is running. + /// + /// The run's future still resolves, with [Iperf3Result.stopped] set. + Future stop(); + + /// Release resources. The instance is unusable afterwards. + Future dispose(); +} diff --git a/lib/main.dart b/lib/main.dart index da2d2fb..1f98860 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,17 +1,22 @@ import 'package:flutter/material.dart'; +import 'package:iperf3gui/data/services/iperf3_service.dart'; +import 'package:iperf3gui/domain/services/iperf3_runner.dart'; import 'package:iperf3gui/ui/features/test_run/test_run_screen.dart'; -/// Application entry point. +/// Application entry point, and the composition root. /// -/// iperf3 is now compiled as a native shared library (libiperf3jni.so) -/// and accessed via JNI — no binary extraction or chmod needed. +/// This is the only place that names a concrete [Iperf3Runner]. Everything +/// above the data layer depends on the interface, so swapping the transport — +/// or running the UI against a fake — touches nothing but this file. Future main() async { WidgetsFlutterBinding.ensureInitialized(); - runApp(const Iperf3GuiApp()); + runApp(Iperf3GuiApp(runner: Iperf3Service())); } class Iperf3GuiApp extends StatelessWidget { - const Iperf3GuiApp({super.key}); + const Iperf3GuiApp({required this.runner, super.key}); + + final Iperf3Runner runner; @override Widget build(BuildContext context) { @@ -21,7 +26,7 @@ class Iperf3GuiApp extends StatelessWidget { colorSchemeSeed: Colors.blue, useMaterial3: true, ), - home: const TestRunScreen(), + home: TestRunScreen(runner: runner), ); } } diff --git a/lib/ui/features/test_run/test_run_screen.dart b/lib/ui/features/test_run/test_run_screen.dart index 16aa122..5eda417 100644 --- a/lib/ui/features/test_run/test_run_screen.dart +++ b/lib/ui/features/test_run/test_run_screen.dart @@ -1,24 +1,25 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; -import 'package:iperf3gui/data/services/iperf3_service.dart'; import 'package:iperf3gui/domain/models/test_configuration.dart'; +import 'package:iperf3gui/domain/services/iperf3_runner.dart'; import 'package:iperf3gui/ui/features/test_run/iperf3_event_formatter.dart'; import 'package:iperf3gui/ui/features/test_run/test_run_view_model.dart'; /// Main test run screen. /// -/// iperf3 is compiled as a native shared library (libiperf3jni.so) and accessed -/// via JNI, so there is no binary extraction or chmod step. +/// Takes an [Iperf3Runner] rather than constructing one, so this layer names no +/// transport: the Android app injects the JNI-backed implementation from +/// main(), and a test injects a fake. class TestRunScreen extends StatelessWidget { - const TestRunScreen({super.key}); + const TestRunScreen({required this.runner, super.key}); + + final Iperf3Runner runner; @override Widget build(BuildContext context) { return ChangeNotifierProvider( - create: (_) => TestRunViewModel( - iperf3Service: Iperf3Service(), - ), + create: (_) => TestRunViewModel(iperf3Service: runner), child: const _TestRunContent(), ); } diff --git a/lib/ui/features/test_run/test_run_view_model.dart b/lib/ui/features/test_run/test_run_view_model.dart index 965cb14..83b2f0e 100644 --- a/lib/ui/features/test_run/test_run_view_model.dart +++ b/lib/ui/features/test_run/test_run_view_model.dart @@ -1,10 +1,10 @@ import 'dart:async'; import 'package:flutter/foundation.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/domain/services/iperf3_runner.dart'; import 'package:iperf3gui/ui/features/test_run/iperf3_event_formatter.dart'; /// State of the test run. @@ -12,10 +12,10 @@ enum TestRunState { idle, running, completed, error } /// ViewModel for managing test run state. /// -/// Subscribes to [Iperf3Service.events] so the transcript and the interval +/// Subscribes to [Iperf3Runner.events] so the transcript and the interval /// series both grow while the test runs, rather than appearing at the end. class TestRunViewModel extends ChangeNotifier { - final Iperf3Service iperf3Service; + final Iperf3Runner iperf3Service; /// How often to notify listeners while output is arriving. iperf3 can report /// several times a second across many streams, and each rebuild re-renders diff --git a/test/architecture_test.dart b/test/architecture_test.dart new file mode 100644 index 0000000..20725d8 --- /dev/null +++ b/test/architecture_test.dart @@ -0,0 +1,126 @@ +// Enforces the layering. A convention nobody checks is a convention that +// decays, so these read the real import statements in lib/ and fail on a +// violation rather than trusting review to catch it. +// +// The rule, in one line: dependencies point inward. ui → domain ← data, and +// never the reverse. + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('layering', () { + test('domain imports no Flutter at all', () { + final offenders = _importsMatching( + 'lib/domain', + (import) => import.startsWith('package:flutter/'), + ); + expect( + offenders, + isEmpty, + reason: 'The domain layer must stay pure Dart so it can be reused ' + 'without a Flutter runtime. Offending imports:\n' + '${_render(offenders)}', + ); + }); + + test('domain does not depend on data or ui', () { + final offenders = _importsMatching( + 'lib/domain', + (import) => + import.contains('/data/') || + import.contains('/ui/') || + import.contains('main.dart'), + ); + expect(offenders, isEmpty, + reason: 'The domain layer is the innermost layer; it may depend on ' + 'nothing else in this package.\n${_render(offenders)}'); + }); + + test('data does not depend on ui', () { + final offenders = _importsMatching( + 'lib/data', + (import) => import.contains('/ui/') || import.contains('main.dart'), + ); + expect(offenders, isEmpty, + reason: 'The data layer must not know about presentation.\n' + '${_render(offenders)}'); + }); + + test('data imports no Flutter widget libraries', () { + // flutter/services is allowed: MethodChannel lives there, and that is + // the whole reason this layer is Flutter-aware at all. + const allowed = {'package:flutter/services.dart'}; + final offenders = _importsMatching( + 'lib/data', + (import) => + import.startsWith('package:flutter/') && !allowed.contains(import), + ); + expect( + offenders, + isEmpty, + reason: 'The data layer may import package:flutter/services.dart only. ' + 'Anything else drags presentation into the transport.\n' + '${_render(offenders)}', + ); + }); + + test('ui does not depend on a concrete data implementation', () { + // The UI talks to Iperf3Runner. main.dart is the composition root and is + // the one file allowed to name an implementation. + final offenders = _importsMatching( + 'lib/ui', + (import) => import.contains('/data/'), + ); + expect( + offenders, + isEmpty, + reason: 'Presentation must depend on domain/services/iperf3_runner.dart ' + 'rather than on a transport. Only main.dart may pick an ' + 'implementation.\n${_render(offenders)}', + ); + }); + + test('every layer directory is actually covered by these rules', () { + // Guards against a new top-level layer appearing and silently escaping + // every rule above. + final layers = Directory('lib') + .listSync() + .whereType() + .map((d) => d.path.split(Platform.pathSeparator).last) + .toSet(); + expect( + layers, + equals({'domain', 'data', 'ui'}), + reason: 'A new layer appeared under lib/. Add rules for it here.', + ); + }); + }); +} + +/// Every `(file, import)` pair under [directory] whose import satisfies [test]. +List<({String file, String import})> _importsMatching( + String directory, + bool Function(String import) test, +) { + final results = <({String file, String import})>[]; + final dir = Directory(directory); + if (!dir.existsSync()) return results; + + final pattern = RegExp(r'''^\s*import\s+['"]([^'"]+)['"]''', multiLine: true); + for (final entity in dir.listSync(recursive: true)) { + if (entity is! File || !entity.path.endsWith('.dart')) continue; + final source = entity.readAsStringSync(); + for (final match in pattern.allMatches(source)) { + final import = match.group(1)!; + if (test(import)) { + results.add((file: entity.path, import: import)); + } + } + } + return results; +} + +String _render(List<({String file, String import})> offenders) => + offenders.map((o) => ' ${o.file} → ${o.import}').join('\n'); 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); }); }