From 5b11342ae3834e3dfc39070d75742637f24dcf82 Mon Sep 17 00:00:00 2001 From: Rodrigo Serradura Date: Tue, 2 Jan 2024 19:53:37 -0300 Subject: [PATCH] [WIP] Add BCDD::Result#and_then! --- lib/bcdd/result.rb | 21 ++- lib/bcdd/result/callable_and_then.rb | 9 + lib/bcdd/result/callable_and_then/caller.rb | 49 ++++++ lib/bcdd/result/callable_and_then/config.rb | 15 ++ lib/bcdd/result/callable_and_then/error.rb | 11 ++ lib/bcdd/result/config.rb | 12 +- lib/bcdd/result/config/switchers/features.rb | 6 +- lib/bcdd/result/context.rb | 26 ++- lib/bcdd/result/context/callable_and_then.rb | 39 +++++ lib/bcdd/result/context/success.rb | 20 ++- lib/bcdd/result/error.rb | 11 +- lib/bcdd/result/transitions.rb | 18 +- .../result/transitions/tracking/disabled.rb | 16 +- .../result/transitions/tracking/enabled.rb | 52 ++++-- lib/bcdd/result/transitions/tree.rb | 16 +- sig/bcdd/result.rbs | 6 +- sig/bcdd/result/callable_and_then.rbs | 60 +++++++ sig/bcdd/result/config.rbs | 2 + sig/bcdd/result/context.rbs | 54 +++++- sig/bcdd/result/error.rbs | 3 + sig/bcdd/result/transitions.rbs | 21 ++- .../result/callable_and_then/arity_test.rb | 107 ++++++++++++ .../results_from_different_sources_test.rb | 157 ++++++++++++++++++ .../returning_context_test.rb | 56 +++++++ .../unexpected_outcome_test.rb | 43 +++++ .../config/feature/and_then_bang_test.rb | 99 +++++++++++ test/bcdd/result/config/feature_test.rb | 10 +- test/bcdd/result/config_test.rb | 6 +- test/bcdd/result/configuration_test.rb | 2 + .../callable_and_then/accumulation_test.rb | 120 +++++++++++++ .../context/callable_and_then/arity_test.rb | 99 +++++++++++ .../result_kind_error_test.rb | 49 ++++++ .../results_from_different_sources_test.rb | 157 ++++++++++++++++++ .../unexpected_outcome_test.rb | 43 +++++ 34 files changed, 1347 insertions(+), 68 deletions(-) create mode 100644 lib/bcdd/result/callable_and_then.rb create mode 100644 lib/bcdd/result/callable_and_then/caller.rb create mode 100644 lib/bcdd/result/callable_and_then/config.rb create mode 100644 lib/bcdd/result/callable_and_then/error.rb create mode 100644 lib/bcdd/result/context/callable_and_then.rb create mode 100644 sig/bcdd/result/callable_and_then.rbs create mode 100644 test/bcdd/result/callable_and_then/arity_test.rb create mode 100644 test/bcdd/result/callable_and_then/results_from_different_sources_test.rb create mode 100644 test/bcdd/result/callable_and_then/returning_context_test.rb create mode 100644 test/bcdd/result/callable_and_then/unexpected_outcome_test.rb create mode 100644 test/bcdd/result/config/feature/and_then_bang_test.rb create mode 100644 test/bcdd/result/context/callable_and_then/accumulation_test.rb create mode 100644 test/bcdd/result/context/callable_and_then/arity_test.rb create mode 100644 test/bcdd/result/context/callable_and_then/result_kind_error_test.rb create mode 100644 test/bcdd/result/context/callable_and_then/results_from_different_sources_test.rb create mode 100644 test/bcdd/result/context/callable_and_then/unexpected_outcome_test.rb diff --git a/lib/bcdd/result.rb b/lib/bcdd/result.rb index 42607bf..2e27bf7 100644 --- a/lib/bcdd/result.rb +++ b/lib/bcdd/result.rb @@ -1,8 +1,11 @@ # frozen_string_literal: true +require 'singleton' + require_relative 'result/version' -require_relative 'result/transitions' require_relative 'result/error' +require_relative 'result/transitions' +require_relative 'result/callable_and_then' require_relative 'result/data' require_relative 'result/handler' require_relative 'result/failure' @@ -88,12 +91,20 @@ def on_unknown tap { yield(value, type) if unknown } end - def and_then(method_name = nil, context = nil, &block) + def and_then(method_name = nil, injected_value = nil, &block) return self if terminal? method_name && block and raise ::ArgumentError, 'method_name and block are mutually exclusive' - method_name ? call_and_then_source_method(method_name, context) : call_and_then_block(block) + method_name ? call_and_then_source_method(method_name, injected_value) : call_and_then_block(block) + end + + def and_then!(source, injected_value = nil, _call: nil) + raise Error::CallableAndThenDisabled unless Config.instance.feature.enabled?(:and_then!) + + return self if terminal? + + call_and_then_callable!(source, value: value, injected_value: injected_value, method_name: _call) end def handle @@ -170,6 +181,10 @@ def call_and_then_block!(block) block.call(value) end + def call_and_then_callable!(source, value:, injected_value:, method_name:) + CallableAndThen::Caller.call(source, value: value, injected_value: injected_value, method_name: method_name) + end + def ensure_result_object(result, origin:) raise Error::UnexpectedOutcome.build(outcome: result, origin: origin) unless result.is_a?(::BCDD::Result) diff --git a/lib/bcdd/result/callable_and_then.rb b/lib/bcdd/result/callable_and_then.rb new file mode 100644 index 0000000..2a26691 --- /dev/null +++ b/lib/bcdd/result/callable_and_then.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class BCDD::Result + module CallableAndThen + require_relative 'callable_and_then/error' + require_relative 'callable_and_then/config' + require_relative 'callable_and_then/caller' + end +end diff --git a/lib/bcdd/result/callable_and_then/caller.rb b/lib/bcdd/result/callable_and_then/caller.rb new file mode 100644 index 0000000..c43459e --- /dev/null +++ b/lib/bcdd/result/callable_and_then/caller.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +class BCDD::Result + class CallableAndThen::Caller + def self.call(source, value:, injected_value:, method_name:) + method = callable_method(source, method_name) + + Transitions.tracking.record_and_then(method, injected_value, source) do + result = + if source.is_a?(::Proc) + call_proc!(source, value, injected_value) + else + call_method!(source, method, value, injected_value) + end + + ensure_result_object(source, value, result) + end + end + + def self.call_proc!(source, value, injected_value) + case source.arity + when 1 then source.call(value) + when 2 then source.call(value, injected_value) + else raise CallableAndThen::Error::InvalidArity.build(source: source, method: :call, arity: '1..2') + end + end + + def self.call_method!(source, method, value, injected_value) + case method.arity + when 1 then source.send(method.name, value) + when 2 then source.send(method.name, value, injected_value) + else raise CallableAndThen::Error::InvalidArity.build(source: source, method: method.name, arity: '1..2') + end + end + + def self.callable_method(source, method_name) + source.method(method_name || Config.instance.and_then!.default_method_name_to_call) + end + + def self.ensure_result_object(source, _value, result) + return result if result.is_a?(::BCDD::Result) + + raise Error::UnexpectedOutcome.build(outcome: result, origin: source) + end + + private_class_method :new, :allocate + private_class_method :call_proc!, :call_method!, :callable_method, :ensure_result_object + end +end diff --git a/lib/bcdd/result/callable_and_then/config.rb b/lib/bcdd/result/callable_and_then/config.rb new file mode 100644 index 0000000..de1da51 --- /dev/null +++ b/lib/bcdd/result/callable_and_then/config.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class BCDD::Result + class CallableAndThen::Config + attr_accessor :default_method_name_to_call + + def initialize + self.default_method_name_to_call = :call + end + + def options + { default_method_name_to_call: default_method_name_to_call } + end + end +end diff --git a/lib/bcdd/result/callable_and_then/error.rb b/lib/bcdd/result/callable_and_then/error.rb new file mode 100644 index 0000000..fc6de24 --- /dev/null +++ b/lib/bcdd/result/callable_and_then/error.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class BCDD::Result + class CallableAndThen::Error < Error + class InvalidArity < self + def self.build(source:, method:, arity:) + new("Invalid arity for #{source.class}##{method} method. Expected arity: #{arity}") + end + end + end +end diff --git a/lib/bcdd/result/config.rb b/lib/bcdd/result/config.rb index f640940..38d0051 100644 --- a/lib/bcdd/result/config.rb +++ b/lib/bcdd/result/config.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'singleton' - require_relative 'config/options' require_relative 'config/switcher' require_relative 'config/switchers/addons' @@ -20,6 +18,11 @@ def initialize @feature = Features.switcher @constant_alias = ConstantAliases.switcher @pattern_matching = PatternMatching.switcher + @and_then_ = CallableAndThen::Config.new + end + + def and_then! + @and_then_ end def freeze @@ -27,6 +30,7 @@ def freeze feature.freeze constant_alias.freeze pattern_matching.freeze + and_then!.freeze super end @@ -45,7 +49,9 @@ def to_h end def inspect - "#<#{self.class.name} options=#{options.keys.sort.inspect}>" + "#<#{self.class.name} " \ + "options=#{options.keys.sort.inspect} " \ + "and_then!=#{and_then!.options.inspect}>" end end end diff --git a/lib/bcdd/result/config/switchers/features.rb b/lib/bcdd/result/config/switchers/features.rb index 427f75f..4a34201 100644 --- a/lib/bcdd/result/config/switchers/features.rb +++ b/lib/bcdd/result/config/switchers/features.rb @@ -10,7 +10,11 @@ module Features }, transitions: { default: true, - affects: %w[BCDD::Result BCDD::Result::Context] + affects: %w[BCDD::Result BCDD::Result::Context BCDD::Result::Expectations BCDD::Result::Context::Expectations] + }, + and_then!: { + default: false, + affects: %w[BCDD::Result BCDD::Result::Context BCDD::Result::Expectations BCDD::Result::Context::Expectations] } }.transform_values!(&:freeze).freeze diff --git a/lib/bcdd/result/context.rb b/lib/bcdd/result/context.rb index a070f15..d35dffb 100644 --- a/lib/bcdd/result/context.rb +++ b/lib/bcdd/result/context.rb @@ -6,6 +6,9 @@ class Context < self require_relative 'context/success' require_relative 'context/mixin' require_relative 'context/expectations' + require_relative 'context/callable_and_then' + + EXPECTED_OUTCOME = 'BCDD::Result::Context::Success or BCDD::Result::Context::Failure' def self.Success(type, **value) Success.new(type: type, value: value) @@ -27,6 +30,14 @@ def and_then(method_name = nil, **injected_value, &block) super(method_name, injected_value, &block) end + def and_then!(source, **injected_value) + _call = injected_value.delete(:_call) + + acc.merge!(injected_value) + + super(source, injected_value, _call: _call) + end + protected attr_reader :acc @@ -35,7 +46,10 @@ def and_then(method_name = nil, **injected_value, &block) SourceMethodArity = ->(method) do return 0 if method.arity.zero? - return 1 if method.parameters.map(&:first).all?(/\Akey/) + + parameters = method.parameters.map(&:first) + + return 1 if !parameters.empty? && parameters.all?(/\Akey/) -1 end @@ -56,6 +70,12 @@ def call_and_then_block!(block) block.call(acc) end + def call_and_then_callable!(source, value:, injected_value:, method_name:) + acc.merge!(value.merge(injected_value)) + + CallableAndThen::Caller.call(source, value: acc, injected_value: injected_value, method_name: method_name) + end + def ensure_result_object(result, origin:) raise_unexpected_outcome_error(result, origin) unless result.is_a?(Context) @@ -64,12 +84,10 @@ def ensure_result_object(result, origin:) raise Error::InvalidResultSource.build(given_result: result, expected_source: source) end - EXPECTED_OUTCOME = 'BCDD::Result::Context::Success or BCDD::Result::Context::Failure' - def raise_unexpected_outcome_error(result, origin) raise Error::UnexpectedOutcome.build(outcome: result, origin: origin, expected: EXPECTED_OUTCOME) end - private_constant :SourceMethodArity, :EXPECTED_OUTCOME + private_constant :SourceMethodArity end end diff --git a/lib/bcdd/result/context/callable_and_then.rb b/lib/bcdd/result/context/callable_and_then.rb new file mode 100644 index 0000000..10fe897 --- /dev/null +++ b/lib/bcdd/result/context/callable_and_then.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class BCDD::Result + module Context::CallableAndThen + class Caller < CallableAndThen::Caller + module KeyArgs + def self.parameters?(source) + parameters = source.parameters.map(&:first) + + !parameters.empty? && parameters.all?(/\Akey/) + end + + def self.invalid_arity(source, method) + CallableAndThen::Error::InvalidArity.build(source: source, method: method, arity: 'only keyword args') + end + end + + def self.call_proc!(source, value, _injected_value) + return source.call(**value) if KeyArgs.parameters?(source) + + raise KeyArgs.invalid_arity(source, :call) + end + + def self.call_method!(source, method, value, _injected_value) + return source.send(method.name, **value) if KeyArgs.parameters?(method) + + raise KeyArgs.invalid_arity(source, method.name) + end + + def self.ensure_result_object(source, value, result) + return result.tap { result.send(:acc).then { _1.merge!(value.merge(_1)) } } if result.is_a?(Context) + + raise Error::UnexpectedOutcome.build(outcome: result, origin: source, expected: Context::EXPECTED_OUTCOME) + end + + private_class_method :call_proc!, :call_method! + end + end +end diff --git a/lib/bcdd/result/context/success.rb b/lib/bcdd/result/context/success.rb index 7c1baae..ce74df5 100644 --- a/lib/bcdd/result/context/success.rb +++ b/lib/bcdd/result/context/success.rb @@ -1,15 +1,19 @@ # frozen_string_literal: true -class BCDD::Result::Context::Success < BCDD::Result::Context - include ::BCDD::Result::Success::Methods +class BCDD::Result + class Context::Success < Context + include ::BCDD::Result::Success::Methods - def and_expose(type, keys, terminal: true) - unless keys.is_a?(::Array) && !keys.empty? && keys.all?(::Symbol) - raise ::ArgumentError, 'keys must be an Array of Symbols' - end + def and_expose(type, keys, terminal: true) + unless keys.is_a?(::Array) && !keys.empty? && keys.all?(::Symbol) + raise ::ArgumentError, 'keys must be an Array of Symbols' + end + + Transitions.tracking.reset_and_then! - exposed_value = acc.merge(value).slice(*keys) + exposed_value = acc.merge(value).slice(*keys) - self.class.new(type: type, value: exposed_value, source: source, terminal: terminal) + self.class.new(type: type, value: exposed_value, source: source, terminal: terminal) + end end end diff --git a/lib/bcdd/result/error.rb b/lib/bcdd/result/error.rb index 61fc448..ce1317d 100644 --- a/lib/bcdd/result/error.rb +++ b/lib/bcdd/result/error.rb @@ -9,7 +9,7 @@ class NotImplemented < self end class MissingTypeArgument < self - def initialize(_arg = nil) + def initialize(_message = nil) super('A type (argument) is required to invoke the #on/#on_type method') end end @@ -47,4 +47,13 @@ def self.build(types:) new("You must handle all cases. #{source} not handled: #{types.map(&:inspect).join(', ')}") end end + + class CallableAndThenDisabled < self + def initialize(_message = nil) + super( + 'You cannot use #and_then! as the feature is disabled. ' \ + 'Please use BCDD::Result.config.feature.enable!(:and_then!) to enable it.' + ) + end + end end diff --git a/lib/bcdd/result/transitions.rb b/lib/bcdd/result/transitions.rb index 2fcef1b..f8c0c94 100644 --- a/lib/bcdd/result/transitions.rb +++ b/lib/bcdd/result/transitions.rb @@ -7,21 +7,19 @@ module Transitions THREAD_VAR_NAME = :bcdd_result_transitions_tracking + EnsureResult = ->(result) do + return result if result.is_a?(::BCDD::Result) + + raise Error::UnexpectedOutcome.build(outcome: result, origin: :transitions) + end + def self.tracking Thread.current[THREAD_VAR_NAME] ||= Tracking.instance end end - def self.transitions(name: nil, desc: nil) - Transitions.tracking.start(name: name, desc: desc) - - result = yield - - result.is_a?(::BCDD::Result) or raise Error::UnexpectedOutcome.build(outcome: result, origin: :transitions) - - Transitions.tracking.finish(result: result) - - result + def self.transitions(name: nil, desc: nil, &block) + Transitions.tracking.exec(name, desc, &block) rescue ::Exception => e Transitions.tracking.reset! diff --git a/lib/bcdd/result/transitions/tracking/disabled.rb b/lib/bcdd/result/transitions/tracking/disabled.rb index 7acf7ed..45e467e 100644 --- a/lib/bcdd/result/transitions/tracking/disabled.rb +++ b/lib/bcdd/result/transitions/tracking/disabled.rb @@ -2,9 +2,9 @@ module BCDD::Result::Transitions module Tracking::Disabled - def self.start(name:, desc:); end - - def self.finish(result:); end + def self.exec(_name, _desc) + EnsureResult[yield] + end def self.reset!; end @@ -13,5 +13,15 @@ def self.record(result); end def self.record_and_then(_type, _data, _source) yield end + + def self.reset_and_then!; end + + class << self + private + + def start(name, desc); end + + def finish(result); end + end end end diff --git a/lib/bcdd/result/transitions/tracking/enabled.rb b/lib/bcdd/result/transitions/tracking/enabled.rb index 4f43235..8f34924 100644 --- a/lib/bcdd/result/transitions/tracking/enabled.rb +++ b/lib/bcdd/result/transitions/tracking/enabled.rb @@ -6,26 +6,18 @@ class Tracking::Enabled private :tree, :tree=, :records, :records=, :root_started_at, :root_started_at= - def start(name:, desc:) - name_and_desc = [name, desc] - - tree.frozen? ? root_start(name_and_desc) : tree.insert!(name_and_desc) - end - - def finish(result:) - node = tree.current + def exec(name, desc) + start(name, desc) - tree.move_up! + transition_node = tree.current - return unless node.root? + result = EnsureResult[yield] - duration = (now_in_milliseconds - root_started_at) + tree.move_to_root! if transition_node.root? - metadata = { duration: duration, tree_map: tree.nested_ids } + finish(result) - result.send(:transitions=, version: Tracking::VERSION, records: records, metadata: metadata) - - reset! + result end def reset! @@ -51,8 +43,36 @@ def record_and_then(type_arg, arg, source) yield end + def reset_and_then! + return if tree.frozen? + + tree.current.value[1] = Tracking::EMPTY_HASH + end + private + def start(name, desc) + name_and_desc = [name, desc] + + tree.frozen? ? root_start(name_and_desc) : tree.insert!(name_and_desc) + end + + def finish(result) + node = tree.current + + tree.move_up! + + return unless node.root? + + duration = (now_in_milliseconds - root_started_at) + + metadata = { duration: duration, tree_map: tree.nested_ids } + + result.send(:transitions=, version: Tracking::VERSION, records: records, metadata: metadata) + + reset! + end + TreeNodeValueNormalizer = ->(id, (nam, des)) { [{ id: id, name: nam, desc: des }, Tracking::EMPTY_HASH] } def root_start(name_and_desc) @@ -74,7 +94,7 @@ def track(result, time:) end def now_in_milliseconds - Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) + ::Process.clock_gettime(::Process::CLOCK_MONOTONIC, :millisecond) end end end diff --git a/lib/bcdd/result/transitions/tree.rb b/lib/bcdd/result/transitions/tree.rb index dfc67e6..7c442db 100644 --- a/lib/bcdd/result/transitions/tree.rb +++ b/lib/bcdd/result/transitions/tree.rb @@ -46,9 +46,9 @@ def inspect def initialize(value, normalizer: ->(_id, val) { val }) @size = 0 - @root = Node.new(value, parent: nil, id: @size, normalizer: normalizer) + @root = Node.new(value, parent: nil, id: size, normalizer: normalizer) - @current = @root + @current = root end def root_value @@ -70,19 +70,23 @@ def insert(value) end def insert!(value) - @current = insert(value) + move_to! insert(value) + end + + def move_to!(node) + tap { @current = node } end def move_up!(level = 1) - tap { level.times { @current = current.parent || root } } + tap { level.times { move_to!(current.parent || root) } } end def move_down!(level = 1, index: -1) - tap { level.times { current.children[index].then { |child| @current = child if child } } } + tap { level.times { current.children[index].then { |child| move_to!(child) if child } } } end def move_to_root! - tap { @current = root } + move_to!(root) end NestedIds = ->(node) { [node.id, node.children.map(&NestedIds)] } diff --git a/sig/bcdd/result.rbs b/sig/bcdd/result.rbs index 5612c84..942331c 100644 --- a/sig/bcdd/result.rbs +++ b/sig/bcdd/result.rbs @@ -10,6 +10,7 @@ class BCDD::Result def self.config: -> BCDD::Result::Config def self.configuration: { (BCDD::Result::Config) -> void } -> BCDD::Result::Config + def self.transitions: { () -> untyped } -> BCDD::Result def initialize: ( type: Symbol, @@ -33,7 +34,9 @@ class BCDD::Result def on_failure: (*Symbol) { (untyped, Symbol) -> void } -> BCDD::Result def on_unknown: () { (untyped, Symbol) -> void } -> BCDD::Result - def and_then: (?Symbol method_name, ?untyped context) ?{ (untyped) -> untyped } -> untyped + def and_then: (?Symbol method_name, ?untyped injected_value) ?{ (untyped) -> untyped } -> untyped + + def and_then!: (untyped, ?untyped injected_value, _call: (Symbol | nil)) -> untyped def handle: () { (BCDD::Result::Handler) -> void } -> untyped @@ -55,6 +58,7 @@ class BCDD::Result def call_and_then_source_method!: (untyped, untyped) -> BCDD::Result def call_and_then_block: (untyped) -> BCDD::Result def call_and_then_block!: (untyped) -> BCDD::Result + def call_and_then_callable!: (untyped, value: untyped, injected_value: untyped, method_name: (Symbol | nil)) -> BCDD::Result def ensure_result_object: (untyped, origin: Symbol) -> BCDD::Result end diff --git a/sig/bcdd/result/callable_and_then.rbs b/sig/bcdd/result/callable_and_then.rbs new file mode 100644 index 0000000..b0c7018 --- /dev/null +++ b/sig/bcdd/result/callable_and_then.rbs @@ -0,0 +1,60 @@ +module BCDD::Result::CallableAndThen + class Config + attr_accessor default_method_name_to_call: Symbol + + def initialize: -> void + + def options: () -> Hash[Symbol, untyped] + end + + class Error < BCDD::Result::Error + end + + class Error::InvalidArity < Error + def self.build: ( + source: untyped, + method: Symbol, + arity: String + ) -> Error::InvalidArity + end + + class Caller + def self.call: ( + untyped source, + value: untyped, + injected_value: untyped, + method_name: (Symbol | nil) + ) -> BCDD::Result + + private + + def self.call_proc!: ( + untyped source, + untyped value, + untyped injected_value + ) -> BCDD::Result + + def self.call_method!: ( + untyped source, + Method method, + untyped value, + untyped injected_value + ) -> BCDD::Result + + def self.callable_method: ( + untyped source, + (Symbol | nil) method_name + ) -> ::Method + + def self.ensure_result_object: ( + untyped source, + untyped value, + BCDD::Result result + ) -> BCDD::Result + + def self.expected_result_object: () -> singleton(BCDD::Result) + + def self.expected_outcome: () -> String + end +end + diff --git a/sig/bcdd/result/config.rbs b/sig/bcdd/result/config.rbs index 4a4ea3c..44b102d 100644 --- a/sig/bcdd/result/config.rbs +++ b/sig/bcdd/result/config.rbs @@ -14,6 +14,8 @@ class BCDD::Result::Config def initialize: -> void + def and_then!: () -> BCDD::Result::CallableAndThen::Config + def freeze: -> BCDD::Result::Config def options: -> Hash[Symbol, BCDD::Result::Config::Switcher] def to_h: -> Hash[Symbol, Hash[Symbol | String, bool]] diff --git a/sig/bcdd/result/context.rbs b/sig/bcdd/result/context.rbs index ac98d71..4932163 100644 --- a/sig/bcdd/result/context.rbs +++ b/sig/bcdd/result/context.rbs @@ -13,11 +13,16 @@ class BCDD::Result::Context < BCDD::Result ?terminal: bool ) -> void - def and_then: (?Symbol, **untyped) ?{ (Hash[Symbol, untyped]) -> untyped } -> BCDD::Result::Context + def and_then: (?Symbol, **untyped) ?{ (Hash[Symbol, untyped]) -> untyped } -> untyped + + def and_then!: (untyped, **untyped) -> untyped private def call_and_then_source_method: (Symbol, Hash[Symbol, untyped]) -> BCDD::Result::Context + + def call_and_then_callable!: (untyped, value: untyped, injected_value: untyped, method_name: (Symbol | nil)) -> BCDD::Result::Context + def ensure_result_object: (untyped, origin: Symbol) -> BCDD::Result::Context def raise_unexpected_outcome_error: (BCDD::Result::Context | untyped, Symbol) -> void @@ -33,6 +38,53 @@ class BCDD::Result::Context def self.Success: (Symbol, **untyped) -> BCDD::Result::Context::Success end +module BCDD::Result::Context::CallableAndThen + class Caller < BCDD::Result::CallableAndThen::Caller + module KeyArgs + def self.parameters?: (untyped) -> bool + + def self.invalid_arity: (untyped, Symbol) -> BCDD::Result::CallableAndThen::Error::InvalidArity + end + + def self.call: ( + untyped source, + value: untyped, + injected_value: untyped, + method_name: (Symbol | nil), + ) -> BCDD::Result::Context + + private + + def self.call_proc!: ( + untyped source, + Hash[Symbol, untyped] value, + nil injected_value + ) -> BCDD::Result::Context + + def self.call_method!: ( + untyped source, + Method method, + Hash[Symbol, untyped] value, + nil injected_value + ) -> BCDD::Result::Context + + def self.callable_method: ( + untyped source, + (Symbol | nil) method_name + ) -> ::Method + + def self.ensure_result_object: ( + untyped source, + untyped value, + BCDD::Result::Context result + ) -> BCDD::Result::Context + + def self.expected_result_object: () -> singleton(BCDD::Result::Context) + + def self.expected_outcome: () -> String + end +end + class BCDD::Result::Context class Failure < BCDD::Result::Context include BCDD::Result::Failure::Methods diff --git a/sig/bcdd/result/error.rbs b/sig/bcdd/result/error.rbs index 9711384..d120096 100644 --- a/sig/bcdd/result/error.rbs +++ b/sig/bcdd/result/error.rbs @@ -27,5 +27,8 @@ class BCDD::Result def self.build: (types: Set[Symbol]) -> BCDD::Result::Error::UnhandledTypes end + + class CallableAndThenDisabled < BCDD::Result::Error + end end end diff --git a/sig/bcdd/result/transitions.rbs b/sig/bcdd/result/transitions.rbs index 372646b..7e48907 100644 --- a/sig/bcdd/result/transitions.rbs +++ b/sig/bcdd/result/transitions.rbs @@ -32,7 +32,8 @@ class BCDD::Result def parent_value: () -> untyped def current_value: () -> untyped def insert: (untyped) -> Node - def insert!: (untyped) -> Node + def insert!: (untyped) -> Tree + def move_to!: (Node) -> Tree def move_up!: (?Integer level) -> Tree def move_down!: (?Integer level) -> Tree def move_to_root!: () -> Tree @@ -54,14 +55,17 @@ class BCDD::Result private attr_accessor records: Array[Hash[Symbol, untyped]] private attr_accessor root_started_at: Integer - def start: (name: String, desc: String) -> void - def finish: (result: BCDD::Result) -> void + def exec: (String, String) { () -> untyped } -> BCDD::Result def reset!: () -> void def record: (BCDD::Result) -> void def record_and_then: ((untyped), untyped, untyped) { () -> BCDD::Result } -> BCDD::Result + def reset_and_then!: () -> void private + def start: (String, String) -> void + def finish: (BCDD::Result) -> void + TreeNodeValueNormalizer: ^(Integer, Array[untyped]) -> untyped def root_start: (Array[untyped]) -> void @@ -72,11 +76,16 @@ class BCDD::Result end module Disabled - def self.start: (name: String, desc: String) -> void - def self.finish: (result: BCDD::Result) -> void + def self.exec: (String, String) { () -> untyped } -> BCDD::Result def self.reset!: () -> void def self.record: (BCDD::Result) -> void def self.record_and_then: ((untyped), untyped, untyped) { () -> BCDD::Result } -> BCDD::Result + def self.reset_and_then!: () -> void + + private + + def self.start: (String, String) -> void + def self.finish: (BCDD::Result) -> void end def self.instance: () -> (Enabled | singleton(Disabled)) @@ -84,6 +93,8 @@ class BCDD::Result THREAD_VAR_NAME: Symbol + EnsureResult: ^(untyped) -> BCDD::Result + def self.tracking: () -> (Tracking::Enabled | singleton(Tracking::Disabled)) end end diff --git a/test/bcdd/result/callable_and_then/arity_test.rb b/test/bcdd/result/callable_and_then/arity_test.rb new file mode 100644 index 0000000..c8bcfdc --- /dev/null +++ b/test/bcdd/result/callable_and_then/arity_test.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require 'test_helper' + +class BCDD::Result + class CallableAndThenArityTest < Minitest::Test + ProcWithoutArg = proc { BCDD::Result::Success(:ok, -1) } + ProcWithOneArg = proc { |arg| BCDD::Result::Success(:ok, arg) } + ProcWithTwoArgs = proc { |arg1, arg2| BCDD::Result::Success(:ok, [arg1, arg2]) } + ProcWithThreeArgs = proc { |arg1, arg2, arg3| BCDD::Result::Success(:ok, [arg1, arg2, arg3]) } + + LambdaWithoutArg = -> { BCDD::Result::Success(:ok, -1) } + LambdaWithOneArg = ->(arg) { BCDD::Result::Success(:ok, arg) } + LambdaWithTwoArgs = ->(arg1, arg2) { BCDD::Result::Success(:ok, [arg1, arg2]) } + LambdaWithThreeArgs = ->(arg1, arg2, arg3) { BCDD::Result::Success(:ok, [arg1, arg2, arg3]) } + + module ModWithoutArg + def self.call; BCDD::Result::Success(:ok, -1); end + end + + module ModWithOneArg + def self.call(arg); BCDD::Result::Success(:ok, arg); end + end + + module ModWithTwoArgs + def self.call(arg1, arg2); BCDD::Result::Success(:ok, [arg1, arg2]); end + end + + module ModWithThreeArgs + def self.call(arg1, arg2, arg3); BCDD::Result::Success(:ok, [arg1, arg2, arg3]); end + end + + def setup + BCDD::Result.config.feature.enable!(:and_then!) + end + + def teardown + BCDD::Result.config.feature.disable!(:and_then!) + end + + test 'arity zero' do + err1 = assert_raises(BCDD::Result::CallableAndThen::Error::InvalidArity) do + BCDD::Result::Success(:ok, 0).and_then!(ProcWithoutArg) + end + + err2 = assert_raises(BCDD::Result::CallableAndThen::Error::InvalidArity) do + BCDD::Result::Success(:ok, 0).and_then!(LambdaWithoutArg) + end + + err3 = assert_raises(BCDD::Result::CallableAndThen::Error::InvalidArity) do + BCDD::Result::Success(:ok, 0).and_then!(ModWithoutArg) + end + + assert_equal 'Invalid arity for Proc#call method. Expected arity: 1..2', err1.message + assert_equal 'Invalid arity for Proc#call method. Expected arity: 1..2', err2.message + assert_equal 'Invalid arity for Module#call method. Expected arity: 1..2', err3.message + end + + test 'arity one' do + result1 = BCDD::Result::Success(:ok, 1).and_then!(ProcWithOneArg) + result2 = BCDD::Result::Success(:ok, 2).and_then!(LambdaWithOneArg) + result3 = BCDD::Result::Success(:ok, 3).and_then!(ModWithOneArg) + + assert(result1.success?(:ok)) + assert_equal 1, result1.value + + assert(result2.success?(:ok)) + assert_equal 2, result2.value + + assert(result3.success?(:ok)) + assert_equal 3, result3.value + end + + test 'arity two' do + result1 = BCDD::Result::Success(:ok, 1).and_then!(ProcWithTwoArgs, 2) + result2 = BCDD::Result::Success(:ok, 2).and_then!(LambdaWithTwoArgs, 3) + result3 = BCDD::Result::Success(:ok, 3).and_then!(ModWithTwoArgs, 4) + + assert(result1.success?(:ok)) + assert_equal [1, 2], result1.value + + assert(result2.success?(:ok)) + assert_equal [2, 3], result2.value + + assert(result3.success?(:ok)) + assert_equal [3, 4], result3.value + end + + test 'arity greater than or equal to three' do + err1 = assert_raises(BCDD::Result::CallableAndThen::Error::InvalidArity) do + BCDD::Result::Success(:ok, 0).and_then!(ProcWithThreeArgs, 1) + end + + err2 = assert_raises(BCDD::Result::CallableAndThen::Error::InvalidArity) do + BCDD::Result::Success(:ok, 0).and_then!(LambdaWithThreeArgs, 1) + end + + err3 = assert_raises(BCDD::Result::CallableAndThen::Error::InvalidArity) do + BCDD::Result::Success(:ok, 0).and_then!(ModWithThreeArgs, 1) + end + + assert_equal 'Invalid arity for Proc#call method. Expected arity: 1..2', err1.message + assert_equal 'Invalid arity for Proc#call method. Expected arity: 1..2', err2.message + assert_equal 'Invalid arity for Module#call method. Expected arity: 1..2', err3.message + end + end +end diff --git a/test/bcdd/result/callable_and_then/results_from_different_sources_test.rb b/test/bcdd/result/callable_and_then/results_from_different_sources_test.rb new file mode 100644 index 0000000..e0f1ed9 --- /dev/null +++ b/test/bcdd/result/callable_and_then/results_from_different_sources_test.rb @@ -0,0 +1,157 @@ +# frozen_string_literal: true + +require 'test_helper' + +class BCDD::Result + class CallableAndThenResultFromDifferentSourcesTest < Minitest::Test + include BCDDResultTransitionAssertions + + module NormalizeEmail + extend BCDD::Result.mixin + + def self.call(input) + BCDD::Result.transitions(name: 'NormalizeEmail') do + Given(input).and_then(:normalize) + end + end + + def self.normalize(input) + input.is_a?(::String) or return Failure(:invalid_input, 'input must be a String') + + Success(:normalized_input, input.downcase.strip) + end + end + + class EmailValidation + include BCDD::Result.mixin + + def initialize(expected_pattern: /\A[^@\s]+@[^@\s]+\z/) + @expected_pattern = expected_pattern + end + + def call(input) + BCDD::Result.transitions(name: 'EmailValidation') do + Given(input).and_then(:validate) + end + end + + def validate(input) + input.match?(@expected_pattern) ? Success(:valid_email, input) : Failure(:invalid_email, input) + end + end + + module NormalizeAndValidateEmail + extend BCDD::Result.mixin + + def self.call(email) + BCDD::Result.transitions(name: 'NormalizeAndValidateEmail') do + Given(email) + .and_then!(NormalizeEmail) + .and_then!(EmailValidation.new) + end + end + end + + def setup + BCDD::Result.config.feature.enable!(:and_then!) + end + + def teardown + BCDD::Result.config.feature.disable!(:and_then!) + end + + test 'results from different sources' do + result1 = NormalizeAndValidateEmail.call(nil) + result2 = NormalizeAndValidateEmail.call(' ') + result3 = NormalizeAndValidateEmail.call(" FOO@bAr.com \n") + + assert(result1.failure?(:invalid_input)) + assert_equal('input must be a String', result1.value) + + assert(result2.failure?(:invalid_email)) + assert_equal('', result2.value) + + assert(result3.success?(:valid_email)) + assert_equal('foo@bar.com', result3.value) + end + + test 'the transitions tracking' do + result1 = NormalizeAndValidateEmail.call(1) + + assert_transitions(result1, size: 3) + + assert_equal([0, [[1, []]]], result1.transitions[:metadata][:tree_map]) + + root = { id: 0, name: 'NormalizeAndValidateEmail', desc: nil } + + { + root: root, + parent: root, + current: root, + result: { kind: :success, type: :given, value: 1 } + }.then { assert_transition_record(result1, 0, _1) } + + { + root: root, + parent: root, + current: { id: 1, name: 'NormalizeEmail', desc: nil }, + result: { kind: :success, type: :given, value: 1 } + }.then { assert_transition_record(result1, 1, _1) } + + { + root: root, + parent: root, + current: { id: 1, name: 'NormalizeEmail', desc: nil }, + result: { kind: :failure, type: :invalid_input, value: 'input must be a String' }, + and_then: { type: :method, arg: nil, source: -> { _1 == NormalizeEmail }, method_name: :normalize } + }.then { assert_transition_record(result1, 2, _1) } + + # --- + + result2 = NormalizeAndValidateEmail.call(" FOO@bAr.com \n") + + assert_transitions(result2, size: 5) + + assert_equal([0, [[1, []], [2, []]]], result2.transitions[:metadata][:tree_map]) + + root = { id: 0, name: 'NormalizeAndValidateEmail', desc: nil } + + { + root: root, + parent: root, + current: root, + result: { kind: :success, type: :given, value: " FOO@bAr.com \n" } + }.then { assert_transition_record(result2, 0, _1) } + + { + root: root, + parent: root, + current: { id: 1, name: 'NormalizeEmail', desc: nil }, + result: { kind: :success, type: :given, value: " FOO@bAr.com \n" } + }.then { assert_transition_record(result2, 1, _1) } + + { + root: root, + parent: root, + current: { id: 1, name: 'NormalizeEmail', desc: nil }, + result: { kind: :success, type: :normalized_input, value: 'foo@bar.com' }, + and_then: { type: :method, arg: nil, source: -> { _1 == NormalizeEmail }, method_name: :normalize } + }.then { assert_transition_record(result2, 2, _1) } + + { + root: root, + parent: root, + current: { id: 2, name: 'EmailValidation', desc: nil }, + result: { kind: :success, type: :given, value: 'foo@bar.com' } + }.then { assert_transition_record(result2, 3, _1) } + + { + root: root, + parent: root, + current: { id: 2, name: 'EmailValidation', desc: nil }, + result: { kind: :success, type: :valid_email, value: 'foo@bar.com' }, + and_then: { type: :method, arg: nil, source: EmailValidation, method_name: :validate } + }.then { assert_transition_record(result2, 4, _1) } + end + end +end diff --git a/test/bcdd/result/callable_and_then/returning_context_test.rb b/test/bcdd/result/callable_and_then/returning_context_test.rb new file mode 100644 index 0000000..ab12ab9 --- /dev/null +++ b/test/bcdd/result/callable_and_then/returning_context_test.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'test_helper' + +class BCDD::Result + class CallableAndThenReturninContextTest < Minitest::Test + include BCDDResultTransitionAssertions + + module NormalizeEmail + extend Context.mixin + + def self.call(arg) + BCDD::Result.transitions(name: 'NormalizeEmail') do + input = arg[:input] + + input.is_a?(::String) or return Failure(:invalid_input, message: 'input must be a String') + + Success(:normalized_input, input: input.downcase.strip) + end + end + end + + module EmailNormalization + extend BCDD::Result.mixin + + def self.call(input) + BCDD::Result.transitions(name: 'EmailNormalization') do + Given(input: input) + .and_then!(NormalizeEmail) + end + end + end + + test 'results from different sources' do + BCDD::Result.config.feature.enable!(:and_then!) + + result1 = EmailNormalization.call(nil) + + assert_transitions(result1, size: 2) + + assert(result1.failure?(:invalid_input)) + assert_equal({ message: 'input must be a String' }, result1.value) + assert_kind_of(::BCDD::Result::Context, result1) + + result2 = EmailNormalization.call(' foo@BAR.com') + + assert_transitions(result1, size: 2) + + assert(result2.success?(:normalized_input)) + assert_equal({ input: 'foo@bar.com' }, result2.value) + assert_kind_of(::BCDD::Result::Context, result2) + ensure + BCDD::Result.config.feature.disable!(:and_then!) + end + end +end diff --git a/test/bcdd/result/callable_and_then/unexpected_outcome_test.rb b/test/bcdd/result/callable_and_then/unexpected_outcome_test.rb new file mode 100644 index 0000000..e2df08e --- /dev/null +++ b/test/bcdd/result/callable_and_then/unexpected_outcome_test.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'test_helper' + +class BCDD::Result + class CallableAndThenUnexpectedOutcomeTest < Minitest::Test + ProcWithArg = proc { |arg| arg } + + module ModWithArg + def self.call(arg); arg; end + end + + def setup + BCDD::Result.config.feature.enable!(:and_then!) + end + + def teardown + BCDD::Result.config.feature.disable!(:and_then!) + end + + test 'unexpected outcome' do + err1 = assert_raises(BCDD::Result::CallableAndThen::Error::UnexpectedOutcome) do + BCDD::Result::Success(:ok, 0).and_then!(ProcWithArg) + end + + err2 = assert_raises(BCDD::Result::CallableAndThen::Error::UnexpectedOutcome) do + BCDD::Result::Success(:ok, 0).and_then!(ModWithArg) + end + + expected_kinds = 'BCDD::Result::Success or BCDD::Result::Failure' + + assert_match( + /Unexpected outcome: 0. The # must return this object wrapped by #{expected_kinds}/, + err1.message + ) + + assert_match( + /Unexpected outcome: 0. The .+::ModWithArg must return this object wrapped by #{expected_kinds}/, + err2.message + ) + end + end +end diff --git a/test/bcdd/result/config/feature/and_then_bang_test.rb b/test/bcdd/result/config/feature/and_then_bang_test.rb new file mode 100644 index 0000000..2eb2f2e --- /dev/null +++ b/test/bcdd/result/config/feature/and_then_bang_test.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require 'test_helper' + +class BCDD::Result::Config + class FeatureAndThenBangTest < Minitest::Test + class AddR + include BCDD::Result.mixin + + def call(arg1, arg2) + Success(:ok, arg1 + arg2) + end + end + + AddR1 = ->(value) { AddR.new.call(value, 1) } + + module AddR2 + def self.call(value) + AddR.new.call(value, 2) + end + end + + module AddR3 + def self.perform(value) + AddR.new.call(value, 3) + end + end + + class AddC + include BCDD::Result::Context.mixin + + def call(number1:, number2:) + Success(:ok, number: number1 + number2) + end + end + + AddC1 = ->(number:) { AddC.new.call(number1: number, number2: 1) } + + module AddC2 + def self.call(number:) + AddC.new.call(number1: number, number2: 2) + end + end + + module AddC3 + def self.perform(number:) + AddC.new.call(number1: number, number2: 3) + end + end + + test 'the side effects' do + BCDD::Result.config.feature.enable!(:and_then!) + + result1 = + AddR1[1] + .and_then!(AddR2) + .and_then!(AddR3, _call: :perform) + .and_then!(AddR1) + + result2 = + AddC1[number: 2] + .and_then!(AddC2) + .and_then!(AddC3, _call: :perform) + .and_then!(AddC1) + + assert(result1.success?(:ok) && result1.value == 8) + assert(result2.success?(:ok) && result2.value == { number: 9 }) + + BCDD::Result.config.feature.disable!(:and_then!) + + expected_error = + 'You cannot use #and_then! as the feature is disabled. ' \ + 'Please use BCDD::Result.config.feature.enable!(:and_then!) to enable it.' + + assert_raises(BCDD::Result::Error::CallableAndThenDisabled, expected_error) { AddR1.call(1).and_then!(AddR2) } + assert_raises(BCDD::Result::Error::CallableAndThenDisabled, expected_error) { AddC1[number: 2].and_then!(AddC2) } + ensure + BCDD::Result.config.feature.disable!(:and_then!) + end + + test 'the default method name' do + BCDD::Result.config.feature.enable!(:and_then!) + + BCDD::Result.config.and_then!.default_method_name_to_call = :perform + + result1 = AddR1[1].and_then!(AddR3) + + result2 = AddC1[number: 2].and_then!(AddC3) + + assert(result1.success?(:ok) && result1.value == 5) + + assert(result2.success?(:ok) && result2.value == { number: 6 }) + ensure + BCDD::Result.config.feature.disable!(:and_then!) + + BCDD::Result.config.and_then!.default_method_name_to_call = :call + end + end +end diff --git a/test/bcdd/result/config/feature_test.rb b/test/bcdd/result/config/feature_test.rb index acd598a..f4fbeaf 100644 --- a/test/bcdd/result/config/feature_test.rb +++ b/test/bcdd/result/config/feature_test.rb @@ -15,10 +15,12 @@ class FeatureInstanceTest < Minitest::Test enabled: true, affects: ['BCDD::Result::Expectations', 'BCDD::Result::Context::Expectations'] }, - transitions: { - enabled: true, - affects: ['BCDD::Result', 'BCDD::Result::Context'] - } + transitions: { enabled: true, affects: %w[ + BCDD::Result BCDD::Result::Context BCDD::Result::Expectations BCDD::Result::Context::Expectations + ] }, + and_then!: { enabled: false, affects: %w[ + BCDD::Result BCDD::Result::Context BCDD::Result::Expectations BCDD::Result::Context::Expectations + ] } }, config.options ) diff --git a/test/bcdd/result/config_test.rb b/test/bcdd/result/config_test.rb index c6911f1..33285ab 100644 --- a/test/bcdd/result/config_test.rb +++ b/test/bcdd/result/config_test.rb @@ -48,7 +48,7 @@ class Test < Minitest::Test config_values = BCDD::Result.config.to_h assert_equal({ continue: false, given: true }, config_values[:addon]) - assert_equal({ expectations: true, transitions: true }, config_values[:feature]) + assert_equal({ expectations: true, transitions: true, and_then!: false }, config_values[:feature]) assert_equal({ nil_as_valid_value_checking: false }, config_values[:pattern_matching]) assert_equal({ 'Result' => false, 'Context' => false, 'BCDD::Context' => false }, config_values[:constant_alias]) @@ -59,7 +59,9 @@ class Test < Minitest::Test test '#inspect' do assert_equal( - '#', + '#:call}>', BCDD::Result.config.inspect ) end diff --git a/test/bcdd/result/configuration_test.rb b/test/bcdd/result/configuration_test.rb index 23864ad..ccb77c2 100644 --- a/test/bcdd/result/configuration_test.rb +++ b/test/bcdd/result/configuration_test.rb @@ -42,6 +42,8 @@ class ConfigurationTest < Minitest::Test assert(BCDD::Result.config.addon.enabled?(:continue)) assert_predicate(BCDD::Result.config, :frozen?) + + assert_predicate(BCDD::Result.config.and_then!, :frozen?) end end end diff --git a/test/bcdd/result/context/callable_and_then/accumulation_test.rb b/test/bcdd/result/context/callable_and_then/accumulation_test.rb new file mode 100644 index 0000000..53a866e --- /dev/null +++ b/test/bcdd/result/context/callable_and_then/accumulation_test.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +require 'test_helper' + +class BCDD::Result + class Context::CallableAndThenResultFromDifferentSourcesTest < Minitest::Test + include BCDDResultTransitionAssertions + + # rubocop:disable Naming/MethodParameterName + class Root + include Context.mixin + + CallC = ->(b:, **) do + BCDD::Result.transitions(name: 'CallC') do + Context::Success(:c, c: b + 1) + end + end + + CallE = ->(d:, **) do + BCDD::Result.transitions(name: 'CallE') do + Context::Success(:e, e: d + 1) + end + end + + def call(a:) + BCDD::Result.transitions(name: 'Root') do + Given(a: a) + .and_then(:call_b, b: 2) + .and_then!(CallC) + .and_then!(self, _call: :call_d) + .and_then!(CallE, f: 5) + .and_then!(self, _call: :call_g, h: 7) + .and_expose(:everything, %i[a b c d e f g h]) + end + end + + def call_b(b:, **) + Success(:b, b: b) + end + + def call_d(c:, **) + Success(:d, d: c + 1) + end + + def call_g(f:, **) + Success(:g, g: f + 1) + end + end + # rubocop:enable Naming/MethodParameterName + + test 'the data accumulation' do + root_process = Root.new + + result = root_process.call(a: 1) + + assert(result.success?(:everything)) + + assert_equal( + { a: 1, b: 2, c: 3, d: 4, e: 5, f: 5, g: 6, h: 7 }, + result.value + ) + + assert_transitions(result, size: 7) + + root = { id: 0, name: 'Root', desc: nil } + + { + root: root, + parent: root, + current: root, + result: { kind: :success, type: :given, value: { a: 1 } } + }.then { assert_transition_record(result, 0, _1) } + + { + root: root, + parent: root, + current: root, + result: { kind: :success, type: :b, value: { b: 2 } }, + and_then: { type: :method, arg: { b: 2 }, source: root_process, method_name: :call_b } + }.then { assert_transition_record(result, 1, _1) } + + { + root: root, + parent: root, + current: { id: 1, name: 'CallC', desc: nil }, + result: { kind: :success, type: :c, value: { c: 3 } } + }.then { assert_transition_record(result, 2, _1) } + + { + root: root, + parent: root, + current: root, + result: { kind: :success, type: :d, value: { d: 4 } }, + and_then: { type: :method, arg: -> { _1.is_a?(Hash) && _1.empty? }, source: root_process, method_name: :call_d } + }.then { assert_transition_record(result, 3, _1) } + + { + root: root, + parent: root, + current: { id: 2, name: 'CallE', desc: nil }, + result: { kind: :success, type: :e, value: { e: 5 } } + }.then { assert_transition_record(result, 4, _1) } + + { + root: root, + parent: root, + current: root, + result: { kind: :success, type: :g, value: { g: 6 } }, + and_then: { type: :method, arg: { h: 7 }, source: root_process, method_name: :call_g } + }.then { assert_transition_record(result, 5, _1) } + + { + root: root, + parent: root, + current: root, + result: { kind: :success, type: :everything, value: { a: 1, b: 2, c: 3, d: 4, e: 5, f: 5, g: 6, h: 7 } } + }.then { assert_transition_record(result, 6, _1) } + end + end +end diff --git a/test/bcdd/result/context/callable_and_then/arity_test.rb b/test/bcdd/result/context/callable_and_then/arity_test.rb new file mode 100644 index 0000000..8f1e165 --- /dev/null +++ b/test/bcdd/result/context/callable_and_then/arity_test.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require 'test_helper' + +class BCDD::Result + class Context::CallableAndThenArityTest < Minitest::Test + # rubocop:disable Naming/MethodParameterName + ProcWithoutKarg = proc { Context::Success(:ok, o: -1) } + ProcWithOneKarg = proc { |n:| Context::Success(:ok, o: n) } + ProcWithTwoKargs = proc { |n:, m:| Context::Success(:ok, o: [n, m]) } + ProcWithArgAndKarg = proc { |foo, bar:| Context::Success(:ok, o: [foo, bar]) } + + LambdaWithoutKarg = -> { Context::Success(:ok, o: -1) } + LambdaWithOneKarg = ->(n:) { Context::Success(:ok, o: n) } + LambdaWithTwoKargs = ->(n:, m:) { Context::Success(:ok, o: [n, m]) } + LambdaWithArgAndKarg = ->(foo, bar:) { Context::Success(:ok, o: [foo, bar]) } + + module ModWithoutKarg + def self.call; Context::Success(:ok, o: -1); end + end + + module ModWithOneKarg + def self.call(n:); Context::Success(:ok, o: n); end + end + + module ModWithTwoKargs + def self.call(n:, m:); Context::Success(:ok, o: [n, m]); end + end + + module ModWithArgAndKarg + def self.call(foo, bar:); Context::Success(:ok, o: [foo, bar]); end + end + # rubocop:enable Naming/MethodParameterName + + def setup + BCDD::Result.config.feature.enable!(:and_then!) + end + + def teardown + BCDD::Result.config.feature.disable!(:and_then!) + end + + test 'zero kargs' do + err1 = assert_raises(BCDD::Result::CallableAndThen::Error::InvalidArity) do + Context::Success(:ok, o: 0).and_then!(ProcWithoutKarg) + end + + err2 = assert_raises(BCDD::Result::CallableAndThen::Error::InvalidArity) do + Context::Success(:ok, o: 0).and_then!(LambdaWithoutKarg) + end + + err3 = assert_raises(BCDD::Result::CallableAndThen::Error::InvalidArity) do + Context::Success(:ok, o: 0).and_then!(ModWithoutKarg) + end + + assert_equal 'Invalid arity for Proc#call method. Expected arity: only keyword args', err1.message + assert_equal 'Invalid arity for Proc#call method. Expected arity: only keyword args', err2.message + assert_equal 'Invalid arity for Module#call method. Expected arity: only keyword args', err3.message + end + + test 'one karg' do + result1 = Context::Success(:ok, n: 1).and_then!(ProcWithOneKarg) + result2 = Context::Success(:ok, n: 2).and_then!(LambdaWithOneKarg) + result3 = Context::Success(:ok, n: 3).and_then!(ModWithOneKarg) + + assert_equal 1, result1.value[:o] + assert_equal 2, result2.value[:o] + assert_equal 3, result3.value[:o] + end + + test 'two kargs' do + result1 = Context::Success(:ok, n: 1).and_then!(ProcWithTwoKargs, m: 2) + result2 = Context::Success(:ok, n: 2).and_then!(LambdaWithTwoKargs, m: 3) + result3 = Context::Success(:ok, n: 3).and_then!(ModWithTwoKargs, m: 4) + + assert_equal([1, 2], result1.value[:o]) + assert_equal([2, 3], result2.value[:o]) + assert_equal([3, 4], result3.value[:o]) + end + + test 'one arg and one karg' do + err1 = assert_raises(BCDD::Result::CallableAndThen::Error::InvalidArity) do + Context::Success(:ok, o: 0).and_then!(ProcWithArgAndKarg, bar: 1) + end + + err2 = assert_raises(BCDD::Result::CallableAndThen::Error::InvalidArity) do + Context::Success(:ok, o: 0).and_then!(LambdaWithArgAndKarg, bar: 2) + end + + err3 = assert_raises(BCDD::Result::CallableAndThen::Error::InvalidArity) do + Context::Success(:ok, o: 0).and_then!(ModWithArgAndKarg, bar: 3) + end + + assert_equal 'Invalid arity for Proc#call method. Expected arity: only keyword args', err1.message + assert_equal 'Invalid arity for Proc#call method. Expected arity: only keyword args', err2.message + assert_equal 'Invalid arity for Module#call method. Expected arity: only keyword args', err3.message + end + end +end diff --git a/test/bcdd/result/context/callable_and_then/result_kind_error_test.rb b/test/bcdd/result/context/callable_and_then/result_kind_error_test.rb new file mode 100644 index 0000000..da98920 --- /dev/null +++ b/test/bcdd/result/context/callable_and_then/result_kind_error_test.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'test_helper' + +class BCDD::Result + class Context::CallableAndThenResultKindErrorTest < Minitest::Test + module NormalizeEmail + extend Context.mixin + + def self.call(input:) + BCDD::Result.transitions(name: 'NormalizeEmail') do + Given(input: input).and_then(:normalize) + end + end + + def self.normalize(input:) + input.is_a?(::String) or return ::BCDD::Result::Failure(:invalid_input, message: 'input must be a String') + + ::BCDD::Result::Success(:normalized_input, input: input.downcase.strip) + end + end + + module EmailNormalization + extend Context.mixin + + def self.call(input) + BCDD::Result.transitions(name: 'EmailNormalization') do + Given(input: input) + .and_then!(NormalizeEmail) + end + end + end + + test 'results from different sources' do + BCDD::Result.config.feature.enable!(:and_then!) + + error = assert_raises(BCDD::Result::Error::UnexpectedOutcome) { EmailNormalization.call(nil) } + + expected_message = [ + 'Unexpected outcome: #"input must be a String"}>.', + 'The method must return this object wrapped by BCDD::Result::Context::Success or BCDD::Result::Context::Failure' + ].join(' ') + + assert_equal(expected_message, error.message) + ensure + BCDD::Result.config.feature.disable!(:and_then!) + end + end +end diff --git a/test/bcdd/result/context/callable_and_then/results_from_different_sources_test.rb b/test/bcdd/result/context/callable_and_then/results_from_different_sources_test.rb new file mode 100644 index 0000000..138d6d8 --- /dev/null +++ b/test/bcdd/result/context/callable_and_then/results_from_different_sources_test.rb @@ -0,0 +1,157 @@ +# frozen_string_literal: true + +require 'test_helper' + +class BCDD::Result + class Context::CallableAndThenResultFromDifferentSourcesTest < Minitest::Test + include BCDDResultTransitionAssertions + + module NormalizeEmail + extend Context.mixin + + def self.call(input:) + BCDD::Result.transitions(name: 'NormalizeEmail') do + Given(input: input).and_then(:normalize) + end + end + + def self.normalize(input:) + input.is_a?(::String) or return Failure(:invalid_input, message: 'input must be a String') + + Success(:normalized_input, input: input.downcase.strip) + end + end + + class EmailValidation + include Context.mixin + + def initialize(expected_pattern: /\A[^@\s]+@[^@\s]+\z/) + @expected_pattern = expected_pattern + end + + def call(input:) + BCDD::Result.transitions(name: 'EmailValidation') do + Given(input: input).and_then(:validate) + end + end + + def validate(input:) + input.match?(@expected_pattern) ? Success(:valid_email, email: input) : Failure(:invalid_email, email: input) + end + end + + module NormalizeAndValidateEmail + extend Context.mixin + + def self.call(input) + BCDD::Result.transitions(name: 'NormalizeAndValidateEmail') do + Given(input: input) + .and_then!(NormalizeEmail) + .and_then!(EmailValidation.new) + end + end + end + + def setup + BCDD::Result.config.feature.enable!(:and_then!) + end + + def teardown + BCDD::Result.config.feature.disable!(:and_then!) + end + + test 'results from different sources' do + result1 = NormalizeAndValidateEmail.call(nil) + result2 = NormalizeAndValidateEmail.call(' ') + result3 = NormalizeAndValidateEmail.call(" FOO@bAr.com \n") + + assert(result1.failure?(:invalid_input)) + assert_equal('input must be a String', result1.value[:message]) + + assert(result2.failure?(:invalid_email)) + assert_equal('', result2.value[:email]) + + assert(result3.success?(:valid_email)) + assert_equal('foo@bar.com', result3.value[:email]) + end + + test 'the transitions tracking' do + result1 = NormalizeAndValidateEmail.call(1) + + assert_transitions(result1, size: 3) + + assert_equal([0, [[1, []]]], result1.transitions[:metadata][:tree_map]) + + root = { id: 0, name: 'NormalizeAndValidateEmail', desc: nil } + + { + root: root, + parent: root, + current: root, + result: { kind: :success, type: :given, value: { input: 1 } } + }.then { assert_transition_record(result1, 0, _1) } + + { + root: root, + parent: root, + current: { id: 1, name: 'NormalizeEmail', desc: nil }, + result: { kind: :success, type: :given, value: { input: 1 } } + }.then { assert_transition_record(result1, 1, _1) } + + { + root: root, + parent: root, + current: { id: 1, name: 'NormalizeEmail', desc: nil }, + result: { kind: :failure, type: :invalid_input, value: { message: 'input must be a String' } }, + and_then: { type: :method, arg: {}, source: -> { _1 == NormalizeEmail }, method_name: :normalize } + }.then { assert_transition_record(result1, 2, _1) } + + # --- + + result2 = NormalizeAndValidateEmail.call(" FOO@bAr.com \n") + + assert_transitions(result2, size: 5) + + assert_equal([0, [[1, []], [2, []]]], result2.transitions[:metadata][:tree_map]) + + root = { id: 0, name: 'NormalizeAndValidateEmail', desc: nil } + + { + root: root, + parent: root, + current: root, + result: { kind: :success, type: :given, value: { input: " FOO@bAr.com \n" } } + }.then { assert_transition_record(result2, 0, _1) } + + { + root: root, + parent: root, + current: { id: 1, name: 'NormalizeEmail', desc: nil }, + result: { kind: :success, type: :given, value: { input: " FOO@bAr.com \n" } } + }.then { assert_transition_record(result2, 1, _1) } + + { + root: root, + parent: root, + current: { id: 1, name: 'NormalizeEmail', desc: nil }, + result: { kind: :success, type: :normalized_input, value: { input: 'foo@bar.com' } }, + and_then: { type: :method, arg: {}, source: -> { _1 == NormalizeEmail }, method_name: :normalize } + }.then { assert_transition_record(result2, 2, _1) } + + { + root: root, + parent: root, + current: { id: 2, name: 'EmailValidation', desc: nil }, + result: { kind: :success, type: :given, value: { input: 'foo@bar.com' } } + }.then { assert_transition_record(result2, 3, _1) } + + { + root: root, + parent: root, + current: { id: 2, name: 'EmailValidation', desc: nil }, + result: { kind: :success, type: :valid_email, value: { email: 'foo@bar.com' } }, + and_then: { type: :method, arg: {}, source: EmailValidation, method_name: :validate } + }.then { assert_transition_record(result2, 4, _1) } + end + end +end diff --git a/test/bcdd/result/context/callable_and_then/unexpected_outcome_test.rb b/test/bcdd/result/context/callable_and_then/unexpected_outcome_test.rb new file mode 100644 index 0000000..9a98a28 --- /dev/null +++ b/test/bcdd/result/context/callable_and_then/unexpected_outcome_test.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'test_helper' + +class BCDD::Result + class Context::CallableAndThenUnexpectedOutcomeTest < Minitest::Test + ProcWithArg = proc { |arg:| arg } + + module ModWithArg + def self.call(arg:); arg; end + end + + def setup + BCDD::Result.config.feature.enable!(:and_then!) + end + + def teardown + BCDD::Result.config.feature.disable!(:and_then!) + end + + test 'unexpected outcome' do + err1 = assert_raises(BCDD::Result::CallableAndThen::Error::UnexpectedOutcome) do + Context::Success(:ok, arg: 0).and_then!(ProcWithArg) + end + + err2 = assert_raises(BCDD::Result::CallableAndThen::Error::UnexpectedOutcome) do + Context::Success(:ok, arg: 0).and_then!(ModWithArg) + end + + expected_kinds = 'BCDD::Result::Context::Success or BCDD::Result::Context::Failure' + + assert_match( + /Unexpected outcome: 0. The # must return this object wrapped by #{expected_kinds}/, + err1.message + ) + + assert_match( + /Unexpected outcome: 0. The .+::ModWithArg must return this object wrapped by #{expected_kinds}/, + err2.message + ) + end + end +end