cov-loupe 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +329 -0
- data/docs/dev/ARCHITECTURE.md +80 -0
- data/docs/dev/BRANCH_ONLY_COVERAGE.md +158 -0
- data/docs/dev/DEVELOPMENT.md +83 -0
- data/docs/dev/README.md +10 -0
- data/docs/dev/RELEASING.md +146 -0
- data/docs/dev/arch-decisions/001-x-arch-decision.md +95 -0
- data/docs/dev/arch-decisions/002-x-arch-decision.md +159 -0
- data/docs/dev/arch-decisions/003-x-arch-decision.md +165 -0
- data/docs/dev/arch-decisions/004-x-arch-decision.md +203 -0
- data/docs/dev/arch-decisions/005-x-arch-decision.md +189 -0
- data/docs/dev/arch-decisions/README.md +60 -0
- data/docs/dev/presentations/cov-loupe-presentation.md +255 -0
- data/docs/fixtures/demo_project/README.md +9 -0
- data/docs/user/ADVANCED_USAGE.md +777 -0
- data/docs/user/CLI_FALLBACK_FOR_LLMS.md +34 -0
- data/docs/user/CLI_USAGE.md +750 -0
- data/docs/user/ERROR_HANDLING.md +93 -0
- data/docs/user/EXAMPLES.md +588 -0
- data/docs/user/INSTALLATION.md +130 -0
- data/docs/user/LIBRARY_API.md +693 -0
- data/docs/user/MCP_INTEGRATION.md +490 -0
- data/docs/user/README.md +14 -0
- data/docs/user/TROUBLESHOOTING.md +197 -0
- data/docs/user/V2-BREAKING-CHANGES.md +472 -0
- data/exe/cov-loupe +23 -0
- data/lib/cov_loupe/app_config.rb +56 -0
- data/lib/cov_loupe/app_context.rb +26 -0
- data/lib/cov_loupe/base_tool.rb +102 -0
- data/lib/cov_loupe/cli.rb +178 -0
- data/lib/cov_loupe/commands/base_command.rb +67 -0
- data/lib/cov_loupe/commands/command_factory.rb +45 -0
- data/lib/cov_loupe/commands/detailed_command.rb +38 -0
- data/lib/cov_loupe/commands/list_command.rb +13 -0
- data/lib/cov_loupe/commands/raw_command.rb +38 -0
- data/lib/cov_loupe/commands/summary_command.rb +41 -0
- data/lib/cov_loupe/commands/totals_command.rb +53 -0
- data/lib/cov_loupe/commands/uncovered_command.rb +45 -0
- data/lib/cov_loupe/commands/validate_command.rb +60 -0
- data/lib/cov_loupe/commands/version_command.rb +33 -0
- data/lib/cov_loupe/config_parser.rb +32 -0
- data/lib/cov_loupe/constants.rb +22 -0
- data/lib/cov_loupe/coverage_reporter.rb +31 -0
- data/lib/cov_loupe/error_handler.rb +165 -0
- data/lib/cov_loupe/error_handler_factory.rb +31 -0
- data/lib/cov_loupe/errors.rb +191 -0
- data/lib/cov_loupe/formatters/source_formatter.rb +152 -0
- data/lib/cov_loupe/formatters.rb +51 -0
- data/lib/cov_loupe/mcp_server.rb +42 -0
- data/lib/cov_loupe/mode_detector.rb +56 -0
- data/lib/cov_loupe/model.rb +339 -0
- data/lib/cov_loupe/option_normalizers.rb +113 -0
- data/lib/cov_loupe/option_parser_builder.rb +147 -0
- data/lib/cov_loupe/option_parsers/env_options_parser.rb +48 -0
- data/lib/cov_loupe/option_parsers/error_helper.rb +110 -0
- data/lib/cov_loupe/path_relativizer.rb +64 -0
- data/lib/cov_loupe/predicate_evaluator.rb +72 -0
- data/lib/cov_loupe/presenters/base_coverage_presenter.rb +42 -0
- data/lib/cov_loupe/presenters/coverage_detailed_presenter.rb +14 -0
- data/lib/cov_loupe/presenters/coverage_raw_presenter.rb +14 -0
- data/lib/cov_loupe/presenters/coverage_summary_presenter.rb +14 -0
- data/lib/cov_loupe/presenters/coverage_uncovered_presenter.rb +14 -0
- data/lib/cov_loupe/presenters/project_coverage_presenter.rb +50 -0
- data/lib/cov_loupe/presenters/project_totals_presenter.rb +27 -0
- data/lib/cov_loupe/resolvers/coverage_line_resolver.rb +122 -0
- data/lib/cov_loupe/resolvers/resolver_factory.rb +28 -0
- data/lib/cov_loupe/resolvers/resultset_path_resolver.rb +76 -0
- data/lib/cov_loupe/resultset_loader.rb +131 -0
- data/lib/cov_loupe/staleness_checker.rb +247 -0
- data/lib/cov_loupe/table_formatter.rb +64 -0
- data/lib/cov_loupe/tools/all_files_coverage_tool.rb +51 -0
- data/lib/cov_loupe/tools/coverage_detailed_tool.rb +35 -0
- data/lib/cov_loupe/tools/coverage_raw_tool.rb +34 -0
- data/lib/cov_loupe/tools/coverage_summary_tool.rb +34 -0
- data/lib/cov_loupe/tools/coverage_table_tool.rb +50 -0
- data/lib/cov_loupe/tools/coverage_totals_tool.rb +44 -0
- data/lib/cov_loupe/tools/help_tool.rb +115 -0
- data/lib/cov_loupe/tools/uncovered_lines_tool.rb +34 -0
- data/lib/cov_loupe/tools/validate_tool.rb +72 -0
- data/lib/cov_loupe/tools/version_tool.rb +32 -0
- data/lib/cov_loupe/util.rb +88 -0
- data/lib/cov_loupe/version.rb +5 -0
- data/lib/cov_loupe.rb +140 -0
- data/spec/MCP_INTEGRATION_TESTS_README.md +111 -0
- data/spec/TIMESTAMPS.md +48 -0
- data/spec/all_files_coverage_tool_spec.rb +53 -0
- data/spec/app_config_spec.rb +142 -0
- data/spec/base_tool_spec.rb +62 -0
- data/spec/cli/show_default_report_spec.rb +33 -0
- data/spec/cli_enumerated_options_spec.rb +90 -0
- data/spec/cli_error_spec.rb +184 -0
- data/spec/cli_format_spec.rb +123 -0
- data/spec/cli_json_options_spec.rb +50 -0
- data/spec/cli_source_spec.rb +44 -0
- data/spec/cli_spec.rb +192 -0
- data/spec/cli_table_spec.rb +28 -0
- data/spec/cli_usage_spec.rb +42 -0
- data/spec/commands/base_command_spec.rb +107 -0
- data/spec/commands/command_factory_spec.rb +76 -0
- data/spec/commands/detailed_command_spec.rb +34 -0
- data/spec/commands/list_command_spec.rb +28 -0
- data/spec/commands/raw_command_spec.rb +69 -0
- data/spec/commands/summary_command_spec.rb +34 -0
- data/spec/commands/totals_command_spec.rb +34 -0
- data/spec/commands/uncovered_command_spec.rb +55 -0
- data/spec/commands/validate_command_spec.rb +213 -0
- data/spec/commands/version_command_spec.rb +38 -0
- data/spec/constants_spec.rb +61 -0
- data/spec/cov_loupe/formatters/source_formatter_spec.rb +267 -0
- data/spec/cov_loupe/formatters_spec.rb +76 -0
- data/spec/cov_loupe/presenters/base_coverage_presenter_spec.rb +79 -0
- data/spec/cov_loupe_model_spec.rb +454 -0
- data/spec/cov_loupe_module_spec.rb +37 -0
- data/spec/cov_loupe_opts_spec.rb +185 -0
- data/spec/coverage_reporter_spec.rb +102 -0
- data/spec/coverage_table_tool_spec.rb +59 -0
- data/spec/coverage_totals_tool_spec.rb +37 -0
- data/spec/error_handler_spec.rb +197 -0
- data/spec/error_mode_spec.rb +139 -0
- data/spec/errors_edge_cases_spec.rb +312 -0
- data/spec/errors_stale_spec.rb +83 -0
- data/spec/file_based_mcp_tools_spec.rb +99 -0
- data/spec/fixtures/project1/lib/bar.rb +5 -0
- data/spec/fixtures/project1/lib/foo.rb +6 -0
- data/spec/help_tool_spec.rb +26 -0
- data/spec/integration_spec.rb +789 -0
- data/spec/logging_fallback_spec.rb +128 -0
- data/spec/mcp_logging_spec.rb +44 -0
- data/spec/mcp_server_integration_spec.rb +23 -0
- data/spec/mcp_server_spec.rb +106 -0
- data/spec/mode_detector_spec.rb +153 -0
- data/spec/model_error_handling_spec.rb +269 -0
- data/spec/model_staleness_spec.rb +79 -0
- data/spec/option_normalizers_spec.rb +203 -0
- data/spec/option_parsers/env_options_parser_spec.rb +221 -0
- data/spec/option_parsers/error_helper_spec.rb +222 -0
- data/spec/path_relativizer_spec.rb +98 -0
- data/spec/presenters/coverage_detailed_presenter_spec.rb +19 -0
- data/spec/presenters/coverage_raw_presenter_spec.rb +15 -0
- data/spec/presenters/coverage_summary_presenter_spec.rb +15 -0
- data/spec/presenters/coverage_uncovered_presenter_spec.rb +16 -0
- data/spec/presenters/project_coverage_presenter_spec.rb +87 -0
- data/spec/presenters/project_totals_presenter_spec.rb +144 -0
- data/spec/resolvers/coverage_line_resolver_spec.rb +282 -0
- data/spec/resolvers/resolver_factory_spec.rb +61 -0
- data/spec/resolvers/resultset_path_resolver_spec.rb +60 -0
- data/spec/resultset_loader_spec.rb +167 -0
- data/spec/shared_examples/README.md +115 -0
- data/spec/shared_examples/coverage_presenter_examples.rb +66 -0
- data/spec/shared_examples/file_based_mcp_tools.rb +179 -0
- data/spec/shared_examples/formatted_command_examples.rb +64 -0
- data/spec/shared_examples/mcp_tool_text_json_response.rb +16 -0
- data/spec/spec_helper.rb +127 -0
- data/spec/staleness_checker_spec.rb +374 -0
- data/spec/staleness_more_spec.rb +42 -0
- data/spec/support/cli_helpers.rb +22 -0
- data/spec/support/control_flow_helpers.rb +20 -0
- data/spec/support/fake_mcp.rb +40 -0
- data/spec/support/io_helpers.rb +29 -0
- data/spec/support/mcp_helpers.rb +35 -0
- data/spec/support/mcp_runner.rb +66 -0
- data/spec/support/mocking_helpers.rb +30 -0
- data/spec/table_format_spec.rb +70 -0
- data/spec/tools/validate_tool_spec.rb +132 -0
- data/spec/tools_error_handling_spec.rb +130 -0
- data/spec/util_spec.rb +154 -0
- data/spec/version_spec.rb +123 -0
- data/spec/version_tool_spec.rb +141 -0
- metadata +290 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe CovLoupe::Presenters::ProjectCoveragePresenter do
|
|
6
|
+
subject(:presenter) do
|
|
7
|
+
described_class.new(
|
|
8
|
+
model: model,
|
|
9
|
+
sort_order: sort_order,
|
|
10
|
+
check_stale: check_stale,
|
|
11
|
+
tracked_globs: tracked_globs
|
|
12
|
+
)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
let(:model) { instance_double(CovLoupe::CoverageModel) }
|
|
16
|
+
let(:sort_order) { :ascending }
|
|
17
|
+
let(:check_stale) { true }
|
|
18
|
+
let(:tracked_globs) { ['lib/**/*.rb'] }
|
|
19
|
+
let(:files) do
|
|
20
|
+
[
|
|
21
|
+
{
|
|
22
|
+
'file' => '/abs/path/lib/foo.rb',
|
|
23
|
+
'covered' => 5,
|
|
24
|
+
'total' => 6,
|
|
25
|
+
'percentage' => 83.33,
|
|
26
|
+
'stale' => false
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
'file' => '/abs/path/lib/bar.rb',
|
|
30
|
+
'covered' => 1,
|
|
31
|
+
'total' => 6,
|
|
32
|
+
'percentage' => 16.67,
|
|
33
|
+
'stale' => 'L'
|
|
34
|
+
}
|
|
35
|
+
]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
before do
|
|
40
|
+
allow(model).to receive(:all_files).with(sort_order: sort_order, check_stale: check_stale,
|
|
41
|
+
tracked_globs: tracked_globs).and_return(files)
|
|
42
|
+
allow(model).to receive(:relativize) do |payload|
|
|
43
|
+
relativizer = CovLoupe::PathRelativizer.new(
|
|
44
|
+
root: '/abs/path',
|
|
45
|
+
scalar_keys: %w[file file_path],
|
|
46
|
+
array_keys: %w[newer_files missing_files deleted_files]
|
|
47
|
+
)
|
|
48
|
+
relativizer.relativize(payload)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
describe '#absolute_payload' do
|
|
53
|
+
it 'returns files and counts with stale metadata' do
|
|
54
|
+
payload = presenter.absolute_payload
|
|
55
|
+
|
|
56
|
+
expect(payload['files']).to eq(files)
|
|
57
|
+
expect(payload['counts']).to eq({ 'total' => 2, 'ok' => 1, 'stale' => 1 })
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it 'memoizes the computed payload' do
|
|
61
|
+
presenter.absolute_payload
|
|
62
|
+
presenter.absolute_payload
|
|
63
|
+
|
|
64
|
+
expect(model).to have_received(:all_files).once
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
describe '#relativized_payload' do
|
|
69
|
+
it 'relativizes the files list' do
|
|
70
|
+
relativized = presenter.relativized_payload
|
|
71
|
+
|
|
72
|
+
expect(relativized['files'].map { |f| f['file'] }).to eq(['lib/foo.rb', 'lib/bar.rb'])
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
describe '#relative_files' do
|
|
77
|
+
it 'returns the relativized file list' do
|
|
78
|
+
expect(presenter.relative_files.map { |f| f['file'] }).to eq(['lib/foo.rb', 'lib/bar.rb'])
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
describe '#relative_counts' do
|
|
83
|
+
it 'returns the relativized counts hash' do
|
|
84
|
+
expect(presenter.relative_counts).to eq({ 'total' => 2, 'ok' => 1, 'stale' => 1 })
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe CovLoupe::Presenters::ProjectTotalsPresenter do
|
|
6
|
+
subject(:presenter) do
|
|
7
|
+
described_class.new(
|
|
8
|
+
model: model,
|
|
9
|
+
check_stale: true,
|
|
10
|
+
tracked_globs: ['lib/**/*.rb']
|
|
11
|
+
)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
let(:model) { instance_double(CovLoupe::CoverageModel) }
|
|
15
|
+
let(:raw_totals) do
|
|
16
|
+
{
|
|
17
|
+
'lines' => { 'total' => 100, 'covered' => 80, 'uncovered' => 20 },
|
|
18
|
+
'percentage' => 80.0,
|
|
19
|
+
'files' => { 'total' => 10, 'ok' => 9, 'stale' => 1 }
|
|
20
|
+
}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
before do
|
|
24
|
+
allow(model).to receive(:project_totals)
|
|
25
|
+
.with(tracked_globs: ['lib/**/*.rb'], check_stale: true)
|
|
26
|
+
.and_return(raw_totals)
|
|
27
|
+
allow(model).to receive(:relativize) { |payload| payload }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
describe '#initialize' do
|
|
31
|
+
it 'stores the model, check_stale, and tracked_globs options' do
|
|
32
|
+
expect(presenter.model).to eq(model)
|
|
33
|
+
expect(presenter.check_stale).to be(true)
|
|
34
|
+
expect(presenter.tracked_globs).to eq(['lib/**/*.rb'])
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
describe '#absolute_payload' do
|
|
39
|
+
it 'returns project totals from the model' do
|
|
40
|
+
result = presenter.absolute_payload
|
|
41
|
+
|
|
42
|
+
expect(result).to include('lines', 'percentage', 'files')
|
|
43
|
+
expect(result['lines']).to include('total' => 100, 'covered' => 80, 'uncovered' => 20)
|
|
44
|
+
expect(result['percentage']).to eq(80.0)
|
|
45
|
+
expect(result['files']).to include('total' => 10, 'ok' => 9, 'stale' => 1)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it 'caches the result on subsequent calls' do
|
|
49
|
+
presenter.absolute_payload
|
|
50
|
+
presenter.absolute_payload
|
|
51
|
+
|
|
52
|
+
expect(model).to have_received(:project_totals).once
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it 'passes tracked_globs to the model' do
|
|
56
|
+
presenter.absolute_payload
|
|
57
|
+
|
|
58
|
+
expect(model).to have_received(:project_totals)
|
|
59
|
+
.with(tracked_globs: ['lib/**/*.rb'], check_stale: true)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
describe '#relativized_payload' do
|
|
64
|
+
it 'returns the relativized payload from the model' do
|
|
65
|
+
result = presenter.relativized_payload
|
|
66
|
+
|
|
67
|
+
expect(result).to eq(raw_totals)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
it 'calls relativize on the model' do
|
|
71
|
+
presenter.relativized_payload
|
|
72
|
+
|
|
73
|
+
expect(model).to have_received(:relativize).with(raw_totals)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
it 'caches the result on subsequent calls' do
|
|
77
|
+
presenter.relativized_payload
|
|
78
|
+
presenter.relativized_payload
|
|
79
|
+
|
|
80
|
+
expect(model).to have_received(:relativize).once
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
context 'with check_stale: false' do
|
|
85
|
+
subject(:presenter) do
|
|
86
|
+
described_class.new(
|
|
87
|
+
model: model,
|
|
88
|
+
check_stale: false,
|
|
89
|
+
tracked_globs: nil
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
before do
|
|
94
|
+
allow(model).to receive(:project_totals)
|
|
95
|
+
.with(tracked_globs: nil, check_stale: false)
|
|
96
|
+
.and_return(raw_totals)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it 'passes check_stale: false to the model' do
|
|
100
|
+
presenter.absolute_payload
|
|
101
|
+
|
|
102
|
+
expect(model).to have_received(:project_totals)
|
|
103
|
+
.with(tracked_globs: nil, check_stale: false)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
context 'with empty tracked_globs' do
|
|
108
|
+
subject(:presenter) do
|
|
109
|
+
described_class.new(
|
|
110
|
+
model: model,
|
|
111
|
+
check_stale: true,
|
|
112
|
+
tracked_globs: []
|
|
113
|
+
)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
before do
|
|
117
|
+
allow(model).to receive(:project_totals)
|
|
118
|
+
.with(tracked_globs: [], check_stale: true)
|
|
119
|
+
.and_return(raw_totals)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
it 'passes empty tracked_globs to the model' do
|
|
123
|
+
presenter.absolute_payload
|
|
124
|
+
|
|
125
|
+
expect(model).to have_received(:project_totals)
|
|
126
|
+
.with(tracked_globs: [], check_stale: true)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
context 'with relativization that transforms data' do
|
|
131
|
+
before do
|
|
132
|
+
allow(model).to receive(:relativize) do |payload|
|
|
133
|
+
# Simulate relativization that might transform file paths in nested data
|
|
134
|
+
payload.merge('transformed' => true)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
it 'applies the transformation from relativize' do
|
|
139
|
+
result = presenter.relativized_payload
|
|
140
|
+
|
|
141
|
+
expect(result['transformed']).to be(true)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe CovLoupe::Resolvers::CoverageLineResolver do
|
|
6
|
+
describe '#lookup_lines' do
|
|
7
|
+
context 'with direct path matching' do
|
|
8
|
+
it 'returns lines array for exact path match' do
|
|
9
|
+
abs_path = '/project/lib/foo.rb'
|
|
10
|
+
cov_data = {
|
|
11
|
+
abs_path => { 'lines' => [1, 0, nil, 2] }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
resolver = described_class.new(cov_data)
|
|
15
|
+
lines = resolver.lookup_lines(abs_path)
|
|
16
|
+
|
|
17
|
+
expect(lines).to eq([1, 0, nil, 2])
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
it 'returns lines array when entry has lines directly' do
|
|
21
|
+
path = '/tmp/test.rb'
|
|
22
|
+
cov_data = {
|
|
23
|
+
path => { 'lines' => [1, 1, 1] }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
resolver = described_class.new(cov_data)
|
|
27
|
+
expect(resolver.lookup_lines(path)).to eq([1, 1, 1])
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
context 'with CWD stripping fallback' do
|
|
32
|
+
it 'finds relative path when absolute path includes CWD' do
|
|
33
|
+
cwd = Dir.pwd
|
|
34
|
+
relative_path = 'lib/bar.rb'
|
|
35
|
+
abs_path = File.join(cwd, relative_path)
|
|
36
|
+
cov_data = {
|
|
37
|
+
relative_path => { 'lines' => [1, 0, 1] }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
resolver = described_class.new(cov_data)
|
|
41
|
+
lines = resolver.lookup_lines(abs_path)
|
|
42
|
+
|
|
43
|
+
expect(lines).to eq([1, 0, 1])
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it 'does not match when absolute path does not start with CWD' do
|
|
47
|
+
cov_data = {
|
|
48
|
+
'lib/baz.rb' => { 'lines' => [1, 1] }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
resolver = described_class.new(cov_data)
|
|
52
|
+
|
|
53
|
+
expect do
|
|
54
|
+
resolver.lookup_lines('/other/directory/lib/baz.rb')
|
|
55
|
+
end.to raise_error(CovLoupe::FileError, /No coverage entry found/)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
context 'when handling errors' do
|
|
60
|
+
it 'raises FileError when file is not found in coverage data' do
|
|
61
|
+
cov_data = {
|
|
62
|
+
'/project/lib/foo.rb' => { 'lines' => [1, 0] }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
resolver = described_class.new(cov_data)
|
|
66
|
+
|
|
67
|
+
expect do
|
|
68
|
+
resolver.lookup_lines('/project/lib/missing.rb')
|
|
69
|
+
end.to raise_error(CovLoupe::FileError, /No coverage entry found/)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
it 'raises FileError when coverage data is empty' do
|
|
73
|
+
resolver = described_class.new({})
|
|
74
|
+
|
|
75
|
+
expect do
|
|
76
|
+
resolver.lookup_lines('/any/path.rb')
|
|
77
|
+
end.to raise_error(CovLoupe::FileError, /No coverage entry found/)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
it 'raises FileError when entry exists but has no lines or branches' do
|
|
81
|
+
cov_data = {
|
|
82
|
+
'/project/lib/foo.rb' => { 'other_key' => 'value' }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
resolver = described_class.new(cov_data)
|
|
86
|
+
|
|
87
|
+
expect do
|
|
88
|
+
resolver.lookup_lines('/project/lib/foo.rb')
|
|
89
|
+
end.to raise_error(CovLoupe::FileError, /No coverage entry found/)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
context 'with branch-only coverage synthesis' do
|
|
94
|
+
it 'synthesizes line hits when only branch coverage exists' do
|
|
95
|
+
abs_path = '/tmp/branch_only.rb'
|
|
96
|
+
branch_cov = {
|
|
97
|
+
abs_path => {
|
|
98
|
+
'lines' => nil,
|
|
99
|
+
'branches' => {
|
|
100
|
+
'[:if, 0, 5, 2, 8, 5]' => {
|
|
101
|
+
'[:then, 1, 6, 4, 6, 15]' => 3,
|
|
102
|
+
'[:else, 2, 7, 4, 7, 15]' => 0
|
|
103
|
+
},
|
|
104
|
+
'[:case, 3, 12, 2, 17, 5]' => {
|
|
105
|
+
'[:when, 4, 13, 4, 13, 14]' => 0,
|
|
106
|
+
'[:when, 5, 14, 4, 14, 14]' => 2,
|
|
107
|
+
'[:else, 6, 16, 4, 16, 12]' => 2
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
resolver = described_class.new(branch_cov)
|
|
114
|
+
lines = resolver.lookup_lines(abs_path)
|
|
115
|
+
|
|
116
|
+
expect(lines[5]).to eq(3) # line 6
|
|
117
|
+
expect(lines[6]).to eq(0) # line 7
|
|
118
|
+
expect(lines[12]).to eq(0) # line 13
|
|
119
|
+
expect(lines[13]).to eq(2) # line 14
|
|
120
|
+
expect(lines[15]).to eq(2) # line 16
|
|
121
|
+
expect(lines.count { |v| !v.nil? }).to eq(5)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
it 'aggregates hits for multiple branches on the same line' do
|
|
125
|
+
path = '/tmp/duplicated.rb'
|
|
126
|
+
branch_cov = {
|
|
127
|
+
path => {
|
|
128
|
+
'lines' => nil,
|
|
129
|
+
'branches' => {
|
|
130
|
+
'[:if, 0, 3, 2, 3, 12]' => {
|
|
131
|
+
'[:then, 1, 3, 2, 3, 12]' => 2,
|
|
132
|
+
'[:else, 2, 3, 2, 3, 12]' => 3
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
resolver = described_class.new(branch_cov)
|
|
139
|
+
lines = resolver.lookup_lines(path)
|
|
140
|
+
|
|
141
|
+
expect(lines[2]).to eq(5) # line 3 with summed hits
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
it 'handles array-style branch metadata' do
|
|
145
|
+
path = '/tmp/array_style.rb'
|
|
146
|
+
cov_data = {
|
|
147
|
+
path => {
|
|
148
|
+
'lines' => nil,
|
|
149
|
+
'branches' => {
|
|
150
|
+
[:if, 0, 5, 2, 8, 5] => {
|
|
151
|
+
[:then, 1, 6, 4, 6, 15] => 2
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
resolver = described_class.new(cov_data)
|
|
158
|
+
lines = resolver.lookup_lines(path)
|
|
159
|
+
|
|
160
|
+
expect(lines[5]).to eq(2) # line 6
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
it 'returns nil for entries with empty branches' do
|
|
164
|
+
path = '/tmp/empty_branches.rb'
|
|
165
|
+
cov_data = {
|
|
166
|
+
path => {
|
|
167
|
+
'lines' => nil,
|
|
168
|
+
'branches' => {}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
resolver = described_class.new(cov_data)
|
|
173
|
+
|
|
174
|
+
expect do
|
|
175
|
+
resolver.lookup_lines(path)
|
|
176
|
+
end.to raise_error(CovLoupe::FileError)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
it 'skips malformed branch entries' do
|
|
180
|
+
path = '/tmp/malformed.rb'
|
|
181
|
+
cov_data = {
|
|
182
|
+
path => {
|
|
183
|
+
'lines' => nil,
|
|
184
|
+
'branches' => {
|
|
185
|
+
'[:if, 0, 5, 2, 8, 5]' => {
|
|
186
|
+
'[:then, 1, 6, 4, 6, 15]' => 2
|
|
187
|
+
},
|
|
188
|
+
'malformed_key' => 'not_a_hash',
|
|
189
|
+
'[:if, 1, 10]' => { # missing elements in tuple
|
|
190
|
+
'[:then]' => 1 # also malformed
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
resolver = described_class.new(cov_data)
|
|
197
|
+
lines = resolver.lookup_lines(path)
|
|
198
|
+
|
|
199
|
+
# Should still get line 6 from the valid branch
|
|
200
|
+
expect(lines[5]).to eq(2)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
context 'with extract_line_number edge cases' do
|
|
205
|
+
let(:resolver) { described_class.new({}) }
|
|
206
|
+
|
|
207
|
+
it 'extracts line number from array metadata' do
|
|
208
|
+
result = resolver.send(:extract_line_number, [:if, 0, 10, 2, 15, 5])
|
|
209
|
+
expect(result).to eq(10)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
it 'extracts line number from stringified array metadata' do
|
|
213
|
+
result = resolver.send(:extract_line_number, '[:if, 0, 15, 2, 20, 5]')
|
|
214
|
+
expect(result).to eq(15)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
it 'returns nil for short array' do
|
|
218
|
+
result = resolver.send(:extract_line_number, [:if, 0])
|
|
219
|
+
expect(result).to be_nil
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
it 'returns nil for short string' do
|
|
223
|
+
result = resolver.send(:extract_line_number, '[:if, 0]')
|
|
224
|
+
expect(result).to be_nil
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
it 'returns nil for non-numeric line element in array' do
|
|
228
|
+
result = resolver.send(:extract_line_number, [:if, 0, 'not_a_number', 2])
|
|
229
|
+
expect(result).to be_nil
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
it 'returns nil for non-numeric line element in string' do
|
|
233
|
+
result = resolver.send(:extract_line_number, '[:if, 0, abc, 2]')
|
|
234
|
+
expect(result).to be_nil
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
it 'handles empty string' do
|
|
238
|
+
result = resolver.send(:extract_line_number, '')
|
|
239
|
+
expect(result).to be_nil
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
it 'handles nil input' do
|
|
243
|
+
result = resolver.send(:extract_line_number, nil)
|
|
244
|
+
expect(result).to be_nil
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# The rescue block catches ArgumentError/TypeError from malformed metadata
|
|
248
|
+
# that can't be converted to line numbers.
|
|
249
|
+
[ArgumentError, TypeError].each do |error_class|
|
|
250
|
+
it "returns nil when string operations raise #{error_class}" do
|
|
251
|
+
weird_object = Object.new
|
|
252
|
+
allow(weird_object).to receive(:to_s).and_raise(error_class, 'test error')
|
|
253
|
+
|
|
254
|
+
result = resolver.send(:extract_line_number, weird_object)
|
|
255
|
+
expect(result).to be_nil
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
context 'with preference for lines over branches' do
|
|
261
|
+
it 'prefers lines array when both lines and branches exist' do
|
|
262
|
+
path = '/tmp/both.rb'
|
|
263
|
+
cov_data = {
|
|
264
|
+
path => {
|
|
265
|
+
'lines' => [1, 2, 3],
|
|
266
|
+
'branches' => {
|
|
267
|
+
'[:if, 0, 100, 2, 105, 5]' => {
|
|
268
|
+
'[:then, 1, 101, 4, 101, 15]' => 99
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
resolver = described_class.new(cov_data)
|
|
275
|
+
lines = resolver.lookup_lines(path)
|
|
276
|
+
|
|
277
|
+
# Should return the lines array, not synthesized branch data
|
|
278
|
+
expect(lines).to eq([1, 2, 3])
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'tmpdir'
|
|
5
|
+
|
|
6
|
+
RSpec.describe CovLoupe::Resolvers::ResolverFactory do
|
|
7
|
+
describe '.create_resultset_resolver' do
|
|
8
|
+
it 'uses provided candidates when present' do
|
|
9
|
+
custom_candidates = ['alt/.resultset.json']
|
|
10
|
+
resolver = described_class.create_resultset_resolver(
|
|
11
|
+
root: '/tmp/sample',
|
|
12
|
+
candidates: custom_candidates
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
expect(resolver).to be_a(CovLoupe::Resolvers::ResultsetPathResolver)
|
|
16
|
+
expect(resolver.instance_variable_get(:@root)).to eq('/tmp/sample')
|
|
17
|
+
expect(resolver.instance_variable_get(:@candidates)).to eq(custom_candidates)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
it 'falls back to default candidates when none provided' do
|
|
21
|
+
resolver = described_class.create_resultset_resolver(root: '/tmp/sample')
|
|
22
|
+
|
|
23
|
+
expect(resolver.instance_variable_get(:@candidates)).to eq(
|
|
24
|
+
CovLoupe::Resolvers::ResultsetPathResolver::DEFAULT_CANDIDATES
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
describe '.create_coverage_resolver' do
|
|
30
|
+
it 'wraps coverage data in a CoverageLineResolver' do
|
|
31
|
+
cov = { '/tmp/foo.rb' => { 'lines' => [1, 0] } }
|
|
32
|
+
resolver = described_class.create_coverage_resolver(cov)
|
|
33
|
+
|
|
34
|
+
expect(resolver).to be_a(CovLoupe::Resolvers::CoverageLineResolver)
|
|
35
|
+
expect(resolver.lookup_lines('/tmp/foo.rb')).to eq([1, 0])
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
describe '.find_resultset' do
|
|
40
|
+
it 'locates default resultset within the provided root' do
|
|
41
|
+
Dir.mktmpdir do |dir|
|
|
42
|
+
resultset_path = File.join(dir, '.resultset.json')
|
|
43
|
+
File.write(resultset_path, '{}')
|
|
44
|
+
|
|
45
|
+
resolved = described_class.find_resultset(dir)
|
|
46
|
+
|
|
47
|
+
expect(resolved).to eq(resultset_path)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
describe '.lookup_lines' do
|
|
53
|
+
it 'delegates to CoverageLineResolver for lookups' do
|
|
54
|
+
cov = { '/tmp/bar.rb' => { 'lines' => [0, 1] } }
|
|
55
|
+
|
|
56
|
+
expect(
|
|
57
|
+
described_class.lookup_lines(cov, '/tmp/bar.rb')
|
|
58
|
+
).to eq([0, 1])
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'tmpdir'
|
|
5
|
+
require 'fileutils'
|
|
6
|
+
|
|
7
|
+
RSpec.describe CovLoupe::Resolvers::ResultsetPathResolver do
|
|
8
|
+
describe '#find_resultset' do
|
|
9
|
+
let(:root) { Dir.mktmpdir }
|
|
10
|
+
let(:resolver) { described_class.new(root: root) }
|
|
11
|
+
|
|
12
|
+
after do
|
|
13
|
+
FileUtils.remove_entry(root) if root && Dir.exist?(root)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it 'raises when a specified resultset file cannot be found' do
|
|
17
|
+
expect do
|
|
18
|
+
resolver.find_resultset(resultset: 'missing.json')
|
|
19
|
+
end.to raise_error(RuntimeError, /Specified resultset not found/)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it 'raises when a specified directory does not contain .resultset.json' do
|
|
23
|
+
nested_dir = File.join(root, 'coverage')
|
|
24
|
+
Dir.mkdir(nested_dir)
|
|
25
|
+
|
|
26
|
+
expect do
|
|
27
|
+
resolver.find_resultset(resultset: nested_dir)
|
|
28
|
+
end.to raise_error(RuntimeError, /No .resultset.json found in directory/)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it 'returns the resolved path when a valid resultset file is provided' do
|
|
32
|
+
file = File.join(root, 'custom.json')
|
|
33
|
+
File.write(file, '{}')
|
|
34
|
+
|
|
35
|
+
expect(resolver.find_resultset(resultset: file)).to eq(file)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it 'raises a helpful error when no fallback candidates are found' do
|
|
39
|
+
expect do
|
|
40
|
+
resolver.find_resultset
|
|
41
|
+
end.to raise_error(RuntimeError, /Could not find .resultset.json/)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it 'accepts a resultset path already nested under the provided root without double-prefixing' do
|
|
45
|
+
project_root = (FIXTURES_DIR / 'project1').to_s
|
|
46
|
+
resolver = described_class.new(root: project_root)
|
|
47
|
+
|
|
48
|
+
resolved = resolver.find_resultset(resultset: 'spec/fixtures/project1/coverage')
|
|
49
|
+
|
|
50
|
+
expect(resolved).to eq(File.join(project_root, 'coverage', '.resultset.json'))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# In non-strict mode, resolve_candidate returns nil instead of raising
|
|
54
|
+
# when the path doesn't exist, allowing fallback resolution to continue.
|
|
55
|
+
it 'returns nil for non-existent path in non-strict mode' do
|
|
56
|
+
result = resolver.send(:resolve_candidate, '/nonexistent/path.json', strict: false)
|
|
57
|
+
expect(result).to be_nil
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|