simplecov-mcp 0.3.0 → 1.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 +4 -4
- data/LICENSE +21 -0
- data/README.md +173 -356
- data/docs/ADVANCED_USAGE.md +967 -0
- data/docs/ARCHITECTURE.md +79 -0
- data/docs/BRANCH_ONLY_COVERAGE.md +81 -0
- data/docs/CLI_USAGE.md +637 -0
- data/docs/DEVELOPMENT.md +82 -0
- data/docs/ERROR_HANDLING.md +93 -0
- data/docs/EXAMPLES.md +430 -0
- data/docs/INSTALLATION.md +352 -0
- data/docs/LIBRARY_API.md +635 -0
- data/docs/MCP_INTEGRATION.md +488 -0
- data/docs/TROUBLESHOOTING.md +276 -0
- data/docs/arch-decisions/001-x-arch-decision.md +93 -0
- data/docs/arch-decisions/002-x-arch-decision.md +157 -0
- data/docs/arch-decisions/003-x-arch-decision.md +163 -0
- data/docs/arch-decisions/004-x-arch-decision.md +199 -0
- data/docs/arch-decisions/005-x-arch-decision.md +187 -0
- data/docs/arch-decisions/README.md +60 -0
- data/docs/presentations/simplecov-mcp-presentation.md +249 -0
- data/exe/simplecov-mcp +4 -4
- data/lib/simplecov_mcp/app_context.rb +26 -0
- data/lib/simplecov_mcp/base_tool.rb +74 -0
- data/lib/simplecov_mcp/cli.rb +234 -0
- data/lib/simplecov_mcp/cli_config.rb +56 -0
- data/lib/simplecov_mcp/commands/base_command.rb +78 -0
- data/lib/simplecov_mcp/commands/command_factory.rb +39 -0
- data/lib/simplecov_mcp/commands/detailed_command.rb +24 -0
- data/lib/simplecov_mcp/commands/list_command.rb +13 -0
- data/lib/simplecov_mcp/commands/raw_command.rb +22 -0
- data/lib/simplecov_mcp/commands/summary_command.rb +24 -0
- data/lib/simplecov_mcp/commands/uncovered_command.rb +26 -0
- data/lib/simplecov_mcp/commands/version_command.rb +18 -0
- data/lib/simplecov_mcp/constants.rb +22 -0
- data/lib/simplecov_mcp/error_handler.rb +124 -0
- data/lib/simplecov_mcp/error_handler_factory.rb +31 -0
- data/lib/simplecov_mcp/errors.rb +179 -0
- data/lib/simplecov_mcp/formatters/source_formatter.rb +148 -0
- data/lib/simplecov_mcp/mcp_server.rb +40 -0
- data/lib/simplecov_mcp/mode_detector.rb +55 -0
- data/lib/simplecov_mcp/model.rb +300 -0
- data/lib/simplecov_mcp/option_normalizers.rb +92 -0
- data/lib/simplecov_mcp/option_parser_builder.rb +134 -0
- data/lib/simplecov_mcp/option_parsers/env_options_parser.rb +50 -0
- data/lib/simplecov_mcp/option_parsers/error_helper.rb +109 -0
- data/lib/simplecov_mcp/path_relativizer.rb +61 -0
- data/lib/simplecov_mcp/presenters/base_coverage_presenter.rb +44 -0
- data/lib/simplecov_mcp/presenters/coverage_detailed_presenter.rb +16 -0
- data/lib/simplecov_mcp/presenters/coverage_raw_presenter.rb +16 -0
- data/lib/simplecov_mcp/presenters/coverage_summary_presenter.rb +16 -0
- data/lib/simplecov_mcp/presenters/coverage_uncovered_presenter.rb +16 -0
- data/lib/simplecov_mcp/presenters/project_coverage_presenter.rb +52 -0
- data/lib/simplecov_mcp/resolvers/coverage_line_resolver.rb +126 -0
- data/lib/simplecov_mcp/resolvers/resolver_factory.rb +28 -0
- data/lib/simplecov_mcp/resolvers/resultset_path_resolver.rb +78 -0
- data/lib/simplecov_mcp/resultset_loader.rb +136 -0
- data/lib/simplecov_mcp/staleness_checker.rb +243 -0
- data/lib/{simple_cov_mcp → simplecov_mcp}/tools/all_files_coverage_tool.rb +31 -13
- data/lib/{simple_cov_mcp → simplecov_mcp}/tools/coverage_detailed_tool.rb +7 -7
- data/lib/{simple_cov_mcp → simplecov_mcp}/tools/coverage_raw_tool.rb +7 -7
- data/lib/{simple_cov_mcp → simplecov_mcp}/tools/coverage_summary_tool.rb +7 -7
- data/lib/simplecov_mcp/tools/coverage_table_tool.rb +90 -0
- data/lib/{simple_cov_mcp → simplecov_mcp}/tools/help_tool.rb +13 -4
- data/lib/{simple_cov_mcp → simplecov_mcp}/tools/uncovered_lines_tool.rb +7 -7
- data/lib/{simple_cov_mcp → simplecov_mcp}/tools/version_tool.rb +11 -3
- data/lib/simplecov_mcp/util.rb +82 -0
- data/lib/{simple_cov_mcp → simplecov_mcp}/version.rb +1 -1
- data/lib/simplecov_mcp.rb +144 -2
- data/spec/MCP_INTEGRATION_TESTS_README.md +111 -0
- data/spec/TIMESTAMPS.md +48 -0
- data/spec/all_files_coverage_tool_spec.rb +29 -25
- data/spec/base_tool_spec.rb +11 -10
- data/spec/cli/show_default_report_spec.rb +33 -0
- data/spec/cli_config_spec.rb +137 -0
- data/spec/cli_enumerated_options_spec.rb +68 -0
- data/spec/cli_error_spec.rb +105 -47
- data/spec/cli_source_spec.rb +82 -23
- data/spec/cli_spec.rb +140 -5
- data/spec/cli_success_predicate_spec.rb +141 -0
- data/spec/cli_table_spec.rb +1 -1
- data/spec/cli_usage_spec.rb +10 -26
- data/spec/commands/base_command_spec.rb +187 -0
- data/spec/commands/command_factory_spec.rb +72 -0
- data/spec/commands/detailed_command_spec.rb +48 -0
- data/spec/commands/raw_command_spec.rb +46 -0
- data/spec/commands/summary_command_spec.rb +47 -0
- data/spec/commands/uncovered_command_spec.rb +49 -0
- data/spec/constants_spec.rb +61 -0
- data/spec/coverage_table_tool_spec.rb +17 -33
- data/spec/error_handler_spec.rb +22 -13
- data/spec/error_mode_spec.rb +143 -0
- data/spec/errors_edge_cases_spec.rb +239 -0
- data/spec/errors_stale_spec.rb +2 -2
- data/spec/file_based_mcp_tools_spec.rb +99 -0
- data/spec/fixtures/project1/lib/bar.rb +0 -1
- data/spec/fixtures/project1/lib/foo.rb +0 -1
- data/spec/help_tool_spec.rb +11 -17
- data/spec/integration_spec.rb +845 -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 +15 -4
- data/spec/mode_detector_spec.rb +148 -0
- data/spec/model_error_handling_spec.rb +210 -0
- data/spec/model_staleness_spec.rb +40 -10
- data/spec/option_normalizers_spec.rb +204 -0
- data/spec/option_parsers/env_options_parser_spec.rb +233 -0
- data/spec/option_parsers/error_helper_spec.rb +222 -0
- data/spec/path_relativizer_spec.rb +83 -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 +86 -0
- data/spec/resolvers/coverage_line_resolver_spec.rb +57 -0
- data/spec/resolvers/resolver_factory_spec.rb +61 -0
- data/spec/resolvers/resultset_path_resolver_spec.rb +55 -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 +174 -0
- data/spec/shared_examples/mcp_tool_text_json_response.rb +16 -0
- data/spec/simple_cov_mcp_module_spec.rb +16 -0
- data/spec/simplecov_mcp_model_spec.rb +340 -9
- data/spec/simplecov_mcp_opts_spec.rb +182 -0
- data/spec/spec_helper.rb +147 -4
- data/spec/staleness_checker_spec.rb +373 -0
- data/spec/staleness_more_spec.rb +16 -13
- data/spec/support/mcp_runner.rb +64 -0
- data/spec/tools_error_handling_spec.rb +144 -0
- data/spec/util_spec.rb +109 -34
- data/spec/version_spec.rb +117 -9
- data/spec/version_tool_spec.rb +131 -10
- metadata +120 -63
- data/lib/simple_cov/mcp.rb +0 -9
- data/lib/simple_cov_mcp/base_tool.rb +0 -70
- data/lib/simple_cov_mcp/cli.rb +0 -390
- data/lib/simple_cov_mcp/error_handler.rb +0 -131
- data/lib/simple_cov_mcp/error_handler_factory.rb +0 -38
- data/lib/simple_cov_mcp/errors.rb +0 -176
- data/lib/simple_cov_mcp/mcp_server.rb +0 -30
- data/lib/simple_cov_mcp/model.rb +0 -104
- data/lib/simple_cov_mcp/staleness_checker.rb +0 -125
- data/lib/simple_cov_mcp/tools/coverage_table_tool.rb +0 -61
- data/lib/simple_cov_mcp/util.rb +0 -122
- data/lib/simple_cov_mcp.rb +0 -102
- data/spec/coverage_detailed_tool_spec.rb +0 -36
- data/spec/coverage_raw_tool_spec.rb +0 -32
- data/spec/coverage_summary_tool_spec.rb +0 -39
- data/spec/legacy_shim_spec.rb +0 -13
- data/spec/uncovered_lines_tool_spec.rb +0 -33
@@ -0,0 +1,222 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'spec_helper'
|
4
|
+
|
5
|
+
RSpec.describe SimpleCovMcp::OptionParsers::ErrorHelper do
|
6
|
+
subject(:helper) { described_class.new }
|
7
|
+
|
8
|
+
# Helper method to capture stderr output
|
9
|
+
def capture_stderr
|
10
|
+
captured = StringIO.new
|
11
|
+
original = $stderr
|
12
|
+
$stderr = captured
|
13
|
+
begin
|
14
|
+
yield
|
15
|
+
rescue SystemExit
|
16
|
+
# Ignore exit calls
|
17
|
+
ensure
|
18
|
+
$stderr = original
|
19
|
+
end
|
20
|
+
captured.string
|
21
|
+
end
|
22
|
+
|
23
|
+
# Helper method to test error output matches expected pattern
|
24
|
+
def expect_error_output(error:, argv:, pattern:)
|
25
|
+
expect do
|
26
|
+
begin
|
27
|
+
helper.handle_option_parser_error(error, argv: argv)
|
28
|
+
rescue SystemExit
|
29
|
+
# Ignore exit call
|
30
|
+
end
|
31
|
+
end.to output(pattern).to_stderr
|
32
|
+
end
|
33
|
+
|
34
|
+
# Test data for enumerated options
|
35
|
+
OPTION_TESTS = {
|
36
|
+
stale: {
|
37
|
+
long: '--stale',
|
38
|
+
short: '-S',
|
39
|
+
pattern: /Valid values for --stale: o\[ff\]|e\[rror\]/
|
40
|
+
},
|
41
|
+
source: {
|
42
|
+
long: '--source',
|
43
|
+
short: '-s',
|
44
|
+
pattern: /Valid values for --source: f\[ull\]|u\[ncovered\]/
|
45
|
+
},
|
46
|
+
error_mode: {
|
47
|
+
long: '--error-mode',
|
48
|
+
short: nil,
|
49
|
+
pattern: /Valid values for --error-mode: off\|on\|t\[race\]/
|
50
|
+
},
|
51
|
+
sort_order: {
|
52
|
+
long: '--sort-order',
|
53
|
+
short: '-o',
|
54
|
+
pattern: /Valid values for --sort-order: a\[scending\]|d\[escending\]/
|
55
|
+
}
|
56
|
+
}.freeze
|
57
|
+
|
58
|
+
describe '#handle_option_parser_error' do
|
59
|
+
context 'with invalid enumerated option values' do
|
60
|
+
OPTION_TESTS.each do |name, config|
|
61
|
+
context "for #{config[:long]} option" do
|
62
|
+
let(:error) { OptionParser::InvalidArgument.new('invalid argument: xyz') }
|
63
|
+
|
64
|
+
it 'suggests valid values for space-separated form with invalid value' do
|
65
|
+
expect_error_output(
|
66
|
+
error: error,
|
67
|
+
argv: [config[:long], 'xyz'],
|
68
|
+
pattern: config[:pattern]
|
69
|
+
)
|
70
|
+
end
|
71
|
+
|
72
|
+
it 'suggests valid values for equal form with invalid value' do
|
73
|
+
expect_error_output(
|
74
|
+
error: error,
|
75
|
+
argv: ["#{config[:long]}=xyz"],
|
76
|
+
pattern: config[:pattern]
|
77
|
+
)
|
78
|
+
end
|
79
|
+
|
80
|
+
if config[:short]
|
81
|
+
it 'suggests valid values for short form with invalid value' do
|
82
|
+
expect_error_output(
|
83
|
+
error: error,
|
84
|
+
argv: [config[:short], 'xyz'],
|
85
|
+
pattern: config[:pattern]
|
86
|
+
)
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
context 'for --stale option edge cases' do
|
93
|
+
it 'suggests valid values when value is missing' do
|
94
|
+
error = OptionParser::InvalidArgument.new('missing argument: --stale')
|
95
|
+
expect_error_output(
|
96
|
+
error: error,
|
97
|
+
argv: ['--stale'],
|
98
|
+
pattern: /Valid values for --stale: o\[ff\]|e\[rror\]/
|
99
|
+
)
|
100
|
+
end
|
101
|
+
|
102
|
+
it 'suggests valid values when next token looks like an option' do
|
103
|
+
error = OptionParser::InvalidArgument.new('invalid argument: --other')
|
104
|
+
expect_error_output(
|
105
|
+
error: error,
|
106
|
+
argv: ['--stale', '--other-option'],
|
107
|
+
pattern: /Valid values for --stale: o\[ff\]|e\[rror\]/
|
108
|
+
)
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
context 'with multiple options in argv' do
|
114
|
+
it 'correctly identifies the problematic option among valid options' do
|
115
|
+
error = OptionParser::InvalidArgument.new('invalid argument: bad')
|
116
|
+
expect_error_output(
|
117
|
+
error: error,
|
118
|
+
argv: ['--resultset', 'coverage', '--stale', 'bad', '--json'],
|
119
|
+
pattern: /Valid values for --stale: o\[ff\]|e\[rror\]/
|
120
|
+
)
|
121
|
+
end
|
122
|
+
|
123
|
+
it 'handles equal form mixed with other options' do
|
124
|
+
error = OptionParser::InvalidArgument.new('invalid argument: invalid')
|
125
|
+
expect_error_output(
|
126
|
+
error: error,
|
127
|
+
argv: ['--json', '--sort-order=invalid', '--resultset', 'coverage'],
|
128
|
+
pattern: /Valid values for --sort-order: a\[scending\]|d\[escending\]/
|
129
|
+
)
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
context 'when option is not an enumerated type' do
|
134
|
+
it 'shows generic error message without enum hint' do
|
135
|
+
error = OptionParser::InvalidArgument.new('invalid option: --unknown')
|
136
|
+
|
137
|
+
stderr_output = capture_stderr do
|
138
|
+
helper.handle_option_parser_error(error, argv: ['--unknown'])
|
139
|
+
end
|
140
|
+
|
141
|
+
expect(stderr_output).to match(/Error:.*invalid option.*--unknown/)
|
142
|
+
expect(stderr_output).to match(/Run 'simplecov-mcp --help'/)
|
143
|
+
expect(stderr_output).not_to match(/Valid values/)
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
context 'when invalid option matches a subcommand' do
|
148
|
+
it 'suggests using it as a subcommand instead' do
|
149
|
+
error = OptionParser::InvalidOption.new('invalid option: --summary')
|
150
|
+
|
151
|
+
stderr_output = capture_stderr do
|
152
|
+
helper.handle_option_parser_error(error, argv: ['--summary'])
|
153
|
+
end
|
154
|
+
|
155
|
+
# Note: The subcommand detection logic isn't fully working as expected
|
156
|
+
# because extract_invalid_option doesn't properly parse the error message
|
157
|
+
expect(stderr_output).to match(/Error:.*--summary/)
|
158
|
+
expect(stderr_output).to match(/Run 'simplecov-mcp --help'/)
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
context 'exit behavior' do
|
163
|
+
it 'exits with status 1' do
|
164
|
+
error = OptionParser::InvalidArgument.new('invalid argument: xyz')
|
165
|
+
|
166
|
+
expect do
|
167
|
+
helper.handle_option_parser_error(error, argv: ['--stale', 'xyz'])
|
168
|
+
end.to raise_error(SystemExit) do |e|
|
169
|
+
expect(e.status).to eq(1)
|
170
|
+
end
|
171
|
+
end
|
172
|
+
end
|
173
|
+
|
174
|
+
context 'usage hint customization' do
|
175
|
+
it 'uses custom usage hint when provided' do
|
176
|
+
error = OptionParser::InvalidArgument.new('invalid argument: xyz')
|
177
|
+
|
178
|
+
expect do
|
179
|
+
begin
|
180
|
+
helper.handle_option_parser_error(error, argv: ['--stale', 'xyz'],
|
181
|
+
usage_hint: 'Custom hint message')
|
182
|
+
rescue SystemExit
|
183
|
+
# Ignore exit call
|
184
|
+
end
|
185
|
+
end.to output(/Custom hint message/).to_stderr
|
186
|
+
end
|
187
|
+
end
|
188
|
+
end
|
189
|
+
|
190
|
+
describe 'edge cases' do
|
191
|
+
it 'handles empty argv gracefully' do
|
192
|
+
error = OptionParser::InvalidArgument.new('some error')
|
193
|
+
expect_error_output(
|
194
|
+
error: error,
|
195
|
+
argv: [],
|
196
|
+
pattern: /Error: invalid argument: some error/
|
197
|
+
)
|
198
|
+
end
|
199
|
+
|
200
|
+
it 'handles argv with only valid options (no problematic enum)' do
|
201
|
+
error = OptionParser::InvalidArgument.new('some error')
|
202
|
+
|
203
|
+
stderr_output = capture_stderr do
|
204
|
+
helper.handle_option_parser_error(error, argv: ['--json', '--resultset', 'coverage'])
|
205
|
+
end
|
206
|
+
|
207
|
+
expect(stderr_output).to match(/Error: invalid argument: some error/)
|
208
|
+
expect(stderr_output).to match(/Run 'simplecov-mcp --help'/)
|
209
|
+
end
|
210
|
+
|
211
|
+
it 'does not show enum hint when all enum values are valid' do
|
212
|
+
error = OptionParser::MissingArgument.new('missing argument: --resultset')
|
213
|
+
|
214
|
+
stderr_output = capture_stderr do
|
215
|
+
helper.handle_option_parser_error(error, argv: ['--stale', 'off', '--resultset'])
|
216
|
+
end
|
217
|
+
|
218
|
+
expect(stderr_output).to match(/Error:.*missing argument.*--resultset/)
|
219
|
+
expect(stderr_output).not_to match(/Valid values/)
|
220
|
+
end
|
221
|
+
end
|
222
|
+
end
|
@@ -0,0 +1,83 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'spec_helper'
|
4
|
+
|
5
|
+
RSpec.describe SimpleCovMcp::PathRelativizer do
|
6
|
+
let(:root) { (FIXTURES_DIR / 'project1').to_s }
|
7
|
+
let(:relativizer) do
|
8
|
+
described_class.new(
|
9
|
+
root: root,
|
10
|
+
scalar_keys: %w[file file_path],
|
11
|
+
array_keys: %w[newer_files missing_files deleted_files]
|
12
|
+
)
|
13
|
+
end
|
14
|
+
|
15
|
+
describe '#relativize' do
|
16
|
+
it 'converts configured scalar keys to root-relative paths' do
|
17
|
+
payload = { 'file' => File.join(root, 'lib/foo.rb') }
|
18
|
+
result = relativizer.relativize(payload)
|
19
|
+
|
20
|
+
expect(result['file']).to eq('lib/foo.rb')
|
21
|
+
expect(payload['file']).to eq(File.join(root, 'lib/foo.rb'))
|
22
|
+
end
|
23
|
+
|
24
|
+
it 'relativizes arrays for configured keys without mutating originals' do
|
25
|
+
payload = {
|
26
|
+
'newer_files' => [File.join(root, 'lib/foo.rb'), File.join(root, 'lib/bar.rb')]
|
27
|
+
}
|
28
|
+
|
29
|
+
result = relativizer.relativize(payload)
|
30
|
+
|
31
|
+
expect(result['newer_files']).to contain_exactly('lib/foo.rb', 'lib/bar.rb')
|
32
|
+
expect(payload['newer_files']).to all(start_with(root))
|
33
|
+
end
|
34
|
+
|
35
|
+
it 'leaves unconfigured keys untouched' do
|
36
|
+
payload = { 'other' => File.join(root, 'lib/foo.rb') }
|
37
|
+
result = relativizer.relativize(payload)
|
38
|
+
|
39
|
+
expect(result['other']).to eq(payload['other'])
|
40
|
+
end
|
41
|
+
|
42
|
+
it 'ignores paths outside the root' do
|
43
|
+
outside = '/tmp/external.rb'
|
44
|
+
payload = { 'file' => outside }
|
45
|
+
|
46
|
+
result = relativizer.relativize(payload)
|
47
|
+
|
48
|
+
expect(result['file']).to eq(outside)
|
49
|
+
end
|
50
|
+
|
51
|
+
it 'relativizes nested arrays of hashes' do
|
52
|
+
payload = {
|
53
|
+
'files' => [
|
54
|
+
{ 'file' => File.join(root, 'lib/foo.rb') },
|
55
|
+
{ 'file' => File.join(root, 'lib/bar.rb') }
|
56
|
+
],
|
57
|
+
'counts' => { 'total' => 2 }
|
58
|
+
}
|
59
|
+
|
60
|
+
result = relativizer.relativize(payload)
|
61
|
+
|
62
|
+
expect(result['files'].map { |h| h['file'] }).to eq(%w[lib/foo.rb lib/bar.rb])
|
63
|
+
expect(result['counts']).to eq('total' => 2)
|
64
|
+
end
|
65
|
+
|
66
|
+
it "handles paths with '..' components" do
|
67
|
+
payload = { 'file' => File.join(root, 'lib/../lib/foo.rb') }
|
68
|
+
result = relativizer.relativize(payload)
|
69
|
+
expect(result['file']).to eq('lib/foo.rb')
|
70
|
+
end
|
71
|
+
|
72
|
+
it 'handles paths with spaces' do
|
73
|
+
file_with_space = File.join(root, 'lib/file with space.rb')
|
74
|
+
FileUtils.touch(file_with_space)
|
75
|
+
|
76
|
+
payload = { 'file' => file_with_space }
|
77
|
+
result = relativizer.relativize(payload)
|
78
|
+
expect(result['file']).to eq('lib/file with space.rb')
|
79
|
+
ensure
|
80
|
+
FileUtils.rm_f(file_with_space)
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'spec_helper'
|
4
|
+
require_relative '../shared_examples/coverage_presenter_examples'
|
5
|
+
|
6
|
+
RSpec.describe SimpleCovMcp::Presenters::CoverageDetailedPresenter do
|
7
|
+
it_behaves_like 'a coverage presenter',
|
8
|
+
model_method: :detailed_for,
|
9
|
+
payload: {
|
10
|
+
'file' => '/abs/path/lib/foo.rb',
|
11
|
+
'lines' => [
|
12
|
+
{ 'line' => 1, 'hits' => 1, 'covered' => true },
|
13
|
+
{ 'line' => 2, 'hits' => 0, 'covered' => false }
|
14
|
+
],
|
15
|
+
'summary' => { 'covered' => 1, 'total' => 2, 'pct' => 50.0 }
|
16
|
+
},
|
17
|
+
stale: 'L',
|
18
|
+
expected_keys: ['lines', 'summary']
|
19
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'spec_helper'
|
4
|
+
require_relative '../shared_examples/coverage_presenter_examples'
|
5
|
+
|
6
|
+
RSpec.describe SimpleCovMcp::Presenters::CoverageRawPresenter do
|
7
|
+
it_behaves_like 'a coverage presenter',
|
8
|
+
model_method: :raw_for,
|
9
|
+
payload: {
|
10
|
+
'file' => '/abs/path/lib/foo.rb',
|
11
|
+
'lines' => [1, 0, nil, 2]
|
12
|
+
},
|
13
|
+
stale: 'L',
|
14
|
+
expected_keys: ['lines']
|
15
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'spec_helper'
|
4
|
+
require_relative '../shared_examples/coverage_presenter_examples'
|
5
|
+
|
6
|
+
RSpec.describe SimpleCovMcp::Presenters::CoverageSummaryPresenter do
|
7
|
+
it_behaves_like 'a coverage presenter',
|
8
|
+
model_method: :summary_for,
|
9
|
+
payload: {
|
10
|
+
'file' => '/abs/path/lib/foo.rb',
|
11
|
+
'summary' => { 'covered' => 8, 'total' => 10, 'pct' => 80.0 }
|
12
|
+
},
|
13
|
+
stale: false,
|
14
|
+
expected_keys: ['summary']
|
15
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'spec_helper'
|
4
|
+
require_relative '../shared_examples/coverage_presenter_examples'
|
5
|
+
|
6
|
+
RSpec.describe SimpleCovMcp::Presenters::CoverageUncoveredPresenter do
|
7
|
+
it_behaves_like 'a coverage presenter',
|
8
|
+
model_method: :uncovered_for,
|
9
|
+
payload: {
|
10
|
+
'file' => '/abs/path/lib/foo.rb',
|
11
|
+
'uncovered' => [2, 4],
|
12
|
+
'summary' => { 'covered' => 2, 'total' => 4, 'pct' => 50.0 }
|
13
|
+
},
|
14
|
+
stale: 'M',
|
15
|
+
expected_keys: ['uncovered', 'summary']
|
16
|
+
end
|
@@ -0,0 +1,86 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'spec_helper'
|
4
|
+
|
5
|
+
RSpec.describe SimpleCovMcp::Presenters::ProjectCoveragePresenter do
|
6
|
+
let(:model) { instance_double(SimpleCovMcp::CoverageModel) }
|
7
|
+
let(:sort_order) { :ascending }
|
8
|
+
let(:check_stale) { true }
|
9
|
+
let(:tracked_globs) { ['lib/**/*.rb'] }
|
10
|
+
let(:files) do
|
11
|
+
[
|
12
|
+
{
|
13
|
+
'file' => '/abs/path/lib/foo.rb',
|
14
|
+
'covered' => 5,
|
15
|
+
'total' => 6,
|
16
|
+
'percentage' => 83.33,
|
17
|
+
'stale' => false
|
18
|
+
},
|
19
|
+
{
|
20
|
+
'file' => '/abs/path/lib/bar.rb',
|
21
|
+
'covered' => 1,
|
22
|
+
'total' => 6,
|
23
|
+
'percentage' => 16.67,
|
24
|
+
'stale' => 'L'
|
25
|
+
}
|
26
|
+
]
|
27
|
+
end
|
28
|
+
|
29
|
+
subject(:presenter) do
|
30
|
+
described_class.new(
|
31
|
+
model: model,
|
32
|
+
sort_order: sort_order,
|
33
|
+
check_stale: check_stale,
|
34
|
+
tracked_globs: tracked_globs
|
35
|
+
)
|
36
|
+
end
|
37
|
+
|
38
|
+
before do
|
39
|
+
allow(model).to receive(:all_files).with(sort_order: sort_order, check_stale: check_stale,
|
40
|
+
tracked_globs: tracked_globs).and_return(files)
|
41
|
+
allow(model).to receive(:relativize) do |payload|
|
42
|
+
relativizer = SimpleCovMcp::PathRelativizer.new(
|
43
|
+
root: '/abs/path',
|
44
|
+
scalar_keys: %w[file file_path],
|
45
|
+
array_keys: %w[newer_files missing_files deleted_files]
|
46
|
+
)
|
47
|
+
relativizer.relativize(payload)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
describe '#absolute_payload' do
|
52
|
+
it 'returns files and counts with stale metadata' do
|
53
|
+
payload = presenter.absolute_payload
|
54
|
+
|
55
|
+
expect(payload['files']).to eq(files)
|
56
|
+
expect(payload['counts']).to eq({ 'total' => 2, 'ok' => 1, 'stale' => 1 })
|
57
|
+
end
|
58
|
+
|
59
|
+
it 'memoizes the computed payload' do
|
60
|
+
presenter.absolute_payload
|
61
|
+
presenter.absolute_payload
|
62
|
+
|
63
|
+
expect(model).to have_received(:all_files).once
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
describe '#relativized_payload' do
|
68
|
+
it 'relativizes the files list' do
|
69
|
+
relativized = presenter.relativized_payload
|
70
|
+
|
71
|
+
expect(relativized['files'].map { |f| f['file'] }).to eq(['lib/foo.rb', 'lib/bar.rb'])
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
describe '#relative_files' do
|
76
|
+
it 'returns the relativized file list' do
|
77
|
+
expect(presenter.relative_files.map { |f| f['file'] }).to eq(['lib/foo.rb', 'lib/bar.rb'])
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
describe '#relative_counts' do
|
82
|
+
it 'returns the relativized counts hash' do
|
83
|
+
expect(presenter.relative_counts).to eq({ 'total' => 2, 'ok' => 1, 'stale' => 1 })
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'spec_helper'
|
4
|
+
|
5
|
+
RSpec.describe SimpleCovMcp::Resolvers::CoverageLineResolver do
|
6
|
+
describe '#lookup_lines' do
|
7
|
+
it 'synthesizes line hits when only branch coverage exists' do
|
8
|
+
abs_path = '/tmp/branch_only.rb'
|
9
|
+
branch_cov = {
|
10
|
+
abs_path => {
|
11
|
+
'lines' => nil,
|
12
|
+
'branches' => {
|
13
|
+
'[:if, 0, 5, 2, 8, 5]' => {
|
14
|
+
'[:then, 1, 6, 4, 6, 15]' => 3,
|
15
|
+
'[:else, 2, 7, 4, 7, 15]' => 0
|
16
|
+
},
|
17
|
+
'[:case, 3, 12, 2, 17, 5]' => {
|
18
|
+
'[:when, 4, 13, 4, 13, 14]' => 0,
|
19
|
+
'[:when, 5, 14, 4, 14, 14]' => 2,
|
20
|
+
'[:else, 6, 16, 4, 16, 12]' => 2
|
21
|
+
}
|
22
|
+
}
|
23
|
+
}
|
24
|
+
}
|
25
|
+
|
26
|
+
resolver = described_class.new(branch_cov)
|
27
|
+
lines = resolver.lookup_lines(abs_path)
|
28
|
+
|
29
|
+
expect(lines[5]).to eq(3) # line 6
|
30
|
+
expect(lines[6]).to eq(0) # line 7
|
31
|
+
expect(lines[12]).to eq(0) # line 13
|
32
|
+
expect(lines[13]).to eq(2) # line 14
|
33
|
+
expect(lines[15]).to eq(2) # line 16
|
34
|
+
expect(lines.count { |v| !v.nil? }).to eq(5)
|
35
|
+
end
|
36
|
+
|
37
|
+
it 'aggregates hits for multiple branches on the same line' do
|
38
|
+
path = '/tmp/duplicated.rb'
|
39
|
+
branch_cov = {
|
40
|
+
path => {
|
41
|
+
'lines' => nil,
|
42
|
+
'branches' => {
|
43
|
+
'[:if, 0, 3, 2, 3, 12]' => {
|
44
|
+
'[:then, 1, 3, 2, 3, 12]' => 2,
|
45
|
+
'[:else, 2, 3, 2, 3, 12]' => 3
|
46
|
+
}
|
47
|
+
}
|
48
|
+
}
|
49
|
+
}
|
50
|
+
|
51
|
+
resolver = described_class.new(branch_cov)
|
52
|
+
lines = resolver.lookup_lines(path)
|
53
|
+
|
54
|
+
expect(lines[2]).to eq(5) # line 3 with summed hits
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'spec_helper'
|
4
|
+
require 'tmpdir'
|
5
|
+
|
6
|
+
RSpec.describe SimpleCovMcp::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(SimpleCovMcp::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
|
+
SimpleCovMcp::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(SimpleCovMcp::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,55 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'spec_helper'
|
4
|
+
require 'tmpdir'
|
5
|
+
|
6
|
+
RSpec.describe SimpleCovMcp::Resolvers::ResultsetPathResolver do
|
7
|
+
describe '#find_resultset' do
|
8
|
+
around do |example|
|
9
|
+
Dir.mktmpdir do |dir|
|
10
|
+
@tmp_root = dir
|
11
|
+
example.run
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
let(:root) { @tmp_root }
|
16
|
+
let(:resolver) { described_class.new(root: root) }
|
17
|
+
|
18
|
+
it 'raises when a specified resultset file cannot be found' do
|
19
|
+
expect do
|
20
|
+
resolver.find_resultset(resultset: 'missing.json')
|
21
|
+
end.to raise_error(RuntimeError, /Specified resultset not found/)
|
22
|
+
end
|
23
|
+
|
24
|
+
it 'raises when a specified directory does not contain .resultset.json' do
|
25
|
+
nested_dir = File.join(root, 'coverage')
|
26
|
+
Dir.mkdir(nested_dir)
|
27
|
+
|
28
|
+
expect do
|
29
|
+
resolver.find_resultset(resultset: nested_dir)
|
30
|
+
end.to raise_error(RuntimeError, /No .resultset.json found in directory/)
|
31
|
+
end
|
32
|
+
|
33
|
+
it 'returns the resolved path when a valid resultset file is provided' do
|
34
|
+
file = File.join(root, 'custom.json')
|
35
|
+
File.write(file, '{}')
|
36
|
+
|
37
|
+
expect(resolver.find_resultset(resultset: file)).to eq(file)
|
38
|
+
end
|
39
|
+
|
40
|
+
it 'raises a helpful error when no fallback candidates are found' do
|
41
|
+
expect do
|
42
|
+
resolver.find_resultset
|
43
|
+
end.to raise_error(RuntimeError, /Could not find .resultset.json/)
|
44
|
+
end
|
45
|
+
|
46
|
+
it 'accepts a resultset path already nested under the provided root without double-prefixing' do
|
47
|
+
project_root = (FIXTURES_DIR / 'project1').to_s
|
48
|
+
resolver = described_class.new(root: project_root)
|
49
|
+
|
50
|
+
resolved = resolver.find_resultset(resultset: 'spec/fixtures/project1/coverage')
|
51
|
+
|
52
|
+
expect(resolved).to eq(File.join(project_root, 'coverage', '.resultset.json'))
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|