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.
Files changed (171) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +329 -0
  4. data/docs/dev/ARCHITECTURE.md +80 -0
  5. data/docs/dev/BRANCH_ONLY_COVERAGE.md +158 -0
  6. data/docs/dev/DEVELOPMENT.md +83 -0
  7. data/docs/dev/README.md +10 -0
  8. data/docs/dev/RELEASING.md +146 -0
  9. data/docs/dev/arch-decisions/001-x-arch-decision.md +95 -0
  10. data/docs/dev/arch-decisions/002-x-arch-decision.md +159 -0
  11. data/docs/dev/arch-decisions/003-x-arch-decision.md +165 -0
  12. data/docs/dev/arch-decisions/004-x-arch-decision.md +203 -0
  13. data/docs/dev/arch-decisions/005-x-arch-decision.md +189 -0
  14. data/docs/dev/arch-decisions/README.md +60 -0
  15. data/docs/dev/presentations/cov-loupe-presentation.md +255 -0
  16. data/docs/fixtures/demo_project/README.md +9 -0
  17. data/docs/user/ADVANCED_USAGE.md +777 -0
  18. data/docs/user/CLI_FALLBACK_FOR_LLMS.md +34 -0
  19. data/docs/user/CLI_USAGE.md +750 -0
  20. data/docs/user/ERROR_HANDLING.md +93 -0
  21. data/docs/user/EXAMPLES.md +588 -0
  22. data/docs/user/INSTALLATION.md +130 -0
  23. data/docs/user/LIBRARY_API.md +693 -0
  24. data/docs/user/MCP_INTEGRATION.md +490 -0
  25. data/docs/user/README.md +14 -0
  26. data/docs/user/TROUBLESHOOTING.md +197 -0
  27. data/docs/user/V2-BREAKING-CHANGES.md +472 -0
  28. data/exe/cov-loupe +23 -0
  29. data/lib/cov_loupe/app_config.rb +56 -0
  30. data/lib/cov_loupe/app_context.rb +26 -0
  31. data/lib/cov_loupe/base_tool.rb +102 -0
  32. data/lib/cov_loupe/cli.rb +178 -0
  33. data/lib/cov_loupe/commands/base_command.rb +67 -0
  34. data/lib/cov_loupe/commands/command_factory.rb +45 -0
  35. data/lib/cov_loupe/commands/detailed_command.rb +38 -0
  36. data/lib/cov_loupe/commands/list_command.rb +13 -0
  37. data/lib/cov_loupe/commands/raw_command.rb +38 -0
  38. data/lib/cov_loupe/commands/summary_command.rb +41 -0
  39. data/lib/cov_loupe/commands/totals_command.rb +53 -0
  40. data/lib/cov_loupe/commands/uncovered_command.rb +45 -0
  41. data/lib/cov_loupe/commands/validate_command.rb +60 -0
  42. data/lib/cov_loupe/commands/version_command.rb +33 -0
  43. data/lib/cov_loupe/config_parser.rb +32 -0
  44. data/lib/cov_loupe/constants.rb +22 -0
  45. data/lib/cov_loupe/coverage_reporter.rb +31 -0
  46. data/lib/cov_loupe/error_handler.rb +165 -0
  47. data/lib/cov_loupe/error_handler_factory.rb +31 -0
  48. data/lib/cov_loupe/errors.rb +191 -0
  49. data/lib/cov_loupe/formatters/source_formatter.rb +152 -0
  50. data/lib/cov_loupe/formatters.rb +51 -0
  51. data/lib/cov_loupe/mcp_server.rb +42 -0
  52. data/lib/cov_loupe/mode_detector.rb +56 -0
  53. data/lib/cov_loupe/model.rb +339 -0
  54. data/lib/cov_loupe/option_normalizers.rb +113 -0
  55. data/lib/cov_loupe/option_parser_builder.rb +147 -0
  56. data/lib/cov_loupe/option_parsers/env_options_parser.rb +48 -0
  57. data/lib/cov_loupe/option_parsers/error_helper.rb +110 -0
  58. data/lib/cov_loupe/path_relativizer.rb +64 -0
  59. data/lib/cov_loupe/predicate_evaluator.rb +72 -0
  60. data/lib/cov_loupe/presenters/base_coverage_presenter.rb +42 -0
  61. data/lib/cov_loupe/presenters/coverage_detailed_presenter.rb +14 -0
  62. data/lib/cov_loupe/presenters/coverage_raw_presenter.rb +14 -0
  63. data/lib/cov_loupe/presenters/coverage_summary_presenter.rb +14 -0
  64. data/lib/cov_loupe/presenters/coverage_uncovered_presenter.rb +14 -0
  65. data/lib/cov_loupe/presenters/project_coverage_presenter.rb +50 -0
  66. data/lib/cov_loupe/presenters/project_totals_presenter.rb +27 -0
  67. data/lib/cov_loupe/resolvers/coverage_line_resolver.rb +122 -0
  68. data/lib/cov_loupe/resolvers/resolver_factory.rb +28 -0
  69. data/lib/cov_loupe/resolvers/resultset_path_resolver.rb +76 -0
  70. data/lib/cov_loupe/resultset_loader.rb +131 -0
  71. data/lib/cov_loupe/staleness_checker.rb +247 -0
  72. data/lib/cov_loupe/table_formatter.rb +64 -0
  73. data/lib/cov_loupe/tools/all_files_coverage_tool.rb +51 -0
  74. data/lib/cov_loupe/tools/coverage_detailed_tool.rb +35 -0
  75. data/lib/cov_loupe/tools/coverage_raw_tool.rb +34 -0
  76. data/lib/cov_loupe/tools/coverage_summary_tool.rb +34 -0
  77. data/lib/cov_loupe/tools/coverage_table_tool.rb +50 -0
  78. data/lib/cov_loupe/tools/coverage_totals_tool.rb +44 -0
  79. data/lib/cov_loupe/tools/help_tool.rb +115 -0
  80. data/lib/cov_loupe/tools/uncovered_lines_tool.rb +34 -0
  81. data/lib/cov_loupe/tools/validate_tool.rb +72 -0
  82. data/lib/cov_loupe/tools/version_tool.rb +32 -0
  83. data/lib/cov_loupe/util.rb +88 -0
  84. data/lib/cov_loupe/version.rb +5 -0
  85. data/lib/cov_loupe.rb +140 -0
  86. data/spec/MCP_INTEGRATION_TESTS_README.md +111 -0
  87. data/spec/TIMESTAMPS.md +48 -0
  88. data/spec/all_files_coverage_tool_spec.rb +53 -0
  89. data/spec/app_config_spec.rb +142 -0
  90. data/spec/base_tool_spec.rb +62 -0
  91. data/spec/cli/show_default_report_spec.rb +33 -0
  92. data/spec/cli_enumerated_options_spec.rb +90 -0
  93. data/spec/cli_error_spec.rb +184 -0
  94. data/spec/cli_format_spec.rb +123 -0
  95. data/spec/cli_json_options_spec.rb +50 -0
  96. data/spec/cli_source_spec.rb +44 -0
  97. data/spec/cli_spec.rb +192 -0
  98. data/spec/cli_table_spec.rb +28 -0
  99. data/spec/cli_usage_spec.rb +42 -0
  100. data/spec/commands/base_command_spec.rb +107 -0
  101. data/spec/commands/command_factory_spec.rb +76 -0
  102. data/spec/commands/detailed_command_spec.rb +34 -0
  103. data/spec/commands/list_command_spec.rb +28 -0
  104. data/spec/commands/raw_command_spec.rb +69 -0
  105. data/spec/commands/summary_command_spec.rb +34 -0
  106. data/spec/commands/totals_command_spec.rb +34 -0
  107. data/spec/commands/uncovered_command_spec.rb +55 -0
  108. data/spec/commands/validate_command_spec.rb +213 -0
  109. data/spec/commands/version_command_spec.rb +38 -0
  110. data/spec/constants_spec.rb +61 -0
  111. data/spec/cov_loupe/formatters/source_formatter_spec.rb +267 -0
  112. data/spec/cov_loupe/formatters_spec.rb +76 -0
  113. data/spec/cov_loupe/presenters/base_coverage_presenter_spec.rb +79 -0
  114. data/spec/cov_loupe_model_spec.rb +454 -0
  115. data/spec/cov_loupe_module_spec.rb +37 -0
  116. data/spec/cov_loupe_opts_spec.rb +185 -0
  117. data/spec/coverage_reporter_spec.rb +102 -0
  118. data/spec/coverage_table_tool_spec.rb +59 -0
  119. data/spec/coverage_totals_tool_spec.rb +37 -0
  120. data/spec/error_handler_spec.rb +197 -0
  121. data/spec/error_mode_spec.rb +139 -0
  122. data/spec/errors_edge_cases_spec.rb +312 -0
  123. data/spec/errors_stale_spec.rb +83 -0
  124. data/spec/file_based_mcp_tools_spec.rb +99 -0
  125. data/spec/fixtures/project1/lib/bar.rb +5 -0
  126. data/spec/fixtures/project1/lib/foo.rb +6 -0
  127. data/spec/help_tool_spec.rb +26 -0
  128. data/spec/integration_spec.rb +789 -0
  129. data/spec/logging_fallback_spec.rb +128 -0
  130. data/spec/mcp_logging_spec.rb +44 -0
  131. data/spec/mcp_server_integration_spec.rb +23 -0
  132. data/spec/mcp_server_spec.rb +106 -0
  133. data/spec/mode_detector_spec.rb +153 -0
  134. data/spec/model_error_handling_spec.rb +269 -0
  135. data/spec/model_staleness_spec.rb +79 -0
  136. data/spec/option_normalizers_spec.rb +203 -0
  137. data/spec/option_parsers/env_options_parser_spec.rb +221 -0
  138. data/spec/option_parsers/error_helper_spec.rb +222 -0
  139. data/spec/path_relativizer_spec.rb +98 -0
  140. data/spec/presenters/coverage_detailed_presenter_spec.rb +19 -0
  141. data/spec/presenters/coverage_raw_presenter_spec.rb +15 -0
  142. data/spec/presenters/coverage_summary_presenter_spec.rb +15 -0
  143. data/spec/presenters/coverage_uncovered_presenter_spec.rb +16 -0
  144. data/spec/presenters/project_coverage_presenter_spec.rb +87 -0
  145. data/spec/presenters/project_totals_presenter_spec.rb +144 -0
  146. data/spec/resolvers/coverage_line_resolver_spec.rb +282 -0
  147. data/spec/resolvers/resolver_factory_spec.rb +61 -0
  148. data/spec/resolvers/resultset_path_resolver_spec.rb +60 -0
  149. data/spec/resultset_loader_spec.rb +167 -0
  150. data/spec/shared_examples/README.md +115 -0
  151. data/spec/shared_examples/coverage_presenter_examples.rb +66 -0
  152. data/spec/shared_examples/file_based_mcp_tools.rb +179 -0
  153. data/spec/shared_examples/formatted_command_examples.rb +64 -0
  154. data/spec/shared_examples/mcp_tool_text_json_response.rb +16 -0
  155. data/spec/spec_helper.rb +127 -0
  156. data/spec/staleness_checker_spec.rb +374 -0
  157. data/spec/staleness_more_spec.rb +42 -0
  158. data/spec/support/cli_helpers.rb +22 -0
  159. data/spec/support/control_flow_helpers.rb +20 -0
  160. data/spec/support/fake_mcp.rb +40 -0
  161. data/spec/support/io_helpers.rb +29 -0
  162. data/spec/support/mcp_helpers.rb +35 -0
  163. data/spec/support/mcp_runner.rb +66 -0
  164. data/spec/support/mocking_helpers.rb +30 -0
  165. data/spec/table_format_spec.rb +70 -0
  166. data/spec/tools/validate_tool_spec.rb +132 -0
  167. data/spec/tools_error_handling_spec.rb +130 -0
  168. data/spec/util_spec.rb +154 -0
  169. data/spec/version_spec.rb +123 -0
  170. data/spec/version_tool_spec.rb +141 -0
  171. metadata +290 -0
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require_relative '../shared_examples/formatted_command_examples'
5
+
6
+ RSpec.describe CovLoupe::Commands::VersionCommand do
7
+ let(:cli_context) { CovLoupe::CoverageCLI.new }
8
+ let(:command) { described_class.new(cli_context) }
9
+
10
+ before do
11
+ cli_context.config.format = :table
12
+ end
13
+
14
+ describe '#execute' do
15
+ context 'with table format' do
16
+ it 'prints version, gem root, and documentation info in text mode' do
17
+ output = capture_command_output(command, [])
18
+
19
+ expect(output).to include('│', CovLoupe::VERSION, 'Gem Root', 'Documentation',
20
+ 'README.md')
21
+ end
22
+
23
+ it 'includes a valid gem root path that exists' do
24
+ output = capture_command_output(command, [])
25
+
26
+ # Extract gem root from table output
27
+ gem_root_line = output.lines.find { |line| line.include?('Gem Root') }
28
+ expect(gem_root_line).not_to be_nil
29
+
30
+ parts = gem_root_line.split('│')
31
+ gem_root = parts[-2].strip
32
+ expect(File.directory?(gem_root)).to be true
33
+ end
34
+ end
35
+
36
+ it_behaves_like 'a command with formatted output', [], ['version', 'gem_root']
37
+ end
38
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe CovLoupe::Constants do
6
+ describe 'OPTIONS_EXPECTING_ARGUMENT' do
7
+ subject(:options) { described_class::OPTIONS_EXPECTING_ARGUMENT }
8
+
9
+ it 'exists' do
10
+ expect(options).not_to be_nil
11
+ end
12
+
13
+ it 'is frozen' do
14
+ expect(options).to be_frozen
15
+ end
16
+
17
+ it 'contains expected CLI options' do
18
+ expected_options = %w[
19
+ -r --resultset
20
+ -R --root
21
+ -f --format
22
+ -o --sort-order
23
+ -s --source
24
+ -c --context-lines
25
+ -S --staleness
26
+ -g --tracked-globs
27
+ -l --log-file
28
+ --error-mode
29
+ ]
30
+
31
+ expect(options).to eq(expected_options)
32
+ end
33
+
34
+ it 'contains only strings' do
35
+ expect(options).to all(be_a(String))
36
+ end
37
+
38
+ it 'contains options that start with dashes' do
39
+ expect(options).to all(start_with('-'))
40
+ end
41
+ end
42
+
43
+ describe 'usage by other classes' do
44
+ it 'is used by ModeDetector' do
45
+ expect(CovLoupe::ModeDetector::OPTIONS_EXPECTING_ARGUMENT)
46
+ .to equal(CovLoupe::Constants::OPTIONS_EXPECTING_ARGUMENT)
47
+ end
48
+
49
+ it 'is used by CoverageCLI' do
50
+ expect(CovLoupe::CoverageCLI::OPTIONS_EXPECTING_ARGUMENT)
51
+ .to equal(CovLoupe::Constants::OPTIONS_EXPECTING_ARGUMENT)
52
+ end
53
+
54
+ it 'ensures both classes reference the same object' do
55
+ cli_options = CovLoupe::CoverageCLI::OPTIONS_EXPECTING_ARGUMENT
56
+ detector_options = CovLoupe::ModeDetector::OPTIONS_EXPECTING_ARGUMENT
57
+
58
+ expect(cli_options).to equal(detector_options)
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,267 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe CovLoupe::Formatters::SourceFormatter do
6
+ subject(:formatter) { described_class.new(color_enabled: color_enabled) }
7
+
8
+ let(:color_enabled) { false }
9
+ let(:model) { instance_double(CovLoupe::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 CovLoupe::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 CovLoupe::Presenters::BaseCoveragePresenter do
6
+ let(:model) { instance_double(CovLoupe::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