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