lib/lopata/scenario_builder.rb in lopata-0.1.13 vs lib/lopata/scenario_builder.rb in lopata-0.1.14
- old
+ new
@@ -1,497 +1,497 @@
-# Context for scenario creation.
-class Lopata::ScenarioBuilder
- # @private
- attr_reader :title, :common_metadata, :options, :diagonals
- # @private
- attr_accessor :shared_step, :group
-
- # Defines one or more scenarios.
- #
- # @example
- # Lopata.define 'scenario' do
- # setup 'test user'
- # action 'login'
- # verify 'home page displayed'
- # end
- #
- # Given block will be calculated in context of the ScenarioBuilder
- #
- # @param title [String] scenario unique title
- # @param metadata [Hash] metadata to be used within the scenario
- # @param block [Block] the scenario definition
- # @see Lopata.define
- def self.define(title, metadata = {}, &block)
- builder = new(title, metadata)
- builder.instance_exec &block
- builder.build
- end
-
- # @private
- def initialize(title, metadata = {})
- @title, @common_metadata = title, metadata
- @diagonals = []
- @options = []
- end
-
- # @private
- def build
- filters = Lopata.configuration.filters
- option_combinations.each do |option_set|
- metadata = common_metadata.merge(option_set.metadata)
- scenario = Lopata::Scenario::Execution.new(title, option_set.title, metadata)
-
- unless filters.empty?
- next unless filters.all? { |f| f[scenario] }
- end
-
- steps_with_hooks.each do |step|
- next if step.condition && !step.condition.match?(scenario)
- step.execution_steps(scenario).each { |s| scenario.steps << s }
- end
-
- world.scenarios << scenario
- end
- end
-
- # @!group Defining variants
-
- # Define option for the scenario.
- #
- # The scenario will be generated for all the options.
- # If more then one option given, the scenarios for all options combinations will be generated.
- #
- # @param metadata_key [Symbol] the key to access option data via metadata.
- # @param variants [Hash{String => Object}] variants for the option
- # Keys are titles of the variant, values are metadata values.
- #
- # @example
- # Lopata.define 'scenario' do
- # option :one, 'one' => 1, 'two' => 2
- # option :two, 'two' => 2, 'three' => 3
- # # will generate 4 scenarios:
- # # - 'scenario one two'
- # # - 'scenario one three'
- # # - 'scenario two two'
- # # - 'scenario two three'
- # end
- #
- # @see #diagonal
- def option(metadata_key, variants)
- @options << Option.new(metadata_key, variants)
- end
-
- # Define diagonal for the scenario.
- #
- # The scenario will be generated for all the variants of the diagonal.
- # Each variant of diagonal will be selected for at least one scenario.
- # It may be included in more then one scenario when other diagonal or option has more variants.
- #
- # @param metadata_key [Symbol] the key to access diagonal data via metadata.
- # @param variants [Hash{String => Object}] variants for the diagonal.
- # Keys are titles of the variant, values are metadata values.
- #
- # @example
- # Lopata.define 'scenario' do
- # option :one, 'one' => 1, 'two' => 2
- # diagonal :two, 'two' => 2, 'three' => 3
- # diagonal :three, 'three' => 3, 'four' => 4, 'five' => 5
- # # will generate 3 scenarios:
- # # - 'scenario one two three'
- # # - 'scenario two three four'
- # # - 'scenario one two five'
- # end
- #
- # @see #option
- def diagonal(metadata_key, variants)
- @diagonals << Diagonal.new(metadata_key, variants)
- end
-
- # Define additional metadata for the scenario
- #
- # @example
- # Lopata.define 'scenario' do
- # metadata key: 'value'
- # it 'metadata available' do
- # expect(metadata[:key]).to eq 'value'
- # end
- # end
- def metadata(hash)
- raise 'metadata expected to be a Hash' unless hash.is_a?(Hash)
- @common_metadata ||= {}
- @common_metadata.merge! hash
- end
-
- # Skip scenario for given variants combination
- #
- # @example
- # Lopata.define 'multiple options' do
- # option :one, 'one' => 1, 'two' => 2
- # option :two, 'two' => 2, 'three' => 3
- # skip_when { |opt| opt.metadata[:one] == opt.metadata[:two] }
- # it 'not equal' do
- # expect(one).to_not eq two
- # end
- # end
- #
- def skip_when(&block)
- @skip_when = block
- end
-
- # @private
- def skip?(option_set)
- @skip_when && @skip_when.call(option_set)
- end
-
- # @!endgroup
-
- # @!group Defining Steps
-
- # @private
- # @macro [attach] define_step_method
- # @!scope instance
- # @method $1
- def self.define_step_method(name)
- name_if = "%s_if" % name
- name_unless = "%s_unless" % name
- define_method name, ->(*args, **metadata, &block) { add_step(name, *args, metadata: metadata, &block) }
- define_method name_if, ->(condition, *args, **metadata, &block) {
- add_step(name, *args, metadata: metadata, condition: Lopata::Condition.new(condition), &block)
- }
- define_method name_unless, ->(condition, *args, **metadata, &block) {
- add_step(name, *args, condition: Lopata::Condition.new(condition, positive: false), metadata: metadata, &block)
- }
- end
-
- # Define setup step.
- # @example
- # setup do
- # end
- #
- # # setup from named shared step
- # setup 'create user'
- #
- # # setup with both shared step and code block
- # setup 'create user' do
- # @user.update(admin: true)
- # end
- #
- # Setup step used for set test data.
- # @overload setup(*steps, &block)
- # @param steps [Array<String, Symbol, Proc>] the steps to be runned as a part of setup.
- # String - name of shared step to be called.
- # Symbol - metadata key, referenced to shared step name.
- # Proc - in-place step implementation.
- # @param block [Block] The implementation of the step.
- define_step_method :setup
-
- # Define action step.
- # @example
- # action do
- # end
- #
- # # action from named shared step
- # action 'login'
- #
- # # setup with both shared step and code block
- # action 'login', 'go dashboard' do
- # @user.update(admin: true)
- # end
- #
- # Action step is used for emulate user or external system action
- #
- # @overload action(*steps, &block)
- # @param steps [Array<String, Symbol, Proc>] the steps to be runned as a part of action.
- # String - name of shared step to be called.
- # Symbol - metadata key, referenced to shared step name.
- # Proc - in-place step implementation.
- # @param block [Block] The implementation of the step.
- define_step_method :action
-
- # Define teardown step.
- # @example
- # setup { @user = User.create! }
- # teardown { @user.destroy }
- # Teardown step will be called at the end of scenario running.
- # But it suggested to be decared right after setup or action step which require teardown.
- #
- # @overload teardown(*steps, &block)
- # @param steps [Array<String, Symbol, Proc>] the steps to be runned as a part of teardown.
- # String - name of shared step to be called.
- # Symbol - metadata key, referenced to shared step name.
- # Proc - in-place step implementation.
- # @param block [Block] The implementation of the step.
- define_step_method :teardown
-
- # Define verify steps.
- # @example
- # verify 'home page displayed' # call shared step.
- # Usually for validation shared steps inclusion
- #
- # @overload verify(*steps, &block)
- # @param steps [Array<String, Symbol, Proc>] the steps to be runned as a part of verification.
- # String - name of shared step to be called.
- # Symbol - metadata key, referenced to shared step name.
- # Proc - in-place step implementation.
- # @param block [Block] The implementation of the step.
- define_step_method :verify
-
- # Define group of steps.
- # The metadata for the group may be overriden
- # @example
- # context 'the task', task: :created do
- # verify 'task setup'
- # it 'created' do
- # expect(metadata[:task]).to eq :created
- # end
- # end
- # Teardown steps within group will be called at the end of the group, not scenario
- # @overload context(title, **metadata, &block)
- # @param title [String] context title
- # @param metadata [Hash] the step additional metadata
- # @param block [Block] The implementation of the step.
- define_step_method :context
-
- # Define single validation step.
- # @example
- # it 'works' do
- # expect(1).to eq 1
- # end
- # @overload it(title, &block)
- # @param title [String] validation title
- # @param block [Block] The implementation of the step.
- define_step_method :it
-
- # Define runtime method for the scenario.
- #
- # @note
- # The method to be called via #method_missing, so it wont override already defined methods.
- #
- # @example
- # let(:square) { |num| num * num }
- # it 'calculated' do
- # expect(square(4)).to eq 16
- # end
- def let(method_name, &block)
- steps << Lopata::Step.new(:let) do
- execution.let(method_name, &block)
- end
- end
-
- # @!endgroup
-
- # @private
- def add_step(method_name, *args, condition: nil, metadata: {}, &block)
- step_class =
- case method_name
- when /^(setup|action|teardown|verify)/ then Lopata::ActionStep
- when /^(context)/ then Lopata::GroupStep
- else Lopata::Step
- end
- step = step_class.new(method_name, *args, condition: condition, shared_step: shared_step, &block)
- step.metadata = metadata
- steps << step
- end
-
- # @private
- def steps
- @steps ||= []
- end
-
- # @private
- def steps_with_hooks
- s = []
- unless Lopata.configuration.before_scenario_steps.empty?
- s << Lopata::ActionStep.new(:setup, *Lopata.configuration.before_scenario_steps)
- end
-
- s += steps
-
- unless Lopata.configuration.after_scenario_steps.empty?
- s << Lopata::ActionStep.new(:teardown, *Lopata.configuration.after_scenario_steps)
- end
-
- s
- end
-
- # @private
- def option_combinations
- combinations = combine([OptionSet.new], options + diagonals)
- while !diagonals.all?(&:complete?)
- combinations << OptionSet.new(*(options + diagonals).map(&:next_variant))
- end
- combinations.reject { |option_set| skip?(option_set) }
- end
-
- # @private
- def combine(source_combinations, rest_options)
- return source_combinations if rest_options.empty?
- combinations = []
- current_option = rest_options.shift
- source_combinations.each do |source_variants|
- current_option.level_variants.each do |v|
- combinations << (source_variants + OptionSet.new(v))
- end
- end
- combine(combinations, rest_options)
- end
-
- # @private
- def world
- Lopata.world
- end
-
- # Set of options for scenario
- class OptionSet
- # @private
- attr_reader :variants
-
- # @private
- def initialize(*variants)
- @variants = {}
- variants.compact.each { |v| self << v }
- end
-
- # @private
- def +(other_set)
- self.class.new(*@variants.values).tap do |sum|
- other_set.each { |v| sum << v }
- end
- end
-
- # @private
- def <<(variant)
- @variants[variant.key] = variant
- end
-
- # @private
- def [](key)
- @variants[key]
- end
-
- # @private
- def each(&block)
- @variants.values.each(&block)
- end
-
- # @private
- def title
- @variants.values.map(&:title).compact.join(' ')
- end
-
- # @return [Hash{Symbol => Object}] metadata for this option set
- def metadata
- @variants.values.inject({}) do |metadata, variant|
- metadata.merge(variant.metadata(self))
- end
- end
- end
-
- # @private
- class Variant
- attr_reader :key, :title, :value, :option
-
- def initialize(option, key, title, value)
- @option, @key, @title, @value = option, key, title, check_lambda_arity(value)
- end
-
- def metadata(option_set)
- data = { key => value }
- if value.is_a? Hash
- value.each do |k, v|
- sub_key = "%s_%s" % [key, k]
- data[sub_key.to_sym] = v
- end
- end
-
- option.available_metadata_keys.each do |key|
- data[key] = nil unless data.has_key?(key)
- end
-
- data.each do |key, v|
- data[key] = v.calculate(option_set) if v.is_a? CalculatedValue
- end
- data
- end
-
- def self.join(variants)
- title, metadata = nil, {}
- variants.each do |v|
- title = [title, v.title].compact.join(' ')
- metadata.merge!(v.metadata)
- end
- [title, metadata]
- end
-
- private
-
- # Лямдда будет передаваться как блок в instance_eval, которому плохеет, если пришло что-то с нулевой
- # arity. Поэтому для лямбд с нулевой arity делаем arity == 1
- def check_lambda_arity(v)
- if v.is_a?(Proc) && v.arity == 0
- ->(_) { instance_exec(&v) }
- else
- v
- end
- end
- end
-
- # @private
- class CalculatedValue
- def initialize(&block)
- @proc = block
- end
-
- def calculate(option_set)
- @proc.call(option_set)
- end
- end
-
- # @private
- class Option
- attr_reader :variants, :key, :use_all_variants
- def initialize(key, variants, use_all_variants = true)
- @key = key
- @variants =
- if variants.is_a? Hash
- variants.map { |title, value| Variant.new(self, key, title, value) }
- else
- # Array of arrays of two elements
- variants.map { |v| Variant.new(self, key, *v) }
- end
- @use_all_variants = use_all_variants
- end
-
- # Variants to apply at one level
- def level_variants
- variants
- end
-
- def next_variant
- @current ||= 0
- selected_variant = variants[@current]
- @current += 1
- @complete = true unless use_all_variants # not need to verify all variants, just use first ones.
- if @current >= variants.length
- @current = 0
- @complete = true # all variants have been selected
- end
- selected_variant
- end
-
- def available_metadata_keys
- @available_metadata_keys ||= variants
- .map(&:value).select { |v| v.is_a?(Hash) }.flat_map(&:keys).map { |k| "#{key}_#{k}".to_sym }.uniq
- end
- end
-
- # @private
- class Diagonal < Option
- def level_variants
- [next_variant]
- end
-
- def complete?
- @complete
- end
- end
-end
+# Context for scenario creation.
+class Lopata::ScenarioBuilder
+ # @private
+ attr_reader :title, :common_metadata, :options, :diagonals
+ # @private
+ attr_accessor :shared_step, :group
+
+ # Defines one or more scenarios.
+ #
+ # @example
+ # Lopata.define 'scenario' do
+ # setup 'test user'
+ # action 'login'
+ # verify 'home page displayed'
+ # end
+ #
+ # Given block will be calculated in context of the ScenarioBuilder
+ #
+ # @param title [String] scenario unique title
+ # @param metadata [Hash] metadata to be used within the scenario
+ # @param block [Block] the scenario definition
+ # @see Lopata.define
+ def self.define(title, metadata = {}, &block)
+ builder = new(title, metadata)
+ builder.instance_exec &block
+ builder.build
+ end
+
+ # @private
+ def initialize(title, metadata = {})
+ @title, @common_metadata = title, metadata
+ @diagonals = []
+ @options = []
+ end
+
+ # @private
+ def build
+ filters = Lopata.configuration.filters
+ option_combinations.each do |option_set|
+ metadata = common_metadata.merge(option_set.metadata)
+ scenario = Lopata::Scenario::Execution.new(title, option_set.title, metadata)
+
+ unless filters.empty?
+ next unless filters.all? { |f| f[scenario] }
+ end
+
+ steps_with_hooks.each do |step|
+ next if step.condition && !step.condition.match?(scenario)
+ step.execution_steps(scenario).each { |s| scenario.steps << s }
+ end
+
+ world.scenarios << scenario
+ end
+ end
+
+ # @!group Defining variants
+
+ # Define option for the scenario.
+ #
+ # The scenario will be generated for all the options.
+ # If more then one option given, the scenarios for all options combinations will be generated.
+ #
+ # @param metadata_key [Symbol] the key to access option data via metadata.
+ # @param variants [Hash{String => Object}] variants for the option
+ # Keys are titles of the variant, values are metadata values.
+ #
+ # @example
+ # Lopata.define 'scenario' do
+ # option :one, 'one' => 1, 'two' => 2
+ # option :two, 'two' => 2, 'three' => 3
+ # # will generate 4 scenarios:
+ # # - 'scenario one two'
+ # # - 'scenario one three'
+ # # - 'scenario two two'
+ # # - 'scenario two three'
+ # end
+ #
+ # @see #diagonal
+ def option(metadata_key, variants)
+ @options << Option.new(metadata_key, variants)
+ end
+
+ # Define diagonal for the scenario.
+ #
+ # The scenario will be generated for all the variants of the diagonal.
+ # Each variant of diagonal will be selected for at least one scenario.
+ # It may be included in more then one scenario when other diagonal or option has more variants.
+ #
+ # @param metadata_key [Symbol] the key to access diagonal data via metadata.
+ # @param variants [Hash{String => Object}] variants for the diagonal.
+ # Keys are titles of the variant, values are metadata values.
+ #
+ # @example
+ # Lopata.define 'scenario' do
+ # option :one, 'one' => 1, 'two' => 2
+ # diagonal :two, 'two' => 2, 'three' => 3
+ # diagonal :three, 'three' => 3, 'four' => 4, 'five' => 5
+ # # will generate 3 scenarios:
+ # # - 'scenario one two three'
+ # # - 'scenario two three four'
+ # # - 'scenario one two five'
+ # end
+ #
+ # @see #option
+ def diagonal(metadata_key, variants)
+ @diagonals << Diagonal.new(metadata_key, variants)
+ end
+
+ # Define additional metadata for the scenario
+ #
+ # @example
+ # Lopata.define 'scenario' do
+ # metadata key: 'value'
+ # it 'metadata available' do
+ # expect(metadata[:key]).to eq 'value'
+ # end
+ # end
+ def metadata(hash)
+ raise 'metadata expected to be a Hash' unless hash.is_a?(Hash)
+ @common_metadata ||= {}
+ @common_metadata.merge! hash
+ end
+
+ # Skip scenario for given variants combination
+ #
+ # @example
+ # Lopata.define 'multiple options' do
+ # option :one, 'one' => 1, 'two' => 2
+ # option :two, 'two' => 2, 'three' => 3
+ # skip_when { |opt| opt.metadata[:one] == opt.metadata[:two] }
+ # it 'not equal' do
+ # expect(one).to_not eq two
+ # end
+ # end
+ #
+ def skip_when(&block)
+ @skip_when = block
+ end
+
+ # @private
+ def skip?(option_set)
+ @skip_when && @skip_when.call(option_set)
+ end
+
+ # @!endgroup
+
+ # @!group Defining Steps
+
+ # @private
+ # @macro [attach] define_step_method
+ # @!scope instance
+ # @method $1
+ def self.define_step_method(name)
+ name_if = "%s_if" % name
+ name_unless = "%s_unless" % name
+ define_method name, ->(*args, **metadata, &block) { add_step(name, *args, metadata: metadata, &block) }
+ define_method name_if, ->(condition, *args, **metadata, &block) {
+ add_step(name, *args, metadata: metadata, condition: Lopata::Condition.new(condition), &block)
+ }
+ define_method name_unless, ->(condition, *args, **metadata, &block) {
+ add_step(name, *args, condition: Lopata::Condition.new(condition, positive: false), metadata: metadata, &block)
+ }
+ end
+
+ # Define setup step.
+ # @example
+ # setup do
+ # end
+ #
+ # # setup from named shared step
+ # setup 'create user'
+ #
+ # # setup with both shared step and code block
+ # setup 'create user' do
+ # @user.update(admin: true)
+ # end
+ #
+ # Setup step used for set test data.
+ # @overload setup(*steps, &block)
+ # @param steps [Array<String, Symbol, Proc>] the steps to be runned as a part of setup.
+ # String - name of shared step to be called.
+ # Symbol - metadata key, referenced to shared step name.
+ # Proc - in-place step implementation.
+ # @param block [Block] The implementation of the step.
+ define_step_method :setup
+
+ # Define action step.
+ # @example
+ # action do
+ # end
+ #
+ # # action from named shared step
+ # action 'login'
+ #
+ # # setup with both shared step and code block
+ # action 'login', 'go dashboard' do
+ # @user.update(admin: true)
+ # end
+ #
+ # Action step is used for emulate user or external system action
+ #
+ # @overload action(*steps, &block)
+ # @param steps [Array<String, Symbol, Proc>] the steps to be runned as a part of action.
+ # String - name of shared step to be called.
+ # Symbol - metadata key, referenced to shared step name.
+ # Proc - in-place step implementation.
+ # @param block [Block] The implementation of the step.
+ define_step_method :action
+
+ # Define teardown step.
+ # @example
+ # setup { @user = User.create! }
+ # teardown { @user.destroy }
+ # Teardown step will be called at the end of scenario running.
+ # But it suggested to be decared right after setup or action step which require teardown.
+ #
+ # @overload teardown(*steps, &block)
+ # @param steps [Array<String, Symbol, Proc>] the steps to be runned as a part of teardown.
+ # String - name of shared step to be called.
+ # Symbol - metadata key, referenced to shared step name.
+ # Proc - in-place step implementation.
+ # @param block [Block] The implementation of the step.
+ define_step_method :teardown
+
+ # Define verify steps.
+ # @example
+ # verify 'home page displayed' # call shared step.
+ # Usually for validation shared steps inclusion
+ #
+ # @overload verify(*steps, &block)
+ # @param steps [Array<String, Symbol, Proc>] the steps to be runned as a part of verification.
+ # String - name of shared step to be called.
+ # Symbol - metadata key, referenced to shared step name.
+ # Proc - in-place step implementation.
+ # @param block [Block] The implementation of the step.
+ define_step_method :verify
+
+ # Define group of steps.
+ # The metadata for the group may be overriden
+ # @example
+ # context 'the task', task: :created do
+ # verify 'task setup'
+ # it 'created' do
+ # expect(metadata[:task]).to eq :created
+ # end
+ # end
+ # Teardown steps within group will be called at the end of the group, not scenario
+ # @overload context(title, **metadata, &block)
+ # @param title [String] context title
+ # @param metadata [Hash] the step additional metadata
+ # @param block [Block] The implementation of the step.
+ define_step_method :context
+
+ # Define single validation step.
+ # @example
+ # it 'works' do
+ # expect(1).to eq 1
+ # end
+ # @overload it(title, &block)
+ # @param title [String] validation title
+ # @param block [Block] The implementation of the step.
+ define_step_method :it
+
+ # Define runtime method for the scenario.
+ #
+ # @note
+ # The method to be called via #method_missing, so it wont override already defined methods.
+ #
+ # @example
+ # let(:square) { |num| num * num }
+ # it 'calculated' do
+ # expect(square(4)).to eq 16
+ # end
+ def let(method_name, &block)
+ steps << Lopata::Step.new(:let) do
+ execution.let(method_name, &block)
+ end
+ end
+
+ # @!endgroup
+
+ # @private
+ def add_step(method_name, *args, condition: nil, metadata: {}, &block)
+ step_class =
+ case method_name
+ when /^(setup|action|teardown|verify)/ then Lopata::ActionStep
+ when /^(context)/ then Lopata::GroupStep
+ else Lopata::Step
+ end
+ step = step_class.new(method_name, *args, condition: condition, shared_step: shared_step, &block)
+ step.metadata = metadata
+ steps << step
+ end
+
+ # @private
+ def steps
+ @steps ||= []
+ end
+
+ # @private
+ def steps_with_hooks
+ s = []
+ unless Lopata.configuration.before_scenario_steps.empty?
+ s << Lopata::ActionStep.new(:setup, *Lopata.configuration.before_scenario_steps)
+ end
+
+ s += steps
+
+ unless Lopata.configuration.after_scenario_steps.empty?
+ s << Lopata::ActionStep.new(:teardown, *Lopata.configuration.after_scenario_steps)
+ end
+
+ s
+ end
+
+ # @private
+ def option_combinations
+ combinations = combine([OptionSet.new], options + diagonals)
+ while !diagonals.all?(&:complete?)
+ combinations << OptionSet.new(*(options + diagonals).map(&:next_variant))
+ end
+ combinations.reject { |option_set| skip?(option_set) }
+ end
+
+ # @private
+ def combine(source_combinations, rest_options)
+ return source_combinations if rest_options.empty?
+ combinations = []
+ current_option = rest_options.shift
+ source_combinations.each do |source_variants|
+ current_option.level_variants.each do |v|
+ combinations << (source_variants + OptionSet.new(v))
+ end
+ end
+ combine(combinations, rest_options)
+ end
+
+ # @private
+ def world
+ Lopata.world
+ end
+
+ # Set of options for scenario
+ class OptionSet
+ # @private
+ attr_reader :variants
+
+ # @private
+ def initialize(*variants)
+ @variants = {}
+ variants.compact.each { |v| self << v }
+ end
+
+ # @private
+ def +(other_set)
+ self.class.new(*@variants.values).tap do |sum|
+ other_set.each { |v| sum << v }
+ end
+ end
+
+ # @private
+ def <<(variant)
+ @variants[variant.key] = variant
+ end
+
+ # @private
+ def [](key)
+ @variants[key]
+ end
+
+ # @private
+ def each(&block)
+ @variants.values.each(&block)
+ end
+
+ # @private
+ def title
+ @variants.values.map(&:title).compact.join(' ')
+ end
+
+ # @return [Hash{Symbol => Object}] metadata for this option set
+ def metadata
+ @variants.values.inject({}) do |metadata, variant|
+ metadata.merge(variant.metadata(self))
+ end
+ end
+ end
+
+ # @private
+ class Variant
+ attr_reader :key, :title, :value, :option
+
+ def initialize(option, key, title, value)
+ @option, @key, @title, @value = option, key, title, check_lambda_arity(value)
+ end
+
+ def metadata(option_set)
+ data = { key => value }
+ if value.is_a? Hash
+ value.each do |k, v|
+ sub_key = "%s_%s" % [key, k]
+ data[sub_key.to_sym] = v
+ end
+ end
+
+ option.available_metadata_keys.each do |key|
+ data[key] = nil unless data.has_key?(key)
+ end
+
+ data.each do |key, v|
+ data[key] = v.calculate(option_set) if v.is_a? CalculatedValue
+ end
+ data
+ end
+
+ def self.join(variants)
+ title, metadata = nil, {}
+ variants.each do |v|
+ title = [title, v.title].compact.join(' ')
+ metadata.merge!(v.metadata)
+ end
+ [title, metadata]
+ end
+
+ private
+
+ # Лямдда будет передаваться как блок в instance_eval, которому плохеет, если пришло что-то с нулевой
+ # arity. Поэтому для лямбд с нулевой arity делаем arity == 1
+ def check_lambda_arity(v)
+ if v.is_a?(Proc) && v.arity == 0
+ ->(_) { instance_exec(&v) }
+ else
+ v
+ end
+ end
+ end
+
+ # @private
+ class CalculatedValue
+ def initialize(&block)
+ @proc = block
+ end
+
+ def calculate(option_set)
+ @proc.call(option_set)
+ end
+ end
+
+ # @private
+ class Option
+ attr_reader :variants, :key, :use_all_variants
+ def initialize(key, variants, use_all_variants = true)
+ @key = key
+ @variants =
+ if variants.is_a? Hash
+ variants.map { |title, value| Variant.new(self, key, title, value) }
+ else
+ # Array of arrays of two elements
+ variants.map { |v| Variant.new(self, key, *v) }
+ end
+ @use_all_variants = use_all_variants
+ end
+
+ # Variants to apply at one level
+ def level_variants
+ variants
+ end
+
+ def next_variant
+ @current ||= 0
+ selected_variant = variants[@current]
+ @current += 1
+ @complete = true unless use_all_variants # not need to verify all variants, just use first ones.
+ if @current >= variants.length
+ @current = 0
+ @complete = true # all variants have been selected
+ end
+ selected_variant
+ end
+
+ def available_metadata_keys
+ @available_metadata_keys ||= variants
+ .map(&:value).select { |v| v.is_a?(Hash) }.flat_map(&:keys).map { |k| "#{key}_#{k}".to_sym }.uniq
+ end
+ end
+
+ # @private
+ class Diagonal < Option
+ def level_variants
+ [next_variant]
+ end
+
+ def complete?
+ @complete
+ end
+ end
+end