reportable 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,163 @@
1
+ module Saulabs
2
+
3
+ module Reportable
4
+
5
+ # The +ReportCache+ class is a regular +ActiveRecord+ model and represents cached results for single {Saulabs::Reportable::ReportingPeriod}s.
6
+ # +ReportCache+ instances are identified by the combination of +model_name+, +report_name+, +grouping+, +aggregation+ and +reporting_period+.
7
+ #
8
+ class ReportCache < ActiveRecord::Base
9
+
10
+ set_table_name :reportable_cache
11
+
12
+ self.skip_time_zone_conversion_for_attributes = [:reporting_period]
13
+
14
+ # Clears the cache for the specified +klass+ and +report+
15
+ #
16
+ # @param [Class] klass
17
+ # the model the report to clear the cache for works on
18
+ # @param [Symbol] report
19
+ # the name of the report to clear the cache for
20
+ #
21
+ # @example Clearing the cache for a report
22
+ #
23
+ # class User < ActiveRecord::Base
24
+ # reportable :registrations
25
+ # end
26
+ #
27
+ # Saulabs::Reportable::ReportCache.clear_for(User, :registrations)
28
+ #
29
+ def self.clear_for(klass, report)
30
+ self.delete_all(:conditions => {
31
+ :model_name => klass.name,
32
+ :report_name => report.to_s
33
+ })
34
+ end
35
+
36
+ # Processes the report using the respective cache.
37
+ #
38
+ # @param [Saulabe::Reportable::Report] report
39
+ # the report to process
40
+ # @param [Hash] options
41
+ # options for the report
42
+ #
43
+ # @option options [Symbol] :grouping (:day)
44
+ # the period records are grouped in (+:hour+, +:day+, +:week+, +:month+); <b>Beware that <tt>reportable</tt> treats weeks as starting on monday!</b>
45
+ # @option options [Fixnum] :limit (100)
46
+ # the number of reporting periods to get (see +:grouping+)
47
+ # @option options [Hash] :conditions ({})
48
+ # conditions like in +ActiveRecord::Base#find+; only records that match these conditions are reported;
49
+ # @option options [Boolean] :live_data (false)
50
+ # specifies whether data for the current reporting period is to be read; <b>if +:live_data+ is +true+, you will experience a performance hit since the request cannot be satisfied from the cache alone</b>
51
+ # @option options [DateTime, Boolean] :end_date (false)
52
+ # when specified, the report will only include data for the +:limit+ reporting periods until this date.
53
+ #
54
+ # @return [Array<Array<DateTime, Float>>]
55
+ # the result of the report as pairs of {DateTime}s and {Float}s
56
+ #
57
+ def self.process(report, options, &block)
58
+ raise ArgumentError.new('A block must be given') unless block_given?
59
+ self.transaction do
60
+ cached_data = read_cached_data(report, options)
61
+ new_data = read_new_data(cached_data, options, &block)
62
+ prepare_result(new_data, cached_data, report, options)
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ def self.prepare_result(new_data, cached_data, report, options)
69
+ new_data = new_data.map { |data| [ReportingPeriod.from_db_string(options[:grouping], data[0]), data[1]] }
70
+ cached_data.map! { |cached| [ReportingPeriod.new(options[:grouping], cached.reporting_period), cached.value] }
71
+ current_reporting_period = ReportingPeriod.new(options[:grouping])
72
+ reporting_period = get_first_reporting_period(options)
73
+ result = []
74
+ while reporting_period < (options[:end_date] ? ReportingPeriod.new(options[:grouping], options[:end_date]).next : current_reporting_period)
75
+ if cached = cached_data.find { |cached| reporting_period == cached[0] }
76
+ result << [cached[0].date_time, cached[1]]
77
+ else
78
+ new_cached = build_cached_data(report, options[:grouping], options[:conditions], reporting_period, find_value(new_data, reporting_period))
79
+ new_cached.save!
80
+ result << [reporting_period.date_time, new_cached.value]
81
+ end
82
+ reporting_period = reporting_period.next
83
+ end
84
+ if options[:live_data]
85
+ result << [current_reporting_period.date_time, find_value(new_data, current_reporting_period)]
86
+ end
87
+ result
88
+ end
89
+
90
+ def self.find_value(data, reporting_period)
91
+ data = data.detect { |d| d[0] == reporting_period }
92
+ data ? data[1] : 0.0
93
+ end
94
+
95
+ def self.build_cached_data(report, grouping, condition, reporting_period, value)
96
+ self.new(
97
+ :model_name => report.klass.to_s,
98
+ :report_name => report.name.to_s,
99
+ :grouping => grouping.identifier.to_s,
100
+ :aggregation => report.aggregation.to_s,
101
+ :condition => condition.to_s,
102
+ :reporting_period => reporting_period.date_time,
103
+ :value => value
104
+ )
105
+ end
106
+
107
+ def self.read_cached_data(report, options)
108
+ conditions = [
109
+ 'model_name = ? AND report_name = ? AND grouping = ? AND aggregation = ? AND `condition` = ?',
110
+ report.klass.to_s,
111
+ report.name.to_s,
112
+ options[:grouping].identifier.to_s,
113
+ report.aggregation.to_s,
114
+ options[:conditions].to_s
115
+ ]
116
+ first_reporting_period = get_first_reporting_period(options)
117
+ last_reporting_period = get_last_reporting_period(options)
118
+ if last_reporting_period
119
+ conditions.first << ' AND reporting_period BETWEEN ? AND ?'
120
+ conditions << first_reporting_period.date_time
121
+ conditions << last_reporting_period.date_time
122
+ else
123
+ conditions.first << ' AND reporting_period >= ?'
124
+ conditions << first_reporting_period.date_time
125
+ end
126
+ self.all(
127
+ :conditions => conditions,
128
+ :limit => options[:limit],
129
+ :order => 'reporting_period ASC'
130
+ )
131
+ end
132
+
133
+ def self.read_new_data(cached_data, options, &block)
134
+ if !options[:live_data] && cached_data.length == options[:limit]
135
+ []
136
+ else
137
+ first_reporting_period_to_read = if cached_data.length < options[:limit]
138
+ get_first_reporting_period(options)
139
+ else
140
+ ReportingPeriod.new(options[:grouping], cached_data.last.reporting_period).next
141
+ end
142
+ last_reporting_period_to_read = options[:end_date] ? ReportingPeriod.new(options[:grouping], options[:end_date]).last_date_time : nil
143
+ yield(first_reporting_period_to_read.date_time, last_reporting_period_to_read)
144
+ end
145
+ end
146
+
147
+ def self.get_first_reporting_period(options)
148
+ if options[:end_date]
149
+ ReportingPeriod.first(options[:grouping], options[:limit] - 1, options[:end_date])
150
+ else
151
+ ReportingPeriod.first(options[:grouping], options[:limit])
152
+ end
153
+ end
154
+
155
+ def self.get_last_reporting_period(options)
156
+ return ReportingPeriod.new(options[:grouping], options[:end_date]) if options[:end_date]
157
+ end
158
+
159
+ end
160
+
161
+ end
162
+
163
+ end
@@ -0,0 +1,181 @@
1
+ module Saulabs
2
+
3
+ module Reportable
4
+
5
+ # A reporting period is a specific hour or a specific day etc. depending on the used {Saulabs::Reportable::Grouping}.
6
+ #
7
+ class ReportingPeriod
8
+
9
+ # The actual +DateTime the reporting period represents
10
+ #
11
+ attr_reader :date_time
12
+
13
+ # The {Saulabs::Reportable::Grouping} of the reporting period
14
+ #
15
+ attr_reader :grouping
16
+
17
+ # Initializes a new reporting period.
18
+ #
19
+ # @param [Saulabs::Reportable::Grouping] grouping
20
+ # the grouping the generate the reporting period for
21
+ # @param [DateTime] date_time
22
+ # the +DateTime+ to generate the reporting period for
23
+ #
24
+ def initialize(grouping, date_time = nil)
25
+ @grouping = grouping
26
+ @date_time = parse_date_time(date_time || DateTime.now)
27
+ end
28
+
29
+ # Gets a reporting period relative to the current one.
30
+ #
31
+ # @param [Fixnum] offset
32
+ # the offset to get the reporting period for
33
+ #
34
+ # @return [Saulabs::Reportable::ReportingPeriod]
35
+ # the reporting period relative by offset to the current one
36
+ #
37
+ # @example Getting the reporting period one week later
38
+ #
39
+ # reporting_period = Saulabs::Reportable::ReportingPeriod.new(:week, DateTime.now)
40
+ # next_week = reporting_period.offset(1)
41
+ #
42
+ def offset(offset)
43
+ self.class.new(@grouping, @date_time + offset.send(@grouping.identifier))
44
+ end
45
+
46
+ # Gets the first reporting period for a grouping and a limit (optionally relative to and end date).
47
+ #
48
+ # @param [Saulabs::ReportingPeriod::Grouping] grouping
49
+ # the grouping to get the first reporting period for
50
+ # @param [Fixnum] limit
51
+ # the limit to get the first reporting period for
52
+ # @param [DateTime] end_date
53
+ # the end date to get the first reporting period for (the first reporting period is then +end_date+ - +limit+ * +grouping+)
54
+ #
55
+ # @return [Saulabs::Reportable::ReportingPeriod]
56
+ # the first reporting period for the grouping, limit and optionally end date
57
+ #
58
+ def self.first(grouping, limit, end_date = nil)
59
+ self.new(grouping, end_date).offset(-limit)
60
+ end
61
+
62
+ # Gets a reporting period from a DB date string.
63
+ #
64
+ # @param [Saulabs::Reportable::Grouping] grouping
65
+ # the grouping to get the reporting period for
66
+ # @param [String] db_string
67
+ # the DB string to parse and get the reporting period for
68
+ #
69
+ # @return [Saulabs::Reportable::ReportingPeriod]
70
+ # the reporting period for the {Saulabs::Reportable::Grouping} as parsed from the db string
71
+ #
72
+ def self.from_db_string(grouping, db_string)
73
+ parts = grouping.date_parts_from_db_string(db_string)
74
+ result = case grouping.identifier
75
+ when :hour
76
+ self.new(grouping, DateTime.new(parts[0], parts[1], parts[2], parts[3], 0, 0))
77
+ when :day
78
+ self.new(grouping, Date.new(parts[0], parts[1], parts[2]))
79
+ when :week
80
+ self.new(grouping, Date.commercial(parts[0], parts[1], 1))
81
+ when :month
82
+ self.new(grouping, Date.new(parts[0], parts[1], 1))
83
+ end
84
+ result
85
+ end
86
+
87
+ # Gets the next reporting period.
88
+ #
89
+ # @return [Saulabs::Reportable::ReportingPeriod]
90
+ # the reporting period after the current one
91
+ #
92
+ def next
93
+ self.offset(1)
94
+ end
95
+
96
+ # Gets the previous reporting period.
97
+ #
98
+ # @return [Saulabs::Reportable::ReportingPeriod]
99
+ # the reporting period before the current one
100
+ #
101
+ def previous
102
+ self.offset(-1)
103
+ end
104
+
105
+ # Gets whether the reporting period +other+ is equal to the current one.
106
+ #
107
+ # @param [Saulabs::Reportable::ReportingPeriod] other
108
+ # the reporting period to check for whether it is equal to the current one
109
+ #
110
+ # @return [Boolean]
111
+ # true if +other+ is equal to the current reporting period, false otherwise
112
+ #
113
+ def ==(other)
114
+ if other.is_a?(Saulabs::Reportable::ReportingPeriod)
115
+ @date_time.to_s == other.date_time.to_s && @grouping.identifier.to_s == other.grouping.identifier.to_s
116
+ elsif other.is_a?(Time) || other.is_a?(DateTime)
117
+ @date_time == parse_date_time(other)
118
+ else
119
+ raise ArgumentError.new("Can only compare instances of #{self.class.name}")
120
+ end
121
+ end
122
+
123
+ # Gets whether the reporting period +other+ is smaller to the current one.
124
+ #
125
+ # @param [Saulabs::Reportable::ReportingPeriod] other
126
+ # the reporting period to check for whether it is smaller to the current one
127
+ #
128
+ # @return [Boolean]
129
+ # true if +other+ is smaller to the current reporting period, false otherwise
130
+ #
131
+ def <(other)
132
+ if other.is_a?(Saulabs::Reportable::ReportingPeriod)
133
+ return @date_time < other.date_time
134
+ elsif other.is_a?(Time) || other.is_a?(DateTime)
135
+ @date_time < parse_date_time(other)
136
+ else
137
+ raise ArgumentError.new("Can only compare instances of #{self.class.name}")
138
+ end
139
+ end
140
+
141
+ # Gets the latest point in time that is included the reporting period. The latest point in time included in a reporting period
142
+ # for grouping hour would be that hour and 59 minutes and 59 seconds.
143
+ #
144
+ # @return [DateTime]
145
+ # the latest point in time that is included in the reporting period
146
+ #
147
+ def last_date_time
148
+ case @grouping.identifier
149
+ when :hour
150
+ DateTime.new(@date_time.year, @date_time.month, @date_time.day, @date_time.hour, 59, 59)
151
+ when :day
152
+ DateTime.new(@date_time.year, @date_time.month, @date_time.day, 23, 59, 59)
153
+ when :week
154
+ date_time = (@date_time - @date_time.wday.days) + 7.days
155
+ Date.new(date_time.year, date_time.month, date_time.day)
156
+ when :month
157
+ Date.new(@date_time.year, @date_time.month, (Date.new(@date_time.year, 12, 31) << (12 - @date_time.month)).day)
158
+ end
159
+ end
160
+
161
+ private
162
+
163
+ def parse_date_time(date_time)
164
+ case @grouping.identifier
165
+ when :hour
166
+ DateTime.new(date_time.year, date_time.month, date_time.day, date_time.hour)
167
+ when :day
168
+ date_time.to_date
169
+ when :week
170
+ date_time = (date_time - date_time.wday.days) + 1.day
171
+ Date.new(date_time.year, date_time.month, date_time.day)
172
+ when :month
173
+ Date.new(date_time.year, date_time.month, 1)
174
+ end
175
+ end
176
+
177
+ end
178
+
179
+ end
180
+
181
+ end
@@ -0,0 +1,62 @@
1
+ module Saulabs
2
+
3
+ module Reportable
4
+
5
+ module SparklineTagHelper
6
+
7
+ # Renders a sparkline with the given data.
8
+ #
9
+ # @param [Array<Array<DateTime, Float>>] data
10
+ # an array of report data as returned by {Saulabs::Reportable::Report#run}
11
+ # @param [Hash] options
12
+ # options for the sparkline
13
+ #
14
+ # @option options [Fixnum] :width (300)
15
+ # the width of the generated image
16
+ # @option options [Fixnum] :height (34)
17
+ # the height of the generated image
18
+ # @option options [String] :line_color ('0077cc')
19
+ # the line color of the generated image
20
+ # @option options [String] :fill_color ('e6f2fa')
21
+ # the fill color of the generated image
22
+ # @option options [Array<Symbol>] :labels ([])
23
+ # the axes to render lables for (Array of +:x+, +:y+, +:r+, +:t+; this is x axis, y axis, right, top)
24
+ # @option options [String] :alt ('')
25
+ # the alt attribute for the generated image
26
+ # @option options [String] :title ('')
27
+ # the title attribute for the generated image
28
+ #
29
+ # @return [String]
30
+ # an image tag showing a sparkline for the passed +data+
31
+ #
32
+ # @example Rendering a sparkline tag for report data
33
+ #
34
+ # <%= sparkline_tag(User.registrations_report, :width => 200, :height => 100, :color => '000') %>
35
+ #
36
+ def sparkline_tag(data, options = {})
37
+ options.reverse_merge!({ :width => 300, :height => 34, :line_color => '0077cc', :fill_color => 'e6f2fa', :labels => [], :alt => '', :title => '' })
38
+ data = data.collect { |d| d[1] }
39
+ labels = ''
40
+ unless options[:labels].empty?
41
+ chxr = {}
42
+ options[:labels].each_with_index do |l, i|
43
+ chxr[l] = "#{i}," + ([:x, :t].include?(l) ? "0,#{data.length}" : "#{[data.min, 0].min},#{data.max}")
44
+ end
45
+ labels = "&chxt=#{options[:labels].map(&:to_s).join(',')}&chxr=#{options[:labels].collect{|l| chxr[l]}.join('|')}"
46
+ end
47
+ title = ''
48
+ unless options[:title].empty?
49
+ title = "&chtt=#{options[:title]}"
50
+ end
51
+ image_tag(
52
+ "http://chart.apis.google.com/chart?cht=ls&chs=#{options[:width]}x#{options[:height]}&chd=t:#{data.join(',')}&chco=#{options[:line_color]}&chm=B,#{options[:fill_color]},0,0,0&chls=1,0,0&chds=#{data.min},#{data.max}#{labels}#{title}",
53
+ :alt => options[:alt],
54
+ :title => options[:title]
55
+ )
56
+ end
57
+
58
+ end
59
+
60
+ end
61
+
62
+ end
@@ -0,0 +1,9 @@
1
+ require 'saulabs/reportable'
2
+
3
+ ActiveRecord::Base.class_eval do
4
+ include Saulabs::Reportable
5
+ end
6
+
7
+ ActionView::Base.class_eval do
8
+ include Saulabs::Reportable::SparklineTagHelper
9
+ end
@@ -0,0 +1,25 @@
1
+ plugin_root = File.join(File.dirname(__FILE__), '..')
2
+
3
+ gem 'rails'
4
+ require 'active_record'
5
+ require 'active_support'
6
+ require 'action_controller'
7
+ require 'action_view'
8
+
9
+ $:.unshift "#{plugin_root}/lib"
10
+
11
+ RAILS_ROOT = File.expand_path(File.dirname(__FILE__) + '/../')
12
+ Rails::Initializer.run(:set_load_path)
13
+ Rails::Initializer.run(:set_autoload_paths)
14
+ Rails::Initializer.run(:initialize_time_zone) do |config|
15
+ config.time_zone = 'Pacific Time (US & Canada)'
16
+ end
17
+
18
+ require File.join(File.dirname(__FILE__), '..', 'rails', 'init.rb')
19
+
20
+ FileUtils.mkdir_p File.join(File.dirname(__FILE__), 'log')
21
+ ActiveRecord::Base.logger = Logger.new(File.join(File.dirname(__FILE__), 'log', 'spec.log'))
22
+
23
+ databases = YAML::load(IO.read(File.join(File.dirname(__FILE__), 'db', 'database.yml')))
24
+ ActiveRecord::Base.establish_connection(databases['sqlite3'])
25
+ load(File.join(File.dirname(__FILE__), 'db', 'schema.rb'))
@@ -0,0 +1,169 @@
1
+ require File.join(File.dirname(__FILE__), '..', 'spec_helper')
2
+
3
+ describe Saulabs::Reportable::CumulatedReport do
4
+
5
+ before do
6
+ @report = Saulabs::Reportable::CumulatedReport.new(User, :cumulated_registrations)
7
+ end
8
+
9
+ describe '#run' do
10
+
11
+ it 'should cumulate the data' do
12
+ @report.should_receive(:cumulate).once
13
+
14
+ @report.run
15
+ end
16
+
17
+ it 'should return an array of the same length as the specified limit when :live_data is false' do
18
+ @report = Saulabs::Reportable::CumulatedReport.new(User, :cumulated_registrations, :limit => 10, :live_data => false)
19
+
20
+ @report.run.length.should == 10
21
+ end
22
+
23
+ it 'should return an array of the same length as the specified limit + 1 when :live_data is true' do
24
+ @report = Saulabs::Reportable::CumulatedReport.new(User, :cumulated_registrations, :limit => 10, :live_data => true)
25
+
26
+ @report.run.length.should == 11
27
+ end
28
+
29
+ for grouping in [:hour, :day, :week, :month] do
30
+
31
+ describe "for grouping #{grouping.to_s}" do
32
+
33
+ [true, false].each do |live_data|
34
+
35
+ describe "with :live_data = #{live_data}" do
36
+
37
+ before(:all) do
38
+ User.delete_all
39
+ User.create!(:login => 'test 1', :created_at => Time.now, :profile_visits => 2)
40
+ User.create!(:login => 'test 2', :created_at => Time.now - 1.send(grouping), :profile_visits => 1)
41
+ User.create!(:login => 'test 3', :created_at => Time.now - 3.send(grouping), :profile_visits => 2)
42
+ User.create!(:login => 'test 4', :created_at => Time.now - 3.send(grouping), :profile_visits => 3)
43
+ end
44
+
45
+ describe 'the returned result' do
46
+
47
+ before do
48
+ @grouping = Saulabs::Reportable::Grouping.new(grouping)
49
+ @report = Saulabs::Reportable::CumulatedReport.new(User, :cumulated_registrations,
50
+ :grouping => grouping,
51
+ :limit => 10,
52
+ :live_data => live_data
53
+ )
54
+ @result = @report.run
55
+ end
56
+
57
+ it "should be an array starting reporting period (Time.now - limit.#{grouping.to_s})" do
58
+ @result.first[0].should == Saulabs::Reportable::ReportingPeriod.new(@grouping, Time.now - 10.send(grouping)).date_time
59
+ end
60
+
61
+ if live_data
62
+ it "should be data ending with the current reporting period" do
63
+ @result.last[0].should == Saulabs::Reportable::ReportingPeriod.new(@grouping).date_time
64
+ end
65
+ else
66
+ it "should be data ending with the reporting period before the current" do
67
+ @result.last[0].should == Saulabs::Reportable::ReportingPeriod.new(@grouping).previous.date_time
68
+ end
69
+ end
70
+
71
+ end
72
+
73
+ it 'should return correct data for aggregation :count' do
74
+ @report = Saulabs::Reportable::CumulatedReport.new(User, :registrations,
75
+ :aggregation => :count,
76
+ :grouping => grouping,
77
+ :limit => 10,
78
+ :live_data => live_data
79
+ )
80
+ result = @report.run
81
+
82
+ result[10][1].should == 4.0 if live_data
83
+ result[9][1].should == 3.0
84
+ result[8][1].should == 2.0
85
+ result[7][1].should == 2.0
86
+ result[6][1].should == 0.0
87
+ end
88
+
89
+ it 'should return correct data for aggregation :sum' do
90
+ @report = Saulabs::Reportable::CumulatedReport.new(User, :registrations,
91
+ :aggregation => :sum,
92
+ :grouping => grouping,
93
+ :value_column => :profile_visits,
94
+ :limit => 10,
95
+ :live_data => live_data
96
+ )
97
+ result = @report.run()
98
+
99
+ result[10][1].should == 8.0 if live_data
100
+ result[9][1].should == 6.0
101
+ result[8][1].should == 5.0
102
+ result[7][1].should == 5.0
103
+ result[6][1].should == 0.0
104
+ end
105
+
106
+ it 'should return correct data for aggregation :count when custom conditions are specified' do
107
+ @report = Saulabs::Reportable::CumulatedReport.new(User, :registrations,
108
+ :aggregation => :count,
109
+ :grouping => grouping,
110
+ :limit => 10,
111
+ :live_data => live_data
112
+ )
113
+ result = @report.run(:conditions => ['login IN (?)', ['test 1', 'test 2', 'test 4']])
114
+
115
+ result[10][1].should == 3.0 if live_data
116
+ result[9][1].should == 2.0
117
+ result[8][1].should == 1.0
118
+ result[7][1].should == 1.0
119
+ result[6][1].should == 0.0
120
+ end
121
+
122
+ it 'should return correct data for aggregation :sum when custom conditions are specified' do
123
+ @report = Saulabs::Reportable::CumulatedReport.new(User, :registrations,
124
+ :aggregation => :sum,
125
+ :grouping => grouping,
126
+ :value_column => :profile_visits,
127
+ :limit => 10,
128
+ :live_data => live_data
129
+ )
130
+ result = @report.run(:conditions => ['login IN (?)', ['test 1', 'test 2', 'test 4']])
131
+
132
+ result[10][1].should == 6.0 if live_data
133
+ result[9][1].should == 4.0
134
+ result[8][1].should == 3.0
135
+ result[7][1].should == 3.0
136
+ result[6][1].should == 0.0
137
+ end
138
+
139
+ end
140
+
141
+ after(:all) do
142
+ User.destroy_all
143
+ end
144
+
145
+ end
146
+
147
+ end
148
+
149
+ end
150
+
151
+ after(:each) do
152
+ Saulabs::Reportable::ReportCache.destroy_all
153
+ end
154
+
155
+ end
156
+
157
+ describe '#cumulate' do
158
+
159
+ it 'should correctly cumulate the given data' do
160
+ first = (Time.now - 1.week).to_s
161
+ second = Time.now.to_s
162
+ data = [[first, 1], [second, 2]]
163
+
164
+ @report.send(:cumulate, data, @report.send(:options_for_run, {})).should == [[first, 1.0], [second, 3.0]]
165
+ end
166
+
167
+ end
168
+
169
+ end