tabs 0.9.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +1 -0
- data/.ruby-version +1 -1
- data/.travis.yml +3 -0
- data/README.md +85 -5
- data/lib/tabs/config.rb +37 -2
- data/lib/tabs/metrics/counter.rb +14 -5
- data/lib/tabs/metrics/task.rb +8 -6
- data/lib/tabs/metrics/task/token.rb +25 -10
- data/lib/tabs/metrics/value.rb +35 -27
- data/lib/tabs/resolution.rb +26 -10
- data/lib/tabs/resolutionable.rb +36 -13
- data/lib/tabs/resolutions/day.rb +9 -1
- data/lib/tabs/resolutions/hour.rb +9 -1
- data/lib/tabs/resolutions/minute.rb +9 -1
- data/lib/tabs/resolutions/month.rb +9 -1
- data/lib/tabs/resolutions/week.rb +13 -7
- data/lib/tabs/resolutions/year.rb +9 -1
- data/lib/tabs/storage.rb +39 -17
- data/lib/tabs/tabs.rb +12 -4
- data/lib/tabs/version.rb +1 -1
- data/spec/lib/tabs/config_spec.rb +60 -0
- data/spec/lib/tabs/metrics/counter_spec.rb +44 -1
- data/spec/lib/tabs/{task_spec.rb → metrics/task_spec.rb} +31 -3
- data/spec/lib/tabs/metrics/value_spec.rb +36 -0
- data/spec/lib/tabs/resolution_spec.rb +26 -3
- data/spec/lib/tabs/resolutionable_spec.rb +53 -0
- data/spec/lib/tabs/resolutions/day_spec.rb +23 -0
- data/spec/lib/tabs/resolutions/hour_spec.rb +23 -0
- data/spec/lib/tabs/resolutions/minute_spec.rb +23 -0
- data/spec/lib/tabs/resolutions/month_spec.rb +23 -0
- data/spec/lib/tabs/resolutions/week_spec.rb +24 -0
- data/spec/lib/tabs/resolutions/year_spec.rb +23 -0
- data/spec/lib/tabs/storage_spec.rb +138 -0
- data/spec/lib/tabs_spec.rb +28 -1
- data/spec/spec_helper.rb +9 -1
- data/spec/support/custom_resolutions.rb +10 -2
- data/tabs.gemspec +6 -21
- metadata +48 -81
@@ -0,0 +1,60 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
require File.expand_path("../../../support/custom_resolutions", __FILE__)
|
3
|
+
|
4
|
+
describe Tabs::Config do
|
5
|
+
context "#decimal_precision" do
|
6
|
+
|
7
|
+
before do
|
8
|
+
@precision = Tabs::Config.decimal_precision
|
9
|
+
end
|
10
|
+
|
11
|
+
after do
|
12
|
+
Tabs::Config.decimal_precision = @precision
|
13
|
+
end
|
14
|
+
|
15
|
+
it "should set/get the decimal precision" do
|
16
|
+
Tabs::Config.decimal_precision = 4
|
17
|
+
expect(Tabs::Config.decimal_precision).to eq(4)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
context "#register_resolution" do
|
22
|
+
it "should register a resolution" do
|
23
|
+
Tabs::Resolution.register(WellFormedResolution)
|
24
|
+
expect(Tabs::Resolution.all).to include(:seconds)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
context "#unregister_resolution" do
|
29
|
+
it "should unregister a resolution" do
|
30
|
+
Tabs::Resolution.unregister(:minute)
|
31
|
+
expect(Tabs::Resolution.all).to_not include(:minute)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
context "#set_expirations" do
|
36
|
+
|
37
|
+
after do
|
38
|
+
Tabs::Config.reset_expirations
|
39
|
+
end
|
40
|
+
|
41
|
+
it "should allow multiple resolutions to be expired" do
|
42
|
+
Tabs::Config.set_expirations({ minute: 1.day, hour: 1.week })
|
43
|
+
expect(Tabs::Config.expiration_settings[:minute]).to eq(1.day)
|
44
|
+
expect(Tabs::Config.expiration_settings[:hour]).to eq(1.week)
|
45
|
+
end
|
46
|
+
|
47
|
+
it "should raise ResolutionMissingError if expiration passed in for invalid resolution" do
|
48
|
+
expect{ Tabs::Config.set_expirations({ missing_resolution: 1.day }) }
|
49
|
+
.to raise_error(Tabs::ResolutionMissingError)
|
50
|
+
end
|
51
|
+
|
52
|
+
end
|
53
|
+
|
54
|
+
context "#prefix" do
|
55
|
+
it "should allow custom prefix for tabs keys" do
|
56
|
+
Tabs::Config.prefix = "rspec"
|
57
|
+
expect(Tabs::Config.prefix).to eq("rspec")
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
@@ -25,6 +25,13 @@ describe Tabs::Metrics::Counter do
|
|
25
25
|
expect(stats).to include({ "timestamp" => time, "count" => 1 })
|
26
26
|
end
|
27
27
|
|
28
|
+
it "raises ResolutionMissingError if unregistered resolution requested" do
|
29
|
+
time = Time.utc(now.year, now.month, now.day, now.hour) - 2.hours
|
30
|
+
metric.increment(time)
|
31
|
+
Tabs::Resolution.unregister(:hour)
|
32
|
+
expect { metric.stats(((now - 3.hours)..now), :hour) }.to raise_error(Tabs::ResolutionMissingError)
|
33
|
+
end
|
34
|
+
|
28
35
|
end
|
29
36
|
|
30
37
|
describe "total count" do
|
@@ -47,6 +54,8 @@ describe Tabs::Metrics::Counter do
|
|
47
54
|
end
|
48
55
|
|
49
56
|
def create_span(time_unit)
|
57
|
+
metric.increment
|
58
|
+
Timecop.freeze(now + 1.send(time_unit))
|
50
59
|
metric.increment
|
51
60
|
Timecop.freeze(now + 3.send(time_unit))
|
52
61
|
metric.increment
|
@@ -94,7 +103,7 @@ describe Tabs::Metrics::Counter do
|
|
94
103
|
it "returns zeros for time periods which do not have any events" do
|
95
104
|
create_span(:days)
|
96
105
|
stats = metric.stats(now..(now + 7.days), :day)
|
97
|
-
expect(stats
|
106
|
+
expect(stats.detect{|s| s["timestamp"] == (now + 2.day)}["count"]).to eq(0)
|
98
107
|
end
|
99
108
|
|
100
109
|
context "for weekly metrics" do
|
@@ -106,6 +115,7 @@ describe Tabs::Metrics::Counter do
|
|
106
115
|
it "returns the expected results for a weekly metric" do
|
107
116
|
create_span(:weeks)
|
108
117
|
stats = metric.stats(period, :week)
|
118
|
+
expect(stats.detect{|s| s["timestamp"] == (now + 1.week).beginning_of_week}["count"]).to eq(1)
|
109
119
|
expect(stats).to include({ "timestamp" => (now + 3.weeks).beginning_of_week, "count" => 1 })
|
110
120
|
expect(stats).to include({ "timestamp" => (now + 6.weeks).beginning_of_week, "count" => 2 })
|
111
121
|
end
|
@@ -150,4 +160,37 @@ describe Tabs::Metrics::Counter do
|
|
150
160
|
|
151
161
|
end
|
152
162
|
|
163
|
+
describe ".drop_by_resolution!" do
|
164
|
+
before do
|
165
|
+
Timecop.freeze(now)
|
166
|
+
2.times { metric.increment }
|
167
|
+
metric.drop_by_resolution!(:minute)
|
168
|
+
end
|
169
|
+
|
170
|
+
it "deletes all metrics for a resolution" do
|
171
|
+
stats = metric.stats((now - 1.minute)..(now + 1.minute), :minute)
|
172
|
+
expect(stats.total).to eq(0)
|
173
|
+
end
|
174
|
+
end
|
175
|
+
|
176
|
+
describe "expiration of counter metrics" do
|
177
|
+
let(:expires_setting){ 6.hours }
|
178
|
+
let(:now){ Time.utc(2050, 1, 1, 0, 0) }
|
179
|
+
|
180
|
+
before do
|
181
|
+
Tabs::Config.set_expirations({ minute: expires_setting })
|
182
|
+
end
|
183
|
+
|
184
|
+
after do
|
185
|
+
Tabs::Config.reset_expirations
|
186
|
+
end
|
187
|
+
|
188
|
+
it "sets an expiration when recording a value" do
|
189
|
+
metric.increment(now)
|
190
|
+
redis_expire_date = Time.now + Tabs::Storage.ttl(metric.storage_key(:minute, now))
|
191
|
+
expire_date = now + expires_setting + Tabs::Resolutions::Minute.to_seconds
|
192
|
+
expect(redis_expire_date).to be_within(2.seconds).of(expire_date)
|
193
|
+
end
|
194
|
+
end
|
195
|
+
|
153
196
|
end
|
@@ -10,7 +10,7 @@ describe Tabs::Metrics::Task do
|
|
10
10
|
|
11
11
|
describe ".start" do
|
12
12
|
|
13
|
-
let(:token) {
|
13
|
+
let(:token) { double(:token) }
|
14
14
|
let(:time) { Time.now }
|
15
15
|
|
16
16
|
it "calls start on the given token" do
|
@@ -29,11 +29,10 @@ describe Tabs::Metrics::Task do
|
|
29
29
|
|
30
30
|
describe ".complete" do
|
31
31
|
|
32
|
-
let(:token) {
|
32
|
+
let(:token) { double(:token) }
|
33
33
|
let(:time) { Time.now }
|
34
34
|
|
35
35
|
it "calls complete on the given token" do
|
36
|
-
token = stub(:token)
|
37
36
|
Tabs::Metrics::Task::Token.should_receive(:new).with(token_1, "foo").and_return(token)
|
38
37
|
token.should_receive(:complete)
|
39
38
|
metric.complete(token_1)
|
@@ -53,6 +52,16 @@ describe Tabs::Metrics::Task do
|
|
53
52
|
|
54
53
|
describe ".stats" do
|
55
54
|
|
55
|
+
it "returns zeroes across the board for no stats" do
|
56
|
+
stats = metric.stats((now - 5.minutes)..(now + 5.minutes), :minute)
|
57
|
+
|
58
|
+
expect(stats.started_within_period).to eq 0
|
59
|
+
expect(stats.completed_within_period).to eq 0
|
60
|
+
expect(stats.started_and_completed_within_period).to eq 0
|
61
|
+
expect(stats.completion_rate).to eq 0.0
|
62
|
+
expect(stats.average_completion_time).to eq 0.0
|
63
|
+
end
|
64
|
+
|
56
65
|
it "returns the expected value" do
|
57
66
|
Timecop.freeze(now)
|
58
67
|
metric.start(token_1)
|
@@ -63,6 +72,25 @@ describe Tabs::Metrics::Task do
|
|
63
72
|
Timecop.freeze(now + 3.minutes)
|
64
73
|
metric.complete(token_3)
|
65
74
|
stats = metric.stats((now - 5.minutes)..(now + 5.minutes), :minute)
|
75
|
+
|
76
|
+
expect(stats.started_within_period).to eq 3
|
77
|
+
expect(stats.completed_within_period).to eq 2
|
78
|
+
expect(stats.started_and_completed_within_period).to eq 2
|
79
|
+
expect(stats.completion_rate).to eq 0.18182
|
80
|
+
expect(stats.average_completion_time).to eq 1.5
|
81
|
+
end
|
82
|
+
|
83
|
+
it "returns the expected value for a week" do
|
84
|
+
Timecop.freeze(now)
|
85
|
+
metric.start(token_1)
|
86
|
+
metric.start(token_2)
|
87
|
+
Timecop.freeze(now + 1.week)
|
88
|
+
metric.complete(token_1)
|
89
|
+
metric.start(token_3)
|
90
|
+
Timecop.freeze(now + 3.weeks)
|
91
|
+
metric.complete(token_3)
|
92
|
+
stats = metric.stats((now - 5.weeks)..(now + 5.weeks), :week)
|
93
|
+
|
66
94
|
expect(stats.started_within_period).to eq 3
|
67
95
|
expect(stats.completed_within_period).to eq 2
|
68
96
|
expect(stats.started_and_completed_within_period).to eq 2
|
@@ -40,6 +40,8 @@ describe Tabs::Metrics::Value do
|
|
40
40
|
|
41
41
|
def create_span(time_unit)
|
42
42
|
metric.record(5)
|
43
|
+
Timecop.freeze(now + 1.send(time_unit))
|
44
|
+
metric.record(25)
|
43
45
|
Timecop.freeze(now + 3.send(time_unit))
|
44
46
|
metric.record(10)
|
45
47
|
Timecop.freeze(now + 6.send(time_unit))
|
@@ -78,6 +80,8 @@ describe Tabs::Metrics::Value do
|
|
78
80
|
it "returns the expected results for a weekly metric" do
|
79
81
|
create_span(:weeks)
|
80
82
|
stats = metric.stats(now..(now + 7.weeks), :week)
|
83
|
+
second_week_stats = stats.detect{|s| s["timestamp"] == (now + 1.week).beginning_of_week }
|
84
|
+
expect(second_week_stats["count"]).to eq(1)
|
81
85
|
expect(stats).to include({ "timestamp" => (now + 3.weeks).beginning_of_week, "count"=>1, "min"=>10, "max"=>10, "sum"=>10, "avg"=>10})
|
82
86
|
expect(stats).to include({ "timestamp" => (now + 6.weeks).beginning_of_week, "count"=>2, "min"=>15, "max"=>20, "sum"=>35, "avg"=>17.5})
|
83
87
|
end
|
@@ -121,4 +125,36 @@ describe Tabs::Metrics::Value do
|
|
121
125
|
|
122
126
|
end
|
123
127
|
|
128
|
+
describe ".drop_by_resolution!" do
|
129
|
+
before do
|
130
|
+
Timecop.freeze(now)
|
131
|
+
2.times { metric.record(rand(30)) }
|
132
|
+
metric.drop_by_resolution!(:minute)
|
133
|
+
end
|
134
|
+
|
135
|
+
it "deletes all metrics for a resolution" do
|
136
|
+
stats = metric.stats((now - 1.minute)..(now + 1.minute), :minute)
|
137
|
+
expect(stats.sum).to eq(0)
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
describe "expiration of value metrics" do
|
142
|
+
let(:expires_setting){ 6.hours }
|
143
|
+
let(:now){ Time.utc(2050, 1, 1, 0, 0) }
|
144
|
+
|
145
|
+
before do
|
146
|
+
Tabs::Config.set_expirations({ minute: expires_setting })
|
147
|
+
end
|
148
|
+
|
149
|
+
after do
|
150
|
+
Tabs::Config.reset_expirations
|
151
|
+
end
|
152
|
+
|
153
|
+
it "sets an expiration when recording a value" do
|
154
|
+
metric.record(17, now)
|
155
|
+
redis_expire_date = Time.now + Tabs::Storage.ttl(metric.storage_key(:minute, now))
|
156
|
+
expire_date = now + expires_setting + Tabs::Resolutions::Minute.to_seconds
|
157
|
+
expect(redis_expire_date).to be_within(2.seconds).of(expire_date)
|
158
|
+
end
|
159
|
+
end
|
124
160
|
end
|
@@ -2,10 +2,11 @@ require "spec_helper"
|
|
2
2
|
require File.expand_path("../../../support/custom_resolutions", __FILE__)
|
3
3
|
|
4
4
|
describe Tabs::Resolution do
|
5
|
+
|
5
6
|
describe "#register" do
|
6
7
|
it "registers a new resolution" do
|
7
|
-
Tabs::Resolution.register(
|
8
|
-
expect(Tabs::Resolution.all).to include
|
8
|
+
Tabs::Resolution.register(WellFormedResolution)
|
9
|
+
expect(Tabs::Resolution.all).to include WellFormedResolution.name
|
9
10
|
end
|
10
11
|
|
11
12
|
context "with a custom resolution" do
|
@@ -14,7 +15,7 @@ describe Tabs::Resolution do
|
|
14
15
|
end
|
15
16
|
|
16
17
|
it "gets stats for custom resolution" do
|
17
|
-
Tabs::Resolution.register(
|
18
|
+
Tabs::Resolution.register(WellFormedResolution)
|
18
19
|
Timecop.freeze(Time.now)
|
19
20
|
|
20
21
|
Tabs.increment_counter("foo")
|
@@ -24,6 +25,28 @@ describe Tabs::Resolution do
|
|
24
25
|
it "raises an error when method not implemented" do
|
25
26
|
expect{BadlyFormedResolution.normalize}.to raise_error
|
26
27
|
end
|
28
|
+
|
29
|
+
it "disregards already registered resolutions" do
|
30
|
+
expect { Tabs::Resolution.register(Tabs::Resolutions::Minute) }.to_not raise_error
|
31
|
+
end
|
27
32
|
end
|
28
33
|
end
|
34
|
+
|
35
|
+
describe "#unregister" do
|
36
|
+
it "unregisters a single resolution" do
|
37
|
+
Tabs::Resolution.unregister(:minute)
|
38
|
+
expect(Tabs::Resolution.all).to_not include(:minute)
|
39
|
+
end
|
40
|
+
|
41
|
+
it "unregisters an array of resolutions" do
|
42
|
+
Tabs::Resolution.unregister([:minute, :hour])
|
43
|
+
expect(Tabs::Resolution.all).to_not include(:hour)
|
44
|
+
expect(Tabs::Resolution.all).to_not include(:minute)
|
45
|
+
end
|
46
|
+
|
47
|
+
it "disregards passing in an unrecognized resolution" do
|
48
|
+
expect { Tabs::Resolution.unregister(:invalid_resolution) }.to_not raise_error
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
29
52
|
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
describe Tabs::Resolutionable do
|
4
|
+
|
5
|
+
module TestResolution
|
6
|
+
include Tabs::Resolutionable
|
7
|
+
extend self
|
8
|
+
|
9
|
+
def name
|
10
|
+
:test
|
11
|
+
end
|
12
|
+
|
13
|
+
def to_seconds
|
14
|
+
1000
|
15
|
+
end
|
16
|
+
|
17
|
+
end
|
18
|
+
|
19
|
+
describe "interface exceptions" do
|
20
|
+
|
21
|
+
["serialize", "deserialize", "from_seconds", "add", "normalize"].each do |method|
|
22
|
+
it "are raised when the #{method} method is not implemented" do
|
23
|
+
expect { TestResolution.send(method) }.to raise_error
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
end
|
28
|
+
|
29
|
+
describe "#expire" do
|
30
|
+
let(:expires_setting){ 1.day }
|
31
|
+
|
32
|
+
before do
|
33
|
+
Tabs::Config.register_resolution(TestResolution)
|
34
|
+
Tabs::Config.set_expirations(test: expires_setting)
|
35
|
+
end
|
36
|
+
|
37
|
+
after do
|
38
|
+
Tabs::Config.reset_expirations
|
39
|
+
Tabs::Config.unregister_resolutions(:test)
|
40
|
+
end
|
41
|
+
|
42
|
+
it "sets the expiration for the given key" do
|
43
|
+
now = Time.utc(2050, 1, 1, 0, 0, 0)
|
44
|
+
Tabs::Storage.set("foo", "bar")
|
45
|
+
TestResolution.expire("foo", now)
|
46
|
+
redis_expire_date = Time.now + Tabs::Storage.ttl("foo")
|
47
|
+
expire_date = now + expires_setting + TestResolution.to_seconds
|
48
|
+
expect(redis_expire_date).to be_within(2.seconds).of(expire_date)
|
49
|
+
end
|
50
|
+
|
51
|
+
end
|
52
|
+
|
53
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
describe Tabs::Resolutions::Day do
|
4
|
+
let(:timestamp){ Time.new(2000, 1, 1, 12, 15) }
|
5
|
+
|
6
|
+
context "#normalize" do
|
7
|
+
it "should normalize the date to year, month, day" do
|
8
|
+
expect(subject.normalize(timestamp)).to eq(timestamp.utc.change(hour: 0))
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
context "#serialize" do
|
13
|
+
it "should return YYYY-MM-DD" do
|
14
|
+
expect(subject.serialize(timestamp)).to eq("2000-01-01")
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
context "#deserialize" do
|
19
|
+
it "should convert string into date" do
|
20
|
+
expect(subject.deserialize("2000-01-01")).to eq(timestamp.utc.change(hour: 0))
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
describe Tabs::Resolutions::Hour do
|
4
|
+
let(:timestamp){ Time.utc(2000, 1, 1, 14, 12) }
|
5
|
+
|
6
|
+
context "#normalize" do
|
7
|
+
it "should normalize the date to year, month, day, hour" do
|
8
|
+
expect(subject.normalize(timestamp)).to eq(timestamp.change(min: 0))
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
context "#serialize" do
|
13
|
+
it "should return YYYY-MM-DD-HH" do
|
14
|
+
expect(subject.serialize(timestamp)).to eq("2000-01-01-14")
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
context "#deserialize" do
|
19
|
+
it "should convert string into date" do
|
20
|
+
expect(subject.deserialize("2000-01-01-14")).to eq(timestamp.change(min: 0))
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
describe Tabs::Resolutions::Minute do
|
4
|
+
let(:timestamp){ Time.utc(2000, 1, 1, 14, 12, 44) }
|
5
|
+
|
6
|
+
context "#normalize" do
|
7
|
+
it "should normalize the date to year, month, day, hour, minute" do
|
8
|
+
expect(subject.normalize(timestamp)).to eq(timestamp.change(sec: 0))
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
context "#serialize" do
|
13
|
+
it "should return YYYY-MM-DD-HH-MM" do
|
14
|
+
expect(subject.serialize(timestamp)).to eq("2000-01-01-14-12")
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
context "#deserialize" do
|
19
|
+
it "should convert string into date" do
|
20
|
+
expect(subject.deserialize("2000-01-01-14-12")).to eq(timestamp.change(sec: 0))
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
describe Tabs::Resolutions::Month do
|
4
|
+
let(:timestamp){ Time.utc(2000, 1, 15) }
|
5
|
+
|
6
|
+
context "#normalize" do
|
7
|
+
it "should normalize the date to year, month" do
|
8
|
+
expect(subject.normalize(timestamp)).to eq(timestamp.change(day: 1))
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
context "#serialize" do
|
13
|
+
it "should return YYYY-MM" do
|
14
|
+
expect(subject.serialize(timestamp)).to eq("2000-01")
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
context "#deserialize" do
|
19
|
+
it "should convert string into date" do
|
20
|
+
expect(subject.deserialize("2000-01")).to eq(timestamp.change(day: 1))
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|