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,269 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe CovLoupe::CoverageModel, 'error handling' do
6
+ let(:root) { (FIXTURES_DIR / 'project1').to_s }
7
+ let(:malformed_resultset) do
8
+ {
9
+ 'RSpec' => {
10
+ 'coverage' => 'not_a_hash' # Should be a hash, not a string
11
+ }
12
+ }
13
+ end
14
+
15
+ describe 'initialization error handling' do
16
+ let(:valid_resultset) do
17
+ {
18
+ 'RSpec' => {
19
+ 'coverage' => {
20
+ "lib/foo\x00bar.rb" => { 'lines' => [1, 0, 1] } # Path with NULL byte
21
+ }
22
+ },
23
+ 'timestamp' => 1000
24
+ }
25
+ end
26
+ let(:malformed_resultset) do
27
+ {
28
+ 'RSpec' => {
29
+ 'coverage' => 'not_a_hash' # Should be a hash, not a string
30
+ }
31
+ }
32
+ end
33
+
34
+ it 'raises CoverageDataError with message detail for invalid JSON format' do
35
+ # Mock JSON.parse to raise JSON::ParserError
36
+ allow(JSON).to receive(:load_file).with(anything)
37
+ .and_raise(JSON::ParserError.new('unexpected token'))
38
+
39
+ expect do
40
+ described_class.new(root: root, resultset: 'coverage')
41
+ end.to raise_error(CovLoupe::CoverageDataError) do |error|
42
+ expect(error.message).to include('Invalid coverage data format')
43
+ expect(error.message).to include('unexpected token')
44
+ end
45
+ end
46
+
47
+ it 'raises FilePermissionError when coverage file is not readable' do
48
+ # Mock File.read to raise Errno::EACCES
49
+ allow(JSON).to receive(:load_file).with(anything).and_raise(
50
+ Errno::EACCES.new('Permission denied')
51
+ )
52
+
53
+ expect do
54
+ described_class.new(root: root, resultset: 'coverage')
55
+ end.to raise_error(CovLoupe::FilePermissionError) do |error|
56
+ expect(error.message).to include('Permission denied reading coverage data')
57
+ expect(error.message).to include('Permission denied')
58
+ end
59
+ end
60
+
61
+
62
+ it 'raises CoverageDataError when resultset structure is invalid (TypeError)' do
63
+ allow(JSON).to receive(:load_file).with(anything).and_return(malformed_resultset)
64
+
65
+ expect do
66
+ described_class.new(root: root, resultset: 'coverage')
67
+ end.to raise_error(CovLoupe::CoverageDataError) do |error|
68
+ expect(error.message).to include('Invalid coverage data structure')
69
+ end
70
+ end
71
+
72
+ it 'raises CoverageDataError when resultset structure causes NoMethodError' do
73
+ # Create a resultset structure that will cause NoMethodError
74
+ malformed_resultset = {
75
+ 'RSpec' => {
76
+ 'coverage' => {
77
+ 'file.rb' => nil # Should have 'lines' key, this will cause NoMethodError
78
+ }
79
+ }
80
+ }
81
+
82
+ allow(File).to receive(:open).and_call_original
83
+ allow(File).to receive(:open).with(end_with('.resultset.json'), 'r')
84
+ .and_return(StringIO.new(malformed_resultset.to_json))
85
+
86
+ broken_map = instance_double('CoverageMap')
87
+ allow(broken_map).to receive(:transform_keys)
88
+ .and_raise(NoMethodError.new("undefined method `upcase' for nil:NilClass"))
89
+ allow(CovLoupe::ResultsetLoader).to receive(:load).and_return(
90
+ CovLoupe::ResultsetLoader::Result.new(coverage_map: broken_map,
91
+ timestamp: 0, suite_names: ['RSpec'])
92
+ )
93
+
94
+ expect do
95
+ described_class.new(root: root, resultset: 'coverage')
96
+ end.to raise_error(CovLoupe::CoverageDataError) do |error|
97
+ expect(error.message).to include('Invalid coverage data structure')
98
+ end
99
+ end
100
+
101
+
102
+
103
+
104
+ it 'raises CoverageDataError when path operations raise ArgumentError' do
105
+ allow(JSON).to receive(:load_file).with(end_with('.resultset.json'))
106
+ .and_return(valid_resultset)
107
+
108
+ # Mock File.absolute_path to raise ArgumentError when called with the problematic path
109
+ # But allow it to work for the root initialization
110
+ allow(File).to receive(:absolute_path).and_call_original
111
+ allow(File).to receive(:absolute_path).with(include("\x00"), anything).and_raise(
112
+ ArgumentError.new('string contains null byte')
113
+ )
114
+
115
+ expect do
116
+ described_class.new(root: root, resultset: 'coverage')
117
+ end.to raise_error(CovLoupe::CoverageDataError) do |error|
118
+ expect(error.message).to include('Invalid path in coverage data')
119
+ expect(error.message).to include('null byte')
120
+ end
121
+ end
122
+
123
+ it 'preserves error context in JSON::ParserError messages' do
124
+ allow(JSON).to receive(:load_file).with(anything).and_raise(
125
+ JSON::ParserError.new('765: unexpected token at line 3, column 5')
126
+ )
127
+
128
+ expect do
129
+ described_class.new(root: root, resultset: 'coverage')
130
+ end.to raise_error(CovLoupe::CoverageDataError) do |error|
131
+ # Verify the original error message details are preserved
132
+ expect(error.message).to include('765')
133
+ expect(error.message).to include('line 3')
134
+ end
135
+ end
136
+
137
+ it 'provides helpful error for permission issues with file path' do
138
+ # Mock to raise permission error with actual file path
139
+ resultset_path = File.join(root, 'coverage', '.resultset.json')
140
+ allow(JSON).to receive(:load_file).with(resultset_path).and_raise(
141
+ Errno::EACCES.new(resultset_path)
142
+ )
143
+
144
+ expect do
145
+ described_class.new(root: root, resultset: 'coverage')
146
+ end.to raise_error(CovLoupe::FilePermissionError) do |error|
147
+ expect(error.message).to include('Permission denied')
148
+ expect(error.message).to match(/\.resultset\.json/)
149
+ end
150
+ end
151
+ end
152
+
153
+ describe 'error context preservation' do
154
+ it 'includes original exception message for JSON::ParserError' do
155
+ allow(JSON).to receive(:load_file).with(anything)
156
+ .and_raise(JSON::ParserError.new('unexpected character at byte 42'))
157
+
158
+ expect do
159
+ described_class.new(root: root, resultset: 'coverage')
160
+ end.to raise_error(CovLoupe::CoverageDataError) do |error|
161
+ expect(error.message).to include('unexpected character at byte 42')
162
+ end
163
+ end
164
+
165
+ it 'includes original exception message for Errno::EACCES' do
166
+ resultset_path = File.join(root, 'coverage', '.resultset.json')
167
+ allow(JSON).to receive(:load_file).with(resultset_path).and_raise(Errno::EACCES.new(resultset_path))
168
+
169
+ expect do
170
+ described_class.new(root: root, resultset: 'coverage')
171
+ end.to raise_error(CovLoupe::FilePermissionError) do |error|
172
+ expect(error.message).to include(resultset_path)
173
+ end
174
+ end
175
+
176
+ it 'includes original exception message for TypeError' do
177
+ # Mock to cause TypeError within ResultsetLoader's processing
178
+ allow(JSON).to receive(:load_file).with(anything).and_return(malformed_resultset)
179
+
180
+ expect do
181
+ described_class.new(root: root, resultset: 'coverage')
182
+ end.to raise_error(CovLoupe::CoverageDataError) do |error|
183
+ expect(error.message).to include('Invalid coverage data structure')
184
+ expect(error.message).to include('suite "RSpec"')
185
+ end
186
+ end
187
+ end
188
+
189
+ describe 'RuntimeError handling from find_resultset' do
190
+ it 'converts RuntimeError to CoverageDataError with helpful message' do
191
+ # Mock find_resultset to raise RuntimeError (simulating missing resultset)
192
+ allow(CovLoupe::CovUtil).to receive(:find_resultset).and_raise(
193
+ RuntimeError.new('Specified resultset not found: /nonexistent/path/.resultset.json')
194
+ )
195
+
196
+ expect do
197
+ described_class.new(root: root, resultset: '/nonexistent/path')
198
+ end.to raise_error(CovLoupe::ResultsetNotFoundError) do |error|
199
+ expect(error.message).to include('Specified resultset not found')
200
+ end
201
+ end
202
+
203
+ it 'handles RuntimeError with generic messages' do
204
+ # Test RuntimeError with any generic message that includes 'resultset'
205
+ allow(CovLoupe::CovUtil).to receive(:find_resultset).and_raise(
206
+ RuntimeError.new('Something went wrong during resultset lookup')
207
+ )
208
+
209
+ expect do
210
+ described_class.new(root: root, resultset: 'coverage')
211
+ end.to raise_error(CovLoupe::ResultsetNotFoundError) do |error|
212
+ expect(error.message).to include('Something went wrong during resultset lookup')
213
+ end
214
+ end
215
+
216
+ it 'converts RuntimeError without "resultset" in message to CoverageDataError' do
217
+ # Test RuntimeError that does NOT contain 'resultset' in its message
218
+ # This exercises the else branch in the RuntimeError rescue clause
219
+ allow(CovLoupe::CovUtil).to receive(:find_resultset).and_raise(
220
+ RuntimeError.new('Some completely unrelated runtime error')
221
+ )
222
+
223
+ expect do
224
+ described_class.new(root: root, resultset: 'coverage')
225
+ end.to raise_error(CovLoupe::CoverageDataError) do |error|
226
+ expect(error.message).to include('Failed to load coverage data')
227
+ expect(error.message).to include('Some completely unrelated runtime error')
228
+ end
229
+ end
230
+ end
231
+
232
+ describe 'all_files error handling' do
233
+ it 'skips files that raise FileError during coverage lookup' do
234
+ # This exercises the `next` statement in the all_files loop when FileError is raised
235
+ model = described_class.new(root: root, resultset: 'coverage')
236
+
237
+ # Mock lookup_lines to raise FileError for one specific file
238
+ allow(CovLoupe::CovUtil).to receive(:lookup_lines).and_call_original
239
+ allow(CovLoupe::CovUtil).to receive(:lookup_lines)
240
+ .with(anything, include('/lib/foo.rb'))
241
+ .and_raise(CovLoupe::FileError.new('Corrupted coverage entry'))
242
+
243
+ # Should not raise, just skip the problematic file
244
+ result = model.all_files(check_stale: false)
245
+
246
+ # The result should contain bar.rb but not foo.rb
247
+ file_names = result.map { |r| File.basename(r['file']) }
248
+ expect(file_names).to include('bar.rb')
249
+ expect(file_names).not_to include('foo.rb')
250
+ end
251
+ end
252
+
253
+ describe 'resolve method error handling' do
254
+ it 'converts RuntimeError from lookup_lines to FileError' do
255
+ # This exercises the RuntimeError rescue clause in the resolve method
256
+ model = described_class.new(root: root, resultset: 'coverage')
257
+
258
+ # Mock lookup_lines to raise RuntimeError for a specific file
259
+ allow(CovLoupe::CovUtil).to receive(:lookup_lines)
260
+ .and_raise(RuntimeError.new('Unexpected runtime error during lookup'))
261
+
262
+ expect do
263
+ model.summary_for('nonexistent_file.rb')
264
+ end.to raise_error(CovLoupe::FileError) do |error|
265
+ expect(error.message).to include('No coverage data found for file')
266
+ end
267
+ end
268
+ end
269
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'tempfile'
5
+
6
+ RSpec.describe CovLoupe::CoverageModel do
7
+ let(:root) { (FIXTURES_DIR / 'project1').to_s }
8
+
9
+ def with_stubbed_coverage_timestamp(timestamp)
10
+ mock_resultset_with_timestamp(root, timestamp)
11
+ yield
12
+ end
13
+
14
+ it "raises stale error when staleness mode is 'error' and file is newer" do
15
+ with_stubbed_coverage_timestamp(VERY_OLD_TIMESTAMP) do
16
+ model = described_class.new(root: root, staleness: :error)
17
+ expect do
18
+ model.summary_for('lib/foo.rb')
19
+ end.to raise_error(CovLoupe::CoverageDataStaleError, /stale/i)
20
+ end
21
+ end
22
+
23
+ it "does not check staleness when mode is 'off'" do
24
+ with_stubbed_coverage_timestamp(VERY_OLD_TIMESTAMP) do
25
+ model = described_class.new(root: root, staleness: :off)
26
+ expect { model.summary_for('lib/foo.rb') }.not_to raise_error
27
+ end
28
+ end
29
+
30
+ it 'all_files raises project-level stale when any source file is newer than coverage' do
31
+ with_stubbed_coverage_timestamp(VERY_OLD_TIMESTAMP) do
32
+ model = described_class.new(root: root, staleness: :error)
33
+ expect { model.all_files }.to raise_error(CovLoupe::CoverageDataProjectStaleError)
34
+ end
35
+ end
36
+
37
+ it 'all_files detects new files via tracked_globs' do
38
+ with_stubbed_coverage_timestamp(Time.now.to_i) do
39
+ Tempfile.create(['brand_new_file', '.rb'], File.join(root, 'lib')) do |f|
40
+ f.write("# new file\n")
41
+ f.flush
42
+ model = described_class.new(root: root, staleness: :error)
43
+ expect do
44
+ model.all_files(tracked_globs: ['lib/**/*.rb'])
45
+ end.to raise_error(CovLoupe::CoverageDataProjectStaleError)
46
+ end
47
+ end
48
+ end
49
+
50
+ describe 'timestamp normalization' do
51
+ it 'parses created_at strings to epoch seconds' do
52
+ created_at = Time.new(2024, 7, 3, 16, 26, 40, '-07:00')
53
+ mock_resultset_with_created_at(root, created_at.strftime('%Y-%m-%d %H:%M:%S %z'))
54
+
55
+ model = described_class.new(root: root, staleness: :off)
56
+
57
+ expect(model.instance_variable_get(:@cov_timestamp)).to eq(created_at.to_i)
58
+ end
59
+
60
+ it 'propagates parsed created_at timestamps into stale errors' do
61
+ file_mtime = File.mtime(File.join(root, 'lib', 'foo.rb'))
62
+ created_at_time = (file_mtime + 3600).utc
63
+ # Use mismatched coverage (3 lines instead of 4) to trigger staleness
64
+ mismatched_coverage = {
65
+ File.join(root, 'lib', 'foo.rb') => { 'lines' => [1, 0, nil] }
66
+ }
67
+ mock_resultset_with_created_at(root, created_at_time.iso8601, coverage: mismatched_coverage)
68
+
69
+ model = described_class.new(root: root, staleness: :error)
70
+
71
+ expect(model.instance_variable_get(:@cov_timestamp)).to eq(created_at_time.to_i)
72
+ expect do
73
+ model.summary_for('lib/foo.rb')
74
+ end.to raise_error(CovLoupe::CoverageDataStaleError) { |error|
75
+ expect(error.cov_timestamp).to eq(created_at_time.to_i)
76
+ }
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe CovLoupe::OptionNormalizers do
6
+ describe '.normalize_sort_order' do
7
+ context 'with strict mode (default)' do
8
+ [
9
+ ['a', :ascending],
10
+ ['ascending', :ascending],
11
+ ['d', :descending],
12
+ ['descending', :descending],
13
+ ['ASCENDING', :ascending],
14
+ ['Descending', :descending]
15
+ ].each do |input, expected|
16
+ it "normalizes '#{input}' to #{expected}" do
17
+ expect(described_class.normalize_sort_order(input)).to eq(expected)
18
+ end
19
+ end
20
+
21
+ it 'raises OptionParser::InvalidArgument for invalid values' do
22
+ expect { described_class.normalize_sort_order('invalid') }
23
+ .to raise_error(OptionParser::InvalidArgument, /invalid argument: invalid/)
24
+ end
25
+ end
26
+
27
+ context 'with strict: false' do
28
+ it 'returns nil for invalid values' do
29
+ expect(described_class.normalize_sort_order('invalid', strict: false)).to be_nil
30
+ end
31
+
32
+ it 'still normalizes valid values' do
33
+ expect(described_class.normalize_sort_order('a', strict: false)).to eq(:ascending)
34
+ end
35
+ end
36
+ end
37
+
38
+ describe '.normalize_source_mode' do
39
+ context 'with strict mode (default)' do
40
+ [nil, ''].each do |input|
41
+ it "raises OptionParser::InvalidArgument for #{input.inspect}" do
42
+ expect { described_class.normalize_source_mode(input) }
43
+ .to raise_error(OptionParser::InvalidArgument, /invalid argument/)
44
+ end
45
+ end
46
+
47
+ [
48
+ ['f', :full],
49
+ ['full', :full],
50
+ ['u', :uncovered],
51
+ ['uncovered', :uncovered],
52
+ ['FULL', :full],
53
+ ['Uncovered', :uncovered]
54
+ ].each do |input, expected|
55
+ it "normalizes '#{input}' to #{expected}" do
56
+ expect(described_class.normalize_source_mode(input)).to eq(expected)
57
+ end
58
+ end
59
+
60
+ it 'raises OptionParser::InvalidArgument for invalid values' do
61
+ expect { described_class.normalize_source_mode('invalid') }
62
+ .to raise_error(OptionParser::InvalidArgument, /invalid argument: invalid/)
63
+ end
64
+ end
65
+
66
+ context 'with strict: false' do
67
+ it 'returns nil for invalid values' do
68
+ expect(described_class.normalize_source_mode('invalid', strict: false)).to be_nil
69
+ end
70
+
71
+ it 'still normalizes valid values' do
72
+ expect(described_class.normalize_source_mode('u', strict: false)).to eq(:uncovered)
73
+ end
74
+ end
75
+ end
76
+
77
+ describe '.normalize_staleness' do
78
+ context 'with strict mode (default)' do
79
+ [
80
+ ['o', :off],
81
+ ['off', :off],
82
+ ['e', :error],
83
+ ['error', :error],
84
+ ['OFF', :off],
85
+ ['Error', :error]
86
+ ].each do |input, expected|
87
+ it "normalizes '#{input}' to #{expected}" do
88
+ expect(described_class.normalize_staleness(input)).to eq(expected)
89
+ end
90
+ end
91
+
92
+ it 'raises OptionParser::InvalidArgument for invalid values' do
93
+ expect { described_class.normalize_staleness('invalid') }
94
+ .to raise_error(OptionParser::InvalidArgument, /invalid argument: invalid/)
95
+ end
96
+ end
97
+
98
+ context 'with strict: false' do
99
+ it 'returns nil for invalid values' do
100
+ expect(described_class.normalize_staleness('invalid', strict: false)).to be_nil
101
+ end
102
+
103
+ it 'still normalizes valid values' do
104
+ expect(described_class.normalize_staleness('e', strict: false)).to eq(:error)
105
+ end
106
+ end
107
+ end
108
+
109
+ describe '.normalize_error_mode' do
110
+ context 'with strict mode (default)' do
111
+ [
112
+ ['off', :off],
113
+ ['o', :off],
114
+ ['log', :log],
115
+ ['l', :log],
116
+ ['debug', :debug],
117
+ ['d', :debug],
118
+ ['OFF', :off],
119
+ ['Log', :log],
120
+ ['DEBUG', :debug]
121
+ ].each do |input, expected|
122
+ it "normalizes '#{input}' to #{expected}" do
123
+ expect(described_class.normalize_error_mode(input)).to eq(expected)
124
+ end
125
+ end
126
+
127
+ ['invalid', 'on', 'trace'].each do |input|
128
+ it "raises OptionParser::InvalidArgument for '#{input}'" do
129
+ expect { described_class.normalize_error_mode(input) }
130
+ .to raise_error(OptionParser::InvalidArgument, /invalid argument: #{input}/)
131
+ end
132
+ end
133
+ end
134
+
135
+ context 'with strict: false and default: :log' do
136
+ [['invalid', :log], [nil, :log]].each do |input, expected|
137
+ it "returns default #{expected} for #{input.inspect}" do
138
+ expect(described_class.normalize_error_mode(input, strict: false,
139
+ default: :log)).to eq(expected)
140
+ end
141
+ end
142
+
143
+ it 'still normalizes valid values' do
144
+ expect(described_class.normalize_error_mode('off', strict: false)).to eq(:off)
145
+ end
146
+ end
147
+
148
+ context 'with custom default' do
149
+ it 'returns custom default for invalid values when not strict' do
150
+ expect(described_class.normalize_error_mode('invalid', strict: false,
151
+ default: :off)).to eq(:off)
152
+ end
153
+ end
154
+ end
155
+
156
+ describe '.normalize_format' do
157
+ context 'with strict mode (default)' do
158
+ [
159
+ ['t', :table],
160
+ ['table', :table],
161
+ ['j', :json],
162
+ ['json', :json],
163
+ ['pretty_json', :pretty_json],
164
+ ['pretty-json', :pretty_json],
165
+ ['y', :yaml],
166
+ ['yaml', :yaml],
167
+ ['a', :awesome_print],
168
+ ['awesome_print', :awesome_print],
169
+ ['ap', :awesome_print],
170
+ ['TABLE', :table],
171
+ ['Json', :json]
172
+ ].each do |input, expected|
173
+ it "normalizes '#{input}' to #{expected}" do
174
+ expect(described_class.normalize_format(input)).to eq(expected)
175
+ end
176
+ end
177
+
178
+ it 'raises OptionParser::InvalidArgument for invalid values' do
179
+ expect { described_class.normalize_format('invalid') }
180
+ .to raise_error(OptionParser::InvalidArgument, /invalid argument: invalid/)
181
+ end
182
+ end
183
+
184
+ context 'with strict: false' do
185
+ it 'returns nil for invalid values' do
186
+ expect(described_class.normalize_format('invalid', strict: false)).to be_nil
187
+ end
188
+
189
+ it 'still normalizes valid values' do
190
+ expect(described_class.normalize_format('json', strict: false)).to eq(:json)
191
+ end
192
+ end
193
+ end
194
+
195
+ describe 'constant maps' do
196
+ [:SORT_ORDER_MAP, :SOURCE_MODE_MAP, :STALENESS_MAP, :ERROR_MODE_MAP,
197
+ :FORMAT_MAP].each do |const|
198
+ it "has frozen #{const}" do
199
+ expect(described_class.const_get(const)).to be_frozen
200
+ end
201
+ end
202
+ end
203
+ end