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>
This commit is contained in:
@@ -0,0 +1,126 @@
|
|||||||
|
// Enforces the layering. A convention nobody checks is a convention that
|
||||||
|
// decays, so these read the real import statements in lib/ and fail on a
|
||||||
|
// violation rather than trusting review to catch it.
|
||||||
|
//
|
||||||
|
// The rule, in one line: dependencies point inward. ui → domain ← data, and
|
||||||
|
// never the reverse.
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('layering', () {
|
||||||
|
test('domain imports no Flutter at all', () {
|
||||||
|
final offenders = _importsMatching(
|
||||||
|
'lib/domain',
|
||||||
|
(import) => import.startsWith('package:flutter/'),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
offenders,
|
||||||
|
isEmpty,
|
||||||
|
reason: 'The domain layer must stay pure Dart so it can be reused '
|
||||||
|
'without a Flutter runtime. Offending imports:\n'
|
||||||
|
'${_render(offenders)}',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('domain does not depend on data or ui', () {
|
||||||
|
final offenders = _importsMatching(
|
||||||
|
'lib/domain',
|
||||||
|
(import) =>
|
||||||
|
import.contains('/data/') ||
|
||||||
|
import.contains('/ui/') ||
|
||||||
|
import.contains('main.dart'),
|
||||||
|
);
|
||||||
|
expect(offenders, isEmpty,
|
||||||
|
reason: 'The domain layer is the innermost layer; it may depend on '
|
||||||
|
'nothing else in this package.\n${_render(offenders)}');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('data does not depend on ui', () {
|
||||||
|
final offenders = _importsMatching(
|
||||||
|
'lib/data',
|
||||||
|
(import) => import.contains('/ui/') || import.contains('main.dart'),
|
||||||
|
);
|
||||||
|
expect(offenders, isEmpty,
|
||||||
|
reason: 'The data layer must not know about presentation.\n'
|
||||||
|
'${_render(offenders)}');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('data imports no Flutter widget libraries', () {
|
||||||
|
// flutter/services is allowed: MethodChannel lives there, and that is
|
||||||
|
// the whole reason this layer is Flutter-aware at all.
|
||||||
|
const allowed = {'package:flutter/services.dart'};
|
||||||
|
final offenders = _importsMatching(
|
||||||
|
'lib/data',
|
||||||
|
(import) =>
|
||||||
|
import.startsWith('package:flutter/') && !allowed.contains(import),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
offenders,
|
||||||
|
isEmpty,
|
||||||
|
reason: 'The data layer may import package:flutter/services.dart only. '
|
||||||
|
'Anything else drags presentation into the transport.\n'
|
||||||
|
'${_render(offenders)}',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ui does not depend on a concrete data implementation', () {
|
||||||
|
// The UI talks to Iperf3Runner. main.dart is the composition root and is
|
||||||
|
// the one file allowed to name an implementation.
|
||||||
|
final offenders = _importsMatching(
|
||||||
|
'lib/ui',
|
||||||
|
(import) => import.contains('/data/'),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
offenders,
|
||||||
|
isEmpty,
|
||||||
|
reason: 'Presentation must depend on domain/services/iperf3_runner.dart '
|
||||||
|
'rather than on a transport. Only main.dart may pick an '
|
||||||
|
'implementation.\n${_render(offenders)}',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every layer directory is actually covered by these rules', () {
|
||||||
|
// Guards against a new top-level layer appearing and silently escaping
|
||||||
|
// every rule above.
|
||||||
|
final layers = Directory('lib')
|
||||||
|
.listSync()
|
||||||
|
.whereType<Directory>()
|
||||||
|
.map((d) => d.path.split(Platform.pathSeparator).last)
|
||||||
|
.toSet();
|
||||||
|
expect(
|
||||||
|
layers,
|
||||||
|
equals({'domain', 'data', 'ui'}),
|
||||||
|
reason: 'A new layer appeared under lib/. Add rules for it here.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every `(file, import)` pair under [directory] whose import satisfies [test].
|
||||||
|
List<({String file, String import})> _importsMatching(
|
||||||
|
String directory,
|
||||||
|
bool Function(String import) test,
|
||||||
|
) {
|
||||||
|
final results = <({String file, String import})>[];
|
||||||
|
final dir = Directory(directory);
|
||||||
|
if (!dir.existsSync()) return results;
|
||||||
|
|
||||||
|
final pattern = RegExp(r'''^\s*import\s+['"]([^'"]+)['"]''', multiLine: true);
|
||||||
|
for (final entity in dir.listSync(recursive: true)) {
|
||||||
|
if (entity is! File || !entity.path.endsWith('.dart')) continue;
|
||||||
|
final source = entity.readAsStringSync();
|
||||||
|
for (final match in pattern.allMatches(source)) {
|
||||||
|
final import = match.group(1)!;
|
||||||
|
if (test(import)) {
|
||||||
|
results.add((file: entity.path, import: import));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _render(List<({String file, String import})> offenders) =>
|
||||||
|
offenders.map((o) => ' ${o.file} → ${o.import}').join('\n');
|
||||||
Reference in New Issue
Block a user