Compare commits

...

14 Commits

Author SHA1 Message Date
mikkeli 7ca74366a3 Merge branch 'fix/release-build-jni-keep-rules'
CI / Analyze & Test (push) Failing after 30s
Release builds aborted on the first callback of every test: R8 renamed the
IperfCallback implementation and its methods, so the JNI GetMethodID lookups
resolved nothing. Adds the keep rules, and makes the native side report a
failed lookup instead of aborting the process on the next JNI call.

Debug builds do not minify, so every on-device check until now passed while the
shipping artifact was broken. Found by running the release APK during release
preparation.

Verified with the release APK on an XQ-AS72: clean run at 806 Mbits/sec,
matching the server's own figure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 22:43:52 +09:00
mikkeli ff13d63333 fix(jni): fail cleanly when a callback method does not resolve
GetMethodID raises NoSuchMethodError when a lookup fails, and any further JNI
call made while an exception is pending aborts the process. runIperfLive
resolved three method IDs back to back without checking, so the first failure
made the second call fatal — turning a diagnosable configuration problem into
a SIGABRT with a stack that pointed at the JNI entry rather than the cause.

resolve_method() clears the pending exception and returns NULL, and the caller
now bails out with a log naming which lookups failed and pointing at the keep
rules. The R8 problem this exposed is fixed separately; this is about the
failure being reportable rather than fatal, whatever the cause.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 22:43:35 +09:00
mikkeli 2b3f20d3a0 fix(android): keep the JNI callback names through R8
Release builds aborted on the first callback of every test:

  NoSuchMethodError: no non-static method "Lp0;.onOutput(Ljava/lang/String;)V"

R8 had renamed MainThreadCallback to p0 along with its methods, so
GetMethodID(env, cls, "onOutput", ...) in iperf_jni.c resolved nothing. The
names are a contract between C and Kotlin that the minifier cannot see.

Adds keep rules for IperfRunner, the IperfCallback interface and — the part
that matters — everything implementing it, since the lookup happens on the
concrete object's class rather than on the interface. A general rule keeps any
class declaring a native method, so a future one is covered without a new rule.

Debug builds do not minify, which is why every on-device check so far passed
while the shipping artifact was broken. Found by running the release APK
during release preparation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 22:43:35 +09:00
mikkeli 894712b7a8 chore: set version to 0.1.0 for the first release
pubspec.yaml still carried flutter create's placeholder 1.0.0+1, which would
have had the APK reporting 1.0.0 while the release was tagged v0.1.0. Matches
the version the tag records.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 22:38:48 +09:00
mikkeli 895b963745 fix: restore speedtest.uztelecom.uz as the default server
Consolidating the UI's seed values onto TestConfiguration's own defaults also
silently changed the default target to iperf.es.net, which was not asked for.
Sets it back in TestConfiguration, which is now the single definition the
screen and the headless entry point both read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 22:38:19 +09:00
mikkeli b76098c7e6 Merge branch 'refactor/core-independent-of-ui'
CI / Analyze & Test (push) Failing after 30s
Make the core work independently of the UI: Iperf3Session accumulates a run in
the domain layer, so the view model holds no run data and the screen derives no
numbers. Replacing the presentation layer now means rewriting rendering, not
re-deriving results.

Adds lib/main_headless.dart, which runs a full test through Iperf3Service and
Iperf3Session with no widget tree, as a standing check on that claim — and as
the accurate way to measure: 30.2% local CPU headless against 79.9% through the
UI on a comparable 4-stream run.

Verified on an XQ-AS72: headless run clean at 859 Mbit/s mean, matching the
server's own 859 Mbits/sec receiver figure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 20:25:24 +09:00
mikkeli 6e35d724dc docs: record that the core runs without the UI, and the CPU cost of the UI
States where run data lives now that Iperf3Session owns it, so the next person
does not put accumulation back in the view model, and documents the headless
entry point as the way to check that claim rather than trust it.

Includes the measurement that motivates using it: 30.2% local CPU headless
versus 79.9% through the UI on a comparable 4-stream run. That gap is worth
recording, because it means throughput read off the app's own screen is
pessimistic at high rates and should not be compared against a headless or
desktop figure without noting which path produced it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 20:25:06 +09:00
mikkeli fc15897541 feat: add a headless entry point that runs a test with no UI
Separation that nothing exercises is separation that quietly stops being true,
so this runs a full test through Iperf3Service and Iperf3Session with
WidgetsFlutterBinding but no runApp: no widget tree, no render surface. If the
run path ever starts needing the UI, this stops working. An architecture test
additionally forbids it importing lib/ui.

It is also the accurate way to measure. A 4-stream run against a local server
reports 30.2% local CPU here, against 79.9% for a comparable run with the
transcript repainting alongside — the UI was costing roughly fifty points of
CPU on a phone already near CPU-bound at gigabit rates. Throughput figures
taken through the UI are therefore pessimistic, and this is the path to use
when the number matters.

Parameters come from --dart-define so a run needs no code edit:
  flutter run -t lib/main_headless.dart --dart-define=IPERF_SERVER=…

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 20:24:44 +09:00
mikkeli 9485049b57 refactor: move run accumulation out of the view model into the domain
The view model was doing two jobs. Alongside presentation state it held what
happened during a run — the interval series, the rule that iperf3's omitted
warm-up intervals do not count, the retained end summary — and the screen
computed mean and peak inline in a widget builder. All of that is the part
that has to be correct, and all of it would have been rewritten by anyone
replacing the UI.

Iperf3Session accumulates a run in the domain layer: samples, totals, errors,
whether it was stopped, and the statistics over them. The view model now feeds
it events and keeps only a formatted transcript and the repaint schedule; the
screen reads every displayed number off the session and derives none.

Two details the session now owns rather than leaving to a caller. Omitted
intervals are excluded, so a warm-up cannot drag the mean down. And
totalRetransmits stays null when no interval reported a count — UDP does not
measure retransmits, and "not measured" must not render as zero.

The aggregation tests move with the logic, into a pure-Dart test with no
widgets or bindings involved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 20:24:44 +09:00
mikkeli 838c883cc0 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>
2026-08-26 19:56:41 +09:00
mikkeli fe042efc29 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 <noreply@anthropic.com>
2026-08-26 19:56:25 +09:00
mikkeli 0cf019379a 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 <noreply@anthropic.com>
2026-08-26 19:56:03 +09:00
mikkeli 95acf5ea38 test: drive the real UI against an in-memory runner
FakeIperf3Runner implements Iperf3Runner, so the widget tests exercise the
actual widget tree — GO, STOP, the status line, the stat row, validation —
with no MethodChannel and no native library. That was impossible while the
screen constructed its own Iperf3Service.

The view model tests share the same fake instead of each file carrying a
double that had to mirror the concrete service.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 19:56:03 +09:00
mikkeli 314a3ea059 refactor: depend on an Iperf3Runner interface instead of the service
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 <noreply@anthropic.com>
2026-08-26 19:56:03 +09:00
18 changed files with 890 additions and 197 deletions
+7
View File
@@ -37,6 +37,13 @@ android {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
// proguard-rules.pro keeps the names JNI resolves at runtime.
// Without it a release build aborts on the first callback.
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
+21
View File
@@ -0,0 +1,21 @@
# JNI reaches these by name. R8 renames by default, so without these rules a
# release build resolves nothing: GetMethodID("onOutput", ...) returns NULL,
# raises NoSuchMethodError, and the next JNI call aborts the process with
# NoSuchMethodError: no non-static method "Lp0;.onOutput(Ljava/lang/String;)V"
# Debug builds do not minify, so this only ever appears in release.
# The plugin object holds the native method declarations and is looked up from
# C by its fully qualified name (Java_com_novoyuuparosk_iperf3gui_IperfRunner_*).
-keep class com.novoyuuparosk.iperf3gui.IperfRunner { *; }
# The callback interface and everything implementing it: iperf_jni.c resolves
# onOutput/onError/onComplete by name on the concrete object's class, so the
# implementors' method names must survive too, not just the interface's.
-keep interface com.novoyuuparosk.iperf3gui.IperfRunner$IperfCallback { *; }
-keep class * implements com.novoyuuparosk.iperf3gui.IperfRunner$IperfCallback { *; }
# Belt and braces for any future native method: keep the name of any class
# declaring one, and the native methods themselves.
-keepclasseswithmembernames,includedescriptorclasses class * {
native <methods>;
}
+30 -3
View File
@@ -86,6 +86,21 @@ struct iperf_run_state {
* iperf_run_client() and cleared after it returns. */
static struct iperf_run_state *g_current_state = NULL;
/* Resolve a method ID, clearing the exception a failed lookup leaves behind.
*
* GetMethodID raises NoSuchMethodError on failure, and any further JNI call
* made while an exception is pending aborts the process. Returning NULL and
* clearing lets the caller fail on its own terms. */
static jmethodID resolve_method(JNIEnv *env, jclass cls,
const char *name, const char *sig) {
jmethodID mid = (*env)->GetMethodID(env, cls, name, sig);
if ((*env)->ExceptionCheck(env)) {
(*env)->ExceptionClear(env);
return NULL;
}
return mid;
}
/* Helper: invoke the output callback via CallVoidMethod */
static void signal_output(JNIEnv *env, struct iperf_run_state *state,
const char *msg) {
@@ -272,13 +287,25 @@ Java_com_novoyuuparosk_iperf3gui_IperfRunner_runIperfLive(
/* Resolve callback method IDs up front so failures can be reported. */
jclass callback_cls = (*env)->GetObjectClass(env, callback);
jmethodID mid_output = (*env)->GetMethodID(
jmethodID mid_output = resolve_method(
env, callback_cls, "onOutput", "(Ljava/lang/String;)V");
jmethodID mid_error = (*env)->GetMethodID(
jmethodID mid_error = resolve_method(
env, callback_cls, "onError", "(Ljava/lang/String;)V");
jmethodID mid_complete = (*env)->GetMethodID(
jmethodID mid_complete = resolve_method(
env, callback_cls, "onComplete", "()V");
/* If the names did not resolve there is no way to report anything: the
* reporting path is the thing that is missing. Bail out loudly instead.
*
* In practice this means R8 renamed the callback in a release build and
* android/app/proguard-rules.pro has stopped covering it. */
if (!mid_output || !mid_error || !mid_complete) {
LOGE("Callback methods did not resolve (onOutput=%p onError=%p "
"onComplete=%p) — check the -keep rules for IperfCallback",
mid_output, mid_error, mid_complete);
return;
}
/* Report a startup failure directly on the callback object and finish,
* so Dart's completer always resolves instead of waiting for a timeout. */
#define FAIL_EARLY(msg) do { \
+57 -8
View File
@@ -6,19 +6,25 @@ 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
├── main_headless.dart # Runs a test with no widgets (see Layering)
├── 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
│ │ └── iperf3_session.dart # One run accumulated: samples, totals, stats
│ └── 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 +54,49 @@ 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.
**The core does not need the UI.** What happened during a run is accumulated by
`Iperf3Session` in the domain layer — samples, totals, errors, and the mean,
peak and retransmit figures over them. The view model holds no run data of its
own and the screen derives no numbers; both read the session. Replacing the
presentation layer therefore means rewriting rendering, not re-deriving results.
`lib/main_headless.dart` is the standing proof: it runs a full test through
`Iperf3Service` and `Iperf3Session` with `WidgetsFlutterBinding` but no
`runApp` — no widget tree, no render surface. If the run path ever starts
needing the UI, it stops working.
```bash
flutter run -t lib/main_headless.dart \
--dart-define=IPERF_SERVER=192.168.2.182 --dart-define=IPERF_STREAMS=4
```
Use it when a figure has to be trustworthy. A 4-stream local run reports
**30.2% local CPU headless against 79.9% through the UI** — the phone is near
CPU-bound at gigabit rates, so a repainting transcript measurably depresses
throughput. Numbers taken through the UI are pessimistic.
`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');
+114
View File
@@ -0,0 +1,114 @@
import 'package:iperf3gui/domain/models/iperf3_event.dart';
/// The accumulated state of one iperf3 run.
///
/// Fed the event stream one report at a time, this is the answer to "what
/// happened during this test" — the interval series, the end totals, any
/// errors, and the statistics derived from them.
///
/// It lives in the domain layer on purpose: it is what has to be *correct*,
/// independent of how anything chooses to draw it. A different UI, a headless
/// run or a future export path all consume the same object, and none of them
/// re-derive a mean or re-decide whether a warm-up interval counts.
class Iperf3Session {
Iperf3Start? _start;
/// Connection details, once the run has reported them.
Iperf3Start? get start => _start;
final List<Iperf3Interval> _intervals = [];
/// Throughput samples in arrival order, warm-up intervals excluded.
List<Iperf3Interval> get intervals => List.unmodifiable(_intervals);
Iperf3End? _summary;
/// Sent/received totals, once the run has finished.
Iperf3End? get summary => _summary;
final List<String> _errors = [];
/// Errors reported during the run, in order.
List<String> get errors => List.unmodifiable(_errors);
bool _stopped = false;
/// Whether the run ended because it was asked to stop.
bool get stopped => _stopped;
/// Whether iperf3 reported any error.
bool get hasError => _errors.isNotEmpty;
/// Whether anything at all has been recorded.
bool get isEmpty =>
_start == null && _intervals.isEmpty && _summary == null && !hasError;
/// How many throughput samples have been recorded.
int get sampleCount => _intervals.length;
/// Throughput of the most recent sample, or null before the first one.
double? get latestBitsPerSecond =>
_intervals.isEmpty ? null : _intervals.last.bitsPerSecond;
/// Mean throughput across the samples, or null before the first one.
///
/// This is the mean of the per-interval rates, which is not identical to the
/// run's overall rate when intervals differ in length — prefer
/// [summary]'s totals for the headline figure once it is available.
double? get meanBitsPerSecond {
if (_intervals.isEmpty) return null;
final total = _intervals.fold<double>(0, (sum, i) => sum + i.bitsPerSecond);
return total / _intervals.length;
}
/// Highest sampled throughput, or null before the first sample.
double? get peakBitsPerSecond {
if (_intervals.isEmpty) return null;
return _intervals
.map((i) => i.bitsPerSecond)
.reduce((a, b) => a > b ? a : b);
}
/// Total bytes across the recorded samples.
int get sampledBytes => _intervals.fold<int>(0, (sum, i) => sum + i.bytes);
/// Total retransmits across the samples, or null if none were reported.
///
/// UDP tests report no retransmit counts, so null means "not measured"
/// rather than zero.
int? get totalRetransmits {
final reported =
_intervals.map((i) => i.retransmits).whereType<int>().toList();
if (reported.isEmpty) return null;
return reported.fold<int>(0, (sum, r) => sum + r);
}
/// Record one report.
void add(Iperf3Event event) {
switch (event) {
case Iperf3Start e:
_start = e;
case Iperf3Interval e:
// Warm-up intervals (-O) are marked omitted by iperf3 and must not
// drag the statistics down.
if (!e.omitted) _intervals.add(e);
case Iperf3End e:
_summary = e;
case Iperf3Error e:
_errors.add(e.message);
case Iperf3Stopped _:
_stopped = true;
case Iperf3LogLine _:
break;
}
}
/// Forget everything, ready for another run.
void clear() {
_start = null;
_intervals.clear();
_summary = null;
_errors.clear();
_stopped = false;
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ class TestConfiguration {
final int parallelStreams;
const TestConfiguration({
this.server = 'iperf.es.net',
this.server = 'speedtest.uztelecom.uz',
this.port = 5201,
this.duration = const Duration(seconds: 10),
this.interval = 1,
+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),
);
}
}
+105
View File
@@ -0,0 +1,105 @@
// Headless entry point: runs one iperf3 test with no widgets.
//
// This exists to keep the core honest. If anything in the run path ever starts
// depending on the UI, this stops compiling or stops working — a claim about
// separation that is checked rather than asserted.
//
// It is also the low-noise way to measure. The UI competes with the test for
// CPU (a gigabit run can put the phone near CPU-bound), so figures taken here
// are cleaner than figures taken with a transcript repainting alongside.
//
// flutter run -t lib/main_headless.dart \
// --dart-define=IPERF_SERVER=192.168.2.182 \
// --dart-define=IPERF_STREAMS=4
//
// Nothing below imports lib/ui, and test/architecture_test.dart enforces that.
import 'package:flutter/widgets.dart' show WidgetsFlutterBinding;
import 'package:flutter/foundation.dart' show debugPrint;
import 'package:iperf3gui/data/services/iperf3_service.dart';
import 'package:iperf3gui/domain/models/iperf3_event.dart';
import 'package:iperf3gui/domain/models/iperf3_session.dart';
import 'package:iperf3gui/domain/models/test_configuration.dart';
import 'package:iperf3gui/domain/services/iperf3_runner.dart';
/// Test parameters, supplied at build time with --dart-define.
const String _server = String.fromEnvironment('IPERF_SERVER',
defaultValue: 'speedtest.uztelecom.uz');
const int _port = int.fromEnvironment('IPERF_PORT', defaultValue: 5201);
const int _seconds = int.fromEnvironment('IPERF_SECONDS', defaultValue: 10);
const int _streams = int.fromEnvironment('IPERF_STREAMS', defaultValue: 1);
Future<void> main() async {
// Required for the MethodChannel. Notably NOT runApp() — there is no widget
// tree, no render surface and no Flutter UI thread work in this process.
WidgetsFlutterBinding.ensureInitialized();
const config = TestConfiguration(
server: _server,
port: _port,
duration: Duration(seconds: _seconds),
parallelStreams: _streams,
);
final Iperf3Runner runner = Iperf3Service();
final session = Iperf3Session();
final subscription = runner.events.listen((event) {
session.add(event);
if (event is Iperf3Interval && !event.omitted) {
_out('interval ${event.start.toStringAsFixed(2)}-'
'${event.end.toStringAsFixed(2)}s '
'${_mbits(event.bitsPerSecond)} Mbit/s'
'${event.retransmits != null ? ' retr=${event.retransmits}' : ''}');
} else if (event is Iperf3Error) {
_out('error: ${event.message}');
}
});
_out('running iperf3 -c ${config.server} -p ${config.port} '
'-t ${config.duration.inSeconds} -P ${config.parallelStreams}');
final result = await runner.run(config);
await subscription.cancel();
await runner.dispose();
_report(result.exitCode, session);
}
void _report(int exitCode, Iperf3Session session) {
_out('--- result ---');
_out('exit code: $exitCode');
_out('samples: ${session.sampleCount}');
final mean = session.meanBitsPerSecond;
final peak = session.peakBitsPerSecond;
if (mean != null) _out('mean: ${_mbits(mean)} Mbit/s');
if (peak != null) _out('peak: ${_mbits(peak)} Mbit/s');
final retr = session.totalRetransmits;
_out('retransmits: ${retr ?? 'not measured'}');
final sent = session.summary?.sent;
final received = session.summary?.received;
if (sent != null) {
_out('sent: ${_mbits(sent.bitsPerSecond)} Mbit/s '
'over ${sent.seconds.toStringAsFixed(2)}s');
}
if (received != null) {
_out('received: ${_mbits(received.bitsPerSecond)} Mbit/s '
'over ${received.seconds.toStringAsFixed(2)}s');
}
final cpu = session.summary?.hostCpuPercent;
if (cpu != null) _out('local cpu: ${cpu.toStringAsFixed(1)}%');
if (session.stopped) _out('ended early: stopped');
for (final error in session.errors) {
_out('error: $error');
}
}
String _mbits(double bitsPerSecond) =>
(bitsPerSecond / 1e6).toStringAsFixed(1);
void _out(String line) => debugPrint('[iperf3-headless] $line');
+23 -20
View File
@@ -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(),
);
}
@@ -109,7 +110,7 @@ class _TestRunContentState extends State<_TestRunContent> {
autocorrect: false,
decoration: const InputDecoration(
labelText: 'Server',
hintText: 'e.g. iperf.es.net or 192.168.1.1',
hintText: 'e.g. speedtest.uztelecom.uz or 192.168.1.1',
),
),
const SizedBox(height: 12),
@@ -194,29 +195,31 @@ class _TestRunContentState extends State<_TestRunContent> {
),
const SizedBox(height: 12),
// Live throughput figures, derived from the parsed interval series
// Live throughput figures. Every number here is read off the
// session; none are computed in the widget tree.
Consumer<TestRunViewModel>(
builder: (context, vm, _) {
final intervals = vm.intervals;
if (intervals.isEmpty) return const SizedBox.shrink();
final rates =
intervals.map((i) => i.bitsPerSecond).toList(growable: false);
final latest = rates.last;
final peak = rates.reduce((a, b) => a > b ? a : b);
final mean = rates.reduce((a, b) => a + b) / rates.length;
final summary = vm.summary?.sent;
final session = vm.session;
final latest = session.latestBitsPerSecond;
final mean = session.meanBitsPerSecond;
final peak = session.peakBitsPerSecond;
if (latest == null || mean == null || peak == null) {
return const SizedBox.shrink();
}
final total = session.summary?.sent;
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_Stat(
label: summary != null ? 'total' : 'now',
value: formatBitrate(summary?.bitsPerSecond ?? latest),
label: total != null ? 'total' : 'now',
value: formatBitrate(total?.bitsPerSecond ?? latest),
),
_Stat(label: 'avg', value: formatBitrate(mean)),
_Stat(label: 'peak', value: formatBitrate(peak)),
_Stat(label: 'samples', value: '${rates.length}'),
_Stat(
label: 'samples', value: '${session.sampleCount}'),
],
),
);
@@ -1,10 +1,11 @@
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/iperf3_session.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 +13,12 @@ enum TestRunState { idle, running, completed, error }
/// ViewModel for managing test run state.
///
/// Subscribes to [Iperf3Service.events] so the transcript and the interval
/// series both grow while the test runs, rather than appearing at the end.
/// Presentation only. What happened during a run is accumulated by
/// [Iperf3Session] in the domain layer; this class subscribes to
/// [Iperf3Runner.events], feeds that session, keeps a formatted transcript for
/// display, and decides when to repaint.
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
@@ -41,24 +44,14 @@ class TestRunViewModel extends ChangeNotifier {
final StringBuffer _transcript = StringBuffer();
/// Human-readable output accumulated so far.
/// Human-readable output accumulated so far. Display only — the structured
/// record of the run is [session].
String get output => _transcript.toString();
final List<Iperf3Interval> _intervals = [];
final Iperf3Session _session = Iperf3Session();
/// Per-interval throughput samples, in arrival order. Structured, so a chart
/// can be driven from this without re-parsing text.
List<Iperf3Interval> get intervals => List.unmodifiable(_intervals);
Iperf3Start? _start;
/// Connection details reported at the start of the run.
Iperf3Start? get start => _start;
Iperf3End? _summary;
/// Totals reported at the end of the run.
Iperf3End? get summary => _summary;
/// The accumulated run: interval series, totals, errors and their statistics.
Iperf3Session get session => _session;
/// Whether a test is currently in flight.
bool get isRunning => _state == TestRunState.running;
@@ -118,26 +111,12 @@ class TestRunViewModel extends ChangeNotifier {
void _resetRunData() {
_result = null;
_transcript.clear();
_intervals.clear();
_start = null;
_summary = null;
_session.clear();
_dirty = false;
}
void _onEvent(Iperf3Event event) {
switch (event) {
case Iperf3Start e:
_start = e;
case Iperf3Interval e:
// Omitted intervals are warm-up (-O) and are not part of the series.
if (!e.omitted) _intervals.add(e);
case Iperf3End e:
_summary = e;
case Iperf3Error _:
case Iperf3Stopped _:
case Iperf3LogLine _:
break;
}
_session.add(event);
final line = formatIperf3Event(event);
if (line != null && line.isNotEmpty) {
+1 -1
View File
@@ -2,7 +2,7 @@ name: iperf3gui
description: "A Flutter-based Android GUI wrapper for iperf3."
publish_to: 'none'
version: 1.0.0+1
version: 0.1.0+1
environment:
sdk: ^3.13.1
+144
View File
@@ -0,0 +1,144 @@
// 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('the headless entry point does not depend on ui', () {
// main_headless.dart is the standing proof that the core runs without a
// widget tree; an import from lib/ui would quietly end that.
final offenders = _importsMatching(
'lib',
(import) => import.contains('/ui/'),
onlyFiles: {'lib/main_headless.dart'},
);
expect(
offenders,
isEmpty,
reason: 'The headless entry point must run the core with no UI.\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, {
Set<String>? onlyFiles,
}) {
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;
if (onlyFiles != null && !onlyFiles.contains(entity.path)) 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');
+135
View File
@@ -0,0 +1,135 @@
// Pure-Dart tests for the run aggregate. No widgets, no bindings, no channel —
// this is the part that has to be correct regardless of what draws it.
import 'package:flutter_test/flutter_test.dart';
import 'package:iperf3gui/domain/models/iperf3_event.dart';
import 'package:iperf3gui/domain/models/iperf3_session.dart';
void main() {
Iperf3Interval sample(double start, double bits,
{int bytes = 1000, int? retransmits, bool omitted = false}) =>
Iperf3Interval(
start: start,
end: start + 1,
bytes: bytes,
bitsPerSecond: bits,
retransmits: retransmits,
omitted: omitted,
);
group('Iperf3Session', () {
test('starts empty', () {
final session = Iperf3Session();
expect(session.isEmpty, isTrue);
expect(session.sampleCount, isZero);
expect(session.intervals, isEmpty);
expect(session.start, isNull);
expect(session.summary, isNull);
expect(session.errors, isEmpty);
expect(session.stopped, isFalse);
expect(session.hasError, isFalse);
});
test('statistics are null before any sample', () {
final session = Iperf3Session();
expect(session.latestBitsPerSecond, isNull);
expect(session.meanBitsPerSecond, isNull);
expect(session.peakBitsPerSecond, isNull);
expect(session.totalRetransmits, isNull);
});
test('records start, intervals and summary', () {
final session = Iperf3Session()
..add(const Iperf3Start(targetHost: 'host', targetPort: 5201))
..add(sample(0, 100))
..add(const Iperf3End(
sent: Iperf3Total(seconds: 1, bytes: 10, bitsPerSecond: 80),
));
expect(session.isEmpty, isFalse);
expect(session.start!.targetHost, equals('host'));
expect(session.sampleCount, equals(1));
expect(session.summary!.sent!.bitsPerSecond, equals(80));
});
test('excludes omitted warm-up intervals from the series', () {
final session = Iperf3Session()
..add(sample(0, 10, omitted: true))
..add(sample(1, 100))
..add(sample(2, 200));
expect(session.sampleCount, equals(2));
expect(session.meanBitsPerSecond, equals(150),
reason: 'the omitted 10 would have pulled the mean to ~103');
});
test('computes mean, peak and latest over the samples', () {
final session = Iperf3Session()
..add(sample(0, 100))
..add(sample(1, 400))
..add(sample(2, 250));
expect(session.latestBitsPerSecond, equals(250));
expect(session.peakBitsPerSecond, equals(400));
expect(session.meanBitsPerSecond, equals(250));
});
test('sums sampled bytes', () {
final session = Iperf3Session()
..add(sample(0, 1, bytes: 1500))
..add(sample(1, 1, bytes: 2500));
expect(session.sampledBytes, equals(4000));
});
test('sums retransmits when reported', () {
final session = Iperf3Session()
..add(sample(0, 1, retransmits: 2))
..add(sample(1, 1, retransmits: 5));
expect(session.totalRetransmits, equals(7));
});
test('retransmits stay null when never reported, not zero', () {
// UDP reports no retransmit counts; "not measured" must not read as 0.
final session = Iperf3Session()
..add(sample(0, 1))
..add(sample(1, 1));
expect(session.totalRetransmits, isNull);
});
test('collects errors in order and flags them', () {
final session = Iperf3Session()
..add(const Iperf3Error('first'))
..add(const Iperf3Error('second'));
expect(session.hasError, isTrue);
expect(session.errors, equals(['first', 'second']));
expect(session.isEmpty, isFalse);
});
test('records a stop', () {
final session = Iperf3Session()..add(const Iperf3Stopped());
expect(session.stopped, isTrue);
});
test('ignores log lines', () {
final session = Iperf3Session()..add(const Iperf3LogLine('noise'));
expect(session.isEmpty, isTrue);
});
test('clear returns it to the empty state', () {
final session = Iperf3Session()
..add(const Iperf3Start(targetHost: 'host'))
..add(sample(0, 100))
..add(const Iperf3Error('boom'))
..add(const Iperf3Stopped())
..add(const Iperf3End())
..clear();
expect(session.isEmpty, isTrue);
expect(session.stopped, isFalse);
expect(session.hasError, isFalse);
expect(session.peakBitsPerSecond, isNull);
});
test('exposed collections are unmodifiable', () {
final session = Iperf3Session()..add(sample(0, 1));
expect(() => session.intervals.add(sample(1, 1)), throwsUnsupportedError);
expect(() => session.errors.add('x'), throwsUnsupportedError);
});
});
}
+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(
@@ -81,48 +82,13 @@ void main() {
expect(vm.output, isNot(contains('ignored')));
});
test('intervals are collected as structured samples', () async {
final mock = _MockIperf3Service();
test('events are fed into the session', () async {
final mock = FakeIperf3Runner();
mock.onRun = (_) async {
mock.emit(const Iperf3Interval(
start: 0, end: 1, bytes: 1000, bitsPerSecond: 8000));
mock.emit(const Iperf3Interval(
start: 1, end: 2, bytes: 2000, bitsPerSecond: 16000));
await Future<void>.delayed(Duration.zero);
return const Iperf3Result(rawOutput: '', exitCode: 0);
};
final vm = TestRunViewModel(iperf3Service: mock);
await vm.runTest(const TestConfiguration(server: '192.168.1.1'));
expect(vm.intervals, hasLength(2));
expect(vm.intervals[1].bitsPerSecond, equals(16000));
});
test('omitted warm-up intervals are excluded from the series', () async {
final mock = _MockIperf3Service();
mock.onRun = (_) async {
mock.emit(const Iperf3Interval(
start: 0, end: 1, bytes: 1, bitsPerSecond: 1, omitted: true));
mock.emit(const Iperf3Interval(
start: 1, end: 2, bytes: 2000, bitsPerSecond: 16000));
await Future<void>.delayed(Duration.zero);
return const Iperf3Result(rawOutput: '', exitCode: 0);
};
final vm = TestRunViewModel(iperf3Service: mock);
await vm.runTest(const TestConfiguration(server: '192.168.1.1'));
expect(vm.intervals, hasLength(1));
expect(vm.intervals.single.bitsPerSecond, equals(16000));
});
test('end event is retained as the summary', () async {
final mock = _MockIperf3Service();
mock.onRun = (_) async {
mock.emit(const Iperf3End(
sent: Iperf3Total(
seconds: 10, bytes: 987654321, bitsPerSecond: 790000000),
sent: Iperf3Total(seconds: 1, bytes: 1000, bitsPerSecond: 8000),
));
await Future<void>.delayed(Duration.zero);
return const Iperf3Result(rawOutput: '', exitCode: 0);
@@ -131,12 +97,12 @@ void main() {
final vm = TestRunViewModel(iperf3Service: mock);
await vm.runTest(const TestConfiguration(server: '192.168.1.1'));
expect(vm.summary?.sent?.bitsPerSecond, equals(790000000));
expect(vm.output, contains('790 Mbits/sec'));
expect(vm.session.sampleCount, equals(1));
expect(vm.session.summary, isNotNull);
});
test('a new run clears the previous interval series', () async {
final mock = _MockIperf3Service();
test('a new run clears the previous session', () async {
final mock = FakeIperf3Runner();
mock.onRun = (_) async {
mock.emit(const Iperf3Interval(
start: 0, end: 1, bytes: 1000, bitsPerSecond: 8000));
@@ -146,14 +112,15 @@ void main() {
final vm = TestRunViewModel(iperf3Service: mock);
await vm.runTest(const TestConfiguration(server: '192.168.1.1'));
expect(vm.intervals, hasLength(1));
expect(vm.session.sampleCount, equals(1));
await vm.runTest(const TestConfiguration(server: '192.168.1.1'));
expect(vm.intervals, hasLength(1), reason: 'not appended to the old run');
expect(vm.session.sampleCount, equals(1),
reason: 'not appended to the old run');
});
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 +130,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 +146,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 +165,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 +180,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 +190,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 +204,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);
});
}