cov-loupe 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +329 -0
- data/docs/dev/ARCHITECTURE.md +80 -0
- data/docs/dev/BRANCH_ONLY_COVERAGE.md +158 -0
- data/docs/dev/DEVELOPMENT.md +83 -0
- data/docs/dev/README.md +10 -0
- data/docs/dev/RELEASING.md +146 -0
- data/docs/dev/arch-decisions/001-x-arch-decision.md +95 -0
- data/docs/dev/arch-decisions/002-x-arch-decision.md +159 -0
- data/docs/dev/arch-decisions/003-x-arch-decision.md +165 -0
- data/docs/dev/arch-decisions/004-x-arch-decision.md +203 -0
- data/docs/dev/arch-decisions/005-x-arch-decision.md +189 -0
- data/docs/dev/arch-decisions/README.md +60 -0
- data/docs/dev/presentations/cov-loupe-presentation.md +255 -0
- data/docs/fixtures/demo_project/README.md +9 -0
- data/docs/user/ADVANCED_USAGE.md +777 -0
- data/docs/user/CLI_FALLBACK_FOR_LLMS.md +34 -0
- data/docs/user/CLI_USAGE.md +750 -0
- data/docs/user/ERROR_HANDLING.md +93 -0
- data/docs/user/EXAMPLES.md +588 -0
- data/docs/user/INSTALLATION.md +130 -0
- data/docs/user/LIBRARY_API.md +693 -0
- data/docs/user/MCP_INTEGRATION.md +490 -0
- data/docs/user/README.md +14 -0
- data/docs/user/TROUBLESHOOTING.md +197 -0
- data/docs/user/V2-BREAKING-CHANGES.md +472 -0
- data/exe/cov-loupe +23 -0
- data/lib/cov_loupe/app_config.rb +56 -0
- data/lib/cov_loupe/app_context.rb +26 -0
- data/lib/cov_loupe/base_tool.rb +102 -0
- data/lib/cov_loupe/cli.rb +178 -0
- data/lib/cov_loupe/commands/base_command.rb +67 -0
- data/lib/cov_loupe/commands/command_factory.rb +45 -0
- data/lib/cov_loupe/commands/detailed_command.rb +38 -0
- data/lib/cov_loupe/commands/list_command.rb +13 -0
- data/lib/cov_loupe/commands/raw_command.rb +38 -0
- data/lib/cov_loupe/commands/summary_command.rb +41 -0
- data/lib/cov_loupe/commands/totals_command.rb +53 -0
- data/lib/cov_loupe/commands/uncovered_command.rb +45 -0
- data/lib/cov_loupe/commands/validate_command.rb +60 -0
- data/lib/cov_loupe/commands/version_command.rb +33 -0
- data/lib/cov_loupe/config_parser.rb +32 -0
- data/lib/cov_loupe/constants.rb +22 -0
- data/lib/cov_loupe/coverage_reporter.rb +31 -0
- data/lib/cov_loupe/error_handler.rb +165 -0
- data/lib/cov_loupe/error_handler_factory.rb +31 -0
- data/lib/cov_loupe/errors.rb +191 -0
- data/lib/cov_loupe/formatters/source_formatter.rb +152 -0
- data/lib/cov_loupe/formatters.rb +51 -0
- data/lib/cov_loupe/mcp_server.rb +42 -0
- data/lib/cov_loupe/mode_detector.rb +56 -0
- data/lib/cov_loupe/model.rb +339 -0
- data/lib/cov_loupe/option_normalizers.rb +113 -0
- data/lib/cov_loupe/option_parser_builder.rb +147 -0
- data/lib/cov_loupe/option_parsers/env_options_parser.rb +48 -0
- data/lib/cov_loupe/option_parsers/error_helper.rb +110 -0
- data/lib/cov_loupe/path_relativizer.rb +64 -0
- data/lib/cov_loupe/predicate_evaluator.rb +72 -0
- data/lib/cov_loupe/presenters/base_coverage_presenter.rb +42 -0
- data/lib/cov_loupe/presenters/coverage_detailed_presenter.rb +14 -0
- data/lib/cov_loupe/presenters/coverage_raw_presenter.rb +14 -0
- data/lib/cov_loupe/presenters/coverage_summary_presenter.rb +14 -0
- data/lib/cov_loupe/presenters/coverage_uncovered_presenter.rb +14 -0
- data/lib/cov_loupe/presenters/project_coverage_presenter.rb +50 -0
- data/lib/cov_loupe/presenters/project_totals_presenter.rb +27 -0
- data/lib/cov_loupe/resolvers/coverage_line_resolver.rb +122 -0
- data/lib/cov_loupe/resolvers/resolver_factory.rb +28 -0
- data/lib/cov_loupe/resolvers/resultset_path_resolver.rb +76 -0
- data/lib/cov_loupe/resultset_loader.rb +131 -0
- data/lib/cov_loupe/staleness_checker.rb +247 -0
- data/lib/cov_loupe/table_formatter.rb +64 -0
- data/lib/cov_loupe/tools/all_files_coverage_tool.rb +51 -0
- data/lib/cov_loupe/tools/coverage_detailed_tool.rb +35 -0
- data/lib/cov_loupe/tools/coverage_raw_tool.rb +34 -0
- data/lib/cov_loupe/tools/coverage_summary_tool.rb +34 -0
- data/lib/cov_loupe/tools/coverage_table_tool.rb +50 -0
- data/lib/cov_loupe/tools/coverage_totals_tool.rb +44 -0
- data/lib/cov_loupe/tools/help_tool.rb +115 -0
- data/lib/cov_loupe/tools/uncovered_lines_tool.rb +34 -0
- data/lib/cov_loupe/tools/validate_tool.rb +72 -0
- data/lib/cov_loupe/tools/version_tool.rb +32 -0
- data/lib/cov_loupe/util.rb +88 -0
- data/lib/cov_loupe/version.rb +5 -0
- data/lib/cov_loupe.rb +140 -0
- data/spec/MCP_INTEGRATION_TESTS_README.md +111 -0
- data/spec/TIMESTAMPS.md +48 -0
- data/spec/all_files_coverage_tool_spec.rb +53 -0
- data/spec/app_config_spec.rb +142 -0
- data/spec/base_tool_spec.rb +62 -0
- data/spec/cli/show_default_report_spec.rb +33 -0
- data/spec/cli_enumerated_options_spec.rb +90 -0
- data/spec/cli_error_spec.rb +184 -0
- data/spec/cli_format_spec.rb +123 -0
- data/spec/cli_json_options_spec.rb +50 -0
- data/spec/cli_source_spec.rb +44 -0
- data/spec/cli_spec.rb +192 -0
- data/spec/cli_table_spec.rb +28 -0
- data/spec/cli_usage_spec.rb +42 -0
- data/spec/commands/base_command_spec.rb +107 -0
- data/spec/commands/command_factory_spec.rb +76 -0
- data/spec/commands/detailed_command_spec.rb +34 -0
- data/spec/commands/list_command_spec.rb +28 -0
- data/spec/commands/raw_command_spec.rb +69 -0
- data/spec/commands/summary_command_spec.rb +34 -0
- data/spec/commands/totals_command_spec.rb +34 -0
- data/spec/commands/uncovered_command_spec.rb +55 -0
- data/spec/commands/validate_command_spec.rb +213 -0
- data/spec/commands/version_command_spec.rb +38 -0
- data/spec/constants_spec.rb +61 -0
- data/spec/cov_loupe/formatters/source_formatter_spec.rb +267 -0
- data/spec/cov_loupe/formatters_spec.rb +76 -0
- data/spec/cov_loupe/presenters/base_coverage_presenter_spec.rb +79 -0
- data/spec/cov_loupe_model_spec.rb +454 -0
- data/spec/cov_loupe_module_spec.rb +37 -0
- data/spec/cov_loupe_opts_spec.rb +185 -0
- data/spec/coverage_reporter_spec.rb +102 -0
- data/spec/coverage_table_tool_spec.rb +59 -0
- data/spec/coverage_totals_tool_spec.rb +37 -0
- data/spec/error_handler_spec.rb +197 -0
- data/spec/error_mode_spec.rb +139 -0
- data/spec/errors_edge_cases_spec.rb +312 -0
- data/spec/errors_stale_spec.rb +83 -0
- data/spec/file_based_mcp_tools_spec.rb +99 -0
- data/spec/fixtures/project1/lib/bar.rb +5 -0
- data/spec/fixtures/project1/lib/foo.rb +6 -0
- data/spec/help_tool_spec.rb +26 -0
- data/spec/integration_spec.rb +789 -0
- data/spec/logging_fallback_spec.rb +128 -0
- data/spec/mcp_logging_spec.rb +44 -0
- data/spec/mcp_server_integration_spec.rb +23 -0
- data/spec/mcp_server_spec.rb +106 -0
- data/spec/mode_detector_spec.rb +153 -0
- data/spec/model_error_handling_spec.rb +269 -0
- data/spec/model_staleness_spec.rb +79 -0
- data/spec/option_normalizers_spec.rb +203 -0
- data/spec/option_parsers/env_options_parser_spec.rb +221 -0
- data/spec/option_parsers/error_helper_spec.rb +222 -0
- data/spec/path_relativizer_spec.rb +98 -0
- data/spec/presenters/coverage_detailed_presenter_spec.rb +19 -0
- data/spec/presenters/coverage_raw_presenter_spec.rb +15 -0
- data/spec/presenters/coverage_summary_presenter_spec.rb +15 -0
- data/spec/presenters/coverage_uncovered_presenter_spec.rb +16 -0
- data/spec/presenters/project_coverage_presenter_spec.rb +87 -0
- data/spec/presenters/project_totals_presenter_spec.rb +144 -0
- data/spec/resolvers/coverage_line_resolver_spec.rb +282 -0
- data/spec/resolvers/resolver_factory_spec.rb +61 -0
- data/spec/resolvers/resultset_path_resolver_spec.rb +60 -0
- data/spec/resultset_loader_spec.rb +167 -0
- data/spec/shared_examples/README.md +115 -0
- data/spec/shared_examples/coverage_presenter_examples.rb +66 -0
- data/spec/shared_examples/file_based_mcp_tools.rb +179 -0
- data/spec/shared_examples/formatted_command_examples.rb +64 -0
- data/spec/shared_examples/mcp_tool_text_json_response.rb +16 -0
- data/spec/spec_helper.rb +127 -0
- data/spec/staleness_checker_spec.rb +374 -0
- data/spec/staleness_more_spec.rb +42 -0
- data/spec/support/cli_helpers.rb +22 -0
- data/spec/support/control_flow_helpers.rb +20 -0
- data/spec/support/fake_mcp.rb +40 -0
- data/spec/support/io_helpers.rb +29 -0
- data/spec/support/mcp_helpers.rb +35 -0
- data/spec/support/mcp_runner.rb +66 -0
- data/spec/support/mocking_helpers.rb +30 -0
- data/spec/table_format_spec.rb +70 -0
- data/spec/tools/validate_tool_spec.rb +132 -0
- data/spec/tools_error_handling_spec.rb +130 -0
- data/spec/util_spec.rb +154 -0
- data/spec/version_spec.rb +123 -0
- data/spec/version_tool_spec.rb +141 -0
- metadata +290 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe CovLoupe::CoverageCLI do
|
|
6
|
+
let(:root) { (FIXTURES_DIR / 'project1').to_s }
|
|
7
|
+
|
|
8
|
+
it 'shows help and exits 0' do
|
|
9
|
+
out, err, status = run_cli_with_status('--help')
|
|
10
|
+
expect(status).to eq(0)
|
|
11
|
+
expect(out).to match(/Usage:.*cov-loupe/)
|
|
12
|
+
expect(out).to include(
|
|
13
|
+
'Repository: https://github.com/keithrbennett/cov-loupe',
|
|
14
|
+
'Subcommands:'
|
|
15
|
+
)
|
|
16
|
+
expect(out).to match(/Version:.*#{CovLoupe::VERSION}/)
|
|
17
|
+
expect(err).to eq('')
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
shared_examples 'maps error to exit 1 with message' do
|
|
21
|
+
before do
|
|
22
|
+
# Build a fake model that raises the specified error from the specified method
|
|
23
|
+
fake_model = Class.new do
|
|
24
|
+
def initialize(*)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
error_to_raise = raised_error
|
|
28
|
+
fake_model.define_method(model_method) { |*| raise error_to_raise }
|
|
29
|
+
stub_const('CovLoupe::CoverageModel', fake_model)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it 'exits with status 1 and friendly message' do
|
|
33
|
+
_out, err, status = run_cli_with_status(*invoke_args)
|
|
34
|
+
expect(status).to eq(1)
|
|
35
|
+
expect(err).to include(expected_message)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
context 'when mapping ENOENT' do
|
|
40
|
+
let(:model_method) { :summary_for }
|
|
41
|
+
let(:raised_error) { Errno::ENOENT.new('No such file or directory @ rb_sysopen - missing.rb') }
|
|
42
|
+
let(:invoke_args) { ['--root', root, '--resultset', 'coverage', 'summary', 'lib/missing.rb'] }
|
|
43
|
+
let(:expected_message) { 'File error: File not found: lib/missing.rb' }
|
|
44
|
+
|
|
45
|
+
it_behaves_like 'maps error to exit 1 with message'
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
context 'when mapping EACCES' do
|
|
49
|
+
let(:model_method) { :raw_for }
|
|
50
|
+
let(:raised_error) { Errno::EACCES.new('Permission denied @ rb_sysopen - secret.rb') }
|
|
51
|
+
let(:invoke_args) { ['--root', root, '--resultset', 'coverage', 'raw', 'lib/secret.rb'] }
|
|
52
|
+
let(:expected_message) { 'Permission denied: lib/secret.rb' }
|
|
53
|
+
|
|
54
|
+
it_behaves_like 'maps error to exit 1 with message'
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
it 'emits detailed stale coverage info and exits 1' do
|
|
58
|
+
mock_resultset_with_timestamp(root, VERY_OLD_TIMESTAMP, coverage: {
|
|
59
|
+
File.join(root, 'lib', 'foo.rb') => { 'lines' => [1, 0, 1] }
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
_out, err, status = run_cli_with_status('--root', root, '--resultset', 'coverage',
|
|
63
|
+
'--staleness', 'error', 'summary', 'lib/foo.rb')
|
|
64
|
+
expect(status).to eq(1)
|
|
65
|
+
expect(err).to include('Coverage data stale:')
|
|
66
|
+
expect(err).to match(/File\s+- time:/)
|
|
67
|
+
expect(err).to match('Coverage\s+- time:')
|
|
68
|
+
expect(err).to match(/Delta\s+- file is [+-]?\d+s newer than coverage/)
|
|
69
|
+
expect(err).to match('Resultset\s+-')
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
it 'honors --no-strict-staleness to disable checks' do
|
|
73
|
+
mock_resultset_with_timestamp(root, VERY_OLD_TIMESTAMP, coverage: {
|
|
74
|
+
File.join(root, 'lib', 'foo.rb') => { 'lines' => [1, 0, 1] }
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
_out, err, status = run_cli_with_status('--root', root, '--resultset', 'coverage',
|
|
78
|
+
'--staleness', 'off', 'summary', 'lib/foo.rb')
|
|
79
|
+
expect(status).to eq(0)
|
|
80
|
+
expect(err).to eq('')
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it 'handles source rendering errors gracefully with fallback message' do
|
|
84
|
+
# Test that source rendering with problematic coverage data doesn't crash
|
|
85
|
+
# This is a regression test for the "can't convert nil into Integer" crash
|
|
86
|
+
# that was previously mentioned in comments
|
|
87
|
+
out, err, status = run_cli_with_status(
|
|
88
|
+
'--root', root, '--resultset', 'coverage', '--source', 'uncovered', '--context-lines', '2',
|
|
89
|
+
'--no-color', 'uncovered', 'lib/foo.rb'
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
expect(status).to eq(0)
|
|
93
|
+
expect(err).to eq('')
|
|
94
|
+
expect(out).to match(/File:\s+lib\/foo\.rb/)
|
|
95
|
+
expect(out).to include('│') # Table format
|
|
96
|
+
expect(out).to show_source_table_or_fallback
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it 'renders source with full mode without crashing' do
|
|
100
|
+
# Additional regression test for source rendering with full mode
|
|
101
|
+
out, err, status = run_cli_with_status(
|
|
102
|
+
'--root', root, '--resultset', 'coverage', '--source', 'full', '--no-color',
|
|
103
|
+
'summary', 'lib/foo.rb'
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
expect(status).to eq(0)
|
|
107
|
+
expect(err).to eq('')
|
|
108
|
+
expect(out).to include('lib/foo.rb')
|
|
109
|
+
expect(out).to include('66.67%')
|
|
110
|
+
expect(out).to show_source_table_or_fallback
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
it 'shows fallback message when source file is unreadable' do
|
|
114
|
+
# Test the fallback path when source files can't be read
|
|
115
|
+
# Temporarily rename the source file to make it unreadable
|
|
116
|
+
foo_path = File.join(root, 'lib', 'foo.rb')
|
|
117
|
+
temp_path = "#{foo_path}.hidden"
|
|
118
|
+
|
|
119
|
+
begin
|
|
120
|
+
File.rename(foo_path, temp_path) if File.exist?(foo_path)
|
|
121
|
+
|
|
122
|
+
out, err, status = run_cli_with_status(
|
|
123
|
+
'--root', root, '--resultset', 'coverage', '--source', 'full', '--no-color',
|
|
124
|
+
'summary', 'lib/foo.rb'
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
expect(status).to eq(0)
|
|
128
|
+
expect(err).to eq('')
|
|
129
|
+
expect(out).to include('lib/foo.rb')
|
|
130
|
+
expect(out).to include('66.67%')
|
|
131
|
+
expect(out).to include('[source not available]')
|
|
132
|
+
ensure
|
|
133
|
+
# Restore the file
|
|
134
|
+
File.rename(temp_path, foo_path) if File.exist?(temp_path)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
describe 'invalid option handling' do
|
|
139
|
+
it 'suggests subcommand for --subcommand-like option' do
|
|
140
|
+
_out, err, status = run_cli_with_status('--summary')
|
|
141
|
+
expect(status).to eq(1)
|
|
142
|
+
expect(err).to include(
|
|
143
|
+
"Error: '--summary' is not a valid option. Did you mean the 'summary' subcommand?"
|
|
144
|
+
)
|
|
145
|
+
expect(err).to include('Try: cov-loupe summary [args]')
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
it 'reports invalid enum value for --opt=value' do
|
|
149
|
+
_out, err, status = run_cli_with_status('--staleness=bogus', 'list')
|
|
150
|
+
expect(status).to eq(1)
|
|
151
|
+
expect(err).to include('invalid argument: --staleness=bogus')
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
it 'reports invalid enum value for --opt value' do
|
|
155
|
+
_out, err, status = run_cli_with_status('--staleness', 'bogus', 'list')
|
|
156
|
+
expect(status).to eq(1)
|
|
157
|
+
expect(err).to include('invalid argument: bogus')
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
it 'handles generic invalid options' do
|
|
161
|
+
_out, err, status = run_cli_with_status('--no-such-option')
|
|
162
|
+
expect(status).to eq(1)
|
|
163
|
+
expect(err).to include('Error: invalid option: --no-such-option')
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
describe 'subcommand error handling' do
|
|
168
|
+
it 'handles generic exceptions from subcommands' do
|
|
169
|
+
# Stub the CommandFactory to return a command that raises a StandardError
|
|
170
|
+
fake_command = Class.new do
|
|
171
|
+
def initialize(_cli) = nil
|
|
172
|
+
def execute(_args) = raise(StandardError, 'Unexpected error in subcommand')
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
allow(CovLoupe::Commands::CommandFactory).to receive(:create)
|
|
176
|
+
.and_return(fake_command.new(nil))
|
|
177
|
+
|
|
178
|
+
_out, err, status = run_cli_with_status('--root', root, '--resultset', 'coverage', 'summary',
|
|
179
|
+
'lib/foo.rb')
|
|
180
|
+
expect(status).to eq(1)
|
|
181
|
+
expect(err).to include('Unexpected error in subcommand')
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe CovLoupe::CoverageCLI, 'format option' do
|
|
6
|
+
let(:root) { (FIXTURES_DIR / 'project1').to_s }
|
|
7
|
+
|
|
8
|
+
def run_cli(*argv)
|
|
9
|
+
cli = CovLoupe::CoverageCLI.new
|
|
10
|
+
output = nil
|
|
11
|
+
silence_output do |stdout, _stderr|
|
|
12
|
+
cli.send(:run, argv)
|
|
13
|
+
output = stdout.string
|
|
14
|
+
end
|
|
15
|
+
output
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
describe 'format normalization' do
|
|
19
|
+
it 'normalizes short format aliases' do
|
|
20
|
+
output = run_cli('--root', root, '--resultset', 'coverage', '--format', 'j', 'list')
|
|
21
|
+
expect(output).to include('"files":', '"percentage":')
|
|
22
|
+
data = JSON.parse(output)
|
|
23
|
+
expect(data['files']).to be_an(Array)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it 'normalizes table format' do
|
|
27
|
+
output = run_cli('--root', root, '--resultset', 'coverage', '--format', 't', 'list')
|
|
28
|
+
expect(output).to include('File', '%') # Table output
|
|
29
|
+
expect(output).not_to include('"files"') # Not JSON
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it 'supports yaml format' do
|
|
33
|
+
output = run_cli('--root', root, '--resultset', 'coverage', '--format', 'y', 'list')
|
|
34
|
+
expect(output).to include('---', 'files:', 'file:')
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it 'supports awesome_print format' do
|
|
38
|
+
output = run_cli('--root', root, '--resultset', 'coverage', '--format', 'a', 'list')
|
|
39
|
+
# AwesomePrint output contains colored/formatted structure
|
|
40
|
+
expect(output).to match(/:files|"files"/)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
describe 'option order requirements' do
|
|
45
|
+
it 'works with format option before subcommand' do
|
|
46
|
+
output = run_cli('--root', root, '--resultset', 'coverage', '--format', 'json', 'list')
|
|
47
|
+
data = JSON.parse(output)
|
|
48
|
+
expect(data).to have_key('files')
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
it 'shows helpful error when global option comes after subcommand' do
|
|
52
|
+
_out, err, status = run_cli_with_status(
|
|
53
|
+
'--root', root, '--resultset', 'coverage', 'list', '--format', 'json'
|
|
54
|
+
)
|
|
55
|
+
expect(status).to eq(1)
|
|
56
|
+
expect(err).to include(
|
|
57
|
+
'Global option(s) must come BEFORE the subcommand',
|
|
58
|
+
'You used: list --format',
|
|
59
|
+
'Correct: --format list',
|
|
60
|
+
'Example:'
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
describe 'format with different subcommands' do
|
|
66
|
+
it 'works with totals subcommand' do
|
|
67
|
+
output = run_cli('--root', root, '--resultset', 'coverage', '--format', 'json', 'totals')
|
|
68
|
+
data = JSON.parse(output)
|
|
69
|
+
expect(data).to have_key('lines')
|
|
70
|
+
expect(data).to have_key('percentage')
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it 'works with summary subcommand' do
|
|
74
|
+
output = run_cli('--root', root, '--resultset', 'coverage', '--format', 'json',
|
|
75
|
+
'summary', 'lib/foo.rb')
|
|
76
|
+
data = JSON.parse(output)
|
|
77
|
+
expect(data).to have_key('file')
|
|
78
|
+
expect(data).to have_key('summary')
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it 'works with version subcommand' do
|
|
82
|
+
output = run_cli('--format', 'json', 'version')
|
|
83
|
+
data = JSON.parse(output)
|
|
84
|
+
expect(data).to have_key('version')
|
|
85
|
+
expect(data).to have_key('gem_root')
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
describe 'comprehensive misplaced option detection' do
|
|
90
|
+
# Array of test cases: [description, args_array, expected_option_in_error]
|
|
91
|
+
[
|
|
92
|
+
# Short-form options
|
|
93
|
+
['short -f after list', ['list', '-f', 'json'], '-f'],
|
|
94
|
+
['short -r after totals', ['totals', '-r', '.resultset.json'], '-r'],
|
|
95
|
+
['short -R after list', ['list', '-R', '/tmp'], '-R'],
|
|
96
|
+
['short -o after list', ['list', '-o', 'a'], '-o'],
|
|
97
|
+
['short -s after list', ['list', '-s', 'full'], '-s'],
|
|
98
|
+
['short -S after list', ['list', '-S', 'error'], '-S'],
|
|
99
|
+
|
|
100
|
+
# Long-form options
|
|
101
|
+
['--sort-order after list', ['list', '--sort-order', 'ascending'], '--sort-order'],
|
|
102
|
+
['--source after list', ['list', '--source', 'full'], '--source'],
|
|
103
|
+
['--staleness after totals', ['totals', '--staleness', 'error'], '--staleness'],
|
|
104
|
+
['--color after list', ['list', '--color'], '--color'],
|
|
105
|
+
['--no-color after list', ['list', '--no-color'], '--no-color'],
|
|
106
|
+
['--log-file after list', ['list', '--log-file', '/tmp/test.log'], '--log-file'],
|
|
107
|
+
|
|
108
|
+
# Different subcommands
|
|
109
|
+
['option after version', ['version', '--format', 'json'], '--format'],
|
|
110
|
+
['option after summary', ['summary', 'lib/foo.rb', '--format', 'json'], '--format'],
|
|
111
|
+
['option after raw', ['raw', 'lib/foo.rb', '-f', 'json'], '-f'],
|
|
112
|
+
['option after detailed', ['detailed', 'lib/foo.rb', '-f', 'json'], '-f'],
|
|
113
|
+
['option after uncovered', ['uncovered', 'lib/foo.rb', '--root', '/tmp'], '--root']
|
|
114
|
+
].each do |desc, args, option|
|
|
115
|
+
it "detects #{desc}" do
|
|
116
|
+
_out, err, status = run_cli_with_status(*args)
|
|
117
|
+
expect(status).to eq(1)
|
|
118
|
+
expect(err).to include('Global option(s) must come BEFORE the subcommand')
|
|
119
|
+
expect(err).to include(option)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe CovLoupe::CoverageCLI, 'json format options' do
|
|
6
|
+
let(:root) { (FIXTURES_DIR / 'project1').to_s }
|
|
7
|
+
|
|
8
|
+
def run_cli_output(*argv)
|
|
9
|
+
cli = CovLoupe::CoverageCLI.new
|
|
10
|
+
output = nil
|
|
11
|
+
silence_output do |stdout, _stderr|
|
|
12
|
+
cli.send(:run, argv)
|
|
13
|
+
output = stdout.string
|
|
14
|
+
end
|
|
15
|
+
output
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
describe 'JSON format options' do
|
|
19
|
+
it 'produces compact JSON with -f j' do
|
|
20
|
+
output = run_cli_output('--root', root, '--resultset', 'coverage', '-f', 'j', 'list')
|
|
21
|
+
|
|
22
|
+
expect(output.strip.lines.count).to eq(1)
|
|
23
|
+
data = JSON.parse(output)
|
|
24
|
+
expect(data['files']).to be_an(Array)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
it 'produces pretty JSON with -f pretty-json' do
|
|
28
|
+
output = run_cli_output('--root', root, '--resultset', 'coverage', '-f', 'pretty-json',
|
|
29
|
+
'list')
|
|
30
|
+
expect(output.strip.lines.count).to be > 1
|
|
31
|
+
data = JSON.parse(output)
|
|
32
|
+
expect(data['files']).to be_an(Array)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it 'produces pretty JSON with -f pretty_json (underscore variant)' do
|
|
36
|
+
output = run_cli_output('--root', root, '--resultset', 'coverage', '-f', 'pretty_json',
|
|
37
|
+
'list')
|
|
38
|
+
expect(output.strip.lines.count).to be > 1
|
|
39
|
+
data = JSON.parse(output)
|
|
40
|
+
expect(data['files']).to be_an(Array)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it 'produces compact JSON with -f json' do
|
|
44
|
+
output = run_cli_output('--root', root, '--resultset', 'coverage', '-f', 'json', 'list')
|
|
45
|
+
expect(output.strip.lines.count).to eq(1)
|
|
46
|
+
data = JSON.parse(output)
|
|
47
|
+
expect(data['files']).to be_an(Array)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe CovLoupe::CoverageCLI do
|
|
6
|
+
let(:root) { (FIXTURES_DIR / 'project1').to_s }
|
|
7
|
+
|
|
8
|
+
it 'renders uncovered source without error for fixture file' do
|
|
9
|
+
out, err, status = run_cli_with_status(
|
|
10
|
+
'--root', root, '--resultset', 'coverage', '--source', 'uncovered', '--context-lines', '1',
|
|
11
|
+
'--no-color', 'uncovered', 'lib/foo.rb'
|
|
12
|
+
)
|
|
13
|
+
expect(status).to eq(0)
|
|
14
|
+
expect(err).to eq('')
|
|
15
|
+
expect(out).to match(/File:\s+lib\/foo\.rb/)
|
|
16
|
+
expect(out).to include('│') # Table format
|
|
17
|
+
expect(out).to show_source_table_or_fallback
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
it 'renders full source for uncovered command without brittle spacing' do
|
|
21
|
+
out, err, status = run_cli_with_status(
|
|
22
|
+
'--root', root, '--resultset', 'coverage', '--source', 'full', '--no-color',
|
|
23
|
+
'uncovered', 'lib/foo.rb'
|
|
24
|
+
)
|
|
25
|
+
expect(status).to eq(0)
|
|
26
|
+
expect(err).to eq('')
|
|
27
|
+
expect(out).to include('│') # Table format
|
|
28
|
+
expect(out).to include('66.67%')
|
|
29
|
+
expect(out).to show_source_table_or_fallback
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it 'renders source for summary with uncovered mode without crashing' do
|
|
33
|
+
out, err, status = run_cli_with_status(
|
|
34
|
+
'--root', root, '--resultset', 'coverage', '--source', 'uncovered', '--context-lines', '1',
|
|
35
|
+
'--no-color', 'summary', 'lib/foo.rb'
|
|
36
|
+
)
|
|
37
|
+
expect(status).to eq(0)
|
|
38
|
+
expect(err).to eq('')
|
|
39
|
+
expect(out).to include('lib/foo.rb')
|
|
40
|
+
expect(out).to include('66.67%')
|
|
41
|
+
expect(out).to include('│') # Table format
|
|
42
|
+
expect(out).to show_source_table_or_fallback
|
|
43
|
+
end
|
|
44
|
+
end
|
data/spec/cli_spec.rb
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'tempfile'
|
|
5
|
+
|
|
6
|
+
RSpec.describe CovLoupe::CoverageCLI do
|
|
7
|
+
let(:root) { (FIXTURES_DIR / 'project1').to_s }
|
|
8
|
+
|
|
9
|
+
def run_cli(*argv)
|
|
10
|
+
cli = described_class.new
|
|
11
|
+
silence_output do |out, _err|
|
|
12
|
+
begin
|
|
13
|
+
cli.run(argv.flatten)
|
|
14
|
+
rescue SystemExit
|
|
15
|
+
# Ignore exit, just capture output
|
|
16
|
+
end
|
|
17
|
+
return out.string
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
describe 'JSON output' do
|
|
22
|
+
def with_json_output(command, *args)
|
|
23
|
+
output = run_cli('--format', 'json', '--root', root, '--resultset', 'coverage',
|
|
24
|
+
command, *args)
|
|
25
|
+
yield JSON.parse(output)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it 'prints summary as JSON' do
|
|
29
|
+
with_json_output('summary', 'lib/foo.rb') do |data|
|
|
30
|
+
expect(data['summary']).to include('covered' => 2)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it 'prints raw as JSON' do
|
|
35
|
+
with_json_output('raw', 'lib/foo.rb') do |data|
|
|
36
|
+
expect(data['lines']).to eq([1, 0, nil, 2])
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it 'prints uncovered as JSON' do
|
|
41
|
+
with_json_output('uncovered', 'lib/foo.rb') do |data|
|
|
42
|
+
expect(data['uncovered']).to eq([2])
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it 'prints detailed as JSON' do
|
|
47
|
+
with_json_output('detailed', 'lib/foo.rb') do |data|
|
|
48
|
+
expect(data['lines']).to be_an(Array)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it 'prints totals as JSON' do
|
|
53
|
+
with_json_output('totals') do |data|
|
|
54
|
+
expect(data['lines']).to include('total' => 6, 'covered' => 3, 'uncovered' => 3)
|
|
55
|
+
expect(data['files']).to include('total' => 2)
|
|
56
|
+
expect(data['files']['ok'] + data['files']['stale']).to eq(data['files']['total'])
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
it 'prints raw lines as text' do
|
|
62
|
+
output = run_cli('--root', root, '--resultset', 'coverage', 'raw', 'lib/foo.rb')
|
|
63
|
+
expect(output).to include('File: lib/foo.rb')
|
|
64
|
+
expect(output).to include('│') # Table format
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it 'list subcommand with --json outputs JSON with sort order' do
|
|
68
|
+
output = run_cli(
|
|
69
|
+
'--format', 'json', '--root', root, '--resultset', 'coverage', '--sort-order', 'a', 'list'
|
|
70
|
+
)
|
|
71
|
+
asc = JSON.parse(output)
|
|
72
|
+
expect(asc['files']).to be_an(Array)
|
|
73
|
+
expect(asc['files'].first['file']).to end_with('lib/bar.rb')
|
|
74
|
+
|
|
75
|
+
# Includes counts for total/ok/stale and they are consistent
|
|
76
|
+
expect(asc['counts']).to include('total', 'ok', 'stale')
|
|
77
|
+
total = asc['counts']['total']
|
|
78
|
+
ok = asc['counts']['ok']
|
|
79
|
+
stale = asc['counts']['stale']
|
|
80
|
+
expect(total).to eq(asc['files'].length)
|
|
81
|
+
expect(ok + stale).to eq(total)
|
|
82
|
+
|
|
83
|
+
output = run_cli(
|
|
84
|
+
'--format', 'json', '--root', root, '--resultset', 'coverage', '--sort-order', 'd', 'list'
|
|
85
|
+
)
|
|
86
|
+
desc = JSON.parse(output)
|
|
87
|
+
expect(desc['files'].first['file']).to end_with('lib/foo.rb')
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
it 'list subcommand outputs formatted table' do
|
|
91
|
+
output = run_cli('--root', root, '--resultset', 'coverage', 'list')
|
|
92
|
+
expect(output).to include('File')
|
|
93
|
+
expect(output).to include('lib/foo.rb')
|
|
94
|
+
expect(output).to include('lib/bar.rb')
|
|
95
|
+
expect(output).to match(/Files: total \d+/)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
it 'list subcommand retains rows when using an absolute tracked glob' do
|
|
99
|
+
absolute_glob = File.join(root, 'lib', '**', '*.rb')
|
|
100
|
+
output = run_cli('--root', root, '--resultset', 'coverage', '--tracked-globs',
|
|
101
|
+
absolute_glob, 'list')
|
|
102
|
+
expect(output).not_to include('No coverage data found')
|
|
103
|
+
expect(output).to include('lib/foo.rb')
|
|
104
|
+
expect(output).to include('lib/bar.rb')
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
it 'totals subcommand prints a readable summary by default' do
|
|
108
|
+
output = run_cli('--root', root, '--resultset', 'coverage', 'totals')
|
|
109
|
+
expect(output).to include('│') # Table format
|
|
110
|
+
expect(output).to include('Lines')
|
|
111
|
+
# expect(output).to include('Average coverage:') # Not in table version
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
it 'can include source in JSON payload (nil if file missing)' do
|
|
115
|
+
output = run_cli('--format', 'json', '--root', root, '--resultset', 'coverage',
|
|
116
|
+
'--source', 'full', 'summary', 'lib/foo.rb')
|
|
117
|
+
data = JSON.parse(output)
|
|
118
|
+
expect(data).to have_key('source')
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
describe 'log file configuration' do
|
|
122
|
+
it 'passes --log-file path into the CLI execution context' do
|
|
123
|
+
Dir.mktmpdir do |dir|
|
|
124
|
+
log_path = File.join(dir, 'custom.log')
|
|
125
|
+
expect(CovLoupe).to receive(:create_context)
|
|
126
|
+
.and_wrap_original do |m, error_handler:, log_target:, mode:|
|
|
127
|
+
# Ensure CLI forwards the requested log path into the context without changing other fields.
|
|
128
|
+
expect(log_target).to eq(log_path)
|
|
129
|
+
m.call(error_handler: error_handler, log_target: log_target, mode: mode)
|
|
130
|
+
end
|
|
131
|
+
original_target = CovLoupe.active_log_file
|
|
132
|
+
run_cli('--format', 'json', '--root', root, '--resultset', 'coverage',
|
|
133
|
+
'--log-file', log_path, 'summary', 'lib/foo.rb')
|
|
134
|
+
expect(CovLoupe.active_log_file).to eq(original_target)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
it 'supports stdout logging within the CLI context' do
|
|
139
|
+
expect(CovLoupe).to receive(:create_context)
|
|
140
|
+
.and_wrap_original do |m, error_handler:, log_target:, mode:|
|
|
141
|
+
# For stdout logging, verify the context is still constructed with the expected value.
|
|
142
|
+
expect(log_target).to eq('stdout')
|
|
143
|
+
m.call(error_handler: error_handler, log_target: log_target, mode: mode)
|
|
144
|
+
end
|
|
145
|
+
original_target = CovLoupe.active_log_file
|
|
146
|
+
run_cli('--format', 'json', '--root', root, '--resultset', 'coverage',
|
|
147
|
+
'--log-file', 'stdout', 'summary', 'lib/foo.rb')
|
|
148
|
+
expect(CovLoupe.active_log_file).to eq(original_target)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
describe 'version command' do
|
|
157
|
+
it 'prints version as plain text by default' do
|
|
158
|
+
output = run_cli('version')
|
|
159
|
+
expect(output).to include('│') # Table format
|
|
160
|
+
expect(output).to include(CovLoupe::VERSION)
|
|
161
|
+
expect(output).not_to include('{')
|
|
162
|
+
expect(output).not_to include('}')
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
it 'prints version as JSON when --json flag is used' do
|
|
166
|
+
output = run_cli('--format', 'json', 'version')
|
|
167
|
+
data = JSON.parse(output)
|
|
168
|
+
expect(data).to have_key('version')
|
|
169
|
+
expect(data['version']).to eq(CovLoupe::VERSION)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
it 'works with version command and other flags' do
|
|
173
|
+
output = run_cli('--root', root, 'version')
|
|
174
|
+
expect(output).to include('│') # Table format
|
|
175
|
+
expect(output).to include(CovLoupe::VERSION)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
describe 'version option (-v)' do
|
|
180
|
+
it 'prints the same version info as the version subcommand' do
|
|
181
|
+
output = run_cli('-v')
|
|
182
|
+
expect(output).to include('│') # Table format
|
|
183
|
+
expect(output).to include(CovLoupe::VERSION)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
it 'respects --json when -v is used' do
|
|
187
|
+
output = run_cli('-v', '--format', 'json')
|
|
188
|
+
data = JSON.parse(output)
|
|
189
|
+
expect(data['version']).to eq(CovLoupe::VERSION)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe CovLoupe::CoverageCLI do
|
|
6
|
+
let(:root) { (FIXTURES_DIR / 'project1').to_s }
|
|
7
|
+
|
|
8
|
+
def run_cli(*argv)
|
|
9
|
+
cli = described_class.new
|
|
10
|
+
silence_output do |out, _err|
|
|
11
|
+
cli.run(argv.flatten)
|
|
12
|
+
return out.string
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it 'prints default table when no subcommand is given' do
|
|
17
|
+
output = run_cli('--root', root, '--resultset', 'coverage')
|
|
18
|
+
|
|
19
|
+
# Contains a header row and at least one data row with expected columns
|
|
20
|
+
expect(output).to include('File')
|
|
21
|
+
expect(output).to include('Covered')
|
|
22
|
+
expect(output).to include('Total')
|
|
23
|
+
|
|
24
|
+
# Should list fixture files from the demo project
|
|
25
|
+
expect(output).to include('lib/foo.rb')
|
|
26
|
+
expect(output).to include('lib/bar.rb')
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'tempfile'
|
|
5
|
+
|
|
6
|
+
RSpec.describe CovLoupe::CoverageCLI do
|
|
7
|
+
let(:root) { (FIXTURES_DIR / 'project1').to_s }
|
|
8
|
+
|
|
9
|
+
it 'errors with usage when summary path is missing' do
|
|
10
|
+
_out, err, status = run_cli_with_status('--root', root, '--resultset', 'coverage', 'summary')
|
|
11
|
+
expect(status).to eq(1)
|
|
12
|
+
expect(err).to include('Usage: cov-loupe summary <path>')
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
it 'errors with meaningful message for unknown subcommand' do
|
|
16
|
+
_out, err, status = run_cli_with_status('--root', root, '--resultset', 'coverage', 'bogus')
|
|
17
|
+
expect(status).to eq(1)
|
|
18
|
+
expect(err).to include("Unknown subcommand: 'bogus'", 'Valid subcommands:')
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it 'list honors stale=error and tracked_globs by exiting 1 when project is stale' do
|
|
22
|
+
Tempfile.create(['brand_new_file_for_cli_usage_spec', '.rb'], File.join(root, 'lib')) do |f|
|
|
23
|
+
f.write("# new file\n")
|
|
24
|
+
f.flush
|
|
25
|
+
_out, err, status = run_cli_with_status(
|
|
26
|
+
'--root', root, '--resultset', 'coverage', '--staleness', 'error', '--tracked-globs',
|
|
27
|
+
'lib/**/*.rb', 'list'
|
|
28
|
+
)
|
|
29
|
+
expect(status).to eq(1)
|
|
30
|
+
expect(err).to include('Coverage data stale (project)')
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it 'list with stale=off prints table and exits 0' do
|
|
35
|
+
out, err, status = run_cli_with_status(
|
|
36
|
+
'--root', root, '--resultset', 'coverage', '--staleness', 'off', 'list'
|
|
37
|
+
)
|
|
38
|
+
expect(status).to eq(0)
|
|
39
|
+
expect(err).to eq('')
|
|
40
|
+
expect(out).to include('File', 'lib/foo.rb')
|
|
41
|
+
end
|
|
42
|
+
end
|