diff --git a/lib/covered/coverage.rb b/lib/covered/coverage.rb index 2ca3bc6..3b324af 100644 --- a/lib/covered/coverage.rb +++ b/lib/covered/coverage.rb @@ -51,6 +51,16 @@ def initialize(source, counts = [], annotations = {}) @annotations = annotations end + # Initialize a copy of this coverage object. + # @parameter other [Covered::Coverage] The coverage object to copy. + def initialize_copy(other) + super + + @source = other.source.dup + @counts = other.counts.dup + @annotations = other.annotations.transform_values(&:dup) + end + # @attribute [Covered::Source] The covered source metadata. attr_accessor :source diff --git a/lib/covered/statistics.rb b/lib/covered/statistics.rb index dbdd90b..79ec8dd 100644 --- a/lib/covered/statistics.rb +++ b/lib/covered/statistics.rb @@ -28,6 +28,17 @@ def self.for(coverage) class Aggregate include Ratio + # Build aggregate statistics from coverage objects. + # @parameter coverages [Enumerable(Covered::Coverage)] The coverage objects to summarize. + # @returns [Covered::Statistics::Aggregate] The aggregate statistics. + def self.for(coverages) + self.new.tap do |aggregate| + coverages.each do |coverage| + aggregate << coverage + end + end + end + # Initialize empty aggregate statistics. def initialize @count = 0 @@ -35,8 +46,7 @@ def initialize @executed_count = 0 end - # Total number of files added. - # @returns [Integer] The number of coverage objects added. + # @attribute [Integer] The total number of coverage instances added. attr :count # The number of lines which could have been executed. @@ -67,22 +77,28 @@ def to_json(options) # Add coverage to these aggregate statistics. # @parameter coverage [Covered::Coverage] The coverage object to add. + # @returns [Covered::Statistics::Aggregate] This aggregate. def << coverage @count += 1 @executable_count += coverage.executable_count @executed_count += coverage.executed_count + + self end end # Initialize empty coverage statistics. def initialize - @total = Aggregate.new + @total = nil @paths = Hash.new end - # @attribute [Covered::Statistics::Aggregate] The total aggregate statistics. - attr :total + # The total aggregate statistics. + # @returns [Covered::Statistics::Aggregate] The total aggregate statistics. + def total + @total ||= Aggregate.for(@paths.values) + end # @attribute [Hash(String, Covered::Coverage)] Coverage statistics indexed by path. attr :paths @@ -96,20 +112,29 @@ def count # The total number of executable lines. # @returns [Integer] The total executable line count. def executable_count - @total.executable_count + total.executable_count end # The total number of executed lines. # @returns [Integer] The total executed line count. def executed_count - @total.executed_count + total.executed_count end # Add coverage to these statistics. # @parameter coverage [Covered::Coverage] The coverage object to add. def << coverage - @total << coverage - (@paths[coverage.path] ||= coverage.empty).merge!(coverage) + if current = @paths[coverage.path] + current.merge!(coverage) + + @total = nil + else + coverage = @paths[coverage.path] = coverage.dup + + @total << coverage if @total + end + + self end # Get coverage for the given path. @@ -124,7 +149,7 @@ def [](path) def as_json { total: total.as_json, - paths: @paths.map{|path, coverage| [path, coverage.as_json]}.to_h, + paths: paths.map{|path, coverage| [path, coverage.as_json]}.to_h, } end @@ -151,7 +176,7 @@ def to_json(options) # Print a human-readable coverage summary. # @parameter output [IO] The output stream. def print(output) - output.puts "#{count} files checked; #{@total.executed_count}/#{@total.executable_count} lines executed; #{@total.percentage.to_f.round(2)}% covered." + output.puts "#{count} files checked; #{total.executed_count}/#{total.executable_count} lines executed; #{total.percentage.to_f.round(2)}% covered." if self.complete? output.puts "🧘 #{COMPLETE.sample}" diff --git a/test/covered/coverage.rb b/test/covered/coverage.rb new file mode 100644 index 0000000..692ca4c --- /dev/null +++ b/test/covered/coverage.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2018-2025, by Samuel Williams. + +require "covered/coverage" + +describe Covered::Coverage do + let(:source) {Covered::Source.new("foo.rb")} + let(:coverage) {subject.new(source, [nil, 1], 1 => ["covered"])} + + it "can be duplicated" do + copy = coverage.dup + + expect(copy.equal?(coverage)).to be == false + expect(copy.source.equal?(coverage.source)).to be == false + expect(copy.counts).to be == coverage.counts + expect(copy.counts.equal?(coverage.counts)).to be == false + expect(copy.annotations).to be == coverage.annotations + expect(copy.annotations.equal?(coverage.annotations)).to be == false + expect(copy.annotations[1].equal?(coverage.annotations[1])).to be == false + end + + it "does not share mutable state with duplicates" do + copy = coverage.dup + + copy.mark(2, 1) + copy.annotate(1, "copy") + copy.path = "copy.rb" + + expect(coverage.path).to be == "foo.rb" + expect(coverage.counts).to be == [nil, 1] + expect(coverage.annotations).to be == {1 => ["covered"]} + end +end diff --git a/test/covered/statistics.rb b/test/covered/statistics.rb index 2cc5d11..c60f6fe 100644 --- a/test/covered/statistics.rb +++ b/test/covered/statistics.rb @@ -58,4 +58,108 @@ def before expect(statistics).not.to be(:complete?) end end + + with "after adding overlapping coverage" do + let(:complete_coverage) {Covered::Coverage.new(source, [nil, 1, 1])} + let(:partial_coverage) {Covered::Coverage.new(source, [nil, 1, 0])} + + def before + statistics << complete_coverage + statistics << partial_coverage + super + end + + it "merges coverage for the same path" do + expect(statistics.count).to be == 1 + expect(statistics.executable_count).to be == 2 + expect(statistics.executed_count).to be == 2 + end + + it "is complete" do + expect(statistics).to be(:complete?) + end + end + + with "after reading total before adding coverage" do + let(:partial_coverage) {Covered::Coverage.new(source, [nil, 1, 0])} + let(:complete_coverage) {Covered::Coverage.new(source, [nil, 0, 1])} + let(:other_coverage) {Covered::Coverage.new(Covered::Source.new("bar.rb"), [nil, 1])} + + it "adds new paths to cached totals" do + statistics << partial_coverage + + total = statistics.total + + statistics << other_coverage + + expect(statistics.total).to be_equal(total) + expect(statistics.count).to be == 2 + expect(statistics.executable_count).to be == 3 + expect(statistics.executed_count).to be == 2 + end + + it "invalidates cached totals" do + statistics << partial_coverage + + total = statistics.total + + expect(statistics.count).to be == 1 + expect(statistics.executable_count).to be == 2 + expect(statistics.executed_count).to be == 1 + expect(statistics.total).to be_equal(total) + + statistics << complete_coverage + + expect(statistics.total).not.to be_equal(total) + expect(statistics.count).to be == 1 + expect(statistics.executable_count).to be == 2 + expect(statistics.executed_count).to be == 2 + end + end + + with "after adding coverage" do + let(:coverage) {Covered::Coverage.new(source, [nil, 1])} + + it "does not share mutable state with the original coverage" do + statistics << coverage + + coverage.mark(2, 1) + coverage.path = "bar.rb" + + expect(statistics.count).to be == 1 + expect(statistics["foo.rb"].counts).to be == [nil, 1] + expect(statistics.executable_count).to be == 1 + expect(statistics.executed_count).to be == 1 + end + end +end + +describe Covered::Statistics::Aggregate do + let(:source) {Covered::Source.new("foo.rb")} + let(:other_source) {Covered::Source.new("bar.rb")} + + with "multiple coverage objects" do + let(:complete_coverage) {Covered::Coverage.new(source, [nil, 1, 1])} + let(:other_coverage) {Covered::Coverage.new(other_source, [nil, 0])} + let(:aggregate) {subject.for([complete_coverage, other_coverage])} + + it "summarizes coverage" do + expect(aggregate.count).to be == 2 + expect(aggregate.executable_count).to be == 3 + expect(aggregate.executed_count).to be == 2 + end + end + + with "an aggregate" do + let(:coverage) {Covered::Coverage.new(source, [nil, 1])} + let(:aggregate) {subject.for([coverage])} + + it "can add coverage" do + aggregate << Covered::Coverage.new(other_source, [nil, 0]) + + expect(aggregate.count).to be == 2 + expect(aggregate.executable_count).to be == 2 + expect(aggregate.executed_count).to be == 1 + end + end end