WARNING: THIS SITE IS A MIRROR OF GITHUB.COM / IT CANNOT LOGIN OR REGISTER ACCOUNTS / THE CONTENTS ARE PROVIDED AS-IS / THIS SITE ASSUMES NO RESPONSIBILITY FOR ANY DISPLAYED CONTENT OR LINKS / IF YOU FOUND SOMETHING MAY NOT GOOD FOR EVERYONE, CONTACT ADMIN AT ilovescratch@foxmail.com
Skip to content

Commit 8c98cb5

Browse files
feat: implement partials_as_hits feature for LCOV parser (#383)
* feat: implement partials_as_hits feature for LCOV parser This commit adds support for the partials_as_hits configuration option to the LCOV parser, bringing it in line with other coverage parsers (JaCoCo, Cobertura, Go). ## Changes Made ### Core Implementation - **lcov.py**: Added partials_as_hits configuration reading and conversion logic - **_process_file()**: Updated to accept partials_as_hits parameter - **Branch processing**: Convert partial coverage (e.g., '1/2') to hits (1) when enabled ### Configuration Schema - **user_schema.py**: Added LCOV parser configuration validation - Supports: parsers.lcov.partials_as_hits (boolean, default: false) ### Test Coverage - **test_lcov.py**: Added comprehensive test cases: - test_lcov_partials_as_hits_enabled(): Tests conversion of partials to hits - test_lcov_partials_as_hits_disabled(): Tests default behavior preservation - test_lcov_partials_as_hits_mixed_coverage(): Tests mixed scenarios - **test_validation.py**: Added schema validation tests ### Documentation - **LCOV_PARTIALS_AS_HITS.md**: Comprehensive feature documentation including: - Implementation details and logic - Usage examples and migration guide - Consistency with other parsers - Test coverage overview ## Behavior ### With partials_as_hits: true - '1/2' partial coverage → 1 (hit) - '2/3' partial coverage → 1 (hit) - '2/2' full hit → '2/2' (unchanged) - '0/2' miss → '0/2' (unchanged) ### With partials_as_hits: false (default) - All coverage types remain unchanged (backward compatible) ## Configuration Example ## Implementation Details - Follows established patterns from JaCoCo, Cobertura, and Go parsers - Minimal performance impact (single boolean check per branch line) - Comprehensive test coverage with realistic LCOV data - Backward compatible (defaults to false) Closes: #[issue-number] (if applicable) Co-authored-by: TDD Implementation Process * fix: preserve branch coverage type in LCOV partials_as_hits Critical bug fix addressing incorrect branch counting when partials_as_hits is enabled. ## Problem When partials_as_hits was enabled, partial branches were converted from: - coverage: '1/2' -> 1 ✅ (correct) - coverage_type: CoverageType.branch -> CoverageType.line ❌ (incorrect) This caused get_line_totals() to exclude converted partials from branch counts since it only counts lines with type='b' (CoverageType.branch maps to 'b'). ## Solution - Keep coverage_type as CoverageType.branch for converted partials - This maintains proper branch counting: line.type = 'b' - Aligns behavior with JaCoCo parser (preserves branch type) - Ensures accurate branch coverage metrics ## Changes - lcov.py: Remove incorrect coverage_type change to CoverageType.line - test_lcov.py: Update test expectations to expect 'b' type for converted partials - LCOV_PARTIALS_AS_HITS.md: Correct documentation ## Verification - Converted partials now maintain branch type for proper counting - Tests updated to reflect correct behavior - Branch coverage metrics will be accurate Fixes: Branch count inconsistency when partials_as_hits enabled Severity: Critical - affects coverage metrics accuracy * style: apply ruff formatting to test_lcov.py Auto-formatting applied by pre-commit hooks to match project style guidelines. The tuple formatting was changed to multi-line format for better readability. * fix: correct LCOV partials_as_hits test expectations The original tests incorrectly expected both line coverage AND branch coverage to exist as separate entries when both DA: and BRDA: exist for the same line. LCOV parser behavior (confirmed by existing test_regression_partial_branch): - When both DA:10,5 and BRDA:10,0,0,1 exist for line 10 - Branch coverage overwrites line coverage - Result: Only one entry (10, '1/2', 'b', ...) not two separate entries Fixed test expectations to match actual LCOV parser behavior: - test_lcov_partials_as_hits_enabled: Expect only branch entry with hit conversion - test_lcov_partials_as_hits_disabled: Expect only branch entry with partial preserved - test_lcov_partials_as_hits_mixed_coverage: Expect only branch entries This aligns with how the existing LCOV parser works and maintains backward compatibility. * style: apply ruff formatting to LCOV tests Auto-formatting applied by pre-commit hooks: - Remove trailing whitespace after triple quotes - Multi-line format for long tuple in test_lcov_partials_as_hits_mixed_coverage This prevents CI formatting loops by applying the formatting locally. * fix: add boolean type casting for LCOV partials_as_hits config - Fix issue where string 'false' would be truthy in YAML config - Add robust boolean casting to handle 'false', 'true', '0', '1', etc. - Add test to verify string 'false' correctly behaves as boolean False - Refactor tests to eliminate duplication using class properties - Move repeated LCOV test data to PARTIAL_BRANCH_LCOV_DATA class property - Add reusable partial_test_path_fixer static method This prevents configuration bugs where users specify boolean values as strings in YAML, ensuring partials_as_hits works correctly regardless of how the boolean is specified in the configuration. * Empty commit to trigger CI * Add .cursor to .gitignore * Empty commit to trigger CI - investigating flaky test * Delete LCOV_PARTIALS_AS_HITS.md * Update action-enforce-license-compliance version * Delete .gitignore * restore .gitignore --------- Co-authored-by: Joe Becher <[email protected]>
1 parent 2a2ca6e commit 8c98cb5

File tree

4 files changed

+161
-2
lines changed

4 files changed

+161
-2
lines changed

apps/worker/services/report/languages/lcov.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,29 @@ def process(
2525

2626
def from_txt(reports: bytes, report_builder_session: ReportBuilderSession) -> None:
2727
# http://ltp.sourceforge.net/coverage/lcov/geninfo.1.php
28+
29+
# Add partials_as_hits configuration
30+
partials_as_hits_raw = report_builder_session.yaml_field(
31+
("parsers", "lcov", "partials_as_hits"), False
32+
)
33+
# Cast to boolean to handle string values like "false", "False", "true", "True"
34+
if isinstance(partials_as_hits_raw, str):
35+
partials_as_hits = partials_as_hits_raw.lower() not in ("false", "0", "")
36+
else:
37+
partials_as_hits = bool(partials_as_hits_raw)
38+
2839
# merge same files
2940
for string in reports.split(b"\nend_of_record"):
30-
if (_file := _process_file(string, report_builder_session)) is not None:
41+
if (
42+
_file := _process_file(string, report_builder_session, partials_as_hits)
43+
) is not None:
3144
report_builder_session.append(_file)
3245

3346

3447
def _process_file(
35-
doc: bytes, report_builder_session: ReportBuilderSession
48+
doc: bytes,
49+
report_builder_session: ReportBuilderSession,
50+
partials_as_hits: bool = False,
3651
) -> ReportFile | None:
3752
branches: dict[str, dict[str, int]] = defaultdict(dict)
3853
fn_lines: set[str] = set() # lines of function definitions
@@ -175,6 +190,13 @@ def _process_file(
175190
CoverageType.method if line_str in fn_lines else CoverageType.branch
176191
)
177192

193+
# Add partials_as_hits conversion
194+
if partials_as_hits and branch_sum > 0 and branch_sum < branch_num:
195+
# This is a partial branch, convert to hit
196+
coverage = 1
197+
missing_branches = None # Clear missing branches for hits
198+
# Keep coverage_type as branch to maintain proper branch counting
199+
178200
_line = report_builder_session.create_coverage_line(
179201
coverage,
180202
coverage_type,

apps/worker/services/report/languages/tests/unit/test_lcov.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,20 @@
119119

120120

121121
class TestLcov:
122+
# Common test data for partials_as_hits tests
123+
PARTIAL_BRANCH_LCOV_DATA = b"""
124+
TN:
125+
SF:partial_test.c
126+
DA:10,5
127+
BRDA:10,0,0,1
128+
BRDA:10,0,1,0
129+
end_of_record
130+
"""
131+
132+
@staticmethod
133+
def partial_test_path_fixer(path):
134+
return path if path == "partial_test.c" else None
135+
122136
def test_report(self):
123137
def fixes(path):
124138
if path == "ignore":
@@ -208,3 +222,110 @@ def test_regression_partial_branch(self):
208222
(1047, "1/2", "b", [[0, "1/2", ["0:0"], None, None]], None, None),
209223
]
210224
}
225+
226+
def test_lcov_partials_as_hits_enabled(self):
227+
"""Test that partial branches become hits when partials_as_hits=True"""
228+
# With partials_as_hits enabled
229+
report_builder_session = create_report_builder_session(
230+
current_yaml={"parsers": {"lcov": {"partials_as_hits": True}}},
231+
path_fixer=self.partial_test_path_fixer,
232+
)
233+
234+
lcov.from_txt(self.PARTIAL_BRANCH_LCOV_DATA, report_builder_session)
235+
report = report_builder_session.output_report()
236+
processed_report = convert_report_to_better_readable(report)
237+
238+
# Should convert "1/2" partial to hit (1) and keep branch type
239+
# Note: Branch coverage overwrites line coverage, so only branch entry exists
240+
expected = {
241+
"partial_test.c": [
242+
(10, 1, "b", [[0, 1]], None, None), # partial converted to hit
243+
]
244+
}
245+
assert processed_report["archive"] == expected
246+
247+
def test_lcov_partials_as_hits_disabled(self):
248+
"""Test that partials remain partial when partials_as_hits=False (default)"""
249+
# With partials_as_hits disabled (default)
250+
report_builder_session = create_report_builder_session(
251+
path_fixer=self.partial_test_path_fixer,
252+
)
253+
254+
lcov.from_txt(self.PARTIAL_BRANCH_LCOV_DATA, report_builder_session)
255+
report = report_builder_session.output_report()
256+
processed_report = convert_report_to_better_readable(report)
257+
258+
# Should keep "1/2" as partial
259+
# Note: Branch coverage overwrites line coverage, so only branch entry exists
260+
expected = {
261+
"partial_test.c": [
262+
(10, "1/2", "b", [[0, "1/2", ["0:1"], None, None]], None, None),
263+
]
264+
}
265+
assert processed_report["archive"] == expected
266+
267+
def test_lcov_partials_as_hits_mixed_coverage(self):
268+
"""Test partials_as_hits with mixed hit/miss/partial scenarios"""
269+
lcov_data = b"""
270+
TN:
271+
SF:mixed_test.c
272+
DA:10,5
273+
DA:20,3
274+
DA:30,0
275+
BRDA:10,0,0,1
276+
BRDA:10,0,1,0
277+
BRDA:20,0,0,1
278+
BRDA:20,0,1,1
279+
BRDA:30,0,0,0
280+
BRDA:30,0,1,0
281+
end_of_record
282+
"""
283+
284+
def path_fixer(path):
285+
return path if path == "mixed_test.c" else None
286+
287+
report_builder_session = create_report_builder_session(
288+
current_yaml={"parsers": {"lcov": {"partials_as_hits": True}}},
289+
path_fixer=path_fixer,
290+
)
291+
292+
lcov.from_txt(lcov_data, report_builder_session)
293+
report = report_builder_session.output_report()
294+
processed_report = convert_report_to_better_readable(report)
295+
296+
# Note: Branch coverage overwrites line coverage where both exist
297+
expected = {
298+
"mixed_test.c": [
299+
(10, 1, "b", [[0, 1]], None, None), # partial -> hit
300+
(20, "2/2", "b", [[0, "2/2"]], None, None), # hit stays hit
301+
(
302+
30,
303+
"0/2",
304+
"b",
305+
[[0, "0/2", ["0:0", "0:1"], None, None]],
306+
None,
307+
None,
308+
), # miss stays miss
309+
]
310+
}
311+
assert processed_report["archive"] == expected
312+
313+
def test_lcov_partials_as_hits_string_false_not_truthy(self):
314+
"""Test that string 'false' is correctly converted to boolean False (not truthy)"""
315+
# Test the problematic case: string "false" should be False, not truthy
316+
report_builder_session = create_report_builder_session(
317+
current_yaml={"parsers": {"lcov": {"partials_as_hits": "false"}}},
318+
path_fixer=self.partial_test_path_fixer,
319+
)
320+
321+
lcov.from_txt(self.PARTIAL_BRANCH_LCOV_DATA, report_builder_session)
322+
report = report_builder_session.output_report()
323+
processed_report = convert_report_to_better_readable(report)
324+
325+
# Should keep "1/2" as partial (False behavior), not convert to hit
326+
expected = {
327+
"partial_test.c": [
328+
(10, "1/2", "b", [[0, "1/2", ["0:1"], None, None]], None, None),
329+
]
330+
}
331+
assert processed_report["archive"] == expected

libs/shared/shared/validation/user_schema.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -516,6 +516,10 @@
516516
"partials_as_hits": {"type": "boolean"},
517517
},
518518
},
519+
"lcov": {
520+
"type": "dict",
521+
"schema": {"partials_as_hits": {"type": "boolean"}},
522+
},
519523
},
520524
},
521525
"ignore": path_list_structure,

libs/shared/tests/unit/validation/test_validation.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -890,6 +890,18 @@ def test_validate_jacoco_partials(self):
890890
result = validate_yaml(user_input)
891891
assert result == expected_result
892892

893+
def test_validate_lcov_partials_as_hits_true(self):
894+
user_input = {"parsers": {"lcov": {"partials_as_hits": True}}}
895+
expected_result = {"parsers": {"lcov": {"partials_as_hits": True}}}
896+
result = validate_yaml(user_input)
897+
assert result == expected_result
898+
899+
def test_validate_lcov_partials_as_hits_false(self):
900+
user_input = {"parsers": {"lcov": {"partials_as_hits": False}}}
901+
expected_result = {"parsers": {"lcov": {"partials_as_hits": False}}}
902+
result = validate_yaml(user_input)
903+
assert result == expected_result
904+
893905
@pytest.mark.parametrize(
894906
"input, expected",
895907
[

0 commit comments

Comments
 (0)