From f10c598ab1465fe37211c717fcfb4e6d95b38074 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sat, 20 Jun 2026 17:01:02 +1200 Subject: [PATCH 1/4] Fix merged coverage statistics --- lib/covered/statistics.rb | 41 +++++++++++++++++++++++++++++--------- test/covered/statistics.rb | 21 +++++++++++++++++++ 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/lib/covered/statistics.rb b/lib/covered/statistics.rb index dbdd90b..d7459bd 100644 --- a/lib/covered/statistics.rb +++ b/lib/covered/statistics.rb @@ -36,7 +36,7 @@ def initialize end # Total number of files added. - # @returns [Integer] The number of coverage objects added. + # @returns [Integer] The number of covered files. attr :count # The number of lines which could have been executed. @@ -68,10 +68,17 @@ def to_json(options) # Add coverage to these aggregate statistics. # @parameter coverage [Covered::Coverage] The coverage object to add. def << coverage - @count += 1 - - @executable_count += coverage.executable_count - @executed_count += coverage.executed_count + add(1, coverage.executable_count, coverage.executed_count) + end + + # Add counts to these aggregate statistics. + # @parameter count [Integer] The number of files to add. + # @parameter executable_count [Integer] The number of executable lines to add. + # @parameter executed_count [Integer] The number of executed lines to add. + def add(count, executable_count, executed_count) + @count += count + @executable_count += executable_count + @executed_count += executed_count end end @@ -96,20 +103,36 @@ 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) + current = @paths[coverage.path] + count = 0 + + unless current + current = @paths[coverage.path] = coverage.empty + count = 1 + end + + executable_count = current.executable_count + executed_count = current.executed_count + + current.merge!(coverage) + + @total.add( + count, + current.executable_count - executable_count, + current.executed_count - executed_count + ) end # Get coverage for the given path. diff --git a/test/covered/statistics.rb b/test/covered/statistics.rb index 2cc5d11..656ff93 100644 --- a/test/covered/statistics.rb +++ b/test/covered/statistics.rb @@ -58,4 +58,25 @@ 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 end From 8fc3bd7a35d92f98c8e43d9f0e98b5616646e9e3 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sat, 20 Jun 2026 17:22:50 +1200 Subject: [PATCH 2/4] Refine statistics aggregate caching --- lib/covered/markdown_summary.rb | 6 +- lib/covered/statistics.rb | 119 +++++++++++++++++--------------- lib/covered/summary.rb | 6 +- test/covered/statistics.rb | 51 ++++++++++++++ 4 files changed, 122 insertions(+), 60 deletions(-) diff --git a/lib/covered/markdown_summary.rb b/lib/covered/markdown_summary.rb index 8ff15e1..ef7dfea 100644 --- a/lib/covered/markdown_summary.rb +++ b/lib/covered/markdown_summary.rb @@ -23,17 +23,17 @@ def initialize(threshold: 1.0) # @parameter coverage [Covered::Coverage] The coverage object below the threshold. # @returns [Covered::Statistics] Statistics for all coverage objects, including omitted ones. def each(wrapper) - statistics = Statistics.new + coverages = [] wrapper.each do |coverage| - statistics << coverage + coverages << coverage if @threshold.nil? or coverage.ratio < @threshold yield coverage end end - return statistics + return Statistics.new(Statistics::Aggregate.new(coverages)) end # Print any annotations for the given line. diff --git a/lib/covered/statistics.rb b/lib/covered/statistics.rb index d7459bd..3fb4ae9 100644 --- a/lib/covered/statistics.rb +++ b/lib/covered/statistics.rb @@ -19,20 +19,36 @@ class Statistics # @parameter coverage [Covered::Coverage] The coverage object to summarize. # @returns [Covered::Statistics] Statistics containing the given coverage. def self.for(coverage) - self.new.tap do |statistics| - statistics << coverage - end + self.new(Aggregate.new([coverage])) end - # Aggregate coverage totals. + # Immutable aggregate coverage statistics. class Aggregate include Ratio - # Initialize empty aggregate statistics. - def initialize - @count = 0 - @executable_count = 0 - @executed_count = 0 + # Initialize aggregate statistics from coverage objects. + # @parameter coverages [Enumerable(Covered::Coverage)] The coverage objects to summarize. + def initialize(coverages = []) + paths = Hash.new + + coverages.each do |coverage| + current = paths[coverage.path] + + unless current + current = paths[coverage.path] = coverage.empty + end + + current.merge!(coverage) + end + + paths.each_value(&:freeze) + @paths = paths.freeze + + @count = paths.size + @executable_count = paths.sum{|_path, coverage| coverage.executable_count} + @executed_count = paths.sum{|_path, coverage| coverage.executed_count} + + freeze end # Total number of files added. @@ -47,6 +63,23 @@ def initialize # @returns [Integer] The executed line count. attr :executed_count + # @attribute [Hash(String, Covered::Coverage)] Coverage statistics indexed by path. + attr :paths + + # Add coverage to a new aggregate statistics object. + # @parameter coverage [Covered::Coverage] The coverage object to add. + # @returns [Covered::Statistics::Aggregate] The new aggregate statistics. + def with(coverage) + self.class.new(@paths.values + [coverage]) + end + + # Get coverage for the given path. + # @parameter path [String] The source path. + # @returns [Covered::Coverage | Nil] The merged coverage for the path. + def [](path) + @paths[path] + end + # A JSON-compatible representation of these aggregate statistics. # @returns [Hash] The aggregate count, line counts and percentage. def as_json @@ -64,40 +97,33 @@ def as_json def to_json(options) as_json.to_json(options) end - - # Add coverage to these aggregate statistics. - # @parameter coverage [Covered::Coverage] The coverage object to add. - def << coverage - add(1, coverage.executable_count, coverage.executed_count) - end - - # Add counts to these aggregate statistics. - # @parameter count [Integer] The number of files to add. - # @parameter executable_count [Integer] The number of executable lines to add. - # @parameter executed_count [Integer] The number of executed lines to add. - def add(count, executable_count, executed_count) - @count += count - @executable_count += executable_count - @executed_count += executed_count - end end - # Initialize empty coverage statistics. - def initialize - @total = Aggregate.new - @paths = Hash.new + # Initialize coverage statistics. + # @parameter aggregate [Covered::Statistics::Aggregate] The aggregate coverage statistics. + def initialize(aggregate = Aggregate.new) + @aggregate = aggregate end - # @attribute [Covered::Statistics::Aggregate] The total aggregate statistics. - attr :total + # @attribute [Covered::Statistics::Aggregate] The aggregate coverage statistics. + attr :aggregate - # @attribute [Hash(String, Covered::Coverage)] Coverage statistics indexed by path. - attr :paths + # The total aggregate statistics. + # @returns [Covered::Statistics::Aggregate] The total aggregate statistics. + def total + @aggregate + end + + # Coverage statistics indexed by path. + # @returns [Hash(String, Covered::Coverage)] The coverage statistics indexed by path. + def paths + @aggregate.paths + end # The number of unique paths with coverage data. # @returns [Integer] The number of unique paths. def count - @paths.size + @aggregate.count end # The total number of executable lines. @@ -115,31 +141,16 @@ def executed_count # Add coverage to these statistics. # @parameter coverage [Covered::Coverage] The coverage object to add. def << coverage - current = @paths[coverage.path] - count = 0 - - unless current - current = @paths[coverage.path] = coverage.empty - count = 1 - end - - executable_count = current.executable_count - executed_count = current.executed_count - - current.merge!(coverage) + @aggregate = @aggregate.with(coverage) - @total.add( - count, - current.executable_count - executable_count, - current.executed_count - executed_count - ) + return self end # Get coverage for the given path. # @parameter path [String] The source path. # @returns [Covered::Coverage | Nil] The merged coverage for the path. def [](path) - @paths[path] + @aggregate[path] end # A JSON-compatible representation of these statistics. @@ -147,7 +158,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 @@ -174,7 +185,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/lib/covered/summary.rb b/lib/covered/summary.rb index 8dcc2e6..20818f3 100644 --- a/lib/covered/summary.rb +++ b/lib/covered/summary.rb @@ -45,17 +45,17 @@ def terminal(output) # @parameter coverage [Covered::Coverage] The coverage object below the threshold. # @returns [Covered::Statistics] Statistics for all coverage objects, including omitted ones. def each(wrapper) - statistics = Statistics.new + coverages = [] wrapper.each do |coverage| - statistics << coverage + coverages << coverage if @threshold.nil? or coverage.ratio < @threshold yield coverage end end - return statistics + return Statistics.new(Statistics::Aggregate.new(coverages)) end # Print any annotations for the given line. diff --git a/test/covered/statistics.rb b/test/covered/statistics.rb index 656ff93..6be002a 100644 --- a/test/covered/statistics.rb +++ b/test/covered/statistics.rb @@ -80,3 +80,54 @@ def before 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(:partial_coverage) {Covered::Coverage.new(source, [nil, 1, 0])} + let(:other_coverage) {Covered::Coverage.new(other_source, [nil, 0])} + let(:aggregate) {subject.new([complete_coverage, partial_coverage, other_coverage])} + + it "merges coverage for the same path" do + expect(aggregate.count).to be == 2 + expect(aggregate.executable_count).to be == 3 + expect(aggregate.executed_count).to be == 2 + end + + it "indexes merged coverage by path" do + expect(aggregate["foo.rb"].counts).to be == [nil, 2, 1] + expect(aggregate["bar.rb"].counts).to be == [nil, 0] + end + end + + with "an existing aggregate" do + let(:coverage) {Covered::Coverage.new(source, [nil, 1])} + let(:other_coverage) {Covered::Coverage.new(other_source, [nil, 0])} + let(:aggregate) {subject.new([coverage])} + + it "is immutable" do + expect(aggregate).to be(:frozen?) + expect(aggregate.paths).to be(:frozen?) + expect(aggregate["foo.rb"]).to be(:frozen?) + + expect do + aggregate.paths["bar.rb"] = other_coverage + end.to raise_exception(FrozenError) + end + + it "returns a new aggregate when adding coverage" do + next_aggregate = aggregate.with(other_coverage) + + expect(aggregate.count).to be == 1 + expect(aggregate.executable_count).to be == 1 + expect(aggregate.executed_count).to be == 1 + + expect(next_aggregate.count).to be == 2 + expect(next_aggregate.executable_count).to be == 2 + expect(next_aggregate.executed_count).to be == 1 + end + end +end From 29e232c420b950a9e8fba0994a974dc20079ee52 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sat, 20 Jun 2026 21:13:59 +1200 Subject: [PATCH 3/4] Narrow statistics cache invalidation --- lib/covered/markdown_summary.rb | 6 ++-- lib/covered/statistics.rb | 56 +++++++++++++-------------------- lib/covered/summary.rb | 6 ++-- test/covered/statistics.rb | 42 +++++++++++-------------- 4 files changed, 45 insertions(+), 65 deletions(-) diff --git a/lib/covered/markdown_summary.rb b/lib/covered/markdown_summary.rb index ef7dfea..8ff15e1 100644 --- a/lib/covered/markdown_summary.rb +++ b/lib/covered/markdown_summary.rb @@ -23,17 +23,17 @@ def initialize(threshold: 1.0) # @parameter coverage [Covered::Coverage] The coverage object below the threshold. # @returns [Covered::Statistics] Statistics for all coverage objects, including omitted ones. def each(wrapper) - coverages = [] + statistics = Statistics.new wrapper.each do |coverage| - coverages << coverage + statistics << coverage if @threshold.nil? or coverage.ratio < @threshold yield coverage end end - return Statistics.new(Statistics::Aggregate.new(coverages)) + return statistics end # Print any annotations for the given line. diff --git a/lib/covered/statistics.rb b/lib/covered/statistics.rb index 3fb4ae9..cadb5bb 100644 --- a/lib/covered/statistics.rb +++ b/lib/covered/statistics.rb @@ -19,7 +19,9 @@ class Statistics # @parameter coverage [Covered::Coverage] The coverage object to summarize. # @returns [Covered::Statistics] Statistics containing the given coverage. def self.for(coverage) - self.new(Aggregate.new([coverage])) + self.new.tap do |statistics| + statistics << coverage + end end # Immutable aggregate coverage statistics. @@ -42,7 +44,6 @@ def initialize(coverages = []) end paths.each_value(&:freeze) - @paths = paths.freeze @count = paths.size @executable_count = paths.sum{|_path, coverage| coverage.executable_count} @@ -63,23 +64,6 @@ def initialize(coverages = []) # @returns [Integer] The executed line count. attr :executed_count - # @attribute [Hash(String, Covered::Coverage)] Coverage statistics indexed by path. - attr :paths - - # Add coverage to a new aggregate statistics object. - # @parameter coverage [Covered::Coverage] The coverage object to add. - # @returns [Covered::Statistics::Aggregate] The new aggregate statistics. - def with(coverage) - self.class.new(@paths.values + [coverage]) - end - - # Get coverage for the given path. - # @parameter path [String] The source path. - # @returns [Covered::Coverage | Nil] The merged coverage for the path. - def [](path) - @paths[path] - end - # A JSON-compatible representation of these aggregate statistics. # @returns [Hash] The aggregate count, line counts and percentage. def as_json @@ -99,31 +83,25 @@ def to_json(options) end end - # Initialize coverage statistics. - # @parameter aggregate [Covered::Statistics::Aggregate] The aggregate coverage statistics. - def initialize(aggregate = Aggregate.new) - @aggregate = aggregate + # Initialize empty coverage statistics. + def initialize + @total = nil + @paths = Hash.new end - # @attribute [Covered::Statistics::Aggregate] The aggregate coverage statistics. - attr :aggregate - # The total aggregate statistics. # @returns [Covered::Statistics::Aggregate] The total aggregate statistics. def total - @aggregate + @total ||= Aggregate.new(@paths.values) end - # Coverage statistics indexed by path. - # @returns [Hash(String, Covered::Coverage)] The coverage statistics indexed by path. - def paths - @aggregate.paths - end + # @attribute [Hash(String, Covered::Coverage)] Coverage statistics indexed by path. + attr :paths # The number of unique paths with coverage data. # @returns [Integer] The number of unique paths. def count - @aggregate.count + @paths.size end # The total number of executable lines. @@ -141,7 +119,15 @@ def executed_count # Add coverage to these statistics. # @parameter coverage [Covered::Coverage] The coverage object to add. def << coverage - @aggregate = @aggregate.with(coverage) + current = @paths[coverage.path] + + unless current + current = @paths[coverage.path] = coverage.empty + end + + current.merge!(coverage) + + @total = nil return self end @@ -150,7 +136,7 @@ def << coverage # @parameter path [String] The source path. # @returns [Covered::Coverage | Nil] The merged coverage for the path. def [](path) - @aggregate[path] + @paths[path] end # A JSON-compatible representation of these statistics. diff --git a/lib/covered/summary.rb b/lib/covered/summary.rb index 20818f3..8dcc2e6 100644 --- a/lib/covered/summary.rb +++ b/lib/covered/summary.rb @@ -45,17 +45,17 @@ def terminal(output) # @parameter coverage [Covered::Coverage] The coverage object below the threshold. # @returns [Covered::Statistics] Statistics for all coverage objects, including omitted ones. def each(wrapper) - coverages = [] + statistics = Statistics.new wrapper.each do |coverage| - coverages << coverage + statistics << coverage if @threshold.nil? or coverage.ratio < @threshold yield coverage end end - return Statistics.new(Statistics::Aggregate.new(coverages)) + return statistics end # Print any annotations for the given line. diff --git a/test/covered/statistics.rb b/test/covered/statistics.rb index 6be002a..0bad60e 100644 --- a/test/covered/statistics.rb +++ b/test/covered/statistics.rb @@ -79,6 +79,24 @@ def before 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])} + + def before + statistics << partial_coverage + statistics.total + statistics << complete_coverage + super + end + + it "invalidates cached totals" do + expect(statistics.count).to be == 1 + expect(statistics.executable_count).to be == 2 + expect(statistics.executed_count).to be == 1 + end + end end describe Covered::Statistics::Aggregate do @@ -96,38 +114,14 @@ def before expect(aggregate.executable_count).to be == 3 expect(aggregate.executed_count).to be == 2 end - - it "indexes merged coverage by path" do - expect(aggregate["foo.rb"].counts).to be == [nil, 2, 1] - expect(aggregate["bar.rb"].counts).to be == [nil, 0] - end end with "an existing aggregate" do let(:coverage) {Covered::Coverage.new(source, [nil, 1])} - let(:other_coverage) {Covered::Coverage.new(other_source, [nil, 0])} let(:aggregate) {subject.new([coverage])} it "is immutable" do expect(aggregate).to be(:frozen?) - expect(aggregate.paths).to be(:frozen?) - expect(aggregate["foo.rb"]).to be(:frozen?) - - expect do - aggregate.paths["bar.rb"] = other_coverage - end.to raise_exception(FrozenError) - end - - it "returns a new aggregate when adding coverage" do - next_aggregate = aggregate.with(other_coverage) - - expect(aggregate.count).to be == 1 - expect(aggregate.executable_count).to be == 1 - expect(aggregate.executed_count).to be == 1 - - expect(next_aggregate.count).to be == 2 - expect(next_aggregate.executable_count).to be == 2 - expect(next_aggregate.executed_count).to be == 1 end end end From 645aa383c20152954c7786e45c3ce712c280d5b6 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sat, 20 Jun 2026 21:16:08 +1200 Subject: [PATCH 4/4] Use coverage dup for statistics aggregation --- lib/covered/coverage.rb | 10 ++++++ lib/covered/statistics.rb | 69 ++++++++++++++++++++------------------ test/covered/coverage.rb | 35 +++++++++++++++++++ test/covered/statistics.rb | 60 +++++++++++++++++++++++++++------ 4 files changed, 131 insertions(+), 43 deletions(-) create mode 100644 test/covered/coverage.rb 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 cadb5bb..79ec8dd 100644 --- a/lib/covered/statistics.rb +++ b/lib/covered/statistics.rb @@ -24,36 +24,29 @@ def self.for(coverage) end end - # Immutable aggregate coverage statistics. + # Aggregate coverage totals. class Aggregate include Ratio - # Initialize aggregate statistics from coverage objects. + # Build aggregate statistics from coverage objects. # @parameter coverages [Enumerable(Covered::Coverage)] The coverage objects to summarize. - def initialize(coverages = []) - paths = Hash.new - - coverages.each do |coverage| - current = paths[coverage.path] - - unless current - current = paths[coverage.path] = coverage.empty + # @returns [Covered::Statistics::Aggregate] The aggregate statistics. + def self.for(coverages) + self.new.tap do |aggregate| + coverages.each do |coverage| + aggregate << coverage end - - current.merge!(coverage) end - - paths.each_value(&:freeze) - - @count = paths.size - @executable_count = paths.sum{|_path, coverage| coverage.executable_count} - @executed_count = paths.sum{|_path, coverage| coverage.executed_count} - - freeze end - # Total number of files added. - # @returns [Integer] The number of covered files. + # Initialize empty aggregate statistics. + def initialize + @count = 0 + @executable_count = 0 + @executed_count = 0 + end + + # @attribute [Integer] The total number of coverage instances added. attr :count # The number of lines which could have been executed. @@ -81,6 +74,18 @@ def as_json def to_json(options) as_json.to_json(options) end + + # 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. @@ -92,7 +97,7 @@ def initialize # The total aggregate statistics. # @returns [Covered::Statistics::Aggregate] The total aggregate statistics. def total - @total ||= Aggregate.new(@paths.values) + @total ||= Aggregate.for(@paths.values) end # @attribute [Hash(String, Covered::Coverage)] Coverage statistics indexed by path. @@ -119,17 +124,17 @@ def executed_count # Add coverage to these statistics. # @parameter coverage [Covered::Coverage] The coverage object to add. def << coverage - current = @paths[coverage.path] - - unless current - current = @paths[coverage.path] = coverage.empty + if current = @paths[coverage.path] + current.merge!(coverage) + + @total = nil + else + coverage = @paths[coverage.path] = coverage.dup + + @total << coverage if @total end - current.merge!(coverage) - - @total = nil - - return self + self end # Get coverage for the given path. 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 0bad60e..c60f6fe 100644 --- a/test/covered/statistics.rb +++ b/test/covered/statistics.rb @@ -83,18 +83,53 @@ def before 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])} - def before + it "adds new paths to cached totals" do statistics << partial_coverage - statistics.total - statistics << complete_coverage - super + + 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 @@ -105,23 +140,26 @@ def before with "multiple coverage objects" do let(:complete_coverage) {Covered::Coverage.new(source, [nil, 1, 1])} - let(:partial_coverage) {Covered::Coverage.new(source, [nil, 1, 0])} let(:other_coverage) {Covered::Coverage.new(other_source, [nil, 0])} - let(:aggregate) {subject.new([complete_coverage, partial_coverage, other_coverage])} + let(:aggregate) {subject.for([complete_coverage, other_coverage])} - it "merges coverage for the same path" do + 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 existing aggregate" do + with "an aggregate" do let(:coverage) {Covered::Coverage.new(source, [nil, 1])} - let(:aggregate) {subject.new([coverage])} + let(:aggregate) {subject.for([coverage])} - it "is immutable" do - expect(aggregate).to be(:frozen?) + 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