fc15897541
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>
145 lines
4.9 KiB
Dart
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');
|