Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,10 @@ Metrics/MethodLength:
CountComments: false
Max: 12 # TODO: Lower to 10

Style/Next:
Description: Prefer `if` guards over `next` inside loops for readability.
Enabled: false

Metrics/ModuleLength:
Description: Avoid modules longer than 100 lines of code.
Max: 300
Expand Down
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,24 @@
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. Line stats additionally include `omitted` (count of blank/comment lines, i.e. lines that cannot be covered)
* 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.
Comment thread
sferik marked this conversation as resolved.
* `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

0.22.1 (2024-09-02)
==========
Expand Down
9 changes: 7 additions & 2 deletions features/step_definitions/json_steps.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions lib/simplecov.rb
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,7 @@ def probably_running_parallel_tests?
require_relative "simplecov/configuration"
SimpleCov.extend SimpleCov::Configuration
require_relative "simplecov/coverage_statistics"
require_relative "simplecov/coverage_violations"
require_relative "simplecov/exit_codes"
require_relative "simplecov/profiles"
require_relative "simplecov/source_file/line"
Expand Down
13 changes: 8 additions & 5 deletions lib/simplecov/coverage_statistics.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,32 +8,35 @@ module SimpleCov
# * total - how many things to cover there are (total relevant loc/branches)
# * covered - how many of the coverables are hit
# * missed - how many of the coverables are missed
# * omitted - how many lines cannot be covered (blank lines/comments); only meaningful for line coverage
# * percent - percentage as covered/missed
# * strength - average hits per/coverable (will not exist for one shot lines format)
class CoverageStatistics
attr_reader :total, :covered, :missed, :strength, :percent
attr_reader :total, :covered, :missed, :omitted, :strength, :percent

def self.from(coverage_statistics)
sum_covered, sum_missed, sum_total_strength =
coverage_statistics.reduce([0, 0, 0.0]) do |(covered, missed, total_strength), file_coverage_statistics|
sum_covered, sum_missed, sum_omitted, sum_total_strength =
coverage_statistics.reduce([0, 0, 0, 0.0]) do |(covered, missed, omitted, total_strength), file_coverage_statistics|
[
covered + file_coverage_statistics.covered,
missed + file_coverage_statistics.missed,
omitted + file_coverage_statistics.omitted,
# gotta remultiply with loc because files have different strength and loc
# giving them a different "weight" in total
total_strength + (file_coverage_statistics.strength * file_coverage_statistics.total)
]
end

new(covered: sum_covered, missed: sum_missed, total_strength: sum_total_strength)
new(covered: sum_covered, missed: sum_missed, omitted: sum_omitted, total_strength: sum_total_strength)
end

# Requires only covered, missed and strength to be initialized.
#
# Other values are computed by this class.
def initialize(covered:, missed:, total_strength: 0.0, percent: nil)
def initialize(covered:, missed:, omitted: 0, total_strength: 0.0, percent: nil)
@covered = covered
@missed = missed
@omitted = omitted
@total = covered + missed
@percent = percent || compute_percent(covered, missed, total)
@strength = compute_strength(total_strength, total)
Expand Down
88 changes: 88 additions & 0 deletions lib/simplecov/coverage_violations.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# frozen_string_literal: true

module SimpleCov
# Computes coverage threshold violations for a given result. Shared by
# the exit-code checks and the JSON formatter's `errors` section.
#
# Each method returns an array of violation hashes. All percents are
# rounded via `SimpleCov.round_coverage` so downstream consumers don't
# need to round again.
module CoverageViolations
class << self
# @return [Array<Hash>] {:criterion, :expected, :actual}
def minimum_overall(result, thresholds)
thresholds.filter_map do |criterion, expected|
actual = round(result.coverage_statistics.fetch(criterion).percent)
{criterion: criterion, expected: expected, actual: actual} if actual < expected
end
end

# @return [Array<Hash>] {:criterion, :expected, :actual, :filename, :project_filename}
def minimum_by_file(result, thresholds)
thresholds.flat_map do |criterion, expected|
result.files.filter_map { |file| file_minimum_violation(file, criterion, expected) }
end
end

# @return [Array<Hash>] {:group_name, :criterion, :expected, :actual}
def minimum_by_group(result, thresholds)
thresholds.flat_map do |group_name, minimums|
group = lookup_group(result, group_name)
group ? group_minimum_violations(group_name, group, minimums) : []
end
end

# @return [Array<Hash>] {:criterion, :maximum, :actual} where `actual`
# is the observed drop (in percentage points) vs. the last run.
def maximum_drop(result, thresholds, last_run: SimpleCov::LastRun.read)
return [] unless last_run

thresholds.filter_map do |criterion, maximum|
actual = compute_drop(criterion, result, last_run)
{criterion: criterion, maximum: maximum, actual: actual} if actual && actual > maximum
end
end

private

def file_minimum_violation(file, criterion, expected)
actual = round(file.coverage_statistics.fetch(criterion).percent)
return unless actual < expected

{
criterion: criterion,
expected: expected,
actual: actual,
filename: file.filename,
project_filename: file.project_filename
}
end

def group_minimum_violations(group_name, group, minimums)
minimums.filter_map do |criterion, expected|
actual = round(group.coverage_statistics.fetch(criterion).percent)
{group_name: group_name, criterion: criterion, expected: expected, actual: actual} if actual < expected
end
end

def lookup_group(result, group_name)
group = result.groups[group_name]
warn "minimum_coverage_by_group: no group named '#{group_name}' exists. Available groups: #{result.groups.keys.join(', ')}" unless group
group
end

def compute_drop(criterion, result, last_run)
last_coverage_percent = last_run.dig(:result, criterion)
last_coverage_percent ||= last_run.dig(:result, :covered_percent) if criterion == :line
return unless last_coverage_percent

current = round(result.coverage_statistics.fetch(criterion).percent)
(last_coverage_percent - current).floor(10)
end

def round(percent)
SimpleCov.round_coverage(percent)
end
end
end
end
60 changes: 7 additions & 53 deletions lib/simplecov/exit_codes/maximum_coverage_drop_check.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,22 @@
module SimpleCov
module ExitCodes
class MaximumCoverageDropCheck
MAX_DROP_ACCURACY = 10

def initialize(result, maximum_coverage_drop)
@result = result
@maximum_coverage_drop = maximum_coverage_drop
end

def failing?
return false unless maximum_coverage_drop && last_run

coverage_drop_violations.any?
violations.any?
end

def report
coverage_drop_violations.each do |violation|
violations.each do |violation|
$stderr.printf(
"%<criterion>s coverage has dropped by %<drop_percent>.2f%% since the last time (maximum allowed: %<max_drop>.2f%%).\n",
criterion: violation[:criterion].capitalize,
drop_percent: SimpleCov.round_coverage(violation[:drop_percent]),
max_drop: violation[:max_drop]
criterion: violation.fetch(:criterion).capitalize,
drop_percent: violation.fetch(:actual),
max_drop: violation.fetch(:maximum)
)
end
end
Expand All @@ -33,50 +29,8 @@ def exit_code

private

attr_reader :result, :maximum_coverage_drop

def last_run
return @last_run if defined?(@last_run)

@last_run = SimpleCov::LastRun.read
end

def coverage_drop_violations
@coverage_drop_violations ||=
compute_coverage_drop_data.select do |achieved|
achieved.fetch(:max_drop) < achieved.fetch(:drop_percent)
end
end

def compute_coverage_drop_data
maximum_coverage_drop.map do |criterion, percent|
{
criterion: criterion,
max_drop: percent,
drop_percent: drop_percent(criterion)
}
end
end

def drop_percent(criterion)
drop = last_coverage(criterion) -
SimpleCov.round_coverage(
result.coverage_statistics.fetch(criterion).percent
)

# floats, I tell ya.
# irb(main):001:0* 80.01 - 80.0
# => 0.010000000000005116
drop.floor(MAX_DROP_ACCURACY)
end

def last_coverage(criterion)
last_coverage_percent = last_run[:result][criterion]

# fallback for old file format
last_coverage_percent = last_run[:result][:covered_percent] if !last_coverage_percent && criterion == :line

last_coverage_percent || 0
def violations
@violations ||= SimpleCov::CoverageViolations.maximum_drop(@result, @maximum_coverage_drop)
end
end
end
Expand Down
33 changes: 7 additions & 26 deletions lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,17 @@ def initialize(result, minimum_coverage_by_file)
end

def failing?
minimum_violations.any?
violations.any?
end

def report
minimum_violations.each do |violation|
violations.each do |violation|
$stderr.printf(
"%<criterion>s coverage by file (%<covered>.2f%%) is below the expected minimum coverage (%<minimum_coverage>.2f%%) in %<filename>s.\n",
covered: SimpleCov.round_coverage(violation.fetch(:actual)),
minimum_coverage: violation.fetch(:minimum_expected),
covered: violation.fetch(:actual),
minimum_coverage: violation.fetch(:expected),
criterion: violation.fetch(:criterion).capitalize,
filename: violation.fetch(:filename)
filename: violation.fetch(:project_filename)
)
end
end
Expand All @@ -30,27 +30,8 @@ def exit_code

private

attr_reader :result, :minimum_coverage_by_file

def minimum_violations
@minimum_violations ||=
compute_minimum_coverage_data.select do |achieved|
achieved.fetch(:actual) < achieved.fetch(:minimum_expected)
end
end

def compute_minimum_coverage_data
minimum_coverage_by_file.flat_map do |criterion, expected_percent|
result.files.map do |file|
actual_coverage = file.coverage_statistics.fetch(criterion)
{
criterion: criterion,
minimum_expected: expected_percent,
actual: SimpleCov.round_coverage(actual_coverage.percent),
filename: file.project_filename
}
end
end
def violations
@violations ||= SimpleCov::CoverageViolations.minimum_by_file(@result, @minimum_coverage_by_file)
end
end
end
Expand Down
45 changes: 6 additions & 39 deletions lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,16 @@ def initialize(result, minimum_coverage_by_group)
end

def failing?
minimum_violations.any?
violations.any?
end

def report
minimum_violations.each do |violation|
violations.each do |violation|
$stderr.printf(
"%<criterion>s coverage by group (%<covered>.2f%%) is below the expected minimum coverage (%<minimum_coverage>.2f%%) in %<group_name>s.\n",
group_name: violation.fetch(:group_name),
covered: SimpleCov.round_coverage(violation.fetch(:actual)),
minimum_coverage: violation.fetch(:minimum_expected),
covered: violation.fetch(:actual),
minimum_coverage: violation.fetch(:expected),
criterion: violation.fetch(:criterion).capitalize
)
end
Expand All @@ -30,41 +30,8 @@ def exit_code

private

attr_reader :result, :minimum_coverage_by_group

def minimum_violations
@minimum_violations ||=
compute_minimum_coverage_data.select do |achieved|
achieved.fetch(:actual) < achieved.fetch(:minimum_expected)
end
end

def compute_minimum_coverage_data
minimum_coverage_by_group.flat_map do |group_name, minimum_group_coverage|
group = find_group(group_name)
next [] unless group

minimum_group_coverage.map do |criterion, expected_percent|
actual_coverage = group.coverage_statistics.fetch(criterion)
minimum_coverage_hash(group_name, criterion, expected_percent, SimpleCov.round_coverage(actual_coverage.percent))
end
end
end

def find_group(group_name)
result.groups[group_name] || begin
warn "minimum_coverage_by_group: no group named '#{group_name}' exists. Available groups: #{result.groups.keys.join(', ')}"
nil
end
end

def minimum_coverage_hash(group_name, criterion, minimum_expected, actual)
{
group_name: group_name,
criterion: criterion,
minimum_expected: minimum_expected,
actual: actual
}
def violations
@violations ||= SimpleCov::CoverageViolations.minimum_by_group(@result, @minimum_coverage_by_group)
end
end
end
Expand Down
Loading