Files
iperf3-gui/test/architecture_test.dart
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

145 lines
4.9 KiB
Dart

// 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');