qfill 0.0.4 → 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.github/dependabot.yml +8 -0
- data/.github/workflows/style.yml +37 -0
- data/.github/workflows/test.yml +55 -0
- data/.rspec +3 -2
- data/.rubocop.yml +26 -0
- data/.rubocop_todo.yml +163 -0
- data/.simplecov +6 -0
- data/CODE_OF_CONDUCT.md +133 -0
- data/Gemfile +33 -0
- data/Guardfile +12 -0
- data/{LICENSE.txt → LICENSE} +1 -1
- data/README.md +2 -2
- data/Rakefile +24 -1
- data/lib/qfill.rb +13 -10
- data/lib/qfill/errors/invalid_index.rb +8 -0
- data/lib/qfill/filter.rb +5 -3
- data/lib/qfill/list.rb +4 -2
- data/lib/qfill/list_set.rb +13 -9
- data/lib/qfill/manager.rb +78 -124
- data/lib/qfill/origin.rb +4 -3
- data/lib/qfill/popper.rb +30 -25
- data/lib/qfill/pusher.rb +33 -22
- data/lib/qfill/result.rb +63 -42
- data/lib/qfill/strategy.rb +13 -0
- data/lib/qfill/strategy/base.rb +65 -0
- data/lib/qfill/strategy/drain_to_empty.rb +73 -0
- data/lib/qfill/strategy/drain_to_limit.rb +46 -0
- data/lib/qfill/strategy/sample.rb +42 -0
- data/lib/qfill/strategy/time_slice.rb +106 -0
- data/lib/qfill/version.rb +3 -1
- data/maintenance-branch +1 -0
- data/qfill.gemspec +15 -13
- data/spec/qfill/filter_spec.rb +35 -26
- data/spec/qfill/list_set_spec.rb +28 -23
- data/spec/qfill/list_spec.rb +35 -27
- data/spec/qfill/manager_spec.rb +670 -434
- data/spec/qfill/origin_spec.rb +45 -35
- data/spec/qfill/popper_spec.rb +36 -30
- data/spec/qfill/pusher_spec.rb +32 -26
- data/spec/qfill/result_spec.rb +49 -38
- data/spec/qfill_spec.rb +6 -5
- data/spec/spec_helper.rb +11 -38
- data/spec/support/helper.rb +13 -0
- data/spec/support/random_object.rb +30 -0
- metadata +52 -23
data/Gemfile
CHANGED
@@ -1,4 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
source 'https://rubygems.org'
|
2
4
|
|
3
5
|
# Specify your gem's dependencies in qfill.gemspec
|
4
6
|
gemspec
|
7
|
+
|
8
|
+
ruby_version = Gem::Version.new(RUBY_VERSION)
|
9
|
+
|
10
|
+
gem 'yard', '~> 0.9.24', require: false
|
11
|
+
|
12
|
+
### deps for rdoc.info
|
13
|
+
group :documentation do
|
14
|
+
gem 'github-markup', platform: :mri
|
15
|
+
gem 'redcarpet', platform: :mri
|
16
|
+
end
|
17
|
+
|
18
|
+
group :development, :test do
|
19
|
+
if ruby_version >= Gem::Version.new('2.4')
|
20
|
+
# No need to run byebug / pry on earlier versions
|
21
|
+
gem 'byebug', platform: :mri
|
22
|
+
gem 'pry', platform: :mri
|
23
|
+
gem 'pry-byebug', platform: :mri
|
24
|
+
end
|
25
|
+
|
26
|
+
if ruby_version >= Gem::Version.new('2.7')
|
27
|
+
# No need to run rubocop or simplecov on earlier versions
|
28
|
+
gem 'rubocop', '~> 1.9', platform: :mri
|
29
|
+
gem 'rubocop-md', platform: :mri
|
30
|
+
gem 'rubocop-packaging', platform: :mri
|
31
|
+
gem 'rubocop-performance', platform: :mri
|
32
|
+
gem 'rubocop-rake', platform: :mri
|
33
|
+
gem 'rubocop-rspec', platform: :mri
|
34
|
+
|
35
|
+
gem 'simplecov', '~> 0.21', platform: :mri
|
36
|
+
end
|
37
|
+
end
|
data/Guardfile
ADDED
@@ -0,0 +1,12 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
guard 'rspec', version: 2 do
|
4
|
+
watch(%r{^spec/.+_spec\.rb$})
|
5
|
+
watch(%r{^lib/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" }
|
6
|
+
watch('spec/spec_helper.rb') { 'spec' }
|
7
|
+
end
|
8
|
+
|
9
|
+
guard 'bundler' do
|
10
|
+
watch('Gemfile')
|
11
|
+
watch(/^.+\.gemspec/)
|
12
|
+
end
|
data/{LICENSE.txt → LICENSE}
RENAMED
data/README.md
CHANGED
@@ -1,4 +1,4 @@
|
|
1
|
-
# Qfill - Advanced Queue
|
1
|
+
# Qfill - Advanced Queue Transformations
|
2
2
|
|
3
3
|
This gem takes a dynamic number of queues (arrays) of things, and manages the transformation into a new set of queues,
|
4
4
|
according to a dynamic set of guidelines.
|
@@ -23,7 +23,7 @@ There will be a dynamic number of origination queues each containing a set of sn
|
|
23
23
|
some matching criteria.
|
24
24
|
There is a Popper which is called to pop the next object from the next origination queue.
|
25
25
|
There is a Filter which is optionally called to validate any object that is popped from the origin.
|
26
|
-
Origin keeps popping until an object is validated
|
26
|
+
Origin keeps popping until an object is validated as a result-worthy object.
|
27
27
|
|
28
28
|
Example:
|
29
29
|
|
data/Rakefile
CHANGED
@@ -1 +1,24 @@
|
|
1
|
-
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'bundler/gem_tasks'
|
4
|
+
|
5
|
+
begin
|
6
|
+
require 'rspec/core/rake_task'
|
7
|
+
RSpec::Core::RakeTask.new(:spec)
|
8
|
+
rescue LoadError
|
9
|
+
task :spec do
|
10
|
+
warn 'RSpec is disabled'
|
11
|
+
end
|
12
|
+
end
|
13
|
+
task test: :spec
|
14
|
+
|
15
|
+
begin
|
16
|
+
require 'rubocop/rake_task'
|
17
|
+
RuboCop::RakeTask.new
|
18
|
+
rescue LoadError
|
19
|
+
task :rubocop do
|
20
|
+
warn 'RuboCop is disabled'
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
task default: [:test]
|
data/lib/qfill.rb
CHANGED
@@ -1,13 +1,16 @@
|
|
1
|
-
|
2
|
-
|
3
|
-
require
|
4
|
-
require
|
5
|
-
require
|
6
|
-
require
|
7
|
-
require
|
8
|
-
require
|
9
|
-
require
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'qfill/version'
|
4
|
+
require 'qfill/filter'
|
5
|
+
require 'qfill/list'
|
6
|
+
require 'qfill/list_set'
|
7
|
+
require 'qfill/origin'
|
8
|
+
require 'qfill/popper'
|
9
|
+
require 'qfill/pusher'
|
10
|
+
require 'qfill/manager'
|
11
|
+
require 'qfill/result'
|
12
|
+
require 'qfill/strategy'
|
10
13
|
|
11
14
|
module Qfill
|
12
|
-
VERBOSE =
|
15
|
+
VERBOSE = ENV['QFILL_VERBOSE'] == 'true'
|
13
16
|
end
|
data/lib/qfill/filter.rb
CHANGED
@@ -1,5 +1,7 @@
|
|
1
|
-
#
|
2
|
-
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# filter1 = Qfill::Filter.new( -> (object, stuff, stank) { object.is_awesome_enough_to_be_in_results?(stuff, stank) }, stuff, stank)
|
4
|
+
# filter2 = Qfill::Filter.new( -> (object, rank, bank) { object.is_awesome_enough_to_be_in_results?(rank, bank) }, rank, bank)
|
3
5
|
#
|
4
6
|
# Filters are destructive. If an item is filtered from a Result list it is lost, since it has already been popped off the origin list, and won't be coming back
|
5
7
|
module Qfill
|
@@ -12,7 +14,7 @@ module Qfill
|
|
12
14
|
end
|
13
15
|
|
14
16
|
def run(*args)
|
15
|
-
|
17
|
+
processor.call(*args, *processor_arguments)
|
16
18
|
end
|
17
19
|
end
|
18
20
|
end
|
data/lib/qfill/list.rb
CHANGED
@@ -1,6 +1,8 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
# This is the base queue class for Origin queues and Result queues.
|
2
4
|
#
|
3
|
-
#Qfill::List.new(:name => "High List",
|
5
|
+
# Qfill::List.new(:name => "High List",
|
4
6
|
# :elements => [Thing1, Thing3],
|
5
7
|
# :filter => filter1),
|
6
8
|
module Qfill
|
@@ -9,10 +11,10 @@ module Qfill
|
|
9
11
|
|
10
12
|
def initialize(options = {})
|
11
13
|
raise ArgumentError, "Missing required option :name for #{self.class}.new()" unless options && options[:name]
|
14
|
+
|
12
15
|
@name = options[:name]
|
13
16
|
@elements = options[:elements] || []
|
14
17
|
@filter = options[:filter]
|
15
18
|
end
|
16
|
-
|
17
19
|
end
|
18
20
|
end
|
data/lib/qfill/list_set.rb
CHANGED
@@ -1,35 +1,39 @@
|
|
1
|
-
#
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# This is the base queues class for Popper queues and Pusher queues.
|
4
|
+
#
|
5
|
+
# popper = Qfill::ListSet.new(
|
2
6
|
# Qfill::List.new( :name => "High List",
|
3
7
|
# :elements => [Thing1, Thing3],
|
4
8
|
# :filter => filter1 ) )
|
5
9
|
module Qfill
|
6
10
|
class ListSet
|
7
|
-
|
8
11
|
attr_accessor :queues, :current_index
|
9
12
|
|
10
13
|
def initialize(*args)
|
11
|
-
raise ArgumentError, "Missing required arguments for #{self.class}.new(queues)" unless args.length
|
14
|
+
raise ArgumentError, "Missing required arguments for #{self.class}.new(queues)" unless args.length.positive?
|
15
|
+
|
12
16
|
@queues = args
|
13
17
|
@current_index = 0
|
14
18
|
end
|
15
19
|
|
16
20
|
def [](key)
|
17
|
-
|
21
|
+
queues.find { |queue| queue.name == key }
|
18
22
|
end
|
19
23
|
|
20
24
|
def index_of(queue_name)
|
21
|
-
index =
|
25
|
+
index = queues.index { |queue| queue.name == queue_name }
|
22
26
|
return index if index
|
23
|
-
|
27
|
+
|
28
|
+
raise Qfill::Errors::InvalidIndex, "Cannot locate index of #{queue_name}"
|
24
29
|
end
|
25
30
|
|
26
31
|
def reset!
|
27
32
|
self.current_index = 0
|
28
33
|
end
|
29
34
|
|
30
|
-
def
|
31
|
-
|
35
|
+
def count_all_elements
|
36
|
+
queues.inject(0) { |counter, queue| counter += queue.elements.length }
|
32
37
|
end
|
33
|
-
|
34
38
|
end
|
35
39
|
end
|
data/lib/qfill/manager.rb
CHANGED
@@ -1,166 +1,120 @@
|
|
1
|
-
#
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'forwardable'
|
4
|
+
|
5
|
+
# A Qfill::Manager builds a set of result data (as `result`) from the source data in Qfill::Popper,
|
6
|
+
# according to the Qfill::Result definitions in the Qfill::Pusher, and the selected strategy.
|
7
|
+
#
|
8
|
+
# Qfill::Manager.new(
|
2
9
|
# :all_list_max => 40,
|
3
10
|
# :popper => popper,
|
4
11
|
# :pusher => pusher,
|
5
|
-
#)
|
12
|
+
# )
|
6
13
|
module Qfill
|
7
14
|
class Manager
|
8
|
-
|
15
|
+
extend Forwardable
|
16
|
+
def_delegators :@strategy,
|
17
|
+
:popper,
|
18
|
+
:pusher,
|
19
|
+
:result,
|
20
|
+
:remaining_to_fill
|
21
|
+
attr_accessor :all_list_max,
|
22
|
+
:primary_list_total,
|
23
|
+
:popper,
|
24
|
+
:pusher,
|
25
|
+
:fill_count,
|
26
|
+
:result,
|
27
|
+
:strategy_options
|
9
28
|
|
10
|
-
STRATEGY_OPTIONS = [
|
29
|
+
STRATEGY_OPTIONS = %i[drain_to_limit drain_to_empty sample time_slice].freeze
|
11
30
|
|
12
31
|
def initialize(options = {})
|
13
|
-
unless options[:popper] && options[:pusher]
|
14
|
-
raise ArgumentError, "#{self.class}: popper and pusher are required options for #{self.class}.new(options)"
|
15
|
-
end
|
16
32
|
unless options[:strategy].nil? || STRATEGY_OPTIONS.include?(options[:strategy])
|
17
|
-
|
18
|
-
|
19
|
-
options[:strategy] = :drain_to_limit
|
20
|
-
else
|
21
|
-
raise ArgumentError, "#{self.class}: strategy is optional, but must be one of #{STRATEGY_OPTIONS.inspect} if provided"
|
22
|
-
end
|
33
|
+
raise ArgumentError,
|
34
|
+
"#{self.class}: strategy is optional, but must be one of #{STRATEGY_OPTIONS.inspect} if provided"
|
23
35
|
end
|
36
|
+
|
37
|
+
@fill_count = 0
|
38
|
+
|
24
39
|
@popper = options[:popper]
|
25
40
|
@pusher = options[:pusher]
|
26
|
-
|
27
|
-
@
|
28
|
-
|
29
|
-
|
41
|
+
@strategy_name = options[:strategy] || :drain_to_limit # or :drain_to_empty or :sample
|
42
|
+
@strategy_options = options[:strategy_options]
|
43
|
+
|
44
|
+
# Allow the strategy to define the pusher when not defined by user
|
45
|
+
@pusher ||= strategy.default_pusher
|
46
|
+
unless @popper && @pusher
|
47
|
+
raise ArgumentError, "#{self.class}: popper and pusher (except where defined by the strategy) are required options for #{self.class}.new(options)"
|
48
|
+
end
|
49
|
+
|
50
|
+
# Provided by user, or defaults to the total number of elements in popper list set
|
51
|
+
@all_list_max = if options[:all_list_max]
|
52
|
+
[options[:all_list_max],
|
53
|
+
popper.count_all_elements].min
|
54
|
+
else
|
55
|
+
popper.count_all_elements
|
56
|
+
end
|
57
|
+
@primary_list_total = popper.count_primary_elements
|
58
|
+
end
|
59
|
+
|
60
|
+
def strategy
|
61
|
+
@strategy ||= case @strategy_name
|
62
|
+
when :drain_to_empty
|
63
|
+
Qfill::Strategy::DrainToEmpty.new(self)
|
64
|
+
when :drain_to_limit
|
65
|
+
Qfill::Strategy::DrainToLimit.new(self)
|
66
|
+
when :sample
|
67
|
+
Qfill::Strategy::Sample.new(self)
|
68
|
+
when :time_slice
|
69
|
+
Qfill::Strategy::TimeSlice.new(self)
|
70
|
+
end
|
30
71
|
end
|
31
72
|
|
32
73
|
def fill!
|
33
|
-
while !is_full? && !
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
poppy = self.result.preferred.select {|x| x == list_name}
|
39
|
-
if poppy
|
40
|
-
preferred_potential_ratio += list_ratio
|
41
|
-
num = self.popper[list_name].elements.length
|
42
|
-
preferred_potential += num
|
43
|
-
self.result.max_tracker[list_name] = num
|
44
|
-
end
|
45
|
-
end
|
46
|
-
self.result.preferred_potential = preferred_potential
|
47
|
-
self.result.preferred_potential_ratio = preferred_potential_ratio
|
48
|
-
end
|
49
|
-
self.fill_to_ratio!
|
50
|
-
self.pusher.set_next_as_current!
|
51
|
-
self.result.elements.shuffle! if self.result.shuffle
|
74
|
+
while !is_full? && !popper.primary_empty? && (self.result = pusher.current_list)
|
75
|
+
strategy.on_fill!
|
76
|
+
fill_to_ratio!
|
77
|
+
pusher.set_next_as_current!
|
78
|
+
result.elements.shuffle! if result.shuffle
|
52
79
|
end
|
53
80
|
end
|
54
81
|
|
55
82
|
def fill_to_ratio!
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
when :drain_to_limit, :sample then
|
60
|
-
result.max = Qfill::Result.get_limit_from_max_and_ratio(self.remaining_to_fill, result.ratio)
|
61
|
-
end
|
62
|
-
#result.max = Qfill::Result.get_limit_from_max_and_ratio(self.all_list_max, result.ratio)
|
63
|
-
if !result.list_ratios.empty?
|
64
|
-
self.fill_according_to_list_ratios!
|
83
|
+
strategy.result_max!
|
84
|
+
if result.list_ratios.empty?
|
85
|
+
fill_up_to_ratio!
|
65
86
|
else
|
66
|
-
|
87
|
+
fill_according_to_list_ratios!
|
67
88
|
end
|
68
89
|
end
|
69
90
|
|
70
91
|
def remaining_to_fill
|
71
|
-
|
92
|
+
primary_list_total - fill_count
|
72
93
|
end
|
73
94
|
|
74
95
|
# Go through the queues this result should be filled from and push elements from them onto the current result list.
|
75
96
|
def fill_according_to_list_ratios!
|
76
|
-
|
77
|
-
tally = 0
|
78
|
-
ratio_modifier = 1
|
79
|
-
case self.current_strategy
|
80
|
-
when :drain_to_empty then
|
81
|
-
# Are there any elements in preferred queues that we should add?
|
82
|
-
if self.result.preferred_potential > 0
|
83
|
-
# Setup a ratio modifier for the non-preferred queues
|
84
|
-
result.list_ratios.each do |list_name, list_ratio|
|
85
|
-
max_from_list = self.result.max_tracker[list_name] || Qfill::Result.get_limit_from_max_and_ratio(result.max, list_ratio)
|
86
|
-
array_to_push = self.popper.next_objects!(list_name, max_from_list)
|
87
|
-
self.popper.current_index = self.popper.index_of(list_name)
|
88
|
-
added = result.push(array_to_push, list_name)
|
89
|
-
puts "[fill_according_to_list_ratios!]#{self}[#{list_name}][added:#{added}]" if Qfill::VERBOSE
|
90
|
-
tally += added
|
91
|
-
end
|
92
|
-
self.fill_count += tally
|
93
|
-
end
|
94
|
-
when :drain_to_limit
|
95
|
-
result.list_ratios.each do |list_name, list_ratio|
|
96
|
-
max_from_list = Qfill::Result.get_limit_from_max_and_ratio(result.max, list_ratio)
|
97
|
-
array_to_push = self.popper.next_objects!(list_name, max_from_list)
|
98
|
-
self.popper.current_index = self.popper.index_of(list_name)
|
99
|
-
added = result.push(array_to_push, list_name)
|
100
|
-
puts "[fill_according_to_list_ratios!]#{self}[#{list_name}][added:#{added}]" if Qfill::VERBOSE
|
101
|
-
tally += added
|
102
|
-
end
|
103
|
-
self.fill_count += tally
|
104
|
-
when :sample then
|
105
|
-
#puts "#{!is_full?} && #{result.fill_count} >= #{result.max} && #{!self.popper.totally_empty?} && #{(list_ratio_tuple = result.current_list_ratio)}"
|
106
|
-
while !is_full? && !result.is_full? && !self.popper.totally_empty? && (list_ratio_tuple = result.current_list_ratio)
|
107
|
-
max_from_list = Qfill::Result.get_limit_from_max_and_ratio(result.max, list_ratio_tuple[1])
|
108
|
-
array_to_push = self.popper.next_objects!(list_ratio_tuple[0], max_from_list)
|
109
|
-
added = result.push(array_to_push, list_ratio_tuple[0])
|
110
|
-
self.fill_count += added
|
111
|
-
puts "[fill_according_to_list_ratios!]#{self}[#{list_ratio_tuple[0]}][added:#{added}]" if Qfill::VERBOSE
|
112
|
-
result.set_next_as_current!
|
113
|
-
end
|
114
|
-
end
|
97
|
+
strategy.fill_according_to_list_ratios!
|
115
98
|
end
|
116
99
|
|
117
100
|
# Go through the primary (non backfill) queues in the popper and push elements from them onto the current result list.
|
118
101
|
def fill_up_to_ratio!
|
119
|
-
|
120
|
-
tally = 0
|
121
|
-
if self.current_strategy == :drain_to_empty
|
122
|
-
self.popper.primary.each do |queue|
|
123
|
-
array_to_push = self.popper.next_objects!(queue.name, result.max)
|
124
|
-
added = result.push(array_to_push, queue.name)
|
125
|
-
self.popper.current_index = self.popper.index_of(queue.name)
|
126
|
-
puts "[fill_up_to_ratio!]#{self}[Q:#{queue.name}][added:#{added}]" if Qfill::VERBOSE
|
127
|
-
tally += added
|
128
|
-
end
|
129
|
-
self.fill_count += added
|
130
|
-
else
|
131
|
-
ratio = 1.0 / self.popper.primary.length # 1 divided by the number of queues
|
132
|
-
max_from_list = Qfill::Result.get_limit_from_max_and_ratio(result.max, ratio)
|
133
|
-
if self.current_strategy == :drain_to_limit
|
134
|
-
self.popper.primary.each do |queue|
|
135
|
-
array_to_push = self.popper.next_objects!(queue.name, max_from_list)
|
136
|
-
added = result.push(array_to_push, queue.name)
|
137
|
-
self.popper.current_index = self.popper.index_of(queue.name)
|
138
|
-
puts "[fill_up_to_ratio!]#{self}[Q:#{queue.name}][added:#{added}]" if Qfill::VERBOSE
|
139
|
-
tally += added
|
140
|
-
end
|
141
|
-
self.fill_count += tally
|
142
|
-
elsif self.current_strategy == :sample
|
143
|
-
while !is_full? && !result.is_full? && !self.popper.totally_empty? && (origin_list = self.popper.current_list)
|
144
|
-
array_to_push = self.popper.next_objects!(origin_list.name, max_from_list)
|
145
|
-
added = result.push(array_to_push, origin_list.name)
|
146
|
-
self.fill_count += added
|
147
|
-
puts "[fill_up_to_ratio!]#{self}[Added:#{added}][Max List:#{max_from_list}][ratio:#{ratio}][added:#{added}]" if Qfill::VERBOSE
|
148
|
-
self.popper.set_next_as_current!
|
149
|
-
end
|
150
|
-
end
|
151
|
-
end
|
102
|
+
strategy.fill_up_to_ratio!
|
152
103
|
end
|
153
104
|
|
154
|
-
def
|
155
|
-
|
105
|
+
def is_full?
|
106
|
+
fill_count >= all_list_max
|
156
107
|
end
|
157
108
|
|
158
|
-
def
|
159
|
-
|
109
|
+
def each(&block)
|
110
|
+
# NOTE: on magic: http://blog.arkency.com/2014/01/ruby-to-enum-for-enumerator/
|
111
|
+
return enum_for(:each) unless block # Sparkling magic!
|
112
|
+
|
113
|
+
pusher.each(&block)
|
160
114
|
end
|
161
115
|
|
162
116
|
def to_s
|
163
|
-
"[#{
|
117
|
+
"[#{strategy_name}][Result Max:#{result.max}][All Max:#{all_list_max}][Current Max:#{result.max}][Filled:#{fill_count}][Primary #:#{popper.count_primary_elements}]"
|
164
118
|
end
|
165
119
|
end
|
166
120
|
end
|