Skip to content

Commit 3453353

Browse files
mkarleskyclaude
andcommitted
Gcov plugin: expand unit test coverage, two small production fixes
CI's coverage report on PR #1261 revealed the Gcov plugin's just-added unit coverage was still thin relative to file size. This adds ~172 new examples targeting core branching, parsing, and dispatch logic across the four under-covered files -- explicitly avoiding tests that just mirror a single line of production code back as an assertion. Coverage: console_reportinator.rb 18% -> 90% (15/81 -> 75/83) gcov.rb 22% -> 79% (40/182 -> 143/182) gcovr_reportinator.rb 44% -> 93% (88/200 -> 185/200) reportgenerator_reportinator.rb 59% -> 85% (67/113 -> 97/114) gcov_reportinator.rb 86% -> 93% (36/42 -> 39/42, untouched -- already well covered) New spec file: spec/units/plugins/console_reportinator_spec.rb (24 examples) covering the real gcov-text-output parsing logic (remap_partial_sources, extract_gcov_source_path, log_coverage_report, run_gcov_summary) that had zero coverage before. Extended spec/units/plugins/gcov_spec.rb (+41 examples): process_untested_sources (the largest single gap -- all three :ignore/:list/:compile modes, MC/DC flag injection, ShellException guidance handling), generate_coverage_reports (reportinator memoization, per-reportinator exception isolation as a build failure rather than aborting the whole build), post_build's gating chain, build_reportinators' utility-to-class dispatch, validate_untested_sources/ validate_utilities_config, collect_untested_sources, post_test_fixture_execute, and the MC/DC-flag-injection pre_test_compile_register/pre_test_link_register paths. Deliberately excludes thin one-line predicates, pure field-copies, and setup() itself (would need an enormous collaborator double for wiring already covered piecemeal by the extracted methods above). Extended spec/units/plugins/gcovr_reportinator_spec.rb (+39 examples): the four args_builder_* report-type methods, generate_reports_modern's skip-when-nothing- enabled branch, collect_gcovr_opts' config-file-vs-normal exclusion handling, run_gcovr's exception/summary flow, and the two previously-untested pure text parsers extract_gcovr_error_message/extract_gcovr_summary (including the multiple-summary-blocks-picks-the-last-one case a naive test would get wrong). Extended spec/units/plugins/reportgenerator_reportinator_spec.rb (+10 examples): build_report_types, and -- after a small testability refactor (below) -- run_gcov's gcov-output filename-extraction/rename regex logic (the biggest previously-untested gap in this file) and generate_gcov_files' directory discovery/sort/exclude logic. Two small production changes, made while designing the above: 1. ConsoleReportinator#log_coverage_report: a Partial-implementation source whose gcov output can't be matched at all previously produced no report AND no log -- silently different from the equivalent non-Partial "found no coverage results" case just below it. Added the same COMPLAIN log to that branch. 2. ReportGeneratorReportinator previously called Dir.glob/File.exist?/ File.rename directly in generate_gcov_files, run_reportgenerator, and run_gcov -- real filesystem I/O with no mockable seam, which is exactly why those methods had zero unit coverage. Routed all of it through FileWrapper (lib/ceedling/file_wrapper.rb), the same DI-friendly abstraction Gcov itself already uses: Dir.glob -> @file_wrapper.directory_listing, File.exist? -> @file_wrapper.exist?, File.rename -> @file_wrapper.mv. Added @file_wrapper = @ceedling[:file_wrapper] to initialize. No unit spec in this PR touches a real file or directory. Verified: full unit suite (3107 examples, 0 failures), mkdocs build --strict, and the full Gcov system-test suite (26 examples, 0 failures, 2 pending -- gdb unavailable, unrelated) run against real gcc/gcovr/reportgenerator via throwtheswitch/madsciencelab-plugins Docker, confirming the FileWrapper refactor and the log_coverage_report fix behave identically end-to-end. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 4cd0c2b commit 3453353

6 files changed

Lines changed: 1096 additions & 15 deletions

File tree

plugins/gcov/lib/console_reportinator.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,13 @@ def log_coverage_report(test, source, results, gcov_source)
178178
section = section.reject { |line| line.include?( File.basename( source,'.*') ) }
179179
report = section.map { |line| report_name + ' | ' + line }.join('')
180180
@loginator.log( report )
181+
else
182+
# A Partial whose remapped name still doesn't match any File header in gcov's
183+
# output -- without this, a Partial's unparseable coverage silently produces no
184+
# report and no log at all, unlike the equivalent non-Partial case just below.
185+
@loginator.lazy( Verbosity::COMPLAIN ) do
186+
"Found no coverage results for #{test}::#{File.basename(source)}"
187+
end
181188
end
182189

183190
# Otherwise, found no coverage results

plugins/gcov/lib/reportgenerator_reportinator.rb

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ def initialize(system_objects, config)
4545
@tool_executor = @ceedling[:tool_executor]
4646
@configurator = @ceedling[:configurator]
4747
@batchinator = @ceedling[:batchinator]
48+
@file_wrapper = @ceedling[:file_wrapper]
4849

4950
# Mutex that serializes each gcov subprocess + rename pair (see run_gcov).
5051
@gcov_cwd_mutex = Mutex.new
@@ -225,15 +226,15 @@ def generate_gcov_files(gcno_exclude_regex)
225226
# (untested sources placed there by process_untested_sources).
226227
# gcov handles a missing .gcda gracefully; it produces a .gcov with 0% coverage —
227228
# so no .gcda guard needed.
228-
gcno_dirs = Dir.glob(File.join(GCOV_BUILD_OUTPUT_PATH, "**", "*#{EXTENSION_GCNO}"))
229+
gcno_dirs = @file_wrapper.directory_listing(File.join(GCOV_BUILD_OUTPUT_PATH, "**", "*#{EXTENSION_GCNO}"))
229230
.map { |f| File.dirname(f) }
230231
.uniq
231232
.sort
232233

233234
# Pre-compute the sorted, filtered file list for each dir before dispatching.
234235
# filter_map drops empty dirs so Batchinator never queues no-op work items.
235236
work_items = gcno_dirs.filter_map do |gcno_dir|
236-
files = Dir.glob(File.join(gcno_dir, "*#{EXTENSION_GCNO}"))
237+
files = @file_wrapper.directory_listing(File.join(gcno_dir, "*#{EXTENSION_GCNO}"))
237238
.reject { |f| gcno_exclude_regex && f =~ gcno_exclude_regex }
238239
# Sort to process non-partial source files before their partial counterparts.
239240
# Processing the coverage compiled version of the original source creates .gcov
@@ -256,7 +257,7 @@ def generate_gcov_files(gcno_exclude_regex)
256257
# Run ReportGenerator if .gcov files are present. Returns the shell result, or nil
257258
# with a complaint log if no .gcov files were produced by the gcov step.
258259
def run_reportgenerator(opts, rg_opts)
259-
unless Dir.glob(File.join(GCOV_BUILD_OUTPUT_PATH, "**", "*#{EXTENSION_GCOV}")).length > 0
260+
unless @file_wrapper.directory_listing(File.join(GCOV_BUILD_OUTPUT_PATH, "**", "*#{EXTENSION_GCOV}")).length > 0
260261
@loginator.log( "No matching .gcno coverage files found", Verbosity::COMPLAIN )
261262
return nil
262263
end
@@ -362,7 +363,7 @@ def run_gcov(gcno_filepath, source_prefix)
362363
dest = File.join(gcno_dir, File.basename(gcov_file))
363364

364365
# Move the generated file after extracting its filename from `gcov` output
365-
File.rename(gcov_file, dest) if File.exist?(gcov_file) && gcov_file != dest
366+
@file_wrapper.mv(gcov_file, dest) if @file_wrapper.exist?(gcov_file) && gcov_file != dest
366367
end
367368

368369
return shell_result
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
# =========================================================================
2+
# Ceedling - Test-Centered Build System for C
3+
# ThrowTheSwitch.org
4+
# Copyright (c) 2010-26 Mike Karlesky, Mark VanderVoord, & Greg Williams
5+
# SPDX-License-Identifier: MIT
6+
# =========================================================================
7+
8+
require 'spec_helper'
9+
require 'ceedling/constants'
10+
require 'ceedling/exceptions'
11+
require 'ceedling/path_mirror'
12+
13+
PROJECT_BUILD_ROOT = 'build' unless defined?(PROJECT_BUILD_ROOT)
14+
PROJECT_BUILD_ARTIFACTS_ROOT = 'artifacts' unless defined?(PROJECT_BUILD_ARTIFACTS_ROOT)
15+
16+
$: << File.expand_path('../../../../plugins/gcov/lib', __FILE__)
17+
18+
require 'gcov_constants'
19+
require 'gcov_types'
20+
require 'gcov_reportinator'
21+
require 'console_reportinator'
22+
23+
TOOLS_GCOV_SUMMARY = { name: 'gcov_summary' }.freeze unless defined?(TOOLS_GCOV_SUMMARY)
24+
25+
describe ConsoleReportinator do
26+
let(:loginator) { double('loginator', log: nil, lazy: nil) }
27+
let(:plugin_reportinator) { double('plugin_reportinator', generate_banner: 'BANNER', generate_heading: 'HEADING') }
28+
let(:test_invoker) { double('test_invoker') }
29+
let(:tool_executor) { double('tool_executor') }
30+
let(:configurator) do
31+
double('configurator', paths_source: ['src'], paths_support: ['support'])
32+
end
33+
let(:system_objects) do
34+
{
35+
configurator: configurator,
36+
loginator: loginator,
37+
plugin_reportinator: plugin_reportinator,
38+
test_invoker: test_invoker,
39+
tool_executor: tool_executor,
40+
}
41+
end
42+
43+
let(:reportinator) { described_class.new(system_objects, {}) }
44+
45+
describe '#remap_partial_sources' do
46+
it 'passes sources through unchanged when there are no Partials' do
47+
sources = ['src/foo.c', 'src/bar.c']
48+
expect( reportinator.send(:remap_partial_sources, sources) ).to eq(sources)
49+
end
50+
51+
it 'drops the original source a Partial replaces' do
52+
sources = ['src/foo.c', 'src/ceedling_partial_foo_impl.c']
53+
result = reportinator.send(:remap_partial_sources, sources)
54+
expect(result).to eq(['src/ceedling_partial_foo_impl.c'])
55+
end
56+
57+
it 'leaves an unrelated source alone when its module has no matching Partial' do
58+
sources = ['src/foo.c', 'src/bar.c', 'src/ceedling_partial_foo_impl.c']
59+
result = reportinator.send(:remap_partial_sources, sources)
60+
expect(result).to eq(['src/bar.c', 'src/ceedling_partial_foo_impl.c'])
61+
end
62+
63+
it 'handles multiple Partials, each dropping only its own original' do
64+
sources = ['src/foo.c', 'src/bar.c', 'src/ceedling_partial_foo_impl.c', 'src/ceedling_partial_bar_impl.c']
65+
result = reportinator.send(:remap_partial_sources, sources)
66+
expect(result).to eq(['src/ceedling_partial_foo_impl.c', 'src/ceedling_partial_bar_impl.c'])
67+
end
68+
end
69+
70+
describe '#extract_gcov_source_path' do
71+
it 'matches the File header for the queried source filename among several' do
72+
results = "File '/proj/src/stdio_stub.h'\nLines executed:10.00% of 1\nFile '/proj/src/foo.c'\nLines executed:80.00% of 5\n"
73+
path = reportinator.send(:extract_gcov_source_path, results, 'test_foo', 'src/foo.c')
74+
expect(path).to eq(File.expand_path('/proj/src/foo.c'))
75+
end
76+
77+
it 'falls back to the first File header when the exact filename never appears (Partial remapping)' do
78+
results = "File '/proj/src/foo.c'\nLines executed:80.00% of 5\n"
79+
path = reportinator.send(:extract_gcov_source_path, results, 'test_foo', 'src/ceedling_partial_foo_impl.c')
80+
expect(path).to eq(File.expand_path('/proj/src/foo.c'))
81+
end
82+
83+
it 'returns an empty string and logs when no File header can be parsed at all' do
84+
expect(loginator).to receive(:lazy).with(Verbosity::DEBUG, LogLabels::ERROR)
85+
path = reportinator.send(:extract_gcov_source_path, "no file headers here\n", 'test_foo', 'src/foo.c')
86+
expect(path).to eq('')
87+
end
88+
end
89+
90+
describe '#log_coverage_report' do
91+
let(:results) do
92+
"File '/proj/src/foo.c'\n" \
93+
"Lines executed:80.00% of 5\n" \
94+
"File '/proj/src/bar.c'\n" \
95+
"Lines executed:50.00% of 2\n"
96+
end
97+
98+
it 'extracts and logs only the section between the matching File header and the next one' do
99+
expect(loginator).to receive(:log).with("foo.c | Lines executed:80.00% of 5\n")
100+
reportinator.send(:log_coverage_report, 'test_foo', 'src/foo.c', results, File.expand_path('src/foo.c'))
101+
end
102+
103+
it 'uses the original module name (gcov_source) for a Partial, not the Partial filename' do
104+
expect(loginator).to receive(:log).with("foo.c | Lines executed:80.00% of 5\n")
105+
reportinator.send(
106+
:log_coverage_report, 'test_foo', 'src/ceedling_partial_foo_impl.c', results, File.expand_path('src/foo.c')
107+
)
108+
end
109+
110+
it 'logs a COMPLAIN and does not crash when a Partial\'s coverage cannot be matched at all' do
111+
expect(loginator).to receive(:lazy).with(Verbosity::COMPLAIN)
112+
reportinator.send(:log_coverage_report, 'test_foo', 'src/ceedling_partial_foo_impl.c', results, '')
113+
end
114+
115+
it 'logs a COMPLAIN for a non-Partial source with no matching coverage results' do
116+
expect(loginator).to receive(:lazy).with(Verbosity::COMPLAIN)
117+
reportinator.send(:log_coverage_report, 'test_foo', 'src/other.c', results, File.expand_path('src/elsewhere.c'))
118+
end
119+
120+
it 'filters out gcov informational lines that echo the source filename' do
121+
noisy_results = "File '/proj/src/foo.c'\nfoo.c: some gcov note\nLines executed:80.00% of 5\n"
122+
expect(loginator).to receive(:log).with("foo.c | Lines executed:80.00% of 5\n")
123+
reportinator.send(:log_coverage_report, 'test_foo', 'src/foo.c', noisy_results, File.expand_path('src/foo.c'))
124+
end
125+
end
126+
127+
describe '#run_gcov_summary' do
128+
# build_command_line's real return shape always includes :options -- run_gcov_summary
129+
# mutates command[:options][:boom], so the stub must include an empty :options hash.
130+
def stub_exec(exit_code:, output:)
131+
allow(tool_executor).to receive(:build_command_line).and_return({ options: {} })
132+
allow(tool_executor).to receive(:exec).and_return({ exit_code: exit_code, output: output })
133+
end
134+
135+
it 'passes -g only when :gcov_mcdc is configured' do
136+
stub_exec(exit_code: 0, output: 'coverage text')
137+
expect(tool_executor).to receive(:build_command_line).with(TOOLS_GCOV_SUMMARY, ['-g'], any_args).and_return({ options: {} })
138+
reportinator.send(:run_gcov_summary, 'test_foo', 'src/foo.c', { gcov_mcdc: true })
139+
end
140+
141+
it 'omits -g when :gcov_mcdc is not configured' do
142+
stub_exec(exit_code: 0, output: 'coverage text')
143+
expect(tool_executor).to receive(:build_command_line).with(TOOLS_GCOV_SUMMARY, [], any_args).and_return({ options: {} })
144+
reportinator.send(:run_gcov_summary, 'test_foo', 'src/foo.c', {})
145+
end
146+
147+
it 'searches a nested source\'s mirrored subdirectory, not the flat test-output root' do
148+
allow(configurator).to receive(:paths_source).and_return(['src'])
149+
allow(configurator).to receive(:paths_support).and_return([])
150+
stub_exec(exit_code: 0, output: 'coverage text')
151+
expect(tool_executor).to receive(:build_command_line)
152+
.with(TOOLS_GCOV_SUMMARY, [], 'foo.c', File.join('build/gcov/out/test_foo', 'nested'))
153+
.and_return({ options: {} })
154+
reportinator.send(:run_gcov_summary, 'test_foo', 'src/nested/foo.c', {})
155+
end
156+
157+
it 'searches the flat test-output root for a source directly under a configured path' do
158+
allow(configurator).to receive(:paths_source).and_return(['src'])
159+
allow(configurator).to receive(:paths_support).and_return([])
160+
stub_exec(exit_code: 0, output: 'coverage text')
161+
expect(tool_executor).to receive(:build_command_line)
162+
.with(TOOLS_GCOV_SUMMARY, [], 'foo.c', 'build/gcov/out/test_foo')
163+
.and_return({ options: {} })
164+
reportinator.send(:run_gcov_summary, 'test_foo', 'src/foo.c', {})
165+
end
166+
167+
it 'returns nil and logs when gcov exits non-zero' do
168+
stub_exec(exit_code: 1, output: 'gcov: error')
169+
expect(loginator).to receive(:lazy).with(Verbosity::DEBUG, LogLabels::ERROR)
170+
expect(loginator).to receive(:lazy).with(Verbosity::COMPLAIN)
171+
expect( reportinator.send(:run_gcov_summary, 'test_foo', 'src/foo.c', {}) ).to be_nil
172+
end
173+
174+
it 'returns nil and logs when gcov succeeds but produces blank output' do
175+
stub_exec(exit_code: 0, output: ' ')
176+
expect(loginator).to receive(:lazy).with(Verbosity::COMPLAIN, LogLabels::NOTICE)
177+
expect( reportinator.send(:run_gcov_summary, 'test_foo', 'src/foo.c', {}) ).to be_nil
178+
end
179+
180+
it 'returns the stripped output on success' do
181+
stub_exec(exit_code: 0, output: " coverage text \n")
182+
expect( reportinator.send(:run_gcov_summary, 'test_foo', 'src/foo.c', {}) ).to eq('coverage text')
183+
end
184+
185+
it 'never raises on a non-zero exit (boom disabled for the summary tool)' do
186+
command_capture = nil
187+
allow(tool_executor).to receive(:build_command_line) { |*| command_capture = { options: {} }; command_capture }
188+
allow(tool_executor).to receive(:exec).and_return({ exit_code: 1, output: 'error' })
189+
reportinator.send(:run_gcov_summary, 'test_foo', 'src/foo.c', {})
190+
expect(command_capture[:options]).to eq({ boom: false })
191+
end
192+
end
193+
194+
describe '#log_untested_sources_section' do
195+
it 'sorts entries by basename, not full path' do
196+
expect(loginator).to receive(:log).with('a.c | No tests executed: 0% coverage').ordered
197+
expect(loginator).to receive(:log).with('z.c | No tests executed: 0% coverage').ordered
198+
# 'zzz/a.c' sorts after 'aaa/z.c' by full path, but must log a.c first by basename.
199+
reportinator.send(:log_untested_sources_section, ['aaa/z.c', 'zzz/a.c'])
200+
end
201+
end
202+
203+
describe '#generate_reports' do
204+
it 'skips extraction/logging for a source whose gcov summary comes back nil' do
205+
allow(test_invoker).to receive(:each_test_with_sources).and_yield('test_foo', ['src/foo.c'])
206+
allow(reportinator).to receive(:remap_partial_sources).and_return(['src/foo.c'])
207+
allow(reportinator).to receive(:run_gcov_summary).and_return(nil)
208+
expect(reportinator).to_not receive(:extract_gcov_source_path)
209+
expect(reportinator).to_not receive(:log_coverage_report)
210+
211+
reportinator.generate_reports({})
212+
end
213+
214+
it 'skips the untested-sources section when the list is empty' do
215+
allow(test_invoker).to receive(:each_test_with_sources)
216+
expect(reportinator).to_not receive(:log_untested_sources_section)
217+
reportinator.generate_reports({}, untested_sources: [])
218+
end
219+
220+
it 'logs the untested-sources section when the list is non-empty' do
221+
allow(test_invoker).to receive(:each_test_with_sources)
222+
expect(reportinator).to receive(:log_untested_sources_section).with(['src/untested.c'])
223+
reportinator.generate_reports({}, untested_sources: ['src/untested.c'])
224+
end
225+
end
226+
end

0 commit comments

Comments
 (0)