Compare commits
9 Commits
838c883cc0
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ca74366a3 | |||
| ff13d63333 | |||
| 2b3f20d3a0 | |||
| 894712b7a8 | |||
| 895b963745 | |||
| b76098c7e6 | |||
| 6e35d724dc | |||
| fc15897541 | |||
| 9485049b57 |
@@ -37,6 +37,13 @@ android {
|
|||||||
// TODO: Add your own signing config for the release build.
|
// TODO: Add your own signing config for the release build.
|
||||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
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",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
+21
@@ -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>;
|
||||||
|
}
|
||||||
@@ -86,6 +86,21 @@ struct iperf_run_state {
|
|||||||
* iperf_run_client() and cleared after it returns. */
|
* iperf_run_client() and cleared after it returns. */
|
||||||
static struct iperf_run_state *g_current_state = NULL;
|
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 */
|
/* Helper: invoke the output callback via CallVoidMethod */
|
||||||
static void signal_output(JNIEnv *env, struct iperf_run_state *state,
|
static void signal_output(JNIEnv *env, struct iperf_run_state *state,
|
||||||
const char *msg) {
|
const char *msg) {
|
||||||
@@ -272,13 +287,25 @@ Java_com_novoyuuparosk_iperf3gui_IperfRunner_runIperfLive(
|
|||||||
|
|
||||||
/* Resolve callback method IDs up front so failures can be reported. */
|
/* Resolve callback method IDs up front so failures can be reported. */
|
||||||
jclass callback_cls = (*env)->GetObjectClass(env, callback);
|
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");
|
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");
|
env, callback_cls, "onError", "(Ljava/lang/String;)V");
|
||||||
jmethodID mid_complete = (*env)->GetMethodID(
|
jmethodID mid_complete = resolve_method(
|
||||||
env, callback_cls, "onComplete", "()V");
|
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,
|
/* Report a startup failure directly on the callback object and finish,
|
||||||
* so Dart's completer always resolves instead of waiting for a timeout. */
|
* so Dart's completer always resolves instead of waiting for a timeout. */
|
||||||
#define FAIL_EARLY(msg) do { \
|
#define FAIL_EARLY(msg) do { \
|
||||||
|
|||||||
+24
-1
@@ -7,11 +7,13 @@ A Flutter-based Android GUI wrapper for iperf3, the popular network performance
|
|||||||
```
|
```
|
||||||
lib/ # Flutter/Dart application layer
|
lib/ # Flutter/Dart application layer
|
||||||
├── main.dart # Composition root: picks the Iperf3Runner impl
|
├── 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
|
├── domain/ # Pure Dart — no Flutter import
|
||||||
│ ├── models/
|
│ ├── models/
|
||||||
│ │ ├── test_configuration.dart # Server, port, duration, interval, parallel streams
|
│ │ ├── test_configuration.dart # Server, port, duration, interval, parallel streams
|
||||||
│ │ ├── iperf3_result.dart # Raw output, exit code, stopped flag
|
│ │ ├── iperf3_result.dart # Raw output, exit code, stopped flag
|
||||||
│ │ └── iperf3_event.dart # Parsed --json-stream reports
|
│ │ ├── iperf3_event.dart # Parsed --json-stream reports
|
||||||
|
│ │ └── iperf3_session.dart # One run accumulated: samples, totals, stats
|
||||||
│ └── services/
|
│ └── services/
|
||||||
│ └── iperf3_runner.dart # The contract the UI depends on
|
│ └── iperf3_runner.dart # The contract the UI depends on
|
||||||
├── data/ # Transport — flutter/services only
|
├── data/ # Transport — flutter/services only
|
||||||
@@ -69,6 +71,27 @@ 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
|
device — and what keeps a test double at five stable members instead of
|
||||||
mirroring the transport's whole surface.
|
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
|
`test/architecture_test.dart` enforces all of this by reading the actual
|
||||||
imports under `lib/`, including a check that no fourth layer directory has
|
imports under `lib/`, including a check that no fourth layer directory has
|
||||||
appeared unnoticed. Change the layering there when the layering should change;
|
appeared unnoticed. Change the layering there when the layering should change;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ class TestConfiguration {
|
|||||||
final int parallelStreams;
|
final int parallelStreams;
|
||||||
|
|
||||||
const TestConfiguration({
|
const TestConfiguration({
|
||||||
this.server = 'iperf.es.net',
|
this.server = 'speedtest.uztelecom.uz',
|
||||||
this.port = 5201,
|
this.port = 5201,
|
||||||
this.duration = const Duration(seconds: 10),
|
this.duration = const Duration(seconds: 10),
|
||||||
this.interval = 1,
|
this.interval = 1,
|
||||||
|
|||||||
@@ -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');
|
||||||
@@ -110,7 +110,7 @@ class _TestRunContentState extends State<_TestRunContent> {
|
|||||||
autocorrect: false,
|
autocorrect: false,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Server',
|
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),
|
const SizedBox(height: 12),
|
||||||
@@ -195,29 +195,31 @@ class _TestRunContentState extends State<_TestRunContent> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
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>(
|
Consumer<TestRunViewModel>(
|
||||||
builder: (context, vm, _) {
|
builder: (context, vm, _) {
|
||||||
final intervals = vm.intervals;
|
final session = vm.session;
|
||||||
if (intervals.isEmpty) return const SizedBox.shrink();
|
final latest = session.latestBitsPerSecond;
|
||||||
final rates =
|
final mean = session.meanBitsPerSecond;
|
||||||
intervals.map((i) => i.bitsPerSecond).toList(growable: false);
|
final peak = session.peakBitsPerSecond;
|
||||||
final latest = rates.last;
|
if (latest == null || mean == null || peak == null) {
|
||||||
final peak = rates.reduce((a, b) => a > b ? a : b);
|
return const SizedBox.shrink();
|
||||||
final mean = rates.reduce((a, b) => a + b) / rates.length;
|
}
|
||||||
final summary = vm.summary?.sent;
|
final total = session.summary?.sent;
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 12),
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
_Stat(
|
_Stat(
|
||||||
label: summary != null ? 'total' : 'now',
|
label: total != null ? 'total' : 'now',
|
||||||
value: formatBitrate(summary?.bitsPerSecond ?? latest),
|
value: formatBitrate(total?.bitsPerSecond ?? latest),
|
||||||
),
|
),
|
||||||
_Stat(label: 'avg', value: formatBitrate(mean)),
|
_Stat(label: 'avg', value: formatBitrate(mean)),
|
||||||
_Stat(label: 'peak', value: formatBitrate(peak)),
|
_Stat(label: 'peak', value: formatBitrate(peak)),
|
||||||
_Stat(label: 'samples', value: '${rates.length}'),
|
_Stat(
|
||||||
|
label: 'samples', value: '${session.sampleCount}'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'dart:async';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:iperf3gui/domain/models/iperf3_event.dart';
|
import 'package:iperf3gui/domain/models/iperf3_event.dart';
|
||||||
import 'package:iperf3gui/domain/models/iperf3_result.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/models/test_configuration.dart';
|
||||||
import 'package:iperf3gui/domain/services/iperf3_runner.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/iperf3_event_formatter.dart';
|
||||||
@@ -12,8 +13,10 @@ enum TestRunState { idle, running, completed, error }
|
|||||||
|
|
||||||
/// ViewModel for managing test run state.
|
/// ViewModel for managing test run state.
|
||||||
///
|
///
|
||||||
/// Subscribes to [Iperf3Runner.events] so the transcript and the interval
|
/// Presentation only. What happened during a run is accumulated by
|
||||||
/// series both grow while the test runs, rather than appearing at the end.
|
/// [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 {
|
class TestRunViewModel extends ChangeNotifier {
|
||||||
final Iperf3Runner iperf3Service;
|
final Iperf3Runner iperf3Service;
|
||||||
|
|
||||||
@@ -41,24 +44,14 @@ class TestRunViewModel extends ChangeNotifier {
|
|||||||
|
|
||||||
final StringBuffer _transcript = StringBuffer();
|
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();
|
String get output => _transcript.toString();
|
||||||
|
|
||||||
final List<Iperf3Interval> _intervals = [];
|
final Iperf3Session _session = Iperf3Session();
|
||||||
|
|
||||||
/// Per-interval throughput samples, in arrival order. Structured, so a chart
|
/// The accumulated run: interval series, totals, errors and their statistics.
|
||||||
/// can be driven from this without re-parsing text.
|
Iperf3Session get session => _session;
|
||||||
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;
|
|
||||||
|
|
||||||
/// Whether a test is currently in flight.
|
/// Whether a test is currently in flight.
|
||||||
bool get isRunning => _state == TestRunState.running;
|
bool get isRunning => _state == TestRunState.running;
|
||||||
@@ -118,26 +111,12 @@ class TestRunViewModel extends ChangeNotifier {
|
|||||||
void _resetRunData() {
|
void _resetRunData() {
|
||||||
_result = null;
|
_result = null;
|
||||||
_transcript.clear();
|
_transcript.clear();
|
||||||
_intervals.clear();
|
_session.clear();
|
||||||
_start = null;
|
|
||||||
_summary = null;
|
|
||||||
_dirty = false;
|
_dirty = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onEvent(Iperf3Event event) {
|
void _onEvent(Iperf3Event event) {
|
||||||
switch (event) {
|
_session.add(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;
|
|
||||||
}
|
|
||||||
|
|
||||||
final line = formatIperf3Event(event);
|
final line = formatIperf3Event(event);
|
||||||
if (line != null && line.isNotEmpty) {
|
if (line != null && line.isNotEmpty) {
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ name: iperf3gui
|
|||||||
description: "A Flutter-based Android GUI wrapper for iperf3."
|
description: "A Flutter-based Android GUI wrapper for iperf3."
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
|
|
||||||
version: 1.0.0+1
|
version: 0.1.0+1
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.13.1
|
sdk: ^3.13.1
|
||||||
|
|||||||
@@ -82,6 +82,22 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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', () {
|
test('every layer directory is actually covered by these rules', () {
|
||||||
// Guards against a new top-level layer appearing and silently escaping
|
// Guards against a new top-level layer appearing and silently escaping
|
||||||
// every rule above.
|
// every rule above.
|
||||||
@@ -102,8 +118,9 @@ void main() {
|
|||||||
/// Every `(file, import)` pair under [directory] whose import satisfies [test].
|
/// Every `(file, import)` pair under [directory] whose import satisfies [test].
|
||||||
List<({String file, String import})> _importsMatching(
|
List<({String file, String import})> _importsMatching(
|
||||||
String directory,
|
String directory,
|
||||||
bool Function(String import) test,
|
bool Function(String import) test, {
|
||||||
) {
|
Set<String>? onlyFiles,
|
||||||
|
}) {
|
||||||
final results = <({String file, String import})>[];
|
final results = <({String file, String import})>[];
|
||||||
final dir = Directory(directory);
|
final dir = Directory(directory);
|
||||||
if (!dir.existsSync()) return results;
|
if (!dir.existsSync()) return results;
|
||||||
@@ -111,6 +128,7 @@ List<({String file, String import})> _importsMatching(
|
|||||||
final pattern = RegExp(r'''^\s*import\s+['"]([^'"]+)['"]''', multiLine: true);
|
final pattern = RegExp(r'''^\s*import\s+['"]([^'"]+)['"]''', multiLine: true);
|
||||||
for (final entity in dir.listSync(recursive: true)) {
|
for (final entity in dir.listSync(recursive: true)) {
|
||||||
if (entity is! File || !entity.path.endsWith('.dart')) continue;
|
if (entity is! File || !entity.path.endsWith('.dart')) continue;
|
||||||
|
if (onlyFiles != null && !onlyFiles.contains(entity.path)) continue;
|
||||||
final source = entity.readAsStringSync();
|
final source = entity.readAsStringSync();
|
||||||
for (final match in pattern.allMatches(source)) {
|
for (final match in pattern.allMatches(source)) {
|
||||||
final import = match.group(1)!;
|
final import = match.group(1)!;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -82,48 +82,13 @@ void main() {
|
|||||||
expect(vm.output, isNot(contains('ignored')));
|
expect(vm.output, isNot(contains('ignored')));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('intervals are collected as structured samples', () async {
|
test('events are fed into the session', () async {
|
||||||
final mock = FakeIperf3Runner();
|
final mock = FakeIperf3Runner();
|
||||||
mock.onRun = (_) async {
|
mock.onRun = (_) async {
|
||||||
mock.emit(const Iperf3Interval(
|
mock.emit(const Iperf3Interval(
|
||||||
start: 0, end: 1, bytes: 1000, bitsPerSecond: 8000));
|
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 = FakeIperf3Runner();
|
|
||||||
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 = FakeIperf3Runner();
|
|
||||||
mock.onRun = (_) async {
|
|
||||||
mock.emit(const Iperf3End(
|
mock.emit(const Iperf3End(
|
||||||
sent: Iperf3Total(
|
sent: Iperf3Total(seconds: 1, bytes: 1000, bitsPerSecond: 8000),
|
||||||
seconds: 10, bytes: 987654321, bitsPerSecond: 790000000),
|
|
||||||
));
|
));
|
||||||
await Future<void>.delayed(Duration.zero);
|
await Future<void>.delayed(Duration.zero);
|
||||||
return const Iperf3Result(rawOutput: '', exitCode: 0);
|
return const Iperf3Result(rawOutput: '', exitCode: 0);
|
||||||
@@ -132,11 +97,11 @@ void main() {
|
|||||||
final vm = TestRunViewModel(iperf3Service: mock);
|
final vm = TestRunViewModel(iperf3Service: mock);
|
||||||
await vm.runTest(const TestConfiguration(server: '192.168.1.1'));
|
await vm.runTest(const TestConfiguration(server: '192.168.1.1'));
|
||||||
|
|
||||||
expect(vm.summary?.sent?.bitsPerSecond, equals(790000000));
|
expect(vm.session.sampleCount, equals(1));
|
||||||
expect(vm.output, contains('790 Mbits/sec'));
|
expect(vm.session.summary, isNotNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a new run clears the previous interval series', () async {
|
test('a new run clears the previous session', () async {
|
||||||
final mock = FakeIperf3Runner();
|
final mock = FakeIperf3Runner();
|
||||||
mock.onRun = (_) async {
|
mock.onRun = (_) async {
|
||||||
mock.emit(const Iperf3Interval(
|
mock.emit(const Iperf3Interval(
|
||||||
@@ -147,10 +112,11 @@ void main() {
|
|||||||
|
|
||||||
final vm = TestRunViewModel(iperf3Service: mock);
|
final vm = TestRunViewModel(iperf3Service: mock);
|
||||||
await vm.runTest(const TestConfiguration(server: '192.168.1.1'));
|
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'));
|
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 {
|
test('falls back to result output when nothing streamed', () async {
|
||||||
|
|||||||
Reference in New Issue
Block a user