simplecov-mcp 1.0.1 → 2.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/README.md +98 -50
- data/docs/{ARCHITECTURE.md → dev/ARCHITECTURE.md} +11 -10
- data/docs/dev/BRANCH_ONLY_COVERAGE.md +158 -0
- data/docs/{DEVELOPMENT.md → dev/DEVELOPMENT.md} +2 -1
- data/docs/dev/README.md +10 -0
- data/docs/dev/RELEASING.md +146 -0
- data/docs/{arch-decisions → dev/arch-decisions}/001-x-arch-decision.md +3 -1
- data/docs/{arch-decisions → dev/arch-decisions}/002-x-arch-decision.md +7 -5
- data/docs/{arch-decisions → dev/arch-decisions}/003-x-arch-decision.md +2 -0
- data/docs/{arch-decisions → dev/arch-decisions}/004-x-arch-decision.md +6 -2
- data/docs/{arch-decisions → dev/arch-decisions}/005-x-arch-decision.md +4 -2
- data/docs/{arch-decisions → dev/arch-decisions}/README.md +3 -3
- data/docs/{presentations → dev/presentations}/simplecov-mcp-presentation.md +28 -22
- data/docs/fixtures/demo_project/README.md +9 -0
- data/docs/{ADVANCED_USAGE.md → user/ADVANCED_USAGE.md} +129 -319
- data/docs/user/CLI_FALLBACK_FOR_LLMS.md +34 -0
- data/docs/user/CLI_USAGE.md +750 -0
- data/docs/{ERROR_HANDLING.md → user/ERROR_HANDLING.md} +12 -12
- data/docs/user/EXAMPLES.md +588 -0
- data/docs/user/INSTALLATION.md +130 -0
- data/docs/{LIBRARY_API.md → user/LIBRARY_API.md} +90 -32
- data/docs/{MCP_INTEGRATION.md → user/MCP_INTEGRATION.md} +36 -34
- data/docs/user/README.md +14 -0
- data/docs/{TROUBLESHOOTING.md → user/TROUBLESHOOTING.md} +21 -100
- data/docs/user/V2-BREAKING-CHANGES.md +472 -0
- data/exe/simplecov-mcp +1 -1
- data/lib/simplecov_mcp/{cli_config.rb → app_config.rb} +12 -12
- data/lib/simplecov_mcp/app_context.rb +1 -1
- data/lib/simplecov_mcp/base_tool.rb +66 -38
- data/lib/simplecov_mcp/cli.rb +67 -123
- data/lib/simplecov_mcp/commands/base_command.rb +16 -27
- data/lib/simplecov_mcp/commands/command_factory.rb +8 -2
- data/lib/simplecov_mcp/commands/detailed_command.rb +16 -2
- data/lib/simplecov_mcp/commands/list_command.rb +1 -1
- data/lib/simplecov_mcp/commands/raw_command.rb +18 -2
- data/lib/simplecov_mcp/commands/summary_command.rb +20 -3
- data/lib/simplecov_mcp/commands/totals_command.rb +53 -0
- data/lib/simplecov_mcp/commands/uncovered_command.rb +24 -5
- data/lib/simplecov_mcp/commands/validate_command.rb +60 -0
- data/lib/simplecov_mcp/commands/version_command.rb +19 -4
- data/lib/simplecov_mcp/config_parser.rb +32 -0
- data/lib/simplecov_mcp/constants.rb +3 -3
- data/lib/simplecov_mcp/coverage_reporter.rb +31 -0
- data/lib/simplecov_mcp/error_handler.rb +81 -40
- data/lib/simplecov_mcp/error_handler_factory.rb +2 -2
- data/lib/simplecov_mcp/errors.rb +12 -19
- data/lib/simplecov_mcp/formatters/source_formatter.rb +23 -19
- data/lib/simplecov_mcp/formatters.rb +51 -0
- data/lib/simplecov_mcp/mcp_server.rb +9 -7
- data/lib/simplecov_mcp/mode_detector.rb +6 -5
- data/lib/simplecov_mcp/model.rb +122 -88
- data/lib/simplecov_mcp/option_normalizers.rb +39 -18
- data/lib/simplecov_mcp/option_parser_builder.rb +82 -65
- data/lib/simplecov_mcp/option_parsers/env_options_parser.rb +3 -5
- data/lib/simplecov_mcp/option_parsers/error_helper.rb +18 -17
- data/lib/simplecov_mcp/path_relativizer.rb +17 -14
- data/lib/simplecov_mcp/predicate_evaluator.rb +72 -0
- data/lib/simplecov_mcp/presenters/base_coverage_presenter.rb +1 -3
- data/lib/simplecov_mcp/presenters/coverage_detailed_presenter.rb +1 -3
- data/lib/simplecov_mcp/presenters/coverage_raw_presenter.rb +1 -3
- data/lib/simplecov_mcp/presenters/coverage_summary_presenter.rb +1 -3
- data/lib/simplecov_mcp/presenters/coverage_uncovered_presenter.rb +1 -3
- data/lib/simplecov_mcp/presenters/project_coverage_presenter.rb +1 -3
- data/lib/simplecov_mcp/presenters/project_totals_presenter.rb +27 -0
- data/lib/simplecov_mcp/resolvers/coverage_line_resolver.rb +14 -18
- data/lib/simplecov_mcp/resolvers/resultset_path_resolver.rb +7 -9
- data/lib/simplecov_mcp/resultset_loader.rb +20 -25
- data/lib/simplecov_mcp/staleness_checker.rb +50 -46
- data/lib/simplecov_mcp/table_formatter.rb +64 -0
- data/lib/simplecov_mcp/tools/all_files_coverage_tool.rb +20 -50
- data/lib/simplecov_mcp/tools/coverage_detailed_tool.rb +13 -7
- data/lib/simplecov_mcp/tools/coverage_raw_tool.rb +12 -7
- data/lib/simplecov_mcp/tools/coverage_summary_tool.rb +13 -8
- data/lib/simplecov_mcp/tools/coverage_table_tool.rb +20 -60
- data/lib/simplecov_mcp/tools/coverage_totals_tool.rb +44 -0
- data/lib/simplecov_mcp/tools/help_tool.rb +38 -66
- data/lib/simplecov_mcp/tools/uncovered_lines_tool.rb +13 -8
- data/lib/simplecov_mcp/tools/validate_tool.rb +72 -0
- data/lib/simplecov_mcp/tools/version_tool.rb +7 -14
- data/lib/simplecov_mcp/util.rb +18 -12
- data/lib/simplecov_mcp/version.rb +1 -1
- data/lib/simplecov_mcp.rb +23 -29
- data/spec/all_files_coverage_tool_spec.rb +4 -3
- data/spec/{cli_config_spec.rb → app_config_spec.rb} +31 -26
- data/spec/base_tool_spec.rb +17 -14
- data/spec/cli/show_default_report_spec.rb +2 -2
- data/spec/cli_enumerated_options_spec.rb +31 -9
- data/spec/cli_error_spec.rb +46 -23
- data/spec/cli_format_spec.rb +123 -0
- data/spec/cli_json_options_spec.rb +50 -0
- data/spec/cli_source_spec.rb +11 -63
- data/spec/cli_spec.rb +82 -97
- data/spec/cli_usage_spec.rb +15 -15
- data/spec/commands/base_command_spec.rb +12 -92
- data/spec/commands/command_factory_spec.rb +7 -3
- data/spec/commands/detailed_command_spec.rb +10 -24
- data/spec/commands/list_command_spec.rb +28 -0
- data/spec/commands/raw_command_spec.rb +43 -20
- data/spec/commands/summary_command_spec.rb +10 -23
- data/spec/commands/totals_command_spec.rb +34 -0
- data/spec/commands/uncovered_command_spec.rb +29 -23
- data/spec/commands/validate_command_spec.rb +213 -0
- data/spec/commands/version_command_spec.rb +38 -0
- data/spec/constants_spec.rb +3 -3
- data/spec/coverage_reporter_spec.rb +102 -0
- data/spec/coverage_table_tool_spec.rb +21 -10
- data/spec/coverage_totals_tool_spec.rb +37 -0
- data/spec/error_handler_spec.rb +120 -4
- data/spec/error_mode_spec.rb +18 -22
- data/spec/errors_edge_cases_spec.rb +101 -28
- data/spec/errors_stale_spec.rb +34 -0
- data/spec/file_based_mcp_tools_spec.rb +6 -6
- data/spec/fixtures/project1/lib/bar.rb +2 -0
- data/spec/fixtures/project1/lib/foo.rb +2 -0
- data/spec/help_tool_spec.rb +2 -18
- data/spec/integration_spec.rb +103 -161
- data/spec/logging_fallback_spec.rb +3 -3
- data/spec/mcp_server_integration_spec.rb +1 -1
- data/spec/mcp_server_spec.rb +70 -53
- data/spec/mode_detector_spec.rb +46 -41
- data/spec/model_error_handling_spec.rb +139 -78
- data/spec/model_staleness_spec.rb +13 -13
- data/spec/option_normalizers_spec.rb +111 -112
- data/spec/option_parsers/env_options_parser_spec.rb +25 -37
- data/spec/option_parsers/error_helper_spec.rb +56 -56
- data/spec/path_relativizer_spec.rb +15 -0
- data/spec/presenters/coverage_detailed_presenter_spec.rb +1 -1
- data/spec/presenters/coverage_summary_presenter_spec.rb +1 -1
- data/spec/presenters/coverage_uncovered_presenter_spec.rb +1 -1
- data/spec/presenters/project_coverage_presenter_spec.rb +9 -8
- data/spec/presenters/project_totals_presenter_spec.rb +144 -0
- data/spec/resolvers/coverage_line_resolver_spec.rb +261 -36
- data/spec/resolvers/resultset_path_resolver_spec.rb +13 -8
- data/spec/shared_examples/file_based_mcp_tools.rb +23 -18
- data/spec/shared_examples/formatted_command_examples.rb +64 -0
- data/spec/simple_cov_mcp_module_spec.rb +24 -3
- data/spec/simplecov_mcp/formatters/source_formatter_spec.rb +267 -0
- data/spec/simplecov_mcp/formatters_spec.rb +76 -0
- data/spec/simplecov_mcp/presenters/base_coverage_presenter_spec.rb +79 -0
- data/spec/simplecov_mcp_model_spec.rb +97 -47
- data/spec/simplecov_mcp_opts_spec.rb +42 -39
- data/spec/spec_helper.rb +27 -92
- data/spec/staleness_checker_spec.rb +10 -9
- data/spec/staleness_more_spec.rb +4 -4
- 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 +10 -8
- 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 +34 -48
- data/spec/util_spec.rb +5 -4
- data/spec/version_spec.rb +7 -7
- data/spec/version_tool_spec.rb +20 -22
- metadata +90 -23
- data/docs/BRANCH_ONLY_COVERAGE.md +0 -81
- data/docs/CLI_USAGE.md +0 -637
- data/docs/EXAMPLES.md +0 -430
- data/docs/INSTALLATION.md +0 -352
- data/spec/cli_success_predicate_spec.rb +0 -141
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
RSpec.shared_examples 'a command with formatted output' do |command_args, expected_json_keys|
|
|
7
|
+
context 'with json format' do
|
|
8
|
+
before { cli_context.config.format = :json }
|
|
9
|
+
|
|
10
|
+
it 'outputs valid JSON' do
|
|
11
|
+
output = capture_command_output(command, command_args)
|
|
12
|
+
json = JSON.parse(output)
|
|
13
|
+
|
|
14
|
+
if expected_json_keys.is_a?(Array)
|
|
15
|
+
expected_json_keys.each { |k| expect(json).to have_key(k) }
|
|
16
|
+
elsif expected_json_keys.is_a?(Hash)
|
|
17
|
+
expected_json_keys.each do |k, v|
|
|
18
|
+
expect(json).to have_key(k)
|
|
19
|
+
# Skip deep comparison if v is nil, just check key existence
|
|
20
|
+
expect(json[k]).to eq(v) if v
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
context 'with yaml format' do
|
|
27
|
+
before { cli_context.config.format = :yaml }
|
|
28
|
+
|
|
29
|
+
it 'outputs valid YAML' do
|
|
30
|
+
output = capture_command_output(command, command_args)
|
|
31
|
+
# Allow Symbol for keys that might be symbols (e.g. from version command)
|
|
32
|
+
yaml = YAML.safe_load(output, permitted_classes: [Symbol])
|
|
33
|
+
|
|
34
|
+
if expected_json_keys.is_a?(Array)
|
|
35
|
+
expected_json_keys.each do |k|
|
|
36
|
+
# Check for string or symbol key
|
|
37
|
+
expect(yaml).to have_key(k).or have_key(k.to_sym)
|
|
38
|
+
end
|
|
39
|
+
elsif expected_json_keys.is_a?(Hash)
|
|
40
|
+
expected_json_keys.each do |k, v|
|
|
41
|
+
val = yaml.key?(k) ? yaml[k] : yaml[k.to_sym]
|
|
42
|
+
expect(val).not_to be_nil
|
|
43
|
+
expect(val).to eq(v) if v
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
context 'with awesome_print format' do
|
|
50
|
+
before { cli_context.config.format = :awesome_print }
|
|
51
|
+
|
|
52
|
+
it 'outputs awesome_print formatted string' do
|
|
53
|
+
output = capture_command_output(command, command_args)
|
|
54
|
+
# Strip ANSI color codes for matching
|
|
55
|
+
plain_output = output.gsub(/\e\[([;\d]+)?m/, '')
|
|
56
|
+
|
|
57
|
+
keys_to_check = expected_json_keys.is_a?(Hash) ? expected_json_keys.keys : expected_json_keys
|
|
58
|
+
keys_to_check.each do |k|
|
|
59
|
+
# Check for string key "key" => or symbol key :key =>
|
|
60
|
+
expect(plain_output).to match(/"#{k}"\s*=>|:#{k}\s*=>/)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -7,10 +7,31 @@ RSpec.describe SimpleCovMcp do
|
|
|
7
7
|
# These tests verify the integration with ModeDetector
|
|
8
8
|
describe 'mode detection integration' do
|
|
9
9
|
it 'uses ModeDetector for CLI mode detection' do
|
|
10
|
-
|
|
10
|
+
allow(described_class::ModeDetector).to receive(:cli_mode?).with(['--force-cli'])
|
|
11
11
|
.and_return(true)
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
cli = instance_double(described_class::CoverageCLI, run: nil)
|
|
13
|
+
allow(described_class::CoverageCLI).to receive(:new).and_return(cli)
|
|
14
|
+
|
|
15
|
+
described_class.run(['--force-cli'])
|
|
16
|
+
|
|
17
|
+
expect(described_class::ModeDetector).to have_received(:cli_mode?).with(['--force-cli'])
|
|
18
|
+
expect(described_class::CoverageCLI).to have_received(:new)
|
|
19
|
+
expect(cli).to have_received(:run)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# When no thread-local context exists, active_log_file= creates one
|
|
24
|
+
# from the default context rather than modifying an existing one.
|
|
25
|
+
describe '.active_log_file=' do
|
|
26
|
+
it 'creates context from default when no current context exists' do
|
|
27
|
+
Thread.current[:simplecov_mcp_context] = nil
|
|
28
|
+
|
|
29
|
+
described_class.active_log_file = '/tmp/test.log'
|
|
30
|
+
|
|
31
|
+
expect(described_class.context).not_to be_nil
|
|
32
|
+
expect(described_class.active_log_file).to eq('/tmp/test.log')
|
|
33
|
+
ensure
|
|
34
|
+
described_class.active_log_file = File::NULL
|
|
14
35
|
end
|
|
15
36
|
end
|
|
16
37
|
end
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe SimpleCovMcp::Formatters::SourceFormatter do
|
|
6
|
+
subject(:formatter) { described_class.new(color_enabled: color_enabled) }
|
|
7
|
+
|
|
8
|
+
let(:color_enabled) { false }
|
|
9
|
+
let(:model) { instance_double(SimpleCovMcp::CoverageModel) }
|
|
10
|
+
let(:path) { 'lib/foo.rb' }
|
|
11
|
+
let(:abs_path) { File.expand_path(path) }
|
|
12
|
+
let(:source_content) do
|
|
13
|
+
<<~RUBY
|
|
14
|
+
class Foo
|
|
15
|
+
def bar
|
|
16
|
+
puts 'bar'
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
RUBY
|
|
20
|
+
end
|
|
21
|
+
let(:coverage_lines) { [1, 1, 0, nil, nil] } # Line 3 is uncovered
|
|
22
|
+
|
|
23
|
+
before do
|
|
24
|
+
allow(model).to receive(:raw_for).with(path).and_return(
|
|
25
|
+
'file' => abs_path,
|
|
26
|
+
'lines' => coverage_lines
|
|
27
|
+
)
|
|
28
|
+
allow(File).to receive(:file?).with(abs_path).and_return(true)
|
|
29
|
+
allow(File).to receive(:readlines).with(abs_path, chomp: true)
|
|
30
|
+
.and_return(source_content.lines(chomp: true))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
describe '#format_source_for' do
|
|
34
|
+
context 'when source is available' do
|
|
35
|
+
it 'renders formatted source lines with line numbers and markers' do
|
|
36
|
+
# Full mode should print every line with coverage markers and numbering.
|
|
37
|
+
result = formatter.format_source_for(model, path, mode: :full)
|
|
38
|
+
|
|
39
|
+
expect(result.lines(chomp: true)).to eq(
|
|
40
|
+
[
|
|
41
|
+
' Line | Source',
|
|
42
|
+
'------ ---+-------------------------------------------------------------',
|
|
43
|
+
' 1 ✓ | class Foo',
|
|
44
|
+
' 2 ✓ | def bar',
|
|
45
|
+
" 3 · | puts 'bar'",
|
|
46
|
+
' 4 | end',
|
|
47
|
+
' 5 | end'
|
|
48
|
+
]
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it 'marks covered lines with a checkmark' do
|
|
53
|
+
# Two covered lines should each get a ✓ in the rendered output.
|
|
54
|
+
result = formatter.format_source_for(model, path, mode: :full)
|
|
55
|
+
# covered: true -> '✓', false -> '·', nil -> ' '
|
|
56
|
+
expect(result.count('✓')).to eq(2)
|
|
57
|
+
expect(result.lines[2]).to match(/\b1\s+✓ \| class Foo/)
|
|
58
|
+
expect(result.lines[3]).to match(/\b2\s+✓ \| def bar/)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
it 'marks uncovered lines with a dot' do
|
|
62
|
+
# The single uncovered line should be marked with a dot.
|
|
63
|
+
result = formatter.format_source_for(model, path, mode: :full)
|
|
64
|
+
expect(result.count('·')).to eq(1)
|
|
65
|
+
expect(result.lines[4]).to match(/\b3\s+· \| puts 'bar'/)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
it 'returns only header when mode is nil (default)' do
|
|
69
|
+
# Default mode skips body rows but still emits the header scaffold.
|
|
70
|
+
result = formatter.format_source_for(model, path)
|
|
71
|
+
# Example header-only output:
|
|
72
|
+
# Line | Source
|
|
73
|
+
# ------ ---+-------------------------------------------------------------
|
|
74
|
+
expect(result).not_to include('class Foo')
|
|
75
|
+
expect(result).to include('Line', 'Source')
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
context 'when source file is not found' do
|
|
80
|
+
it 'returns fallback message' do
|
|
81
|
+
# Simulate missing file; formatter should not raise and should return a placeholder.
|
|
82
|
+
allow(File).to receive(:file?).with(abs_path).and_return(false)
|
|
83
|
+
expect(formatter.format_source_for(model, path)).to eq('[source not available]')
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
context 'when raw coverage data is missing' do
|
|
88
|
+
it 'returns fallback message' do
|
|
89
|
+
# No coverage entry for the path should also trigger the placeholder.
|
|
90
|
+
allow(model).to receive(:raw_for).with(path).and_return(nil)
|
|
91
|
+
expect(formatter.format_source_for(model, path)).to eq('[source not available]')
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
context 'when an error occurs during formatting' do
|
|
96
|
+
it 'returns fallback message instead of crashing' do
|
|
97
|
+
# Create a pathological coverage array with an object that raises on to_i
|
|
98
|
+
bad_object = Object.new
|
|
99
|
+
def bad_object.to_i = raise(StandardError, 'Bad data')
|
|
100
|
+
def bad_object.nil? = false
|
|
101
|
+
|
|
102
|
+
bad_coverage = [1, 1, bad_object, nil, nil]
|
|
103
|
+
|
|
104
|
+
allow(model).to receive(:raw_for).with(path)
|
|
105
|
+
.and_return('file' => abs_path, 'lines' => bad_coverage)
|
|
106
|
+
|
|
107
|
+
result = formatter.format_source_for(model, path, mode: :full)
|
|
108
|
+
expect(result).to eq('[source not available]')
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
it 'propagates ArgumentError' do
|
|
112
|
+
expect do
|
|
113
|
+
formatter.format_source_for(model, path, mode: :full, context: -1)
|
|
114
|
+
end.to raise_error(ArgumentError, 'Context lines cannot be negative')
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
context 'with color enabled' do
|
|
119
|
+
let(:color_enabled) { true }
|
|
120
|
+
|
|
121
|
+
it 'includes ANSI color codes' do
|
|
122
|
+
# Markers should be wrapped with green/red ANSI sequences when colors are on.
|
|
123
|
+
# Example colored line: " 1 \e[32m✓\e[0m | class Foo"
|
|
124
|
+
result = formatter.format_source_for(model, path, mode: :full)
|
|
125
|
+
expect(result).to include("\e[32m", "\e[31m") # green for checkmark, red for dot
|
|
126
|
+
expect(result.lines[2]).to include("\e[32m✓\e[0m") # line 1 checkmark is green
|
|
127
|
+
expect(result.lines[3]).to include("\e[32m✓\e[0m") # line 2 checkmark is green
|
|
128
|
+
expect(result.lines[4]).to include("\e[31m·\e[0m") # line 3 dot is red
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
describe '#build_source_payload' do
|
|
134
|
+
it 'returns row data when source is available' do
|
|
135
|
+
# Payload should mirror the row hashes used by CLI formatting.
|
|
136
|
+
result = formatter.build_source_payload(model, path, mode: :full)
|
|
137
|
+
expect(result).to be_a(Array)
|
|
138
|
+
expect(result.size).to eq(5)
|
|
139
|
+
expect(result.first).to include('code' => 'class Foo', 'line' => 1)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
it 'returns nil when raw coverage is missing' do
|
|
143
|
+
# Without coverage data, there is no payload to build.
|
|
144
|
+
allow(model).to receive(:raw_for).with(path).and_return(nil)
|
|
145
|
+
expect(formatter.build_source_payload(model, path)).to be_nil
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
it 'returns nil when source file is missing' do
|
|
149
|
+
# Missing source file should also produce a nil payload.
|
|
150
|
+
allow(File).to receive(:file?).with(abs_path).and_return(false)
|
|
151
|
+
expect(formatter.build_source_payload(model, path)).to be_nil
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
describe '#build_source_rows' do
|
|
156
|
+
it 'raises error for negative context count' do
|
|
157
|
+
# Negative context should raise ArgumentError
|
|
158
|
+
expect do
|
|
159
|
+
formatter.build_source_rows(
|
|
160
|
+
source_content.lines(chomp: true),
|
|
161
|
+
coverage_lines,
|
|
162
|
+
mode: :uncovered,
|
|
163
|
+
context: -1
|
|
164
|
+
)
|
|
165
|
+
end.to raise_error(ArgumentError, 'Context lines cannot be negative')
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
it 'handles default context (2 lines)' do
|
|
169
|
+
# With the default context of 2, uncovered lines pull in surrounding rows.
|
|
170
|
+
rows = formatter.build_source_rows(
|
|
171
|
+
source_content.lines(chomp: true),
|
|
172
|
+
coverage_lines,
|
|
173
|
+
mode: :uncovered,
|
|
174
|
+
context: 2
|
|
175
|
+
)
|
|
176
|
+
# Uncovered is line 3. Context 2 means lines 1..5 (indexes 0..4).
|
|
177
|
+
# Total line count is 5, so all lines should be included.
|
|
178
|
+
expect(rows.size).to eq(5)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
it 'handles bad context input (non-numeric)' do
|
|
182
|
+
# Non-numeric context coerces to 0 via to_i, so only the miss is included.
|
|
183
|
+
rows = formatter.build_source_rows(
|
|
184
|
+
source_content.lines(chomp: true),
|
|
185
|
+
coverage_lines,
|
|
186
|
+
mode: :uncovered,
|
|
187
|
+
context: 'bad'
|
|
188
|
+
)
|
|
189
|
+
# "bad".to_i is 0 so context should be 0.
|
|
190
|
+
# Uncovered is line 3.
|
|
191
|
+
expect(rows.size).to eq(1)
|
|
192
|
+
expect(rows.first['line']).to eq(3)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
it 'handles context input that raises error on to_i conversion' do
|
|
196
|
+
# Create an object where to_i raises an error
|
|
197
|
+
bad_context = Object.new
|
|
198
|
+
def bad_context.to_i = raise(StandardError, 'Cannot convert')
|
|
199
|
+
|
|
200
|
+
# Falling back to default context should still include surrounding lines.
|
|
201
|
+
rows = formatter.build_source_rows(
|
|
202
|
+
source_content.lines(chomp: true),
|
|
203
|
+
coverage_lines,
|
|
204
|
+
mode: :uncovered,
|
|
205
|
+
context: bad_context
|
|
206
|
+
)
|
|
207
|
+
# Should fall back to default context of 2
|
|
208
|
+
# Uncovered is line 3. Context 2 means lines 1..5 (indexes 0..4).
|
|
209
|
+
# Total lines is 5. So all lines should be included.
|
|
210
|
+
expect(rows.size).to eq(5)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
it 'handles nil coverage lines defensively' do
|
|
214
|
+
# Nil coverage array should not raise; hits/covered become nil.
|
|
215
|
+
# This covers the "coverage data missing entirely" path; in real coverage we'd see 1/0 hits.
|
|
216
|
+
# Expected rows when coverage is nil:
|
|
217
|
+
# [
|
|
218
|
+
# { 'line' => 1, 'code' => 'class Foo', 'hits' => nil, 'covered' => nil },
|
|
219
|
+
# { 'line' => 2, 'code' => ' def bar', 'hits' => nil, 'covered' => nil },
|
|
220
|
+
# { 'line' => 3, 'code' => " puts 'bar'", 'hits' => nil, 'covered' => nil },
|
|
221
|
+
# { 'line' => 4, 'code' => ' end', 'hits' => nil, 'covered' => nil },
|
|
222
|
+
# { 'line' => 5, 'code' => 'end', 'hits' => nil, 'covered' => nil }
|
|
223
|
+
# ]
|
|
224
|
+
# And the formatted output (markers blank because coverage is missing) would be:
|
|
225
|
+
# Line | Source
|
|
226
|
+
# ------ ---+-------------------------------------------------------------
|
|
227
|
+
# 1 | class Foo
|
|
228
|
+
# 2 | def bar
|
|
229
|
+
# 3 | puts 'bar'
|
|
230
|
+
# 4 | end
|
|
231
|
+
# 5 | end
|
|
232
|
+
rows = formatter.build_source_rows(
|
|
233
|
+
source_content.lines(chomp: true),
|
|
234
|
+
nil,
|
|
235
|
+
mode: :full,
|
|
236
|
+
context: 2
|
|
237
|
+
)
|
|
238
|
+
expect(rows.size).to eq(5)
|
|
239
|
+
expect(rows.first['hits']).to be_nil
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
describe '#format_detailed_rows' do
|
|
244
|
+
it 'formats rows into a table' do
|
|
245
|
+
# Detailed mode should align numeric columns and boolean covered flags.
|
|
246
|
+
rows = [
|
|
247
|
+
{ 'line' => 1, 'hits' => 5, 'covered' => true },
|
|
248
|
+
{ 'line' => 2, 'hits' => 0, 'covered' => false }
|
|
249
|
+
]
|
|
250
|
+
# Expected table:
|
|
251
|
+
# Line Hits Covered
|
|
252
|
+
# ----- ---- -------
|
|
253
|
+
# 1 5 yes
|
|
254
|
+
# 2 0 no
|
|
255
|
+
result = formatter.format_detailed_rows(rows)
|
|
256
|
+
expect(result).to include('Line', 'Hits', 'Covered', '5', 'yes', 'no')
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
describe 'private #fetch_raw error handling' do
|
|
261
|
+
it 'returns nil if model raises error' do
|
|
262
|
+
# fetch_raw should swallow model errors and return nil instead of propagating.
|
|
263
|
+
allow(model).to receive(:raw_for).and_raise(StandardError)
|
|
264
|
+
expect(formatter.send(:fetch_raw, model, path)).to be_nil
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe SimpleCovMcp::Formatters do
|
|
6
|
+
describe '.formatter_for' do
|
|
7
|
+
it 'returns a lambda for known format' do
|
|
8
|
+
expect(described_class.formatter_for(:json)).to respond_to(:call)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
it 'raises ArgumentError for unknown format' do
|
|
12
|
+
expect { described_class.formatter_for(:unknown) }
|
|
13
|
+
.to raise_error(ArgumentError, /Unknown format: unknown/)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
describe '.ensure_requirements_for' do
|
|
18
|
+
it 'requires the library if needed' do
|
|
19
|
+
# We rely on the fact that 'yaml' is in FORMAT_REQUIRES
|
|
20
|
+
expect(described_class).to receive(:require).with('yaml')
|
|
21
|
+
described_class.ensure_requirements_for(:yaml)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it 'does nothing if no requirement' do
|
|
25
|
+
expect(described_class).not_to receive(:require)
|
|
26
|
+
described_class.ensure_requirements_for(:json) # JSON already required by app
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
describe '.format' do
|
|
31
|
+
let(:obj) { { 'foo' => 'bar' } }
|
|
32
|
+
|
|
33
|
+
[
|
|
34
|
+
[:json, '{"foo":"bar"}', :eq],
|
|
35
|
+
[:pretty_json, "{\n \"foo\": \"bar\"\n}", :include],
|
|
36
|
+
[:table, { 'foo' => 'bar' }, :eq],
|
|
37
|
+
[:yaml, "---\nfoo: bar\n", :include]
|
|
38
|
+
].each do |format, expected, matcher|
|
|
39
|
+
it "formats as #{format}" do
|
|
40
|
+
result = described_class.format(obj, format)
|
|
41
|
+
expect(result).to send(matcher, expected)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
context 'when a required gem is missing' do
|
|
46
|
+
before do
|
|
47
|
+
error = LoadError.new('cannot load such file -- awesome_print')
|
|
48
|
+
allow(described_class).to receive(:require).with('awesome_print').and_raise(error)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
it 'raises a helpful LoadError' do
|
|
52
|
+
expect { described_class.format(obj, :awesome_print) }
|
|
53
|
+
.to raise_error(LoadError, /requires the 'awesome_print' gem/)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
context 'when awesome_print is available' do
|
|
58
|
+
before do
|
|
59
|
+
# Stub require on the module for ensure_requirements_for
|
|
60
|
+
allow(described_class).to receive(:require).with('awesome_print')
|
|
61
|
+
|
|
62
|
+
# Stub global require for the lambda's internal require
|
|
63
|
+
allow(Kernel).to receive(:require).and_call_original
|
|
64
|
+
allow(Kernel).to receive(:require).with('awesome_print').and_return(true)
|
|
65
|
+
|
|
66
|
+
# Mock .ai on the object
|
|
67
|
+
allow(obj).to receive(:ai).and_return('awesome output')
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
it 'formats using awesome_print' do
|
|
71
|
+
result = described_class.format(obj, :awesome_print)
|
|
72
|
+
expect(result).to eq('awesome output')
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe SimpleCovMcp::Presenters::BaseCoveragePresenter do
|
|
6
|
+
let(:model) { instance_double(SimpleCovMcp::CoverageModel) }
|
|
7
|
+
let(:path) { 'lib/foo.rb' }
|
|
8
|
+
let(:presenter) { described_class.new(model: model, path: path) }
|
|
9
|
+
|
|
10
|
+
describe '#initialize' do
|
|
11
|
+
it 'sets model and path' do
|
|
12
|
+
expect(presenter.model).to eq(model)
|
|
13
|
+
expect(presenter.path).to eq(path)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
describe '#absolute_payload' do
|
|
18
|
+
it 'raises NotImplementedError because build_payload is abstract' do
|
|
19
|
+
expect { presenter.absolute_payload }.to raise_error(NotImplementedError)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
context 'with a concrete implementation' do
|
|
24
|
+
let(:concrete_class) do
|
|
25
|
+
Class.new(described_class) do
|
|
26
|
+
# Provide a concrete implementation of the abstract build_payload method
|
|
27
|
+
# for testing the BaseCoveragePresenter functionality
|
|
28
|
+
def build_payload
|
|
29
|
+
{ 'file' => path, 'data' => 'test' }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
let(:presenter) { concrete_class.new(model: model, path: path) }
|
|
34
|
+
let(:payload_with_stale) { { 'file' => path, 'data' => 'test', 'stale' => false } }
|
|
35
|
+
|
|
36
|
+
before do
|
|
37
|
+
allow(model).to receive(:staleness_for).with(path).and_return(false)
|
|
38
|
+
allow(model).to receive(:relativize).with(payload_with_stale).and_return(payload_with_stale)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
describe '#absolute_payload' do
|
|
42
|
+
it 'merges stale status into payload' do
|
|
43
|
+
expect(presenter.absolute_payload).to include('stale' => false)
|
|
44
|
+
expect(presenter.absolute_payload).to include('data' => 'test')
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it 'caches the result' do
|
|
48
|
+
r1 = presenter.absolute_payload
|
|
49
|
+
r2 = presenter.absolute_payload
|
|
50
|
+
expect(r1).to equal(r2)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
describe '#relativized_payload' do
|
|
55
|
+
it 'delegates to model.relativize' do
|
|
56
|
+
expect(model).to receive(:relativize).with(presenter.absolute_payload)
|
|
57
|
+
presenter.relativized_payload
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it 'caches the result' do
|
|
61
|
+
presenter.relativized_payload
|
|
62
|
+
expect(model).to have_received(:relativize).once
|
|
63
|
+
presenter.relativized_payload
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
describe '#stale' do
|
|
68
|
+
it 'delegates to absolute_payload' do
|
|
69
|
+
expect(presenter.stale).to be(false)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
describe '#relative_path' do
|
|
74
|
+
it 'delegates to relativized_payload' do
|
|
75
|
+
expect(presenter.relative_path).to eq('lib/foo.rb')
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|