Merge branch 'refactor/separate-ui-from-data'
CI / Analyze & Test (push) Failing after 38s

Separate presentation from the data layer: the UI depends on an Iperf3Runner
interface declared in the domain layer, and main.dart is the only file that
names a concrete implementation.

Adds widget tests that drive the real UI against an in-memory runner with no
MethodChannel or native library, and an architecture test that reads the
imports under lib/ and fails the build when a layer boundary is crossed.

Verified on an XQ-AS72 after the change: clean run, iperf_run_client returned
0, server logged 608 Mbits/sec, no ANR or native abort.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 19:56:41 +09:00
10 changed files with 403 additions and 107 deletions
+34 -8
View File
@@ -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
+27 -14
View File
@@ -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<Iperf3Event>.broadcast();
/// Live reports, one event per native report. Use this to render progress.
@override
Stream<Iperf3Event> 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<void> 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<void> 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<Iperf3Result> 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<Iperf3Result>();
_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');
+36
View File
@@ -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<Iperf3Event> 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<Iperf3Result> 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<void> stop();
/// Release resources. The instance is unusable afterwards.
Future<void> dispose();
}
+11 -6
View File
@@ -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<void> 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),
);
}
}
@@ -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(),
);
}
@@ -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
+126
View File
@@ -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<Directory>()
.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');
+62
View File
@@ -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 'package:flutter_test/flutter_test.dart';
import 'package:iperf3gui/data/services/iperf3_service.dart';
import 'package:iperf3gui/domain/models/iperf3_event.dart';
import 'package:iperf3gui/domain/models/iperf3_result.dart';
import 'package:iperf3gui/domain/models/test_configuration.dart';
import 'package:iperf3gui/ui/features/test_run/test_run_view_model.dart';
import '../../../support/fake_iperf3_runner.dart';
void main() {
group('TestRunViewModel', () {
test('starts in idle state', () {
final vm = TestRunViewModel(iperf3Service: _MockIperf3Service());
final vm = TestRunViewModel(iperf3Service: FakeIperf3Runner());
expect(vm.state, equals(TestRunState.idle));
expect(vm.result, isNull);
expect(vm.currentConfig, isNull);
@@ -18,13 +19,13 @@ void main() {
});
test('can be reset', () {
final vm = TestRunViewModel(iperf3Service: _MockIperf3Service());
final vm = TestRunViewModel(iperf3Service: FakeIperf3Runner());
vm.reset();
expect(vm.state, equals(TestRunState.idle));
});
test('invalid config sets error state without running', () async {
final mock = _MockIperf3Service();
final mock = FakeIperf3Runner();
final vm = TestRunViewModel(iperf3Service: mock);
const config = TestConfiguration(server: '');
await vm.runTest(config);
@@ -33,7 +34,7 @@ void main() {
});
test('runTest with valid config calls service', () async {
final mock = _MockIperf3Service(
final mock = FakeIperf3Runner(
onRun: (config) async {
expect(config.server, equals('192.168.1.1'));
return const Iperf3Result(rawOutput: 'done', exitCode: 0);
@@ -47,7 +48,7 @@ void main() {
});
test('non-zero exit code lands in error state', () async {
final mock = _MockIperf3Service(
final mock = FakeIperf3Runner(
onRun: (_) async =>
const Iperf3Result(rawOutput: 'boom', exitCode: 1),
);
@@ -58,7 +59,7 @@ void main() {
});
test('streamed events accumulate into output during the run', () async {
final mock = _MockIperf3Service();
final mock = FakeIperf3Runner();
mock.onRun = (_) async {
mock.emit(const Iperf3Start(targetHost: '192.168.1.1', targetPort: 5201));
mock.emit(const Iperf3Interval(
@@ -82,7 +83,7 @@ void main() {
});
test('intervals are collected as structured samples', () async {
final mock = _MockIperf3Service();
final mock = FakeIperf3Runner();
mock.onRun = (_) async {
mock.emit(const Iperf3Interval(
start: 0, end: 1, bytes: 1000, bitsPerSecond: 8000));
@@ -100,7 +101,7 @@ void main() {
});
test('omitted warm-up intervals are excluded from the series', () async {
final mock = _MockIperf3Service();
final mock = FakeIperf3Runner();
mock.onRun = (_) async {
mock.emit(const Iperf3Interval(
start: 0, end: 1, bytes: 1, bitsPerSecond: 1, omitted: true));
@@ -118,7 +119,7 @@ void main() {
});
test('end event is retained as the summary', () async {
final mock = _MockIperf3Service();
final mock = FakeIperf3Runner();
mock.onRun = (_) async {
mock.emit(const Iperf3End(
sent: Iperf3Total(
@@ -136,7 +137,7 @@ void main() {
});
test('a new run clears the previous interval series', () async {
final mock = _MockIperf3Service();
final mock = FakeIperf3Runner();
mock.onRun = (_) async {
mock.emit(const Iperf3Interval(
start: 0, end: 1, bytes: 1000, bitsPerSecond: 8000));
@@ -153,7 +154,7 @@ void main() {
});
test('falls back to result output when nothing streamed', () async {
final mock = _MockIperf3Service(
final mock = FakeIperf3Runner(
onRun: (_) async =>
const Iperf3Result(rawOutput: 'summary only', exitCode: 0),
);
@@ -163,7 +164,7 @@ void main() {
});
test('output is cleared when a new run starts', () async {
final mock = _MockIperf3Service(
final mock = FakeIperf3Runner(
onRun: (_) async =>
const Iperf3Result(rawOutput: 'first', exitCode: 0),
);
@@ -179,7 +180,7 @@ void main() {
});
test('stopTest forwards to the service only while running', () async {
final mock = _MockIperf3Service();
final mock = FakeIperf3Runner();
final vm = TestRunViewModel(iperf3Service: mock);
await vm.stopTest();
@@ -198,7 +199,7 @@ void main() {
});
test('wasStopped reflects a user-requested stop', () async {
final mock = _MockIperf3Service(
final mock = FakeIperf3Runner(
onRun: (_) async => const Iperf3Result(
rawOutput: 'stopped early',
exitCode: 0,
@@ -213,7 +214,7 @@ void main() {
});
test('wasStopped is false for a run that finished on its own', () async {
final mock = _MockIperf3Service(
final mock = FakeIperf3Runner(
onRun: (_) async =>
const Iperf3Result(rawOutput: 'done', exitCode: 0),
);
@@ -223,7 +224,7 @@ void main() {
});
test('a second runTest while running is ignored', () async {
final mock = _MockIperf3Service();
final mock = FakeIperf3Runner();
final gate = Completer<Iperf3Result>();
mock.onRun = (_) => gate.future;
@@ -237,49 +238,3 @@ void main() {
});
});
}
class _MockIperf3Service implements Iperf3Service {
_MockIperf3Service({this.onRun});
Future<Iperf3Result> Function(TestConfiguration)? onRun;
final StreamController<Iperf3Event> _controller =
StreamController<Iperf3Event>.broadcast();
int runCallCount = 0;
int stopCallCount = 0;
bool disposed = false;
bool _isRunning = false;
/// Push an event as if the native layer had reported it.
void emit(Iperf3Event event) => _controller.add(event);
@override
Stream<Iperf3Event> get events => _controller.stream;
@override
bool get isRunning => _isRunning;
@override
Future<Iperf3Result> run(TestConfiguration config) async {
runCallCount++;
_isRunning = true;
try {
if (onRun != null) return await onRun!(config);
return const Iperf3Result(rawOutput: '', exitCode: 0);
} finally {
_isRunning = false;
}
}
@override
Future<void> stop() async {
stopCallCount++;
}
@override
Future<void> dispose() async {
disposed = true;
await _controller.close();
}
}
+78 -6
View File
@@ -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<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);
});
}