diff --git a/test/architecture_test.dart b/test/architecture_test.dart new file mode 100644 index 0000000..20725d8 --- /dev/null +++ b/test/architecture_test.dart @@ -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() + .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');