Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 39 additions & 30 deletions lib/src/cli/flutter_cli.dart
Original file line number Diff line number Diff line change
Expand Up @@ -75,41 +75,50 @@ class CoverageMetrics {
List<Record> records, {
String? excludeFromCoverage,
}) {
final glob = excludeFromCoverage != null ? Glob(excludeFromCoverage) : null;
return records.fold<CoverageMetrics>(const CoverageMetrics(), (
current,
record,
) {
final found = record.lines?.found ?? 0;
final hit = record.lines?.hit ?? 0;
if (glob != null && record.file != null) {
if (glob.matches(record.file!)) return current;
final globs = <Glob>[];

if (excludeFromCoverage != null && excludeFromCoverage.isNotEmpty) {
for (final glob in excludeFromCoverage.trim().split(' ')) {
if (glob.isNotEmpty) globs.add(Glob(glob));
}
}

final file = record.file;
final details = record.lines?.details;
final uncoveredLines = Map<String, List<int>>.from(
current.uncoveredLines,
);

if (file != null && details != null) {
for (final line in details) {
if ((line.hit ?? 1) == 0 && line.line != null) {
uncoveredLines.update(
file,
(lines) => [...lines, line.line!],
ifAbsent: () => [line.line!],
);
return records.fold<CoverageMetrics>(
const CoverageMetrics(),
(current, record) {
final found = record.lines?.found ?? 0;
final hit = record.lines?.hit ?? 0;
if (globs.isNotEmpty && record.file != null) {
for (final glob in globs) {
if (glob.matches(record.file!)) return current;
}
}
}

return CoverageMetrics(
totalFound: current.totalFound + found,
totalHits: current.totalHits + hit,
uncoveredLines: uncoveredLines,
);
});
final file = record.file;
final details = record.lines?.details;
final uncoveredLines = Map<String, List<int>>.from(
current.uncoveredLines,
);

if (file != null && details != null) {
for (final line in details) {
if ((line.hit ?? 1) == 0 && line.line != null) {
uncoveredLines.update(
file,
(lines) => [...lines, line.line!],
ifAbsent: () => [line.line!],
);
}
}
}

return CoverageMetrics(
totalFound: current.totalFound + found,
totalHits: current.totalHits + hit,
uncoveredLines: uncoveredLines,
);
},
);
}

/// Total number of lines hit (covered) across all included files.
Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ dependencies:
dart_mcp: ^0.5.0
equatable: ^2.0.5
glob: ^2.1.2
lcov_parser: ^0.1.2
lcov_parser: ^0.1.3
mason: ^0.1.0
mason_logger: ^0.3.0
meta: ^1.15.0
Expand Down
3 changes: 2 additions & 1 deletion site/docs/commands/test.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ very_good test [arguments]
-j, --concurrency The number of concurrent test suites run.
(defaults to "4")
-t, --tags Run only tests associated with the specified tags.
--exclude-coverage A glob which will be used to exclude files that match from the coverage (e.g. '**/*.g.dart').
--exclude-coverage One or more space-separated globs which will be used to exclude files that match from the coverage (e.g. '**/*.g.dart **/*.freezed.dart').
-x, --exclude-tags Run only tests that do not have the specified tags.
--min-coverage Whether to enforce a minimum coverage percentage.
--test-randomize-ordering-seed The seed to randomize the execution order of test cases within test files.
Expand All @@ -38,6 +38,7 @@ very_good test [arguments]
--collect-coverage-from=<imports|all>
Whether to collect coverage from imported files only or all files.
(defaults to "imports")
--flavor Build a custom app flavor as defined by platform-specific build setup. Supports the use of product flavors in Android Gradle scripts, and the use of custom Xcode schemes.
--fail-fast Stop running tests after the first failure.

Run "very_good help" to see global options.
Expand Down
223 changes: 222 additions & 1 deletion test/src/cli/flutter_cli_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import 'dart:async';

import 'package:lcov_parser/lcov_parser.dart';
import 'package:mason/mason.dart';
import 'package:mocktail/mocktail.dart';
import 'package:path/path.dart' as p;
Expand Down Expand Up @@ -57,7 +58,7 @@ void main() {
'Some error',
);

group('Flutter', () {
group(Flutter, () {
late _TestProcess process;
late Logger logger;
late Progress progress;
Expand Down Expand Up @@ -302,4 +303,224 @@ void main() {
});
});
});

group(CoverageMetrics, () {
List<Record> parseRecords(List<String> lines) => Parser.parseLines(lines);

group('.fromLcovRecords', () {
test('returns empty metrics for an empty record list', () {
final metrics = CoverageMetrics.fromLcovRecords([]);

expect(metrics.totalHits, equals(0));
expect(metrics.totalFound, equals(0));
expect(metrics.uncoveredLines, isEmpty);
});

test('aggregates hits and found across records', () {
final records = parseRecords([
'SF:lib/a.dart',
'LF:10',
'LH:8',
'end_of_record',
'SF:lib/b.dart',
'LF:5',
'LH:5',
'end_of_record',
]);

final metrics = CoverageMetrics.fromLcovRecords(records);

expect(metrics.totalFound, equals(15));
expect(metrics.totalHits, equals(13));
});

test('collects uncovered lines per file', () {
final records = parseRecords([
'SF:lib/a.dart',
'DA:1,1',
'DA:2,0',
'DA:3,0',
'LF:3',
'LH:1',
'end_of_record',
]);

final metrics = CoverageMetrics.fromLcovRecords(records);

expect(
metrics.uncoveredLines,
equals({
'lib/a.dart': [2, 3],
}),
);
});

test('accumulates uncovered lines across multiple records', () {
final records = parseRecords([
'SF:lib/a.dart',
'DA:10,0',
'DA:20,1',
'LF:2',
'LH:1',
'end_of_record',
'SF:lib/b.dart',
'DA:5,0',
'DA:6,0',
'LF:2',
'LH:0',
'end_of_record',
]);

final metrics = CoverageMetrics.fromLcovRecords(records);

expect(
metrics.uncoveredLines,
equals({
'lib/a.dart': [10],
'lib/b.dart': [5, 6],
}),
);
});

test('handles records with no DA entries', () {
final records = parseRecords([
'SF:lib/a.dart',
'LF:0',
'LH:0',
'end_of_record',
'SF:lib/b.dart',
'LF:4',
'LH:4',
'end_of_record',
]);

final metrics = CoverageMetrics.fromLcovRecords(records);

expect(metrics.totalFound, equals(4));
expect(metrics.totalHits, equals(4));
expect(metrics.uncoveredLines, isEmpty);
});

group('excludeFromCoverage', () {
test('handles null', () {
final records = parseRecords([
'SF:lib/a.dart',
'LF:3',
'LH:3',
'end_of_record',
]);

final metrics = CoverageMetrics.fromLcovRecords(records);

expect(metrics.totalFound, equals(3));
expect(metrics.totalHits, equals(3));
});

test('handles empty string', () {
final records = parseRecords([
'SF:lib/a.dart',
'LF:3',
'LH:3',
'end_of_record',
]);

final metrics = CoverageMetrics.fromLcovRecords(
records,
excludeFromCoverage: '',
);

expect(metrics.totalFound, equals(3));
expect(metrics.totalHits, equals(3));
});

test('excludes a single glob-matched file', () {
final records = parseRecords([
'SF:lib/a.dart',
'LF:10',
'LH:8',
'end_of_record',
'SF:lib/generated/b.g.dart',
'LF:5',
'LH:5',
'end_of_record',
]);

final metrics = CoverageMetrics.fromLcovRecords(
records,
excludeFromCoverage: 'lib/generated/**',
);

expect(metrics.totalFound, equals(10));
expect(metrics.totalHits, equals(8));
});

test('excludes multiple space-separated globs', () {
final records = parseRecords([
'SF:lib/a.dart',
'LF:10',
'LH:8',
'end_of_record',
'SF:lib/generated/b.g.dart',
'LF:5',
'LH:5',
'end_of_record',
'SF:lib/mocks/mock_c.dart',
'LF:4',
'LH:4',
'end_of_record',
]);

final metrics = CoverageMetrics.fromLcovRecords(
records,
excludeFromCoverage: 'lib/generated/** lib/mocks/**',
);

expect(metrics.totalFound, equals(10));
expect(metrics.totalHits, equals(8));
});

test('handles multiple consecutive spaces between globs', () {
final records = parseRecords([
'SF:lib/a.dart',
'LF:10',
'LH:8',
'end_of_record',
'SF:lib/generated/b.g.dart',
'LF:5',
'LH:5',
'end_of_record',
'SF:lib/mocks/mock_c.dart',
'LF:4',
'LH:4',
'end_of_record',
]);

final metrics = CoverageMetrics.fromLcovRecords(
records,
excludeFromCoverage: 'lib/generated/** lib/mocks/**',
);

expect(metrics.totalFound, equals(10));
expect(metrics.totalHits, equals(8));
});

test('does not exclude file when glob does not match', () {
final records = parseRecords([
'SF:lib/a.dart',
'LF:5',
'LH:5',
'end_of_record',
]);

final metrics = CoverageMetrics.fromLcovRecords(
records,
excludeFromCoverage: 'lib/generated/**',
);

expect(metrics.totalFound, equals(5));
expect(metrics.totalHits, equals(5));
});
});
});
});
}
Loading
Loading