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