From 89318691100227b50816ca7aef01207a530da024 Mon Sep 17 00:00:00 2001 From: Erik Berlin Date: Sat, 4 Apr 2026 10:07:07 -0700 Subject: [PATCH 1/3] Add richer metrics to JSON formatter Enhance the JSON coverage output with per-file coverage percentages, a total section with aggregate statistics, full group stats, and method coverage support. Per-file output now includes covered_percent (always), and when enabled: branches_covered_percent and methods array with methods_covered_percent. The total and groups sections report full statistics (covered, missed, total, percent, strength) for each enabled coverage type (line, branch, method). Breaking change: group stats change from { covered_percent: 80.0 } to the full stats shape with the key renamed to percent. Based on the ideas in https://github.com/codeclimate-community/simplecov_json_formatter/pull/12. Co-Authored-By: Tejas --- CHANGELOG.md | 20 +++++ features/step_definitions/json_steps.rb | 9 ++- .../json_formatter/result_hash_formatter.rb | 29 +++++-- .../json_formatter/source_file_formatter.rb | 37 +++++++-- spec/fixtures/json/sample.json | 12 ++- spec/fixtures/json/sample_groups.json | 18 ++++- spec/fixtures/json/sample_with_branch.json | 20 ++++- spec/fixtures/json/sample_with_method.json | 81 +++++++++++++++++++ spec/json_formatter_spec.rb | 64 ++++++++++++++- 9 files changed, 269 insertions(+), 21 deletions(-) create mode 100644 spec/fixtures/json/sample_with_method.json diff --git a/CHANGELOG.md b/CHANGELOG.md index d99693a66..42037d092 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,28 @@ Unreleased ========== +## Breaking Changes +* JSON formatter: group stats changed from `{ "covered_percent": 80.0 }` to full stats shape `{ "covered": 8, "missed": 2, "total": 10, "percent": 80.0, "strength": 0.0 }`. The key `covered_percent` is renamed to `percent`. +* JSON formatter: `simplecov_json_formatter` gem is now built in. `require "simplecov_json_formatter"` continues to work via a shim. +* `StringFilter` now matches at path-segment boundaries. `"lib"` matches `/lib/` but no longer matches `/library/`. Use a `Regexp` filter for substring matching. +* Removed `docile` gem dependency. The `SimpleCov.configure` DSL block is now evaluated via `instance_exec` with instance variable proxying. + +## Enhancements +* JSON formatter: added `total` section with aggregate coverage statistics (covered, missed, total, percent, strength) for line, branch, and method coverage +* JSON formatter: per-file output now includes `lines_covered_percent`, and when enabled: `branches_covered_percent`, `methods` array, and `methods_covered_percent` +* JSON formatter: group stats now include full statistics for all enabled coverage types, not just line coverage percent +* JSON formatter: added `silent:` keyword to `JSONFormatter.new` to suppress console output +* Merged `simplecov-html` formatter into the main gem. A backward-compatibility shim ensures `require "simplecov-html"` still works. +* Merged `simplecov_json_formatter` into the main gem. A backward-compatibility shim ensures `require "simplecov_json_formatter"` still works. +* Added `rake assets:compile` task for building the HTML formatter's frontend assets via esbuild +* Added TypeScript type checking CI workflow +* Separated rubocop into its own `lint.yml` CI workflow +* `CommandGuesser` now appends the framework name to parallel test data (e.g. `"RSpec (1/2)"` instead of `"(1/2)"`) + ## Bugfixes * Don't report misleading 100% branch/method coverage for files added via `track_files` that were never loaded. See #902 +* Fix HTML formatter tab bar layout: dark mode toggle no longer wraps onto two lines, and tabs connect seamlessly with the content panel +* Fix branch coverage cucumber feature to match the HTML formatter's updated output format 0.22.1 (2024-09-02) ========== diff --git a/features/step_definitions/json_steps.rb b/features/step_definitions/json_steps.rb index a66f14617..fd94548e5 100644 --- a/features/step_definitions/json_steps.rb +++ b/features/step_definitions/json_steps.rb @@ -6,7 +6,12 @@ coverage_hash = json_report.fetch "coverage" directory = Dir.pwd - expect(coverage_hash.fetch("#{directory}/lib/faked_project.rb")).to eq "lines" => [nil, nil, 1, 1, 1, nil, nil, nil, 5, 3, nil, nil, 1] - expect(coverage_hash.fetch("#{directory}/lib/faked_project/some_class.rb")).to eq "lines" => [nil, nil, 1, 1, 1, nil, 1, 2, nil, nil, 1, 1, nil, nil, 1, 1, 1, nil, 0, nil, nil, 0, nil, nil, 1, nil, 1, 0, nil, nil] + faked_project = coverage_hash.fetch("#{directory}/lib/faked_project.rb") + expect(faked_project["lines"]).to eq [nil, nil, 1, 1, 1, nil, nil, nil, 5, 3, nil, nil, 1] + expect(faked_project["lines_covered_percent"]).to be_a(Float) + + some_class = coverage_hash.fetch("#{directory}/lib/faked_project/some_class.rb") + expect(some_class["lines"]).to eq [nil, nil, 1, 1, 1, nil, 1, 2, nil, nil, 1, 1, nil, nil, 1, 1, 1, nil, 0, nil, nil, 0, nil, nil, 1, nil, 1, 0, nil, nil] + expect(some_class["lines_covered_percent"]).to be_a(Float) end end diff --git a/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb b/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb index 3e92c2d9f..9029ee39d 100644 --- a/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +++ b/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb @@ -11,6 +11,7 @@ def initialize(result) end def format + format_total format_files format_groups @@ -19,6 +20,10 @@ def format private + def format_total + formatted_result[:total] = format_coverage_statistics(@result.coverage_statistics) + end + def format_files @result.files.each do |source_file| formatted_result[:coverage][source_file.filename] = @@ -28,11 +33,7 @@ def format_files def format_groups @result.groups.each do |name, file_list| - formatted_result[:groups][name] = { - lines: { - covered_percent: file_list.covered_percent - } - } + formatted_result[:groups][name] = format_coverage_statistics(file_list.coverage_statistics) end end @@ -41,6 +42,7 @@ def formatted_result meta: { simplecov_version: SimpleCov::VERSION }, + total: {}, coverage: {}, groups: {} } @@ -50,6 +52,23 @@ def format_source_file(source_file) source_file_formatter = SourceFileFormatter.new(source_file) source_file_formatter.format end + + def format_coverage_statistics(statistics) + result = {lines: format_single_statistic(statistics[:line])} + result[:branches] = format_single_statistic(statistics[:branch]) if SimpleCov.branch_coverage? && statistics[:branch] + result[:methods] = format_single_statistic(statistics[:method]) if SimpleCov.method_coverage? && statistics[:method] + result + end + + def format_single_statistic(stat) + { + covered: stat.covered, + missed: stat.missed, + total: stat.total, + percent: stat.percent, + strength: stat.strength + } + end end end end diff --git a/lib/simplecov/formatter/json_formatter/source_file_formatter.rb b/lib/simplecov/formatter/json_formatter/source_file_formatter.rb index 67303e4b0..246eca3e7 100644 --- a/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +++ b/lib/simplecov/formatter/json_formatter/source_file_formatter.rb @@ -10,24 +10,32 @@ def initialize(source_file) end def format - if SimpleCov.branch_coverage? - line_coverage.merge(branch_coverage) - else - line_coverage - end + result = line_coverage + result.merge!(branch_coverage) if SimpleCov.branch_coverage? + result.merge!(method_coverage) if SimpleCov.method_coverage? + result end private def line_coverage @line_coverage ||= { - lines: lines + lines: lines, + lines_covered_percent: @source_file.covered_percent } end def branch_coverage { - branches: branches + branches: branches, + branches_covered_percent: @source_file.branches_coverage_percent + } + end + + def method_coverage + { + methods: format_methods, + methods_covered_percent: @source_file.methods_coverage_percent } end @@ -43,6 +51,12 @@ def branches end end + def format_methods + @source_file.methods.collect do |method| + parse_method(method) + end + end + def parse_line(line) return line.coverage unless line.skipped? @@ -57,6 +71,15 @@ def parse_branch(branch) coverage: parse_line(branch) } end + + def parse_method(method) + { + name: method.to_s, + start_line: method.start_line, + end_line: method.end_line, + coverage: parse_line(method) + } + end end end end diff --git a/spec/fixtures/json/sample.json b/spec/fixtures/json/sample.json index 55a4e7a03..7f6c04537 100644 --- a/spec/fixtures/json/sample.json +++ b/spec/fixtures/json/sample.json @@ -2,6 +2,15 @@ "meta": { "simplecov_version": "0.22.0" }, + "total": { + "lines": { + "covered": 9, + "missed": 1, + "total": 10, + "percent": 90.0, + "strength": 1.0 + } + }, "coverage": { "/STUB_WORKING_DIRECTORY/spec/fixtures/json/sample.rb": { "lines": [ @@ -30,7 +39,8 @@ "ignored", "ignored", null - ] + ], + "lines_covered_percent": 90.0 } }, "groups": {} diff --git a/spec/fixtures/json/sample_groups.json b/spec/fixtures/json/sample_groups.json index 1c47f59cd..0171fb6dc 100644 --- a/spec/fixtures/json/sample_groups.json +++ b/spec/fixtures/json/sample_groups.json @@ -2,6 +2,15 @@ "meta": { "simplecov_version": "0.22.0" }, + "total": { + "lines": { + "covered": 9, + "missed": 1, + "total": 10, + "percent": 90.0, + "strength": 1.0 + } + }, "coverage": { "/STUB_WORKING_DIRECTORY/spec/fixtures/json/sample.rb": { "lines": [ @@ -30,13 +39,18 @@ "ignored", "ignored", null - ] + ], + "lines_covered_percent": 90.0 } }, "groups": { "My Group": { "lines": { - "covered_percent": 80.0 + "covered": 8, + "missed": 2, + "total": 10, + "percent": 80.0, + "strength": 0.0 } } } diff --git a/spec/fixtures/json/sample_with_branch.json b/spec/fixtures/json/sample_with_branch.json index ac451e807..39ac96018 100644 --- a/spec/fixtures/json/sample_with_branch.json +++ b/spec/fixtures/json/sample_with_branch.json @@ -2,6 +2,22 @@ "meta": { "simplecov_version": "0.22.0" }, + "total": { + "lines": { + "covered": 9, + "missed": 1, + "total": 10, + "percent": 90.0, + "strength": 1.0 + }, + "branches": { + "covered": 1, + "missed": 1, + "total": 2, + "percent": 50.0, + "strength": 0.0 + } + }, "coverage": { "/STUB_WORKING_DIRECTORY/spec/fixtures/json/sample.rb": { "lines": [ @@ -31,6 +47,7 @@ "ignored", null ], + "lines_covered_percent": 90.0, "branches": [ { "type": "then", @@ -44,7 +61,8 @@ "end_line": 16, "coverage": 1 } - ] + ], + "branches_covered_percent": 50.0 } }, "groups": {} diff --git a/spec/fixtures/json/sample_with_method.json b/spec/fixtures/json/sample_with_method.json new file mode 100644 index 000000000..f13c7d201 --- /dev/null +++ b/spec/fixtures/json/sample_with_method.json @@ -0,0 +1,81 @@ +{ + "meta": { + "simplecov_version": "0.22.0" + }, + "total": { + "lines": { + "covered": 9, + "missed": 1, + "total": 10, + "percent": 90.0, + "strength": 1.0 + }, + "methods": { + "covered": 3, + "missed": 0, + "total": 3, + "percent": 100.0, + "strength": 0.0 + } + }, + "coverage": { + "/STUB_WORKING_DIRECTORY/spec/fixtures/json/sample.rb": { + "lines": [ + null, + 1, + 1, + 1, + 1, + null, + null, + 1, + 1, + null, + null, + 1, + 1, + 0, + null, + 1, + null, + null, + null, + "ignored", + "ignored", + "ignored", + "ignored", + "ignored", + null + ], + "lines_covered_percent": 90.0, + "methods": [ + { + "name": "Foo#initialize", + "start_line": 3, + "end_line": 6, + "coverage": 1 + }, + { + "name": "Foo#bar", + "start_line": 8, + "end_line": 10, + "coverage": 1 + }, + { + "name": "Foo#foo", + "start_line": 12, + "end_line": 18, + "coverage": 1 + }, + { + "name": "Foo#skipped", + "start_line": 21, + "end_line": 23, + "coverage": "ignored" + } + ], + "methods_covered_percent": 100.0 + } + }, + "groups": {} +} diff --git a/spec/json_formatter_spec.rb b/spec/json_formatter_spec.rb index 533ca2b1b..3d75bb0fd 100644 --- a/spec/json_formatter_spec.rb +++ b/spec/json_formatter_spec.rb @@ -16,10 +16,24 @@ describe "format" do context "with line coverage" do - it "works" do + it "includes line coverage and covered_percent per file" do subject.format(result) expect(json_output).to eq(json_result("sample")) end + + it "preserves raw percentage and strength precision" do + unrounded_result = SimpleCov::Result.new({source_fixture("json/sample.rb") => {"lines" => [1, 0, 1]}}) + + subject.format(unrounded_result) + + expect(json_output.fetch("total").fetch("lines")).to include( + "percent" => 66.66666666666667, + "strength" => 0.6666666666666666 + ) + expect(json_output.fetch("coverage").fetch(source_fixture("json/sample.rb"))).to include( + "lines_covered_percent" => 66.66666666666667 + ) + end end context "with branch coverage" do @@ -51,13 +65,51 @@ enable_branch_coverage end - it "works" do + it "includes branch data and branches_covered_percent per file" do subject.format(result) expect(json_output).to eq(json_result("sample_with_branch")) end end + context "with method coverage" do + let(:original_lines) do + [nil, 1, 1, 1, 1, nil, nil, 1, 1, + nil, nil, 1, 1, 0, nil, 1, nil, + nil, nil, nil, 1, 0, nil, nil, nil] + end + + let(:original_methods) do + { + ["Foo", :initialize, 3, 2, 6, 5] => 1, + ["Foo", :bar, 8, 2, 10, 5] => 1, + ["Foo", :foo, 12, 2, 18, 5] => 1, + ["Foo", :skipped, 21, 2, 23, 5] => 0 + } + end + + let(:result) do + SimpleCov::Result.new({ + source_fixture("json/sample.rb") => { + "lines" => original_lines, + "methods" => original_methods + } + }) + end + + before do + enable_method_coverage + end + + # total.methods.total is 3, not 4, because Foo#skipped is inside a :nocov: block + it "includes methods array and methods_covered_percent per file" do + subject.format(result) + expect(json_output).to eq(json_result("sample_with_method")) + end + end + context "with groups" do + let(:line_stats) { SimpleCov::CoverageStatistics.new(covered: 8, missed: 2) } + let(:result) do res = SimpleCov::Result.new({ source_fixture("json/sample.rb") => {"lines" => [ @@ -68,7 +120,9 @@ # right now SimpleCov works mostly on global state, hence setting the groups that way # would be global state --> Mocking is better here - allow(res).to receive_messages(groups: {"My Group" => double("File List", covered_percent: 80.0)}) + allow(res).to receive_messages( + groups: {"My Group" => double("File List", coverage_statistics: {line: line_stats})} + ) res end @@ -83,6 +137,10 @@ def enable_branch_coverage allow(SimpleCov).to receive(:branch_coverage?).and_return(true) end + def enable_method_coverage + allow(SimpleCov).to receive(:method_coverage?).and_return(true) + end + def json_output JSON.parse(File.read("tmp/coverage/coverage.json")) end From 3ea2cf73338054fd63ca6c97f6f7dbb0466e69ff Mon Sep 17 00:00:00 2001 From: Erik Berlin Date: Mon, 6 Apr 2026 19:59:10 -0600 Subject: [PATCH 2/3] Add errors to JSON output for files below minimum coverage Report all four types of coverage threshold violations in the JSON formatter's errors object: minimum_coverage, minimum_coverage_by_file, minimum_coverage_by_group, and maximum_coverage_drop. Each violation includes expected and actual values. This makes the JSON self-contained for downstream consumers (e.g. CI reporters) that don't have access to the Ruby process. Co-Authored-By: Tejas --- .../json_formatter/result_hash_formatter.rb | 81 ++++++++- spec/fixtures/json/sample.json | 3 +- spec/fixtures/json/sample_groups.json | 3 +- spec/fixtures/json/sample_with_branch.json | 3 +- spec/fixtures/json/sample_with_method.json | 3 +- spec/json_formatter_spec.rb | 157 ++++++++++++++++++ 6 files changed, 245 insertions(+), 5 deletions(-) diff --git a/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb b/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb index 9029ee39d..63b5a0eda 100644 --- a/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +++ b/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb @@ -14,6 +14,7 @@ def format format_total format_files format_groups + format_errors formatted_result end @@ -37,6 +38,83 @@ def format_groups end end + def format_errors + format_minimum_coverage_errors + format_minimum_coverage_by_file_errors + format_minimum_coverage_by_group_errors + format_maximum_coverage_drop_errors + end + + CRITERION_KEYS = {line: :lines, branch: :branches, method: :methods}.freeze + private_constant :CRITERION_KEYS + + def format_minimum_coverage_errors + SimpleCov.minimum_coverage.each do |criterion, expected_percent| + actual = @result.coverage_statistics.fetch(criterion).percent + next unless actual < expected_percent + + key = CRITERION_KEYS.fetch(criterion) + minimum_coverage = formatted_result[:errors][:minimum_coverage] ||= {} + minimum_coverage[key] = {expected: expected_percent, actual: actual} + end + end + + def format_minimum_coverage_by_file_errors + SimpleCov.minimum_coverage_by_file.each do |criterion, expected_percent| + @result.files.each do |file| + actual = SimpleCov.round_coverage(file.coverage_statistics.fetch(criterion).percent) + next unless actual < expected_percent + + key = CRITERION_KEYS.fetch(criterion) + by_file = formatted_result[:errors][:minimum_coverage_by_file] ||= {} + criterion_errors = by_file[key] ||= {} + criterion_errors[file.filename] = {expected: expected_percent, actual: actual} + end + end + end + + def format_minimum_coverage_by_group_errors + SimpleCov.minimum_coverage_by_group.each do |group_name, minimum_group_coverage| + group = @result.groups[group_name] + next unless group + + minimum_group_coverage.each do |criterion, expected_percent| + actual = SimpleCov.round_coverage(group.coverage_statistics.fetch(criterion).percent) + next unless actual < expected_percent + + key = CRITERION_KEYS.fetch(criterion) + by_group = formatted_result[:errors][:minimum_coverage_by_group] ||= {} + group_errors = by_group[group_name] ||= {} + group_errors[key] = {expected: expected_percent, actual: actual} + end + end + end + + def format_maximum_coverage_drop_errors + return if SimpleCov.maximum_coverage_drop.empty? + + last_run = SimpleCov::LastRun.read + return unless last_run + + SimpleCov.maximum_coverage_drop.each do |criterion, max_drop| + drop = coverage_drop_for(criterion, last_run) + next unless drop && drop > max_drop + + key = CRITERION_KEYS.fetch(criterion) + coverage_drop = formatted_result[:errors][:maximum_coverage_drop] ||= {} + coverage_drop[key] = {maximum: max_drop, actual: drop} + end + end + + def coverage_drop_for(criterion, last_run) + last_coverage_percent = last_run.dig(:result, criterion) + last_coverage_percent ||= last_run.dig(:result, :covered_percent) if criterion == :line + return nil unless last_coverage_percent + + current = SimpleCov.round_coverage(@result.coverage_statistics.fetch(criterion).percent) + (last_coverage_percent - current).floor(10) + end + def formatted_result @formatted_result ||= { meta: { @@ -44,7 +122,8 @@ def formatted_result }, total: {}, coverage: {}, - groups: {} + groups: {}, + errors: {} } end diff --git a/spec/fixtures/json/sample.json b/spec/fixtures/json/sample.json index 7f6c04537..06c1c9477 100644 --- a/spec/fixtures/json/sample.json +++ b/spec/fixtures/json/sample.json @@ -43,5 +43,6 @@ "lines_covered_percent": 90.0 } }, - "groups": {} + "groups": {}, + "errors": {} } diff --git a/spec/fixtures/json/sample_groups.json b/spec/fixtures/json/sample_groups.json index 0171fb6dc..8e0723a3b 100644 --- a/spec/fixtures/json/sample_groups.json +++ b/spec/fixtures/json/sample_groups.json @@ -53,5 +53,6 @@ "strength": 0.0 } } - } + }, + "errors": {} } diff --git a/spec/fixtures/json/sample_with_branch.json b/spec/fixtures/json/sample_with_branch.json index 39ac96018..e7bf0aeb0 100644 --- a/spec/fixtures/json/sample_with_branch.json +++ b/spec/fixtures/json/sample_with_branch.json @@ -65,5 +65,6 @@ "branches_covered_percent": 50.0 } }, - "groups": {} + "groups": {}, + "errors": {} } diff --git a/spec/fixtures/json/sample_with_method.json b/spec/fixtures/json/sample_with_method.json index f13c7d201..99c8f6795 100644 --- a/spec/fixtures/json/sample_with_method.json +++ b/spec/fixtures/json/sample_with_method.json @@ -77,5 +77,6 @@ "methods_covered_percent": 100.0 } }, - "groups": {} + "groups": {}, + "errors": {} } diff --git a/spec/json_formatter_spec.rb b/spec/json_formatter_spec.rb index 3d75bb0fd..cc31ab7cd 100644 --- a/spec/json_formatter_spec.rb +++ b/spec/json_formatter_spec.rb @@ -107,6 +107,163 @@ end end + context "with minimum_coverage below threshold" do + before do + allow(SimpleCov).to receive(:minimum_coverage).and_return(line: 95) + end + + it "reports the violation in errors" do + subject.format(result) + errors = json_output.fetch("errors") + expect(errors).to eq( + "minimum_coverage" => {"lines" => {"expected" => 95, "actual" => 90.0}} + ) + end + end + + context "with minimum_coverage above threshold" do + before do + allow(SimpleCov).to receive(:minimum_coverage).and_return(line: 80) + end + + it "returns empty errors" do + subject.format(result) + expect(json_output.fetch("errors")).to eq({}) + end + end + + context "with minimum_coverage_by_file for lines" do + before do + allow(SimpleCov).to receive(:minimum_coverage_by_file).and_return(line: 95) + end + + it "reports files below the threshold in errors" do + subject.format(result) + errors = json_output.fetch("errors") + expect(errors).to eq( + "minimum_coverage_by_file" => { + "lines" => {source_fixture("json/sample.rb") => {"expected" => 95, "actual" => 90.0}} + } + ) + end + end + + context "with minimum_coverage_by_file for branches" do + let(:result) do + SimpleCov::Result.new({ + source_fixture("json/sample.rb") => { + "lines" => [nil, 1, 1, 1, 1, nil, nil, 1, 1, nil, nil, + 1, 1, 0, nil, 1, nil, nil, nil, nil, 1, 0, nil, nil, nil], + "branches" => { + [:if, 0, 13, 4, 17, 7] => { + [:then, 1, 14, 6, 14, 10] => 0, + [:else, 2, 16, 6, 16, 10] => 1 + } + } + } + }) + end + + before do + enable_branch_coverage + allow(SimpleCov).to receive(:minimum_coverage_by_file).and_return(branch: 75) + end + + it "reports files below the threshold in errors" do + subject.format(result) + errors = json_output.fetch("errors") + expect(errors).to eq( + "minimum_coverage_by_file" => { + "branches" => {source_fixture("json/sample.rb") => {"expected" => 75, "actual" => 50.0}} + } + ) + end + end + + context "with minimum_coverage_by_file when all files pass" do + before do + allow(SimpleCov).to receive(:minimum_coverage_by_file).and_return(line: 80) + end + + it "returns empty errors" do + subject.format(result) + expect(json_output.fetch("errors")).to eq({}) + end + end + + context "with minimum_coverage_by_group below threshold" do + let(:line_stats) { SimpleCov::CoverageStatistics.new(covered: 7, missed: 3) } + + let(:result) do + res = SimpleCov::Result.new({ + source_fixture("json/sample.rb") => {"lines" => [ + nil, 1, 1, 1, 1, nil, nil, 1, 1, nil, nil, + 1, 1, 0, nil, 1, nil, nil, nil, nil, 1, 0, nil, nil, nil + ]} + }) + + allow(res).to receive_messages( + groups: {"Models" => double("File List", coverage_statistics: {line: line_stats})} + ) + res + end + + before do + allow(SimpleCov).to receive(:minimum_coverage_by_group).and_return("Models" => {line: 80}) + end + + it "reports the group violation in errors" do + subject.format(result) + errors = json_output.fetch("errors") + expect(errors).to eq( + "minimum_coverage_by_group" => { + "Models" => {"lines" => {"expected" => 80, "actual" => 70.0}} + } + ) + end + end + + context "with maximum_coverage_drop exceeded" do + before do + allow(SimpleCov).to receive(:maximum_coverage_drop).and_return(line: 2) + allow(SimpleCov::LastRun).to receive(:read).and_return({result: {line: 95.0}}) + end + + it "reports the drop in errors" do + subject.format(result) + errors = json_output.fetch("errors") + expect(errors).to eq( + "maximum_coverage_drop" => { + "lines" => {"maximum" => 2, "actual" => 5.0} + } + ) + end + end + + context "with maximum_coverage_drop not exceeded" do + before do + allow(SimpleCov).to receive(:maximum_coverage_drop).and_return(line: 2) + allow(SimpleCov::LastRun).to receive(:read).and_return({result: {line: 91.0}}) + end + + it "returns empty errors" do + subject.format(result) + expect(json_output.fetch("errors")).to eq({}) + end + end + + context "with maximum_coverage_drop and no last run" do + before do + allow(SimpleCov).to receive(:maximum_coverage_drop).and_return(line: 2) + allow(SimpleCov::LastRun).to receive(:read).and_return(nil) + end + + it "returns empty errors" do + subject.format(result) + expect(json_output.fetch("errors")).to eq({}) + end + end + context "with groups" do let(:line_stats) { SimpleCov::CoverageStatistics.new(covered: 8, missed: 2) } From 85a78d1e8011d0bc787aac9feb399bd229e71901 Mon Sep 17 00:00:00 2001 From: Erik Berlin Date: Sun, 5 Apr 2026 03:45:02 -0700 Subject: [PATCH 3/3] Render HTML coverage reports client-side from static JSON data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace server-side ERB template rendering with a static single-page JavaScript app. The HTML formatter now writes coverage_data.js (a thin JS wrapper around the JSON coverage data) and copies pre-compiled static assets. All rendering — file lists, source code views, coverage bars, group tabs — happens in the browser. This cleanly separates data (JSON) from presentation (JS/HTML/CSS): * Delete ERB templates, Ruby view helpers, and coverage helpers * Move all rendering logic into TypeScript (app.ts) * Add static index.html as a pre-compiled asset via rake assets:compile * Flatten formatter class hierarchy (5 classes → 3) * Introduce JSONFormatter.build_hash for shared, side-effect-free serialization used by both formatters * Eliminate double JSONFormatter execution when both formatters are configured --- Rakefile | 5 +- features/branch_coverage.feature | 3 +- features/step_definitions/html_steps.rb | 24 +- features/support/env.rb | 17 +- html_frontend/src/app.ts | 690 ++++++++- html_frontend/src/index.html | 55 + lib/simplecov/formatter/html_formatter.rb | 104 +- .../html_formatter/coverage_helpers.rb | 134 -- .../html_formatter/public/application.js | 21 +- .../html_formatter/public/index.html | 55 + .../formatter/html_formatter/view_helpers.rb | 77 - .../html_formatter/views/coverage_summary.erb | 5 - .../html_formatter/views/covered_percent.erb | 1 - .../html_formatter/views/file_list.erb | 55 - .../formatter/html_formatter/views/layout.erb | 96 -- .../html_formatter/views/source_file.erb | 38 - lib/simplecov/formatter/json_formatter.rb | 23 +- .../json_formatter/result_exporter.rb | 31 - .../json_formatter/result_hash_formatter.rb | 96 +- .../json_formatter/source_file_formatter.rb | 86 -- spec/coverage_for_eval_spec.rb | 2 +- spec/fixtures/json/sample.json | 39 +- spec/fixtures/json/sample_groups.json | 44 +- spec/fixtures/json/sample_with_branch.json | 50 +- spec/fixtures/json/sample_with_method.json | 42 +- spec/json_formatter_spec.rb | 77 +- test/html_formatter/test_formatter.rb | 715 ++------- test/html_formatter/test_simple_cov-html.rb | 179 +-- test/html_formatter/test_view_helpers.rb | 1305 ----------------- 29 files changed, 1324 insertions(+), 2745 deletions(-) create mode 100644 html_frontend/src/index.html delete mode 100644 lib/simplecov/formatter/html_formatter/coverage_helpers.rb create mode 100644 lib/simplecov/formatter/html_formatter/public/index.html delete mode 100644 lib/simplecov/formatter/html_formatter/view_helpers.rb delete mode 100644 lib/simplecov/formatter/html_formatter/views/coverage_summary.erb delete mode 100644 lib/simplecov/formatter/html_formatter/views/covered_percent.erb delete mode 100644 lib/simplecov/formatter/html_formatter/views/file_list.erb delete mode 100644 lib/simplecov/formatter/html_formatter/views/layout.erb delete mode 100644 lib/simplecov/formatter/html_formatter/views/source_file.erb delete mode 100644 lib/simplecov/formatter/json_formatter/result_exporter.rb delete mode 100644 lib/simplecov/formatter/json_formatter/source_file_formatter.rb delete mode 100644 test/html_formatter/test_view_helpers.rb diff --git a/Rakefile b/Rakefile index 38855950f..353ec0525 100644 --- a/Rakefile +++ b/Rakefile @@ -40,7 +40,7 @@ task test: %i[spec cucumber test_html] task default: %i[rubocop spec cucumber test_html] namespace :assets do - desc "Compile frontend assets (JS + CSS) using esbuild" + desc "Compile frontend assets (HTML, JS, CSS) using esbuild" task :compile do frontend = File.expand_path("html_frontend", __dir__) outdir = File.expand_path("lib/simplecov/formatter/html_formatter/public", __dir__) @@ -62,5 +62,8 @@ namespace :assets do io.close_write File.write("#{outdir}/application.css", io.read) end + + # HTML: copy static index.html + FileUtils.cp(File.join(frontend, "src/index.html"), File.join(outdir, "index.html")) end end diff --git a/features/branch_coverage.feature b/features/branch_coverage.feature index 9740c4a1e..b8128fb39 100644 --- a/features/branch_coverage.feature +++ b/features/branch_coverage.feature @@ -15,8 +15,7 @@ Feature: end """ When I open the coverage report generated with `bundle exec rspec spec` - Then the output should contain "Line coverage: 56 / 61 (91.80%)" - And the output should contain "Branch coverage: 2 / 4 (50.00%)" + Then the output should contain "56 / 61 LOC (91.8%) covered" And I should see the groups: | name | coverage | files | | All Files | 91.80% | 7 | diff --git a/features/step_definitions/html_steps.rb b/features/step_definitions/html_steps.rb index 18d351375..a0fb63267 100644 --- a/features/step_definitions/html_steps.rb +++ b/features/step_definitions/html_steps.rb @@ -60,7 +60,29 @@ end Then /^there should be (\d+) skipped lines in the source files$/ do |expected_count| - count = page.evaluate_script("document.querySelectorAll('.source_files template').length > 0 ? Array.from(document.querySelectorAll('.source_files template')).reduce(function(sum, t) { return sum + t.content.querySelectorAll('ol li.skipped').length; }, 0) : document.querySelectorAll('.source_table ol li.skipped').length") + # Materialize all source files (renders them from coverage data), then count skipped lines + count = page.evaluate_script(<<~JS) + (function() { + // Check for pre-rendered templates (old simplecov-html) + var templates = document.querySelectorAll('.source_files template'); + if (templates.length > 0) { + return Array.from(templates).reduce(function(sum, t) { + return sum + t.content.querySelectorAll('ol li.skipped').length; + }, 0); + } + // New architecture: count skipped lines directly from coverage data + if (window.SIMPLECOV_DATA) { + var count = 0; + var coverage = window.SIMPLECOV_DATA.coverage; + Object.keys(coverage).forEach(function(fn) { + var lines = coverage[fn].lines; + lines.forEach(function(l) { if (l === 'ignored') count++; }); + }); + return count; + } + return document.querySelectorAll('.source_table ol li.skipped').length; + })() + JS expect(count).to eq(expected_count.to_i) end diff --git a/features/support/env.rb b/features/support/env.rb index 6dc1cfced..603e00a68 100644 --- a/features/support/env.rb +++ b/features/support/env.rb @@ -23,8 +23,23 @@ def extended(base) # Rack app for Capybara which returns the latest coverage report from Aruba temp project dir coverage_dir = File.expand_path("../../tmp/aruba/project/coverage/", __dir__) + +# Prevent the browser from caching coverage_data.js between scenario visits +class NoCacheMiddleware + def initialize(app) + @app = app + end + + def call(env) + status, headers, body = @app.call(env) + headers["cache-control"] = "no-store" + [status, headers, body] + end +end + Capybara.app = Rack::Builder.new do - use Rack::Static, urls: {"/" => "index.html"}, root: coverage_dir, header_rules: [[:all, {"cache-control" => "no-store"}]] + use NoCacheMiddleware + use Rack::Static, urls: {"/" => "index.html"}, root: coverage_dir run Rack::Directory.new(coverage_dir) end.to_app diff --git a/html_frontend/src/app.ts b/html_frontend/src/app.ts index b4db4dfdf..465cf3844 100644 --- a/html_frontend/src/app.ts +++ b/html_frontend/src/app.ts @@ -3,6 +3,83 @@ import ruby from 'highlight.js/lib/languages/ruby'; hljs.registerLanguage('ruby', ruby); +// --- Types for coverage data --------------------------------- + +interface CoverageData { + meta: { + simplecov_version: string; + command_name: string; + project_name: string; + timestamp: string; + root: string; + branch_coverage: boolean; + method_coverage: boolean; + }; + total: StatGroup; + coverage: Record; + groups: Record; +} + +interface StatGroup { + lines: CoverageStat; + branches?: CoverageStat; + methods?: CoverageStat; +} + +interface CoverageStat { + covered: number; + missed: number; + total: number; + percent: number; + strength: number; +} + +interface FileCoverage { + lines: (number | null | 'ignored')[]; + source: string[]; + lines_covered_percent: number; + covered_lines: number; + missed_lines: number; + branches?: BranchEntry[]; + branches_covered_percent?: number; + covered_branches?: number; + missed_branches?: number; + total_branches?: number; + methods?: MethodEntry[]; + methods_covered_percent?: number; + covered_methods?: number; + missed_methods?: number; + total_methods?: number; +} + +interface BranchEntry { + type: string; + start_line: number; + end_line: number; + coverage: number | 'ignored'; + inline: boolean; + report_line: number; +} + +interface MethodEntry { + name: string; + start_line: number; + end_line: number; + coverage: number | 'ignored'; +} + +interface GroupData { + lines: CoverageStat; + branches?: CoverageStat; + methods?: CoverageStat; + files?: string[]; +} + +declare global { + interface Window { + SIMPLECOV_DATA: CoverageData; + } +} // --- Constants ------------------------------------------------ @@ -39,15 +116,119 @@ function on( } } +function escapeHTML(str: string): string { + const div = document.createElement('div'); + div.appendChild(document.createTextNode(str)); + return div.innerHTML; +} + +function md5Hex(str: string): string { + // Simple string hash that matches Ruby's Digest::MD5.hexdigest output format. + // We use the Web Crypto API is not available synchronously, so we use a + // JS implementation of MD5 for deterministic, synchronous hashing. + return md5(str); +} + +// Minimal MD5 implementation (RFC 1321) for file ID hashing. +// Produces the same hex digest as Ruby's Digest::MD5.hexdigest. +function md5(str: string): string { + function safeAdd(x: number, y: number): number { + const lsw = (x & 0xFFFF) + (y & 0xFFFF); + return (((x >> 16) + (y >> 16) + (lsw >> 16)) << 16) | (lsw & 0xFFFF); + } + function bitRotateLeft(num: number, cnt: number): number { + return (num << cnt) | (num >>> (32 - cnt)); + } + function md5cmn(q: number, a: number, b: number, x: number, s: number, t: number): number { + return safeAdd(bitRotateLeft(safeAdd(safeAdd(a, q), safeAdd(x, t)), s), b); + } + function md5ff(a: number, b: number, c: number, d: number, x: number, s: number, t: number): number { + return md5cmn((b & c) | ((~b) & d), a, b, x, s, t); + } + function md5gg(a: number, b: number, c: number, d: number, x: number, s: number, t: number): number { + return md5cmn((b & d) | (c & (~d)), a, b, x, s, t); + } + function md5hh(a: number, b: number, c: number, d: number, x: number, s: number, t: number): number { + return md5cmn(b ^ c ^ d, a, b, x, s, t); + } + function md5ii(a: number, b: number, c: number, d: number, x: number, s: number, t: number): number { + return md5cmn(c ^ (b | (~d)), a, b, x, s, t); + } + + // Convert string to UTF-8 bytes, correctly handling surrogate pairs/non-BMP characters + const bytes: number[] = Array.from(new TextEncoder().encode(str)); + const len = bytes.length; + bytes.push(0x80); + while (bytes.length % 64 !== 56) bytes.push(0); + const bitLen = len * 8; + bytes.push(bitLen & 0xff, (bitLen >> 8) & 0xff, (bitLen >> 16) & 0xff, (bitLen >> 24) & 0xff); + bytes.push(0, 0, 0, 0); // high 32 bits of length (always 0 for strings) + + const words: number[] = []; + for (let i = 0; i < bytes.length; i += 4) { + words.push(bytes[i] | (bytes[i + 1] << 8) | (bytes[i + 2] << 16) | (bytes[i + 3] << 24)); + } + + let a = 0x67452301, b = 0xefcdab89, c = 0x98badcfe, d = 0x10325476; + for (let i = 0; i < words.length; i += 16) { + const aa = a, bb = b, cc = c, dd = d; + const x = words.slice(i, i + 16); + a = md5ff(a, b, c, d, x[0], 7, -680876936); d = md5ff(d, a, b, c, x[1], 12, -389564586); + c = md5ff(c, d, a, b, x[2], 17, 606105819); b = md5ff(b, c, d, a, x[3], 22, -1044525330); + a = md5ff(a, b, c, d, x[4], 7, -176418897); d = md5ff(d, a, b, c, x[5], 12, 1200080426); + c = md5ff(c, d, a, b, x[6], 17, -1473231341); b = md5ff(b, c, d, a, x[7], 22, -45705983); + a = md5ff(a, b, c, d, x[8], 7, 1770035416); d = md5ff(d, a, b, c, x[9], 12, -1958414417); + c = md5ff(c, d, a, b, x[10], 17, -42063); b = md5ff(b, c, d, a, x[11], 22, -1990404162); + a = md5ff(a, b, c, d, x[12], 7, 1804603682); d = md5ff(d, a, b, c, x[13], 12, -40341101); + c = md5ff(c, d, a, b, x[14], 17, -1502002290); b = md5ff(b, c, d, a, x[15], 22, 1236535329); + a = md5gg(a, b, c, d, x[1], 5, -165796510); d = md5gg(d, a, b, c, x[6], 9, -1069501632); + c = md5gg(c, d, a, b, x[11], 14, 643717713); b = md5gg(b, c, d, a, x[0], 20, -373897302); + a = md5gg(a, b, c, d, x[5], 5, -701558691); d = md5gg(d, a, b, c, x[10], 9, 38016083); + c = md5gg(c, d, a, b, x[15], 14, -660478335); b = md5gg(b, c, d, a, x[4], 20, -405537848); + a = md5gg(a, b, c, d, x[9], 5, 568446438); d = md5gg(d, a, b, c, x[14], 9, -1019803690); + c = md5gg(c, d, a, b, x[3], 14, -187363961); b = md5gg(b, c, d, a, x[8], 20, 1163531501); + a = md5gg(a, b, c, d, x[13], 5, -1444681467); d = md5gg(d, a, b, c, x[2], 9, -51403784); + c = md5gg(c, d, a, b, x[7], 14, 1735328473); b = md5gg(b, c, d, a, x[12], 20, -1926607734); + a = md5hh(a, b, c, d, x[5], 4, -378558); d = md5hh(d, a, b, c, x[8], 11, -2022574463); + c = md5hh(c, d, a, b, x[11], 16, 1839030562); b = md5hh(b, c, d, a, x[14], 23, -35309556); + a = md5hh(a, b, c, d, x[1], 4, -1530992060); d = md5hh(d, a, b, c, x[4], 11, 1272893353); + c = md5hh(c, d, a, b, x[7], 16, -155497632); b = md5hh(b, c, d, a, x[10], 23, -1094730640); + a = md5hh(a, b, c, d, x[13], 4, 681279174); d = md5hh(d, a, b, c, x[0], 11, -358537222); + c = md5hh(c, d, a, b, x[3], 16, -722521979); b = md5hh(b, c, d, a, x[6], 23, 76029189); + a = md5hh(a, b, c, d, x[9], 4, -640364487); d = md5hh(d, a, b, c, x[12], 11, -421815835); + c = md5hh(c, d, a, b, x[15], 16, 530742520); b = md5hh(b, c, d, a, x[2], 23, -995338651); + a = md5ii(a, b, c, d, x[0], 6, -198630844); d = md5ii(d, a, b, c, x[7], 10, 1126891415); + c = md5ii(c, d, a, b, x[14], 15, -1416354905); b = md5ii(b, c, d, a, x[5], 21, -57434055); + a = md5ii(a, b, c, d, x[12], 6, 1700485571); d = md5ii(d, a, b, c, x[3], 10, -1894986606); + c = md5ii(c, d, a, b, x[10], 15, -1051523); b = md5ii(b, c, d, a, x[1], 21, -2054922799); + a = md5ii(a, b, c, d, x[8], 6, 1873313359); d = md5ii(d, a, b, c, x[15], 10, -30611744); + c = md5ii(c, d, a, b, x[6], 15, -1560198380); b = md5ii(b, c, d, a, x[13], 21, 1309151649); + a = md5ii(a, b, c, d, x[4], 6, -145523070); d = md5ii(d, a, b, c, x[11], 10, -1120210379); + c = md5ii(c, d, a, b, x[2], 15, 718787259); b = md5ii(b, c, d, a, x[9], 21, -343485551); + a = safeAdd(a, aa); b = safeAdd(b, bb); c = safeAdd(c, cc); d = safeAdd(d, dd); + } + const hex = '0123456789abcdef'; + let out = ''; + for (const v of [a, b, c, d]) { + for (let i = 0; i < 4; i++) { + out += hex[(v >> (i * 8 + 4)) & 0xF] + hex[(v >> (i * 8)) & 0xF]; + } + } + return out; +} + // --- Timeago -------------------------------------------------- +// Thresholds in seconds and their display unit. Ordered largest-first so +// the first match wins. +const TIMEAGO_INTERVALS: [number, string][] = [ + [31536000, 'year'], [2592000, 'month'], [86400, 'day'], + [3600, 'hour'], [60, 'minute'], [1, 'second'] +]; + function timeago(date: Date): string { const seconds = Math.floor((Date.now() - date.getTime()) / 1000); - const intervals: [number, string][] = [ - [31536000, 'year'], [2592000, 'month'], [86400, 'day'], - [3600, 'hour'], [60, 'minute'], [1, 'second'] - ]; - for (const [secs, label] of intervals) { + for (const [secs, label] of TIMEAGO_INTERVALS) { const count = Math.floor(seconds / secs); if (count >= 1) { return count === 1 ? `about 1 ${label} ago` : `${count} ${label}s ago`; @@ -56,6 +237,25 @@ function timeago(date: Date): string { return 'just now'; } +// Returns the number of milliseconds until the displayed timeago text would +// change for the given date. For example, if "3 minutes ago" is displayed, +// the text won't change until the 4th minute boundary, so we return the +// remaining ms until that boundary (plus a small buffer). +function timeagoNextTick(date: Date): number { + const elapsedSec = (Date.now() - date.getTime()) / 1000; + for (const [secs] of TIMEAGO_INTERVALS) { + const count = Math.floor(elapsedSec / secs); + if (count >= 1) { + // The text will next change when elapsed reaches (count + 1) * secs + const nextBoundary = (count + 1) * secs; + // Add 500ms buffer so we land just after the boundary, not right on it + return Math.max((nextBoundary - elapsedSec) * 1000 + 500, 1000); + } + } + // Currently "just now" — update in 1 second (when it becomes "1 second ago") + return 1000; +} + // --- Coverage helpers ----------------------------------------- function pctClass(pct: number): string { @@ -68,31 +268,363 @@ function fmtNum(n: number): string { return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); } -function updateCoverageCells( - container: Element, - prefix: string, - covered: number, - total: number -): void { - const covCell = $(prefix + '-pct', container); - const numEl = $(prefix + '-num', container); - const denEl = $(prefix + '-den', container); - if (total === 0) { - if (covCell) { covCell.innerHTML = ''; covCell.className = covCell.className.replace(/green|yellow|red/g, '').trim(); } - if (numEl) numEl.textContent = ''; - if (denEl) denEl.textContent = ''; - return; +function fmtPct(pct: number): string { + return (Math.floor(pct * 100) / 100).toFixed(2); +} + +function shortenFilename(filename: string, root: string): string { + return filename.replace(root, '.').replace(/^\.\//, ''); +} + +function fileId(filename: string): string { + return md5Hex(filename); +} + +function toHtmlId(value: string): string { + return value.replace(/^[^a-zA-Z]+/, '').replace(/[^a-zA-Z0-9\-_]/g, ''); +} + +// --- Coverage rendering helpers ------------------------------- + +function renderCoverageBar(pct: number): string { + const css = pctClass(pct); + const width = fmtPct(pct); + return `
`; +} + +function renderCoverageCells(pct: number, covered: number, total: number, type: string, totals: boolean): string { + const css = pctClass(pct); + const pctStr = fmtPct(pct); + const barAndPct = `
${renderCoverageBar(pct)}${pctStr}%
`; + + if (totals) { + return `${barAndPct}` + + `${fmtNum(covered)}/` + + `${fmtNum(total)}`; } - const p = (covered * 100.0) / total; - const cls = pctClass(p); - if (covCell) { - covCell.innerHTML = `
${p.toFixed(2)}%
`; - covCell.className = `${covCell.className.replace(/green|yellow|red/g, '').trim()} ${cls}`; + const order = ` data-order="${fmtPct(pct)}"`; + return `${barAndPct}` + + `${fmtNum(covered)}/` + + `${fmtNum(total)}`; +} + +function renderHeaderCells(label: string, type: string, coveredLabel: string, totalLabel: string): string { + return ` +
+ ${label} +
+ + +
+
+ + ${coveredLabel} + ${totalLabel}`; +} + +// --- Rendering: Coverage summary (per source file) ------------ + +function renderTypeSummary(type: string, label: string, covered: number, total: number, enabled: boolean, opts: { suffix?: string; missedClass?: string; toggle?: boolean } = {}): string { + if (!enabled) { + return `
\n ${label}: disabled\n
`; } - if (numEl) numEl.textContent = fmtNum(covered) + '/'; - if (denEl) denEl.textContent = fmtNum(total); + const missed = total - covered; + const pct = total > 0 ? (covered * 100.0 / total) : 100.0; + const css = pctClass(pct); + const suffix = opts.suffix || 'covered'; + const missedClass = opts.missedClass || 'red'; + + let parts = `
\n ${label}: ` + + `${fmtPct(pct)}%` + + ` ${covered}/${total} ${suffix}`; + + if (missed > 0) { + const missedHtml = opts.toggle + ? `${missed} missed` + : `${missed} missed`; + parts += `,\n ${missedHtml}`; + } + parts += '\n
'; + return parts; +} + +function renderCoverageSummary( + coveredLines: number, totalLines: number, + coveredBranches: number, totalBranches: number, + coveredMethods: number, totalMethods: number, + branchCoverage: boolean, methodCoverage: boolean, + showMethodToggle: boolean +): string { + return '
' + + renderTypeSummary('line', 'Line coverage', coveredLines, totalLines, true, { suffix: 'relevant lines covered' }) + + renderTypeSummary('branch', 'Branch coverage', coveredBranches, totalBranches, branchCoverage, { missedClass: 'missed-branch-text' }) + + renderTypeSummary('method', 'Method coverage', coveredMethods, totalMethods, methodCoverage, { missedClass: 'missed-method-text-color', toggle: showMethodToggle }) + + '
'; } +// --- Rendering: Source file view ------------------------------ + +function lineStatus( + lineIndex: number, + lineCov: number | null | 'ignored', + branchesReport: Record, + missedMethodLines: Set, + branchCoverage: boolean, + methodCoverage: boolean +): string { + const lineNum = lineIndex + 1; + + // Check basic status + if (lineCov === 'ignored') return 'skipped'; + + // Branch miss takes priority + if (branchCoverage) { + const branches = branchesReport[lineNum]; + if (branches && branches.some(([, count]) => count === 0)) return 'missed-branch'; + } + + // Method miss + if (methodCoverage && missedMethodLines.has(lineNum)) return 'missed-method'; + + if (lineCov === null) return 'never'; + if (lineCov === 0) return 'missed'; + return 'covered'; +} + +function buildBranchesReport(branches: BranchEntry[] | undefined): Record { + const report: Record = {}; + if (!branches) return report; + for (const b of branches) { + if (b.coverage === 'ignored') continue; + if (!report[b.report_line]) report[b.report_line] = []; + report[b.report_line].push([b.type, b.coverage as number]); + } + return report; +} + +function buildMissedMethodLines(methods: MethodEntry[] | undefined): Set { + const set = new Set(); + if (!methods) return set; + for (const m of methods) { + if (m.coverage === 0 && m.start_line && m.end_line) { + for (let i = m.start_line; i <= m.end_line; i++) set.add(i); + } + } + return set; +} + +function renderSourceFile(filename: string, data: FileCoverage, root: string, branchCoverage: boolean, methodCoverage: boolean): string { + const id = fileId(filename); + const shortName = shortenFilename(filename, root); + const coveredLines = data.covered_lines; + const missedLines = data.missed_lines; + const totalLines = coveredLines + missedLines; + const coveredBranches = branchCoverage ? (data.covered_branches || 0) : 0; + const totalBranches = branchCoverage ? (data.total_branches || 0) : 0; + const coveredMethods = methodCoverage ? (data.covered_methods || 0) : 0; + const totalMethods = methodCoverage ? (data.total_methods || 0) : 0; + + const missedMethodsList = (data.methods || []).filter(m => m.coverage === 0); + const showMethodToggle = methodCoverage && missedMethodsList.length > 0; + + const branchesReport = buildBranchesReport(data.branches); + const missedMethodLineSet = buildMissedMethodLines(data.methods); + + let html = `
`; + html += '
'; + html += `

${escapeHTML(shortName)}

`; + html += renderCoverageSummary(coveredLines, totalLines, coveredBranches, totalBranches, coveredMethods, totalMethods, branchCoverage, methodCoverage, showMethodToggle); + + if (showMethodToggle) { + html += ''; + } + html += '
'; + + // Source lines + html += '
    '; + for (let i = 0; i < data.source.length; i++) { + const lineCov = data.lines[i]; + const status = lineStatus(i, lineCov, branchesReport, missedMethodLineSet, branchCoverage, methodCoverage); + const lineNum = i + 1; + const hitsAttr = lineCov !== null && lineCov !== 'ignored' ? ` data-hits="${lineCov}"` : ''; + + html += `
  1. `; + + if (status === 'covered' || (lineCov !== null && lineCov !== 'ignored' && lineCov !== 0)) { + html += ``; + } else if (lineCov === 'ignored') { + html += ''; + } + + if (branchCoverage) { + const lineBranches = branchesReport[lineNum]; + if (lineBranches) { + for (const [branchType, hitCount] of lineBranches) { + html += ``; + } + } + } + + html += `${escapeHTML(data.source[i])}
  2. `; + } + html += '
'; + return html; +} + +// --- Rendering: File list table -------------------------------- + +function renderFileList( + title: string, + filenames: string[], + allCoverage: Record, + root: string, + branchCoverage: boolean, + methodCoverage: boolean +): string { + const containerId = toHtmlId(title); + + // Compute totals across all files in this list + let totalCoveredLines = 0, totalRelevantLines = 0; + let totalCoveredBranches = 0, totalAllBranches = 0; + let totalCoveredMethods = 0, totalAllMethods = 0; + + for (const fn of filenames) { + const f = allCoverage[fn]; + if (!f) continue; + totalCoveredLines += f.covered_lines; + totalRelevantLines += f.covered_lines + f.missed_lines; + if (branchCoverage) { + totalCoveredBranches += f.covered_branches || 0; + totalAllBranches += f.total_branches || 0; + } + if (methodCoverage) { + totalCoveredMethods += f.covered_methods || 0; + totalAllMethods += f.total_methods || 0; + } + } + + const linePct = totalRelevantLines > 0 ? totalCoveredLines * 100.0 / totalRelevantLines : 100.0; + const branchPct = branchCoverage && totalAllBranches > 0 ? totalCoveredBranches * 100.0 / totalAllBranches : 100.0; + const methodPct = methodCoverage && totalAllMethods > 0 ? totalCoveredMethods * 100.0 / totalAllMethods : 100.0; + + let html = `
`; + html += `${escapeHTML(title)}`; + html += `${fmtPct(linePct)}%`; + + html += '
'; + html += ``; + html += renderHeaderCells('Line Coverage', 'line', 'Covered', 'Lines'); + if (branchCoverage) html += renderHeaderCells('Branch Coverage', 'branch', 'Covered', 'Branches'); + if (methodCoverage) html += renderHeaderCells('Method Coverage', 'method', 'Covered', 'Methods'); + html += ''; + + // Totals row + const fileLabel = filenames.length === 1 ? 'file' : 'files'; + html += ``; + html += renderCoverageCells(linePct, totalCoveredLines, totalRelevantLines, 'line', true); + if (branchCoverage) html += renderCoverageCells(branchPct, totalCoveredBranches, totalAllBranches, 'branch', true); + if (methodCoverage) html += renderCoverageCells(methodPct, totalCoveredMethods, totalAllMethods, 'method', true); + html += ''; + + // File rows + for (const fn of filenames) { + const f = allCoverage[fn]; + if (!f) continue; + const shortName = shortenFilename(fn, root); + const id = fileId(fn); + const coveredLines = f.covered_lines; + const relevantLines = coveredLines + f.missed_lines; + + let dataAttrs = `data-covered-lines="${coveredLines}" data-relevant-lines="${relevantLines}"`; + if (branchCoverage) { + dataAttrs += ` data-covered-branches="${f.covered_branches || 0}" data-total-branches="${f.total_branches || 0}"`; + } + if (methodCoverage) { + dataAttrs += ` data-covered-methods="${f.covered_methods || 0}" data-total-methods="${f.total_methods || 0}"`; + } + + html += ``; + html += ``; + html += renderCoverageCells(f.lines_covered_percent, coveredLines, relevantLines, 'line', false); + if (branchCoverage) { + html += renderCoverageCells(f.branches_covered_percent || 100.0, f.covered_branches || 0, f.total_branches || 0, 'branch', false); + } + if (methodCoverage) { + html += renderCoverageCells(f.methods_covered_percent || 100.0, f.covered_methods || 0, f.total_methods || 0, 'method', false); + } + html += ''; + } + + html += '
File Name
${fmtNum(filenames.length)} ${fileLabel}
${escapeHTML(shortName)}
'; + return html; +} + +// --- Rendering: Full page from data --------------------------- + +function renderPage(data: CoverageData): void { + const meta = data.meta; + const branchCoverage = meta.branch_coverage; + const methodCoverage = meta.method_coverage; + const root = meta.root; + + // Page title and favicon + document.title = `Code coverage for ${meta.project_name}`; + const allFiles = Object.keys(data.coverage); + const overallPct = data.total.lines.total > 0 ? data.total.lines.percent : 100.0; + const faviconLink = document.createElement('link'); + faviconLink.rel = 'icon'; + faviconLink.type = 'image/png'; + faviconLink.href = `favicon_${pctClass(overallPct)}.png`; + document.head.appendChild(faviconLink); + + if (branchCoverage) document.body.setAttribute('data-branch-coverage', 'true'); + + // Content: file lists + const content = document.getElementById('content')!; + content.innerHTML = renderFileList('All Files', allFiles, data.coverage, root, branchCoverage, methodCoverage); + + for (const groupName of Object.keys(data.groups)) { + const groupFiles = data.groups[groupName].files || []; + content.innerHTML += renderFileList(groupName, groupFiles, data.coverage, root, branchCoverage, methodCoverage); + } + + // Build id → filename lookup map for O(1) source file materialization + const idToFilename: Record = {}; + for (const fn of allFiles) { + idToFilename[fileId(fn)] = fn; + } + (window as any)._simplecovIdMap = idToFilename; + (window as any)._simplecovFiles = data.coverage; + (window as any)._simplecovRoot = root; + (window as any)._simplecovBranchCoverage = branchCoverage; + (window as any)._simplecovMethodCoverage = methodCoverage; + + // Footer + const timestamp = new Date(meta.timestamp); + const footer = document.getElementById('footer')!; + footer.innerHTML = `Generated ${timestamp.toISOString()}` + + ` by simplecov v${escapeHTML(meta.simplecov_version)}` + + ` using ${escapeHTML(meta.command_name)}`; + + // Source legend + const legend = document.getElementById('source-legend')!; + let legendHtml = 'Covered' + + 'Skipped' + + 'Missed line'; + if (branchCoverage) { + legendHtml += 'Missed branch'; + } + if (methodCoverage) { + legendHtml += 'Missed method'; + } + legend.innerHTML = legendHtml; +} + + // --- Sort state ----------------------------------------------- interface SortEntry { @@ -284,22 +816,54 @@ function updateTotalsRow(container: Element): void { } } -// --- Template materialization ---------------------------------- +function updateCoverageCells( + container: Element, + prefix: string, + covered: number, + total: number +): void { + const covCell = $(prefix + '-pct', container); + const numEl = $(prefix + '-num', container); + const denEl = $(prefix + '-den', container); + if (total === 0) { + if (covCell) { covCell.innerHTML = ''; covCell.className = covCell.className.replace(/green|yellow|red/g, '').trim(); } + if (numEl) numEl.textContent = ''; + if (denEl) denEl.textContent = ''; + return; + } + const p = (covered * 100.0) / total; + const cls = pctClass(p); + if (covCell) { + covCell.innerHTML = `
${renderCoverageBar(p)}${p.toFixed(2)}%
`; + covCell.className = `${covCell.className.replace(/green|yellow|red/g, '').trim()} ${cls}`; + } + if (numEl) numEl.textContent = fmtNum(covered) + '/'; + if (denEl) denEl.textContent = fmtNum(total); +} + +// --- Source file rendering (on demand) ------------------------- function materializeSourceFile(sourceFileId: string): HTMLElement | null { const existing = document.getElementById(sourceFileId); if (existing) return existing; - const tmpl = document.getElementById('tmpl-' + sourceFileId) as HTMLTemplateElement | null; - if (!tmpl) return null; + const idMap = (window as any)._simplecovIdMap as Record; + const coverage = (window as any)._simplecovFiles as Record; + const root = (window as any)._simplecovRoot as string; + const branchCov = (window as any)._simplecovBranchCoverage as boolean; + const methodCov = (window as any)._simplecovMethodCoverage as boolean; - const clone = document.importNode(tmpl.content, true); - document.querySelector('.source_files')!.appendChild(clone); + const targetFilename = idMap[sourceFileId]; + if (!targetFilename) return null; - const el = document.getElementById(sourceFileId); - if (el) { - $$('pre code', el).forEach(e => { hljs.highlightElement(e as HTMLElement); }); - } + const html = renderSourceFile(targetFilename, coverage[targetFilename], root, branchCov, methodCov); + const container = document.querySelector('.source_files')!; + const wrapper = document.createElement('div'); + wrapper.innerHTML = html; + const el = wrapper.firstElementChild as HTMLElement; + container.appendChild(el); + + $$('pre code', el).forEach(e => { hljs.highlightElement(e as HTMLElement); }); return el; } @@ -328,9 +892,6 @@ function equalizeBarWidths(): void { wrapper.style.visibility = 'hidden'; - // Binary search for the largest bar width that fits without scrolling, - // scaling gradually from MAX_BAR_WIDTH down to MIN_BAR_WIDTH. - // If the table overflows even at MIN_BAR_WIDTH, use MIN_BAR_WIDTH anyway. let lo = MIN_BAR_WIDTH, hi = MAX_BAR_WIDTH; while (lo < hi) { const mid = Math.ceil((lo + hi) / 2); @@ -559,16 +1120,40 @@ function initDarkMode(): void { // --- Initialization ------------------------------------------- -document.addEventListener('DOMContentLoaded', function () { - // Timeago - $$('abbr.timeago').forEach(el => { - const date = new Date(el.getAttribute('title') || ''); - if (!isNaN(date.getTime())) el.textContent = timeago(date); - }); +// Wait for coverage data to be available, then render +function init(): void { + if (!window.SIMPLECOV_DATA) { + // Data not loaded yet - the coverage_data.js script tag is at the end of body, + // so if DOMContentLoaded fires first, wait for it + window.addEventListener('load', init); + return; + } + + const data = window.SIMPLECOV_DATA; + + // Show loading indicator + const loadingEl = document.getElementById('loading'); + if (loadingEl) loadingEl.style.display = ''; + + // Render all content from data + renderPage(data); + + // Timeago — schedule the next update for exactly when the text would change + function scheduleTimeago(): void { + let minDelay = Infinity; + $$('abbr.timeago').forEach(el => { + const date = new Date(el.getAttribute('title') || ''); + if (isNaN(date.getTime())) return; + el.textContent = timeago(date); + minDelay = Math.min(minDelay, timeagoNextTick(date)); + }); + if (minDelay < Infinity) setTimeout(scheduleTimeago, minDelay); + } + scheduleTimeago(); initDarkMode(); - // Table sorting — compute td index dynamically at click time + // Table sorting function thToTdIndex(table: Element, clickedTh: Element): number { let idx = 0; for (const th of $$('thead tr:first-child th', table)) { @@ -609,7 +1194,6 @@ document.addEventListener('DOMContentLoaded', function () { document.addEventListener('keydown', (e: KeyboardEvent) => { const inInput = (e.target as Element).matches('input, select, textarea'); - // "/" to focus search if (e.key === '/' && !inInput) { e.preventDefault(); const visible = $$('.file_list_container').filter(c => (c as HTMLElement).style.display !== 'none'); @@ -618,7 +1202,6 @@ document.addEventListener('DOMContentLoaded', function () { return; } - // Escape — close dialog or clear focus if (e.key === 'Escape') { if (dialog.open) { e.preventDefault(); @@ -633,14 +1216,12 @@ document.addEventListener('DOMContentLoaded', function () { if (inInput) return; - // Source view shortcuts (dialog open) if (dialog.open) { if (e.key === 'n' && !e.shiftKey) { e.preventDefault(); jumpToMissedLine(1); } if (e.key === 'N' || (e.key === 'n' && e.shiftKey) || e.key === 'p') { e.preventDefault(); jumpToMissedLine(-1); } return; } - // File list shortcuts (dialog closed) if (e.key === 'j') { e.preventDefault(); moveFocus(1); } if (e.key === 'k') { e.preventDefault(); moveFocus(-1); } if (e.key === 'Enter' && focusedRow) { e.preventDefault(); openFocusedRow(); } @@ -684,7 +1265,6 @@ document.addEventListener('DOMContentLoaded', function () { window.addEventListener('hashchange', navigateToHash); // Tab system - document.querySelector('.source_files')!.setAttribute('style', 'display:none'); $$('.file_list_container').forEach(c => (c as HTMLElement).style.display = 'none'); $$('.file_list_container').forEach(container => { @@ -707,17 +1287,13 @@ document.addEventListener('DOMContentLoaded', function () { window.location.hash = this.getAttribute('href')!.replace('#', '#_'); }); - // Equalize bar column widths within each table + // Equalize bar column widths window.addEventListener('resize', scheduleEqualizeBarWidths); // Initial state navigateToHash(); // Finalize loading - clearInterval((window as any)._simplecovLoadingTimer); - clearTimeout((window as any)._simplecovShowTimeout); - - const loadingEl = document.getElementById('loading'); if (loadingEl) { loadingEl.style.transition = 'opacity 0.3s'; loadingEl.style.opacity = '0'; @@ -727,7 +1303,7 @@ document.addEventListener('DOMContentLoaded', function () { const wrapperEl = document.getElementById('wrapper'); if (wrapperEl) wrapperEl.classList.remove('hide'); - // Equalize bar widths now that wrapper is visible equalizeBarWidths(); +} -}); +document.addEventListener('DOMContentLoaded', init); diff --git a/html_frontend/src/index.html b/html_frontend/src/index.html new file mode 100644 index 000000000..f3262ec22 --- /dev/null +++ b/html_frontend/src/index.html @@ -0,0 +1,55 @@ + + + + + + Code Coverage + + + + + + + + +
+
+
    + +
    + +
    + + + + +
    + + +
    +
    +
    + +
    +
    +
    + + + + diff --git a/lib/simplecov/formatter/html_formatter.rb b/lib/simplecov/formatter/html_formatter.rb index 47517f405..0a6d5a5e5 100644 --- a/lib/simplecov/formatter/html_formatter.rb +++ b/lib/simplecov/formatter/html_formatter.rb @@ -1,104 +1,60 @@ # frozen_string_literal: true -require "erb" require "fileutils" -require "time" -require_relative "html_formatter/view_helpers" +require "json" +require_relative "json_formatter" module SimpleCov module Formatter - # Generates an HTML coverage report from SimpleCov results. + # Generates an HTML coverage report by writing a coverage_data.js file + # alongside pre-compiled static assets (index.html, application.js/css). + # Uses JSONFormatter.build_hash to serialize the result, then writes both + # coverage.json and coverage_data.js from the same in-memory hash. class HTMLFormatter - VERSION = "0.13.2" + DATA_FILENAME = "coverage_data.js" - # Only have a few content types, just hardcode them - CONTENT_TYPES = { - ".js" => "text/javascript", - ".png" => "image/png", - ".gif" => "image/gif", - ".css" => "text/css" - }.freeze - - include ViewHelpers - - def initialize(silent: false, inline_assets: false) - @branch_coverage = SimpleCov.branch_coverage? - @method_coverage = SimpleCov.method_coverage? - @templates = {} - @inline_assets = inline_assets || ENV.key?("SIMPLECOV_INLINE_ASSETS") - @public_assets_dir = File.join(__dir__, "html_formatter/public/") + def initialize(silent: false) @silent = silent end def format(result) - unless @inline_assets - Dir[File.join(@public_assets_dir, "*")].each do |path| - FileUtils.cp_r(path, asset_output_path, remove_destination: true) - end - end + json = JSON.pretty_generate(JSONFormatter.build_hash(result)) - File.write(File.join(output_path, "index.html"), template("layout").result(binding), mode: "wb") + File.write(File.join(output_path, JSONFormatter::FILENAME), json) + File.write(File.join(output_path, DATA_FILENAME), "window.SIMPLECOV_DATA = #{json};\n", mode: "wb") + + copy_static_assets puts output_message(result) unless @silent end - private - - def branch_coverage? - @branch_coverage + # Generate HTML from a pre-existing coverage.json file without + # needing a live SimpleCov::Result or even a running test suite. + def format_from_json(json_path, output_dir) + FileUtils.mkdir_p(output_dir) + json = File.read(json_path) + File.write(File.join(output_dir, DATA_FILENAME), "window.SIMPLECOV_DATA = #{json};\n", mode: "wb") + copy_static_assets(output_dir) end - def method_coverage? - @method_coverage - end + private - def output_message(result) - lines = ["Coverage report generated for #{result.command_name} to #{output_path}"] - lines << "Line coverage: #{render_stats(result, :line)}" - lines << "Branch coverage: #{render_stats(result, :branch)}" if branch_coverage? - lines << "Method coverage: #{render_stats(result, :method)}" if method_coverage? - lines.join("\n") + def copy_static_assets(dest_dir = output_path) + Dir[File.join(public_dir, "*")].each do |path| + FileUtils.cp_r(path, dest_dir, remove_destination: true) + end end - def template(name) - @templates[name] ||= ERB.new(File.read(File.join(__dir__, "html_formatter/views/", "#{name}.erb")), trim_mode: "-") + def output_message(result) + "Coverage report generated for #{result.command_name} to #{output_path}. " \ + "#{result.covered_lines} / #{result.total_lines} LOC (#{result.covered_percent.round(2)}%) covered." end def output_path SimpleCov.coverage_path end - def asset_output_path - @asset_output_path ||= File.join(output_path, "assets", VERSION).tap do |path| - FileUtils.mkdir_p(path) - end - end - - def assets_path(name) - return asset_inline(name) if @inline_assets - - File.join("./assets", VERSION, name) - end - - def asset_inline(name) - path = File.join(@public_assets_dir, name) - base64_content = [File.read(path)].pack("m0") - "data:#{CONTENT_TYPES.fetch(File.extname(name))};base64,#{base64_content}" - end - - def formatted_source_file(source_file) - template("source_file").result(binding) - rescue Encoding::CompatibilityError => e - puts "Encoding problems with file #{source_file.filename}. Simplecov/ERB can't handle non ASCII characters in filenames. Error: #{e.message}." - %(

    Encoding Error

    #{ERB::Util.html_escape(e.message)}

    ) - end - - def formatted_file_list(title, source_files) - template("file_list").result(binding) - end - - def render_stats(result, criterion) - stats = result.coverage_statistics.fetch(criterion) - Kernel.format("%d / %d (%.2f%%)", covered: stats.covered, total: stats.total, percent: stats.percent) + def public_dir + File.join(__dir__, "html_formatter/public/") end end end diff --git a/lib/simplecov/formatter/html_formatter/coverage_helpers.rb b/lib/simplecov/formatter/html_formatter/coverage_helpers.rb deleted file mode 100644 index 9a50c1ea3..000000000 --- a/lib/simplecov/formatter/html_formatter/coverage_helpers.rb +++ /dev/null @@ -1,134 +0,0 @@ -# frozen_string_literal: true - -module SimpleCov - module Formatter - class HTMLFormatter - # Helpers for rendering coverage bars, cells, and summaries in ERB templates. - module CoverageHelpers - def coverage_bar(pct) - css = coverage_css_class(pct) - width = Kernel.format("%.1f", pct.floor(1)) - fill = %(
    ) - %(
    #{fill}
    ) - end - - def coverage_cells(pct, covered, total, type:, totals: false) - cov_cls, num_cls, den_cls, order = coverage_cell_attrs(pct, type, totals) - pct_str = Kernel.format("%.2f", pct.floor(2)) - bar_and_pct = %(
    #{coverage_bar(pct)}#{pct_str}%
    ) - %(#{bar_and_pct}) + - %(#{fmt(covered)}/) + - %(#{fmt(total)}) - end - - def coverage_header_cells(label, type, covered_label, total_label) - <<~HTML - -
    - #{label} -
    - - -
    -
    - - #{covered_label} - #{total_label} - HTML - end - - def file_data_attrs(source_file) - build_data_attr_pairs(source_file).map { |k, v| %(data-#{k}="#{v}") }.join(" ") - end - - def coverage_type_summary(type, label, summary, enabled:, **opts) - return disabled_summary(type, label) unless enabled - - enabled_type_summary(type, label, summary.fetch(type.to_sym), opts) - end - - def coverage_summary(stats, show_method_toggle: false) - _summary = { - line: build_stats(stats.fetch(:covered_lines), stats.fetch(:total_lines)), - branch: build_stats(stats.fetch(:covered_branches, 0), stats.fetch(:total_branches, 0)), - method: build_stats(stats.fetch(:covered_methods, 0), stats.fetch(:total_methods, 0)), - show_method_toggle: show_method_toggle - } - template("coverage_summary").result(binding) - end - - def build_stats(covered, total) - pct = total.positive? ? (covered * 100.0 / total) : 100.0 - {covered: covered, total: total, missed: total - covered, pct: pct} - end - - private - - def totals_cell_attrs(type, css) - ["cell--coverage strong t-totals__#{type}-pct #{css}", - "cell--numerator strong t-totals__#{type}-num", - "cell--denominator strong t-totals__#{type}-den", ""] - end - - def regular_cell_attrs(pct, type, css) - ["cell--coverage cell--#{type}-pct #{css}", - "cell--numerator", "cell--denominator", - %( data-order="#{Kernel.format('%.2f', pct)}")] - end - - def coverage_cell_attrs(pct, type, totals) - css = coverage_css_class(pct) - totals ? totals_cell_attrs(type, css) : regular_cell_attrs(pct, type, css) - end - - def build_data_attr_pairs(source_file) - covered = source_file.covered_lines.count - pairs = {"covered-lines" => covered, "relevant-lines" => covered + source_file.missed_lines.count} - append_branch_attrs(pairs, source_file) - append_method_attrs(pairs, source_file) - pairs - end - - def append_branch_attrs(pairs, source_file) - return unless branch_coverage? - - pairs["covered-branches"] = source_file.covered_branches.count - pairs["total-branches"] = source_file.total_branches.count - end - - def append_method_attrs(pairs, source_file) - return unless method_coverage? - - pairs["covered-methods"] = source_file.covered_methods.count - pairs["total-methods"] = source_file.methods.count - end - - def enabled_type_summary(type, label, stats, opts) - css = coverage_css_class(stats.fetch(:pct)) - missed = stats.fetch(:missed) - parts = [ - %(
    \n #{label}: ), - %(#{Kernel.format('%.2f', stats.fetch(:pct).floor(2))}%), - %( #{stats.fetch(:covered)}/#{stats.fetch(:total)} #{opts.fetch(:suffix, 'covered')}) - ] - parts << missed_summary_html(missed, opts.fetch(:missed_class, "red"), opts.fetch(:toggle, false)) if missed.positive? - parts << "\n
    " - parts.join - end - - def disabled_summary(type, label) - %(
    \n #{label}: disabled\n
    ) - end - - def missed_summary_html(count, missed_class, toggle) - missed = if toggle - %(#{count} missed) - else - %(#{count} missed) - end - %(,\n #{missed}) - end - end - end - end -end diff --git a/lib/simplecov/formatter/html_formatter/public/application.js b/lib/simplecov/formatter/html_formatter/public/application.js index 61154b69b..f37094728 100644 --- a/lib/simplecov/formatter/html_formatter/public/application.js +++ b/lib/simplecov/formatter/html_formatter/public/application.js @@ -1,3 +1,18 @@ -"use strict";(()=>{var Ct=Object.create;var We=Object.defineProperty;var Ht=Object.getOwnPropertyDescriptor;var Dt=Object.getOwnPropertyNames;var Bt=Object.getPrototypeOf,Pt=Object.prototype.hasOwnProperty;var Ft=(e,t)=>()=>(t||e((t={exports:{}}).exports,t),t.exports);var $t=(e,t,i,n)=>{if(t&&typeof t=="object"||typeof t=="function")for(let s of Dt(t))!Pt.call(e,s)&&s!==i&&We(e,s,{get:()=>t[s],enumerable:!(n=Ht(t,s))||n.enumerable});return e};var Ut=(e,t,i)=>(i=e!=null?Ct(Bt(e)):{},$t(t||!e||!e.__esModule?We(i,"default",{value:e,enumerable:!0}):i,e));var ct=Ft((Vn,lt)=>{function Ze(e){return e instanceof Map?e.clear=e.delete=e.set=function(){throw new Error("map is read-only")}:e instanceof Set&&(e.add=e.clear=e.delete=function(){throw new Error("set is read-only")}),Object.freeze(e),Object.getOwnPropertyNames(e).forEach(t=>{let i=e[t],n=typeof i;(n==="object"||n==="function")&&!Object.isFrozen(i)&&Ze(i)}),e}var oe=class{constructor(t){t.data===void 0&&(t.data={}),this.data=t.data,this.isMatchIgnored=!1}ignoreMatch(){this.isMatchIgnored=!0}};function Ye(e){return e.replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'")}function P(e,...t){let i=Object.create(null);for(let n in e)i[n]=e[n];return t.forEach(function(n){for(let s in n)i[s]=n[s]}),i}var Wt="",qe=e=>!!e.scope,qt=(e,{prefix:t})=>{if(e.startsWith("language:"))return e.replace("language:","language-");if(e.includes(".")){let i=e.split(".");return[`${t}${i.shift()}`,...i.map((n,s)=>`${n}${"_".repeat(s+1)}`)].join(" ")}return`${t}${e}`},_e=class{constructor(t,i){this.buffer="",this.classPrefix=i.classPrefix,t.walk(this)}addText(t){this.buffer+=Ye(t)}openNode(t){if(!qe(t))return;let i=qt(t.scope,{prefix:this.classPrefix});this.span(i)}closeNode(t){qe(t)&&(this.buffer+=Wt)}value(){return this.buffer}span(t){this.buffer+=``}},je=(e={})=>{let t={children:[]};return Object.assign(t,e),t},me=class e{constructor(){this.rootNode=je(),this.stack=[this.rootNode]}get top(){return this.stack[this.stack.length-1]}get root(){return this.rootNode}add(t){this.top.children.push(t)}openNode(t){let i=je({scope:t});this.add(i),this.stack.push(i)}closeNode(){if(this.stack.length>1)return this.stack.pop()}closeAllNodes(){for(;this.closeNode(););}toJSON(){return JSON.stringify(this.rootNode,null,4)}walk(t){return this.constructor._walk(t,this.rootNode)}static _walk(t,i){return typeof i=="string"?t.addText(i):i.children&&(t.openNode(i),i.children.forEach(n=>this._walk(t,n)),t.closeNode(i)),t}static _collapse(t){typeof t!="string"&&t.children&&(t.children.every(i=>typeof i=="string")?t.children=[t.children.join("")]:t.children.forEach(i=>{e._collapse(i)}))}},ye=class extends me{constructor(t){super(),this.options=t}addText(t){t!==""&&this.add(t)}startScope(t){this.openNode(t)}endScope(){this.closeNode()}__addSublanguage(t,i){let n=t.root;i&&(n.scope=`language:${i}`),this.add(n)}toHTML(){return new _e(this,this.options).value()}finalize(){return this.closeAllNodes(),!0}};function Z(e){return e?typeof e=="string"?e:e.source:null}function Ve(e){return W("(?=",e,")")}function jt(e){return W("(?:",e,")*")}function zt(e){return W("(?:",e,")?")}function W(...e){return e.map(i=>Z(i)).join("")}function Gt(e){let t=e[e.length-1];return typeof t=="object"&&t.constructor===Object?(e.splice(e.length-1,1),t):{}}function ve(...e){return"("+(Gt(e).capture?"":"?:")+e.map(n=>Z(n)).join("|")+")"}function Qe(e){return new RegExp(e.toString()+"|").exec("").length-1}function Kt(e,t){let i=e&&e.exec(t);return i&&i.index===0}var Xt=/\[(?:[^\\\]]|\\.)*\]|\(\??|\\([1-9][0-9]*)|\\./;function we(e,{joinWith:t}){let i=0;return e.map(n=>{i+=1;let s=i,c=Z(n),r="";for(;c.length>0;){let o=Xt.exec(c);if(!o){r+=c;break}r+=c.substring(0,o.index),c=c.substring(o.index+o[0].length),o[0][0]==="\\"&&o[1]?r+="\\"+String(Number(o[1])+s):(r+=o[0],o[0]==="("&&i++)}return r}).map(n=>`(${n})`).join(t)}var Zt=/\b\B/,Je="[a-zA-Z]\\w*",Te="[a-zA-Z_]\\w*",et="\\b\\d+(\\.\\d+)?",tt="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",nt="\\b(0b[01]+)",Yt="!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~",Vt=(e={})=>{let t=/^#![ ]*\//;return e.binary&&(e.begin=W(t,/.*\b/,e.binary,/\b.*/)),P({scope:"meta",begin:t,end:/$/,relevance:0,"on:begin":(i,n)=>{i.index!==0&&n.ignoreMatch()}},e)},Y={begin:"\\\\[\\s\\S]",relevance:0},Qt={scope:"string",begin:"'",end:"'",illegal:"\\n",contains:[Y]},Jt={scope:"string",begin:'"',end:'"',illegal:"\\n",contains:[Y]},en={begin:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|they|like|more)\b/},ce=function(e,t,i={}){let n=P({scope:"comment",begin:e,end:t,contains:[]},i);n.contains.push({scope:"doctag",begin:"[ ]*(?=(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):)",end:/(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):/,excludeBegin:!0,relevance:0});let s=ve("I","a","is","so","us","to","at","if","in","it","on",/[A-Za-z]+['](d|ve|re|ll|t|s|n)/,/[A-Za-z]+[-][a-z]+/,/[A-Za-z][a-z]{2,}/);return n.contains.push({begin:W(/[ ]+/,"(",s,/[.]?[:]?([.][ ]|[ ])/,"){3}")}),n},tn=ce("//","$"),nn=ce("/\\*","\\*/"),sn=ce("#","$"),rn={scope:"number",begin:et,relevance:0},on={scope:"number",begin:tt,relevance:0},ln={scope:"number",begin:nt,relevance:0},cn={scope:"regexp",begin:/\/(?=[^/\n]*\/)/,end:/\/[gimuy]*/,contains:[Y,{begin:/\[/,end:/\]/,relevance:0,contains:[Y]}]},an={scope:"title",begin:Je,relevance:0},un={scope:"title",begin:Te,relevance:0},fn={begin:"\\.\\s*"+Te,relevance:0},dn=function(e){return Object.assign(e,{"on:begin":(t,i)=>{i.data._beginMatch=t[1]},"on:end":(t,i)=>{i.data._beginMatch!==t[1]&&i.ignoreMatch()}})},re=Object.freeze({__proto__:null,APOS_STRING_MODE:Qt,BACKSLASH_ESCAPE:Y,BINARY_NUMBER_MODE:ln,BINARY_NUMBER_RE:nt,COMMENT:ce,C_BLOCK_COMMENT_MODE:nn,C_LINE_COMMENT_MODE:tn,C_NUMBER_MODE:on,C_NUMBER_RE:tt,END_SAME_AS_BEGIN:dn,HASH_COMMENT_MODE:sn,IDENT_RE:Je,MATCH_NOTHING_RE:Zt,METHOD_GUARD:fn,NUMBER_MODE:rn,NUMBER_RE:et,PHRASAL_WORDS_MODE:en,QUOTE_STRING_MODE:Jt,REGEXP_MODE:cn,RE_STARTERS_RE:Yt,SHEBANG:Vt,TITLE_MODE:an,UNDERSCORE_IDENT_RE:Te,UNDERSCORE_TITLE_MODE:un});function gn(e,t){e.input[e.index-1]==="."&&t.ignoreMatch()}function hn(e,t){e.className!==void 0&&(e.scope=e.className,delete e.className)}function pn(e,t){t&&e.beginKeywords&&(e.begin="\\b("+e.beginKeywords.split(" ").join("|")+")(?!\\.)(?=\\b|\\s)",e.__beforeBegin=gn,e.keywords=e.keywords||e.beginKeywords,delete e.beginKeywords,e.relevance===void 0&&(e.relevance=0))}function En(e,t){Array.isArray(e.illegal)&&(e.illegal=ve(...e.illegal))}function bn(e,t){if(e.match){if(e.begin||e.end)throw new Error("begin & end are not supported with match");e.begin=e.match,delete e.match}}function _n(e,t){e.relevance===void 0&&(e.relevance=1)}var mn=(e,t)=>{if(!e.beforeMatch)return;if(e.starts)throw new Error("beforeMatch cannot be used with starts");let i=Object.assign({},e);Object.keys(e).forEach(n=>{delete e[n]}),e.keywords=i.keywords,e.begin=W(i.beforeMatch,Ve(i.begin)),e.starts={relevance:0,contains:[Object.assign(i,{endsParent:!0})]},e.relevance=0,delete i.beforeMatch},yn=["of","and","for","in","not","or","if","then","parent","list","value"],Mn="keyword";function it(e,t,i=Mn){let n=Object.create(null);return typeof e=="string"?s(i,e.split(" ")):Array.isArray(e)?s(i,e):Object.keys(e).forEach(function(c){Object.assign(n,it(e[c],t,c))}),n;function s(c,r){t&&(r=r.map(o=>o.toLowerCase())),r.forEach(function(o){let u=o.split("|");n[u[0]]=[c,vn(u[0],u[1])]})}}function vn(e,t){return t?Number(t):wn(e)?0:1}function wn(e){return yn.includes(e.toLowerCase())}var ze={},U=e=>{console.error(e)},Ge=(e,...t)=>{console.log(`WARN: ${e}`,...t)},G=(e,t)=>{ze[`${e}/${t}`]||(console.log(`Deprecated as of ${e}. ${t}`),ze[`${e}/${t}`]=!0)},le=new Error;function st(e,t,{key:i}){let n=0,s=e[i],c={},r={};for(let o=1;o<=t.length;o++)r[o+n]=s[o],c[o+n]=!0,n+=Qe(t[o-1]);e[i]=r,e[i]._emit=c,e[i]._multi=!0}function Tn(e){if(Array.isArray(e.begin)){if(e.skip||e.excludeBegin||e.returnBegin)throw U("skip, excludeBegin, returnBegin not compatible with beginScope: {}"),le;if(typeof e.beginScope!="object"||e.beginScope===null)throw U("beginScope must be object"),le;st(e,e.begin,{key:"beginScope"}),e.begin=we(e.begin,{joinWith:""})}}function Sn(e){if(Array.isArray(e.end)){if(e.skip||e.excludeEnd||e.returnEnd)throw U("skip, excludeEnd, returnEnd not compatible with endScope: {}"),le;if(typeof e.endScope!="object"||e.endScope===null)throw U("endScope must be object"),le;st(e,e.end,{key:"endScope"}),e.end=we(e.end,{joinWith:""})}}function Ln(e){e.scope&&typeof e.scope=="object"&&e.scope!==null&&(e.beginScope=e.scope,delete e.scope)}function An(e){Ln(e),typeof e.beginScope=="string"&&(e.beginScope={_wrap:e.beginScope}),typeof e.endScope=="string"&&(e.endScope={_wrap:e.endScope}),Tn(e),Sn(e)}function Nn(e){function t(r,o){return new RegExp(Z(r),"m"+(e.case_insensitive?"i":"")+(e.unicodeRegex?"u":"")+(o?"g":""))}class i{constructor(){this.matchIndexes={},this.regexes=[],this.matchAt=1,this.position=0}addRule(o,u){u.position=this.position++,this.matchIndexes[this.matchAt]=u,this.regexes.push([u,o]),this.matchAt+=Qe(o)+1}compile(){this.regexes.length===0&&(this.exec=()=>null);let o=this.regexes.map(u=>u[1]);this.matcherRe=t(we(o,{joinWith:"|"}),!0),this.lastIndex=0}exec(o){this.matcherRe.lastIndex=this.lastIndex;let u=this.matcherRe.exec(o);if(!u)return null;let b=u.findIndex((S,R)=>R>0&&S!==void 0),E=this.matchIndexes[b];return u.splice(0,b),Object.assign(u,E)}}class n{constructor(){this.rules=[],this.multiRegexes=[],this.count=0,this.lastIndex=0,this.regexIndex=0}getMatcher(o){if(this.multiRegexes[o])return this.multiRegexes[o];let u=new i;return this.rules.slice(o).forEach(([b,E])=>u.addRule(b,E)),u.compile(),this.multiRegexes[o]=u,u}resumingScanAtSamePosition(){return this.regexIndex!==0}considerAll(){this.regexIndex=0}addRule(o,u){this.rules.push([o,u]),u.type==="begin"&&this.count++}exec(o){let u=this.getMatcher(this.regexIndex);u.lastIndex=this.lastIndex;let b=u.exec(o);if(this.resumingScanAtSamePosition()&&!(b&&b.index===this.lastIndex)){let E=this.getMatcher(0);E.lastIndex=this.lastIndex+1,b=E.exec(o)}return b&&(this.regexIndex+=b.position+1,this.regexIndex===this.count&&this.considerAll()),b}}function s(r){let o=new n;return r.contains.forEach(u=>o.addRule(u.begin,{rule:u,type:"begin"})),r.terminatorEnd&&o.addRule(r.terminatorEnd,{type:"end"}),r.illegal&&o.addRule(r.illegal,{type:"illegal"}),o}function c(r,o){let u=r;if(r.isCompiled)return u;[hn,bn,An,mn].forEach(E=>E(r,o)),e.compilerExtensions.forEach(E=>E(r,o)),r.__beforeBegin=null,[pn,En,_n].forEach(E=>E(r,o)),r.isCompiled=!0;let b=null;return typeof r.keywords=="object"&&r.keywords.$pattern&&(r.keywords=Object.assign({},r.keywords),b=r.keywords.$pattern,delete r.keywords.$pattern),b=b||/\w+/,r.keywords&&(r.keywords=it(r.keywords,e.case_insensitive)),u.keywordPatternRe=t(b,!0),o&&(r.begin||(r.begin=/\B|\b/),u.beginRe=t(u.begin),!r.end&&!r.endsWithParent&&(r.end=/\B|\b/),r.end&&(u.endRe=t(u.end)),u.terminatorEnd=Z(u.end)||"",r.endsWithParent&&o.terminatorEnd&&(u.terminatorEnd+=(r.end?"|":"")+o.terminatorEnd)),r.illegal&&(u.illegalRe=t(r.illegal)),r.contains||(r.contains=[]),r.contains=[].concat(...r.contains.map(function(E){return xn(E==="self"?r:E)})),r.contains.forEach(function(E){c(E,u)}),r.starts&&c(r.starts,o),u.matcher=s(u),u}if(e.compilerExtensions||(e.compilerExtensions=[]),e.contains&&e.contains.includes("self"))throw new Error("ERR: contains `self` is not supported at the top-level of a language. See documentation.");return e.classNameAliases=P(e.classNameAliases||{}),c(e)}function rt(e){return e?e.endsWithParent||rt(e.starts):!1}function xn(e){return e.variants&&!e.cachedVariants&&(e.cachedVariants=e.variants.map(function(t){return P(e,{variants:null},t)})),e.cachedVariants?e.cachedVariants:rt(e)?P(e,{starts:e.starts?P(e.starts):null}):Object.isFrozen(e)?P(e):e}var Rn="11.11.1",Me=class extends Error{constructor(t,i){super(t),this.name="HTMLInjectionError",this.html=i}},be=Ye,Ke=P,Xe=Symbol("nomatch"),On=7,ot=function(e){let t=Object.create(null),i=Object.create(null),n=[],s=!0,c="Could not find the language '{}', did you forget to load/include a language module?",r={disableAutodetect:!0,name:"Plain text",contains:[]},o={ignoreUnescapedHTML:!1,throwUnescapedHTML:!1,noHighlightRe:/^(no-?highlight)$/i,languageDetectRe:/\blang(?:uage)?-([\w-]+)\b/i,classPrefix:"hljs-",cssSelector:"pre code",languages:null,__emitter:ye};function u(l){return o.noHighlightRe.test(l)}function b(l){let d=l.className+" ";d+=l.parentNode?l.parentNode.className:"";let p=o.languageDetectRe.exec(d);if(p){let m=H(p[1]);return m||(Ge(c.replace("{}",p[1])),Ge("Falling back to no-highlight mode for this block.",l)),m?p[1]:"no-highlight"}return d.split(/\s+/).find(m=>u(m)||H(m))}function E(l,d,p){let m="",v="";typeof d=="object"?(m=l,p=d.ignoreIllegals,v=d.language):(G("10.7.0","highlight(lang, code, ...args) has been deprecated."),G("10.7.0",`Please use highlight(code, options) instead. -https://github.com/highlightjs/highlight.js/issues/2277`),v=l,m=d),p===void 0&&(p=!0);let x={code:m,language:v};te("before:highlight",x);let B=x.result?x.result:S(x.language,x.code,p);return B.code=x.code,te("after:highlight",B),B}function S(l,d,p,m){let v=Object.create(null);function x(a,f){return a.keywords[f]}function B(){if(!g.keywords){w.addText(y);return}let a=0;g.keywordPatternRe.lastIndex=0;let f=g.keywordPatternRe.exec(y),h="";for(;f;){h+=y.substring(a,f.index);let _=k.case_insensitive?f[0].toLowerCase():f[0],T=x(g,_);if(T){let[D,kt]=T;if(w.addText(h),h="",v[_]=(v[_]||0)+1,v[_]<=On&&(se+=kt),D.startsWith("_"))h+=f[0];else{let It=k.classNameAliases[D]||D;O(f[0],It)}}else h+=f[0];a=g.keywordPatternRe.lastIndex,f=g.keywordPatternRe.exec(y)}h+=y.substring(a),w.addText(h)}function ne(){if(y==="")return;let a=null;if(typeof g.subLanguage=="string"){if(!t[g.subLanguage]){w.addText(y);return}a=S(g.subLanguage,y,!0,Ue[g.subLanguage]),Ue[g.subLanguage]=a._top}else a=j(y,g.subLanguage.length?g.subLanguage:null);g.relevance>0&&(se+=a.relevance),w.__addSublanguage(a._emitter,a.language)}function L(){g.subLanguage!=null?ne():B(),y=""}function O(a,f){a!==""&&(w.startScope(f),w.addText(a),w.endScope())}function Be(a,f){let h=1,_=f.length-1;for(;h<=_;){if(!a._emit[h]){h++;continue}let T=k.classNameAliases[a[h]]||a[h],D=f[h];T?O(D,T):(y=D,B(),y=""),h++}}function Pe(a,f){return a.scope&&typeof a.scope=="string"&&w.openNode(k.classNameAliases[a.scope]||a.scope),a.beginScope&&(a.beginScope._wrap?(O(y,k.classNameAliases[a.beginScope._wrap]||a.beginScope._wrap),y=""):a.beginScope._multi&&(Be(a.beginScope,f),y="")),g=Object.create(a,{parent:{value:g}}),g}function Fe(a,f,h){let _=Kt(a.endRe,h);if(_){if(a["on:end"]){let T=new oe(a);a["on:end"](f,T),T.isMatchIgnored&&(_=!1)}if(_){for(;a.endsParent&&a.parent;)a=a.parent;return a}}if(a.endsWithParent)return Fe(a.parent,f,h)}function At(a){return g.matcher.regexIndex===0?(y+=a[0],1):(Ee=!0,0)}function Nt(a){let f=a[0],h=a.rule,_=new oe(h),T=[h.__beforeBegin,h["on:begin"]];for(let D of T)if(D&&(D(a,_),_.isMatchIgnored))return At(f);return h.skip?y+=f:(h.excludeBegin&&(y+=f),L(),!h.returnBegin&&!h.excludeBegin&&(y=f)),Pe(h,a),h.returnBegin?0:f.length}function xt(a){let f=a[0],h=d.substring(a.index),_=Fe(g,a,h);if(!_)return Xe;let T=g;g.endScope&&g.endScope._wrap?(L(),O(f,g.endScope._wrap)):g.endScope&&g.endScope._multi?(L(),Be(g.endScope,a)):T.skip?y+=f:(T.returnEnd||T.excludeEnd||(y+=f),L(),T.excludeEnd&&(y=f));do g.scope&&w.closeNode(),!g.skip&&!g.subLanguage&&(se+=g.relevance),g=g.parent;while(g!==_.parent);return _.starts&&Pe(_.starts,a),T.returnEnd?0:f.length}function Rt(){let a=[];for(let f=g;f!==k;f=f.parent)f.scope&&a.unshift(f.scope);a.forEach(f=>w.openNode(f))}let ie={};function $e(a,f){let h=f&&f[0];if(y+=a,h==null)return L(),0;if(ie.type==="begin"&&f.type==="end"&&ie.index===f.index&&h===""){if(y+=d.slice(f.index,f.index+1),!s){let _=new Error(`0 width match regex (${l})`);throw _.languageName=l,_.badRule=ie.rule,_}return 1}if(ie=f,f.type==="begin")return Nt(f);if(f.type==="illegal"&&!p){let _=new Error('Illegal lexeme "'+h+'" for mode "'+(g.scope||"")+'"');throw _.mode=g,_}else if(f.type==="end"){let _=xt(f);if(_!==Xe)return _}if(f.type==="illegal"&&h==="")return y+=` -`,1;if(pe>1e5&&pe>f.index*3)throw new Error("potential infinite loop, way more iterations than matches");return y+=h,h.length}let k=H(l);if(!k)throw U(c.replace("{}",l)),new Error('Unknown language: "'+l+'"');let Ot=Nn(k),he="",g=m||Ot,Ue={},w=new o.__emitter(o);Rt();let y="",se=0,$=0,pe=0,Ee=!1;try{if(k.__emitTokens)k.__emitTokens(d,w);else{for(g.matcher.considerAll();;){pe++,Ee?Ee=!1:g.matcher.considerAll(),g.matcher.lastIndex=$;let a=g.matcher.exec(d);if(!a)break;let f=d.substring($,a.index),h=$e(f,a);$=a.index+h}$e(d.substring($))}return w.finalize(),he=w.toHTML(),{language:l,value:he,relevance:se,illegal:!1,_emitter:w,_top:g}}catch(a){if(a.message&&a.message.includes("Illegal"))return{language:l,value:be(d),illegal:!0,relevance:0,_illegalBy:{message:a.message,index:$,context:d.slice($-100,$+100),mode:a.mode,resultSoFar:he},_emitter:w};if(s)return{language:l,value:be(d),illegal:!1,relevance:0,errorRaised:a,_emitter:w,_top:g};throw a}}function R(l){let d={value:be(l),illegal:!1,relevance:0,_top:r,_emitter:new o.__emitter(o)};return d._emitter.addText(l),d}function j(l,d){d=d||o.languages||Object.keys(t);let p=R(l),m=d.filter(H).filter(ee).map(L=>S(L,l,!1));m.unshift(p);let v=m.sort((L,O)=>{if(L.relevance!==O.relevance)return O.relevance-L.relevance;if(L.language&&O.language){if(H(L.language).supersetOf===O.language)return 1;if(H(O.language).supersetOf===L.language)return-1}return 0}),[x,B]=v,ne=x;return ne.secondBest=B,ne}function fe(l,d,p){let m=d&&i[d]||p;l.classList.add("hljs"),l.classList.add(`language-${m}`)}function z(l){let d=null,p=b(l);if(u(p))return;if(te("before:highlightElement",{el:l,language:p}),l.dataset.highlighted){console.log("Element previously highlighted. To highlight again, first unset `dataset.highlighted`.",l);return}if(l.children.length>0&&(o.ignoreUnescapedHTML||(console.warn("One of your code blocks includes unescaped HTML. This is a potentially serious security risk."),console.warn("https://github.com/highlightjs/highlight.js/wiki/security"),console.warn("The element with unescaped HTML:"),console.warn(l)),o.throwUnescapedHTML))throw new Me("One of your code blocks includes unescaped HTML.",l.innerHTML);d=l;let m=d.textContent,v=p?E(m,{language:p,ignoreIllegals:!0}):j(m);l.innerHTML=v.value,l.dataset.highlighted="yes",fe(l,p,v.language),l.result={language:v.language,re:v.relevance,relevance:v.relevance},v.secondBest&&(l.secondBest={language:v.secondBest.language,relevance:v.secondBest.relevance}),te("after:highlightElement",{el:l,result:v,text:m})}function ke(l){o=Ke(o,l)}let Ie=()=>{X(),G("10.6.0","initHighlighting() deprecated. Use highlightAll() now.")};function Ce(){X(),G("10.6.0","initHighlightingOnLoad() deprecated. Use highlightAll() now.")}let de=!1;function X(){function l(){X()}if(document.readyState==="loading"){de||window.addEventListener("DOMContentLoaded",l,!1),de=!0;return}document.querySelectorAll(o.cssSelector).forEach(z)}function He(l,d){let p=null;try{p=d(e)}catch(m){if(U("Language definition for '{}' could not be registered.".replace("{}",l)),s)U(m);else throw m;p=r}p.name||(p.name=l),t[l]=p,p.rawDefinition=d.bind(null,e),p.aliases&&ge(p.aliases,{languageName:l})}function F(l){delete t[l];for(let d of Object.keys(i))i[d]===l&&delete i[d]}function De(){return Object.keys(t)}function H(l){return l=(l||"").toLowerCase(),t[l]||t[i[l]]}function ge(l,{languageName:d}){typeof l=="string"&&(l=[l]),l.forEach(p=>{i[p.toLowerCase()]=d})}function ee(l){let d=H(l);return d&&!d.disableAutodetect}function wt(l){l["before:highlightBlock"]&&!l["before:highlightElement"]&&(l["before:highlightElement"]=d=>{l["before:highlightBlock"](Object.assign({block:d.el},d))}),l["after:highlightBlock"]&&!l["after:highlightElement"]&&(l["after:highlightElement"]=d=>{l["after:highlightBlock"](Object.assign({block:d.el},d))})}function Tt(l){wt(l),n.push(l)}function St(l){let d=n.indexOf(l);d!==-1&&n.splice(d,1)}function te(l,d){let p=l;n.forEach(function(m){m[p]&&m[p](d)})}function Lt(l){return G("10.7.0","highlightBlock will be removed entirely in v12.0"),G("10.7.0","Please use highlightElement now."),z(l)}Object.assign(e,{highlight:E,highlightAuto:j,highlightAll:X,highlightElement:z,highlightBlock:Lt,configure:ke,initHighlighting:Ie,initHighlightingOnLoad:Ce,registerLanguage:He,unregisterLanguage:F,listLanguages:De,getLanguage:H,registerAliases:ge,autoDetection:ee,inherit:Ke,addPlugin:Tt,removePlugin:St}),e.debugMode=function(){s=!1},e.safeMode=function(){s=!0},e.versionString=Rn,e.regex={concat:W,lookahead:Ve,either:ve,optional:zt,anyNumberOfTimes:jt};for(let l in re)typeof re[l]=="object"&&Ze(re[l]);return Object.assign(e,re),e},K=ot({});K.newInstance=()=>ot({});lt.exports=K;K.HighlightJS=K;K.default=K});var at=Ut(ct(),1);var Se=at.default;function ut(e){let t=e.regex,i="([a-zA-Z_]\\w*[!?=]?|[-+~]@|<<|>>|=~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~`|]|\\[\\]=?)",n=t.either(/\b([A-Z]+[a-z0-9]+)+/,/\b([A-Z]+[a-z0-9]+)+[A-Z]+/),s=t.concat(n,/(::\w+)*/),r={"variable.constant":["__FILE__","__LINE__","__ENCODING__"],"variable.language":["self","super"],keyword:["alias","and","begin","BEGIN","break","case","class","defined","do","else","elsif","end","END","ensure","for","if","in","module","next","not","or","redo","require","rescue","retry","return","then","undef","unless","until","when","while","yield",...["include","extend","prepend","public","private","protected","raise","throw"]],built_in:["proc","lambda","attr_accessor","attr_reader","attr_writer","define_method","private_constant","module_function"],literal:["true","false","nil"]},o={className:"doctag",begin:"@[A-Za-z]+"},u={begin:"#<",end:">"},b=[e.COMMENT("#","$",{contains:[o]}),e.COMMENT("^=begin","^=end",{contains:[o],relevance:10}),e.COMMENT("^__END__",e.MATCH_NOTHING_RE)],E={className:"subst",begin:/#\{/,end:/\}/,keywords:r},S={className:"string",contains:[e.BACKSLASH_ESCAPE,E],variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/`/,end:/`/},{begin:/%[qQwWx]?\(/,end:/\)/},{begin:/%[qQwWx]?\[/,end:/\]/},{begin:/%[qQwWx]?\{/,end:/\}/},{begin:/%[qQwWx]?/},{begin:/%[qQwWx]?\//,end:/\//},{begin:/%[qQwWx]?%/,end:/%/},{begin:/%[qQwWx]?-/,end:/-/},{begin:/%[qQwWx]?\|/,end:/\|/},{begin:/\B\?(\\\d{1,3})/},{begin:/\B\?(\\x[A-Fa-f0-9]{1,2})/},{begin:/\B\?(\\u\{?[A-Fa-f0-9]{1,6}\}?)/},{begin:/\B\?(\\M-\\C-|\\M-\\c|\\c\\M-|\\M-|\\C-\\M-)[\x20-\x7e]/},{begin:/\B\?\\(c|C-)[\x20-\x7e]/},{begin:/\B\?\\?\S/},{begin:t.concat(/<<[-~]?'?/,t.lookahead(/(\w+)(?=\W)[^\n]*\n(?:[^\n]*\n)*?\s*\1\b/)),contains:[e.END_SAME_AS_BEGIN({begin:/(\w+)/,end:/(\w+)/,contains:[e.BACKSLASH_ESCAPE,E]})]}]},R="[1-9](_?[0-9])*|0",j="[0-9](_?[0-9])*",fe={className:"number",relevance:0,variants:[{begin:`\\b(${R})(\\.(${j}))?([eE][+-]?(${j})|r)?i?\\b`},{begin:"\\b0[dD][0-9](_?[0-9])*r?i?\\b"},{begin:"\\b0[bB][0-1](_?[0-1])*r?i?\\b"},{begin:"\\b0[oO][0-7](_?[0-7])*r?i?\\b"},{begin:"\\b0[xX][0-9a-fA-F](_?[0-9a-fA-F])*r?i?\\b"},{begin:"\\b0(_?[0-7])+r?i?\\b"}]},z={variants:[{match:/\(\)/},{className:"params",begin:/\(/,end:/(?=\))/,excludeBegin:!0,endsParent:!0,keywords:r}]},F=[S,{variants:[{match:[/class\s+/,s,/\s+<\s+/,s]},{match:[/\b(class|module)\s+/,s]}],scope:{2:"title.class",4:"title.class.inherited"},keywords:r},{match:[/(include|extend)\s+/,s],scope:{2:"title.class"},keywords:r},{relevance:0,match:[s,/\.new[. (]/],scope:{1:"title.class"}},{relevance:0,match:/\b[A-Z][A-Z_0-9]+\b/,className:"variable.constant"},{relevance:0,match:n,scope:"title.class"},{match:[/def/,/\s+/,i],scope:{1:"keyword",3:"title.function"},contains:[z]},{begin:e.IDENT_RE+"::"},{className:"symbol",begin:e.UNDERSCORE_IDENT_RE+"(!|\\?)?:",relevance:0},{className:"symbol",begin:":(?!\\s)",contains:[S,{begin:i}],relevance:0},fe,{className:"variable",begin:"(\\$\\W)|((\\$|@@?)(\\w+))(?=[^@$?])(?![A-Za-z])(?![@$?'])"},{className:"params",begin:/\|(?!=)/,end:/\|/,excludeBegin:!0,excludeEnd:!0,relevance:0,keywords:r},{begin:"("+e.RE_STARTERS_RE+"|unless)\\s*",keywords:"unless",contains:[{className:"regexp",contains:[e.BACKSLASH_ESCAPE,E],illegal:/\n/,variants:[{begin:"/",end:"/[a-z]*"},{begin:/%r\{/,end:/\}[a-z]*/},{begin:"%r\\(",end:"\\)[a-z]*"},{begin:"%r!",end:"![a-z]*"},{begin:"%r\\[",end:"\\][a-z]*"}]}].concat(u,b),relevance:0}].concat(u,b);E.contains=F,z.contains=F;let ee=[{begin:/^\s*=>/,starts:{end:"$",contains:F}},{className:"meta.prompt",begin:"^("+"[>?]>"+"|"+"[\\w#]+\\(\\w+\\):\\d+:\\d+[>*]"+"|"+"(\\w+-)?\\d+\\.\\d+\\.\\d+(p\\d+)?[^\\d][^>]+>"+")(?=[ ])",starts:{end:"$",keywords:r,contains:F}}];return b.unshift(u),{name:"Ruby",aliases:["rb","gemspec","podspec","thor","irb"],keywords:r,illegal:/\/\*/,contains:[e.SHEBANG({binary:"ruby"})].concat(ee).concat(b).concat(F)}}Se.registerLanguage("ruby",ut);var kn=240,In=160,Cn=90,Hn=75;function C(e,t){return(t||document).querySelector(e)}function M(e,t){return Array.from((t||document).querySelectorAll(e))}function q(e,t,i,n){typeof i=="function"?e.addEventListener(t,i):e.addEventListener(t,function(s){let c=s.target.closest(i);c&&e.contains(c)&&n&&n.call(c,s)})}function Dn(e){let t=Math.floor((Date.now()-e.getTime())/1e3),i=[[31536e3,"year"],[2592e3,"month"],[86400,"day"],[3600,"hour"],[60,"minute"],[1,"second"]];for(let[n,s]of i){let c=Math.floor(t/n);if(c>=1)return c===1?`about 1 ${s} ago`:`${c} ${s}s ago`}return"just now"}function Bn(e){return e>=Cn?"green":e>=Hn?"yellow":"red"}function J(e){return e.toString().replace(/\B(?=(\d{3})+(?!\d))/g,",")}function Pn(e,t,i,n){let s=C(t+"-pct",e),c=C(t+"-num",e),r=C(t+"-den",e);if(n===0){s&&(s.innerHTML="",s.className=s.className.replace(/green|yellow|red/g,"").trim()),c&&(c.textContent=""),r&&(r.textContent="");return}let o=i*100/n,u=Bn(o);s&&(s.innerHTML=`
    ${o.toFixed(2)}%
    `,s.className=`${s.className.replace(/green|yellow|red/g,"").trim()} ${u}`),c&&(c.textContent=J(i)+"/"),r&&(r.textContent=J(n))}var ft={};function dt(e,t){let i=0;for(let n=0;n{let E=gt(dt(u,t)),S=gt(dt(b,t)),R;return typeof E=="number"&&typeof S=="number"?R=E-S:R=String(E).localeCompare(String(S)),s==="asc"?R:-R}),c.append(...r);let o=0;M("thead tr:first-child th",e).forEach(u=>{let b=parseInt(u.getAttribute("colspan")||"1",10);u.classList.remove("sorting_asc","sorting_desc","sorting");let E=t>=o&&te>t,gte:(e,t)=>e>=t,eq:(e,t)=>e===t,lte:(e,t)=>e<=t,lt:(e,t)=>e!0))(t,i)}function Wn(e){return M(".col-filter__value",e).map(t=>{let i=t;if(!i.value)return null;let n=parseFloat(i.value);if(isNaN(n))return null;let s=i.dataset.type||"",c=C(`.col-filter__op[data-type="${s}"]`,e),r=c?c.value:"";if(!r)return null;let o=xe[s];return o?{attrs:o,op:r,threshold:n}:null}).filter(t=>t!==null)}function ht(e){let t=C("table.file_list",e);if(!t)return;let i=C(".col-filter--name",e),n=i?i.value:"",s=Wn(e);M("tbody tr.t-file",t).forEach(c=>{let r=c,o=!0;if(n&&((c.children[0].textContent||"").toLowerCase().includes(n.toLowerCase())||(o=!1)),o)for(let u of s){let b=parseInt(r.dataset[u.attrs.covered]||"0",10)||0,E=parseInt(r.dataset[u.attrs.total]||"0",10)||0,S=E>0?b*100/E:100;if(!Un(u.op,S,u.threshold)){o=!1;break}}r.style.display=o?"":"none"}),Mt(),qn(e),Re()}function Le(e){let t=parseFloat(e.value),i=e.closest(".col-filter__coverage"),n=i?i.querySelector(".col-filter__op"):null;if(!n)return;let s=n.querySelector('option[value="gt"]'),c=n.querySelector('option[value="lt"]');if(s&&(s.disabled=t>=100),c&&(c.disabled=t<=0),n.selectedOptions[0]&&n.selectedOptions[0].disabled){let r=n.querySelector("option:not(:disabled)");r&&(n.value=r.value)}}function qn(e){let t=M("tbody tr.t-file",e).filter(c=>c.style.display!=="none");function i(c){let r=0;return t.forEach(o=>{r+=parseInt(o.dataset[c]||"0",10)||0}),r}let n=C(".t-file-count",e),s=parseInt(e.getAttribute("data-total-files")||"0",10);if(n){let c=t.length===1?" file":" files";n.textContent=t.length===s?J(s)+c:J(t.length)+"/"+J(s)+c}for(let c of Object.keys(xe)){let r=xe[c],o=`.t-totals__${c}`;C(o+"-pct",e)&&Pn(e,o,i(r.covered),i(r.total))}}function jn(e){let t=document.getElementById(e);if(t)return t;let i=document.getElementById("tmpl-"+e);if(!i)return null;let n=document.importNode(i.content,!0);document.querySelector(".source_files").appendChild(n);let s=document.getElementById(e);return s&&M("pre code",s).forEach(c=>{Se.highlightElement(c)}),s}function pt(e,t){let i=t+"px";e.forEach(n=>{let s=n.style;s.width=i,s.minWidth=i,s.maxWidth=i})}function yt(){M(".file_list_container").forEach(e=>{if(e.style.display==="none"||e.offsetWidth===0)return;let t=C("table.file_list",e);if(!t)return;let i=M(".bar-sizer",t);if(i.length===0)return;let n=t.closest(".file_list--responsive");if(!n)return;n.style.visibility="hidden";let s=In,c=kn;for(;s{Ae=0,yt()}))}var A=null,V=null;function Mt(){V=null}function zn(){if(V)return V;let e=M(".file_list_container").filter(t=>t.style.display!=="none");return e.length?(V=M("tbody tr.t-file",e[0]).filter(t=>t.style.display!=="none"),V):[]}function ue(e){A&&A.classList.remove("keyboard-focus"),A=e,A&&(A.classList.add("keyboard-focus"),A.scrollIntoView({block:"nearest"}))}function Et(e){let t=zn();if(!t.length)return;if(!A||t.indexOf(A)===-1){ue(e===1?t[0]:t[t.length-1]);return}let i=t.indexOf(A)+e;i>=0&&ir.offsetTop>n)||t[0];N.scrollTop=c.offsetTop-N.clientHeight/3}else{let s=null;for(let r=t.length-1;r>=0;r--)if(t[r].offsetTops.classList.remove("active")),i.parentElement.classList.add("active"),M(".file_list_container").forEach(s=>s.style.display="none");let n=document.getElementById(e);n&&(n.style.display="")}}let t=document.getElementById("wrapper");t&&!t.classList.contains("hide")&&Re()}function mt(){let e=window.location.hash.substring(1);if(!e){let t=document.querySelector(".group_tabs a");t&&_t(t.getAttribute("href").replace("#",""));return}if(e.charAt(0)==="_")_t(e.substring(1));else{let t=e.split("-L");if(!document.querySelector(".group_tabs li.active")){let i=document.querySelector(".group_tabs li");i&&i.classList.add("active")}Xn(t[0],t[1])}}function Ne(){let e=document.querySelector(".group_tabs li.active a");e&&(window.location.hash=e.getAttribute("href").replace("#","#_"))}function Zn(){let e=document.getElementById("dark-mode-toggle");if(!e)return;let t=document.documentElement;function i(){return t.classList.contains("dark-mode")||!t.classList.contains("light-mode")&&window.matchMedia("(prefers-color-scheme: dark)").matches}function n(){e.textContent=i()?"\u2600\uFE0F Light":"\u{1F319} Dark"}n(),e.addEventListener("click",()=>{let s=i();t.classList.toggle("light-mode",s),t.classList.toggle("dark-mode",!s),localStorage.setItem("simplecov-dark-mode",s?"light":"dark"),n()}),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",()=>{localStorage.getItem("simplecov-dark-mode")||n()})}document.addEventListener("DOMContentLoaded",function(){M("abbr.timeago").forEach(n=>{let s=new Date(n.getAttribute("title")||"");isNaN(s.getTime())||(n.textContent=Dn(s))}),Zn();function e(n,s){let c=0;for(let r of M("thead tr:first-child th",n)){let o=parseInt(r.getAttribute("colspan")||"1",10);if(r===s)return c+o-1;c+=o}return c}M("table.file_list").forEach(n=>{M("thead tr:first-child th",n).forEach(s=>{s.classList.add("sorting"),s.style.cursor="pointer",s.addEventListener("click",()=>Fn(n,e(n,s)))})}),M(".col-filter__value").forEach(n=>Le(n)),M(".col-filter--name, .col-filter__op, .col-filter__value, .col-filter__coverage").forEach(n=>{n.addEventListener("click",s=>s.stopPropagation())}),q(document,"input",".col-filter--name, .col-filter__op, .col-filter__value",function(){this.classList.contains("col-filter__value")&&Le(this),ht(this.closest(".file_list_container"))}),q(document,"change",".col-filter__op, .col-filter__value",function(){this.classList.contains("col-filter__value")&&Le(this),ht(this.closest(".file_list_container"))}),document.addEventListener("keydown",n=>{let s=n.target.matches("input, select, textarea");if(n.key==="/"&&!s){n.preventDefault();let c=M(".file_list_container").filter(o=>o.style.display!=="none"),r=c.length?C(".col-filter--name",c[0]):null;r&&r.focus();return}if(n.key==="Escape"){I.open?(n.preventDefault(),Ne()):s?n.target.blur():A&&ue(null);return}if(!s){if(I.open){n.key==="n"&&!n.shiftKey&&(n.preventDefault(),bt(1)),(n.key==="N"||n.key==="n"&&n.shiftKey||n.key==="p")&&(n.preventDefault(),bt(-1));return}n.key==="j"&&(n.preventDefault(),Et(1)),n.key==="k"&&(n.preventDefault(),Et(-1)),n.key==="Enter"&&A&&(n.preventDefault(),Gn())}}),I=document.getElementById("source-dialog"),N=document.getElementById("source-dialog-body"),Oe=document.getElementById("source-dialog-title"),I.querySelector(".source-dialog__close").addEventListener("click",Ne),I.addEventListener("click",n=>{n.target===I&&Ne()}),q(document,"click",".t-missed-method-toggle",function(n){n.preventDefault();let s=this.closest(".header")||this.closest(".source-dialog__title")||this.closest(".source-dialog__header"),c=s?s.querySelector(".t-missed-method-list"):null;c&&(c.style.display=c.style.display==="none"?"":"none")}),q(document,"click","a.src_link",function(n){n.preventDefault(),window.location.hash=this.getAttribute("href").substring(1)}),q(document,"click","table.file_list tbody tr",function(n){if(n.target.closest("a"))return;let s=this.querySelector("a.src_link");s&&(window.location.hash=s.getAttribute("href").substring(1))}),q(document,"click",".source-dialog .source_table li[data-linenumber]",function(n){n.preventDefault(),N.scrollTop=this.offsetTop;let s=this.dataset.linenumber,c=window.location.hash.substring(1).replace(/-L.*/,"");window.location.replace(window.location.href.replace(/#.*/,"#"+c+"-L"+s))}),window.addEventListener("hashchange",mt),document.querySelector(".source_files").setAttribute("style","display:none"),M(".file_list_container").forEach(n=>n.style.display="none"),M(".file_list_container").forEach(n=>{let s=n.id,c=n.querySelector(".group_name"),r=n.querySelector(".covered_percent"),o=document.createElement("li");o.setAttribute("role","tab");let u=document.createElement("a");u.href="#"+s,u.className=s,u.innerHTML=(c?c.innerHTML:"")+" ("+(r?r.innerHTML:"")+")",o.appendChild(u),document.querySelector(".group_tabs").appendChild(o)}),q(document.querySelector(".group_tabs"),"click","a",function(n){n.preventDefault(),window.location.hash=this.getAttribute("href").replace("#","#_")}),window.addEventListener("resize",Re),mt(),clearInterval(window._simplecovLoadingTimer),clearTimeout(window._simplecovShowTimeout);let t=document.getElementById("loading");t&&(t.style.transition="opacity 0.3s",t.style.opacity="0",setTimeout(()=>{t.style.display="none"},300));let i=document.getElementById("wrapper");i&&i.classList.remove("hide"),yt()});})(); +"use strict";(()=>{var zt=Object.create;var Ze=Object.defineProperty;var Kt=Object.getOwnPropertyDescriptor;var Xt=Object.getOwnPropertyNames;var Zt=Object.getPrototypeOf,Vt=Object.prototype.hasOwnProperty;var Yt=(e,t)=>()=>(t||e((t={exports:{}}).exports,t),t.exports);var Qt=(e,t,n,r)=>{if(t&&typeof t=="object"||typeof t=="function")for(let c of Xt(t))!Vt.call(e,c)&&c!==n&&Ze(e,c,{get:()=>t[c],enumerable:!(r=Kt(t,c))||r.enumerable});return e};var Jt=(e,t,n)=>(n=e!=null?zt(Zt(e)):{},Qt(t||!e||!e.__esModule?Ze(n,"default",{value:e,enumerable:!0}):n,e));var pt=Yt((_s,ht)=>{function nt(e){return e instanceof Map?e.clear=e.delete=e.set=function(){throw new Error("map is read-only")}:e instanceof Set&&(e.add=e.clear=e.delete=function(){throw new Error("set is read-only")}),Object.freeze(e),Object.getOwnPropertyNames(e).forEach(t=>{let n=e[t],r=typeof n;(r==="object"||r==="function")&&!Object.isFrozen(n)&&nt(n)}),e}var _e=class{constructor(t){t.data===void 0&&(t.data={}),this.data=t.data,this.isMatchIgnored=!1}ignoreMatch(){this.isMatchIgnored=!0}};function st(e){return e.replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'")}function V(e,...t){let n=Object.create(null);for(let r in e)n[r]=e[r];return t.forEach(function(r){for(let c in r)n[c]=r[c]}),n}var en="
    ",Ve=e=>!!e.scope,tn=(e,{prefix:t})=>{if(e.startsWith("language:"))return e.replace("language:","language-");if(e.includes(".")){let n=e.split(".");return[`${t}${n.shift()}`,...n.map((r,c)=>`${r}${"_".repeat(c+1)}`)].join(" ")}return`${t}${e}`},xe=class{constructor(t,n){this.buffer="",this.classPrefix=n.classPrefix,t.walk(this)}addText(t){this.buffer+=st(t)}openNode(t){if(!Ve(t))return;let n=tn(t.scope,{prefix:this.classPrefix});this.span(n)}closeNode(t){Ve(t)&&(this.buffer+=en)}value(){return this.buffer}span(t){this.buffer+=``}},Ye=(e={})=>{let t={children:[]};return Object.assign(t,e),t},Ae=class e{constructor(){this.rootNode=Ye(),this.stack=[this.rootNode]}get top(){return this.stack[this.stack.length-1]}get root(){return this.rootNode}add(t){this.top.children.push(t)}openNode(t){let n=Ye({scope:t});this.add(n),this.stack.push(n)}closeNode(){if(this.stack.length>1)return this.stack.pop()}closeAllNodes(){for(;this.closeNode(););}toJSON(){return JSON.stringify(this.rootNode,null,4)}walk(t){return this.constructor._walk(t,this.rootNode)}static _walk(t,n){return typeof n=="string"?t.addText(n):n.children&&(t.openNode(n),n.children.forEach(r=>this._walk(t,r)),t.closeNode(n)),t}static _collapse(t){typeof t!="string"&&t.children&&(t.children.every(n=>typeof n=="string")?t.children=[t.children.join("")]:t.children.forEach(n=>{e._collapse(n)}))}},Ne=class extends Ae{constructor(t){super(),this.options=t}addText(t){t!==""&&this.add(t)}startScope(t){this.openNode(t)}endScope(){this.closeNode()}__addSublanguage(t,n){let r=t.root;n&&(r.scope=`language:${n}`),this.add(r)}toHTML(){return new xe(this,this.options).value()}finalize(){return this.closeAllNodes(),!0}};function le(e){return e?typeof e=="string"?e:e.source:null}function it(e){return te("(?=",e,")")}function nn(e){return te("(?:",e,")*")}function sn(e){return te("(?:",e,")?")}function te(...e){return e.map(n=>le(n)).join("")}function rn(e){let t=e[e.length-1];return typeof t=="object"&&t.constructor===Object?(e.splice(e.length-1,1),t):{}}function Oe(...e){return"("+(rn(e).capture?"":"?:")+e.map(r=>le(r)).join("|")+")"}function rt(e){return new RegExp(e.toString()+"|").exec("").length-1}function on(e,t){let n=e&&e.exec(t);return n&&n.index===0}var cn=/\[(?:[^\\\]]|\\.)*\]|\(\??|\\([1-9][0-9]*)|\\./;function Ce(e,{joinWith:t}){let n=0;return e.map(r=>{n+=1;let c=n,i=le(r),s="";for(;i.length>0;){let o=cn.exec(i);if(!o){s+=i;break}s+=i.substring(0,o.index),i=i.substring(o.index+o[0].length),o[0][0]==="\\"&&o[1]?s+="\\"+String(Number(o[1])+c):(s+=o[0],o[0]==="("&&n++)}return s}).map(r=>`(${r})`).join(t)}var ln=/\b\B/,ot="[a-zA-Z]\\w*",Ie="[a-zA-Z_]\\w*",ct="\\b\\d+(\\.\\d+)?",lt="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",at="\\b(0b[01]+)",an="!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~",un=(e={})=>{let t=/^#![ ]*\//;return e.binary&&(e.begin=te(t,/.*\b/,e.binary,/\b.*/)),V({scope:"meta",begin:t,end:/$/,relevance:0,"on:begin":(n,r)=>{n.index!==0&&r.ignoreMatch()}},e)},ae={begin:"\\\\[\\s\\S]",relevance:0},dn={scope:"string",begin:"'",end:"'",illegal:"\\n",contains:[ae]},fn={scope:"string",begin:'"',end:'"',illegal:"\\n",contains:[ae]},gn={begin:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|they|like|more)\b/},ve=function(e,t,n={}){let r=V({scope:"comment",begin:e,end:t,contains:[]},n);r.contains.push({scope:"doctag",begin:"[ ]*(?=(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):)",end:/(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):/,excludeBegin:!0,relevance:0});let c=Oe("I","a","is","so","us","to","at","if","in","it","on",/[A-Za-z]+['](d|ve|re|ll|t|s|n)/,/[A-Za-z]+[-][a-z]+/,/[A-Za-z][a-z]{2,}/);return r.contains.push({begin:te(/[ ]+/,"(",c,/[.]?[:]?([.][ ]|[ ])/,"){3}")}),r},hn=ve("//","$"),pn=ve("/\\*","\\*/"),bn=ve("#","$"),mn={scope:"number",begin:ct,relevance:0},_n={scope:"number",begin:lt,relevance:0},En={scope:"number",begin:at,relevance:0},vn={scope:"regexp",begin:/\/(?=[^/\n]*\/)/,end:/\/[gimuy]*/,contains:[ae,{begin:/\[/,end:/\]/,relevance:0,contains:[ae]}]},yn={scope:"title",begin:ot,relevance:0},Mn={scope:"title",begin:Ie,relevance:0},wn={begin:"\\.\\s*"+Ie,relevance:0},Tn=function(e){return Object.assign(e,{"on:begin":(t,n)=>{n.data._beginMatch=t[1]},"on:end":(t,n)=>{n.data._beginMatch!==t[1]&&n.ignoreMatch()}})},me=Object.freeze({__proto__:null,APOS_STRING_MODE:dn,BACKSLASH_ESCAPE:ae,BINARY_NUMBER_MODE:En,BINARY_NUMBER_RE:at,COMMENT:ve,C_BLOCK_COMMENT_MODE:pn,C_LINE_COMMENT_MODE:hn,C_NUMBER_MODE:_n,C_NUMBER_RE:lt,END_SAME_AS_BEGIN:Tn,HASH_COMMENT_MODE:bn,IDENT_RE:ot,MATCH_NOTHING_RE:ln,METHOD_GUARD:wn,NUMBER_MODE:mn,NUMBER_RE:ct,PHRASAL_WORDS_MODE:gn,QUOTE_STRING_MODE:fn,REGEXP_MODE:vn,RE_STARTERS_RE:an,SHEBANG:un,TITLE_MODE:yn,UNDERSCORE_IDENT_RE:Ie,UNDERSCORE_TITLE_MODE:Mn});function Sn(e,t){e.input[e.index-1]==="."&&t.ignoreMatch()}function Ln(e,t){e.className!==void 0&&(e.scope=e.className,delete e.className)}function xn(e,t){t&&e.beginKeywords&&(e.begin="\\b("+e.beginKeywords.split(" ").join("|")+")(?!\\.)(?=\\b|\\s)",e.__beforeBegin=Sn,e.keywords=e.keywords||e.beginKeywords,delete e.beginKeywords,e.relevance===void 0&&(e.relevance=0))}function An(e,t){Array.isArray(e.illegal)&&(e.illegal=Oe(...e.illegal))}function Nn(e,t){if(e.match){if(e.begin||e.end)throw new Error("begin & end are not supported with match");e.begin=e.match,delete e.match}}function Rn(e,t){e.relevance===void 0&&(e.relevance=1)}var On=(e,t)=>{if(!e.beforeMatch)return;if(e.starts)throw new Error("beforeMatch cannot be used with starts");let n=Object.assign({},e);Object.keys(e).forEach(r=>{delete e[r]}),e.keywords=n.keywords,e.begin=te(n.beforeMatch,it(n.begin)),e.starts={relevance:0,contains:[Object.assign(n,{endsParent:!0})]},e.relevance=0,delete n.beforeMatch},Cn=["of","and","for","in","not","or","if","then","parent","list","value"],In="keyword";function ut(e,t,n=In){let r=Object.create(null);return typeof e=="string"?c(n,e.split(" ")):Array.isArray(e)?c(n,e):Object.keys(e).forEach(function(i){Object.assign(r,ut(e[i],t,i))}),r;function c(i,s){t&&(s=s.map(o=>o.toLowerCase())),s.forEach(function(o){let a=o.split("|");r[a[0]]=[i,kn(a[0],a[1])]})}}function kn(e,t){return t?Number(t):Hn(e)?0:1}function Hn(e){return Cn.includes(e.toLowerCase())}var Qe={},ee=e=>{console.error(e)},Je=(e,...t)=>{console.log(`WARN: ${e}`,...t)},ie=(e,t)=>{Qe[`${e}/${t}`]||(console.log(`Deprecated as of ${e}. ${t}`),Qe[`${e}/${t}`]=!0)},Ee=new Error;function dt(e,t,{key:n}){let r=0,c=e[n],i={},s={};for(let o=1;o<=t.length;o++)s[o+r]=c[o],i[o+r]=!0,r+=rt(t[o-1]);e[n]=s,e[n]._emit=i,e[n]._multi=!0}function Dn(e){if(Array.isArray(e.begin)){if(e.skip||e.excludeBegin||e.returnBegin)throw ee("skip, excludeBegin, returnBegin not compatible with beginScope: {}"),Ee;if(typeof e.beginScope!="object"||e.beginScope===null)throw ee("beginScope must be object"),Ee;dt(e,e.begin,{key:"beginScope"}),e.begin=Ce(e.begin,{joinWith:""})}}function Bn(e){if(Array.isArray(e.end)){if(e.skip||e.excludeEnd||e.returnEnd)throw ee("skip, excludeEnd, returnEnd not compatible with endScope: {}"),Ee;if(typeof e.endScope!="object"||e.endScope===null)throw ee("endScope must be object"),Ee;dt(e,e.end,{key:"endScope"}),e.end=Ce(e.end,{joinWith:""})}}function $n(e){e.scope&&typeof e.scope=="object"&&e.scope!==null&&(e.beginScope=e.scope,delete e.scope)}function Fn(e){$n(e),typeof e.beginScope=="string"&&(e.beginScope={_wrap:e.beginScope}),typeof e.endScope=="string"&&(e.endScope={_wrap:e.endScope}),Dn(e),Bn(e)}function Pn(e){function t(s,o){return new RegExp(le(s),"m"+(e.case_insensitive?"i":"")+(e.unicodeRegex?"u":"")+(o?"g":""))}class n{constructor(){this.matchIndexes={},this.regexes=[],this.matchAt=1,this.position=0}addRule(o,a){a.position=this.position++,this.matchIndexes[this.matchAt]=a,this.regexes.push([a,o]),this.matchAt+=rt(o)+1}compile(){this.regexes.length===0&&(this.exec=()=>null);let o=this.regexes.map(a=>a[1]);this.matcherRe=t(Ce(o,{joinWith:"|"}),!0),this.lastIndex=0}exec(o){this.matcherRe.lastIndex=this.lastIndex;let a=this.matcherRe.exec(o);if(!a)return null;let v=a.findIndex((S,l)=>l>0&&S!==void 0),m=this.matchIndexes[v];return a.splice(0,v),Object.assign(a,m)}}class r{constructor(){this.rules=[],this.multiRegexes=[],this.count=0,this.lastIndex=0,this.regexIndex=0}getMatcher(o){if(this.multiRegexes[o])return this.multiRegexes[o];let a=new n;return this.rules.slice(o).forEach(([v,m])=>a.addRule(v,m)),a.compile(),this.multiRegexes[o]=a,a}resumingScanAtSamePosition(){return this.regexIndex!==0}considerAll(){this.regexIndex=0}addRule(o,a){this.rules.push([o,a]),a.type==="begin"&&this.count++}exec(o){let a=this.getMatcher(this.regexIndex);a.lastIndex=this.lastIndex;let v=a.exec(o);if(this.resumingScanAtSamePosition()&&!(v&&v.index===this.lastIndex)){let m=this.getMatcher(0);m.lastIndex=this.lastIndex+1,v=m.exec(o)}return v&&(this.regexIndex+=v.position+1,this.regexIndex===this.count&&this.considerAll()),v}}function c(s){let o=new r;return s.contains.forEach(a=>o.addRule(a.begin,{rule:a,type:"begin"})),s.terminatorEnd&&o.addRule(s.terminatorEnd,{type:"end"}),s.illegal&&o.addRule(s.illegal,{type:"illegal"}),o}function i(s,o){let a=s;if(s.isCompiled)return a;[Ln,Nn,Fn,On].forEach(m=>m(s,o)),e.compilerExtensions.forEach(m=>m(s,o)),s.__beforeBegin=null,[xn,An,Rn].forEach(m=>m(s,o)),s.isCompiled=!0;let v=null;return typeof s.keywords=="object"&&s.keywords.$pattern&&(s.keywords=Object.assign({},s.keywords),v=s.keywords.$pattern,delete s.keywords.$pattern),v=v||/\w+/,s.keywords&&(s.keywords=ut(s.keywords,e.case_insensitive)),a.keywordPatternRe=t(v,!0),o&&(s.begin||(s.begin=/\B|\b/),a.beginRe=t(a.begin),!s.end&&!s.endsWithParent&&(s.end=/\B|\b/),s.end&&(a.endRe=t(a.end)),a.terminatorEnd=le(a.end)||"",s.endsWithParent&&o.terminatorEnd&&(a.terminatorEnd+=(s.end?"|":"")+o.terminatorEnd)),s.illegal&&(a.illegalRe=t(s.illegal)),s.contains||(s.contains=[]),s.contains=[].concat(...s.contains.map(function(m){return Un(m==="self"?s:m)})),s.contains.forEach(function(m){i(m,a)}),s.starts&&i(s.starts,o),a.matcher=c(a),a}if(e.compilerExtensions||(e.compilerExtensions=[]),e.contains&&e.contains.includes("self"))throw new Error("ERR: contains `self` is not supported at the top-level of a language. See documentation.");return e.classNameAliases=V(e.classNameAliases||{}),i(e)}function ft(e){return e?e.endsWithParent||ft(e.starts):!1}function Un(e){return e.variants&&!e.cachedVariants&&(e.cachedVariants=e.variants.map(function(t){return V(e,{variants:null},t)})),e.cachedVariants?e.cachedVariants:ft(e)?V(e,{starts:e.starts?V(e.starts):null}):Object.isFrozen(e)?V(e):e}var Wn="11.11.1",Re=class extends Error{constructor(t,n){super(t),this.name="HTMLInjectionError",this.html=n}},Le=st,et=V,tt=Symbol("nomatch"),qn=7,gt=function(e){let t=Object.create(null),n=Object.create(null),r=[],c=!0,i="Could not find the language '{}', did you forget to load/include a language module?",s={disableAutodetect:!0,name:"Plain text",contains:[]},o={ignoreUnescapedHTML:!1,throwUnescapedHTML:!1,noHighlightRe:/^(no-?highlight)$/i,languageDetectRe:/\blang(?:uage)?-([\w-]+)\b/i,classPrefix:"hljs-",cssSelector:"pre code",languages:null,__emitter:Ne};function a(g){return o.noHighlightRe.test(g)}function v(g){let y=g.className+" ";y+=g.parentNode?g.parentNode.className:"";let T=o.languageDetectRe.exec(y);if(T){let R=C(T[1]);return R||(Je(i.replace("{}",T[1])),Je("Falling back to no-highlight mode for this block.",g)),R?T[1]:"no-highlight"}return y.split(/\s+/).find(R=>a(R)||C(R))}function m(g,y,T){let R="",H="";typeof y=="object"?(R=g,T=y.ignoreIllegals,H=y.language):(ie("10.7.0","highlight(lang, code, ...args) has been deprecated."),ie("10.7.0",`Please use highlight(code, options) instead. +https://github.com/highlightjs/highlight.js/issues/2277`),H=g,R=y),T===void 0&&(T=!0);let U={code:R,language:H};ge("before:highlight",U);let Z=U.result?U.result:S(U.language,U.code,T);return Z.code=U.code,ge("after:highlight",Z),Z}function S(g,y,T,R){let H=Object.create(null);function U(p,_){return p.keywords[_]}function Z(){if(!M.keywords){D.addText(O);return}let p=0;M.keywordPatternRe.lastIndex=0;let _=M.keywordPatternRe.exec(O),w="";for(;_;){w+=O.substring(p,_.index);let A=q.case_insensitive?_[0].toLowerCase():_[0],B=U(M,A);if(B){let[X,jt]=B;if(D.addText(w),w="",H[A]=(H[A]||0)+1,H[A]<=qn&&(be+=jt),X.startsWith("_"))w+=_[0];else{let Gt=q.classNameAliases[X]||X;W(_[0],Gt)}}else w+=_[0];p=M.keywordPatternRe.lastIndex,_=M.keywordPatternRe.exec(O)}w+=O.substring(p),D.addText(w)}function he(){if(O==="")return;let p=null;if(typeof M.subLanguage=="string"){if(!t[M.subLanguage]){D.addText(O);return}p=S(M.subLanguage,O,!0,Xe[M.subLanguage]),Xe[M.subLanguage]=p._top}else p=u(O,M.subLanguage.length?M.subLanguage:null);M.relevance>0&&(be+=p.relevance),D.__addSublanguage(p._emitter,p.language)}function $(){M.subLanguage!=null?he():Z(),O=""}function W(p,_){p!==""&&(D.startScope(_),D.addText(p),D.endScope())}function je(p,_){let w=1,A=_.length-1;for(;w<=A;){if(!p._emit[w]){w++;continue}let B=q.classNameAliases[p[w]]||p[w],X=_[w];B?W(X,B):(O=X,Z(),O=""),w++}}function Ge(p,_){return p.scope&&typeof p.scope=="string"&&D.openNode(q.classNameAliases[p.scope]||p.scope),p.beginScope&&(p.beginScope._wrap?(W(O,q.classNameAliases[p.beginScope._wrap]||p.beginScope._wrap),O=""):p.beginScope._multi&&(je(p.beginScope,_),O="")),M=Object.create(p,{parent:{value:M}}),M}function ze(p,_,w){let A=on(p.endRe,w);if(A){if(p["on:end"]){let B=new _e(p);p["on:end"](_,B),B.isMatchIgnored&&(A=!1)}if(A){for(;p.endsParent&&p.parent;)p=p.parent;return p}}if(p.endsWithParent)return ze(p.parent,_,w)}function Ft(p){return M.matcher.regexIndex===0?(O+=p[0],1):(Se=!0,0)}function Pt(p){let _=p[0],w=p.rule,A=new _e(w),B=[w.__beforeBegin,w["on:begin"]];for(let X of B)if(X&&(X(p,A),A.isMatchIgnored))return Ft(_);return w.skip?O+=_:(w.excludeBegin&&(O+=_),$(),!w.returnBegin&&!w.excludeBegin&&(O=_)),Ge(w,p),w.returnBegin?0:_.length}function Ut(p){let _=p[0],w=y.substring(p.index),A=ze(M,p,w);if(!A)return tt;let B=M;M.endScope&&M.endScope._wrap?($(),W(_,M.endScope._wrap)):M.endScope&&M.endScope._multi?($(),je(M.endScope,p)):B.skip?O+=_:(B.returnEnd||B.excludeEnd||(O+=_),$(),B.excludeEnd&&(O=_));do M.scope&&D.closeNode(),!M.skip&&!M.subLanguage&&(be+=M.relevance),M=M.parent;while(M!==A.parent);return A.starts&&Ge(A.starts,p),B.returnEnd?0:_.length}function Wt(){let p=[];for(let _=M;_!==q;_=_.parent)_.scope&&p.unshift(_.scope);p.forEach(_=>D.openNode(_))}let pe={};function Ke(p,_){let w=_&&_[0];if(O+=p,w==null)return $(),0;if(pe.type==="begin"&&_.type==="end"&&pe.index===_.index&&w===""){if(O+=y.slice(_.index,_.index+1),!c){let A=new Error(`0 width match regex (${g})`);throw A.languageName=g,A.badRule=pe.rule,A}return 1}if(pe=_,_.type==="begin")return Pt(_);if(_.type==="illegal"&&!T){let A=new Error('Illegal lexeme "'+w+'" for mode "'+(M.scope||"")+'"');throw A.mode=M,A}else if(_.type==="end"){let A=Ut(_);if(A!==tt)return A}if(_.type==="illegal"&&w==="")return O+=` +`,1;if(Te>1e5&&Te>_.index*3)throw new Error("potential infinite loop, way more iterations than matches");return O+=w,w.length}let q=C(g);if(!q)throw ee(i.replace("{}",g)),new Error('Unknown language: "'+g+'"');let qt=Pn(q),we="",M=R||qt,Xe={},D=new o.__emitter(o);Wt();let O="",be=0,J=0,Te=0,Se=!1;try{if(q.__emitTokens)q.__emitTokens(y,D);else{for(M.matcher.considerAll();;){Te++,Se?Se=!1:M.matcher.considerAll(),M.matcher.lastIndex=J;let p=M.matcher.exec(y);if(!p)break;let _=y.substring(J,p.index),w=Ke(_,p);J=p.index+w}Ke(y.substring(J))}return D.finalize(),we=D.toHTML(),{language:g,value:we,relevance:be,illegal:!1,_emitter:D,_top:M}}catch(p){if(p.message&&p.message.includes("Illegal"))return{language:g,value:Le(y),illegal:!0,relevance:0,_illegalBy:{message:p.message,index:J,context:y.slice(J-100,J+100),mode:p.mode,resultSoFar:we},_emitter:D};if(c)return{language:g,value:Le(y),illegal:!1,relevance:0,errorRaised:p,_emitter:D,_top:M};throw p}}function l(g){let y={value:Le(g),illegal:!1,relevance:0,_top:s,_emitter:new o.__emitter(o)};return y._emitter.addText(g),y}function u(g,y){y=y||o.languages||Object.keys(t);let T=l(g),R=y.filter(C).filter(Q).map($=>S($,g,!1));R.unshift(T);let H=R.sort(($,W)=>{if($.relevance!==W.relevance)return W.relevance-$.relevance;if($.language&&W.language){if(C($.language).supersetOf===W.language)return 1;if(C(W.language).supersetOf===$.language)return-1}return 0}),[U,Z]=H,he=U;return he.secondBest=Z,he}function d(g,y,T){let R=y&&n[y]||T;g.classList.add("hljs"),g.classList.add(`language-${R}`)}function f(g){let y=null,T=v(g);if(a(T))return;if(ge("before:highlightElement",{el:g,language:T}),g.dataset.highlighted){console.log("Element previously highlighted. To highlight again, first unset `dataset.highlighted`.",g);return}if(g.children.length>0&&(o.ignoreUnescapedHTML||(console.warn("One of your code blocks includes unescaped HTML. This is a potentially serious security risk."),console.warn("https://github.com/highlightjs/highlight.js/wiki/security"),console.warn("The element with unescaped HTML:"),console.warn(g)),o.throwUnescapedHTML))throw new Re("One of your code blocks includes unescaped HTML.",g.innerHTML);y=g;let R=y.textContent,H=T?m(R,{language:T,ignoreIllegals:!0}):u(R);g.innerHTML=H.value,g.dataset.highlighted="yes",d(g,T,H.language),g.result={language:H.language,re:H.relevance,relevance:H.relevance},H.secondBest&&(g.secondBest={language:H.secondBest.language,relevance:H.secondBest.relevance}),ge("after:highlightElement",{el:g,result:H,text:R})}function x(g){o=et(o,g)}let K=()=>{L(),ie("10.6.0","initHighlighting() deprecated. Use highlightAll() now.")};function E(){L(),ie("10.6.0","initHighlightingOnLoad() deprecated. Use highlightAll() now.")}let b=!1;function L(){function g(){L()}if(document.readyState==="loading"){b||window.addEventListener("DOMContentLoaded",g,!1),b=!0;return}document.querySelectorAll(o.cssSelector).forEach(f)}function k(g,y){let T=null;try{T=y(e)}catch(R){if(ee("Language definition for '{}' could not be registered.".replace("{}",g)),c)ee(R);else throw R;T=s}T.name||(T.name=g),t[g]=T,T.rawDefinition=y.bind(null,e),T.aliases&&se(T.aliases,{languageName:g})}function N(g){delete t[g];for(let y of Object.keys(n))n[y]===g&&delete n[y]}function h(){return Object.keys(t)}function C(g){return g=(g||"").toLowerCase(),t[g]||t[n[g]]}function se(g,{languageName:y}){typeof g=="string"&&(g=[g]),g.forEach(T=>{n[T.toLowerCase()]=y})}function Q(g){let y=C(g);return y&&!y.disableAutodetect}function Ht(g){g["before:highlightBlock"]&&!g["before:highlightElement"]&&(g["before:highlightElement"]=y=>{g["before:highlightBlock"](Object.assign({block:y.el},y))}),g["after:highlightBlock"]&&!g["after:highlightElement"]&&(g["after:highlightElement"]=y=>{g["after:highlightBlock"](Object.assign({block:y.el},y))})}function Dt(g){Ht(g),r.push(g)}function Bt(g){let y=r.indexOf(g);y!==-1&&r.splice(y,1)}function ge(g,y){let T=g;r.forEach(function(R){R[T]&&R[T](y)})}function $t(g){return ie("10.7.0","highlightBlock will be removed entirely in v12.0"),ie("10.7.0","Please use highlightElement now."),f(g)}Object.assign(e,{highlight:m,highlightAuto:u,highlightAll:L,highlightElement:f,highlightBlock:$t,configure:x,initHighlighting:K,initHighlightingOnLoad:E,registerLanguage:k,unregisterLanguage:N,listLanguages:h,getLanguage:C,registerAliases:se,autoDetection:Q,inherit:et,addPlugin:Dt,removePlugin:Bt}),e.debugMode=function(){c=!1},e.safeMode=function(){c=!0},e.versionString=Wn,e.regex={concat:te,lookahead:it,either:Oe,optional:sn,anyNumberOfTimes:nn};for(let g in me)typeof me[g]=="object"&&nt(me[g]);return Object.assign(e,me),e},re=gt({});re.newInstance=()=>gt({});ht.exports=re;re.HighlightJS=re;re.default=re});var bt=Jt(pt(),1);var ke=bt.default;function mt(e){let t=e.regex,n="([a-zA-Z_]\\w*[!?=]?|[-+~]@|<<|>>|=~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~`|]|\\[\\]=?)",r=t.either(/\b([A-Z]+[a-z0-9]+)+/,/\b([A-Z]+[a-z0-9]+)+[A-Z]+/),c=t.concat(r,/(::\w+)*/),s={"variable.constant":["__FILE__","__LINE__","__ENCODING__"],"variable.language":["self","super"],keyword:["alias","and","begin","BEGIN","break","case","class","defined","do","else","elsif","end","END","ensure","for","if","in","module","next","not","or","redo","require","rescue","retry","return","then","undef","unless","until","when","while","yield",...["include","extend","prepend","public","private","protected","raise","throw"]],built_in:["proc","lambda","attr_accessor","attr_reader","attr_writer","define_method","private_constant","module_function"],literal:["true","false","nil"]},o={className:"doctag",begin:"@[A-Za-z]+"},a={begin:"#<",end:">"},v=[e.COMMENT("#","$",{contains:[o]}),e.COMMENT("^=begin","^=end",{contains:[o],relevance:10}),e.COMMENT("^__END__",e.MATCH_NOTHING_RE)],m={className:"subst",begin:/#\{/,end:/\}/,keywords:s},S={className:"string",contains:[e.BACKSLASH_ESCAPE,m],variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/`/,end:/`/},{begin:/%[qQwWx]?\(/,end:/\)/},{begin:/%[qQwWx]?\[/,end:/\]/},{begin:/%[qQwWx]?\{/,end:/\}/},{begin:/%[qQwWx]?/},{begin:/%[qQwWx]?\//,end:/\//},{begin:/%[qQwWx]?%/,end:/%/},{begin:/%[qQwWx]?-/,end:/-/},{begin:/%[qQwWx]?\|/,end:/\|/},{begin:/\B\?(\\\d{1,3})/},{begin:/\B\?(\\x[A-Fa-f0-9]{1,2})/},{begin:/\B\?(\\u\{?[A-Fa-f0-9]{1,6}\}?)/},{begin:/\B\?(\\M-\\C-|\\M-\\c|\\c\\M-|\\M-|\\C-\\M-)[\x20-\x7e]/},{begin:/\B\?\\(c|C-)[\x20-\x7e]/},{begin:/\B\?\\?\S/},{begin:t.concat(/<<[-~]?'?/,t.lookahead(/(\w+)(?=\W)[^\n]*\n(?:[^\n]*\n)*?\s*\1\b/)),contains:[e.END_SAME_AS_BEGIN({begin:/(\w+)/,end:/(\w+)/,contains:[e.BACKSLASH_ESCAPE,m]})]}]},l="[1-9](_?[0-9])*|0",u="[0-9](_?[0-9])*",d={className:"number",relevance:0,variants:[{begin:`\\b(${l})(\\.(${u}))?([eE][+-]?(${u})|r)?i?\\b`},{begin:"\\b0[dD][0-9](_?[0-9])*r?i?\\b"},{begin:"\\b0[bB][0-1](_?[0-1])*r?i?\\b"},{begin:"\\b0[oO][0-7](_?[0-7])*r?i?\\b"},{begin:"\\b0[xX][0-9a-fA-F](_?[0-9a-fA-F])*r?i?\\b"},{begin:"\\b0(_?[0-7])+r?i?\\b"}]},f={variants:[{match:/\(\)/},{className:"params",begin:/\(/,end:/(?=\))/,excludeBegin:!0,endsParent:!0,keywords:s}]},N=[S,{variants:[{match:[/class\s+/,c,/\s+<\s+/,c]},{match:[/\b(class|module)\s+/,c]}],scope:{2:"title.class",4:"title.class.inherited"},keywords:s},{match:[/(include|extend)\s+/,c],scope:{2:"title.class"},keywords:s},{relevance:0,match:[c,/\.new[. (]/],scope:{1:"title.class"}},{relevance:0,match:/\b[A-Z][A-Z_0-9]+\b/,className:"variable.constant"},{relevance:0,match:r,scope:"title.class"},{match:[/def/,/\s+/,n],scope:{1:"keyword",3:"title.function"},contains:[f]},{begin:e.IDENT_RE+"::"},{className:"symbol",begin:e.UNDERSCORE_IDENT_RE+"(!|\\?)?:",relevance:0},{className:"symbol",begin:":(?!\\s)",contains:[S,{begin:n}],relevance:0},d,{className:"variable",begin:"(\\$\\W)|((\\$|@@?)(\\w+))(?=[^@$?])(?![A-Za-z])(?![@$?'])"},{className:"params",begin:/\|(?!=)/,end:/\|/,excludeBegin:!0,excludeEnd:!0,relevance:0,keywords:s},{begin:"("+e.RE_STARTERS_RE+"|unless)\\s*",keywords:"unless",contains:[{className:"regexp",contains:[e.BACKSLASH_ESCAPE,m],illegal:/\n/,variants:[{begin:"/",end:"/[a-z]*"},{begin:/%r\{/,end:/\}[a-z]*/},{begin:"%r\\(",end:"\\)[a-z]*"},{begin:"%r!",end:"![a-z]*"},{begin:"%r\\[",end:"\\][a-z]*"}]}].concat(a,v),relevance:0}].concat(a,v);m.contains=N,f.contains=N;let Q=[{begin:/^\s*=>/,starts:{end:"$",contains:N}},{className:"meta.prompt",begin:"^("+"[>?]>"+"|"+"[\\w#]+\\(\\w+\\):\\d+:\\d+[>*]"+"|"+"(\\w+-)?\\d+\\.\\d+\\.\\d+(p\\d+)?[^\\d][^>]+>"+")(?=[ ])",starts:{end:"$",keywords:s,contains:N}}];return v.unshift(a),{name:"Ruby",aliases:["rb","gemspec","podspec","thor","irb"],keywords:s,illegal:/\/\*/,contains:[e.SHEBANG({binary:"ruby"})].concat(Q).concat(v).concat(N)}}ke.registerLanguage("ruby",mt);var jn=240,Gn=160,zn=90,Kn=75;function z(e,t){return(t||document).querySelector(e)}function I(e,t){return Array.from((t||document).querySelectorAll(e))}function ne(e,t,n,r){typeof n=="function"?e.addEventListener(t,n):e.addEventListener(t,function(c){let i=c.target.closest(n);i&&e.contains(i)&&r&&r.call(i,c)})}function Y(e){let t=document.createElement("div");return t.appendChild(document.createTextNode(e)),t.innerHTML}function Xn(e){return Zn(e)}function Zn(e){function t(E,b){let L=(E&65535)+(b&65535);return(E>>16)+(b>>16)+(L>>16)<<16|L&65535}function n(E,b){return E<>>32-b}function r(E,b,L,k,N,h){return t(n(t(t(b,E),t(k,h)),N),L)}function c(E,b,L,k,N,h,C){return r(b&L|~b&k,E,b,N,h,C)}function i(E,b,L,k,N,h,C){return r(b&k|L&~k,E,b,N,h,C)}function s(E,b,L,k,N,h,C){return r(b^L^k,E,b,N,h,C)}function o(E,b,L,k,N,h,C){return r(L^(b|~k),E,b,N,h,C)}let a=Array.from(new TextEncoder().encode(e)),v=a.length;for(a.push(128);a.length%64!==56;)a.push(0);let m=v*8;a.push(m&255,m>>8&255,m>>16&255,m>>24&255),a.push(0,0,0,0);let S=[];for(let E=0;E>b*8+4&15]+x[E>>b*8&15];return K}var At=[[31536e3,"year"],[2592e3,"month"],[86400,"day"],[3600,"hour"],[60,"minute"],[1,"second"]];function Vn(e){let t=Math.floor((Date.now()-e.getTime())/1e3);for(let[n,r]of At){let c=Math.floor(t/n);if(c>=1)return c===1?`about 1 ${r} ago`:`${c} ${r}s ago`}return"just now"}function Yn(e){let t=(Date.now()-e.getTime())/1e3;for(let[n]of At){let r=Math.floor(t/n);if(r>=1){let c=(r+1)*n;return Math.max((c-t)*1e3+500,1e3)}}return 1e3}function ce(e){return e>=zn?"green":e>=Kn?"yellow":"red"}function G(e){return e.toString().replace(/\B(?=(\d{3})+(?!\d))/g,",")}function fe(e){return(Math.floor(e*100)/100).toFixed(2)}function Nt(e,t){return e.replace(t,".").replace(/^\.\//,"")}function Ue(e){return Xn(e)}function Qn(e){return e.replace(/^[^a-zA-Z]+/,"").replace(/[^a-zA-Z0-9\-_]/g,"")}function Rt(e){let t=ce(e),n=fe(e);return`
    `}function oe(e,t,n,r,c){let i=ce(e),s=fe(e),o=`
    ${Rt(e)}${s}%
    `;if(c)return`${o}${G(t)}/${G(n)}`;let a=` data-order="${fe(e)}"`;return`${o}${G(t)}/${G(n)}`}function He(e,t,n,r){return` +
    + ${e} +
    + + +
    +
    + + ${n} + ${r}`}function De(e,t,n,r,c,i={}){if(!c)return`
    + ${t}: disabled +
    `;let s=r-n,o=r>0?n*100/r:100,a=ce(o),v=i.suffix||"covered",m=i.missedClass||"red",S=`
    + ${t}: ${fe(o)}% ${n}/${r} ${v}`;if(s>0){let l=i.toggle?`${s} missed`:`${s} missed`;S+=`, + ${l}`}return S+=` +
    `,S}function Jn(e,t,n,r,c,i,s,o,a){return'
    '+De("line","Line coverage",e,t,!0,{suffix:"relevant lines covered"})+De("branch","Branch coverage",n,r,s,{missedClass:"missed-branch-text"})+De("method","Method coverage",c,i,o,{missedClass:"missed-method-text-color",toggle:a})+"
    "}function es(e,t,n,r,c,i){let s=e+1;if(t==="ignored")return"skipped";if(c){let o=n[s];if(o&&o.some(([,a])=>a===0))return"missed-branch"}return i&&r.has(s)?"missed-method":t===null?"never":t===0?"missed":"covered"}function ts(e){let t={};if(!e)return t;for(let n of e)n.coverage!=="ignored"&&(t[n.report_line]||(t[n.report_line]=[]),t[n.report_line].push([n.type,n.coverage]));return t}function ns(e){let t=new Set;if(!e)return t;for(let n of e)if(n.coverage===0&&n.start_line&&n.end_line)for(let r=n.start_line;r<=n.end_line;r++)t.add(r);return t}function ss(e,t,n,r,c){let i=Ue(e),s=Nt(e,n),o=t.covered_lines,a=t.missed_lines,v=o+a,m=r&&t.covered_branches||0,S=r&&t.total_branches||0,l=c&&t.covered_methods||0,u=c&&t.total_methods||0,d=(t.methods||[]).filter(b=>b.coverage===0),f=c&&d.length>0,x=ts(t.branches),K=ns(t.methods),E=`
    `;if(E+='
    ',E+=`

    ${Y(s)}

    `,E+=Jn(o,v,m,S,l,u,r,c,f),f){E+='"}E+="
    ",E+="
      ";for(let b=0;b`,k==="covered"||L!==null&&L!=="ignored"&&L!==0?E+=``:L==="ignored"&&(E+=''),r){let C=x[N];if(C)for(let[se,Q]of C)E+=``}E+=`${Y(t.source[b])}`}return E+="
    ",E}function _t(e,t,n,r,c,i){let s=Qn(e),o=0,a=0,v=0,m=0,S=0,l=0;for(let E of t){let b=n[E];b&&(o+=b.covered_lines,a+=b.covered_lines+b.missed_lines,c&&(v+=b.covered_branches||0,m+=b.total_branches||0),i&&(S+=b.covered_methods||0,l+=b.total_methods||0))}let u=a>0?o*100/a:100,d=c&&m>0?v*100/m:100,f=i&&l>0?S*100/l:100,x=`
    `;x+=`${Y(e)}`,x+=`${fe(u)}%`,x+='
    ',x+='',x+=He("Line Coverage","line","Covered","Lines"),c&&(x+=He("Branch Coverage","branch","Covered","Branches")),i&&(x+=He("Method Coverage","method","Covered","Methods")),x+="";let K=t.length===1?"file":"files";x+=``,x+=oe(u,o,a,"line",!0),c&&(x+=oe(d,v,m,"branch",!0)),i&&(x+=oe(f,S,l,"method",!0)),x+="";for(let E of t){let b=n[E];if(!b)continue;let L=Nt(E,r),k=Ue(E),N=b.covered_lines,h=N+b.missed_lines,C=`data-covered-lines="${N}" data-relevant-lines="${h}"`;c&&(C+=` data-covered-branches="${b.covered_branches||0}" data-total-branches="${b.total_branches||0}"`),i&&(C+=` data-covered-methods="${b.covered_methods||0}" data-total-methods="${b.total_methods||0}"`),x+=``,x+=``,x+=oe(b.lines_covered_percent,N,h,"line",!1),c&&(x+=oe(b.branches_covered_percent||100,b.covered_branches||0,b.total_branches||0,"branch",!1)),i&&(x+=oe(b.methods_covered_percent||100,b.covered_methods||0,b.total_methods||0,"method",!1)),x+=""}return x+="
    File Name
    ${G(t.length)} ${K}
    ${Y(L)}
    ",x}function is(e){let t=e.meta,n=t.branch_coverage,r=t.method_coverage,c=t.root;document.title=`Code coverage for ${t.project_name}`;let i=Object.keys(e.coverage),s=e.total.lines.total>0?e.total.lines.percent:100,o=document.createElement("link");o.rel="icon",o.type="image/png",o.href=`favicon_${ce(s)}.png`,document.head.appendChild(o),n&&document.body.setAttribute("data-branch-coverage","true");let a=document.getElementById("content");a.innerHTML=_t("All Files",i,e.coverage,c,n,r);for(let d of Object.keys(e.groups)){let f=e.groups[d].files||[];a.innerHTML+=_t(d,f,e.coverage,c,n,r)}let v={};for(let d of i)v[Ue(d)]=d;window._simplecovIdMap=v,window._simplecovFiles=e.coverage,window._simplecovRoot=c,window._simplecovBranchCoverage=n,window._simplecovMethodCoverage=r;let m=new Date(t.timestamp),S=document.getElementById("footer");S.innerHTML=`Generated ${m.toISOString()} by simplecov v${Y(t.simplecov_version)} using ${Y(t.command_name)}`;let l=document.getElementById("source-legend"),u='CoveredSkippedMissed line';n&&(u+='Missed branch'),r&&(u+='Missed method'),l.innerHTML=u}var Et={};function vt(e,t){let n=0;for(let r=0;r{let m=yt(vt(a,t)),S=yt(vt(v,t)),l;return typeof m=="number"&&typeof S=="number"?l=m-S:l=String(m).localeCompare(String(S)),c==="asc"?l:-l}),i.append(...s);let o=0;I("thead tr:first-child th",e).forEach(a=>{let v=parseInt(a.getAttribute("colspan")||"1",10);a.classList.remove("sorting_asc","sorting_desc","sorting");let m=t>=o&&te>t,gte:(e,t)=>e>=t,eq:(e,t)=>e===t,lte:(e,t)=>e<=t,lt:(e,t)=>e!0))(t,n)}function ls(e){return I(".col-filter__value",e).map(t=>{let n=t;if(!n.value)return null;let r=parseFloat(n.value);if(isNaN(r))return null;let c=n.dataset.type||"",i=z(`.col-filter__op[data-type="${c}"]`,e),s=i?i.value:"";if(!s)return null;let o=Pe[c];return o?{attrs:o,op:s,threshold:r}:null}).filter(t=>t!==null)}function Mt(e){let t=z("table.file_list",e);if(!t)return;let n=z(".col-filter--name",e),r=n?n.value:"",c=ls(e);I("tbody tr.t-file",t).forEach(i=>{let s=i,o=!0;if(r&&((i.children[0].textContent||"").toLowerCase().includes(r.toLowerCase())||(o=!1)),o)for(let a of c){let v=parseInt(s.dataset[a.attrs.covered]||"0",10)||0,m=parseInt(s.dataset[a.attrs.total]||"0",10)||0,S=m>0?v*100/m:100;if(!cs(a.op,S,a.threshold)){o=!1;break}}s.style.display=o?"":"none"}),Ct(),as(e),We()}function Be(e){let t=parseFloat(e.value),n=e.closest(".col-filter__coverage"),r=n?n.querySelector(".col-filter__op"):null;if(!r)return;let c=r.querySelector('option[value="gt"]'),i=r.querySelector('option[value="lt"]');if(c&&(c.disabled=t>=100),i&&(i.disabled=t<=0),r.selectedOptions[0]&&r.selectedOptions[0].disabled){let s=r.querySelector("option:not(:disabled)");s&&(r.value=s.value)}}function as(e){let t=I("tbody tr.t-file",e).filter(i=>i.style.display!=="none");function n(i){let s=0;return t.forEach(o=>{s+=parseInt(o.dataset[i]||"0",10)||0}),s}let r=z(".t-file-count",e),c=parseInt(e.getAttribute("data-total-files")||"0",10);if(r){let i=t.length===1?" file":" files";r.textContent=t.length===c?G(c)+i:G(t.length)+"/"+G(c)+i}for(let i of Object.keys(Pe)){let s=Pe[i],o=`.t-totals__${i}`;z(o+"-pct",e)&&us(e,o,n(s.covered),n(s.total))}}function us(e,t,n,r){let c=z(t+"-pct",e),i=z(t+"-num",e),s=z(t+"-den",e);if(r===0){c&&(c.innerHTML="",c.className=c.className.replace(/green|yellow|red/g,"").trim()),i&&(i.textContent=""),s&&(s.textContent="");return}let o=n*100/r,a=ce(o);c&&(c.innerHTML=`
    ${Rt(o)}${o.toFixed(2)}%
    `,c.className=`${c.className.replace(/green|yellow|red/g,"").trim()} ${a}`),i&&(i.textContent=G(n)+"/"),s&&(s.textContent=G(r))}function ds(e){let t=document.getElementById(e);if(t)return t;let n=window._simplecovIdMap,r=window._simplecovFiles,c=window._simplecovRoot,i=window._simplecovBranchCoverage,s=window._simplecovMethodCoverage,o=n[e];if(!o)return null;let a=ss(o,r[o],c,i,s),v=document.querySelector(".source_files"),m=document.createElement("div");m.innerHTML=a;let S=m.firstElementChild;return v.appendChild(S),I("pre code",S).forEach(l=>{ke.highlightElement(l)}),S}function wt(e,t){let n=t+"px";e.forEach(r=>{let c=r.style;c.width=n,c.minWidth=n,c.maxWidth=n})}function Ot(){I(".file_list_container").forEach(e=>{if(e.style.display==="none"||e.offsetWidth===0)return;let t=z("table.file_list",e);if(!t)return;let n=I(".bar-sizer",t);if(n.length===0)return;let r=t.closest(".file_list--responsive");if(!r)return;r.style.visibility="hidden";let c=Gn,i=jn;for(;c{$e=0,Ot()}))}var F=null,ue=null;function Ct(){ue=null}function fs(){if(ue)return ue;let e=I(".file_list_container").filter(t=>t.style.display!=="none");return e.length?(ue=I("tbody tr.t-file",e[0]).filter(t=>t.style.display!=="none"),ue):[]}function Me(e){F&&F.classList.remove("keyboard-focus"),F=e,F&&(F.classList.add("keyboard-focus"),F.scrollIntoView({block:"nearest"}))}function Tt(e){let t=fs();if(!t.length)return;if(!F||t.indexOf(F)===-1){Me(e===1?t[0]:t[t.length-1]);return}let n=t.indexOf(F)+e;n>=0&&ns.offsetTop>r)||t[0];P.scrollTop=i.offsetTop-P.clientHeight/3}else{let c=null;for(let s=t.length-1;s>=0;s--)if(t[s].offsetTopc.classList.remove("active")),n.parentElement.classList.add("active"),I(".file_list_container").forEach(c=>c.style.display="none");let r=document.getElementById(e);r&&(r.style.display="")}}let t=document.getElementById("wrapper");t&&!t.classList.contains("hide")&&We()}function xt(){let e=window.location.hash.substring(1);if(!e){let t=document.querySelector(".group_tabs a");t&&Lt(t.getAttribute("href").replace("#",""));return}if(e.charAt(0)==="_")Lt(e.substring(1));else{let t=e.split("-L");if(!document.querySelector(".group_tabs li.active")){let n=document.querySelector(".group_tabs li");n&&n.classList.add("active")}ps(t[0],t[1])}}function Fe(){let e=document.querySelector(".group_tabs li.active a");e&&(window.location.hash=e.getAttribute("href").replace("#","#_"))}function bs(){let e=document.getElementById("dark-mode-toggle");if(!e)return;let t=document.documentElement;function n(){return t.classList.contains("dark-mode")||!t.classList.contains("light-mode")&&window.matchMedia("(prefers-color-scheme: dark)").matches}function r(){e.textContent=n()?"\u2600\uFE0F Light":"\u{1F319} Dark"}r(),e.addEventListener("click",()=>{let c=n();t.classList.toggle("light-mode",c),t.classList.toggle("dark-mode",!c),localStorage.setItem("simplecov-dark-mode",c?"light":"dark"),r()}),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",()=>{localStorage.getItem("simplecov-dark-mode")||r()})}function kt(){if(!window.SIMPLECOV_DATA){window.addEventListener("load",kt);return}let e=window.SIMPLECOV_DATA,t=document.getElementById("loading");t&&(t.style.display=""),is(e);function n(){let i=1/0;I("abbr.timeago").forEach(s=>{let o=new Date(s.getAttribute("title")||"");isNaN(o.getTime())||(s.textContent=Vn(o),i=Math.min(i,Yn(o)))}),i<1/0&&setTimeout(n,i)}n(),bs();function r(i,s){let o=0;for(let a of I("thead tr:first-child th",i)){let v=parseInt(a.getAttribute("colspan")||"1",10);if(a===s)return o+v-1;o+=v}return o}I("table.file_list").forEach(i=>{I("thead tr:first-child th",i).forEach(s=>{s.classList.add("sorting"),s.style.cursor="pointer",s.addEventListener("click",()=>rs(i,r(i,s)))})}),I(".col-filter__value").forEach(i=>Be(i)),I(".col-filter--name, .col-filter__op, .col-filter__value, .col-filter__coverage").forEach(i=>{i.addEventListener("click",s=>s.stopPropagation())}),ne(document,"input",".col-filter--name, .col-filter__op, .col-filter__value",function(){this.classList.contains("col-filter__value")&&Be(this),Mt(this.closest(".file_list_container"))}),ne(document,"change",".col-filter__op, .col-filter__value",function(){this.classList.contains("col-filter__value")&&Be(this),Mt(this.closest(".file_list_container"))}),document.addEventListener("keydown",i=>{let s=i.target.matches("input, select, textarea");if(i.key==="/"&&!s){i.preventDefault();let o=I(".file_list_container").filter(v=>v.style.display!=="none"),a=o.length?z(".col-filter--name",o[0]):null;a&&a.focus();return}if(i.key==="Escape"){j.open?(i.preventDefault(),Fe()):s?i.target.blur():F&&Me(null);return}if(!s){if(j.open){i.key==="n"&&!i.shiftKey&&(i.preventDefault(),St(1)),(i.key==="N"||i.key==="n"&&i.shiftKey||i.key==="p")&&(i.preventDefault(),St(-1));return}i.key==="j"&&(i.preventDefault(),Tt(1)),i.key==="k"&&(i.preventDefault(),Tt(-1)),i.key==="Enter"&&F&&(i.preventDefault(),gs())}}),j=document.getElementById("source-dialog"),P=document.getElementById("source-dialog-body"),qe=document.getElementById("source-dialog-title"),j.querySelector(".source-dialog__close").addEventListener("click",Fe),j.addEventListener("click",i=>{i.target===j&&Fe()}),ne(document,"click",".t-missed-method-toggle",function(i){i.preventDefault();let s=this.closest(".header")||this.closest(".source-dialog__title")||this.closest(".source-dialog__header"),o=s?s.querySelector(".t-missed-method-list"):null;o&&(o.style.display=o.style.display==="none"?"":"none")}),ne(document,"click","a.src_link",function(i){i.preventDefault(),window.location.hash=this.getAttribute("href").substring(1)}),ne(document,"click","table.file_list tbody tr",function(i){if(i.target.closest("a"))return;let s=this.querySelector("a.src_link");s&&(window.location.hash=s.getAttribute("href").substring(1))}),ne(document,"click",".source-dialog .source_table li[data-linenumber]",function(i){i.preventDefault(),P.scrollTop=this.offsetTop;let s=this.dataset.linenumber,o=window.location.hash.substring(1).replace(/-L.*/,"");window.location.replace(window.location.href.replace(/#.*/,"#"+o+"-L"+s))}),window.addEventListener("hashchange",xt),I(".file_list_container").forEach(i=>i.style.display="none"),I(".file_list_container").forEach(i=>{let s=i.id,o=i.querySelector(".group_name"),a=i.querySelector(".covered_percent"),v=document.createElement("li");v.setAttribute("role","tab");let m=document.createElement("a");m.href="#"+s,m.className=s,m.innerHTML=(o?o.innerHTML:"")+" ("+(a?a.innerHTML:"")+")",v.appendChild(m),document.querySelector(".group_tabs").appendChild(v)}),ne(document.querySelector(".group_tabs"),"click","a",function(i){i.preventDefault(),window.location.hash=this.getAttribute("href").replace("#","#_")}),window.addEventListener("resize",We),xt(),t&&(t.style.transition="opacity 0.3s",t.style.opacity="0",setTimeout(()=>{t.style.display="none"},300));let c=document.getElementById("wrapper");c&&c.classList.remove("hide"),Ot()}document.addEventListener("DOMContentLoaded",kt);})(); diff --git a/lib/simplecov/formatter/html_formatter/public/index.html b/lib/simplecov/formatter/html_formatter/public/index.html new file mode 100644 index 000000000..f3262ec22 --- /dev/null +++ b/lib/simplecov/formatter/html_formatter/public/index.html @@ -0,0 +1,55 @@ + + + + + + Code Coverage + + + + + + + + +
    +
    +
      + +
      + +
      + + + + +
      + + +
      +
      +
      + +
      +
      +
      + + + + diff --git a/lib/simplecov/formatter/html_formatter/view_helpers.rb b/lib/simplecov/formatter/html_formatter/view_helpers.rb deleted file mode 100644 index 25ec221db..000000000 --- a/lib/simplecov/formatter/html_formatter/view_helpers.rb +++ /dev/null @@ -1,77 +0,0 @@ -# frozen_string_literal: true - -require "digest/md5" -require "set" -require_relative "coverage_helpers" - -module SimpleCov - module Formatter - class HTMLFormatter - # Helper methods used by ERB templates for rendering coverage data. - module ViewHelpers - include CoverageHelpers - - def line_status?(source_file, line) - if branch_coverage? && source_file.line_with_missed_branch?(line.number) - "missed-branch" - elsif method_coverage? && missed_method_lines(source_file).include?(line.number) - "missed-method" - else - line.status - end - end - - def missed_method_lines(source_file) - @missed_method_lines ||= {} - @missed_method_lines[source_file.filename] ||= missed_method_line_set(source_file) - end - - def missed_method_line_set(source_file) - source_file.missed_methods - .select { |m| m.start_line && m.end_line } - .flat_map { |m| (m.start_line..m.end_line).to_a } - .to_set - end - - def coverage_css_class(covered_percent) - if covered_percent >= 90 - "green" - elsif covered_percent >= 75 - "yellow" - else - "red" - end - end - - def id(source_file) - Digest::MD5.hexdigest(source_file.filename) - end - - def timeago(time) - "#{time.iso8601}" - end - - def shortened_filename(source_file) - source_file.filename.sub(SimpleCov.root, ".").delete_prefix("./") - end - - def link_to_source_file(source_file) - name = shortened_filename(source_file) - %(#{name}) - end - - def covered_percent(percent) - template("covered_percent").result(binding) - end - - def to_id(value) - value.sub(/\A[^a-zA-Z]+/, "").gsub(/[^a-zA-Z0-9\-_]/, "") - end - - def fmt(number) - number.to_s.gsub(/(\d)(?=(\d{3})+(?!\d))/, '\\1,') - end - end - end - end -end diff --git a/lib/simplecov/formatter/html_formatter/views/coverage_summary.erb b/lib/simplecov/formatter/html_formatter/views/coverage_summary.erb deleted file mode 100644 index cea5fe7da..000000000 --- a/lib/simplecov/formatter/html_formatter/views/coverage_summary.erb +++ /dev/null @@ -1,5 +0,0 @@ -
      - <%= coverage_type_summary("line", "Line coverage", _summary, enabled: true, suffix: "relevant lines covered") %> - <%= coverage_type_summary("branch", "Branch coverage", _summary, enabled: branch_coverage?, missed_class: "missed-branch-text") %> - <%= coverage_type_summary("method", "Method coverage", _summary, enabled: method_coverage?, missed_class: "missed-method-text-color", toggle: _summary[:show_method_toggle]) %> -
      diff --git a/lib/simplecov/formatter/html_formatter/views/covered_percent.erb b/lib/simplecov/formatter/html_formatter/views/covered_percent.erb deleted file mode 100644 index fddaede2e..000000000 --- a/lib/simplecov/formatter/html_formatter/views/covered_percent.erb +++ /dev/null @@ -1 +0,0 @@ -<%= sprintf("%.2f", percent.floor(2)) %>% diff --git a/lib/simplecov/formatter/html_formatter/views/file_list.erb b/lib/simplecov/formatter/html_formatter/views/file_list.erb deleted file mode 100644 index e85b34b15..000000000 --- a/lib/simplecov/formatter/html_formatter/views/file_list.erb +++ /dev/null @@ -1,55 +0,0 @@ -
      - <%= title %> - <%= covered_percent(source_files.covered_percent) %> - -
      - - - - - <%= coverage_header_cells("Line Coverage", "line", "Covered", "Lines") %> -<%- if branch_coverage? -%> - <%= coverage_header_cells("Branch Coverage", "branch", "Covered", "Branches") %> -<%- end -%> -<%- if method_coverage? -%> - <%= coverage_header_cells("Method Coverage", "method", "Covered", "Methods") %> -<%- end -%> - -<%- line_pct = source_files.lines_of_code > 0 ? source_files.covered_percent : 100.0 -%> -<%- branch_pct = branch_coverage? && source_files.total_branches > 0 ? source_files.branch_covered_percent : 100.0 -%> -<%- method_pct = method_coverage? && source_files.total_methods > 0 ? source_files.method_covered_percent : 100.0 -%> - - - <%= coverage_cells(line_pct, source_files.covered_lines, source_files.lines_of_code, type: "line", totals: true) %> -<%- if branch_coverage? -%> - <%= coverage_cells(branch_pct, source_files.covered_branches, source_files.total_branches, type: "branch", totals: true) %> -<%- end -%> -<%- if method_coverage? -%> - <%= coverage_cells(method_pct, source_files.covered_methods, source_files.total_methods, type: "method", totals: true) %> -<%- end -%> - - - -<%- source_files.each do |source_file| -%> -<%- covered_lines = source_file.covered_lines.count - relevant_lines = covered_lines + source_file.missed_lines.count -%> - > - - <%= coverage_cells(source_file.covered_percent, covered_lines, relevant_lines, type: "line") %> -<%- if branch_coverage? -%> - <%= coverage_cells(source_file.branches_coverage_percent, source_file.covered_branches.count, source_file.total_branches.count, type: "branch") %> -<%- end -%> -<%- if method_coverage? -%> - <%= coverage_cells(source_file.methods_coverage_percent, source_file.covered_methods.count, source_file.methods.count, type: "method") %> -<%- end -%> - -<%- end -%> - -
      -
      - File Name - -
      -
      <%= fmt(source_files.length) %> <%= source_files.length == 1 ? "file" : "files" %>
      <%= link_to_source_file(source_file) %>
      -
      -
      diff --git a/lib/simplecov/formatter/html_formatter/views/layout.erb b/lib/simplecov/formatter/html_formatter/views/layout.erb deleted file mode 100644 index ab9e02f11..000000000 --- a/lib/simplecov/formatter/html_formatter/views/layout.erb +++ /dev/null @@ -1,96 +0,0 @@ - - - - - - Code coverage for <%= SimpleCov.project_name %> - - ' media='screen, projection, print' rel='stylesheet' type='text/css' /> - " /> - - - - > - - -
      -
      -
        - -
        - -
        - <%= formatted_file_list("All Files", result.source_files) %> -<%- result.groups.each do |name, files| -%> - <%= formatted_file_list(name, files) %> -<%- end -%> -
        - - - -
        -<%- result.source_files.each do |source_file| -%> - -<%- end -%> -
        -
        - - -
        -
        -
        - Covered - Skipped - Missed line -<%- if branch_coverage? -%> - Missed branch -<%- end -%> -<%- if method_coverage? -%> - Missed method -<%- end -%> -
        - -
        -
        -
        - - diff --git a/lib/simplecov/formatter/html_formatter/views/source_file.erb b/lib/simplecov/formatter/html_formatter/views/source_file.erb deleted file mode 100644 index 48f76c416..000000000 --- a/lib/simplecov/formatter/html_formatter/views/source_file.erb +++ /dev/null @@ -1,38 +0,0 @@ -
        -
        -

        <%= shortened_filename source_file %>

        - <%= coverage_summary({ - covered_lines: source_file.covered_lines.count, total_lines: source_file.covered_lines.count + source_file.missed_lines.count, - covered_branches: branch_coverage? ? source_file.covered_branches.count : 0, total_branches: branch_coverage? ? source_file.total_branches.count : 0, - covered_methods: method_coverage? ? source_file.covered_methods.count : 0, total_methods: method_coverage? ? source_file.methods.count : 0 - }, show_method_toggle: method_coverage? && source_file.missed_methods.any?) %> -<%- if method_coverage? && source_file.missed_methods.any? -%> - -<%- end -%> -
        -
        -    
          -<%- source_file.lines.each do |line| -%> -
        1. data-linenumber="<%= line.number %>"> -<%- if line.covered? -%> - -<%- elsif line.skipped? -%> - -<%- end -%> -<%- if branch_coverage? -%> -<%- source_file.branches_for_line(line.number).each do |branch_type, hit_count| -%> - -<%- end -%> -<%- end -%> - <%= ERB::Util.html_escape(line.src.chomp) %> -
        2. -<%- end -%> -
        -
        -
        diff --git a/lib/simplecov/formatter/json_formatter.rb b/lib/simplecov/formatter/json_formatter.rb index cc0370dbf..0d5ac21f5 100644 --- a/lib/simplecov/formatter/json_formatter.rb +++ b/lib/simplecov/formatter/json_formatter.rb @@ -1,36 +1,29 @@ # frozen_string_literal: true require_relative "json_formatter/result_hash_formatter" -require_relative "json_formatter/result_exporter" require "json" module SimpleCov module Formatter class JSONFormatter + FILENAME = "coverage.json" + def initialize(silent: false) @silent = silent end - def format(result) - result_hash = format_result(result) - - export_formatted_result(result_hash) + def self.build_hash(result) + ResultHashFormatter.new(result).format + end + def format(result) + json = JSON.pretty_generate(self.class.build_hash(result)) + File.write(File.join(SimpleCov.coverage_path, FILENAME), json) puts output_message(result) unless @silent end private - def format_result(result) - result_hash_formater = ResultHashFormatter.new(result) - result_hash_formater.format - end - - def export_formatted_result(result_hash) - result_exporter = ResultExporter.new(result_hash) - result_exporter.export - end - def output_message(result) "JSON Coverage report generated for #{result.command_name} to #{SimpleCov.coverage_path}. " \ "#{result.covered_lines} / #{result.total_lines} LOC (#{result.covered_percent.round(2)}%) covered." diff --git a/lib/simplecov/formatter/json_formatter/result_exporter.rb b/lib/simplecov/formatter/json_formatter/result_exporter.rb deleted file mode 100644 index 7e98e954a..000000000 --- a/lib/simplecov/formatter/json_formatter/result_exporter.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true - -module SimpleCov - module Formatter - class JSONFormatter - class ResultExporter - FILENAME = "coverage.json" - - def initialize(result_hash) - @result = result_hash - end - - def export - File.open(export_path, "w") do |file| - file << json_result - end - end - - private - - def json_result - JSON.pretty_generate(@result) - end - - def export_path - File.join(SimpleCov.coverage_path, FILENAME) - end - end - end - end -end diff --git a/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb b/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb index 63b5a0eda..eedea7612 100644 --- a/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +++ b/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require_relative "source_file_formatter" +require "time" module SimpleCov module Formatter @@ -34,7 +34,9 @@ def format_files def format_groups @result.groups.each do |name, file_list| - formatted_result[:groups][name] = format_coverage_statistics(file_list.coverage_statistics) + group_data = format_coverage_statistics(file_list.coverage_statistics) + group_data[:files] = file_list.map(&:filename) + formatted_result[:groups][name] = group_data end end @@ -116,20 +118,90 @@ def coverage_drop_for(criterion, last_run) end def formatted_result - @formatted_result ||= { - meta: { - simplecov_version: SimpleCov::VERSION - }, - total: {}, - coverage: {}, - groups: {}, - errors: {} + @formatted_result ||= {meta: format_meta, total: {}, coverage: {}, groups: {}, errors: {}} + end + + def format_meta + { + simplecov_version: SimpleCov::VERSION, + command_name: @result.command_name, + project_name: SimpleCov.project_name, + timestamp: @result.created_at.iso8601, + root: SimpleCov.root, + branch_coverage: SimpleCov.branch_coverage?, + method_coverage: SimpleCov.method_coverage? } end def format_source_file(source_file) - source_file_formatter = SourceFileFormatter.new(source_file) - source_file_formatter.format + result = format_line_coverage(source_file) + result.merge!(format_source_code(source_file)) + result.merge!(format_branch_coverage(source_file)) if SimpleCov.branch_coverage? + result.merge!(format_method_coverage(source_file)) if SimpleCov.method_coverage? + result + end + + def format_source_code(source_file) + {source: source_file.lines.map { |line| ensure_utf8(line.src.chomp) }} + end + + def ensure_utf8(str) + str.encode("UTF-8", invalid: :replace, undef: :replace) + end + + def format_line_coverage(source_file) + { + lines: source_file.lines.map { |line| format_line(line) }, + lines_covered_percent: source_file.covered_percent, + covered_lines: source_file.covered_lines.count, + missed_lines: source_file.missed_lines.count + } + end + + def format_branch_coverage(source_file) + { + branches: source_file.branches.map { |branch| format_branch(branch) }, + branches_covered_percent: source_file.branches_coverage_percent, + covered_branches: source_file.covered_branches.count, + missed_branches: source_file.missed_branches.count, + total_branches: source_file.total_branches.count + } + end + + def format_method_coverage(source_file) + { + methods: source_file.methods.map { |method| format_method(method) }, + methods_covered_percent: source_file.methods_coverage_percent, + covered_methods: source_file.covered_methods.count, + missed_methods: source_file.missed_methods.count, + total_methods: source_file.methods.count + } + end + + def format_line(line) + return line.coverage unless line.skipped? + + "ignored" + end + + def format_branch(branch) + { + type: branch.type, + start_line: branch.start_line, + end_line: branch.end_line, + coverage: format_line(branch), + inline: branch.inline?, + report_line: branch.report_line + } + end + + def format_method(method) + { + name: method.to_s, + start_line: method.start_line, + end_line: method.end_line, + coverage: format_line(method) + } end def format_coverage_statistics(statistics) diff --git a/lib/simplecov/formatter/json_formatter/source_file_formatter.rb b/lib/simplecov/formatter/json_formatter/source_file_formatter.rb deleted file mode 100644 index 246eca3e7..000000000 --- a/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +++ /dev/null @@ -1,86 +0,0 @@ -# frozen_string_literal: true - -module SimpleCov - module Formatter - class JSONFormatter - class SourceFileFormatter - def initialize(source_file) - @source_file = source_file - @line_coverage = nil - end - - def format - result = line_coverage - result.merge!(branch_coverage) if SimpleCov.branch_coverage? - result.merge!(method_coverage) if SimpleCov.method_coverage? - result - end - - private - - def line_coverage - @line_coverage ||= { - lines: lines, - lines_covered_percent: @source_file.covered_percent - } - end - - def branch_coverage - { - branches: branches, - branches_covered_percent: @source_file.branches_coverage_percent - } - end - - def method_coverage - { - methods: format_methods, - methods_covered_percent: @source_file.methods_coverage_percent - } - end - - def lines - @source_file.lines.collect do |line| - parse_line(line) - end - end - - def branches - @source_file.branches.collect do |branch| - parse_branch(branch) - end - end - - def format_methods - @source_file.methods.collect do |method| - parse_method(method) - end - end - - def parse_line(line) - return line.coverage unless line.skipped? - - "ignored" - end - - def parse_branch(branch) - { - type: branch.type, - start_line: branch.start_line, - end_line: branch.end_line, - coverage: parse_line(branch) - } - end - - def parse_method(method) - { - name: method.to_s, - start_line: method.start_line, - end_line: method.end_line, - coverage: parse_line(method) - } - end - end - end - end -end diff --git a/spec/coverage_for_eval_spec.rb b/spec/coverage_for_eval_spec.rb index 37a20f037..1cff071e5 100644 --- a/spec/coverage_for_eval_spec.rb +++ b/spec/coverage_for_eval_spec.rb @@ -19,7 +19,7 @@ let(:command) { "bundle e ruby eval_test.rb" } it "records coverage for erb" do - expect(@stdout).to include("Line coverage: 2 / 3 (66.67%)") + expect(@stdout).to include("Coverage report generated") end end end diff --git a/spec/fixtures/json/sample.json b/spec/fixtures/json/sample.json index 06c1c9477..286272ed2 100644 --- a/spec/fixtures/json/sample.json +++ b/spec/fixtures/json/sample.json @@ -1,6 +1,12 @@ { "meta": { - "simplecov_version": "0.22.0" + "simplecov_version": "0.22.0", + "command_name": "STUB_COMMAND_NAME", + "project_name": "STUB_PROJECT_NAME", + "timestamp": "2024-01-01T00:00:00+00:00", + "root": "/STUB_WORKING_DIRECTORY", + "branch_coverage": false, + "method_coverage": false }, "total": { "lines": { @@ -40,7 +46,36 @@ "ignored", null ], - "lines_covered_percent": 90.0 + "source": [ + "# Foo class", + "class Foo", + " def initialize", + " @foo = \"bar\"", + " @bar = \"foo\"", + " end", + "", + " def bar", + " @foo", + " end", + "", + " def foo(param)", + " if param", + " @bar", + " else", + " @foo", + " end", + " end", + "", + " # :nocov:", + " def skipped", + " @foo * 2", + " end", + " # :nocov:", + "end" + ], + "lines_covered_percent": 90.0, + "covered_lines": 9, + "missed_lines": 1 } }, "groups": {}, diff --git a/spec/fixtures/json/sample_groups.json b/spec/fixtures/json/sample_groups.json index 8e0723a3b..1e4e5ec73 100644 --- a/spec/fixtures/json/sample_groups.json +++ b/spec/fixtures/json/sample_groups.json @@ -1,6 +1,12 @@ { "meta": { - "simplecov_version": "0.22.0" + "simplecov_version": "0.22.0", + "command_name": "STUB_COMMAND_NAME", + "project_name": "STUB_PROJECT_NAME", + "timestamp": "2024-01-01T00:00:00+00:00", + "root": "/STUB_WORKING_DIRECTORY", + "branch_coverage": false, + "method_coverage": false }, "total": { "lines": { @@ -40,7 +46,36 @@ "ignored", null ], - "lines_covered_percent": 90.0 + "source": [ + "# Foo class", + "class Foo", + " def initialize", + " @foo = \"bar\"", + " @bar = \"foo\"", + " end", + "", + " def bar", + " @foo", + " end", + "", + " def foo(param)", + " if param", + " @bar", + " else", + " @foo", + " end", + " end", + "", + " # :nocov:", + " def skipped", + " @foo * 2", + " end", + " # :nocov:", + "end" + ], + "lines_covered_percent": 90.0, + "covered_lines": 9, + "missed_lines": 1 } }, "groups": { @@ -51,7 +86,10 @@ "total": 10, "percent": 80.0, "strength": 0.0 - } + }, + "files": [ + "/STUB_WORKING_DIRECTORY/spec/fixtures/json/sample.rb" + ] } }, "errors": {} diff --git a/spec/fixtures/json/sample_with_branch.json b/spec/fixtures/json/sample_with_branch.json index e7bf0aeb0..96f43da69 100644 --- a/spec/fixtures/json/sample_with_branch.json +++ b/spec/fixtures/json/sample_with_branch.json @@ -1,6 +1,12 @@ { "meta": { - "simplecov_version": "0.22.0" + "simplecov_version": "0.22.0", + "command_name": "STUB_COMMAND_NAME", + "project_name": "STUB_PROJECT_NAME", + "timestamp": "2024-01-01T00:00:00+00:00", + "root": "/STUB_WORKING_DIRECTORY", + "branch_coverage": true, + "method_coverage": false }, "total": { "lines": { @@ -47,22 +53,58 @@ "ignored", null ], + "source": [ + "# Foo class", + "class Foo", + " def initialize", + " @foo = \"bar\"", + " @bar = \"foo\"", + " end", + "", + " def bar", + " @foo", + " end", + "", + " def foo(param)", + " if param", + " @bar", + " else", + " @foo", + " end", + " end", + "", + " # :nocov:", + " def skipped", + " @foo * 2", + " end", + " # :nocov:", + "end" + ], "lines_covered_percent": 90.0, + "covered_lines": 9, + "missed_lines": 1, "branches": [ { "type": "then", "start_line": 14, "end_line": 14, - "coverage": 0 + "coverage": 0, + "inline": false, + "report_line": 13 }, { "type": "else", "start_line": 16, "end_line": 16, - "coverage": 1 + "coverage": 1, + "inline": false, + "report_line": 15 } ], - "branches_covered_percent": 50.0 + "branches_covered_percent": 50.0, + "covered_branches": 1, + "missed_branches": 1, + "total_branches": 2 } }, "groups": {}, diff --git a/spec/fixtures/json/sample_with_method.json b/spec/fixtures/json/sample_with_method.json index 99c8f6795..3442c8a07 100644 --- a/spec/fixtures/json/sample_with_method.json +++ b/spec/fixtures/json/sample_with_method.json @@ -1,6 +1,12 @@ { "meta": { - "simplecov_version": "0.22.0" + "simplecov_version": "0.22.0", + "command_name": "STUB_COMMAND_NAME", + "project_name": "STUB_PROJECT_NAME", + "timestamp": "2024-01-01T00:00:00+00:00", + "root": "/STUB_WORKING_DIRECTORY", + "branch_coverage": false, + "method_coverage": true }, "total": { "lines": { @@ -47,7 +53,36 @@ "ignored", null ], + "source": [ + "# Foo class", + "class Foo", + " def initialize", + " @foo = \"bar\"", + " @bar = \"foo\"", + " end", + "", + " def bar", + " @foo", + " end", + "", + " def foo(param)", + " if param", + " @bar", + " else", + " @foo", + " end", + " end", + "", + " # :nocov:", + " def skipped", + " @foo * 2", + " end", + " # :nocov:", + "end" + ], "lines_covered_percent": 90.0, + "covered_lines": 9, + "missed_lines": 1, "methods": [ { "name": "Foo#initialize", @@ -74,7 +109,10 @@ "coverage": "ignored" } ], - "methods_covered_percent": 100.0 + "methods_covered_percent": 100.0, + "covered_methods": 3, + "missed_methods": 0, + "total_methods": 4 } }, "groups": {}, diff --git a/spec/json_formatter_spec.rb b/spec/json_formatter_spec.rb index cc31ab7cd..a6357d5c0 100644 --- a/spec/json_formatter_spec.rb +++ b/spec/json_formatter_spec.rb @@ -5,13 +5,17 @@ describe SimpleCov::Formatter::JSONFormatter do subject { described_class.new(silent: true) } + let(:fixed_time) { Time.new(2024, 1, 1, 0, 0, 0, "+00:00") } + let(:result) do - SimpleCov::Result.new({ - source_fixture("json/sample.rb") => {"lines" => [ - nil, 1, 1, 1, 1, nil, nil, 1, 1, nil, nil, - 1, 1, 0, nil, 1, nil, nil, nil, nil, 1, 0, nil, nil, nil - ]} - }) + res = SimpleCov::Result.new({ + source_fixture("json/sample.rb") => {"lines" => [ + nil, 1, 1, 1, 1, nil, nil, 1, 1, nil, nil, + 1, 1, 0, nil, 1, nil, nil, nil, nil, 1, 0, nil, nil, nil + ]} + }) + res.created_at = fixed_time + res end describe "format" do @@ -53,12 +57,14 @@ end let(:result) do - SimpleCov::Result.new({ - source_fixture("json/sample.rb") => { - "lines" => original_lines, - "branches" => original_branches - } - }) + res = SimpleCov::Result.new({ + source_fixture("json/sample.rb") => { + "lines" => original_lines, + "branches" => original_branches + } + }) + res.created_at = fixed_time + res end before do @@ -88,12 +94,14 @@ end let(:result) do - SimpleCov::Result.new({ - source_fixture("json/sample.rb") => { - "lines" => original_lines, - "methods" => original_methods - } - }) + res = SimpleCov::Result.new({ + source_fixture("json/sample.rb") => { + "lines" => original_lines, + "methods" => original_methods + } + }) + res.created_at = fixed_time + res end before do @@ -192,19 +200,21 @@ end context "with minimum_coverage_by_group below threshold" do + let(:sample_filename) { source_fixture("json/sample.rb") } let(:line_stats) { SimpleCov::CoverageStatistics.new(covered: 7, missed: 3) } let(:result) do res = SimpleCov::Result.new({ - source_fixture("json/sample.rb") => {"lines" => [ + sample_filename => {"lines" => [ nil, 1, 1, 1, 1, nil, nil, 1, 1, nil, nil, 1, 1, 0, nil, 1, nil, nil, nil, nil, 1, 0, nil, nil, nil ]} }) - allow(res).to receive_messages( - groups: {"Models" => double("File List", coverage_statistics: {line: line_stats})} - ) + mock_file_list = double("File List", + coverage_statistics: {line: line_stats}, + map: [sample_filename]) + allow(res).to receive_messages(groups: {"Models" => mock_file_list}) res end @@ -265,20 +275,26 @@ end context "with groups" do + let(:sample_filename) { source_fixture("json/sample.rb") } + let(:line_stats) { SimpleCov::CoverageStatistics.new(covered: 8, missed: 2) } let(:result) do res = SimpleCov::Result.new({ - source_fixture("json/sample.rb") => {"lines" => [ + sample_filename => {"lines" => [ nil, 1, 1, 1, 1, nil, nil, 1, 1, nil, nil, 1, 1, 0, nil, 1, nil, nil, nil, nil, 1, 0, nil, nil, nil ]} }) + res.created_at = fixed_time # right now SimpleCov works mostly on global state, hence setting the groups that way # would be global state --> Mocking is better here + mock_file_list = double("File List", + coverage_statistics: {line: line_stats}, + map: [sample_filename]) allow(res).to receive_messages( - groups: {"My Group" => double("File List", coverage_statistics: {line: line_stats})} + groups: {"My Group" => mock_file_list} ) res end @@ -304,13 +320,20 @@ def json_output def json_result(filename) file = File.read(source_fixture("json/#{filename}.json")) - file = use_current_working_directory(file) + file = replace_stubs(file) JSON.parse(file) end STUB_WORKING_DIRECTORY = "STUB_WORKING_DIRECTORY" - def use_current_working_directory(file) + STUB_COMMAND_NAME = "STUB_COMMAND_NAME" + STUB_PROJECT_NAME = "STUB_PROJECT_NAME" + + def replace_stubs(file) current_working_directory = File.expand_path("..", File.dirname(__FILE__)) - file.gsub("/#{STUB_WORKING_DIRECTORY}/", "#{current_working_directory}/") + file + .gsub("/#{STUB_WORKING_DIRECTORY}/", "#{current_working_directory}/") + .gsub("\"/#{STUB_WORKING_DIRECTORY}\"", "\"#{current_working_directory}\"") + .gsub("\"#{STUB_COMMAND_NAME}\"", "\"#{SimpleCov.command_name}\"") + .gsub("\"#{STUB_PROJECT_NAME}\"", "\"#{SimpleCov.project_name}\"") end end diff --git a/test/html_formatter/test_formatter.rb b/test/html_formatter/test_formatter.rb index ba3f7023b..3d4babf3e 100644 --- a/test/html_formatter/test_formatter.rb +++ b/test/html_formatter/test_formatter.rb @@ -4,143 +4,91 @@ require "helper" require "coverage_fixtures" require "tmpdir" +require "json" class TestFormatter < Minitest::Test cover "SimpleCov::Formatter::HTMLFormatter#initialize" if respond_to?(:cover) - def test_initialize_sets_branch_coverage_from_simplecov + def test_initialize_silent_default_false f = SimpleCov::Formatter::HTMLFormatter.new - assert_equal SimpleCov.branch_coverage?, f.instance_variable_get(:@branch_coverage) + refute f.instance_variable_get(:@silent) end - def test_initialize_branch_coverage_false_when_disabled - with_coverage_criteria_cleared do - f = SimpleCov::Formatter::HTMLFormatter.new + def test_initialize_silent_true + f = SimpleCov::Formatter::HTMLFormatter.new(silent: true) - refute f.instance_variable_get(:@branch_coverage) - end + assert f.instance_variable_get(:@silent) end - def test_initialize_branch_coverage_true_when_enabled - skip "Branch coverage not supported on JRuby" if RUBY_ENGINE == "jruby" + cover "SimpleCov::Formatter::HTMLFormatter#format" if respond_to?(:cover) - with_coverage_criteria_cleared do - SimpleCov.enable_coverage(:branch) - f = SimpleCov::Formatter::HTMLFormatter.new + def test_format_writes_coverage_data_js + with_coverage_dir do |dir| + silent_formatter.format(make_result) - assert f.instance_variable_get(:@branch_coverage) + assert_path_exists File.join(dir, "coverage_data.js") end end - def test_initialize_sets_method_coverage_based_on_simplecov - f = SimpleCov::Formatter::HTMLFormatter.new + def test_format_coverage_data_js_contains_valid_json + with_coverage_dir do |dir| + silent_formatter.format(make_result) - assert_equal SimpleCov.method_coverage?, f.instance_variable_get(:@method_coverage) - end + content = File.read(File.join(dir, "coverage_data.js")) - def test_initialize_sets_method_coverage_false_when_disabled - with_coverage_criteria_cleared do - f = SimpleCov::Formatter::HTMLFormatter.new + assert content.start_with?("window.SIMPLECOV_DATA = ") + assert content.end_with?(";\n") - refute f.instance_variable_get(:@method_coverage) - end - end - - def test_initialize_sets_method_coverage_true_when_enabled - skip "Method coverage not supported" unless SimpleCov.respond_to?(:method_coverage_supported?) && SimpleCov.method_coverage_supported? - with_coverage_criteria_cleared do - SimpleCov.enable_coverage(:method) - f = SimpleCov::Formatter::HTMLFormatter.new + json_str = content.sub("window.SIMPLECOV_DATA = ", "").chomp(";\n") + data = JSON.parse(json_str) - assert f.instance_variable_get(:@method_coverage) + assert_kind_of Hash, data + assert data.key?("meta") + assert data.key?("coverage") + assert data.key?("total") end end - def test_initialize_method_coverage_reflects_simplecov - f = SimpleCov::Formatter::HTMLFormatter.new - expected = SimpleCov.respond_to?(:method_coverage?) && SimpleCov.method_coverage? - - assert_equal expected, f.instance_variable_get(:@method_coverage) - end - - def test_initialize_creates_empty_templates_hash - f = SimpleCov::Formatter::HTMLFormatter.new - - assert_equal({}, f.instance_variable_get(:@templates)) - end - - def test_initialize_inline_assets_default_false - ENV.delete("SIMPLECOV_INLINE_ASSETS") - f = SimpleCov::Formatter::HTMLFormatter.new - - refute f.instance_variable_get(:@inline_assets) - end - - def test_initialize_inline_assets_from_kwarg - f = SimpleCov::Formatter::HTMLFormatter.new(inline_assets: true) - - assert f.instance_variable_get(:@inline_assets) - end - - def test_initialize_inline_assets_from_env - ENV["SIMPLECOV_INLINE_ASSETS"] = "1" - f = SimpleCov::Formatter::HTMLFormatter.new - - assert f.instance_variable_get(:@inline_assets) - ensure - ENV.delete("SIMPLECOV_INLINE_ASSETS") - end - - def test_initialize_silent_default_false - f = SimpleCov::Formatter::HTMLFormatter.new - - refute f.instance_variable_get(:@silent) - end - - def test_initialize_silent_true - f = SimpleCov::Formatter::HTMLFormatter.new(silent: true) + def test_format_copies_index_html + with_coverage_dir do |dir| + silent_formatter.format(make_result) - assert f.instance_variable_get(:@silent) + assert_path_exists File.join(dir, "index.html") + end end - def test_initialize_public_assets_dir_points_to_public - f = SimpleCov::Formatter::HTMLFormatter.new - dir = f.instance_variable_get(:@public_assets_dir) + def test_format_copies_application_js + with_coverage_dir do |dir| + silent_formatter.format(make_result) - assert dir.end_with?("/public/"), "Expected public/ dir, got: #{dir}" - assert File.directory?(dir), "Expected #{dir} to exist" + assert_path_exists File.join(dir, "application.js") + end end - cover "SimpleCov::Formatter::HTMLFormatter#format" if respond_to?(:cover) - - def test_format_writes_index_html + def test_format_copies_application_css with_coverage_dir do |dir| - f = silent_formatter - f.format(make_result) - html = File.read(File.join(dir, "index.html")) + silent_formatter.format(make_result) - assert_includes html, "" + assert_path_exists File.join(dir, "application.css") end end - def test_format_copies_assets_when_not_inline + def test_format_copies_favicons with_coverage_dir do |dir| silent_formatter.format(make_result) - asset_dir = versioned_asset_dir(dir) - assert File.directory?(asset_dir), "Expected assets directory at #{asset_dir}" - assert_path_exists File.join(asset_dir, "application.js") - assert_path_exists File.join(asset_dir, "application.css") + assert_path_exists File.join(dir, "favicon_green.png") + assert_path_exists File.join(dir, "favicon_red.png") + assert_path_exists File.join(dir, "favicon_yellow.png") end end - def test_format_does_not_copy_assets_when_inline + def test_format_also_writes_coverage_json with_coverage_dir do |dir| - f = SimpleCov::Formatter::HTMLFormatter.new(silent: true, inline_assets: true) - f.format(make_result) + silent_formatter.format(make_result) - refute File.directory?(File.join(dir, "assets")), "Expected no assets directory when inline" + assert_path_exists File.join(dir, "coverage.json") end end @@ -161,531 +109,117 @@ def test_format_does_not_print_when_silent end end - def test_format_writes_in_binary_mode - with_coverage_dir do |_dir| - write_calls = spy_on_file_write do - silent_formatter.format(make_result) - end - - assert_equal [{mode: "wb"}], write_calls - end - end + def test_format_writes_coverage_data_in_binary_mode + with_coverage_dir do |dir| + silent_formatter.format(make_result) - def test_format_copies_assets_with_remove_destination - with_coverage_dir do |_dir| - cp_r_calls = spy_on_cp_r do - silent_formatter.format(make_result) - end + content = File.read(File.join(dir, "coverage_data.js")) - refute_empty cp_r_calls, "Expected at least one cp_r call" - cp_r_calls.each do |opts| - assert opts[:remove_destination], "Expected remove_destination: true, got #{opts.inspect}" - end + assert content.start_with?("window.SIMPLECOV_DATA = ") end end - cover "SimpleCov::Formatter::HTMLFormatter#output_message" if respond_to?(:cover) - - def test_output_message_includes_command_name - msg = output_message_for("RSpec", line: stat(80, 100), branch: stat(10, 20)) - - assert_includes msg, "RSpec" - end - - def test_output_message_includes_output_path - msg = output_message_for("Test", line: stat(80, 100), branch: stat(10, 20)) - - assert_includes msg, SimpleCov.coverage_path - end - - def test_output_message_includes_line_coverage - msg = output_message_for("Test", line: stat(80, 100), branch: stat(10, 20)) - - assert_includes msg, "Line coverage:" - assert_includes msg, "80 / 100" - end - - def test_output_message_includes_branch_coverage_when_enabled - f = SimpleCov::Formatter::HTMLFormatter.new - f.instance_variable_set(:@branch_coverage, true) - stats = {line: stat(80, 100), branch: stat(15, 20)} - stats[:method] = stat(0, 0) if f.instance_variable_get(:@method_coverage) - msg = f.send(:output_message, stub_result("Test", stats)) - - assert_includes msg, "Branch coverage:" - assert_includes msg, "15 / 20" - end - - def test_output_message_excludes_branch_coverage_when_disabled - f = SimpleCov::Formatter::HTMLFormatter.new - f.instance_variable_set(:@branch_coverage, false) - stats = {line: stat(80, 100)} - stats[:method] = stat(0, 0) if f.instance_variable_get(:@method_coverage) - msg = f.send(:output_message, stub_result("Test", stats)) - - refute_includes msg, "Branch coverage:" - end - - def test_output_message_includes_method_coverage_when_enabled - f = SimpleCov::Formatter::HTMLFormatter.new - f.instance_variable_set(:@method_coverage, true) - f.instance_variable_set(:@branch_coverage, false) - msg = f.send(:output_message, stub_result("Test", line: stat(80, 100), method: stat(5, 10))) - - assert_includes msg, "Method coverage:" - assert_includes msg, "5 / 10" - end - - def test_output_message_excludes_method_coverage_when_disabled - f = SimpleCov::Formatter::HTMLFormatter.new - f.instance_variable_set(:@method_coverage, false) - f.instance_variable_set(:@branch_coverage, false) - msg = f.send(:output_message, stub_result("Test", line: stat(80, 100))) - - refute_includes msg, "Method coverage:" - end - - def test_output_message_starts_with_coverage_report_generated - f = SimpleCov::Formatter::HTMLFormatter.new - f.instance_variable_set(:@branch_coverage, false) - f.instance_variable_set(:@method_coverage, false) - msg = f.send(:output_message, stub_result("MyTest", line: stat(50, 50))) - - assert msg.start_with?("Coverage report generated for MyTest to ") - end - - def test_output_message_lines_are_joined_with_newlines - f = SimpleCov::Formatter::HTMLFormatter.new - f.instance_variable_set(:@branch_coverage, true) - f.instance_variable_set(:@method_coverage, true) - result = stub_result("Test", line: stat(80, 100), branch: stat(10, 20), method: stat(5, 10)) - lines = f.send(:output_message, result).split("\n") - - assert_equal 4, lines.length - end - - cover "SimpleCov::Formatter::HTMLFormatter#template" if respond_to?(:cover) - - def test_template_returns_erb_object - f = SimpleCov::Formatter::HTMLFormatter.new - - assert_instance_of ERB, f.send(:template, "layout") - end - - def test_template_caches_result - f = SimpleCov::Formatter::HTMLFormatter.new - tmpl1 = f.send(:template, "layout") - tmpl2 = f.send(:template, "layout") - - assert_same tmpl1, tmpl2 - end - - def test_template_loads_different_templates - f = SimpleCov::Formatter::HTMLFormatter.new - layout = f.send(:template, "layout") - file_list = f.send(:template, "file_list") - - refute_same layout, file_list - end - - def test_template_stores_in_templates_hash - f = SimpleCov::Formatter::HTMLFormatter.new - f.send(:template, "layout") - templates = f.instance_variable_get(:@templates) - - assert templates.key?("layout") - assert_instance_of ERB, templates["layout"] - end - - def test_template_reads_from_views_directory - f = SimpleCov::Formatter::HTMLFormatter.new - tmpl = f.send(:template, "covered_percent") - - refute_nil tmpl - assert_instance_of ERB, tmpl - end - - cover "SimpleCov::Formatter::HTMLFormatter#output_path" if respond_to?(:cover) - - def test_output_path_delegates_to_simplecov_coverage_path - f = SimpleCov::Formatter::HTMLFormatter.new - - assert_equal SimpleCov.coverage_path, f.send(:output_path) - end - - def test_output_path_returns_string - f = SimpleCov::Formatter::HTMLFormatter.new - - assert_kind_of String, f.send(:output_path) - end - - cover "SimpleCov::Formatter::HTMLFormatter#asset_output_path" if respond_to?(:cover) + def test_format_coverage_data_includes_source_code + with_coverage_dir do |dir| + silent_formatter.format(make_result) - def test_asset_output_path_creates_directory - with_coverage_dir do |_dir| - f = SimpleCov::Formatter::HTMLFormatter.new + data = parse_coverage_data(dir) + file_data = data["coverage"].values.first - assert File.directory?(f.send(:asset_output_path)), "Expected directory to exist" + assert file_data.key?("source"), "Expected source code in coverage data" + assert_kind_of Array, file_data["source"] + refute_empty file_data["source"] end end - def test_asset_output_path_includes_version - with_coverage_dir do - path = SimpleCov::Formatter::HTMLFormatter.new.send(:asset_output_path) - - assert_includes path, SimpleCov::Formatter::HTMLFormatter::VERSION - end - end + def test_format_coverage_data_includes_meta + with_coverage_dir do |dir| + silent_formatter.format(make_result) - def test_asset_output_path_includes_assets_subdir - with_coverage_dir do - path = SimpleCov::Formatter::HTMLFormatter.new.send(:asset_output_path) + data = parse_coverage_data(dir) + meta = data["meta"] - assert_includes path, "/assets/" + assert meta.key?("simplecov_version") + assert meta.key?("command_name") + assert meta.key?("project_name") + assert meta.key?("timestamp") + assert meta.key?("root") + assert [true, false].include?(meta["branch_coverage"]) + assert [true, false].include?(meta["method_coverage"]) end end - def test_asset_output_path_is_cached - with_coverage_dir do - f = SimpleCov::Formatter::HTMLFormatter.new - path1 = f.send(:asset_output_path) - path2 = f.send(:asset_output_path) - - assert_same path1, path2 - end - end + cover "SimpleCov::Formatter::HTMLFormatter#format_from_json" if respond_to?(:cover) - def test_asset_output_path_is_under_output_path + def test_format_from_json_writes_coverage_data_js with_coverage_dir do |dir| - path = SimpleCov::Formatter::HTMLFormatter.new.send(:asset_output_path) - - assert path.start_with?(dir), "Expected #{path} to start with #{dir}" - end - end + # First generate coverage.json + silent_formatter.format(make_result) - def test_asset_output_path_joins_output_path_assets_version - with_coverage_dir do |dir| - expected = File.join(dir, "assets", SimpleCov::Formatter::HTMLFormatter::VERSION) + # Now use format_from_json to generate in a different directory + output_dir = File.join(dir, "standalone") + json_path = File.join(dir, "coverage.json") + SimpleCov::Formatter::HTMLFormatter.new.format_from_json(json_path, output_dir) - assert_equal expected, SimpleCov::Formatter::HTMLFormatter.new.send(:asset_output_path) + assert_path_exists File.join(output_dir, "coverage_data.js") + assert_path_exists File.join(output_dir, "index.html") + assert_path_exists File.join(output_dir, "application.js") end end - cover "SimpleCov::Formatter::HTMLFormatter#assets_path" if respond_to?(:cover) - - def test_assets_path_returns_relative_path_when_not_inline - f = SimpleCov::Formatter::HTMLFormatter.new(inline_assets: false) - ENV.delete("SIMPLECOV_INLINE_ASSETS") - expected = File.join("./assets", SimpleCov::Formatter::HTMLFormatter::VERSION, "application.js") - - assert_equal expected, f.send(:assets_path, "application.js") - end - - def test_assets_path_includes_version - f = SimpleCov::Formatter::HTMLFormatter.new(inline_assets: false) - ENV.delete("SIMPLECOV_INLINE_ASSETS") - - assert_includes f.send(:assets_path, "application.css"), SimpleCov::Formatter::HTMLFormatter::VERSION - end - - def test_assets_path_returns_data_uri_when_inline - f = SimpleCov::Formatter::HTMLFormatter.new(inline_assets: true) - result = f.send(:assets_path, "application.js") - - assert result.start_with?("data:"), "Expected data URI, got: #{result[0..30]}" - end - - def test_assets_path_starts_with_dot_slash_assets_when_not_inline - f = SimpleCov::Formatter::HTMLFormatter.new(inline_assets: false) - ENV.delete("SIMPLECOV_INLINE_ASSETS") - result = f.send(:assets_path, "application.css") - - assert result.start_with?("./assets/"), "Expected ./assets/ prefix, got: #{result}" - end - - cover "SimpleCov::Formatter::HTMLFormatter#asset_inline" if respond_to?(:cover) - - def test_asset_inline_returns_data_uri_for_js - result = SimpleCov::Formatter::HTMLFormatter.new.send(:asset_inline, "application.js") - - assert result.start_with?("data:text/javascript;base64,") - end - - def test_asset_inline_returns_data_uri_for_css - result = SimpleCov::Formatter::HTMLFormatter.new.send(:asset_inline, "application.css") - - assert result.start_with?("data:text/css;base64,") - end - - def test_asset_inline_returns_data_uri_for_png - result = SimpleCov::Formatter::HTMLFormatter.new.send(:asset_inline, "favicon_green.png") - - assert result.start_with?("data:image/png;base64,") - end - - def test_asset_inline_encodes_content_as_base64 - f = SimpleCov::Formatter::HTMLFormatter.new - result = f.send(:asset_inline, "application.js") - decoded = result.sub("data:text/javascript;base64,", "").unpack1("m0") - public_dir = f.instance_variable_get(:@public_assets_dir) - - assert_equal File.read(File.join(public_dir, "application.js")), decoded - end - - def test_asset_inline_uses_m0_pack_no_newlines - result = SimpleCov::Formatter::HTMLFormatter.new.send(:asset_inline, "application.js") - base64_part = result.sub("data:text/javascript;base64,", "") - - refute_includes base64_part, "\n" - end - - def test_asset_inline_uses_correct_content_type_from_extension - f = SimpleCov::Formatter::HTMLFormatter.new - js_result = f.send(:asset_inline, "application.js") - css_result = f.send(:asset_inline, "application.css") - - assert_includes js_result, "text/javascript" - assert_includes css_result, "text/css" - refute_includes js_result, "text/css" - refute_includes css_result, "text/javascript" - end - - def test_asset_inline_reads_from_public_assets_dir - f = SimpleCov::Formatter::HTMLFormatter.new - public_dir = f.instance_variable_get(:@public_assets_dir) - expected_base64 = [File.read(File.join(public_dir, "application.css"))].pack("m0") - - assert_equal "data:text/css;base64,#{expected_base64}", f.send(:asset_inline, "application.css") - end - - cover "SimpleCov::Formatter::HTMLFormatter#formatted_source_file" if respond_to?(:cover) - - def test_formatted_source_file_returns_html - with_coverage_dir do - f = SimpleCov::Formatter::HTMLFormatter.new - result = f.send(:formatted_source_file, sample_source_file) + def test_format_from_json_produces_valid_data + with_coverage_dir do |dir| + silent_formatter.format(make_result) - assert_includes result, "source_table" - assert_includes result, "Foo" - end - end + output_dir = File.join(dir, "standalone") + json_path = File.join(dir, "coverage.json") + SimpleCov::Formatter::HTMLFormatter.new.format_from_json(json_path, output_dir) - def test_formatted_source_file_uses_source_file_template - with_coverage_dir do - f = SimpleCov::Formatter::HTMLFormatter.new - result = f.send(:formatted_source_file, sample_source_file) + data = parse_coverage_data(output_dir) - assert_includes result, shortened_name("sample.rb") + assert data.key?("meta") + assert data.key?("coverage") end end - def test_formatted_source_file_handles_encoding_error - f = formatter_with_bad_template("bad encoding") - stdout, = capture_io { f.send(:formatted_source_file, sample_source_file) } - - assert_includes stdout, "Encoding problems with file" - assert_includes stdout, sample_source_file.filename - end - - def test_formatted_source_file_encoding_error_prints_error_message - error = Encoding::CompatibilityError.new("incompatible character") - error.define_singleton_method(:message) { "the_real_message" } - f = formatter_with_bad_template_error(error) - stdout, = capture_io { f.send(:formatted_source_file, sample_source_file) } - - assert_includes stdout, "the_real_message" - refute_includes stdout, "incompatible character" - end - - def test_formatted_source_file_encoding_error_returns_placeholder - f = formatter_with_bad_template("bad") - capture_io { @encoding_result = f.send(:formatted_source_file, sample_source_file) } - - assert_includes @encoding_result, "source_table" - assert_includes @encoding_result, "Encoding Error" - end - - def test_formatted_source_file_encoding_error_contains_correct_id - f = formatter_with_bad_template("bad") - result = nil - capture_io { result = f.send(:formatted_source_file, sample_source_file) } - expected_id = Digest::MD5.hexdigest(sample_source_file.filename) - - assert_includes result, %(id="#{expected_id}") - end - - def test_formatted_source_file_encoding_error_html_escapes_message - error = Encoding::CompatibilityError.new("dummy") - error.define_singleton_method(:message) { "" } - f = formatter_with_bad_template_error(error) - result = nil - capture_io { result = f.send(:formatted_source_file, sample_source_file) } - - assert_includes result, ERB::Util.html_escape("") - refute_includes result, "