From 314a3ea059fc746dace73583060c53748e6afa33 Mon Sep 17 00:00:00 2001 From: Mikkeli Date: Wed, 26 Aug 2026 19:56:03 +0900 Subject: [PATCH 1/4] refactor: depend on an Iperf3Runner interface instead of the service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The presentation layer imported Iperf3Service directly, so the UI was welded to the MethodChannel transport: the view model named it, and the screen constructed one. Dependency direction was already inward-only, but the UI depended on an implementation rather than a contract. Iperf3Runner states that contract in the domain layer in five members, in pure Dart with no Flutter import, and Iperf3Service implements it. The view model and screen now take an Iperf3Runner; main.dart is the only file that names a concrete one, which makes it the composition root. Two consequences worth having. The UI can be driven against an in-memory runner with no MethodChannel, native library or device involved. And a test double implements five stable members rather than tracking every public member the transport happens to expose — the previous double, forced to implement the concrete class, broke twice while this branch's work was going on. Also drops flutter/foundation from the data layer: debugPrint gives way to dart:developer, leaving flutter/services (where MethodChannel lives) as that layer's only Flutter dependency. Co-Authored-By: Claude Opus 5 --- lib/data/services/iperf3_service.dart | 41 ++++++++++++------- lib/domain/services/iperf3_runner.dart | 36 ++++++++++++++++ lib/main.dart | 17 +++++--- lib/ui/features/test_run/test_run_screen.dart | 15 +++---- .../test_run/test_run_view_model.dart | 6 +-- 5 files changed, 85 insertions(+), 30 deletions(-) create mode 100644 lib/domain/services/iperf3_runner.dart 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 From 95acf5ea38c41496716fbbd711208da8b7dd7806 Mon Sep 17 00:00:00 2001 From: Mikkeli Date: Wed, 26 Aug 2026 19:56:03 +0900 Subject: [PATCH 2/4] test: drive the real UI against an in-memory runner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- test/support/fake_iperf3_runner.dart | 62 ++++++++++++++ .../test_run/test_run_view_model_test.dart | 81 ++++-------------- test/widget_test.dart | 84 +++++++++++++++++-- 3 files changed, 158 insertions(+), 69 deletions(-) create mode 100644 test/support/fake_iperf3_runner.dart 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); }); } From 0cf019379a13ee56a8f4bf1b96f7a0c76a06ab57 Mon Sep 17 00:00:00 2001 From: Mikkeli Date: Wed, 26 Aug 2026 19:56:03 +0900 Subject: [PATCH 3/4] test: fail the build when a layer boundary is crossed The layering only holds if something checks it, so these tests read the real import statements under lib/ and assert the direction: domain imports no Flutter and nothing else in the package, data never reaches into ui and may import flutter/services alone, and ui never names a concrete transport. A final test asserts the set of directories under lib/ is exactly the three layers, so a new one cannot appear and silently escape every rule above. Each violation reports the offending file and import with the reason, since a guard that only says "expected empty" costs more than it saves. Verified by introducing a UI-to-data import and a Flutter import in domain and confirming both fail. Co-Authored-By: Claude Opus 5 --- test/architecture_test.dart | 126 ++++++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 test/architecture_test.dart 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'); From fe042efc29d2d3e93eae65411069ecbf59f991ed Mon Sep 17 00:00:00 2001 From: Mikkeli Date: Wed, 26 Aug 2026 19:56:25 +0900 Subject: [PATCH 4/4] docs: record the layering rule and where it is enforced The architecture section listed files but said nothing about which layer may import what, so the direction of dependency was only visible by reading the imports. States the rule as a table, notes that the UI depends on the Iperf3Runner contract rather than on a transport and what that buys, and points at the test that enforces it so the rule is not just prose. Also brings the tree up to date: it was missing the domain services directory, the event model and the formatter. Co-Authored-By: Claude Opus 5 --- docs/README.md | 42 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 8 deletions(-) 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