From 9b16680db183100a1a30c567e0864bb61d4ab1bd Mon Sep 17 00:00:00 2001 From: "unicoderbot[bot]" <269805761+unicoderbot[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 10:58:35 +0000 Subject: [PATCH 1/2] feat(dart): add dart config to very_good.yaml Closes #1655 Co-authored-by: marcossevilla --- .../dart/commands/dart_test_command.dart | 137 +++++++++++++--- .../very_good_config/very_good_config.dart | 149 +++++++++++++++++- .../very_good_config/very_good_config.g.dart | 86 +++++++++- site/docs/commands/test.md | 13 ++ .../dart/commands/dart_test_test.dart | 96 +++++++++++ .../fixtures/all_dart_test_options.yaml | 19 +++ .../very_good_config_test.dart | 126 +++++++++++++++ 7 files changed, 603 insertions(+), 23 deletions(-) create mode 100644 test/src/very_good_config/fixtures/all_dart_test_options.yaml diff --git a/lib/src/commands/dart/commands/dart_test_command.dart b/lib/src/commands/dart/commands/dart_test_command.dart index 124dd2487..c8a09d4de 100644 --- a/lib/src/commands/dart/commands/dart_test_command.dart +++ b/lib/src/commands/dart/commands/dart_test_command.dart @@ -7,6 +7,7 @@ import 'package:mason/mason.dart'; import 'package:meta/meta.dart'; import 'package:path/path.dart' as path; import 'package:very_good_cli/src/cli/cli.dart'; +import 'package:very_good_cli/src/very_good_config/very_good_config.dart'; /// Options for configuring the Dart test command. class DartTestOptions { @@ -32,18 +33,53 @@ class DartTestOptions { }); /// Parses [ArgResults] into a [DartTestOptions] instance. - factory DartTestOptions.parse(ArgResults argResults) { - final concurrency = argResults['concurrency'] as String; - final collectCoverage = argResults['coverage'] as bool; - final minCoverage = double.tryParse( - argResults['min-coverage'] as String? ?? '', + /// + /// When [config] is provided, its values are used as defaults for any + /// option that was not explicitly parsed on the command line. + factory DartTestOptions.parse( + ArgResults argResults, { + VeryGoodConfig config = VeryGoodConfig.empty, + }) { + final testConfig = config.dart.test; + + final concurrency = _resolveArg( + argResults, + 'concurrency', + testConfig.concurrency, + ); + final collectCoverage = _resolveArg( + argResults, + 'coverage', + testConfig.coverage, + ); + final minCoverageString = _resolveArg( + argResults, + 'min-coverage', + testConfig.minCoverage, + ); + final minCoverage = double.tryParse(minCoverageString ?? ''); + final showUncovered = _resolveArg( + argResults, + 'show-uncovered', + testConfig.showUncovered, + ); + final excludeTags = _resolveArg( + argResults, + 'exclude-tags', + testConfig.excludeTags, + ); + final tags = _resolveArg(argResults, 'tags', testConfig.tags); + final excludeFromCoverage = _resolveArg( + argResults, + 'exclude-coverage', + testConfig.excludeCoverage, + ); + final collectCoverageFromString = _resolveArg( + argResults, + 'collect-coverage-from', + testConfig.collectCoverageFrom, + fallbackValue: 'imports', ); - final showUncovered = argResults['show-uncovered'] as bool; - final excludeTags = argResults['exclude-tags'] as String?; - final tags = argResults['tags'] as String?; - final excludeFromCoverage = argResults['exclude-coverage'] as String?; - final collectCoverageFromString = - argResults['collect-coverage-from'] as String? ?? 'imports'; final collectCoverageFrom = CoverageCollectionMode.fromString( collectCoverageFromString, ); @@ -52,17 +88,46 @@ class DartTestOptions { final randomSeed = randomOrderingSeed == 'random' ? Random().nextInt(4294967295).toString() : randomOrderingSeed; - final optimizePerformance = argResults['optimization'] as bool; - final failFast = argResults['fail-fast'] as bool; + final optimizePerformance = _resolveArg( + argResults, + 'optimization', + testConfig.optimization, + ); + final failFast = _resolveArg( + argResults, + 'fail-fast', + testConfig.failFast, + ); final forceAnsi = argResults['force-ansi'] as bool?; - final platform = argResults['platform'] as String?; - final reportOn = (argResults['report-on'] as List) + final platform = _resolveArg( + argResults, + 'platform', + testConfig.platform, + ); + final reportOn = _resolveArg>( + argResults, + 'report-on', + testConfig.reportOn, + ); + final effectiveReportOn = reportOn .expand((e) => e.split(RegExp(r'[,\s]+'))) .where((e) => e.isNotEmpty) .toList(); - final runSkipped = argResults['run-skipped'] as bool; - final checkIgnore = argResults['check-ignore'] as bool; - final fileReporter = argResults['file-reporter'] as String?; + final runSkipped = _resolveArg( + argResults, + 'run-skipped', + testConfig.runSkipped, + ); + final checkIgnore = _resolveArg( + argResults, + 'check-ignore', + testConfig.checkIgnore, + ); + final fileReporter = _resolveArg( + argResults, + 'file-reporter', + testConfig.fileReporter, + ); final rest = argResults.rest; return DartTestOptions._( @@ -79,7 +144,7 @@ class DartTestOptions { failFast: failFast, forceAnsi: forceAnsi, platform: platform, - reportOn: reportOn, + reportOn: effectiveReportOn, runSkipped: runSkipped, checkIgnore: checkIgnore, fileReporter: fileReporter, @@ -144,6 +209,27 @@ class DartTestOptions { final List rest; } +/// Resolves the value for the argument named [name] against a `very_good.yaml` +/// configuration value. +/// +/// Resolution follows a fixed precedence, from highest to lowest: +/// +/// 1. A command line argument that was explicitly parsed. +/// 2. [configValue], the corresponding value from the configuration file. +/// 3. [fallbackValue], used when neither the command line nor the configuration +/// provide a value (typically the argument's command line default). +T _resolveArg( + ArgResults argResults, + String name, + T? configValue, { + T? fallbackValue, +}) { + final value = configValue != null && !argResults.wasParsed(name) + ? configValue + : argResults[name] as T?; + return (value ?? fallbackValue) as T; +} + /// Signature for the [Dart.installed] method. typedef DartInstalledCommand = Future Function({required Logger logger}); @@ -327,9 +413,20 @@ This command should be run from the root of your Dart project.'''); return ExitCode.noInput.code; } + final VeryGoodConfig config; + try { + config = VeryGoodConfig.loadFromClosestAncestor(Directory(targetPath)); + } on VeryGoodConfigParseException catch (e) { + _logger.err( + 'Could not read `$veryGoodConfigFileName`.\n' + '${e.message}', + ); + return ExitCode.config.code; + } + final isDartInstalled = await _dartInstalled(logger: _logger); - final options = DartTestOptions.parse(_argResults); + final options = DartTestOptions.parse(_argResults, config: config); if (isDartInstalled) { try { diff --git a/lib/src/very_good_config/very_good_config.dart b/lib/src/very_good_config/very_good_config.dart index a94fedde2..5977871de 100644 --- a/lib/src/very_good_config/very_good_config.dart +++ b/lib/src/very_good_config/very_good_config.dart @@ -48,7 +48,10 @@ class VeryGoodConfigParseException implements Exception { ) class VeryGoodConfig extends Equatable { /// {@macro very_good_config} - const VeryGoodConfig({this.test = const VeryGoodTestConfig()}); + const VeryGoodConfig({ + this.test = const VeryGoodTestConfig(), + this.dart = const VeryGoodDartConfig(), + }); /// Creates a [VeryGoodConfig] from a decoded YAML/JSON [json] map. factory VeryGoodConfig.fromJson(Map json) { @@ -121,8 +124,11 @@ class VeryGoodConfig extends Equatable { /// Configuration values for the `very_good test` command. final VeryGoodTestConfig test; + /// Configuration values for the `very_good dart test` command. + final VeryGoodDartConfig dart; + @override - List get props => [test]; + List get props => [test, dart]; } /// {@template very_good_test_config} @@ -256,6 +262,145 @@ class VeryGoodTestConfig extends Equatable { ]; } +/// {@template very_good_dart_config} +/// Configuration values that customize the defaults of the +/// `very_good dart test` command. +/// +/// Any field that is left as `null` retains its CLI default. +/// {@endtemplate} +@JsonSerializable( + anyMap: true, + checked: true, + createToJson: false, + disallowUnrecognizedKeys: true, + fieldRename: FieldRename.snake, +) +class VeryGoodDartConfig extends Equatable { + /// {@macro very_good_dart_config} + const VeryGoodDartConfig({ + this.test = const VeryGoodDartTestConfig(), + }); + + /// Creates a [VeryGoodDartConfig] from a decoded YAML/JSON [json] map. + factory VeryGoodDartConfig.fromJson(Map json) { + return _$VeryGoodDartConfigFromJson(json); + } + + /// Configuration values for the `very_good dart test` subcommand. + final VeryGoodDartTestConfig test; + + @override + List get props => [test]; +} + +/// {@template very_good_dart_test_config} +/// Configuration values that customize the defaults of the +/// `very_good dart test` command. +/// +/// Any field that is left as `null` retains its CLI default. +/// {@endtemplate} +@JsonSerializable( + anyMap: true, + checked: true, + createToJson: false, + disallowUnrecognizedKeys: true, + fieldRename: FieldRename.snake, +) +class VeryGoodDartTestConfig extends Equatable { + /// {@macro very_good_dart_test_config} + const VeryGoodDartTestConfig({ + this.coverage, + this.optimization, + this.concurrency, + this.tags, + this.excludeCoverage, + this.excludeTags, + this.minCoverage, + this.showUncovered, + this.collectCoverageFrom, + this.failFast, + this.platform, + this.reportOn, + this.runSkipped, + this.checkIgnore, + this.fileReporter, + }); + + /// Creates a [VeryGoodDartTestConfig] from a decoded YAML/JSON [json] map. + factory VeryGoodDartTestConfig.fromJson(Map json) { + return _$VeryGoodDartTestConfigFromJson(json); + } + + /// Whether to collect coverage information. + final bool? coverage; + + /// Whether to apply optimizations for test performance. + final bool? optimization; + + /// The number of concurrent test suites run. + @JsonKey(fromJson: _concurrency) + final String? concurrency; + + /// Run only tests associated with the specified tags. + final String? tags; + + /// A glob which will be used to exclude files that match from the coverage. + final String? excludeCoverage; + + /// Run only tests that do not have the specified tags. + final String? excludeTags; + + /// The minimum coverage percentage enforced. + @JsonKey(fromJson: _minCoverage) + final String? minCoverage; + + /// Whether to show uncovered lines when coverage is below 100%. + final bool? showUncovered; + + /// Whether to collect coverage from imported files only or all files. + @JsonKey(fromJson: _collectCoverageFrom) + final String? collectCoverageFrom; + + /// Whether to stop running tests after the first failure. + final bool? failFast; + + /// The platform to run tests on (e.g. `chrome`, `vm`). + final String? platform; + + /// Optional file paths to report coverage information to. + @JsonKey(fromJson: _stringList) + final List? reportOn; + + /// Whether to run skipped tests instead of skipping them. + final bool? runSkipped; + + /// Whether to check for and respect coverage ignore comments. + final bool? checkIgnore; + + /// Additional reporter that writes test results to a file, expressed as + /// `:` (e.g. `json:reports/tests.json`). + final String? fileReporter; + + @override + List get props => [ + coverage, + optimization, + concurrency, + tags, + excludeCoverage, + excludeTags, + minCoverage, + showUncovered, + collectCoverageFrom, + failFast, + platform, + reportOn, + runSkipped, + checkIgnore, + fileReporter, + ]; +} + // The coercers below intentionally validate more strictly than the CLI flag // parser. A value such as `min_coverage: 150` is rejected here at config load // time even though `--min-coverage 150` is accepted by the flag parser, so diff --git a/lib/src/very_good_config/very_good_config.g.dart b/lib/src/very_good_config/very_good_config.g.dart index e9f95ff40..c34d23848 100644 --- a/lib/src/very_good_config/very_good_config.g.dart +++ b/lib/src/very_good_config/very_good_config.g.dart @@ -8,7 +8,7 @@ part of 'very_good_config.dart'; VeryGoodConfig _$VeryGoodConfigFromJson(Map json) => $checkedCreate('VeryGoodConfig', json, ($checkedConvert) { - $checkKeys(json, allowedKeys: const ['test']); + $checkKeys(json, allowedKeys: const ['test', 'dart']); final val = VeryGoodConfig( test: $checkedConvert( 'test', @@ -16,6 +16,12 @@ VeryGoodConfig _$VeryGoodConfigFromJson(Map json) => ? const VeryGoodTestConfig() : VeryGoodTestConfig.fromJson(v as Map), ), + dart: $checkedConvert( + 'dart', + (v) => v == null + ? const VeryGoodDartConfig() + : VeryGoodDartConfig.fromJson(v as Map), + ), ); return val; }); @@ -92,3 +98,81 @@ VeryGoodTestConfig _$VeryGoodTestConfigFromJson(Map json) => $checkedCreate( 'fileReporter': 'file_reporter', }, ); + +VeryGoodDartConfig _$VeryGoodDartConfigFromJson(Map json) => + $checkedCreate('VeryGoodDartConfig', json, ($checkedConvert) { + $checkKeys(json, allowedKeys: const ['test']); + final val = VeryGoodDartConfig( + test: $checkedConvert( + 'test', + (v) => v == null + ? const VeryGoodDartTestConfig() + : VeryGoodDartTestConfig.fromJson(v as Map), + ), + ); + return val; + }); + +VeryGoodDartTestConfig _$VeryGoodDartTestConfigFromJson(Map json) => + $checkedCreate( + 'VeryGoodDartTestConfig', + json, + ($checkedConvert) { + $checkKeys( + json, + allowedKeys: const [ + 'coverage', + 'optimization', + 'concurrency', + 'tags', + 'exclude_coverage', + 'exclude_tags', + 'min_coverage', + 'show_uncovered', + 'collect_coverage_from', + 'fail_fast', + 'platform', + 'report_on', + 'run_skipped', + 'check_ignore', + 'file_reporter', + ], + ); + final val = VeryGoodDartTestConfig( + coverage: $checkedConvert('coverage', (v) => v as bool?), + optimization: $checkedConvert('optimization', (v) => v as bool?), + concurrency: $checkedConvert('concurrency', (v) => _concurrency(v)), + tags: $checkedConvert('tags', (v) => v as String?), + excludeCoverage: $checkedConvert( + 'exclude_coverage', + (v) => v as String?, + ), + excludeTags: $checkedConvert('exclude_tags', (v) => v as String?), + minCoverage: $checkedConvert('min_coverage', (v) => _minCoverage(v)), + showUncovered: $checkedConvert('show_uncovered', (v) => v as bool?), + collectCoverageFrom: $checkedConvert( + 'collect_coverage_from', + (v) => _collectCoverageFrom(v), + ), + failFast: $checkedConvert('fail_fast', (v) => v as bool?), + platform: $checkedConvert('platform', (v) => v as String?), + reportOn: $checkedConvert('report_on', (v) => _stringList(v)), + runSkipped: $checkedConvert('run_skipped', (v) => v as bool?), + checkIgnore: $checkedConvert('check_ignore', (v) => v as bool?), + fileReporter: $checkedConvert('file_reporter', (v) => v as String?), + ); + return val; + }, + fieldKeyMap: const { + 'excludeCoverage': 'exclude_coverage', + 'excludeTags': 'exclude_tags', + 'minCoverage': 'min_coverage', + 'showUncovered': 'show_uncovered', + 'collectCoverageFrom': 'collect_coverage_from', + 'failFast': 'fail_fast', + 'reportOn': 'report_on', + 'runSkipped': 'run_skipped', + 'checkIgnore': 'check_ignore', + 'fileReporter': 'file_reporter', + }, + ); diff --git a/site/docs/commands/test.md b/site/docs/commands/test.md index b76fc14ae..09b688441 100644 --- a/site/docs/commands/test.md +++ b/site/docs/commands/test.md @@ -126,4 +126,17 @@ test: With the file above, running `very_good test` behaves the same as running `very_good test --min-coverage 100 --exclude-coverage '**/*.g.dart' --report-on lib/ --dart-define=FLAVOR=development`. You can still override any of these values on the command line, for example `very_good test --min-coverage 90` to lower the coverage threshold for a single run. +To customize the defaults for `very_good dart test`, use the `dart.test` section. It accepts the same options as `test` (minus the Flutter-only flags such as `update_goldens`, `flavor`, `timeout`, `dart_define`, and `dart_define_from_file`) plus `check_ignore`. + +```yaml +# very_good.yaml +dart: + test: + min_coverage: 100 + exclude_coverage: '**/*.g.dart' + report_on: + - lib/ + file_reporter: json:reports/tests.json +``` + The `very_good.yaml` file is looked up starting from the directory where the command runs and walking up through its ancestors. The closest file wins; configuration from ancestor directories is not merged. This lets a single `very_good.yaml` at the repository root apply to commands run from any nested package. diff --git a/test/src/commands/dart/commands/dart_test_test.dart b/test/src/commands/dart/commands/dart_test_test.dart index 9de61ba60..d157d012b 100644 --- a/test/src/commands/dart/commands/dart_test_test.dart +++ b/test/src/commands/dart/commands/dart_test_test.dart @@ -11,6 +11,7 @@ import 'package:path/path.dart' as path; import 'package:test/test.dart'; import 'package:very_good_cli/src/cli/cli.dart'; import 'package:very_good_cli/src/commands/dart/commands/commands.dart'; +import 'package:very_good_cli/src/very_good_config/very_good_config.dart'; import '../../../../helpers/helpers.dart'; @@ -829,5 +830,100 @@ void main() { ), ).called(1); }); + + group('very_good.yaml configuration', () { + test( + 'fails with exit code ${ExitCode.config.code} ' + 'when very_good.yaml is malformed', + withRunner((commandRunner, logger, pubUpdater, printLogs) async { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() { + Directory.current = cwd; + tempDirectory.deleteSync(recursive: true); + }); + + Directory.current = tempDirectory.path; + File(path.join(tempDirectory.path, 'pubspec.yaml')).createSync(); + File( + path.join(tempDirectory.path, 'very_good.yaml'), + ).writeAsStringSync('- not\n- a\n- map'); + + final result = await commandRunner.run(['dart', 'test']); + expect(result, equals(ExitCode.config.code)); + verify( + () => logger.err( + any(that: contains('Could not read `very_good.yaml`')), + ), + ).called(1); + }), + ); + + test('applies config value when arg was not parsed', () { + when(() => argResults.wasParsed(any())).thenReturn(false); + final options = DartTestOptions.parse( + argResults, + config: const VeryGoodConfig( + dart: VeryGoodDartConfig( + test: VeryGoodDartTestConfig( + minCoverage: '90', + excludeCoverage: '**/*.g.dart', + reportOn: ['lib/'], + fileReporter: 'json:reports/tests.json', + ), + ), + ), + ); + expect(options.minCoverage, equals(90)); + expect(options.excludeFromCoverage, equals('**/*.g.dart')); + expect(options.reportOn, equals(['lib/'])); + expect(options.fileReporter, equals('json:reports/tests.json')); + }); + + test('CLI argument takes precedence over config value', () { + when(() => argResults.wasParsed(any())).thenReturn(false); + when(() => argResults.wasParsed('min-coverage')).thenReturn(true); + when(() => argResults['min-coverage']).thenReturn('50'); + + final options = DartTestOptions.parse( + argResults, + config: const VeryGoodConfig( + dart: VeryGoodDartConfig( + test: VeryGoodDartTestConfig(minCoverage: '90'), + ), + ), + ); + expect(options.minCoverage, equals(50)); + }); + + test('CLI --file-reporter takes precedence over config value', () { + when(() => argResults.wasParsed(any())).thenReturn(false); + when(() => argResults.wasParsed('file-reporter')).thenReturn(true); + when( + () => argResults['file-reporter'], + ).thenReturn('json:cli.json'); + + final options = DartTestOptions.parse( + argResults, + config: const VeryGoodConfig( + dart: VeryGoodDartConfig( + test: VeryGoodDartTestConfig(fileReporter: 'json:config.json'), + ), + ), + ); + expect(options.fileReporter, equals('json:cli.json')); + }); + + test('falls back to the CLI default when the parsed arg is null ' + 'and the config is unset', () { + when(() => argResults.wasParsed(any())).thenReturn(true); + when( + () => argResults['collect-coverage-from'], + ).thenReturn(null); + + final options = DartTestOptions.parse(argResults); + + expect(options.collectCoverageFrom, CoverageCollectionMode.imports); + }); + }); }); } diff --git a/test/src/very_good_config/fixtures/all_dart_test_options.yaml b/test/src/very_good_config/fixtures/all_dart_test_options.yaml new file mode 100644 index 000000000..9a6468d65 --- /dev/null +++ b/test/src/very_good_config/fixtures/all_dart_test_options.yaml @@ -0,0 +1,19 @@ +dart: + test: + coverage: true + optimization: false + concurrency: 8 + tags: my-tag + exclude_coverage: "**/*.g.dart" + exclude_tags: skip + min_coverage: 95 + show_uncovered: true + collect_coverage_from: all + fail_fast: true + platform: chrome + report_on: + - lib/ + - packages/foo/lib/ + run_skipped: true + check_ignore: false + file_reporter: json:reports/tests.json diff --git a/test/src/very_good_config/very_good_config_test.dart b/test/src/very_good_config/very_good_config_test.dart index 2494babbf..3b0937cf1 100644 --- a/test/src/very_good_config/very_good_config_test.dart +++ b/test/src/very_good_config/very_good_config_test.dart @@ -161,6 +161,69 @@ test: ); }); + test('parses all supported dart test options', () { + final fixture = File( + p.join( + 'test', + 'src', + 'very_good_config', + 'fixtures', + 'all_dart_test_options.yaml', + ), + ); + final config = VeryGoodConfig.fromString(fixture.readAsStringSync()); + + expect(config.dart.test.coverage, isTrue); + expect(config.dart.test.optimization, isFalse); + expect(config.dart.test.concurrency, equals('8')); + expect(config.dart.test.tags, equals('my-tag')); + expect(config.dart.test.excludeCoverage, equals('**/*.g.dart')); + expect(config.dart.test.excludeTags, equals('skip')); + expect(config.dart.test.minCoverage, equals('95')); + expect(config.dart.test.showUncovered, isTrue); + expect(config.dart.test.collectCoverageFrom, equals('all')); + expect(config.dart.test.failFast, isTrue); + expect(config.dart.test.platform, equals('chrome')); + expect( + config.dart.test.reportOn, + equals(['lib/', 'packages/foo/lib/']), + ); + expect(config.dart.test.runSkipped, isTrue); + expect(config.dart.test.checkIgnore, isFalse); + expect( + config.dart.test.fileReporter, + equals('json:reports/tests.json'), + ); + }); + + test('throws when dart section is not a map', () { + expect( + () => VeryGoodConfig.fromString('dart: foo'), + throwsA(isA()), + ); + }); + + test('throws when dart.test section is not a map', () { + expect( + () => VeryGoodConfig.fromString('dart:\n test: foo'), + throwsA(isA()), + ); + }); + + test('throws when an unrecognized dart key is present', () { + expect( + () => VeryGoodConfig.fromString('dart:\n unknown: true'), + throwsA(isA()), + ); + }); + + test('throws when an unrecognized dart.test key is present', () { + expect( + () => VeryGoodConfig.fromString('dart:\n test:\n unknown: true'), + throwsA(isA()), + ); + }); + test('throws when an unrecognized root key is present', () { expect( () => VeryGoodConfig.fromString('unknown: true'), @@ -313,6 +376,36 @@ test: equals(VeryGoodConfig(test: VeryGoodTestConfig(coverage: false))), ), ); + expect( + VeryGoodConfig( + dart: VeryGoodDartConfig( + test: VeryGoodDartTestConfig(coverage: true), + ), + ), + equals( + VeryGoodConfig( + dart: VeryGoodDartConfig( + test: VeryGoodDartTestConfig(coverage: true), + ), + ), + ), + ); + expect( + VeryGoodConfig( + dart: VeryGoodDartConfig( + test: VeryGoodDartTestConfig(coverage: true), + ), + ), + isNot( + equals( + VeryGoodConfig( + dart: VeryGoodDartConfig( + test: VeryGoodDartTestConfig(coverage: false), + ), + ), + ), + ), + ); }); }); @@ -329,6 +422,39 @@ test: }); }); + group(VeryGoodDartConfig, () { + test('supports value equality', () { + expect(VeryGoodDartConfig(), equals(VeryGoodDartConfig())); + expect( + VeryGoodDartConfig(test: VeryGoodDartTestConfig(coverage: true)), + equals( + VeryGoodDartConfig(test: VeryGoodDartTestConfig(coverage: true)), + ), + ); + expect( + VeryGoodDartConfig(test: VeryGoodDartTestConfig(coverage: true)), + isNot( + equals( + VeryGoodDartConfig(test: VeryGoodDartTestConfig(coverage: false)), + ), + ), + ); + }); + }); + + group(VeryGoodDartTestConfig, () { + test('supports value equality', () { + expect( + VeryGoodDartTestConfig(coverage: true, minCoverage: '95'), + equals(VeryGoodDartTestConfig(coverage: true, minCoverage: '95')), + ); + expect( + VeryGoodDartTestConfig(coverage: true), + isNot(equals(VeryGoodDartTestConfig(coverage: false))), + ); + }); + }); + group(VeryGoodConfigParseException, () { test('provides message via toString', () { const exception = VeryGoodConfigParseException('bad thing'); From ed52dc813a652459437c05cfd862cee62b1c8665 Mon Sep 17 00:00:00 2001 From: Marcos Sevilla Date: Mon, 27 Jul 2026 17:17:22 +0200 Subject: [PATCH 2/2] chore: simplify --- .../dart/commands/dart_test_command.dart | 78 ++++--------------- lib/src/commands/test/test.dart | 77 +++++------------- .../very_good_config/very_good_config.dart | 26 +++++-- 3 files changed, 57 insertions(+), 124 deletions(-) diff --git a/lib/src/commands/dart/commands/dart_test_command.dart b/lib/src/commands/dart/commands/dart_test_command.dart index c8a09d4de..4cea82b6c 100644 --- a/lib/src/commands/dart/commands/dart_test_command.dart +++ b/lib/src/commands/dart/commands/dart_test_command.dart @@ -42,40 +42,30 @@ class DartTestOptions { }) { final testConfig = config.dart.test; - final concurrency = _resolveArg( - argResults, + final concurrency = argResults.resolve( 'concurrency', testConfig.concurrency, ); - final collectCoverage = _resolveArg( - argResults, - 'coverage', - testConfig.coverage, - ); - final minCoverageString = _resolveArg( - argResults, + final collectCoverage = argResults.resolve('coverage', testConfig.coverage); + final minCoverageString = argResults.resolve( 'min-coverage', testConfig.minCoverage, ); final minCoverage = double.tryParse(minCoverageString ?? ''); - final showUncovered = _resolveArg( - argResults, + final showUncovered = argResults.resolve( 'show-uncovered', testConfig.showUncovered, ); - final excludeTags = _resolveArg( - argResults, + final excludeTags = argResults.resolve( 'exclude-tags', testConfig.excludeTags, ); - final tags = _resolveArg(argResults, 'tags', testConfig.tags); - final excludeFromCoverage = _resolveArg( - argResults, + final tags = argResults.resolve('tags', testConfig.tags); + final excludeFromCoverage = argResults.resolve( 'exclude-coverage', testConfig.excludeCoverage, ); - final collectCoverageFromString = _resolveArg( - argResults, + final collectCoverageFromString = argResults.resolve( 'collect-coverage-from', testConfig.collectCoverageFrom, fallbackValue: 'imports', @@ -88,43 +78,30 @@ class DartTestOptions { final randomSeed = randomOrderingSeed == 'random' ? Random().nextInt(4294967295).toString() : randomOrderingSeed; - final optimizePerformance = _resolveArg( - argResults, + final optimizePerformance = argResults.resolve( 'optimization', testConfig.optimization, ); - final failFast = _resolveArg( - argResults, - 'fail-fast', - testConfig.failFast, - ); + final failFast = argResults.resolve('fail-fast', testConfig.failFast); final forceAnsi = argResults['force-ansi'] as bool?; - final platform = _resolveArg( - argResults, + final platform = argResults.resolve( 'platform', testConfig.platform, ); - final reportOn = _resolveArg>( - argResults, - 'report-on', - testConfig.reportOn, - ); - final effectiveReportOn = reportOn + final reportOn = argResults + .resolve>('report-on', testConfig.reportOn) .expand((e) => e.split(RegExp(r'[,\s]+'))) .where((e) => e.isNotEmpty) .toList(); - final runSkipped = _resolveArg( - argResults, + final runSkipped = argResults.resolve( 'run-skipped', testConfig.runSkipped, ); - final checkIgnore = _resolveArg( - argResults, + final checkIgnore = argResults.resolve( 'check-ignore', testConfig.checkIgnore, ); - final fileReporter = _resolveArg( - argResults, + final fileReporter = argResults.resolve( 'file-reporter', testConfig.fileReporter, ); @@ -144,7 +121,7 @@ class DartTestOptions { failFast: failFast, forceAnsi: forceAnsi, platform: platform, - reportOn: effectiveReportOn, + reportOn: reportOn, runSkipped: runSkipped, checkIgnore: checkIgnore, fileReporter: fileReporter, @@ -209,27 +186,6 @@ class DartTestOptions { final List rest; } -/// Resolves the value for the argument named [name] against a `very_good.yaml` -/// configuration value. -/// -/// Resolution follows a fixed precedence, from highest to lowest: -/// -/// 1. A command line argument that was explicitly parsed. -/// 2. [configValue], the corresponding value from the configuration file. -/// 3. [fallbackValue], used when neither the command line nor the configuration -/// provide a value (typically the argument's command line default). -T _resolveArg( - ArgResults argResults, - String name, - T? configValue, { - T? fallbackValue, -}) { - final value = configValue != null && !argResults.wasParsed(name) - ? configValue - : argResults[name] as T?; - return (value ?? fallbackValue) as T; -} - /// Signature for the [Dart.installed] method. typedef DartInstalledCommand = Future Function({required Logger logger}); diff --git a/lib/src/commands/test/test.dart b/lib/src/commands/test/test.dart index a71572d12..cfe0ce8ac 100644 --- a/lib/src/commands/test/test.dart +++ b/lib/src/commands/test/test.dart @@ -46,40 +46,33 @@ class FlutterTestOptions { }) { final testConfig = config.test; - final concurrency = _resolveArg( - argResults, + final concurrency = argResults.resolve( 'concurrency', testConfig.concurrency, ); - final collectCoverage = _resolveArg( - argResults, + final collectCoverage = argResults.resolve( 'coverage', testConfig.coverage, ); - final minCoverage = _resolveArg( - argResults, + final minCoverage = argResults.resolve( 'min-coverage', testConfig.minCoverage, ); final effectiveMinCoverage = double.tryParse(minCoverage ?? ''); - final showUncovered = _resolveArg( - argResults, + final showUncovered = argResults.resolve( 'show-uncovered', testConfig.showUncovered, ); - final excludeTags = _resolveArg( - argResults, + final excludeTags = argResults.resolve( 'exclude-tags', testConfig.excludeTags, ); - final tags = _resolveArg(argResults, 'tags', testConfig.tags); - final excludeFromCoverage = _resolveArg( - argResults, + final tags = argResults.resolve('tags', testConfig.tags); + final excludeFromCoverage = argResults.resolve( 'exclude-coverage', testConfig.excludeCoverage, ); - final collectCoverageFrom = _resolveArg( - argResults, + final collectCoverageFrom = argResults.resolve( 'collect-coverage-from', testConfig.collectCoverageFrom, fallbackValue: 'imports', @@ -92,39 +85,32 @@ class FlutterTestOptions { final randomSeed = randomOrderingSeed == 'random' ? Random().nextInt(4294967295).toString() : randomOrderingSeed; - final optimizePerformance = _resolveArg( - argResults, + final optimizePerformance = argResults.resolve( 'optimization', testConfig.optimization, ); - final updateGoldens = _resolveArg( - argResults, + final updateGoldens = argResults.resolve( 'update-goldens', testConfig.updateGoldens, ); - final failFast = _resolveArg( - argResults, + final failFast = argResults.resolve( 'fail-fast', testConfig.failFast, ); final forceAnsi = argResults['force-ansi'] as bool?; - final dartDefine = _resolveArg?>( - argResults, + final dartDefine = argResults.resolve?>( 'dart-define', testConfig.dartDefine, ); - final dartDefineFromFile = _resolveArg?>( - argResults, + final dartDefineFromFile = argResults.resolve?>( 'dart-define-from-file', testConfig.dartDefineFromFile, ); - final platform = _resolveArg( - argResults, + final platform = argResults.resolve( 'platform', testConfig.platform, ); - final reportOn = _resolveArg>( - argResults, + final reportOn = argResults.resolve>( 'report-on', testConfig.reportOn, ); @@ -133,18 +119,15 @@ class FlutterTestOptions { .where((e) => e.isNotEmpty) .toList(); - final runSkipped = _resolveArg( - argResults, + final runSkipped = argResults.resolve( 'run-skipped', testConfig.runSkipped, ); - final flavor = _resolveArg( - argResults, + final flavor = argResults.resolve( 'flavor', testConfig.flavor, ); - final timeout = _resolveArg( - argResults, + final timeout = argResults.resolve( 'timeout', testConfig.timeout, ); @@ -152,8 +135,7 @@ class FlutterTestOptions { final effectiveTimeout = timeoutSeconds != null ? Duration(seconds: timeoutSeconds) : null; - final fileReporter = _resolveArg( - argResults, + final fileReporter = argResults.resolve( 'file-reporter', testConfig.fileReporter, ); @@ -255,27 +237,6 @@ class FlutterTestOptions { final List rest; } -/// Resolves the value for the argument named [name] against a `very_good.yaml` -/// configuration value. -/// -/// Resolution follows a fixed precedence, from highest to lowest: -/// -/// 1. A command line argument that was explicitly parsed. -/// 2. [configValue], the corresponding value from the configuration file. -/// 3. [fallbackValue], used when neither the command line nor the configuration -/// provide a value (typically the argument's command line default). -T _resolveArg( - ArgResults argResults, - String name, - T? configValue, { - T? fallbackValue, -}) { - final value = configValue != null && !argResults.wasParsed(name) - ? configValue - : argResults[name] as T?; - return (value ?? fallbackValue) as T; -} - /// Signature for the [Flutter.installed] method. typedef FlutterInstalledCommand = Future Function({required Logger logger}); diff --git a/lib/src/very_good_config/very_good_config.dart b/lib/src/very_good_config/very_good_config.dart index 5977871de..9c5f39f2c 100644 --- a/lib/src/very_good_config/very_good_config.dart +++ b/lib/src/very_good_config/very_good_config.dart @@ -7,7 +7,7 @@ library; import 'dart:io'; - +import 'package:args/args.dart'; import 'package:checked_yaml/checked_yaml.dart'; import 'package:equatable/equatable.dart'; import 'package:json_annotation/json_annotation.dart'; @@ -401,10 +401,26 @@ class VeryGoodDartTestConfig extends Equatable { ]; } -// The coercers below intentionally validate more strictly than the CLI flag -// parser. A value such as `min_coverage: 150` is rejected here at config load -// time even though `--min-coverage 150` is accepted by the flag parser, so -// misconfigured `very_good.yaml` files fail fast with a clear message. +/// Extension that resolves argument values against `very_good.yaml` +/// configuration values. +extension ArgResultsResolver on ArgResults { + /// Resolves the value for the argument named [name] against a + /// `very_good.yaml` configuration value. + /// + /// Resolution follows a fixed precedence, from highest to lowest: + /// + /// 1. A command line argument that was explicitly parsed. + /// 2. [configValue], the corresponding value from the configuration file. + /// 3. [fallbackValue], used when neither the command line nor the + /// configuration provide a value (typically the argument's command line + /// default). + T resolve(String name, T? configValue, {T? fallbackValue}) { + final value = configValue != null && !wasParsed(name) + ? configValue + : this[name] as T?; + return (value ?? fallbackValue) as T; + } +} /// Coerces a `num` or `String` value into a `String`. ///