From 633b2107ed6baf2cf26ddbe4ca2d30be455a3aa1 Mon Sep 17 00:00:00 2001 From: Juan Gomez Date: Thu, 9 Apr 2026 22:15:51 -0400 Subject: [PATCH 1/2] Move result transition to a resolver class Move more method to resolver Update result.rb Fix specs --- CHANGELOG.md | 3 + lib/cmdx/executor.rb | 27 +- lib/cmdx/middlewares/timeout.rb | 14 +- lib/cmdx/resolver.rb | 263 ++++++++++++++++ lib/cmdx/result.rb | 247 ++------------- lib/cmdx/task.rb | 13 +- spec/cmdx/executor_spec.rb | 75 ++--- spec/cmdx/faults_spec.rb | 11 +- spec/cmdx/middlewares/timeout_spec.rb | 8 +- spec/cmdx/resolver_spec.rb | 316 ++++++++++++++++++++ spec/cmdx/result_spec.rb | 414 ++++---------------------- spec/cmdx/task_spec.rb | 16 +- 12 files changed, 761 insertions(+), 646 deletions(-) create mode 100644 lib/cmdx/resolver.rb create mode 100644 spec/cmdx/resolver_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c5ee2e1c..f6b473901 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [UNRELEASED] +### Added +- Add `CMDx::Resolver` class to manage result transitions + ## [1.21.0] - 2026-04-09 ### Added diff --git a/lib/cmdx/executor.rb b/lib/cmdx/executor.rb index 5a2e2711b..727d7d032 100644 --- a/lib/cmdx/executor.rb +++ b/lib/cmdx/executor.rb @@ -28,7 +28,7 @@ class Executor # @rbs @task: Task attr_reader :task - def_delegators :task, :result + def_delegators :task, :result, :resolver # @param task [CMDx::Task] The task to execute # @@ -78,13 +78,13 @@ def execute rescue UndefinedMethodError => e raise_exception(e) rescue Fault => e - result.throw!(e.result, halt: false, cause: e) + resolver.throw!(e.result, halt: false, cause: e) rescue StandardError => e retry if retry_execution?(e) - result.fail!(Utils::Normalize.exception(e), halt: false, cause: e, source: :exception) + resolver.fail!(Utils::Normalize.exception(e), halt: false, cause: e, source: :exception) task.class.settings.exception_handler&.call(task, e) ensure - result.executed! + resolver.executed! post_execution! end @@ -111,20 +111,20 @@ def execute! rescue UndefinedMethodError => e raise_exception(e) rescue Fault => e - result.throw!(e.result, halt: false, cause: e) + resolver.throw!(e.result, halt: false, cause: e) if halt_execution?(e) raise_exception(e) else - result.executed! + resolver.executed! post_execution! end rescue StandardError => e retry if retry_execution?(e) - result.fail!(Utils::Normalize.exception(e), halt: false, cause: e, source: :exception) + resolver.fail!(Utils::Normalize.exception(e), halt: false, cause: e, source: :exception) raise_exception(e) else - result.executed! + resolver.executed! post_execution! end @@ -221,7 +221,7 @@ def pre_execution! task.class.settings.attributes.define_and_verify(task) return if task.errors.empty? - result.fail!( + resolver.fail!( Locale.t("cmdx.faults.invalid"), source: :validation, errors: { @@ -238,7 +238,7 @@ def pre_execution! def execution! invoke_callbacks(:before_execution) - result.executing! + resolver.executing! catch(:cmdx_halt) { task.work } end @@ -254,7 +254,7 @@ def verify_context_returns! missing.each { |name| task.errors.add(name, Locale.t("cmdx.returns.missing")) } - result.fail!( + resolver.fail!( Locale.t("cmdx.faults.invalid"), source: :context, errors: { @@ -285,12 +285,12 @@ def post_execution! def verify_middleware_yield! return unless result.initialized? - result.fail!( + resolver.fail!( Locale.t("cmdx.faults.invalid"), halt: false, source: :middleware ) - result.executed! + resolver.executed! end # Finalizes execution by freezing the task, logging results, and rolling back work. @@ -339,6 +339,7 @@ def freeze_execution! return unless CMDx.configuration.freeze_results task.freeze + resolver.freeze result.freeze # Freezing the context and chain can only be done once the outer-most diff --git a/lib/cmdx/middlewares/timeout.rb b/lib/cmdx/middlewares/timeout.rb index 8c40dee74..a7dad82b2 100644 --- a/lib/cmdx/middlewares/timeout.rb +++ b/lib/cmdx/middlewares/timeout.rb @@ -63,14 +63,12 @@ def call(task, **options, &) ::Timeout.timeout(limit, TimeoutError, "execution exceeded #{limit} seconds", &) rescue TimeoutError => e - task.result.tap do |r| - r.fail!( - Utils::Normalize.exception(e), - cause: e, - source: :timeout, - limit: - ) - end + task.resolver.fail!( + Utils::Normalize.exception(e), + cause: e, + source: :timeout, + limit: + ) end end diff --git a/lib/cmdx/resolver.rb b/lib/cmdx/resolver.rb new file mode 100644 index 000000000..d201eea3b --- /dev/null +++ b/lib/cmdx/resolver.rb @@ -0,0 +1,263 @@ +# frozen_string_literal: true + +module CMDx + # Manages state transitions for a {CMDx::Result}, handling success annotation, + # skipping, failure, throwing, and halting with fault raising. + # + # The Resolver owns the +strict+ flag and all transition logic. It writes to the + # Result via +instance_variable_set+ so that Result remains a pure data object. + # + # @example Accessed through the task + # task.resolver.fail!("Validation failed", cause: validation_error) + # task.resolver.skip!("Already processed", halt: false) + class Resolver + + extend Forwardable + + # Returns the result being resolved. + # + # @return [CMDx::Result] The result instance + # + # @rbs @result: Result + attr_reader :result + + def_delegators :result, :task, :success?, :skipped?, :failed?, + :initialized?, :executing?, :complete?, :interrupted? + + # @param result [CMDx::Result] The result to manage transitions for + # + # @return [CMDx::Resolver] A new resolver instance + # + # @raise [TypeError] When result is not a CMDx::Result instance + # + # @example + # resolver = CMDx::Resolver.new(result) + # + # @rbs (Result) -> void + def initialize(result) + raise TypeError, "must be a CMDx::Result" unless result.is_a?(Result) + + @result = result + end + + # Transitions the result to the appropriate final state based on status. + # Delegates to {#complete!} when successful, {#interrupt!} otherwise. + # + # @return [void] + # + # @example + # resolver.executed! + # + # @rbs () -> void + def executed! + success? ? complete! : interrupt! + end + + # Transitions the result state from initialized to executing. + # + # @raise [RuntimeError] When attempting to transition from invalid state + # + # @example + # resolver.executing! + # + # @rbs () -> void + def executing! + return if executing? + + raise "can only transition to #{Result::EXECUTING} from #{Result::INITIALIZED}" unless initialized? + + assign(state: Result::EXECUTING) + end + + # Transitions the result state from executing to complete. + # + # @raise [RuntimeError] When attempting to transition from invalid state + # + # @example + # resolver.complete! + # + # @rbs () -> void + def complete! + return if complete? + + raise "can only transition to #{Result::COMPLETE} from #{Result::EXECUTING}" unless executing? + + assign(state: Result::COMPLETE) + end + + # Transitions the result state to interrupted. + # + # @raise [RuntimeError] When attempting to transition from invalid state + # + # @example + # resolver.interrupt! + # + # @rbs () -> void + def interrupt! + return if interrupted? + + raise "cannot transition to #{Result::INTERRUPTED} from #{Result::COMPLETE}" if complete? + + assign(state: Result::INTERRUPTED) + end + + # Sets a reason and optional metadata on a successful result without + # changing its state or status. Useful for annotating why a task succeeded. + # When halt is true, uses throw/catch to exit the work method early. + # + # @param reason [String, nil] Reason or note for the success + # @param halt [Boolean] Whether to halt execution after success + # @param metadata [Hash] Additional metadata about the success + # @option metadata [Object] :* Any key-value pairs for additional metadata + # + # @raise [RuntimeError] When status is not success + # + # @example + # resolver.success!("Created 42 records") + # resolver.success!("Imported", halt: false, rows: 100) + # + # @rbs (?String? reason, halt: bool, **untyped metadata) -> void + def success!(reason = nil, halt: true, **metadata) + raise "can only be used while #{Result::SUCCESS}" unless success? + + assign( + reason:, + metadata: + ) + + throw(:cmdx_halt) if halt + end + + # @param reason [String, nil] Reason for skipping the task + # @param halt [Boolean] Whether to halt execution after skipping + # @param cause [Exception, nil] Exception that caused the skip + # @param strict [Boolean] Whether this skip is strict (default: true). + # @param metadata [Hash] Additional metadata about the skip + # @option metadata [Object] :* Any key-value pairs for additional metadata + # + # @raise [RuntimeError] When attempting to skip from invalid status + # + # @example + # resolver.skip!("Dependencies not met", cause: dependency_error) + # resolver.skip!("Already processed", halt: false) + # resolver.skip!("Optional step", strict: false) + # + # @rbs (?String? reason, halt: bool, cause: Exception?, strict: bool, **untyped metadata) -> void + def skip!(reason = nil, halt: true, cause: nil, strict: true, **metadata) + return if skipped? + + raise "can only transition to #{Result::SKIPPED} from #{Result::SUCCESS}" unless success? + + assign( + state: Result::INTERRUPTED, + status: Result::SKIPPED, + reason: reason || Locale.t("cmdx.reasons.unspecified"), + cause:, + strict:, + metadata: + ) + + halt! if halt + end + + # @param reason [String, nil] Reason for task failure + # @param halt [Boolean] Whether to halt execution after failure + # @param cause [Exception, nil] Exception that caused the failure + # @param strict [Boolean] Whether this failure is strict (default: true). + # @param metadata [Hash] Additional metadata about the failure + # @option metadata [Object] :* Any key-value pairs for additional metadata + # + # @raise [RuntimeError] When attempting to fail from invalid status + # + # @example + # resolver.fail!("Validation failed", cause: validation_error) + # resolver.fail!("Network timeout", halt: false, timeout: 30) + # resolver.fail!("Soft failure", strict: false) + # + # @rbs (?String? reason, halt: bool, cause: Exception?, strict: bool, **untyped metadata) -> void + def fail!(reason = nil, halt: true, cause: nil, strict: true, **metadata) + return if failed? + + raise "can only transition to #{Result::FAILED} from #{Result::SUCCESS}" unless success? + + assign( + state: Result::INTERRUPTED, + status: Result::FAILED, + reason: reason || Locale.t("cmdx.reasons.unspecified"), + cause:, + strict:, + metadata: + ) + + halt! if halt + end + + # @raise [SkipFault] When task was skipped + # @raise [FailFault] When task failed + # + # @example + # resolver.halt! + # + # @rbs () -> void + def halt! + return if success? + + klass = skipped? ? SkipFault : FailFault + fault = klass.new(result) + + frames = caller_locations(3..-1) + + unless frames.empty? + frames = frames.map(&:to_s) + + if (cleaner = task.class.settings.backtrace_cleaner) + cleaner.call(frames) + end + + fault.set_backtrace(frames) + end + + raise(fault) + end + + # @param other [CMDx::Result] Result to throw from current result + # @param halt [Boolean] Whether to halt execution after throwing + # @param cause [Exception, nil] Exception that caused the throw + # @param metadata [Hash] Additional metadata to merge + # @option metadata [Object] :* Any key-value pairs for additional metadata + # + # @raise [TypeError] When other is not a CMDx::Result instance + # + # @example + # other_result = OtherTask.execute + # resolver.throw!(other_result, cause: upstream_error) + # + # @rbs (Result other, halt: bool, cause: Exception?, **untyped metadata) -> void + def throw!(other, halt: true, cause: nil, **metadata) + raise TypeError, "must be a CMDx::Result" unless other.is_a?(Result) + + assign( + state: other.state, + status: other.status, + reason: other.reason, + cause: cause || other.cause, + strict: other.strict?, + metadata: other.metadata.merge(metadata) + ) + + halt! if halt + end + + private + + # Writes attributes to the result via instance_variable_set. + # + # @param attrs [Hash] Attributes to assign to the result + # + # @rbs (**untyped attrs) -> void + def assign(**attrs) + attrs.each { |key, value| @result.instance_variable_set(:"@#{key}", value) } + end + + end +end diff --git a/lib/cmdx/result.rb b/lib/cmdx/result.rb index e13d213d1..d46141660 100644 --- a/lib/cmdx/result.rb +++ b/lib/cmdx/result.rb @@ -99,21 +99,11 @@ class Result # @rbs @cause: (Exception | nil) attr_reader :cause - # Returns the number of retries attempted. - # - # @return [Integer] The number of retries attempted - # - # @example - # result.retries # => 2 - # - # @rbs @retries: Integer - attr_accessor :retries - - # Returns whether this result is strict. + # Returns whether this result transitions are strict. # When false, {CMDx::Executor#halt_execution?} returns false # regardless of the task's breakpoint settings. # - # @return [Boolean] Whether the result is strict + # @return [Boolean] Whether the result transitions are strict # # @example # result.strict? # => true @@ -121,6 +111,16 @@ class Result # @rbs @strict: bool attr_reader :strict + # Returns the number of retries attempted. + # + # @return [Integer] The number of retries attempted + # + # @example + # result.retries # => 2 + # + # @rbs @retries: Integer + attr_accessor :retries + # Returns whether the result has been rolled back. # # @return [Boolean] Whether the result has been rolled back @@ -154,8 +154,8 @@ def initialize(task) @metadata = {} @reason = nil @cause = nil - @retries = 0 @strict = true + @retries = 0 @rolled_back = false end @@ -170,16 +170,6 @@ def initialize(task) define_method(:"#{s}?") { state == s } end - # @return [self] Returns self for method chaining - # - # @example - # result.executed! # Transitions to complete or interrupted - # - # @rbs () -> self - def executed! - success? ? complete! : interrupt! - end - # @return [Boolean] Whether the task has been executed (complete or interrupted) # # @example @@ -190,48 +180,6 @@ def executed? complete? || interrupted? end - # @raise [RuntimeError] When attempting to transition from invalid state - # - # @example - # result.executing! # Transitions from initialized to executing - # - # @rbs () -> void - def executing! - return if executing? - - raise "can only transition to #{EXECUTING} from #{INITIALIZED}" unless initialized? - - @state = EXECUTING - end - - # @raise [RuntimeError] When attempting to transition from invalid state - # - # @example - # result.complete! # Transitions from executing to complete - # - # @rbs () -> void - def complete! - return if complete? - - raise "can only transition to #{COMPLETE} from #{EXECUTING}" unless executing? - - @state = COMPLETE - end - - # @raise [RuntimeError] When attempting to transition from invalid state - # - # @example - # result.interrupt! # Transitions from executing to interrupted - # - # @rbs () -> void - def interrupt! - return if interrupted? - - raise "cannot transition to #{INTERRUPTED} from #{COMPLETE}" if complete? - - @state = INTERRUPTED - end - STATUSES.each do |s| # @return [Boolean] Whether the result has the specified status # @@ -282,147 +230,6 @@ def on(*states_or_statuses, &) self end - # Sets a reason and optional metadata on a successful result without - # changing its state or status. Useful for annotating why a task succeeded. - # When halt is true, uses throw/catch to exit the work method early. - # - # @param reason [String, nil] Reason or note for the success - # @param halt [Boolean] Whether to halt execution after success - # @param metadata [Hash] Additional metadata about the success - # @option metadata [Object] :* Any key-value pairs for additional metadata - # - # @raise [RuntimeError] When status is not success - # - # @example - # result.success!("Created 42 records") - # result.success!("Imported", halt: false, rows: 100) - # - # @rbs (?String? reason, halt: bool, **untyped metadata) -> void - def success!(reason = nil, halt: true, **metadata) - raise "can only be used while #{SUCCESS}" unless success? - - @reason = reason - @metadata = metadata - - throw(:cmdx_halt) if halt - end - - # @param reason [String, nil] Reason for skipping the task - # @param halt [Boolean] Whether to halt execution after skipping - # @param cause [Exception, nil] Exception that caused the skip - # @param strict [Boolean] Whether this skip is strict (default: true). - # When false, {CMDx::Executor#halt_execution?} returns false regardless of task settings. - # @param metadata [Hash] Additional metadata about the skip - # @option metadata [Object] :* Any key-value pairs for additional metadata - # - # @raise [RuntimeError] When attempting to skip from invalid status - # - # @example - # result.skip!("Dependencies not met", cause: dependency_error) - # result.skip!("Already processed", halt: false) - # result.skip!("Optional step", strict: false) - # - # @rbs (?String? reason, halt: bool, cause: Exception?, strict: bool, **untyped metadata) -> void - def skip!(reason = nil, halt: true, cause: nil, strict: true, **metadata) - return if skipped? - - raise "can only transition to #{SKIPPED} from #{SUCCESS}" unless success? - - @state = INTERRUPTED - @status = SKIPPED - @reason = reason || Locale.t("cmdx.reasons.unspecified") - @cause = cause - @strict = strict - @metadata = metadata - - halt! if halt - end - - # @param reason [String, nil] Reason for task failure - # @param halt [Boolean] Whether to halt execution after failure - # @param cause [Exception, nil] Exception that caused the failure - # @param strict [Boolean] Whether this failure is strict (default: true). - # When false, {CMDx::Executor#halt_execution?} returns false regardless of task settings. - # @param metadata [Hash] Additional metadata about the failure - # @option metadata [Object] :* Any key-value pairs for additional metadata - # - # @raise [RuntimeError] When attempting to fail from invalid status - # - # @example - # result.fail!("Validation failed", cause: validation_error) - # result.fail!("Network timeout", halt: false, timeout: 30) - # result.fail!("Soft failure", strict: false) - # - # @rbs (?String? reason, halt: bool, cause: Exception?, strict: bool, **untyped metadata) -> void - def fail!(reason = nil, halt: true, cause: nil, strict: true, **metadata) - return if failed? - - raise "can only transition to #{FAILED} from #{SUCCESS}" unless success? - - @state = INTERRUPTED - @status = FAILED - @reason = reason || Locale.t("cmdx.reasons.unspecified") - @cause = cause - @strict = strict - @metadata = metadata - - halt! if halt - end - - # @raise [SkipFault] When task was skipped - # @raise [FailFault] When task failed - # - # @example - # result.halt! # Raises appropriate fault based on status - # - # @rbs () -> void - def halt! - return if success? - - klass = skipped? ? SkipFault : FailFault - fault = klass.new(self) - - # Strip the first two frames (this method and the delegator) - frames = caller_locations(3..-1) - - unless frames.empty? - frames = frames.map(&:to_s) - - if (cleaner = task.class.settings.backtrace_cleaner) - cleaner.call(frames) - end - - fault.set_backtrace(frames) - end - - raise(fault) - end - - # @param result [CMDx::Result] Result to throw from current result - # @param halt [Boolean] Whether to halt execution after throwing - # @param cause [Exception, nil] Exception that caused the throw - # @param metadata [Hash] Additional metadata to merge - # @option metadata [Object] :* Any key-value pairs for additional metadata - # - # @raise [TypeError] When result is not a CMDx::Result instance - # - # @example - # other_result = OtherTask.execute - # result.throw!(other_result, cause: upstream_error) - # - # @rbs (Result result, halt: bool, cause: Exception?, **untyped metadata) -> void - def throw!(result, halt: true, cause: nil, **metadata) - raise TypeError, "must be a CMDx::Result" unless result.is_a?(Result) - - @state = result.state - @status = result.status - @reason = result.reason - @cause = cause || result.cause - @metadata = result.metadata.merge(metadata) - - halt! if halt - end - # @return [CMDx::Result, nil] The result that caused this failure, or nil # # @example @@ -500,24 +307,24 @@ def thrown_failure? failed? && !caused_failure? end - # @return [Boolean] Whether the result has been retried + # @return [Boolean] Whether the result transitions are strict # # @example - # result.retried? # => true + # result.strict? # => true # # @rbs () -> bool - def retried? - retries.positive? + def strict? + !!@strict end - # @return [Boolean] Whether the result is strict + # @return [Boolean] Whether the result has been retried # # @example - # result.strict? # => true + # result.retried? # => true # # @rbs () -> bool - def strict? - !!@strict + def retried? + retries.positive? end # @return [Boolean] Whether the result has been rolled back @@ -620,12 +427,12 @@ def deconstruct(*) # @rbs (*untyped) -> Hash[Symbol, untyped] def deconstruct_keys(*) { - state: state, - status: status, - reason: reason, - cause: cause, - metadata: metadata, - outcome: outcome, + state:, + status:, + reason:, + cause:, + metadata:, + outcome:, executed: executed?, good: good?, bad: bad? diff --git a/lib/cmdx/task.rb b/lib/cmdx/task.rb index bd6d6e772..0dde605db 100644 --- a/lib/cmdx/task.rb +++ b/lib/cmdx/task.rb @@ -70,7 +70,17 @@ class Task # @rbs @chain: Chain attr_reader :chain - def_delegators :result, :success!, :skip!, :fail!, :throw! + # Returns the resolver for managing result state transitions. + # + # @return [Resolver] The resolver instance + # + # @example + # task.resolver.fail!("Validation failed") + # + # @rbs @resolver: Resolver + attr_reader :resolver + + def_delegators :resolver, :success!, :skip!, :fail!, :throw! def_delegators :chain, :dry_run? # @param context [Hash, Context, nil] The initial context for the task @@ -92,6 +102,7 @@ def initialize(context = nil) @context = Context.build(context) @errors = Errors.new @result = Result.new(self) + @resolver = Resolver.new(@result) @chain = Chain.build(@result, dry_run: @context.delete(:dry_run)) @attributes = {} diff --git a/spec/cmdx/executor_spec.rb b/spec/cmdx/executor_spec.rb index 561fdc228..9bfadfd42 100644 --- a/spec/cmdx/executor_spec.rb +++ b/spec/cmdx/executor_spec.rb @@ -66,8 +66,8 @@ # Setup result state to support proper transitions allow(task.result).to receive_messages(to_h: { test: "data" }, state: "executing", executing?: true, executed?: true, success?: true) - allow(task.result).to receive(:complete!) - allow(task.result).to receive(:executed!) + allow(task.resolver).to receive(:complete!) + allow(task.resolver).to receive(:executed!) end context "when execution is successful" do @@ -75,7 +75,7 @@ expect(middlewares).to receive(:call!).with(task).and_yield expect(worker).to receive(:pre_execution!) expect(worker).to receive(:execution!) - expect(task.result).to receive(:executed!) + expect(task.resolver).to receive(:executed!) expect(worker).to receive(:post_execution!) expect(worker).to receive(:freeze_execution!) @@ -87,7 +87,7 @@ allow(worker).to receive(:pre_execution!) allow(worker).to receive(:execution!) - allow(task.result).to receive(:executed!) + allow(task.resolver).to receive(:executed!) allow(worker).to receive(:post_execution!) expect(logger).to receive(:info) @@ -121,9 +121,9 @@ allow(worker).to receive(:pre_execution!) allow(worker).to receive(:execution!).and_raise(fault) - expect(task.result).to receive(:throw!).with(fault_result, halt: false, cause: fault) + expect(task.resolver).to receive(:throw!).with(fault_result, halt: false, cause: fault) - allow(task.result).to receive(:executed!) + allow(task.resolver).to receive(:executed!) allow(worker).to receive(:post_execution!) worker.execute @@ -134,9 +134,9 @@ allow(worker).to receive(:pre_execution!) allow(worker).to receive(:execution!).and_raise(fault) - allow(task.result).to receive(:throw!) + allow(task.resolver).to receive(:throw!) - expect(task.result).to receive(:executed!) + expect(task.resolver).to receive(:executed!) expect(worker).to receive(:post_execution!) expect(worker).to receive(:freeze_execution!) @@ -153,9 +153,9 @@ allow(worker).to receive(:pre_execution!) allow(worker).to receive(:execution!).and_raise(standard_error) - expect(task.result).to receive(:fail!).with("[StandardError] something went wrong", halt: false, cause: standard_error, source: :exception) + expect(task.resolver).to receive(:fail!).with("[StandardError] something went wrong", halt: false, cause: standard_error, source: :exception) - allow(task.result).to receive(:executed!) + allow(task.resolver).to receive(:executed!) allow(worker).to receive(:post_execution!) worker.execute @@ -166,9 +166,9 @@ allow(worker).to receive(:pre_execution!) allow(worker).to receive(:execution!).and_raise(standard_error) - allow(task.result).to receive(:fail!) + allow(task.resolver).to receive(:fail!) - expect(task.result).to receive(:executed!) + expect(task.resolver).to receive(:executed!) expect(worker).to receive(:post_execution!) expect(worker).to receive(:freeze_execution!) @@ -185,9 +185,9 @@ allow(worker).to receive(:pre_execution!) allow(worker).to receive(:execution!).and_raise(custom_error) - expect(task.result).to receive(:fail!).with("[CMDx::TestError] test error", halt: false, cause: custom_error, source: :exception) + expect(task.resolver).to receive(:fail!).with("[CMDx::TestError] test error", halt: false, cause: custom_error, source: :exception) - allow(task.result).to receive(:executed!) + allow(task.resolver).to receive(:executed!) allow(worker).to receive(:post_execution!) worker.execute @@ -209,8 +209,8 @@ # Setup result state to support proper transitions allow(task.result).to receive_messages(to_h: { test: "data" }, state: "executing", executing?: true, executed?: true, success?: true) - allow(task.result).to receive(:complete!) - allow(task.result).to receive(:executed!) + allow(task.resolver).to receive(:complete!) + allow(task.resolver).to receive(:executed!) end context "when execution is successful" do @@ -218,7 +218,7 @@ expect(middlewares).to receive(:call!).with(task).and_yield expect(worker).to receive(:pre_execution!) expect(worker).to receive(:execution!) - expect(task.result).to receive(:executed!) + expect(task.resolver).to receive(:executed!) expect(worker).to receive(:post_execution!) expect(worker).to receive(:freeze_execution!) @@ -252,8 +252,8 @@ allow(worker).to receive(:pre_execution!) allow(worker).to receive(:execution!).and_raise(fault) - expect(task.result).to receive(:throw!).with(fault_result, halt: false, cause: fault) - expect(task.result).to receive(:executed!) + expect(task.resolver).to receive(:throw!).with(fault_result, halt: false, cause: fault) + expect(task.resolver).to receive(:executed!) expect(worker).to receive(:halt_execution?).with(fault).and_return(false) expect(worker).to receive(:post_execution!) @@ -268,7 +268,7 @@ allow(worker).to receive(:pre_execution!) allow(worker).to receive(:execution!).and_raise(fault) - expect(task.result).to receive(:throw!).with(fault_result, halt: false, cause: fault) + expect(task.resolver).to receive(:throw!).with(fault_result, halt: false, cause: fault) expect(worker).to receive(:halt_execution?).with(fault).and_return(true) expect(worker).to receive(:raise_exception).with(fault).and_raise(fault) @@ -286,7 +286,7 @@ allow(worker).to receive(:pre_execution!) allow(worker).to receive(:execution!).and_raise(standard_error) - expect(task.result).to receive(:fail!).with("[StandardError] something went wrong", halt: false, cause: standard_error, source: :exception) + expect(task.resolver).to receive(:fail!).with("[StandardError] something went wrong", halt: false, cause: standard_error, source: :exception) expect(worker).to receive(:raise_exception).with(standard_error).and_raise(standard_error) expect { worker.execute! }.to raise_error(standard_error) @@ -295,12 +295,13 @@ end describe "#halt_execution?" do - let(:fault_result) { instance_double(CMDx::Result, status: "failed", strict?: true, reason: "test failure") } + let(:fault_resolver) { instance_double(CMDx::Resolver) } + let(:fault_task) { instance_double(CMDx::Task, resolver: fault_resolver) } + let(:fault_result) { instance_double(CMDx::Result, status: "failed", task: fault_task, reason: "test failure", strict?: true) } let(:fault) { CMDx::FailFault.new(fault_result) } - context "when result has strict: false" do - let(:fault_result) { instance_double(CMDx::Result, status: "failed", strict?: false, reason: "test failure") } - let(:fault) { CMDx::FailFault.new(fault_result) } + context "when resolver has strict: false" do + let(:fault_result) { instance_double(CMDx::Result, status: "failed", task: fault_task, reason: "test failure", strict?: false) } before do allow(task.class).to receive(:settings).and_return(mock_settings(breakpoints: %w[failed skipped])) @@ -323,7 +324,9 @@ end context "when exception result status is not in breakpoints" do - let(:success_result) { instance_double(CMDx::Result, status: "success", strict?: true, reason: "test success") } + let(:success_resolver) { instance_double(CMDx::Resolver) } + let(:success_task) { instance_double(CMDx::Task, resolver: success_resolver) } + let(:success_result) { instance_double(CMDx::Result, status: "success", task: success_task, reason: "test success", strict?: true) } let(:success_fault) { CMDx::SkipFault.new(success_result) } it "returns false" do @@ -686,11 +689,11 @@ def call(_task, retries) to_s: "Validation failed", to_h: { name: ["is required"] } ) - allow(task.result).to receive(:fail!) + allow(task.resolver).to receive(:fail!) end it "calls fail! on result with error information" do - expect(task.result).to receive(:fail!).with( + expect(task.resolver).to receive(:fail!).with( "Invalid", source: :validation, errors: { @@ -710,13 +713,13 @@ def call(_task, retries) before do allow(task.class).to receive(:settings).and_return(mock_settings(callbacks: callbacks)) allow(callbacks).to receive(:invoke) - allow(task.result).to receive(:executing!) + allow(task.resolver).to receive(:executing!) allow(task).to receive(:work) end it "invokes before_execution callback, sets executing state, and calls work" do expect(callbacks).to receive(:invoke).with(:before_execution, task) - expect(task.result).to receive(:executing!) + expect(task.resolver).to receive(:executing!) expect(task).to receive(:work) worker.send(:execution!) @@ -788,7 +791,7 @@ def call(_task, retries) end it "adds errors for missing returns and fails the result" do - expect(task.result).to receive(:fail!).with( + expect(task.resolver).to receive(:fail!).with( "Invalid", source: :context, errors: { @@ -808,7 +811,7 @@ def call(_task, retries) end it "adds errors for all missing returns and fails the result" do - expect(task.result).to receive(:fail!).with( + expect(task.resolver).to receive(:fail!).with( "Invalid", source: :context, errors: { @@ -1075,12 +1078,12 @@ def call(_task, retries) end it "marks result as failed and transitions to executed" do - expect(task.result).to receive(:fail!).with( + expect(task.resolver).to receive(:fail!).with( CMDx::Locale.t("cmdx.faults.invalid"), halt: false, source: :middleware ) - expect(task.result).to receive(:executed!) + expect(task.resolver).to receive(:executed!) worker.send(:verify_middleware_yield!) end @@ -1092,8 +1095,8 @@ def call(_task, retries) end it "does nothing" do - expect(task.result).not_to receive(:fail!) - expect(task.result).not_to receive(:executed!) + expect(task.resolver).not_to receive(:fail!) + expect(task.resolver).not_to receive(:executed!) worker.send(:verify_middleware_yield!) end diff --git a/spec/cmdx/faults_spec.rb b/spec/cmdx/faults_spec.rb index 5e0a3fdc2..1572309a5 100644 --- a/spec/cmdx/faults_spec.rb +++ b/spec/cmdx/faults_spec.rb @@ -6,9 +6,8 @@ let(:task_class) { create_successful_task(name: "TestTask") } let(:task) { task_class.new } let(:result) do - task.result.tap do |r| - r.fail!("test failure reason", halt: false) - end + task.resolver.fail!("test failure reason", halt: false) + task.result end describe "#initialize" do @@ -110,10 +109,8 @@ describe ".matches?" do let(:fault_with_metadata) do - result_with_metadata = task.result.tap do |r| - r.fail!("failure", halt: false, metadata: { error_code: 500 }) - end - described_class.new(result_with_metadata) + task.resolver.fail!("failure", halt: false, metadata: { error_code: 500 }) + described_class.new(task.result) end context "when block is provided" do diff --git a/spec/cmdx/middlewares/timeout_spec.rb b/spec/cmdx/middlewares/timeout_spec.rb index 4c91e157c..2f5543479 100644 --- a/spec/cmdx/middlewares/timeout_spec.rb +++ b/spec/cmdx/middlewares/timeout_spec.rb @@ -5,14 +5,14 @@ RSpec.describe CMDx::Middlewares::Timeout, type: :unit do subject(:timeout_middleware) { described_class } - let(:task) { double("CMDx::Task", result: result) } # rubocop:disable RSpec/VerifiedDoubles + let(:task) { double("CMDx::Task", result: result, resolver: resolver) } # rubocop:disable RSpec/VerifiedDoubles let(:result) { instance_double(CMDx::Result) } + let(:resolver) { instance_double(CMDx::Resolver) } let(:block_result) { "block executed" } let(:test_block) { proc { block_result } } before do - allow(result).to receive(:fail!) - allow(result).to receive(:tap).and_return(result) + allow(resolver).to receive(:fail!) end describe ".call" do @@ -191,7 +191,7 @@ it "re-raises non-timeout errors without calling fail!" do expect(Timeout).to receive(:timeout).and_yield.and_raise(standard_error) - expect(result).not_to receive(:fail!) + expect(resolver).not_to receive(:fail!) expect do timeout_middleware.call(task, seconds: 5, &error_block) diff --git a/spec/cmdx/resolver_spec.rb b/spec/cmdx/resolver_spec.rb new file mode 100644 index 000000000..bcaf7bdc1 --- /dev/null +++ b/spec/cmdx/resolver_spec.rb @@ -0,0 +1,316 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe CMDx::Resolver, type: :unit do + let(:task_class) { create_successful_task(name: "TestTask") } + let(:task) { task_class.new } + let(:result) { task.result } + let(:resolver) { task.resolver } + + describe "#initialize" do + context "with valid result" do + it "initializes with correct defaults" do + expect(resolver.result).to eq(result) + expect(result.strict?).to be(true) + end + end + + context "with invalid result" do + it "raises TypeError when result is not a CMDx::Result" do + expect { described_class.new("not a result") }.to raise_error(TypeError, "must be a CMDx::Result") + end + end + end + + describe "#success!" do + context "when successful" do + it "sets the reason" do + catch(:cmdx_halt) { resolver.success!("Created 42 records") } + + expect(result.status).to eq(CMDx::Result::SUCCESS) + expect(result.reason).to eq("Created 42 records") + expect(result.metadata).to eq({}) + end + + it "accepts metadata" do + catch(:cmdx_halt) { resolver.success!("Imported", rows: 100) } + + expect(result.reason).to eq("Imported") + expect(result.metadata).to eq({ rows: 100 }) + end + + it "allows nil reason" do + catch(:cmdx_halt) { resolver.success! } + + expect(result.reason).to be_nil + end + + it "does not change state or status" do + original_state = result.state + original_status = result.status + catch(:cmdx_halt) { resolver.success!("note") } + + expect(result.state).to eq(original_state) + expect(result.status).to eq(original_status) + end + + it "throws :cmdx_halt by default" do + expect { resolver.success!("done") }.to throw_symbol(:cmdx_halt) + end + + it "does not throw when halt is false" do + expect { resolver.success!("done", halt: false) }.not_to throw_symbol(:cmdx_halt) + end + end + + context "when not successful" do + it "raises error when skipped" do + resolver.skip!("test", halt: false) + + expect { resolver.success!("reason") }.to raise_error(/can only be used while success/) + end + + it "raises error when failed" do + resolver.fail!("test", halt: false) + + expect { resolver.success!("reason") }.to raise_error(/can only be used while success/) + end + end + end + + describe "#skip!" do + context "when successful" do + it "transitions to skipped status" do + resolver.skip!("test reason", halt: false) + + expect(result.status).to eq(CMDx::Result::SKIPPED) + expect(result.state).to eq(CMDx::Result::INTERRUPTED) + expect(result.reason).to eq("test reason") + expect(result.cause).to be_nil + expect(result.metadata).to eq({}) + end + + it "accepts metadata" do + resolver.skip!("test reason", halt: false, foo: "bar") + + expect(result.metadata).to eq({ foo: "bar" }) + end + + it "accepts cause" do + cause = StandardError.new("cause") + resolver.skip!("test reason", halt: false, cause: cause) + + expect(result.cause).to eq(cause) + end + + it "uses default reason when none provided" do + allow(CMDx::Locale).to receive(:t).with("cmdx.reasons.unspecified").and_return("Unspecified") + + resolver.skip!(halt: false) + + expect(result.reason).to eq("Unspecified") + end + + it "calls halt! by default" do + expect { resolver.skip!("test reason") }.to raise_error(CMDx::SkipFault) + end + + it "does not call halt! when halt: false" do + expect { resolver.skip!("test reason", halt: false) }.not_to raise_error + end + end + + context "when already skipped" do + it "returns early without changes" do + resolver.skip!("first reason", halt: false) + original_reason = result.reason + resolver.skip!("second reason", halt: false) + + expect(result.reason).to eq(original_reason) + end + end + + context "when not successful" do + it "raises error when trying to skip from failed" do + resolver.fail!("test reason", halt: false) + + expect { resolver.skip!("another reason", halt: false) }.to raise_error(/can only transition to skipped from success/) + end + end + end + + describe "#fail!" do + context "when successful" do + it "transitions to failed status" do + resolver.fail!("test reason", halt: false) + + expect(result.status).to eq(CMDx::Result::FAILED) + expect(result.state).to eq(CMDx::Result::INTERRUPTED) + expect(result.reason).to eq("test reason") + expect(result.cause).to be_nil + expect(result.metadata).to eq({}) + end + + it "accepts metadata" do + resolver.fail!("test reason", halt: false, foo: "bar") + + expect(result.metadata).to eq({ foo: "bar" }) + end + + it "accepts cause" do + cause = StandardError.new("cause") + resolver.fail!("test reason", halt: false, cause: cause) + + expect(result.cause).to eq(cause) + end + + it "uses default reason when none provided" do + allow(CMDx::Locale).to receive(:t).with("cmdx.reasons.unspecified").and_return("Unspecified") + + resolver.fail!(halt: false) + + expect(result.reason).to eq("Unspecified") + end + + it "calls halt! by default" do + expect { resolver.fail!("test reason") }.to raise_error(CMDx::FailFault) + end + + it "does not call halt! when halt: false" do + expect { resolver.fail!("test reason", halt: false) }.not_to raise_error + end + end + + context "when already failed" do + it "returns early without changes" do + resolver.fail!("first reason", halt: false) + original_reason = result.reason + resolver.fail!("second reason", halt: false) + + expect(result.reason).to eq(original_reason) + end + end + + context "when not successful" do + it "raises error when trying to fail from skipped" do + resolver.skip!("test reason", halt: false) + + expect { resolver.fail!("another reason", halt: false) }.to raise_error(/can only transition to failed from success/) + end + end + end + + describe "#halt!" do + context "when successful" do + it "returns early without raising" do + expect { resolver.halt! }.not_to raise_error + end + end + + context "when skipped" do + it "raises SkipFault" do + resolver.skip!("test reason", halt: false) + + expect { resolver.halt! }.to raise_error(CMDx::SkipFault) do |fault| + expect(fault.result).to eq(result) + expect(fault.message).to eq("test reason") + end + end + + it "sets proper backtrace" do + resolver.skip!("test reason", halt: false) + + begin + resolver.halt! + rescue CMDx::SkipFault => e + expect(e.backtrace).to be_an(Array) + expect(e.backtrace).not_to be_empty + end + end + end + + context "when failed" do + it "raises FailFault" do + resolver.fail!("test reason", halt: false) + + expect { resolver.halt! }.to raise_error(CMDx::FailFault) do |fault| + expect(fault.result).to eq(result) + expect(fault.message).to eq("test reason") + end + end + end + end + + describe "#throw!" do + let(:other_task) { create_failing_task.new } + let(:other_result) { other_task.result } + + before do + other_task.resolver.fail!("source failure", halt: false, foo: "bar") + end + + context "with valid result" do + it "copies state and status from other result" do + resolver.throw!(other_result, halt: false) + + expect(result.state).to eq(other_result.state) + expect(result.status).to eq(other_result.status) + expect(result.reason).to eq(other_result.reason) + end + + it "merges metadata" do + resolver.throw!(other_result, halt: false, baz: "qux") + + expect(result.metadata).to eq({ foo: "bar", baz: "qux" }) + end + + it "uses provided cause over other result's cause" do + custom_cause = StandardError.new("custom") + + resolver.throw!(other_result, halt: false, cause: custom_cause) + expect(result.cause).to eq(custom_cause) + end + + it "uses other result's cause when none provided" do + other_cause = StandardError.new("other") + other_result.instance_variable_set(:@cause, other_cause) + resolver.throw!(other_result, halt: false) + + expect(result.cause).to eq(other_cause) + end + + it "calls halt! by default" do + expect { resolver.throw!(other_result) }.to raise_error(CMDx::FailFault) + end + + it "does not call halt! when halt: false" do + expect { resolver.throw!(other_result, halt: false) }.not_to raise_error + end + end + + context "with invalid result" do + it "raises TypeError when not a CMDx::Result" do + expect { resolver.throw!("not a result", halt: false) }.to raise_error(TypeError, "must be a CMDx::Result") + end + end + end + + describe "#strict?" do + it "returns true by default" do + expect(result.strict?).to be(true) + end + + it "returns false when strict is false via fail!" do + resolver.fail!("test reason", halt: false, strict: false) + + expect(result.strict?).to be(false) + end + + it "returns false when strict is false via skip!" do + resolver.skip!("test reason", halt: false, strict: false) + + expect(result.strict?).to be(false) + end + end +end diff --git a/spec/cmdx/result_spec.rb b/spec/cmdx/result_spec.rb index ad801e62b..e06438848 100644 --- a/spec/cmdx/result_spec.rb +++ b/spec/cmdx/result_spec.rb @@ -6,6 +6,7 @@ let(:task_class) { create_successful_task(name: "TestTask") } let(:task) { task_class.new } let(:result) { task.result } + let(:resolver) { task.resolver } describe "#initialize" do context "with valid task" do @@ -38,7 +39,7 @@ end it "returns false when state is not initialized" do - result.executing! + resolver.executing! expect(result.initialized?).to be(false) end @@ -50,7 +51,7 @@ end it "returns true when state is executing" do - result.executing! + resolver.executing! expect(result.executing?).to be(true) end @@ -62,8 +63,8 @@ end it "returns true when state is complete" do - result.executing! - result.complete! + resolver.executing! + resolver.complete! expect(result.complete?).to be(true) end @@ -75,8 +76,8 @@ end it "returns true when state is interrupted" do - result.executing! - result.interrupt! + resolver.executing! + resolver.interrupt! expect(result.interrupted?).to be(true) end @@ -87,15 +88,15 @@ describe "#executing!" do context "when initialized" do it "transitions to executing state" do - result.executing! + resolver.executing! expect(result.state).to eq(CMDx::Result::EXECUTING) end it "returns early if already executing" do - result.executing! + resolver.executing! initial_state = result.state - result.executing! + resolver.executing! expect(result.state).to eq(initial_state) end @@ -103,35 +104,35 @@ context "when not initialized" do it "raises error when trying to transition from complete" do - result.executing! - result.complete! + resolver.executing! + resolver.complete! - expect { result.executing! }.to raise_error(/can only transition to executing from initialized/) + expect { resolver.executing! }.to raise_error(/can only transition to executing from initialized/) end it "raises error when trying to transition from interrupted" do - result.executing! - result.interrupt! + resolver.executing! + resolver.interrupt! - expect { result.executing! }.to raise_error(/can only transition to executing from initialized/) + expect { resolver.executing! }.to raise_error(/can only transition to executing from initialized/) end end end describe "#complete!" do context "when executing" do - before { result.executing! } + before { resolver.executing! } it "transitions to complete state" do - result.complete! + resolver.complete! expect(result.state).to eq(CMDx::Result::COMPLETE) end it "returns early if already complete" do - result.complete! + resolver.complete! initial_state = result.state - result.complete! + resolver.complete! expect(result.state).to eq(initial_state) end @@ -139,7 +140,7 @@ context "when not executing" do it "raises error when trying to transition from initialized" do - expect { result.complete! }.to raise_error(/can only transition to complete from executing/) + expect { resolver.complete! }.to raise_error(/can only transition to complete from executing/) end end end @@ -147,22 +148,22 @@ describe "#interrupt!" do context "when not complete" do it "transitions to interrupted from initialized" do - result.interrupt! + resolver.interrupt! expect(result.state).to eq(CMDx::Result::INTERRUPTED) end it "transitions to interrupted from executing" do - result.executing! - result.interrupt! + resolver.executing! + resolver.interrupt! expect(result.state).to eq(CMDx::Result::INTERRUPTED) end it "returns early if already interrupted" do - result.interrupt! + resolver.interrupt! initial_state = result.state - result.interrupt! + resolver.interrupt! expect(result.state).to eq(initial_state) end @@ -170,10 +171,10 @@ context "when complete" do it "raises error when trying to transition from complete" do - result.executing! - result.complete! + resolver.executing! + resolver.complete! - expect { result.interrupt! }.to raise_error(/cannot transition to interrupted from complete/) + expect { resolver.interrupt! }.to raise_error(/cannot transition to interrupted from complete/) end end end @@ -186,13 +187,13 @@ end it "returns false after skip!" do - result.skip!("test reason", halt: false) + resolver.skip!("test reason", halt: false) expect(result.success?).to be(false) end it "returns false after fail!" do - result.fail!("test reason", halt: false) + resolver.fail!("test reason", halt: false) expect(result.success?).to be(false) end @@ -204,7 +205,7 @@ end it "returns true after skip!" do - result.skip!("test reason", halt: false) + resolver.skip!("test reason", halt: false) expect(result.skipped?).to be(true) end @@ -216,7 +217,7 @@ end it "returns true after fail!" do - result.fail!("test reason", halt: false) + resolver.fail!("test reason", halt: false) expect(result.failed?).to be(true) end @@ -228,13 +229,13 @@ end it "returns true when skipped" do - result.skip!("test reason", halt: false) + resolver.skip!("test reason", halt: false) expect(result.good?).to be(true) end it "returns false when failed" do - result.fail!("test reason", halt: false) + resolver.fail!("test reason", halt: false) expect(result.good?).to be(false) end @@ -246,30 +247,18 @@ end it "returns true when skipped" do - result.skip!("test reason", halt: false) + resolver.skip!("test reason", halt: false) expect(result.bad?).to be(true) end it "returns true when failed" do - result.fail!("test reason", halt: false) + resolver.fail!("test reason", halt: false) expect(result.bad?).to be(true) end end - describe "#strict?" do - it "returns true by default" do - expect(result.strict?).to be(true) - end - - it "returns false when strict is false" do - result.fail!("test reason", halt: false, strict: false) - - expect(result.strict?).to be(false) - end - end - describe "#rolled_back?" do it "returns false by default" do expect(result.rolled_back?).to be(false) @@ -299,8 +288,8 @@ describe "#executed!" do context "when successful" do it "calls complete!" do - result.executing! - result.executed! + resolver.executing! + resolver.executed! expect(result.complete?).to be(true) end @@ -308,15 +297,15 @@ context "when not successful" do it "calls interrupt! when skipped" do - result.skip!("test reason", halt: false) - result.executed! + resolver.skip!("test reason", halt: false) + resolver.executed! expect(result.interrupted?).to be(true) end it "calls interrupt! when failed" do - result.fail!("test reason", halt: false) - result.executed! + resolver.fail!("test reason", halt: false) + resolver.executed! expect(result.interrupted?).to be(true) end @@ -329,14 +318,14 @@ end it "returns true when complete" do - result.executing! - result.complete! + resolver.executing! + resolver.complete! expect(result.executed?).to be(true) end it "returns true when interrupted" do - result.interrupt! + resolver.interrupt! expect(result.executed?).to be(true) end @@ -348,7 +337,7 @@ end it "calls block when executed" do - result.interrupt! + resolver.interrupt! called = false result.on(:executed) { |_r| called = true } @@ -368,279 +357,6 @@ end end - describe "#success!" do - context "when successful" do - it "sets the reason" do - catch(:cmdx_halt) { result.success!("Created 42 records") } - - expect(result.status).to eq(CMDx::Result::SUCCESS) - expect(result.reason).to eq("Created 42 records") - expect(result.metadata).to eq({}) - end - - it "accepts metadata" do - catch(:cmdx_halt) { result.success!("Imported", rows: 100) } - - expect(result.reason).to eq("Imported") - expect(result.metadata).to eq({ rows: 100 }) - end - - it "allows nil reason" do - catch(:cmdx_halt) { result.success! } - - expect(result.reason).to be_nil - end - - it "does not change state or status" do - original_state = result.state - original_status = result.status - catch(:cmdx_halt) { result.success!("note") } - - expect(result.state).to eq(original_state) - expect(result.status).to eq(original_status) - end - - it "throws :cmdx_halt by default" do - expect { result.success!("done") }.to throw_symbol(:cmdx_halt) - end - - it "does not throw when halt is false" do - expect { result.success!("done", halt: false) }.not_to throw_symbol(:cmdx_halt) - end - end - - context "when not successful" do - it "raises error when skipped" do - result.skip!("test", halt: false) - - expect { result.success!("reason") }.to raise_error(/can only be used while success/) - end - - it "raises error when failed" do - result.fail!("test", halt: false) - - expect { result.success!("reason") }.to raise_error(/can only be used while success/) - end - end - end - - describe "#skip!" do - context "when successful" do - it "transitions to skipped status" do - result.skip!("test reason", halt: false) - - expect(result.status).to eq(CMDx::Result::SKIPPED) - expect(result.state).to eq(CMDx::Result::INTERRUPTED) - expect(result.reason).to eq("test reason") - expect(result.cause).to be_nil - expect(result.metadata).to eq({}) - end - - it "accepts metadata" do - result.skip!("test reason", halt: false, foo: "bar") - - expect(result.metadata).to eq({ foo: "bar" }) - end - - it "accepts cause" do - cause = StandardError.new("cause") - result.skip!("test reason", halt: false, cause: cause) - - expect(result.cause).to eq(cause) - end - - it "uses default reason when none provided" do - allow(CMDx::Locale).to receive(:t).with("cmdx.reasons.unspecified").and_return("Unspecified") - - result.skip!(halt: false) - - expect(result.reason).to eq("Unspecified") - end - - it "calls halt! by default" do - expect { result.skip!("test reason") }.to raise_error(CMDx::SkipFault) - end - - it "does not call halt! when halt: false" do - expect { result.skip!("test reason", halt: false) }.not_to raise_error - end - end - - context "when already skipped" do - it "returns early without changes" do - result.skip!("first reason", halt: false) - original_reason = result.reason - result.skip!("second reason", halt: false) - - expect(result.reason).to eq(original_reason) - end - end - - context "when not successful" do - it "raises error when trying to skip from failed" do - result.fail!("test reason", halt: false) - - expect { result.skip!("another reason", halt: false) }.to raise_error(/can only transition to skipped from success/) - end - end - end - - describe "#fail!" do - context "when successful" do - it "transitions to failed status" do - result.fail!("test reason", halt: false) - - expect(result.status).to eq(CMDx::Result::FAILED) - expect(result.state).to eq(CMDx::Result::INTERRUPTED) - expect(result.reason).to eq("test reason") - expect(result.cause).to be_nil - expect(result.metadata).to eq({}) - end - - it "accepts metadata" do - result.fail!("test reason", halt: false, foo: "bar") - - expect(result.metadata).to eq({ foo: "bar" }) - end - - it "accepts cause" do - cause = StandardError.new("cause") - result.fail!("test reason", halt: false, cause: cause) - - expect(result.cause).to eq(cause) - end - - it "uses default reason when none provided" do - allow(CMDx::Locale).to receive(:t).with("cmdx.reasons.unspecified").and_return("Unspecified") - - result.fail!(halt: false) - - expect(result.reason).to eq("Unspecified") - end - - it "calls halt! by default" do - expect { result.fail!("test reason") }.to raise_error(CMDx::FailFault) - end - - it "does not call halt! when halt: false" do - expect { result.fail!("test reason", halt: false) }.not_to raise_error - end - end - - context "when already failed" do - it "returns early without changes" do - result.fail!("first reason", halt: false) - original_reason = result.reason - result.fail!("second reason", halt: false) - - expect(result.reason).to eq(original_reason) - end - end - - context "when not successful" do - it "raises error when trying to fail from skipped" do - result.skip!("test reason", halt: false) - - expect { result.fail!("another reason", halt: false) }.to raise_error(/can only transition to failed from success/) - end - end - end - - describe "#halt!" do - context "when successful" do - it "returns early without raising" do - expect { result.halt! }.not_to raise_error - end - end - - context "when skipped" do - it "raises SkipFault" do - result.skip!("test reason", halt: false) - - expect { result.halt! }.to raise_error(CMDx::SkipFault) do |fault| - expect(fault.result).to eq(result) - expect(fault.message).to eq("test reason") - end - end - - it "sets proper backtrace" do - result.skip!("test reason", halt: false) - - begin - result.halt! - rescue CMDx::SkipFault => e - expect(e.backtrace).to be_an(Array) - expect(e.backtrace).not_to be_empty - end - end - end - - context "when failed" do - it "raises FailFault" do - result.fail!("test reason", halt: false) - - expect { result.halt! }.to raise_error(CMDx::FailFault) do |fault| - expect(fault.result).to eq(result) - expect(fault.message).to eq("test reason") - end - end - end - end - - describe "#throw!" do - let(:other_task) { create_failing_task.new } - let(:other_result) { other_task.result } - - before do - other_result.fail!("source failure", halt: false, foo: "bar") - end - - context "with valid result" do - it "copies state and status from other result" do - result.throw!(other_result, halt: false) - - expect(result.state).to eq(other_result.state) - expect(result.status).to eq(other_result.status) - expect(result.reason).to eq(other_result.reason) - end - - it "merges metadata" do - result.throw!(other_result, halt: false, baz: "qux") - - expect(result.metadata).to eq({ foo: "bar", baz: "qux" }) - end - - it "uses provided cause over other result's cause" do - custom_cause = StandardError.new("custom") - - result.throw!(other_result, halt: false, cause: custom_cause) - expect(result.cause).to eq(custom_cause) - end - - it "uses other result's cause when none provided" do - other_cause = StandardError.new("other") - other_result.instance_variable_set(:@cause, other_cause) - result.throw!(other_result, halt: false) - - expect(result.cause).to eq(other_cause) - end - - it "calls halt! by default" do - expect { result.throw!(other_result) }.to raise_error(CMDx::FailFault) - end - - it "does not call halt! when halt: false" do - expect { result.throw!(other_result, halt: false) }.not_to raise_error - end - end - - context "with invalid result" do - it "raises TypeError when not a CMDx::Result" do - expect { result.throw!("not a result", halt: false) }.to raise_error(TypeError, "must be a CMDx::Result") - end - end - end - describe "failure tracking methods" do let(:chain) { CMDx::Chain.new } let(:first_task) { create_successful_task.new } @@ -657,7 +373,7 @@ r.instance_variable_set(:@chain, chain) end - second_result.fail!("test failure", halt: false) + second_task.resolver.fail!("test failure", halt: false) end describe "#caused_failure" do @@ -697,7 +413,7 @@ chain.results << fourth_result fourth_result.instance_variable_set(:@chain, chain) - fourth_result.fail!("another failure", halt: false) + fourth_task.resolver.fail!("another failure", halt: false) end it "returns the next failed result after current" do @@ -767,7 +483,7 @@ context "when not initialized and not thrown failure" do it "returns status" do - result.executing! + resolver.executing! expect(result.outcome).to eq(result.status) end @@ -795,7 +511,7 @@ context "when successful with reason" do it "includes reason without cause or rolled_back" do - catch(:cmdx_halt) { result.success!("Created 42 records") } + catch(:cmdx_halt) { resolver.success!("Created 42 records") } hash = result.to_h @@ -806,7 +522,7 @@ context "when interrupted" do it "includes reason, cause and rolled_back status" do - result.skip!("test reason", halt: false, cause: StandardError.new("test")) + resolver.skip!("test reason", halt: false, cause: StandardError.new("test")) hash = result.to_h @@ -818,7 +534,7 @@ context "when failed" do it "includes failure information" do - result.fail!("test failure", halt: false) + resolver.fail!("test failure", halt: false) # Create mock objects that avoid calling to_h to prevent infinite recursion threw_failure_mock = instance_double(described_class, to_h: { index: 1, class: "Test", id: "123" }) @@ -913,12 +629,12 @@ when CMDx::Result::INITIALIZED # Already in initialized state when CMDx::Result::EXECUTING - result.executing! + resolver.executing! when CMDx::Result::COMPLETE - result.executing! - result.complete! + resolver.executing! + resolver.complete! when CMDx::Result::INTERRUPTED - result.interrupt! + resolver.interrupt! end end @@ -941,7 +657,7 @@ before do case state when CMDx::Result::INITIALIZED - result.executing! + resolver.executing! when CMDx::Result::EXECUTING # Stay in initialized state when CMDx::Result::COMPLETE @@ -979,9 +695,9 @@ when CMDx::Result::SUCCESS # Already in success status when CMDx::Result::SKIPPED - result.skip!("test", halt: false) + resolver.skip!("test", halt: false) when CMDx::Result::FAILED - result.fail!("test", halt: false) + resolver.fail!("test", halt: false) end end @@ -1004,7 +720,7 @@ before do case status when CMDx::Result::SUCCESS - result.skip!("test", halt: false) + resolver.skip!("test", halt: false) when CMDx::Result::SKIPPED # Stay in success status when CMDx::Result::FAILED @@ -1041,7 +757,7 @@ end it "calls the block for skipped" do - result.skip!("test", halt: false) + resolver.skip!("test", halt: false) called = false result.on(:good) { |_r| called = true } @@ -1051,7 +767,7 @@ context "when not good" do it "does not call the block for failed" do - result.fail!("test", halt: false) + resolver.fail!("test", halt: false) called = false result.on(:good) { |_r| called = true } @@ -1071,7 +787,7 @@ context "when bad" do it "calls the block for skipped" do - result.skip!("test", halt: false) + resolver.skip!("test", halt: false) called = false result.on(:bad) { |_r| called = true } @@ -1079,7 +795,7 @@ end it "calls the block for failed" do - result.fail!("test", halt: false) + resolver.fail!("test", halt: false) called = false result.on(:bad) { |_r| called = true } diff --git a/spec/cmdx/task_spec.rb b/spec/cmdx/task_spec.rb index caded1fad..0b01f311c 100644 --- a/spec/cmdx/task_spec.rb +++ b/spec/cmdx/task_spec.rb @@ -75,26 +75,26 @@ end describe "delegated methods" do - it "delegates success! to result" do - expect(task.result).to receive(:success!).with("reason", metadata: "data") + it "delegates success! to resolver" do + expect(task.resolver).to receive(:success!).with("reason", metadata: "data") task.success!("reason", metadata: "data") end - it "delegates skip! to result" do - expect(task.result).to receive(:skip!).with("reason", metadata: "data") + it "delegates skip! to resolver" do + expect(task.resolver).to receive(:skip!).with("reason", metadata: "data") task.skip!("reason", metadata: "data") end - it "delegates fail! to result" do - expect(task.result).to receive(:fail!).with("reason", metadata: "data") + it "delegates fail! to resolver" do + expect(task.resolver).to receive(:fail!).with("reason", metadata: "data") task.fail!("reason", metadata: "data") end - it "delegates throw! to result" do - expect(task.result).to receive(:throw!).with("reason", metadata: "data") + it "delegates throw! to resolver" do + expect(task.resolver).to receive(:throw!).with("reason", metadata: "data") task.throw!("reason", metadata: "data") end From 543b50ff32999711273f5a0f369288ae813d92d2 Mon Sep 17 00:00:00 2001 From: Juan Gomez Date: Fri, 10 Apr 2026 11:31:21 -0400 Subject: [PATCH 2/2] Rebuild --- | 2 + CHANGELOG.md | 32 +- Gemfile.lock | 2 +- benchmarks/memory.rb | 321 +++++ benchmarks/performance.rb | 204 +++ lib/cmdx.rb | 96 +- lib/cmdx/attribute.rb | 457 +------ lib/cmdx/attribute_registry.rb | 214 ++- lib/cmdx/attribute_value.rb | 252 ---- lib/cmdx/callback_registry.rb | 170 +-- lib/cmdx/chain.rb | 149 +-- lib/cmdx/coercion_registry.rb | 156 +-- lib/cmdx/coercions/array.rb | 52 +- lib/cmdx/coercions/big_decimal.rb | 47 +- lib/cmdx/coercions/boolean.rb | 57 +- lib/cmdx/coercions/complex.rb | 40 +- lib/cmdx/coercions/date.rb | 51 +- lib/cmdx/coercions/date_time.rb | 51 +- lib/cmdx/coercions/float.rb | 40 +- lib/cmdx/coercions/hash.rb | 68 +- lib/cmdx/coercions/integer.rb | 43 +- lib/cmdx/coercions/rational.rb | 46 +- lib/cmdx/coercions/string.rb | 34 +- lib/cmdx/coercions/symbol.rb | 34 +- lib/cmdx/coercions/time.rb | 53 +- lib/cmdx/configuration.rb | 282 +--- lib/cmdx/context.rb | 209 +-- lib/cmdx/deprecator.rb | 86 +- lib/cmdx/errors.rb | 95 +- lib/cmdx/exception.rb | 66 +- lib/cmdx/executor.rb | 379 ------ lib/cmdx/fault.rb | 101 +- lib/cmdx/identifier.rb | 24 +- lib/cmdx/locale.rb | 98 +- lib/cmdx/log_formatters/json.rb | 45 +- lib/cmdx/log_formatters/key_value.rb | 52 +- lib/cmdx/log_formatters/line.rb | 37 +- lib/cmdx/log_formatters/logstash.rb | 54 +- lib/cmdx/log_formatters/raw.rb | 30 +- lib/cmdx/middleware_registry.rb | 163 +-- lib/cmdx/middlewares/correlate.rb | 133 +- lib/cmdx/middlewares/runtime.rb | 77 -- lib/cmdx/middlewares/runtime_tracker.rb | 22 + lib/cmdx/middlewares/timeout.rb | 75 +- lib/cmdx/outcome.rb | 70 + lib/cmdx/parallelizer.rb | 109 +- lib/cmdx/pipeline.rb | 187 +-- lib/cmdx/railtie.rb | 45 +- lib/cmdx/resolver.rb | 263 ---- lib/cmdx/result.rb | 490 ++----- lib/cmdx/retry.rb | 166 --- lib/cmdx/retry_strategy.rb | 64 + lib/cmdx/runtime.rb | 275 ++++ lib/cmdx/settings.rb | 271 ++-- lib/cmdx/signals.rb | 79 ++ lib/cmdx/task.rb | 562 ++++---- lib/cmdx/utils/call.rb | 73 +- lib/cmdx/utils/condition.rb | 82 +- lib/cmdx/utils/format.rb | 83 +- lib/cmdx/utils/normalize.rb | 53 +- lib/cmdx/utils/wrap.rb | 33 +- lib/cmdx/validator_registry.rb | 157 +-- lib/cmdx/validators/absence.rb | 56 +- lib/cmdx/validators/exclusion.rb | 80 +- lib/cmdx/validators/format.rb | 64 +- lib/cmdx/validators/inclusion.rb | 82 +- lib/cmdx/validators/length.rb | 184 +-- lib/cmdx/validators/numeric.rb | 179 +-- lib/cmdx/validators/presence.rb | 61 +- lib/cmdx/value_resolver.rb | 83 ++ lib/cmdx/version.rb | 6 +- lib/cmdx/workflow.rb | 151 +-- lib/generators/cmdx/templates/install.rb | 50 +- spec/cmdx/attribute_registry_spec.rb | 353 +---- spec/cmdx/attribute_spec.rb | 761 ++--------- spec/cmdx/attribute_value_spec.rb | 763 ----------- spec/cmdx/callback_registry_spec.rb | 431 ++---- spec/cmdx/chain_spec.rb | 413 ++---- spec/cmdx/coercion_registry_spec.rb | 300 +---- spec/cmdx/coercions/array_spec.rb | 213 +-- spec/cmdx/coercions/big_decimal_spec.rb | 164 +-- spec/cmdx/coercions/boolean_spec.rb | 252 +--- spec/cmdx/coercions/complex_spec.rb | 216 +-- spec/cmdx/coercions/date_spec.rb | 228 +--- spec/cmdx/coercions/date_time_spec.rb | 184 +-- spec/cmdx/coercions/float_spec.rb | 222 +--- spec/cmdx/coercions/hash_spec.rb | 286 +--- spec/cmdx/coercions/integer_spec.rb | 199 +-- spec/cmdx/coercions/rational_spec.rb | 252 +--- spec/cmdx/coercions/string_spec.rb | 270 +--- spec/cmdx/coercions/symbol_spec.rb | 224 +--- spec/cmdx/coercions/time_spec.rb | 227 +--- spec/cmdx/configuration_spec.rb | 302 +---- spec/cmdx/context_spec.rb | 496 ++----- spec/cmdx/deprecator_spec.rb | 179 +-- spec/cmdx/errors_spec.rb | 431 +----- spec/cmdx/executor_spec.rb | 1162 ----------------- spec/cmdx/faults_spec.rb | 189 --- spec/cmdx/identifier_spec.rb | 47 +- spec/cmdx/locale_spec.rb | 147 --- spec/cmdx/log_formatters/json_spec.rb | 253 +--- spec/cmdx/log_formatters/key_value_spec.rb | 389 +----- spec/cmdx/log_formatters/line_spec.rb | 291 +---- spec/cmdx/log_formatters/logstash_spec.rb | 264 +--- spec/cmdx/log_formatters/raw_spec.rb | 205 +-- spec/cmdx/middleware_registry_spec.rb | 469 +------ spec/cmdx/middlewares/correlate_spec.rb | 450 +------ spec/cmdx/middlewares/runtime_spec.rb | 180 --- spec/cmdx/middlewares/runtime_tracker_spec.rb | 23 + spec/cmdx/middlewares/timeout_spec.rb | 278 +--- spec/cmdx/outcome_spec.rb | 57 + spec/cmdx/parallelizer_spec.rb | 65 - spec/cmdx/pipeline_spec.rb | 389 ------ spec/cmdx/resolver_spec.rb | 316 ----- spec/cmdx/result_spec.rb | 895 ++----------- spec/cmdx/retry_spec.rb | 360 ----- spec/cmdx/retry_strategy_spec.rb | 48 + spec/cmdx/runtime_spec.rb | 123 ++ spec/cmdx/settings_spec.rb | 342 +---- spec/cmdx/signals_spec.rb | 96 ++ spec/cmdx/task_spec.rb | 673 ++-------- spec/cmdx/utils/call_spec.rb | 424 ------ spec/cmdx/utils/condition_spec.rb | 444 ------- spec/cmdx/utils/format_spec.rb | 289 ---- spec/cmdx/utils/normalize_spec.rb | 94 -- spec/cmdx/utils/wrap_spec.rb | 60 - spec/cmdx/validator_registry_spec.rb | 439 +------ spec/cmdx/validators/absence_spec.rb | 150 +-- spec/cmdx/validators/exclusion_spec.rb | 353 +---- spec/cmdx/validators/format_spec.rb | 275 +--- spec/cmdx/validators/inclusion_spec.rb | 362 +---- spec/cmdx/validators/length_spec.rb | 436 +------ spec/cmdx/validators/numeric_spec.rb | 384 +----- spec/cmdx/validators/presence_spec.rb | 162 +-- spec/cmdx/value_resolver_spec.rb | 150 +++ spec/cmdx/workflow_spec.rb | 383 +----- spec/cmdx_spec.rb | 216 --- spec/examples.txt | 265 ++++ spec/integration/tasks/attributes_spec.rb | 482 ------- .../integration/tasks/basic_execution_spec.rb | 139 ++ spec/integration/tasks/callbacks_spec.rb | 536 -------- spec/integration/tasks/execution_spec.rb | 815 ------------ spec/integration/tasks/handlers_spec.rb | 332 ----- spec/integration/tasks/middlewares_spec.rb | 56 - spec/integration/tasks/returns_spec.rb | 346 ----- .../integration/workflows/breakpoints_spec.rb | 190 --- .../workflows/conditionals_spec.rb | 184 --- spec/integration/workflows/execution_spec.rb | 105 -- spec/spec_helper.rb | 54 +- spec/support/config/i18n.rb | 13 - spec/support/helpers/ruby_version.rb | 19 - spec/support/helpers/task_builders.rb | 110 -- spec/support/helpers/workflow_builders.rb | 69 - 153 files changed, 5104 insertions(+), 27066 deletions(-) create mode 100644 create mode 100644 benchmarks/memory.rb create mode 100644 benchmarks/performance.rb delete mode 100644 lib/cmdx/attribute_value.rb delete mode 100644 lib/cmdx/executor.rb delete mode 100644 lib/cmdx/middlewares/runtime.rb create mode 100644 lib/cmdx/middlewares/runtime_tracker.rb create mode 100644 lib/cmdx/outcome.rb delete mode 100644 lib/cmdx/resolver.rb delete mode 100644 lib/cmdx/retry.rb create mode 100644 lib/cmdx/retry_strategy.rb create mode 100644 lib/cmdx/runtime.rb create mode 100644 lib/cmdx/signals.rb create mode 100644 lib/cmdx/value_resolver.rb delete mode 100644 spec/cmdx/attribute_value_spec.rb delete mode 100644 spec/cmdx/executor_spec.rb delete mode 100644 spec/cmdx/faults_spec.rb delete mode 100644 spec/cmdx/locale_spec.rb delete mode 100644 spec/cmdx/middlewares/runtime_spec.rb create mode 100644 spec/cmdx/middlewares/runtime_tracker_spec.rb create mode 100644 spec/cmdx/outcome_spec.rb delete mode 100644 spec/cmdx/parallelizer_spec.rb delete mode 100644 spec/cmdx/pipeline_spec.rb delete mode 100644 spec/cmdx/resolver_spec.rb delete mode 100644 spec/cmdx/retry_spec.rb create mode 100644 spec/cmdx/retry_strategy_spec.rb create mode 100644 spec/cmdx/runtime_spec.rb create mode 100644 spec/cmdx/signals_spec.rb delete mode 100644 spec/cmdx/utils/call_spec.rb delete mode 100644 spec/cmdx/utils/condition_spec.rb delete mode 100644 spec/cmdx/utils/format_spec.rb delete mode 100644 spec/cmdx/utils/normalize_spec.rb delete mode 100644 spec/cmdx/utils/wrap_spec.rb create mode 100644 spec/cmdx/value_resolver_spec.rb delete mode 100644 spec/cmdx_spec.rb create mode 100644 spec/examples.txt delete mode 100644 spec/integration/tasks/attributes_spec.rb create mode 100644 spec/integration/tasks/basic_execution_spec.rb delete mode 100644 spec/integration/tasks/callbacks_spec.rb delete mode 100644 spec/integration/tasks/execution_spec.rb delete mode 100644 spec/integration/tasks/handlers_spec.rb delete mode 100644 spec/integration/tasks/middlewares_spec.rb delete mode 100644 spec/integration/tasks/returns_spec.rb delete mode 100644 spec/integration/workflows/breakpoints_spec.rb delete mode 100644 spec/integration/workflows/conditionals_spec.rb delete mode 100644 spec/integration/workflows/execution_spec.rb delete mode 100644 spec/support/config/i18n.rb delete mode 100644 spec/support/helpers/ruby_version.rb delete mode 100644 spec/support/helpers/task_builders.rb delete mode 100644 spec/support/helpers/workflow_builders.rb diff --git a/ b/ new file mode 100644 index 000000000..cf40e047d --- /dev/null +++ b/ @@ -0,0 +1,2 @@ +Total allocated: 233.60 kB (2650 objects) +Total retained: 0 B (0 objects) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6b473901..1e5a3e3ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,38 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [UNRELEASED] +## [2.0.0] - Complete Redesign + +### Changed (BREAKING) +- **Signal-based control flow**: `fail!`, `skip!`, and `success!` now use `throw(:cmdx_signal)` / `catch(:cmdx_signal)` instead of raising `Fault` exceptions. ~3x faster for non-exceptional interruptions. +- **Immutable Result**: `Result` is now frozen on creation. All fields are `attr_reader` only. No more mutable state transitions. +- **Unified Runtime**: Replaced `Executor` + `Resolver` with a single `Runtime` orchestrator that owns the entire lifecycle. +- **Module-based Task composition**: Task is now composed from focused modules (`Signals`, attributes DSL, callbacks DSL, middleware DSL, returns DSL, settings DSL, execution class methods). +- **Task instance methods reduced to 9**: `context`/`ctx`, `logger`, `work`, `rollback`, `success!`, `skip!`, `fail!`, `throw!`, `dry_run?`. No more `id`, `result`, `chain`, `errors`, `attributes`, `resolver`. +- **Attribute readers via anonymous modules**: Attribute accessors are defined on included anonymous modules (visible in `ancestors`, overridable with `super`). +- **Outcome struct**: Internal mutable scratch pad (`Outcome`) used during execution, converted to frozen `Result` at the end. +- **No circular references**: `Result` stores `task_id` (String) and `task_class` (Class), never the task instance. Task never holds Result. +- **Callback signature**: Runtime injects `@_result` before callbacks, removes after. Class-based callbacks receive `(task, result)`. +- **Settings lazy delegation**: Per-task `Settings` delegates to parent then global `Configuration` via `resolved_*` methods. +- **COW registries**: All 5 registries (Attribute, Callback, Middleware, Validator, Coercion) use copy-on-write for safe inheritance. ### Added -- Add `CMDx::Resolver` class to manage result transitions +- `CMDx::Signals` module with `success!`, `skip!`, `fail!`, `throw!`, `dry_run?` +- `CMDx::Outcome` struct for mutable execution tracking +- `CMDx::Runtime` single lifecycle orchestrator +- `CMDx::RetryStrategy` with configurable delay and jitter +- `CMDx::ValueResolver` pipeline: source → derive → default → coerce → transform +- `CMDx::Deprecator` with `:restrict` and `:warn` modes +- Full test suite (263 examples, 0 failures) + +### Removed +- `CMDx::Executor` (replaced by `Runtime`) +- `CMDx::Resolver` (replaced by `Runtime`) +- `CMDx::AttributeValue` (replaced by `ValueResolver`) +- `CMDx::Retry` (replaced by `RetryStrategy`) +- Mutable `Result` state transitions +- Circular references between Task, Result, and Resolver +- Require `time` in `Coercions::Time` so `Time.parse` is available on Ruby 3.4+ ## [1.21.0] - 2026-04-09 diff --git a/Gemfile.lock b/Gemfile.lock index 497a87f37..554cfac72 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - cmdx (1.21.0) + cmdx (2.0.0) bigdecimal logger zeitwerk diff --git a/benchmarks/memory.rb b/benchmarks/memory.rb new file mode 100644 index 000000000..722d3dd55 --- /dev/null +++ b/benchmarks/memory.rb @@ -0,0 +1,321 @@ +# frozen_string_literal: true + +require "memory_profiler" +require_relative "../lib/cmdx" + +CMDx.configure { |c| c.logger = Logger.new(File::NULL) } + +# ───────────────────────────────────────────────────── +# Test tasks (new system) +# ───────────────────────────────────────────────────── + +class NoopTask < CMDx::Task + def work; end +end + +class FailTask < CMDx::Task + def work + fail!("boom") + end +end + +class SkipTask < CMDx::Task + def work + skip!("not needed") + end +end + +class AttrTask < CMDx::Task + required :name, :string + required :age, :integer + optional :email, :string, default: "none" + + def work + ctx[:greeting] = "Hello #{name}" + end +end + +class FullTask < CMDx::Task + required :name, :string + optional :count, :integer, default: 0 + on_success :after_success + register CMDx::Middlewares::Correlate + returns :output + + def work + ctx[:output] = "processed #{name}" + end + + private + + def after_success; end +end + +# ───────────────────────────────────────────────────── +# Old-style simulation (raise/rescue + mutable Result) +# ───────────────────────────────────────────────────── + +module OldStyle + class FaultError < StandardError + attr_reader :data + def initialize(msg, data = {}) + @data = data + super(msg) + end + end + + class MutableResult + attr_accessor :state, :status, :reason, :cause, :metadata, + :strict, :retries, :rolled_back, :task_id, + :task_class, :task_type, :task_tags, :context, + :chain, :errors, :index + + def initialize + @state = "initialized" + @status = "success" + @metadata = {} + @strict = true + @retries = 0 + @rolled_back = false + end + + def success? = status == "success" + def failed? = status == "failed" + def to_h = { task_id:, status:, reason: } + end + + # Simulates old executor pattern: create result, mutate it 10+ times, raise on fail + def self.execute_noop + result = MutableResult.new + result.task_id = SecureRandom.uuid + result.task_class = "OldNoop" + result.task_type = "old.noop" + result.task_tags = [] + result.state = "executing" + # work (noop) + result.state = "complete" + result.context = {} + result.chain = nil + result.errors = {} + result.index = 0 + result + end + + def self.execute_fail + result = MutableResult.new + result.task_id = SecureRandom.uuid + result.task_class = "OldFail" + result.task_type = "old.fail" + result.task_tags = [] + result.state = "executing" + begin + raise FaultError.new("boom", status: "failed") + rescue FaultError => e + result.status = "failed" + result.reason = e.message + result.cause = e + end + result.state = "interrupted" + result.context = {} + result.chain = nil + result.errors = {} + result.index = 0 + result + end +end + +# ───────────────────────────────────────────────────── +# Helpers +# ───────────────────────────────────────────────────── + +def profile(label, &block) + # Warm up + 10.times { CMDx::Chain.clear; block.call } + + report = MemoryProfiler.report(top: 0, allow_files: "cmdx") do + 100.times { CMDx::Chain.clear; block.call } + end + + total = report.total_allocated_memsize + objects = report.total_allocated + retained_mem = report.total_retained_memsize + retained_obj = report.total_retained + + per_call_mem = total / 100 + per_call_obj = objects / 100 + + { + label:, + per_call_mem:, + per_call_obj:, + retained_mem: retained_mem / 100, + retained_obj: retained_obj / 100 + } +end + +def profile_old(label, &block) + 10.times(&block) + + report = MemoryProfiler.report(top: 0) do + 100.times(&block) + end + + total = report.total_allocated_memsize + objects = report.total_allocated + + { + label:, + per_call_mem: total / 100, + per_call_obj: objects / 100, + retained_mem: report.total_retained_memsize / 100, + retained_obj: report.total_retained / 100 + } +end + +def print_comparison(title, old_data, new_data) + puts title + puts "-" * 70 + + fmt = " %-22s %8s %8s %10s" + puts format(fmt, "", "Old", "New", "Reduction") + puts format(fmt, "", "---", "---", "---------") + + mem_old = old_data[:per_call_mem] + mem_new = new_data[:per_call_mem] + mem_pct = mem_old.zero? ? "N/A" : "#{((1.0 - mem_new.to_f / mem_old) * 100).round(1)}%" + + obj_old = old_data[:per_call_obj] + obj_new = new_data[:per_call_obj] + obj_pct = obj_old.zero? ? "N/A" : "#{((1.0 - obj_new.to_f / obj_old) * 100).round(1)}%" + + puts format(fmt, "Allocated bytes/call", mem_old.to_s, mem_new.to_s, mem_pct) + puts format(fmt, "Allocated objs/call", obj_old.to_s, obj_new.to_s, obj_pct) + puts format(fmt, "Retained bytes/call", old_data[:retained_mem].to_s, new_data[:retained_mem].to_s, "") + puts format(fmt, "Retained objs/call", old_data[:retained_obj].to_s, new_data[:retained_obj].to_s, "") + puts +end + +def print_solo(data) + puts " #{data[:label]}" + puts " Allocated: #{data[:per_call_mem]} bytes, #{data[:per_call_obj]} objects per call" + puts " Retained: #{data[:retained_mem]} bytes, #{data[:retained_obj]} objects per call" +end + +# ───────────────────────────────────────────────────── +# Run +# ───────────────────────────────────────────────────── + +puts "=" * 70 +puts "CMDx #{CMDx::VERSION} Memory & Allocation Benchmarks" +puts "Ruby #{RUBY_VERSION} (#{RUBY_PLATFORM})" +puts "=" * 70 +puts + +# 1. Noop: old mutable vs new immutable +old_noop = profile_old("Old: noop (mutable Result, 10+ mutations)") { OldStyle.execute_noop } +new_noop = profile("New: noop task (frozen Result, signal-based)") { NoopTask.execute } +print_comparison("1. NOOP TASK — mutable Result vs frozen snapshot", old_noop, new_noop) + +# 2. Fail: old raise/rescue vs new throw/catch +old_fail = profile_old("Old: fail (raise FaultError + rescue)") { OldStyle.execute_fail } +new_fail = profile("New: fail! (throw/catch signal)") { FailTask.execute } +print_comparison("2. FAIL TASK — raise/rescue vs throw/catch", old_fail, new_fail) + +# 3. Control flow isolation: just the signal vs exception +puts "3. CONTROL FLOW ISOLATION — signal vs exception allocation" +puts "-" * 70 + +signal_report = MemoryProfiler.report(top: 0) do + 1000.times { catch(:cmdx_signal) { throw(:cmdx_signal, { status: :failed }) } } +end + +exception_report = MemoryProfiler.report(top: 0) do + 1000.times do + raise OldStyle::FaultError.new("boom", status: "failed") + rescue OldStyle::FaultError + nil + end +end + +sig_mem = signal_report.total_allocated_memsize / 1000 +sig_obj = signal_report.total_allocated / 1000 +exc_mem = exception_report.total_allocated_memsize / 1000 +exc_obj = exception_report.total_allocated / 1000 + +fmt = " %-25s %8s bytes %6s objects" +puts format(fmt, "throw/catch (signal)", sig_mem.to_s, sig_obj.to_s) +puts format(fmt, "raise/rescue (exception)", exc_mem.to_s, exc_obj.to_s) +if exc_mem.positive? && sig_mem.positive? + puts " Signal uses #{(sig_mem.to_f / exc_mem * 100).round(1)}% of exception memory" +elsif sig_mem.zero? + puts " Signal: ZERO allocations — throw/catch allocates nothing beyond the hash literal" +end +puts + +# 4. New system breakdown by feature +puts "4. NEW SYSTEM — per-feature allocation breakdown" +puts "-" * 70 + +results = [] +results << profile("Noop task") { NoopTask.execute } +results << profile("Fail task") { FailTask.execute } +results << profile("Skip task") { SkipTask.execute } +results << profile("3 attrs + coercion") { AttrTask.execute(name: "Juan", age: "30", email: "j@x.com") } +results << profile("Full stack (attrs+cb+mw+ret)") { FullTask.execute(name: "test") } + +results.each { |r| print_solo(r) } +puts + +# 5. Result object comparison +puts "5. RESULT OBJECT — frozen snapshot vs mutable" +puts "-" * 70 + +frozen_report = MemoryProfiler.report(top: 0) do + 1000.times do + CMDx::Result.new( + task_id: "abc", task_class: NoopTask, task_type: "noop", + task_tags: [], state: "complete", status: "success", + reason: nil, cause: nil, metadata: {}, strict: true, + retries: 0, rolled_back: false, index: 0 + ) + end +end + +mutable_report = MemoryProfiler.report(top: 0) do + 1000.times do + r = OldStyle::MutableResult.new + r.task_id = "abc" + r.task_class = "X" + r.task_type = "x" + r.task_tags = [] + r.state = "executing" + r.state = "complete" + r.status = "success" + r.context = {} + r.chain = nil + r.errors = {} + r.index = 0 + end +end + +fr_mem = frozen_report.total_allocated_memsize / 1000 +fr_obj = frozen_report.total_allocated / 1000 +mu_mem = mutable_report.total_allocated_memsize / 1000 +mu_obj = mutable_report.total_allocated / 1000 + +puts format(fmt, "Frozen Result.new", fr_mem.to_s, fr_obj.to_s) +puts format(fmt, "Mutable Result (10+ sets)", mu_mem.to_s, mu_obj.to_s) +pct = mu_mem.positive? ? "#{((1.0 - fr_mem.to_f / mu_mem) * 100).round(1)}%" : "N/A" +puts " Frozen snapshot uses #{pct} less memory" +puts + +# 6. Allocation hotspot detail for full task +puts "6. ALLOCATION HOTSPOTS — Full stack task (top 15 locations)" +puts "-" * 70 + +detail = MemoryProfiler.report(top: 15, allow_files: "cmdx") do + 50.times { CMDx::Chain.clear; FullTask.execute(name: "test") } +end + +detail.pretty_print(to_file: $stdout, detailed_report: false, scale_bytes: true, + allocated_strings: 0, retained_strings: 0) diff --git a/benchmarks/performance.rb b/benchmarks/performance.rb new file mode 100644 index 000000000..98a59fb6d --- /dev/null +++ b/benchmarks/performance.rb @@ -0,0 +1,204 @@ +# frozen_string_literal: true + +require "benchmark/ips" +require_relative "../lib/cmdx" + +CMDx.configure { |c| c.logger = Logger.new(File::NULL) } + +# --- Test Tasks --- + +class NoopTask < CMDx::Task + def work; end +end + +class FailSignalTask < CMDx::Task + def work + fail!("boom") + end +end + +class SkipSignalTask < CMDx::Task + def work + skip!("not needed") + end +end + +class NonHaltingFailTask < CMDx::Task + def work + fail!("deferred", halt: false) + end +end + +class AttrTask < CMDx::Task + required :name, :string + required :age, :integer + optional :email, :string + + def work + ctx[:greeting] = "Hello #{name}, age #{age}" + end +end + +class CallbackTask < CMDx::Task + on_success :log_it + on_failed :handle_fail + before_execution :prep + + def work + ctx[:done] = true + end + + private + + def log_it; end + def handle_fail; end + def prep; end +end + +class MiddlewareTask < CMDx::Task + register CMDx::Middlewares::RuntimeTracker + + def work + ctx[:done] = true + end +end + +class FullTask < CMDx::Task + required :name, :string + optional :count, :integer, default: 0 + on_success :after_success + register CMDx::Middlewares::Correlate + returns :output + + def work + ctx[:output] = "processed #{name} (#{count})" + end + + private + + def after_success; end +end + +# --- Exception-based control flow comparison --- + +class FaultException < CMDx::Fault + def initialize + @result = nil + super(CMDx::Result.new(status: "failed", reason: "boom")) + end +end + +# --- Benchmarks --- + +puts "=" * 60 +puts "CMDx #{CMDx::VERSION} Performance Benchmarks" +puts "Ruby #{RUBY_VERSION} (#{RUBY_PLATFORM})" +puts "=" * 60 +puts + +Benchmark.ips do |x| + x.config(warmup: 2, time: 5) + + # 1. Control flow: throw/catch vs raise/rescue + x.report("throw/catch (signal)") do + catch(:cmdx_signal) { throw(:cmdx_signal, { status: :failed }) } + end + + x.report("raise/rescue (exception)") do + raise FaultException + rescue CMDx::Fault + nil + end + + x.compare! +end + +puts +Benchmark.ips do |x| + x.config(warmup: 2, time: 5) + + # 2. Result construction + x.report("Result.new (frozen)") do + CMDx::Result.new( + task_id: "abc", task_class: NoopTask, task_type: "noop", + task_tags: [], state: "complete", status: "success", + reason: nil, cause: nil, metadata: {}, strict: true, + retries: 0, rolled_back: false, index: 0 + ) + end + + x.report("Outcome struct") do + CMDx::Outcome.new + end + + x.compare! +end + +puts +Benchmark.ips do |x| + x.config(warmup: 2, time: 5) + + # 3. Task execution throughput + x.report("noop task") do + CMDx::Chain.clear + NoopTask.execute + end + + x.report("fail! task") do + CMDx::Chain.clear + FailSignalTask.execute + end + + x.report("skip! task") do + CMDx::Chain.clear + SkipSignalTask.execute + end + + x.report("non-halting fail") do + CMDx::Chain.clear + NonHaltingFailTask.execute + end + + x.compare! +end + +puts +Benchmark.ips do |x| + x.config(warmup: 2, time: 5) + + # 4. Attribute resolution + x.report("3 attrs + coercion") do + CMDx::Chain.clear + AttrTask.execute(name: "Juan", age: "30", email: "j@x.com") + end + + x.report("noop (no attrs)") do + CMDx::Chain.clear + NoopTask.execute + end + + x.compare! +end + +puts +Benchmark.ips do |x| + x.config(warmup: 2, time: 5) + + # 5. Callbacks + middleware overhead + x.report("with callbacks") do + CMDx::Chain.clear + CallbackTask.execute + end + + x.report("with middleware") do + CMDx::Chain.clear + MiddlewareTask.execute + end + + x.report("full stack") do + CMDx::Chain.clear + FullTask.execute(name: "test") + end + + x.compare! +end diff --git a/lib/cmdx.rb b/lib/cmdx.rb index 89a4f8bcf..1a8c4a51d 100644 --- a/lib/cmdx.rb +++ b/lib/cmdx.rb @@ -1,79 +1,71 @@ # frozen_string_literal: true require "bigdecimal" -require "date" -require "forwardable" -require "json" require "logger" -require "pathname" require "securerandom" require "set" -require "time" -require "timeout" -require "yaml" require "zeitwerk" module CMDx - # @rbs EMPTY_ARRAY: Array[untyped] - EMPTY_ARRAY = [].freeze - private_constant :EMPTY_ARRAY - # @rbs EMPTY_HASH: Hash[untyped, untyped] EMPTY_HASH = {}.freeze - private_constant :EMPTY_HASH + + # @rbs EMPTY_ARRAY: Array[untyped] + EMPTY_ARRAY = [].freeze # @rbs EMPTY_STRING: String EMPTY_STRING = "" - private_constant :EMPTY_STRING - extend self + class << self + + # Returns the global configuration instance. + # + # @return [Configuration] + # + # @rbs () -> Configuration + def configuration + @configuration ||= Configuration.new + end + + # Yields the global configuration for modification. + # + # @yield [Configuration] + # + # @rbs () { (Configuration) -> void } -> Configuration + def configure + yield(configuration) + configuration + end + + # Resets the global configuration to defaults. + # + # @return [Configuration] + # + # @rbs () -> Configuration + def reset_configuration! + @configuration = Configuration.new + end - # Returns the path to the CMDx gem. - # - # @return [Pathname] the path to the CMDx gem - # - # @example - # CMDx.gem_path # => Pathname.new("/path/to/cmdx") - # - # @rbs return: Pathname - def gem_path - @gem_path ||= Pathname.new(__dir__).parent end end -# Set up Zeitwerk loader for the CMDx gem +# Manually require files that Zeitwerk should not autoload +require_relative "cmdx/version" +require_relative "cmdx/exception" +require_relative "cmdx/fault" +require_relative "cmdx/configuration" + loader = Zeitwerk::Loader.for_gem -loader.inflector.inflect("cmdx" => "CMDx", "json" => "JSON") -loader.ignore("#{__dir__}/cmdx/configuration") -loader.ignore("#{__dir__}/cmdx/exception") -loader.ignore("#{__dir__}/cmdx/fault") -loader.ignore("#{__dir__}/cmdx/railtie") +loader.inflector.inflect("cmdx" => "CMDx") +loader.ignore("#{__dir__}/cmdx/version.rb") +loader.ignore("#{__dir__}/cmdx/exception.rb") +loader.ignore("#{__dir__}/cmdx/fault.rb") +loader.ignore("#{__dir__}/cmdx/configuration.rb") +loader.ignore("#{__dir__}/cmdx/railtie.rb") loader.ignore("#{__dir__}/generators") loader.ignore("#{__dir__}/locales") loader.setup -# Pre-load configuration to make module methods available -# This is acceptable since configuration is fundamental to the framework -require_relative "cmdx/configuration" - -# Pre-load exceptions to make them available at the top level -# This ensures CMDx::Error and its descendants are always available -require_relative "cmdx/exception" - -# Pre-load fault classes to make them available at the top level -# This ensures CMDx::FailFault and CMDx::SkipFault are always available -require_relative "cmdx/fault" - -# Conditionally load Rails components if Rails is available -if defined?(Rails::Generators) - require_relative "generators/cmdx/install_generator" - require_relative "generators/cmdx/locale_generator" - require_relative "generators/cmdx/task_generator" - require_relative "generators/cmdx/workflow_generator" -end - -# Load the Railtie last after everything else is required so we don't -# need to load any CMDx components when we use this Railtie. require_relative "cmdx/railtie" if defined?(Rails::Railtie) diff --git a/lib/cmdx/attribute.rb b/lib/cmdx/attribute.rb index 67010afef..cd9e77874 100644 --- a/lib/cmdx/attribute.rb +++ b/lib/cmdx/attribute.rb @@ -1,439 +1,114 @@ # frozen_string_literal: true module CMDx - # Represents a configurable attribute within a CMDx task. - # Attributes define the data structure and validation rules for task parameters. - # They can be nested to create complex hierarchical data structures. + # Defines a single task attribute with its options (type, default, validations, etc.) class Attribute - # @rbs AFFIX: Proc - AFFIX = proc do |value, &block| - value == true ? block.call : value - end.freeze - private_constant :AFFIX - - # Returns the task instance associated with this attribute. - # - # @return [CMDx::Task] The task instance - # - # @example - # attribute.task.context[:user_id] # => 42 - # - # @rbs @task: Task - attr_accessor :task - - # Returns the name of this attribute. - # - # @return [Symbol] The attribute name - # - # @example - # attribute.name # => :user_id - # - # @rbs @name: Symbol + # @return [Symbol] the attribute name attr_reader :name - # Returns the configuration options for this attribute. - # - # @return [Hash{Symbol => Object}] Configuration options hash - # - # @example - # attribute.options # => { required: true, default: 0 } - # - # @rbs @options: Hash[Symbol, untyped] - attr_reader :options + # @return [Symbol, nil] the coercion type + attr_reader :type - # Returns the child attributes for nested structures. - # - # @return [Array] Array of child attributes - # - # @example - # attribute.children # => [#, #] - # - # @rbs @children: Array[Attribute] - attr_reader :children + # @return [Boolean] whether the attribute is required + attr_reader :required - # Returns the parent attribute if this is a nested attribute. - # - # @return [Attribute, nil] The parent attribute, or nil if root-level - # - # @example - # attribute.parent # => # - # - # @rbs @parent: (Attribute | nil) - attr_reader :parent + # @return [Object, nil] default value or callable + attr_reader :default - # Returns the expected type(s) for this attribute's value. - # - # @return [Array] Array of expected type classes - # - # @example - # attribute.types # => [Integer, String] - # - # @rbs @types: Array[Class] - attr_reader :types + # @return [Symbol, nil] alias name for the accessor + attr_reader :as - # Returns the description of the attribute. - # - # @return [String] The description of the attribute - # - # @example - # attribute.description # => "The user's name" - # - # @rbs @description: String - attr_reader :description + # @return [Symbol, nil] source method for value resolution + attr_reader :from - # Creates a new attribute with the specified name and configuration. - # - # @param name [Symbol, String] The name of the attribute - # @param options [Hash] Configuration options for the attribute - # @option options [Attribute] :parent The parent attribute for nested structures - # @option options [Boolean] :required Whether the attribute is required (default: false) - # @option options [Array, Class] :types The expected type(s) for the attribute value - # @option options [String] :description The description of the attribute - # @option options [Symbol, String, Proc] :source The source of the attribute value - # @option options [Symbol, String] :as The method name to use for this attribute - # @option options [Symbol, String, Boolean] :prefix The prefix to add to the method name - # @option options [Symbol, String, Boolean] :suffix The suffix to add to the method name - # @option options [Object] :default The default value for the attribute - # - # @yield [self] Block to configure nested attributes - # - # @example - # Attribute.new(:user_id, required: true, types: [Integer, String]) do - # required :name, types: String - # optional :email, types: String - # end - # - # @rbs ((Symbol | String) name, ?Hash[Symbol, untyped] options) ?{ () -> void } -> void - def initialize(name, options = {}, &) - @parent = options.delete(:parent) - @required = options.delete(:required) || false - @types = Utils::Wrap.array(options.delete(:types) || options.delete(:type)) - @description = options.delete(:description) || options.delete(:desc) + # @return [Proc, nil] derivation callable + attr_reader :derive - @name = name.to_sym - @options = options - @children = [] - - instance_eval(&) if block_given? - end - - # Deep-copies children and clears task-dependent memoization so that - # duped attributes can be safely bound to a task without mutating the - # class-level originals. This makes concurrent execution thread-safe. - # - # @param source [Attribute] The attribute being duplicated - # - # @rbs (Attribute source) -> void - def initialize_dup(source) - super - @children = source.children.map(&:dup) - remove_instance_variable(:@source) if defined?(@source) - remove_instance_variable(:@method_name) if defined?(@method_name) - @task = nil - end - - class << self + # @return [Proc, nil] transformation callable + attr_reader :transform - # Builds multiple attributes with the same configuration. - # - # @param names [Array] The names of the attributes to create - # @param options [Hash] Configuration options for the attributes - # @option options [Object] :* Any attribute configuration option - # - # @yield [self] Block to configure nested attributes - # - # @return [Array] Array of created attributes - # - # @raise [ArgumentError] When no names are provided or :as is used with multiple attributes - # - # @example - # Attribute.build(:first_name, :last_name, required: true, types: String) - # - # @rbs (*untyped names, **untyped options) ?{ () -> void } -> Array[Attribute] - def build(*names, **options, &) - if names.none? - raise ArgumentError, "no attributes given" - elsif (names.size > 1) && options.key?(:as) - raise ArgumentError, "the :as option only supports one attribute per definition" - end - - names.filter_map { |name| new(name, **options, &) } - end + # @return [Hash{Symbol => Hash}] validation rules + attr_reader :validations - # Creates optional attributes (not required). - # - # @param names [Array] The names of the attributes to create - # @param options [Hash] Configuration options for the attributes - # @option options [Object] :* Any attribute configuration option - # - # @yield [self] Block to configure nested attributes - # - # @return [Array] Array of created optional attributes - # - # @example - # Attribute.optional(:description, :tags, types: String) - # - # @rbs (*untyped names, **untyped options) ?{ () -> void } -> Array[Attribute] - def optional(*names, **options, &) - build(*names, **options.merge(required: false), &) - end - - # Creates required attributes. - # - # @param names [Array] The names of the attributes to create - # @param options [Hash] Configuration options for the attributes - # @option options [Object] :* Any attribute configuration option - # - # @yield [self] Block to configure nested attributes - # - # @return [Array] Array of created required attributes - # - # @example - # Attribute.required(:id, :name, types: [Integer, String]) - # - # @rbs (*untyped names, **untyped options) ?{ () -> void } -> Array[Attribute] - def required(*names, **options, &) - build(*names, **options.merge(required: true), &) - end + # @return [Hash] raw options + attr_reader :options + # @param name [Symbol] the attribute name + # @param type [Symbol, nil] the coercion type + # @param options [Hash] attribute options + # + # @rbs (Symbol name, ?Symbol? type, **untyped options) -> void + def initialize(name, type = nil, **options) + @name = name.to_sym + @type = type + @required = options.fetch(:required, true) + @default = options[:default] + @as = options[:as]&.to_sym + @from = options[:from]&.to_sym + @derive = options[:derive] + @transform = options[:transform] + @validations = extract_validations(options) + @options = options end - # Checks if the attribute is optional. + # The name used for the accessor method and attributes hash key. # - # @return [Boolean] true if the attribute is optional, false otherwise + # @return [Symbol] # - # @example - # attribute.optional? # => true - # - # @rbs () -> bool - def optional? - !required? || !!options[:optional] + # @rbs () -> Symbol + def allocation_name + as || name end - # Checks if the attribute is required. - # - # @return [Boolean] true if the attribute is required, false otherwise - # - # @example - # attribute.required? # => true + # @return [Boolean] # # @rbs () -> bool def required? - !!@required - end - - # Determines the source of the attribute value. Returns :context - # as a safe fallback when task is not yet set (e.g., schema introspection). - # - # @return [Symbol] The source identifier for the attribute value - # - # @example - # attribute.source # => :context - # - # @rbs () -> untyped - def source - return @source if defined?(@source) - - parent&.method_name || begin - value = options[:source] - - if value.is_a?(Proc) - task ? @source = task.instance_eval(&value) : :context - elsif value.respond_to?(:call) - task ? @source = value.call(task) : :context - else - @source = value || :context - end - end - end - - # Returns the method name for this attribute when it can be resolved - # statically (without a task instance). Returns nil for Proc/callable - # sources whose method name depends on runtime evaluation. - # - # @return [Symbol, nil] The static method name, or nil if dynamic - # - # @example - # attribute.allocation_name # => :user_name - # - # @rbs () -> Symbol? - def allocation_name - return @allocation_name if defined?(@allocation_name) - - @allocation_name = options[:as] || begin - src = options[:source] - source_name = - if parent - parent.allocation_name - elsif !src.is_a?(Proc) && !src.respond_to?(:call) - src || :context - end - - if source_name.is_a?(Symbol) - prefix = AFFIX.call(options[:prefix]) { "#{source_name}_" } - suffix = AFFIX.call(options[:suffix]) { "_#{source_name}" } - :"#{prefix}#{name}#{suffix}" - end - end + !!required end - # Generates the method name for accessing this attribute. - # - # @return [Symbol] The method name for the attribute + # @return [Boolean] # - # @example - # attribute.method_name # => :user_name - # - # @rbs () -> Symbol - def method_name - return @method_name if defined?(@method_name) - - result = options[:as] || begin - prefix = AFFIX.call(options[:prefix]) { "#{source}_" } - suffix = AFFIX.call(options[:suffix]) { "_#{source}" } - :"#{prefix}#{name}#{suffix}" - end - - # Only memoize if @source is defined to avoid memoizing method - # name when no task is present. - return result unless defined?(@source) - - @method_name = result + # @rbs () -> bool + def optional? + !required? end - # Defines and verifies the entire attribute tree including nested children. + # @return [Boolean] # - # @rbs () -> void - def define_and_verify_tree - define_and_verify - - children.each do |child| - child.task = task - child.define_and_verify_tree - end + # @rbs () -> bool + def typed? + !type.nil? end - # Recursively clears the task reference from this attribute and all children. - # Prevents the class-level attribute from retaining the last-executed task instance. + # @return [Boolean] # - # @rbs () -> void - def clear_task_tree! - @task = nil - children.each(&:clear_task_tree!) + # @rbs () -> bool + def default? + !default.nil? end + alias has_default? default? - # @return [Hash] A hash representation of the attribute - # - # @example - # attribute.to_h # => { - # name: :user_id, - # method_name: :current_user_id, - # description: "The user's name", - # required: true, - # types: [:integer], - # options: {}, - # children: [] - # } + # @return [Hash{Symbol => Object}] # # @rbs () -> Hash[Symbol, untyped] def to_h - { - name: name, - method_name: method_name, - description: description, - required: required?, - types: types, - options: options.except(:if, :unless), - children: children.map(&:to_h) - } + { name:, type:, required:, default:, as:, from:, validations: } end private - # Creates nested attributes as children of this attribute. - # - # @param names [Array] The names of the child attributes - # @param options [Hash] Configuration options for the child attributes - # @option options [Object] :* Any attribute configuration option - # - # @yield [self] Block to configure the child attributes - # - # @return [Array] Array of created child attributes - # - # @example - # attributes :street, :city, :zip, types: String - # - # @rbs (*untyped names, **untyped options) ?{ () -> void } -> Array[Attribute] - def attributes(*names, **options, &) - attrs = self.class.build(*names, **options.merge(parent: self), &) - children.concat(attrs) - end - alias attribute attributes - - # Creates optional nested attributes. - # - # @param names [Array] The names of the optional child attributes - # @param options [Hash] Configuration options for the child attributes - # @option options [Object] :* Any attribute configuration option - # - # @yield [self] Block to configure the child attributes - # - # @return [Array] Array of created optional child attributes + # Extracts validation rules from the options hash. # - # @example - # optional :middle_name, :nickname, types: String - # - # @rbs (*untyped names, **untyped options) ?{ () -> void } -> Array[Attribute] - def optional(*names, **options, &) - attributes(*names, **options.merge(required: false), &) - end - - # Creates required nested attributes. - # - # @param names [Array] The names of the required child attributes - # @param options [Hash] Configuration options for the child attributes - # @option options [Object] :* Any attribute configuration option - # - # @yield [self] Block to configure the child attributes - # - # @return [Array] Array of created required child attributes - # - # @example - # required :first_name, :last_name, types: String - # - # @rbs (*untyped names, **untyped options) ?{ () -> void } -> Array[Attribute] - def required(*names, **options, &) - attributes(*names, **options.merge(required: true), &) - end - - # Defines the attribute reader on the task class (once) and - # generates/validates the per-instance value (every execution). - # - # @raise [RuntimeError] When the method name is already defined on the task - # - # @rbs () -> void - def define_and_verify - name_of_method = method_name - - unless task.class.method_defined?(name_of_method) - if task.respond_to?(name_of_method, true) - raise <<~MESSAGE - The method #{name_of_method.inspect} is already defined on the #{task.class.name} task. - This may be due conflicts with one of the task's user defined or internal methods/attributes. - - Use :as, :prefix, and/or :suffix attribute options to avoid conflicts with existing methods. - MESSAGE - end - - task.class.define_method(name_of_method) do - attributes[name_of_method] - end + # @rbs (Hash[Symbol, untyped] options) -> Hash[Symbol, Hash[Symbol, untyped]] + def extract_validations(options) + validations = {} + ValidatorRegistry::BUILT_INS.each_key do |key| + validations[key] = options[key] if options.key?(key) end - - attribute_value = AttributeValue.new(self) - attribute_value.generate - attribute_value.validate + validations[:presence] = true if required? && !validations.key?(:presence) + validations end end diff --git a/lib/cmdx/attribute_registry.rb b/lib/cmdx/attribute_registry.rb index ce7f9425a..ea5aef59c 100644 --- a/lib/cmdx/attribute_registry.rb +++ b/lib/cmdx/attribute_registry.rb @@ -1,184 +1,126 @@ # frozen_string_literal: true module CMDx - # Manages a collection of attributes for task definition and verification. - # The registry provides methods to register, deregister, and process attributes - # in a hierarchical structure, supporting nested attribute definitions. + # Registry of attribute definitions for a task class. + # Manages attribute reader modules and COW semantics for inheritance. class AttributeRegistry - # Returns the collection of registered attributes. - # - # @return [Array] Array of registered attributes - # - # @example - # registry.registry # => [#, #] - # - # @rbs @registry: Array[Attribute] - attr_reader :registry - alias to_a registry + # @rbs @definitions: Hash[Symbol, Attribute] + # @rbs @reader_module: Module? + attr_reader :definitions - # Creates a new attribute registry with an optional initial collection. - # - # @param registry [Array] Initial attributes to register - # - # @return [AttributeRegistry] A new registry instance - # - # @example - # registry = AttributeRegistry.new - # registry = AttributeRegistry.new([attr1, attr2]) - # - # @rbs (?Array[Attribute] registry) -> void - def initialize(registry = []) - @registry = registry + # @rbs (?Hash[Symbol, Attribute]? definitions) -> void + def initialize(definitions = nil) + @definitions = definitions || {} + @reader_module = nil end - # Creates a duplicate of this registry with copied attributes. - # - # @return [AttributeRegistry] A new registry with duplicated attributes + # Registers an attribute definition. # - # @example - # new_registry = registry.dup + # @param attribute [Attribute] the attribute to register # - # @rbs () -> AttributeRegistry - def dup - self.class.new(registry.dup) + # @rbs (Attribute attribute) -> void + def register(attribute) + definitions[attribute.name] = attribute end - # Registers one or more attributes to the registry. - # - # @param attributes [Attribute, Array] Attribute(s) to register + # Removes an attribute definition. # - # @return [AttributeRegistry] Self for method chaining + # @param name [Symbol] the attribute name # - # @example - # registry.register(attribute) - # registry.register([attr1, attr2]) - # - # @rbs (Attribute | Array[Attribute] attributes) -> self - def register(attributes) - @registry.concat(Utils::Wrap.array(attributes)) - self + # @rbs (Symbol name) -> void + def deregister(name) + definitions.delete(name.to_sym) end - # Removes attributes from the registry by name. - # Supports hierarchical attribute removal by matching the entire attribute tree. - # - # @param names [Symbol, String, Array] Name(s) of attributes to remove + # Looks up an attribute by name. # - # @return [AttributeRegistry] Self for method chaining + # @param name [Symbol] the attribute name # - # @example - # registry.deregister(:name) - # registry.deregister(['name1', 'name2']) + # @return [Attribute, nil] # - # @rbs ((Symbol | String | Array[Symbol | String]) names) -> self - def deregister(names) - Utils::Wrap.array(names).each do |name| - @registry.reject! { |attribute| matches_attribute_tree?(attribute, name.to_sym) } - end - - self + # @rbs (Symbol name) -> Attribute? + def [](name) + definitions[name.to_sym] end - # Eagerly defines attribute reader methods on the task class for all - # attributes whose method names can be statically resolved. Called at - # class definition time so readers are defined once, not per-execution. + # @return [Boolean] # - # @param task_class [Class] The task class to define readers on - # @param attrs [Array] Attributes to process (defaults to all) - # - # @rbs (Class task_class, ?Array[Attribute] attrs) -> void - def define_readers_on!(task_class, attrs = registry) - attrs.each { |attr| define_reader_tree!(task_class, attr) } + # @rbs () -> bool + def any? + !definitions.empty? end - # Removes eagerly defined reader methods from the task class for - # attributes about to be deregistered. Must be called before {#deregister}. - # - # @param task_class [Class] The task class to undefine readers on - # @param names [Array] Attribute method names being removed + # @return [Integer] # - # @rbs (Class task_class, (Symbol | String | Array[Symbol | String]) names) -> void - def undefine_readers_on!(task_class, names) - Utils::Wrap.array(names).each do |name| - sym = name.to_sym - - registry.each do |attribute| - next unless matches_attribute_tree?(attribute, sym) + # @rbs () -> Integer + def size + definitions.size + end - undefine_reader_tree!(task_class, attribute) - end - end + # @rbs () { (Symbol, Attribute) -> void } -> void + def each(&) + definitions.each(&) end - # Verifies attribute definitions against a task instance. - # Each attribute is duped before binding so the class-level originals - # are never mutated — this eliminates the concurrency hazard of - # shared mutable @task, @source, and @method_name state. + # Builds the reader module for the task class and includes it. + # This creates accessor methods for all attributes on an anonymous module. # - # @param task [Task] The task to verify attributes against + # @param task_class [Class] the task class to include the module into # - # @rbs (Task task) -> void - def define_and_verify(task) - registry.each do |attribute| - duplicate = attribute.dup - duplicate.task = task - duplicate.define_and_verify_tree + # @rbs (untyped task_class) -> void + def define_readers!(task_class) + return if definitions.empty? + + mod = Module.new + definitions.each_value do |attr| + alloc_name = attr.allocation_name + next unless alloc_name + + mod.define_method(alloc_name) { @_attributes[alloc_name] } end + task_class.include(mod) + @reader_module = mod end - private - - # Recursively defines reader methods for an attribute and its children - # when the method name is statically resolvable. + # Resolves all attribute values from the given context into a hash. # - # @param task_class [Class] The task class to define readers on - # @param attribute [Attribute] The attribute to define a reader for + # @param task [Task] the task instance + # @param context [Context] the execution context + # @param errors [Errors] the error collection # - # @rbs (Class task_class, Attribute attribute) -> void - def define_reader_tree!(task_class, attribute) - name = attribute.allocation_name - - if name && !task_class.method_defined?(name) - if task_class.private_method_defined?(name) - raise <<~MESSAGE - The method #{name.inspect} is already defined on the #{task_class.name} task. - This may be due conflicts with one of the task's user defined or internal methods/attributes. + # @return [Hash{Symbol => Object}] resolved attribute values + # + # @rbs (untyped task, Context context, Errors errors) -> Hash[Symbol, untyped] + def resolve(task, context, errors) + definitions.each_with_object({}) do |(_name, attr), resolved| + value = ValueResolver.call(attr, task, context) + resolved[attr.allocation_name || attr.name] = value - Use :as, :prefix, and/or :suffix attribute options to avoid conflicts with existing methods. - MESSAGE + attr.validations.each do |type, options| + validator = ValidatorRegistry.new.resolve(type) + opts = options.is_a?(Hash) ? options : {} + message = validator.call(value, **opts) + errors.add(attr.name, message) if message end - - task_class.define_method(name) { attributes[name] } end - - attribute.children.each { |child| define_reader_tree!(task_class, child) } end - # Recursively removes reader methods for an attribute and its children. + # Returns a schema representation of all attributes. # - # @param task_class [Class] The task class to undefine readers on - # @param attribute [Attribute] The attribute whose readers to remove + # @return [Hash{Symbol => Hash}] # - # @rbs (Class task_class, Attribute attribute) -> void - def undefine_reader_tree!(task_class, attribute) - name = attribute.allocation_name - task_class.remove_method(name) if name && task_class.method_defined?(name, false) - attribute.children.each { |child| undefine_reader_tree!(task_class, child) } + # @rbs () -> Hash[Symbol, Hash[Symbol, untyped]] + def schema + definitions.transform_values(&:to_h) end - # Recursively checks if an attribute or any of its children match the given name. - # - # @param attribute [Attribute] The attribute to check - # @param name [Symbol] The name to match against + # @return [AttributeRegistry] a duplicated registry for child classes # - # @return [Boolean] True if the attribute or any child matches the name - # - # @rbs (Attribute attribute, Symbol name) -> bool - def matches_attribute_tree?(attribute, name) - return true if attribute.method_name == name - - attribute.children.any? { |child| matches_attribute_tree?(child, name) } + # @rbs () -> AttributeRegistry + def for_child + duped = definitions.transform_values(&:dup) + self.class.new(duped) end end diff --git a/lib/cmdx/attribute_value.rb b/lib/cmdx/attribute_value.rb deleted file mode 100644 index 92032f0f6..000000000 --- a/lib/cmdx/attribute_value.rb +++ /dev/null @@ -1,252 +0,0 @@ -# frozen_string_literal: true - -module CMDx - # Manages the value lifecycle for a single attribute within a task. - # Handles value sourcing, derivation, coercion, and validation through - # a coordinated pipeline that ensures data integrity and type safety. - class AttributeValue - - extend Forwardable - - # Returns the attribute managed by this value handler. - # - # @return [Attribute] The attribute instance - # - # @example - # attr_value.attribute.name # => :user_id - # - # @rbs @attribute: Attribute - attr_reader :attribute - - def_delegators :attribute, :task, :parent, :name, :options, :types, :source, :method_name, :required?, :optional? - def_delegators :task, :attributes, :errors - - # Creates a new attribute value manager for the given attribute. - # - # @param attribute [Attribute] The attribute to manage values for - # - # @example - # attr = Attribute.new(:user_id, required: true) - # attr_value = AttributeValue.new(attr) - # - # @rbs (Attribute attribute) -> void - def initialize(attribute) - @attribute = attribute - end - - # Retrieves the current value for this attribute from the task's attributes. - # - # @return [Object, nil] The current attribute value or nil if not set - # - # @example - # attr_value.value # => "john_doe" - # - # @rbs () -> untyped - def value - attributes[method_name] - end - - # Generates the attribute value through the complete pipeline: - # sourcing, derivation, coercion, and storage. - # - # @return [Object, nil] The generated value or nil if generation failed - # - # @example - # attr_value.generate # => 42 - # - # @rbs () -> untyped - def generate - return value if attributes.key?(method_name) - - sourced_value = source_value - return if errors.for?(method_name) - - derived_value = derive_value(sourced_value) - return if errors.for?(method_name) - - coerced_value = coerce_value(derived_value) - transformed_value = transform_value(coerced_value) - return if errors.for?(method_name) - - attributes[method_name] = transformed_value - end - - # Validates the current attribute value against configured validators. - # - # @raise [ValidationError] When validation fails (handled internally) - # - # @example - # attr_value.validate - # # Validates value against :presence, :format, etc. - # - # @rbs () -> void - def validate - registry = task.class.settings.validators - - options.slice(*registry.keys).each do |type, opts| - registry.validate(type, task, value, opts) - rescue ValidationError => e - errors.add(method_name, e.message) - nil - end - end - - private - - # Retrieves the source value for this attribute from various sources. - # - # @return [Object, nil] The sourced value or nil if unavailable - # - # @raise [NoMethodError] When the source method doesn't exist - # - # @example - # # Sources from task method, proc, or direct value - # source_value # => "raw_value" - # @rbs () -> untyped - def source_value - sourced_value = - case source - when Symbol then task.send(source) - when Proc then task.instance_eval(&source) - else source.respond_to?(:call) ? source.call(task) : source - end - - if required? && (parent.nil? || parent&.required?) && Utils::Condition.evaluate(task, options) - case sourced_value - when Context then sourced_value.key?(name) - when Hash then sourced_value.key?(name.to_s) || sourced_value.key?(name.to_sym) - when Proc then true # HACK: Cannot be determined so assume it will respond - else sourced_value.respond_to?(name, true) - end || errors.add(method_name, Locale.t("cmdx.attributes.required", method: source)) - end - - sourced_value - rescue NoMethodError - errors.add(method_name, Locale.t("cmdx.attributes.undefined", method: source)) - nil - end - - # Retrieves the default value for this attribute if configured. - # - # @return [Object, nil] The default value or nil if not configured - # - # @example - # # Default can be symbol, proc, or direct value - # -> { rand(100) } # => 23 - # - # @rbs () -> untyped - def default_value - default = options[:default] - - if default.is_a?(Symbol) && task.respond_to?(default, true) - task.send(default) - elsif default.is_a?(Proc) - task.instance_eval(&default) - elsif default.respond_to?(:call) - default.call(task) - else - default - end - end - - # Derives the actual value from the source value using various strategies. - # - # @param source_value [Object] The source value to derive from - # - # @return [Object, nil] The derived value or nil if derivation failed - # - # @raise [NoMethodError] When the derivation method doesn't exist - # - # @example - # # Derives from hash key, method call, or proc execution - # context.user_id # => 42 - # - # @rbs (untyped source_value) -> untyped - def derive_value(source_value) - derived_value = - case source_value - when Context then source_value[name] - when Hash - if source_value.key?(name.to_s) - source_value[name.to_s] - elsif source_value.key?(name.to_sym) - source_value[name.to_sym] - end - when Symbol then source_value.send(name) - when Proc then task.instance_exec(name, &source_value) - else - if source_value.respond_to?(:call) - source_value.call(task, name) - elsif source_value.respond_to?(name, true) - source_value.send(name) - end - end - - derived_value.nil? ? default_value : derived_value - rescue NoMethodError - errors.add(method_name, Locale.t("cmdx.attributes.undefined", method: name)) - nil - end - - # Transforms the derived value using the transform option. - # - # @param derived_value [Object] The value to transform - # - # @return [Object, nil] The transformed value or nil if transformation failed - # - # @example - # :downcase # => "hello" - # - # @rbs (untyped derived_value) -> untyped - def transform_value(derived_value) - transform = options[:transform] - - if transform.is_a?(Symbol) && derived_value.respond_to?(transform, true) - derived_value.send(transform) - elsif transform.respond_to?(:call) - transform.call(derived_value) - else - derived_value - end - end - - # Coerces the derived value to the expected type(s) using the coercion registry. - # - # @param transformed_value [Object] The value to coerce - # - # @return [Object, nil] The coerced value or nil if coercion failed - # - # @raise [CoercionError] When coercion fails (handled internally) - # - # @example - # # Coerces "42" to Integer, "true" to Boolean, etc. - # coerce_value("42") # => 42 - # - # @rbs (untyped transformed_value) -> untyped - def coerce_value(transformed_value) - return transformed_value if types.empty? - - registry = task.class.settings.coercions - last_idx = types.size - 1 - - types.find.with_index do |type, i| - break registry.coerce(type, task, transformed_value, options) - rescue CoercionError => e - next if i != last_idx - - unless optional? && transformed_value.nil? - message = - if last_idx.zero? - e.message - else - tl = types.map { |t| Locale.t("cmdx.types.#{t}") }.join(", ") - Locale.t("cmdx.coercions.into_any", types: tl) - end - - errors.add(method_name, message) - end - end - end - - end -end diff --git a/lib/cmdx/callback_registry.rb b/lib/cmdx/callback_registry.rb index 1724eab5e..2d80e24b7 100644 --- a/lib/cmdx/callback_registry.rb +++ b/lib/cmdx/callback_registry.rb @@ -1,168 +1,92 @@ # frozen_string_literal: true module CMDx - # Registry for managing callbacks that can be executed at various points during task execution. - # - # Callbacks are organized by type and can be registered with optional conditions and options. - # Each callback type represents a specific execution phase or outcome. - # - # Supports copy-on-write semantics: a duped registry shares the parent's - # data until a write operation triggers materialization. + # Registry of lifecycle callbacks organized by type. + # Uses copy-on-write for safe inheritance across task classes. class CallbackRegistry - extend Forwardable - # @rbs TYPES: Array[Symbol] TYPES = %i[ before_validation before_execution - on_complete - on_interrupted - on_executed on_success on_skipped on_failed + on_complete + on_interrupted + on_executed on_good on_bad ].freeze - # @rbs TYPES_SET: Set[Symbol] - TYPES_SET = TYPES.to_set.freeze - private_constant :TYPES_SET + # @rbs @callbacks: Hash[Symbol, Array[untyped]] + attr_reader :callbacks - def_delegators :registry, :empty? - - # @param registry [Hash, nil] Initial registry hash, defaults to empty - # - # @rbs (?Hash[Symbol, Set[Array[untyped]]]? registry) -> void - def initialize(registry = nil) - @registry = registry || {} + # @rbs (?Hash[Symbol, Array[untyped]]? callbacks) -> void + def initialize(callbacks = nil) + @callbacks = callbacks || TYPES.to_h { |t| [t, []] } end - # Sets up copy-on-write state when duplicated via dup. + # Registers a callback for a lifecycle type. # - # @param source [CallbackRegistry] The registry being duplicated + # @param type [Symbol] the callback type + # @param callable [Symbol, Proc, Object] the callable + # @param options [Hash] condition options (:if, :unless) # - # @rbs (CallbackRegistry source) -> void - def initialize_dup(source) - @parent = source - @registry = nil - super + # @rbs (Symbol type, untyped callable, **untyped options) -> void + def register(type, callable, **options) + callbacks[type] << { callable:, **options } end - # Returns the internal registry of callbacks organized by type. - # Delegates to the parent registry when not yet materialized. + # Returns all registered callbacks for a type. # - # @return [Hash{Symbol => Set}] Hash mapping callback types to their registered callables + # @param type [Symbol] the callback type # - # @example - # registry.registry # => { before_execution: # } + # @return [Array] callback entries # - # @rbs () -> Hash[Symbol, Set[Array[untyped]]] - def registry - @registry || @parent.registry + # @rbs (Symbol type) -> Array[Hash[Symbol, untyped]] + def for_type(type) + callbacks.fetch(type, EMPTY_ARRAY) end - alias to_h registry - # Registers one or more callables for a specific callback type - # - # @param type [Symbol] The callback type from TYPES - # @param callables [Array<#call>] Callable objects to register - # @param options [Hash] Options to pass to the callback - # @param block [Proc] Optional block to register as a callable - # @option options [Hash] :if Condition hash for conditional execution - # @option options [Hash] :unless Inverse condition hash for conditional execution + # Invokes all callbacks for a type. # - # @return [CallbackRegistry] self for method chaining + # @param type [Symbol] the callback type + # @param task [Task] the task instance + # @param result [Result] the result instance # - # @raise [ArgumentError] When type is not a valid callback type - # - # @example Register a method callback - # registry.register(:before_execution, :validate_inputs) - # @example Register a block with conditions - # registry.register(:on_success, if: { status: :completed }) do |task| - # task.log("Success callback executed") - # end - # - # @rbs (Symbol type, *untyped callables, **untyped options) ?{ (Task) -> void } -> self - def register(type, *callables, **options, &block) - materialize! - - callables << block if block_given? + # @rbs (Symbol type, untyped task, untyped result) -> void + def invoke(type, task, result) + for_type(type).each do |entry| + next unless evaluate_conditions(entry, task) - @registry[type] ||= Set.new - @registry[type] << [callables, options] - self + Utils::Call.invoke_callback(entry[:callable], task, result) + end end - # Removes one or more callables for a specific callback type - # - # @param type [Symbol] The callback type from TYPES - # @param callables [Array<#call>] Callable objects to remove - # @param options [Hash] Options that were used during registration - # @param block [Proc] Optional block to remove - # @option options [Object] :* Any option key-value pairs - # - # @return [CallbackRegistry] self for method chaining - # - # @example Remove a specific callback - # registry.deregister(:before_execution, :validate_inputs) + # @return [Boolean] true if any callbacks are registered # - # @rbs (Symbol type, *untyped callables, **untyped options) ?{ (Task) -> void } -> self - def deregister(type, *callables, **options, &block) - materialize! - - callables << block if block_given? - return self unless @registry[type] - - @registry[type].delete([callables, options]) - @registry.delete(type) if @registry[type].empty? - self + # @rbs () -> bool + def any? + callbacks.any? { |_type, list| !list.empty? } end - # Invokes all registered callbacks for a given type - # - # @param type [Symbol] The callback type to invoke - # @param task [Task] The task instance to pass to callbacks + # @return [CallbackRegistry] a duplicated registry for child classes # - # @raise [TypeError] When type is not a valid callback type - # - # @example Invoke all before_execution callbacks - # registry.invoke(:before_execution, task) - # - # @rbs (Symbol type, Task task) -> void - def invoke(type, task) - raise TypeError, "unknown callback type #{type.inspect}" unless TYPES_SET.include?(type) - return unless registry[type] - - registry[type].each do |callables, options| - next unless Utils::Condition.evaluate(task, options) - - Utils::Wrap.array(callables).each do |callable| - if callable.is_a?(Symbol) - task.send(callable) - elsif callable.is_a?(Proc) - task.instance_exec(&callable) - elsif callable.respond_to?(:call) - callable.call(task) - else - raise "cannot invoke #{callable}" - end - end - end + # @rbs () -> CallbackRegistry + def for_child + duped = callbacks.transform_values(&:dup) + self.class.new(duped) end private - # Copies the parent's registry data into this instance, - # severing the copy-on-write link. - # - # @rbs () -> void - def materialize! - return if @registry + # @rbs (Hash[Symbol, untyped] entry, untyped task) -> bool + def evaluate_conditions(entry, task) # rubocop:disable Naming/PredicateMethod + return false if entry[:if] && !Utils::Condition.truthy?(entry[:if], task) + return false if entry[:unless] && !Utils::Condition.falsy?(entry[:unless], task) - @registry = @parent.registry.transform_values(&:dup) - @parent = nil + true end end diff --git a/lib/cmdx/chain.rb b/lib/cmdx/chain.rb index 9f5016f5e..2d05603a2 100644 --- a/lib/cmdx/chain.rb +++ b/lib/cmdx/chain.rb @@ -1,44 +1,25 @@ # frozen_string_literal: true module CMDx - # Manages a collection of task execution results in a thread and fiber safe manner. - # Chains provide a way to track related task executions and their outcomes - # within the same execution context. + # Thread and fiber safe collection of task execution results. + # Tracks related task executions within the same execution context + # using Fiber.storage (Ruby 3.2+) with Thread.current fallback. class Chain - extend Forwardable - # @rbs CONCURRENCY_KEY: Symbol CONCURRENCY_KEY = :cmdx_chain - # Returns the unique identifier for this chain. - # - # @return [String] The chain identifier - # - # @example - # chain.id # => "abc123xyz" + # @return [String] unique chain identifier # # @rbs @id: String attr_reader :id - # Returns the collection of execution results in this chain. - # - # @return [Array] Array of task results - # - # @example - # chain.results # => [#, #] + # @return [Array] ordered execution results # # @rbs @results: Array[Result] attr_reader :results - def_delegators :results, :first, :last, :size - def_delegators :first, :state, :status, :outcome - - # Creates a new chain with a unique identifier and empty results collection. - # - # @return [Chain] A new chain instance - # - # @rbs () -> void + # @rbs (?dry_run: bool) -> void def initialize(dry_run: false) @mutex = Mutex.new @id = Identifier.generate @@ -48,65 +29,32 @@ def initialize(dry_run: false) class << self - # Retrieves the current chain for the current execution context. - # - # @return [Chain, nil] The current chain or nil if none exists - # - # @example - # chain = Chain.current - # if chain - # puts "Current chain: #{chain.id}" - # end + # @return [Chain, nil] current chain for this execution context # # @rbs () -> Chain? def current thread_or_fiber[CONCURRENCY_KEY] end - # Sets the current chain for the current execution context. - # - # @param chain [Chain] The chain to set as current - # - # @return [Chain] The set chain - # - # @example - # Chain.current = my_chain - # # @rbs (Chain chain) -> Chain def current=(chain) thread_or_fiber[CONCURRENCY_KEY] = chain end - # Clears the current chain for the current execution context. - # - # @return [nil] Always returns nil - # - # @example - # Chain.clear - # # @rbs () -> nil def clear thread_or_fiber[CONCURRENCY_KEY] = nil end # Builds or extends the current chain by adding a result. - # Creates a new chain if none exists, otherwise appends to the current one. - # - # @param result [Result] The task execution result to add # - # @return [Chain] The current chain (newly created or existing) + # @param result [Result] the result to add + # @param dry_run [Boolean] whether this is a dry run # - # @raise [TypeError] If result is not a CMDx::Result instance + # @return [Chain] the current chain # - # @example - # result = task.execute - # chain = Chain.build(result) - # puts "Chain size: #{chain.size}" - # - # @rbs (Result result) -> Chain + # @rbs (Result result, ?dry_run: bool) -> Chain def build(result, dry_run: false) - raise TypeError, "must be a CMDx::Result" unless result.is_a?(Result) - self.current ||= new(dry_run:) current.push(result) current @@ -114,10 +62,6 @@ def build(result, dry_run: false) private - # Returns the thread or fiber storage for the current execution context. - # - # @return [Hash] The thread or fiber storage - # # @rbs () -> Hash if Fiber.respond_to?(:storage) def thread_or_fiber = Fiber.storage @@ -127,86 +71,59 @@ def thread_or_fiber = Thread.current end - # Thread-safe append of a result to the chain. - # Caches the result's index to avoid repeated O(n) lookups. + # Thread-safe append. # - # @param result [Result] The result to append + # @param result [Result] the result to append # - # @return [Array] The updated results array + # @return [Array] # # @rbs (Result result) -> Array[Result] def push(result) - @mutex.synchronize do - result.instance_variable_set(:@chain_index, @results.size) - @results << result - end + @mutex.synchronize { @results << result } + end + + # Returns the next index for a result being added. + # + # @return [Integer] + # + # @rbs () -> Integer + def next_index + @mutex.synchronize { @results.size } end - # Thread-safe lookup of a result's position in the chain. + # Thread-safe index lookup. # - # @param result [Result] The result to find + # @param result [Result] the result to find # - # @return [Integer, nil] The zero-based index or nil if not found + # @return [Integer, nil] zero-based index # # @rbs (Result result) -> Integer? def index(result) @mutex.synchronize { @results.index(result) } end - # Returns whether the chain is running in dry-run mode. - # - # @return [Boolean] Whether the chain is running in dry-run mode - # - # @example - # chain.dry_run? # => true + # @return [Boolean] # # @rbs () -> bool def dry_run? !!@dry_run end - # Freezes the chain and its internal results to prevent modifications. - # - # @return [Chain] the frozen chain - # - # @example - # chain.freeze - # chain.results << result # => raises FrozenError - # + def first = results.first + def last = results.last + def size = results.size + # @rbs () -> self def freeze results.freeze super end - # Converts the chain to a hash representation. - # - # @option return [String] :id The chain identifier - # @option return [Array] :results Array of result hashes - # - # @return [Hash] Hash containing chain id and serialized results - # - # @example - # chain_hash = chain.to_h - # puts chain_hash[:id] - # puts chain_hash[:results].size - # # @rbs () -> Hash[Symbol, untyped] def to_h - { - id:, - dry_run: dry_run?, - results: results.map(&:to_h) - } + { id:, dry_run: dry_run?, results: results.map(&:to_h) } end - # Converts the chain to a string representation. - # - # @return [String] Formatted string representation of the chain - # - # @example - # puts chain.to_s - # # @rbs () -> String def to_s Utils::Format.to_str(to_h) diff --git a/lib/cmdx/coercion_registry.rb b/lib/cmdx/coercion_registry.rb index 8affbf9b3..aed97f6eb 100644 --- a/lib/cmdx/coercion_registry.rb +++ b/lib/cmdx/coercion_registry.rb @@ -1,137 +1,75 @@ # frozen_string_literal: true module CMDx - # Registry for managing type coercion handlers. - # - # Provides a centralized way to register, deregister, and execute type coercions - # for various data types including arrays, numbers, dates, and other primitives. - # - # Supports copy-on-write semantics: a duped registry shares the parent's - # data until a write operation triggers materialization. + # Registry of coercion types mapping symbols to coercion classes. + # Uses copy-on-write for safe inheritance across task classes. class CoercionRegistry - # Initialize a new coercion registry. - # - # @param registry [Hash{Symbol => Class}, nil] optional initial registry hash - # - # @example - # registry = CoercionRegistry.new - # registry = CoercionRegistry.new(custom: CustomCoercion) - # - # @rbs (?Hash[Symbol, Class]? registry) -> void - def initialize(registry = nil) - @registry = registry || { - array: Coercions::Array, - big_decimal: Coercions::BigDecimal, - boolean: Coercions::Boolean, - complex: Coercions::Complex, - date: Coercions::Date, - datetime: Coercions::DateTime, - float: Coercions::Float, - hash: Coercions::Hash, - integer: Coercions::Integer, - rational: Coercions::Rational, - string: Coercions::String, - time: Coercions::Time - } - end + # @rbs BUILT_INS: Hash[Symbol, String] + BUILT_INS = { + array: "Coercions::Array", + big_decimal: "Coercions::BigDecimal", + boolean: "Coercions::Boolean", + complex: "Coercions::Complex", + date: "Coercions::Date", + date_time: "Coercions::DateTime", + float: "Coercions::Float", + hash: "Coercions::Hash", + integer: "Coercions::Integer", + rational: "Coercions::Rational", + string: "Coercions::String", + symbol: "Coercions::Symbol", + time: "Coercions::Time" + }.freeze - # Sets up copy-on-write state when duplicated via dup. - # - # @param source [CoercionRegistry] The registry being duplicated - # - # @rbs (CoercionRegistry source) -> void - def initialize_dup(source) - @parent = source - @registry = nil - super - end + # @rbs @registry: Hash[Symbol, untyped] + attr_reader :registry - # Returns the internal registry mapping coercion types to handler classes. - # Delegates to the parent registry when not yet materialized. - # - # @return [Hash{Symbol => Class}] Hash of coercion type names to coercion classes - # - # @example - # registry.registry # => { integer: Coercions::Integer, boolean: Coercions::Boolean } - # - # @rbs () -> Hash[Symbol, Class] - def registry - @registry || @parent.registry + # @rbs (?Hash[Symbol, untyped]? registry) -> void + def initialize(registry = nil) + @registry = registry || {} end - alias to_h registry - # Register a new coercion handler for a type. - # - # @param name [Symbol, String] the type name to register - # @param coercion [Class] the coercion class to handle this type + # Returns the coercion class for the given type. # - # @return [CoercionRegistry] self for method chaining + # @param type [Symbol, Class] the coercion type # - # @example - # registry.register(:custom_type, CustomCoercion) - # registry.register("another_type", AnotherCoercion) + # @return [Class] the coercion class + # @raise [UnknownCoercionError] when the type is unknown # - # @rbs ((Symbol | String) name, Class coercion) -> self - def register(name, coercion) - materialize! + # @rbs (untyped type) -> untyped + def resolve(type) + return type if type.is_a?(Class) || type.is_a?(Module) - @registry[name.to_sym] = coercion - self + name = type.to_sym + registry[name] || resolve_built_in(name) end - # Remove a coercion handler for a type. - # - # @param name [Symbol, String] the type name to deregister + # Registers a custom coercion type. # - # @return [CoercionRegistry] self for method chaining + # @param name [Symbol] the type name + # @param klass [Class] the coercion class # - # @example - # registry.deregister(:custom_type) - # registry.deregister("another_type") - # - # @rbs ((Symbol | String) name) -> self - def deregister(name) - materialize! - - @registry.delete(name.to_sym) - self + # @rbs (Symbol name, untyped klass) -> void + def register(name, klass) + registry[name.to_sym] = klass end - # Coerce a value to the specified type using the registered handler. - # - # @param type [Symbol] the type to coerce to - # @param task [Object] the task context for the coercion - # @param value [Object] the value to coerce - # @param options [Hash] additional options for the coercion - # @option options [Object] :* Any coercion option key-value pairs + # @return [CoercionRegistry] a duplicated registry for child classes # - # @return [Object] the coerced value - # - # @raise [TypeError] when the type is not registered - # - # @example - # result = registry.coerce(:integer, task, "42") - # result = registry.coerce(:boolean, task, "true", strict: true) - # - # @rbs (Symbol type, untyped task, untyped value, ?Hash[Symbol, untyped] options) -> untyped - def coerce(type, task, value, options = EMPTY_HASH) - raise TypeError, "unknown coercion type #{type.inspect}" unless registry.key?(type) - - Utils::Call.invoke(task, registry[type], value, options) + # @rbs () -> CoercionRegistry + def for_child + self.class.new(registry.dup) end private - # Copies the parent's registry data into this instance, - # severing the copy-on-write link. - # - # @rbs () -> void - def materialize! - return if @registry + # @rbs (Symbol name) -> untyped + def resolve_built_in(name) + const_name = BUILT_INS[name] + raise UnknownCoercionError, Locale.t("cmdx.coercions.unknown", type: name) unless const_name - @registry = @parent.registry.dup - @parent = nil + CMDx.const_get(const_name) end end diff --git a/lib/cmdx/coercions/array.rb b/lib/cmdx/coercions/array.rb index 805cc5f65..c9fb340e2 100644 --- a/lib/cmdx/coercions/array.rb +++ b/lib/cmdx/coercions/array.rb @@ -2,46 +2,26 @@ module CMDx module Coercions - # Converts various input types to Array format - # - # Handles conversion from strings that look like JSON arrays and other - # values that can be wrapped in an array using Ruby's Array() method. + # Coerces a value into an Array. module Array - extend self - - # Converts a value to an Array - # - # @param value [Object] The value to convert to an array - # @param options [Hash] Optional configuration parameters (currently unused) - # @option options [Object] :unused Currently no options are used - # - # @return [Array] The converted array value - # - # @raise [CoercionError] If the value cannot be converted to an array + # @param value [Object] + # @return [Array] # - # @example Convert a JSON-like string to an array - # Array.call("[1, 2, 3]") # => [1, 2, 3] - # @example Convert other values using Array() - # Array.call("hello") # => ["hello"] - # Array.call(42) # => [42] - # Array.call(nil) # => [] - # @example Handle invalid JSON-like strings - # Array.call("[not json") # => raises CoercionError - # - # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> Array[untyped] - def call(value, options = EMPTY_HASH) - if value.is_a?(::String) && ( - value.start_with?("[") || - value.strip == "null" - ) - JSON.parse(value) || [] - else - Utils::Wrap.array(value) + # @rbs (untyped value) -> Array[untyped] + def self.call(value) + case value + when ::Array then value + when ::Hash then value.to_a + when nil then [] + else Kernel.Array(value) end - rescue JSON::ParserError - type = Locale.t("cmdx.types.array") - raise CoercionError, Locale.t("cmdx.coercions.into_an", type:) + rescue StandardError + raise_coercion_error!("array") + end + + def self.raise_coercion_error!(type) + raise Error, Locale.t("cmdx.coercions.into_an", type:) end end diff --git a/lib/cmdx/coercions/big_decimal.rb b/lib/cmdx/coercions/big_decimal.rb index 2fa28615c..4ab64822f 100644 --- a/lib/cmdx/coercions/big_decimal.rb +++ b/lib/cmdx/coercions/big_decimal.rb @@ -2,40 +2,25 @@ module CMDx module Coercions - # Converts various input types to BigDecimal format - # - # Handles conversion from numeric strings, integers, floats, and other - # values that can be converted to BigDecimal using Ruby's BigDecimal() method. + # Coerces a value into a BigDecimal. module BigDecimal - extend self - - # @rbs DEFAULT_PRECISION: Integer - DEFAULT_PRECISION = 14 - - # Converts a value to a BigDecimal - # - # @param value [Object] The value to convert to BigDecimal - # @param options [Hash] Optional configuration parameters - # @option options [Integer] :precision The precision to use (defaults to DEFAULT_PRECISION) - # - # @return [BigDecimal] The converted BigDecimal value - # - # @raise [CoercionError] If the value cannot be converted to BigDecimal - # - # @example Convert numeric strings to BigDecimal - # BigDecimal.call("123.45") # => # - # BigDecimal.call("0.001", precision: 6) # => # - # @example Convert other numeric types - # BigDecimal.call(42) # => # - # BigDecimal.call(3.14159) # => # + # @param value [Object] + # @return [BigDecimal] # - # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> BigDecimal - def call(value, options = EMPTY_HASH) - BigDecimal(value, options[:precision] || DEFAULT_PRECISION) - rescue ArgumentError, TypeError - type = Locale.t("cmdx.types.big_decimal") - raise CoercionError, Locale.t("cmdx.coercions.into_a", type:) + # @rbs (untyped value) -> BigDecimal + def self.call(value) + case value + when ::BigDecimal then value + when ::Float, ::Integer, ::Rational + Kernel.BigDecimal(value, 0) + when ::String + Kernel.BigDecimal(value) + else + Kernel.BigDecimal(value.to_s) + end + rescue StandardError + raise Error, Locale.t("cmdx.coercions.into_a", type: "big decimal") end end diff --git a/lib/cmdx/coercions/boolean.rb b/lib/cmdx/coercions/boolean.rb index 9901b2af7..5b65b5d2b 100644 --- a/lib/cmdx/coercions/boolean.rb +++ b/lib/cmdx/coercions/boolean.rb @@ -2,55 +2,24 @@ module CMDx module Coercions - # Converts various input types to Boolean format - # - # Handles conversion from strings, numbers, and other values to boolean - # using predefined truthy and falsey patterns. + # Coerces a value into a Boolean (true/false). module Boolean - extend self + # @rbs TRUTHY: Array[untyped] + TRUTHY = [true, 1, "1", "true", "yes", "on", "t", "y"].freeze - # @rbs FALSEY: Regexp - FALSEY = /\A(false|f|no|n|0)\z/i + # @rbs FALSY: Array[untyped] + FALSY = [false, 0, "0", "false", "no", "off", "f", "n", nil].freeze - # @rbs TRUTHY: Regexp - TRUTHY = /\A(true|t|yes|y|1)\z/i - - # Converts a value to a Boolean - # - # @param value [Object] The value to convert to boolean - # @param options [Hash] Optional configuration parameters (currently unused) - # @option options [Object] :unused Currently no options are used - # - # @return [Boolean] The converted boolean value + # @param value [Object] + # @return [Boolean] # - # @raise [CoercionError] If the value cannot be converted to boolean - # - # @example Convert truthy strings to true - # Boolean.call("true") # => true - # Boolean.call("yes") # => true - # Boolean.call("1") # => true - # @example Convert falsey strings to false - # Boolean.call("false") # => false - # Boolean.call("no") # => false - # Boolean.call("0") # => false - # Boolean.call(nil) # => false - # Boolean.call("") # => false - # @example Handle case-insensitive input - # Boolean.call("TRUE") # => true - # Boolean.call("False") # => false - # @example Handle edge cases - # Boolean.call("abc") # => raises CoercionError - # - # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> bool - def call(value, options = EMPTY_HASH) - case value.to_s - when FALSEY, EMPTY_STRING then false - when TRUTHY then true - else - type = Locale.t("cmdx.types.boolean") - raise CoercionError, Locale.t("cmdx.coercions.into_a", type:) - end + # @rbs (untyped value) -> bool + def self.call(value) + return true if TRUTHY.include?(value) + return false if FALSY.include?(value) + + !!value end end diff --git a/lib/cmdx/coercions/complex.rb b/lib/cmdx/coercions/complex.rb index ddceafa36..0689dd3a7 100644 --- a/lib/cmdx/coercions/complex.rb +++ b/lib/cmdx/coercions/complex.rb @@ -2,38 +2,20 @@ module CMDx module Coercions - # Converts various input types to Complex number format - # - # Handles conversion from numeric strings, integers, floats, and other - # values that can be converted to Complex using Ruby's Complex() method. + # Coerces a value into a Complex number. module Complex - extend self - - # Converts a value to a Complex number - # - # @param value [Object] The value to convert to Complex - # @param options [Hash] Optional configuration parameters (currently unused) - # @option options [Object] :* Any configuration option (unused) - # - # @return [Complex] The converted Complex number value - # - # @raise [CoercionError] If the value cannot be converted to Complex - # - # @example Convert numeric strings to Complex - # Complex.call("3+4i") # => (3+4i) - # Complex.call("2.5") # => (2.5+0i) - # @example Convert other numeric types - # Complex.call(5) # => (5+0i) - # Complex.call(3.14) # => (3.14+0i) - # Complex.call(Complex(1, 2)) # => (1+2i) + # @param value [Object] + # @return [Complex] # - # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> Complex - def call(value, options = EMPTY_HASH) - Complex(value) - rescue ArgumentError, TypeError - type = Locale.t("cmdx.types.complex") - raise CoercionError, Locale.t("cmdx.coercions.into_a", type:) + # @rbs (untyped value) -> Complex + def self.call(value) + case value + when ::Complex then value + else Kernel.Complex(value) + end + rescue StandardError + raise Error, Locale.t("cmdx.coercions.into_a", type: "complex") end end diff --git a/lib/cmdx/coercions/date.rb b/lib/cmdx/coercions/date.rb index 691bee6f9..19a923950 100644 --- a/lib/cmdx/coercions/date.rb +++ b/lib/cmdx/coercions/date.rb @@ -1,45 +1,26 @@ # frozen_string_literal: true +require "date" + module CMDx module Coercions - # Converts various input types to Date format - # - # Handles conversion from strings, Date objects, DateTime objects, Time objects, - # and other date-like values to Date objects using Ruby's built-in parsing - # capabilities and optional custom format parsing. + # Coerces a value into a Date. module Date - extend self - - # Converts a value to a Date object - # - # @param value [Object] The value to convert to a Date - # @param options [Hash] Optional configuration parameters - # @option options [String] :strptime Custom date format string for parsing - # - # @return [Date] The converted Date object + # @param value [Object] + # @return [Date] # - # @raise [CoercionError] If the value cannot be converted to a Date - # - # @example Convert string to Date using default parsing - # Date.call("2023-12-25") # => # - # Date.call("Dec 25, 2023") # => # - # @example Convert string using custom format - # Date.call("25/12/2023", strptime: "%d/%m/%Y") # => # - # Date.call("12-25-2023", strptime: "%m-%d-%Y") # => # - # @example Return existing Date objects unchanged - # Date.call(Date.new(2023, 12, 25)) # => # - # Date.call(DateTime.new(2023, 12, 25)) # => # - # - # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> Date - def call(value, options = EMPTY_HASH) - return value.to_date if value.respond_to?(:to_date) - return ::Date.strptime(value, options[:strptime]) if options[:strptime] - - ::Date.parse(value) - rescue TypeError, ::Date::Error - type = Locale.t("cmdx.types.date") - raise CoercionError, Locale.t("cmdx.coercions.into_a", type:) + # @rbs (untyped value) -> Date + def self.call(value) + case value + when ::Date then value + when ::Time, ::DateTime then value.to_date + when ::String then ::Date.parse(value) + when ::Integer, ::Float then ::Time.at(value).to_date + else ::Date.parse(value.to_s) + end + rescue StandardError + raise Error, Locale.t("cmdx.coercions.into_a", type: "date") end end diff --git a/lib/cmdx/coercions/date_time.rb b/lib/cmdx/coercions/date_time.rb index 4211a5c13..7169e87d5 100644 --- a/lib/cmdx/coercions/date_time.rb +++ b/lib/cmdx/coercions/date_time.rb @@ -1,45 +1,26 @@ # frozen_string_literal: true +require "date" + module CMDx module Coercions - # Converts various input types to DateTime format - # - # Handles conversion from date strings, Date objects, Time objects, and other - # values that can be converted to DateTime using Ruby's DateTime.parse method - # or custom strptime formats. + # Coerces a value into a DateTime. module DateTime - extend self - - # Converts a value to a DateTime - # - # @param value [Object] The value to convert to DateTime - # @param options [Hash] Optional configuration parameters - # @option options [String] :strptime Custom date format string for parsing - # - # @return [DateTime] The converted DateTime value + # @param value [Object] + # @return [DateTime] # - # @raise [CoercionError] If the value cannot be converted to DateTime - # - # @example Convert date strings to DateTime - # DateTime.call("2023-12-25") # => # - # DateTime.call("Dec 25, 2023") # => # - # @example Convert with custom strptime format - # DateTime.call("25/12/2023", strptime: "%d/%m/%Y") - # # => # - # @example Convert existing date objects - # DateTime.call(Date.new(2023, 12, 25)) # => # - # DateTime.call(Time.new(2023, 12, 25)) # => # - # - # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> DateTime - def call(value, options = EMPTY_HASH) - return value.to_datetime if value.respond_to?(:to_datetime) - return ::DateTime.strptime(value, options[:strptime]) if options[:strptime] - - ::DateTime.parse(value) - rescue TypeError, ::Date::Error - type = Locale.t("cmdx.types.date_time") - raise CoercionError, Locale.t("cmdx.coercions.into_a", type:) + # @rbs (untyped value) -> DateTime + def self.call(value) + case value + when ::DateTime then value + when ::Date, ::Time then value.to_datetime + when ::String then ::DateTime.parse(value) + when ::Integer, ::Float then ::Time.at(value).to_datetime + else ::DateTime.parse(value.to_s) + end + rescue StandardError + raise Error, Locale.t("cmdx.coercions.into_a", type: "date time") end end diff --git a/lib/cmdx/coercions/float.rb b/lib/cmdx/coercions/float.rb index d2cda10db..625ca5c10 100644 --- a/lib/cmdx/coercions/float.rb +++ b/lib/cmdx/coercions/float.rb @@ -2,41 +2,17 @@ module CMDx module Coercions - # Converts various input types to Float format - # - # Handles conversion from numeric strings, integers, and other numeric types - # that can be converted to floats using Ruby's Float() method. + # Coerces a value into a Float. module Float - extend self - - # Converts a value to a Float - # - # @param value [Object] The value to convert to a float - # @param options [Hash] Optional configuration parameters (currently unused) - # @option options [Object] :unused Currently no options are used - # - # @return [Float] The converted float value - # - # @raise [CoercionError] If the value cannot be converted to a float - # - # @example Convert numeric strings to float - # Float.call("123") # => 123.0 - # Float.call("123.456") # => 123.456 - # Float.call("-42.5") # => -42.5 - # Float.call("1.23e4") # => 12300.0 - # @example Convert numeric types to float - # Float.call(42) # => 42.0 - # Float.call(BigDecimal("123.456")) # => 123.456 - # Float.call(Rational(3, 4)) # => 0.75 - # Float.call(Complex(5.0, 0)) # => 5.0 + # @param value [Object] + # @return [Float] # - # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> Float - def call(value, options = EMPTY_HASH) - Float(value) - rescue ArgumentError, RangeError, TypeError - type = Locale.t("cmdx.types.float") - raise CoercionError, Locale.t("cmdx.coercions.into_a", type:) + # @rbs (untyped value) -> Float + def self.call(value) + Kernel.Float(value) + rescue StandardError + raise Error, Locale.t("cmdx.coercions.into_a", type: "float") end end diff --git a/lib/cmdx/coercions/hash.rb b/lib/cmdx/coercions/hash.rb index baa553928..af4c00f64 100644 --- a/lib/cmdx/coercions/hash.rb +++ b/lib/cmdx/coercions/hash.rb @@ -2,66 +2,24 @@ module CMDx module Coercions - # Coerces various input types into Hash objects - # - # Supports conversion from: - # - Nil values (converted to empty Hash) - # - Hash objects (returned as-is) - # - Array objects (converted using Hash[*array]) - # - JSON strings starting with "{" (parsed into Hash) - # - JSON strings that are "null" (parsed into empty Hash) - # - Other types raise CoercionError + # Coerces a value into a Hash. module Hash - extend self - - # Coerces a value into a Hash - # - # @param value [Object] The value to coerce - # @param options [Hash] Additional options (currently unused) - # @option options [Symbol] :strict Whether to enforce strict conversion - # - # @return [Hash] The coerced hash value - # - # @raise [CoercionError] When the value cannot be coerced to a Hash - # - # @example Coerce from existing Hash - # Hash.call({a: 1, b: 2}) # => {a: 1, b: 2} - # @example Coerce from Array - # Hash.call([:a, 1, :b, 2]) # => {a: 1, b: 2} - # @example Coerce from JSON string - # Hash.call('{"key": "value"}') # => {"key" => "value"} + # @param value [Object] + # @return [Hash] # - # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> Hash[untyped, untyped] - def call(value, options = EMPTY_HASH) - if value.nil? - {} - elsif value.is_a?(::Hash) - value - elsif value.is_a?(::Array) - ::Hash[*value] - elsif value.is_a?(::String) && ( - value.start_with?("{") || - value.strip == "null" - ) - JSON.parse(value) || {} - elsif value.respond_to?(:to_h) - value.to_h + # @rbs (untyped value) -> Hash[untyped, untyped] + def self.call(value) + case value + when ::Hash then value + when ::Array then value.to_h else - raise_coercion_error! - end - rescue ArgumentError, TypeError, JSON::ParserError - raise_coercion_error! - end - - private + return value.to_h if value.respond_to?(:to_h) - # Raises a CoercionError with localized message - # - # @raise [CoercionError] Always raised with localized error message - def raise_coercion_error! - type = Locale.t("cmdx.types.hash") - raise CoercionError, Locale.t("cmdx.coercions.into_a", type:) + raise Error, Locale.t("cmdx.coercions.into_a", type: "hash") + end + rescue StandardError + raise Error, Locale.t("cmdx.coercions.into_a", type: "hash") end end diff --git a/lib/cmdx/coercions/integer.rb b/lib/cmdx/coercions/integer.rb index 287fc4851..a5ad0c73b 100644 --- a/lib/cmdx/coercions/integer.rb +++ b/lib/cmdx/coercions/integer.rb @@ -2,44 +2,17 @@ module CMDx module Coercions - # Converts various input types to Integer format - # - # Handles conversion from strings, numbers, and other values to integers - # using Ruby's Integer() method. Raises CoercionError for values that - # cannot be converted to integers. + # Coerces a value into an Integer. module Integer - extend self - - # Converts a value to an Integer - # - # @param value [Object] The value to convert to an integer - # @param options [Hash] Optional configuration parameters (currently unused) - # @option options [Object] :unused Currently no options are used - # - # @return [Integer] The converted integer value - # - # @raise [CoercionError] If the value cannot be converted to an integer - # - # @example Convert numeric strings to integers - # Integer.call("42") # => 42 - # Integer.call("-123") # => -123 - # Integer.call("0") # => 0 - # @example Convert numeric types to integers - # Integer.call(42.0) # => 42 - # Integer.call(3.14) # => 3 - # Integer.call(0.0) # => 0 - # @example Handle edge cases - # Integer.call("") # => raises CoercionError - # Integer.call(nil) # => raises CoercionError - # Integer.call("abc") # => raises CoercionError + # @param value [Object] + # @return [Integer] # - # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> Integer - def call(value, options = EMPTY_HASH) - Integer(value) - rescue ArgumentError, FloatDomainError, RangeError, TypeError - type = Locale.t("cmdx.types.integer") - raise CoercionError, Locale.t("cmdx.coercions.into_an", type:) + # @rbs (untyped value) -> Integer + def self.call(value) + Kernel.Integer(value) + rescue StandardError + raise Error, Locale.t("cmdx.coercions.into_an", type: "integer") end end diff --git a/lib/cmdx/coercions/rational.rb b/lib/cmdx/coercions/rational.rb index 42256d106..ac6b00d98 100644 --- a/lib/cmdx/coercions/rational.rb +++ b/lib/cmdx/coercions/rational.rb @@ -2,44 +2,20 @@ module CMDx module Coercions - # Converts various input types to Rational format - # - # Handles conversion from strings, numbers, and other values to rational - # numbers using Ruby's Rational() method. Raises CoercionError for values - # that cannot be converted to rational numbers. + # Coerces a value into a Rational. module Rational - extend self - - # Converts a value to a Rational - # - # @param value [Object] The value to convert to a rational number - # @param options [Hash] Optional configuration parameters (currently unused) - # @option options [Object] :unused Currently no options are used - # - # @return [Rational] The converted rational number - # - # @raise [CoercionError] If the value cannot be converted to a rational number - # - # @example Convert numeric strings to rational numbers - # Rational.call("3/4") # => (3/4) - # Rational.call("2.5") # => (5/2) - # Rational.call("0") # => (0/1) - # @example Convert numeric types to rational numbers - # Rational.call(3.14) # => (157/50) - # Rational.call(2) # => (2/1) - # Rational.call(0.5) # => (1/2) - # @example Handle edge cases - # Rational.call("") # => (0/1) - # Rational.call(nil) # => (0/1) - # Rational.call(0) # => (0/1) + # @param value [Object] + # @return [Rational] # - # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> Rational - def call(value, options = EMPTY_HASH) - Rational(value) - rescue ArgumentError, FloatDomainError, RangeError, TypeError, ZeroDivisionError - type = Locale.t("cmdx.types.rational") - raise CoercionError, Locale.t("cmdx.coercions.into_a", type:) + # @rbs (untyped value) -> Rational + def self.call(value) + case value + when ::Rational then value + else Kernel.Rational(value) + end + rescue StandardError + raise Error, Locale.t("cmdx.coercions.into_a", type: "rational") end end diff --git a/lib/cmdx/coercions/string.rb b/lib/cmdx/coercions/string.rb index 44b015668..a75dd743d 100644 --- a/lib/cmdx/coercions/string.rb +++ b/lib/cmdx/coercions/string.rb @@ -2,35 +2,17 @@ module CMDx module Coercions - # Coerces values to String type using Ruby's built-in String() method. - # - # This coercion handles various input types by converting them to their - # string representation. It's a simple wrapper around Ruby's String() - # method for consistency with the CMDx coercion interface. + # Coerces a value into a String. module String - extend self - - # Coerces a value to String type. - # - # @param value [Object] The value to coerce to a string - # @param options [Hash] Optional configuration parameters (unused in this coercion) - # @option options [Object] :* Any configuration option (unused) - # - # @return [String] The coerced string value - # - # @raise [TypeError] If the value cannot be converted to a string - # - # @example Basic string coercion - # String.call("hello") # => "hello" - # String.call(42) # => "42" - # String.call([1, 2, 3]) # => "[1, 2, 3]" - # String.call(nil) # => "" - # String.call(true) # => "true" + # @param value [Object] + # @return [String] # - # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> String - def call(value, options = EMPTY_HASH) - String(value) + # @rbs (untyped value) -> String + def self.call(value) + Kernel.String(value) + rescue StandardError + raise Error, Locale.t("cmdx.coercions.into_a", type: "string") end end diff --git a/lib/cmdx/coercions/symbol.rb b/lib/cmdx/coercions/symbol.rb index 7fbdd3b40..bc962682a 100644 --- a/lib/cmdx/coercions/symbol.rb +++ b/lib/cmdx/coercions/symbol.rb @@ -2,37 +2,17 @@ module CMDx module Coercions - # Coerces values to Symbol type using Ruby's to_sym method. - # - # This coercion handles various input types by converting them to symbols. - # It provides error handling for values that cannot be converted to symbols - # and raises appropriate CMDx coercion errors with localized messages. + # Coerces a value into a Symbol. module Symbol - extend self - - # Coerces a value to Symbol type. - # - # @param value [Object] The value to coerce to a symbol - # @param options [Hash] Optional configuration parameters (unused in this coercion) - # @option options [Object] :* Any configuration option (unused) - # - # @return [Symbol] The coerced symbol value - # - # @raise [CoercionError] If the value cannot be converted to a symbol - # - # @example Basic symbol coercion - # Symbol.call("hello") # => :hello - # Symbol.call("user_id") # => :user_id - # Symbol.call("") # => :"" - # Symbol.call(:existing) # => :existing + # @param value [Object] + # @return [Symbol] # - # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> Symbol - def call(value, options = EMPTY_HASH) + # @rbs (untyped value) -> Symbol + def self.call(value) value.to_sym - rescue NoMethodError - type = Locale.t("cmdx.types.symbol") - raise CoercionError, Locale.t("cmdx.coercions.into_a", type:) + rescue StandardError + raise Error, Locale.t("cmdx.coercions.into_a", type: "symbol") end end diff --git a/lib/cmdx/coercions/time.rb b/lib/cmdx/coercions/time.rb index 888580eed..7b99374ea 100644 --- a/lib/cmdx/coercions/time.rb +++ b/lib/cmdx/coercions/time.rb @@ -1,47 +1,26 @@ # frozen_string_literal: true +require "time" + module CMDx module Coercions - # Converts various input types to Time format - # - # Handles conversion from strings, dates, and other time-like objects to Time - # using Ruby's built-in time parsing methods. Supports custom strptime formats - # and raises CoercionError for values that cannot be converted to Time. + # Coerces a value into a Time. module Time - extend self - - # Converts a value to a Time object - # - # @param value [Object] The value to convert to a Time object - # @param options [Hash] Optional configuration parameters - # @option options [String] :strptime Custom strptime format string for parsing - # - # @return [Time] The converted Time object + # @param value [Object] + # @return [Time] # - # @raise [CoercionError] If the value cannot be converted to a Time object - # - # @example Convert time-like objects - # Time.call(Time.now) # => Time object (unchanged) - # Time.call(DateTime.now) # => Time object (converted) - # Time.call(Date.today) # => Time object (converted) - # @example Convert strings with default parsing - # Time.call("2023-12-25 10:30:00") # => Time object - # Time.call("2023-12-25") # => Time object - # Time.call("10:30:00") # => Time object - # @example Convert strings with custom format - # Time.call("25/12/2023", strptime: "%d/%m/%Y") # => Time object - # Time.call("12-25-2023", strptime: "%m-%d-%Y") # => Time object - # - # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> Time - def call(value, options = EMPTY_HASH) - return value.to_time if value.respond_to?(:to_time) - return ::Time.strptime(value, options[:strptime]) if options[:strptime] - - ::Time.parse(value) - rescue ArgumentError, TypeError - type = Locale.t("cmdx.types.time") - raise CoercionError, Locale.t("cmdx.coercions.into_a", type:) + # @rbs (untyped value) -> Time + def self.call(value) + case value + when ::Time then value + when ::Date, ::DateTime then value.to_time + when ::String then ::Time.parse(value) + when ::Integer, ::Float then ::Time.at(value) + else ::Time.parse(value.to_s) + end + rescue StandardError + raise Error, Locale.t("cmdx.coercions.into_a", type: "time") end end diff --git a/lib/cmdx/configuration.rb b/lib/cmdx/configuration.rb index 293538a91..50a9d537f 100644 --- a/lib/cmdx/configuration.rb +++ b/lib/cmdx/configuration.rb @@ -1,280 +1,40 @@ # frozen_string_literal: true module CMDx - - # Configuration class that manages global settings for CMDx including middlewares, - # callbacks, coercions, validators, breakpoints, backtraces, and logging. + # Global configuration with sensible defaults. + # Modify via `CMDx.configure { |c| c.logger = ... }`. class Configuration - # @rbs DEFAULT_BREAKPOINTS: Array[String] - DEFAULT_BREAKPOINTS = %w[failed].freeze - - # @rbs DEFAULT_ROLLPOINTS: Array[String] - DEFAULT_ROLLPOINTS = %w[failed].freeze - - # Returns the middleware registry for task execution. - # - # @return [MiddlewareRegistry] The middleware registry - # - # @example - # config.middlewares.register(CustomMiddleware) - # - # @rbs @middlewares: MiddlewareRegistry - attr_accessor :middlewares - - # Returns the callback registry for task lifecycle hooks. - # - # @return [CallbackRegistry] The callback registry - # - # @example - # config.callbacks.register(:before_execution, :log_start) - # - # @rbs @callbacks: CallbackRegistry - attr_accessor :callbacks - - # Returns the coercion registry for type conversions. - # - # @return [CoercionRegistry] The coercion registry - # - # @example - # config.coercions.register(:custom, CustomCoercion) - # - # @rbs @coercions: CoercionRegistry - attr_accessor :coercions - - # Returns the validator registry for attribute validation. - # - # @return [ValidatorRegistry] The validator registry - # - # @example - # config.validators.register(:email, EmailValidator) - # - # @rbs @validators: ValidatorRegistry - attr_accessor :validators - - # Returns the breakpoint statuses for task execution interruption. - # - # @return [Array] Array of status names that trigger breakpoints - # - # @example - # config.task_breakpoints = ["failed", "skipped"] - # - # @rbs @task_breakpoints: Array[String] - attr_accessor :task_breakpoints - - # Returns the breakpoint statuses for workflow execution interruption. - # - # @return [Array] Array of status names that trigger breakpoints - # - # @example - # config.workflow_breakpoints = ["failed", "skipped"] - # - # @rbs @task_breakpoints: Array[String] - # @rbs @workflow_breakpoints: Array[String] - attr_accessor :workflow_breakpoints - - # Returns the logger instance for CMDx operations. - # - # @return [Logger] The logger instance - # - # @example - # config.logger.level = Logger::DEBUG - # + # @return [Logger] the global logger instance # @rbs @logger: Logger attr_accessor :logger - # Returns whether to log backtraces for failed tasks. - # - # @return [Boolean] true if backtraces should be logged - # - # @example - # config.backtrace = true - # - # @rbs @backtrace: bool - attr_accessor :backtrace - - # Returns the proc used to clean backtraces before logging. - # - # @return [Proc, nil] The backtrace cleaner proc, or nil if not set - # - # @example - # config.backtrace_cleaner = ->(bt) { bt.first(5) } - # - # @rbs @backtrace_cleaner: (Proc | nil) - attr_accessor :backtrace_cleaner - - # Returns the proc called when exceptions occur during execution. - # - # @return [Proc, nil] The exception handler proc, or nil if not set - # - # @example - # config.exception_handler = ->(task, error) { Sentry.capture_exception(error) } - # - # @rbs @exception_handler: (Proc | nil) - attr_accessor :exception_handler + # @return [Symbol, nil] global log level override + # @rbs @log_level: Symbol? + attr_accessor :log_level - # Returns the statuses that trigger a task execution rollback. - # - # @return [Array] Array of status names that trigger rollback - # - # @example - # config.rollback_on = ["failed", "skipped"] - # - # @rbs @rollback_on: Array[String] - attr_accessor :rollback_on + # @return [Proc, nil] global log formatter override + # @rbs @log_formatter: Proc? + attr_accessor :log_formatter - # Returns whether to include context data in hash representation output. - # - # @return [Boolean] true if context should be included (default: false) - # - # @example - # config.dump_context = true - # - # @rbs @dump_context: bool - attr_accessor :dump_context + # @return [Boolean] whether to raise faults on validation errors + # @rbs @strict_attributes: bool + attr_accessor :strict_attributes - # Returns whether to freeze task results after execution. - # Set to false in test environments to allow stubbing on result objects. - # - # @return [Boolean] true if results should be frozen (default: true) - # - # @example - # config.freeze_results = false - # - # @rbs @freeze_results: bool - attr_accessor :freeze_results - - # Returns the default locale used for built-in translation lookups. - # Must match the basename of a YAML file in lib/locales/ (e.g. "en", "es", "ja"). - # - # @return [String] The locale identifier (default: "en") - # - # @example - # config.default_locale = "es" - # - # @rbs @locale: String - attr_accessor :default_locale - - # Initializes a new Configuration instance with default values. - # - # Creates new registry instances for middlewares, callbacks, coercions, and - # validators. Sets default breakpoints and configures a basic logger. - # - # @return [Configuration] a new Configuration instance - # - # @example - # config = Configuration.new - # config.middlewares.class # => MiddlewareRegistry - # config.task_breakpoints # => ["failed"] - # # @rbs () -> void def initialize - @middlewares = MiddlewareRegistry.new - @callbacks = CallbackRegistry.new - @coercions = CoercionRegistry.new - @validators = ValidatorRegistry.new - - @task_breakpoints = DEFAULT_BREAKPOINTS - @workflow_breakpoints = DEFAULT_BREAKPOINTS - @rollback_on = DEFAULT_ROLLPOINTS - @dump_context = false - @freeze_results = true - @default_locale = "en" - - @backtrace = false - @backtrace_cleaner = nil - @exception_handler = nil - - @logger = Logger.new( - $stdout, - progname: "cmdx", - formatter: LogFormatters::Line.new, - level: Logger::INFO - ) - end - - # Converts the configuration to a hash representation. - # - # @return [Hash{Symbol => Object}] hash containing all configuration values - # - # @example - # config = Configuration.new - # config.to_h - # # => { middlewares: #, callbacks: #, ... } - # - # @rbs () -> Hash[Symbol, untyped] - def to_h - { - middlewares: @middlewares, - callbacks: @callbacks, - coercions: @coercions, - validators: @validators, - task_breakpoints: @task_breakpoints, - workflow_breakpoints: @workflow_breakpoints, - rollback_on: @rollback_on, - freeze_results: @freeze_results, - default_locale: @default_locale, - backtrace: @backtrace, - backtrace_cleaner: @backtrace_cleaner, - exception_handler: @exception_handler, - dump_context: @dump_context, - logger: @logger - } + @logger = default_logger + @log_level = nil + @log_formatter = nil + @strict_attributes = true end - end - - extend self - - # Returns the global configuration instance, creating it if it doesn't exist. - # - # @return [Configuration] the global configuration instance - # - # @example - # config = CMDx.configuration - # config.middlewares # => # - # - # @rbs () -> Configuration - def configuration - return @configuration if @configuration + private - @configuration ||= Configuration.new - end - - # Configures CMDx using a block that receives the configuration instance. - # - # @yield [Configuration] the configuration instance to configure - # - # @return [Configuration] the configured configuration instance - # - # @raise [ArgumentError] when no block is provided - # - # @example - # CMDx.configure do |config| - # config.task_breakpoints = ["failed", "skipped"] - # config.logger.level = Logger::DEBUG - # end - # - # @rbs () { (Configuration) -> void } -> Configuration - def configure - raise ArgumentError, "block required" unless block_given? - - config = configuration - yield(config) - config - end + # @rbs () -> Logger + def default_logger + ::Logger.new($stdout, level: ::Logger::INFO) + end - # Resets the global configuration to a new instance with default values. - # - # @return [Configuration] the new configuration instance - # - # @example - # CMDx.reset_configuration! - # # Configuration is now reset to defaults - # - # @rbs () -> Configuration - def reset_configuration! - @configuration = Configuration.new end - end diff --git a/lib/cmdx/context.rb b/lib/cmdx/context.rb index 32e7adf4c..23b3fd2f3 100644 --- a/lib/cmdx/context.rb +++ b/lib/cmdx/context.rb @@ -1,41 +1,19 @@ # frozen_string_literal: true module CMDx - # A hash-like context object that provides a flexible way to store and access - # key-value pairs during task execution. Keys are automatically converted to - # symbols for consistency. - # - # The Context class extends Forwardable to delegate common hash methods and - # provides additional convenience methods for working with context data. + # Hash-like context object for storing and accessing key-value pairs + # during task execution. Keys are automatically symbolized. class Context - extend Forwardable - - # Returns the internal hash storing context data. - # - # @return [Hash{Symbol => Object}] The internal hash table - # - # @example - # context.table # => { name: "John", age: 30 } + # @return [Hash{Symbol => Object}] the internal data store # # @rbs @table: Hash[Symbol, untyped] attr_reader :table alias to_h table - def_delegators :table, :keys, :values, :each, :each_key, :each_value, :map - - # Creates a new Context instance from the given arguments. + # @param args [Hash, Object] initial data (must respond to `to_h` or `to_hash`) # - # @param args [Hash, Object] arguments to initialize the context with - # @option args [Object] :key the key-value pairs to store in the context - # - # @return [Context] a new Context instance - # - # @raise [ArgumentError] when args doesn't respond to `to_h` or `to_hash` - # - # @example - # context = Context.new(name: "John", age: 30) - # context[:name] # => "John" + # @raise [ArgumentError] when args cannot be converted to a hash # # @rbs (untyped args) -> void def initialize(args = {}) @@ -49,17 +27,11 @@ def initialize(args = {}) end.transform_keys(&:to_sym) end - # Builds a Context instance, reusing existing unfrozen contexts when possible. - # - # @param context [Context, Object] the context to build from - # @option context [Object] :key the key-value pairs to store in the context + # Builds a Context, reusing existing unfrozen instances when possible. # - # @return [Context] a Context instance, either new or reused + # @param context [Context, Object] source data # - # @example - # existing = Context.new(name: "John") - # built = Context.build(existing) # reuses existing context - # built.object_id == existing.object_id # => true + # @return [Context] # # @rbs (untyped context) -> Context def self.build(context = {}) @@ -72,74 +44,22 @@ def self.build(context = {}) end end - # Retrieves a value from the context by key. - # - # @param key [String, Symbol] the key to retrieve - # - # @return [Object, nil] the value associated with the key, or nil if not found - # - # @example - # context = Context.new(name: "John") - # context[:name] # => "John" - # context["name"] # => "John" (automatically converted to symbol) - # # @rbs ((String | Symbol) key) -> untyped def [](key) table[key.to_sym] end - # Stores a key-value pair in the context. - # - # @param key [String, Symbol] the key to store - # @param value [Object] the value to store - # - # @return [Object] the stored value - # - # @example - # context = Context.new - # context.store(:name, "John") - # context[:name] # => "John" - # # @rbs ((String | Symbol) key, untyped value) -> untyped def store(key, value) table[key.to_sym] = value end alias []= store - # Fetches a value from the context by key, with optional default value. - # - # @param key [String, Symbol] the key to fetch - # - # @yield [key] a block to compute the default value - # - # @return [Object] the value associated with the key, or the default/default block result - # - # @example - # context = Context.new(name: "John") - # context.fetch(:name) # => "John" - # context.fetch(:age, 25) # => 25 - # context.fetch(:city) { |key| "Unknown #{key}" } # => "Unknown city" - # # @rbs ((String | Symbol) key, *untyped) ?{ ((String | Symbol)) -> untyped } -> untyped def fetch(key, ...) table.fetch(key.to_sym, ...) end - # Fetches a value from the context by key, or stores and returns a default value if not found. - # - # @param key [String, Symbol] the key to fetch or store - # @param value [Object] the default value to store if key is not found - # - # @yield [key] a block to compute the default value to store - # - # @return [Object] the existing value if key is found, otherwise the stored default value - # - # @example - # context = Context.new(name: "John") - # context.fetch_or_store(:name, "Default") # => "John" (existing value) - # context.fetch_or_store(:age, 25) # => 25 (stored and returned) - # context.fetch_or_store(:city) { |key| "Unknown #{key}" } # => "Unknown city" (stored and returned) - # # @rbs ((String | Symbol) key, ?untyped value) ?{ () -> untyped } -> untyped def fetch_or_store(key, value = nil) table.fetch(key.to_sym) do @@ -147,18 +67,6 @@ def fetch_or_store(key, value = nil) end end - # Merges the given arguments into the current context, modifying it in place. - # - # @param args [Hash, Object] arguments to merge into the context - # @option args [Object] :key the key-value pairs to merge - # - # @return [Context] self for method chaining - # - # @example - # context = Context.new(name: "John") - # context.merge!(age: 30, city: "NYC") - # context.to_h # => {name: "John", age: 30, city: "NYC"} - # # @rbs (?untyped args) -> self def merge!(args = EMPTY_HASH) table.merge!(args.to_h.transform_keys(&:to_sym)) @@ -166,34 +74,12 @@ def merge!(args = EMPTY_HASH) end alias merge merge! - # Deletes a key-value pair from the context. - # - # @param key [String, Symbol] the key to delete - # - # @yield [key] a block to handle the case when key is not found - # - # @return [Object, nil] the deleted value, or the block result if key not found - # - # @example - # context = Context.new(name: "John", age: 30) - # context.delete!(:age) # => 30 - # context.delete!(:city) { |key| "Key #{key} not found" } # => "Key city not found" - # # @rbs ((String | Symbol) key) ?{ ((String | Symbol)) -> untyped } -> untyped def delete!(key, &) table.delete(key.to_sym, &) end alias delete delete! - # Clears all key-value pairs from the context. - # - # @return [Context] self for method chaining - # - # @example - # context = Context.new(name: "John") - # context.clear! - # context.to_h # => {} - # # @rbs () -> self def clear! table.clear @@ -201,64 +87,29 @@ def clear! end alias clear clear! - # Compares this context with another object for equality. - # - # @param other [Object] the object to compare with - # - # @return [Boolean] true if other is a Context with the same data - # - # @example - # context1 = Context.new(name: "John") - # context2 = Context.new(name: "John") - # context1 == context2 # => true - # # @rbs (untyped other) -> bool def eql?(other) other.is_a?(self.class) && (table == other.to_h) end alias == eql? - # Checks if the context contains a specific key. - # - # @param key [String, Symbol] the key to check - # - # @return [Boolean] true if the key exists in the context - # - # @example - # context = Context.new(name: "John") - # context.key?(:name) # => true - # context.key?(:age) # => false - # # @rbs ((String | Symbol) key) -> bool def key?(key) table.key?(key.to_sym) end - # Digs into nested structures using the given keys. - # - # @param key [String, Symbol] the first key to dig with - # @param keys [Array] additional keys for deeper digging - # - # @return [Object, nil] the value found by digging, or nil if not found - # - # @example - # context = Context.new(user: {profile: {name: "John"}}) - # context.dig(:user, :profile, :name) # => "John" - # context.dig(:user, :profile, :age) # => nil - # # @rbs ((String | Symbol) key, *(String | Symbol) keys) -> untyped def dig(key, *keys) table.dig(key.to_sym, *keys) end - # Converts the context to a string representation. - # - # @return [String] a formatted string representation of the context data - # - # @example - # context = Context.new(name: "John", age: 30) - # context.to_s # => "name: John, age: 30" - # + def keys = table.keys + def values = table.values + def each(&) = table.each(&) + def each_key(&) = table.each_key(&) + def each_value(&) = table.each_value(&) + def map(&) = table.map(&) + # @rbs () -> String def to_s Utils::Format.to_str(to_h) @@ -266,20 +117,8 @@ def to_s private - # Handles method calls that don't match defined methods. - # Supports assignment-style calls (e.g., `name=`) and key access. - # - # @param method_name [Symbol] the method name that was called - # @param args [Array] arguments passed to the method - # @param _kwargs [Hash] keyword arguments (unused) - # @option _kwargs [Object] :* Any keyword arguments (unused) - # - # @yield [Object] optional block - # - # @return [Object] the result of the method call - # - # @rbs (Symbol method_name, *untyped args, **untyped _kwargs) ?{ () -> untyped } -> untyped - def method_missing(method_name, *args, **_kwargs, &) + # @rbs (Symbol method_name, *untyped args, **untyped) ?{ () -> untyped } -> untyped + def method_missing(method_name, *args, **, &) if method_name.end_with?("=") store(method_name.name.chop, args.first) else @@ -287,20 +126,6 @@ def method_missing(method_name, *args, **_kwargs, &) end end - # Checks if the object responds to a given method. - # Supports both getter access for existing keys and setter methods. - # - # @param method_name [Symbol] the method name to check - # @param include_private [Boolean] whether to include private methods - # - # @return [Boolean] true if the method can be called - # - # @example - # context = Context.new(name: "John") - # context.respond_to?(:name) # => true - # context.respond_to?(:name=) # => true - # context.respond_to?(:age) # => false - # # @rbs (Symbol method_name, ?bool include_private) -> bool def respond_to_missing?(method_name, include_private = false) key?(method_name) || method_name.end_with?("=") || super diff --git a/lib/cmdx/deprecator.rb b/lib/cmdx/deprecator.rb index ba1fac7d1..f50ca43ba 100644 --- a/lib/cmdx/deprecator.rb +++ b/lib/cmdx/deprecator.rb @@ -2,76 +2,36 @@ module CMDx # Handles deprecation warnings and restrictions for tasks. - # - # The Deprecator module provides functionality to restrict usage of deprecated - # tasks based on configuration settings. It supports different deprecation - # behaviors including warnings, logging, and errors. module Deprecator - extend self - - # @rbs RAISE_REGEXP: Regexp - RAISE_REGEXP = /\Araise\z/ - private_constant :RAISE_REGEXP - - # @rbs LOG_REGEXP: Regexp - LOG_REGEXP = /\Alog\z/ - private_constant :LOG_REGEXP - - # @rbs WARN_REGEXP: Regexp - WARN_REGEXP = /\Awarn\z/ - private_constant :WARN_REGEXP - - # @rbs EVAL: Proc - EVAL = proc do |target, callable| - case callable - when NilClass, FalseClass, TrueClass then !!callable - when RAISE_REGEXP, LOG_REGEXP, WARN_REGEXP then callable - when Symbol then target.send(callable) - when Proc then target.instance_eval(&callable) - else - raise "cannot evaluate #{callable.inspect}" unless callable.respond_to?(:call) - - callable.call(target) - end - end.freeze - private_constant :EVAL - - # Restricts task usage based on deprecation settings. - # - # @param task [Object] The task object to check for deprecation - # @option task.class.settings.deprecate [Symbol, Proc, String, Boolean] - # The deprecation configuration for the task - # @option task.class.settings.deprecate :raise Raises DeprecationError - # @option task.class.settings.deprecate :log Logs deprecation warning - # @option task.class.settings.deprecate :warn Outputs warning to stderr - # @option task.class.settings.deprecate true Raises DeprecationError - # @option task.class.settings.deprecate false No action taken - # @option task.class.settings.deprecate nil No action taken - # - # @raise [DeprecationError] When deprecation type is :raise or true - # @raise [RuntimeError] When deprecation type is unknown + # Checks deprecation settings and raises or warns accordingly. # - # @example - # class MyTask - # settings(deprecate: :warn) - # end + # @param task_class [Class] the task class to check # - # MyTask.new # => [MyTask] DEPRECATED: migrate to a replacement or discontinue use + # @raise [DeprecationError] when :restrict mode is active # - # @rbs (Task task) -> void - def restrict(task) - setting = task.class.settings.deprecate - return unless setting - - case type = EVAL.call(task, setting) - when NilClass, FalseClass then nil # Do nothing - when TrueClass, RAISE_REGEXP then raise DeprecationError, "#{task.class.name} usage prohibited" - when LOG_REGEXP then task.logger.warn { "DEPRECATED: migrate to a replacement or discontinue use" } - when WARN_REGEXP then warn("[#{task.class.name}] DEPRECATED: migrate to a replacement or discontinue use", category: :deprecated) - else raise "unknown deprecation type #{type.inspect}" + # @rbs (untyped task_class) -> void + def self.check!(task_class) + config = task_class.task_settings.resolved_deprecate + return unless config + + message = deprecation_message(task_class, config) + + case config[:mode]&.to_sym + when :restrict + raise DeprecationError, message + when :warn + warn("[DEPRECATED] #{message}") end end + # @rbs (untyped task_class, Hash[Symbol, untyped] config) -> String + def self.deprecation_message(task_class, config) + msg = "#{task_class.name} is deprecated" + msg += ": #{config[:message]}" if config[:message] + msg += " (use #{config[:alternative]} instead)" if config[:alternative] + msg + end + end end diff --git a/lib/cmdx/errors.rb b/lib/cmdx/errors.rb index eb907d72b..9e117ae5d 100644 --- a/lib/cmdx/errors.rb +++ b/lib/cmdx/errors.rb @@ -2,40 +2,21 @@ module CMDx # Collection of validation and execution errors organized by attribute. - # Provides methods to add, query, and format error messages for different - # attributes in a task or workflow execution. + # Provides methods to add, query, and format error messages. class Errors - extend Forwardable - - # Returns the internal hash of error messages by attribute. - # - # @return [Hash{Symbol => Set}] Hash mapping attribute names to error message sets - # - # @example - # errors.messages # => { email: # } + # @return [Hash{Symbol => Set}] error messages keyed by attribute # # @rbs @messages: Hash[Symbol, Set[String]] attr_reader :messages - def_delegators :messages, :any?, :clear, :empty?, :size - - # Initialize a new error collection. - # # @rbs () -> void def initialize @messages = {} end - # Add an error message for a specific attribute. - # - # @param attribute [Symbol] The attribute name associated with the error - # @param message [String] The error message to add - # - # @example - # errors = CMDx::Errors.new - # errors.add(:email, "must be valid format") - # errors.add(:email, "cannot be blank") + # @param attribute [Symbol] the attribute name + # @param message [String] the error message # # @rbs (Symbol attribute, String message) -> void def add(attribute, message) @@ -45,15 +26,9 @@ def add(attribute, message) messages[attribute] << message end - # Check if there are any errors for a specific attribute. + # @param attribute [Symbol] the attribute to check # - # @param attribute [Symbol] The attribute name to check for errors - # - # @return [Boolean] true if the attribute has errors, false otherwise - # - # @example - # errors.for?(:email) # => true - # errors.for?(:name) # => false + # @return [Boolean] true if the attribute has errors # # @rbs (Symbol attribute) -> bool def for?(attribute) @@ -61,52 +36,60 @@ def for?(attribute) !set.nil? && !set.empty? end - # Convert errors to a hash format with arrays of full messages. + # @return [Boolean] true if there are no errors # - # @return [Hash{Symbol => Array}] Hash with attribute keys and message arrays + # @rbs () -> bool + def empty? + messages.empty? + end + + # @return [Boolean] true if there are any errors # - # @example - # errors.full_messages # => { email: ["email must be valid format", "email cannot be blank"] } + # @rbs () -> bool + def any? + !empty? + end + + # @return [Integer] the number of attributes with errors + # + # @rbs () -> Integer + def size + messages.size + end + + # Removes all errors. + # + # @rbs () -> void + def clear + messages.clear + end + + # @return [Hash{Symbol => Array}] full messages with attribute names # # @rbs () -> Hash[Symbol, Array[String]] def full_messages - messages.each_with_object({}) do |(attribute, messages), hash| - hash[attribute] = messages.map { |message| "#{attribute} #{message}" } + messages.each_with_object({}) do |(attribute, msgs), hash| + hash[attribute] = msgs.map { |m| "#{attribute} #{m}" } end end - # Convert errors to a hash format with arrays of messages. - # - # @return [Hash{Symbol => Array}] Hash with attribute keys and message arrays - # - # @example - # errors.to_h # => { email: ["must be valid format", "cannot be blank"] } + # @return [Hash{Symbol => Array}] messages without attribute names # # @rbs () -> Hash[Symbol, Array[String]] def to_h messages.transform_values(&:to_a) end - # Convert errors to a hash format with optional full messages. + # @param full [Boolean] whether to include attribute names # - # @param full [Boolean] Whether to include full messages with attribute names - # @return [Hash{Symbol => Array}] Hash with attribute keys and message arrays - # - # @example - # errors.to_hash # => { email: ["must be valid format", "cannot be blank"] } - # errors.to_hash(true) # => { email: ["email must be valid format", "email cannot be blank"] } + # @return [Hash{Symbol => Array}] # # @rbs (?bool full) -> Hash[Symbol, Array[String]] def to_hash(full = false) full ? full_messages : to_h end - # Convert errors to a human-readable string format. - # - # @return [String] Formatted error messages joined with periods - # - # @example - # errors.to_s # => "email must be valid format. email cannot be blank" + # @return [String] human-readable error summary # # @rbs () -> String def to_s diff --git a/lib/cmdx/exception.rb b/lib/cmdx/exception.rb index 9d7151840..28506e348 100644 --- a/lib/cmdx/exception.rb +++ b/lib/cmdx/exception.rb @@ -2,45 +2,31 @@ module CMDx - # Base exception class for all CMDx-related errors. - # - # This serves as the root exception class for all errors raised by the CMDx - # framework. It inherits from StandardError and provides a common base for - # handling CMDx-specific exceptions. - Exception = Error = Class.new(StandardError) - - # Raised when attribute coercion fails during task execution. - # - # This error occurs when a attribute value cannot be converted to the expected - # type using the registered coercion handlers. It indicates that the provided - # value is incompatible with the attribute's defined type. - CoercionError = Class.new(Error) - - # Raised when a deprecated task is used. - # - # This error occurs when a deprecated task is called. It indicates that the - # task is no longer supported and should be replaced with a newer alternative. - DeprecationError = Class.new(Error) - - # Raised when an abstract method is called without being implemented. - # - # This error occurs when a subclass fails to implement required abstract - # methods such as 'task' in tasks. It indicates incomplete implementation - # of required functionality. - UndefinedMethodError = Class.new(Error) - - # Error raised when task execution exceeds the configured timeout limit. - # - # This error occurs when a task takes longer to execute than the specified - # time limit. Timeout errors are raised by Ruby's Timeout module and are - # caught by the middleware to properly fail the task with timeout information. - TimeoutError = Class.new(Interrupt) - - # Raised when attribute validation fails during task execution. - # - # This error occurs when a attribute value doesn't meet the validation criteria - # defined by the validator. It indicates that the provided value violates - # business rules or data integrity constraints. - ValidationError = Class.new(Error) + # Base error class for all CMDx exceptions. + class Error < StandardError; end + + # Raised when a required method is not implemented by the developer. + class UndefinedMethodError < Error; end + + # Raised when an attribute declaration is invalid. + class AttributeError < Error; end + + # Raised when a configuration value is invalid. + class ConfigurationError < Error; end + + # Raised when a coercion type is unknown. + class UnknownCoercionError < Error; end + + # Raised when a validation type is unknown. + class UnknownValidatorError < Error; end + + # Raised when middleware does not yield. + class MiddlewareError < Error; end + + # Raised when a deprecation restriction is violated. + class DeprecationError < Error; end + + # Raised for invalid return declarations. + class ReturnError < Error; end end diff --git a/lib/cmdx/executor.rb b/lib/cmdx/executor.rb deleted file mode 100644 index 727d7d032..000000000 --- a/lib/cmdx/executor.rb +++ /dev/null @@ -1,379 +0,0 @@ -# frozen_string_literal: true - -module CMDx - # Executes CMDx tasks with middleware support, error handling, and lifecycle management. - # - # The Executor class is responsible for orchestrating task execution, including - # pre-execution validation, execution with middleware, post-execution callbacks, - # and proper error handling for different types of failures. - class Executor - - extend Forwardable - - # @rbs STATE_CALLBACKS: Hash[String, Symbol] - STATE_CALLBACKS = Result::STATES.to_h { |s| [s, :"on_#{s}"] }.freeze - private_constant :STATE_CALLBACKS - - # @rbs STATUS_CALLBACKS: Hash[String, Symbol] - STATUS_CALLBACKS = Result::STATUSES.to_h { |s| [s, :"on_#{s}"] }.freeze - private_constant :STATUS_CALLBACKS - - # Returns the task being executed. - # - # @return [Task] The task instance - # - # @example - # executor.task.id # => "abc123" - # - # @rbs @task: Task - attr_reader :task - - def_delegators :task, :result, :resolver - - # @param task [CMDx::Task] The task to execute - # - # @return [CMDx::Executor] A new executor instance - # - # @example - # executor = CMDx::Executor.new(my_task) - # - # @rbs (Task task) -> void - def initialize(task) - @task = task - end - - # Executes a task with optional exception raising. - # - # @param task [CMDx::Task] The task to execute - # @param raise [Boolean] Whether to raise exceptions (default: false) - # - # @return [CMDx::Result] The execution result - # - # @raise [StandardError] When raise is true and execution fails - # - # @example - # CMDx::Executor.execute(my_task) - # CMDx::Executor.execute(my_task, raise: true) - # - # @rbs (Task task, raise: bool) -> Result - def self.execute(task, raise: false) - instance = new(task) - raise ? instance.execute! : instance.execute - end - - # Executes the task with graceful error handling. - # - # @return [CMDx::Result] The execution result - # - # @example - # executor = CMDx::Executor.new(my_task) - # result = executor.execute - # - # @rbs () -> Result - def execute - task.class.settings.middlewares.call!(task) do - pre_execution! unless @pre_execution - execution! - verify_context_returns! - rescue UndefinedMethodError => e - raise_exception(e) - rescue Fault => e - resolver.throw!(e.result, halt: false, cause: e) - rescue StandardError => e - retry if retry_execution?(e) - resolver.fail!(Utils::Normalize.exception(e), halt: false, cause: e, source: :exception) - task.class.settings.exception_handler&.call(task, e) - ensure - resolver.executed! - post_execution! - end - - verify_middleware_yield! - finalize_execution! - end - - # Executes the task with exception raising on failure. - # - # @return [CMDx::Result] The execution result - # - # @raise [StandardError] When execution fails - # - # @example - # executor = CMDx::Executor.new(my_task) - # result = executor.execute! - # - # @rbs () -> Result - def execute! - task.class.settings.middlewares.call!(task) do - pre_execution! unless @pre_execution - execution! - verify_context_returns! - rescue UndefinedMethodError => e - raise_exception(e) - rescue Fault => e - resolver.throw!(e.result, halt: false, cause: e) - - if halt_execution?(e) - raise_exception(e) - else - resolver.executed! - post_execution! - end - rescue StandardError => e - retry if retry_execution?(e) - resolver.fail!(Utils::Normalize.exception(e), halt: false, cause: e, source: :exception) - raise_exception(e) - else - resolver.executed! - post_execution! - end - - verify_middleware_yield! - finalize_execution! - end - - protected - - # Determines if execution should halt based on breakpoint configuration. - # Returns false when the result was created with +strict: false+. - # - # @param exception [Exception] The exception that occurred - # - # @return [Boolean] Whether execution should halt - # - # @rbs (Exception exception) -> bool - def halt_execution?(exception) - return false unless exception.result.strict? - - @halt_statuses ||= Utils::Normalize.statuses( - task.class.settings.breakpoints || - task.class.settings.task_breakpoints - ).freeze - - @halt_statuses.include?(exception.result.status) - end - - # Determines if execution should be retried based on retry configuration. - # - # @param exception [Exception] The exception that occurred - # - # @return [Boolean] Whether execution should be retried - # - # @rbs (Exception exception) -> bool - def retry_execution?(exception) - @retry ||= Retry.new(task) - - return false unless @retry.available? && @retry.remaining? - return false unless @retry.exception?(exception) - - result.retries += 1 - - task.logger.warn do - reason = Utils::Normalize.exception(exception) - task.to_h.merge!(reason:, remaining_retries: @retry.remaining) - end - - task.errors.clear - - wait = @retry.wait - sleep(wait) if wait.positive? - - true - end - - # Raises an exception and clears the chain. - # - # @param exception [Exception] The exception to raise - # - # @raise [Exception] The provided exception - # - # @rbs (Exception exception) -> void - def raise_exception(exception) - Chain.clear - - raise(exception) - end - - # Invokes callbacks of a specific type for the task. - # - # @param type [Symbol] The type of callback to invoke - # - # @return [void] - # - # @example - # invoke_callbacks(:before_execution) - # - # @rbs (Symbol type) -> void - def invoke_callbacks(type) - task.class.settings.callbacks.invoke(type, task) - end - - private - - # Performs pre-execution tasks including validation and attribute verification. - # - # @rbs () -> void - def pre_execution! - @pre_execution = true - - invoke_callbacks(:before_validation) - - task.class.settings.attributes.define_and_verify(task) - return if task.errors.empty? - - resolver.fail!( - Locale.t("cmdx.faults.invalid"), - source: :validation, - errors: { - full_message: task.errors.to_s, - messages: task.errors.to_h - } - ) - end - - # Executes the main task logic. - # Wraps task.work in catch(:cmdx_halt) so that success! can halt early. - # - # @rbs () -> void - def execution! - invoke_callbacks(:before_execution) - - resolver.executing! - catch(:cmdx_halt) { task.work } - end - - # Verifies that all declared returns are present in the context after execution. - # - # @rbs () -> void - def verify_context_returns! - return unless result.success? - - returns = Utils::Wrap.array(task.class.settings.returns) - missing = returns.reject { |name| task.context.key?(name) } - return if missing.empty? - - missing.each { |name| task.errors.add(name, Locale.t("cmdx.returns.missing")) } - - resolver.fail!( - Locale.t("cmdx.faults.invalid"), - source: :context, - errors: { - full_message: task.errors.to_s, - messages: task.errors.to_h - } - ) - end - - # Performs post-execution tasks including callback invocation. - # - # @rbs () -> void - def post_execution! - return if task.class.settings.callbacks.empty? - - invoke_callbacks(STATE_CALLBACKS[result.state]) - invoke_callbacks(:on_executed) if result.executed? - - invoke_callbacks(STATUS_CALLBACKS[result.status]) - invoke_callbacks(:on_good) if result.good? - invoke_callbacks(:on_bad) if result.bad? - end - - # Detects if middleware swallowed the block without yielding. - # When this happens the result is still in the initialized state. - # - # @rbs () -> void - def verify_middleware_yield! - return unless result.initialized? - - resolver.fail!( - Locale.t("cmdx.faults.invalid"), - halt: false, - source: :middleware - ) - resolver.executed! - end - - # Finalizes execution by freezing the task, logging results, and rolling back work. - # - # @rbs () -> void - def finalize_execution! - log_execution! - log_backtrace! if task.class.settings.backtrace - - rollback_execution! - freeze_execution! - clear_chain! - end - - # Logs the execution result at the configured log level. - # - # @rbs () -> void - def log_execution! - task.logger.info { result.to_h } - end - - # Logs the backtrace of the exception if the task failed. - # - # @rbs () -> void - def log_backtrace! - return unless result.failed? - - exception = result.caused_failure&.cause - return if exception.nil? || exception.is_a?(Fault) - - task.logger.error do - Utils::Normalize.exception(exception) << "\n" << - if (cleaner = task.class.settings.backtrace_cleaner) - cleaner.call(exception.backtrace).join("\n\t") - else - exception.full_message(highlight: false) - end - end - end - - # Freezes the task and its associated objects to prevent modifications. - # - # @rbs () -> void - def freeze_execution! - # Stubbing on frozen objects is not allowed in most test environments. - return unless CMDx.configuration.freeze_results - - task.freeze - resolver.freeze - result.freeze - - # Freezing the context and chain can only be done once the outer-most - # task has completed. - return unless result.index.zero? - - task.context.freeze - task.chain.freeze - end - - # Clears the chain if the task is the outermost (top-level) task - # and the current thread's chain is the same instance this task belongs to. - # - # @rbs () -> void - def clear_chain! - return unless result.index.zero? - return unless Chain.current.equal?(task.chain) - - Chain.clear - end - - # Rolls back the work of a task. - # - # @rbs () -> void - def rollback_execution! - return if result.rolled_back? - return unless task.respond_to?(:rollback) - - @rollback_statuses ||= Utils::Normalize.statuses(task.class.settings.rollback_on).freeze - return unless @rollback_statuses.include?(result.status) - - result.rolled_back = true - task.rollback - end - - end -end diff --git a/lib/cmdx/fault.rb b/lib/cmdx/fault.rb index d43191754..adb8fb25c 100644 --- a/lib/cmdx/fault.rb +++ b/lib/cmdx/fault.rb @@ -2,108 +2,39 @@ module CMDx - # Base fault class for handling task execution failures and interruptions. - # - # Faults represent error conditions that occur during task execution, providing - # a structured way to handle and categorize different types of failures. - # Each fault contains a reference to the result object that caused the fault. + # Base class for control flow interruptions surfaced by execute! (bang variant). + # Carries the frozen Result so callers can inspect execution state. class Fault < Error - extend Forwardable - - # Returns the result that caused this fault. - # - # @return [Result] The result instance - # - # @example - # fault.result.reason # => "Validation failed" + # @return [Result] the result that triggered the fault # # @rbs @result: Result attr_reader :result - def_delegators :result, :task, :context, :chain - - # Initialize a new fault with the given result. - # - # @param result [Result] the result object that caused this fault - # - # @raise [ArgumentError] if result is nil or invalid - # - # @example - # fault = Fault.new(task_result) - # fault.result.reason # => "Task validation failed" + # @param result [Result] the completed result # # @rbs (Result result) -> void def initialize(result) @result = result - - super(result.reason) + super(result.reason || Locale.t("cmdx.reasons.unspecified")) end - class << self - - # Create a fault class that matches specific task types. - # - # @param tasks [Array] array of task classes to match against - # - # @return [Class] a new fault class that matches the specified tasks - # - # @example - # Fault.for?(UserTask, AdminUserTask) - # # => true if fault.task is a UserTask or AdminUserTask - # - # @rbs (*Class tasks) -> Class - def for?(*tasks) - temp_fault = Class.new(self) do - def self.===(other) - other.is_a?(superclass) && @tasks.any? { |task| other.task.is_a?(task) } - end - end - - temp_fault.tap { |c| c.instance_variable_set(:@tasks, tasks) } - end - - # Create a fault class that matches based on a custom block. - # - # @param block [Proc] block that determines if a fault matches - # - # @return [Class] a new fault class that matches based on the block - # - # @raise [ArgumentError] if no block is provided - # - # @example - # Fault.matches? { |fault| fault.result.metadata[:critical] } - # # => true if fault has critical metadata - # - # @rbs () { (Fault) -> bool } -> Class - def matches?(&block) - raise ArgumentError, "block required" unless block_given? - - temp_fault = Class.new(self) do - def self.===(other) - other.is_a?(superclass) && @block.call(other) - end - end - - temp_fault.tap { |c| c.instance_variable_set(:@block, block) } - end + # @rbs () -> Hash[Symbol, untyped] + def to_h + result.to_h + end + # @rbs () -> String + def to_s + result.to_s end end - # Fault raised when a task is intentionally skipped during execution. - # - # This fault occurs when a task determines it should not execute based on - # its current context or conditions. Skipped tasks are not considered failures - # but rather intentional bypasses of task execution logic. - SkipFault = Class.new(Fault) + # Raised by execute! when a task fails. + class FailFault < Fault; end - # Fault raised when a task execution fails due to errors or validation failures. - # - # This fault occurs when a task encounters an error condition, validation failure, - # or any other condition that prevents successful completion. Failed tasks indicate - # that the intended operation could not be completed successfully. - FailFault = Class.new(Fault) + # Raised by execute! when a task is skipped. + class SkipFault < Fault; end end diff --git a/lib/cmdx/identifier.rb b/lib/cmdx/identifier.rb index 671f5e4d3..b9245bd0b 100644 --- a/lib/cmdx/identifier.rb +++ b/lib/cmdx/identifier.rb @@ -1,29 +1,21 @@ # frozen_string_literal: true module CMDx - # Generates unique identifiers for tasks, workflows, and other CMDx components. + # Generates unique identifiers for tasks and chains. # - # The Identifier module provides a consistent way to generate unique identifiers - # across the CMDx system, with fallback support for different Ruby versions. + # Uses SecureRandom.uuid for globally unique IDs. module Identifier - extend self - - # Generates a unique identifier string. - # - # @return [String] A unique identifier string (UUID v7 if available, otherwise UUID v4) + # Generates a new unique identifier string. # - # @raise [StandardError] If SecureRandom is unavailable or fails to generate an identifier + # @return [String] a UUID string # - # @example Generate a unique identifier - # CMDx::Identifier.generate - # # => "01890b2c-1234-5678-9abc-def123456789" + # @example + # Identifier.generate # => "550e8400-e29b-41d4-a716-446655440000" # # @rbs () -> String - if SecureRandom.respond_to?(:uuid_v7) - def generate = SecureRandom.uuid_v7 - else - def generate = SecureRandom.uuid + def self.generate + SecureRandom.uuid end end diff --git a/lib/cmdx/locale.rb b/lib/cmdx/locale.rb index 5d1bf3847..0b2605e81 100644 --- a/lib/cmdx/locale.rb +++ b/lib/cmdx/locale.rb @@ -1,77 +1,67 @@ # frozen_string_literal: true +require "yaml" + module CMDx - # Provides internationalization and localization support for CMDx. - # Handles translation lookups with fallback to the configured locale's - # default messages when I18n gem is not available. + # Handles internationalization of messages and error strings. + # Uses I18n when available, falls back to built-in YAML translations. module Locale - extend self + # @rbs LOCALE_PATH: String + LOCALE_PATH = File.expand_path("../locales", __dir__) + + # @rbs @translations: Hash[String, untyped] - # Translates a key to the current locale with optional interpolation. - # Falls back to the configured locale's YAML file translations if - # I18n gem is unavailable. + # Translates a key with optional interpolation. # - # @param key [String, Symbol] The translation key (supports dot notation) - # @param options [Hash] Translation options - # @option options [String] :default Fallback message if translation missing - # @option options [String] :locale Target locale (when I18n available) - # @option options [Hash] :scope Translation scope (when I18n available) - # @option options [Object] :* Any other options passed to I18n.t or string interpolation + # @param key [String] dot-separated translation key (e.g., "cmdx.errors.blank") + # @param options [Hash] interpolation values # - # @return [String] The translated message + # @return [String] translated string # - # @raise [ArgumentError] When interpolation fails due to missing keys + # @rbs (String key, **untyped options) -> String + def self.t(key, **options) + if defined?(I18n) + I18n.t(key, **options, default: fallback(key, **options)) + else + fallback(key, **options) + end + end + + # @param locale [Symbol, String] locale code # - # @example Basic translation - # Locale.translate("errors.invalid_input") - # # => "Invalid input provided" - # @example With interpolation - # Locale.translate("welcome.message", name: "John") - # # => "Welcome, John!" - # @example With fallback - # Locale.translate("missing.key", default: "Custom fallback message") - # # => "Custom fallback message" + # @return [Hash] loaded translations # - # @rbs ((String | Symbol) key, **untyped options) -> String - def translate(key, **options) - options[:default] ||= translation_default(key) - return ::I18n.t(key, **options) if defined?(::I18n) - - case message = options.delete(:default) - when String then message % options - when NilClass then "Translation missing: #{key}" - else message + # @rbs (?Symbol locale) -> Hash[String, untyped] + def self.translations(locale = :en) + @translations ||= {} + @translations[locale.to_s] ||= begin + file = File.join(LOCALE_PATH, "#{locale}.yml") + data = File.exist?(file) ? YAML.safe_load_file(file) : {} + data[locale.to_s] || {} end end - # @see #translate - alias t translate - - private - - # Resolves and caches the default translation for a key by digging - # into the configured locale's YAML translations. - # - # @param key [String, Symbol] The translation key + # @param key [String] dot-separated translation key + # @param options [Hash] interpolation values # - # @return [String, nil] The resolved translation or nil + # @return [String] translated string or key as fallback # - # @rbs ((String | Symbol) key) -> String? - def translation_default(key) - tkey = "#{CMDx.configuration.default_locale}.#{key}" + # @rbs (String key, **untyped options) -> String + def self.fallback(key, **options) + locale = options.delete(:locale) || :en + keys = key.split(".") + value = keys.reduce(translations(locale)) do |hash, k| + break nil unless hash.is_a?(Hash) - @translation_defaults ||= {} - return @translation_defaults[tkey] if @translation_defaults.key?(tkey) + hash[k] + end - @default_translations ||= begin - path = CMDx.gem_path.join("lib/locales/#{CMDx.configuration.default_locale}.yml") - raise ArgumentError, "locale file not found: #{path}" unless path.exist? + return key unless value.is_a?(String) - YAML.load_file(path).freeze + options.reduce(value) do |str, (k, v)| + str.gsub("%{#{k}}", v.to_s) end - - @translation_defaults[tkey] = @default_translations.dig(*tkey.split(".")) end end diff --git a/lib/cmdx/log_formatters/json.rb b/lib/cmdx/log_formatters/json.rb index 3a4b60672..148f3e984 100644 --- a/lib/cmdx/log_formatters/json.rb +++ b/lib/cmdx/log_formatters/json.rb @@ -1,38 +1,27 @@ # frozen_string_literal: true +require "json" + module CMDx module LogFormatters - # Formats log messages as JSON for structured logging - # - # This formatter converts log entries into JSON format with standardized fields - # including severity, timestamp, program name, process ID, and formatted message. - # The output is suitable for log aggregation systems and structured analysis. - class JSON + # Formats log output as JSON. + class Json - # Formats a log entry as a JSON string - # - # @param severity [String] The log level (e.g., "INFO", "ERROR", "DEBUG") - # @param time [Time] The timestamp when the log entry was created - # @param progname [String, nil] The program name or identifier - # @param message [Object] The log message content + # @param severity [String] log level + # @param datetime [Time] timestamp + # @param _progname [String, nil] program name + # @param result [Result] the result to format # - # @return [String] A JSON-formatted log entry with a trailing newline + # @return [String] JSON-formatted log line # - # @example Basic usage - # logger_formatter.call("INFO", Time.now, "MyApp", "User logged in") - # # => '{"severity":"INFO","timestamp":"2024-01-15T10:30:45.123456Z","progname":"MyApp","pid":12345,"message":"User logged in"}\n' - # - # @rbs (String severity, Time time, String? progname, String message) -> String - def call(severity, time, progname, message) - hash = { - severity:, - timestamp: time.utc.iso8601(6), - progname:, - pid: Process.pid, - message: Utils::Format.to_log(message) - } - - ::JSON.dump(hash) << "\n" + # @rbs (String severity, Time datetime, String? _progname, untyped result) -> String + def call(severity, datetime, _progname, result) + data = if result.is_a?(Result) + result.to_h.merge(severity:, timestamp: datetime&.iso8601) + else + { message: result.to_s, severity:, timestamp: datetime&.iso8601 } + end + "#{::JSON.generate(data)}\n" end end diff --git a/lib/cmdx/log_formatters/key_value.rb b/lib/cmdx/log_formatters/key_value.rb index 87e0f4087..c67a10d0a 100644 --- a/lib/cmdx/log_formatters/key_value.rb +++ b/lib/cmdx/log_formatters/key_value.rb @@ -2,37 +2,37 @@ module CMDx module LogFormatters - # Formats log messages as key-value pairs for structured logging - # - # This formatter converts log entries into key-value format with standardized fields - # including severity, timestamp, program name, process ID, and formatted message. - # The output is suitable for log parsing tools and human-readable structured logs. + # Formats log output as key=value pairs. class KeyValue - # Formats a log entry as a key-value string + # @param severity [String] log level + # @param datetime [Time] timestamp + # @param _progname [String, nil] program name + # @param result [Result] the result to format # - # @param severity [String] The log level (e.g., "INFO", "ERROR", "DEBUG") - # @param time [Time] The timestamp when the log entry was created - # @param progname [String, nil] The program name or identifier - # @param message [Object] The log message content + # @return [String] key=value formatted log line # - # @return [String] A key-value formatted log entry with a trailing newline - # - # @example Basic usage - # logger_formatter.call("INFO", Time.now, "MyApp", "User logged in") - # # => "severity=INFO timestamp=2024-01-15T10:30:45.123456Z progname=MyApp pid=12345 message=User logged in\n" - # - # @rbs (String severity, Time time, String? progname, String message) -> String - def call(severity, time, progname, message) - hash = { - severity:, - timestamp: time.utc.iso8601(6), - progname:, - pid: Process.pid, - message: Utils::Format.to_log(message) - } + # @rbs (String severity, Time datetime, String? _progname, untyped result) -> String + def call(severity, datetime, _progname, result) + pairs = [ + "severity=#{severity}", + "timestamp=#{datetime&.iso8601}" + ] + + if result.is_a?(Result) + pairs.push( + "task=#{result.task_class&.name}", + "task_id=#{result.task_id}", + "status=#{result.status}", + "state=#{result.state}" + ) + pairs << "reason=#{result.reason.inspect}" if result.reason + pairs << "retries=#{result.retries}" if result.retried? + else + pairs << "message=#{result}" + end - Utils::Format.to_str(hash) << "\n" + "#{pairs.join(' ')}\n" end end diff --git a/lib/cmdx/log_formatters/line.rb b/lib/cmdx/log_formatters/line.rb index a58b32ad4..068b2064e 100644 --- a/lib/cmdx/log_formatters/line.rb +++ b/lib/cmdx/log_formatters/line.rb @@ -2,29 +2,28 @@ module CMDx module LogFormatters - # Formats log messages as single-line text for human-readable logging - # - # This formatter converts log entries into a compact single-line format with - # severity abbreviation, ISO8601 timestamp, process ID, and formatted message. - # The output is optimized for human readability and traditional log file formats. + # Formats log output as a single human-readable line. class Line - # Formats a log entry as a single-line string + # @param _severity [String] log level + # @param _datetime [Time] timestamp + # @param _progname [String, nil] program name + # @param result [Result] the result to format # - # @param severity [String] The log level (e.g., "INFO", "ERROR", "DEBUG") - # @param time [Time] The timestamp when the log entry was created - # @param progname [String, nil] The program name or identifier - # @param message [Object] The log message content + # @return [String] formatted log line # - # @return [String] A single-line formatted log entry with a trailing newline - # - # @example Basic usage - # logger_formatter.call("INFO", Time.now, "MyApp", "User logged in") - # # => "I, [2024-01-15T10:30:45.123456Z #12345] INFO -- MyApp: User logged in\n" - # - # @rbs (String severity, Time time, String? progname, String message) -> String - def call(severity, time, progname, message) - "#{severity[0]}, [#{time.utc.iso8601(6)} ##{Process.pid}] #{severity} -- #{progname}: #{message}\n" + # @rbs (String _severity, Time _datetime, String? _progname, untyped result) -> String + def call(_severity, _datetime, _progname, result) + return "#{result}\n" unless result.is_a?(Result) + + parts = [ + "[#{result.status.upcase}]", + result.task_class&.name || "anonymous", + "(#{result.task_id})" + ] + parts << "reason=#{result.reason.inspect}" if result.reason + parts << "retries=#{result.retries}" if result.retried? + "#{parts.join(' ')}\n" end end diff --git a/lib/cmdx/log_formatters/logstash.rb b/lib/cmdx/log_formatters/logstash.rb index 4df2b9082..a91dff43f 100644 --- a/lib/cmdx/log_formatters/logstash.rb +++ b/lib/cmdx/log_formatters/logstash.rb @@ -1,40 +1,42 @@ # frozen_string_literal: true +require "json" + module CMDx module LogFormatters - # Formats log messages as Logstash-compatible JSON for structured logging - # - # This formatter converts log entries into Logstash-compatible JSON format with - # standardized fields including @version, @timestamp, severity, program name, - # process ID, and formatted message. The output follows Logstash event format - # specifications for seamless integration with ELK stack and similar systems. + # Formats log output in Logstash-compatible JSON format. class Logstash - # Formats a log entry as a Logstash-compatible JSON string - # - # @param severity [String] The log level (e.g., "INFO", "ERROR", "DEBUG") - # @param time [Time] The timestamp when the log entry was created - # @param progname [String, nil] The program name or identifier - # @param message [Object] The log message content + # @param severity [String] log level + # @param datetime [Time] timestamp + # @param _progname [String, nil] program name + # @param result [Result] the result to format # - # @return [String] A Logstash-compatible JSON-formatted log entry with a trailing newline + # @return [String] Logstash-compatible JSON log line # - # @example Basic usage - # logger_formatter.call("INFO", Time.now, "MyApp", "User logged in") - # # => '{"severity":"INFO","progname":"MyApp","pid":12345,"message":"User logged in","@version":"1","@timestamp":"2024-01-15T10:30:45.123456Z"}\n' - # - # @rbs (String severity, Time time, String? progname, String message) -> String - def call(severity, time, progname, message) - hash = { - severity:, - progname:, - pid: Process.pid, - message: Utils::Format.to_log(message), + # @rbs (String severity, Time datetime, String? _progname, untyped result) -> String + def call(severity, datetime, _progname, result) + data = { + "@timestamp" => datetime&.iso8601, "@version" => "1", - "@timestamp" => time.utc.iso8601(6) + "level" => severity } - ::JSON.dump(hash) << "\n" + if result.is_a?(Result) + data.merge!( + "task" => result.task_class&.name, + "task_id" => result.task_id, + "task_type" => result.task_type, + "status" => result.status, + "state" => result.state, + "reason" => result.reason, + "retries" => result.retries + ) + else + data["message"] = result.to_s + end + + "#{::JSON.generate(data)}\n" end end diff --git a/lib/cmdx/log_formatters/raw.rb b/lib/cmdx/log_formatters/raw.rb index 57a43fcdc..3b0b2cb1d 100644 --- a/lib/cmdx/log_formatters/raw.rb +++ b/lib/cmdx/log_formatters/raw.rb @@ -2,30 +2,20 @@ module CMDx module LogFormatters - # Formats log messages as raw text without additional formatting - # - # This formatter outputs log messages in their original form with minimal - # processing, adding only a trailing newline. It's useful for scenarios - # where you want to preserve the exact message content without metadata - # or structured formatting. + # Passes through the raw result hash without formatting. class Raw - # Formats a log entry as raw text + # @param _severity [String] log level + # @param _datetime [Time] timestamp + # @param _progname [String, nil] program name + # @param result [Result] the result to format # - # @param severity [String] The log level (e.g., "INFO", "ERROR", "DEBUG") - # @param time [Time] The timestamp when the log entry was created - # @param progname [String, nil] The program name or identifier - # @param message [Object] The log message content + # @return [String] raw string representation # - # @return [String] The raw message with a trailing newline - # - # @example Basic usage - # logger_formatter.call("INFO", Time.now, "MyApp", "User logged in") - # # => "User logged in\n" - # - # @rbs (String severity, Time time, String? progname, String message) -> String - def call(severity, time, progname, message) - "#{message}\n" + # @rbs (String _severity, Time _datetime, String? _progname, untyped result) -> String + def call(_severity, _datetime, _progname, result) + data = result.is_a?(Result) ? result.to_h : result + "#{data}\n" end end diff --git a/lib/cmdx/middleware_registry.rb b/lib/cmdx/middleware_registry.rb index a1d5070dc..094a0e9e1 100644 --- a/lib/cmdx/middleware_registry.rb +++ b/lib/cmdx/middleware_registry.rb @@ -1,147 +1,74 @@ # frozen_string_literal: true module CMDx - # Registry for managing middleware components in a task execution chain. - # - # The MiddlewareRegistry maintains an ordered list of middleware components - # that can be inserted, removed, and executed in sequence. Each middleware - # can be configured with specific options and is executed in the order - # they were registered. - # - # Supports copy-on-write semantics: a duped registry shares the parent's - # data until a write operation triggers materialization. + # Registry of middleware classes for a task. + # Middleware wraps execution in an onion-style call chain. + # Uses copy-on-write for safe inheritance across task classes. class MiddlewareRegistry - # Initialize a new middleware registry. - # - # @param registry [Array, nil] Initial array of middleware entries - # - # @example - # registry = MiddlewareRegistry.new - # registry = MiddlewareRegistry.new([[MyMiddleware, {option: 'value'}]]) - # - # @rbs (?Array[Array[untyped]]? registry) -> void - def initialize(registry = nil) - @registry = registry || [] - end + # @rbs @stack: Array[untyped] + attr_reader :stack - # Sets up copy-on-write state when duplicated via dup. - # - # @param source [MiddlewareRegistry] The registry being duplicated - # - # @rbs (MiddlewareRegistry source) -> void - def initialize_dup(source) - @parent = source - @registry = nil - super + # @rbs (?Array[untyped]? stack) -> void + def initialize(stack = nil) + @stack = stack || [] end - # Returns the ordered collection of middleware entries. - # Delegates to the parent registry when not yet materialized. - # - # @return [Array] Array of middleware-options pairs - # - # @example - # registry.registry # => [[LoggingMiddleware, {level: :debug}], [AuthMiddleware, {}]] - # - # @rbs () -> Array[Array[untyped]] - def registry - @registry || @parent.registry - end - alias to_a registry - - # Register a middleware component in the registry. - # - # @param middleware [Object] The middleware object to register - # @param at [Integer] Position to insert the middleware (default: -1, end of list) - # @param options [Hash] Configuration options for the middleware - # @option options [Symbol] :key Configuration key for the middleware - # @option options [Object] :value Configuration value for the middleware - # - # @return [MiddlewareRegistry] Returns self for method chaining + # Adds a middleware to the stack. # - # @example - # registry.register(LoggingMiddleware, at: 0, log_level: :debug) - # registry.register(AuthMiddleware, at: -1, timeout: 30) + # @param klass [Class] the middleware class (must respond to .call or #call) + # @param args [Array] arguments to pass to the middleware # - # @rbs (untyped middleware, ?at: Integer, **untyped options) -> self - def register(middleware, at: -1, **options) - materialize! - - @registry.insert(at, [middleware, options]) - self + # @rbs (untyped klass, *untyped args) -> void + def register(klass, *args) + stack << { klass:, args: } end - # Remove a middleware component from the registry. - # - # @param middleware [Object] The middleware object to remove + # Removes a middleware from the stack. # - # @return [MiddlewareRegistry] Returns self for method chaining + # @param klass [Class] the middleware class to remove # - # @example - # registry.deregister(LoggingMiddleware) - # - # @rbs (untyped middleware) -> self - def deregister(middleware) - materialize! - - @registry.reject! { |mw, _opts| mw == middleware } - self + # @rbs (untyped klass) -> void + def deregister(klass) + stack.reject! { |entry| entry[:klass] == klass } end - # Execute the middleware chain for a given task. - # - # @param task [Object] The task object to process through middleware - # - # @yield [task] Block to execute after all middleware processing - # @yieldparam task [Object] The processed task object - # - # @return [Object] Result of the block execution + # Executes the middleware stack around the given block. # - # @raise [ArgumentError] When no block is provided + # @param task [Task] the task instance + # @yield the inner work to wrap # - # @example - # result = registry.call!(my_task) do |processed_task| - # processed_task.execute - # end + # @return [Object] the block's return value # - # @rbs (untyped task) { (untyped) -> untyped } -> untyped - def call!(task, &) - raise ArgumentError, "block required" unless block_given? + # @rbs (untyped task) { () -> untyped } -> untyped + def call(task, &block) + return yield if stack.empty? - recursively_call_middleware(0, task, &) + chain = stack.reverse.reduce(block) do |next_middleware, entry| + yielded = false + proc do + entry[:klass].call(task, *entry[:args]) do + yielded = true + next_middleware.call + end + raise MiddlewareError, "#{entry[:klass]} did not yield" unless yielded + end + end + chain.call end - private - - # Copies the parent's registry data into this instance, - # severing the copy-on-write link. + # @return [Boolean] true if any middleware is registered # - # @rbs () -> void - def materialize! - return if @registry - - @registry = @parent.registry.map(&:dup) - @parent = nil + # @rbs () -> bool + def any? + !stack.empty? end - # Recursively execute middleware in the chain. - # - # @param index [Integer] Current middleware index in the chain - # @param task [Object] The task object being processed - # @param block [Proc] Block to execute after middleware processing + # @return [MiddlewareRegistry] a duplicated registry for child classes # - # @yield [task] Block to execute after middleware processing - # @yieldparam task [Object] The processed task object - # - # @return [Object] Result of the block execution or next middleware call - # - # @rbs (Integer index, untyped task) { (untyped) -> untyped } -> untyped - def recursively_call_middleware(index, task, &block) - return yield(task) if index >= registry.size - - middleware, options = registry[index] - middleware.call(task, **options) { recursively_call_middleware(index + 1, task, &block) } + # @rbs () -> MiddlewareRegistry + def for_child + self.class.new(stack.dup) end end diff --git a/lib/cmdx/middlewares/correlate.rb b/lib/cmdx/middlewares/correlate.rb index aa9fcdf0f..2f4648ddc 100644 --- a/lib/cmdx/middlewares/correlate.rb +++ b/lib/cmdx/middlewares/correlate.rb @@ -2,137 +2,16 @@ module CMDx module Middlewares - # Middleware for correlating task executions with unique identifiers. - # - # The Correlate middleware provides thread and fiber safe correlation ID management - # for tracking task execution flows across different operations. It automatically - # generates correlation IDs when none are provided and stores them in task result - # metadata for traceability. + # Assigns a correlation ID to the context for tracing. module Correlate - extend self - - # @rbs CONCURRENCY_KEY: Symbol - CONCURRENCY_KEY = :cmdx_correlate - - # Retrieves the current correlation ID from local storage. - # - # @return [String, nil] The current correlation ID or nil if not set - # - # @example Get current correlation ID - # Correlate.id # => "550e8400-e29b-41d4-a716-446655440000" - # - # @rbs () -> String? - def id - thread_or_fiber[CONCURRENCY_KEY] - end - - # Sets the correlation ID in local storage. - # - # @param id [String] The correlation ID to set - # @return [String] The set correlation ID - # - # @example Set correlation ID - # Correlate.id = "abc-123-def" - # - # @rbs (String id) -> String - def id=(id) - thread_or_fiber[CONCURRENCY_KEY] = id - end - - # Clears the current correlation ID from local storage. - # - # @return [nil] Always returns nil - # - # @example Clear correlation ID - # Correlate.clear - # - # @rbs () -> nil - def clear - thread_or_fiber[CONCURRENCY_KEY] = nil - end - - # Temporarily uses a new correlation ID for the duration of a block. - # Restores the previous ID after the block completes, even if an error occurs. + # @param task [Task] the task instance + # @param key [Symbol] the context key # - # @param new_id [String] The correlation ID to use temporarily - # @yield The block to execute with the new correlation ID - # @return [Object] The result of the yielded block - # - # @example Use temporary correlation ID - # Correlate.use("temp-id") do - # # Operations here use "temp-id" - # perform_operation - # end - # # Previous ID is restored - # - # @rbs (String new_id) { () -> untyped } -> untyped - def use(new_id) - old_id = id - self.id = new_id + # @rbs (untyped task, ?Symbol key) { () -> untyped } -> untyped + def self.call(task, key = :correlation_id, &) + task.context.fetch_or_store(key) { Identifier.generate } yield - ensure - self.id = old_id - end - - # Middleware entry point that applies correlation ID logic to task execution. - # - # Evaluates the condition from options and applies correlation ID handling - # if enabled. Generates or retrieves correlation IDs based on the :id option - # and stores them in task result metadata. - # - # @param task [Task] The task being executed - # @param options [Hash] Configuration options for correlation - # @option options [Symbol, Proc, Object, nil] :id The correlation ID source - # @option options [Symbol, Proc, Object, nil] :if Condition to enable correlation - # @option options [Symbol, Proc, Object, nil] :unless Condition to disable correlation - # - # @yield The task execution block - # - # @return [Object] The result of task execution - # - # @example Basic usage with automatic ID generation - # Correlate.call(task, &block) - # @example Use custom correlation ID - # Correlate.call(task, id: "custom-123", &block) - # @example Use task method for ID - # Correlate.call(task, id: :correlation_id, &block) - # @example Use proc for dynamic ID generation - # Correlate.call(task, id: -> { "dynamic-#{Time.now.to_i}" }, &block) - # @example Conditional correlation - # Correlate.call(task, if: :enable_correlation, &block) - # - # @rbs (Task task, **untyped options) { () -> untyped } -> untyped - def call(task, **options, &) - return yield unless Utils::Condition.evaluate(task, options) - - correlation_id = task.result.metadata[:correlation_id] ||= - id || - case callable = options[:id] - when Symbol then task.send(callable) - when Proc then task.instance_eval(&callable) - else - if callable.respond_to?(:call) - callable.call(task) - else - callable || id || Identifier.generate - end - end - - use(correlation_id, &) - end - - private - - # Returns the thread or fiber storage for the current execution context. - # - # @return [Hash] The thread or fiber storage - # - # @rbs () -> Hash - if Fiber.respond_to?(:storage) - def thread_or_fiber = Fiber.storage - else - def thread_or_fiber = Thread.current end end diff --git a/lib/cmdx/middlewares/runtime.rb b/lib/cmdx/middlewares/runtime.rb deleted file mode 100644 index 09d102ed6..000000000 --- a/lib/cmdx/middlewares/runtime.rb +++ /dev/null @@ -1,77 +0,0 @@ -# frozen_string_literal: true - -module CMDx - module Middlewares - # Middleware for measuring task execution runtime. - # - # The Runtime middleware provides performance monitoring by measuring - # the execution time of tasks using monotonic clock for accuracy. - # It stores runtime measurements in task result metadata for analysis. - module Runtime - - extend self - - # Middleware entry point that measures task execution runtime and - # task execution start and end times. - # - # Evaluates the condition from options and measures execution time - # if enabled. Uses monotonic clock for precise timing measurements - # and stores the result in task metadata. - # - # @param task [Task] The task being executed - # @param options [Hash] Configuration options for runtime measurement - # @option options [Symbol, Proc, Object, nil] :if Condition to enable runtime measurement - # @option options [Symbol, Proc, Object, nil] :unless Condition to disable runtime measurement - # - # @yield The task execution block - # - # @return [Object] The result of task execution - # - # @example Basic usage with automatic runtime measurement - # Runtime.call(task, &block) - # @example Conditional runtime measurement - # Runtime.call(task, if: :enable_profiling, &block) - # @example Disable runtime measurement - # Runtime.call(task, unless: :skip_profiling, &block) - # - # @rbs (Task task, **untyped options) { () -> untyped } -> untyped - def call(task, **options) - unow = utc_time - mnow = monotonic_time - return yield unless Utils::Condition.evaluate(task, options) - - result = yield - task.result.metadata.merge!( - runtime: monotonic_time - mnow, - ended_at: utc_time, - started_at: unow - ) - result - end - - private - - # Gets the current monotonic time in milliseconds. - # - # Uses Process.clock_gettime with CLOCK_MONOTONIC for consistent - # timing measurements that are not affected by system clock changes. - # - # @return [Integer] Current monotonic time in milliseconds - # - # @rbs () -> Integer - def monotonic_time - Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) - end - - # Gets the current UTC time in ISO 8601 format. - # - # @return [String] Current UTC time in ISO 8601 format - # - # @rbs () -> String - def utc_time - Time.now.utc.iso8601 - end - - end - end -end diff --git a/lib/cmdx/middlewares/runtime_tracker.rb b/lib/cmdx/middlewares/runtime_tracker.rb new file mode 100644 index 000000000..3a041fb39 --- /dev/null +++ b/lib/cmdx/middlewares/runtime_tracker.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module CMDx + module Middlewares + # Tracks the wall clock time of task execution in the context. + module RuntimeTracker + + # @param task [Task] the task instance + # @param key [Symbol] the context key for storing the duration + # + # @rbs (untyped task, ?Symbol key) { () -> untyped } -> untyped + def self.call(task, key = :runtime_ms, &) + start = Process.clock_gettime(Process::CLOCK_MONOTONIC) + yield + ensure + elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round(2) + task.context[key] = elapsed + end + + end + end +end diff --git a/lib/cmdx/middlewares/timeout.rb b/lib/cmdx/middlewares/timeout.rb index a7dad82b2..f4be59bba 100644 --- a/lib/cmdx/middlewares/timeout.rb +++ b/lib/cmdx/middlewares/timeout.rb @@ -1,74 +1,21 @@ # frozen_string_literal: true +require "timeout" + module CMDx module Middlewares - # Middleware for enforcing execution time limits on tasks. - # - # The Timeout middleware provides execution time control by wrapping - # task execution with Ruby's Timeout module. It automatically fails - # tasks that exceed the configured time limit and provides detailed - # error information including the exceeded limit. + # Enforces a maximum execution time for the task. module Timeout - extend self - - # Default timeout limit in seconds when none is specified. - # - # @rbs DEFAULT_LIMIT: Integer - DEFAULT_LIMIT = 3 - - # Middleware entry point that enforces execution time limits. - # - # Evaluates the condition from options and applies timeout control - # if enabled. Supports various timeout limit configurations including - # numeric values, task method calls, and dynamic proc evaluation. - # - # @param task [Task] The task being executed - # @param options [Hash] Configuration options for timeout control - # @option options [Numeric, Symbol, Proc, Object] :seconds The timeout limit source - # @option options [Symbol, Proc, Object, nil] :if Condition to enable timeout control - # @option options [Symbol, Proc, Object, nil] :unless Condition to disable timeout control - # - # @yield The task execution block + # @param _task [Task] the task instance (reserved for API symmetry) + # @param seconds [Numeric] timeout in seconds # - # @return [Object] The result of task execution - # - # @raise [TimeoutError] When execution exceeds the configured limit - # - # @example Basic usage with default 3 second timeout - # Timeout.call(task, &block) - # @example Custom timeout limit in seconds - # Timeout.call(task, seconds: 10, &block) - # @example Use task method for timeout limit - # Timeout.call(task, seconds: :timeout_limit, &block) - # @example Use proc for dynamic timeout calculation - # Timeout.call(task, seconds: -> { calculate_timeout }, &block) - # @example Conditional timeout control - # Timeout.call(task, if: :enable_timeout, &block) - # - # @rbs (Task task, **untyped options) { () -> untyped } -> untyped - def call(task, **options, &) - return yield unless Utils::Condition.evaluate(task, options) - - limit = - case callable = options[:seconds] - when Numeric then callable - when Symbol then task.send(callable) - when Proc then task.instance_eval(&callable) - else callable.respond_to?(:call) ? callable.call(task) : DEFAULT_LIMIT - end - - limit = Float(limit) - return yield unless limit.positive? - - ::Timeout.timeout(limit, TimeoutError, "execution exceeded #{limit} seconds", &) - rescue TimeoutError => e - task.resolver.fail!( - Utils::Normalize.exception(e), - cause: e, - source: :timeout, - limit: - ) + # @rbs (untyped _task, Numeric seconds) { () -> untyped } -> untyped + def self.call(_task, seconds = 30, &) + ::Timeout.timeout(seconds, &) + rescue ::Timeout::Error + # Stdlib ::Timeout may raise outside the Task `catch(:cmdx_signal)` scope; raise so Runtime records failure. + raise StandardError, "execution timed out after #{seconds}s" end end diff --git a/lib/cmdx/outcome.rb b/lib/cmdx/outcome.rb new file mode 100644 index 000000000..469a57156 --- /dev/null +++ b/lib/cmdx/outcome.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module CMDx + + # Mutable scratch pad used by the Runtime to track execution state. + # Converted into an immutable Result after execution completes. + # + # @!attribute state + # @return [Symbol] current lifecycle state + # @!attribute status + # @return [Symbol] business outcome status + # @!attribute reason + # @return [String, nil] interruption reason + # @!attribute cause + # @return [Exception, nil] causing exception + # @!attribute metadata + # @return [Hash] additional execution data + # @!attribute strict + # @return [Boolean] whether breakpoints apply + # @!attribute retries + # @return [Integer] retry attempt count + # @!attribute rolled_back + # @return [Boolean] whether rollback was performed + Outcome = Struct.new( + :state, :status, :reason, :cause, :metadata, + :strict, :retries, :rolled_back, + keyword_init: true + ) do + def initialize(**) + super + self.state ||= "initialized" + self.status ||= "success" + self.metadata ||= {} + self.strict = true if strict.nil? + self.retries ||= 0 + self.rolled_back ||= false + end + + # @rbs () -> bool + def success? + status == "success" + end + + # @rbs () -> bool + def failed? + status == "failed" + end + + # @rbs () -> bool + def skipped? + status == "skipped" + end + + # Applies a signal hash from throw/catch control flow. + # + # @param signal [Hash] signal data from Task control flow methods + # + # @rbs (Hash[Symbol, untyped] signal) -> void + def apply_signal(signal) + return unless signal + + self.status = signal[:status].to_s if signal[:status] + self.reason = signal[:reason] || Locale.t("cmdx.reasons.unspecified") unless success? + self.cause = signal[:cause] if signal.key?(:cause) + self.strict = signal.fetch(:strict, true) + self.metadata = metadata.merge(signal[:metadata] || {}) + end + end + +end diff --git a/lib/cmdx/parallelizer.rb b/lib/cmdx/parallelizer.rb index ea51f275f..5e26ed886 100644 --- a/lib/cmdx/parallelizer.rb +++ b/lib/cmdx/parallelizer.rb @@ -1,97 +1,60 @@ # frozen_string_literal: true module CMDx - # Bounded thread pool that processes items concurrently. - # - # Distributes work across a fixed number of threads using a queue, - # collecting results in submission order. + # Bounded thread pool for parallel task execution. class Parallelizer - # Returns the items to process. - # - # @return [Array] the items to process - # - # @example - # parallelizer.items # => [1, 2, 3] - # - # @rbs @items: Array[untyped] - attr_reader :items - - # Returns the number of threads in the pool. - # - # @return [Integer] the thread pool size - # - # @example - # parallelizer.pool_size # => 4 - # + # @return [Integer] maximum concurrent threads # @rbs @pool_size: Integer attr_reader :pool_size - # Creates a new Parallelizer instance. - # - # @param items [Array] the items to process concurrently - # @param pool_size [Integer] number of threads (defaults to item count) - # - # @return [Parallelizer] a new parallelizer instance - # - # @example - # Parallelizer.new([1, 2, 3], pool_size: 2) + # @param pool_size [Integer] maximum concurrent threads # - # @rbs (Array[untyped] items, ?pool_size: Integer) -> void - def initialize(items, pool_size: nil) - @items = items - @pool_size = Integer(pool_size || items.size) + # @rbs (?Integer pool_size) -> void + def initialize(pool_size = 5) + @pool_size = pool_size end - # Processes items concurrently and returns results in submission order. + # Executes work items in parallel with bounded concurrency. # - # @param items [Array] the items to process concurrently - # @param pool_size [Integer] number of threads (defaults to item count) + # @param items [Array] items to process + # @yield [item] block to execute per item # - # @yield [item] block called for each item in a worker thread - # @yieldparam item [Object] an item from the items array - # @yieldreturn [Object] the result for this item + # @return [Array] results in order # - # @return [Array] results in the same order as input items - # - # @example - # Parallelizer.call([1, 2, 3], pool_size: 2) { |n| n * 10 } - # # => [10, 20, 30] - # - # @rbs [T, R] (Array[T] items, ?pool_size: Integer) { (T) -> R } -> Array[R] - def self.call(items, pool_size: nil, &block) - new(items, pool_size:).call(&block) - end + # @rbs (Array[untyped] items) { (untyped) -> untyped } -> Array[untyped] + def call(items, &block) + return [] if items.empty? + return items.map(&block) if items.size == 1 - # Distributes items across the thread pool and returns results - # in submission order. - # - # @yield [item] block called for each item in a worker thread - # @yieldparam item [Object] an item from the items array - # @yieldreturn [Object] the result for this item - # - # @return [Array] results in the same order as input items - # - # @example - # Parallelizer.new(%w[a b c]).call { |s| s.upcase } - # # => ["A", "B", "C"] - # - # @rbs [T, R] () { (T) -> R } -> Array[R] - def call(&block) - results = Array.new(items.size) queue = Queue.new - items.each_with_index { |item, i| queue << [item, i] } - pool_size.times { queue << nil } - Array.new(pool_size) do + results = ::Array.new(items.size) + errors = [] + mutex = Mutex.new + n_threads = [pool_size, items.size].min + + workers = Array.new(n_threads) do Thread.new do - while (entry = queue.pop) - item, index = entry - results[index] = block.call(item) # rubocop:disable Performance/RedundantBlockCall + loop do + item, idx = + begin + queue.pop(true) + rescue ThreadError + break + end + begin + results[idx] = block.call(item) # rubocop:disable Performance/RedundantBlockCall + rescue StandardError => e + mutex.synchronize { errors << e } + end end end - end.each(&:join) + end + + workers.each(&:join) + raise errors.first if errors.any? results end diff --git a/lib/cmdx/pipeline.rb b/lib/cmdx/pipeline.rb index 40eeb6741..d97a5d65a 100644 --- a/lib/cmdx/pipeline.rb +++ b/lib/cmdx/pipeline.rb @@ -1,168 +1,77 @@ # frozen_string_literal: true module CMDx - # Executes workflows by processing task groups with conditional logic and breakpoint handling. - # The Pipeline class manages the execution flow of workflow tasks, evaluating conditions - # and handling breakpoints that can interrupt execution at specific task statuses. + # Executes a series of task entries sequentially or in parallel. + # Used by Workflow to orchestrate multi-task execution. class Pipeline - # @rbs SEQUENTIAL_REGEXP: Regexp - SEQUENTIAL_REGEXP = /\Asequential\z/ - private_constant :SEQUENTIAL_REGEXP - - # @rbs PARALLEL_REGEXP: Regexp - PARALLEL_REGEXP = /\Aparallel\z/ - private_constant :PARALLEL_REGEXP - - # Returns the workflow being executed by this pipeline. - # - # @return [Workflow] The workflow instance + # @param entries [Array] the task entries to execute + # @param context [Context] shared execution context + # @param chain [Chain] the result chain # - # @example - # pipeline.workflow.context[:status] # => "processing" - # - # @rbs @workflow: Workflow - attr_reader :workflow - - # @param workflow [Workflow] The workflow to execute + # @return [Chain] the chain with all results # - # @return [Pipeline] A new pipeline instance - # - # @example - # pipeline = Pipeline.new(my_workflow) - # - # @rbs (Workflow workflow) -> void - def initialize(workflow) - @workflow = workflow + # @rbs (Array[Hash[Symbol, untyped]] entries, Context context, Chain chain) -> Chain + def self.call(entries, context, chain) + new(entries, context, chain).call end - # Executes a workflow using a new pipeline instance. - # - # @param workflow [Workflow] The workflow to execute - # - # @return [void] - # - # @example - # Pipeline.execute(my_workflow) - # - # @rbs (Workflow workflow) -> void - def self.execute(workflow) - new(workflow).execute + # @rbs (Array[Hash[Symbol, untyped]] entries, Context context, Chain chain) -> void + def initialize(entries, context, chain) + @entries = entries + @context = context + @chain = chain end - # Executes the workflow by processing all task groups in sequence. - # Each group is evaluated against its conditions, and breakpoints are checked - # after each task execution to determine if workflow should continue or halt. - # - # @return [void] - # - # @example - # pipeline = Pipeline.new(my_workflow) - # pipeline.execute - # - # @rbs () -> void - def execute - default_breakpoints = Utils::Normalize.statuses( - workflow.class.settings.breakpoints || - workflow.class.settings.workflow_breakpoints - ) - - workflow.class.pipeline.each do |group| - next unless Utils::Condition.evaluate(workflow, group.options) - - breakpoints = - if group.options.key?(:breakpoints) - Utils::Normalize.statuses(group.options[:breakpoints]) - else - default_breakpoints - end - - execute_group_tasks(group, breakpoints) + # @rbs () -> Chain + def call + @entries.each do |entry| + if entry[:parallel] + execute_parallel(entry[:tasks]) + else + result = execute_task(entry) + break if should_halt?(result, entry) + end end + @chain end private - # Executes a group of tasks using the specified execution strategy. - # - # @param group [CMDx::Group] The task group to execute - # @param breakpoints [Array] Status values that trigger execution breaks - # @option group.options [Symbol, String] :strategy Execution strategy (:sequential, :parallel, or nil for default) - # - # @return [void] - # - # @example - # execute_group_tasks(group, ["failed", "skipped"]) - # - # @rbs (untyped group, Array[String] breakpoints) -> void - def execute_group_tasks(group, breakpoints) - case strategy = group.options[:strategy] - when NilClass, SEQUENTIAL_REGEXP then execute_tasks_in_sequence(group, breakpoints) - when PARALLEL_REGEXP then execute_tasks_in_parallel(group, breakpoints) - else raise "unknown execution strategy #{strategy.inspect}" - end - end + # @rbs (Hash[Symbol, untyped] entry) -> Result + def execute_task(entry) + task_class = entry[:task] + options = entry.fetch(:options, {}) - # Executes tasks sequentially within a group, checking breakpoints after each task. - # If a task result status matches a breakpoint, the workflow is interrupted. - # - # @param group [ExecutionGroup] The group of tasks to execute - # @param breakpoints [Array] Breakpoint statuses that trigger workflow interruption - # - # @return [void] - # - # @raise [HaltError] When a task result status matches a breakpoint - # - # @example - # execute_tasks_in_sequence(group, ["failed", "skipped"]) - # - # @rbs (untyped group, Array[String] breakpoints) -> void - def execute_tasks_in_sequence(group, breakpoints) - group.tasks.each do |task| - task_result = task.execute(workflow.context) - next unless breakpoints.include?(task_result.status) + result = task_class.execute(**@context.to_h) - workflow.throw!(task_result) + if result.bad? && options[:on_failure] == :skip + # Continue execution despite failure + elsif result.bad? && result.strict? + # Halt on strict failures (default) end + + result end - # Each task receives a snapshot of the workflow context to prevent - # unsynchronized concurrent writes to a shared Hash. Snapshots are - # merged back into the workflow context after all tasks complete. - # - # @param group [CMDx::Group] The task group to execute in parallel - # @param breakpoints [Array] Status values that trigger execution breaks - # @option group.options [Integer] :pool_size Number of concurrent threads (defaults to task count) - # - # @return [void] - # - # @raise [Fault] When a task result status matches a breakpoint - # - # @example - # execute_tasks_in_parallel(group, ["failed"]) - # - # @rbs (untyped group, Array[String] breakpoints) -> void - def execute_tasks_in_parallel(group, breakpoints) - contexts = group.tasks.map { Context.new(workflow.context.to_h) } - ctx_pairs = group.tasks.zip(contexts) - pool_size = group.options.fetch(:pool_size, ctx_pairs.size) + # @rbs (Array[Hash[Symbol, untyped]] tasks) -> Array[Result] + def execute_parallel(tasks) + pool_size = tasks.first&.dig(:options, :pool_size) || 5 + parallelizer = Parallelizer.new(pool_size) - results = Parallelizer.call(ctx_pairs, pool_size:) do |task, context| - Chain.current = workflow.chain - task.execute(context) + parallelizer.call(tasks) do |entry| + entry[:task].execute(**@context.to_h) end + end - contexts.each { |ctx| workflow.context.merge!(ctx) } + # @rbs (Result result, Hash[Symbol, untyped] entry) -> bool + def should_halt?(result, entry) + return false if result.success? - faulted = results.select { |r| breakpoints.include?(r.status) } - return if faulted.empty? + on_failure = entry.dig(:options, :on_failure) + return false if %i[skip none].include?(on_failure) - workflow.public_send( - :"#{faulted.last.status}!", - Locale.t("cmdx.reasons.unspecified"), - source: :parallel, - faults: faulted.map(&:to_h) - ) + result.strict? end end diff --git a/lib/cmdx/railtie.rb b/lib/cmdx/railtie.rb index 8b4d03fd4..73aa319b1 100644 --- a/lib/cmdx/railtie.rb +++ b/lib/cmdx/railtie.rb @@ -1,47 +1,20 @@ # frozen_string_literal: true module CMDx - # Rails integration class that automatically configures CMDx when the Rails - # application initializes. Handles locale configuration and I18n setup. + # Railtie for Rails integration. + # Loads locale files and sets up the Rails logger. class Railtie < Rails::Railtie - railtie_name :cmdx - - # Configures CMDx locales during Rails application initialization. - # - # Iterates through available locales from the Rails configuration and loads - # corresponding CMDx locale files. Reloads the I18n system to ensure - # all locales are properly registered. - # - # @param app [Rails::Application] the Rails application instance - # - # @raise [LoadError] if locale files cannot be loaded - # - # @example - # # This initializer runs automatically when Rails starts - # # It will load locales like en.yml, es.yml, fr.yml if they exist - # # in the CMDx gem's locales directory - # - # @rbs (untyped app) -> void - initializer("cmdx.configure_locales") do |app| - Utils::Wrap.array(app.config.i18n.available_locales).each do |locale| - path = CMDx.gem_path.join("lib/locales/#{locale}.yml") - next unless File.file?(path) - - ::I18n.load_path << path + initializer "cmdx.i18n" do + if defined?(I18n) + locale_path = File.expand_path("../../locales/*.yml", __dir__) + I18n.load_path += Dir[locale_path] end - - ::I18n.reload! end - # Configures the backtrace cleaner for CMDx in a Rails environment. - # - # Sets the backtrace cleaner to the Rails backtrace cleaner. - # - # @rbs () -> void - initializer("cmdx.backtrace_cleaner") do - CMDx.configuration.backtrace_cleaner = lambda do |backtrace| - Rails.backtrace_cleaner.clean(backtrace) + initializer "cmdx.logger" do + config.after_initialize do + CMDx.configuration.logger ||= Rails.logger end end diff --git a/lib/cmdx/resolver.rb b/lib/cmdx/resolver.rb deleted file mode 100644 index d201eea3b..000000000 --- a/lib/cmdx/resolver.rb +++ /dev/null @@ -1,263 +0,0 @@ -# frozen_string_literal: true - -module CMDx - # Manages state transitions for a {CMDx::Result}, handling success annotation, - # skipping, failure, throwing, and halting with fault raising. - # - # The Resolver owns the +strict+ flag and all transition logic. It writes to the - # Result via +instance_variable_set+ so that Result remains a pure data object. - # - # @example Accessed through the task - # task.resolver.fail!("Validation failed", cause: validation_error) - # task.resolver.skip!("Already processed", halt: false) - class Resolver - - extend Forwardable - - # Returns the result being resolved. - # - # @return [CMDx::Result] The result instance - # - # @rbs @result: Result - attr_reader :result - - def_delegators :result, :task, :success?, :skipped?, :failed?, - :initialized?, :executing?, :complete?, :interrupted? - - # @param result [CMDx::Result] The result to manage transitions for - # - # @return [CMDx::Resolver] A new resolver instance - # - # @raise [TypeError] When result is not a CMDx::Result instance - # - # @example - # resolver = CMDx::Resolver.new(result) - # - # @rbs (Result) -> void - def initialize(result) - raise TypeError, "must be a CMDx::Result" unless result.is_a?(Result) - - @result = result - end - - # Transitions the result to the appropriate final state based on status. - # Delegates to {#complete!} when successful, {#interrupt!} otherwise. - # - # @return [void] - # - # @example - # resolver.executed! - # - # @rbs () -> void - def executed! - success? ? complete! : interrupt! - end - - # Transitions the result state from initialized to executing. - # - # @raise [RuntimeError] When attempting to transition from invalid state - # - # @example - # resolver.executing! - # - # @rbs () -> void - def executing! - return if executing? - - raise "can only transition to #{Result::EXECUTING} from #{Result::INITIALIZED}" unless initialized? - - assign(state: Result::EXECUTING) - end - - # Transitions the result state from executing to complete. - # - # @raise [RuntimeError] When attempting to transition from invalid state - # - # @example - # resolver.complete! - # - # @rbs () -> void - def complete! - return if complete? - - raise "can only transition to #{Result::COMPLETE} from #{Result::EXECUTING}" unless executing? - - assign(state: Result::COMPLETE) - end - - # Transitions the result state to interrupted. - # - # @raise [RuntimeError] When attempting to transition from invalid state - # - # @example - # resolver.interrupt! - # - # @rbs () -> void - def interrupt! - return if interrupted? - - raise "cannot transition to #{Result::INTERRUPTED} from #{Result::COMPLETE}" if complete? - - assign(state: Result::INTERRUPTED) - end - - # Sets a reason and optional metadata on a successful result without - # changing its state or status. Useful for annotating why a task succeeded. - # When halt is true, uses throw/catch to exit the work method early. - # - # @param reason [String, nil] Reason or note for the success - # @param halt [Boolean] Whether to halt execution after success - # @param metadata [Hash] Additional metadata about the success - # @option metadata [Object] :* Any key-value pairs for additional metadata - # - # @raise [RuntimeError] When status is not success - # - # @example - # resolver.success!("Created 42 records") - # resolver.success!("Imported", halt: false, rows: 100) - # - # @rbs (?String? reason, halt: bool, **untyped metadata) -> void - def success!(reason = nil, halt: true, **metadata) - raise "can only be used while #{Result::SUCCESS}" unless success? - - assign( - reason:, - metadata: - ) - - throw(:cmdx_halt) if halt - end - - # @param reason [String, nil] Reason for skipping the task - # @param halt [Boolean] Whether to halt execution after skipping - # @param cause [Exception, nil] Exception that caused the skip - # @param strict [Boolean] Whether this skip is strict (default: true). - # @param metadata [Hash] Additional metadata about the skip - # @option metadata [Object] :* Any key-value pairs for additional metadata - # - # @raise [RuntimeError] When attempting to skip from invalid status - # - # @example - # resolver.skip!("Dependencies not met", cause: dependency_error) - # resolver.skip!("Already processed", halt: false) - # resolver.skip!("Optional step", strict: false) - # - # @rbs (?String? reason, halt: bool, cause: Exception?, strict: bool, **untyped metadata) -> void - def skip!(reason = nil, halt: true, cause: nil, strict: true, **metadata) - return if skipped? - - raise "can only transition to #{Result::SKIPPED} from #{Result::SUCCESS}" unless success? - - assign( - state: Result::INTERRUPTED, - status: Result::SKIPPED, - reason: reason || Locale.t("cmdx.reasons.unspecified"), - cause:, - strict:, - metadata: - ) - - halt! if halt - end - - # @param reason [String, nil] Reason for task failure - # @param halt [Boolean] Whether to halt execution after failure - # @param cause [Exception, nil] Exception that caused the failure - # @param strict [Boolean] Whether this failure is strict (default: true). - # @param metadata [Hash] Additional metadata about the failure - # @option metadata [Object] :* Any key-value pairs for additional metadata - # - # @raise [RuntimeError] When attempting to fail from invalid status - # - # @example - # resolver.fail!("Validation failed", cause: validation_error) - # resolver.fail!("Network timeout", halt: false, timeout: 30) - # resolver.fail!("Soft failure", strict: false) - # - # @rbs (?String? reason, halt: bool, cause: Exception?, strict: bool, **untyped metadata) -> void - def fail!(reason = nil, halt: true, cause: nil, strict: true, **metadata) - return if failed? - - raise "can only transition to #{Result::FAILED} from #{Result::SUCCESS}" unless success? - - assign( - state: Result::INTERRUPTED, - status: Result::FAILED, - reason: reason || Locale.t("cmdx.reasons.unspecified"), - cause:, - strict:, - metadata: - ) - - halt! if halt - end - - # @raise [SkipFault] When task was skipped - # @raise [FailFault] When task failed - # - # @example - # resolver.halt! - # - # @rbs () -> void - def halt! - return if success? - - klass = skipped? ? SkipFault : FailFault - fault = klass.new(result) - - frames = caller_locations(3..-1) - - unless frames.empty? - frames = frames.map(&:to_s) - - if (cleaner = task.class.settings.backtrace_cleaner) - cleaner.call(frames) - end - - fault.set_backtrace(frames) - end - - raise(fault) - end - - # @param other [CMDx::Result] Result to throw from current result - # @param halt [Boolean] Whether to halt execution after throwing - # @param cause [Exception, nil] Exception that caused the throw - # @param metadata [Hash] Additional metadata to merge - # @option metadata [Object] :* Any key-value pairs for additional metadata - # - # @raise [TypeError] When other is not a CMDx::Result instance - # - # @example - # other_result = OtherTask.execute - # resolver.throw!(other_result, cause: upstream_error) - # - # @rbs (Result other, halt: bool, cause: Exception?, **untyped metadata) -> void - def throw!(other, halt: true, cause: nil, **metadata) - raise TypeError, "must be a CMDx::Result" unless other.is_a?(Result) - - assign( - state: other.state, - status: other.status, - reason: other.reason, - cause: cause || other.cause, - strict: other.strict?, - metadata: other.metadata.merge(metadata) - ) - - halt! if halt - end - - private - - # Writes attributes to the result via instance_variable_set. - # - # @param attrs [Hash] Attributes to assign to the result - # - # @rbs (**untyped attrs) -> void - def assign(**attrs) - attrs.each { |key, value| @result.instance_variable_set(:"@#{key}", value) } - end - - end -end diff --git a/lib/cmdx/result.rb b/lib/cmdx/result.rb index d46141660..75f054938 100644 --- a/lib/cmdx/result.rb +++ b/lib/cmdx/result.rb @@ -1,442 +1,238 @@ # frozen_string_literal: true module CMDx - # Represents the execution result of a CMDx task, tracking state transitions, - # status changes, and providing methods for handling different outcomes. - # - # The Result class manages the lifecycle of task execution from initialization - # through completion or interruption, offering a fluent interface for status - # checking and conditional handling. + # Immutable snapshot of a task execution result. + # Built once by the Runtime after execution completes, then frozen. class Result - extend Forwardable - # @rbs STATES: Array[String] - STATES = [ - INITIALIZED = "initialized", # Initial state before execution - EXECUTING = "executing", # Currently executing task logic - COMPLETE = "complete", # Successfully completed execution - INTERRUPTED = "interrupted" # Execution was halted due to failure - ].freeze + STATES = %w[initialized executing complete interrupted].freeze # @rbs STATUSES: Array[String] - STATUSES = [ - SUCCESS = "success", # Task completed successfully - SKIPPED = "skipped", # Task was skipped intentionally - FAILED = "failed" # Task failed due to error or validation - ].freeze - - # @rbs STRIP_FAILURE: Proc - STRIP_FAILURE = proc do |hash, result, key| - unless result.public_send(:"#{key}?") - # Strip caused/threw failures since its the same info as the log line - hash[key] = result.public_send(key).to_h.except(:caused_failure, :threw_failure) - end - end.freeze - private_constant :STRIP_FAILURE - - # @rbs FAILURE_KEY_REGEX: Regexp - FAILURE_KEY_REGEX = /_failure\z/ - private_constant :FAILURE_KEY_REGEX + STATUSES = %w[success skipped failed].freeze - # Returns the task instance associated with this result. - # - # @return [CMDx::Task] The task instance - # - # @example - # result.task.id # => "users/create" + # Identity # - # @rbs @task: Task - attr_reader :task + # @return [String] + attr_reader :task_id - # Returns the current execution state of the result. - # - # @return [String] One of: "initialized", "executing", "complete", "interrupted" - # - # @example - # result.state # => "complete" + # @return [Class] + attr_reader :task_class + + # @return [String] + attr_reader :task_type + + # @return [Array] + attr_reader :task_tags + + # Execution data # - # @rbs @state: String + # @return [String] attr_reader :state - # Returns the execution status of the result. - # - # @return [String] One of: "success", "skipped", "failed" - # - # @example - # result.status # => "success" - # - # @rbs @status: String + # @return [String] attr_reader :status - # Returns additional metadata about the result. - # - # @return [Hash{Symbol => Object}] Metadata hash - # - # @example - # result.metadata # => { duration: 1.5, code: 200, message: "Success" } - # - # @rbs @metadata: Hash[Symbol, untyped] - attr_reader :metadata - - # Returns the reason for interruption (skip or failure). - # - # @return [String, nil] The reason message, or nil if not interrupted - # - # @example - # result.reason # => "Validation failed" - # - # @rbs @reason: (String | nil) + # @return [String, nil] attr_reader :reason - # Returns the exception that caused the interruption. - # - # @return [Exception, nil] The causing exception, or nil if not interrupted - # - # @example - # result.cause # => # - # - # @rbs @cause: (Exception | nil) + # @return [Exception, nil] attr_reader :cause - # Returns whether this result transitions are strict. - # When false, {CMDx::Executor#halt_execution?} returns false - # regardless of the task's breakpoint settings. - # - # @return [Boolean] Whether the result transitions are strict - # - # @example - # result.strict? # => true - # - # @rbs @strict: bool + # @return [Hash] + attr_reader :metadata + + # @return [Boolean] attr_reader :strict - # Returns the number of retries attempted. - # - # @return [Integer] The number of retries attempted - # - # @example - # result.retries # => 2 - # - # @rbs @retries: Integer - attr_accessor :retries + # @return [Integer] + attr_reader :retries - # Returns whether the result has been rolled back. - # - # @return [Boolean] Whether the result has been rolled back - # - # @example - # result.rolled_back? # => true + # @return [Boolean] + attr_reader :rolled_back + + # Associated objects # - # @rbs @rolled_back: bool - attr_accessor :rolled_back + # @return [Context] + attr_reader :context - def_delegators :task, :context, :chain, :errors, :dry_run? - alias ctx context + # @return [Chain] + attr_reader :chain - # @param task [CMDx::Task] The task instance this result represents - # - # @return [CMDx::Result] A new result instance for the task - # - # @raise [TypeError] When task is not a CMDx::Task instance - # - # @example - # result = CMDx::Result.new(my_task) - # result.state # => "initialized" - # - # @rbs (Task) -> void - def initialize(task) - raise TypeError, "must be a CMDx::Task" unless task.is_a?(CMDx::Task) - - @task = task - @state = INITIALIZED - @status = SUCCESS - @metadata = {} - @reason = nil - @cause = nil - @strict = true - @retries = 0 - @rolled_back = false + # @return [Errors] + attr_reader :errors + + # @return [Integer, nil] position in the chain + attr_reader :index + + # @rbs (**untyped attrs) -> void + def initialize(**attrs) + attrs.each { |k, v| instance_variable_set(:"@#{k}", v) } + freeze end - STATES.each do |s| - # @return [Boolean] Whether the result is in the specified state - # - # @example - # result.initialized? # => true - # result.executing? # => false - # - # @rbs () -> bool - define_method(:"#{s}?") { state == s } + # State queries + + # @rbs () -> bool + def complete? + state == "complete" + end + + # @rbs () -> bool + def interrupted? + state == "interrupted" end - # @return [Boolean] Whether the task has been executed (complete or interrupted) - # - # @example - # result.executed? # => true if complete? || interrupted? - # # @rbs () -> bool def executed? complete? || interrupted? end - STATUSES.each do |s| - # @return [Boolean] Whether the result has the specified status - # - # @example - # result.success? # => true - # result.failed? # => false - # - # @rbs () -> bool - define_method(:"#{s}?") { status == s } + # Status queries + + # @rbs () -> bool + def success? + status == "success" end - # @return [Boolean] Whether the task execution was successful (not failed) - # - # @example - # result.good? # => true if !failed? - # + # @rbs () -> bool + def skipped? + status == "skipped" + end + + # @rbs () -> bool + def failed? + status == "failed" + end + + # Compound queries + # @rbs () -> bool def good? - !failed? + success? end alias ok? good? - # @return [Boolean] Whether the task execution was unsuccessful (not success) - # - # @example - # result.bad? # => true if !success? - # # @rbs () -> bool def bad? - !success? + !good? end - # @yield [self] Executes the block if task status or state matches - # - # @return [self] Returns self for method chaining - # - # @raise [ArgumentError] When no block is provided - # - # @example - # result.on(:bad) { |r| puts "Task had issues: #{r.reason}" } - # result.on(:success, :complete) { |r| puts "Task completed successfully" } - # - # @rbs () { (Result) -> void } -> self - def on(*states_or_statuses, &) - raise ArgumentError, "block required" unless block_given? + # @rbs () -> bool + def strict? + !!strict + end - yield(self) if states_or_statuses.any? { |s| public_send(:"#{s}?") } - self + # @rbs () -> bool + def retried? + retries.positive? end - # @return [CMDx::Result, nil] The result that caused this failure, or nil - # - # @example - # cause = result.caused_failure - # puts "Caused by: #{cause.task.id}" if cause + # @rbs () -> bool + def rolled_back? + !!rolled_back + end + + # @rbs () -> bool + def dry_run? + chain&.dry_run? || false + end + + # Chain analysis + + # @return [Result, nil] the first failed result in the chain that is strict # # @rbs () -> Result? def caused_failure - return unless failed? + return unless chain - chain.results.reverse_each.find(&:failed?) + chain.results.find { |r| r.failed? && r.strict? } end - # @return [Boolean] Whether this result caused the failure - # - # @example - # if result.caused_failure? - # puts "This task caused the failure" - # end - # # @rbs () -> bool def caused_failure? - return false unless failed? - - caused_failure == self + !caused_failure.nil? end - # @return [CMDx::Result, nil] The result that threw this failure, or nil - # - # @example - # thrown = result.threw_failure - # puts "Thrown by: #{thrown.task.id}" if thrown + # @return [Result, nil] the result that was thrown into this execution # # @rbs () -> Result? def threw_failure - return unless failed? - - current = index - last_failed = nil + return unless chain && metadata - chain.results.each do |r| - next unless r.failed? + thrown_id = metadata[:thrown_from] + return unless thrown_id - return r if r.index > current - - last_failed = r - end - - last_failed + chain.results.find { |r| r.task_id == thrown_id } end - # @return [Boolean] Whether this result threw the failure - # - # @example - # if result.threw_failure? - # puts "This task threw the failure" - # end - # # @rbs () -> bool def threw_failure? - return false unless failed? - - threw_failure == self + !threw_failure.nil? end - # @return [Boolean] Whether this result is a thrown failure - # - # @example - # if result.thrown_failure? - # puts "This failure was thrown from another task" - # end - # # @rbs () -> bool def thrown_failure? - failed? && !caused_failure? + threw_failure? end - # @return [Boolean] Whether the result transitions are strict - # - # @example - # result.strict? # => true + # @return [String] human-readable outcome label # - # @rbs () -> bool - def strict? - !!@strict - end + # @rbs () -> String + def outcome + return "success" if success? + return "skipped" if skipped? - # @return [Boolean] Whether the result has been retried - # - # @example - # result.retried? # => true - # - # @rbs () -> bool - def retried? - retries.positive? + "failed" end - # @return [Boolean] Whether the result has been rolled back + # Handlers + + # Yields the block if the result matches one of the given states or statuses. # - # @example - # result.rolled_back? # => true + # @param filters [Array] states or statuses to match # - # @rbs () -> bool - def rolled_back? - !!@rolled_back + # @return [Object, nil] the block result or nil + # + # @rbs (*(String | Symbol) filters) { (Result) -> untyped } -> untyped + def on(*filters, &) + matched = filters.any? do |f| + f = f.to_s + f == state || f == status + end + yield(self) if matched end - # @return [Integer] Index of this result in the chain - # - # @example - # position = result.index - # puts "Task #{position + 1} of #{chain.results.count}" - # - # @rbs () -> Integer - def index - @chain_index || chain.index(self) + # Pattern matching + + # @rbs () -> Array[untyped] + def deconstruct + [state, status, reason] end - # @return [String] The outcome of the task execution - # - # @example - # result.outcome # => "success" or "interrupted" - # - # @rbs () -> String - def outcome - initialized? || thrown_failure? ? state : status + # @rbs (?Array[Symbol]? keys) -> Hash[Symbol, untyped] + def deconstruct_keys(keys = nil) + h = to_h + keys ? h.slice(*keys) : h end - # @return [Hash] Hash representation of the result - # - # @example - # result.to_h - # # => {state: "complete", status: "success", outcome: "success", reason: "Unspecified", metadata: {}} - # + # Serialization + # @rbs () -> Hash[Symbol, untyped] def to_h - task.to_h.merge!( - state:, - status:, - outcome:, - reason:, - metadata: - ).tap do |hash| - if interrupted? - hash[:cause] = cause - hash[:rolled_back] = rolled_back? - end - - if failed? - STRIP_FAILURE.call(hash, self, :threw_failure) - STRIP_FAILURE.call(hash, self, :caused_failure) - end - end + { + task_id:, task_class: task_class&.name, task_type:, task_tags:, + state:, status:, reason:, metadata:, + strict:, retries:, rolled_back:, + index:, errors: errors&.to_h + } end - # @return [String] String representation of the result - # - # @example - # result.to_s # => "task_id=my_task state=complete status=success" - # @example With failure - # result.to_s # => "task_id=my_task state=complete status=failed threw_failure=<[1] MyTask: my_task>" - # # @rbs () -> String def to_s - Utils::Format.to_str(to_h) do |key, value| - if FAILURE_KEY_REGEX.match?(key) - "#{key}=<[#{value[:index]}] #{value[:class]}: #{value[:id]}>" - else - "#{key}=#{value.inspect}" - end - end - end - - # @return [Array] Array containing state, status, reason, cause, and metadata - # - # @example - # state, status = result.deconstruct - # puts "State: #{state}, Status: #{status}" - # - # @rbs (*untyped) -> Array[untyped] - def deconstruct(*) - [state, status, reason, cause, metadata] - end - - # @return [Hash] Hash with key-value pairs for pattern matching - # - # @example - # case result.deconstruct_keys - # in {state: "complete", good: true} - # puts "Task completed successfully" - # in {bad: true} - # puts "Task had issues" - # end - # - # @rbs (*untyped) -> Hash[Symbol, untyped] - def deconstruct_keys(*) - { - state:, - status:, - reason:, - cause:, - metadata:, - outcome:, - executed: executed?, - good: good?, - bad: bad? - } + parts = ["[#{status.upcase}]", task_class&.name || "anonymous"] + parts << "(#{reason})" if reason + parts.join(" ") end end diff --git a/lib/cmdx/retry.rb b/lib/cmdx/retry.rb deleted file mode 100644 index 4f9019794..000000000 --- a/lib/cmdx/retry.rb +++ /dev/null @@ -1,166 +0,0 @@ -# frozen_string_literal: true - -module CMDx - # Manages retry logic and state for task execution. - # - # The Retry class tracks retry availability, attempt counts, and - # remaining retries for a given task. It also resolves exception - # matching and computes wait times using configurable jitter strategies. - class Retry - - # Returns the task instance associated with this retry. - # - # @return [Task] the task being retried - # - # @example - # retry_instance.task # => # - # - # @rbs @task: Task - attr_reader :task - - # Creates a new Retry instance for the given task. - # - # @param task [Task] the task to manage retries for - # - # @return [Retry] a new Retry instance - # - # @example - # retry_instance = Retry.new(task) - # - # @rbs (Task task) -> void - def initialize(task) - @task = task - end - - # Returns the total number of retries configured for the task. - # - # @return [Integer] the configured retry count - # - # @example - # retry_instance.available # => 3 - # - # @rbs () -> Integer - def available - Integer(task.class.settings.retries || 0) - end - - # Checks if the task has any retries configured. - # - # @return [Boolean] true if retries are configured - # - # @example - # retry_instance.available? # => true - # - # @rbs () -> bool - def available? - available.positive? - end - - # Returns the number of retry attempts already made. - # - # @return [Integer] the current retry attempt count - # - # @example - # retry_instance.attempts # => 1 - # - # @rbs () -> Integer - def attempts - Integer(task.result.retries || 0) - end - - # Checks if the task has been retried at least once. - # - # @return [Boolean] true if at least one retry has occurred - # - # @example - # retry_instance.retried? # => true - # - # @rbs () -> bool - def retried? - attempts.positive? - end - - # Returns the number of retries still available. - # - # @return [Integer] the remaining retry count - # - # @example - # retry_instance.remaining # => 2 - # - # @rbs () -> Integer - def remaining - available - attempts - end - - # Checks if there are retries still available. - # - # @return [Boolean] true if remaining retries exist - # - # @example - # retry_instance.remaining? # => true - # - # @rbs () -> bool - def remaining? - remaining.positive? - end - - # Returns the list of exception classes eligible for retry. - # - # @return [Array] exception classes that trigger a retry - # - # @example - # retry_instance.exceptions # => [StandardError, CMDx::TimeoutError] - # - # @rbs () -> Array[Class] - def exceptions - @exceptions ||= Utils::Wrap.array( - task.class.settings.retry_on || - [StandardError, CMDx::TimeoutError] - ) - end - - # Checks if the given exception matches any configured retry exception. - # - # @param exception [Exception] the exception to check - # - # @return [Boolean] true if the exception qualifies for retry - # - # @example - # retry_instance.exception?(RuntimeError.new("fail")) # => true - # - # @rbs (Exception exception) -> bool - def exception?(exception) - exceptions.any? { |e| exception.class <= e } - end - - # Computes the wait time before the next retry attempt. - # - # Supports multiple jitter strategies: a Symbol calls a task method, - # a Proc is evaluated in the task instance context, a callable object - # receives the task and attempts, and a Numeric is multiplied by the - # attempt count. - # - # @return [Float] the wait duration in seconds - # - # @example With numeric jitter (0.5 * attempts) - # retry_instance.wait # => 1.0 - # @example With symbol jitter referencing a task method - # retry_instance.wait # => 2.5 - # - # @rbs () -> Float - def wait - jitter = task.class.settings.retry_jitter - - if jitter.is_a?(Symbol) - task.send(jitter, attempts) - elsif jitter.is_a?(Proc) - task.instance_exec(attempts, &jitter) - elsif jitter.respond_to?(:call) - jitter.call(task, attempts) - else - jitter.to_f * attempts - end.to_f - end - - end -end diff --git a/lib/cmdx/retry_strategy.rb b/lib/cmdx/retry_strategy.rb new file mode 100644 index 000000000..d44207000 --- /dev/null +++ b/lib/cmdx/retry_strategy.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module CMDx + # Encapsulates retry logic with configurable delay and jitter. + class RetryStrategy + + # @return [Integer] maximum retries + # @rbs @max_retries: Integer + attr_reader :max_retries + + # @return [Numeric] base delay between retries in seconds + # @rbs @delay: Numeric + attr_reader :delay + + # @return [Numeric] max random jitter to add + # @rbs @jitter: Numeric + attr_reader :jitter + + # @return [Array] exception classes eligible for retry + # @rbs @retry_on: Array[Class] + attr_reader :retry_on + + # @param settings [Settings] task settings + # + # @rbs (Settings settings) -> void + def initialize(settings) + @max_retries = settings.resolved_retry_count + @delay = settings.resolved_retry_delay + @jitter = settings.resolved_retry_jitter + @retry_on = settings.resolved_retry_on + end + + # @return [Boolean] true if retries are configured + # + # @rbs () -> bool + def retryable? + max_retries.positive? + end + + # Whether the exception is eligible for retry and attempts remain. + # + # @param exception [Exception] the exception to check + # @param attempt [Integer] current attempt count + # + # @return [Boolean] + # + # @rbs (Exception exception, Integer attempt) -> bool + def should_retry?(exception, attempt) + retryable? && + attempt < max_retries && + retry_on.any? { |klass| exception.is_a?(klass) } + end + + # Sleeps for the configured delay plus random jitter. + # + # @rbs () -> void + def wait + return unless delay.positive? || jitter.positive? + + sleep(delay + rand(0.0..jitter.to_f)) + end + + end +end diff --git a/lib/cmdx/runtime.rb b/lib/cmdx/runtime.rb new file mode 100644 index 000000000..8ac0e34a2 --- /dev/null +++ b/lib/cmdx/runtime.rb @@ -0,0 +1,275 @@ +# frozen_string_literal: true + +module CMDx + # Single orchestrator for the full task lifecycle. + # Replaces the old Executor + Resolver split. + # Creates the Task, catches signals, builds the frozen Result. + class Runtime + + # @return [Class] the task class being executed + # @rbs @task_class: untyped + attr_reader :task_class + + # Entry point for task execution. + # + # @param task_class [Class] the task class + # @param args [Hash] input arguments + # @param raise_on_fault [Boolean] whether to raise on non-success + # + # @return [Result] the frozen result + # + # @rbs (untyped task_class, Hash[Symbol, untyped] args, ?raise_on_fault: bool) ?{ (Result) -> void } -> Result + def self.call(task_class, args = {}, raise_on_fault: false, &block) + new(task_class, args).call(raise_on_fault:, &block) + end + + # @param task_class [Class] the task class + # @param args [Hash] input arguments + # + # @rbs (untyped task_class, Hash[Symbol, untyped] args) -> void + def initialize(task_class, args) + @task_class = task_class + @args = args + @outcome = Outcome.new + @errors = Errors.new + @task = nil + @context = nil + @chain = nil + @owns_chain = false + @result = nil + @task_id = Identifier.generate + @retry_strategy = RetryStrategy.new(task_class.task_settings) + end + + # Runs the full lifecycle. + # + # @param raise_on_fault [Boolean] whether to raise on non-success + # + # @return [Result] + # + # @rbs (?raise_on_fault: bool) ?{ (Result) -> void } -> Result + def call(raise_on_fault: false, &block) + deprecation_check! + prepare_task! + validate_attributes! + execute_with_retries! if @outcome.success? + verify_returns! if @outcome.success? + rescue UndefinedMethodError => e + raise e + rescue Fault => e + apply_fault(e) + rescue StandardError => e + @outcome.status = "failed" + @outcome.reason = e.message + @outcome.cause = e + ensure + finalize!(&block) + raise_fault! if raise_on_fault && !@outcome.success? + return @result # rubocop:disable Lint/EnsureReturn + end + + private + + # @rbs () -> void + def deprecation_check! + Deprecator.check!(task_class) + end + + # @rbs () -> void + def prepare_task! + @context = Context.build(@args) + @task = task_class.allocate + @task.instance_variable_set(:@context, @context) + @task.instance_variable_set(:@_signal, nil) + @task.instance_variable_set(:@_success, nil) + @task.instance_variable_set(:@_attributes, {}) + @task.__send__(:initialize) + parent_chain = Chain.current + @owns_chain = parent_chain.nil? + @chain = parent_chain || Chain.new(dry_run: @context[:dry_run]) + Chain.current = @chain + end + + # @rbs () -> void + def validate_attributes! + registry = task_class.attribute_registry + return unless registry.any? + + invoke_callbacks(:before_validation) + + resolved = registry.resolve(@task, @context, @errors) + @task.instance_variable_set(:@_attributes, resolved) + + return unless @errors.any? + + @outcome.status = "failed" + @outcome.reason = Locale.t("cmdx.faults.invalid") + end + + # @rbs () -> void + def run_middleware_and_work! + invoke_callbacks(:before_execution) + @outcome.state = "executing" + + task_class.middleware_registry.call(@task) do + execute_work + end + end + + # @rbs () -> void + def execute_work + signal = catch(:cmdx_signal) do + @task.work + @task.instance_variable_get(:@_signal) + end + + if signal + @outcome.apply_signal(signal) + elsif (success_data = @task.instance_variable_get(:@_success)) + @outcome.metadata = @outcome.metadata.merge(success_data[:metadata] || {}) + @outcome.reason = success_data[:reason] if success_data[:reason] + end + end + + # @rbs () -> void + def verify_returns! + return unless task_class.respond_to?(:returns_registry) && task_class.returns_registry.any? + + task_class.returns_registry.each do |name, opts| + next if @context.key?(name) + next if opts[:if] && !Utils::Condition.truthy?(opts[:if], @task) + next if opts[:unless] && !Utils::Condition.falsy?(opts[:unless], @task) + + @errors.add(name, Locale.t("cmdx.returns.missing")) + end + + return unless @errors.any? && @outcome.success? + + @outcome.status = "failed" + @outcome.reason = Locale.t("cmdx.faults.invalid") + end + + # @rbs () -> void + def execute_with_retries! + run_middleware_and_work! + rescue StandardError => e + if @retry_strategy.should_retry?(e, @outcome.retries) + @outcome.retries += 1 + @retry_strategy.wait + @task.instance_variable_set(:@_signal, nil) + @task.instance_variable_set(:@_success, nil) + retry + end + + @outcome.status = "failed" + @outcome.reason = e.message + @outcome.cause = e + end + + # @rbs (Fault e) -> void + def apply_fault(e) + @outcome.status = "failed" + @outcome.reason = e.message + @outcome.cause = e + end + + # @rbs () ?{ (Result) -> void } -> void + def finalize!(&block) + @outcome.state = @outcome.success? ? "complete" : "interrupted" + build_result + @chain.push(@result) + + inject_result_for_callbacks! + invoke_status_callbacks + invoke_state_callbacks + log_result! + rollback! unless @outcome.success? + remove_result_from_task! + + block&.call(@result) + + return unless @owns_chain + + Chain.clear + @context.freeze + @chain.freeze + end + + # @rbs () -> void + def build_result + @result = Result.new( + task_id: @task_id, + task_class: task_class, + task_type: task_class.respond_to?(:type) ? task_class.type : Utils::Format.type_name(task_class), + task_tags: task_class.task_settings.resolved_tags, + state: @outcome.state, + status: @outcome.status, + reason: @outcome.reason, + cause: @outcome.cause, + metadata: @outcome.metadata, + strict: @outcome.strict, + retries: @outcome.retries, + rolled_back: @outcome.rolled_back, + context: @context, + chain: @chain, + errors: @errors, + index: @chain.next_index + ) + end + + # @rbs () -> void + def invoke_status_callbacks + type = :"on_#{@outcome.status}" + invoke_callbacks(type) + invoke_callbacks(@outcome.success? ? :on_good : :on_bad) + end + + # @rbs () -> void + def invoke_state_callbacks + invoke_callbacks(@outcome.success? ? :on_complete : :on_interrupted) + invoke_callbacks(:on_executed) + end + + # @rbs (Symbol type) -> void + def invoke_callbacks(type) + task_class.callback_registry.invoke(type, @task, @result) + end + + # @rbs () -> void + def inject_result_for_callbacks! + @task.instance_variable_set(:@_result, @result) + end + + # @rbs () -> void + def remove_result_from_task! + @task.remove_instance_variable(:@_result) if @task.instance_variable_defined?(:@_result) + end + + # @rbs () -> void + def log_result! + return unless @result + + @task.logger.info(@result.to_s) + rescue StandardError + nil + end + + # @rbs () -> void + def rollback! + return unless @task.respond_to?(:rollback, true) + return if @outcome.rolled_back + + @task.rollback + @outcome.rolled_back = true + rescue StandardError + nil + end + + # @rbs () -> void + def raise_fault! + klass = @outcome.failed? ? FailFault : SkipFault + raise klass, @result + end + + end +end diff --git a/lib/cmdx/settings.rb b/lib/cmdx/settings.rb index 920c20c6d..862e00582 100644 --- a/lib/cmdx/settings.rb +++ b/lib/cmdx/settings.rb @@ -1,225 +1,118 @@ # frozen_string_literal: true module CMDx - # Value object encapsulating all per-task configuration. Registries are - # deep-duped on inheritance; scalar settings delegate to a parent Settings - # or to the global Configuration rather than eagerly copying values. + # Per-task settings with lazy delegation to the parent class or global configuration. + # Each setting defaults to nil (meaning "inherit from parent"). class Settings - class << self + # @rbs VALID_ON_FAILURE: Array[Symbol] + VALID_ON_FAILURE = %i[raise skip none].freeze - private - - # Defines a reader that delegates to the parent Settings chain, - # falling through to Configuration when no parent exists. - # - # @param names [Array] Setting names to define - # - # @rbs (*Symbol names) -> void - def delegate_to_configuration(*names) - names.each do |name| - ivar = :"@#{name}" - - attr_writer(name) - - define_method(name) do - return instance_variable_get(ivar) if instance_variable_defined?(ivar) - - value = @parent ? @parent.public_send(name) : CMDx.configuration.public_send(name) - instance_variable_set(ivar, value) - - value - end - end - end - - # Defines a reader that delegates to the parent Settings only. - # Returns nil when the chain is exhausted. - # - # @param names [Array] Setting names to define - # @param with_fallback [Boolean] Whether to fall back to Configuration - # - # @rbs (*Symbol names, with_fallback: bool) -> void - def delegate_to_parent(*names, with_fallback: false) - names.each do |name| - ivar = :"@#{name}" - - attr_writer(name) - - define_method(name) do - return instance_variable_get(ivar) if instance_variable_defined?(ivar) - - value = @parent&.public_send(name) - value ||= CMDx.configuration.public_send(name) if with_fallback - instance_variable_set(ivar, value) + # @return [Settings, nil] parent settings for inheritance + # + # @rbs @parent: Settings? + attr_reader :parent - value - end - end - end + attr_accessor :logger, :log_level, :log_formatter, :tags, :on_failure, :retry_count, :retry_delay, :retry_jitter, :retry_on, :deprecate + # @param parent [Settings, nil] parent settings for lazy delegation + # + # @rbs (?Settings? parent) -> void + def initialize(parent = nil) + @parent = parent end - # Returns the attribute registry for task parameters. - # - # @return [AttributeRegistry] The attribute registry + # @return [Logger] resolved logger # - # @rbs @attributes: AttributeRegistry - attr_accessor :attributes + # @rbs () -> Logger + def resolved_logger + logger || parent&.resolved_logger || CMDx.configuration.logger + end - # Returns the callback registry for task lifecycle hooks. - # - # @return [CallbackRegistry] The callback registry + # @return [Symbol, nil] resolved log level # - # @rbs @callbacks: CallbackRegistry - attr_accessor :callbacks + # @rbs () -> Symbol? + def resolved_log_level + log_level || parent&.resolved_log_level || CMDx.configuration.log_level + end - # Returns the coercion registry for type conversions. - # - # @return [CoercionRegistry] The coercion registry + # @return [Proc, nil] resolved log formatter # - # @rbs @coercions: CoercionRegistry - attr_accessor :coercions + # @rbs () -> Proc? + def resolved_log_formatter + log_formatter || parent&.resolved_log_formatter || CMDx.configuration.log_formatter + end - # Returns the middleware registry for task execution. - # - # @return [MiddlewareRegistry] The middleware registry + # @return [Array] resolved tags # - # @rbs @middlewares: MiddlewareRegistry - attr_accessor :middlewares + # @rbs () -> Array[Symbol] + def resolved_tags + tags || parent&.resolved_tags || EMPTY_ARRAY + end - # Returns the validator registry for attribute validation. + # @return [Symbol] resolved on_failure behavior # - # @return [ValidatorRegistry] The validator registry - # - # @rbs @validators: ValidatorRegistry - attr_accessor :validators + # @rbs () -> Symbol + def resolved_on_failure + on_failure || parent&.resolved_on_failure || :raise + end - # Returns the expected return keys after execution. - # - # @return [Array] Expected return keys after execution + # @return [Integer] resolved retry count # - # @rbs @returns: Array[Symbol] - attr_accessor :returns + # @rbs () -> Integer + def resolved_retry_count + retry_count || parent&.resolved_retry_count || 0 + end - # Returns the tags for task categorization. - # - # @return [Array] Tags for categorization + # @return [Numeric] resolved retry delay in seconds # - # @rbs @tags: Array[Symbol] - attr_accessor :tags - - # @!attribute [rw] backtrace - # @return [Boolean] true if backtraces should be logged - delegate_to_configuration :backtrace - - # @!attribute [rw] dump_context - # @return [Boolean] true if context should be included in Task#to_h - delegate_to_configuration :dump_context - - # @!attribute [rw] rollback_on - # @return [Array] Statuses that trigger rollback - delegate_to_configuration :rollback_on - - # @!attribute [rw] task_breakpoints - # @return [Array] Default task breakpoint statuses - delegate_to_configuration :task_breakpoints - - # @!attribute [rw] workflow_breakpoints - # @return [Array] Default workflow breakpoint statuses - delegate_to_configuration :workflow_breakpoints - - # @!attribute [rw] backtrace_cleaner - # @return [Proc, nil] The backtrace cleaner proc - delegate_to_parent :backtrace_cleaner, with_fallback: true - - # @!attribute [rw] breakpoints - # @return [Array, nil] Per-task breakpoints override - delegate_to_parent :breakpoints - - # @!attribute [rw] deprecate - # @return [Symbol, Proc, Boolean, nil] Deprecation behavior - delegate_to_parent :deprecate - - # @!attribute [rw] exception_handler - # @return [Proc, nil] The exception handler proc - delegate_to_parent :exception_handler, with_fallback: true - - # @!attribute [rw] logger - # @return [Logger] The logger instance - delegate_to_parent :logger, with_fallback: true - - # @!attribute [rw] log_formatter - # @return [Proc, nil] Per-task log formatter override - delegate_to_parent :log_formatter - - # @!attribute [rw] log_level - # @return [Integer, nil] Per-task log level override - delegate_to_parent :log_level - - # @!attribute [rw] retries - # @return [Integer, nil] Number of retries on failure - delegate_to_parent :retries - - # @!attribute [rw] retry_jitter - # @return [Numeric, Symbol, Proc, nil] Jitter between retries - delegate_to_parent :retry_jitter - - # @!attribute [rw] retry_on - # @return [Array, Class, nil] Exception classes to retry on - delegate_to_parent :retry_on + # @rbs () -> Numeric + def resolved_retry_delay + retry_delay || parent&.resolved_retry_delay || 0 + end - # Creates a new Settings instance, inheriting registries from a parent - # Settings or the global Configuration. Scalar settings are resolved - # lazily via delegation rather than eagerly copied. - # - # @param parent [Settings, nil] Parent settings to inherit from - # @param overrides [Hash] Field values to override after inheritance + # @return [Numeric] resolved jitter max # - # @example - # Settings.new(parent: ParentTask.settings, deprecate: true) + # @rbs () -> Numeric + def resolved_retry_jitter + retry_jitter || parent&.resolved_retry_jitter || 0 + end + + # @return [Array] exception classes to retry on # - # @rbs (?parent: Settings?, **untyped overrides) -> void - def initialize(parent: nil, **overrides) - @parent = parent + # @rbs () -> Array[Class] + def resolved_retry_on + retry_on || parent&.resolved_retry_on || [StandardError] + end - init_registries - init_collections + # @return [Hash, nil] resolved deprecation config + # + # @rbs () -> Hash[Symbol, untyped]? + def resolved_deprecate + deprecate || parent&.resolved_deprecate + end - overrides.each { |key, value| public_send(:"#{key}=", value) } + # @return [Boolean] whether retries are configured + # + # @rbs () -> bool + def retryable? + resolved_retry_count.positive? end - private - - # Dups registries from the parent Settings or global Configuration - # so each task class gets its own mutable copy. - # - # @rbs () -> void - def init_registries - if @parent - @middlewares = @parent.middlewares.dup - @callbacks = @parent.callbacks.dup - @coercions = @parent.coercions.dup - @validators = @parent.validators.dup - @attributes = @parent.attributes.dup - else - config = CMDx.configuration - - @middlewares = config.middlewares.dup - @callbacks = config.callbacks.dup - @coercions = config.coercions.dup - @validators = config.validators.dup - @attributes = AttributeRegistry.new - end + # @return [Boolean] whether the task is deprecated + # + # @rbs () -> bool + def deprecated? + !resolved_deprecate.nil? end - # Initializes array-valued settings that need their own copy - # to avoid cross-class mutation. + # Duplicates settings for a child class. + # + # @return [Settings] a new Settings with self as parent # - # @rbs () -> void - def init_collections - @returns = @parent&.returns&.dup || EMPTY_ARRAY - @tags = @parent&.tags&.dup || EMPTY_ARRAY + # @rbs () -> Settings + def for_child + self.class.new(self) end end diff --git a/lib/cmdx/signals.rb b/lib/cmdx/signals.rb new file mode 100644 index 000000000..a057ecf5a --- /dev/null +++ b/lib/cmdx/signals.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +module CMDx + # Control flow methods mixed into Task. + # Uses throw/catch for halting signals (3x faster than raise/rescue). + module Signals + + # Signals success, optionally halting execution. + # + # @param reason [String, nil] success reason + # @param halt [Boolean] whether to halt execution immediately + # @param metadata [Hash] additional metadata + # + # @rbs (?String? reason, ?halt: bool, **untyped metadata) -> void + def success!(reason = nil, halt: true, **metadata) + raise "cannot annotate after interruption" if @_signal + + @_success = { reason:, metadata: } + throw(:cmdx_signal, { status: :success, reason:, metadata: }) if halt + end + + # Signals a skip, optionally halting execution. + # + # @param reason [String, nil] skip reason + # @param halt [Boolean] whether to halt execution immediately + # @param strict [Boolean] whether breakpoints apply + # @param metadata [Hash] additional metadata + # + # @rbs (?String? reason, ?halt: bool, ?strict: bool, **untyped metadata) -> void + def skip!(reason = nil, halt: true, strict: true, **metadata) + return if @_signal + + signal = { status: :skipped, reason:, strict:, metadata: } + halt ? throw(:cmdx_signal, signal) : (@_signal ||= signal) + end + + # Signals a failure, optionally halting execution. + # + # @param reason [String, nil] failure reason + # @param halt [Boolean] whether to halt execution immediately + # @param strict [Boolean] whether breakpoints apply + # @param metadata [Hash] additional metadata + # + # @rbs (?String? reason, ?halt: bool, ?strict: bool, **untyped metadata) -> void + def fail!(reason = nil, halt: true, strict: true, **metadata) + signal = { status: :failed, reason:, strict:, metadata: } + halt ? throw(:cmdx_signal, signal) : (@_signal ||= signal) + end + + # Re-throws another task's failure result into this execution. + # + # @param other_result [Result] the result to propagate + # @param halt [Boolean] whether to halt execution immediately + # @param metadata [Hash] additional metadata + # + # @rbs (Result other_result, ?halt: bool, **untyped metadata) -> void + def throw!(other_result, halt: true, **metadata) + signal = { + status: other_result.status.to_sym, + reason: other_result.reason, + cause: other_result.cause, + strict: other_result.strict?, + metadata: (other_result.metadata || {}).merge(metadata), + thrown_from: other_result.task_id + } + halt ? throw(:cmdx_signal, signal) : (@_signal ||= signal) + end + + # Whether the current execution is a dry run. + # + # @return [Boolean] + # + # @rbs () -> bool + def dry_run? + !!context[:dry_run] + end + + end +end diff --git a/lib/cmdx/task.rb b/lib/cmdx/task.rb index 0dde605db..852141864 100644 --- a/lib/cmdx/task.rb +++ b/lib/cmdx/task.rb @@ -1,429 +1,307 @@ # frozen_string_literal: true module CMDx - # Represents a task that can be executed within the CMDx framework. - # Tasks define attributes, callbacks, and execution logic that can be - # chained together to form workflows. + # Base class for all tasks. Composed from focused modules for each concern. + # + # Developers subclass Task and implement `#work`. Optionally override `#rollback`. + # + # @example + # class CreateUser < CMDx::Task + # required :email, :string + # + # def work + # ctx[:user] = User.create!(email:) + # end + # end class Task - extend Forwardable + include Signals - # Returns the hash of processed attribute values for this task. - # - # @return [Hash{Symbol => Object}] Hash of attribute names to their values - # - # @example - # task.attributes # => { user_id: 42, user_name: "John" } - # - # @rbs @attributes: Hash[Symbol, untyped] - attr_reader :attributes - - # Returns the collection of validation and execution errors. - # - # @return [Errors] The errors collection - # - # @example - # task.errors.to_h # => { email: ["must be valid"] } - # - # @rbs @errors: Errors - attr_reader :errors - - # Returns the unique identifier for this task instance. - # - # @return [String] The task identifier - # - # @example - # task.id # => "abc123xyz" - # - # @rbs @id: String - attr_reader :id - - # Returns the execution context for this task. - # - # @return [Context] The context instance - # - # @example - # task.context[:user_id] # => 42 - # - # @rbs @context: Context + # @return [Context] the shared execution context attr_reader :context alias ctx context - # Returns the execution result for this task. - # - # @return [Result] The result instance - # - # @example - # task.result.status # => "success" - # - # @rbs @result: Result - attr_reader :result - alias res result - - # Returns the execution chain containing all task results. + # The main entry point — subclasses MUST override this. # - # @return [Chain] The chain instance + # @raise [UndefinedMethodError] when not overridden # - # @example - # task.chain.results.size # => 3 - # - # @rbs @chain: Chain - attr_reader :chain + # @rbs () -> void + def work + raise UndefinedMethodError, "undefined method #{self.class.name}#work" + end - # Returns the resolver for managing result state transitions. - # - # @return [Resolver] The resolver instance + # Optional rollback — called when execution fails. # - # @example - # task.resolver.fail!("Validation failed") - # - # @rbs @resolver: Resolver - attr_reader :resolver - - def_delegators :resolver, :success!, :skip!, :fail!, :throw! - def_delegators :chain, :dry_run? + # @rbs () -> void + def rollback; end - # @param context [Hash, Context, nil] The initial context for the task - # - # @return [Task] A new task instance - # - # @raise [DeprecationError] If the task class is deprecated - # - # @example - # task = MyTask.new - # task = MyTask.new(name: "example", priority: :high) - # task = MyTask.new(Context.build(name: "example")) + # @return [Logger] memoized logger with task-level overrides # - # @rbs (untyped context) -> void - def initialize(context = nil) - Deprecator.restrict(self) - - @id = Identifier.generate - @context = Context.build(context) - @errors = Errors.new - @result = Result.new(self) - @resolver = Resolver.new(@result) - @chain = Chain.build(@result, dry_run: @context.delete(:dry_run)) - - @attributes = {} + # @rbs () -> Logger + def logger + @logger ||= resolve_logger end class << self - # Returns the cached task type string for this class. + # Executes the task and returns a frozen Result. # - # @return [String] "Workflow" or "Task" + # @param args [Hash] input arguments # - # @rbs () -> String - def type - @type ||= include?(Workflow) ? "Workflow" : "Task" + # @return [Result] + # + # @rbs (**untyped args) ?{ (Result) -> void } -> Result + def execute(**args, &) + Runtime.call(self, args, raise_on_fault: false, &) end - # Returns (and lazily creates) the task-level Settings object. - # On first access, inherits from the superclass settings or - # the global Configuration. Optional overrides are applied once. - # - # @param overrides [Hash] Configuration overrides applied on first access - # @option overrides [Object] :* Any configuration override key-value pairs + # Executes the task; raises FailFault or SkipFault on non-success. # - # @return [Settings] The settings instance for this task class + # @param args [Hash] input arguments # - # @example - # class MyTask < Task - # settings deprecate: true, tags: [:experimental] - # end + # @return [Result] + # @raise [FailFault] on failure + # @raise [SkipFault] on skip # - # @rbs (**untyped overrides) -> Settings - def settings(**overrides) - @settings ||= begin - parent = superclass.settings if superclass.respond_to?(:settings) - Settings.new(parent:, **overrides) - end + # @rbs (**untyped args) ?{ (Result) -> void } -> Result + def execute!(**args, &) + Runtime.call(self, args, raise_on_fault: true, &) end - # @param type [Symbol] The type of registry to register with - # @param object [Object] The object to register - # - # @raise [RuntimeError] If the registry type is unknown - # - # @example - # register(:attribute, MyAttribute.new) - # register(:callback, :before, -> { puts "before" }) - # - # @rbs (Symbol type, untyped object, *untyped) -> void - def register(type, object, ...) - case type - when :attribute - settings.attributes.register(object) - settings.attributes.define_readers_on!(self, Utils::Wrap.array(object)) - when :callback then settings.callbacks.register(object, ...) - when :middleware then settings.middlewares.register(object, ...) - when :validator then settings.validators.register(object, ...) - when :coercion then settings.coercions.register(object, ...) - else raise "unknown registry type #{type.inspect}" - end + # Per-task settings with lazy parent delegation. + # + # @return [Settings] + # + # @rbs () -> Settings + def task_settings + @task_settings ||= if superclass.respond_to?(:task_settings) + superclass.task_settings.for_child + else + Settings.new + end end - # @param type [Symbol] The type of registry to deregister from - # @param object [Object] The object to deregister - # - # @raise [RuntimeError] If the registry type is unknown - # - # @example - # deregister(:attribute, :name) - # deregister(:callback, :before, MyCallback) - # - # @rbs (Symbol type, untyped object, *untyped) -> void - def deregister(type, object, ...) - case type - when :attribute - settings.attributes.undefine_readers_on!(self, object) - settings.attributes.deregister(object) - when :callback then settings.callbacks.deregister(object, ...) - when :middleware then settings.middlewares.deregister(object, ...) - when :validator then settings.validators.deregister(object, ...) - when :coercion then settings.coercions.deregister(object, ...) - else raise "unknown registry type #{type.inspect}" - end + # DSL for configuring task settings. + # + # @yield [Settings] the settings object + # + # @rbs () { (Settings) -> void } -> Settings + def settings(&block) + yield(task_settings) if block + task_settings end - # @example - # attributes :name, :email - # attributes :age, type: Integer, default: 18 + # Task type derived from class name. + # + # @return [String] # - # @rbs (*untyped) -> void - def attributes(...) - register(:attribute, Attribute.build(...)) + # @rbs () -> String + def type + Utils::Format.type_name(self) end - alias attribute attributes - # @example - # optional :description, :notes - # optional :priority, type: Symbol, default: :normal + # --- Attribute DSL --- + + # @return [AttributeRegistry] # - # @rbs (*untyped) -> void - def optional(...) - register(:attribute, Attribute.optional(...)) + # @rbs () -> AttributeRegistry + def attribute_registry + @attribute_registry ||= if superclass.respond_to?(:attribute_registry) + superclass.attribute_registry.for_child + else + AttributeRegistry.new + end end - # @example - # required :name, :email - # required :age, type: Integer, min: 0 + # Declares a required attribute. + # + # @param name [Symbol] attribute name + # @param type [Symbol, nil] coercion type + # @param options [Hash] attribute options # - # @rbs (*untyped) -> void - def required(...) - register(:attribute, Attribute.required(...)) + # @rbs (Symbol name, ?Symbol? type, **untyped options) -> void + def required(name, type = nil, **options) + define_attribute(name, type, required: true, **options) end - # @param names [Array] Names of attributes to remove + # Declares an optional attribute. # - # @example - # remove_attributes :old_field, :deprecated_field + # @param name [Symbol] attribute name + # @param type [Symbol, nil] coercion type + # @param options [Hash] attribute options # - # @rbs (*Symbol names) -> void - def remove_attributes(*names) - deregister(:attribute, names) + # @rbs (Symbol name, ?Symbol? type, **untyped options) -> void + def optional(name, type = nil, **options) + define_attribute(name, type, required: false, **options) end - alias remove_attribute remove_attributes - # Declares expected context returns that must be set after task execution. - # If any declared return is missing from the context after {#work} completes - # successfully, the task will fail with a validation error. + # Generic attribute declaration. # - # @param names [Array] Names of expected return keys in the context + # @param name [Symbol] attribute name + # @param type [Symbol, nil] coercion type + # @param options [Hash] attribute options # - # @example - # returns :user, :token - # - # @rbs (*untyped names) -> void - def returns(*names) - settings.returns |= names.map(&:to_sym) + # @rbs (Symbol name, ?Symbol? type, **untyped options) -> void + def attribute(name, type = nil, **options) + define_attribute(name, type, **options) end - # Removes declared returns from the task. - # - # @param names [Array] Names of returns to remove + # Removes an attribute. # - # @example - # remove_returns :old_return + # @param name [Symbol] attribute name # - # @rbs (*Symbol names) -> void - def remove_returns(*names) - settings.returns -= names.map(&:to_sym) + # @rbs (Symbol name) -> void + def remove_attribute(name) + attribute_registry.deregister(name) end - alias remove_return remove_returns - # @return [Hash] Hash of attribute names to their configurations + # Returns the schema for all declared attributes. # - # @example - # MyTask.attributes_schema #=> { - # user_id: { name: :user_id, method_name: :user_id, required: true, types: [:integer], options: {}, children: [] }, - # email: { name: :email, method_name: :email, required: false, types: [:string], options: { default: nil }, children: [] }, - # profile: { name: :profile, method_name: :profile, required: false, types: [:hash], options: {}, children: [ - # { name: :bio, method_name: :bio, required: false, types: [:string], options: {}, children: [] }, - # { name: :name, method_name: :name, required: true, types: [:string], options: {}, children: [] } - # ] } - # } + # @return [Hash{Symbol => Hash}] # # @rbs () -> Hash[Symbol, Hash[Symbol, untyped]] def attributes_schema - Utils::Wrap.array(settings.attributes).to_h do |attr| - [attr.method_name, attr.to_h] - end + attribute_registry.schema + end + + # --- Callback DSL --- + + # @return [CallbackRegistry] + # + # @rbs () -> CallbackRegistry + def callback_registry + @callback_registry ||= if superclass.respond_to?(:callback_registry) + superclass.callback_registry.for_child + else + CallbackRegistry.new + end end - CallbackRegistry::TYPES.each do |callback| - # @param callables [Array] Callable objects to register as callbacks - # @param options [Hash] Options for the callback registration - # @option options [Symbol] :priority Priority of the callback - # @option options [Boolean] :async Whether the callback should run asynchronously - # @param block [Proc] Block to register as a callback - # - # @example - # before { puts "before execution" } - # after :cleanup, priority: :high - # around ->(task) { task.logger.info("starting") } - # - # @rbs (*untyped callables, **untyped options) ?{ () -> void } -> void - define_method(callback) do |*callables, **options, &block| - register(:callback, callback, *callables, **options, &block) + CallbackRegistry::TYPES.each do |callback_type| + define_method(callback_type) do |callable = nil, **options, &block| + callback_registry.register(callback_type, callable || block, **options) end end - # @param args [Array] Arguments to pass to the task constructor - # @param kwargs [Hash] Keyword arguments to pass to the task constructor - # @option kwargs [Object] :* Any key-value pairs to pass to the task constructor + # --- Middleware DSL --- + + # @return [MiddlewareRegistry] # - # @return [Result] The execution result + # @rbs () -> MiddlewareRegistry + def middleware_registry + @middleware_registry ||= if superclass.respond_to?(:middleware_registry) + superclass.middleware_registry.for_child + else + MiddlewareRegistry.new + end + end + + # Registers middleware for this task. # - # @example - # result = MyTask.execute(name: "example") + # @param klass [Class] the middleware class + # @param args [Array] arguments for the middleware # - # @rbs (*untyped args, dry_run: bool, **untyped kwargs) ?{ (Result) -> void } -> Result - def execute(*args, **kwargs) - task = new(*args, **kwargs) - task.execute(raise: false) - block_given? ? yield(task.result) : task.result + # @rbs (untyped klass, *untyped args) -> void + def register(klass, *args) + middleware_registry.register(klass, *args) end - # @param args [Array] Arguments to pass to the task constructor - # @param kwargs [Hash] Keyword arguments to pass to the task constructor - # @option kwargs [Object] :* Any key-value pairs to pass to the task constructor + # Removes middleware from this task. # - # @return [Result] The execution result + # @param klass [Class] the middleware class # - # @raise [ExecutionError] If the task execution fails + # @rbs (untyped klass) -> void + def deregister(klass) + middleware_registry.deregister(klass) + end + + # --- Returns DSL --- + + # @return [Hash{Symbol => Hash}] # - # @example - # result = MyTask.execute!(name: "example") + # @rbs () -> Hash[Symbol, Hash[Symbol, untyped]] + def returns_registry + @returns_registry ||= if superclass.respond_to?(:returns_registry) + superclass.returns_registry.dup + else + {} + end + end + + # Declares an expected return key in the context. # - # @rbs (*untyped args, dry_run: bool, **untyped kwargs) ?{ (Result) -> void } -> Result - def execute!(*args, **kwargs) - task = new(*args, **kwargs) - task.execute(raise: true) - block_given? ? yield(task.result) : task.result + # @param name [Symbol] the context key + # @param options [Hash] options (:if, :unless) + # + # @rbs (Symbol name, **untyped options) -> void + def returns(name, **options) + returns_registry[name.to_sym] = options end - end + # Removes a return declaration. + # + # @param name [Symbol] the context key + # + # @rbs (Symbol name) -> void + def remove_returns(name) + returns_registry.delete(name.to_sym) + end - # @param raise [Boolean] Whether to raise exceptions on failure - # - # @return [Result] The execution result - # - # @example - # result = task.execute - # result = task.execute(raise: true) - # - # @rbs (raise: bool) ?{ (Result) -> void } -> Result - def execute(raise: false) - Executor.execute(self, raise:) - block_given? ? yield(result) : result - end + # --- Inheritance hook --- + + private + + # @rbs (Symbol name, Symbol? type, **untyped options) -> void + def define_attribute(name, type = nil, **options) + attr_def = Attribute.new(name, type, **options) + attribute_registry.register(attr_def) + attribute_registry.define_readers!(self) + end + + public + + # @rbs (untyped subclass) -> void + def inherited(subclass) + super + subclass.instance_variable_set(:@task_settings, task_settings.for_child) + subclass.instance_variable_set(:@attribute_registry, attribute_registry.for_child) + subclass.instance_variable_set(:@callback_registry, callback_registry.for_child) + subclass.instance_variable_set(:@middleware_registry, middleware_registry.for_child) + subclass.instance_variable_set(:@returns_registry, returns_registry.dup) + attribute_registry.define_readers!(subclass) + end - # @raise [UndefinedMethodError] Always raised as this method must be overridden - # - # @example - # class MyTask < Task - # def work - # # Custom work logic here - # puts "Performing work..." - # end - # end - # - # @rbs () -> void - def work - raise UndefinedMethodError, "undefined method #{self.class.name}#work" end - # Returns a logger for this task. When a custom log_level or - # log_formatter is configured, the shared logger is duplicated - # so the original instance is never mutated. - # - # @return [Logger] The logger instance for this task - # - # @example - # logger.info "Starting task execution" - # logger.error "Task failed", error: exception - # - # @rbs () -> Logger - def logger - @logger ||= begin - settings = self.class.settings - log_instance = settings.logger || CMDx.configuration.logger - - if settings.log_level || settings.log_formatter - log_instance = log_instance.dup - log_instance.level = settings.log_level if settings.log_level - log_instance.formatter = settings.log_formatter if settings.log_formatter - end + private + + # @return [Hash{Symbol => Object}] backing store for attribute accessors + attr_reader :_attributes - log_instance + # @rbs () -> Logger + def resolve_logger + s = self.class.task_settings + log = s.resolved_logger + if s.resolved_log_level || s.resolved_log_formatter + log = log.dup + log.level = s.resolved_log_level if s.resolved_log_level + log.formatter = s.resolved_log_formatter if s.resolved_log_formatter end + log end - # @option return [Integer] :index The result index - # @option return [String] :chain_id The chain identifier - # @option return [String] :type The task type ("Task" or "Workflow") - # @option return [Array] :tags The task tags - # @option return [String] :class The task class name - # @option return [String] :id The task identifier - # @option return [Hash] :context The task context (when dump_context is true) - # - # @return [Hash] A hash representation of the task - # - # @example - # task_hash = task.to_h - # puts "Task type: #{task_hash[:type]}" - # puts "Task tags: #{task_hash[:tags].join(', ')}" - # - # @rbs () -> Hash[Symbol, untyped] - def to_h - { - index: result.index, - chain_id: chain.id, - type: self.class.type, - class: self.class.name, - id:, - dry_run: dry_run?, - tags: self.class.settings.tags - }.tap do |hash| - if self.class.settings.dump_context - # Large context can make dumps and logs very noisy, - # so only include it if explicitly enabled - hash[:context] = context.to_h - end + # @rbs (Symbol method_name, *untyped args, **untyped) ?{ () -> untyped } -> untyped + def method_missing(method_name, *args, **, &) + if @_attributes&.key?(method_name) + @_attributes[method_name] + else + super end end - # @return [String] A string representation of the task - # - # @example - # puts task.to_s - # # Output: "Task[MyTask] tags: [:important] id: abc123" - # - # @rbs () -> String - def to_s - Utils::Format.to_str(to_h) + # @rbs (Symbol method_name, ?bool include_private) -> bool + def respond_to_missing?(method_name, include_private = false) + @_attributes&.key?(method_name) || super end end diff --git a/lib/cmdx/utils/call.rb b/lib/cmdx/utils/call.rb index 7a53b6d06..441f59443 100644 --- a/lib/cmdx/utils/call.rb +++ b/lib/cmdx/utils/call.rb @@ -2,49 +2,52 @@ module CMDx module Utils - # Utility module for invoking callable objects with different invocation strategies. - # - # This module provides a unified interface for calling methods, procs, and other - # callable objects on target objects, handling the appropriate invocation method - # based on the callable type. + # Invokes a callable in the appropriate context. module Call - extend self - - # Invokes a callable object on the target with the given arguments. - # - # @param target [Object] The target object to invoke the callable on - # @param callable [Symbol, Proc, #call] The callable to invoke - # @param args [Array] Positional arguments to pass to the callable - # @param kwargs [Hash] Keyword arguments to pass to the callable - # @option kwargs [Object] :* Any keyword arguments to pass to the callable + # Invokes a callable reference. # - # @yield [Object] Block to pass to the callable + # @param callable [Symbol, Proc, Object] the callable + # @param task [Task, nil] the context for symbol/instance_exec + # @param args [Array] arguments to pass # - # @return [Object] The result of invoking the callable + # @return [Object] the return value # - # @raise [RuntimeError] When the callable cannot be invoked + # @rbs (untyped callable, untyped task, *untyped args) -> untyped + def self.invoke(callable, task, *args) + case callable + when Symbol + task.__send__(callable) + when Proc + if callable.arity.zero? + task.instance_exec(&callable) + else + task.instance_exec(*args, &callable) + end + else + callable.call(*args) + end + end + + # Invokes a callable with task and result arguments (for callbacks). # - # @example Invoking a method by symbol - # Call.invoke(user, :name) - # Call.invoke(user, :update, { name: 'John' }) - # @example Invoking a proc - # proc = ->(name) { "Hello #{name}" } - # Call.invoke(user, proc, 'John') - # @example Invoking a callable object - # callable = MyCallable.new - # Call.invoke(user, callable, 'data') + # @param callable [Symbol, Proc, Object] the callable + # @param task [Task] the task instance + # @param result [Result] the result instance # - # @rbs (untyped target, (Symbol | Proc | untyped) callable, *untyped args, **untyped kwargs) ?{ () -> untyped } -> untyped - def invoke(target, callable, *args, **kwargs, &) - if callable.is_a?(Symbol) - target.send(callable, *args, **kwargs, &) - elsif callable.is_a?(Proc) - target.instance_exec(*args, **kwargs, &callable) - elsif callable.respond_to?(:call) - callable.call(*args, **kwargs, &) + # @rbs (untyped callable, untyped task, untyped result) -> untyped + def self.invoke_callback(callable, task, result) + case callable + when Symbol + task.__send__(callable) + when Proc + if callable.arity.zero? + task.instance_exec(&callable) + else + task.instance_exec(result, &callable) + end else - raise "cannot invoke #{callable}" + callable.call(task, result) end end diff --git a/lib/cmdx/utils/condition.rb b/lib/cmdx/utils/condition.rb index bf052e65b..ff2a3cc60 100644 --- a/lib/cmdx/utils/condition.rb +++ b/lib/cmdx/utils/condition.rb @@ -2,68 +2,42 @@ module CMDx module Utils - # Provides conditional evaluation utilities for CMDx tasks and workflows. - # - # This module handles conditional logic evaluation with support for `if` and `unless` - # conditions using various callable types including symbols, procs, and objects - # responding to `call`. + # Evaluates conditional expressions against a context. module Condition - extend self - - # @rbs EVAL: Proc - EVAL = proc do |target, callable, *args, **kwargs, &block| - case callable - when NilClass, FalseClass, TrueClass then !!callable - when Symbol then target.send(callable, *args, **kwargs, &block) - when Proc then target.instance_exec(*args, **kwargs, &callable) - else - raise "cannot evaluate #{callable.inspect}" unless callable.respond_to?(:call) - - callable.call(*args, **kwargs, &block) - end - end.freeze - private_constant :EVAL - - # Evaluates conditional logic based on provided options. + # Returns true when a conditional evaluates to true. # - # Supports both `if` and `unless` conditions, with `unless` taking precedence - # when both are specified. Returns true if no conditions are provided. + # @param conditional [Symbol, Proc, nil] the condition to evaluate + # @param context [Object] the receiver for symbol/proc # - # @param target [Object] The target object to evaluate conditions against - # @param options [Hash] Conditional options hash - # @option options [Object] :if Condition that must be true for evaluation to succeed - # @option options [Object] :unless Condition that must be false for evaluation to succeed + # @return [Boolean] # - # @return [Boolean] true if conditions are met, false otherwise + # @rbs (untyped conditional, untyped context) -> bool + def self.truthy?(conditional, context) + return true if conditional.nil? + + result = + case conditional + when Symbol then context.__send__(conditional) + when Proc then context.instance_exec(&conditional) + else conditional + end + + !!result + end + + # Returns true when a conditional evaluates to false. # - # @raise [RuntimeError] When a callable cannot be evaluated + # @param conditional [Symbol, Proc, nil] the condition to evaluate + # @param context [Object] the receiver for symbol/proc # - # @example Basic if condition - # Condition.evaluate(user, if: :active?) - # # => true if user.active? returns true - # @example Unless condition - # Condition.evaluate(user, unless: :blocked?) - # # => true if user.blocked? returns false - # @example Combined conditions - # Condition.evaluate(user, if: :verified?, unless: :suspended?) - # # => true if user.verified? is true AND user.suspended? is false - # @example With arguments and block - # Condition.evaluate(user, if: ->(u) { u.has_permission?(:admin) }, :admin) - # # => true if the proc returns true when called with user and :admin + # @return [Boolean] # - # @rbs (untyped target, Hash[Symbol, untyped] options, *untyped) ?{ () -> untyped } -> bool - def evaluate(target, options, ...) - case options - in if: if_cond, unless: unless_cond - EVAL.call(target, if_cond, ...) && !EVAL.call(target, unless_cond, ...) - in if: if_cond - EVAL.call(target, if_cond, ...) - in unless: unless_cond - !EVAL.call(target, unless_cond, ...) - else - true - end + # @rbs (untyped conditional, untyped context) -> bool + def self.falsy?(conditional, context) + return true if conditional.nil? + + !truthy?(conditional, context) end end diff --git a/lib/cmdx/utils/format.rb b/lib/cmdx/utils/format.rb index 4b092c1f6..b9cd8cf80 100644 --- a/lib/cmdx/utils/format.rb +++ b/lib/cmdx/utils/format.rb @@ -2,79 +2,42 @@ module CMDx module Utils - # Utility module for formatting data structures into log-friendly strings - # and converting messages to appropriate formats for logging + # Formatting helpers used for inspect strings and display. module Format - extend self + EMPTY_HASH = {}.freeze + EMPTY_ARRAY = [].freeze + EMPTY_STRING = "" - # @rbs FORMATTER: Proc - FORMATTER = proc do |key, value| - "#{key}=#{value.inspect}" - end.freeze - private_constant :FORMATTER - - # Converts a message to a format suitable for logging - # - # @param message [Object] The message to format + # Converts a hash to a readable string. # - # @return [Hash, Object] Returns a hash if the message responds to to_h and is a CMDx object, otherwise returns the original message + # @param hash [Hash] the data to format # - # @example Hash like objects - # Format.to_log({user_id: 123, action: "login"}) - # # => {user_id: 123, action: "login"} - # @example Simple message - # Format.to_log("simple message") - # # => "simple message" - # @example CMDx object - # Format.to_log(CMDx::Task.new(name: "task1")) - # # => {name: "task1"} + # @return [String] # - # @rbs (untyped message) -> untyped - def to_log(message) - if message.respond_to?(:to_h) && cmdx_based_object?(message.class) - message.to_h - else - message - end + # @rbs (Hash[untyped, untyped] hash) -> String + def self.to_str(hash) + hash.map { |k, v| "#{k}: #{v.inspect}" }.join(", ") end - # Converts a hash to a formatted string using a custom formatter - # - # @param hash [Hash] The hash to convert to string - # @param block [Proc, nil] Optional custom formatter block - # @option block [String] :key The hash key - # @option block [Object] :value The hash value + # Converts a class name to a formatted task type. # - # @return [String] Space-separated formatted key-value pairs + # @param klass [Class, String] the class to format # - # @example Default formatter - # Format.to_str({user_id: 123, status: "active"}) - # # => "user_id=123 status=\"active\"" - # @example Custom formatter - # Format.to_str({count: 5, total: 100}) { |k, v| "#{k}:#{v}" } - # # => "count:5 total:100" + # @return [String] # - # @rbs (Hash[untyped, untyped] hash) ?{ (untyped, untyped) -> String } -> String - def to_str(hash, &block) - block ||= FORMATTER - hash.map(&block).join(" ") - end - - private + # @rbs (untyped klass) -> String + def self.type_name(klass) + name = klass.is_a?(String) ? klass : klass.name + return "anonymous" if name.nil? - # Checks if a class belongs to the CMDx namespace, caching per class. - # - # @param klass [Class] The class to check - # - # @return [Boolean] true if the class is in the CMDx namespace - # - # @rbs (Class klass) -> bool - def cmdx_based_object?(klass) - @cmdx_classes ||= {} - return @cmdx_classes[klass] if @cmdx_classes.key?(klass) + name.gsub("::", ".").downcase + end - @cmdx_classes[klass] = klass.ancestors.any? { |a| a.name&.start_with?("CMDx::") } + # @rbs (untyped value) -> String + def self.truncate(value, max: 100) + str = value.to_s + str.length > max ? "#{str[0, max]}..." : str end end diff --git a/lib/cmdx/utils/normalize.rb b/lib/cmdx/utils/normalize.rb index 6163719bd..fedd840e4 100644 --- a/lib/cmdx/utils/normalize.rb +++ b/lib/cmdx/utils/normalize.rb @@ -2,49 +2,38 @@ module CMDx module Utils - # Provides normalization utilities for a variety of objects - # into consistent formats. + # Normalizes input values for attribute processing. module Normalize - extend self - - # Normalizes an exception into a string representation. - # - # @param exception [Exception] The exception to normalize + # Normalizes a value to nil when it is blank (empty string or nil). # - # @return [String] The normalized exception string + # @param value [Object] # - # @example From exception - # Normalize.exception(StandardError.new("test")) - # # => "[StandardError] test" + # @return [Object, nil] # - # @rbs (Exception exception) -> String - def exception(exception) - "[#{exception.class}] #{exception.message}" + # @rbs (untyped value) -> untyped + def self.blank_to_nil(value) + return nil if value.nil? + return nil if value.respond_to?(:empty?) && value.empty? + + value end - # Normalizes an object into an array of unique status strings. - # - # @param object [Object] The object to normalize into status strings + # Extracts a nested value using a dot-separated path. # - # @return [Array] Unique status strings + # @param source [Hash] the source data + # @param path [String] dot-separated path # - # @example From array of symbols - # Normalize.statuses([:success, :pending, :success]) - # # => ["success", "pending"] - # @example From single value - # Normalize.statuses(:success) - # # => ["success"] - # @example From nil - # Normalize.statuses(nil) - # # => [] + # @return [Object, nil] # - # @rbs (untyped object) -> Array[String] - def statuses(object) - ary = Wrap.array(object) - return EMPTY_ARRAY if ary.empty? + # @rbs (Hash[untyped, untyped] source, String path) -> untyped + def self.dig(source, path) + keys = path.split(".") + keys.reduce(source) do |obj, key| + break nil unless obj.respond_to?(:[]) - ary.map(&:to_s).uniq + obj[key.to_sym] || obj[key] + end end end diff --git a/lib/cmdx/utils/wrap.rb b/lib/cmdx/utils/wrap.rb index 1ad151e47..bb2c2af28 100644 --- a/lib/cmdx/utils/wrap.rb +++ b/lib/cmdx/utils/wrap.rb @@ -2,34 +2,21 @@ module CMDx module Utils - # Provides array wrapping utilities for normalizing input values - # into consistent array structures. + # Wraps values into arrays. module Wrap - extend self - - # Wraps an object in an array if it is not already an array. - # - # @param object [Object] The object to wrap in an array + # Ensures a value is wrapped in an array. # - # @return [Array] The wrapped array + # @param value [Object] the value to wrap # - # @example Already an array - # Wrap.array([1, 2, 3]) - # # => [1, 2, 3] - # @example Single value - # Wrap.array(1) - # # => [1] - # @example Nil value - # Wrap.array(nil) - # # => [] + # @return [Array] # - # @rbs (untyped object) -> Array[untyped] - def array(object) - case object - when Array then object - when NilClass then EMPTY_ARRAY - else Array(object) + # @rbs (untyped value) -> Array[untyped] + def self.call(value) + case value + when nil then [] + when Array then value + else [value] end end diff --git a/lib/cmdx/validator_registry.rb b/lib/cmdx/validator_registry.rb index 8b5381532..b01474563 100644 --- a/lib/cmdx/validator_registry.rb +++ b/lib/cmdx/validator_registry.rb @@ -1,142 +1,69 @@ # frozen_string_literal: true module CMDx - # Registry for managing validation rules and their corresponding validator classes. - # Provides methods to register, deregister, and execute validators against task values. - # - # Supports copy-on-write semantics: a duped registry shares the parent's - # data until a write operation triggers materialization. + # Registry of validator types mapping symbols to validator classes. + # Uses copy-on-write for safe inheritance across task classes. class ValidatorRegistry - extend Forwardable - - def_delegators :registry, :keys - - # Initialize a new validator registry with default validators. - # - # @param registry [Hash, nil] Optional hash mapping validator names to validator classes - # - # @return [ValidatorRegistry] A new validator registry instance - # - # @rbs (?Hash[Symbol, Class]? registry) -> void + # @rbs BUILT_INS: Hash[Symbol, String] + BUILT_INS = { + absence: "Validators::Absence", + exclusion: "Validators::Exclusion", + format: "Validators::Format", + inclusion: "Validators::Inclusion", + length: "Validators::Length", + numeric: "Validators::Numeric", + presence: "Validators::Presence" + }.freeze + + # @rbs @registry: Hash[Symbol, untyped] + attr_reader :registry + + # @rbs (?Hash[Symbol, untyped]? registry) -> void def initialize(registry = nil) - @registry = registry || { - absence: Validators::Absence, - exclusion: Validators::Exclusion, - format: Validators::Format, - inclusion: Validators::Inclusion, - length: Validators::Length, - numeric: Validators::Numeric, - presence: Validators::Presence - } + @registry = registry || {} end - # Sets up copy-on-write state when duplicated via dup. + # Returns the validator class for the given type. # - # @param source [ValidatorRegistry] The registry being duplicated + # @param type [Symbol, Class] the validator type # - # @rbs (ValidatorRegistry source) -> void - def initialize_dup(source) - @parent = source - @registry = nil - super - end - - # Returns the internal registry mapping validator types to classes. - # Delegates to the parent registry when not yet materialized. + # @return [Class] the validator class + # @raise [UnknownValidatorError] when the type is unknown # - # @return [Hash{Symbol => Class}] Hash of validator type names to validator classes - # - # @example - # registry.registry # => { presence: Validators::Presence, format: Validators::Format } - # - # @rbs () -> Hash[Symbol, Class] - def registry - @registry || @parent.registry - end - alias to_h registry + # @rbs (untyped type) -> untyped + def resolve(type) + return type if type.is_a?(Class) || type.is_a?(Module) - # Register a new validator class with the given name. - # - # @param name [String, Symbol] The name to register the validator under - # @param validator [Class] The validator class to register - # - # @return [ValidatorRegistry] Returns self for method chaining - # - # @example - # registry.register(:custom, CustomValidator) - # registry.register("email", EmailValidator) - # - # @rbs ((String | Symbol) name, Class validator) -> self - def register(name, validator) - materialize! - - @registry[name.to_sym] = validator - self + name = type.to_sym + registry[name] || resolve_built_in(name) end - # Remove a validator from the registry by name. - # - # @param name [String, Symbol] The name of the validator to remove + # Registers a custom validator type. # - # @return [ValidatorRegistry] Returns self for method chaining + # @param name [Symbol] the type name + # @param klass [Class] the validator class # - # @example - # registry.deregister(:format) - # registry.deregister("presence") - # - # @rbs ((String | Symbol) name) -> self - def deregister(name) - materialize! - - @registry.delete(name.to_sym) - self + # @rbs (Symbol name, untyped klass) -> void + def register(name, klass) + registry[name.to_sym] = klass end - # Validate a value using the specified validator type and options. - # - # @param type [Symbol] The type of validator to use - # @param task [Task] The task context for validation - # @param value [Object] The value to validate - # @param options [Hash, Object] Validation options or condition - # @option options [Boolean] :allow_nil Whether to allow nil values + # @return [ValidatorRegistry] a duplicated registry for child classes # - # @raise [TypeError] When the validator type is not registered - # - # @example - # registry.validate(:presence, task, user.name, presence: true) - # registry.validate(:length, task, password, { min: 8, allow_nil: false }) - # - # @rbs (Symbol type, Task task, untyped value, untyped options) -> untyped - def validate(type, task, value, options = EMPTY_HASH) - raise TypeError, "unknown validator type #{type.inspect}" unless registry.key?(type) - - match = - if options.is_a?(Hash) - case options - in allow_nil: then !(allow_nil && value.nil?) - else Utils::Condition.evaluate(task, options, value) - end - else - options - end - - return unless match - - Utils::Call.invoke(task, registry[type], value, options) + # @rbs () -> ValidatorRegistry + def for_child + self.class.new(registry.dup) end private - # Copies the parent's registry data into this instance, - # severing the copy-on-write link. - # - # @rbs () -> void - def materialize! - return if @registry + # @rbs (Symbol name) -> untyped + def resolve_built_in(name) + const_name = BUILT_INS[name] + raise UnknownValidatorError, "unknown validator: #{name}" unless const_name - @registry = @parent.registry.dup - @parent = nil + CMDx.const_get(const_name) end end diff --git a/lib/cmdx/validators/absence.rb b/lib/cmdx/validators/absence.rb index d46799005..d5a29bbcf 100644 --- a/lib/cmdx/validators/absence.rb +++ b/lib/cmdx/validators/absence.rb @@ -2,58 +2,20 @@ module CMDx module Validators - # Validates that a value is absent or empty - # - # This validator ensures that the given value is nil, empty, or consists only of whitespace. - # It handles different value types appropriately: - # - Strings: checks for absence of non-whitespace characters - # - Collections: checks for empty collections - # - Other objects: checks for nil values + # Validates that a value is absent (nil or empty). module Absence - extend self - - # Validates that a value is absent or empty - # - # @param value [Object] The value to validate for absence - # @param options [Hash] Validation configuration options - # @option options [String] :message Custom error message - # - # @return [nil] Returns nil if validation passes + # @param value [Object] the value to validate + # @param _options [Hash] unused # - # @raise [ValidationError] When the value is present, not empty, or contains non-whitespace characters + # @return [String, nil] error message or nil # - # @example Validate string absence - # Absence.call("") - # # => nil (validation passes) - # @example Validate non-empty string - # Absence.call("hello") - # # => raises ValidationError - # @example Validate array absence - # Absence.call([]) - # # => nil (validation passes) - # @example Validate non-empty array - # Absence.call([1, 2, 3]) - # # => raises ValidationError - # @example Validate with custom message - # Absence.call("hello", message: "Value must be empty") - # # => raises ValidationError with custom message - # - # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> nil - def call(value, options = EMPTY_HASH) - match = - if value.is_a?(String) - /\S/.match?(value) - elsif value.respond_to?(:empty?) - !value.empty? - else - !value.nil? - end - - return unless match + # @rbs (untyped value, **untyped) -> String? + def self.call(value, **) + return if value.nil? + return if value.respond_to?(:empty?) && value.empty? - message = options[:message] if options.is_a?(Hash) - raise ValidationError, message || Locale.t("cmdx.validators.absence") + Locale.t("cmdx.validators.absence") end end diff --git a/lib/cmdx/validators/exclusion.rb b/lib/cmdx/validators/exclusion.rb index b49b27462..d62da79cb 100644 --- a/lib/cmdx/validators/exclusion.rb +++ b/lib/cmdx/validators/exclusion.rb @@ -2,78 +2,28 @@ module CMDx module Validators - # Validates that a value is not included in a specified set or range - # - # This validator ensures that the given value is excluded from a collection - # of forbidden values or falls outside a specified range. It supports both - # discrete value lists and range-based exclusions. + # Validates that a value is NOT within a given set or range. module Exclusion - extend self - - # Validates that a value is excluded from the specified options - # - # @param value [Object] The value to validate for exclusion - # @param options [Hash] Validation configuration options - # @option options [Array, Range] :in The collection of forbidden values or range - # @option options [Array, Range] :within Alias for :in option - # @option options [String] :message Custom error message template - # @option options [String] :of_message Custom message for discrete value exclusions - # @option options [String] :in_message Custom message for range-based exclusions - # @option options [String] :within_message Custom message for range-based exclusions - # - # @raise [ValidationError] When the value is found in the forbidden collection - # - # @example Exclude specific values - # Exclusion.call("admin", in: ["admin", "root", "superuser"]) - # # => raises ValidationError if value is "admin" - # @example Exclude values within a range - # Exclusion.call(5, in: 1..10) - # # => raises ValidationError if value is 5 (within 1..10) - # @example Exclude with custom message - # Exclusion.call("test", in: ["test", "demo"], message: "value %{values} is forbidden") - # - # @rbs (untyped value, Hash[Symbol, untyped] options) -> nil - def call(value, options = EMPTY_HASH) - values = options[:in] || options[:within] - - if values.is_a?(Range) - raise_within_validation_error!(values.begin, values.end, options) if values.cover?(value) - elsif Utils::Wrap.array(values).any? { |v| v === value } - raise_of_validation_error!(values, options) - end - end - - private - - # Raises validation error for discrete value exclusions + # @param value [Object] the value to validate + # @param of [Array, nil] excluded values + # @param within [Range, nil] excluded range # - # @param values [Array] The forbidden values that caused the error - # @param options [Hash] Validation options containing custom messages - # @option options [Object] :* Any validation option key-value pairs + # @return [String, nil] error message or nil # - # @raise [ValidationError] With appropriate error message - def raise_of_validation_error!(values, options) - values = values.map(&:inspect).join(", ") unless values.nil? - message = options[:of_message] || options[:message] - message %= { values: } unless message.nil? + # @rbs (untyped value, ?of: Array[untyped]?, ?within: Range[untyped]?, **untyped) -> String? + def self.call(value, of: nil, within: nil, **) + return if value.nil? - raise ValidationError, message || Locale.t("cmdx.validators.exclusion.of", values:) - end + if of + return unless of.include?(value) - # Raises validation error for range-based exclusions - # - # @param min [Object] The minimum value of the forbidden range - # @param max [Object] The maximum value of the forbidden range - # @param options [Hash] Validation options containing custom messages - # @option options [Object] :* Any validation option key-value pairs - # - # @raise [ValidationError] With appropriate error message - def raise_within_validation_error!(min, max, options) - message = options[:in_message] || options[:within_message] || options[:message] - message %= { min:, max: } unless message.nil? + Locale.t("cmdx.validators.exclusion.of", values: of.join(", ")) + elsif within + return unless within.cover?(value) - raise ValidationError, message || Locale.t("cmdx.validators.exclusion.within", min:, max:) + Locale.t("cmdx.validators.exclusion.within", min: within.min, max: within.max) + end end end diff --git a/lib/cmdx/validators/format.rb b/lib/cmdx/validators/format.rb index ee3de1f2b..6c35b420c 100644 --- a/lib/cmdx/validators/format.rb +++ b/lib/cmdx/validators/format.rb @@ -2,65 +2,21 @@ module CMDx module Validators - # Validates that a value matches a specified format pattern - # - # This validator ensures that the given value conforms to a specific format - # using regular expressions. It supports both direct regex matching and - # conditional matching with inclusion/exclusion patterns. + # Validates that a value matches a given regular expression. module Format - extend self - - # Validates that a value matches the specified format pattern - # - # @param value [Object] The value to validate for format compliance - # @param options [Hash, Regexp] Validation configuration options or direct regex pattern - # @option options [Regexp] :with Required pattern that the value must match - # @option options [Regexp] :without Pattern that the value must not match - # @option options [String] :message Custom error message - # - # @return [nil] Returns nil if validation passes + # @param value [Object] the value to validate + # @param with [Regexp] the pattern to match # - # @raise [ValidationError] When the value doesn't match the required format + # @return [String, nil] error message or nil # - # @example Direct regex validation - # Format.call("user@example.com", /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i) - # # => nil (validation passes) - # @example Validate with required pattern - # Format.call("ABC123", with: /\A[A-Z]{3}\d{3}\z/) - # # => nil (validation passes) - # @example Validate with exclusion pattern - # Format.call("hello", without: /\d/) - # # => nil (validation passes - no digits) - # @example Validate with both patterns - # Format.call("test123", with: /\A\w+\z/, without: /\A\d+\z/) - # # => nil (validation passes - alphanumeric but not all digits) - # @example Validate with custom message - # Format.call("invalid", with: /\A\d+\z/, message: "Must contain only digits") - # # => raises ValidationError with custom message - # - # @rbs (untyped value, (Hash[Symbol, untyped] | Regexp) options) -> nil - def call(value, options = EMPTY_HASH) - match = - if options.is_a?(Regexp) - value&.match?(options) - else - case options - in with:, without: - value&.match?(with) && !value&.match?(without) - in with: - value&.match?(with) - in without: - !value&.match?(without) - else - false - end - end - - return if match + # @rbs (untyped value, ?with: Regexp?, **untyped) -> String? + def self.call(value, with: nil, **) + return if value.nil? + return unless with + return if with.match?(value.to_s) - message = options[:message] if options.is_a?(Hash) - raise ValidationError, message || Locale.t("cmdx.validators.format") + Locale.t("cmdx.validators.format") end end diff --git a/lib/cmdx/validators/inclusion.rb b/lib/cmdx/validators/inclusion.rb index 82a860282..6310a8ae3 100644 --- a/lib/cmdx/validators/inclusion.rb +++ b/lib/cmdx/validators/inclusion.rb @@ -2,80 +2,28 @@ module CMDx module Validators - # Validates that a value is included in a specified set or range - # - # This validator ensures that the given value is present within a collection - # of allowed values or falls within a specified range. It supports both - # discrete value lists and range-based validations. + # Validates that a value is within a given set or range. module Inclusion - extend self - - # Validates that a value is included in the specified options - # - # @param value [Object] The value to validate for inclusion - # @param options [Hash] Validation configuration options - # @option options [Array, Range] :in The collection of allowed values or range - # @option options [Array, Range] :within Alias for :in option - # @option options [String] :message Custom error message template - # @option options [String] :of_message Custom message for discrete value inclusions - # @option options [String] :in_message Custom message for range-based inclusions - # @option options [String] :within_message Custom message for range-based inclusions - # - # @return [nil] Returns nil if validation passes - # - # @raise [ValidationError] When the value is not found in the allowed collection + # @param value [Object] the value to validate + # @param of [Array, nil] allowed values + # @param within [Range, nil] allowed range # - # @example Include specific values - # Inclusion.call("admin", in: ["admin", "user", "guest"]) - # # => nil (validation passes) - # @example Include values within a range - # Inclusion.call(5, in: 1..10) - # # => nil (validation passes - 5 is within 1..10) - # @example Include with custom message - # Inclusion.call("test", in: ["admin", "user"], message: "must be one of: %{values}") + # @return [String, nil] error message or nil # - # @rbs (untyped value, Hash[Symbol, untyped] options) -> nil - def call(value, options = EMPTY_HASH) - values = options[:in] || options[:within] + # @rbs (untyped value, ?of: Array[untyped]?, ?within: Range[untyped]?, **untyped) -> String? + def self.call(value, of: nil, within: nil, **) + return if value.nil? - if values.is_a?(Range) - raise_within_validation_error!(values.begin, values.end, options) unless values.cover?(value) - elsif Utils::Wrap.array(values).none? { |v| v === value } - raise_of_validation_error!(values, options) - end - end + if of + return if of.include?(value) - private + Locale.t("cmdx.validators.inclusion.of", values: of.join(", ")) + elsif within + return if within.cover?(value) - # Raises validation error for discrete value inclusions - # - # @param values [Array] The allowed values that caused the error - # @param options [Hash] Validation options containing custom messages - # @option options [Object] :* Any validation option key-value pairs - # - # @raise [ValidationError] With appropriate error message - def raise_of_validation_error!(values, options) - values = values.map(&:inspect).join(", ") unless values.nil? - message = options[:of_message] || options[:message] - message %= { values: } unless message.nil? - - raise ValidationError, message || Locale.t("cmdx.validators.inclusion.of", values:) - end - - # Raises validation error for range-based inclusions - # - # @param min [Object] The minimum value of the allowed range - # @param max [Object] The maximum value of the allowed range - # @param options [Hash] Validation options containing custom messages - # @option options [Object] :* Any validation option key-value pairs - # - # @raise [ValidationError] With appropriate error message - def raise_within_validation_error!(min, max, options) - message = options[:in_message] || options[:within_message] || options[:message] - message %= { min:, max: } unless message.nil? - - raise ValidationError, message || Locale.t("cmdx.validators.inclusion.within", min:, max:) + Locale.t("cmdx.validators.inclusion.within", min: within.min, max: within.max) + end end end diff --git a/lib/cmdx/validators/length.rb b/lib/cmdx/validators/length.rb index 04fa18ee5..8f1773eb4 100644 --- a/lib/cmdx/validators/length.rb +++ b/lib/cmdx/validators/length.rb @@ -2,182 +2,36 @@ module CMDx module Validators - # Validates the length of a value against various constraints. - # - # This validator supports multiple length validation strategies including exact length, - # minimum/maximum bounds, and range-based validation. It can be used to ensure - # values meet specific length requirements for strings, arrays, and other - # enumerable objects. + # Validates the length of a value (string, array, etc.) module Length - extend self - - # Validates a value's length against specified constraints. - # - # @param value [String, Array, Hash, Object] The value to validate (must respond to #length) - # @param options [Hash] Validation options - # @option options [Range] :within Range that the length must fall within (inclusive) - # @option options [Range] :not_within Range that the length must not fall within - # @option options [Range] :in Alias for :within - # @option options [Range] :not_in Range that the length must not fall within - # @option options [Integer] :min Minimum allowed length - # @option options [Integer] :max Maximum allowed length - # @option options [Integer] :is Exact required length - # @option options [Integer] :is_not Length that is not allowed - # @option options [String] :message Custom error message for all validations - # @option options [String] :within_message Custom message for within/range validations - # @option options [String] :in_message Custom message for :in validation - # @option options [String] :not_within_message Custom message for not_within validation - # @option options [String] :not_in_message Custom message for not_in validation - # @option options [String] :min_message Custom message for minimum length validation - # @option options [String] :max_message Custom message for maximum length validation - # @option options [String] :is_message Custom message for exact length validation - # @option options [String] :is_not_message Custom message for is_not validation - # - # @return [nil] Returns nil if validation passes + # @param value [Object] the value to validate + # @param is [Integer, nil] exact length + # @param is_not [Integer, nil] length to reject + # @param min [Integer, nil] minimum length + # @param max [Integer, nil] maximum length + # @param within [Range, nil] length range # - # @raise [ValidationError] When validation fails - # @raise [ArgumentError] When unknown validation options are provided + # @return [String, nil] error message or nil # - # @example Exact length validation - # Length.call("hello", is: 5) - # # => nil (validation passes) - # @example Range-based validation - # Length.call("test", within: 3..6) - # # => nil (validation passes - length 4 is within range) - # @example Min/max validation - # Length.call("username", min: 3, max: 20) - # # => nil (validation passes - length 8 is between 3 and 20) - # @example Exclusion validation - # Length.call("short", not_in: 1..3) - # # => nil (validation passes - length 5 is not in excluded range) - # - # @rbs (untyped value, Hash[Symbol, untyped] options) -> nil - def call(value, options = EMPTY_HASH) - length = value&.length + # @rbs (untyped value, ?is: Integer?, ?is_not: Integer?, ?min: Integer?, ?max: Integer?, ?within: Range[Integer]?, **untyped) -> String? + def self.call(value, is: nil, is_not: nil, min: nil, max: nil, within: nil, **) # rubocop:disable Metrics/ParameterLists + return if value.nil? + return unless value.respond_to?(:length) - case options - in within: - raise_within_validation_error!(within.begin, within.end, options) unless within&.cover?(length) - in not_within: - raise_not_within_validation_error!(not_within.begin, not_within.end, options) if not_within&.cover?(length) - in in: xin - raise_within_validation_error!(xin.begin, xin.end, options) unless xin&.cover?(length) - in not_in: - raise_not_within_validation_error!(not_in.begin, not_in.end, options) if not_in&.cover?(length) - in min:, max: - raise_within_validation_error!(min, max, options) unless length&.between?(min, max) - in min: - raise_min_validation_error!(min, options) unless !length.nil? && (min <= length) - in max: - raise_max_validation_error!(max, options) unless !length.nil? && (length <= max) - in is: - raise_is_validation_error!(is, options) unless !length.nil? && (length == is) - in is_not: - raise_is_not_validation_error!(is_not, options) if !length.nil? && (length == is_not) - else - raise ArgumentError, "unknown length validator options given" - end - end - - private - - # Raises validation error for within/range validations. - # - # @param min [Integer] Minimum length value - # @param max [Integer] Maximum length value - # @param options [Hash] Validation options containing custom messages - # @option options [Object] :* Any validation option key-value pairs - # - # @raise [ValidationError] Always raised with appropriate message - # - # @rbs (Integer min, Integer max, Hash[Symbol, untyped] options) -> void - def raise_within_validation_error!(min, max, options) - message = options[:within_message] || options[:in_message] || options[:message] - message %= { min:, max: } unless message.nil? + len = value.length - raise ValidationError, message || Locale.t("cmdx.validators.length.within", min:, max:) - end + return Locale.t("cmdx.validators.length.is", is:) if is && len != is - # Raises validation error for not_within validations. - # - # @param min [Integer] Minimum length value - # @param max [Integer] Maximum length value - # @param options [Hash] Validation options containing custom messages - # @option options [Object] :* Any validation option key-value pairs - # - # @raise [ValidationError] Always raised with appropriate message - # - # @rbs (Integer min, Integer max, Hash[Symbol, untyped] options) -> void - def raise_not_within_validation_error!(min, max, options) - message = options[:not_within_message] || options[:not_in_message] || options[:message] - message %= { min:, max: } unless message.nil? + return Locale.t("cmdx.validators.length.is_not", is_not:) if is_not && len == is_not - raise ValidationError, message || Locale.t("cmdx.validators.length.not_within", min:, max:) - end + return Locale.t("cmdx.validators.length.min", min:) if min && len < min - # Raises validation error for minimum length validation. - # - # @param min [Integer] Minimum required length - # @param options [Hash] Validation options containing custom messages - # @option options [Object] :* Any validation option key-value pairs - # - # @raise [ValidationError] Always raised with appropriate message - # - # @rbs (Integer min, Hash[Symbol, untyped] options) -> void - def raise_min_validation_error!(min, options) - message = options[:min_message] || options[:message] - message %= { min: } unless message.nil? + return Locale.t("cmdx.validators.length.max", max:) if max && len > max - raise ValidationError, message || Locale.t("cmdx.validators.length.min", min:) - end - - # Raises validation error for maximum length validation. - # - # @param max [Integer] Maximum allowed length - # @param options [Hash] Validation options containing custom messages - # @option options [Object] :* Any validation option key-value pairs - # - # @raise [ValidationError] Always raised with appropriate message - # - # @rbs (Integer max, Hash[Symbol, untyped] options) -> void - def raise_max_validation_error!(max, options) - message = options[:max_message] || options[:message] - message %= { max: } unless message.nil? - - raise ValidationError, message || Locale.t("cmdx.validators.length.max", max:) - end - - # Raises validation error for exact length validation. - # - # @param is [Integer] Required exact length - # @param options [Hash] Validation options containing custom messages - # @option options [Object] :* Any validation option key-value pairs - # - # @raise [ValidationError] Always raised with appropriate message - # - # @rbs (Integer is, Hash[Symbol, untyped] options) -> void - def raise_is_validation_error!(is, options) - message = options[:is_message] || options[:message] - message %= { is: } unless message.nil? - - raise ValidationError, message || Locale.t("cmdx.validators.length.is", is:) - end - - # Raises validation error for is_not length validation. - # - # @param is_not [Integer] Length that is not allowed - # @param options [Hash] Validation options containing custom messages - # @option options [Object] :* Any validation option key-value pairs - # - # @raise [ValidationError] Always raised with appropriate message - # - # @rbs (Integer is_not, Hash[Symbol, untyped] options) -> void - def raise_is_not_validation_error!(is_not, options) - message = options[:is_not_message] || options[:message] - message %= { is_not: } unless message.nil? + return Locale.t("cmdx.validators.length.within", min: within.min, max: within.max) if within && !within.cover?(len) - raise ValidationError, message || Locale.t("cmdx.validators.length.is_not", is_not:) + nil end end diff --git a/lib/cmdx/validators/numeric.rb b/lib/cmdx/validators/numeric.rb index d20e9e852..75da141cb 100644 --- a/lib/cmdx/validators/numeric.rb +++ b/lib/cmdx/validators/numeric.rb @@ -2,177 +2,34 @@ module CMDx module Validators - # Validates numeric values against various constraints and ranges - # - # This validator ensures that numeric values meet specified criteria such as - # minimum/maximum bounds, exact matches, or range inclusions. It supports - # both inclusive and exclusive range validations with customizable error messages. + # Validates numeric constraints on a value. module Numeric - extend self - - # Validates a numeric value against the specified options - # - # @param value [Numeric] The numeric value to validate - # @param options [Hash] Validation configuration options - # @option options [Range] :within Range that the value must fall within (inclusive) - # @option options [Range] :not_within Range that the value must not fall within - # @option options [Range] :in Alias for :within option - # @option options [Range] :not_in Alias for :not_within option - # @option options [Numeric] :min Minimum allowed value (inclusive) - # @option options [Numeric] :max Maximum allowed value (inclusive) - # @option options [Numeric] :is Exact value that must match - # @option options [Numeric] :is_not Value that must not match - # @option options [String] :message Custom error message template - # @option options [String] :within_message Custom message for range validations - # @option options [String] :not_within_message Custom message for exclusion validations - # @option options [String] :min_message Custom message for minimum validation - # @option options [String] :max_message Custom message for maximum validation - # @option options [String] :is_message Custom message for exact match validation - # @option options [String] :is_not_message Custom message for exclusion validation - # - # @return [nil] Returns nil if validation passes - # - # @raise [ValidationError] When the value fails validation - # @raise [ArgumentError] When unknown validator options are provided - # - # @example Validate value within a range - # Numeric.call(5, within: 1..10) - # # => nil (validation passes) - # @example Validate minimum and maximum bounds - # Numeric.call(15, min: 10, max: 20) - # # => nil (validation passes) - # @example Validate exact value match - # Numeric.call(42, is: 42) - # # => nil (validation passes) - # @example Validate value exclusion - # Numeric.call(5, not_in: 1..10) - # # => nil (validation passes - 5 is not in 1..10) - # - # @rbs (Numeric value, Hash[Symbol, untyped] options) -> nil - def call(value, options = EMPTY_HASH) - case options - in within: - raise_within_validation_error!(within.begin, within.end, options) unless within&.cover?(value) - in not_within: - raise_not_within_validation_error!(not_within.begin, not_within.end, options) if not_within&.cover?(value) - in in: xin - raise_within_validation_error!(xin.begin, xin.end, options) unless xin&.cover?(value) - in not_in: - raise_not_within_validation_error!(not_in.begin, not_in.end, options) if not_in&.cover?(value) - in min:, max: - raise_within_validation_error!(min, max, options) unless value&.between?(min, max) - in min: - raise_min_validation_error!(min, options) unless !value.nil? && (min <= value) - in max: - raise_max_validation_error!(max, options) unless !value.nil? && (value <= max) - in is: - raise_is_validation_error!(is, options) unless !value.nil? && (value == is) - in is_not: - raise_is_not_validation_error!(is_not, options) if !value.nil? && (value == is_not) - else - raise ArgumentError, "unknown numeric validator options given" - end - end - - private - - # Raises validation error for range inclusion validation + # @param value [Object] the value to validate + # @param is [Numeric, nil] exact value + # @param is_not [Numeric, nil] rejected value + # @param min [Numeric, nil] minimum value + # @param max [Numeric, nil] maximum value + # @param within [Range, nil] value range # - # @param min [Numeric] The minimum value of the allowed range - # @param max [Numeric] The maximum value of the allowed range - # @param options [Hash] Validation options containing custom messages - # @option options [Object] :* Any validation option key-value pairs + # @return [String, nil] error message or nil # - # @raise [ValidationError] With appropriate error message - # - # @rbs (Numeric min, Numeric max, Hash[Symbol, untyped] options) -> void - def raise_within_validation_error!(min, max, options) - message = options[:within_message] || options[:in_message] || options[:message] - message %= { min:, max: } unless message.nil? + # @rbs (untyped value, ?is: Numeric?, ?is_not: Numeric?, ?min: Numeric?, ?max: Numeric?, ?within: Range[Numeric]?, **untyped) -> String? + def self.call(value, is: nil, is_not: nil, min: nil, max: nil, within: nil, **) # rubocop:disable Metrics/ParameterLists + return if value.nil? + return unless value.is_a?(::Numeric) - raise ValidationError, message || Locale.t("cmdx.validators.numeric.within", min:, max:) - end + return Locale.t("cmdx.validators.numeric.is", is:) if is && value != is - # Raises validation error for range exclusion validation - # - # @param min [Numeric] The minimum value of the excluded range - # @param max [Numeric] The maximum value of the excluded range - # @param options [Hash] Validation options containing custom messages - # @option options [Object] :* Any validation option key-value pairs - # - # @raise [ValidationError] With appropriate error message - # - # @rbs (Numeric min, Numeric max, Hash[Symbol, untyped] options) -> void - def raise_not_within_validation_error!(min, max, options) - message = options[:not_within_message] || options[:not_in_message] || options[:message] - message %= { min:, max: } unless message.nil? + return Locale.t("cmdx.validators.numeric.is_not", is_not:) if is_not && value == is_not - raise ValidationError, message || Locale.t("cmdx.validators.numeric.not_within", min:, max:) - end + return Locale.t("cmdx.validators.numeric.min", min:) if min && value < min - # Raises validation error for minimum value validation - # - # @param min [Numeric] The minimum allowed value - # @param options [Hash] Validation options containing custom messages - # @option options [Object] :* Any validation option key-value pairs - # - # @raise [ValidationError] With appropriate error message - # - # @rbs (Numeric min, Hash[Symbol, untyped] options) -> void - def raise_min_validation_error!(min, options) - message = options[:min_message] || options[:message] - message %= { min: } unless message.nil? + return Locale.t("cmdx.validators.numeric.max", max:) if max && value > max - raise ValidationError, message || Locale.t("cmdx.validators.numeric.min", min:) - end - - # Raises validation error for maximum value validation - # - # @param max [Numeric] The maximum allowed value - # @param options [Hash] Validation options containing custom messages - # @option options [Object] :* Any validation option key-value pairs - # - # @raise [ValidationError] With appropriate error message - # - # @rbs (Numeric max, Hash[Symbol, untyped] options) -> void - def raise_max_validation_error!(max, options) - message = options[:max_message] || options[:message] - message %= { max: } unless message.nil? - - raise ValidationError, message || Locale.t("cmdx.validators.numeric.max", max:) - end - - # Raises validation error for exact value match validation - # - # @param is [Numeric] The exact value that was expected - # @param options [Hash] Validation options containing custom messages - # @option options [Object] :* Any validation option key-value pairs - # - # @raise [ValidationError] With appropriate error message - # - # @rbs (Numeric is, Hash[Symbol, untyped] options) -> void - def raise_is_validation_error!(is, options) - message = options[:is_message] || options[:message] - message %= { is: } unless message.nil? - - raise ValidationError, message || Locale.t("cmdx.validators.numeric.is", is:) - end - - # Raises validation error for value exclusion validation - # - # @param is_not [Numeric] The value that was not allowed - # @param options [Hash] Validation options containing custom messages - # @option options [Object] :* Any validation option key-value pairs - # - # @raise [ValidationError] With appropriate error message - # - # @rbs (Numeric is_not, Hash[Symbol, untyped] options) -> void - def raise_is_not_validation_error!(is_not, options) - message = options[:is_not_message] || options[:message] - message %= { is_not: } unless message.nil? + return Locale.t("cmdx.validators.numeric.within", min: within.min, max: within.max) if within && !within.cover?(value) - raise ValidationError, message || Locale.t("cmdx.validators.numeric.is_not", is_not:) + nil end end diff --git a/lib/cmdx/validators/presence.rb b/lib/cmdx/validators/presence.rb index de51ab27b..0e3436286 100644 --- a/lib/cmdx/validators/presence.rb +++ b/lib/cmdx/validators/presence.rb @@ -2,58 +2,27 @@ module CMDx module Validators - # Validates that a value is present and not empty - # - # This validator ensures that the given value exists and contains meaningful content. - # It handles different value types appropriately: - # - Strings: checks for non-whitespace characters - # - Collections: checks for non-empty collections - # - Other objects: checks for non-nil values + # Validates that a value is present (not nil, not empty). module Presence - extend self - - # Validates that a value is present and not empty - # - # @param value [Object] The value to validate for presence - # @param options [Hash] Validation configuration options - # @option options [String] :message Custom error message - # - # @return [nil] Returns nil if validation passes + # @param value [Object] the value to validate + # @param _options [Hash] unused # - # @raise [ValidationError] When the value is empty, nil, or contains only whitespace + # @return [String, nil] error message or nil # - # @example Validate string presence - # Presence.call("hello world") - # # => nil (validation passes) - # @example Validate empty string - # Presence.call(" ") - # # => raises ValidationError - # @example Validate array presence - # Presence.call([1, 2, 3]) - # # => nil (validation passes) - # @example Validate empty array - # Presence.call([]) - # # => raises ValidationError - # @example Validate with custom message - # Presence.call(nil, message: "Value cannot be blank") - # # => raises ValidationError with custom message - # - # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> nil - def call(value, options = EMPTY_HASH) - match = - if value.is_a?(String) - /\S/.match?(value) - elsif value.respond_to?(:empty?) - !value.empty? - else - !value.nil? - end + # @rbs (untyped value, **untyped) -> String? + def self.call(value, **) + return if present?(value) + + Locale.t("cmdx.validators.presence") + end - return if match + # @rbs (untyped value) -> bool + def self.present?(value) + return false if value.nil? + return false if value.respond_to?(:empty?) && value.empty? - message = options[:message] if options.is_a?(Hash) - raise ValidationError, message || Locale.t("cmdx.validators.presence") + true end end diff --git a/lib/cmdx/value_resolver.rb b/lib/cmdx/value_resolver.rb new file mode 100644 index 000000000..ada94cae6 --- /dev/null +++ b/lib/cmdx/value_resolver.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +module CMDx + # Resolves a single attribute's value through the pipeline: + # source → derive → default → coerce → transform + module ValueResolver + + # Resolves the value for an attribute. + # + # @param attr [Attribute] the attribute definition + # @param task [Task] the task instance + # @param context [Context] the execution context + # + # @return [Object] the resolved value + # + # @rbs (Attribute attr, untyped task, Context context) -> untyped + def self.call(attr, task, context) + value = source(attr, task, context) + value = derive(attr, task, value) + value = apply_default(attr, task, value) + value = coerce(attr, value) + transform(attr, task, value) + end + + # Resolves the raw value from context or a :from source. + # + # @rbs (Attribute attr, untyped task, Context context) -> untyped + def self.source(attr, task, context) + if attr.from + if task.respond_to?(attr.from, true) + task.__send__(attr.from) + else + context[attr.from] + end + else + context[attr.name] + end + end + + # Applies a derivation callable if configured. + # + # @rbs (Attribute attr, untyped task, untyped value) -> untyped + def self.derive(attr, task, value) + return value unless attr.derive + + Utils::Call.invoke(attr.derive, task, value) + end + + # Applies the default value if the current value is nil. + # + # @rbs (Attribute attr, untyped task, untyped value) -> untyped + def self.apply_default(attr, task, value) + return value unless value.nil? && attr.has_default? + + default = attr.default + case default + when Proc then task.instance_exec(&default) + when Symbol then task.__send__(default) + else default + end + end + + # Coerces the value through the registered coercion type. + # + # @rbs (Attribute attr, untyped value) -> untyped + def self.coerce(attr, value) + return value unless attr.typed? && !value.nil? + + coercer = CoercionRegistry.new.resolve(attr.type) + coercer.call(value) + end + + # Applies a transformation callable if configured. + # + # @rbs (Attribute attr, untyped task, untyped value) -> untyped + def self.transform(attr, task, value) + return value unless attr.transform + + Utils::Call.invoke(attr.transform, task, value) + end + + end +end diff --git a/lib/cmdx/version.rb b/lib/cmdx/version.rb index b07955686..7cf56dd70 100644 --- a/lib/cmdx/version.rb +++ b/lib/cmdx/version.rb @@ -2,9 +2,7 @@ module CMDx - # @return [String] the version of the CMDx gem - # - # @rbs return: String - VERSION = "1.21.0" + # @rbs VERSION: String + VERSION = "2.0.0" end diff --git a/lib/cmdx/workflow.rb b/lib/cmdx/workflow.rb index 5a006e4a9..f1e211b61 100644 --- a/lib/cmdx/workflow.rb +++ b/lib/cmdx/workflow.rb @@ -1,134 +1,69 @@ # frozen_string_literal: true module CMDx - # Provides workflow execution capabilities by organizing tasks into execution groups. - # Workflows allow you to define sequences of tasks that can be executed conditionally - # with breakpoint handling and context management. + # Module providing workflow DSL for orchestrating multiple tasks. + # + # @example + # class OnboardUser < CMDx::Task + # include CMDx::Workflow + # + # task CreateAccount + # task SendWelcomeEmail, on_failure: :skip + # tasks CreateProfile, SetupPreferences, parallel: true + # end module Workflow - module ClassMethods - - # Prevents redefinition of the work method to maintain workflow integrity. - # - # @param method_name [Symbol] The name of the method being added - # - # @raise [RuntimeError] If attempting to redefine the work method - # - # @example - # class MyWorkflow - # include CMDx::Workflow - # # This would raise an error: - # # def work; end - # end - # - # @rbs (Symbol method_name) -> void - def method_added(method_name) - raise "cannot redefine #{name}##{method_name} method" if method_name == :work + # @rbs (untyped base) -> void + def self.included(base) + base.extend(ClassMethods) + end - super - end + # Class-level DSL for declaring workflow steps. + module ClassMethods - # Returns the collection of execution groups for this workflow. + # @return [Array] ordered list of task entries # - # @return [Array] Array of execution groups - # - # @example - # class MyWorkflow - # include CMDx::Workflow - # task Task1 - # task Task2 - # puts pipeline.size # => 2 - # end - # - # @rbs () -> Array[ExecutionGroup] - def pipeline - @pipeline ||= [] + # @rbs () -> Array[Hash[Symbol, untyped]] + def workflow_entries + @workflow_entries ||= if superclass.respond_to?(:workflow_entries) + superclass.workflow_entries.dup + else + [] + end end - # Adds multiple tasks to the workflow with optional configuration. - # - # @param tasks [Array] Array of task classes to add - # @param options [Hash] Configuration options for the task execution - # @option options [Hash] :breakpoints Breakpoints that trigger workflow interruption - # @option options [Hash] :conditions Conditional logic for task execution + # Declares a single task step in the workflow. # - # @raise [TypeError] If any task is not a CMDx::Task subclass + # @param task_class [Class] the task class + # @param options [Hash] execution options (:on_failure, :if, :unless) # - # @example - # class MyWorkflow - # include CMDx::Workflow - # tasks ValidateTask, ProcessTask, NotifyTask, breakpoints: [:failure, :halt] - # end - # - # @rbs (*untyped tasks, **untyped options) -> void - def tasks(*tasks, **options) - pipeline << ExecutionGroup.new( - tasks.map do |task| - next task if task.is_a?(Class) && (task <= Task) - - raise TypeError, "must be a CMDx::Task" - end, - options - ) + # @rbs (untyped task_class, **untyped options) -> void + def task(task_class, **options) + workflow_entries << { task: task_class, options: } end - alias task tasks - # Returns all tasks in the pipeline. + # Declares parallel task steps in the workflow. # - # @return [Array] Array of task classes + # @param task_classes [Array] task classes to run in parallel + # @param options [Hash] execution options (:pool_size, :on_failure) # - # @example - # class MyWorkflow - # include CMDx::Workflow - # task Task1 - # task Task2 - # puts subtasks.size # => 2 - # end - # - # @rbs () -> Array[Class] - def subtasks - pipeline.flat_map(&:tasks) + # @rbs (*untyped task_classes, **untyped options) -> void + def tasks(*task_classes, **options) + entries = task_classes.map { |tc| { task: tc, options: } } + workflow_entries << { parallel: true, tasks: entries } end end - # Represents a group of tasks with shared execution options. - # @attr tasks [Array] Array of task classes in this group - # @attr options [Hash] Configuration options for the group - ExecutionGroup = Struct.new(:tasks, :options) - - # Extends the including class with workflow capabilities. - # - # @param base [Class] The class including this module - # - # @example - # class MyWorkflow - # include CMDx::Workflow - # # Now has access to task, tasks, and work methods - # end - # - # @rbs (Class base) -> void - def self.included(base) - base.extend(ClassMethods) - end - - # Executes the workflow by processing all tasks in the pipeline. - # This method delegates execution to the Pipeline class which handles - # the processing of tasks with proper error handling and context management. - # - # @example - # class MyWorkflow - # include CMDx::Workflow - # task ValidateTask - # task ProcessTask - # end - # - # workflow = MyWorkflow.new - # result = workflow.work + # Override work to execute the workflow pipeline (must be public; Runtime invokes it). # # @rbs () -> void def work - Pipeline.execute(self) + entries = self.class.workflow_entries + chain = Chain.current || Chain.new(dry_run: dry_run?) + Chain.current = chain + + Pipeline.call(entries, context, chain) end end diff --git a/lib/generators/cmdx/templates/install.rb b/lib/generators/cmdx/templates/install.rb index 9ebadffa8..28eb72ced 100644 --- a/lib/generators/cmdx/templates/install.rb +++ b/lib/generators/cmdx/templates/install.rb @@ -1,23 +1,8 @@ # frozen_string_literal: true CMDx.configure do |config| - # Task breakpoint configuration - controls when execute! raises faults - # See https://github.com/drexed/cmdx/blob/main/docs/outcomes/statuses.md for more details - # - # Available statuses: "success", "skipped", "failed" - # If set to an empty array, task will never halt - config.task_breakpoints = %w[failed] - - # Workflow breakpoint configuration - controls when workflows stop execution - # When a task returns these statuses, subsequent workflow tasks won't execute - # See https://github.com/drexed/cmdx/blob/main/docs/workflows.md for more details - # - # Available statuses: "success", "skipped", "failed" - # If set to an empty array, workflow will never halt - config.workflow_breakpoints = %w[failed] - # Logger configuration - choose from multiple formatters - # See https://github.com/drexed/cmdx/blob/main/docs/logging.md for more details + # See https://github.com/drexed/cmdx for more details # # Available formatters: # - CMDx::LogFormatters::Json @@ -32,34 +17,9 @@ level: Logger::INFO ) - # Rollback configuration - controls which statuses trigger task rollback - # See https://github.com/drexed/cmdx/blob/main/docs/outcomes/statuses.md for more details - # - # Available statuses: "success", "skipped", "failed" - # If set to an empty array, task will never rollback - config.rollback_on = %w[failed] - - # Default locale configuration - used for built-in translation lookups - # Must match the basename of a YAML file in lib/locales/ (e.g. "en", "es", "ja") - # config.default_locale = "en" - - # Backtrace configuration - controls whether to log backtraces on faults and exceptions - # https://github.com/drexed/cmdx/blob/main/docs/configuration.md#backtraces - # config.backtrace = false - # config.backtrace_cleaner = nil - - # Exception handler configuration - called when non-fault exceptions are raised - # https://github.com/drexed/cmdx/blob/main/docs/configuration.md#exception-handlers - # config.exception_handler = nil + # Log level override (optional) + # config.log_level = :info - # Dump context configuration - include context data in hash representation output - # https://github.com/drexed/cmdx/blob/main/docs/configuration.md#dump-context - # config.dump_context = false - - # Additional global configurations - automatically applied to all tasks - # - # Middlewares - https://github.com/drexed/cmdx/blob/main/docs/middlewares.md - # Callbacks - https://github.com/drexed/cmdx/blob/main/docs/callbacks.md - # Coercions - https://github.com/drexed/cmdx/blob/main/docs/attributes/coercions.md - # Validations - https://github.com/drexed/cmdx/blob/main/docs/attributes/validations.md + # Log formatter override (optional) + # config.log_formatter = CMDx::LogFormatters::Line.new end diff --git a/spec/cmdx/attribute_registry_spec.rb b/spec/cmdx/attribute_registry_spec.rb index 14b15c067..ce588e0f3 100644 --- a/spec/cmdx/attribute_registry_spec.rb +++ b/spec/cmdx/attribute_registry_spec.rb @@ -2,334 +2,73 @@ require "spec_helper" -RSpec.describe CMDx::AttributeRegistry, type: :unit do - let(:attribute1) { instance_double(CMDx::Attribute) } - let(:attribute2) { instance_double(CMDx::Attribute) } - let(:initial_registry) { [attribute1] } - let(:task) { instance_double(CMDx::Task) } +RSpec.describe CMDx::AttributeRegistry do + let(:registry) { described_class.new } - describe "#initialize" do - context "without arguments" do - subject(:registry) { described_class.new } + describe "#register, #deregister, and #[]" do + it "registers, looks up, and removes attributes" do + attr = CMDx::Attribute.new(:email, :string, required: true) + registry.register(attr) - it "initializes with empty registry" do - expect(registry.registry).to eq([]) - end - end - - context "with initial registry" do - subject(:registry) { described_class.new(initial_registry) } - - it "initializes with provided registry" do - expect(registry.registry).to eq(initial_registry) - end + expect(registry[:email]).to be(attr) + registry.deregister(:email) + expect(registry[:email]).to be_nil end end - describe "#registry" do - subject(:registry) { described_class.new(initial_registry) } - - it "returns the internal registry array" do - expect(registry.registry).to eq(initial_registry) - end - end + describe "#define_readers!" do + it "defines accessors that read from @_attributes" do + attr = CMDx::Attribute.new(:email, :string, required: false) + registry.register(attr) - describe "#to_a" do - subject(:registry) { described_class.new(initial_registry) } + host = Class.new + registry.define_readers!(host) + instance = host.new + instance.instance_variable_set(:@_attributes, { email: "a@b.c" }) - it "aliases to registry method" do - expect(registry.to_a).to eq(registry.registry) - expect(registry.to_a).to eq(initial_registry) + expect(instance.email).to eq("a@b.c") end end - describe "#dup" do - subject(:registry) { described_class.new(initial_registry) } + describe "#resolve" do + let(:task_class) { Class.new(CMDx::Task) { def work; end } } + let(:task) { task_class.allocate } - let(:duplicated_registry) { registry.dup } + it "resolves values and runs validations" do + registry.register(CMDx::Attribute.new(:email, :string, required: true)) + errors = CMDx::Errors.new - it "creates new instance with duplicated registry" do - expect(duplicated_registry).to be_a(described_class) - expect(duplicated_registry).not_to be(registry) - expect(duplicated_registry.registry).to eq(registry.registry) - expect(duplicated_registry.registry).not_to be(registry.registry) - end - - it "maintains independence between original and duplicate" do - new_attribute = instance_double(CMDx::Attribute) - - duplicated_registry.register(new_attribute) + empty_ctx = CMDx::Context.new({}) + registry.resolve(task, empty_ctx, errors) + expect(errors.any?).to be(true) - expect(duplicated_registry.registry).to include(new_attribute) - expect(registry.registry).not_to include(new_attribute) + errors.clear + good_ctx = CMDx::Context.new(email: "x") + resolved = registry.resolve(task, good_ctx, errors) + expect(errors).to be_empty + expect(resolved[:email]).to eq("x") end end - describe "#register" do - subject(:registry) { described_class.new } - - context "with single attribute" do - it "adds attribute to registry and returns self" do - result = registry.register(attribute1) - - expect(registry.registry).to include(attribute1) - expect(result).to be(registry) - end - end - - context "with multiple attributes as array" do - let(:attributes) { [attribute1, attribute2] } - - it "adds all attributes to registry" do - registry.register(attributes) - - expect(registry.registry).to include(attribute1) - expect(registry.registry).to include(attribute2) - expect(registry.registry.size).to eq(2) - end - end - - context "with non-array attribute" do - it "converts to array and adds to registry" do - registry.register(attribute1) - - expect(registry.registry).to eq([attribute1]) - end - end - - context "when adding to existing registry" do - subject(:registry) { described_class.new(initial_registry) } - - it "appends new attributes to existing ones" do - registry.register(attribute2) - - expect(registry.registry).to eq([attribute1, attribute2]) - expect(registry.registry.size).to eq(2) - end - end - - context "with empty array" do - it "does not modify registry" do - original_size = registry.registry.size - - registry.register([]) - - expect(registry.registry.size).to eq(original_size) - end + describe "#schema" do + it "returns a hash representation of attributes" do + registry.register(CMDx::Attribute.new(:name, :string, required: false, default: "anon")) + expect(registry.schema[:name]).to include(name: :name, type: :string, required: false) end end - describe "#deregister" do - subject(:registry) { described_class.new([parent_attribute, other_attribute]) } - - let(:child_attribute) { instance_double(CMDx::Attribute, method_name: :child_attr, children: []) } - let(:parent_attribute) { instance_double(CMDx::Attribute, method_name: :parent_attr, children: [child_attribute]) } - let(:other_attribute) { instance_double(CMDx::Attribute, method_name: :other_attr, children: []) } - - context "with single attribute name" do - it "removes attribute by method_name and returns self" do - result = registry.deregister(:parent_attr) - - expect(registry.registry).not_to include(parent_attribute) - expect(registry.registry).to include(other_attribute) - expect(result).to be(registry) - end - - it "converts string names to symbols" do - registry.deregister("parent_attr") - - expect(registry.registry).not_to include(parent_attribute) - expect(registry.registry).to include(other_attribute) - end - end - - context "with multiple attribute names" do - it "handles array of names" do - registry.deregister(%i[parent_attr other_attr]) - - expect(registry.registry).to be_empty - end - end - - context "with child attribute name" do - it "removes parent attribute when child matches" do - registry.deregister(:child_attr) - - expect(registry.registry).not_to include(parent_attribute) - expect(registry.registry).to include(other_attribute) - end - end - - context "with non-existent attribute name" do - it "does not modify registry" do - original_registry = registry.registry.dup - - registry.deregister(:non_existent) - - expect(registry.registry).to eq(original_registry) - end - end - - context "with nested children" do - subject(:registry) { described_class.new([complex_parent, other_attribute]) } - - let(:grandchild_attribute) { instance_double(CMDx::Attribute, method_name: :grandchild_attr, children: []) } - let(:child_with_children) { instance_double(CMDx::Attribute, method_name: :child_with_children, children: [grandchild_attribute]) } - let(:complex_parent) { instance_double(CMDx::Attribute, method_name: :complex_parent, children: [child_with_children]) } - - it "removes parent when deeply nested child matches" do - registry.deregister(:grandchild_attr) - - expect(registry.registry).not_to include(complex_parent) - expect(registry.registry).to include(other_attribute) - end - end - - context "with empty registry" do - subject(:registry) { described_class.new([]) } - - it "does not raise error" do - expect { registry.deregister(:any_name) }.not_to raise_error - end - end - end - - describe "#define_readers_on!" do - let(:task_class) { Class.new(CMDx::Task) } - - context "with static attributes" do - it "defines reader methods on the task class" do - attr = CMDx::Attribute.new(:username) - registry = described_class.new([attr]) - registry.define_readers_on!(task_class) - - expect(task_class.method_defined?(:username)).to be(true) - end - end - - context "with nested static attributes" do - it "defines readers for parent and children" do - attr = CMDx::Attribute.new(:address) do - required :street - required :city - end - registry = described_class.new([attr]) - registry.define_readers_on!(task_class) - - expect(task_class.method_defined?(:address)).to be(true) - expect(task_class.method_defined?(:street)).to be(true) - expect(task_class.method_defined?(:city)).to be(true) - end - end - - context "with dynamic (Proc) source" do - it "skips the reader definition" do - attr = CMDx::Attribute.new(:username, source: -> { :dynamic }) - registry = described_class.new([attr]) - registry.define_readers_on!(task_class) - - expect(task_class.method_defined?(:username)).to be(false) - end - end - - context "when method already exists as private" do - before { task_class.define_method(:taken_name) { "private" } } - - it "raises a conflict error" do - task_class.send(:private, :taken_name) - attr = CMDx::Attribute.new(:taken_name) - registry = described_class.new([attr]) - - expect { registry.define_readers_on!(task_class) }.to raise_error(/already defined/) - end - end - - context "with subset of attributes" do - it "only defines readers for the given attributes" do - attr1 = CMDx::Attribute.new(:first) - attr2 = CMDx::Attribute.new(:second) - registry = described_class.new([attr1]) - registry.define_readers_on!(task_class, [attr2]) - - expect(task_class.method_defined?(:first)).to be(false) - expect(task_class.method_defined?(:second)).to be(true) - end - end - end - - describe "#undefine_readers_on!" do - let(:task_class) { Class.new(CMDx::Task) } - - it "removes eagerly defined reader methods" do - attr = CMDx::Attribute.new(:username) - registry = described_class.new([attr]) - registry.define_readers_on!(task_class) - - expect(task_class.method_defined?(:username)).to be(true) - - registry.undefine_readers_on!(task_class, [:username]) - - expect(task_class.method_defined?(:username, false)).to be(false) - end - - it "removes nested readers" do - attr = CMDx::Attribute.new(:profile) do - required :bio - end - registry = described_class.new([attr]) - registry.define_readers_on!(task_class) - - registry.undefine_readers_on!(task_class, [:profile]) - - expect(task_class.method_defined?(:profile, false)).to be(false) - expect(task_class.method_defined?(:bio, false)).to be(false) - end - - it "does not raise for non-existent methods" do - registry = described_class.new([]) - - expect { registry.undefine_readers_on!(task_class, [:missing]) }.not_to raise_error - end - end - - describe "#define_and_verify" do - subject(:registry) { described_class.new([attribute1, attribute2]) } - - it "dups each attribute, binds the task to the dup, and verifies without mutating originals" do - bound1 = instance_double(CMDx::Attribute) - bound2 = instance_double(CMDx::Attribute) - - allow(attribute1).to receive(:dup).and_return(bound1) - allow(attribute2).to receive(:dup).and_return(bound2) - - expect(bound1).to receive(:task=).with(task).ordered - expect(bound1).to receive(:define_and_verify_tree).ordered - expect(bound2).to receive(:task=).with(task).ordered - expect(bound2).to receive(:define_and_verify_tree).ordered - - expect(attribute1).not_to receive(:task=) - expect(attribute2).not_to receive(:task=) - - registry.define_and_verify(task) - end - - context "with empty registry" do - subject(:registry) { described_class.new([]) } - - it "does not call any methods" do - expect { registry.define_and_verify(task) }.not_to raise_error - end - end + describe "#for_child" do + it "duplicates definitions without sharing attribute objects" do + attr = CMDx::Attribute.new(:n, nil, required: false) + registry.register(attr) - context "when attribute raises error" do - it "propagates the error without requiring cleanup" do - bound1 = instance_double(CMDx::Attribute) - allow(attribute1).to receive(:dup).and_return(bound1) - allow(bound1).to receive(:task=) - allow(bound1).to receive(:define_and_verify_tree).and_raise(StandardError, "attribute error") + child = registry.for_child + expect(child[:n]).not_to be(attr) + expect(child[:n].name).to eq(:n) - expect { registry.define_and_verify(task) }.to raise_error(StandardError, "attribute error") - end + child.register(CMDx::Attribute.new(:other, nil, required: false)) + expect(registry[:other]).to be_nil + expect(child[:other]).to be_a(CMDx::Attribute) end end end diff --git a/spec/cmdx/attribute_spec.rb b/spec/cmdx/attribute_spec.rb index e19fa0455..43cff5f96 100644 --- a/spec/cmdx/attribute_spec.rb +++ b/spec/cmdx/attribute_spec.rb @@ -2,706 +2,121 @@ require "spec_helper" -RSpec.describe CMDx::Attribute, type: :unit do - let(:task_class) { create_task_class } - let(:task) { task_class.new } - let(:attribute_name) { :test_attr } - let(:attribute_options) { {} } - let(:attribute) { described_class.new(attribute_name, **attribute_options) } - +RSpec.describe CMDx::Attribute do describe "#initialize" do - subject(:new_attribute) { described_class.new(attribute_name, **attribute_options) } - - context "with basic parameters" do - it "sets the name" do - expect(new_attribute.name).to eq(attribute_name) - end - - it "sets empty options" do - expect(new_attribute.options).to eq({}) - end - - it "initializes empty children array" do - expect(new_attribute.children).to eq([]) - end - - it "sets parent to nil" do - expect(new_attribute.parent).to be_nil - end - - it "sets required to false by default" do - expect(new_attribute.required?).to be(false) - end - - it "sets empty types array" do - expect(new_attribute.types).to eq([]) - end - end - - context "with parent option" do - let(:parent_attribute) { described_class.new(:parent_attr) } - let(:attribute_options) { { parent: parent_attribute } } - - it "sets the parent" do - expect(new_attribute.parent).to eq(parent_attribute) - end - - it "removes parent from options" do - expect(new_attribute.options).not_to have_key(:parent) - end - end - - context "with required option" do - let(:attribute_options) { { required: true } } - - it "sets required to true" do - expect(new_attribute.required?).to be(true) - end - - it "removes required from options" do - expect(new_attribute.options).not_to have_key(:required) - end - end - - context "with optional option" do - let(:attribute_options) { { required: false } } - - it "sets optional to true" do - expect(new_attribute.optional?).to be(true) - end - - it "removes optional from options" do - expect(new_attribute.options).not_to have_key(:required) - end - end - - context "with types option" do - let(:attribute_options) { { types: %i[string integer] } } - - it "sets types array" do - expect(new_attribute.types).to eq(%i[string integer]) - end - - it "removes types from options" do - expect(new_attribute.options).not_to have_key(:types) - end - end - - context "with type option (singular)" do - let(:attribute_options) { { type: :string } } - - it "converts type to types array" do - expect(new_attribute.types).to eq([:string]) - end - - it "removes type from options" do - expect(new_attribute.options).not_to have_key(:type) - end - end - - context "with other options" do - let(:attribute_options) { { source: :context, default: "value", as: :custom_name } } - - it "preserves other options" do - expect(new_attribute.options).to eq({ source: :context, default: "value", as: :custom_name }) - end - end - - context "with block" do - it "executes the block in instance context" do - expect do - described_class.new(attribute_name) { nil } - end.not_to raise_error - end - end - end - - describe ".build" do - subject(:defined_attributes) { described_class.build(*names, **attribute_options) } - - let(:names) { %i[attr1 attr2] } - - context "with multiple names" do - it "creates attributes for each name" do - expect(defined_attributes.size).to eq(2) - expect(defined_attributes.map(&:name)).to eq(%i[attr1 attr2]) - end - - it "returns array of attributes" do - expect(defined_attributes).to all(be_a(described_class)) - end - end - - context "with no names" do - let(:names) { [] } - - it "raises ArgumentError" do - expect { defined_attributes }.to raise_error(ArgumentError, "no attributes given") - end - end - - context "with multiple names and :as option" do - let(:attribute_options) { { as: :custom_name } } - - it "raises ArgumentError" do - expect { defined_attributes }.to raise_error(ArgumentError, "the :as option only supports one attribute per definition") - end - end - - context "with single name and :as option" do - let(:names) { [:attr1] } - let(:attribute_options) { { as: :custom_name } } - - it "creates attribute with custom name" do - expect(defined_attributes.size).to eq(1) - expect(defined_attributes.first.options[:as]).to eq(:custom_name) - end - end - - context "with block" do - it "passes block to each attribute" do - attributes = described_class.build(*names) { nil } - - expect(attributes.size).to eq(2) - end - end - end - - describe ".optional" do - subject(:optional_attributes) { described_class.optional(*names, **attribute_options) } - - let(:names) { %i[attr1 attr2] } - - it "creates attributes with required: false" do - expect(optional_attributes).to all(satisfy { |attr| !attr.required? }) - end - - context "with existing required option" do - let(:attribute_options) { { required: true } } - - it "overrides with required: false" do - expect(optional_attributes).to all(satisfy { |attr| !attr.required? }) - end - end - end - - describe ".required" do - subject(:required_attributes) { described_class.required(*names, **attribute_options) } - - let(:names) { %i[attr1 attr2] } - - it "creates attributes with required: true" do - expect(required_attributes).to all(satisfy(&:required?)) - end - - context "with existing required option" do - let(:attribute_options) { { required: false } } - - it "overrides with required: true" do - expect(required_attributes).to all(satisfy(&:required?)) - end - end - end - - describe "#required?" do - subject { attribute.required? } - - context "when required is false" do - let(:attribute_options) { { required: false } } - - it { is_expected.to be(false) } - end - - context "when required is true" do - let(:attribute_options) { { required: true } } - - it { is_expected.to be(true) } - end - - context "when required is nil" do - it { is_expected.to be(false) } - end - end - - describe "#source" do - subject(:source_value) { attribute.source } - - before { attribute.task = task } - - context "when parent has method_name" do - let(:parent_attribute) do - described_class.new(:parent_attr).tap do |attr| - allow(attr).to receive(:method_name).and_return(:parent_method) - end - end - let(:attribute_options) { { parent: parent_attribute } } - - it "returns parent method_name" do - expect(source_value).to eq(:parent_method) - end - end - - context "without parent" do - context "when source option is a symbol" do - let(:attribute_options) { { source: :custom_source } } - - it "returns the symbol" do - expect(source_value).to eq(:custom_source) - end - end - - context "when source option is a proc" do - let(:attribute_options) { { source: proc { :proc_result } } } - - it "evaluates proc in task context" do - expect(source_value).to eq(:proc_result) - end - end - - context "when source option responds to call" do - let(:callable_source) do - object = Object.new - allow(object).to receive(:call).and_return(:callable_result) - object - end - let(:attribute_options) { { source: callable_source } } - - it "calls the object with task" do - expect(callable_source).to receive(:call).with(task) - expect(source_value).to eq(:callable_result) - end - end - - context "when source option is a string" do - let(:attribute_options) { { source: "string_source" } } - - it "returns the string" do - expect(source_value).to eq("string_source") - end - end - - context "without source option" do - it "returns :context as default" do - expect(source_value).to eq(:context) - end - end - end - - context "with memoization" do - let(:attribute_options) { { source: proc { Time.now.to_f } } } - - it "memoizes the result" do - first_result = attribute.source - second_result = attribute.source - - expect(first_result).to eq(second_result) - end + it "exposes name, type, required, default, as, from, derive, transform, options" do + derive = ->(v) { v } + transform = ->(v) { v } + attr = described_class.new( + :email, + :string, + required: false, + default: "x@y.z", + as: :mail, + from: :raw_email, + derive: derive, + transform: transform, + foo: :bar + ) + + expect(attr.name).to eq(:email) + expect(attr.type).to eq(:string) + expect(attr.required).to be(false) + expect(attr.default).to eq("x@y.z") + expect(attr.as).to eq(:mail) + expect(attr.from).to eq(:raw_email) + expect(attr.derive).to equal(derive) + expect(attr.transform).to equal(transform) + expect(attr.options[:foo]).to eq(:bar) + end + + it "defaults required to true" do + expect(described_class.new(:id).required).to be(true) + end + + it "symbolizes name" do + expect(described_class.new("token").name).to eq(:token) end end describe "#allocation_name" do - subject(:static_name) { attribute.allocation_name } - - context "when :as option is provided" do - let(:attribute_options) { { as: :custom_method } } - - it "returns the custom method name" do - expect(static_name).to eq(:custom_method) - end + it "returns #as when set" do + attr = described_class.new(:user_id, as: :id) + expect(attr.allocation_name).to eq(:id) end - context "with static symbol source" do - let(:attribute_options) { { source: :params } } - - it "returns the attribute name" do - expect(static_name).to eq(attribute_name) - end - end - - context "with prefix and suffix" do - let(:attribute_options) { { prefix: true, suffix: true, source: :params } } - - it "applies prefix and suffix using source" do - expect(static_name).to eq(:params_test_attr_params) - end - end - - context "with Proc source" do - let(:attribute_options) { { source: -> { :dynamic } } } - - it "returns nil" do - expect(static_name).to be_nil - end - end - - context "with callable source" do - let(:callable) { Class.new { def call(_task) = :dynamic }.new } - let(:attribute_options) { { source: callable } } - - it "returns nil" do - expect(static_name).to be_nil - end - end - - context "with static parent" do - let(:parent_attribute) { described_class.new(:parent_attr) } - let(:attribute_options) { { parent: parent_attribute } } - - it "uses parent allocation_name as source" do - expect(static_name).to eq(attribute_name) - end - end - - context "with dynamic parent" do - let(:parent_attribute) { described_class.new(:parent_attr, source: -> { :dynamic }) } - let(:attribute_options) { { parent: parent_attribute } } - - it "returns nil" do - expect(static_name).to be_nil - end - end - - context "with memoization" do - it "memoizes the result" do - first = attribute.allocation_name - second = attribute.allocation_name - - expect(first).to equal(second) - end - end - end - - describe "#method_name" do - subject(:method_name_value) { attribute.method_name } - - before { attribute.task = task } - - context "when :as option is provided" do - let(:attribute_options) { { as: :custom_method } } - - it "returns the custom method name" do - expect(method_name_value).to eq(:custom_method) - end - end - - context "without :as option" do - context "with default settings" do - it "returns the attribute name" do - expect(method_name_value).to eq(attribute_name) - end - end - - context "with prefix option set to true" do - let(:attribute_options) { { prefix: true, source: :params } } - - it "adds source as prefix" do - expect(method_name_value).to eq(:params_test_attr) - end - end - - context "with prefix option set to string" do - let(:attribute_options) { { prefix: "custom" } } - - it "uses custom prefix" do - expect(method_name_value).to eq(:customtest_attr) - end - end - - context "with suffix option set to true" do - let(:attribute_options) { { suffix: true, source: :params } } - - it "adds source as suffix" do - expect(method_name_value).to eq(:test_attr_params) - end - end - - context "with suffix option set to string" do - let(:attribute_options) { { suffix: "custom" } } - - it "uses custom suffix" do - expect(method_name_value).to eq(:test_attrcustom) - end - end - - context "with both prefix and suffix" do - let(:attribute_options) { { prefix: "pre", suffix: "suf" } } - - it "combines both" do - expect(method_name_value).to eq(:pretest_attrsuf) - end - end - end - - context "with memoization" do - it "memoizes the result" do - first_result = attribute.method_name - second_result = attribute.method_name - - expect(first_result).to equal(second_result) - end - end - end - - describe "#define_and_verify_tree" do - let(:child1) { described_class.new(:child1) } - let(:child2) { described_class.new(:child2) } - - before do - attribute.task = task - attribute.children.push(child1, child2) - - allow(attribute).to receive(:define_and_verify) - allow(child1).to receive(:define_and_verify_tree) - allow(child2).to receive(:define_and_verify_tree) - end - - it "calls define_and_verify on self" do - expect(attribute).to receive(:define_and_verify) - - attribute.define_and_verify_tree - end - - it "sets task on all children" do - attribute.define_and_verify_tree - - expect(child1.task).to eq(task) - expect(child2.task).to eq(task) - end - - it "calls define_and_verify_tree on all children" do - expect(child1).to receive(:define_and_verify_tree) - expect(child2).to receive(:define_and_verify_tree) - - attribute.define_and_verify_tree + it "returns #name when :as is omitted" do + attr = described_class.new(:user_id) + expect(attr.allocation_name).to eq(:user_id) end end - describe "#to_h" do - context "with basic attribute" do - let(:attribute_options) { { type: :string, default: "test" } } - - it "returns a hash representation" do - result = attribute.to_h - - expect(result).to be_a(Hash) - expect(result).to include( - name: :test_attr, - method_name: :test_attr, - required: false, - types: [:string], - children: [] - ) - end + describe "#required?, #optional?, #typed?, #has_default?" do + it "reflects required and type" do + req = described_class.new(:a, :integer) + expect(req).to be_required + expect(req).not_to be_optional + expect(req).to be_typed - it "includes options without :if and :unless" do - result = attribute.to_h - - expect(result[:options]).to eq(default: "test") - end + opt = described_class.new(:b, required: false) + expect(opt).not_to be_required + expect(opt).to be_optional + expect(opt).not_to be_typed end - context "with required attribute" do - let(:attribute_options) { { required: true, type: :integer } } - - it "sets required to true" do - expect(attribute.to_h[:required]).to be(true) - end - end - - context "with conditional options" do - let(:attribute_options) { { type: :string, if: :some_condition?, unless: :other_condition?, default: "value" } } - - it "excludes :if and :unless from options" do - result = attribute.to_h - - expect(result[:options]).not_to have_key(:if) - expect(result[:options]).not_to have_key(:unless) - expect(result[:options]).to eq(default: "value") - end - end - - context "with nested children" do - let(:attribute) do - described_class.new(:parent_attr, type: :hash) do - optional :child1, type: :string - required :child2, type: :integer - end - end - - it "includes children as array of hashes" do - result = attribute.to_h - - expect(result[:children]).to be_an(Array) - expect(result[:children].size).to eq(2) - end - - it "recursively converts children to hashes" do - result = attribute.to_h - child_names = result[:children].map { |c| c[:name] } - - expect(child_names).to contain_exactly(:child1, :child2) - end - - it "preserves child metadata" do - result = attribute.to_h - child1 = result[:children].find { |c| c[:name] == :child1 } - child2 = result[:children].find { |c| c[:name] == :child2 } - - expect(child1[:required]).to be(false) - expect(child1[:types]).to eq([:string]) - expect(child2[:required]).to be(true) - expect(child2[:types]).to eq([:integer]) - end - end - - context "with custom method name" do - let(:attribute_options) { { as: :custom_method, type: :string } } - - it "includes the custom method name" do - expect(attribute.to_h[:method_name]).to eq(:custom_method) - end + it "reflects default presence" do + expect(described_class.new(:a)).not_to be_has_default + expect(described_class.new(:a, default: 0)).to be_has_default end end - describe "private methods" do - describe "#attribute" do - let(:child_options) { { required: true } } - - before do - attribute.send(:attribute, :child_attr, **child_options) - end - - it "creates child attribute" do - expect(attribute.children.size).to eq(1) - expect(attribute.children.first.name).to eq(:child_attr) - end - - it "sets parent on child" do - expect(attribute.children.first.parent).to eq(attribute) - end - - it "merges parent option with provided options" do - expect(attribute.children.first.required?).to be(true) - end + describe "#validations" do + it "auto-adds :presence when required and :presence not given" do + attr = described_class.new(:name, :string) + expect(attr.validations[:presence]).to be(true) end - describe "#attributes" do - let(:names) { %i[child1 child2] } - let(:child_options) { { required: true } } - - before do - attribute.send(:attributes, *names, **child_options) - end - - it "creates multiple child attributes" do - expect(attribute.children.size).to eq(2) - expect(attribute.children.map(&:name)).to eq(%i[child1 child2]) - end - - it "sets parent on all children" do - expect(attribute.children).to all(have_attributes(parent: attribute)) - end + it "does not auto-add :presence when optional" do + attr = described_class.new(:name, :string, required: false) + expect(attr.validations).not_to have_key(:presence) end - describe "#optional" do - before do - attribute.send(:optional, :child1, :child2, type: :string) - end - - it "creates optional child attributes" do - expect(attribute.children.size).to eq(2) - expect(attribute.children).to all(satisfy { |attr| !attr.required? }) - end + it "does not overwrite explicit :presence" do + attr = described_class.new(:name, :string, presence: false) + expect(attr.validations[:presence]).to be(false) end - describe "#required" do - before do - attribute.send(:required, :child1, :child2, type: :string) - end - - it "creates required child attributes" do - expect(attribute.children.size).to eq(2) - expect(attribute.children).to all(satisfy(&:required?)) - end + it "extracts validator options from declaration (e.g. length)" do + attr = described_class.new(:code, :string, required: false, length: { min: 3 }) + expect(attr.validations[:length]).to eq({ min: 3 }) + expect(attr.validations).not_to have_key(:presence) end - describe "#define_and_verify" do - let(:attribute_value) { instance_double(CMDx::AttributeValue, generate: nil, validate: nil) } - - before do - attribute.task = task - - allow(CMDx::AttributeValue).to receive(:new).and_return(attribute_value) - allow(attribute).to receive(:method_name).and_return(:test_method) - end - - context "when method is not already defined" do - before do - allow(task).to receive(:respond_to?).with(:test_method, true).and_return(false) - end - - it "creates AttributeValue instance" do - expect(CMDx::AttributeValue).to receive(:new).with(attribute) - - attribute.send(:define_and_verify) - end - - it "calls generate and validate on AttributeValue" do - expect(attribute_value).to receive(:generate) - expect(attribute_value).to receive(:validate) - - attribute.send(:define_and_verify) - end - - it "defines method on task class" do - expect(task.class).to receive(:define_method).with(:test_method) - - attribute.send(:define_and_verify) - end - end - - context "when method is already defined" do - before do - allow(task).to receive(:respond_to?).with(:test_method, true).and_return(true) - allow(task.class).to receive(:name).and_return("TestTask") - end - - it "raises error" do - expect do - attribute.send(:define_and_verify) - end.to raise_error(<<~MESSAGE) - The method :test_method is already defined on the TestTask task. - This may be due conflicts with one of the task's user defined or internal methods/attributes. - - Use :as, :prefix, and/or :suffix attribute options to avoid conflicts with existing methods. - MESSAGE - end - end + it "extracts multiple built-in validators" do + attr = described_class.new( + :x, + :integer, + required: false, + format: { with: /\A\d+\z/ }, + inclusion: { of: [1, 2, 3] } + ) + expect(attr.validations[:format]).to eq({ with: /\A\d+\z/ }) + expect(attr.validations[:inclusion]).to eq({ of: [1, 2, 3] }) end end - describe "AFFIX constant" do - subject(:affix_proc) { described_class.const_get(:AFFIX) } - - it "is a frozen proc" do - expect(affix_proc).to be_a(Proc) - expect(affix_proc).to be_frozen - end - - context "when value is true" do - it "calls the block" do - result = affix_proc.call(true) { "block_result" } - - expect(result).to eq("block_result") - end - end - - context "when value is not true" do - it "returns the value" do - result = affix_proc.call("custom_value") { "block_result" } - - expect(result).to eq("custom_value") - end + describe "#to_h" do + it "returns a serializable summary" do + attr = described_class.new(:email, :string, as: :mail, from: :raw, required: false, default: nil) + expect(attr.to_h).to eq( + name: :email, + type: :string, + required: false, + default: nil, + as: :mail, + from: :raw, + validations: {} + ) end end end diff --git a/spec/cmdx/attribute_value_spec.rb b/spec/cmdx/attribute_value_spec.rb deleted file mode 100644 index 4dd5a1852..000000000 --- a/spec/cmdx/attribute_value_spec.rb +++ /dev/null @@ -1,763 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::AttributeValue, type: :unit do - let(:task_class) { create_task_class } - let(:errors) { instance_double(CMDx::Errors, add: nil, for?: false) } - let(:task) { task_class.new } - let(:attribute) { CMDx::Attribute.new(:test_attr, **attribute_options) } - let(:attribute_options) { {} } - let(:attribute_value) { described_class.new(attribute) } - - before do - attribute.task = task - allow(task).to receive_messages(attributes: {}, errors: errors) - end - - describe "#initialize" do - it "sets the attribute" do - expect(attribute_value.attribute).to eq(attribute) - end - end - - describe "#value" do - let(:method_name) { :test_method } - - before do - allow(attribute_value).to receive(:method_name).and_return(method_name) - allow(task).to receive(:attributes).and_return({ method_name => "test_value" }) - end - - it "returns value from attributes hash" do - expect(attribute_value.value).to eq("test_value") - end - end - - describe "#generate" do - let(:method_name) { :test_method } - let(:attributes) { {} } - - before do - allow(attribute_value).to receive(:method_name).and_return(method_name) - allow(task).to receive(:attributes).and_return(attributes) - end - - context "when value already exists in attributes" do - let(:attributes) { { method_name => "existing_value" } } - - it "returns existing value" do - expect(attribute_value.generate).to eq("existing_value") - end - - it "does not call source_value" do - expect(attribute_value).not_to receive(:source_value) - attribute_value.generate - end - end - - context "when value does not exist" do - before do - allow(attribute_value).to receive_messages( - source_value: "sourced", - derive_value: "derived", - coerce_value: "coerced" - ) - end - - it "processes value through pipeline and stores result" do - allow(attribute_value).to receive(:source_value).and_return("sourced") - allow(attribute_value).to receive(:derive_value).with("sourced").and_return("derived") - allow(attribute_value).to receive(:coerce_value).with("derived").and_return("coerced") - - expect { attribute_value.generate }.to change { attributes[method_name] }.to("coerced") - end - - context "when errors occur after source_value" do - before { allow(errors).to receive(:for?).with(method_name).and_return(true) } - - it "returns nil without processing further" do - allow(attribute_value).to receive(:source_value).and_return("sourced") - - expect(attribute_value).not_to receive(:derive_value) - - expect(attribute_value.generate).to be_nil - end - end - - context "when errors occur after derive_value" do - before do - allow(errors).to receive(:for?).with(method_name).and_return(false, true) - allow(attribute_value).to receive(:derive_value).and_return("derived") - end - - it "returns nil without coercing" do - allow(attribute_value).to receive(:source_value).and_return("sourced") - allow(attribute_value).to receive(:derive_value).with("sourced").and_return("derived") - - expect(attribute_value).not_to receive(:coerce_value) - - expect(attribute_value.generate).to be_nil - end - end - - context "when errors occur after coerce_value" do - before do - allow(errors).to receive(:for?).with(method_name).and_return(false, false, true) - allow(attribute_value).to receive(:coerce_value).and_return("coerced") - end - - it "returns nil without storing value" do - allow(attribute_value).to receive(:source_value).and_return("sourced") - allow(attribute_value).to receive(:derive_value).with("sourced").and_return("derived") - allow(attribute_value).to receive(:coerce_value).with("derived").and_return("coerced") - - result = attribute_value.generate - - expect(result).to be_nil - expect(attributes).not_to have_key(method_name) - end - end - end - end - - describe "#validate" do - let(:method_name) { :test_method } - let(:validator_registry) { instance_double(CMDx::ValidatorRegistry) } - let(:task_settings) { mock_settings(validators: validator_registry) } - let(:attribute_options) { { format: /\d+/, presence: true } } - - before do - allow(attribute_value).to receive_messages(method_name: method_name, value: "test_value") - allow(task.class).to receive(:settings).and_return(task_settings) - allow(validator_registry).to receive(:keys).and_return(%i[format presence]) - allow(validator_registry).to receive(:validate) - end - - it "validates each matching validator option" do - expect(validator_registry).to receive(:validate).with(:format, task, "test_value", /\d+/) - expect(validator_registry).to receive(:validate).with(:presence, task, "test_value", true) - - expect { attribute_value.validate }.not_to raise_error - end - - context "when validation fails" do - let(:validation_error) { CMDx::ValidationError.new("invalid format") } - - before do - allow(validator_registry).to receive(:validate).and_raise(validation_error) - end - - it "adds error message" do - expect(errors).to receive(:add).with(method_name, "invalid format").twice - - attribute_value.validate - end - end - - context "when options don't match registry keys" do - let(:attribute_options) { { unknown_option: true } } - - it "does not validate unknown options" do - expect(validator_registry).not_to receive(:validate) - - expect { attribute_value.validate }.not_to raise_error - end - end - end - - describe "private methods" do - describe "#source_value" do - let(:source) { :context } - let(:method_name) { :test_method } - - before do - allow(attribute_value).to receive_messages( - source: source, - method_name: method_name, - required?: false - ) - end - - context "when source is a symbol" do - let(:source) { :config } - - before { allow(task).to receive(:config).and_return("config_value") } - - it "calls method on task" do - allow(task).to receive(:config).and_return("config_value") - - expect(attribute_value.send(:source_value)).to eq("config_value") - end - end - - context "when source is a proc" do - let(:source) { proc { "proc_result" } } - - before { allow(task).to receive(:instance_eval).and_return("proc_result") } - - it "evaluates proc in task context" do - allow(task).to receive(:instance_eval).and_return("proc_result") - - expect(attribute_value.send(:source_value)).to eq("proc_result") - end - end - - context "when source is callable" do - let(:callable) { instance_double("MockCallable", call: "callable_result") } - let(:source) { callable } - - before { allow(source).to receive(:respond_to?).with(:call).and_return(true) } - - it "calls object with task" do - allow(source).to receive(:call).with(task).and_return("callable_result") - - expect(attribute_value.send(:source_value)).to eq("callable_result") - end - end - - context "when source is not callable" do - let(:source) { "string_value" } - - it "returns source directly" do - expect(attribute_value.send(:source_value)).to eq("string_value") - end - end - - context "when source method raises NoMethodError" do - let(:source) { :nonexistent_method } - - before do - allow(task).to receive(:nonexistent_method).and_raise(NoMethodError) - allow(CMDx::Locale).to receive(:t).with("cmdx.attributes.undefined", method: source).and_return("undefined method error") - end - - it "adds error and returns nil" do - expect(errors).to receive(:add).with(method_name, "undefined method error") - - expect(attribute_value.send(:source_value)).to be_nil - end - end - - context "when attribute is required" do - let(:name) { :test_name } - - before do - allow(attribute_value).to receive_messages( - required?: true, - parent: nil, - name: name, - options: {} - ) - allow(CMDx::Utils::Condition).to receive(:evaluate).with(task, {}).and_return(true) - end - - context "with Context source" do - let(:context) { CMDx::Context.new(test_name: "value") } - let(:source) { context } - - before { allow(source).to receive(:respond_to?).with(:call).and_return(false) } - - it "checks if context has key" do - expect(attribute_value.send(:source_value)).to eq(context) - end - - context "when context missing key" do - let(:context) { CMDx::Context.new({}) } - - before do - allow(CMDx::Locale).to receive(:t).with("cmdx.attributes.required", hash_including(:method)).and_return("required error") - allow(CMDx::Utils::Condition).to receive(:evaluate).with(task, {}).and_return(true) - end - - it "adds required error" do - expect(errors).to receive(:add).with(method_name, "required error") - - expect(attribute_value.send(:source_value)).to eq(context) - end - end - end - - context "with Hash source" do - let(:source) { { test_name: "value" } } - - before { allow(source).to receive(:respond_to?).with(:call).and_return(false) } - - it "checks if hash has key" do - expect(attribute_value.send(:source_value)).to eq(source) - end - end - - context "with string-keyed Hash source" do - let(:source) { { "test_name" => "value" } } - - before { allow(source).to receive(:respond_to?).with(:call).and_return(false) } - - it "checks both string and symbol key presence" do - expect(attribute_value.send(:source_value)).to eq(source) - end - - context "when hash missing key" do - let(:source) { { "other_key" => "value" } } - - before do - allow(CMDx::Locale).to receive(:t).with("cmdx.attributes.required", hash_including(:method)).and_return("required error") - end - - it "adds required error" do - expect(errors).to receive(:add).with(method_name, "required error") - - expect(attribute_value.send(:source_value)).to eq(source) - end - end - end - - context "with Proc source" do - let(:source) { proc { "value" } } - - before { allow(task).to receive(:instance_eval).and_return("value") } - - it "assumes proc can provide value" do - expect(attribute_value.send(:source_value)).to eq("value") - end - end - - context "with object that responds to method" do - let(:source_object) { instance_double("MockSource", test_name: "value") } - let(:source) { source_object } - - before do - allow(source).to receive(:respond_to?).with(:call).and_return(false) - allow(source).to receive(:respond_to?).with(name, true).and_return(true) - end - - it "checks if object responds to method" do - expect(attribute_value.send(:source_value)).to eq(source_object) - end - end - - context "when parent is required" do - let(:parent) { instance_double(CMDx::Attribute, required?: true) } - - before { allow(attribute_value).to receive(:parent).and_return(parent) } - - context "when source doesn't provide value" do - let(:source_object) { instance_double("MockSource") } - let(:source) { source_object } - - before do - allow(source).to receive(:respond_to?).with(:call).and_return(false) - allow(source).to receive(:respond_to?).with(name, true).and_return(false) - allow(CMDx::Locale).to receive(:t).with("cmdx.attributes.required", hash_including(:method)).and_return("required error") - allow(attribute_value).to receive(:options).and_return({}) - allow(CMDx::Utils::Condition).to receive(:evaluate).with(task, {}).and_return(true) - end - - it "adds required error" do - expect(errors).to receive(:add).with(method_name, "required error") - - expect(attribute_value.send(:source_value)).to eq(source_object) - end - end - end - - context "with conditional requirement" do - let(:source_object) { instance_double("MockSource") } - let(:source) { source_object } - - before do - allow(source).to receive(:respond_to?).with(:call).and_return(false) - allow(source).to receive(:respond_to?).with(name, true).and_return(false) - allow(attribute_value).to receive(:options).and_return(attribute_options) - end - - context "when condition evaluates to true" do - let(:attribute_options) { { if: :enabled? } } - - before do - allow(CMDx::Utils::Condition).to receive(:evaluate).with(task, attribute_options).and_return(true) - allow(CMDx::Locale).to receive(:t).with("cmdx.attributes.required", hash_including(:method)).and_return("required error") - end - - it "validates required attribute" do - expect(errors).to receive(:add).with(method_name, "required error") - - expect(attribute_value.send(:source_value)).to eq(source_object) - end - end - - context "when condition evaluates to false" do - let(:attribute_options) { { if: :enabled? } } - - before { allow(CMDx::Utils::Condition).to receive(:evaluate).with(task, attribute_options).and_return(false) } - - it "skips required validation" do - expect(errors).not_to receive(:add) - - expect(attribute_value.send(:source_value)).to eq(source_object) - end - end - - context "when no condition is specified" do - let(:attribute_options) { {} } - - before do - allow(CMDx::Utils::Condition).to receive(:evaluate).with(task, attribute_options).and_return(true) - allow(CMDx::Locale).to receive(:t).with("cmdx.attributes.required", hash_including(:method)).and_return("required error") - end - - it "defaults to true and validates required attribute" do - expect(errors).to receive(:add).with(method_name, "required error") - - expect(attribute_value.send(:source_value)).to eq(source_object) - end - end - - context "when condition uses unless" do - let(:attribute_options) { { unless: :disabled? } } - - before { allow(CMDx::Utils::Condition).to receive(:evaluate).with(task, attribute_options).and_return(false) } - - it "skips required validation when unless condition is true" do - expect(errors).not_to receive(:add) - - expect(attribute_value.send(:source_value)).to eq(source_object) - end - end - end - end - end - - describe "#default_value" do - let(:attribute_options) { { default: default_option } } - let(:default_option) { "default_string" } - - context "when default is a string" do - it "returns the string" do - expect(attribute_value.send(:default_value)).to eq("default_string") - end - end - - context "when default is a symbol and task responds to it" do - let(:default_option) { :default_method } - - before do - allow(task).to receive(:respond_to?).with(:default_method, true).and_return(true) - allow(task).to receive(:respond_to?).with(:default_method).and_return(true) - allow(task).to receive(:default_method).and_return("method_result") - end - - it "calls method on task" do - allow(task).to receive(:default_method).and_return("method_result") - - expect(attribute_value.send(:default_value)).to eq("method_result") - end - end - - context "when default is a symbol but task doesn't respond" do - let(:default_option) { :nonexistent_method } - - before { allow(task).to receive(:respond_to?).with(:nonexistent_method, true).and_return(false) } - - it "returns the symbol" do - expect(attribute_value.send(:default_value)).to eq(:nonexistent_method) - end - end - - context "when default is a proc" do - let(:default_option) { proc { "proc_default" } } - - before { allow(task).to receive(:instance_eval).and_return("proc_default") } - - it "evaluates proc in task context" do - allow(task).to receive(:instance_eval).and_return("proc_default") - - expect(attribute_value.send(:default_value)).to eq("proc_default") - end - end - - context "when default is callable" do - let(:callable) { instance_double("MockCallable", call: "callable_default") } - let(:default_option) { callable } - - before { allow(default_option).to receive(:respond_to?).with(:call).and_return(true) } - - it "calls object with task" do - allow(default_option).to receive(:call).with(task).and_return("callable_default") - - expect(attribute_value.send(:default_value)).to eq("callable_default") - end - end - - context "when no default option" do - let(:attribute_options) { {} } - - it "returns nil" do - expect(attribute_value.send(:default_value)).to be_nil - end - end - end - - describe "#derive_value" do - let(:name) { :test_name } - let(:method_name) { :test_method } - - before do - allow(attribute_value).to receive_messages( - name: name, - method_name: method_name, - default_value: "default" - ) - end - - context "when source_value is Context" do - let(:context) { CMDx::Context.new(test_name: "context_value") } - - it "extracts value using name key" do - expect(attribute_value.send(:derive_value, context)).to eq("context_value") - end - - context "when context doesn't have key" do - let(:context) { CMDx::Context.new({}) } - - it "returns default value" do - expect(attribute_value.send(:derive_value, context)).to eq("default") - end - end - end - - context "when source_value is Hash" do - let(:hash) { { test_name: "hash_value" } } - - it "extracts value using name key" do - expect(attribute_value.send(:derive_value, hash)).to eq("hash_value") - end - - context "with string keys" do - let(:hash) { { "test_name" => "hash_value" } } - - it "extracts value using string or symbol key" do - expect(attribute_value.send(:derive_value, hash)).to eq("hash_value") - end - end - end - - context "when source_value responds to attribute name" do - let(:source_object) { instance_double("MockSource", test_name: "object_value") } - - before do - allow(source_object).to receive(:respond_to?).with(:call).and_return(false) - allow(source_object).to receive(:respond_to?).with(name, true).and_return(true) - end - - it "derives value via send" do - expect(attribute_value.send(:derive_value, source_object)).to eq("object_value") - end - - context "when send raises NoMethodError" do - before do - allow(source_object).to receive(:test_name).and_raise(NoMethodError) - allow(CMDx::Locale).to receive(:t).with("cmdx.attributes.undefined", method: name).and_return("undefined error") - end - - it "adds error and returns nil" do - expect(errors).to receive(:add).with(method_name, "undefined error") - - expect(attribute_value.send(:derive_value, source_object)).to be_nil - end - end - end - - context "when source_value does not respond to call or attribute name" do - let(:source_object) { instance_double("MockSource") } - - before do - allow(source_object).to receive(:respond_to?).with(:call).and_return(false) - allow(source_object).to receive(:respond_to?).with(name, true).and_return(false) - end - - it "returns default value" do - expect(attribute_value.send(:derive_value, source_object)).to eq("default") - end - end - - context "when source_value is Proc" do - let(:proc_obj) { proc { |n| "proc_#{n}" } } - - before { allow(task).to receive(:instance_exec).with(name, &proc_obj).and_return("proc_test_name") } - - it "executes proc with name in task context" do - allow(task).to receive(:instance_exec).with(name, &proc_obj).and_return("proc_test_name") - - expect(attribute_value.send(:derive_value, proc_obj)).to eq("proc_test_name") - end - end - - context "when source_value is callable" do - let(:callable) { instance_double("MockCallable", call: "callable_value") } - - before { allow(callable).to receive(:respond_to?).with(:call).and_return(true) } - - it "calls object with task and name" do - allow(callable).to receive(:call).with(task, name).and_return("callable_value") - - expect(attribute_value.send(:derive_value, callable)).to eq("callable_value") - end - end - - context "when source_value is not callable" do - it "returns default value" do - expect(attribute_value.send(:derive_value, "not_callable")).to eq("default") - end - end - - context "when derived_value is nil" do - let(:hash) { { other_key: "value" } } - - it "returns default value" do - expect(attribute_value.send(:derive_value, hash)).to eq("default") - end - end - end - - describe "#transform_value" do - let(:attribute_options) { { transform: transform_option } } - let(:transform_option) { :upcase } - let(:derived_value) { "hello" } - - context "when transform is a symbol and value responds to it" do - let(:transform_option) { :upcase } - - it "calls method on derived value" do - expect(attribute_value.send(:transform_value, derived_value)).to eq("HELLO") - end - end - - context "when transform is a symbol but value doesn't respond to it" do - let(:transform_option) { :nonexistent_method } - - it "returns derived value unchanged" do - expect(attribute_value.send(:transform_value, derived_value)).to eq("hello") - end - end - - context "when transform is a proc" do - let(:transform_option) { proc { |v| "transformed_#{v}" } } - - before { allow(task).to receive(:instance_eval).with(derived_value, &transform_option).and_return("transformed_hello") } - - it "evaluates proc with derived value in task context" do - expect(attribute_value.send(:transform_value, derived_value)).to eq("transformed_hello") - end - end - - context "when transform is callable" do - let(:callable) { instance_double("MockCallable", call: "callable_transformed") } - let(:transform_option) { callable } - - before { allow(transform_option).to receive(:respond_to?).with(:call).and_return(true) } - - it "calls object with derived value" do - allow(transform_option).to receive(:call).with(derived_value).and_return("callable_transformed") - - expect(attribute_value.send(:transform_value, derived_value)).to eq("callable_transformed") - end - end - - context "when transform is not callable" do - let(:transform_option) { "string_transform" } - - it "returns derived value unchanged" do - expect(attribute_value.send(:transform_value, derived_value)).to eq("hello") - end - end - - context "when no transform option" do - let(:attribute_options) { {} } - - it "returns derived value unchanged" do - expect(attribute_value.send(:transform_value, derived_value)).to eq("hello") - end - end - - context "when transform is nil" do - let(:transform_option) { nil } - - it "returns derived value unchanged" do - expect(attribute_value.send(:transform_value, derived_value)).to eq("hello") - end - end - end - - describe "#coerce_value" do - let(:method_name) { :test_method } - let(:coercion_registry) { instance_double(CMDx::CoercionRegistry) } - let(:task_settings) { mock_settings(coercions: coercion_registry) } - let(:types) { %i[string integer] } - - before do - allow(attribute_value).to receive(:method_name).and_return(method_name) - allow(attribute).to receive(:types).and_return(types) - allow(task.class).to receive(:settings).and_return(task_settings) - end - - context "when attribute has no types" do - let(:types) { [] } - - it "returns value unchanged" do - expect(attribute_value.send(:coerce_value, "unchanged")).to eq("unchanged") - end - end - - context "when coercion succeeds on first type" do - before do - allow(coercion_registry).to receive(:coerce).with(:string, task, "123", {}).and_return("coerced_string") - end - - it "returns coerced value" do - expect(attribute_value.send(:coerce_value, "123")).to eq("coerced_string") - end - end - - context "when first coercion fails but second succeeds" do - before do - allow(coercion_registry).to receive(:coerce).with(:string, task, "123", {}).and_raise(CMDx::CoercionError) - allow(coercion_registry).to receive(:coerce).with(:integer, task, "123", {}).and_return(123) - end - - it "returns coerced value from successful type" do - expect(attribute_value.send(:coerce_value, "123")).to eq(123) - end - end - - context "when all coercions fail" do - before do - allow(coercion_registry).to receive(:coerce).and_raise(CMDx::CoercionError) - allow(CMDx::Locale).to receive(:t).with("cmdx.types.string").and_return("String") - allow(CMDx::Locale).to receive(:t).with("cmdx.types.integer").and_return("Integer") - allow(CMDx::Locale).to receive(:t).with("cmdx.coercions.into_any", types: "String, Integer").and_return("coercion error") - end - - it "adds error and returns nil" do - expect(errors).to receive(:add).with(method_name, "coercion error") - - expect(attribute_value.send(:coerce_value, "invalid")).to be_nil - end - end - - context "when coercion fails on intermediate type" do - let(:types) { %i[string integer float] } - - before do - allow(coercion_registry).to receive(:coerce).with(:string, task, "123", {}).and_raise(CMDx::CoercionError) - allow(coercion_registry).to receive(:coerce).with(:integer, task, "123", {}).and_raise(CMDx::CoercionError) - allow(coercion_registry).to receive(:coerce).with(:float, task, "123", {}).and_return(123.0) - end - - it "continues to next type and succeeds" do - expect(attribute_value.send(:coerce_value, "123")).to eq(123.0) - end - end - end - end -end diff --git a/spec/cmdx/callback_registry_spec.rb b/spec/cmdx/callback_registry_spec.rb index 14e0843e5..7fdc6dcf4 100644 --- a/spec/cmdx/callback_registry_spec.rb +++ b/spec/cmdx/callback_registry_spec.rb @@ -2,395 +2,110 @@ require "spec_helper" -RSpec.describe CMDx::CallbackRegistry, type: :unit do - subject(:registry) { described_class.new(initial_registry) } - - let(:initial_registry) { {} } - let(:mock_task) { instance_double(CMDx::Task) } - let(:callable_proc) { proc { |task| } } - let(:callable_symbol) { :some_method } - let(:callable_object) { instance_double("MockCallable", call: nil) } - - describe "#initialize" do - context "when no registry is provided" do - subject(:registry) { described_class.new } - - it "initializes with an empty hash" do - expect(registry.registry).to eq({}) - end - end - - context "when a registry is provided" do - let(:initial_registry) { { before_execution: Set.new } } - - it "initializes with the provided registry" do - expect(registry.registry).to eq(initial_registry) - end - end +RSpec.describe CMDx::CallbackRegistry do + let(:registry) { described_class.new } + + let(:result) do + CMDx::Result.new( + task_id: "tid", + task_class: nil, + task_type: "spec", + task_tags: [], + state: "complete", + status: "success", + reason: nil, + cause: nil, + metadata: {}, + strict: true, + retries: 0, + rolled_back: false, + context: CMDx::Context.new, + chain: nil, + errors: CMDx::Errors.new, + index: 0 + ) end - describe "#registry" do - it "returns the internal registry" do - expect(registry.registry).to eq(initial_registry) + describe "#register and #for_type" do + it "stores callbacks for a type" do + registry.register(:on_success, :record) + expect(registry.for_type(:on_success).size).to eq(1) + expect(registry.for_type(:on_success).first[:callable]).to eq(:record) end end - describe "#to_h" do - it "aliases the registry method" do - expect(registry.to_h).to eq(registry.registry) - end - end - - describe "#dup" do - context "when registry has values" do - let(:initial_registry) do - { - before_execution: Set.new([[callable_proc], {}]), - on_success: Set.new([[callable_symbol], { if: :success? }]) - } - end - - it "returns a new instance" do - duplicated = registry.dup - - expect(duplicated).not_to be(registry) - expect(duplicated).to be_a(described_class) - end - - it "shares the parent registry until first write" do - duplicated = registry.dup - - expect(duplicated.registry).to eq(registry.registry) - expect(duplicated.registry).to be(registry.registry) - end + describe "#invoke" do + let(:task_class) do + Class.new do + attr_reader :events - it "materializes on write and does not affect the parent" do - duplicated = registry.dup - original_size = registry.registry[:before_execution].size + def initialize + @events = [] + end - duplicated.register(:before_execution, :new_callback) + def record + @events << :record + end - expect(duplicated.registry).not_to be(registry.registry) - expect(duplicated.registry[:before_execution]).not_to be(registry.registry[:before_execution]) - expect(registry.registry[:before_execution].size).to eq(original_size) + def track(result) + @events << [:track, result] + end end end - context "when registry is empty" do - let(:initial_registry) { {} } - - it "returns a new instance with empty registry" do - duplicated = registry.dup + it "invokes all callbacks for a type using Utils::Call.invoke_callback" do + task = task_class.new + registry.register(:on_success, :record) + registry.register(:on_success, ->(r) { track(r) }) - expect(duplicated.registry).to eq({}) - expect(duplicated.registry).to be(registry.registry) - end - end - end + registry.invoke(:on_success, task, result) - describe "#register" do - it "returns self for method chaining" do - result = registry.register(:before_execution, callable_proc) - - expect(result).to be(registry) + expect(task.events).to eq([:record, [:track, result]]) end - context "when registering a single callable" do - it "adds the callable to the registry" do - registry.register(:before_execution, callable_proc) - - expect(registry.registry[:before_execution]).to be_a(Set) - expect(registry.registry[:before_execution]).to include([[callable_proc], {}]) + it "filters with :if using Utils::Condition.truthy?" do + task = task_class.new + def task.allow? + false end - end - context "when registering multiple callables" do - it "adds all callables as a single entry" do - registry.register(:before_execution, callable_proc, callable_symbol) + registry.register(:on_success, :record, if: :allow?) + registry.register(:on_success, :record) - expect(registry.registry[:before_execution]).to include([[callable_proc, callable_symbol], {}]) - end + registry.invoke(:on_success, task, result) + expect(task.events).to eq([:record]) end - context "when registering with options" do - let(:options) { { if: :active?, unless: :disabled? } } - - it "stores the options with the callables" do - registry.register(:before_execution, callable_proc, **options) - - expect(registry.registry[:before_execution]).to include([[callable_proc], options]) + it "filters with :unless using Utils::Condition.falsy?" do + task = task_class.new + def task.block? + true end - end - context "when registering with a block" do - it "adds the block to the callables list" do - block = proc { |task| task.blocked = true } - registry.register(:before_execution, callable_proc, &block) + registry.register(:on_success, :record, unless: :block?) - expect(registry.registry[:before_execution]).to include([[callable_proc, block], {}]) - end - end - - context "when registering to the same type multiple times" do - it "accumulates callbacks in a Set" do - registry.register(:before_execution, callable_proc) - registry.register(:before_execution, callable_symbol) - - expect(registry.registry[:before_execution].size).to eq(2) - expect(registry.registry[:before_execution]).to include([[callable_proc], {}]) - expect(registry.registry[:before_execution]).to include([[callable_symbol], {}]) - end - end - - context "when registering the same callback twice" do - it "stores only one copy due to Set behavior" do - registry.register(:before_execution, callable_proc) - registry.register(:before_execution, callable_proc) - - expect(registry.registry[:before_execution].size).to eq(1) - end + registry.invoke(:on_success, task, result) + expect(task.events).to eq([]) end end - describe "#deregister" do - it "returns self for method chaining" do - registry.register(:before_execution, callable_proc) - result = registry.deregister(:before_execution, callable_proc) - - expect(result).to be(registry) - end - - context "when deregistering a single callable" do - before do - registry.register(:before_execution, callable_proc) - end - - it "removes the callable from the registry" do - registry.deregister(:before_execution, callable_proc) - - expect(registry.registry).not_to have_key(:before_execution) - end - end - - context "when deregistering multiple callables" do - before do - registry.register(:before_execution, callable_proc, callable_symbol) - end - - it "removes the exact callables entry" do - registry.deregister(:before_execution, callable_proc, callable_symbol) - - expect(registry.registry).not_to have_key(:before_execution) - end - end - - context "when deregistering with options" do - let(:options) { { if: :active?, unless: :disabled? } } - - before do - registry.register(:before_execution, callable_proc, **options) - end - - it "removes only the entry with matching options" do - registry.deregister(:before_execution, callable_proc, **options) - - expect(registry.registry).not_to have_key(:before_execution) - end - - it "does not remove entries with different options" do - registry.register(:before_execution, callable_proc, if: :other?) - registry.deregister(:before_execution, callable_proc, **options) - - expect(registry.registry[:before_execution]).to include([[callable_proc], { if: :other? }]) - end - end - - context "when deregistering with a block" do - it "removes the entry with the block" do - block = proc { |task| task.blocked = true } - registry.register(:before_execution, callable_proc, &block) - registry.deregister(:before_execution, callable_proc, &block) - - expect(registry.registry).not_to have_key(:before_execution) - end - end - - context "when deregistering from empty registry" do - it "does not raise an error" do - expect { registry.deregister(:before_execution, callable_proc) }.not_to raise_error - end - - it "returns self" do - result = registry.deregister(:before_execution, callable_proc) - - expect(result).to be(registry) - end - end - - context "when deregistering non-existent callback" do - before do - registry.register(:before_execution, callable_symbol) - end - - it "does not affect existing callbacks" do - registry.deregister(:before_execution, callable_proc) - - expect(registry.registry[:before_execution]).to include([[callable_symbol], {}]) - end - end - - context "when deregistering from non-existent type" do - it "does not raise an error" do - expect { registry.deregister(:non_existent, callable_proc) }.not_to raise_error - end - - it "returns self" do - result = registry.deregister(:non_existent, callable_proc) - - expect(result).to be(registry) - end - end - - context "when deregistering the last callback of a type" do - before do - registry.register(:before_execution, callable_proc) - end - - it "removes the type from the registry" do - registry.deregister(:before_execution, callable_proc) - - expect(registry.registry).not_to have_key(:before_execution) - end - end - - context "when deregistering one of multiple callbacks" do - before do - registry.register(:before_execution, callable_proc) - registry.register(:before_execution, callable_symbol) - end - - it "removes only the specified callback" do - registry.deregister(:before_execution, callable_proc) - - expect(registry.registry[:before_execution]).not_to include([[callable_proc], {}]) - expect(registry.registry[:before_execution]).to include([[callable_symbol], {}]) - end + describe "#any?" do + it "is false when empty and true after register" do + expect(registry.any?).to be(false) + registry.register(:before_validation, proc {}) + expect(registry.any?).to be(true) end end - describe "#invoke" do - context "when type is valid" do - before do - allow(CMDx::Utils::Condition).to receive(:evaluate).and_return(true) - end - - context "with symbol callbacks" do - before do - registry.register(:before_execution, callable_symbol) - end - - it "evaluates conditions for each callback entry" do - allow(mock_task).to receive(:send).with(callable_symbol) - expect(CMDx::Utils::Condition).to receive(:evaluate).with(mock_task, {}) - - registry.invoke(:before_execution, mock_task) - end - - it "invokes the symbol via task.send" do - expect(mock_task).to receive(:send).with(callable_symbol) - - registry.invoke(:before_execution, mock_task) - end - end - - context "with proc callbacks" do - before do - registry.register(:before_execution, callable_proc) - end - - it "invokes the proc via task.instance_exec" do - expect(mock_task).to receive(:instance_exec) do |&block| - expect(block).to eq(callable_proc) - end - - registry.invoke(:before_execution, mock_task) - end - end - - context "with callable object callbacks" do - before do - registry.register(:before_execution, callable_object) - end - - it "invokes the callable via .call" do - expect(callable_object).to receive(:call).with(mock_task) - - registry.invoke(:before_execution, mock_task) - end - end - - context "when conditions are not met" do - before do - allow(CMDx::Utils::Condition).to receive(:evaluate).and_return(false) - registry.register(:before_execution, callable_symbol) - end - - it "does not invoke the callables" do - expect(mock_task).not_to receive(:send) - - registry.invoke(:before_execution, mock_task) - end - end - - context "when multiple callback entries exist" do - before do - registry.register(:before_execution, callable_proc, if: :active?) - registry.register(:before_execution, callable_symbol, unless: :disabled?) - allow(mock_task).to receive(:send).with(callable_symbol) - allow(mock_task).to receive(:instance_exec) - end + describe "#for_child" do + it "duplicates callbacks for copy-on-write" do + registry.register(:on_failed, proc {}) - it "processes all entries" do - expect(CMDx::Utils::Condition).to receive(:evaluate).twice + child = registry.for_child + child.register(:on_failed, proc {}) - registry.invoke(:before_execution, mock_task) - end - end - end - - context "when type is invalid" do - it "raises TypeError for unknown callback type" do - expect { registry.invoke(:invalid_type, mock_task) } - .to raise_error(TypeError, "unknown callback type :invalid_type") - end - end - - context "when no callbacks are registered for the type" do - it "does not raise an error" do - expect { registry.invoke(:before_execution, mock_task) }.not_to raise_error - end - - it "does not attempt to invoke any callables" do - expect(mock_task).not_to receive(:send) - expect(mock_task).not_to receive(:instance_exec) - - registry.invoke(:before_execution, mock_task) - end - end - - context "when registry value is not a Set" do - before do - allow(CMDx::Utils::Condition).to receive(:evaluate).and_return(true) - registry.registry[:before_execution] = [[callable_proc], {}] - end - - it "handles Array conversion gracefully" do - expect(mock_task).to receive(:instance_exec) do |&block| - expect(block).to eq(callable_proc) - end - - expect { registry.invoke(:before_execution, mock_task) }.not_to raise_error - end + expect(registry.for_type(:on_failed).size).to eq(1) + expect(child.for_type(:on_failed).size).to eq(2) end end end diff --git a/spec/cmdx/chain_spec.rb b/spec/cmdx/chain_spec.rb index a7e7735ce..eb3f1e30d 100644 --- a/spec/cmdx/chain_spec.rb +++ b/spec/cmdx/chain_spec.rb @@ -2,354 +2,129 @@ require "spec_helper" -RSpec.describe CMDx::Chain, type: :unit do - subject(:chain) { described_class.new } +RSpec.describe CMDx::Chain do + after { described_class.clear } - let(:fiber_or_thread) { Fiber.respond_to?(:storage) ? Fiber.storage : Thread.current } - let(:mock_task) { instance_double(CMDx::Task) } - let(:mock_result) do - instance_double(CMDx::Result, to_h: { id: "result-1" }).tap do |mock| - allow(mock).to receive(:is_a?) do |klass| - klass == CMDx::Result - end - end - end - let(:mock_result2) do - instance_double(CMDx::Result, to_h: { id: "result-2" }).tap do |mock| - allow(mock).to receive(:is_a?) do |klass| - klass == CMDx::Result - end - end - end - - before do - allow(CMDx::Identifier).to receive(:generate).and_return("chain-id-123") - end - - describe "#initialize" do - it "generates a unique id" do - expect(CMDx::Identifier).to receive(:generate) - - chain - end - - it "initializes with an empty results array" do - expect(chain.results).to eq([]) - end - - it "sets the id from Identifier.generate" do - expect(chain.id).to eq("chain-id-123") - end - end - - describe "attr_readers" do - it "provides read access to id" do - expect(chain.id).to eq("chain-id-123") - end - - it "provides read access to results" do - expect(chain.results).to eq([]) - end - end - - describe ".current" do - after { described_class.clear } - - context "when no chain is set" do - it "returns nil" do - expect(described_class.current).to be_nil - end - end - - context "when a chain is set in current thread" do - it "returns the current chain" do - described_class.current = chain - expect(described_class.current).to eq(chain) - end - end - end - - describe ".current=" do - after { described_class.clear } - - it "sets the current chain in thread storage" do + describe ".current, .current=, .clear" do + it "stores the chain on the current fiber or thread" do + chain = described_class.new described_class.current = chain - expect(fiber_or_thread[described_class::CONCURRENCY_KEY]).to eq(chain) - end - - it "allows setting to nil" do - described_class.current = chain - described_class.current = nil - expect(described_class.current).to be_nil - end - end - - describe ".clear" do - before { described_class.current = chain } - - it "sets current chain to nil" do + expect(described_class.current).to equal(chain) described_class.clear expect(described_class.current).to be_nil end - - it "clears thread storage" do - described_class.clear - expect(fiber_or_thread[described_class::CONCURRENCY_KEY]).to be_nil - end end describe ".build" do - after { described_class.clear } - - context "when result is not a CMDx::Result" do - it "raises TypeError" do - expect { described_class.build("not-a-result") }.to raise_error( - TypeError, "must be a CMDx::Result" - ) - end - - it "raises TypeError for nil" do - expect { described_class.build(nil) }.to raise_error( - TypeError, "must be a CMDx::Result" - ) - end - end - - context "when result is a valid CMDx::Result" do - context "when no current chain exists" do - it "creates a new chain and sets it as current" do - result_chain = described_class.build(mock_result) - - expect(result_chain).to be_a(described_class) - expect(described_class.current).to eq(result_chain) - expect(result_chain.results).to contain_exactly(mock_result) - end - end - - context "when a current chain already exists" do - before { described_class.current = chain } - - it "uses the existing chain and adds the result" do - result_chain = described_class.build(mock_result) - - expect(result_chain).to eq(chain) - expect(chain.results).to contain_exactly(mock_result) - end - end - - context "when building multiple results" do - before { described_class.current = chain } - - it "adds results in order" do - described_class.build(mock_result) - described_class.build(mock_result2) - - expect(chain.results).to eq([mock_result, mock_result2]) - end - end - - context "with dry_run option" do - context "when no current chain exists" do - it "creates a new chain with dry_run set to true" do - result_chain = described_class.build(mock_result, dry_run: true) - - expect(result_chain.dry_run?).to be(true) - end - - it "creates a new chain with dry_run set to false" do - result_chain = described_class.build(mock_result, dry_run: false) - - expect(result_chain.dry_run?).to be(false) - end - end - - context "when a current chain already exists" do - before { described_class.current = chain } - - it "does not update existing chain dry_run status" do - result_chain = described_class.build(mock_result, dry_run: true) - - expect(result_chain).to eq(chain) - expect(result_chain.dry_run?).to be(false) - end - end - end - end - end - - describe "#to_h" do - let(:result_hash1) { { id: "result-1", status: "success" } } - let(:result_hash2) { { id: "result-2", status: "failed" } } - - before do - allow(mock_result).to receive(:to_h).and_return(result_hash1) - allow(mock_result2).to receive(:to_h).and_return(result_hash2) - end - - context "when results array is empty" do - it "returns hash with id and empty results array" do - expect(chain.to_h).to eq( - { - id: "chain-id-123", - dry_run: false, - results: [] - } - ) - end + def minimal_result(**attrs) + defaults = { + task_id: CMDx::Identifier.generate, + task_class: String, + task_type: "string", + task_tags: [], + state: "complete", + status: "success", + reason: nil, + cause: nil, + metadata: {}, + strict: true, + retries: 0, + rolled_back: false, + context: CMDx::Context.new, + chain: nil, + errors: CMDx::Errors.new, + index: 0 + } + CMDx::Result.new(**defaults, **attrs) + end + + it "creates a chain when none exists and pushes the result" do + described_class.clear + r = minimal_result + c = described_class.build(r) + expect(c.results).to eq([r]) + expect(described_class.current).to equal(c) end - context "when results array has results" do - before do - chain.results << mock_result - chain.results << mock_result2 - - allow(mock_result).to receive(:to_h).and_return(result_hash1) - allow(mock_result2).to receive(:to_h).and_return(result_hash2) - end - - it "returns hash with id and results converted to hashes" do - expect(chain.to_h).to eq( - { - id: "chain-id-123", - dry_run: false, - results: [result_hash1, result_hash2] - } - ) - end - - it "calls to_h on each result" do - expect(mock_result).to receive(:to_h) - expect(mock_result2).to receive(:to_h) - - chain.to_h - end + it "extends the existing chain" do + described_class.clear + r1 = minimal_result + r2 = minimal_result + c1 = described_class.build(r1) + c2 = described_class.build(r2) + expect(c2).to equal(c1) + expect(c2.results).to eq([r1, r2]) end end - describe "#to_s" do - let(:formatted_string) { "id=\"chain-id-123\" dry_run=false results=[]" } - - it "converts to hash and formats as string" do - expect(CMDx::Utils::Format).to receive(:to_str).with( - { - id: "chain-id-123", - dry_run: false, - results: [] - } + describe "instance behavior" do + subject(:chain) { described_class.new(dry_run: true) } + + let(:result) do + CMDx::Result.new( + task_id: "tid", + task_class: String, + task_type: "string", + task_tags: [], + state: "complete", + status: "success", + reason: nil, + cause: nil, + metadata: {}, + strict: true, + retries: 0, + rolled_back: false, + context: CMDx::Context.new, + chain: nil, + errors: CMDx::Errors.new, + index: 0 ) - - chain.to_s end - end - - describe "#index" do - it "returns the position of a result in the chain" do - chain.push(mock_result) - chain.push(mock_result2) - - expect(chain.index(mock_result)).to eq(0) - expect(chain.index(mock_result2)).to eq(1) - end - - it "returns nil for a result not in the chain" do - expect(chain.index(mock_result)).to be_nil - end - - it "is thread-safe under concurrent push and index" do - results = Array.new(100) do |i| - instance_double(CMDx::Result, to_h: { id: "result-#{i}" }).tap do |mock| - allow(mock).to receive(:is_a?).with(CMDx::Result).and_return(true) - end - end - - threads = results.map { |r| Thread.new { chain.push(r) } } - threads.each(&:join) - results.each do |r| - expect(chain.index(r)).to be_a(Integer) + describe "#push, #next_index" do + it "appends results and next_index tracks size" do + expect(chain.next_index).to eq(0) + chain.push(result) + expect(chain.next_index).to eq(1) + expect(chain.results.size).to eq(1) end end - end - - describe "thread safety" do - after { described_class.clear } - - it "maintains separate chains per thread" do - thread1_chain = nil - thread2_chain = nil - - thread1 = Thread.new do - described_class.current = described_class.new - thread1_chain = described_class.current - end - thread2 = Thread.new do - described_class.current = described_class.new - thread2_chain = described_class.current + describe "#dry_run?" do + it "reflects the constructor flag" do + expect(chain.dry_run?).to be true + expect(described_class.new(dry_run: false).dry_run?).to be false end - - thread1.join - thread2.join - - expect(thread1_chain).not_to eq(thread2_chain) - expect(thread1_chain).to be_a(described_class) - expect(thread2_chain).to be_a(described_class) end - it "does not interfere with main thread chain" do - main_chain = described_class.new - described_class.current = main_chain - - thread_chain = nil - thread = Thread.new do - described_class.current = described_class.new - thread_chain = described_class.current + describe "#first, #last, #size" do + it "delegates to results" do + chain.push(result) + expect(chain.first).to equal(result) + expect(chain.last).to equal(result) + expect(chain.size).to eq(1) end - thread.join - - expect(described_class.current).to eq(main_chain) - expect(thread_chain).not_to eq(main_chain) end - end - - describe "fiber safety" do - after { described_class.clear } - - it "maintains separate chains per fiber" do - fiber1_chain = nil - fiber2_chain = nil - fiber1 = Fiber.new do - described_class.current = described_class.new - fiber1_chain = described_class.current + describe "#freeze" do + it "freezes the results array" do + chain.push(result) + chain.freeze + expect(chain.results).to be_frozen + expect { chain.results << result }.to raise_error(FrozenError) end - - fiber2 = Fiber.new do - described_class.current = described_class.new - fiber2_chain = described_class.current - end - - fiber1.resume - fiber2.resume - - expect(fiber1_chain).not_to eq(fiber2_chain) - expect(fiber1_chain).to be_a(described_class) - expect(fiber2_chain).to be_a(described_class) end - it "does not interfere with main fiber chain" do - main_chain = described_class.new - described_class.current = main_chain - - fiber_chain = nil - fiber = Fiber.new do - described_class.current = described_class.new - fiber_chain = described_class.current + describe "#to_h, #to_s" do + it "serializes id, dry_run, and result hashes" do + chain = described_class.new(dry_run: false) + chain.push(result) + h = chain.to_h + expect(h[:id]).to be_a(String) + expect(h[:dry_run]).to be false + expect(h[:results].size).to eq(1) + expect(chain.to_s).to include("id:") end - fiber.resume - - expect(described_class.current).to eq(main_chain) - expect(fiber_chain).not_to eq(main_chain) end end end diff --git a/spec/cmdx/coercion_registry_spec.rb b/spec/cmdx/coercion_registry_spec.rb index f9787fe06..2f3d5484c 100644 --- a/spec/cmdx/coercion_registry_spec.rb +++ b/spec/cmdx/coercion_registry_spec.rb @@ -2,289 +2,53 @@ require "spec_helper" -RSpec.describe CMDx::CoercionRegistry, type: :unit do - subject(:registry) { described_class.new(initial_registry) } - - let(:initial_registry) { nil } - let(:mock_coercion) { instance_double("MockCoercion") } - let(:mock_task) { instance_double(CMDx::Task) } - - describe "#initialize" do - context "when no registry is provided" do - subject(:registry) { described_class.new } - - it "initializes with default coercions" do - expect(registry.registry).to include( - array: CMDx::Coercions::Array, - big_decimal: CMDx::Coercions::BigDecimal, - boolean: CMDx::Coercions::Boolean, - complex: CMDx::Coercions::Complex, - date: CMDx::Coercions::Date, - datetime: CMDx::Coercions::DateTime, - float: CMDx::Coercions::Float, - hash: CMDx::Coercions::Hash, - integer: CMDx::Coercions::Integer, - rational: CMDx::Coercions::Rational, - string: CMDx::Coercions::String, - time: CMDx::Coercions::Time - ) - end - end - - context "when a registry is provided" do - let(:initial_registry) { { custom: mock_coercion } } - - it "initializes with the provided registry" do - expect(registry.registry).to eq({ custom: mock_coercion }) - end - end - end - - describe "#registry" do - let(:initial_registry) { { custom: mock_coercion } } - - it "returns the internal registry hash" do - expect(registry.registry).to eq({ custom: mock_coercion }) - end - end - - describe "#to_h" do - let(:initial_registry) { { custom: mock_coercion } } - - it "returns the registry hash" do - expect(registry.to_h).to eq({ custom: mock_coercion }) - end - - it "is an alias for registry" do - expect(registry.method(:to_h)).to eq(registry.method(:registry)) - end - end - - describe "#dup" do - it "returns a new CoercionRegistry instance" do - duplicated = registry.dup - - expect(duplicated).to be_a(described_class) - expect(duplicated).not_to be(registry) +RSpec.describe CMDx::CoercionRegistry do + describe "#resolve" do + it "returns built-in coercion modules" do + reg = described_class.new + expect(reg.resolve(:string)).to eq(CMDx::Coercions::String) + expect(reg.resolve(:integer)).to eq(CMDx::Coercions::Integer) end - it "shares the parent registry until first write" do - duplicated = registry.dup - - expect(duplicated.registry).to eq(registry.registry) - expect(duplicated.registry).to be(registry.registry) + it "returns a Class or Module unchanged" do + reg = described_class.new + mod = CMDx::Coercions::Hash + expect(reg.resolve(mod)).to be(mod) end - it "materializes on write and does not affect the parent" do - duplicated = registry.dup - - duplicated.register(:new_type, mock_coercion) - - expect(duplicated.registry).to have_key(:new_type) - expect(registry.registry).not_to have_key(:new_type) - expect(duplicated.registry).not_to be(registry.registry) + it "raises UnknownCoercionError for unknown types" do + reg = described_class.new + expect { reg.resolve(:not_registered) }.to raise_error(CMDx::UnknownCoercionError) end end describe "#register" do - context "when registering a coercion with string name" do - it "adds the coercion to the registry with symbol key" do - registry.register("custom", mock_coercion) - - expect(registry.registry[:custom]).to eq(mock_coercion) - end - - it "returns self for method chaining" do - result = registry.register("custom", mock_coercion) - - expect(result).to be(registry) - end - end - - context "when registering a coercion with symbol name" do - it "adds the coercion to the registry" do - registry.register(:custom, mock_coercion) - - expect(registry.registry[:custom]).to eq(mock_coercion) - end - end - - context "when registering to an existing registry" do - let(:initial_registry) { { existing: "existing_coercion" } } - - it "adds new coercion to existing ones" do - registry.register(:new_type, mock_coercion) - - expect(registry.registry).to include(existing: "existing_coercion", new_type: mock_coercion) - end - end - - context "when registering over an existing coercion" do - let(:initial_registry) { { existing: "old_coercion" } } - - it "overwrites the existing coercion" do - registry.register(:existing, mock_coercion) - - expect(registry.registry[:existing]).to eq(mock_coercion) - end - end - end - - describe "#deregister" do - context "when deregistering with string name" do - before do - registry.register("custom", mock_coercion) - end - - it "removes the coercion from the registry" do - registry.deregister("custom") - - expect(registry.registry).not_to have_key(:custom) - end - - it "returns self for method chaining" do - result = registry.deregister("custom") - - expect(result).to be(registry) - end - end - - context "when deregistering with symbol name" do - before do - registry.register(:custom, mock_coercion) - end - - it "removes the coercion from the registry" do - registry.deregister(:custom) - - expect(registry.registry).not_to have_key(:custom) - end - end - - context "when deregistering from existing registry" do - let(:initial_registry) { { existing: "existing_coercion", custom: mock_coercion } } - - it "removes only the specified coercion" do - registry.deregister(:custom) - - expect(registry.registry).to include(existing: "existing_coercion") - expect(registry.registry).not_to have_key(:custom) - end - end - - context "when deregistering non-existent coercion" do - it "does not raise an error" do - expect { registry.deregister(:nonexistent) }.not_to raise_error - end - - it "returns self" do - result = registry.deregister(:nonexistent) - - expect(result).to be(registry) - end - - it "does not affect existing coercions" do - registry.register(:existing, mock_coercion) - registry.deregister(:nonexistent) - - expect(registry.registry[:existing]).to eq(mock_coercion) - end - end - - context "when deregistering from empty registry" do - let(:initial_registry) { {} } - - it "does not raise an error" do - expect { registry.deregister(:custom) }.not_to raise_error - end - - it "returns self" do - result = registry.deregister(:custom) - - expect(result).to be(registry) - end - end - - context "when deregistering default coercions" do - subject(:registry) { described_class.new } - - it "removes the default coercion" do - registry.deregister(:string) - - expect(registry.registry).not_to have_key(:string) - end - - it "does not affect other default coercions" do - registry.deregister(:string) - - expect(registry.registry).to have_key(:integer) - expect(registry.registry).to have_key(:boolean) - end - end - end - - describe "#coerce" do - let(:initial_registry) { { custom: mock_coercion } } - let(:value) { "test_value" } - let(:options) { { option1: "value1" } } - - context "when coercion type exists" do - it "calls Utils::Call.invoke with correct parameters" do - expect(CMDx::Utils::Call).to receive(:invoke).with( - mock_task, mock_coercion, value, options - ) - - registry.coerce(:custom, mock_task, value, options) - end - - it "returns the result from Utils::Call.invoke" do - allow(CMDx::Utils::Call).to receive(:invoke).and_return("coerced_value") - - result = registry.coerce(:custom, mock_task, value, options) - - expect(result).to eq("coerced_value") - end - - context "with string type name" do - let(:initial_registry) { { "custom" => mock_coercion } } - - it "works with string type names" do - expect(CMDx::Utils::Call).to receive(:invoke).with( - mock_task, mock_coercion, value, options - ) - - registry.coerce("custom", mock_task, value, options) + it "adds a custom coercion" do + reg = described_class.new + klass = Class.new do + def self.call(value) + "coerced:#{value}" end end - end - - context "when options are not provided" do - it "passes empty hash as options" do - expect(CMDx::Utils::Call).to receive(:invoke).with( - mock_task, mock_coercion, value, {} - ) - registry.coerce(:custom, mock_task, value) - end + reg.register(:widget, klass) + expect(reg.resolve(:widget)).to be(klass) + expect(klass.call(1)).to eq("coerced:1") end + end - context "when coercion type does not exist" do - it "raises TypeError with descriptive message" do - expect { registry.coerce(:nonexistent, mock_task, value) } - .to raise_error(TypeError, "unknown coercion type :nonexistent") - end + describe "#for_child" do + it "duplicates the registry for copy-on-write" do + parent = described_class.new + parent.register(:widget, CMDx::Coercions::String) - it "raises TypeError for string type names" do - expect { registry.coerce("string", mock_task, value) } - .to raise_error(TypeError, 'unknown coercion type "string"') - end - end + child = parent.for_child + child.register(:other, CMDx::Coercions::Integer) - context "when type is nil" do - it "raises TypeError" do - expect { registry.coerce(nil, mock_task, value) } - .to raise_error(TypeError, "unknown coercion type nil") - end + expect(parent.registry.key?(:other)).to be(false) + expect(child.registry.key?(:other)).to be(true) + expect(parent.resolve(:widget)).to eq(CMDx::Coercions::String) + expect(child.resolve(:widget)).to eq(CMDx::Coercions::String) end end end diff --git a/spec/cmdx/coercions/array_spec.rb b/spec/cmdx/coercions/array_spec.rb index d39c7e571..75fb4cd0c 100644 --- a/spec/cmdx/coercions/array_spec.rb +++ b/spec/cmdx/coercions/array_spec.rb @@ -2,215 +2,32 @@ require "spec_helper" -RSpec.describe CMDx::Coercions::Array, type: :unit do - subject(:coercion) { described_class } - +RSpec.describe CMDx::Coercions::Array do describe ".call" do - context "when value is a JSON string starting with '['" do - it "parses valid JSON array string" do - result = coercion.call('["a", "b", "c"]') - - expect(result).to eq(%w[a b c]) - end - - it "parses JSON array with mixed types" do - result = coercion.call('[1, "string", true, null]') - - expect(result).to eq([1, "string", true, nil]) - end - - it "parses empty JSON array" do - result = coercion.call("[]") - - expect(result).to eq([]) - end - - it "parses nested JSON arrays" do - result = coercion.call("[[1, 2], [3, 4]]") - - expect(result).to eq([[1, 2], [3, 4]]) - end - - it "parses JSON array with objects" do - result = coercion.call('[{"key": "value"}, {"number": 42}]') - - expect(result).to eq([{ "key" => "value" }, { "number" => 42 }]) - end - - it "parses JSON null string as empty array" do - result = coercion.call("null") - - expect(result).to eq([]) - end - - it "parses JSON null string with whitespace as empty array" do - result = coercion.call(" null ") - - expect(result).to eq([]) - end - - context "with invalid JSON" do - it "raises CoercionError for malformed JSON" do - expect { coercion.call("[invalid json") } - .to raise_error(CMDx::CoercionError) - end - - it "raises CoercionError for incomplete array" do - expect { coercion.call("[1, 2,") } - .to raise_error(CMDx::CoercionError) - end - - it "raises CoercionError for unquoted strings" do - expect { coercion.call("[unquoted, string]") } - .to raise_error(CMDx::CoercionError) - end - end - end - - context "when value is a string not starting with '['" do - it "wraps single string value in array" do - result = coercion.call("hello") - - expect(result).to eq(["hello"]) - end - - it "wraps empty string in array" do - result = coercion.call("") - - expect(result).to eq([""]) - end - - it "wraps string starting with other characters" do - result = coercion.call("{key: value}") - - expect(result).to eq(["{key: value}"]) - end - - it "wraps numeric string in array" do - result = coercion.call("123") - - expect(result).to eq(["123"]) - end - end - - context "when value is already an array" do - it "returns the array unchanged" do - input = [1, 2, 3] - - result = coercion.call(input) - - expect(result).to eq([1, 2, 3]) - end - - it "returns empty array unchanged" do - input = [] - - result = coercion.call(input) - - expect(result).to eq([]) - end - - it "returns nested array unchanged" do - input = [[1, 2], [3, 4]] - - result = coercion.call(input) - - expect(result).to eq([[1, 2], [3, 4]]) - end - end - - context "when value is nil" do - it "converts nil to empty array" do - result = coercion.call(nil) - - expect(result).to eq([]) - end + it "returns an Array unchanged" do + arr = [1, 2] + expect(described_class.call(arr)).to equal(arr) end - context "when value is a number" do - it "wraps integer in array" do - result = coercion.call(42) - - expect(result).to eq([42]) - end - - it "wraps float in array" do - result = coercion.call(3.14) - - expect(result).to eq([3.14]) - end - - it "wraps zero in array" do - result = coercion.call(0) - - expect(result).to eq([0]) - end - end - - context "when value is a boolean" do - it "wraps true in array" do - result = coercion.call(true) - - expect(result).to eq([true]) - end - - it "wraps false in array" do - result = coercion.call(false) - - expect(result).to eq([false]) - end + it "converts a Hash to an Array of pairs" do + expect(described_class.call({ a: 1, b: 2 })).to eq([[:a, 1], [:b, 2]]) end - context "when value is a hash" do - it "converts hash to array of key-value pairs" do - input = { key: "value" } - - result = coercion.call(input) - - expect(result).to eq([[:key, "value"]]) - end - - it "converts empty hash to empty array" do - result = coercion.call({}) - - expect(result).to eq([]) - end + it "returns an empty Array for nil" do + expect(described_class.call(nil)).to eq([]) end - context "when value is an enumerable object" do - it "converts range to array" do - result = coercion.call(1..3) - - expect(result).to eq([1, 2, 3]) - end - - it "converts set to array" do - input = Set.new([1, 2, 3]) - - result = coercion.call(input) - - expect(result).to eq([1, 2, 3]) - end + it "wraps other values per Kernel.Array" do + expect(described_class.call(1)).to eq([1]) end - context "with options parameter" do - it "ignores options when processing JSON string" do - result = coercion.call('["a", "b"]', { unused: "option" }) - - expect(result).to eq(%w[a b]) + it "raises CMDx::Error when coercion fails" do + bad = Object.new + def bad.to_ary + raise StandardError, "nope" end - it "ignores options when wrapping non-JSON values" do - result = coercion.call("hello", { unused: "option" }) - - expect(result).to eq(["hello"]) - end - - it "works with empty options hash" do - result = coercion.call([1, 2, 3], {}) - - expect(result).to eq([1, 2, 3]) - end + expect { described_class.call(bad) }.to raise_error(CMDx::Error, /array/) end end end diff --git a/spec/cmdx/coercions/big_decimal_spec.rb b/spec/cmdx/coercions/big_decimal_spec.rb index 7f7cd6428..d157c3ce0 100644 --- a/spec/cmdx/coercions/big_decimal_spec.rb +++ b/spec/cmdx/coercions/big_decimal_spec.rb @@ -2,167 +2,23 @@ require "spec_helper" -RSpec.describe CMDx::Coercions::BigDecimal, type: :unit do - subject(:coercion) { described_class } - +RSpec.describe CMDx::Coercions::BigDecimal do describe ".call" do - context "when value is a valid string" do - it "coerces string integer to BigDecimal" do - result = coercion.call("123") - - expect(result).to be_a(BigDecimal) - expect(result).to eq(BigDecimal(123)) - end - - it "coerces string decimal to BigDecimal" do - result = coercion.call("123.456") - - expect(result).to be_a(BigDecimal) - expect(result).to eq(BigDecimal("123.456")) - end - - it "coerces negative string to BigDecimal" do - result = coercion.call("-123.456") - - expect(result).to be_a(BigDecimal) - expect(result).to eq(BigDecimal("-123.456")) - end - - it "coerces string with scientific notation to BigDecimal" do - result = coercion.call("1.23e4") - - expect(result).to be_a(BigDecimal) - expect(result).to eq(BigDecimal("1.23e4")) - end - - it "coerces zero string to BigDecimal" do - result = coercion.call("0") - - expect(result).to be_a(BigDecimal) - expect(result).to eq(BigDecimal(0)) - end - end - - context "when value is a numeric type" do - it "coerces integer to BigDecimal" do - result = coercion.call(123) - - expect(result).to be_a(BigDecimal) - expect(result).to eq(BigDecimal(123)) - end - - it "coerces float to BigDecimal" do - result = coercion.call(123.456) - - expect(result).to be_a(BigDecimal) - expect(result).to eq(BigDecimal("123.456")) - end - - it "coerces rational to BigDecimal" do - result = coercion.call(Rational(22, 7)) - - expect(result).to be_a(BigDecimal) - expect(result.to_f).to be_within(0.001).of(3.14285) - end - - it "coerces existing BigDecimal" do - original = BigDecimal("123.456") - result = coercion.call(original) - - expect(result).to be_a(BigDecimal) - expect(result).to eq(original) - end + it "returns a BigDecimal unchanged" do + bd = BigDecimal("12.34") + expect(described_class.call(bd)).to eq(bd) end - context "with precision option" do - it "uses custom precision when provided" do - result = coercion.call("123.456789", precision: 4) - - expect(result).to be_a(BigDecimal) - expect(result).to eq(BigDecimal("123.456789", 4)) - end - - it "uses default precision when not provided" do - result = coercion.call("123.456789") - - expect(result).to be_a(BigDecimal) - expect(result).to eq(BigDecimal("123.456789", 14)) - end - - it "uses zero precision" do - result = coercion.call("123.456", precision: 0) - - expect(result).to be_a(BigDecimal) - expect(result).to eq(BigDecimal("123.456", 0)) - end + it "coerces from String" do + expect(described_class.call("99.5")).to eq(BigDecimal("99.5")) end - context "with invalid values" do - it "raises CoercionError for non-numeric string" do - expect { coercion.call("invalid") } - .to raise_error(CMDx::CoercionError, "could not coerce into a big decimal") - end - - it "raises CoercionError for empty string" do - expect { coercion.call("") } - .to raise_error(CMDx::CoercionError, "could not coerce into a big decimal") - end - - it "raises CoercionError for nil" do - expect { coercion.call(nil) } - .to raise_error(CMDx::CoercionError, "could not coerce into a big decimal") - end - - it "raises CoercionError for boolean" do - expect { coercion.call(true) } - .to raise_error(CMDx::CoercionError, "could not coerce into a big decimal") - end - - it "raises CoercionError for array" do - expect { coercion.call([1, 2, 3]) } - .to raise_error(CMDx::CoercionError, "could not coerce into a big decimal") - end - - it "raises CoercionError for hash" do - expect { coercion.call({ key: "value" }) } - .to raise_error(CMDx::CoercionError, "could not coerce into a big decimal") - end - - it "raises CoercionError for symbol" do - expect { coercion.call(:symbol) } - .to raise_error(CMDx::CoercionError, "could not coerce into a big decimal") - end - - it "raises CoercionError for complex number" do - expect { coercion.call(Complex(1, 2)) } - .to raise_error(CMDx::CoercionError, "could not coerce into a big decimal") - end + it "coerces from Float" do + expect(described_class.call(1.25)).to eq(BigDecimal("1.25")) end - context "with edge cases" do - it "coerces string with leading/trailing whitespace" do - result = coercion.call(" 123.456 ") - - expect(result).to be_a(BigDecimal) - expect(result).to eq(BigDecimal("123.456")) - end - - it "coerces string with plus sign" do - result = coercion.call("+123.456") - - expect(result).to be_a(BigDecimal) - expect(result).to eq(BigDecimal("123.456")) - end - - it "raises CoercionError for string with invalid characters" do - expect { coercion.call("123.45.67") } - .to raise_error(CMDx::CoercionError, "could not coerce into a big decimal") - end - - it "raises CoercionError for string with letters mixed in" do - expect { coercion.call("12a3.456") } - .to raise_error(CMDx::CoercionError, "could not coerce into a big decimal") - end + it "coerces from Integer" do + expect(described_class.call(7)).to eq(BigDecimal("7")) end end end diff --git a/spec/cmdx/coercions/boolean_spec.rb b/spec/cmdx/coercions/boolean_spec.rb index cef3be908..bc598e761 100644 --- a/spec/cmdx/coercions/boolean_spec.rb +++ b/spec/cmdx/coercions/boolean_spec.rb @@ -2,251 +2,27 @@ require "spec_helper" -RSpec.describe CMDx::Coercions::Boolean, type: :unit do - subject(:coercion) { described_class } - +RSpec.describe CMDx::Coercions::Boolean do describe ".call" do - context "when value is truthy" do - it "coerces string 'true' to true" do - result = coercion.call("true") - - expect(result).to be(true) - end - - it "coerces string 'TRUE' to true" do - result = coercion.call("TRUE") - - expect(result).to be(true) - end - - it "coerces string 'True' to true" do - result = coercion.call("True") - - expect(result).to be(true) - end - - it "coerces string 't' to true" do - result = coercion.call("t") - - expect(result).to be(true) - end - - it "coerces string 'T' to true" do - result = coercion.call("T") - - expect(result).to be(true) - end - - it "coerces string 'yes' to true" do - result = coercion.call("yes") - - expect(result).to be(true) - end - - it "coerces string 'YES' to true" do - result = coercion.call("YES") - - expect(result).to be(true) - end - - it "coerces string 'Yes' to true" do - result = coercion.call("Yes") - - expect(result).to be(true) - end - - it "coerces string 'y' to true" do - result = coercion.call("y") - - expect(result).to be(true) - end - - it "coerces string 'Y' to true" do - result = coercion.call("Y") - - expect(result).to be(true) - end - - it "coerces string '1' to true" do - result = coercion.call("1") - - expect(result).to be(true) - end - - it "coerces integer 1 to true" do - result = coercion.call(1) - - expect(result).to be(true) - end - - it "coerces boolean true to true" do - result = coercion.call(true) - - expect(result).to be(true) + it "treats configured truthy values as true" do + %w[1 true yes on t y].each do |s| + expect(described_class.call(s)).to be(true) end + expect(described_class.call(true)).to be(true) + expect(described_class.call(1)).to be(true) end - context "when value is falsey" do - it "coerces string 'false' to false" do - result = coercion.call("false") - - expect(result).to be(false) - end - - it "coerces string 'FALSE' to false" do - result = coercion.call("FALSE") - - expect(result).to be(false) - end - - it "coerces string 'False' to false" do - result = coercion.call("False") - - expect(result).to be(false) - end - - it "coerces string 'f' to false" do - result = coercion.call("f") - - expect(result).to be(false) - end - - it "coerces string 'F' to false" do - result = coercion.call("F") - - expect(result).to be(false) - end - - it "coerces string 'no' to false" do - result = coercion.call("no") - - expect(result).to be(false) - end - - it "coerces string 'NO' to false" do - result = coercion.call("NO") - - expect(result).to be(false) - end - - it "coerces string 'No' to false" do - result = coercion.call("No") - - expect(result).to be(false) - end - - it "coerces string 'n' to false" do - result = coercion.call("n") - - expect(result).to be(false) - end - - it "coerces string 'N' to false" do - result = coercion.call("N") - - expect(result).to be(false) - end - - it "coerces string '0' to false" do - result = coercion.call("0") - - expect(result).to be(false) - end - - it "coerces integer 0 to false" do - result = coercion.call(0) - - expect(result).to be(false) - end - - it "coerces boolean false to false" do - result = coercion.call(false) - - expect(result).to be(false) - end - - it "coerces nil to false" do - result = coercion.call(nil) - - expect(result).to be(false) - end - - it "coerces empty string to false" do - result = coercion.call("") - - expect(result).to be(false) - end - end - - context "when value is invalid" do - it "raises CoercionError for invalid string" do - expect { coercion.call("invalid") } - .to raise_error(CMDx::CoercionError, "could not coerce into a boolean") - end - - it "raises CoercionError for array" do - expect { coercion.call([1, 2, 3]) } - .to raise_error(CMDx::CoercionError, "could not coerce into a boolean") - end - - it "raises CoercionError for hash" do - expect { coercion.call({ key: "value" }) } - .to raise_error(CMDx::CoercionError, "could not coerce into a boolean") - end - - it "raises CoercionError for symbol" do - expect { coercion.call(:symbol) } - .to raise_error(CMDx::CoercionError, "could not coerce into a boolean") - end - - it "raises CoercionError for float" do - expect { coercion.call(3.14) } - .to raise_error(CMDx::CoercionError, "could not coerce into a boolean") - end - - it "raises CoercionError for integer 2" do - expect { coercion.call(2) } - .to raise_error(CMDx::CoercionError, "could not coerce into a boolean") - end - - it "raises CoercionError for negative integer" do - expect { coercion.call(-1) } - .to raise_error(CMDx::CoercionError, "could not coerce into a boolean") - end - - it "raises CoercionError for partial match string" do - expect { coercion.call("truth") } - .to raise_error(CMDx::CoercionError, "could not coerce into a boolean") - end - - it "raises CoercionError for string with whitespace" do - expect { coercion.call(" true ") } - .to raise_error(CMDx::CoercionError, "could not coerce into a boolean") - end - - it "raises CoercionError for string with mixed case that doesn't match patterns" do - expect { coercion.call("TrUeX") } - .to raise_error(CMDx::CoercionError, "could not coerce into a boolean") + it "treats configured falsy values as false" do + %w[0 false no off f n].each do |s| + expect(described_class.call(s)).to be(false) end + expect(described_class.call(false)).to be(false) + expect(described_class.call(0)).to be(false) + expect(described_class.call(nil)).to be(false) end - context "with options parameter" do - it "ignores options parameter for valid truthy value" do - result = coercion.call("true", { some: "option" }) - - expect(result).to be(true) - end - - it "ignores options parameter for valid falsey value" do - result = coercion.call("false", { some: "option" }) - - expect(result).to be(false) - end - - it "ignores options parameter for invalid value" do - expect { coercion.call("invalid", { some: "option" }) } - .to raise_error(CMDx::CoercionError, "could not coerce into a boolean") - end + it "uses !!value for other objects" do + expect(described_class.call(Object.new)).to be(true) end end end diff --git a/spec/cmdx/coercions/complex_spec.rb b/spec/cmdx/coercions/complex_spec.rb index 757171e39..9e9c66597 100644 --- a/spec/cmdx/coercions/complex_spec.rb +++ b/spec/cmdx/coercions/complex_spec.rb @@ -2,219 +2,23 @@ require "spec_helper" -RSpec.describe CMDx::Coercions::Complex, type: :unit do - subject(:coercion) { described_class } - +RSpec.describe CMDx::Coercions::Complex do describe ".call" do - context "when value is a valid string" do - it "coerces string integer to Complex" do - result = coercion.call("123") - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(123)) - end - - it "coerces string decimal to Complex" do - result = coercion.call("123.456") - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(123.456)) - end - - it "coerces negative string to Complex" do - result = coercion.call("-123.456") - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(-123.456)) - end - - it "coerces string with imaginary unit to Complex" do - result = coercion.call("3+4i") - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(3, 4)) - end - - it "coerces string with negative imaginary unit to Complex" do - result = coercion.call("3-4i") - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(3, -4)) - end - - it "coerces pure imaginary string to Complex" do - result = coercion.call("4i") - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(0, 4)) - end - - it "coerces zero string to Complex" do - result = coercion.call("0") - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(0)) - end - - it "coerces string with scientific notation to Complex" do - result = coercion.call("1.23e4") - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(1.23e4)) - end - end - - context "when value is a numeric type" do - it "coerces integer to Complex" do - result = coercion.call(123) - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(123)) - end - - it "coerces float to Complex" do - result = coercion.call(123.456) - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(123.456)) - end - - it "coerces rational to Complex" do - result = coercion.call(Rational(22, 7)) - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(Rational(22, 7))) - end - - it "coerces existing Complex" do - original = Complex(3, 4) - result = coercion.call(original) - - expect(result).to be_a(Complex) - expect(result).to eq(original) - end - - it "coerces zero to Complex" do - result = coercion.call(0) - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(0)) - end - - it "coerces negative number to Complex" do - result = coercion.call(-42) - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(-42)) - end + it "returns a Complex unchanged" do + c = Complex(3, 4) + expect(described_class.call(c)).to eq(c) end - context "with invalid values" do - it "raises CoercionError for non-numeric string" do - expect { coercion.call("invalid") } - .to raise_error(CMDx::CoercionError, "could not coerce into a complex") - end - - it "raises CoercionError for empty string" do - expect { coercion.call("") } - .to raise_error(CMDx::CoercionError, "could not coerce into a complex") - end - - it "raises CoercionError for nil" do - expect { coercion.call(nil) } - .to raise_error(CMDx::CoercionError, "could not coerce into a complex") - end - - it "raises CoercionError for boolean" do - expect { coercion.call(true) } - .to raise_error(CMDx::CoercionError, "could not coerce into a complex") - end - - it "raises CoercionError for array" do - expect { coercion.call([1, 2, 3]) } - .to raise_error(CMDx::CoercionError, "could not coerce into a complex") - end - - it "raises CoercionError for hash" do - expect { coercion.call({ key: "value" }) } - .to raise_error(CMDx::CoercionError, "could not coerce into a complex") - end - - it "raises CoercionError for symbol" do - expect { coercion.call(:symbol) } - .to raise_error(CMDx::CoercionError, "could not coerce into a complex") - end - - it "raises CoercionError for Object" do - expect { coercion.call(Object.new) } - .to raise_error(CMDx::CoercionError, "could not coerce into a complex") - end + it "coerces from a numeric value" do + expect(described_class.call(5)).to eq(Complex(5, 0)) end - context "with edge cases" do - it "coerces string with decimal imaginary unit" do - result = coercion.call("1.5+2.7i") - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(1.5, 2.7)) - end - - it "coerces string with just 'i' as imaginary unit" do - result = coercion.call("i") - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(0, 1)) - end - - it "coerces string with negative imaginary unit 'i'" do - result = coercion.call("-i") - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(0, -1)) - end - - it "coerces string with 'j' notation" do - result = coercion.call("3+4j") - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(3, 4)) - end - - it "raises CoercionError for string with spaces" do - expect { coercion.call("3 + 4i") } - .to raise_error(CMDx::CoercionError, "could not coerce into a complex") - end - - it "raises CoercionError for string with multiple operators" do - expect { coercion.call("3++4i") } - .to raise_error(CMDx::CoercionError, "could not coerce into a complex") - end - - it "raises CoercionError for malformed complex string" do - expect { coercion.call("3+i4") } - .to raise_error(CMDx::CoercionError, "could not coerce into a complex") - end + it "coerces from a string" do + expect(described_class.call("2+3i")).to eq(Complex(2, 3)) end - context "with options parameter" do - it "ignores options parameter for valid complex number" do - result = coercion.call("3+4i", { some: "option" }) - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(3, 4)) - end - - it "ignores options parameter for valid numeric value" do - result = coercion.call(42, { some: "option" }) - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(42)) - end - - it "ignores options parameter for invalid value" do - expect { coercion.call("invalid", { some: "option" }) } - .to raise_error(CMDx::CoercionError, "could not coerce into a complex") - end + it "raises CMDx::Error on invalid input" do + expect { described_class.call("not-a-complex") }.to raise_error(CMDx::Error, /complex/) end end end diff --git a/spec/cmdx/coercions/date_spec.rb b/spec/cmdx/coercions/date_spec.rb index 0a395d7a0..77261ecdb 100644 --- a/spec/cmdx/coercions/date_spec.rb +++ b/spec/cmdx/coercions/date_spec.rb @@ -2,230 +2,28 @@ require "spec_helper" -RSpec.describe CMDx::Coercions::Date, type: :unit do - subject(:coercion) { described_class } - +RSpec.describe CMDx::Coercions::Date do describe ".call" do - context "when value is already an analog type" do - it "returns Date unchanged" do - date = Date.new(2023, 12, 25) - result = coercion.call(date) - - expect(result).to eq(date) - end - - it "converts DateTime to Date" do - datetime = DateTime.new(2023, 12, 25, 14, 30, 0) - result = coercion.call(datetime) - - expect(result).to be_a(Date) - expect(result).not_to be_a(DateTime) - expect(result).to eq(Date.new(2023, 12, 25)) - end - - it "converts Time to Date" do - time = Time.new(2023, 12, 25, 14, 30, 0) - result = coercion.call(time) - - expect(result).to be_a(Date) - expect(result).to eq(Date.new(2023, 12, 25)) - end + it "returns a Date unchanged" do + d = Date.new(2024, 6, 1) + expect(described_class.call(d)).to eq(d) end - context "when value is a parseable string" do - it "parses ISO 8601 date string" do - result = coercion.call("2023-12-25") - - expect(result).to be_a(Date) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end - - it "parses YYYY/MM/DD format date string" do - result = coercion.call("2023/12/25") - - expect(result).to be_a(Date) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end - - it "parses DD/MM/YYYY format date string" do - result = coercion.call("25/12/2023") - - expect(result).to be_a(Date) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end - - it "parses DD.MM.YYYY format date string" do - result = coercion.call("25.12.2023") - - expect(result).to be_a(Date) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end - - it "parses textual date format" do - result = coercion.call("Dec 25, 2023") - - expect(result).to be_a(Date) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end - - it "parses date with time component, extracting date part" do - result = coercion.call("2023-12-25 14:30:00") - - expect(result).to be_a(Date) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end + it "parses a string" do + expect(described_class.call("2024-06-15")).to eq(Date.new(2024, 6, 15)) end - context "with strptime option" do - it "parses date using custom format" do - result = coercion.call("25-Dec-2023", strptime: "%d-%b-%Y") - - expect(result).to be_a(Date) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end - - it "parses date using different custom format" do - result = coercion.call("2023/12/25", strptime: "%Y/%m/%d") - - expect(result).to be_a(Date) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end - - it "parses date with year first format" do - result = coercion.call("23-12-25", strptime: "%y-%m-%d") - - expect(result).to be_a(Date) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end - - it "raises CoercionError when string doesn't match strptime format" do - expect { coercion.call("invalid-date", strptime: "%Y-%m-%d") } - .to raise_error(CMDx::CoercionError, "could not coerce into a date") - end + it "converts Time to Date" do + t = Time.utc(2024, 3, 10, 12, 0, 0) + expect(described_class.call(t)).to eq(Date.new(2024, 3, 10)) end - context "when value cannot be coerced" do - it "raises CoercionError for invalid date string" do - expect { coercion.call("not-a-date") } - .to raise_error(CMDx::CoercionError, "could not coerce into a date") - end - - it "raises CoercionError for empty string" do - expect { coercion.call("") } - .to raise_error(CMDx::CoercionError, "could not coerce into a date") - end - - it "raises CoercionError for nil" do - expect { coercion.call(nil) } - .to raise_error(CMDx::CoercionError, "could not coerce into a date") - end - - it "raises CoercionError for integer" do - expect { coercion.call(123) } - .to raise_error(CMDx::CoercionError, "could not coerce into a date") - end - - it "raises CoercionError for float" do - expect { coercion.call(12.3) } - .to raise_error(CMDx::CoercionError, "could not coerce into a date") - end - - it "raises CoercionError for boolean" do - expect { coercion.call(true) } - .to raise_error(CMDx::CoercionError, "could not coerce into a date") - end - - it "raises CoercionError for array" do - expect { coercion.call([2023, 12, 25]) } - .to raise_error(CMDx::CoercionError, "could not coerce into a date") - end - - it "raises CoercionError for hash" do - expect { coercion.call({ year: 2023, month: 12, day: 25 }) } - .to raise_error(CMDx::CoercionError, "could not coerce into a date") - end - - it "raises CoercionError for symbol" do - expect { coercion.call(:date) } - .to raise_error(CMDx::CoercionError, "could not coerce into a date") - end - - it "raises CoercionError for object" do - expect { coercion.call(Object.new) } - .to raise_error(CMDx::CoercionError, "could not coerce into a date") - end - - it "raises CoercionError for invalid date components" do - expect { coercion.call("2023-13-45") } - .to raise_error(CMDx::CoercionError, "could not coerce into a date") - end - - it "raises CoercionError for US format date string" do - expect { coercion.call("12/25/2023") } - .to raise_error(CMDx::CoercionError, "could not coerce into a date") - end - - it "raises CoercionError for malformed date string" do - expect { coercion.call("2023/25/12/extra") } - .to raise_error(CMDx::CoercionError, "could not coerce into a date") - end - - it "raises CoercionError for partially valid date string" do - expect { coercion.call("2023-12") } - .to raise_error(CMDx::CoercionError, "could not coerce into a date") - end + it "converts Integer epoch seconds via Time.at" do + expect(described_class.call(0)).to eq(Time.at(0).to_date) end - context "with edge cases" do - it "handles leap year date" do - result = coercion.call("2024-02-29") - - expect(result).to be_a(Date) - expect(result.year).to eq(2024) - expect(result.month).to eq(2) - expect(result.day).to eq(29) - end - - it "raises CoercionError for invalid leap year date" do - expect { coercion.call("2023-02-29") } - .to raise_error(CMDx::CoercionError, "could not coerce into a date") - end - - it "handles end of year date" do - result = coercion.call("2023-12-31") - - expect(result).to be_a(Date) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(31) - end - - it "handles start of year date" do - result = coercion.call("2023-01-01") - - expect(result).to be_a(Date) - expect(result.year).to eq(2023) - expect(result.month).to eq(1) - expect(result.day).to eq(1) - end + it "raises CMDx::Error on invalid input" do + expect { described_class.call("not-a-date") }.to raise_error(CMDx::Error, /date/) end end end diff --git a/spec/cmdx/coercions/date_time_spec.rb b/spec/cmdx/coercions/date_time_spec.rb index e44c545d4..b2b896403 100644 --- a/spec/cmdx/coercions/date_time_spec.rb +++ b/spec/cmdx/coercions/date_time_spec.rb @@ -2,179 +2,33 @@ require "spec_helper" -RSpec.describe CMDx::Coercions::DateTime, type: :unit do - subject(:coercion) { described_class } - +RSpec.describe CMDx::Coercions::DateTime do describe ".call" do - context "when value is already an analog type" do - it "converts Date to DateTime" do - date = Date.new(2023, 12, 25) - result = coercion.call(date) - - expect(result).to be_a(DateTime) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end - - it "returns DateTime unchanged" do - datetime = DateTime.new(2023, 12, 25, 14, 30, 0) - result = coercion.call(datetime) - - expect(result).to eq(datetime) - end - - it "converts Time to DateTime" do - time = Time.new(2023, 12, 25, 14, 30, 0) - result = coercion.call(time) - - expect(result).to be_a(DateTime) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end + it "returns a DateTime unchanged" do + dt = DateTime.new(2024, 5, 1, 8, 30, 0) + expect(described_class.call(dt)).to eq(dt) end - context "when value is a parseable string" do - it "parses ISO 8601 datetime string" do - result = coercion.call("2023-12-25T14:30:00") - - expect(result).to be_a(DateTime) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - expect(result.hour).to eq(14) - expect(result.minute).to eq(30) - expect(result.second).to eq(0) - end - - it "parses date string" do - result = coercion.call("2023-12-25") - - expect(result).to be_a(DateTime) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end - - it "parses datetime with timezone" do - result = coercion.call("2023-12-25T14:30:00+02:00") - - expect(result).to be_a(DateTime) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - expect(result.hour).to eq(14) - expect(result.minute).to eq(30) - expect(result.offset).to eq(Rational(2, 24)) - end - - it "parses slash format date" do - result = coercion.call("2023/12/25") - - expect(result).to be_a(DateTime) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end - - it "parses natural language date" do - result = coercion.call("December 25, 2023") - - expect(result).to be_a(DateTime) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end + it "parses a string" do + result = described_class.call("2024-05-01T10:15:30+00:00") + expect(result).to be_a(DateTime) + expect(result.year).to eq(2024) + expect(result.month).to eq(5) + expect(result.day).to eq(1) end - context "with strptime option" do - it "parses string using custom format" do - result = coercion.call("25-12-2023 14:30", strptime: "%d-%m-%Y %H:%M") - - expect(result).to be_a(DateTime) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - expect(result.hour).to eq(14) - expect(result.minute).to eq(30) - end - - it "parses date-only string with custom format" do - result = coercion.call("25/12/2023", strptime: "%d/%m/%Y") - - expect(result).to be_a(DateTime) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end - - it "raises error when string doesn't match strptime format" do - expect { coercion.call("2023-12-25", strptime: "%d/%m/%Y") } - .to raise_error(CMDx::CoercionError, "could not coerce into a date time") - end + it "converts Date to DateTime" do + d = Date.new(2024, 4, 20) + expect(described_class.call(d)).to eq(d.to_datetime) end - context "when value is invalid" do - it "raises CoercionError for invalid date string" do - expect { coercion.call("invalid-date") } - .to raise_error(CMDx::CoercionError, "could not coerce into a date time") - end - - it "raises CoercionError for empty string" do - expect { coercion.call("") } - .to raise_error(CMDx::CoercionError, "could not coerce into a date time") - end - - it "raises CoercionError for nil" do - expect { coercion.call(nil) } - .to raise_error(CMDx::CoercionError, "could not coerce into a date time") - end - - it "raises CoercionError for integer" do - expect { coercion.call(123) } - .to raise_error(CMDx::CoercionError, "could not coerce into a date time") - end - - it "raises CoercionError for float" do - expect { coercion.call(12.34) } - .to raise_error(CMDx::CoercionError, "could not coerce into a date time") - end - - it "raises CoercionError for boolean" do - expect { coercion.call(true) } - .to raise_error(CMDx::CoercionError, "could not coerce into a date time") - end - - it "raises CoercionError for array" do - expect { coercion.call([2023, 12, 25]) } - .to raise_error(CMDx::CoercionError, "could not coerce into a date time") - end - - it "raises CoercionError for hash" do - expect { coercion.call({ year: 2023, month: 12, day: 25 }) } - .to raise_error(CMDx::CoercionError, "could not coerce into a date time") - end - - it "raises CoercionError for symbol" do - expect { coercion.call(:datetime) } - .to raise_error(CMDx::CoercionError, "could not coerce into a date time") - end - - it "raises CoercionError for object" do - expect { coercion.call(Object.new) } - .to raise_error(CMDx::CoercionError, "could not coerce into a date time") - end - - it "raises CoercionError for invalid date components" do - expect { coercion.call("2023-13-32") } - .to raise_error(CMDx::CoercionError, "could not coerce into a date time") - end + it "converts Time to DateTime" do + t = Time.utc(2024, 4, 20, 14, 0, 0) + expect(described_class.call(t)).to eq(t.to_datetime) + end - it "raises CoercionError for malformed datetime string" do - expect { coercion.call("2023/12/25T25:61:70") } - .to raise_error(CMDx::CoercionError, "could not coerce into a date time") - end + it "raises CMDx::Error on invalid input" do + expect { described_class.call("bogus") }.to raise_error(CMDx::Error, /date time/) end end end diff --git a/spec/cmdx/coercions/float_spec.rb b/spec/cmdx/coercions/float_spec.rb index 86349be0e..05637b95d 100644 --- a/spec/cmdx/coercions/float_spec.rb +++ b/spec/cmdx/coercions/float_spec.rb @@ -2,226 +2,18 @@ require "spec_helper" -RSpec.describe CMDx::Coercions::Float, type: :unit do - subject(:coercion) { described_class } - +RSpec.describe CMDx::Coercions::Float do describe ".call" do - context "when value is a valid string" do - it "coerces string integer to Float" do - result = coercion.call("123") - - expect(result).to be_a(Float) - expect(result).to eq(123.0) - end - - it "coerces string decimal to Float" do - result = coercion.call("123.456") - - expect(result).to be_a(Float) - expect(result).to eq(123.456) - end - - it "coerces negative string to Float" do - result = coercion.call("-123.456") - - expect(result).to be_a(Float) - expect(result).to eq(-123.456) - end - - it "coerces zero string to Float" do - result = coercion.call("0") - - expect(result).to be_a(Float) - expect(result).to eq(0.0) - end - - it "coerces negative zero string to Float" do - result = coercion.call("-0") - - expect(result).to be_a(Float) - expect(result).to eq(-0.0) - end - - it "coerces string with scientific notation to Float" do - result = coercion.call("1.23e4") - - expect(result).to be_a(Float) - expect(result).to eq(1.23e4) - end - - it "coerces string with negative scientific notation to Float" do - result = coercion.call("-1.23e-4") - - expect(result).to be_a(Float) - expect(result).to eq(-1.23e-4) - end - - it "coerces string with positive exponent notation to Float" do - result = coercion.call("1.5E+2") - - expect(result).to be_a(Float) - expect(result).to eq(150.0) - end - - it "coerces string with leading/trailing whitespace to Float" do - result = coercion.call(" 123.45 ") - - expect(result).to be_a(Float) - expect(result).to eq(123.45) - end + it "coerces from String" do + expect(described_class.call("3.5")).to eq(3.5) end - context "when value is a numeric type" do - it "coerces integer to Float" do - result = coercion.call(42) - - expect(result).to be_a(Float) - expect(result).to eq(42.0) - end - - it "coerces negative integer to Float" do - result = coercion.call(-42) - - expect(result).to be_a(Float) - expect(result).to eq(-42.0) - end - - it "coerces zero integer to Float" do - result = coercion.call(0) - - expect(result).to be_a(Float) - expect(result).to eq(0.0) - end - - it "returns Float unchanged" do - value = 123.456 - result = coercion.call(value) - - expect(result).to be_a(Float) - expect(result).to eq(value) - expect(result).to be(value) - end - - it "coerces BigDecimal to Float" do - value = BigDecimal("123.456") - result = coercion.call(value) - - expect(result).to be_a(Float) - expect(result).to eq(123.456) - end - - it "coerces Rational to Float" do - value = Rational(3, 4) - result = coercion.call(value) - - expect(result).to be_a(Float) - expect(result).to eq(0.75) - end - - it "coerces Complex with zero imaginary part to Float" do - value = Complex(123.456, 0) - result = coercion.call(value) - - expect(result).to be_a(Float) - expect(result).to eq(123.456) - end + it "coerces from Integer" do + expect(described_class.call(10)).to eq(10.0) end - context "when value is an invalid type" do - it "raises CoercionError for non-numeric string" do - expect { coercion.call("not_a_number") } - .to raise_error(CMDx::CoercionError, "could not coerce into a float") - end - - it "raises CoercionError for empty string" do - expect { coercion.call("") } - .to raise_error(CMDx::CoercionError, "could not coerce into a float") - end - - it "raises CoercionError for string with letters mixed with numbers" do - expect { coercion.call("123abc") } - .to raise_error(CMDx::CoercionError, "could not coerce into a float") - end - - it "raises CoercionError for string with multiple decimal points" do - expect { coercion.call("123.45.67") } - .to raise_error(CMDx::CoercionError, "could not coerce into a float") - end - - it "raises CoercionError for nil" do - expect { coercion.call(nil) } - .to raise_error(CMDx::CoercionError, "could not coerce into a float") - end - - it "raises CoercionError for boolean true" do - expect { coercion.call(true) } - .to raise_error(CMDx::CoercionError, "could not coerce into a float") - end - - it "raises CoercionError for boolean false" do - expect { coercion.call(false) } - .to raise_error(CMDx::CoercionError, "could not coerce into a float") - end - - it "raises CoercionError for array" do - expect { coercion.call([1, 2, 3]) } - .to raise_error(CMDx::CoercionError, "could not coerce into a float") - end - - it "raises CoercionError for hash" do - expect { coercion.call({ key: "value" }) } - .to raise_error(CMDx::CoercionError, "could not coerce into a float") - end - - it "raises CoercionError for symbol" do - expect { coercion.call(:symbol) } - .to raise_error(CMDx::CoercionError, "could not coerce into a float") - end - - it "raises CoercionError for Complex with non-zero imaginary part" do - expect { coercion.call(Complex(1, 2)) } - .to raise_error(CMDx::CoercionError, "could not coerce into a float") - end - - it "raises CoercionError for infinity string" do - expect { coercion.call("Infinity") } - .to raise_error(CMDx::CoercionError, "could not coerce into a float") - end - - it "raises CoercionError for negative infinity string" do - expect { coercion.call("-Infinity") } - .to raise_error(CMDx::CoercionError, "could not coerce into a float") - end - - it "raises CoercionError for NaN string" do - expect { coercion.call("NaN") } - .to raise_error(CMDx::CoercionError, "could not coerce into a float") - end - end - - context "when value is out of range" do - it "converts extremely large numbers to Infinity" do - large_number = "1" + ("0" * 400) - result = coercion.call(large_number) - - expect(result).to be_a(Float) - expect(result).to be_infinite - expect(result).to be > 0 - end - end - - context "with options parameter" do - it "ignores options and coerces successfully" do - result = coercion.call("123.45", precision: 2) - - expect(result).to be_a(Float) - expect(result).to eq(123.45) - end - - it "ignores options and raises error for invalid input" do - expect { coercion.call("invalid", precision: 2) } - .to raise_error(CMDx::CoercionError, "could not coerce into a float") - end + it "raises CMDx::Error for invalid strings" do + expect { described_class.call("x.y") }.to raise_error(CMDx::Error, /float/) end end end diff --git a/spec/cmdx/coercions/hash_spec.rb b/spec/cmdx/coercions/hash_spec.rb index 86d232701..78c232c66 100644 --- a/spec/cmdx/coercions/hash_spec.rb +++ b/spec/cmdx/coercions/hash_spec.rb @@ -2,286 +2,32 @@ require "spec_helper" -RSpec.describe CMDx::Coercions::Hash, type: :unit do - subject(:coercion) { described_class } - +RSpec.describe CMDx::Coercions::Hash do describe ".call" do - context "when value is nil" do - it "returns an empty hash" do - hash = {} - - result = coercion.call(nil) - - expect(result).to be_a(Hash) - expect(result).to eq(hash) - end - end - - context "when value is already a Hash" do - it "returns the hash unchanged" do - hash = { key: "value", nested: { inner: "data" } } - - result = coercion.call(hash) - - expect(result).to be_a(Hash) - expect(result).to eq(hash) - expect(result).to be(hash) - end - - it "returns an empty hash unchanged" do - hash = {} - - result = coercion.call(hash) - - expect(result).to be_a(Hash) - expect(result).to eq({}) - expect(result).to be(hash) - end + it "returns a Hash unchanged" do + h = { a: 1 } + expect(described_class.call(h)).to equal(h) end - context "when value is an Array" do - it "converts even-length array to hash" do - array = [:key1, "value1", :key2, "value2"] - - result = coercion.call(array) - - expect(result).to be_a(Hash) - expect(result).to eq(key1: "value1", key2: "value2") - end - - it "converts array with string keys to hash" do - array = %w[name John age 30] - - result = coercion.call(array) - - expect(result).to be_a(Hash) - expect(result).to eq("name" => "John", "age" => "30") - end - - it "converts array with mixed types to hash" do - array = [:symbol, 123, "string", true] - - result = coercion.call(array) - - expect(result).to be_a(Hash) - expect(result).to eq(symbol: 123, "string" => true) - end - - it "converts empty array to empty hash" do - result = coercion.call([]) - - expect(result).to be_a(Hash) - expect(result).to eq({}) - end - - it "raises CoercionError for odd-length array" do - expect { coercion.call([:key1, "value1", :key2]) } - .to raise_error(CMDx::CoercionError, "could not coerce into a hash") - end - end - - context "when value is a JSON string" do - it "parses valid JSON object string" do - json_string = '{"name": "John", "age": 30}' - - result = coercion.call(json_string) - - expect(result).to be_a(Hash) - expect(result).to eq("name" => "John", "age" => 30) - end - - it "parses JSON string with nested objects" do - json_string = '{"user": {"name": "John", "details": {"age": 30}}}' - - result = coercion.call(json_string) - - expect(result).to be_a(Hash) - expect(result).to eq("user" => { "name" => "John", "details" => { "age" => 30 } }) - end - - it "parses JSON string with arrays" do - json_string = '{"tags": ["ruby", "programming"], "count": 2}' - - result = coercion.call(json_string) - - expect(result).to be_a(Hash) - expect(result).to eq("tags" => %w[ruby programming], "count" => 2) - end - - it "parses empty JSON object" do - result = coercion.call("{}") - - expect(result).to be_a(Hash) - expect(result).to eq({}) - end - - it "parses JSON null string as empty hash" do - result = coercion.call("null") - - expect(result).to be_a(Hash) - expect(result).to eq({}) - end - - it "parses JSON null string with whitespace as empty hash" do - result = coercion.call(" null ") - - expect(result).to be_a(Hash) - expect(result).to eq({}) - end - - it "raises CoercionError for invalid JSON" do - expect { coercion.call('{"invalid": json}') } - .to raise_error(CMDx::CoercionError, "could not coerce into a hash") - end - - it "raises CoercionError for JSON array string" do - expect { coercion.call('["not", "a", "hash"]') } - .to raise_error(CMDx::CoercionError, "could not coerce into a hash") - end - - it "raises CoercionError for JSON string primitive" do - expect { coercion.call('"just a string"') } - .to raise_error(CMDx::CoercionError, "could not coerce into a hash") - end - - it "raises CoercionError for unclosed JSON" do - expect { coercion.call('{"unclosed": "object"') } - .to raise_error(CMDx::CoercionError, "could not coerce into a hash") - end + it "converts an Array of pairs" do + expect(described_class.call([[:a, 1], [:b, 2]])).to eq({ a: 1, b: 2 }) end - context "when value is an object that responds to to_h" do - it "converts the object to a hash" do - object = Object.new - def object.to_h - { key: "value" } + it "uses #to_h when available" do + wrapper = Class.new do + def initialize(data) + @data = data end - result = coercion.call(object) - - expect(result).to be_a(Hash) - expect(result).to eq(key: "value") - end - end - - context "when value is invalid" do - it "raises CoercionError for string not starting with '{'" do - expect { coercion.call("not json") } - .to raise_error(CMDx::CoercionError, "could not coerce into a hash") - end - - it "raises CoercionError for integer" do - expect { coercion.call(123) } - .to raise_error(CMDx::CoercionError, "could not coerce into a hash") - end - - it "raises CoercionError for float" do - expect { coercion.call(123.45) } - .to raise_error(CMDx::CoercionError, "could not coerce into a hash") - end - - it "raises CoercionError for boolean true" do - expect { coercion.call(true) } - .to raise_error(CMDx::CoercionError, "could not coerce into a hash") - end - - it "raises CoercionError for boolean false" do - expect { coercion.call(false) } - .to raise_error(CMDx::CoercionError, "could not coerce into a hash") - end - - it "raises CoercionError for symbol" do - expect { coercion.call(:symbol) } - .to raise_error(CMDx::CoercionError, "could not coerce into a hash") - end - - it "raises CoercionError for object" do - expect { coercion.call(Object.new) } - .to raise_error(CMDx::CoercionError, "could not coerce into a hash") - end - - it "raises CoercionError for empty string" do - expect { coercion.call("") } - .to raise_error(CMDx::CoercionError, "could not coerce into a hash") - end - - it "raises CoercionError for string starting with '{' but invalid JSON" do - expect { coercion.call("{not valid json}") } - .to raise_error(CMDx::CoercionError, "could not coerce into a hash") - end - end - - context "with options parameter" do - it "ignores options and converts valid hash" do - hash = { key: "value" } - - result = coercion.call(hash, some: "option") - - expect(result).to be_a(Hash) - expect(result).to eq(hash) - end - - it "ignores options and converts valid array" do - array = [:key, "value"] - - result = coercion.call(array, precision: 2) - - expect(result).to be_a(Hash) - expect(result).to eq(key: "value") - end - - it "ignores options and converts valid JSON string" do - json_string = '{"test": "value"}' - - result = coercion.call(json_string, format: :json) - - expect(result).to be_a(Hash) - expect(result).to eq("test" => "value") - end - - it "ignores options and raises error for invalid input" do - expect { coercion.call("invalid", some: "option") } - .to raise_error(CMDx::CoercionError, "could not coerce into a hash") + def to_h + @data + end end + expect(described_class.call(wrapper.new({ "k" => :v }))).to eq({ "k" => :v }) end - context "with edge cases" do - it "handles array with nil values" do - array = [:key1, nil, :key2, "value"] - - result = coercion.call(array) - - expect(result).to be_a(Hash) - expect(result).to eq(key1: nil, key2: "value") - end - - it "handles JSON string with null values" do - json_string = '{"key1": null, "key2": "value"}' - - result = coercion.call(json_string) - - expect(result).to be_a(Hash) - expect(result).to eq("key1" => nil, "key2" => "value") - end - - it "handles JSON string with special characters" do - json_string = '{"special": "\\n\\t\\r\\"", "unicode": "\\u0041"}' - - result = coercion.call(json_string) - - expect(result).to be_a(Hash) - expect(result).to eq("special" => "\n\t\r\"", "unicode" => "A") - end - - it "handles very large JSON string" do - large_hash = (1..100).to_h { |i| ["key#{i}", "value#{i}"] } - json_string = large_hash.to_json - - result = coercion.call(json_string) - - expect(result).to be_a(Hash) - expect(result).to eq(large_hash) - end + it "raises CMDx::Error when no hash conversion exists" do + expect { described_class.call(123) }.to raise_error(CMDx::Error, /hash/) end end end diff --git a/spec/cmdx/coercions/integer_spec.rb b/spec/cmdx/coercions/integer_spec.rb index f9eac66b1..72e469022 100644 --- a/spec/cmdx/coercions/integer_spec.rb +++ b/spec/cmdx/coercions/integer_spec.rb @@ -2,203 +2,18 @@ require "spec_helper" -RSpec.describe CMDx::Coercions::Integer, type: :unit do - subject(:coercion) { described_class } - +RSpec.describe CMDx::Coercions::Integer do describe ".call" do - context "when value is a valid string" do - it "coerces string integer to Integer" do - result = coercion.call("123") - - expect(result).to be_a(Integer) - expect(result).to eq(123) - end - - it "coerces negative string to Integer" do - result = coercion.call("-456") - - expect(result).to be_a(Integer) - expect(result).to eq(-456) - end - - it "coerces zero string to Integer" do - result = coercion.call("0") - - expect(result).to be_a(Integer) - expect(result).to eq(0) - end - - it "coerces string with leading/trailing whitespace to Integer" do - result = coercion.call(" 789 ") - - expect(result).to be_a(Integer) - expect(result).to eq(789) - end - - it "coerces string with positive sign to Integer" do - result = coercion.call("+42") - - expect(result).to be_a(Integer) - expect(result).to eq(42) - end - - it "coerces octal string to Integer" do - result = coercion.call("0777") - - expect(result).to be_a(Integer) - expect(result).to eq(511) - end - - it "coerces hexadecimal string to Integer" do - result = coercion.call("0xFF") - - expect(result).to be_a(Integer) - expect(result).to eq(255) - end - - it "coerces binary string to Integer" do - result = coercion.call("0b1010") - - expect(result).to be_a(Integer) - expect(result).to eq(10) - end + it "coerces from String" do + expect(described_class.call("42")).to eq(42) end - context "when value is a numeric type" do - it "coerces Integer to Integer" do - result = coercion.call(123) - - expect(result).to be_a(Integer) - expect(result).to eq(123) - end - - it "coerces negative Integer to Integer" do - result = coercion.call(-456) - - expect(result).to be_a(Integer) - expect(result).to eq(-456) - end - - it "coerces Float to Integer" do - result = coercion.call(123.0) - - expect(result).to be_a(Integer) - expect(result).to eq(123) - end - - it "coerces negative Float to Integer" do - result = coercion.call(-456.0) - - expect(result).to be_a(Integer) - expect(result).to eq(-456) - end - - it "coerces Rational to Integer" do - result = coercion.call(Rational(15, 3)) - - expect(result).to be_a(Integer) - expect(result).to eq(5) - end - - it "coerces BigDecimal to Integer" do - result = coercion.call(BigDecimal(123)) - - expect(result).to be_a(Integer) - expect(result).to eq(123) - end - - it "coerces Complex with zero imaginary part to Integer" do - result = coercion.call(Complex(123, 0)) - - expect(result).to be_a(Integer) - expect(result).to eq(123) - end + it "coerces from Float" do + expect(described_class.call(9.9)).to eq(9) end - context "when value has fractional part" do - it "truncates Float with fractional part to Integer" do - result = coercion.call(123.456) - - expect(result).to be_a(Integer) - expect(result).to eq(123) - end - - it "truncates negative Float with fractional part to Integer" do - result = coercion.call(-123.456) - - expect(result).to be_a(Integer) - expect(result).to eq(-123) - end - - it "raises CoercionError for string decimal" do - expect { coercion.call("123.789") }.to raise_error(CMDx::CoercionError) - end - end - - context "when value is invalid" do - it "raises CoercionError for invalid string" do - expect { coercion.call("abc") }.to raise_error(CMDx::CoercionError) - end - - it "raises CoercionError for empty string" do - expect { coercion.call("") }.to raise_error(CMDx::CoercionError) - end - - it "raises CoercionError for nil" do - expect { coercion.call(nil) }.to raise_error(CMDx::CoercionError) - end - - it "raises CoercionError for boolean" do - expect { coercion.call(true) }.to raise_error(CMDx::CoercionError) - end - - it "raises CoercionError for array" do - expect { coercion.call([1, 2, 3]) }.to raise_error(CMDx::CoercionError) - end - - it "raises CoercionError for hash" do - expect { coercion.call({ a: 1 }) }.to raise_error(CMDx::CoercionError) - end - - it "raises CoercionError for object" do - expect { coercion.call(Object.new) }.to raise_error(CMDx::CoercionError) - end - - it "raises CoercionError for Complex with non-zero imaginary part" do - expect { coercion.call(Complex(1, 2)) }.to raise_error(CMDx::CoercionError) - end - - it "raises CoercionError for Infinity" do - expect { coercion.call(Float::INFINITY) }.to raise_error(CMDx::CoercionError) - end - - it "raises CoercionError for NaN" do - expect { coercion.call(Float::NAN) }.to raise_error(CMDx::CoercionError) - end - - it "raises CoercionError for string with invalid characters" do - expect { coercion.call("123abc") }.to raise_error(CMDx::CoercionError) - end - - it "raises CoercionError for string with multiple decimal points" do - expect { coercion.call("12.34.56") }.to raise_error(CMDx::CoercionError) - end - - it "raises CoercionError for value that triggers RangeError" do - # Test using a value that would trigger RangeError in Integer conversion - very_large_number = ("9" * 1000) + ".0" - - expect { coercion.call(very_large_number) }.to raise_error(CMDx::CoercionError) - end - end - - context "with options parameter" do - it "accepts options parameter but ignores it" do - result = coercion.call("123", { unused_option: true }) - - expect(result).to be_a(Integer) - expect(result).to eq(123) - end + it "raises CMDx::Error for invalid strings" do + expect { described_class.call("4.2") }.to raise_error(CMDx::Error, /integer/) end end end diff --git a/spec/cmdx/coercions/rational_spec.rb b/spec/cmdx/coercions/rational_spec.rb index aed4f6439..fd067945e 100644 --- a/spec/cmdx/coercions/rational_spec.rb +++ b/spec/cmdx/coercions/rational_spec.rb @@ -2,255 +2,23 @@ require "spec_helper" -RSpec.describe CMDx::Coercions::Rational, type: :unit do - subject(:coercion) { described_class } - +RSpec.describe CMDx::Coercions::Rational do describe ".call" do - context "when value is a valid string" do - it "coerces string integer to Rational" do - result = coercion.call("123") - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(123)) - end - - it "coerces string fraction to Rational" do - result = coercion.call("3/4") - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(3, 4)) - end - - it "coerces string decimal to Rational" do - result = coercion.call("0.5") - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(1, 2)) - end - - it "coerces negative string to Rational" do - result = coercion.call("-456") - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(-456)) - end - - it "coerces negative fraction string to Rational" do - result = coercion.call("-3/4") - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(-3, 4)) - end - - it "coerces zero string to Rational" do - result = coercion.call("0") - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(0)) - end - - it "coerces string with positive sign to Rational" do - result = coercion.call("+42") - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(42)) - end - - it "coerces string with leading/trailing whitespace to Rational" do - result = coercion.call(" 3/5 ") - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(3, 5)) - end - - it "coerces improper fraction string to Rational" do - result = coercion.call("7/3") - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(7, 3)) - end - - it "coerces decimal with many digits to Rational" do - result = coercion.call("0.125") - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(1, 8)) - end - - it "coerces scientific notation string to Rational" do - result = coercion.call("1e3") - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(1000)) - end + it "returns a Rational unchanged" do + r = Rational(2, 3) + expect(described_class.call(r)).to eq(r) end - context "when value is a valid number" do - it "coerces integer to Rational" do - result = coercion.call(42) - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(42)) - end - - it "coerces negative integer to Rational" do - result = coercion.call(-42) - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(-42)) - end - - it "coerces zero to Rational" do - result = coercion.call(0) - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(0)) - end - - it "coerces float to Rational" do - result = coercion.call(0.75) - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(3, 4)) - end - - it "coerces negative float to Rational" do - result = coercion.call(-0.25) - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(-1, 4)) - end - - it "coerces BigDecimal to Rational" do - result = coercion.call(BigDecimal("0.333")) - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(333, 1000)) - end - - it "coerces existing Rational to Rational" do - input = Rational(5, 8) - result = coercion.call(input) - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(5, 8)) - expect(result).to be(input) - end - - it "coerces Complex with zero imaginary part to Rational" do - result = coercion.call(Complex(3, 0)) - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(3)) - end + it "coerces from String" do + expect(described_class.call("3/4")).to eq(Rational(3, 4)) end - context "when value is invalid" do - it "raises CoercionError for non-numeric string" do - expect { coercion.call("not_a_number") } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end - - it "raises CoercionError for empty string" do - expect { coercion.call("") } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end - - it "raises CoercionError for string with letters mixed with numbers" do - expect { coercion.call("123abc") } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end - - it "raises CoercionError for string with invalid fraction" do - expect { coercion.call("3//4") } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end - - it "raises CoercionError for nil" do - expect { coercion.call(nil) } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end - - it "raises CoercionError for boolean true" do - expect { coercion.call(true) } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end - - it "raises CoercionError for boolean false" do - expect { coercion.call(false) } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end - - it "raises CoercionError for array" do - expect { coercion.call([1, 2, 3]) } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end - - it "raises CoercionError for hash" do - expect { coercion.call({ key: "value" }) } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end - - it "raises CoercionError for symbol" do - expect { coercion.call(:symbol) } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end - - it "raises CoercionError for object" do - expect { coercion.call(Object.new) } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end - - it "raises CoercionError for Complex with non-zero imaginary part" do - expect { coercion.call(Complex(1, 2)) } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end - - it "raises CoercionError for Infinity" do - expect { coercion.call(Float::INFINITY) } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end - - it "raises CoercionError for NaN" do - expect { coercion.call(Float::NAN) } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end - - it "raises CoercionError for division by zero string" do - expect { coercion.call("1/0") } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end - - it "raises CoercionError for string with multiple decimal points" do - expect { coercion.call("1.2.3") } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end - - it "raises CoercionError for string with multiple slashes" do - expect { coercion.call("1/2/3") } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end - - it "raises CoercionError for string with text after number" do - expect { coercion.call("42units") } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end + it "coerces from a number" do + expect(described_class.call(0.5)).to eq(Rational(1, 2)) end - context "with options parameter" do - it "accepts options parameter but ignores it" do - result = coercion.call("3/4", { unused_option: true }) - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(3, 4)) - end - - it "accepts empty options hash" do - result = coercion.call("1/2", {}) - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(1, 2)) - end + it "raises CMDx::Error on invalid input" do + expect { described_class.call("not-rational") }.to raise_error(CMDx::Error, /rational/) end end end diff --git a/spec/cmdx/coercions/string_spec.rb b/spec/cmdx/coercions/string_spec.rb index 9a3e2ee02..17d05ccc1 100644 --- a/spec/cmdx/coercions/string_spec.rb +++ b/spec/cmdx/coercions/string_spec.rb @@ -2,274 +2,14 @@ require "spec_helper" -RSpec.describe CMDx::Coercions::String, type: :unit do - subject(:coercion) { described_class } - +RSpec.describe CMDx::Coercions::String do describe ".call" do - context "when value is already a String" do - it "returns the string unchanged" do - string = "hello world" - - result = coercion.call(string) - - expect(result).to be_a(String) - expect(result).to eq("hello world") - expect(result).to be(string) - end - - it "returns an empty string unchanged" do - string = "" - - result = coercion.call(string) - - expect(result).to be_a(String) - expect(result).to eq("") - expect(result).to be(string) - end - - it "returns string with special characters unchanged" do - string = "hello\nworld\t!" - - result = coercion.call(string) - - expect(result).to be_a(String) - expect(result).to eq("hello\nworld\t!") - expect(result).to be(string) - end - - it "returns unicode string unchanged" do - string = "héllo wörld 🌍" - - result = coercion.call(string) - - expect(result).to be_a(String) - expect(result).to eq("héllo wörld 🌍") - expect(result).to be(string) - end - end - - context "when value is a Symbol" do - it "converts symbol to string" do - result = coercion.call(:hello) - - expect(result).to be_a(String) - expect(result).to eq("hello") - end - - it "converts symbol with underscores to string" do - result = coercion.call(:hello_world) - - expect(result).to be_a(String) - expect(result).to eq("hello_world") - end - - it "converts empty symbol to string" do - result = coercion.call(:"") - - expect(result).to be_a(String) - expect(result).to eq("") - end - end - - context "when value is a numeric type" do - it "converts integer to string" do - result = coercion.call(123) - - expect(result).to be_a(String) - expect(result).to eq("123") - end - - it "converts negative integer to string" do - result = coercion.call(-456) - - expect(result).to be_a(String) - expect(result).to eq("-456") - end - - it "converts zero to string" do - result = coercion.call(0) - - expect(result).to be_a(String) - expect(result).to eq("0") - end - - it "converts float to string" do - result = coercion.call(123.456) - - expect(result).to be_a(String) - expect(result).to eq("123.456") - end - - it "converts Rational to string" do - result = coercion.call(Rational(3, 4)) - - expect(result).to be_a(String) - expect(result).to eq("3/4") - end - - it "converts Complex to string" do - result = coercion.call(Complex(3, 4)) - - expect(result).to be_a(String) - expect(result).to eq("3+4i") - end - - it "converts BigDecimal to string" do - result = coercion.call(BigDecimal("123.456")) - - expect(result).to be_a(String) - expect(result).to eq("0.123456e3") - end - end - - context "when value is a boolean" do - it "converts true to string" do - result = coercion.call(true) - - expect(result).to be_a(String) - expect(result).to eq("true") - end - - it "converts false to string" do - result = coercion.call(false) - - expect(result).to be_a(String) - expect(result).to eq("false") - end + it "coerces from Integer" do + expect(described_class.call(100)).to eq("100") end - context "when value is nil" do - it "converts nil to empty string" do - result = coercion.call(nil) - - expect(result).to be_a(String) - expect(result).to eq("") - end - end - - context "when value is a collection" do - it "converts array to string" do - result = coercion.call([1, 2, 3]) - - expect(result).to be_a(String) - expect(result).to eq("[1, 2, 3]") - end - - it "converts empty array to string" do - result = coercion.call([]) - - expect(result).to be_a(String) - expect(result).to eq("[]") - end - - it "converts hash to string" do - result = coercion.call({ a: 1, b: 2 }) - - expect(result).to be_a(String) - - if RubyVersion.min?(3.4) - expect(result).to eq("{a: 1, b: 2}") - else - expect(result).to eq("{:a=>1, :b=>2}") - end - end - - it "converts empty hash to string" do - result = coercion.call({}) - - expect(result).to be_a(String) - expect(result).to eq("{}") - end - - it "converts set to string" do - result = coercion.call(Set.new([1, 2, 3])) - - expect(result).to be_a(String) - - if RubyVersion.min?(4.0) - expect(result).to eq("Set[1, 2, 3]") - else - expect(result).to eq("#") - end - end - end - - context "when value is a special object" do - it "converts class to string" do - result = coercion.call(String) - - expect(result).to be_a(String) - expect(result).to eq("String") - end - - it "converts module to string" do - result = coercion.call(Enumerable) - - expect(result).to be_a(String) - expect(result).to eq("Enumerable") - end - - it "converts regex to string" do - result = coercion.call(/hello/) - - expect(result).to be_a(String) - expect(result).to eq("(?-mix:hello)") - end - - it "converts range to string" do - result = coercion.call(1..5) - - expect(result).to be_a(String) - expect(result).to eq("1..5") - end - - it "converts time to string" do - time = Time.new(2023, 12, 25, 10, 30, 45) - - result = coercion.call(time) - - expect(result).to be_a(String) - expect(result).to include("2023-12-25") - end - - it "converts date to string" do - date = Date.new(2023, 12, 25) - - result = coercion.call(date) - - expect(result).to be_a(String) - expect(result).to eq("2023-12-25") - end - end - - context "when value has custom to_s method" do - it "converts object with custom to_s to string" do - custom_object = Object.new - def custom_object.to_s - "custom object" - end - - result = coercion.call(custom_object) - - expect(result).to be_a(String) - expect(result).to eq("custom object") - end - end - - context "when options are provided" do - it "ignores options parameter" do - result = coercion.call(123, { format: :uppercase }) - - expect(result).to be_a(String) - expect(result).to eq("123") - end - - it "works with empty options hash" do - result = coercion.call("hello", {}) - - expect(result).to be_a(String) - expect(result).to eq("hello") - end + it "coerces from Symbol" do + expect(described_class.call(:foo)).to eq("foo") end end end diff --git a/spec/cmdx/coercions/symbol_spec.rb b/spec/cmdx/coercions/symbol_spec.rb index a82483913..bf4addbd7 100644 --- a/spec/cmdx/coercions/symbol_spec.rb +++ b/spec/cmdx/coercions/symbol_spec.rb @@ -2,228 +2,10 @@ require "spec_helper" -RSpec.describe CMDx::Coercions::Symbol, type: :unit do - subject(:coercion) { described_class } - +RSpec.describe CMDx::Coercions::Symbol do describe ".call" do - context "when value is already a Symbol" do - it "returns the symbol unchanged" do - symbol = :hello - - result = coercion.call(symbol) - - expect(result).to be_a(Symbol) - expect(result).to eq(:hello) - expect(result).to be(symbol) - end - - it "returns empty symbol unchanged" do - symbol = :"" - - result = coercion.call(symbol) - - expect(result).to be_a(Symbol) - expect(result).to eq(:"") - expect(result).to be(symbol) - end - - it "returns symbol with special characters unchanged" do - symbol = :hello_world! - - result = coercion.call(symbol) - - expect(result).to be_a(Symbol) - expect(result).to eq(:hello_world!) - expect(result).to be(symbol) - end - - it "returns symbol with unicode characters unchanged" do - symbol = :héllo_wörld_🌍 - - result = coercion.call(symbol) - - expect(result).to be_a(Symbol) - expect(result).to eq(:héllo_wörld_🌍) - expect(result).to be(symbol) - end - end - - context "when value is a String" do - it "converts string to symbol" do - result = coercion.call("hello") - - expect(result).to be_a(Symbol) - expect(result).to eq(:hello) - end - - it "converts empty string to empty symbol" do - result = coercion.call("") - - expect(result).to be_a(Symbol) - expect(result).to eq(:"") - end - - it "converts string with special characters to symbol" do - result = coercion.call("hello_world!") - - expect(result).to be_a(Symbol) - expect(result).to eq(:hello_world!) - end - - it "converts string with spaces to symbol" do - result = coercion.call("hello world") - - expect(result).to be_a(Symbol) - expect(result).to eq(:"hello world") - end - - it "converts string with unicode characters to symbol" do - result = coercion.call("héllo wörld 🌍") - - expect(result).to be_a(Symbol) - expect(result).to eq(:"héllo wörld 🌍") - end - - it "converts string with newlines and tabs to symbol" do - result = coercion.call("hello\nworld\t!") - - expect(result).to be_a(Symbol) - expect(result).to eq(:"hello\nworld\t!") - end - - it "converts numeric string to symbol" do - result = coercion.call("123") - - expect(result).to be_a(Symbol) - expect(result).to eq(:"123") - end - end - - context "when value has custom to_sym method" do - it "uses the custom to_sym method" do - custom_object = Object.new - def custom_object.to_sym - :custom_symbol - end - - result = coercion.call(custom_object) - - expect(result).to be_a(Symbol) - expect(result).to eq(:custom_symbol) - end - end - - context "when value is invalid" do - it "raises CoercionError for nil" do - expect { coercion.call(nil) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for integer" do - expect { coercion.call(123) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for float" do - expect { coercion.call(3.14) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for rational" do - expect { coercion.call(Rational(3, 4)) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for complex" do - expect { coercion.call(Complex(1, 2)) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for BigDecimal" do - expect { coercion.call(BigDecimal("123.456")) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for boolean true" do - expect { coercion.call(true) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for boolean false" do - expect { coercion.call(false) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for array" do - expect { coercion.call([1, 2, 3]) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for hash" do - expect { coercion.call({ key: "value" }) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for object without to_sym method" do - expect { coercion.call(Object.new) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for class" do - expect { coercion.call(String) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for module" do - expect { coercion.call(Enumerable) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for proc" do - expect { coercion.call(proc { "hello" }) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for lambda" do - expect { coercion.call(-> { "hello" }) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for method object" do - expect { coercion.call("test".method(:upcase)) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for regex" do - expect { coercion.call(/pattern/) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for range" do - expect { coercion.call(1..10) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for set" do - expect { coercion.call(Set.new([1, 2, 3])) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - end - - context "with options parameter" do - it "ignores options and converts value normally" do - result = coercion.call("hello", { some: "option" }) - - expect(result).to be_a(Symbol) - expect(result).to eq(:hello) - end - - it "works with empty options hash" do - result = coercion.call("world", {}) - - expect(result).to be_a(Symbol) - expect(result).to eq(:world) - end + it "coerces from String" do + expect(described_class.call("bar")).to eq(:bar) end end end diff --git a/spec/cmdx/coercions/time_spec.rb b/spec/cmdx/coercions/time_spec.rb index c444d012a..3baf2232a 100644 --- a/spec/cmdx/coercions/time_spec.rb +++ b/spec/cmdx/coercions/time_spec.rb @@ -2,225 +2,32 @@ require "spec_helper" -RSpec.describe CMDx::Coercions::Time, type: :unit do - subject(:coercion) { described_class } - +RSpec.describe CMDx::Coercions::Time do describe ".call" do - context "when value is already an analog type" do - it "converts DateTime to Time" do - datetime = DateTime.new(2023, 12, 25, 14, 30, 0) - - result = coercion.call(datetime) - - expect(result).to be_a(Time) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end - - it "returns Time unchanged" do - time = Time.new(2023, 12, 25, 14, 30, 0) - - result = coercion.call(time) - - expect(result).to eq(time) - end + it "returns Time unchanged" do + t = Time.utc(2024, 2, 1, 0, 0, 0) + expect(described_class.call(t)).to eq(t) end - context "when value responds to to_time" do - it "calls to_time on the value" do - date = Date.new(2023, 12, 25) - - result = coercion.call(date) - - expect(result).to be_a(Time) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end - - it "returns the to_time result" do - time_result = Time.new(2023, 1, 1) - value = instance_double("MockTime", to_time: time_result) - - result = coercion.call(value) - - expect(result).to eq(time_result) - end + it "parses a string" do + result = described_class.call("2024-02-01T00:00:00Z") + expect(result).to be_a(Time) + expect(result.utc.year).to eq(2024) + expect(result.utc.month).to eq(2) + expect(result.utc.day).to eq(1) end - context "with strptime option" do - it "parses string using custom format" do - result = coercion.call("25-12-2023 14:30", strptime: "%d-%m-%Y %H:%M") - - expect(result).to be_a(Time) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - expect(result.hour).to eq(14) - expect(result.min).to eq(30) - end - - it "parses date string with custom format" do - result = coercion.call("2023/12/25", strptime: "%Y/%m/%d") - - expect(result).to be_a(Time) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end - - it "raises CoercionError when string doesn't match strptime format" do - expect { coercion.call("2023-12-25", strptime: "%d/%m/%Y") } - .to raise_error(CMDx::CoercionError, "could not coerce into a time") - end - - it "raises CoercionError for invalid date with strptime" do - expect { coercion.call("32/13/2023", strptime: "%d/%m/%Y") } - .to raise_error(CMDx::CoercionError, "could not coerce into a time") - end - end - - context "when value is a parseable string" do - it "parses ISO 8601 time string" do - result = coercion.call("2023-12-25T14:30:00") - - expect(result).to be_a(Time) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - expect(result.hour).to eq(14) - expect(result.min).to eq(30) - expect(result.sec).to eq(0) - end - - it "parses date string" do - result = coercion.call("2023-12-25") - - expect(result).to be_a(Time) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end - - it "parses time string" do - result = coercion.call("14:30:00") - - expect(result).to be_a(Time) - expect(result.hour).to eq(14) - expect(result.min).to eq(30) - expect(result.sec).to eq(0) - end - - it "parses RFC 2822 formatted string" do - result = coercion.call("Mon, 25 Dec 2023 14:30:00 +0000") - - expect(result).to be_a(Time) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - expect(result.hour).to eq(14) - expect(result.min).to eq(30) - end - - it "parses human-readable date string" do - result = coercion.call("December 25, 2023") - - expect(result).to be_a(Time) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end + it "converts Date to Time" do + d = Date.new(2024, 2, 1) + expect(described_class.call(d)).to eq(d.to_time) end - context "when value cannot be coerced" do - it "raises CoercionError for invalid string" do - expect { coercion.call("invalid-time") } - .to raise_error(CMDx::CoercionError, "could not coerce into a time") - end - - it "raises CoercionError for empty string" do - expect { coercion.call("") } - .to raise_error(CMDx::CoercionError, "could not coerce into a time") - end - - it "raises CoercionError for nil" do - expect { coercion.call(nil) } - .to raise_error(CMDx::CoercionError, "could not coerce into a time") - end - - it "raises CoercionError for integer" do - expect { coercion.call(123) } - .to raise_error(CMDx::CoercionError, "could not coerce into a time") - end - - it "raises CoercionError for float" do - expect { coercion.call(123.45) } - .to raise_error(CMDx::CoercionError, "could not coerce into a time") - end - - it "raises CoercionError for boolean true" do - expect { coercion.call(true) } - .to raise_error(CMDx::CoercionError, "could not coerce into a time") - end - - it "raises CoercionError for boolean false" do - expect { coercion.call(false) } - .to raise_error(CMDx::CoercionError, "could not coerce into a time") - end - - it "raises CoercionError for array" do - expect { coercion.call([1, 2, 3]) } - .to raise_error(CMDx::CoercionError, "could not coerce into a time") - end - - it "raises CoercionError for hash" do - expect { coercion.call({ year: 2023 }) } - .to raise_error(CMDx::CoercionError, "could not coerce into a time") - end - - it "raises CoercionError for symbol" do - expect { coercion.call(:time) } - .to raise_error(CMDx::CoercionError, "could not coerce into a time") - end - - it "raises CoercionError for object" do - expect { coercion.call(Object.new) } - .to raise_error(CMDx::CoercionError, "could not coerce into a time") - end - - it "raises CoercionError when Time.parse raises ArgumentError" do - allow(Time).to receive(:parse).and_raise(ArgumentError) - - expect { coercion.call("some-string") } - .to raise_error(CMDx::CoercionError, "could not coerce into a time") - end - - it "raises CoercionError when Time.parse raises TypeError" do - allow(Time).to receive(:parse).and_raise(TypeError) - - expect { coercion.call("some-string") } - .to raise_error(CMDx::CoercionError, "could not coerce into a time") - end + it "converts Integer epoch seconds via Time.at" do + expect(described_class.call(0)).to eq(Time.at(0)) end - context "without options" do - it "calls Time.parse when no options provided" do - time_string = "2023-12-25T14:30:00" - parsed_time = Time.new(2023, 12, 25, 14, 30, 0) - - expect(Time).to receive(:parse).with(time_string).and_return(parsed_time) - - result = coercion.call(time_string) - - expect(result).to eq(parsed_time) - end - - it "does not call Time.strptime when no strptime option" do - expect(Time).not_to receive(:strptime) - - coercion.call("2023-12-25") - end + it "raises CMDx::Error on invalid input" do + expect { described_class.call("not-a-time") }.to raise_error(CMDx::Error, /time/) end end end diff --git a/spec/cmdx/configuration_spec.rb b/spec/cmdx/configuration_spec.rb index 829f60d5f..f1fc92a65 100644 --- a/spec/cmdx/configuration_spec.rb +++ b/spec/cmdx/configuration_spec.rb @@ -2,277 +2,73 @@ require "spec_helper" -RSpec.describe CMDx::Configuration, type: :unit do - subject(:configuration) { described_class.new } +RSpec.describe CMDx::Configuration do + describe "defaults" do + before { CMDx.reset_configuration! } - describe "#initialize" do - it "initializes middlewares registry" do - expect(configuration.middlewares).to be_a(CMDx::MiddlewareRegistry) - expect(configuration.middlewares.registry).to be_empty + it "uses a Logger writing to $stdout" do + log = CMDx.configuration.logger + expect(log).to be_a(Logger) + expect(log.instance_variable_get(:@logdev).dev).to eq($stdout) end - it "initializes callbacks registry" do - expect(configuration.callbacks).to be_a(CMDx::CallbackRegistry) - expect(configuration.callbacks.registry).to be_empty - end - - it "initializes coercions registry with default coercions" do - expect(configuration.coercions).to be_a(CMDx::CoercionRegistry) - expect(configuration.coercions.registry).to include( - array: CMDx::Coercions::Array, - boolean: CMDx::Coercions::Boolean, - string: CMDx::Coercions::String, - integer: CMDx::Coercions::Integer, - float: CMDx::Coercions::Float, - hash: CMDx::Coercions::Hash, - big_decimal: CMDx::Coercions::BigDecimal, - complex: CMDx::Coercions::Complex, - date: CMDx::Coercions::Date, - datetime: CMDx::Coercions::DateTime, - rational: CMDx::Coercions::Rational, - time: CMDx::Coercions::Time - ) - end - - it "initializes validators registry with default validators" do - expect(configuration.validators).to be_a(CMDx::ValidatorRegistry) - expect(configuration.validators.registry).to include( - presence: CMDx::Validators::Presence, - format: CMDx::Validators::Format, - inclusion: CMDx::Validators::Inclusion, - exclusion: CMDx::Validators::Exclusion, - length: CMDx::Validators::Length, - numeric: CMDx::Validators::Numeric - ) - end - - it "sets breakpoints to default values" do - expect(configuration.task_breakpoints).to eq(%w[failed]) - expect(configuration.workflow_breakpoints).to eq(%w[failed]) - end - - it "sets dump_context to false by default" do - expect(configuration.dump_context).to be(false) - end - - it "sets locale to en by default" do - expect(configuration.default_locale).to eq("en") - end - - it "initializes logger with default configuration" do - logger = configuration.logger - - expect(logger).to be_a(Logger) - expect(logger.progname).to eq("cmdx") - expect(logger.formatter).to be_a(CMDx::LogFormatters::Line) - expect(logger.level).to eq(Logger::INFO) + it "defaults log_level, log_formatter to nil and strict_attributes to true" do + c = CMDx.configuration + expect(c.log_level).to be_nil + expect(c.log_formatter).to be_nil + expect(c.strict_attributes).to be(true) end end - describe "attribute accessors" do - describe "#middlewares" do - let(:custom_registry) { CMDx::MiddlewareRegistry.new } - - it "allows setting and getting middlewares" do - configuration.middlewares = custom_registry - - expect(configuration.middlewares).to eq(custom_registry) - end - - context "with nil value" do - it "accepts nil assignment" do - expect { configuration.middlewares = nil }.not_to raise_error - expect(configuration.middlewares).to be_nil - end - end - end - - describe "#callbacks" do - let(:custom_registry) { CMDx::CallbackRegistry.new } - - it "allows setting and getting callbacks" do - configuration.callbacks = custom_registry - - expect(configuration.callbacks).to eq(custom_registry) - end - - context "with nil value" do - it "accepts nil assignment" do - expect { configuration.callbacks = nil }.not_to raise_error - expect(configuration.callbacks).to be_nil - end - end - end - - describe "#coercions" do - let(:custom_registry) { CMDx::CoercionRegistry.new } - - it "allows setting and getting coercions" do - configuration.coercions = custom_registry - - expect(configuration.coercions).to eq(custom_registry) - end - - context "with nil value" do - it "accepts nil assignment" do - expect { configuration.coercions = nil }.not_to raise_error - expect(configuration.coercions).to be_nil - end - end - end - - describe "#validators" do - let(:custom_registry) { CMDx::ValidatorRegistry.new } - - it "allows setting and getting validators" do - configuration.validators = custom_registry - - expect(configuration.validators).to eq(custom_registry) - end - - context "with nil value" do - it "accepts nil assignment" do - expect { configuration.validators = nil }.not_to raise_error - expect(configuration.validators).to be_nil - end - end - end - - describe "#task_breakpoints" do - let(:custom_breakpoints) { %w[failed error timeout] } + describe "mutators" do + let(:custom_logger) { Logger.new(File::NULL) } + let(:formatter) { proc { |_, _, _, msg| "#{msg}\n" } } - it "allows setting and getting task_breakpoints" do - configuration.task_breakpoints = custom_breakpoints + it "assigns logger, log_level, log_formatter, and strict_attributes" do + CMDx.configuration.logger = custom_logger + CMDx.configuration.log_level = :warn + CMDx.configuration.log_formatter = formatter + CMDx.configuration.strict_attributes = false - expect(configuration.task_breakpoints).to eq(custom_breakpoints) - end - - context "with empty array" do - it "accepts empty array assignment" do - configuration.task_breakpoints = [] - - expect(configuration.task_breakpoints).to eq([]) - end - end - - context "with nil value" do - it "accepts nil assignment" do - configuration.task_breakpoints = nil - - expect(configuration.task_breakpoints).to be_nil - end - end + c = CMDx.configuration + expect(c.logger).to be(custom_logger) + expect(c.log_level).to eq(:warn) + expect(c.log_formatter).to be(formatter) + expect(c.strict_attributes).to be(false) end + end - describe "#workflow_breakpoints" do - let(:custom_breakpoints) { %w[failed timeout interrupted] } - - it "allows setting and getting workflow_breakpoints" do - configuration.workflow_breakpoints = custom_breakpoints - - expect(configuration.workflow_breakpoints).to eq(custom_breakpoints) - end - - context "with empty array" do - it "accepts empty array assignment" do - configuration.workflow_breakpoints = [] - - expect(configuration.workflow_breakpoints).to eq([]) - end - end - - context "with nil value" do - it "accepts nil assignment" do - configuration.workflow_breakpoints = nil - - expect(configuration.workflow_breakpoints).to be_nil - end - end - end - - describe "#dump_context" do - it "allows setting and getting dump_context" do - configuration.dump_context = true - - expect(configuration.dump_context).to be(true) - end - end - - describe "#default_locale" do - it "allows setting and getting locale" do - configuration.default_locale = "es" - - expect(configuration.default_locale).to eq("es") - end - - context "with nil value" do - it "accepts nil assignment" do - expect { configuration.default_locale = nil }.not_to raise_error - expect(configuration.default_locale).to be_nil - end - end - end - - describe "#logger" do - let(:custom_logger) { Logger.new($stderr, progname: "test") } - - it "allows setting and getting logger" do - configuration.logger = custom_logger - - expect(configuration.logger).to eq(custom_logger) + describe ".configure" do + it "yields the global configuration and returns it" do + yielded = nil + ret = CMDx.configure do |c| + yielded = c + c.log_level = :error end - context "with nil value" do - it "accepts nil assignment" do - expect { configuration.logger = nil }.not_to raise_error - expect(configuration.logger).to be_nil - end - end + expect(yielded).to be(CMDx.configuration) + expect(ret).to be(CMDx.configuration) + expect(CMDx.configuration.log_level).to eq(:error) end end - describe "#to_h" do - let(:result) { configuration.to_h } - - it "returns hash with all configuration attributes" do - expect(result).to include( - middlewares: configuration.middlewares, - callbacks: configuration.callbacks, - coercions: configuration.coercions, - validators: configuration.validators, - task_breakpoints: configuration.task_breakpoints, - workflow_breakpoints: configuration.workflow_breakpoints, - dump_context: configuration.dump_context, - default_locale: configuration.default_locale, - logger: configuration.logger - ) - end - - context "with modified attributes" do - let(:custom_breakpoints) { %w[custom_failed custom_error] } - let(:custom_logger) { Logger.new($stderr, progname: "test") } - - before do - configuration.task_breakpoints = custom_breakpoints - configuration.logger = custom_logger + describe "CMDx.reset_configuration!" do + it "restores defaults" do + CMDx.configure do |c| + c.logger = Logger.new(File::NULL) + c.log_level = :fatal + c.log_formatter = proc { |_| } + c.strict_attributes = false end - it "reflects the current attribute values" do - expect(result[:task_breakpoints]).to eq(custom_breakpoints) - expect(result[:logger]).to eq(custom_logger) - end - end + CMDx.reset_configuration! - context "with nil attributes" do - before do - configuration.middlewares = nil - configuration.logger = nil - end - - it "includes nil values in the hash" do - expect(result[:middlewares]).to be_nil - expect(result[:logger]).to be_nil - end + c = CMDx.configuration + expect(c.logger).to be_a(Logger) + expect(c.logger.instance_variable_get(:@logdev).dev).to eq($stdout) + expect(c.log_level).to be_nil + expect(c.log_formatter).to be_nil + expect(c.strict_attributes).to be(true) end end end diff --git a/spec/cmdx/context_spec.rb b/spec/cmdx/context_spec.rb index 02a5a8f74..7973abc44 100644 --- a/spec/cmdx/context_spec.rb +++ b/spec/cmdx/context_spec.rb @@ -2,464 +2,136 @@ require "spec_helper" -RSpec.describe CMDx::Context, type: :unit do - subject(:context) { described_class.new(initial_data) } - - let(:initial_data) { { name: "John", age: 30 } } - - describe "#initialize" do - context "when given a hash" do - let(:initial_data) { { name: "Alice", age: 25 } } - - it "converts keys to symbols and stores the data" do - expect(context.table).to eq(name: "Alice", age: 25) - end - end - - context "when given an object that responds to to_hash" do - let(:initial_data) do - object = Object.new - def object.to_hash - { "city" => "NYC", "country" => "USA" } - end - object - end - - it "converts to hash and symbolizes keys" do - expect(context.table).to eq(city: "NYC", country: "USA") - end - end - - context "when given an object that responds to to_h" do - let(:initial_data) do - object = Object.new - def object.to_h - { "score" => 100, "level" => 5 } - end - object - end - - it "converts to hash and symbolizes keys" do - expect(context.table).to eq(score: 100, level: 5) - end - end - - context "when given an object that responds to neither to_h nor to_hash" do - let(:initial_data) { "invalid" } - - it "raises ArgumentError" do - expect { context }.to raise_error(ArgumentError, "must respond to `to_h` or `to_hash`") - end +RSpec.describe CMDx::Context do + describe ".new" do + it "builds from a hash and symbolizes keys" do + ctx = described_class.new("foo" => 1, bar: 2) + expect(ctx[:foo]).to eq(1) + expect(ctx["foo"]).to eq(1) + expect(ctx.to_h.keys).to all(be_a(Symbol)) end - context "when given string keys" do - let(:initial_data) { { "name" => "Bob", "active" => true } } - - it "converts string keys to symbols" do - expect(context.table).to eq(name: "Bob", active: true) - end - end - - context "when given no arguments" do - subject(:context) { described_class.new } - - it "creates empty context" do - expect(context.table).to eq({}) + it "accepts objects responding to to_hash" do + obj = Object.new + def obj.to_hash + { "x" => 3 } end + expect(described_class.new(obj)[:x]).to eq(3) end end describe ".build" do - context "when given a Context instance that is not frozen" do - let(:existing_context) { described_class.new(name: "test") } - - it "returns the same instance" do - result = described_class.build(existing_context) - - expect(result).to be(existing_context) - end + it "reuses an unfrozen Context instance" do + ctx = described_class.new(a: 1) + expect(described_class.build(ctx)).to equal(ctx) end - context "when given a frozen Context instance" do - let(:frozen_context) { described_class.new(name: "test").freeze } - - it "creates a new Context instance" do - result = described_class.build(frozen_context) - - expect(result).not_to be(frozen_context) - expect(result.table).to eq(name: "test") - end - end - - context "when given an object that responds to context" do - let(:object_with_context) do - object = Object.new - def object.context - { user_id: 123 } - end - object - end - - it "recursively builds from the context method result" do - result = described_class.build(object_with_context) - - expect(result).to be_a(described_class) - expect(result.table).to eq(user_id: 123) - end - end - - context "when given a hash" do - let(:hash_data) { { role: "admin", permissions: %w[read write] } } - - it "creates new Context instance" do - result = described_class.build(hash_data) - - expect(result).to be_a(described_class) - expect(result.table).to eq(role: "admin", permissions: %w[read write]) - end - end - - context "when given nil" do - it "creates empty Context instance" do - result = described_class.build(nil) - - expect(result).to be_a(described_class) - expect(result.table).to eq({}) - end + it "wraps a plain hash in a new Context" do + ctx = described_class.build("k" => "v") + expect(ctx).to be_a(described_class) + expect(ctx[:k]).to eq("v") end end - describe "#[]" do - it "retrieves value by symbol key" do - expect(context[:name]).to eq("John") - end - - it "retrieves value by string key converted to symbol" do - expect(context["name"]).to eq("John") - end - - it "returns nil for non-existent key" do - expect(context[:missing]).to be_nil + describe "#[], #[]=, #store" do + it "reads and writes with string or symbol keys interchangeably" do + ctx = described_class.new + ctx[:a] = 1 + expect(ctx["a"]).to eq(1) + ctx.store("b", 2) + expect(ctx[:b]).to eq(2) end end - describe "#store and #[]=" do - it "stores value with symbol key" do - context.store(:email, "john@example.com") + describe "#fetch, #fetch_or_store" do + let(:ctx) { described_class.new(existing: 10) } - expect(context[:email]).to eq("john@example.com") + it "fetch uses symbolized keys, default value, and lazy block default" do + expect(ctx.fetch(:existing)).to eq(10) + expect(ctx.fetch("existing")).to eq(10) + expect(ctx.fetch(:missing, 99)).to eq(99) + calls = 0 + expect(ctx.fetch("existing") { calls += 1 }).to eq(10) + expect(calls).to eq(0) + expect(ctx.fetch(:missing) do + calls += 1 + 7 + end).to eq(7) + expect(calls).to eq(1) end - it "stores value with string key converted to symbol" do - context["phone"] = "555-1234" - - expect(context[:phone]).to eq("555-1234") + it "fetch_or_store sets and returns a computed value when missing" do + expect(ctx.fetch_or_store(:other) { 42 }).to eq(42) + expect(ctx[:other]).to eq(42) end - it "overwrites existing value" do - context[:age] = 35 - - expect(context[:age]).to eq(35) + it "fetch_or_store uses the given value when no block" do + expect(ctx.fetch_or_store(:z, 5)).to eq(5) + expect(ctx[:z]).to eq(5) end end - describe "#fetch" do - it "retrieves existing value" do - expect(context.fetch(:name)).to eq("John") - end + describe "#merge!, #delete!, #clear!" do + let(:ctx) { described_class.new(a: 1) } - it "retrieves value with string key converted to symbol" do - expect(context.fetch("age")).to eq(30) + it "merge! symbolizes keys and returns self" do + expect(ctx.merge!("b" => 2)).to equal(ctx) + expect(ctx[:b]).to eq(2) end - it "returns default value for missing key" do - expect(context.fetch(:missing, "default")).to eq("default") - end - - it "executes block for missing key" do - result = context.fetch(:missing) { 1 + 1 } - - expect(result).to eq(2) + it "delete! removes by string or symbol" do + ctx.delete!("a") + expect(ctx.key?(:a)).to be false end - it "raises KeyError for missing key without default" do - expect { context.fetch(:missing) }.to raise_error(KeyError) + it "clear! empties the table" do + ctx.clear! + expect(ctx.to_h).to be_empty end end - describe "#fetch_or_store" do - it "returns existing value when key exists" do - result = context.fetch_or_store(:name, "Default") - - expect(result).to eq("John") - expect(context.table).to include(name: "John") - end - - it "stores and returns default value when key does not exist" do - result = context.fetch_or_store(:email, "john@example.com") - - expect(result).to eq("john@example.com") - expect(context.table).to include(email: "john@example.com") - end - - it "converts string key to symbol" do - result = context.fetch_or_store("phone", "555-1234") - - expect(result).to eq("555-1234") - expect(context.table).to include(phone: "555-1234") - end - - it "returns existing value and does not execute block when key exists" do - block_called = false - result = context.fetch_or_store(:name) do - block_called = true - "Default" - end - - expect(result).to eq("John") - expect(block_called).to be(false) - end + describe "#key?, #dig, #keys, #values, #each" do + let(:ctx) { described_class.new(outer: { inner: 7 }) } - it "stores nil when no value or block provided" do - result = context.fetch_or_store(:status) + it "delegates hash-like introspection" do + expect(ctx.key?("outer")).to be true + expect(ctx.dig(:outer, :inner)).to eq(7) + expect(ctx.keys).to eq([:outer]) + expect(ctx.values.size).to eq(1) - expect(result).to be_nil - expect(context.table).to include(status: nil) - end - - it "overwrites existing value when block is provided and key exists" do - context[:counter] = 5 - result = context.fetch_or_store(:counter) { 10 } - - expect(result).to eq(5) - expect(context.table).to include(counter: 5) - end - - it "handles complex default values" do - default_hash = { active: true, role: "admin" } - result = context.fetch_or_store(:settings, default_hash) - - expect(result).to eq(default_hash) - expect(context.table).to include(settings: default_hash) + expect { |b| ctx.each(&b) }.to yield_successive_args([:outer, { inner: 7 }]) end end - describe "#merge!" do - context "when given a hash" do - it "merges new data and returns self" do - result = context.merge!(email: "john@example.com", active: true) - - expect(result).to be(context) - expect(context.table).to include( - name: "John", - age: 30, - email: "john@example.com", - active: true - ) - end - end - - context "when given object with to_h" do - let(:mergeable) do - object = Object.new - def object.to_h - { "status" => "active", "role" => "user" } - end - object - end - - it "converts to hash and merges with symbol keys" do - context.merge!(mergeable) - - expect(context.table).to include(status: "active", role: "user") - end - end - - context "when given empty hash" do - it "returns self without changes" do - original_table = context.table.dup - result = context.merge!({}) - - expect(result).to be(context) - expect(context.table).to eq(original_table) - end - end - - it "overwrites existing keys" do - context.merge!(name: "Jane", age: 25) - - expect(context.table).to eq(name: "Jane", age: 25) - end - end - - describe "#delete!" do - it "deletes existing key and returns value" do - result = context.delete!(:name) - - expect(result).to eq("John") - expect(context.table).not_to have_key(:name) - end - - it "deletes key with string converted to symbol" do - result = context.delete!("age") - - expect(result).to eq(30) - expect(context.table).not_to have_key(:age) - end - - it "returns nil for non-existent key" do - result = context.delete!(:missing) - - expect(result).to be_nil - end - - it "executes block for non-existent key" do - result = context.delete!(:missing) { "not found" } - - expect(result).to eq("not found") - end - end - - describe "#clear!" do - it "clears all data and returns self" do - result = context.clear! - - expect(result).to be(context) - expect(context.table).to be_empty - end - end - - describe "#eql? and #==" do - let(:other_context) { described_class.new(name: "John", age: 30) } - let(:different_context) { described_class.new(name: "Jane", age: 25) } - - it "returns true for contexts with same data" do - expect(context).to eql(other_context) - expect(context).to eq(other_context) - end - - it "returns false for contexts with different data" do - expect(context).not_to eql(different_context) - expect(context).not_to eq(different_context) - end - - it "returns false when compared to non-Context object" do - expect(context).not_to eql({ name: "John", age: 30 }) - expect(context).not_to eq({ name: "John", age: 30 }) - end - end - - describe "#key?" do - it "returns true for existing symbol key" do - expect(context.key?(:name)).to be(true) - end - - it "returns true for existing key given as string" do - expect(context.key?("name")).to be(true) - end - - it "returns false for non-existent key" do - expect(context.key?(:missing)).to be(false) - end - end - - describe "#dig" do - let(:initial_data) do - { - user: { - profile: { - name: "John", - settings: { theme: "dark" } - } - } - } - end - - it "digs into nested hash with symbol keys" do - expect(context.dig(:user, :profile, :name)).to eq("John") - end - - it "digs into nested hash with string key converted to symbol" do - expect(context.dig("user", :profile, :settings, :theme)).to eq("dark") - end - - it "returns nil for non-existent nested path" do - expect(context.dig(:user, :missing, :key)).to be_nil - end - - it "returns nil for partial path" do - expect(context.dig(:user, :profile, :missing)).to be_nil - end - end - - describe "#to_h" do - it "returns the internal table" do - expect(context.to_h).to eq(context.table) - expect(context.to_h).to be(context.table) - end - end - - describe "#to_s" do - it "delegates to Utils::Format.to_str" do - allow(CMDx::Utils::Format).to receive(:to_str).with(context.table).and_return("formatted string") - - expect(context.to_s).to eq("formatted string") + describe "#eql?, #==" do + it "compares table contents" do + a = described_class.new(x: 1) + b = described_class.new("x" => 1) + expect(a).to eq(b) + expect(a).to eql(b) + expect(a == Object.new).to be false end end - describe "#each" do - it "delegates to table" do - result = [] - context.each { |key, value| result << [key, value] } # rubocop:disable Style/MapIntoArray + describe "#to_h, #to_s" do + let(:ctx) { described_class.new(n: 1) } - expect(result).to contain_exactly([:name, "John"], [:age, 30]) + it "to_h returns the symbol-keyed table" do + expect(ctx.to_h).to eq({ n: 1 }) end - end - - describe "#map" do - it "delegates to table" do - result = context.map { |key, value| "#{key}:#{value}" } - expect(result).to contain_exactly("name:John", "age:30") + it "to_s formats like Utils::Format.to_str" do + expect(ctx.to_s).to include("n:") end end - describe "method_missing and respond_to_missing?" do - context "when method name matches existing key" do - it "returns the value" do - expect(context.name).to eq("John") - expect(context.age).to eq(30) - end + describe "dynamic accessors via method_missing" do + let(:ctx) { described_class.new(count: 0) } - it "responds to the method" do - expect(context).to respond_to(:name) - expect(context).to respond_to(:age) - end - end - - context "when method name does not match any key" do - it "returns nil" do - expect(context.missing_method).to be_nil - end - - it "does not respond to the method" do - expect(context).not_to respond_to(:missing_method) - end - end - - context "when method name ends with equals sign" do - it "stores the value" do - context.email = "john@example.com" - - expect(context.table).to include(email: "john@example.com") - end - end - - context "when checking private method visibility" do - it "delegates to super for respond_to_missing?" do - expect(context.respond_to?(:name, true)).to be(true) - expect(context.respond_to?(:missing, true)).to be(false) - end + it "reads and writes attributes by name" do + expect(ctx.count).to eq(0) + ctx.count = 3 + expect(ctx[:count]).to eq(3) end end end diff --git a/spec/cmdx/deprecator_spec.rb b/spec/cmdx/deprecator_spec.rb index 35edc4a69..985bb4752 100644 --- a/spec/cmdx/deprecator_spec.rb +++ b/spec/cmdx/deprecator_spec.rb @@ -2,176 +2,27 @@ require "spec_helper" -RSpec.describe CMDx::Deprecator, type: :unit do - let(:mock_task) { instance_double("MockTask") } - let(:mock_task_class) { class_double(CMDx::Task, name: "TestTask") } - let(:mock_logger) { instance_double(Logger) } - let(:task_settings) { mock_settings(deprecate: deprecate_value) } - let(:deprecate_value) { false } - - before do - allow(mock_task_class).to receive(:settings).and_return(task_settings) - allow(mock_task).to receive_messages(class: mock_task_class, logger: mock_logger) - allow(mock_logger).to receive(:warn) - end - - describe "#restrict" do - context "when deprecate setting is nil or false" do - let(:deprecate_value) { nil } - - it "does nothing for nil" do - expect { described_class.restrict(mock_task) }.not_to raise_error - end - - context "when deprecate setting is false" do - let(:deprecate_value) { false } - - it "does nothing for false" do - expect { described_class.restrict(mock_task) }.not_to raise_error - end - end - end - - context "when deprecate setting is true" do - let(:deprecate_value) { true } - - it "raises DeprecationError" do - expect { described_class.restrict(mock_task) }.to raise_error( - CMDx::DeprecationError, "TestTask usage prohibited" - ) - end - end - - context "when deprecate setting contains 'raise'" do - let(:deprecate_value) { "raise" } - - it "raises DeprecationError" do - expect { described_class.restrict(mock_task) }.to raise_error( - CMDx::DeprecationError, "TestTask usage prohibited" - ) - end - - context "when deprecate setting is 'custom_raise'" do - let(:deprecate_value) { "custom_raise" } - - it "raises error for unevaluable deprecation value" do - expect { described_class.restrict(mock_task) }.to raise_error( - RuntimeError, 'cannot evaluate "custom_raise"' - ) - end - end - end - - context "when deprecate setting contains 'log'" do - let(:deprecate_value) { "log" } - - it "logs a warning message" do - expect(mock_logger).to receive(:warn) - - described_class.restrict(mock_task) - end - - context "when deprecate setting is 'custom_log'" do - let(:deprecate_value) { "custom_log" } - - it "raises error for unevaluable deprecation value" do - expect { described_class.restrict(mock_task) }.to raise_error( - RuntimeError, 'cannot evaluate "custom_log"' - ) - end - end - end - - context "when deprecate setting contains 'warn'" do - let(:deprecate_value) { "warn" } - - it "calls warn with deprecation message" do - expect(described_class).to receive(:warn).with( - "[TestTask] DEPRECATED: migrate to a replacement or discontinue use", - category: :deprecated - ) - - described_class.restrict(mock_task) - end - - context "when deprecate setting is 'custom_warn'" do - let(:deprecate_value) { "custom_warn" } - - it "raises error for unevaluable deprecation value" do - expect { described_class.restrict(mock_task) }.to raise_error( - RuntimeError, 'cannot evaluate "custom_warn"' - ) - end - end - end - - context "when deprecate setting is an unknown type" do - let(:deprecate_value) { "unknown" } - - it "raises an error for unknown deprecation type" do - expect { described_class.restrict(mock_task) }.to raise_error( - RuntimeError, 'cannot evaluate "unknown"' - ) - end - end - - context "when deprecate setting is a symbol" do - let(:deprecate_value) { :test_method } - - it "evaluates the symbol and processes the result" do - allow(mock_task).to receive(:test_method).and_return("symbol_result") - - expect { described_class.restrict(mock_task) }.to raise_error( - RuntimeError, 'unknown deprecation type "symbol_result"' - ) - end +RSpec.describe CMDx::Deprecator do + let(:task_class) do + Class.new(CMDx::Task) do + def work; end end + end - context "when deprecate setting is a proc" do - let(:deprecate_value) { proc { "log" } } - - it "evaluates the proc and processes the result" do - expect(mock_logger).to receive(:warn) - - described_class.restrict(mock_task) - end - end - - context "when deprecate setting is a callable object" do - let(:callable_object) { instance_double("MockCallable", call: "warn") } - let(:deprecate_value) { callable_object } - - it "calls the object and processes the result" do - allow(callable_object).to receive(:call).with(mock_task).and_return("warn") - expect(described_class).to receive(:warn).with( - "[TestTask] DEPRECATED: migrate to a replacement or discontinue use", - category: :deprecated - ) - - described_class.restrict(mock_task) - end + describe ".check!" do + it "raises DeprecationError for :restrict mode" do + task_class.settings { |s| s.deprecate = { mode: :restrict, message: "gone" } } + expect { described_class.check!(task_class) }.to raise_error(CMDx::DeprecationError, /gone/) end - context "when deprecate setting returns a boolean from symbol" do - let(:deprecate_value) { :check_deprecated } - - it "evaluates symbol and handles boolean result" do - allow(mock_task).to receive(:check_deprecated).and_return(false) - - expect { described_class.restrict(mock_task) }.not_to raise_error - end + it "warns for :warn mode" do + task_class.settings { |s| s.deprecate = { mode: :warn, message: "soon" } } + expect { described_class.check!(task_class) }.to output(/DEPRECATED.*soon/).to_stderr end - context "when deprecate setting returns true from symbol" do - let(:deprecate_value) { :check_deprecated } - - it "evaluates symbol and raises error for true result" do - allow(mock_task).to receive(:check_deprecated).and_return(true) - - expect { described_class.restrict(mock_task) }.to raise_error( - CMDx::DeprecationError, "TestTask usage prohibited" - ) - end + it "does nothing when no deprecation configured" do + expect { described_class.check!(task_class) }.not_to raise_error + expect { described_class.check!(task_class) }.not_to output.to_stderr end end end diff --git a/spec/cmdx/errors_spec.rb b/spec/cmdx/errors_spec.rb index 370746f40..a49ea9d38 100644 --- a/spec/cmdx/errors_spec.rb +++ b/spec/cmdx/errors_spec.rb @@ -2,424 +2,81 @@ require "spec_helper" -RSpec.describe CMDx::Errors, type: :unit do +RSpec.describe CMDx::Errors do subject(:errors) { described_class.new } - describe "#initialize" do - it "initializes with empty messages hash" do - expect(errors.messages).to eq({}) - end - - it "is empty by default" do - expect(errors).to be_empty - end - end - describe "#add" do - context "when adding a valid message" do - it "adds a message for an attribute" do - errors.add(:name, "is required") - - expect(errors.messages[:name]).to include("is required") - end - - it "creates a Set for the attribute if it doesn't exist" do - errors.add(:email, "is invalid") - - expect(errors.messages[:email]).to be_a(Set) - end - - it "adds multiple messages for the same attribute" do - errors.add(:password, "is too short") - errors.add(:password, "must contain numbers") - - expect(errors.messages[:password]).to include("is too short", "must contain numbers") - expect(errors.messages[:password].size).to eq(2) - end - - it "does not duplicate the same message for an attribute" do - errors.add(:username, "is taken") - errors.add(:username, "is taken") - - expect(errors.messages[:username].size).to eq(1) - expect(errors.messages[:username]).to include("is taken") - end - - it "handles string attributes" do - errors.add("category", "is invalid") - - expect(errors.messages["category"]).to include("is invalid") - end - - it "handles symbol attributes" do - errors.add(:status, "is not allowed") - - expect(errors.messages[:status]).to include("is not allowed") - end + it "stores messages per attribute using a Set (no duplicate messages)" do + errors.add(:email, "is invalid") + errors.add(:email, "is invalid") + errors.add(:email, "can't be blank") + expect(errors.messages[:email].to_a).to contain_exactly("is invalid", "can't be blank") end - context "when adding an empty message" do - it "does not add empty string messages" do - errors.add(:name, "") - - expect(errors.messages).to eq({}) - expect(errors).to be_empty - end - - it "raises an error when trying to add nil messages" do - expect { errors.add(:name, nil) }.to raise_error(NoMethodError, /undefined method.*empty.*for nil/) - end + it "ignores empty messages" do + errors.add(:name, "") + expect(errors.messages).to be_empty end end describe "#for?" do - context "when attribute has errors" do - before do - errors.add(:email, "is required") - end - - it "returns true for attributes with errors" do - expect(errors.for?(:email)).to be(true) - end + it "is true when the attribute has messages" do + errors.add(:foo, "nope") + expect(errors.for?(:foo)).to be true end - context "when attribute has no errors" do - it "returns false for attributes without errors" do - expect(errors.for?(:name)).to be(false) - end - - it "returns false for non-existent attributes" do - expect(errors.for?(:nonexistent)).to be(false) - end - end - - context "when attribute exists but has empty errors" do - before do - errors.messages[:status] = Set.new - end - - it "returns false for attributes with empty error sets" do - expect(errors.for?(:status)).to be(false) - end + it "is false when the attribute has no messages" do + expect(errors.for?(:foo)).to be false end end - describe "#any?" do - context "when no errors have been added" do - it "returns false" do - expect(errors.any?).to be(false) - end - end + describe "#empty?, #any?, #size" do + it "reports emptiness and attribute count" do + expect(errors.empty?).to be true + expect(errors.any?).to be false + expect(errors.size).to eq(0) - context "when errors have been added" do - before do - errors.add(:name, "is required") - end + errors.add(:a, "one") + errors.add(:b, "two") + errors.add(:b, "three") - it "returns true" do - expect(errors.any?).to be(true) - end + expect(errors.empty?).to be false + expect(errors.any?).to be true + expect(errors.size).to eq(2) end end describe "#clear" do - context "when errors have been added" do - before do - errors.add(:name, "is required") - errors.add(:email, "is invalid") - end - - it "removes all errors" do - errors.clear - - expect(errors).to be_empty - end - end - end - - describe "#size" do - context "when no errors have been added" do - it "returns 0" do - expect(errors.size).to eq(0) - end - end - - context "when errors have been added for multiple attributes" do - before do - errors.add(:name, "is required") - errors.add(:email, "is invalid") - end - - it "returns the number of attributes with errors" do - expect(errors.size).to eq(2) - end - end - end - - describe "#empty?" do - context "when no errors have been added" do - it "returns true" do - expect(errors).to be_empty - end - end - - context "when errors have been added" do - before do - errors.add(:name, "is required") - end - - it "returns false" do - expect(errors).not_to be_empty - end - end - - context "when only empty messages were attempted to be added" do - before do - errors.add(:name, "") - end - - it "returns true" do - expect(errors).to be_empty - end - end - end - - describe "#to_h" do - context "when there are no errors" do - it "returns an empty hash" do - expect(errors.to_h).to eq({}) - end - end - - context "when there are errors" do - before do - errors.add(:name, "is required") - errors.add(:name, "is too short") - errors.add(:email, "is invalid") - end - - it "returns a hash with arrays as values" do - result = errors.to_h - - expect(result[:name]).to be_a(Array) - expect(result[:email]).to be_a(Array) - end - - it "converts Sets to Arrays for each attribute" do - result = errors.to_h - - expect(result[:name]).to contain_exactly("is required", "is too short") - expect(result[:email]).to contain_exactly("is invalid") - end - - it "preserves all error messages" do - result = errors.to_h - - expect(result[:name].size).to eq(2) - expect(result[:email].size).to eq(1) - end + it "removes all messages" do + errors.add(:x, "y") + errors.clear + expect(errors.messages).to be_empty + expect(errors.empty?).to be true end end describe "#full_messages" do - context "when there are no errors" do - it "returns an empty hash" do - expect(errors.full_messages).to eq({}) - end - end - - context "when there are errors" do - before do - errors.add(:name, "is required") - errors.add(:name, "is too short") - errors.add(:email, "is invalid") - end - - it "returns a hash with full messages as values" do - result = errors.full_messages - - expect(result[:name]).to be_a(Array) - expect(result[:email]).to be_a(Array) - end - - it "includes attribute names in the messages" do - result = errors.full_messages - - expect(result[:name]).to contain_exactly("name is required", "name is too short") - expect(result[:email]).to contain_exactly("email is invalid") - end - - it "preserves all error messages" do - result = errors.full_messages - - expect(result[:name].size).to eq(2) - expect(result[:email].size).to eq(1) - end - end - - context "when there are mixed attribute types" do - before do - errors.add(:symbol_attr, "symbol error") - errors.add("string_attr", "string error") - end - - it "handles both string and symbol attributes" do - result = errors.full_messages - - expect(result[:symbol_attr]).to contain_exactly("symbol_attr symbol error") - expect(result["string_attr"]).to contain_exactly("string_attr string error") - end + it "prepends the attribute name to each message" do + errors.add(:email, "is invalid") + expect(errors.full_messages).to eq({ email: ["email is invalid"] }) end end - describe "#to_hash" do - context "when there are no errors" do - it "returns an empty hash when full is false" do - expect(errors.to_hash).to eq({}) - end - - it "returns an empty hash when full is true" do - expect(errors.to_hash(true)).to eq({}) - end - end - - context "when there are errors" do - before do - errors.add(:name, "is required") - errors.add(:name, "is too short") - errors.add(:email, "is invalid") - end - - context "when full is false (default)" do - it "returns a hash with arrays as values" do - result = errors.to_hash - - expect(result[:name]).to be_a(Array) - expect(result[:email]).to be_a(Array) - end - - it "converts Sets to Arrays for each attribute" do - result = errors.to_hash - - expect(result[:name]).to contain_exactly("is required", "is too short") - expect(result[:email]).to contain_exactly("is invalid") - end - - it "preserves all error messages" do - result = errors.to_hash - - expect(result[:name].size).to eq(2) - expect(result[:email].size).to eq(1) - end - end - - context "when full is true" do - it "returns a hash with full messages as values" do - result = errors.to_hash(true) - - expect(result[:name]).to be_a(Array) - expect(result[:email]).to be_a(Array) - end - - it "includes attribute names in the messages" do - result = errors.to_hash(true) - - expect(result[:name]).to contain_exactly("name is required", "name is too short") - expect(result[:email]).to contain_exactly("email is invalid") - end - - it "preserves all error messages" do - result = errors.to_hash(true) - - expect(result[:name].size).to eq(2) - expect(result[:email].size).to eq(1) - end - end - end - - context "when there are mixed attribute types" do - before do - errors.add(:symbol_attr, "symbol error") - errors.add("string_attr", "string error") - end - - it "handles both string and symbol attributes when full is false" do - result = errors.to_hash - - expect(result[:symbol_attr]).to contain_exactly("symbol error") - expect(result["string_attr"]).to contain_exactly("string error") - end - - it "handles both string and symbol attributes when full is true" do - result = errors.to_hash(true) - - expect(result[:symbol_attr]).to contain_exactly("symbol_attr symbol error") - expect(result["string_attr"]).to contain_exactly("string_attr string error") - end + describe "#to_h" do + it "returns arrays of messages without attribute prefixes" do + errors.add(:name, "too short") + expect(errors.to_h).to eq({ name: ["too short"] }) end end describe "#to_s" do - context "when there are no errors" do - it "returns an empty string" do - expect(errors.to_s).to eq("") - end - end - - context "when there is one error for one attribute" do - before do - errors.add(:name, "is required") - end - - it "returns a formatted string" do - expect(errors.to_s).to eq("name is required") - end - end - - context "when there are multiple errors for one attribute" do - before do - errors.add(:password, "is too short") - errors.add(:password, "must contain numbers") - end - - it "returns all errors for the attribute separated by periods" do - result = errors.to_s - - expect(result).to include("password is too short") - expect(result).to include("password must contain numbers") - expect(result.split(". ").length).to eq(2) - end - end - - context "when there are errors for multiple attributes" do - before do - errors.add(:name, "is required") - errors.add(:email, "is invalid") - errors.add(:password, "is too short") - end - - it "returns all errors separated by periods" do - result = errors.to_s - - expect(result).to include("name is required") - expect(result).to include("email is invalid") - expect(result).to include("password is too short") - expect(result.split(". ").length).to eq(3) - end - end - - context "when there are mixed attribute types" do - before do - errors.add(:symbol_attr, "symbol error") - errors.add("string_attr", "string error") - end - - it "handles both string and symbol attributes" do - result = errors.to_s - - expect(result).to include("symbol_attr symbol error") - expect(result).to include("string_attr string error") - end + it "joins full messages with period-space" do + errors.add(:a, "bad") + errors.add(:b, "worse") + str = errors.to_s + expect(str).to include("a bad") + expect(str).to include("b worse") + expect(str).to include(". ") end end end diff --git a/spec/cmdx/executor_spec.rb b/spec/cmdx/executor_spec.rb deleted file mode 100644 index 9bfadfd42..000000000 --- a/spec/cmdx/executor_spec.rb +++ /dev/null @@ -1,1162 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Executor, type: :unit do - subject(:worker) { described_class.new(task) } - - let(:task_class) { create_successful_task(name: "TestTask") } - let(:task) { task_class.new } - - describe "#initialize" do - it "assigns the task" do - expect(worker.task).to eq(task) - end - - it "provides read access to task attribute" do - expect(described_class.instance_methods).to include(:task) - expect(described_class.private_instance_methods).not_to include(:task) - end - end - - describe ".execute" do - context "with raise: false" do - it "creates worker instance and calls execute" do - expect(described_class).to receive(:new).with(task).and_return(worker) - expect(worker).to receive(:execute).and_return(:result) - - result = described_class.execute(task, raise: false) - - expect(result).to eq(:result) - end - end - - context "with raise: true" do - it "creates worker instance and calls execute!" do - expect(described_class).to receive(:new).with(task).and_return(worker) - expect(worker).to receive(:execute!).and_return(:result) - - result = described_class.execute(task, raise: true) - - expect(result).to eq(:result) - end - end - - context "without raise parameter" do - it "defaults to raise: false" do - expect(described_class).to receive(:new).with(task).and_return(worker) - expect(worker).to receive(:execute).and_return(:result) - - described_class.execute(task) - end - end - end - - describe "#execute" do - let(:middlewares) { instance_double(CMDx::MiddlewareRegistry) } - let(:logger) { instance_double(Logger) } - let(:callbacks) { instance_double(CMDx::CallbackRegistry) } - - before do - allow(callbacks).to receive_messages(invoke: nil, empty?: false) - allow(task.class).to receive(:settings).and_return(mock_settings(middlewares: middlewares, callbacks: callbacks)) - allow(task).to receive(:logger).and_return(logger) - allow(logger).to receive(:info) - allow(worker).to receive(:freeze_execution!) - - # Setup result state to support proper transitions - allow(task.result).to receive_messages(to_h: { test: "data" }, state: "executing", executing?: true, executed?: true, success?: true) - allow(task.resolver).to receive(:complete!) - allow(task.resolver).to receive(:executed!) - end - - context "when execution is successful" do - it "calls middleware with task and executes successfully" do - expect(middlewares).to receive(:call!).with(task).and_yield - expect(worker).to receive(:pre_execution!) - expect(worker).to receive(:execution!) - expect(task.resolver).to receive(:executed!) - expect(worker).to receive(:post_execution!) - expect(worker).to receive(:freeze_execution!) - - worker.execute - end - - it "logs execution information" do - expect(middlewares).to receive(:call!).with(task).and_yield - - allow(worker).to receive(:pre_execution!) - allow(worker).to receive(:execution!) - allow(task.resolver).to receive(:executed!) - allow(worker).to receive(:post_execution!) - - expect(logger).to receive(:info) - - worker.execute - end - end - - context "when UndefinedMethodError is raised" do - let(:undefined_error) { CMDx::UndefinedMethodError.new("undefined method") } - - it "re-raises the exception without clearing chain" do - expect(middlewares).to receive(:call!).with(task).and_yield - - allow(worker).to receive(:pre_execution!) - allow(worker).to receive(:execution!).and_raise(undefined_error) - - expect(CMDx::Chain).to receive(:clear).at_least(:once) - - expect { worker.execute }.to raise_error(CMDx::UndefinedMethodError) - end - end - - context "when Fault is raised" do - let(:fault_result) { instance_double(CMDx::Result, reason: "test failure") } - let(:fault) { CMDx::FailFault.new(fault_result) } - - it "calls throw! on task result with fault result" do - expect(middlewares).to receive(:call!).with(task).and_yield - - allow(worker).to receive(:pre_execution!) - allow(worker).to receive(:execution!).and_raise(fault) - - expect(task.resolver).to receive(:throw!).with(fault_result, halt: false, cause: fault) - - allow(task.resolver).to receive(:executed!) - allow(worker).to receive(:post_execution!) - - worker.execute - end - - it "continues with normal execution flow" do - expect(middlewares).to receive(:call!).with(task).and_yield - - allow(worker).to receive(:pre_execution!) - allow(worker).to receive(:execution!).and_raise(fault) - allow(task.resolver).to receive(:throw!) - - expect(task.resolver).to receive(:executed!) - expect(worker).to receive(:post_execution!) - expect(worker).to receive(:freeze_execution!) - - worker.execute - end - end - - context "when StandardError is raised" do - let(:standard_error) { StandardError.new("something went wrong") } - - it "calls fail! on task result with formatted error message" do - expect(middlewares).to receive(:call!).with(task).and_yield - - allow(worker).to receive(:pre_execution!) - allow(worker).to receive(:execution!).and_raise(standard_error) - - expect(task.resolver).to receive(:fail!).with("[StandardError] something went wrong", halt: false, cause: standard_error, source: :exception) - - allow(task.resolver).to receive(:executed!) - allow(worker).to receive(:post_execution!) - - worker.execute - end - - it "continues with normal execution flow" do - expect(middlewares).to receive(:call!).with(task).and_yield - - allow(worker).to receive(:pre_execution!) - allow(worker).to receive(:execution!).and_raise(standard_error) - allow(task.resolver).to receive(:fail!) - - expect(task.resolver).to receive(:executed!) - expect(worker).to receive(:post_execution!) - expect(worker).to receive(:freeze_execution!) - - worker.execute - end - end - - context "when custom error is raised" do - let(:custom_error) { CMDx::TestError.new("test error") } - - it "calls fail! on task result with formatted error message" do - expect(middlewares).to receive(:call!).with(task).and_yield - - allow(worker).to receive(:pre_execution!) - allow(worker).to receive(:execution!).and_raise(custom_error) - - expect(task.resolver).to receive(:fail!).with("[CMDx::TestError] test error", halt: false, cause: custom_error, source: :exception) - - allow(task.resolver).to receive(:executed!) - allow(worker).to receive(:post_execution!) - - worker.execute - end - end - end - - describe "#execute!" do - let(:middlewares) { instance_double(CMDx::MiddlewareRegistry) } - let(:logger) { instance_double(Logger) } - let(:callbacks) { instance_double(CMDx::CallbackRegistry) } - - before do - allow(callbacks).to receive_messages(invoke: nil, empty?: false) - allow(task.class).to receive(:settings).and_return(mock_settings(middlewares: middlewares, callbacks: callbacks)) - allow(task).to receive(:logger).and_return(logger) - allow(logger).to receive(:info) - allow(worker).to receive(:freeze_execution!) - - # Setup result state to support proper transitions - allow(task.result).to receive_messages(to_h: { test: "data" }, state: "executing", executing?: true, executed?: true, success?: true) - allow(task.resolver).to receive(:complete!) - allow(task.resolver).to receive(:executed!) - end - - context "when execution is successful" do - it "calls middleware with task and executes successfully" do - expect(middlewares).to receive(:call!).with(task).and_yield - expect(worker).to receive(:pre_execution!) - expect(worker).to receive(:execution!) - expect(task.resolver).to receive(:executed!) - expect(worker).to receive(:post_execution!) - expect(worker).to receive(:freeze_execution!) - - worker.execute! - end - end - - context "when UndefinedMethodError is raised" do - let(:undefined_error) { CMDx::UndefinedMethodError.new("undefined method") } - - it "calls raise_exception with the error" do - expect(middlewares).to receive(:call!).with(task).and_yield - - allow(worker).to receive(:pre_execution!) - allow(worker).to receive(:execution!).and_raise(undefined_error) - - expect(worker).to receive(:raise_exception).with(undefined_error).and_raise(undefined_error) - - expect { worker.execute! }.to raise_error(undefined_error) - end - end - - context "when Fault is raised" do - let(:fault_result) { instance_double(CMDx::Result, status: "failed", reason: "test failure") } - let(:fault) { CMDx::FailFault.new(fault_result) } - - context "when halt_execution? returns false" do - it "calls throw!, executed!, and post_execution!" do - expect(middlewares).to receive(:call!).with(task).and_yield - - allow(worker).to receive(:pre_execution!) - allow(worker).to receive(:execution!).and_raise(fault) - - expect(task.resolver).to receive(:throw!).with(fault_result, halt: false, cause: fault) - expect(task.resolver).to receive(:executed!) - expect(worker).to receive(:halt_execution?).with(fault).and_return(false) - expect(worker).to receive(:post_execution!) - - worker.execute! - end - end - - context "when halt_execution? returns true" do - it "calls throw! and raise_exception" do - expect(middlewares).to receive(:call!).with(task).and_yield - - allow(worker).to receive(:pre_execution!) - allow(worker).to receive(:execution!).and_raise(fault) - - expect(task.resolver).to receive(:throw!).with(fault_result, halt: false, cause: fault) - expect(worker).to receive(:halt_execution?).with(fault).and_return(true) - expect(worker).to receive(:raise_exception).with(fault).and_raise(fault) - - expect { worker.execute! }.to raise_error(fault) - end - end - end - - context "when StandardError is raised" do - let(:standard_error) { StandardError.new("something went wrong") } - - it "calls fail! and raise_exception" do - expect(middlewares).to receive(:call!).with(task).and_yield - - allow(worker).to receive(:pre_execution!) - allow(worker).to receive(:execution!).and_raise(standard_error) - - expect(task.resolver).to receive(:fail!).with("[StandardError] something went wrong", halt: false, cause: standard_error, source: :exception) - expect(worker).to receive(:raise_exception).with(standard_error).and_raise(standard_error) - - expect { worker.execute! }.to raise_error(standard_error) - end - end - end - - describe "#halt_execution?" do - let(:fault_resolver) { instance_double(CMDx::Resolver) } - let(:fault_task) { instance_double(CMDx::Task, resolver: fault_resolver) } - let(:fault_result) { instance_double(CMDx::Result, status: "failed", task: fault_task, reason: "test failure", strict?: true) } - let(:fault) { CMDx::FailFault.new(fault_result) } - - context "when resolver has strict: false" do - let(:fault_result) { instance_double(CMDx::Result, status: "failed", task: fault_task, reason: "test failure", strict?: false) } - - before do - allow(task.class).to receive(:settings).and_return(mock_settings(breakpoints: %w[failed skipped])) - end - - it "returns false regardless of breakpoints config" do - expect(worker.send(:halt_execution?, fault)).to be(false) - end - end - - context "when breakpoints setting exists" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(breakpoints: %w[failed skipped])) - end - - context "when exception result status is in breakpoints" do - it "returns true" do - expect(worker.send(:halt_execution?, fault)).to be(true) - end - end - - context "when exception result status is not in breakpoints" do - let(:success_resolver) { instance_double(CMDx::Resolver) } - let(:success_task) { instance_double(CMDx::Task, resolver: success_resolver) } - let(:success_result) { instance_double(CMDx::Result, status: "success", task: success_task, reason: "test success", strict?: true) } - let(:success_fault) { CMDx::SkipFault.new(success_result) } - - it "returns false" do - expect(worker.send(:halt_execution?, success_fault)).to be(false) - end - end - end - - context "when task_breakpoints setting exists" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(task_breakpoints: [:failed])) - end - - it "converts symbols to strings and checks inclusion" do - expect(worker.send(:halt_execution?, fault)).to be(true) - end - end - - context "when no breakpoints are configured" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(task_breakpoints: nil)) - end - - it "returns false" do - expect(worker.send(:halt_execution?, fault)).to be(false) - end - end - - context "when breakpoints is nil" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(breakpoints: nil, task_breakpoints: nil)) - end - - it "returns false" do - expect(worker.send(:halt_execution?, fault)).to be(false) - end - end - - context "with duplicate breakpoints" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(breakpoints: ["failed", "failed", :failed])) - end - - it "removes duplicates after string conversion" do - expect(worker.send(:halt_execution?, fault)).to be(true) - end - end - end - - describe "#retry_execution?" do - let(:exception) { StandardError.new("test error") } - let(:logger) { instance_double(Logger) } - - before do - allow(logger).to receive(:warn) - allow(task).to receive_messages(logger: logger, to_h: { id: "123" }) - end - - context "when retries is not configured" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings) - end - - it "returns false" do - expect(worker.send(:retry_execution?, exception)).to be(false) - end - end - - context "when retries is 0" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 0)) - end - - it "returns false" do - expect(worker.send(:retry_execution?, exception)).to be(false) - end - end - - context "when retries are exhausted" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 2)) - allow(task.result).to receive(:retries).and_return(2) - end - - it "returns false" do - expect(worker.send(:retry_execution?, exception)).to be(false) - end - end - - context "when exception type does not match retry_on" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 3, retry_on: [ArgumentError])) - allow(task.result).to receive(:retries).and_return(0) - end - - it "returns false" do - expect(worker.send(:retry_execution?, exception)).to be(false) - end - end - - context "when retry should happen" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 3)) - allow(task.result).to receive_messages(retries: 0, :retries= => nil) - end - - it "returns true" do - expect(worker.send(:retry_execution?, exception)).to be(true) - end - - it "increments retry count on result" do - allow(task.result).to receive_messages(retries: 1, :retries= => nil) - - expect(task.result).to receive(:retries=).with(2) - - worker.send(:retry_execution?, exception) - end - - it "clears task errors" do - task.errors.add(:base, "previous error") - - worker.send(:retry_execution?, exception) - - expect(task.errors).to be_empty - end - - it "logs warning with reason and remaining retries" do - allow(task.result).to receive_messages(retries: 0, :retries= => nil) - - expect(logger).to receive(:warn) do |&block| - result = block.call - expect(result[:reason]).to eq("[StandardError] test error") - expect(result[:remaining_retries]).to eq(3) - end - - worker.send(:retry_execution?, exception) - end - end - - context "with retry_on configuration" do - context "when exception matches configured type" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 2, retry_on: [StandardError])) - allow(task.result).to receive_messages(retries: 0, :retries= => nil) - end - - it "returns true" do - expect(worker.send(:retry_execution?, exception)).to be(true) - end - end - - context "when exception is subclass of configured type" do - let(:custom_error) { CMDx::TestError.new("test error") } - - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 2, retry_on: [StandardError])) - allow(task.result).to receive_messages(retries: 0, :retries= => nil) - end - - it "returns true" do - expect(worker.send(:retry_execution?, custom_error)).to be(true) - end - end - - context "when multiple exception types are configured" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 2, retry_on: [ArgumentError, StandardError])) - allow(task.result).to receive_messages(retries: 0, :retries= => nil) - end - - it "returns true if exception matches any type" do - expect(worker.send(:retry_execution?, exception)).to be(true) - end - end - end - - context "with retry_jitter as numeric value" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 3, retry_jitter: 0.5)) - allow(task.result).to receive_messages(retries: 1, :retries= => nil) - end - - it "sleeps for jitter multiplied by current retries" do - expect(worker).to receive(:sleep).with(0.5) - - worker.send(:retry_execution?, exception) - end - - context "when first retry" do - before do - allow(task.result).to receive_messages(retries: 0, :retries= => nil) - end - - it "does not sleep when jitter calculation is 0" do - expect(worker).not_to receive(:sleep) - - worker.send(:retry_execution?, exception) - end - end - - context "when second retry" do - before do - allow(task.result).to receive_messages(retries: 1, :retries= => nil) - end - - it "sleeps for jitter * 1" do - expect(worker).to receive(:sleep).with(0.5) - - worker.send(:retry_execution?, exception) - end - end - - context "when third retry" do - before do - allow(task.result).to receive_messages(retries: 2, :retries= => nil) - end - - it "sleeps for jitter * 2" do - expect(worker).to receive(:sleep).with(1.0) - - worker.send(:retry_execution?, exception) - end - end - end - - context "with retry_jitter as symbol" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 3, retry_jitter: :custom_jitter)) - allow(task.result).to receive_messages(retries: 1, :retries= => nil) - allow(task).to receive(:custom_jitter).with(1).and_return(2.5) - end - - it "calls method on task with current retries" do - expect(task).to receive(:custom_jitter).with(1).and_return(2.5) - expect(worker).to receive(:sleep).with(2.5) - - worker.send(:retry_execution?, exception) - end - end - - context "with retry_jitter as proc" do - let(:jitter_proc) { ->(retries) { retries * 0.75 } } - - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 3, retry_jitter: jitter_proc)) - allow(task.result).to receive_messages(retries: 2, :retries= => nil) - end - - it "instance_execs proc with current retries" do - expect(worker).to receive(:sleep).with(1.5) - - worker.send(:retry_execution?, exception) - end - end - - context "with retry_jitter as callable object" do - let(:jitter_callable) do - Class.new do - def call(_task, retries) - retries * 1.25 - end - end.new - end - - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 3, retry_jitter: jitter_callable)) - allow(task.result).to receive_messages(retries: 2, :retries= => nil) - end - - it "calls object with task and current retries" do - expect(jitter_callable).to receive(:call).with(task, 2).and_return(2.5) - expect(worker).to receive(:sleep).with(2.5) - - worker.send(:retry_execution?, exception) - end - end - - context "when jitter calculation returns negative value" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 3, retry_jitter: -0.5)) - allow(task.result).to receive_messages(retries: 1, :retries= => nil) - end - - it "does not sleep" do - expect(worker).not_to receive(:sleep) - - worker.send(:retry_execution?, exception) - end - end - - context "when jitter calculation returns zero" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 3, retry_jitter: 0)) - allow(task.result).to receive_messages(retries: 1, :retries= => nil) - end - - it "does not sleep" do - expect(worker).not_to receive(:sleep) - - worker.send(:retry_execution?, exception) - end - end - end - - describe "#raise_exception" do - let(:exception) { StandardError.new("test error") } - - it "clears the chain and raises the exception" do - expect(CMDx::Chain).to receive(:clear).at_least(:once) - - expect { worker.send(:raise_exception, exception) }.to raise_error(exception) - end - end - - describe "#invoke_callbacks" do - let(:callbacks) { instance_double(CMDx::CallbackRegistry) } - - before do - allow(task.class).to receive(:settings).and_return(mock_settings(callbacks: callbacks)) - end - - it "delegates to callbacks registry with type and task" do - expect(callbacks).to receive(:invoke).with(:before_validation, task) - - worker.send(:invoke_callbacks, :before_validation) - end - end - - describe "#pre_execution!" do - let(:callbacks) { instance_double(CMDx::CallbackRegistry) } - let(:attributes) { instance_double(CMDx::AttributeRegistry) } - let(:errors) { instance_double(CMDx::Errors) } - - before do - allow(task.class).to receive(:settings).and_return( - mock_settings(callbacks: callbacks, attributes: attributes) - ) - allow(task).to receive(:errors).and_return(errors) - allow(callbacks).to receive(:invoke) - allow(attributes).to receive(:define_and_verify) - end - - context "when task has no errors" do - before do - allow(errors).to receive(:empty?).and_return(true) - end - - it "invokes before_validation callback and defines attributes" do - expect(callbacks).to receive(:invoke).with(:before_validation, task) - expect(attributes).to receive(:define_and_verify).with(task) - - worker.send(:pre_execution!) - end - end - - context "when task has errors" do - before do - allow(errors).to receive_messages( - empty?: false, - to_s: "Validation failed", - to_h: { name: ["is required"] } - ) - allow(task.resolver).to receive(:fail!) - end - - it "calls fail! on result with error information" do - expect(task.resolver).to receive(:fail!).with( - "Invalid", - source: :validation, - errors: { - full_message: "Validation failed", - messages: { name: ["is required"] } - } - ) - - worker.send(:pre_execution!) - end - end - end - - describe "#execution!" do - let(:callbacks) { instance_double(CMDx::CallbackRegistry) } - - before do - allow(task.class).to receive(:settings).and_return(mock_settings(callbacks: callbacks)) - allow(callbacks).to receive(:invoke) - allow(task.resolver).to receive(:executing!) - allow(task).to receive(:work) - end - - it "invokes before_execution callback, sets executing state, and calls work" do - expect(callbacks).to receive(:invoke).with(:before_execution, task) - expect(task.resolver).to receive(:executing!) - expect(task).to receive(:work) - - worker.send(:execution!) - end - end - - describe "#verify_context_returns!" do - let(:errors) { task.errors } - - context "when no returns are declared" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(returns: [])) - allow(task.result).to receive(:success?).and_return(true) - end - - it "does not fail the result" do - expect(task.result).not_to receive(:fail!) - - worker.send(:verify_context_returns!) - end - end - - context "when returns setting is nil" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(returns: nil)) - allow(task.result).to receive(:success?).and_return(true) - end - - it "does not fail the result" do - expect(task.result).not_to receive(:fail!) - - worker.send(:verify_context_returns!) - end - end - - context "when result is not success" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(returns: [:user])) - allow(task.result).to receive(:success?).and_return(false) - end - - it "skips verification" do - expect(task.result).not_to receive(:fail!) - - worker.send(:verify_context_returns!) - end - end - - context "when all declared returns are present in context" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(returns: %i[user token])) - allow(task.result).to receive(:success?).and_return(true) - task.context[:user] = "John" - task.context[:token] = "abc123" - end - - it "does not fail the result" do - expect(task.result).not_to receive(:fail!) - - worker.send(:verify_context_returns!) - end - end - - context "when some declared returns are missing" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(returns: %i[user token])) - allow(task.result).to receive(:success?).and_return(true) - task.context[:user] = "John" - end - - it "adds errors for missing returns and fails the result" do - expect(task.resolver).to receive(:fail!).with( - "Invalid", - source: :context, - errors: { - full_message: "token must be set in the context", - messages: { token: ["must be set in the context"] } - } - ) - - worker.send(:verify_context_returns!) - end - end - - context "when all declared returns are missing" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(returns: %i[user token])) - allow(task.result).to receive(:success?).and_return(true) - end - - it "adds errors for all missing returns and fails the result" do - expect(task.resolver).to receive(:fail!).with( - "Invalid", - source: :context, - errors: { - full_message: "user must be set in the context. token must be set in the context", - messages: { - user: ["must be set in the context"], - token: ["must be set in the context"] - } - } - ) - - worker.send(:verify_context_returns!) - end - end - end - - describe "#post_execution!" do - let(:callbacks) { instance_double(CMDx::CallbackRegistry) } - let(:result) { instance_double(CMDx::Result) } - - before do - allow(task.class).to receive(:settings).and_return(mock_settings(callbacks: callbacks)) - allow(task).to receive(:result).and_return(result) - allow(callbacks).to receive(:invoke) - end - - context "when callback registry is empty" do - before do - allow(callbacks).to receive(:empty?).and_return(true) - end - - it "skips all callback invocations" do - expect(callbacks).not_to receive(:invoke) - - worker.send(:post_execution!) - end - end - - context "when result is executed and good" do - before do - allow(callbacks).to receive(:empty?).and_return(false) - allow(result).to receive_messages( - state: "complete", - status: "success", - executed?: true, - good?: true, - bad?: false - ) - end - - it "invokes all appropriate callbacks" do - expect(callbacks).to receive(:invoke).with(:on_complete, task) - expect(callbacks).to receive(:invoke).with(:on_executed, task) - expect(callbacks).to receive(:invoke).with(:on_success, task) - expect(callbacks).to receive(:invoke).with(:on_good, task) - - worker.send(:post_execution!) - end - end - - context "when result is failed and bad" do - before do - allow(callbacks).to receive(:empty?).and_return(false) - allow(result).to receive_messages( - state: "interrupted", - status: "failed", - executed?: false, - good?: false, - bad?: true - ) - end - - it "invokes all appropriate callbacks" do - expect(callbacks).to receive(:invoke).with(:on_interrupted, task) - expect(callbacks).to receive(:invoke).with(:on_failed, task) - expect(callbacks).to receive(:invoke).with(:on_bad, task) - - worker.send(:post_execution!) - end - end - - context "when result is skipped" do - before do - allow(callbacks).to receive(:empty?).and_return(false) - allow(result).to receive_messages( - state: "interrupted", - status: "skipped", - executed?: false, - good?: true, - bad?: false - ) - end - - it "invokes appropriate callbacks for skipped state" do - expect(callbacks).to receive(:invoke).with(:on_interrupted, task) - expect(callbacks).to receive(:invoke).with(:on_skipped, task) - expect(callbacks).to receive(:invoke).with(:on_good, task) - - worker.send(:post_execution!) - end - end - end - - describe "#finalize_execution!" do - let(:logger) { instance_double(Logger) } - - before do - allow(worker).to receive(:freeze_execution!) - allow(task).to receive(:logger).and_return(logger) - allow(logger).to receive(:info) - allow(task.result).to receive(:to_h).and_return({ id: "123", status: "success" }) - end - - it "freezes the task" do - expect(worker).to receive(:freeze_execution!) - - worker.send(:finalize_execution!) - end - - it "logs the result information at info level" do - expect(task).to receive(:logger) - expect(logger).to receive(:info) - - worker.send(:finalize_execution!) - end - - context "when logger block is called" do - it "calls to_h on task result" do - expect(task.result).to receive(:to_h).and_return({ id: "123", status: "success" }) - - expect(logger).to receive(:info) do |&block| - # When the block is called, it should return the result of to_h - expect(block.call).to eq({ id: "123", status: "success" }) - end - - worker.send(:finalize_execution!) - end - end - end - - describe "#rollback_execution!" do - context "when task does not respond to rollback" do - before do - allow(task).to receive(:respond_to?).with(:rollback).and_return(false) - allow(task.result).to receive(:status).and_return("failed") - end - - it "does not call rollback" do - expect(task).not_to receive(:rollback) - - worker.send(:rollback_execution!) - end - end - - context "when task responds to rollback" do - before do - allow(task).to receive(:respond_to?).with(:rollback).and_return(true) - allow(task).to receive(:rollback) - end - - context "when rollpoints setting exists" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(rollback_on: %w[failed skipped])) - end - - context "when result status is in rollpoints" do - before do - allow(task.result).to receive(:status).and_return("failed") - end - - it "calls rollback and marks result as rolled back" do - expect(task).to receive(:rollback) - - worker.send(:rollback_execution!) - - expect(task.result.rolled_back?).to be(true) - end - end - - context "when result status is not in rollpoints" do - before do - allow(task.result).to receive(:status).and_return("success") - end - - it "does not call rollback" do - expect(task).not_to receive(:rollback) - - worker.send(:rollback_execution!) - end - end - end - - context "when no rollpoints are configured" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(rollback_on: [])) - allow(task.result).to receive(:status).and_return("failed") - end - - it "does not call rollback" do - expect(task).not_to receive(:rollback) - - worker.send(:rollback_execution!) - end - end - - context "when rollpoints is nil" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(rollback_on: nil)) - allow(task.result).to receive(:status).and_return("failed") - end - - it "does not call rollback" do - expect(task).not_to receive(:rollback) - - worker.send(:rollback_execution!) - end - end - - context "with duplicate rollpoints" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(rollback_on: ["failed", "failed", :failed])) - allow(task.result).to receive(:status).and_return("failed") - end - - it "removes duplicates after string conversion and calls rollback" do - expect(task).to receive(:rollback).once - - worker.send(:rollback_execution!) - end - end - - context "with multiple statuses in rollpoints" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(rollback_on: %w[failed skipped])) - end - - it "calls rollback and marks result as rolled back when status is failed" do - allow(task.result).to receive(:status).and_return("failed") - - expect(task).to receive(:rollback) - - worker.send(:rollback_execution!) - - expect(task.result.rolled_back?).to be(true) - end - - it "calls rollback and marks result as rolled back when status is skipped" do - allow(task.result).to receive(:status).and_return("skipped") - - expect(task).to receive(:rollback) - - worker.send(:rollback_execution!) - - expect(task.result.rolled_back?).to be(true) - end - end - end - end - - describe "#verify_middleware_yield!" do - context "when result is still initialized (middleware did not yield)" do - before do - allow(task.result).to receive(:initialized?).and_return(true) - end - - it "marks result as failed and transitions to executed" do - expect(task.resolver).to receive(:fail!).with( - CMDx::Locale.t("cmdx.faults.invalid"), - halt: false, - source: :middleware - ) - expect(task.resolver).to receive(:executed!) - - worker.send(:verify_middleware_yield!) - end - end - - context "when result has progressed past initialized" do - before do - allow(task.result).to receive(:initialized?).and_return(false) - end - - it "does nothing" do - expect(task.resolver).not_to receive(:fail!) - expect(task.resolver).not_to receive(:executed!) - - worker.send(:verify_middleware_yield!) - end - end - - context "when middleware swallows the block during #execute" do - let(:swallowing_task_class) { create_successful_task(name: "SwallowTestTask") } - - before do - swallowing_task_class.settings.middlewares.register( - Class.new do - def self.call(_task, **_opts); end - end - ) - end - - it "detects and fails the result" do - result = swallowing_task_class.execute - - expect(result.failed?).to be(true) - expect(result.metadata[:source]).to eq(:middleware) - expect(result.interrupted?).to be(true) - end - end - end - - describe "#clear_chain!" do - let(:chain) { task.chain } - - after { CMDx::Chain.clear } - - context "when result is the first in the chain" do - it "clears the chain when current matches task chain" do - expect(task.result.index).to eq(0) - expect { worker.send(:clear_chain!) }.to change(CMDx::Chain, :current).to(nil) - end - end - - context "when result is not the first in the chain" do - before do - other_task = task_class.new - described_class.new(other_task) - end - - it "does not clear the chain" do - expect(task.result.index).not_to eq(0) - expect { worker.send(:clear_chain!) }.not_to change(CMDx::Chain, :current) - end - end - - context "when current chain is a different instance" do - it "does not clear the chain" do - worker # force creation so task.chain is established - - other_chain = CMDx::Chain.new - CMDx::Chain.current = other_chain - - worker.send(:clear_chain!) - expect(CMDx::Chain.current).to equal(other_chain) - end - end - end -end diff --git a/spec/cmdx/faults_spec.rb b/spec/cmdx/faults_spec.rb deleted file mode 100644 index 1572309a5..000000000 --- a/spec/cmdx/faults_spec.rb +++ /dev/null @@ -1,189 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Fault, type: :unit do - let(:task_class) { create_successful_task(name: "TestTask") } - let(:task) { task_class.new } - let(:result) do - task.resolver.fail!("test failure reason", halt: false) - task.result - end - - describe "#initialize" do - subject(:fault) { described_class.new(result) } - - it "initializes with result and sets reason from result" do - expect(fault.result).to eq(result) - expect(fault.message).to eq("test failure reason") - end - - it "inherits from CMDx::Error" do - expect(fault).to be_a(CMDx::Error) - end - - it "inherits from StandardError" do - expect(fault).to be_a(StandardError) - end - - context "when result has no reason" do - let(:result) do - task.result.tap do |r| - r.instance_variable_set(:@reason, nil) - end - end - - it "initializes with nil message passed to Error" do - expect(fault.message).to eq("CMDx::Fault") - end - end - end - - describe ".for?" do - let(:task_class_a) { create_successful_task(name: "TaskA") } - let(:task_class_b) { create_successful_task(name: "TaskB") } - let(:task_a) { task_class_a.new } - let(:task_b) { task_class_b.new } - let(:fault_a) { described_class.new(task_a.result) } - let(:fault_b) { described_class.new(task_b.result) } - - context "when matching single task class" do - subject(:custom_fault_class) { described_class.for?(task_class_a) } - - it "returns a new fault class" do - expect(custom_fault_class).to be_a(Class) - expect(custom_fault_class.superclass).to eq(described_class) - end - - it "matches faults from specified task class" do - allow(fault_a).to receive(:task).and_return(fault_a.result.task) - expect(custom_fault_class === fault_a).to be(true) - end - - it "does not match faults from other task classes" do - allow(fault_b).to receive(:task).and_return(fault_b.result.task) - expect(custom_fault_class === fault_b).to be(false) - end - - it "does not match non-fault objects" do - expect(custom_fault_class === "not a fault").to be(false) - end - - it "stores task classes in instance variable" do - expect(custom_fault_class.instance_variable_get(:@tasks)).to eq([task_class_a]) - end - end - - context "when matching multiple task classes" do - subject(:custom_fault_class) { described_class.for?(task_class_a, task_class_b) } - - it "matches faults from any specified task class" do - allow(fault_a).to receive(:task).and_return(fault_a.result.task) - allow(fault_b).to receive(:task).and_return(fault_b.result.task) - - expect(custom_fault_class === fault_a).to be(true) - expect(custom_fault_class === fault_b).to be(true) - end - - it "stores all task classes in instance variable" do - expect(custom_fault_class.instance_variable_get(:@tasks)).to eq([task_class_a, task_class_b]) - end - end - - context "when no task classes provided" do - subject(:custom_fault_class) { described_class.for? } - - it "returns fault class that matches no faults" do - allow(fault_a).to receive(:task).and_return(fault_a.result.task) - allow(fault_b).to receive(:task).and_return(fault_b.result.task) - - expect(custom_fault_class === fault_a).to be(false) - expect(custom_fault_class === fault_b).to be(false) - end - - it "stores empty array in instance variable" do - expect(custom_fault_class.instance_variable_get(:@tasks)).to eq([]) - end - end - end - - describe ".matches?" do - let(:fault_with_metadata) do - task.resolver.fail!("failure", halt: false, metadata: { error_code: 500 }) - described_class.new(task.result) - end - - context "when block is provided" do - subject(:custom_fault_class) do - described_class.matches? { |fault| fault.result.reason == "failure" } - end - - it "returns a new fault class" do - expect(custom_fault_class).to be_a(Class) - expect(custom_fault_class.superclass).to eq(described_class) - end - - it "matches faults that satisfy the block condition" do - expect(custom_fault_class === fault_with_metadata).to be_truthy - end - - it "does not match faults that don't satisfy the block condition" do - simple_fault = described_class.new(result) - expect(custom_fault_class === simple_fault).to be(false) - end - - it "does not match non-fault objects" do - expect(custom_fault_class === "not a fault").to be(false) - end - - it "stores block in instance variable" do - expect(custom_fault_class.instance_variable_get(:@block)).to be_a(Proc) - end - end - - context "when no block is provided" do - it "raises ArgumentError" do - expect { described_class.matches? }.to raise_error(ArgumentError, "block required") - end - end - - context "when block returns falsy values" do - subject(:custom_fault_class) do - described_class.matches? { |fault| fault.result.metadata[:nonexistent] } - end - - it "does not match when block returns nil" do - simple_fault = described_class.new(result) - expect(custom_fault_class === simple_fault).to be_falsy - end - - it "does not match when block returns false" do - custom_false_class = described_class.matches? { |_| false } - simple_fault = described_class.new(result) - expect(custom_false_class === simple_fault).to be_falsy - end - end - - context "when block returns truthy values" do - it "matches when block returns true" do - custom_true_class = described_class.matches? { |_| true } - simple_fault = described_class.new(result) - expect(custom_true_class === simple_fault).to be_truthy - end - - it "matches when block returns truthy object" do - custom_truthy_class = described_class.matches? { |_| "truthy" } - simple_fault = described_class.new(result) - expect(custom_truthy_class === simple_fault).to be_truthy - end - end - end - - describe "task accessor" do - subject(:fault) { described_class.new(result) } - - it "provides access to task through result" do - expect(fault.result.task).to eq(task) - end - end -end diff --git a/spec/cmdx/identifier_spec.rb b/spec/cmdx/identifier_spec.rb index eae822cb4..61168f077 100644 --- a/spec/cmdx/identifier_spec.rb +++ b/spec/cmdx/identifier_spec.rb @@ -2,48 +2,17 @@ require "spec_helper" -RSpec.describe CMDx::Identifier, type: :unit do - subject(:identifier) { described_class } - +RSpec.describe CMDx::Identifier do describe ".generate" do - context "when SecureRandom responds to uuid_v7" do - let(:uuid_v7) { "018f1234-5678-7000-8000-123456789abc" } - - before do - allow(SecureRandom).to receive(:respond_to?).with(:uuid_v7).and_return(true) - end - - it "returns a UUID v7" do - allow(SecureRandom).to receive(:uuid_v7).and_return(uuid_v7) - - expect(identifier.generate).to eq(uuid_v7) - end - - it "does not call the fallback uuid method" do - skip("Not supported on Ruby versions prior to 3.4") unless RubyVersion.min?(3.4) - - expect(SecureRandom).not_to receive(:uuid) - - identifier.generate - end + it "returns a UUID-shaped string" do + id = described_class.generate + expect(id).to be_a(String) + expect(id).to match(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i) end - context "when called multiple times" do - before do - allow(SecureRandom).to receive(:respond_to?).with(:uuid_v7).and_return(true) - end - - it "generates different UUIDs" do - allow(SecureRandom).to receive(:uuid_v7).and_return( - "018f1234-5678-7000-8000-123456789abc", - "018f1234-5678-7000-8000-123456789def" - ) - - first_id = described_class.generate - second_id = described_class.generate - - expect(first_id).not_to eq(second_id) - end + it "returns a different value on each call" do + ids = Array.new(50) { described_class.generate } + expect(ids.uniq.size).to eq(50) end end end diff --git a/spec/cmdx/locale_spec.rb b/spec/cmdx/locale_spec.rb deleted file mode 100644 index 2ee0ee0ef..000000000 --- a/spec/cmdx/locale_spec.rb +++ /dev/null @@ -1,147 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Locale, type: :unit do - subject(:translate_result) { described_class.translate(key, **options) } - - describe "#translate" do - context "when I18n is not defined" do - before { hide_const("I18n") } - - context "with a valid key" do - let(:key) { "cmdx.validators.presence" } - let(:options) { {} } - - it "returns the default translation from EN" do - expect(translate_result).to eq("cannot be empty") - end - end - - context "with a nested key" do - let(:key) { "cmdx.validators.length.min" } - let(:options) { { min: 5 } } - - it "returns the interpolated message" do - expect(translate_result).to eq("length must be at least 5") - end - end - - context "with a key that has multiple interpolations" do - let(:key) { "cmdx.validators.length.within" } - let(:options) { { min: 3, max: 10 } } - - it "interpolates all variables" do - expect(translate_result).to eq("length must be within 3 and 10") - end - end - - context "with a non-existent key" do - let(:key) { "cmdx.non.existent.key" } - let(:options) { {} } - - it "returns a missing translation message" do - expect(translate_result).to eq("Translation missing: cmdx.non.existent.key") - end - end - - context "with an explicit default option" do - let(:key) { "cmdx.non.existent.key" } - let(:options) { { default: "Custom default message" } } - - it "uses the provided default" do - expect(translate_result).to eq("Custom default message") - end - end - - context "with an explicit default option and interpolation" do - let(:key) { "cmdx.non.existent.key" } - let(:options) { { default: "Custom %s message", value: "test" } } - - it "interpolates the custom default" do - expect(translate_result).to eq("Custom test message") - end - end - - context "with a non-string default" do - let(:key) { "cmdx.non.existent.key" } - let(:options) { { default: [:array, "value"] } } - - it "returns the default as-is" do - expect(translate_result).to eq([:array, "value"]) - end - end - - context "with a symbol key" do - let(:key) { :"cmdx.validators.presence" } - let(:options) { {} } - - it "converts the symbol to string and translates" do - expect(translate_result).to eq("cannot be empty") - end - end - - context "with a key containing dots" do - let(:key) { "cmdx.types.big_decimal" } - let(:options) { {} } - - it "properly navigates nested hash structure" do - expect(translate_result).to eq("big decimal") - end - end - - context "with empty options" do - let(:key) { "cmdx.validators.format" } - let(:options) { {} } - - it "returns the translation without interpolation" do - expect(translate_result).to eq("is an invalid format") - end - end - - context "with extra options that are not in the template" do - let(:key) { "cmdx.validators.format" } - let(:options) { { extra: "value", another: "option" } } - - it "ignores extra options and returns the translation" do - expect(translate_result).to eq("is an invalid format") - end - end - end - - context "when I18n is defined" do - let(:i18n_double) { class_double("I18n") } - let(:key) { "cmdx.validators.presence" } - let(:options) { { locale: :es } } - - before do - stub_const("I18n", i18n_double) - end - - it "delegates to I18n.t with the key and options" do - expect(i18n_double).to receive(:t).with(key, **options, default: "cannot be empty").and_return("no puede estar vacío") - expect(translate_result).to eq("no puede estar vacío") - end - - context "when the key is not found in EN" do - let(:key) { "some.unknown.key" } - - it "sets default to nil and delegates to I18n" do - expect(i18n_double).to receive(:t).with(key, **options, default: nil).and_return("some translation") - - translate_result - end - end - - context "with an explicit default option" do - let(:options) { { default: "Custom default", locale: :es } } - - it "preserves the provided default" do - expect(i18n_double).to receive(:t).with(key, **options).and_return("some translation") - - translate_result - end - end - end - end -end diff --git a/spec/cmdx/log_formatters/json_spec.rb b/spec/cmdx/log_formatters/json_spec.rb index 2eadc06fa..e98d22dab 100644 --- a/spec/cmdx/log_formatters/json_spec.rb +++ b/spec/cmdx/log_formatters/json_spec.rb @@ -2,242 +2,29 @@ require "spec_helper" -RSpec.describe CMDx::LogFormatters::JSON, type: :unit do - subject(:formatter) { described_class.new } +RSpec.describe CMDx::LogFormatters::Json do + let(:formatter) { described_class.new } + let(:time) { Time.utc(2026, 4, 10, 12, 0, 0) } - let(:severity) { "INFO" } - let(:time) { Time.new(2023, 12, 15, 10, 30, 45.123454) } - let(:progname) { "TestApp" } - let(:message) { "Test message" } + let(:result) do + k = Class.new(CMDx::Task) { def work; end } + k.execute + end describe "#call" do - context "with typical log parameters" do - it "returns properly formatted JSON with newline" do - result = formatter.call(severity, time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed).to eq( - { - "severity" => "INFO", - "timestamp" => "2023-12-15T10:30:45.123454Z", - "progname" => "TestApp", - "pid" => Process.pid, - "message" => "Test message" - } - ) - expect(result).to end_with("\n") - end - - it "includes current process PID" do - result = formatter.call(severity, time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["pid"]).to eq(Process.pid) - end - - it "formats timestamp in UTC ISO8601 with microseconds" do - result = formatter.call(severity, time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["timestamp"]).to eq("2023-12-15T10:30:45.123454Z") - end - end - - context "with different severity levels" do - %w[DEBUG INFO WARN ERROR FATAL].each do |level| - it "handles #{level} severity" do - result = formatter.call(level, time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["severity"]).to eq(level) - end - end - end - - context "with different time zones" do - it "converts time to UTC" do - local_time = Time.new(2023, 12, 15, 15, 30, 45.123454, "-05:00") - - result = formatter.call(severity, local_time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["timestamp"]).to eq("2023-12-15T20:30:45.123454Z") - end - end - - context "with nil values" do - it "handles nil severity" do - result = formatter.call(nil, time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["severity"]).to be_nil - end - - it "handles nil progname" do - result = formatter.call(severity, time, nil, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["progname"]).to be_nil - end - - it "handles nil message" do - allow(CMDx::Utils::Format).to receive(:to_log).with(nil).and_return(nil) - - result = formatter.call(severity, time, progname, nil) - parsed = JSON.parse(result.chomp) - - expect(parsed["message"]).to be_nil - end - end - - context "with special characters in strings" do - it "properly escapes quotes in severity" do - severity_with_quotes = 'INFO "quoted"' - - result = formatter.call(severity_with_quotes, time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["severity"]).to eq('INFO "quoted"') - end - - it "properly escapes newlines in progname" do - progname_with_newline = "TestApp\nSecondLine" - - result = formatter.call(severity, time, progname_with_newline, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["progname"]).to eq("TestApp\nSecondLine") - end - - it "handles unicode characters" do - unicode_message = "Test message with émojis 🚀" - - allow(CMDx::Utils::Format).to receive(:to_log).with(unicode_message).and_return(unicode_message) - - result = formatter.call(severity, time, progname, unicode_message) - parsed = JSON.parse(result.chomp) - - expect(parsed["message"]).to eq("Test message with émojis 🚀") - end - end - - context "with CMDx objects as message" do - let(:cmdx_hash) { { "state" => "complete", "status" => "success" } } - - it "uses Utils::Format.to_log for message processing" do - allow(CMDx::Utils::Format).to receive(:to_log).with(message).and_return(cmdx_hash) - - expect(CMDx::Utils::Format).to receive(:to_log).with(message) - - result = formatter.call(severity, time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["message"]).to eq(cmdx_hash) - end - - it "handles complex nested structures" do - complex_hash = { - "task" => "ProcessData", - "context" => { "user_id" => 123, "session" => "abc123" }, - "metadata" => { "attempts" => 1, "duration" => 0.45 } - } - - allow(CMDx::Utils::Format).to receive(:to_log).with(message).and_return(complex_hash) - - result = formatter.call(severity, time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["message"]).to eq(complex_hash) - end - end - - context "with numeric and boolean messages" do - it "handles integer messages" do - integer_message = 42 - allow(CMDx::Utils::Format).to receive(:to_log).with(integer_message).and_return(integer_message) - - result = formatter.call(severity, time, progname, integer_message) - parsed = JSON.parse(result.chomp) - - expect(parsed["message"]).to eq(42) - end - - it "handles boolean messages" do - boolean_message = true - - allow(CMDx::Utils::Format).to receive(:to_log).with(boolean_message).and_return(boolean_message) - - result = formatter.call(severity, time, progname, boolean_message) - parsed = JSON.parse(result.chomp) - - expect(parsed["message"]).to be(true) - end - - it "handles float messages" do - float_message = 3.14159 - - allow(CMDx::Utils::Format).to receive(:to_log).with(float_message).and_return(float_message) - - result = formatter.call(severity, time, progname, float_message) - parsed = JSON.parse(result.chomp) - - expect(parsed["message"]).to eq(3.14159) - end - end - - context "with array messages" do - it "handles array messages" do - array_message = %w[item1 item2 item3] - - result = formatter.call(severity, time, progname, array_message) - parsed = JSON.parse(result.chomp) - - expect(parsed["message"]).to eq(%w[item1 item2 item3]) - end - end - - context "with extreme time values" do - it "handles very old timestamps" do - old_time = Time.new(1970, 1, 1, 0, 0, 0.0) - - result = formatter.call(severity, old_time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["timestamp"]).to eq("1970-01-01T00:00:00.000000Z") - end - - it "handles future timestamps" do - future_time = Time.new(2099, 12, 31, 23, 59, 59.999999) - - result = formatter.call(severity, future_time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["timestamp"]).to eq("2099-12-31T23:59:59.999999Z") - end - end - - context "when Utils::Format.to_log raises an error" do - it "allows the error to propagate" do - allow(CMDx::Utils::Format).to receive(:to_log).and_raise(StandardError, "Format error") - - expect do - formatter.call(severity, time, progname, message) - end.to raise_error(StandardError, "Format error") - end - end - - context "with very long strings" do - it "handles large messages without truncation" do - large_message = "x" * 10_000 - - allow(CMDx::Utils::Format).to receive(:to_log).with(large_message).and_return(large_message) - - result = formatter.call(severity, time, progname, large_message) - parsed = JSON.parse(result.chomp) - - expect(parsed["message"]).to eq(large_message) - expect(parsed["message"].length).to eq(10_000) - end + it "formats a Result as JSON with trailing newline" do + line = formatter.call("INFO", time, nil, result) + expect(line).to end_with("\n") + data = JSON.parse(line) + expect(data["status"]).to eq("success") + expect(data["severity"]).to eq("INFO") + end + + it "formats a plain string as JSON with trailing newline" do + line = formatter.call("WARN", time, nil, "ping") + expect(line).to end_with("\n") + data = JSON.parse(line) + expect(data["message"]).to eq("ping") end end end diff --git a/spec/cmdx/log_formatters/key_value_spec.rb b/spec/cmdx/log_formatters/key_value_spec.rb index 52e859084..b2f8c7112 100644 --- a/spec/cmdx/log_formatters/key_value_spec.rb +++ b/spec/cmdx/log_formatters/key_value_spec.rb @@ -2,384 +2,27 @@ require "spec_helper" -RSpec.describe CMDx::LogFormatters::KeyValue, type: :unit do - subject(:formatter) { described_class.new } +RSpec.describe CMDx::LogFormatters::KeyValue do + let(:formatter) { described_class.new } + let(:time) { Time.utc(2026, 4, 10, 12, 0, 0) } - let(:severity) { "INFO" } - let(:time) { Time.new(2023, 12, 15, 10, 30, 45.123454) } - let(:progname) { "TestApp" } - let(:message) { "Test message" } + let(:result) do + k = Class.new(CMDx::Task) { def work; end } + k.execute + end describe "#call" do - context "with typical log parameters" do - it "returns properly formatted key-value string with newline" do - result = formatter.call(severity, time, progname, message) - - expect(result).to eq( - 'severity="INFO" timestamp="2023-12-15T10:30:45.123454Z" progname="TestApp" ' \ - "pid=#{Process.pid} message=\"Test message\"\n" - ) - expect(result).to end_with("\n") - end - - it "includes current process PID" do - result = formatter.call(severity, time, progname, message) - - expect(result).to include("pid=#{Process.pid}") - end - - it "formats timestamp in UTC ISO8601 with microseconds" do - result = formatter.call(severity, time, progname, message) - - expect(result).to include('timestamp="2023-12-15T10:30:45.123454Z"') - end - - it "uses Utils::Format.to_log for message processing" do - allow(CMDx::Utils::Format).to receive(:to_log).with(message).and_return("processed message") - - expect(CMDx::Utils::Format).to receive(:to_log).with(message) - - result = formatter.call(severity, time, progname, message) - - expect(result).to include('message="processed message"') - end - - it "uses Utils::Format.to_str to format the hash" do - expected_hash = { - severity: severity, - timestamp: time.utc.iso8601(6), - progname: progname, - pid: Process.pid, - message: message - } - - allow(CMDx::Utils::Format).to receive(:to_str).with(expected_hash).and_return(+"formatted output") - - expect(CMDx::Utils::Format).to receive(:to_str).with(expected_hash) - - result = formatter.call(severity, time, progname, message) - - expect(result).to eq("formatted output\n") - end - end - - context "with different severity levels" do - %w[DEBUG INFO WARN ERROR FATAL].each do |level| - it "handles #{level} severity" do - result = formatter.call(level, time, progname, message) - - expect(result).to include("severity=\"#{level}\"") - end - end - end - - context "with different time zones" do - it "converts time to UTC" do - local_time = Time.new(2023, 12, 15, 15, 30, 45.123454, "-05:00") - - result = formatter.call(severity, local_time, progname, message) - - expect(result).to include('timestamp="2023-12-15T20:30:45.123454Z"') - end - - it "handles UTC time correctly" do - utc_time = Time.new(2023, 12, 15, 10, 30, 45.123454, "+00:00") - - result = formatter.call(severity, utc_time, progname, message) - - expect(result).to include('timestamp="2023-12-15T10:30:45.123454Z"') - end - end - - context "with nil values" do - it "handles nil severity" do - result = formatter.call(nil, time, progname, message) - - expect(result).to include("severity=nil") - end - - it "handles nil progname" do - result = formatter.call(severity, time, nil, message) - - expect(result).to include("progname=nil") - end - - it "handles nil message" do - allow(CMDx::Utils::Format).to receive(:to_log).with(nil).and_return(nil) - - result = formatter.call(severity, time, progname, nil) - - expect(result).to include("message=nil") - end + it "formats a Result as key=value line with trailing newline" do + line = formatter.call("INFO", time, nil, result) + expect(line).to end_with("\n") + expect(line).to include("severity=INFO") + expect(line).to include("status=#{result.status}") end - context "with special characters in strings" do - it "properly escapes quotes in severity" do - severity_with_quotes = 'INFO "quoted"' - - result = formatter.call(severity_with_quotes, time, progname, message) - - expect(result).to include('severity="INFO \"quoted\""') - end - - it "properly escapes newlines in progname" do - progname_with_newline = "TestApp\nSecondLine" - - result = formatter.call(severity, time, progname_with_newline, message) - - expect(result).to include("progname=\"TestApp\\nSecondLine\"") - end - - it "handles unicode characters" do - unicode_message = "Test message with émojis 🚀" - - allow(CMDx::Utils::Format).to receive(:to_log).with(unicode_message).and_return(unicode_message) - - result = formatter.call(severity, time, progname, unicode_message) - - expect(result).to include("message=\"Test message with émojis 🚀\"") - end - - it "handles backslashes in strings" do - message_with_backslash = "C:\\Windows\\Path" - - allow(CMDx::Utils::Format).to receive(:to_log).with(message_with_backslash).and_return(message_with_backslash) - - result = formatter.call(severity, time, progname, message_with_backslash) - - expect(result).to include("message=\"C:\\\\Windows\\\\Path\"") - end - end - - context "with CMDx objects as message" do - let(:cmdx_hash) { { "state" => "complete", "status" => "success" } } - - it "processes CMDx objects through Utils::Format.to_log" do - allow(CMDx::Utils::Format).to receive(:to_log).with(message).and_return(cmdx_hash) - - expect(CMDx::Utils::Format).to receive(:to_log).with(message) - - result = formatter.call(severity, time, progname, message) - - if RubyVersion.min?(3.4) - expect(result).to include('message={"state" => "complete", "status" => "success"}') - else - expect(result).to include('message={"state"=>"complete", "status"=>"success"}') - end - end - - it "handles complex nested structures" do - complex_hash = { - "task" => "ProcessData", - "context" => { "user_id" => 123, "session" => "abc123" }, - "metadata" => { "attempts" => 1, "duration" => 0.45 } - } - - allow(CMDx::Utils::Format).to receive(:to_log).with(message).and_return(complex_hash) - - result = formatter.call(severity, time, progname, message) - - expect(result).to include("message=#{complex_hash.inspect}") - end - end - - context "with numeric and boolean messages" do - it "handles integer messages" do - integer_message = 42 - - allow(CMDx::Utils::Format).to receive(:to_log).with(integer_message).and_return(integer_message) - - result = formatter.call(severity, time, progname, integer_message) - - expect(result).to include("message=42") - end - - it "handles boolean messages" do - boolean_message = true - - allow(CMDx::Utils::Format).to receive(:to_log).with(boolean_message).and_return(boolean_message) - - result = formatter.call(severity, time, progname, boolean_message) - - expect(result).to include("message=true") - end - - it "handles float messages" do - float_message = 3.14159 - - allow(CMDx::Utils::Format).to receive(:to_log).with(float_message).and_return(float_message) - - result = formatter.call(severity, time, progname, float_message) - - expect(result).to include("message=3.14159") - end - - it "handles false boolean message" do - false_message = false - - allow(CMDx::Utils::Format).to receive(:to_log).with(false_message).and_return(false_message) - - result = formatter.call(severity, time, progname, false_message) - - expect(result).to include("message=false") - end - end - - context "with array messages" do - it "handles array messages" do - array_message = %w[item1 item2 item3] - - allow(CMDx::Utils::Format).to receive(:to_log).with(array_message).and_return(array_message) - - result = formatter.call(severity, time, progname, array_message) - - expect(result).to include('message=["item1", "item2", "item3"]') - end - - it "handles empty array messages" do - empty_array = [] - - allow(CMDx::Utils::Format).to receive(:to_log).with(empty_array).and_return(empty_array) - - result = formatter.call(severity, time, progname, empty_array) - - expect(result).to include("message=[]") - end - end - - context "with extreme time values" do - it "handles very old timestamps" do - old_time = Time.new(1970, 1, 1, 0, 0, 0.0) - - result = formatter.call(severity, old_time, progname, message) - - expect(result).to include('timestamp="1970-01-01T00:00:00.000000Z"') - end - - it "handles future timestamps" do - future_time = Time.new(2099, 12, 31, 23, 59, 59.999999) - - result = formatter.call(severity, future_time, progname, message) - - expect(result).to include('timestamp="2099-12-31T23:59:59.999999Z"') - end - - it "handles time with no microseconds" do - time_no_microseconds = Time.new(2023, 1, 1, 12, 0, 0) - - result = formatter.call(severity, time_no_microseconds, progname, message) - - expect(result).to include('timestamp="2023-01-01T12:00:00.000000Z"') - end - end - - context "when Utils::Format.to_log raises an error" do - it "allows the error to propagate" do - allow(CMDx::Utils::Format).to receive(:to_log).and_raise(StandardError, "Format error") - - expect do - formatter.call(severity, time, progname, message) - end.to raise_error(StandardError, "Format error") - end - end - - context "when Utils::Format.to_str raises an error" do - it "allows the error to propagate" do - allow(CMDx::Utils::Format).to receive(:to_str).and_raise(StandardError, "String format error") - - expect do - formatter.call(severity, time, progname, message) - end.to raise_error(StandardError, "String format error") - end - end - - context "with very long strings" do - it "handles large messages without truncation" do - large_message = "x" * 10_000 - - allow(CMDx::Utils::Format).to receive(:to_log).with(large_message).and_return(large_message) - - result = formatter.call(severity, time, progname, large_message) - - expect(result).to include("message=\"#{'x' * 10_000}\"") - expect(result.length).to be > 10_000 - end - - it "handles large progname" do - large_progname = "LongApplicationName" * 100 - - result = formatter.call(severity, time, large_progname, message) - - expect(result).to include("progname=\"#{large_progname}\"") - end - end - - context "with symbol values" do - it "handles symbol severity" do - symbol_severity = :warning - - result = formatter.call(symbol_severity, time, progname, message) - - expect(result).to include("severity=:warning") - end - - it "handles symbol message" do - symbol_message = :success - - allow(CMDx::Utils::Format).to receive(:to_log).with(symbol_message).and_return(symbol_message) - - result = formatter.call(severity, time, progname, symbol_message) - - expect(result).to include("message=:success") - end - end - - context "with hash message" do - it "handles hash messages" do - hash_message = { key: "value", count: 5 } - - allow(CMDx::Utils::Format).to receive(:to_log).with(hash_message).and_return(hash_message) - - result = formatter.call(severity, time, progname, hash_message) - - if RubyVersion.min?(3.4) - expect(result).to include('message={key: "value", count: 5}') - else - expect(result).to include('message={:key=>"value", :count=>5}') - end - end - - it "handles empty hash messages" do - empty_hash = {} - - allow(CMDx::Utils::Format).to receive(:to_log).with(empty_hash).and_return(empty_hash) - - result = formatter.call(severity, time, progname, empty_hash) - - expect(result).to include("message={}") - end - end - - context "with empty string values" do - it "handles empty string severity" do - result = formatter.call("", time, progname, message) - - expect(result).to include('severity=""') - end - - it "handles empty string progname" do - result = formatter.call(severity, time, "", message) - - expect(result).to include('progname=""') - end - - it "handles empty string message" do - allow(CMDx::Utils::Format).to receive(:to_log).with("").and_return("") - - result = formatter.call(severity, time, progname, "") - - expect(result).to include('message=""') - end + it "formats a plain string with trailing newline" do + line = formatter.call("ERROR", time, nil, "oops") + expect(line).to end_with("\n") + expect(line).to include("message=oops") end end end diff --git a/spec/cmdx/log_formatters/line_spec.rb b/spec/cmdx/log_formatters/line_spec.rb index d8c5179d8..4687fda3f 100644 --- a/spec/cmdx/log_formatters/line_spec.rb +++ b/spec/cmdx/log_formatters/line_spec.rb @@ -2,284 +2,33 @@ require "spec_helper" -RSpec.describe CMDx::LogFormatters::Line, type: :unit do - subject(:formatter) { described_class.new } +RSpec.describe CMDx::LogFormatters::Line do + let(:formatter) { described_class.new } + let(:time) { Time.utc(2026, 4, 10, 12, 0, 0) } - let(:severity) { "INFO" } - let(:time) { Time.new(2023, 12, 15, 10, 30, 45.123454) } - let(:progname) { "TestApp" } - let(:message) { "Test message" } - - describe "#call" do - context "with typical log parameters" do - it "returns properly formatted line string with newline" do - result = formatter.call(severity, time, progname, message) - - expect(result).to eq("I, [2023-12-15T10:30:45.123454Z ##{Process.pid}] INFO -- TestApp: Test message\n") - expect(result).to end_with("\n") - end - - it "includes severity prefix as first character" do - result = formatter.call(severity, time, progname, message) - - expect(result).to start_with("I,") - end - - it "includes current process PID" do - result = formatter.call(severity, time, progname, message) - - expect(result).to include("##{Process.pid}") - end - - it "formats timestamp in UTC ISO8601 with microseconds" do - result = formatter.call(severity, time, progname, message) - - expect(result).to include("[2023-12-15T10:30:45.123454Z") - end - - it "includes full severity name" do - result = formatter.call(severity, time, progname, message) - - expect(result).to include("] INFO --") - end - - it "includes progname and message" do - result = formatter.call(severity, time, progname, message) - - expect(result).to include("TestApp: Test message") - end - end - - context "with different severity levels" do - %w[DEBUG INFO WARN ERROR FATAL].each do |level| - it "handles #{level} severity with correct prefix" do - result = formatter.call(level, time, progname, message) - expected_prefix = level[0] - - expect(result).to start_with("#{expected_prefix},") - expect(result).to include("] #{level} --") - end - end - - it "handles custom severity levels" do - custom_severity = "TRACE" - - result = formatter.call(custom_severity, time, progname, message) - - expect(result).to start_with("T,") - expect(result).to include("] TRACE --") - end - end - - context "with different time zones" do - it "converts time to UTC" do - local_time = Time.new(2023, 12, 15, 15, 30, 45.123454, "-05:00") - - result = formatter.call(severity, local_time, progname, message) - - expect(result).to include("[2023-12-15T20:30:45.123454Z") - end - - it "handles UTC time correctly" do - utc_time = Time.new(2023, 12, 15, 10, 30, 45.123454, "+00:00") - - result = formatter.call(severity, utc_time, progname, message) - - expect(result).to include("[2023-12-15T10:30:45.123454Z") - end - end - - context "with nil values" do - it "handles nil severity" do - expect do - formatter.call(nil, time, progname, message) - end.to raise_error(NoMethodError) - end - - it "handles nil progname" do - result = formatter.call(severity, time, nil, message) - - expect(result).to include("INFO -- : Test message") - end - - it "handles nil message" do - result = formatter.call(severity, time, progname, nil) - - expect(result).to include("TestApp: \n") - end - end - - context "with special characters in strings" do - it "handles quotes in severity" do - severity_with_quotes = 'INFO"quoted"' - - result = formatter.call(severity_with_quotes, time, progname, message) - - expect(result).to include('] INFO"quoted" --') - end - - it "handles newlines in progname" do - progname_with_newline = "TestApp\nSecondLine" - - result = formatter.call(severity, time, progname_with_newline, message) - - expect(result).to include("TestApp\nSecondLine: Test message") - end - - it "handles unicode characters in message" do - unicode_message = "Test message with émojis 🚀" - - result = formatter.call(severity, time, progname, unicode_message) - - expect(result).to include("TestApp: Test message with émojis 🚀") - end - - it "handles backslashes in message" do - message_with_backslash = "C:\\Windows\\Path" - - result = formatter.call(severity, time, progname, message_with_backslash) - - expect(result).to include("TestApp: C:\\Windows\\Path") - end - end - - context "with complex objects as message" do - it "handles hash messages" do - hash_message = { "state" => "complete", "status" => "success" } - - result = formatter.call(severity, time, progname, hash_message) - - if RubyVersion.min?(3.4) - expect(result).to include('TestApp: {"state" => "complete", "status" => "success"}') - else - expect(result).to include('TestApp: {"state"=>"complete", "status"=>"success"}') - end - end - - it "handles array messages" do - array_message = %w[item1 item2 item3] - - result = formatter.call(severity, time, progname, array_message) - - expect(result).to include('TestApp: ["item1", "item2", "item3"]') - end - - it "handles numeric messages" do - numeric_message = 42 - - result = formatter.call(severity, time, progname, numeric_message) - - expect(result).to include("TestApp: 42") - end - - it "handles boolean messages" do - boolean_message = true - - result = formatter.call(severity, time, progname, boolean_message) - - expect(result).to include("TestApp: true") - end - end - - context "with extreme time values" do - it "handles very old timestamps" do - old_time = Time.new(1970, 1, 1, 0, 0, 0.0) - - result = formatter.call(severity, old_time, progname, message) - - expect(result).to include("[1970-01-01T00:00:00.000000Z") - end - - it "handles future timestamps" do - future_time = Time.new(2099, 12, 31, 23, 59, 59.999999) - - result = formatter.call(severity, future_time, progname, message) - - expect(result).to include("[2099-12-31T23:59:59.999999Z") - end - - it "handles time with no microseconds" do - time_no_microseconds = Time.new(2023, 1, 1, 12, 0, 0) - - result = formatter.call(severity, time_no_microseconds, progname, message) - - expect(result).to include("[2023-01-01T12:00:00.000000Z") - end - end - - context "with empty string values" do - it "handles empty string severity" do - result = formatter.call("", time, progname, message) - - expect(result).to start_with(", ") - end - - it "handles empty string progname" do - result = formatter.call(severity, time, "", message) - - expect(result).to include("INFO -- : Test message") + let(:result) do + k = Class.new(CMDx::Task) do + def self.name + "My::Task" end - it "handles empty string message" do - result = formatter.call(severity, time, progname, "") - - expect(result).to include("TestApp: \n") - end + def work; end end + k.execute + end - context "with very long strings" do - it "handles large messages without truncation" do - large_message = "x" * 10_000 - - result = formatter.call(severity, time, progname, large_message) - - expect(result).to include("TestApp: #{'x' * 10_000}") - expect(result.length).to be > 10_000 - end - - it "handles large progname" do - large_progname = "LongApplicationName" * 100 - - result = formatter.call(severity, time, large_progname, message) - - expect(result).to include("#{large_progname}: Test message") - end - - it "handles large severity" do - large_severity = "VERYLONGSEVERITYLEVEL" * 10 - - result = formatter.call(large_severity, time, progname, message) - - expect(result).to start_with("V,") - expect(result).to include("] #{large_severity} --") - end + describe "#call" do + it "formats a Result with trailing newline" do + line = formatter.call("INFO", time, nil, result) + expect(line).to end_with("\n") + expect(line).to include("[SUCCESS]") + expect(line).to include("My::Task") + expect(line).to include(result.task_id) end - context "with symbol values" do - it "handles symbol severity" do - symbol_severity = :warning - - result = formatter.call(symbol_severity, time, progname, message) - - expect(result).to start_with("w,") - expect(result).to include("] warning --") - end - - it "handles symbol progname" do - symbol_progname = :my_app - - result = formatter.call(severity, time, symbol_progname, message) - - expect(result).to include("my_app: Test message") - end - - it "handles symbol message" do - symbol_message = :success - - result = formatter.call(severity, time, progname, symbol_message) - - expect(result).to include("TestApp: success") - end + it "formats a plain string with trailing newline" do + line = formatter.call("INFO", time, nil, "hello") + expect(line).to eq("hello\n") end end end diff --git a/spec/cmdx/log_formatters/logstash_spec.rb b/spec/cmdx/log_formatters/logstash_spec.rb index 49601c020..be1e72cd4 100644 --- a/spec/cmdx/log_formatters/logstash_spec.rb +++ b/spec/cmdx/log_formatters/logstash_spec.rb @@ -2,253 +2,29 @@ require "spec_helper" -RSpec.describe CMDx::LogFormatters::Logstash, type: :unit do - subject(:formatter) { described_class.new } +RSpec.describe CMDx::LogFormatters::Logstash do + let(:formatter) { described_class.new } + let(:time) { Time.utc(2026, 4, 10, 12, 0, 0) } - let(:severity) { "INFO" } - let(:time) { Time.new(2023, 12, 15, 10, 30, 45.123454) } - let(:progname) { "TestApp" } - let(:message) { "Test message" } + let(:result) do + k = Class.new(CMDx::Task) { def work; end } + k.execute + end describe "#call" do - context "with typical log parameters" do - it "returns properly formatted JSON with newline" do - result = formatter.call(severity, time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed).to eq( - { - "@version" => "1", - "@timestamp" => "2023-12-15T10:30:45.123454Z", - "severity" => "INFO", - "progname" => "TestApp", - "pid" => Process.pid, - "message" => "Test message" - } - ) - expect(result).to end_with("\n") - end - - it "includes current process PID" do - result = formatter.call(severity, time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["pid"]).to eq(Process.pid) - end - - it "formats timestamp in UTC ISO8601 with microseconds" do - result = formatter.call(severity, time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["@timestamp"]).to eq("2023-12-15T10:30:45.123454Z") - end - - it "includes Logstash version field" do - result = formatter.call(severity, time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["@version"]).to eq("1") - end - end - - context "with different severity levels" do - %w[DEBUG INFO WARN ERROR FATAL].each do |level| - it "handles #{level} severity" do - result = formatter.call(level, time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["severity"]).to eq(level) - end - end - end - - context "with different time zones" do - it "converts time to UTC" do - local_time = Time.new(2023, 12, 15, 15, 30, 45.123454, "-05:00") - - result = formatter.call(severity, local_time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["@timestamp"]).to eq("2023-12-15T20:30:45.123454Z") - end - end - - context "with nil values" do - it "handles nil severity" do - result = formatter.call(nil, time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["severity"]).to be_nil - end - - it "handles nil progname" do - result = formatter.call(severity, time, nil, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["progname"]).to be_nil - end - - it "handles nil message" do - allow(CMDx::Utils::Format).to receive(:to_log).with(nil).and_return(nil) - - result = formatter.call(severity, time, progname, nil) - parsed = JSON.parse(result.chomp) - - expect(parsed["message"]).to be_nil - end - end - - context "with special characters in strings" do - it "properly escapes quotes in severity" do - severity_with_quotes = 'INFO "quoted"' - - result = formatter.call(severity_with_quotes, time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["severity"]).to eq('INFO "quoted"') - end - - it "properly escapes newlines in progname" do - progname_with_newline = "TestApp\nSecondLine" - - result = formatter.call(severity, time, progname_with_newline, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["progname"]).to eq("TestApp\nSecondLine") - end - - it "handles unicode characters" do - unicode_message = "Test message with émojis 🚀" - - allow(CMDx::Utils::Format).to receive(:to_log).with(unicode_message).and_return(unicode_message) - - result = formatter.call(severity, time, progname, unicode_message) - parsed = JSON.parse(result.chomp) - - expect(parsed["message"]).to eq("Test message with émojis 🚀") - end - end - - context "with CMDx objects as message" do - let(:cmdx_hash) { { "state" => "complete", "status" => "success" } } - - it "uses Utils::Format.to_log for message processing" do - allow(CMDx::Utils::Format).to receive(:to_log).with(message).and_return(cmdx_hash) - - expect(CMDx::Utils::Format).to receive(:to_log).with(message) - - result = formatter.call(severity, time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["message"]).to eq(cmdx_hash) - end - - it "handles complex nested structures" do - complex_hash = { - "task" => "ProcessData", - "context" => { "user_id" => 123, "session" => "abc123" }, - "metadata" => { "attempts" => 1, "duration" => 0.45 } - } - - allow(CMDx::Utils::Format).to receive(:to_log).with(message).and_return(complex_hash) - - result = formatter.call(severity, time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["message"]).to eq(complex_hash) - end - end - - context "with numeric and boolean messages" do - it "handles integer messages" do - integer_message = 42 - - allow(CMDx::Utils::Format).to receive(:to_log).with(integer_message).and_return(integer_message) - - result = formatter.call(severity, time, progname, integer_message) - parsed = JSON.parse(result.chomp) - - expect(parsed["message"]).to eq(42) - end - - it "handles boolean messages" do - boolean_message = true - - allow(CMDx::Utils::Format).to receive(:to_log).with(boolean_message).and_return(boolean_message) - - result = formatter.call(severity, time, progname, boolean_message) - parsed = JSON.parse(result.chomp) - - expect(parsed["message"]).to be(true) - end - - it "handles float messages" do - float_message = 3.14159 - - allow(CMDx::Utils::Format).to receive(:to_log).with(float_message).and_return(float_message) - - result = formatter.call(severity, time, progname, float_message) - parsed = JSON.parse(result.chomp) - - expect(parsed["message"]).to eq(3.14159) - end - end - - context "with array messages" do - it "handles array messages" do - array_message = %w[item1 item2 item3] - - allow(CMDx::Utils::Format).to receive(:to_log).with(array_message).and_return(array_message) - - result = formatter.call(severity, time, progname, array_message) - parsed = JSON.parse(result.chomp) - - expect(parsed["message"]).to eq(%w[item1 item2 item3]) - end - end - - context "with extreme time values" do - it "handles very old timestamps" do - old_time = Time.new(1970, 1, 1, 0, 0, 0.0) - - result = formatter.call(severity, old_time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["@timestamp"]).to eq("1970-01-01T00:00:00.000000Z") - end - - it "handles future timestamps" do - future_time = Time.new(2099, 12, 31, 23, 59, 59.999999) - - result = formatter.call(severity, future_time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["@timestamp"]).to eq("2099-12-31T23:59:59.999999Z") - end - end - - context "when Utils::Format.to_log raises an error" do - it "allows the error to propagate" do - allow(CMDx::Utils::Format).to receive(:to_log).and_raise(StandardError, "Format error") - - expect do - formatter.call(severity, time, progname, message) - end.to raise_error(StandardError, "Format error") - end - end - - context "with very long strings" do - it "handles large messages without truncation" do - large_message = "x" * 10_000 - - allow(CMDx::Utils::Format).to receive(:to_log).with(large_message).and_return(large_message) - - result = formatter.call(severity, time, progname, large_message) - parsed = JSON.parse(result.chomp) - - expect(parsed["message"]).to eq(large_message) - expect(parsed["message"].length).to eq(10_000) - end + it "formats a Result as Logstash JSON with trailing newline" do + line = formatter.call("INFO", time, nil, result) + expect(line).to end_with("\n") + data = JSON.parse(line) + expect(data["@version"]).to eq("1") + expect(data["status"]).to eq(result.status) + end + + it "formats a plain string with trailing newline" do + line = formatter.call("WARN", time, nil, "x") + expect(line).to end_with("\n") + data = JSON.parse(line) + expect(data["message"]).to eq("x") end end end diff --git a/spec/cmdx/log_formatters/raw_spec.rb b/spec/cmdx/log_formatters/raw_spec.rb index e729326c8..66b344315 100644 --- a/spec/cmdx/log_formatters/raw_spec.rb +++ b/spec/cmdx/log_formatters/raw_spec.rb @@ -2,202 +2,25 @@ require "spec_helper" -RSpec.describe CMDx::LogFormatters::Raw, type: :unit do - subject(:formatter) { described_class.new } +RSpec.describe CMDx::LogFormatters::Raw do + let(:formatter) { described_class.new } + let(:time) { Time.utc(2026, 4, 10, 12, 0, 0) } - let(:severity) { "INFO" } - let(:time) { Time.new(2023, 12, 15, 10, 30, 45.123454) } - let(:progname) { "TestApp" } - let(:message) { "Test message" } + let(:result) do + k = Class.new(CMDx::Task) { def work; end } + k.execute + end describe "#call" do - context "with typical log parameters" do - it "returns message with newline" do - result = formatter.call(severity, time, progname, message) - - expect(result).to eq("Test message\n") - end - - it "ends with newline" do - result = formatter.call(severity, time, progname, message) - - expect(result).to end_with("\n") - end - - it "ignores severity parameter" do - result1 = formatter.call("DEBUG", time, progname, message) - result2 = formatter.call("FATAL", time, progname, message) - - expect(result1).to eq(result2) - expect(result1).to eq("Test message\n") - end - - it "ignores time parameter" do - time1 = Time.new(2020, 1, 1) - time2 = Time.new(2030, 12, 31) - result1 = formatter.call(severity, time1, progname, message) - result2 = formatter.call(severity, time2, progname, message) - - expect(result1).to eq(result2) - expect(result1).to eq("Test message\n") - end - - it "ignores progname parameter" do - result1 = formatter.call(severity, time, "App1", message) - result2 = formatter.call(severity, time, "App2", message) - - expect(result1).to eq(result2) - expect(result1).to eq("Test message\n") - end - end - - context "with nil values" do - it "handles nil severity" do - result = formatter.call(nil, time, progname, message) - - expect(result).to eq("Test message\n") - end - - it "handles nil time" do - result = formatter.call(severity, nil, progname, message) - - expect(result).to eq("Test message\n") - end - - it "handles nil progname" do - result = formatter.call(severity, time, nil, message) - - expect(result).to eq("Test message\n") - end - - it "handles nil message" do - result = formatter.call(severity, time, progname, nil) - - expect(result).to eq("\n") - end - end - - context "with special characters in message" do - it "handles newlines in message" do - message_with_newline = "Line 1\nLine 2" - - result = formatter.call(severity, time, progname, message_with_newline) - - expect(result).to eq("Line 1\nLine 2\n") - end - - it "handles unicode characters in message" do - unicode_message = "Test message with émojis 🚀" - - result = formatter.call(severity, time, progname, unicode_message) - - expect(result).to eq("Test message with émojis 🚀\n") - end - - it "handles backslashes in message" do - message_with_backslash = "C:\\Windows\\Path" - - result = formatter.call(severity, time, progname, message_with_backslash) - - expect(result).to eq("C:\\Windows\\Path\n") - end - - it "handles quotes in message" do - message_with_quotes = 'Message with "quotes" and \'apostrophes\'' - - result = formatter.call(severity, time, progname, message_with_quotes) - - expect(result).to eq("Message with \"quotes\" and 'apostrophes'\n") - end + it "formats a Result using to_h with trailing newline" do + line = formatter.call("INFO", time, nil, result) + expect(line).to end_with("\n") + expect(line).to include("success") end - context "with complex objects as message" do - it "handles hash messages" do - hash_message = { "state" => "complete", "status" => "success" } - - result = formatter.call(severity, time, progname, hash_message) - - if RubyVersion.min?(3.4) - expect(result).to eq("{\"state\" => \"complete\", \"status\" => \"success\"}\n") - else - expect(result).to eq("{\"state\"=>\"complete\", \"status\"=>\"success\"}\n") - end - end - - it "handles array messages" do - array_message = %w[item1 item2 item3] - - result = formatter.call(severity, time, progname, array_message) - - expect(result).to eq("[\"item1\", \"item2\", \"item3\"]\n") - end - - it "handles numeric messages" do - numeric_message = 42 - - result = formatter.call(severity, time, progname, numeric_message) - - expect(result).to eq("42\n") - end - - it "handles boolean messages" do - boolean_message = true - - result = formatter.call(severity, time, progname, boolean_message) - - expect(result).to eq("true\n") - end - - it "handles symbol messages" do - symbol_message = :success - - result = formatter.call(severity, time, progname, symbol_message) - - expect(result).to eq("success\n") - end - end - - context "with empty string values" do - it "handles empty string message" do - result = formatter.call(severity, time, progname, "") - - expect(result).to eq("\n") - end - - it "handles empty string for other parameters" do - result = formatter.call("", nil, "", message) - - expect(result).to eq("Test message\n") - end - end - - context "with very long strings" do - it "handles large messages without truncation" do - large_message = "x" * 10_000 - - result = formatter.call(severity, time, progname, large_message) - - expect(result).to eq("#{'x' * 10_000}\n") - expect(result.length).to eq(10_001) - end - end - - context "with different message types" do - it "handles string interpolation correctly" do - interpolated_message = "Value is 42" - - result = formatter.call(severity, time, progname, interpolated_message) - - expect(result).to eq("Value is 42\n") - end - - it "handles frozen strings" do - frozen_message = "Frozen message" - - result = formatter.call(severity, time, progname, frozen_message) - - expect(result).to eq("Frozen message\n") - end + it "formats a plain value with trailing newline" do + line = formatter.call("INFO", time, nil, "plain") + expect(line).to eq("plain\n") end end end diff --git a/spec/cmdx/middleware_registry_spec.rb b/spec/cmdx/middleware_registry_spec.rb index df5662468..56f638fbb 100644 --- a/spec/cmdx/middleware_registry_spec.rb +++ b/spec/cmdx/middleware_registry_spec.rb @@ -2,452 +2,103 @@ require "spec_helper" -RSpec.describe CMDx::MiddlewareRegistry, type: :unit do - subject(:registry) { described_class.new(initial_registry) } +module CmdxMiddlewareRegistrySpecFixtures - let(:initial_registry) { [] } - let(:mock_task) { instance_double(CMDx::Task) } - let(:mock_middleware1) { instance_double("MockMiddleware1", call: nil) } - let(:mock_middleware2) { instance_double("MockMiddleware2", call: nil) } + module OuterMw - describe "#initialize" do - context "when no registry is provided" do - subject(:registry) { described_class.new } - - it "initializes with an empty array" do - expect(registry.registry).to eq([]) - end - end - - context "when a registry is provided" do - let(:initial_registry) { [[mock_middleware1, {}]] } - - it "initializes with the provided registry" do - expect(registry.registry).to eq([[mock_middleware1, {}]]) - end + def self.call(task, *, &) + task.context[:trace] ||= [] + task.context[:trace] << :outer_before + yield + task.context[:trace] << :outer_after end - end - - describe "#registry" do - let(:initial_registry) { [[mock_middleware1, {}]] } - it "returns the internal registry array" do - expect(registry.registry).to eq([[mock_middleware1, {}]]) - end end - describe "#to_a" do - let(:initial_registry) { [[mock_middleware1, {}]] } + module InnerMw - it "returns the registry array" do - expect(registry.to_a).to eq([[mock_middleware1, {}]]) + def self.call(task, *, &) + task.context[:trace] ||= [] + task.context[:trace] << :inner_before + yield + task.context[:trace] << :inner_after end - it "is an alias for registry" do - expect(registry.method(:to_a)).to eq(registry.method(:registry)) - end end - describe "#dup" do - let(:initial_registry) { [[mock_middleware1, { option: "value" }]] } + module NoYieldMw - it "returns a new MiddlewareRegistry instance" do - duplicated = registry.dup - - expect(duplicated).to be_a(described_class) - expect(duplicated).not_to be(registry) - end - - it "shares the parent registry until first write" do - duplicated = registry.dup - - expect(duplicated.registry).to eq(registry.registry) - expect(duplicated.registry).to be(registry.registry) - end - - it "materializes on write and does not affect the parent" do - duplicated = registry.dup - - duplicated.register(mock_middleware2) - - expect(duplicated.registry.size).to eq(2) - expect(registry.registry.size).to eq(1) - expect(duplicated.registry).not_to be(registry.registry) + def self.call(_task, *) + # intentionally does not yield end - it "materializes on deregister and does not affect the parent" do - duplicated = registry.dup - - duplicated.deregister(mock_middleware1) - - expect(duplicated.registry).to be_empty - expect(registry.registry.size).to eq(1) - end - - context "when registry is empty" do - let(:initial_registry) { [] } - - it "returns a new empty MiddlewareRegistry" do - duplicated = registry.dup - - expect(duplicated.registry).to eq([]) - expect(duplicated).not_to be(registry) - end - end end - describe "#register" do - context "when registering middleware without options" do - it "adds the middleware to the end of the registry" do - registry.register(mock_middleware1) - - expect(registry.registry).to eq([[mock_middleware1, {}]]) - end - - it "returns self for method chaining" do - result = registry.register(mock_middleware1) - - expect(result).to be(registry) - end - end - - context "when registering middleware with options" do - let(:options) { { timeout: 30, retry: true } } - - it "stores the middleware with its options" do - registry.register(mock_middleware1, **options) - - expect(registry.registry).to eq([[mock_middleware1, options]]) - end - end - - context "when registering middleware at a specific position" do - let(:initial_registry) { [[mock_middleware1, {}]] } - - it "inserts at the beginning when at: 0" do - registry.register(mock_middleware2, at: 0) - - expect(registry.registry).to eq( - [ - [mock_middleware2, {}], - [mock_middleware1, {}] - ] - ) - end - - it "inserts at a specific index" do - registry.register(mock_middleware2, at: 1) - - expect(registry.registry).to eq( - [ - [mock_middleware1, {}], - [mock_middleware2, {}] - ] - ) - end - - it "inserts at the end when at: -1 (default)" do - registry.register(mock_middleware2, at: -1) - - expect(registry.registry).to eq( - [ - [mock_middleware1, {}], - [mock_middleware2, {}] - ] - ) - end - end - - context "when registering multiple middlewares" do - it "maintains insertion order" do - registry.register(mock_middleware1) - registry.register(mock_middleware2) - - expect(registry.registry).to eq( - [ - [mock_middleware1, {}], - [mock_middleware2, {}] - ] - ) - end - end - - context "when registering middleware with position and options" do - let(:initial_registry) { [[mock_middleware1, {}]] } - let(:options) { { timeout: 30 } } +end - it "inserts at specified position with options" do - registry.register(mock_middleware2, at: 0, **options) +RSpec.describe CMDx::MiddlewareRegistry do + let(:registry) { described_class.new } - expect(registry.registry).to eq( - [ - [mock_middleware2, options], - [mock_middleware1, {}] - ] - ) + let(:task_class) do + Class.new(CMDx::Task) do + def work + context[:ran] = true end end end - describe "#deregister" do - context "when deregistering middleware without options" do - before do - registry.register(mock_middleware1) - registry.register(mock_middleware2) - end - - it "removes the middleware from the registry" do - registry.deregister(mock_middleware1) - - expect(registry.registry).to eq([[mock_middleware2, {}]]) - end - - it "returns self for method chaining" do - result = registry.deregister(mock_middleware1) - - expect(result).to be(registry) - end - end - - context "when deregistering middleware with options" do - let(:options) { { timeout: 30, retry: true } } - - before do - registry.register(mock_middleware1, **options) - registry.register(mock_middleware2) - end - - it "removes all instances of the middleware regardless of options" do - registry.deregister(mock_middleware1) - - expect(registry.registry).to eq([[mock_middleware2, {}]]) - end - - it "removes middleware with any options" do - registry.register(mock_middleware1, timeout: 60) - registry.deregister(mock_middleware1) - - expect(registry.registry).to eq([[mock_middleware2, {}]]) - expect(registry.registry).not_to include([mock_middleware1, { timeout: 60 }]) - expect(registry.registry).not_to include([mock_middleware1, options]) - end - end - - context "when deregistering from empty registry" do - it "does not raise an error" do - expect { registry.deregister(mock_middleware1) }.not_to raise_error - end - - it "returns self" do - result = registry.deregister(mock_middleware1) - - expect(result).to be(registry) - end - end - - context "when deregistering non-existent middleware" do - before do - registry.register(mock_middleware1) - end - - it "does not affect existing middleware" do - registry.deregister(mock_middleware2) - - expect(registry.registry).to include([mock_middleware1, {}]) - end - - it "returns self" do - result = registry.deregister(mock_middleware2) - - expect(result).to be(registry) - end - end - - context "when deregistering middleware registered multiple times" do - let(:options1) { { timeout: 30 } } - let(:options2) { { timeout: 60 } } - - before do - registry.register(mock_middleware1, **options1) - registry.register(mock_middleware1, **options2) - end - - it "removes all instances of the middleware" do - registry.deregister(mock_middleware1) - - expect(registry.registry).to be_empty - end - end - - context "when deregistering one of multiple middleware" do - before do - registry.register(mock_middleware1) - registry.register(mock_middleware2) - end - - it "removes only the specified middleware" do - registry.deregister(mock_middleware1) - - expect(registry.registry).not_to include([mock_middleware1, {}]) - expect(registry.registry).to include([mock_middleware2, {}]) - end - - it "maintains order of remaining middleware" do - registry.register(mock_middleware1) # Now we have [mw1, mw2, mw1] - registry.deregister(mock_middleware2) - - expect(registry.registry).to eq([ - [mock_middleware1, {}], - [mock_middleware1, {}] - ]) - end - end + let(:task) do + t = task_class.allocate + t.instance_variable_set(:@context, CMDx::Context.new) + t end - describe "#call!" do - let(:block_result) { "block_executed" } - let(:test_block) { proc { |_task| block_result } } - - context "when no block is given" do - it "raises ArgumentError" do - expect { registry.call!(mock_task) }.to raise_error(ArgumentError, "block required") - end + describe "#register and #deregister" do + it "adds and removes middleware entries" do + registry.register(CmdxMiddlewareRegistrySpecFixtures::OuterMw) + expect(registry.stack.size).to eq(1) + registry.deregister(CmdxMiddlewareRegistrySpecFixtures::OuterMw) + expect(registry.stack).to be_empty end + end - context "when registry is empty" do - it "yields to the block immediately" do - result = registry.call!(mock_task, &test_block) - - expect(result).to eq(block_result) - end - - it "passes the task to the block" do - yielded_task = nil - registry.call!(mock_task) { |task| yielded_task = task } - - expect(yielded_task).to be(mock_task) - end - end - - context "when registry has one middleware" do - before do - registry.register(mock_middleware1) - end - - it "calls the middleware with empty options by default" do - expect(mock_middleware1).to receive(:call).with(mock_task).and_yield - - registry.call!(mock_task, &test_block) - end - - it "yields the result when middleware calls the block" do - allow(mock_middleware1).to receive(:call).and_yield - - result = registry.call!(mock_task, &test_block) - - expect(result).to eq(block_result) - end + describe "#call" do + it "returns the block value when the stack is empty" do + expect(registry.call(task) { 42 }).to eq(42) end - context "when middleware is registered with options" do - let(:options) { { timeout: 30, retry: true } } - - before do - registry.register(mock_middleware1, **options) - end - - it "passes options to the middleware" do - expect(mock_middleware1).to receive(:call).with(mock_task, **options).and_yield - - registry.call!(mock_task, &test_block) - end - end - - context "when registry has multiple middlewares" do - before do - registry.register(mock_middleware1) - registry.register(mock_middleware2) - end - - it "calls middlewares in order" do - call_order = [] - - allow(mock_middleware1).to receive(:call) do |_task, &block| - call_order << :middleware1 - block.call - end - allow(mock_middleware2).to receive(:call) do |_task, &block| - call_order << :middleware2 - block.call - end - - registry.call!(mock_task) { call_order << :block } - - expect(call_order).to eq(%i[middleware1 middleware2 block]) - end - - it "passes the task through the middleware chain" do - expect(mock_middleware1).to receive(:call).with(mock_task).and_yield - expect(mock_middleware2).to receive(:call).with(mock_task).and_yield - - registry.call!(mock_task, &test_block) - end - - it "returns the final result from the block" do - allow(mock_middleware1).to receive(:call).and_yield - allow(mock_middleware2).to receive(:call).and_yield + it "wraps execution in an onion chain (outer registered first)" do + registry.register(CmdxMiddlewareRegistrySpecFixtures::OuterMw) + registry.register(CmdxMiddlewareRegistrySpecFixtures::InnerMw) - result = registry.call!(mock_task, &test_block) + registry.call(task) { task.context[:inner] = true } - expect(result).to eq(block_result) - end + expect(task.context[:inner]).to be(true) + expect(task.context[:trace]).to eq(%i[outer_before inner_before inner_after outer_after]) end - context "when middleware modifies the result" do - let(:middleware_result) { "modified_result" } - - before do - registry.register(mock_middleware1) - - allow(mock_middleware1).to receive(:call) do |_task, &block| - block.call - middleware_result - end - end - - it "returns the middleware's result" do - result = registry.call!(mock_task, &test_block) - - expect(result).to eq(middleware_result) - end + it "raises MiddlewareError when middleware does not yield" do + registry.register(CmdxMiddlewareRegistrySpecFixtures::NoYieldMw) + expect { registry.call(task) { :ok } }.to raise_error(CMDx::MiddlewareError, /did not yield/) end + end - context "when middleware doesn't yield" do - before do - registry.register(mock_middleware1) - - allow(mock_middleware1).to receive(:call).and_return("middleware_stopped") - end - - it "stops execution and returns middleware result" do - block_called = false - result = registry.call!(mock_task) { block_called = true } - - expect(block_called).to be(false) - expect(result).to eq("middleware_stopped") - end + describe "#any?" do + it "reflects whether middleware is registered" do + expect(registry.any?).to be(false) + registry.register(CmdxMiddlewareRegistrySpecFixtures::OuterMw) + expect(registry.any?).to be(true) end + end - context "when middleware raises an error" do - before do - registry.register(mock_middleware1) - - allow(mock_middleware1).to receive(:call).and_raise(StandardError, "middleware error") - end + describe "#for_child" do + it "duplicates the stack for copy-on-write" do + registry.register(CmdxMiddlewareRegistrySpecFixtures::OuterMw) + child = registry.for_child + child.register(CmdxMiddlewareRegistrySpecFixtures::InnerMw) - it "propagates the error" do - expect { registry.call!(mock_task, &test_block) }.to raise_error(StandardError, "middleware error") - end + expect(registry.stack.size).to eq(1) + expect(child.stack.size).to eq(2) end end end diff --git a/spec/cmdx/middlewares/correlate_spec.rb b/spec/cmdx/middlewares/correlate_spec.rb index 024185e3e..b47b52137 100644 --- a/spec/cmdx/middlewares/correlate_spec.rb +++ b/spec/cmdx/middlewares/correlate_spec.rb @@ -2,443 +2,33 @@ require "spec_helper" -RSpec.describe CMDx::Middlewares::Correlate, type: :unit do - subject(:correlate) { described_class } +RSpec.describe CMDx::Middlewares::Correlate do + let(:task_class) do + Class.new(CMDx::Task) do + register CMDx::Middlewares::Correlate - let(:task) { double("CMDx::Task", id: "task-123", result: result) } # rubocop:disable RSpec/VerifiedDoubles - let(:result) { instance_double(CMDx::Result, metadata: metadata) } - let(:metadata) { {} } - - before do - described_class.clear - end - - describe ".id" do - context "when no correlation ID is set" do - it "returns nil" do - expect(correlate.id).to be_nil - end - end - - context "when correlation ID is set" do - before { correlate.id = "test-correlation-id" } - - it "returns the correlation ID" do - expect(correlate.id).to eq("test-correlation-id") - end + def work; end end end - describe ".id=" do - it "sets the correlation ID in fiber or thread local storage" do - correlate.id = "new-correlation-id" - - expect(correlate.id).to eq("new-correlation-id") - end - - it "overwrites existing correlation ID" do - correlate.id = "first-id" - correlate.id = "second-id" - - expect(correlate.id).to eq("second-id") - end - - it "accepts nil values" do - correlate.id = "some-id" - correlate.id = nil - - expect(correlate.id).to be_nil - end + it "sets correlation_id in the context" do + result = task_class.execute + id = result.context[:correlation_id] + expect(id).to be_a(String) + expect(id).not_to be_empty end - describe ".clear" do - it "sets correlation ID to nil" do - correlate.id = "test-id" - - correlate.clear - - expect(correlate.id).to be_nil - end - - context "when no ID was set" do - it "remains nil" do - correlate.clear - - expect(correlate.id).to be_nil - end - end + it "reuses an existing correlation_id and still yields" do + result = task_class.execute(correlation_id: "fixed-id") + expect(result.context[:correlation_id]).to eq("fixed-id") + expect(result).to be_success end - describe ".use" do - let(:new_id) { "temporary-id" } - let(:block_result) { "block executed" } - - context "when no existing ID is set" do - it "sets the new ID during block execution" do - called_with_id = nil - - correlate.use(new_id) do - called_with_id = correlate.id - end - - expect(called_with_id).to eq(new_id) - end - - it "clears the ID after block execution" do - correlate.use(new_id) { nil } - - expect(correlate.id).to be_nil - end - - it "returns the block result" do - result = correlate.use(new_id) { block_result } - - expect(result).to eq(block_result) - end - end - - context "when existing ID is set" do - let(:original_id) { "original-id" } - - before { correlate.id = original_id } - - it "temporarily replaces the ID during block execution" do - called_with_id = nil - - correlate.use(new_id) do - called_with_id = correlate.id - end - - expect(called_with_id).to eq(new_id) - end - - it "restores the original ID after block execution" do - correlate.use(new_id) { nil } - - expect(correlate.id).to eq(original_id) - end - - it "restores original ID even when block raises error" do - expect do - correlate.use(new_id) { raise StandardError, "test error" } - end.to raise_error(StandardError, "test error") - - expect(correlate.id).to eq(original_id) - end - end - - context "when block raises an error" do - it "ensures cleanup and re-raises the error" do - expect do - correlate.use(new_id) { raise ArgumentError, "block error" } - end.to raise_error(ArgumentError, "block error") - end - end - end - - describe ".call" do - let(:block_result) { "task executed" } - let(:test_block) { proc { block_result } } - - before do - allow(CMDx::Identifier).to receive(:generate).and_return("generated-uuid") - end - - context "when no id option is provided" do - context "with no existing correlation ID" do - it "generates a new correlation ID using Identifier.generate" do - expect(CMDx::Identifier).to receive(:generate) - - correlate.call(task, &test_block) - end - - it "sets the generated ID in metadata" do - correlate.call(task, &test_block) - - expect(metadata[:correlation_id]).to eq("generated-uuid") - end - end - - context "with existing correlation ID" do - before { correlate.id = "existing-id" } - - it "uses the existing correlation ID" do - expect(CMDx::Identifier).not_to receive(:generate) - - correlate.call(task, &test_block) - - expect(metadata[:correlation_id]).to eq("existing-id") - end - end - end - - context "when id option is a Symbol" do - let(:method_name) { :id } - - it "calls the method on the task" do - expect(task).to receive(method_name) - - correlate.call(task, id: method_name, &test_block) - end - - it "uses the method result as correlation ID" do - correlate.call(task, id: method_name, &test_block) - - expect(metadata[:correlation_id]).to eq("task-123") - end - end - - context "when id option is a Proc" do - let(:id_proc) { proc { "proc-generated-id" } } - - it "uses the proc result as correlation ID" do - correlate.call(task, id: id_proc, &test_block) - - expect(metadata[:correlation_id]).to eq("proc-generated-id") - end - end - - context "when id option responds to call" do - let(:callable) { instance_double("MockCallable", call: "callable-id") } - - it "calls the callable with the task" do - expect(callable).to receive(:call).with(task) - - correlate.call(task, id: callable, &test_block) - end - - it "uses the callable result as correlation ID" do - correlate.call(task, id: callable, &test_block) - - expect(metadata[:correlation_id]).to eq("callable-id") - end - end - - context "when id option is a string value" do - let(:static_id) { "static-correlation-id" } - - it "uses the static value as correlation ID" do - expect(CMDx::Identifier).not_to receive(:generate) - - correlate.call(task, id: static_id, &test_block) - - expect(metadata[:correlation_id]).to eq(static_id) - end - end - - context "when id option is nil" do - context "with no existing correlation ID" do - it "generates a new correlation ID" do - expect(CMDx::Identifier).to receive(:generate) - - correlate.call(task, id: nil, &test_block) - - expect(metadata[:correlation_id]).to eq("generated-uuid") - end - end - - context "with existing correlation ID" do - before { correlate.id = "existing-id" } - - it "uses the existing correlation ID" do - expect(CMDx::Identifier).not_to receive(:generate) - - correlate.call(task, id: nil, &test_block) - - expect(metadata[:correlation_id]).to eq("existing-id") - end - end - end - - context "when id option is false" do - it "generates a new correlation ID when falsy value provided" do - expect(CMDx::Identifier).to receive(:generate) - - correlate.call(task, id: false, &test_block) - - expect(metadata[:correlation_id]).to eq("generated-uuid") - end - end - - it "executes the block with the correlation ID set" do - called_with_id = nil - - correlate.call(task, id: "test-id") do - called_with_id = correlate.id - end - - expect(called_with_id).to eq("test-id") - end - - it "returns the block result" do - result = correlate.call(task, id: "test-id", &test_block) - - expect(result).to eq(block_result) - end - - it "restores the original correlation ID after execution" do - original_id = "original-id" - correlate.id = original_id - - correlate.call(task, id: "temporary-id", &test_block) - - expect(correlate.id).to eq(original_id) - end - - context "when block raises an error" do - let(:error_block) { proc { raise StandardError, "execution error" } } - - it "ensures cleanup and re-raises the error" do - original_id = "original-id" - correlate.id = original_id - - expect do - correlate.call(task, id: "temp-id", &error_block) - end.to raise_error(StandardError, "execution error") - - expect(correlate.id).to eq(original_id) - end - - it "still sets the correlation ID in metadata before cleanup" do - correlate.call(task, id: "error-id") do - task.result.metadata[:correlation_id] = "error-id" - raise StandardError, "execution error" - end - rescue StandardError - expect(metadata[:correlation_id]).to eq("error-id") - end - end - - context "with conditional execution using 'if'" do - before do - allow(task).to receive(:should_correlate?).and_return(true) - end - - it "applies correlation when 'if' condition is true" do - correlate.call(task, id: "test-id", if: :should_correlate?, &test_block) - - expect(metadata[:correlation_id]).to eq("test-id") - end - - it "skips correlation when 'if' condition is false" do - allow(task).to receive(:should_correlate?).and_return(false) - - result = correlate.call(task, id: "test-id", if: :should_correlate?, &test_block) - - expect(metadata[:correlation_id]).to be_nil - - expect(result).to eq(block_result) - end - end - - context "with conditional execution using 'unless'" do - before do - allow(task).to receive(:skip_correlation?).and_return(false) - end - - it "applies correlation when 'unless' condition is false" do - correlate.call(task, id: "test-id", unless: :skip_correlation?, &test_block) - - expect(metadata[:correlation_id]).to eq("test-id") - end - - it "skips correlation when 'unless' condition is true" do - allow(task).to receive(:skip_correlation?).and_return(true) - - result = correlate.call(task, id: "test-id", unless: :skip_correlation?, &test_block) - - expect(metadata[:correlation_id]).to be_nil - - expect(result).to eq(block_result) - end - end - - context "with additional options" do - it "ignores unknown options" do - expect do - correlate.call(task, id: "test-id", unknown_option: "value", &test_block) - end.not_to raise_error - end - end - end - - describe "thread safety" do - after { described_class.clear } - - it "maintains separate correlation IDs per thread" do - thread1_id = nil - thread2_id = nil - - thread1 = Thread.new do - described_class.id = "thread-1-id" - thread1_id = described_class.id - end - - thread2 = Thread.new do - described_class.id = "thread-2-id" - thread2_id = described_class.id - end - - thread1.join - thread2.join - - expect(thread1_id).to eq("thread-1-id") - expect(thread2_id).to eq("thread-2-id") - expect(thread1_id).not_to eq(thread2_id) - end - - it "does not interfere with main thread correlation ID" do - described_class.id = "main-thread-id" - - thread_id = nil - thread = Thread.new do - described_class.id = "child-thread-id" - thread_id = described_class.id - end - thread.join - - expect(described_class.id).to eq("main-thread-id") - expect(thread_id).to eq("child-thread-id") - end - end - - describe "fiber safety" do - after { described_class.clear } - - it "maintains separate correlation IDs per fiber" do - fiber1_id = nil - fiber2_id = nil - - fiber1 = Fiber.new do - described_class.id = "fiber-1-id" - fiber1_id = described_class.id - end - - fiber2 = Fiber.new do - described_class.id = "fiber-2-id" - fiber2_id = described_class.id - end - - fiber1.resume - fiber2.resume - - expect(fiber1_id).to eq("fiber-1-id") - expect(fiber2_id).to eq("fiber-2-id") - expect(fiber1_id).not_to eq(fiber2_id) - end - - it "does not interfere with main fiber correlation ID" do - described_class.id = "main-fiber-id" - - fiber_id = nil - fiber = Fiber.new do - described_class.id = "child-fiber-id" - fiber_id = described_class.id - end - fiber.resume - - expect(described_class.id).to eq("main-fiber-id") - expect(fiber_id).to eq("child-fiber-id") - end + it "yields to the block" do + task = task_class.allocate + task.instance_variable_set(:@context, CMDx::Context.new) + ran = false + described_class.call(task) { ran = true } + expect(ran).to be(true) end end diff --git a/spec/cmdx/middlewares/runtime_spec.rb b/spec/cmdx/middlewares/runtime_spec.rb deleted file mode 100644 index 05de432f3..000000000 --- a/spec/cmdx/middlewares/runtime_spec.rb +++ /dev/null @@ -1,180 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Middlewares::Runtime, type: :unit do - subject(:runtime) { described_class } - - let(:task) { double("CMDx::Task", result: result) } # rubocop:disable RSpec/VerifiedDoubles - let(:result) { instance_double(CMDx::Result, metadata: metadata) } - let(:metadata) { {} } - let(:block_result) { "task executed" } - let(:test_block) { proc { block_result } } - - describe ".call" do - before do - allow(Process).to receive(:clock_gettime) - .with(Process::CLOCK_MONOTONIC, :millisecond) - .and_return(100, 150) # Start time: 100ms, end time: 150ms - end - - it "measures runtime and stores it in metadata" do - result = runtime.call(task, &test_block) - - expect(metadata[:runtime]).to eq(50) # 150 - 100 = 50ms - expect(result).to eq(block_result) - end - - it "calls monotonic_time twice to measure duration" do - expect(Process).to receive(:clock_gettime) - .with(Process::CLOCK_MONOTONIC, :millisecond) - .twice - - runtime.call(task, &test_block) - end - - it "returns the block result" do - result = runtime.call(task, &test_block) - - expect(result).to eq(block_result) - end - - context "when block execution takes no measurable time" do - before do - allow(Process).to receive(:clock_gettime) - .with(Process::CLOCK_MONOTONIC, :millisecond) - .and_return(100, 100) # Same time - end - - it "stores zero runtime" do - runtime.call(task, &test_block) - - expect(metadata[:runtime]).to eq(0) - end - end - - context "when block raises an error" do - let(:error_block) { proc { raise StandardError, "test error" } } - - before do - allow(Process).to receive(:clock_gettime) - .with(Process::CLOCK_MONOTONIC, :millisecond) - .and_return(100) - end - - it "re-raises the error without storing runtime" do - expect do - runtime.call(task, &error_block) - end.to raise_error(StandardError, "test error") - - expect(metadata[:runtime]).to be_nil - end - - it "only calls monotonic_time once before the error" do - expect(Process).to receive(:clock_gettime) - .with(Process::CLOCK_MONOTONIC, :millisecond) - .once - - begin - runtime.call(task, &error_block) - rescue StandardError - # Expected to raise - end - end - end - - context "with conditional execution using 'if'" do - before do - allow(task).to receive(:should_measure_runtime?).and_return(true) - end - - it "measures runtime when 'if' condition is true" do - result = runtime.call(task, if: :should_measure_runtime?, &test_block) - - expect(metadata[:runtime]).to eq(50) - - expect(result).to eq(block_result) - end - - it "skips runtime measurement when 'if' condition is false" do - allow(task).to receive(:should_measure_runtime?).and_return(false) - - result = runtime.call(task, if: :should_measure_runtime?, &test_block) - - expect(metadata[:runtime]).to be_nil - - expect(result).to eq(block_result) - end - end - - context "with conditional execution using 'unless'" do - before do - allow(task).to receive(:skip_runtime_measurement?).and_return(false) - end - - it "measures runtime when 'unless' condition is false" do - result = runtime.call(task, unless: :skip_runtime_measurement?, &test_block) - - expect(metadata[:runtime]).to eq(50) - - expect(result).to eq(block_result) - end - - it "skips runtime measurement when 'unless' condition is true" do - allow(task).to receive(:skip_runtime_measurement?).and_return(true) - - result = runtime.call(task, unless: :skip_runtime_measurement?, &test_block) - - expect(metadata[:runtime]).to be_nil - - expect(result).to eq(block_result) - end - end - - context "with additional options" do - it "ignores unknown options" do - expect do - runtime.call(task, unknown_option: "value", &test_block) - end.not_to raise_error - - expect(metadata[:runtime]).to eq(50) - end - end - - context "when block returns nil" do - let(:nil_block) { proc {} } - - it "still measures runtime correctly" do - result = runtime.call(task, &nil_block) - - expect(metadata[:runtime]).to eq(50) - - expect(result).to be_nil - end - end - - context "when block returns false" do - let(:false_block) { proc { false } } - - it "still measures runtime correctly" do - result = runtime.call(task, &false_block) - - expect(metadata[:runtime]).to eq(50) - - expect(result).to be(false) - end - end - end - - describe "#monotonic_time" do - it "uses Process.clock_gettime with CLOCK_MONOTONIC in milliseconds" do - allow(Process).to receive(:clock_gettime) - .with(Process::CLOCK_MONOTONIC, :millisecond) - .and_return(123_456) - - time = runtime.send(:monotonic_time) - - expect(time).to eq(123_456) - end - end -end diff --git a/spec/cmdx/middlewares/runtime_tracker_spec.rb b/spec/cmdx/middlewares/runtime_tracker_spec.rb new file mode 100644 index 000000000..5ad80aa9b --- /dev/null +++ b/spec/cmdx/middlewares/runtime_tracker_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe CMDx::Middlewares::RuntimeTracker do + let(:task_class) do + Class.new(CMDx::Task) do + register CMDx::Middlewares::RuntimeTracker + + def work + sleep 0.01 + end + end + end + + it "records runtime_ms on the context after execution" do + result = task_class.execute + ms = result.context[:runtime_ms] + expect(ms).to be_a(Numeric) + expect(ms).to be >= 0 + expect(ms).to be >= 5 + end +end diff --git a/spec/cmdx/middlewares/timeout_spec.rb b/spec/cmdx/middlewares/timeout_spec.rb index 2f5543479..6cc291b30 100644 --- a/spec/cmdx/middlewares/timeout_spec.rb +++ b/spec/cmdx/middlewares/timeout_spec.rb @@ -2,271 +2,37 @@ require "spec_helper" -RSpec.describe CMDx::Middlewares::Timeout, type: :unit do - subject(:timeout_middleware) { described_class } +RSpec.describe CMDx::Middlewares::Timeout do + let(:slow_class) do + Class.new(CMDx::Task) do + register CMDx::Middlewares::Timeout, 0.05 - let(:task) { double("CMDx::Task", result: result, resolver: resolver) } # rubocop:disable RSpec/VerifiedDoubles - let(:result) { instance_double(CMDx::Result) } - let(:resolver) { instance_double(CMDx::Resolver) } - let(:block_result) { "block executed" } - let(:test_block) { proc { block_result } } - - before do - allow(resolver).to receive(:fail!) - end - - describe ".call" do - context "when seconds option is a Numeric" do - it "uses the numeric value as timeout limit coerced to float" do - expect(Timeout).to receive(:timeout).with(5.0, CMDx::TimeoutError, "execution exceeded 5.0 seconds").and_yield.and_return(block_result) - - result = timeout_middleware.call(task, seconds: 5, &test_block) - - expect(result).to eq(block_result) - end - - it "handles float values" do - expect(Timeout).to receive(:timeout).with(2.5, CMDx::TimeoutError, "execution exceeded 2.5 seconds").and_yield.and_return(block_result) - - timeout_middleware.call(task, seconds: 2.5, &test_block) - end - - it "yields directly when timeout is zero" do - expect(Timeout).not_to receive(:timeout) - - result = timeout_middleware.call(task, seconds: 0, &test_block) - - expect(result).to eq(block_result) - end - - it "yields directly when timeout is negative" do - expect(Timeout).not_to receive(:timeout) - - result = timeout_middleware.call(task, seconds: -1, &test_block) - - expect(result).to eq(block_result) - end - end - - context "when seconds option is a Symbol" do - let(:method_name) { :timeout_value } - - before do - allow(task).to receive(:send).with(method_name).and_return(10) - end - - it "calls the method on the task and uses the result as float" do - allow(task).to receive(:send).with(method_name).and_return(10) - - expect(Timeout).to receive(:timeout).with(10.0, CMDx::TimeoutError, "execution exceeded 10.0 seconds").and_yield.and_return(block_result) - - timeout_middleware.call(task, seconds: method_name, &test_block) - end - - it "raises ArgumentError when method returns non-numeric value" do - allow(task).to receive(:send).with(method_name).and_return("invalid") - - expect do - timeout_middleware.call(task, seconds: method_name, &test_block) - end.to raise_error(ArgumentError) - end - end - - context "when seconds option is a Proc" do - let(:timeout_proc) { proc { 15 } } - - before do - allow(task).to receive(:instance_eval).and_yield.and_return(15) - end - - it "evaluates the proc in task context and uses the result as float" do - expect(task).to receive(:instance_eval).and_yield.and_return(15) - - expect(Timeout).to receive(:timeout).with(15.0, CMDx::TimeoutError, "execution exceeded 15.0 seconds").and_yield.and_return(block_result) - - timeout_middleware.call(task, seconds: timeout_proc, &test_block) - end - - it "raises TypeError when proc returns nil" do - expect(task).to receive(:instance_eval).and_yield.and_return(nil) - - expect do - timeout_middleware.call(task, seconds: timeout_proc, &test_block) - end.to raise_error(TypeError) - end - end - - context "when seconds option responds to call" do - let(:callable) { instance_double("MockCallable", call: 20) } - - it "calls the callable with the task and uses the result as float" do - allow(callable).to receive(:call).with(task).and_return(20) - - expect(Timeout).to receive(:timeout).with(20.0, CMDx::TimeoutError, "execution exceeded 20.0 seconds").and_yield.and_return(block_result) - - timeout_middleware.call(task, seconds: callable, &test_block) - end - - it "yields directly when callable returns zero" do - allow(callable).to receive(:call).with(task).and_return(0) - - expect(Timeout).not_to receive(:timeout) - - result = timeout_middleware.call(task, seconds: callable, &test_block) - - expect(result).to eq(block_result) - end - end - - context "when seconds option is nil" do - it "uses the default timeout limit as float" do - expect(Timeout).to receive(:timeout).with(3.0, CMDx::TimeoutError, "execution exceeded 3.0 seconds").and_yield.and_return(block_result) - - timeout_middleware.call(task, seconds: nil, &test_block) - end - end - - context "when seconds option is false" do - it "uses the default timeout limit as float" do - expect(Timeout).to receive(:timeout).with(3.0, CMDx::TimeoutError, "execution exceeded 3.0 seconds").and_yield.and_return(block_result) - - timeout_middleware.call(task, seconds: false, &test_block) - end - end - - context "when no seconds option is provided" do - it "uses the default timeout limit as float" do - expect(Timeout).to receive(:timeout).with(3.0, CMDx::TimeoutError, "execution exceeded 3.0 seconds").and_yield.and_return(block_result) - - timeout_middleware.call(task, &test_block) - end - end - - context "when seconds option is an unsupported type" do - it "uses the default timeout limit for string values" do - expect(Timeout).to receive(:timeout).with(3.0, CMDx::TimeoutError, "execution exceeded 3.0 seconds").and_yield.and_return(block_result) - - timeout_middleware.call(task, seconds: "invalid", &test_block) - end - - it "uses the default timeout limit for array values" do - expect(Timeout).to receive(:timeout).with(3.0, CMDx::TimeoutError, "execution exceeded 3.0 seconds").and_yield.and_return(block_result) - - timeout_middleware.call(task, seconds: [1, 2, 3], &test_block) - end - end - - context "when block execution succeeds" do - it "returns the block result" do - allow(Timeout).to receive(:timeout).and_yield.and_return(block_result) - - result = timeout_middleware.call(task, seconds: 5, &test_block) - - expect(result).to eq(block_result) - end - - it "returns nil when block returns nil" do - nil_block = proc {} - allow(Timeout).to receive(:timeout).and_yield.and_return(nil) - - result = timeout_middleware.call(task, seconds: 5, &nil_block) - - expect(result).to be_nil - end - - it "returns false when block returns false" do - false_block = proc { false } - allow(Timeout).to receive(:timeout).and_yield.and_return(false) - - result = timeout_middleware.call(task, seconds: 5, &false_block) - - expect(result).to be(false) - end - end - - context "when block raises other errors" do - let(:standard_error) { StandardError.new("unexpected error") } - let(:error_block) { proc { raise standard_error } } - - it "re-raises non-timeout errors without calling fail!" do - expect(Timeout).to receive(:timeout).and_yield.and_raise(standard_error) - - expect(resolver).not_to receive(:fail!) - - expect do - timeout_middleware.call(task, seconds: 5, &error_block) - end.to raise_error(StandardError, "unexpected error") - end - end - - context "with conditional execution using 'if'" do - before do - allow(task).to receive(:should_timeout?).and_return(true) - allow(Timeout).to receive(:timeout) - end - - it "executes timeout when 'if' condition is true" do - expect(Timeout).to receive(:timeout).with(5.0, CMDx::TimeoutError, "execution exceeded 5.0 seconds").and_yield.and_return(block_result) - - result = timeout_middleware.call(task, seconds: 5, if: :should_timeout?, &test_block) - - expect(result).to eq(block_result) - end - - it "skips timeout when 'if' condition is false" do - allow(task).to receive(:should_timeout?).and_return(false) - expect(Timeout).not_to receive(:timeout) - - result = timeout_middleware.call(task, seconds: 5, if: :should_timeout?, &test_block) - - expect(result).to eq(block_result) - end - end - - context "with conditional execution using 'unless'" do - before do - allow(task).to receive(:skip_timeout?).and_return(false) - allow(Timeout).to receive(:timeout) - end - - it "executes timeout when 'unless' condition is false" do - expect(Timeout).to receive(:timeout).with(5.0, CMDx::TimeoutError, "execution exceeded 5.0 seconds").and_yield.and_return(block_result) - - result = timeout_middleware.call(task, seconds: 5, unless: :skip_timeout?, &test_block) - - expect(result).to eq(block_result) - end - - it "skips timeout when 'unless' condition is true" do - allow(task).to receive(:skip_timeout?).and_return(true) - - expect(Timeout).not_to receive(:timeout) - - result = timeout_middleware.call(task, seconds: 5, unless: :skip_timeout?, &test_block) - - expect(result).to eq(block_result) + def work + sleep 0.2 end end + end - context "with additional options" do - it "ignores unknown options" do - expect(Timeout).to receive(:timeout).with(5.0, CMDx::TimeoutError, "execution exceeded 5.0 seconds").and_yield.and_return(block_result) + let(:fast_class) do + Class.new(CMDx::Task) do + register CMDx::Middlewares::Timeout, 1 - expect do - timeout_middleware.call(task, seconds: 5, unknown_option: "value", &test_block) - end.not_to raise_error + def work + context[:done] = true end end end - describe "CMDx::TimeoutError" do - it "is a subclass of Interrupt" do - expect(CMDx::TimeoutError.superclass).to eq(Interrupt) - end + it "yields and completes within the time limit" do + result = fast_class.execute + expect(result).to be_success + expect(result.context[:done]).to be(true) + end - it "can be instantiated with a message" do - error = CMDx::TimeoutError.new("test timeout") - expect(error.message).to eq("test timeout") - end + it "signals failure when execution exceeds the timeout" do + result = slow_class.execute + expect(result).to be_failed + expect(result.reason).to include("timed out") + expect(result.reason).to include("0.05") end end diff --git a/spec/cmdx/outcome_spec.rb b/spec/cmdx/outcome_spec.rb new file mode 100644 index 000000000..f1ce359ca --- /dev/null +++ b/spec/cmdx/outcome_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe CMDx::Outcome do + describe "defaults" do + it "initializes lifecycle and outcome fields" do + o = described_class.new + expect(o.state).to eq("initialized") + expect(o.status).to eq("success") + expect(o.metadata).to eq({}) + expect(o.strict).to be true + expect(o.retries).to eq(0) + expect(o.rolled_back).to be false + end + end + + describe "#success?, #failed?, #skipped?" do + it "matches string status values" do + expect(described_class.new(status: "success").success?).to be true + expect(described_class.new(status: "failed").failed?).to be true + expect(described_class.new(status: "skipped").skipped?).to be true + end + end + + describe "#apply_signal" do + let(:outcome) { described_class.new } + + it "no-ops on nil" do + expect { outcome.apply_signal(nil) }.not_to change(outcome, :status) + end + + it "sets status, reason, strict, and merges metadata for non-success outcomes" do + outcome.apply_signal( + status: :failed, + reason: "blocked", + strict: false, + metadata: { step: 1 } + ) + expect(outcome.status).to eq("failed") + expect(outcome.reason).to eq("blocked") + expect(outcome.strict).to be false + expect(outcome.metadata).to eq({ step: 1 }) + end + + it "fills an unspecified reason from locale when status is not success" do + outcome.apply_signal(status: "skipped") + expect(outcome.reason).to eq("Unspecified") + end + + it "assigns cause when present in the signal" do + err = StandardError.new("boom") + outcome.apply_signal(status: "failed", cause: err) + expect(outcome.cause).to equal(err) + end + end +end diff --git a/spec/cmdx/parallelizer_spec.rb b/spec/cmdx/parallelizer_spec.rb deleted file mode 100644 index 7c4799e64..000000000 --- a/spec/cmdx/parallelizer_spec.rb +++ /dev/null @@ -1,65 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Parallelizer, type: :unit do - describe ".call" do - it "processes all items and returns results in order" do - results = described_class.call([3, 1, 2]) { |n| n * 10 } - - expect(results).to eq([30, 10, 20]) - end - - it "runs items concurrently across threads" do - mutex = Mutex.new - thread_ids = [] - - described_class.call(%w[a b c]) do |_item| - mutex.synchronize { thread_ids << Thread.current.object_id } - end - - expect(thread_ids.size).to eq(3) - end - - context "with pool_size smaller than item count" do - it "limits the number of concurrent threads" do - mutex = Mutex.new - thread_ids = [] - - described_class.call([1, 2, 3, 4], pool_size: 2) do |item| - mutex.synchronize { thread_ids << Thread.current.object_id } - item - end - - expect(thread_ids.uniq.size).to be <= 2 - end - - it "still processes all items" do - results = described_class.call([1, 2, 3, 4], pool_size: 2) { |n| n + 1 } - - expect(results).to eq([2, 3, 4, 5]) - end - end - - context "with an empty items array" do - it "returns an empty array" do - results = described_class.call([]) { |n| n } - - expect(results).to eq([]) - end - end - end - - describe "#call" do - it "yields each item to the block" do - yielded = [] - mutex = Mutex.new - - described_class.new(%w[x y z]).call do |item| - mutex.synchronize { yielded << item } - end - - expect(yielded).to match_array(%w[x y z]) - end - end -end diff --git a/spec/cmdx/pipeline_spec.rb b/spec/cmdx/pipeline_spec.rb deleted file mode 100644 index 15e5c8e87..000000000 --- a/spec/cmdx/pipeline_spec.rb +++ /dev/null @@ -1,389 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Pipeline, type: :unit do - let(:pipeline) { described_class.new(workflow) } - let(:workflow_class) { class_double("WorkflowClass") } - let(:workflow) { instance_double("WorkflowInstance", class: workflow_class) } - - describe ".execute" do - subject(:execute) { described_class.execute(workflow) } - - it "creates a new instance and executes it" do - expect(described_class).to receive(:new).with(workflow).and_return(pipeline) - expect(pipeline).to receive(:execute) - execute - end - end - - describe "#initialize" do - it "sets the workflow" do - expect(pipeline.workflow).to eq(workflow) - end - end - - describe "#execute" do - let(:execution_group) { instance_double("ExecutionGroup") } - let(:group_options) { {} } - let(:breakpoints) { [] } - - before do - allow(execution_group).to receive_messages(options: group_options, tasks: []) - allow(CMDx::Utils::Condition).to receive(:evaluate).and_return(true) - allow(workflow_class).to receive_messages(pipeline: [execution_group], settings: mock_settings(workflow_breakpoints: [])) - end - - it "iterates through workflow pipeline groups" do - expect(workflow_class).to receive(:pipeline).and_return([execution_group]) - pipeline.execute - end - - context "when condition evaluates to true" do - it "executes the group tasks" do - expect(pipeline).to receive(:execute_group_tasks).with(execution_group, []) - pipeline.execute - end - - context "with breakpoints in group options" do - let(:group_options) { { breakpoints: %w[step1 step2] } } - - it "uses group breakpoints" do - expect(pipeline).to receive(:execute_group_tasks).with(execution_group, %w[step1 step2]) - pipeline.execute - end - end - - context "with breakpoints in workflow settings" do - before do - allow(workflow_class).to receive(:settings).and_return(mock_settings(breakpoints: ["workflow_step"])) - end - - it "uses workflow breakpoints" do - expect(pipeline).to receive(:execute_group_tasks).with(execution_group, ["workflow_step"]) - pipeline.execute - end - end - - context "with breakpoints in workflow_breakpoints setting" do - before do - allow(workflow_class).to receive(:settings).and_return(mock_settings(workflow_breakpoints: ["wf_step"])) - end - - it "uses workflow_breakpoints" do - expect(pipeline).to receive(:execute_group_tasks).with(execution_group, ["wf_step"]) - pipeline.execute - end - end - - context "with multiple breakpoint sources" do - let(:group_options) { { breakpoints: ["group_step"] } } - - before do - allow(workflow_class).to receive(:settings).and_return( - mock_settings(breakpoints: ["workflow_step"], workflow_breakpoints: ["wf_step"]) - ) - end - - it "prioritizes group breakpoints" do - expect(pipeline).to receive(:execute_group_tasks).with(execution_group, ["group_step"]) - pipeline.execute - end - end - - context "with string and symbol breakpoints" do - let(:group_options) { { breakpoints: ["step1", :step2, "step3"] } } - - it "converts all breakpoints to strings and removes duplicates" do - expect(pipeline).to receive(:execute_group_tasks).with(execution_group, %w[step1 step2 step3]) - pipeline.execute - end - end - end - - context "when condition evaluates to false" do - before do - allow(CMDx::Utils::Condition).to receive(:evaluate).and_return(false) - end - - it "skips the group tasks" do - expect(pipeline).not_to receive(:execute_group_tasks) - pipeline.execute - end - end - - context "with multiple execution groups" do - let(:execution_group2) { instance_double("ExecutionGroup2") } - let(:group_options2) { {} } - - before do - allow(workflow_class).to receive(:pipeline).and_return([execution_group, execution_group2]) - allow(execution_group2).to receive_messages(options: group_options2, tasks: []) - allow(CMDx::Utils::Condition).to receive(:evaluate).with(workflow, group_options, workflow).and_return(true) - allow(CMDx::Utils::Condition).to receive(:evaluate).with(workflow, group_options2, workflow).and_return(true) - end - - it "processes each group independently" do - expect(pipeline).to receive(:execute_group_tasks).with(execution_group, []) - expect(pipeline).to receive(:execute_group_tasks).with(execution_group2, []) - pipeline.execute - end - end - end - - describe "#execute_group_tasks" do - let(:execution_group) { instance_double("ExecutionGroup") } - let(:breakpoints) { [] } - - context "when strategy is nil" do - before do - allow(execution_group).to receive(:options).and_return({}) - end - - it "calls execute_tasks_in_sequence" do - expect(pipeline).to receive(:execute_tasks_in_sequence).with(execution_group, breakpoints) - pipeline.send(:execute_group_tasks, execution_group, breakpoints) - end - end - - context "when strategy is sequential" do - before do - allow(execution_group).to receive(:options).and_return({ strategy: :sequential }) - end - - it "calls execute_tasks_in_sequence" do - expect(pipeline).to receive(:execute_tasks_in_sequence).with(execution_group, breakpoints) - pipeline.send(:execute_group_tasks, execution_group, breakpoints) - end - end - - context "when strategy is parallel" do - before do - allow(execution_group).to receive(:options).and_return({ strategy: :parallel }) - end - - it "calls execute_tasks_in_parallel" do - expect(pipeline).to receive(:execute_tasks_in_parallel).with(execution_group, breakpoints) - pipeline.send(:execute_group_tasks, execution_group, breakpoints) - end - end - - context "when strategy is unknown" do - before do - allow(execution_group).to receive(:options).and_return({ strategy: :unknown }) - end - - it "raises an error" do - expect { pipeline.send(:execute_group_tasks, execution_group, breakpoints) } - .to raise_error("unknown execution strategy :unknown") - end - end - end - - describe "#execute_tasks_in_sequence" do - let(:execution_group) { instance_double("ExecutionGroup") } - let(:tasks) { [task1, task2, task3] } - let(:task1) { instance_double("Task1") } - let(:task2) { instance_double("Task2") } - let(:task3) { instance_double("Task3") } - let(:breakpoints) { [] } - let(:context) { instance_double("Context") } - let(:result1) { instance_double("Result1") } - let(:result2) { instance_double("Result2") } - let(:result3) { instance_double("Result3") } - - before do - allow(workflow).to receive(:context).and_return(context) - allow(execution_group).to receive(:tasks).and_return(tasks) - allow(task1).to receive(:execute).with(context).and_return(result1) - allow(task2).to receive(:execute).with(context).and_return(result2) - allow(task3).to receive(:execute).with(context).and_return(result3) - allow(result1).to receive(:status).and_return("success") - allow(result2).to receive(:status).and_return("success") - allow(result3).to receive(:status).and_return("success") - allow(workflow).to receive(:throw!) - end - - it "executes all tasks in sequence" do - expect(task1).to receive(:execute).with(context).and_return(result1) - expect(task2).to receive(:execute).with(context).and_return(result2) - expect(task3).to receive(:execute).with(context).and_return(result3) - pipeline.send(:execute_tasks_in_sequence, execution_group, breakpoints) - end - - context "when breakpoint is triggered on first task" do - let(:breakpoints) { ["success"] } - - before do - allow(result1).to receive(:status).and_return("success") - end - - it "throws on first task and continues executing remaining tasks" do - expect(workflow).to receive(:throw!).with(result1) - expect(task1).to receive(:execute).with(context).and_return(result1) - expect(task2).to receive(:execute).with(context).and_return(result2) - expect(task3).to receive(:execute).with(context).and_return(result3) - pipeline.send(:execute_tasks_in_sequence, execution_group, breakpoints) - end - end - - context "when breakpoint is triggered on second task" do - let(:breakpoints) { ["success"] } - - before do - allow(result1).to receive(:status).and_return("failure") - allow(result2).to receive(:status).and_return("success") - end - - it "executes first task, then throws on second and continues with remaining tasks" do - expect(workflow).to receive(:throw!).with(result2) - expect(task1).to receive(:execute).with(context).and_return(result1) - expect(task2).to receive(:execute).with(context).and_return(result2) - expect(task3).to receive(:execute).with(context).and_return(result3) - pipeline.send(:execute_tasks_in_sequence, execution_group, breakpoints) - end - end - - context "with string breakpoints" do - let(:breakpoints) { ["halt"] } - - before do - allow(result1).to receive(:status).and_return("halt") - end - - it "matches string breakpoints correctly" do - expect(workflow).to receive(:throw!).with(result1) - expect(task1).to receive(:execute).with(context).and_return(result1) - pipeline.send(:execute_tasks_in_sequence, execution_group, breakpoints) - end - end - - context "with symbol breakpoints" do - let(:breakpoints) { ["halt"] } - - before do - allow(result1).to receive(:status).and_return("halt") - end - - it "matches symbol breakpoints correctly" do - expect(workflow).to receive(:throw!).with(result1) - expect(task1).to receive(:execute).with(context).and_return(result1) - pipeline.send(:execute_tasks_in_sequence, execution_group, breakpoints) - end - end - - context "with mixed breakpoint types" do - let(:breakpoints) { %w[success failure] } - - before do - allow(result1).to receive(:status).and_return("success") - end - - it "matches both string breakpoints" do - expect(workflow).to receive(:throw!).with(result1) - expect(task1).to receive(:execute).with(context).and_return(result1) - pipeline.send(:execute_tasks_in_sequence, execution_group, breakpoints) - end - end - end - - describe "#execute_tasks_in_parallel" do - let(:execution_group) { instance_double("ExecutionGroup") } - let(:tasks) { [task1, task2, task3] } - let(:task1) { instance_double("Task1") } - let(:task2) { instance_double("Task2") } - let(:task3) { instance_double("Task3") } - let(:breakpoints) { [] } - let(:context) { CMDx::Context.new(user_id: 1) } - let(:result1) { instance_double("Result1") } - let(:result2) { instance_double("Result2") } - let(:result3) { instance_double("Result3") } - let(:chain) { instance_double("Chain") } - let(:result_hash) { { status: "failed" } } - - before do - allow(workflow).to receive_messages(context: context, chain: chain) - allow(execution_group).to receive_messages(tasks: tasks, options: {}) - allow(task1).to receive(:execute).and_return(result1) - allow(task2).to receive(:execute).and_return(result2) - allow(task3).to receive(:execute).and_return(result3) - allow(result1).to receive_messages(status: "success", to_h: result_hash) - allow(result2).to receive_messages(status: "success", to_h: result_hash) - allow(result3).to receive_messages(status: "success", to_h: result_hash) - allow(CMDx::Chain).to receive(:current=) - end - - it "creates context snapshots for each task" do - expect(task1).to receive(:execute) do |ctx| - expect(ctx).to be_a(CMDx::Context) - expect(ctx.to_h).to eq(user_id: 1) - expect(ctx).not_to equal(context) - result1 - end - pipeline.send(:execute_tasks_in_parallel, execution_group, breakpoints) - end - - it "executes all tasks via Parallelizer" do - expect(task1).to receive(:execute).and_return(result1) - expect(task2).to receive(:execute).and_return(result2) - expect(task3).to receive(:execute).and_return(result3) - pipeline.send(:execute_tasks_in_parallel, execution_group, breakpoints) - end - - it "merges context snapshots back into workflow context" do - expect(workflow.context).to receive(:merge!).exactly(3).times - pipeline.send(:execute_tasks_in_parallel, execution_group, breakpoints) - end - - it "sets Chain.current for each thread" do - expect(CMDx::Chain).to receive(:current=).with(chain).at_least(3).times - pipeline.send(:execute_tasks_in_parallel, execution_group, breakpoints) - end - - context "when breakpoint is triggered" do - let(:breakpoints) { ["failed"] } - - before do - allow(result2).to receive(:status).and_return("failed") - allow(workflow).to receive(:failed!) - end - - it "calls the faulted status method on the workflow" do - expect(workflow).to receive(:failed!).with( - CMDx::Locale.t("cmdx.reasons.unspecified"), - source: :parallel, - faults: [result_hash] - ) - pipeline.send(:execute_tasks_in_parallel, execution_group, breakpoints) - end - end - - context "when multiple breakpoints are triggered" do - let(:breakpoints) { %w[failed skipped] } - - before do - allow(result1).to receive(:status).and_return("skipped") - allow(result3).to receive(:status).and_return("failed") - allow(workflow).to receive(:failed!) - end - - it "uses the last faulted result status and includes all faulted results" do - expect(workflow).to receive(:failed!).with( - CMDx::Locale.t("cmdx.reasons.unspecified"), - source: :parallel, - faults: [result_hash, result_hash] - ) - pipeline.send(:execute_tasks_in_parallel, execution_group, breakpoints) - end - end - - context "when no breakpoints are triggered" do - let(:breakpoints) { ["failed"] } - - it "does not call any fault method on the workflow" do - expect(workflow).not_to receive(:failed!) - pipeline.send(:execute_tasks_in_parallel, execution_group, breakpoints) - end - end - end -end diff --git a/spec/cmdx/resolver_spec.rb b/spec/cmdx/resolver_spec.rb deleted file mode 100644 index bcaf7bdc1..000000000 --- a/spec/cmdx/resolver_spec.rb +++ /dev/null @@ -1,316 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Resolver, type: :unit do - let(:task_class) { create_successful_task(name: "TestTask") } - let(:task) { task_class.new } - let(:result) { task.result } - let(:resolver) { task.resolver } - - describe "#initialize" do - context "with valid result" do - it "initializes with correct defaults" do - expect(resolver.result).to eq(result) - expect(result.strict?).to be(true) - end - end - - context "with invalid result" do - it "raises TypeError when result is not a CMDx::Result" do - expect { described_class.new("not a result") }.to raise_error(TypeError, "must be a CMDx::Result") - end - end - end - - describe "#success!" do - context "when successful" do - it "sets the reason" do - catch(:cmdx_halt) { resolver.success!("Created 42 records") } - - expect(result.status).to eq(CMDx::Result::SUCCESS) - expect(result.reason).to eq("Created 42 records") - expect(result.metadata).to eq({}) - end - - it "accepts metadata" do - catch(:cmdx_halt) { resolver.success!("Imported", rows: 100) } - - expect(result.reason).to eq("Imported") - expect(result.metadata).to eq({ rows: 100 }) - end - - it "allows nil reason" do - catch(:cmdx_halt) { resolver.success! } - - expect(result.reason).to be_nil - end - - it "does not change state or status" do - original_state = result.state - original_status = result.status - catch(:cmdx_halt) { resolver.success!("note") } - - expect(result.state).to eq(original_state) - expect(result.status).to eq(original_status) - end - - it "throws :cmdx_halt by default" do - expect { resolver.success!("done") }.to throw_symbol(:cmdx_halt) - end - - it "does not throw when halt is false" do - expect { resolver.success!("done", halt: false) }.not_to throw_symbol(:cmdx_halt) - end - end - - context "when not successful" do - it "raises error when skipped" do - resolver.skip!("test", halt: false) - - expect { resolver.success!("reason") }.to raise_error(/can only be used while success/) - end - - it "raises error when failed" do - resolver.fail!("test", halt: false) - - expect { resolver.success!("reason") }.to raise_error(/can only be used while success/) - end - end - end - - describe "#skip!" do - context "when successful" do - it "transitions to skipped status" do - resolver.skip!("test reason", halt: false) - - expect(result.status).to eq(CMDx::Result::SKIPPED) - expect(result.state).to eq(CMDx::Result::INTERRUPTED) - expect(result.reason).to eq("test reason") - expect(result.cause).to be_nil - expect(result.metadata).to eq({}) - end - - it "accepts metadata" do - resolver.skip!("test reason", halt: false, foo: "bar") - - expect(result.metadata).to eq({ foo: "bar" }) - end - - it "accepts cause" do - cause = StandardError.new("cause") - resolver.skip!("test reason", halt: false, cause: cause) - - expect(result.cause).to eq(cause) - end - - it "uses default reason when none provided" do - allow(CMDx::Locale).to receive(:t).with("cmdx.reasons.unspecified").and_return("Unspecified") - - resolver.skip!(halt: false) - - expect(result.reason).to eq("Unspecified") - end - - it "calls halt! by default" do - expect { resolver.skip!("test reason") }.to raise_error(CMDx::SkipFault) - end - - it "does not call halt! when halt: false" do - expect { resolver.skip!("test reason", halt: false) }.not_to raise_error - end - end - - context "when already skipped" do - it "returns early without changes" do - resolver.skip!("first reason", halt: false) - original_reason = result.reason - resolver.skip!("second reason", halt: false) - - expect(result.reason).to eq(original_reason) - end - end - - context "when not successful" do - it "raises error when trying to skip from failed" do - resolver.fail!("test reason", halt: false) - - expect { resolver.skip!("another reason", halt: false) }.to raise_error(/can only transition to skipped from success/) - end - end - end - - describe "#fail!" do - context "when successful" do - it "transitions to failed status" do - resolver.fail!("test reason", halt: false) - - expect(result.status).to eq(CMDx::Result::FAILED) - expect(result.state).to eq(CMDx::Result::INTERRUPTED) - expect(result.reason).to eq("test reason") - expect(result.cause).to be_nil - expect(result.metadata).to eq({}) - end - - it "accepts metadata" do - resolver.fail!("test reason", halt: false, foo: "bar") - - expect(result.metadata).to eq({ foo: "bar" }) - end - - it "accepts cause" do - cause = StandardError.new("cause") - resolver.fail!("test reason", halt: false, cause: cause) - - expect(result.cause).to eq(cause) - end - - it "uses default reason when none provided" do - allow(CMDx::Locale).to receive(:t).with("cmdx.reasons.unspecified").and_return("Unspecified") - - resolver.fail!(halt: false) - - expect(result.reason).to eq("Unspecified") - end - - it "calls halt! by default" do - expect { resolver.fail!("test reason") }.to raise_error(CMDx::FailFault) - end - - it "does not call halt! when halt: false" do - expect { resolver.fail!("test reason", halt: false) }.not_to raise_error - end - end - - context "when already failed" do - it "returns early without changes" do - resolver.fail!("first reason", halt: false) - original_reason = result.reason - resolver.fail!("second reason", halt: false) - - expect(result.reason).to eq(original_reason) - end - end - - context "when not successful" do - it "raises error when trying to fail from skipped" do - resolver.skip!("test reason", halt: false) - - expect { resolver.fail!("another reason", halt: false) }.to raise_error(/can only transition to failed from success/) - end - end - end - - describe "#halt!" do - context "when successful" do - it "returns early without raising" do - expect { resolver.halt! }.not_to raise_error - end - end - - context "when skipped" do - it "raises SkipFault" do - resolver.skip!("test reason", halt: false) - - expect { resolver.halt! }.to raise_error(CMDx::SkipFault) do |fault| - expect(fault.result).to eq(result) - expect(fault.message).to eq("test reason") - end - end - - it "sets proper backtrace" do - resolver.skip!("test reason", halt: false) - - begin - resolver.halt! - rescue CMDx::SkipFault => e - expect(e.backtrace).to be_an(Array) - expect(e.backtrace).not_to be_empty - end - end - end - - context "when failed" do - it "raises FailFault" do - resolver.fail!("test reason", halt: false) - - expect { resolver.halt! }.to raise_error(CMDx::FailFault) do |fault| - expect(fault.result).to eq(result) - expect(fault.message).to eq("test reason") - end - end - end - end - - describe "#throw!" do - let(:other_task) { create_failing_task.new } - let(:other_result) { other_task.result } - - before do - other_task.resolver.fail!("source failure", halt: false, foo: "bar") - end - - context "with valid result" do - it "copies state and status from other result" do - resolver.throw!(other_result, halt: false) - - expect(result.state).to eq(other_result.state) - expect(result.status).to eq(other_result.status) - expect(result.reason).to eq(other_result.reason) - end - - it "merges metadata" do - resolver.throw!(other_result, halt: false, baz: "qux") - - expect(result.metadata).to eq({ foo: "bar", baz: "qux" }) - end - - it "uses provided cause over other result's cause" do - custom_cause = StandardError.new("custom") - - resolver.throw!(other_result, halt: false, cause: custom_cause) - expect(result.cause).to eq(custom_cause) - end - - it "uses other result's cause when none provided" do - other_cause = StandardError.new("other") - other_result.instance_variable_set(:@cause, other_cause) - resolver.throw!(other_result, halt: false) - - expect(result.cause).to eq(other_cause) - end - - it "calls halt! by default" do - expect { resolver.throw!(other_result) }.to raise_error(CMDx::FailFault) - end - - it "does not call halt! when halt: false" do - expect { resolver.throw!(other_result, halt: false) }.not_to raise_error - end - end - - context "with invalid result" do - it "raises TypeError when not a CMDx::Result" do - expect { resolver.throw!("not a result", halt: false) }.to raise_error(TypeError, "must be a CMDx::Result") - end - end - end - - describe "#strict?" do - it "returns true by default" do - expect(result.strict?).to be(true) - end - - it "returns false when strict is false via fail!" do - resolver.fail!("test reason", halt: false, strict: false) - - expect(result.strict?).to be(false) - end - - it "returns false when strict is false via skip!" do - resolver.skip!("test reason", halt: false, strict: false) - - expect(result.strict?).to be(false) - end - end -end diff --git a/spec/cmdx/result_spec.rb b/spec/cmdx/result_spec.rb index e06438848..a5eef8d4d 100644 --- a/spec/cmdx/result_spec.rb +++ b/spec/cmdx/result_spec.rb @@ -2,850 +2,119 @@ require "spec_helper" -RSpec.describe CMDx::Result, type: :unit do - let(:task_class) { create_successful_task(name: "TestTask") } - let(:task) { task_class.new } - let(:result) { task.result } - let(:resolver) { task.resolver } +RSpec.describe CMDx::Result do + def build(**attrs) + defaults = { + task_id: "task-1", + task_class: String, + task_type: "string", + task_tags: %i[a b], + state: "complete", + status: "success", + reason: nil, + cause: nil, + metadata: { k: 1 }, + strict: true, + retries: 0, + rolled_back: false, + context: CMDx::Context.new, + chain: nil, + errors: CMDx::Errors.new, + index: 0 + } + described_class.new(**defaults, **attrs) + end describe "#initialize" do - context "with valid task" do - it "initializes with correct defaults" do - expect(result.task).to eq(task) - expect(result.state).to eq(CMDx::Result::INITIALIZED) - expect(result.status).to eq(CMDx::Result::SUCCESS) - expect(result.metadata).to eq({}) - expect(result.reason).to be_nil - expect(result.cause).to be_nil - end - - it "delegates context and chain to task" do - expect(result.context).to eq(task.context) - expect(result.chain).to eq(task.chain) - end - end - - context "with invalid task" do - it "raises TypeError when task is not a CMDx::Task" do - expect { described_class.new("not a task") }.to raise_error(TypeError, "must be a CMDx::Task") - end + it "freezes the instance" do + r = build + expect(r).to be_frozen end end - describe "state predicates" do - describe "#initialized?" do - it "returns true when state is initialized" do - expect(result.initialized?).to be(true) - end - - it "returns false when state is not initialized" do - resolver.executing! - - expect(result.initialized?).to be(false) - end - end - - describe "#executing?" do - it "returns false when state is initialized" do - expect(result.executing?).to be(false) - end - - it "returns true when state is executing" do - resolver.executing! - - expect(result.executing?).to be(true) - end - end - - describe "#complete?" do - it "returns false when state is not complete" do - expect(result.complete?).to be(false) - end - - it "returns true when state is complete" do - resolver.executing! - resolver.complete! - - expect(result.complete?).to be(true) - end - end - - describe "#interrupted?" do - it "returns false when state is not interrupted" do - expect(result.interrupted?).to be(false) - end - - it "returns true when state is interrupted" do - resolver.executing! - resolver.interrupt! - - expect(result.interrupted?).to be(true) - end + describe "state queries" do + it "#complete?, #interrupted?, #executed?" do + expect(build(state: "complete").complete?).to be true + expect(build(state: "interrupted").interrupted?).to be true + expect(build(state: "executing").executed?).to be false + expect(build(state: "complete").executed?).to be true + expect(build(state: "interrupted").executed?).to be true end end - describe "state transitions" do - describe "#executing!" do - context "when initialized" do - it "transitions to executing state" do - resolver.executing! - - expect(result.state).to eq(CMDx::Result::EXECUTING) - end - - it "returns early if already executing" do - resolver.executing! - initial_state = result.state - resolver.executing! - - expect(result.state).to eq(initial_state) - end - end - - context "when not initialized" do - it "raises error when trying to transition from complete" do - resolver.executing! - resolver.complete! - - expect { resolver.executing! }.to raise_error(/can only transition to executing from initialized/) - end - - it "raises error when trying to transition from interrupted" do - resolver.executing! - resolver.interrupt! - - expect { resolver.executing! }.to raise_error(/can only transition to executing from initialized/) - end - end - end - - describe "#complete!" do - context "when executing" do - before { resolver.executing! } - - it "transitions to complete state" do - resolver.complete! - - expect(result.state).to eq(CMDx::Result::COMPLETE) - end - - it "returns early if already complete" do - resolver.complete! - initial_state = result.state - resolver.complete! - - expect(result.state).to eq(initial_state) - end - end - - context "when not executing" do - it "raises error when trying to transition from initialized" do - expect { resolver.complete! }.to raise_error(/can only transition to complete from executing/) - end - end - end - - describe "#interrupt!" do - context "when not complete" do - it "transitions to interrupted from initialized" do - resolver.interrupt! - - expect(result.state).to eq(CMDx::Result::INTERRUPTED) - end - - it "transitions to interrupted from executing" do - resolver.executing! - resolver.interrupt! - - expect(result.state).to eq(CMDx::Result::INTERRUPTED) - end - - it "returns early if already interrupted" do - resolver.interrupt! - initial_state = result.state - resolver.interrupt! - - expect(result.state).to eq(initial_state) - end - end - - context "when complete" do - it "raises error when trying to transition from complete" do - resolver.executing! - resolver.complete! - - expect { resolver.interrupt! }.to raise_error(/cannot transition to interrupted from complete/) - end - end + describe "status queries" do + it "#success?, #skipped?, #failed?" do + expect(build(status: "success").success?).to be true + expect(build(status: "skipped").skipped?).to be true + expect(build(status: "failed").failed?).to be true end end - describe "status predicates" do - describe "#success?" do - it "returns true by default" do - expect(result.success?).to be(true) - end - - it "returns false after skip!" do - resolver.skip!("test reason", halt: false) - - expect(result.success?).to be(false) - end - - it "returns false after fail!" do - resolver.fail!("test reason", halt: false) - - expect(result.success?).to be(false) - end - end - - describe "#skipped?" do - it "returns false by default" do - expect(result.skipped?).to be(false) - end - - it "returns true after skip!" do - resolver.skip!("test reason", halt: false) + describe "compound queries" do + it "#good? / #ok?, #bad?, #strict?, #retried?, #rolled_back?, #dry_run?" do + expect(build.good?).to be true + expect(build.ok?).to be true + expect(build(status: "failed").bad?).to be true + expect(build(strict: false).strict?).to be false + expect(build(retries: 2).retried?).to be true + expect(build(rolled_back: true).rolled_back?).to be true - expect(result.skipped?).to be(true) - end - end - - describe "#failed?" do - it "returns false by default" do - expect(result.failed?).to be(false) - end - - it "returns true after fail!" do - resolver.fail!("test reason", halt: false) - - expect(result.failed?).to be(true) - end - end - - describe "#good?" do - it "returns true when not failed" do - expect(result.good?).to be(true) - end - - it "returns true when skipped" do - resolver.skip!("test reason", halt: false) - - expect(result.good?).to be(true) - end - - it "returns false when failed" do - resolver.fail!("test reason", halt: false) - - expect(result.good?).to be(false) - end - end - - describe "#bad?" do - it "returns false when successful" do - expect(result.bad?).to be(false) - end - - it "returns true when skipped" do - resolver.skip!("test reason", halt: false) - - expect(result.bad?).to be(true) - end - - it "returns true when failed" do - resolver.fail!("test reason", halt: false) - - expect(result.bad?).to be(true) - end - end - - describe "#rolled_back?" do - it "returns false by default" do - expect(result.rolled_back?).to be(false) - end - - it "returns true when rolled back" do - result.rolled_back = true - - expect(result.rolled_back?).to be(true) - end - end - - describe "#retried?" do - it "returns false when retries is zero" do - expect(result.retried?).to be(false) - end - - it "returns true when retries is positive" do - result.retries = 1 - - expect(result.retried?).to be(true) - end + chain = CMDx::Chain.new(dry_run: true) + expect(build(chain: chain).dry_run?).to be true + expect(build.dry_run?).to be false end end - describe "execution methods" do - describe "#executed!" do - context "when successful" do - it "calls complete!" do - resolver.executing! - resolver.executed! - - expect(result.complete?).to be(true) - end - end - - context "when not successful" do - it "calls interrupt! when skipped" do - resolver.skip!("test reason", halt: false) - resolver.executed! - - expect(result.interrupted?).to be(true) - end - - it "calls interrupt! when failed" do - resolver.fail!("test reason", halt: false) - resolver.executed! + describe "#on" do + it "yields self when a filter matches state or status" do + r = build(state: "interrupted", status: "failed") + seen = [] + r.on("nope") { seen << :bad } + expect(seen).to be_empty - expect(result.interrupted?).to be(true) - end - end - end - - describe "#executed?" do - it "returns false when not executed" do - expect(result.executed?).to be(false) - end - - it "returns true when complete" do - resolver.executing! - resolver.complete! - - expect(result.executed?).to be(true) - end - - it "returns true when interrupted" do - resolver.interrupt! - - expect(result.executed?).to be(true) - end - end - - describe "#on(:executed)" do - it "raises ArgumentError without block" do - expect { result.on(:executed) }.to raise_error(ArgumentError, "block required") - end - - it "calls block when executed" do - resolver.interrupt! - called = false - result.on(:executed) { |_r| called = true } - - expect(called).to be(true) - end - - it "does not call block when not executed" do - called = false - result.on(:executed) { |_r| called = true } - - expect(called).to be(false) - end - - it "returns self" do - expect(result.on(:executed) { "test" }).to eq(result) - end + r.on("interrupted") { seen << :state } + r.on(:failed) { seen << :status } + expect(seen).to contain_exactly(:state, :status) end end - describe "failure tracking methods" do - let(:chain) { CMDx::Chain.new } - let(:first_task) { create_successful_task.new } - let(:second_task) { create_failing_task.new } - let(:third_task) { create_successful_task.new } - let(:first_result) { first_task.result } - let(:second_result) { second_task.result } - let(:third_result) { third_task.result } + describe "#deconstruct, #deconstruct_keys" do + it "supports array and hash pattern matching" do + r = build(state: "complete", status: "skipped", reason: "n/a") + expect(r.deconstruct).to eq(["complete", "skipped", "n/a"]) - before do - chain.results.push(first_result, second_result, third_result) + slice = r.deconstruct_keys(%i[state status]) + expect(slice).to eq({ state: "complete", status: "skipped" }) - [first_result, second_result, third_result].each do |r| - r.instance_variable_set(:@chain, chain) - end - - second_task.resolver.fail!("test failure", halt: false) - end - - describe "#caused_failure" do - context "when failed" do - it "returns the first failed result in chain" do - expect(second_result.caused_failure).to eq(second_result) - end - end - - context "when not failed" do - it "returns nil" do - expect(first_result.caused_failure).to be_nil - end - end - end - - describe "#caused_failure?" do - it "returns true for the causing failure" do - expect(second_result.caused_failure?).to be(true) - end - - it "returns false for non-failing results" do - expect(first_result.caused_failure?).to be(false) - end - - it "returns false for non-failed results" do - expect(third_result.caused_failure?).to be(false) - end - end - - describe "#threw_failure" do - context "when failed" do - let(:fourth_task) { create_failing_task.new } - let(:fourth_result) { fourth_task.result } - - before do - chain.results << fourth_result - - fourth_result.instance_variable_set(:@chain, chain) - fourth_task.resolver.fail!("another failure", halt: false) - end - - it "returns the next failed result after current" do - expect(second_result.threw_failure).to eq(fourth_result) - end - - it "returns the last failed result when no failures after current" do - expect(fourth_result.threw_failure).to eq(fourth_result) - end - end - - context "when not failed" do - it "returns nil" do - expect(first_result.threw_failure).to be_nil - end - end - end - - describe "#threw_failure?" do - it "returns true when the result is the last failure" do - expect(second_result.threw_failure?).to be(true) - end - - it "returns false for non-failing results" do - expect(first_result.threw_failure?).to be(false) - end - end - - describe "#thrown_failure?" do - it "returns false when result caused the failure" do - expect(second_result.thrown_failure?).to be(false) - end - - it "returns false for non-failed results" do - expect(first_result.thrown_failure?).to be(false) - end - end - end - - describe "#index" do - it "returns the cached chain index set during push" do - expect(result.index).to eq(0) - end - - it "falls back to chain.index when cache is not set" do - result.remove_instance_variable(:@chain_index) if result.instance_variable_defined?(:@chain_index) - allow(result.chain).to receive(:index).with(result).and_return(42) - - expect(result.index).to eq(42) + full = r.deconstruct_keys + expect(full).to include(:task_id, :errors, :metadata) + expect(full[:task_class]).to eq("String") end end describe "#outcome" do - context "when initialized" do - it "returns state" do - expect(result.outcome).to eq(result.state) - end - end - - context "when thrown failure" do - it "returns state" do - allow(result).to receive(:thrown_failure?).and_return(true) - - expect(result.outcome).to eq(result.state) - end - end - - context "when not initialized and not thrown failure" do - it "returns status" do - resolver.executing! - - expect(result.outcome).to eq(result.status) - end - end - end - - describe "#to_h" do - it "includes basic task and result information" do - hash = result.to_h - task_hash = task.to_h - - expect(hash).to include( - state: result.state, - status: result.status, - outcome: result.outcome, - metadata: result.metadata, - index: task_hash[:index], - chain_id: task_hash[:chain_id], - type: task_hash[:type], - tags: task_hash[:tags], - id: task_hash[:id], - class: start_with("TestTask") - ) - end - - context "when successful with reason" do - it "includes reason without cause or rolled_back" do - catch(:cmdx_halt) { resolver.success!("Created 42 records") } - - hash = result.to_h - - expect(hash[:reason]).to eq("Created 42 records") - expect(hash).not_to include(:cause, :rolled_back) - end - end - - context "when interrupted" do - it "includes reason, cause and rolled_back status" do - resolver.skip!("test reason", halt: false, cause: StandardError.new("test")) - - hash = result.to_h - - expect(hash).to include(:reason, :cause, :rolled_back) - expect(hash[:reason]).to eq("test reason") - expect(hash[:rolled_back]).to be(false) - end - end - - context "when failed" do - it "includes failure information" do - resolver.fail!("test failure", halt: false) - - # Create mock objects that avoid calling to_h to prevent infinite recursion - threw_failure_mock = instance_double(described_class, to_h: { index: 1, class: "Test", id: "123" }) - caused_failure_mock = instance_double(described_class, to_h: { index: 0, class: "Test", id: "456" }) - - allow(result).to receive_messages(threw_failure?: false, caused_failure?: false, threw_failure: threw_failure_mock, caused_failure: caused_failure_mock) - - hash = result.to_h - - expect(hash).to include(:threw_failure, :caused_failure) - expect(hash[:threw_failure]).to eq({ index: 1, class: "Test", id: "123" }) - expect(hash[:caused_failure]).to eq({ index: 0, class: "Test", id: "456" }) - end - end - end - - describe "#to_s" do - it "formats hash using Utils::Format.to_str" do - expect(CMDx::Utils::Format).to receive(:to_str).and_return("formatted string") - - expect(result.to_s).to eq("formatted string") - end - - it "handles failure formatting in block" do - expect(CMDx::Utils::Format).to receive(:to_str).and_return("formatted string") - - result.to_s - end - end - - describe "#deconstruct" do - it "returns state and status as array" do - expect(result.deconstruct).to eq( - [ - result.state, - result.status, - result.reason, - result.cause, - result.metadata - ] - ) - end - - it "ignores arguments" do - expect(result.deconstruct(:anything, :here)).to eq( - [ - result.state, - result.status, - result.reason, - result.cause, - result.metadata - ] - ) - end - end - - describe "#deconstruct_keys" do - it "returns hash with key attributes" do - expected = { - state: result.state, - status: result.status, - reason: result.reason, - cause: result.cause, - metadata: result.metadata, - outcome: result.outcome, - executed: result.executed?, - good: result.good?, - bad: result.bad? - } - - expect(result.deconstruct_keys).to eq(expected) - end - - it "ignores arguments" do - expected = result.deconstruct_keys - - expect(result.deconstruct_keys(:anything)).to eq(expected) - end - end - - describe "handle methods" do - describe "state handle methods" do - CMDx::Result::STATES.each do |state| - describe "#on(#{state})" do - it "raises ArgumentError without block" do - expect { result.on(state) }.to raise_error(ArgumentError, "block required") - end - - context "when in #{state} state" do - before do - case state - when CMDx::Result::INITIALIZED - # Already in initialized state - when CMDx::Result::EXECUTING - resolver.executing! - when CMDx::Result::COMPLETE - resolver.executing! - resolver.complete! - when CMDx::Result::INTERRUPTED - resolver.interrupt! - end - end - - it "calls the block" do - called = false - result.on(state) { |_r| called = true } - - expect(called).to be(true) - end - - it "passes result to block" do - block_result = nil - result.on(state) { |r| block_result = r } - - expect(block_result).to eq(result) - end - end - - context "when not in #{state} state" do - before do - case state - when CMDx::Result::INITIALIZED - resolver.executing! - when CMDx::Result::EXECUTING - # Stay in initialized state - when CMDx::Result::COMPLETE - # Stay in initialized state - when CMDx::Result::INTERRUPTED - # Stay in initialized state - end - end - - it "does not call the block" do - called = false - result.on(state) { |_r| called = true } - - expect(called).to be(false) - end - end - - it "returns self" do - expect(result.on(state) { "test" }).to eq(result) - end - end - end - end - - describe "status handle methods" do - CMDx::Result::STATUSES.each do |status| - describe "#on(#{status})" do - it "raises ArgumentError without block" do - expect { result.on(status) }.to raise_error(ArgumentError, "block required") - end - - context "when in #{status} status" do - before do - case status - when CMDx::Result::SUCCESS - # Already in success status - when CMDx::Result::SKIPPED - resolver.skip!("test", halt: false) - when CMDx::Result::FAILED - resolver.fail!("test", halt: false) - end - end - - it "calls the block" do - called = false - result.on(status) { |_r| called = true } - - expect(called).to be(true) - end - - it "passes result to block" do - block_result = nil - result.on(status) { |r| block_result = r } - - expect(block_result).to eq(result) - end - end - - context "when not in #{status} status" do - before do - case status - when CMDx::Result::SUCCESS - resolver.skip!("test", halt: false) - when CMDx::Result::SKIPPED - # Stay in success status - when CMDx::Result::FAILED - # Stay in success status - end - end - - it "does not call the block" do - called = false - result.on(status) { |_r| called = true } - - expect(called).to be(false) - end - end - - it "returns self" do - expect(result.on(status) { "test" }).to eq(result) - end - end - end - end - - describe "#on(:good)" do - it "raises ArgumentError without block" do - expect { result.on(:good) }.to raise_error(ArgumentError, "block required") - end - - context "when good" do - it "calls the block for success" do - called = false - result.on(:good) { |_r| called = true } - - expect(called).to be(true) - end - - it "calls the block for skipped" do - resolver.skip!("test", halt: false) - called = false - result.on(:good) { |_r| called = true } - - expect(called).to be(true) - end - end - - context "when not good" do - it "does not call the block for failed" do - resolver.fail!("test", halt: false) - called = false - result.on(:good) { |_r| called = true } - - expect(called).to be(false) - end - end - - it "returns self" do - expect(result.on(:good) { "test" }).to eq(result) - end - end - - describe "#on(:bad)" do - it "raises ArgumentError without block" do - expect { result.on(:bad) }.to raise_error(ArgumentError, "block required") - end - - context "when bad" do - it "calls the block for skipped" do - resolver.skip!("test", halt: false) - called = false - result.on(:bad) { |_r| called = true } - - expect(called).to be(true) - end - - it "calls the block for failed" do - resolver.fail!("test", halt: false) - called = false - result.on(:bad) { |_r| called = true } - - expect(called).to be(true) - end - end - - context "when not bad" do - it "does not call the block for success" do - called = false - result.on(:bad) { |_r| called = true } - - expect(called).to be(false) - end - end - - it "returns self" do - expect(result.on(:bad) { "test" }).to eq(result) - end + it "returns a human label for the status" do + expect(build(status: "success").outcome).to eq("success") + expect(build(status: "skipped").outcome).to eq("skipped") + expect(build(status: "failed").outcome).to eq("failed") end end - describe "constants" do - describe "STATES" do - it "defines all expected states" do - expect(CMDx::Result::STATES).to contain_exactly( - "initialized", - "executing", - "complete", - "interrupted" - ) - end + describe "#to_h, #to_s" do + let(:r) { build(reason: "oops") } - it "freezes the array" do - expect(CMDx::Result::STATES).to be_frozen - end + it "to_h includes core fields and stringifies task_class" do + h = r.to_h + expect(h[:task_id]).to eq("task-1") + expect(h[:task_class]).to eq("String") + expect(h[:state]).to eq("complete") + expect(h[:errors]).to eq({}) end - describe "STATUSES" do - it "defines all expected statuses" do - expect(CMDx::Result::STATUSES).to contain_exactly( - "success", - "skipped", - "failed" - ) - end - - it "freezes the array" do - expect(CMDx::Result::STATUSES).to be_frozen - end + it "to_s summarizes status and class" do + expect(r.to_s).to start_with("[SUCCESS]") + expect(r.to_s).to include("String") + expect(r.to_s).to include("(oops)") end end end diff --git a/spec/cmdx/retry_spec.rb b/spec/cmdx/retry_spec.rb deleted file mode 100644 index e35a57c75..000000000 --- a/spec/cmdx/retry_spec.rb +++ /dev/null @@ -1,360 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Retry, type: :unit do - subject(:retry_instance) { described_class.new(task) } - - let(:task_class) { create_successful_task(name: "RetryTask") } - let(:task) { task_class.new } - - describe "#initialize" do - it "assigns the task" do - expect(retry_instance.task).to eq(task) - end - - it "provides read access to task attribute" do - expect(described_class.instance_methods).to include(:task) - expect(described_class.private_instance_methods).not_to include(:task) - end - end - - describe "#available" do - context "when retries is configured" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 3)) - end - - it "returns the configured retry count" do - expect(retry_instance.available).to eq(3) - end - end - - context "when retries is nil" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: nil)) - end - - it "returns 0" do - expect(retry_instance.available).to eq(0) - end - end - - context "when retries is not configured" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings) - end - - it "returns 0" do - expect(retry_instance.available).to eq(0) - end - end - end - - describe "#available?" do - context "when retries are configured" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 2)) - end - - it "returns true" do - expect(retry_instance.available?).to be(true) - end - end - - context "when retries is 0" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 0)) - end - - it "returns false" do - expect(retry_instance.available?).to be(false) - end - end - - context "when retries is nil" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: nil)) - end - - it "returns false" do - expect(retry_instance.available?).to be(false) - end - end - end - - describe "#attempts" do - context "when no retries have occurred" do - it "returns 0" do - expect(retry_instance.attempts).to eq(0) - end - end - - context "when retries have occurred" do - before do - task.result.retries = 2 - end - - it "returns the number of attempts" do - expect(retry_instance.attempts).to eq(2) - end - end - end - - describe "#retried?" do - context "when no retries have occurred" do - it "returns false" do - expect(retry_instance.retried?).to be(false) - end - end - - context "when at least one retry has occurred" do - before do - task.result.retries = 1 - end - - it "returns true" do - expect(retry_instance.retried?).to be(true) - end - end - end - - describe "#remaining" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 5)) - end - - context "when no retries have occurred" do - it "returns the full retry count" do - expect(retry_instance.remaining).to eq(5) - end - end - - context "when some retries have occurred" do - before do - task.result.retries = 2 - end - - it "returns the difference between available and attempts" do - expect(retry_instance.remaining).to eq(3) - end - end - - context "when all retries are exhausted" do - before do - task.result.retries = 5 - end - - it "returns 0" do - expect(retry_instance.remaining).to eq(0) - end - end - end - - describe "#remaining?" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 3)) - end - - context "when retries remain" do - it "returns true" do - expect(retry_instance.remaining?).to be(true) - end - end - - context "when all retries are exhausted" do - before do - task.result.retries = 3 - end - - it "returns false" do - expect(retry_instance.remaining?).to be(false) - end - end - end - - describe "#exceptions" do - context "when retry_on is configured with specific exceptions" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retry_on: [ArgumentError, RuntimeError])) - end - - it "returns the configured exception classes" do - expect(retry_instance.exceptions).to eq([ArgumentError, RuntimeError]) - end - end - - context "when retry_on is a single exception class" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retry_on: ArgumentError)) - end - - it "wraps it in an array" do - expect(retry_instance.exceptions).to eq([ArgumentError]) - end - end - - context "when retry_on is nil" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retry_on: nil)) - end - - it "defaults to StandardError" do - expect(retry_instance.exceptions).to eq([StandardError, CMDx::TimeoutError]) - end - end - - context "when retry_on is not configured" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings) - end - - it "defaults to StandardError" do - expect(retry_instance.exceptions).to eq([StandardError, CMDx::TimeoutError]) - end - end - - it "memoizes the result" do - allow(task.class).to receive(:settings).and_return(mock_settings(retry_on: [ArgumentError])) - - first_call = retry_instance.exceptions - second_call = retry_instance.exceptions - - expect(first_call).to equal(second_call) - end - end - - describe "#exception?" do - context "with default retry_on (StandardError)" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retry_on: nil)) - end - - it "returns true for StandardError" do - expect(retry_instance.exception?(StandardError.new)).to be(true) - end - - it "returns true for subclass of StandardError" do - expect(retry_instance.exception?(ArgumentError.new)).to be(true) - end - - it "returns false for non-matching exception" do - expect(retry_instance.exception?(Exception.new)).to be(false) - end - end - - context "with specific retry_on" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retry_on: [ArgumentError])) - end - - it "returns true for exact match" do - expect(retry_instance.exception?(ArgumentError.new)).to be(true) - end - - it "returns false for non-matching exception" do - expect(retry_instance.exception?(RuntimeError.new)).to be(false) - end - - it "returns false for parent class of configured exception" do - expect(retry_instance.exception?(StandardError.new)).to be(false) - end - end - - context "with multiple retry_on exceptions" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retry_on: [ArgumentError, RuntimeError])) - end - - it "returns true when matching any configured exception" do - expect(retry_instance.exception?(ArgumentError.new)).to be(true) - expect(retry_instance.exception?(RuntimeError.new)).to be(true) - end - end - end - - describe "#wait" do - context "with numeric jitter" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 3, retry_jitter: 0.5)) - end - - it "returns jitter multiplied by attempts" do - task.result.retries = 2 - - expect(retry_instance.wait).to eq(1.0) - end - - it "returns 0.0 when no attempts have been made" do - expect(retry_instance.wait).to eq(0.0) - end - end - - context "with symbol jitter" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 3, retry_jitter: :custom_wait)) - allow(task).to receive(:custom_wait).with(1).and_return(2.5) - task.result.retries = 1 - end - - it "calls the named method on the task with attempts" do - expect(task).to receive(:custom_wait).with(1) - - retry_instance.wait - end - - it "returns the method result as a float" do - expect(retry_instance.wait).to eq(2.5) - end - end - - context "with proc jitter" do - let(:jitter_proc) { ->(retries) { retries * 0.75 } } - - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 3, retry_jitter: jitter_proc)) - task.result.retries = 2 - end - - it "instance_execs the proc with attempts" do - expect(retry_instance.wait).to eq(1.5) - end - end - - context "with callable object jitter" do - let(:jitter_callable) do - Class.new do - def call(_task, retries) - retries * 1.25 - end - end.new - end - - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 3, retry_jitter: jitter_callable)) - task.result.retries = 2 - end - - it "calls the object with task and attempts" do - expect(jitter_callable).to receive(:call).with(task, 2).and_call_original - - retry_instance.wait - end - - it "returns the callable result as a float" do - expect(retry_instance.wait).to eq(2.5) - end - end - - context "with nil jitter" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 3, retry_jitter: nil)) - task.result.retries = 2 - end - - it "returns 0.0" do - expect(retry_instance.wait).to eq(0.0) - end - end - end -end diff --git a/spec/cmdx/retry_strategy_spec.rb b/spec/cmdx/retry_strategy_spec.rb new file mode 100644 index 000000000..53e8c19d9 --- /dev/null +++ b/spec/cmdx/retry_strategy_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe CMDx::RetryStrategy do + def strategy_with(**attrs) + settings = CMDx::Settings.new + attrs.each { |k, v| settings.public_send(:"#{k}=", v) } + described_class.new(settings) + end + + describe "#retryable?" do + it "is true when resolved retry_count > 0" do + expect(strategy_with(retry_count: 1).retryable?).to be(true) + expect(strategy_with(retry_count: 0).retryable?).to be(false) + end + end + + describe "#should_retry?" do + it "checks exception class and attempt count against max_retries" do + s = strategy_with(retry_count: 2, retry_on: [RuntimeError]) + err = RuntimeError.new("x") + expect(s.should_retry?(err, 0)).to be(true) + expect(s.should_retry?(err, 1)).to be(true) + expect(s.should_retry?(err, 2)).to be(false) + expect(s.should_retry?(ArgumentError.new("y"), 0)).to be(false) + end + end + + describe "#wait" do + it "sleeps for delay plus jitter within range" do + s = strategy_with(retry_delay: 0.02, retry_jitter: 0.03) + t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC) + s.wait + elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0 + expect(elapsed).to be >= 0.02 + expect(elapsed).to be < 0.08 + end + + it "returns immediately when delay and jitter are zero" do + s = strategy_with(retry_delay: 0, retry_jitter: 0) + t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC) + s.wait + elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0 + expect(elapsed).to be < 0.01 + end + end +end diff --git a/spec/cmdx/runtime_spec.rb b/spec/cmdx/runtime_spec.rb new file mode 100644 index 000000000..b2224b2e6 --- /dev/null +++ b/spec/cmdx/runtime_spec.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe CMDx::Runtime do + describe ".call" do + it "runs prepare → validate → execute → finalize for a successful task" do + events = [] + k = Class.new(CMDx::Task) do + required :n, :integer + + before_validation { events << :bv } + before_execution { events << :be } + on_success { events << :os } + on_complete { events << :oc } + on_executed { events << :ex } + + def work + ctx[:out] = n * 2 + end + end + + r = described_class.call(k, { n: 3 }, raise_on_fault: false) + expect(r.success?).to be(true) + expect(r.state).to eq("complete") + expect(r.status).to eq("success") + expect(r.context[:out]).to eq(6) + expect(events).to eq(%i[bv be os oc ex]) + end + + it "applies signal-based fail!: halts and sets failed status" do + k = Class.new(CMDx::Task) do + def work + fail!("nope", halt: true) + end + end + r = described_class.call(k, {}, raise_on_fault: false) + expect(r.failed?).to be(true) + expect(r.state).to eq("interrupted") + expect(r.status).to eq("failed") + end + + it "honors non-halting fail!(halt: false) as failure signal without throw" do + k = Class.new(CMDx::Task) do + def work + fail!("soft", halt: false) + end + end + r = described_class.call(k, {}, raise_on_fault: false) + expect(r.failed?).to be(true) + expect(r.reason).to include("soft") + end + + it "retries on configured exceptions then succeeds" do + tries = [0] + k = Class.new(CMDx::Task) do + settings do |s| + s.retry_count = 2 + s.retry_delay = 0 + s.retry_jitter = 0 + s.retry_on = [RuntimeError] + end + + define_method(:work) do + tries[0] += 1 + raise "boom" if tries[0] < 2 + + ctx[:ok] = true + end + end + + r = described_class.call(k, {}, raise_on_fault: false) + expect(r.success?).to be(true) + expect(r.retries).to eq(1) + end + + it "calls rollback on failure" do + rolled = false + k = Class.new(CMDx::Task) do + define_method(:rollback) { rolled = true } + + def work + fail!("x", halt: true) + end + end + r = described_class.call(k, {}, raise_on_fault: false) + expect(r.failed?).to be(true) + expect(rolled).to be(true) + end + + it "invokes on_failed when execution fails" do + seen = [] + k = Class.new(CMDx::Task) do + on_failed { seen << :failed } + + def work + fail!("z", halt: true) + end + end + described_class.call(k, {}, raise_on_fault: false) + expect(seen).to eq([:failed]) + end + + it "fails when declared returns are missing from context" do + k = Class.new(CMDx::Task) do + returns :required_key + def work; end + end + r = described_class.call(k, {}, raise_on_fault: false) + expect(r.failed?).to be(true) + expect(r.errors.for?(:required_key)).to be(true) + end + + it "deprecation_check! raises DeprecationError when deprecate mode is :restrict" do + k = Class.new(CMDx::Task) do + settings { |s| s.deprecate = { mode: :restrict } } + def work; end + end + rt = described_class.new(k, {}) + expect { rt.send(:deprecation_check!) }.to raise_error(CMDx::DeprecationError) + end + end +end diff --git a/spec/cmdx/settings_spec.rb b/spec/cmdx/settings_spec.rb index 02bb98162..03249dc26 100644 --- a/spec/cmdx/settings_spec.rb +++ b/spec/cmdx/settings_spec.rb @@ -2,303 +2,81 @@ require "spec_helper" -RSpec.describe CMDx::Settings, type: :unit do - describe "#initialize" do - context "without parent" do - subject(:settings) { described_class.new } - - it "creates fresh registries from configuration" do - expect(settings.attributes).to be_a(CMDx::AttributeRegistry) - expect(settings.attributes.registry).to be_empty - expect(settings.middlewares).to be_a(CMDx::MiddlewareRegistry) - expect(settings.callbacks).to be_a(CMDx::CallbackRegistry) - expect(settings.coercions).to be_a(CMDx::CoercionRegistry) - expect(settings.validators).to be_a(CMDx::ValidatorRegistry) - end - - it "dups registries so they are independent from configuration" do - expect(settings.middlewares).not_to equal(CMDx.configuration.middlewares) - expect(settings.callbacks).not_to equal(CMDx.configuration.callbacks) - expect(settings.coercions).not_to equal(CMDx.configuration.coercions) - expect(settings.validators).not_to equal(CMDx.configuration.validators) - end - - it "inherits scalar values from configuration" do - expect(settings.task_breakpoints).to eq(CMDx.configuration.task_breakpoints) - expect(settings.workflow_breakpoints).to eq(CMDx.configuration.workflow_breakpoints) - expect(settings.rollback_on).to eq(CMDx.configuration.rollback_on) - expect(settings.backtrace).to eq(CMDx.configuration.backtrace) - expect(settings.dump_context).to eq(CMDx.configuration.dump_context) - expect(settings.logger).to eq(CMDx.configuration.logger) - end - - it "initializes nullable fields from configuration" do - expect(settings.backtrace_cleaner).to eq(CMDx.configuration.backtrace_cleaner) - expect(settings.exception_handler).to eq(CMDx.configuration.exception_handler) - end - - it "sets arrays to frozen empty arrays" do - expect(settings.returns).to eq([]) - expect(settings.returns).to be_frozen - expect(settings.tags).to eq([]) - expect(settings.tags).to be_frozen - end - - it "defaults task-level settings to nil" do - expect(settings.breakpoints).to be_nil - expect(settings.log_level).to be_nil - expect(settings.log_formatter).to be_nil - expect(settings.retries).to be_nil - expect(settings.retry_on).to be_nil - expect(settings.retry_jitter).to be_nil - expect(settings.deprecate).to be_nil - end +RSpec.describe CMDx::Settings do + describe "lazy accessors" do + it "returns nil for unset values" do + s = described_class.new + expect(s.logger).to be_nil + expect(s.log_level).to be_nil + expect(s.tags).to be_nil + expect(s.on_failure).to be_nil + expect(s.retry_count).to be_nil + expect(s.deprecate).to be_nil end + end - context "with parent" do - subject(:settings) { described_class.new(parent: parent) } - - let(:parent) do - mock_settings( - attributes: CMDx::AttributeRegistry.new, - middlewares: CMDx::MiddlewareRegistry.new, - callbacks: CMDx::CallbackRegistry.new, - coercions: CMDx::CoercionRegistry.new, - validators: CMDx::ValidatorRegistry.new, - task_breakpoints: %w[failed skipped], - workflow_breakpoints: %w[failed], - rollback_on: %w[failed], - breakpoints: %w[failed], - backtrace: true, - backtrace_cleaner: ->(bt) { bt.first(3) }, - exception_handler: ->(_task, _err) {}, - logger: Logger.new(nil), - log_level: Logger::WARN, - log_formatter: proc { |*| "fmt" }, - retries: 3, - retry_on: [StandardError, CMDx::TimeoutError], - retry_jitter: :exponential, - deprecate: :warn, - returns: %i[user token], - tags: %i[auth critical] - ) - end - - it "deep-dups registries from parent" do - expect(settings.attributes.registry).to eq(parent.attributes.registry) - expect(settings.attributes).not_to equal(parent.attributes) - - expect(settings.middlewares).not_to equal(parent.middlewares) - expect(settings.callbacks).not_to equal(parent.callbacks) - expect(settings.coercions).not_to equal(parent.coercions) - expect(settings.validators).not_to equal(parent.validators) - end - - it "inherits scalar/callable values from parent" do - expect(settings.task_breakpoints).to eq(%w[failed skipped]) - expect(settings.workflow_breakpoints).to eq(%w[failed]) - expect(settings.rollback_on).to eq(%w[failed]) - expect(settings.breakpoints).to eq(%w[failed]) - expect(settings.backtrace).to be true - expect(settings.backtrace_cleaner).to eq(parent.backtrace_cleaner) - expect(settings.exception_handler).to eq(parent.exception_handler) - expect(settings.logger).to eq(parent.logger) - expect(settings.log_level).to eq(Logger::WARN) - expect(settings.log_formatter).to eq(parent.log_formatter) - end - - it "inherits task-level values from parent" do - expect(settings.retries).to eq(3) - expect(settings.retry_on).to eq([StandardError, CMDx::TimeoutError]) - expect(settings.retry_jitter).to eq(:exponential) - expect(settings.deprecate).to eq(:warn) - end - - it "dups returns and tags arrays" do - expect(settings.returns).to eq(%i[user token]) - expect(settings.returns).not_to equal(parent.returns) - expect(settings.tags).to eq(%i[auth critical]) - expect(settings.tags).not_to equal(parent.tags) - end - - context "when parent has nil returns and tags" do - let(:parent) do - mock_settings( - attributes: CMDx::AttributeRegistry.new, - middlewares: CMDx::MiddlewareRegistry.new, - callbacks: CMDx::CallbackRegistry.new, - coercions: CMDx::CoercionRegistry.new, - validators: CMDx::ValidatorRegistry.new, - task_breakpoints: %w[failed], - workflow_breakpoints: %w[failed], - rollback_on: %w[failed], - breakpoints: nil, - backtrace: false, - backtrace_cleaner: nil, - exception_handler: nil, - logger: nil, - log_level: nil, - log_formatter: nil, - retries: nil, - retry_on: nil, - retry_jitter: nil, - deprecate: nil, - returns: nil, - tags: nil - ) - end - - it "falls back to frozen empty arrays" do - expect(settings.returns).to eq([]) - expect(settings.returns).to be_frozen - expect(settings.tags).to eq([]) - expect(settings.tags).to be_frozen - end - end - - context "when parent has nil backtrace_cleaner, exception_handler, and logger" do - let(:parent) do - mock_settings( - attributes: CMDx::AttributeRegistry.new, - middlewares: CMDx::MiddlewareRegistry.new, - callbacks: CMDx::CallbackRegistry.new, - coercions: CMDx::CoercionRegistry.new, - validators: CMDx::ValidatorRegistry.new, - task_breakpoints: %w[failed], - workflow_breakpoints: %w[failed], - rollback_on: %w[failed], - breakpoints: nil, - backtrace: false, - backtrace_cleaner: nil, - exception_handler: nil, - logger: nil, - log_level: nil, - log_formatter: nil, - retries: nil, - retry_on: nil, - retry_jitter: nil, - deprecate: nil, - returns: nil, - tags: nil - ) - end - - it "falls back to configuration values" do - expect(settings.backtrace_cleaner).to eq(CMDx.configuration.backtrace_cleaner) - expect(settings.exception_handler).to eq(CMDx.configuration.exception_handler) - expect(settings.logger).to eq(CMDx.configuration.logger) - end - end + describe "parent delegation" do + it "resolves through parent for resolved_* readers" do + parent = described_class.new + parent.tags = %i[a b] + parent.on_failure = :skip + parent.retry_count = 2 + parent.retry_delay = 0.5 + parent.retry_jitter = 0.1 + parent.retry_on = [ArgumentError] + + child = described_class.new(parent) + + expect(child.resolved_tags).to eq(%i[a b]) + expect(child.resolved_on_failure).to eq(:skip) + expect(child.resolved_retry_count).to eq(2) + expect(child.resolved_retry_delay).to eq(0.5) + expect(child.resolved_retry_jitter).to eq(0.1) + expect(child.resolved_retry_on).to eq([ArgumentError]) end + end - context "with overrides" do - subject(:settings) { described_class.new(retries: 5, backtrace: true) } - - it "applies overrides after inheriting from configuration" do - expect(settings.retries).to eq(5) - expect(settings.backtrace).to be true - end + describe "global configuration fallback" do + it "falls back to CMDx.configuration.logger for resolved_logger" do + global = CMDx.configuration.logger + child = described_class.new + expect(child.resolved_logger).to be(global) end + end - context "with parent and overrides" do - subject(:settings) { described_class.new(parent: parent, retries: 10, deprecate: :warn) } - - let(:parent) do - mock_settings( - attributes: CMDx::AttributeRegistry.new, - middlewares: CMDx::MiddlewareRegistry.new, - callbacks: CMDx::CallbackRegistry.new, - coercions: CMDx::CoercionRegistry.new, - validators: CMDx::ValidatorRegistry.new, - task_breakpoints: %w[failed], - workflow_breakpoints: %w[failed], - rollback_on: %w[failed], - breakpoints: nil, - backtrace: false, - backtrace_cleaner: nil, - exception_handler: nil, - logger: nil, - log_level: nil, - log_formatter: nil, - retries: 2, - retry_on: nil, - retry_jitter: nil, - deprecate: nil, - returns: nil, - tags: nil - ) - end + describe "#for_child" do + it "returns a new Settings with self as parent" do + parent = described_class.new + parent.tags = [:x] - it "overrides parent values" do - expect(settings.retries).to eq(10) - expect(settings.deprecate).to eq(:warn) - end + child = parent.for_child + expect(child.parent).to be(parent) + expect(child.resolved_tags).to eq([:x]) end end - describe "delegation" do - context "with delegate_to_configuration settings" do - it "reflects configuration changes without re-creating settings" do - settings = described_class.new - - CMDx.configuration.task_breakpoints = %w[failed skipped] - expect(settings.task_breakpoints).to eq(%w[failed skipped]) - - CMDx.configuration.rollback_on = %w[skipped] - expect(settings.rollback_on).to eq(%w[skipped]) - end - - it "stops delegating once locally overridden" do - settings = described_class.new - settings.task_breakpoints = %w[custom] - - CMDx.configuration.task_breakpoints = %w[failed skipped] - expect(settings.task_breakpoints).to eq(%w[custom]) - end + describe "#retryable?" do + it "is false when retry count resolves to zero" do + expect(described_class.new.retryable?).to be(false) end - context "with delegate_with_fallback settings" do - it "reflects configuration logger changes" do - settings = described_class.new - new_logger = Logger.new(nil) - - CMDx.configuration.logger = new_logger - expect(settings.logger).to equal(new_logger) - end - - it "stops delegating once locally overridden" do - settings = described_class.new - local_logger = Logger.new(nil) - settings.logger = local_logger - - CMDx.configuration.logger = Logger.new(nil) - expect(settings.logger).to equal(local_logger) - end + it "is true when retry count resolves positive" do + s = described_class.new + s.retry_count = 1 + expect(s.retryable?).to be(true) end + end - context "with parent chain delegation" do - it "walks the parent chain to resolve values" do - grandparent = described_class.new(retries: 5) - parent = described_class.new(parent: grandparent) - child = described_class.new(parent: parent) - - expect(child.retries).to eq(5) - end - - it "prefers the closest override in the chain" do - grandparent = described_class.new(retries: 5) - parent = described_class.new(parent: grandparent, retries: 10) - child = described_class.new(parent: parent) - - expect(child.retries).to eq(10) - end - - it "local override wins over parent chain" do - parent = described_class.new(retries: 5) - child = described_class.new(parent: parent, retries: 20) + describe "#deprecated?" do + it "is false when deprecate resolves to nil" do + expect(described_class.new.deprecated?).to be(false) + end - expect(child.retries).to eq(20) - end + it "is true when deprecate is set on self or parent" do + parent = described_class.new + parent.deprecate = { message: "nope" } + expect(parent.deprecated?).to be(true) + expect(described_class.new(parent).deprecated?).to be(true) end end end diff --git a/spec/cmdx/signals_spec.rb b/spec/cmdx/signals_spec.rb new file mode 100644 index 000000000..d70e59335 --- /dev/null +++ b/spec/cmdx/signals_spec.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe CMDx::Signals do + def task_with_context(**ctx) + klass = Class.new(CMDx::Task) do + def work; end + end + t = klass.allocate + t.instance_variable_set(:@context, CMDx::Context.new(ctx)) + t + end + + describe "#success!" do + it "with halt: true throws :cmdx_signal" do + task = task_with_context + caught = catch(:cmdx_signal) do + task.success!("ok", halt: true, meta: 1) + :not_thrown + end + expect(caught).to eq({ status: :success, reason: "ok", metadata: { meta: 1 } }) + end + + it "with halt: false sets @_success and does not throw" do + task = task_with_context + val = catch(:cmdx_signal) do + task.success!(halt: false, note: "x") + :inner + end + expect(val).to eq(:inner) + expect(task.instance_variable_get(:@_success)).to eq( + { reason: nil, metadata: { note: "x" } } + ) + end + end + + describe "#fail!" do + it "with halt: true throws :cmdx_signal with status: :failed" do + task = task_with_context + caught = catch(:cmdx_signal) { task.fail!("nope", halt: true) } + expect(caught[:status]).to eq(:failed) + expect(caught[:reason]).to eq("nope") + end + + it "with halt: false sets @_signal" do + task = task_with_context + task.fail!("soft", halt: false) + sig = task.instance_variable_get(:@_signal) + expect(sig[:status]).to eq(:failed) + expect(sig[:reason]).to eq("soft") + end + end + + describe "#skip!" do + it "with halt: true throws :cmdx_signal with status: :skipped" do + task = task_with_context + caught = catch(:cmdx_signal) { task.skip!("later", halt: true) } + expect(caught[:status]).to eq(:skipped) + expect(caught[:reason]).to eq("later") + end + + it "does not override existing @_signal" do + task = task_with_context + task.instance_variable_set(:@_signal, { status: :failed, reason: "first" }) + task.skip!("ignored", halt: false) + expect(task.instance_variable_get(:@_signal)[:reason]).to eq("first") + end + end + + describe "#throw!" do + it "propagates another result's status" do + src_class = Class.new(CMDx::Task) do + def work + fail!("from source", halt: true) + end + end + other = src_class.execute + expect(other.status).to eq("failed") + + task = task_with_context + caught = catch(:cmdx_signal) { task.throw!(other, halt: true) } + expect(caught[:status]).to eq(:failed) + expect(caught[:reason]).to eq(other.reason) + expect(caught[:thrown_from]).to eq(other.task_id) + end + end + + describe "#dry_run?" do + it "reads from context[:dry_run]" do + expect(task_with_context(dry_run: true).dry_run?).to be(true) + expect(task_with_context(dry_run: false).dry_run?).to be(false) + expect(task_with_context.dry_run?).to be(false) + end + end +end diff --git a/spec/cmdx/task_spec.rb b/spec/cmdx/task_spec.rb index 0b01f311c..660072440 100644 --- a/spec/cmdx/task_spec.rb +++ b/spec/cmdx/task_spec.rb @@ -2,609 +2,222 @@ require "spec_helper" -RSpec.describe CMDx::Task, type: :unit do - let(:task_class) { create_task_class(name: "TestTask") } - let(:task) { task_class.new } - let(:context_hash) { { foo: "bar", baz: 42 } } - - describe "#initialize" do - context "with no arguments" do - it "initializes with default values" do - expect(task.attributes).to eq({}) - expect(task.errors).to be_a(CMDx::Errors) - expect(task.errors).to be_empty - expect(task.id).to be_a(String) - expect(task.context).to be_a(CMDx::Context) - expect(task.context.to_h).to eq({}) - expect(task.result).to be_a(CMDx::Result) - expect(task.result.task).to eq(task) - expect(task.chain).to be_a(CMDx::Chain) - expect(task.dry_run?).to be(false) - end - - it "generates unique IDs for different instances" do - task1 = task_class.new - task2 = task_class.new - - expect(task1.id).not_to eq(task2.id) - end - end - - context "with context hash" do - let(:task) { task_class.new(context_hash) } - - it "initializes context with provided hash" do - expect(task.context.to_h).to eq(context_hash) +RSpec.describe CMDx::Task do + describe ".execute and .execute!" do + let(:ok_task) do + Class.new(described_class) do + def work + ctx[:out] = 1 + end end end - context "with context object" do - let(:context_obj) { CMDx::Context.new(context_hash) } - let(:task) { task_class.new(context_obj) } - - it "uses the provided context object" do - expect(task.context).to eq(context_obj) - expect(task.context.to_h).to eq(context_hash) + let(:fail_task) do + Class.new(described_class) do + def work + fail!("bad", halt: true) + end end end - context "with object that responds to context" do - let(:context_wrapper) { instance_double("MockContextWrapper", context: context_hash) } - let(:task) { task_class.new(context_wrapper) } - - it "extracts context from the wrapper object" do - expect(task.context.to_h).to eq(context_hash) + let(:skip_task) do + Class.new(described_class) do + def work + skip!("nope", halt: true) + end end end - it "calls Deprecator.restrict" do - expect(CMDx::Deprecator).to receive(:restrict).with(an_instance_of(task_class)) - - task_class.new - end - end - - describe "aliases" do - it "aliases ctx to context" do - expect(task.ctx).to eq(task.context) - end - - it "aliases res to result" do - expect(task.res).to eq(task.result) - end - end - - describe "delegated methods" do - it "delegates success! to resolver" do - expect(task.resolver).to receive(:success!).with("reason", metadata: "data") - - task.success!("reason", metadata: "data") - end - - it "delegates skip! to resolver" do - expect(task.resolver).to receive(:skip!).with("reason", metadata: "data") - - task.skip!("reason", metadata: "data") - end - - it "delegates fail! to resolver" do - expect(task.resolver).to receive(:fail!).with("reason", metadata: "data") - - task.fail!("reason", metadata: "data") - end - - it "delegates throw! to resolver" do - expect(task.resolver).to receive(:throw!).with("reason", metadata: "data") - - task.throw!("reason", metadata: "data") - end - end - - describe ".settings" do - context "when called for the first time" do - it "returns default settings with required keys" do - settings = task_class.settings - - expect(settings).to be_a(CMDx::Settings) - expect(settings.attributes).to be_a(CMDx::AttributeRegistry) - expect(settings.tags).to eq([]) - end + it ".execute returns a Result" do + r = ok_task.execute + expect(r).to be_a(CMDx::Result) + expect(r).to be_frozen + expect(r.success?).to be(true) end - context "when superclass has settings" do - let(:parent_class) { create_task_class(name: "ParentTask") } - let(:child_class) { Class.new(parent_class) } - - before do - parent_class.settings(deprecate: true) - end - - it "inherits from superclass settings" do - child_settings = child_class.settings - - expect(child_settings.deprecate).to be(true) - end - - it "can override inherited settings" do - child_settings = child_class.settings(deprecate: false) - - expect(child_settings.deprecate).to be(false) + it ".execute! raises FailFault on failure" do + expect { fail_task.execute! }.to raise_error(CMDx::FailFault) do |e| + expect(e.result.failed?).to be(true) end end - context "with custom options" do - it "merges custom options with defaults" do - settings = task_class.settings(deprecate: :warn, tags: ["tag1"]) - - expect(settings.deprecate).to eq(:warn) - expect(settings.tags).to eq(["tag1"]) + it ".execute! raises SkipFault on skip" do + expect { skip_task.execute! }.to raise_error(CMDx::SkipFault) do |e| + expect(e.result.skipped?).to be(true) end end - - it "memoizes settings" do - settings1 = task_class.settings - settings2 = task_class.settings - - expect(settings1).to be(settings2) - end end - describe ".register" do - let(:mock_registry) { instance_double("MockRegistry") } - - before do - allow(task_class.settings).to receive_messages( - attributes: mock_registry, - callbacks: mock_registry, - coercions: mock_registry, - middlewares: mock_registry, - validators: mock_registry - ) - end - - context "with :attribute type" do - it "registers with attribute registry and defines readers" do - expect(mock_registry).to receive(:register).with("object") - expect(mock_registry).to receive(:define_readers_on!).with(task_class, ["object"]) - - task_class.register(:attribute, "object") - end - end - - context "with :callback type" do - it "registers with callback registry" do - expect(mock_registry).to receive(:register).with("object", "arg1", "arg2") - - task_class.register(:callback, "object", "arg1", "arg2") - end - end - - context "with :coercion type" do - it "registers with coercion registry" do - expect(mock_registry).to receive(:register).with("object", "arg1", "arg2") - - task_class.register(:coercion, "object", "arg1", "arg2") - end - end - - context "with :middleware type" do - it "registers with middleware registry" do - expect(mock_registry).to receive(:register).with("object", "arg1", "arg2") - - task_class.register(:middleware, "object", "arg1", "arg2") - end - end - - context "with :validator type" do - it "registers with validator registry" do - expect(mock_registry).to receive(:register).with("object", "arg1", "arg2") - - task_class.register(:validator, "object", "arg1", "arg2") - end - end + describe ".type" do + it "returns formatted class name" do + k = Class.new(described_class) do + def self.name + "MyApp::DoThing" + end - context "with unknown type" do - it "raises an error" do - expect { task_class.register(:unknown, "object") }.to raise_error("unknown registry type :unknown") + def work; end end + expect(k.type).to eq("myapp.dothing") end end - describe ".attributes" do - it "defines and registers multiple attributes" do - mock_attributes = instance_double(CMDx::Attribute) - expect(CMDx::Attribute).to receive(:build).with("arg1", "arg2").and_return(mock_attributes) - expect(task_class).to receive(:register).with(:attribute, mock_attributes) - - task_class.attributes("arg1", "arg2") - end - end - - describe ".optional" do - it "defines and registers optional attributes" do - mock_attribute = instance_double(CMDx::Attribute) - expect(CMDx::Attribute).to receive(:optional).with("arg1", "arg2").and_return(mock_attribute) - expect(task_class).to receive(:register).with(:attribute, mock_attribute) - - task_class.optional("arg1", "arg2") - end - end - - describe ".required" do - it "defines and registers required attributes" do - mock_attribute = instance_double(CMDx::Attribute) - expect(CMDx::Attribute).to receive(:required).with("arg1", "arg2").and_return(mock_attribute) - expect(task_class).to receive(:register).with(:attribute, mock_attribute) - - task_class.required("arg1", "arg2") - end - end - - describe ".remove_attributes" do - it "removes multiple attributes from the registry" do - mock_registry = instance_double(CMDx::AttributeRegistry) - allow(task_class.settings).to receive(:attributes).and_return(mock_registry) - - expect(mock_registry).to receive(:undefine_readers_on!).with(task_class, %w[attr1 attr2 attr3]) - expect(mock_registry).to receive(:deregister).with(%w[attr1 attr2 attr3]) - - task_class.remove_attributes("attr1", "attr2", "attr3") + describe ".task_settings and .settings" do + it ".task_settings returns Settings" do + k = Class.new(described_class) { def work; end } + expect(k.task_settings).to be_a(CMDx::Settings) end - it "handles single attribute removal" do - mock_registry = instance_double(CMDx::AttributeRegistry) - allow(task_class.settings).to receive(:attributes).and_return(mock_registry) - - expect(mock_registry).to receive(:undefine_readers_on!).with(task_class, ["single_attr"]) - expect(mock_registry).to receive(:deregister).with(["single_attr"]) - - task_class.remove_attributes("single_attr") + it ".settings yields Settings for configuration" do + k = Class.new(described_class) { def work; end } + seen = nil + s = k.settings do |st| + seen = st + st.tags = [:a] + end + expect(seen).to be_a(CMDx::Settings) + expect(s.resolved_tags).to eq([:a]) end end - describe ".attributes_schema" do - let(:task_with_attrs) do - Class.new(CMDx::Task) do - required :user_id, type: :integer - optional :email, type: :string, default: "test@example.com" - optional :profile, type: :hash do - optional :bio, type: :string - required :name, type: :string + describe "instance behavior" do + let(:concrete) do + Class.new(described_class) do + def work + ctx[:ran] = true end - - def work; end end end - it "returns a hash keyed by attribute method names" do - schema = task_with_attrs.attributes_schema - - expect(schema).to be_a(Hash) - expect(schema.keys).to contain_exactly(:user_id, :email, :profile) + it "#work raises UndefinedMethodError when not overridden" do + k = Class.new(described_class) + task = k.allocate + task.instance_variable_set(:@context, CMDx::Context.new) + task.instance_variable_set(:@_attributes, {}) + task.send(:initialize) + expect { task.work }.to raise_error(CMDx::UndefinedMethodError, /#work/) end - it "includes attribute metadata from to_h" do - schema = task_with_attrs.attributes_schema - - expect(schema[:user_id]).to include( - name: :user_id, - method_name: :user_id, - required: true, - types: [:integer] - ) - - expect(schema[:email]).to include( - name: :email, - method_name: :email, - required: false, - types: [:string] - ) - expect(schema[:email][:options]).to include(default: "test@example.com") + it "#context and #ctx return the context" do + r = concrete.execute(foo: 2) + expect(r.context[:foo]).to eq(2) end - it "includes nested children" do - schema = task_with_attrs.attributes_schema - - expect(schema[:profile][:children]).to be_an(Array) - expect(schema[:profile][:children].size).to eq(2) - - child_names = schema[:profile][:children].map { |c| c[:name] } - expect(child_names).to contain_exactly(:bio, :name) - end - - it "returns empty hash when no attributes defined" do - empty_task = Class.new(CMDx::Task) { def work; end } - - expect(empty_task.attributes_schema).to eq({}) + it "#logger returns a Logger" do + t = concrete.allocate + t.instance_variable_set(:@context, CMDx::Context.new) + t.instance_variable_set(:@_attributes, {}) + expect(t.logger).to be_a(Logger) end end - describe "callback methods" do - CMDx::CallbackRegistry::TYPES.each do |callback_type| - describe ".#{callback_type}" do - it "registers the callback" do - expect(task_class).to receive(:register).with(:callback, callback_type, "callable1", "callable2", option: "value") - - task_class.public_send(callback_type, "callable1", "callable2", option: "value") - end - - it "accepts a block" do - block = proc { "test" } - expect(task_class).to receive(:register).with(:callback, callback_type, option: "value") do |*_args, &passed_block| - expect(passed_block).to eq(block) - end + describe "attribute DSL" do + let(:attr_task) do + Class.new(described_class) do + required :name, :string + optional :age, :integer - task_class.public_send(callback_type, option: "value", &block) + def work + ctx[:label] = "#{name}-#{age}" end end end - end - - describe "#dry_run?" do - it "returns false by default" do - expect(task.dry_run?).to be(false) - end - - context "when initialized with dry_run: true" do - let(:task) { task_class.new(dry_run: true) } - - it "returns true" do - expect(task.dry_run?).to be(true) - end - end - - context "when executed with dry_run: true" do - let(:dry_run_class) do - create_task_class do - def work; end - end - end - - it "returns true via execute" do - result = dry_run_class.execute(dry_run: true) - - expect(result).to be_dry_run - end - - it "returns true via execute!" do - result = dry_run_class.execute!(dry_run: true) - expect(result).to be_dry_run + it "supports .required, .optional, .attribute, .remove_attribute, .attributes_schema" do + k = Class.new(described_class) do + attribute :x, :string, required: false + def work; end end + expect(k.attributes_schema).to have_key(:x) + k.remove_attribute(:x) + expect(k.attributes_schema).not_to have_key(:x) end - end - - describe ".execute" do - let(:mock_task) { instance_double(described_class, result: "result") } - - it "creates new task instance and executes with raise: false" do - expect(task_class).to receive(:new).with("arg1", "arg2").and_return(mock_task) - expect(mock_task).to receive(:execute).with(raise: false) - - result = task_class.execute("arg1", "arg2") - - expect(result).to eq("result") - end - end - - describe ".execute!" do - let(:mock_task) { instance_double(described_class, result: "result") } - - it "creates new task instance and executes with raise: true" do - expect(task_class).to receive(:new).with("arg1", "arg2").and_return(mock_task) - expect(mock_task).to receive(:execute).with(raise: true) - result = task_class.execute!("arg1", "arg2") - - expect(result).to eq("result") + it "validates and exposes coerced attributes" do + r = attr_task.execute(name: "ann", age: "3") + expect(r.success?).to be(true) + expect(r.context[:label]).to eq("ann-3") end end - describe "#execute" do - context "with raise: false" do - it "delegates to Executor.execute with raise: false" do - expect(CMDx::Executor).to receive(:execute).with(task, raise: false) - - task.execute(raise: false) - end - end - - context "with raise: true" do - it "delegates to Executor.execute with raise: true" do - expect(CMDx::Executor).to receive(:execute).with(task, raise: true) - - task.execute(raise: true) - end - end + describe "callback DSL" do + it "registers on_success, on_failed, before_validation, etc." do + log = [] + k = Class.new(described_class) do + required :n, :integer - context "with no arguments" do - it "defaults to raise: false" do - expect(CMDx::Executor).to receive(:execute).with(task, raise: false) + before_validation { log << :bv } + before_execution { log << :be } + on_success { log << :os } + on_failed { log << :of } - task.execute + def work + fail!("x", halt: true) if n.negative? + end end - end - end - describe "#work" do - it "raises UndefinedMethodError" do - expect { task.work }.to raise_error( - CMDx::UndefinedMethodError, - /undefined method.*#work/ - ) - end + k.execute(n: 1) + expect(log).to eq(%i[bv be os]) - it "includes the class name in the error message" do - expect { task.work }.to raise_error( - CMDx::UndefinedMethodError, - /TestTask\d+#work/ - ) + log.clear + k.execute(n: -1) + expect(log).to include(:bv, :be, :of) end end - describe "#logger" do - context "when class settings has logger" do - let(:class_logger) { Logger.new(IO::NULL) } - - before do - allow(task.class).to receive(:settings).and_return(mock_settings(logger: class_logger)) - end - - it "returns the class logger" do - expect(task.logger).to equal(class_logger) - end - end - - context "when class settings has no logger" do - let(:config_logger) { Logger.new(IO::NULL) } - - before do - allow(task.class).to receive(:settings).and_return(mock_settings) - allow(CMDx.configuration).to receive(:logger).and_return(config_logger) - end - - it "returns the configuration logger" do - expect(task.logger).to equal(config_logger) - end - end - - context "when log_level is customized" do - let(:shared_logger) { Logger.new(IO::NULL).tap { |l| l.level = Logger::INFO } } - - before do - allow(task.class).to receive(:settings).and_return(mock_settings(logger: shared_logger, log_level: Logger::DEBUG)) - end - - it "does not mutate the shared logger" do - task.logger - expect(shared_logger.level).to eq(Logger::INFO) + describe "middleware DSL" do + it ".register and .deregister wrap execution" do + wrapper = Module.new do + def self.call(task, *) + task.ctx[:mw] = (task.ctx[:mw] || 0) + 1 + yield + end end + k = Class.new(described_class) do + register wrapper - it "returns a different logger instance" do - expect(task.logger).not_to equal(shared_logger) - expect(task.logger.level).to eq(Logger::DEBUG) + def work; end end + expect(k.execute.context[:mw]).to eq(1) + k.deregister(wrapper) + expect(k.execute.context[:mw]).to be_nil end + end - context "when log_formatter is customized" do - let(:shared_logger) { Logger.new(IO::NULL) } - let(:original_formatter) { shared_logger.formatter } - let(:custom_formatter) { proc { |_s, _d, _p, msg| "#{msg}\n" } } - - before do - allow(task.class).to receive(:settings).and_return(mock_settings(logger: shared_logger, log_formatter: custom_formatter)) - end - - it "does not mutate the shared logger" do - task.logger - expect(shared_logger.formatter).to eq(original_formatter) + describe "returns DSL" do + it ".returns and .remove_returns verify context keys" do + k = Class.new(described_class) do + returns :needed + def work; end end + r = k.execute + expect(r.failed?).to be(true) - it "returns a different logger instance" do - expect(task.logger).not_to equal(shared_logger) - expect(task.logger.formatter).to eq(custom_formatter) - end + k.remove_returns(:needed) + expect(k.execute.success?).to be(true) end end - describe "#to_h" do - let(:workflow_class) { Class.new(task_class) { include CMDx::Workflow } } - let(:workflow_task) { workflow_class.new } - - before do - allow(task.result).to receive(:index).and_return(5) - allow(task.chain).to receive(:id).and_return("chain-123") - allow(task.class).to receive(:settings).and_return(mock_settings(tags: %w[tag1 tag2])) - end - - context "when task is regular task" do - it "returns hash representation" do - result_hash = task.to_h - - expect(result_hash[:index]).to eq(5) - expect(result_hash[:chain_id]).to eq("chain-123") - expect(result_hash[:type]).to eq("Task") - expect(result_hash[:tags]).to eq(%w[tag1 tag2]) - expect(result_hash[:class]).to be_a(String) - expect(result_hash[:class]).to match(/TestTask\d+/) - expect(result_hash[:dry_run]).to be(false) - expect(result_hash[:id]).to eq(task.id) - expect(result_hash).not_to have_key(:context) - end - end - - context "when dump_context is enabled globally" do - let(:task) { task_class.new(foo: "bar") } - - before do - allow(task.result).to receive(:index).and_return(5) - allow(task.chain).to receive(:id).and_return("chain-123") - CMDx.configuration.dump_context = true - end - - after { CMDx.configuration.dump_context = false } - - it "includes context in hash" do - expect(task.to_h[:context]).to eq(foo: "bar") + describe "inheritance" do + let(:parent) do + Class.new(described_class) do + settings { |s| s.tags = [:p] } + required :a, :string + def work; end end end - context "when dump_context is enabled via task settings" do - let(:ctx_task_class) do - create_task_class(name: "CtxTask") do - settings dump_context: true + it "child inherits settings and registries" do + child = Class.new(parent) do + optional :b, :string + def work + ctx[:ab] = "#{a}#{b}" end end - let(:task) { ctx_task_class.new(baz: 42) } - - before do - allow(task.result).to receive(:index).and_return(0) - allow(task.chain).to receive(:id).and_return("chain-456") - allow(task.class).to receive(:settings).and_return( - mock_settings(tags: [], dump_context: true) - ) - end - - it "includes context in hash" do - expect(task.to_h[:context]).to eq(baz: 42) - end - end - - context "when task is workflow task" do - before do - allow(workflow_task.result).to receive(:index).and_return(3) - allow(workflow_task.chain).to receive(:id).and_return("workflow-chain-456") - allow(workflow_task.class).to receive(:settings).and_return(mock_settings(tags: ["workflow"])) - end - - it "returns hash with type 'Workflow'" do - result_hash = workflow_task.to_h - - expect(result_hash[:index]).to eq(3) - expect(result_hash[:chain_id]).to eq("workflow-chain-456") - expect(result_hash[:type]).to eq("Workflow") - expect(result_hash[:tags]).to eq(["workflow"]) - expect(result_hash[:class]).to be_a(String) - expect(result_hash[:class]).to match(/TestTask\d+/) - expect(result_hash[:dry_run]).to be(false) - expect(result_hash[:id]).to eq(workflow_task.id) - end - end - end - - describe "#to_s" do - let(:hash_representation) { { key: "value", number: 42 } } - - before do - allow(task).to receive(:to_h).and_return(hash_representation) - end - - it "formats the hash using Utils::Format.to_str" do - expect(CMDx::Utils::Format).to receive(:to_str).with(hash_representation).and_return("formatted_string") - - result = task.to_s - - expect(result).to eq("formatted_string") + expect(child.task_settings.parent).to eq(parent.task_settings) + expect(child.attributes_schema).to have_key(:a) + r = child.execute(a: "1", b: "2") + expect(r.success?).to be(true) + expect(r.context[:ab]).to eq("12") end end end diff --git a/spec/cmdx/utils/call_spec.rb b/spec/cmdx/utils/call_spec.rb deleted file mode 100644 index cef60a7ef..000000000 --- a/spec/cmdx/utils/call_spec.rb +++ /dev/null @@ -1,424 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Utils::Call, type: :unit do - subject(:call_module) { described_class } - - let(:target_object) do - Class.new do - def test_method(*args, **kwargs, &block) - result = { args: args, kwargs: kwargs } - result[:block_result] = yield if block - result - end - - def no_args_method - "no_args_result" - end - - def method_with_return_value(value) - "returned_#{value}" - end - - def method_with_side_effect - @side_effect_called = true - "side_effect_result" - end - - def side_effect_called? - @side_effect_called == true - end - end.new - end - - describe ".invoke" do - context "when callable is a Symbol" do - it "calls the method on target with no arguments" do - result = call_module.invoke(target_object, :no_args_method) - - expect(result).to eq("no_args_result") - end - - it "calls the method on target with positional arguments" do - result = call_module.invoke(target_object, :test_method, "arg1", "arg2") - - expect(result).to eq({ - args: %w[arg1 arg2], - kwargs: {} - }) - end - - it "calls the method on target with keyword arguments" do - result = call_module.invoke(target_object, :test_method, key1: "value1", key2: "value2") - - expect(result).to eq({ - args: [], - kwargs: { key1: "value1", key2: "value2" } - }) - end - - it "calls the method on target with both positional and keyword arguments" do - result = call_module.invoke(target_object, :test_method, "arg1", key: "value") - - expect(result).to eq({ - args: ["arg1"], - kwargs: { key: "value" } - }) - end - - it "calls the method on target with a block" do - result = call_module.invoke(target_object, :test_method) { "block_result" } - - expect(result).to eq({ - args: [], - kwargs: {}, - block_result: "block_result" - }) - end - - it "calls the method on target with arguments and block" do - result = call_module.invoke(target_object, :test_method, "arg1", key: "value") { "block_result" } - - expect(result).to eq({ - args: ["arg1"], - kwargs: { key: "value" }, - block_result: "block_result" - }) - end - - it "returns the method's return value" do - result = call_module.invoke(target_object, :method_with_return_value, "test") - - expect(result).to eq("returned_test") - end - - it "executes method with side effects" do - expect(target_object.side_effect_called?).to be(false) - - call_module.invoke(target_object, :method_with_side_effect) - - expect(target_object.side_effect_called?).to be(true) - end - end - - context "when callable is a Proc" do - it "executes proc in target context with no arguments" do - proc_callable = proc { no_args_method } - - result = call_module.invoke(target_object, proc_callable) - - expect(result).to eq("no_args_result") - end - - it "executes proc in target context with positional arguments" do - proc_callable = proc { |arg1, arg2| test_method(arg1, arg2) } - - result = call_module.invoke(target_object, proc_callable, "arg1", "arg2") - - expect(result).to eq({ - args: %w[arg1 arg2], - kwargs: {} - }) - end - - it "executes proc in target context with keyword arguments" do - proc_callable = proc { |key1:, key2:| test_method(key1: key1, key2: key2) } - - result = call_module.invoke(target_object, proc_callable, key1: "value1", key2: "value2") - - expect(result).to eq({ - args: [], - kwargs: { key1: "value1", key2: "value2" } - }) - end - - it "executes proc in target context with mixed arguments" do - proc_callable = proc { |arg, key:| test_method(arg, key: key) } - - result = call_module.invoke(target_object, proc_callable, "arg1", key: "value") - - expect(result).to eq({ - args: ["arg1"], - kwargs: { key: "value" } - }) - end - - it "executes proc with access to target instance variables" do - proc_callable = proc do - @test_var = "set_by_proc" - @test_var # rubocop:disable RSpec/InstanceVariable - end - - result = call_module.invoke(target_object, proc_callable) - - expect(result).to eq("set_by_proc") - expect(target_object.instance_variable_get(:@test_var)).to eq("set_by_proc") - end - - it "executes proc with access to target methods" do - proc_callable = proc { method_with_return_value("from_proc") } - - result = call_module.invoke(target_object, proc_callable) - - expect(result).to eq("returned_from_proc") - end - - it "executes proc with block" do - proc_callable = proc { test_method { "block_result" } } - - result = call_module.invoke(target_object, proc_callable) - - expect(result).to eq({ - args: [], - kwargs: {}, - block_result: "block_result" - }) - end - - it "executes proc that modifies target state" do - proc_callable = proc { method_with_side_effect } - - expect(target_object.side_effect_called?).to be(false) - - call_module.invoke(target_object, proc_callable) - - expect(target_object.side_effect_called?).to be(true) - end - - it "handles proc that returns nil" do - proc_callable = proc {} - - result = call_module.invoke(target_object, proc_callable) - - expect(result).to be_nil - end - - it "handles proc that raises an exception" do - proc_callable = proc { raise StandardError, "proc error" } - - expect do - call_module.invoke(target_object, proc_callable) - end.to raise_error(StandardError, "proc error") - end - end - - context "when callable responds to :call" do - let(:callable_object) do - Class.new do - def call(*args, **kwargs, &block) - result = { args: args, kwargs: kwargs } - result[:block_result] = yield if block - result - end - end.new - end - - it "calls the callable object with no arguments" do - result = call_module.invoke(target_object, callable_object) - - expect(result).to eq({ - args: [], - kwargs: {} - }) - end - - it "calls the callable object with positional arguments" do - result = call_module.invoke(target_object, callable_object, "arg1", "arg2") - - expect(result).to eq({ - args: %w[arg1 arg2], - kwargs: {} - }) - end - - it "calls the callable object with keyword arguments" do - result = call_module.invoke(target_object, callable_object, key1: "value1", key2: "value2") - - expect(result).to eq({ - args: [], - kwargs: { key1: "value1", key2: "value2" } - }) - end - - it "calls the callable object with mixed arguments" do - result = call_module.invoke(target_object, callable_object, "arg1", key: "value") - - expect(result).to eq({ - args: ["arg1"], - kwargs: { key: "value" } - }) - end - - it "calls the callable object with a block" do - result = call_module.invoke(target_object, callable_object) { "block_result" } - - expect(result).to eq({ - args: [], - kwargs: {}, - block_result: "block_result" - }) - end - - it "returns the callable object's return value" do - return_value_callable = Class.new do - def call - "callable_result" - end - end.new - - result = call_module.invoke(target_object, return_value_callable) - - expect(result).to eq("callable_result") - end - - it "handles callable that returns nil" do - nil_callable = Class.new do - def call - nil - end - end.new - - result = call_module.invoke(target_object, nil_callable) - - expect(result).to be_nil - end - - it "handles callable that raises an exception" do - error_callable = Class.new do - def call - raise StandardError, "callable error" - end - end.new - - expect do - call_module.invoke(target_object, error_callable) - end.to raise_error(StandardError, "callable error") - end - end - - context "when callable is lambda" do - it "executes lambda in target context" do - lambda_callable = -> { no_args_method } - - result = call_module.invoke(target_object, lambda_callable) - - expect(result).to eq("no_args_result") - end - - it "executes lambda with strict argument checking" do - lambda_callable = ->(arg) { method_with_return_value(arg) } - - result = call_module.invoke(target_object, lambda_callable, "test") - - expect(result).to eq("returned_test") - end - - it "raises error when lambda argument count doesn't match" do - lambda_callable = ->(arg) { method_with_return_value(arg) } - - expect do - call_module.invoke(target_object, lambda_callable) - end.to raise_error(ArgumentError) - end - end - - context "when callable is invalid" do - it "raises error for string" do - expect do - call_module.invoke(target_object, "invalid_string") - end.to raise_error(/cannot invoke invalid_string/) - end - - it "raises error for integer" do - expect do - call_module.invoke(target_object, 42) - end.to raise_error(/cannot invoke 42/) - end - - it "raises error for array" do - expect do - call_module.invoke(target_object, [1, 2, 3]) - end.to raise_error(/cannot invoke \[1, 2, 3\]/) - end - - it "raises error for hash" do - if RubyVersion.min?(3.4) - expect do - call_module.invoke(target_object, { key: "value" }) - end.to raise_error(/cannot invoke {key: "value"}/) - else - expect do - call_module.invoke(target_object, { key: "value" }) - end.to raise_error(/cannot invoke {:key=>"value"}/) - end - end - - it "raises error for nil" do - expect do - call_module.invoke(target_object, nil) - end.to raise_error(/cannot invoke /) - end - - it "raises error for object that doesn't respond to call" do - non_callable = Class.new.new - - expect do - call_module.invoke(target_object, non_callable) - end.to raise_error(/cannot invoke/) - end - end - - context "when target raises NoMethodError for symbol callable" do - it "raises NoMethodError for undefined method" do - expect do - call_module.invoke(target_object, :undefined_method) - end.to raise_error(NoMethodError) - end - end - - context "with edge cases" do - it "handles empty symbol" do - empty_symbol = :"" - - expect do - call_module.invoke(target_object, empty_symbol) - end.to raise_error(NoMethodError) - end - - it "handles proc with default parameters" do - proc_with_defaults = proc { |arg = "default"| method_with_return_value(arg) } - - result = call_module.invoke(target_object, proc_with_defaults) - - expect(result).to eq("returned_default") - end - - it "handles proc with variable arguments" do - proc_with_splat = proc { |*args| test_method(*args) } - - result = call_module.invoke(target_object, proc_with_splat, "arg1", "arg2", "arg3") - - expect(result).to eq({ - args: %w[arg1 arg2 arg3], - kwargs: {} - }) - end - - it "handles callable with variable arguments" do - splat_callable = Class.new do - def call(*args, **kwargs) - { received_args: args, received_kwargs: kwargs } - end - end.new - - result = call_module.invoke(target_object, splat_callable, "a", "b", x: 1, y: 2) - - expect(result).to eq({ - received_args: %w[a b], - received_kwargs: { x: 1, y: 2 } - }) - end - end - end -end diff --git a/spec/cmdx/utils/condition_spec.rb b/spec/cmdx/utils/condition_spec.rb deleted file mode 100644 index cf21d20ae..000000000 --- a/spec/cmdx/utils/condition_spec.rb +++ /dev/null @@ -1,444 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Utils::Condition, type: :unit do - subject(:condition_module) { described_class } - - let(:target_object) do - Class.new do - def test_method(*args, **kwargs, &block) - result = { args: args, kwargs: kwargs } - result[:block_result] = yield if block - result - end - - def no_args_method - "no_args_result" - end - - def method_with_args(arg1, arg2) - "#{arg1}_#{arg2}" - end - - def method_with_kwargs(name:, value:) - "#{name}: #{value}" - end - - def truthy_method? - true - end - - def falsy_method? - false - end - - def instance_variable_check - @instance_variable_check ||= "instance_value" - end - - attr_accessor :accessible_value - end.new - end - - describe ".evaluate" do - context "when options contain if condition" do - context "with Symbol if condition" do - it "returns true when if condition evaluates to truthy" do - result = condition_module.evaluate(target_object, { if: :truthy_method? }) - - expect(result).to be(true) - end - - it "returns false when if condition evaluates to falsy" do - result = condition_module.evaluate(target_object, { if: :falsy_method? }) - - expect(result).to be(false) - end - - it "passes arguments to the if condition method" do - result = condition_module.evaluate(target_object, { if: :method_with_args }, "hello", "world") - - expect(result).to be_truthy - end - - it "passes keyword arguments to the if condition method" do - result = condition_module.evaluate(target_object, { if: :method_with_kwargs }, name: "test", value: "data") - - expect(result).to be_truthy - end - - it "passes block to the if condition method" do - expect(target_object).to receive(:test_method).and_return(true) - - condition_module.evaluate(target_object, { if: :test_method }) { "block_value" } - end - end - - context "with Proc if condition" do - it "returns true when Proc evaluates to truthy" do - truthy_proc = proc { true } - result = condition_module.evaluate(target_object, { if: truthy_proc }) - - expect(result).to be(true) - end - - it "returns false when Proc evaluates to falsy" do - falsy_proc = proc { false } - result = condition_module.evaluate(target_object, { if: falsy_proc }) - - expect(result).to be(false) - end - - it "executes Proc in the context of target object" do - context_proc = proc { instance_variable_check } - result = condition_module.evaluate(target_object, { if: context_proc }) - - expect(result).to be_truthy - end - - it "passes arguments to the Proc" do - arg_proc = proc { |arg1, arg2| arg1 == "hello" && arg2 == "world" } - result = condition_module.evaluate(target_object, { if: arg_proc }, "hello", "world") - - expect(result).to be(true) - end - - it "passes keyword arguments to the Proc" do - kwarg_proc = proc { |name:, value:| name == "test" && value == "data" } - result = condition_module.evaluate(target_object, { if: kwarg_proc }, name: "test", value: "data") - - expect(result).to be(true) - end - end - - context "with callable object if condition" do - let(:callable_object) do - Class.new do - def call(*_args, **_kwargs, &) - true - end - end.new - end - - let(:falsy_callable_object) do - Class.new do - def call(*_args, **_kwargs, &) - false - end - end.new - end - - it "returns true when callable returns truthy" do - result = condition_module.evaluate(target_object, { if: callable_object }) - - expect(result).to be(true) - end - - it "returns false when callable returns falsy" do - result = condition_module.evaluate(target_object, { if: falsy_callable_object }) - - expect(result).to be(false) - end - - it "passes arguments to the callable" do - arg_callable = Class.new do - def call(arg1, arg2) - arg1 == "hello" && arg2 == "world" - end - end.new - - result = condition_module.evaluate(target_object, { if: arg_callable }, "hello", "world") - - expect(result).to be(true) - end - end - - context "with boolean if condition" do - it "returns true when if condition is true" do - result = condition_module.evaluate(target_object, { if: true }) - - expect(result).to be(true) - end - - it "returns false when if condition is false" do - result = condition_module.evaluate(target_object, { if: false }) - - expect(result).to be(false) - end - - it "returns false when if condition is nil" do - result = condition_module.evaluate(target_object, { if: nil }) - - expect(result).to be(false) - end - end - end - - context "when options contain unless condition" do - context "with Symbol unless condition" do - it "returns false when unless condition evaluates to truthy" do - result = condition_module.evaluate(target_object, { unless: :truthy_method? }) - - expect(result).to be(false) - end - - it "returns true when unless condition evaluates to falsy" do - result = condition_module.evaluate(target_object, { unless: :falsy_method? }) - - expect(result).to be(true) - end - - it "passes arguments to the unless condition method" do - result = condition_module.evaluate(target_object, { unless: :method_with_args }, "hello", "world") - - expect(result).to be_falsy - end - end - - context "with Proc unless condition" do - it "returns false when Proc evaluates to truthy" do - truthy_proc = proc { true } - result = condition_module.evaluate(target_object, { unless: truthy_proc }) - - expect(result).to be(false) - end - - it "returns true when Proc evaluates to falsy" do - falsy_proc = proc { false } - result = condition_module.evaluate(target_object, { unless: falsy_proc }) - - expect(result).to be(true) - end - end - - context "with boolean unless condition" do - it "returns false when unless condition is true" do - result = condition_module.evaluate(target_object, { unless: true }) - - expect(result).to be(false) - end - - it "returns true when unless condition is false" do - result = condition_module.evaluate(target_object, { unless: false }) - - expect(result).to be(true) - end - - it "returns true when unless condition is nil" do - result = condition_module.evaluate(target_object, { unless: nil }) - - expect(result).to be(true) - end - end - end - - context "when options contain both if and unless conditions" do - it "returns true when if is truthy and unless is falsy" do - result = condition_module.evaluate(target_object, { if: :truthy_method?, unless: :falsy_method? }) - - expect(result).to be(true) - end - - it "returns false when if is truthy and unless is truthy" do - result = condition_module.evaluate(target_object, { if: :truthy_method?, unless: :truthy_method? }) - - expect(result).to be(false) - end - - it "returns false when if is falsy and unless is falsy" do - result = condition_module.evaluate(target_object, { if: :falsy_method?, unless: :falsy_method? }) - - expect(result).to be(false) - end - - it "returns false when if is falsy and unless is truthy" do - result = condition_module.evaluate(target_object, { if: :falsy_method?, unless: :truthy_method? }) - - expect(result).to be(false) - end - - it "passes arguments to both conditions" do - if_proc = proc { |arg| arg == "test" } - unless_proc = proc { |arg| arg == "fail" } - - result = condition_module.evaluate(target_object, { if: if_proc, unless: unless_proc }, "test") - - expect(result).to be(true) - end - end - - context "when options contain neither if nor unless" do - it "returns true for empty options" do - result = condition_module.evaluate(target_object, {}) - - expect(result).to be(true) - end - - it "returns true for options with other keys" do - result = condition_module.evaluate(target_object, { other_key: "value" }) - - expect(result).to be(true) - end - end - - context "with invalid callable objects" do - let(:invalid_callable) do - Object.new - end - - it "raises an error when if condition is not callable" do - expect do - condition_module.evaluate(target_object, { if: invalid_callable }) - end.to raise_error(RuntimeError, /cannot evaluate/) - end - - it "raises an error when unless condition is not callable" do - expect do - condition_module.evaluate(target_object, { unless: invalid_callable }) - end.to raise_error(RuntimeError, /cannot evaluate/) - end - - it "includes the invalid object in the error message" do - expect do - condition_module.evaluate(target_object, { if: invalid_callable }) - end.to raise_error(RuntimeError, /#{Regexp.escape(invalid_callable.inspect)}/) - end - end - - context "when target object doesn't respond to method" do - it "raises NoMethodError for Symbol condition" do - expect do - condition_module.evaluate(target_object, { if: :nonexistent_method }) - end.to raise_error(NoMethodError) - end - end - end - - describe "EVAL constant" do - let(:eval_proc) { described_class.const_get(:EVAL) } - - context "when callable is NilClass" do - it "returns false" do - result = eval_proc.call(target_object, nil) - - expect(result).to be(false) - end - end - - context "when callable is FalseClass" do - it "returns false" do - result = eval_proc.call(target_object, false) - - expect(result).to be(false) - end - end - - context "when callable is TrueClass" do - it "returns true" do - result = eval_proc.call(target_object, true) - - expect(result).to be(true) - end - end - - context "when callable is Symbol" do - it "calls the method on target" do - result = eval_proc.call(target_object, :no_args_method) - - expect(result).to eq("no_args_result") - end - - it "passes arguments to the method" do - result = eval_proc.call(target_object, :method_with_args, "hello", "world") - - expect(result).to eq("hello_world") - end - - it "passes keyword arguments to the method" do - result = eval_proc.call(target_object, :method_with_kwargs, name: "test", value: "data") - - expect(result).to eq("test: data") - end - - it "passes block to the method" do - result = eval_proc.call(target_object, :test_method) { "block_value" } - - expect(result[:block_result]).to eq("block_value") - end - end - - context "when callable is Proc" do - it "executes the Proc in target context" do - test_proc = proc { instance_variable_check } - result = eval_proc.call(target_object, test_proc) - - expect(result).to eq("instance_value") - end - - it "passes arguments to the Proc" do - arg_proc = proc { |arg1, arg2| "#{arg1}_#{arg2}" } - result = eval_proc.call(target_object, arg_proc, "hello", "world") - - expect(result).to eq("hello_world") - end - - it "passes keyword arguments to the Proc" do - kwarg_proc = proc { |name:, value:| "#{name}: #{value}" } - result = eval_proc.call(target_object, kwarg_proc, name: "test", value: "data") - - expect(result).to eq("test: data") - end - end - - context "when callable has call method" do - let(:callable_object) do - Class.new do - def call(*args, **kwargs, &block) - { args: args, kwargs: kwargs, block_called: block&.call } - end - end.new - end - - it "calls the call method" do - result = eval_proc.call(target_object, callable_object) - - expect(result).to eq({ args: [], kwargs: {}, block_called: nil }) - end - - it "passes arguments to the call method" do - result = eval_proc.call(target_object, callable_object, "arg1", "arg2") - - expect(result).to eq({ args: %w[arg1 arg2], kwargs: {}, block_called: nil }) - end - - it "passes keyword arguments to the call method" do - result = eval_proc.call(target_object, callable_object, name: "test", value: "data") - - expect(result).to eq({ args: [], kwargs: { name: "test", value: "data" }, block_called: nil }) - end - - it "passes block to the call method" do - result = eval_proc.call(target_object, callable_object) { "block_value" } - - expect(result).to eq({ args: [], kwargs: {}, block_called: "block_value" }) - end - end - - context "when callable doesn't respond to call" do - let(:invalid_object) { Object.new } - - it "raises an error" do - expect do - eval_proc.call(target_object, invalid_object) - end.to raise_error(RuntimeError, /cannot evaluate/) - end - - it "includes the object in the error message" do - expect do - eval_proc.call(target_object, invalid_object) - end.to raise_error(RuntimeError, /#{Regexp.escape(invalid_object.inspect)}/) - end - end - end -end diff --git a/spec/cmdx/utils/format_spec.rb b/spec/cmdx/utils/format_spec.rb deleted file mode 100644 index 0d43e1587..000000000 --- a/spec/cmdx/utils/format_spec.rb +++ /dev/null @@ -1,289 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Utils::Format, type: :unit do - subject(:format_module) { described_class } - - describe ".to_log" do - context "when message is a CMDx object with to_h method" do - let(:cmdx_context) { CMDx::Context.new(user_id: 123, name: "test") } - let(:task_class) do - Class.new(CMDx::Task) do - def work - # no-op - end - end - end - let(:task) { task_class.new } - - it "returns the hash representation of CMDx::Context" do - result = format_module.to_log(cmdx_context) - - expect(result).to eq({ user_id: 123, name: "test" }) - end - - it "returns the hash representation of CMDx::Task" do - result = format_module.to_log(task) - - expect(result).to be_a(Hash) - expect(result).to include(:class, :index, :chain_id, :type, :tags, :id) - end - - it "returns the hash representation of CMDx::Chain" do - chain = CMDx::Chain.new - - result = format_module.to_log(chain) - - expect(result).to be_a(Hash) - expect(result).to include(:id, :results) - end - - it "returns the hash representation of CMDx::Result" do - task = task_class.new - result_obj = CMDx::Result.new(task) - - result = format_module.to_log(result_obj) - - expect(result).to be_a(Hash) - expect(result).to include(:state, :status, :outcome, :metadata) - end - - it "returns the hash representation of CMDx::Errors" do - errors = CMDx::Errors.new - errors.add(:name, "can't be blank") - - result = format_module.to_log(errors) - - expect(result).to eq({ name: ["can't be blank"] }) - end - end - - context "when message responds to to_h but is not a CMDx object" do - let(:non_cmdx_object) do - Class.new do - def to_h - { key: "value" } - end - - def class - Class.new do - def ancestors - [Object, BasicObject] - end - end.new - end - end.new - end - - it "returns the original message unchanged" do - result = format_module.to_log(non_cmdx_object) - - expect(result).to eq(non_cmdx_object) - end - end - - context "when message does not respond to to_h" do - it "returns string message unchanged" do - message = "Simple log message" - - result = format_module.to_log(message) - - expect(result).to eq("Simple log message") - end - - it "returns integer message unchanged" do - message = 42 - - result = format_module.to_log(message) - - expect(result).to eq(42) - end - - it "returns array message unchanged" do - message = [1, 2, 3] - - result = format_module.to_log(message) - - expect(result).to eq([1, 2, 3]) - end - - it "returns hash message unchanged" do - message = { key: "value" } - - result = format_module.to_log(message) - - expect(result).to eq({ key: "value" }) - end - - it "returns nil message unchanged" do - result = format_module.to_log(nil) - - expect(result).to be_nil - end - end - - context "when message responds to to_h but ancestors check returns false" do - let(:object_with_to_h) do - obj = Object.new - def obj.to_h - { custom: "hash" } - end - obj - end - - it "returns the original message unchanged" do - result = format_module.to_log(object_with_to_h) - - expect(result).to eq(object_with_to_h) - end - end - end - - describe ".to_str" do - context "when using default formatter" do - let(:hash) { { name: "John", age: 30, active: true } } - - it "formats hash using default key=value.inspect format" do - result = format_module.to_str(hash) - - expect(result).to eq('name="John" age=30 active=true') - end - - it "handles empty hash" do - result = format_module.to_str({}) - - expect(result).to eq("") - end - - it "handles hash with symbol keys" do - hash = { user_id: 123, email: "test@example.com" } - - result = format_module.to_str(hash) - - expect(result).to eq('user_id=123 email="test@example.com"') - end - - it "handles hash with string keys" do - hash = { "name" => "Alice", "role" => "admin" } - - result = format_module.to_str(hash) - - expect(result).to eq('name="Alice" role="admin"') - end - - it "handles hash with mixed value types" do - hash = { id: 1, name: "Test", data: [1, 2, 3], meta: { nested: true } } - - result = format_module.to_str(hash) - - if RubyVersion.min?(3.4) - expect(result).to eq('id=1 name="Test" data=[1, 2, 3] meta={nested: true}') - else - expect(result).to eq('id=1 name="Test" data=[1, 2, 3] meta={:nested=>true}') - end - end - - it "handles hash with nil values" do - hash = { name: "John", email: nil, age: 25 } - - result = format_module.to_str(hash) - - expect(result).to eq('name="John" email=nil age=25') - end - end - - context "when using custom formatter block" do - let(:hash) { { name: "John", age: 30, city: "NYC" } } - - it "uses the provided block for formatting" do - result = format_module.to_str(hash) { |key, value| "#{key}: #{value}" } - - expect(result).to eq("name: John age: 30 city: NYC") - end - - it "allows custom separator in block" do - result = format_module.to_str(hash) { |key, value| "#{key}=#{value}" } - - expect(result).to eq("name=John age=30 city=NYC") - end - - it "handles complex formatting logic in block" do - result = format_module.to_str(hash) do |key, value| - case key - when :name then "USER: #{value.upcase}" - when :age then "AGE: #{value} years" - else "#{key.upcase}: #{value}" - end - end - - expect(result).to eq("USER: JOHN AGE: 30 years CITY: NYC") - end - - it "handles block that returns nil" do - result = format_module.to_str(hash) { |_key, _value| nil } - - expect(result).to eq(" ") - end - - it "handles block that returns empty string" do - result = format_module.to_str(hash) { |_key, _value| "" } - - expect(result).to eq(" ") - end - end - - context "with edge cases" do - it "handles hash with special characters in values" do - hash = { message: "Hello\nWorld", path: "/tmp/test file.txt" } - - result = format_module.to_str(hash) - - expect(result).to eq('message="Hello\nWorld" path="/tmp/test file.txt"') - end - - it "handles hash with unicode characters" do - hash = { name: "José", emoji: "🚀", chinese: "测试" } - - result = format_module.to_str(hash) - - expect(result).to eq('name="José" emoji="🚀" chinese="测试"') - end - - it "handles large hash efficiently" do - large_hash = (1..100).to_h { |i| ["key#{i}", "value#{i}"] } - - expect { format_module.to_str(large_hash) }.not_to raise_error - end - end - end - - describe "FORMATTER constant" do - it "is private" do - expect { described_class::FORMATTER }.to raise_error(NameError) - end - - it "is frozen" do - formatter = described_class.send(:const_get, :FORMATTER) - - expect(formatter).to be_frozen - end - - it "formats key-value pairs correctly" do - formatter = described_class.send(:const_get, :FORMATTER) - - result = formatter.call(:name, "John") - - expect(result).to eq('name="John"') - end - - it "handles different value types" do - formatter = described_class.send(:const_get, :FORMATTER) - - expect(formatter.call(:id, 123)).to eq("id=123") - expect(formatter.call(:active, true)).to eq("active=true") - expect(formatter.call(:data, nil)).to eq("data=nil") - expect(formatter.call(:items, [1, 2])).to eq("items=[1, 2]") - end - end -end diff --git a/spec/cmdx/utils/normalize_spec.rb b/spec/cmdx/utils/normalize_spec.rb deleted file mode 100644 index 5e27b6c8e..000000000 --- a/spec/cmdx/utils/normalize_spec.rb +++ /dev/null @@ -1,94 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Utils::Normalize, type: :unit do - subject(:normalize_module) { described_class } - - describe ".exception" do - context "when given a standard error" do - it "returns class and message in bracket format" do - error = StandardError.new("something went wrong") - result = normalize_module.exception(error) - - expect(result).to eq("[StandardError] something went wrong") - end - end - - context "when given a custom error class" do - it "includes the full class name" do - error = ArgumentError.new("bad argument") - result = normalize_module.exception(error) - - expect(result).to eq("[ArgumentError] bad argument") - end - end - - context "when given an error with an empty message" do - it "returns the class with an empty message" do - error = RuntimeError.new("") - result = normalize_module.exception(error) - - expect(result).to eq("[RuntimeError] ") - end - end - end - - describe ".statuses" do - context "when object is an array of symbols" do - it "returns unique string representations" do - result = normalize_module.statuses(%i[success pending success]) - - expect(result).to eq(%w[success pending]) - end - end - - context "when object is an array of strings" do - it "returns unique strings" do - result = normalize_module.statuses(%w[success pending success]) - - expect(result).to eq(%w[success pending]) - end - end - - context "when object is an array of mixed types" do - it "converts all to strings and deduplicates" do - result = normalize_module.statuses([:success, "success"]) - - expect(result).to eq(["success"]) - end - end - - context "when object is a single symbol" do - it "returns a single-element string array" do - result = normalize_module.statuses(:success) - - expect(result).to eq(["success"]) - end - end - - context "when object is a single string" do - it "returns a single-element string array" do - result = normalize_module.statuses("pending") - - expect(result).to eq(["pending"]) - end - end - - context "when object is nil" do - it "returns an empty array" do - result = normalize_module.statuses(nil) - - expect(result).to eq([]) - end - end - - context "when object is an empty array" do - it "returns an empty array" do - result = normalize_module.statuses([]) - - expect(result).to eq([]) - end - end - end -end diff --git a/spec/cmdx/utils/wrap_spec.rb b/spec/cmdx/utils/wrap_spec.rb deleted file mode 100644 index a7129ee49..000000000 --- a/spec/cmdx/utils/wrap_spec.rb +++ /dev/null @@ -1,60 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Utils::Wrap, type: :unit do - subject(:wrap_module) { described_class } - - describe ".array" do - context "when object is already an array" do - it "returns the same array" do - array = [1, 2, 3] - result = wrap_module.array(array) - - expect(result).to be(array) - end - - it "returns an empty array as-is" do - result = wrap_module.array([]) - - expect(result).to eq([]) - end - end - - context "when object is nil" do - it "returns an empty array" do - result = wrap_module.array(nil) - - expect(result).to eq([]) - end - end - - context "when object is a single value" do - it "wraps an integer" do - result = wrap_module.array(1) - - expect(result).to eq([1]) - end - - it "wraps a string" do - result = wrap_module.array("hello") - - expect(result).to eq(["hello"]) - end - - it "wraps a symbol" do - result = wrap_module.array(:foo) - - expect(result).to eq([:foo]) - end - end - - context "when object is a hash" do - it "wraps the hash in an array" do - result = wrap_module.array({ a: 1, b: 2 }) - - expect(result).to eq([[:a, 1], [:b, 2]]) - end - end - end -end diff --git a/spec/cmdx/validator_registry_spec.rb b/spec/cmdx/validator_registry_spec.rb index cf0d308da..b87162e9f 100644 --- a/spec/cmdx/validator_registry_spec.rb +++ b/spec/cmdx/validator_registry_spec.rb @@ -2,432 +2,45 @@ require "spec_helper" -RSpec.describe CMDx::ValidatorRegistry, type: :unit do - subject(:registry) { described_class.new(initial_registry) } - - let(:initial_registry) { nil } - let(:mock_validator) { instance_double("MockValidator") } - let(:mock_task) { instance_double(CMDx::Task) } - - describe "#initialize" do - context "when no registry is provided" do - subject(:registry) { described_class.new } - - it "initializes with default coercions" do - expect(registry.registry).to include( - exclusion: CMDx::Validators::Exclusion, - format: CMDx::Validators::Format, - inclusion: CMDx::Validators::Inclusion, - length: CMDx::Validators::Length, - numeric: CMDx::Validators::Numeric, - presence: CMDx::Validators::Presence - ) - end - end - - context "when a registry is provided" do - let(:initial_registry) { { custom: mock_validator } } - - it "initializes with the provided registry" do - expect(registry.registry).to eq({ custom: mock_validator }) - end - end - end - - describe "#registry" do - let(:initial_registry) { { custom: mock_validator } } - - it "returns the internal registry hash" do - expect(registry.registry).to eq({ custom: mock_validator }) - end - end - - describe "#to_h" do - let(:initial_registry) { { custom: mock_validator } } - - it "returns the registry hash" do - expect(registry.to_h).to eq({ custom: mock_validator }) - end - - it "is an alias for registry" do - expect(registry.method(:to_h)).to eq(registry.method(:registry)) - end - end - - describe "#keys" do - let(:initial_registry) { { custom: mock_validator, another: mock_validator } } - - it "returns the keys from the registry" do - expect(registry.keys).to match_array(%i[custom another]) - end - - it "delegates to the registry hash" do - allow(registry.registry).to receive(:keys).and_return([:delegated]) - - expect(registry.keys).to eq([:delegated]) - end - end - - describe "#dup" do - it "returns a new ValidatorRegistry instance" do - duplicated = registry.dup - - expect(duplicated).to be_a(described_class) - expect(duplicated).not_to be(registry) - end - - it "shares the parent registry until first write" do - duplicated = registry.dup - - expect(duplicated.registry).to eq(registry.registry) - expect(duplicated.registry).to be(registry.registry) +RSpec.describe CMDx::ValidatorRegistry do + describe "#resolve" do + it "returns built-in validators" do + reg = described_class.new + expect(reg.resolve(:presence)).to eq(CMDx::Validators::Presence) + expect(reg.resolve(:format)).to eq(CMDx::Validators::Format) end - it "materializes on write and does not affect the parent" do - duplicated = registry.dup - - duplicated.register(:new_type, mock_validator) - - expect(duplicated.registry).to have_key(:new_type) - expect(registry.registry).not_to have_key(:new_type) - expect(duplicated.registry).not_to be(registry.registry) + it "raises UnknownValidatorError for unknown types" do + reg = described_class.new + expect { reg.resolve(:not_a_validator) }.to raise_error(CMDx::UnknownValidatorError, /unknown validator/) end end describe "#register" do - context "when registering a validator with string name" do - it "adds the validator to the registry with symbol key" do - registry.register("custom", mock_validator) - - expect(registry.registry[:custom]).to eq(mock_validator) - end - - it "returns self for method chaining" do - result = registry.register("custom", mock_validator) - - expect(result).to be(registry) - end - end - - context "when registering a validator with symbol name" do - it "adds the validator to the registry" do - registry.register(:custom, mock_validator) - - expect(registry.registry[:custom]).to eq(mock_validator) - end - end - - context "when registering to an existing registry" do - let(:initial_registry) { { existing: "existing_validator" } } - - it "adds new validator to existing ones" do - registry.register(:new_type, mock_validator) - - expect(registry.registry).to include(existing: "existing_validator", new_type: mock_validator) - end - end - - context "when registering over an existing validator" do - let(:initial_registry) { { existing: "old_validator" } } - - it "overwrites the existing validator" do - registry.register(:existing, mock_validator) - - expect(registry.registry[:existing]).to eq(mock_validator) - end - end - end - - describe "#deregister" do - context "when deregistering with string name" do - before do - registry.register("custom", mock_validator) - end - - it "removes the validator from the registry" do - registry.deregister("custom") - - expect(registry.registry).not_to have_key(:custom) - end - - it "returns self for method chaining" do - result = registry.deregister("custom") - - expect(result).to be(registry) - end - end - - context "when deregistering with symbol name" do - before do - registry.register(:custom, mock_validator) - end - - it "removes the validator from the registry" do - registry.deregister(:custom) - - expect(registry.registry).not_to have_key(:custom) - end - end - - context "when deregistering from existing registry" do - let(:initial_registry) { { existing: "existing_validator", custom: mock_validator } } - - it "removes only the specified validator" do - registry.deregister(:custom) - - expect(registry.registry).to include(existing: "existing_validator") - expect(registry.registry).not_to have_key(:custom) - end - end - - context "when deregistering non-existent validator" do - it "does not raise an error" do - expect { registry.deregister(:nonexistent) }.not_to raise_error - end - - it "returns self" do - result = registry.deregister(:nonexistent) - - expect(result).to be(registry) - end - - it "does not affect existing validators" do - registry.register(:existing, mock_validator) - registry.deregister(:nonexistent) - - expect(registry.registry[:existing]).to eq(mock_validator) - end - end - - context "when deregistering from empty registry" do - let(:initial_registry) { {} } - - it "does not raise an error" do - expect { registry.deregister(:custom) }.not_to raise_error - end - - it "returns self" do - result = registry.deregister(:custom) - - expect(result).to be(registry) - end - end - - context "when deregistering default validators" do - subject(:registry) { described_class.new } - - it "removes the default validator" do - registry.deregister(:presence) - - expect(registry.registry).not_to have_key(:presence) - end - - it "does not affect other default validators" do - registry.deregister(:presence) - - expect(registry.registry).to have_key(:format) - expect(registry.registry).to have_key(:length) - end - end - end - - describe "#validate" do - let(:initial_registry) { { custom: mock_validator } } - let(:value) { "test_value" } - let(:options) { { option1: "value1" } } - - before do - allow(CMDx::Utils::Call).to receive(:invoke).and_return("validation_result") - allow(CMDx::Utils::Condition).to receive(:evaluate).and_return(true) - end - - context "when validator type exists" do - context "with hash options" do - it "evaluates condition and calls Utils::Call.invoke when condition is true" do - expect(CMDx::Utils::Condition).to receive(:evaluate).with(mock_task, options, value) - expect(CMDx::Utils::Call).to receive(:invoke).with( - mock_task, mock_validator, value, options - ) - - registry.validate(:custom, mock_task, value, options) - end - - it "returns the result from Utils::Call.invoke" do - result = registry.validate(:custom, mock_task, value, options) - - expect(result).to eq("validation_result") - end - - context "when condition evaluates to false" do - before do - allow(CMDx::Utils::Condition).to receive(:evaluate).and_return(false) - end - - it "does not call the validator" do - expect(CMDx::Utils::Call).not_to receive(:invoke) - - registry.validate(:custom, mock_task, value, options) - end - - it "returns nil" do - result = registry.validate(:custom, mock_task, value, options) - - expect(result).to be_nil - end - end - - context "with allow_nil: true" do - context "when value is nil" do - let(:value) { nil } - let(:options) { { allow_nil: true } } - - it "skips the validator" do - expect(CMDx::Utils::Condition).not_to receive(:evaluate) - expect(CMDx::Utils::Call).not_to receive(:invoke) - - registry.validate(:custom, mock_task, value, options) - end - - it "returns nil" do - result = registry.validate(:custom, mock_task, value, options) - - expect(result).to be_nil - end - end - - context "when value is not nil" do - let(:options) { { allow_nil: true } } - - it "calls the validator" do - expect(CMDx::Utils::Condition).not_to receive(:evaluate) - expect(CMDx::Utils::Call).to receive(:invoke).with( - mock_task, mock_validator, value, options - ) - - registry.validate(:custom, mock_task, value, options) - end - - it "returns the result from Utils::Call.invoke" do - result = registry.validate(:custom, mock_task, value, options) - - expect(result).to eq("validation_result") - end - end - end - - context "with allow_nil: false" do - context "when value is nil" do - let(:value) { nil } - let(:options) { { allow_nil: false } } - - it "calls the validator" do - expect(CMDx::Utils::Condition).not_to receive(:evaluate) - expect(CMDx::Utils::Call).to receive(:invoke).with( - mock_task, mock_validator, value, options - ) - - registry.validate(:custom, mock_task, value, options) - end - - it "returns the result from Utils::Call.invoke" do - result = registry.validate(:custom, mock_task, value, options) - - expect(result).to eq("validation_result") - end - end - - context "when value is not nil" do - let(:options) { { allow_nil: false } } - - it "calls the validator" do - expect(CMDx::Utils::Condition).not_to receive(:evaluate) - expect(CMDx::Utils::Call).to receive(:invoke).with( - mock_task, mock_validator, value, options - ) - - registry.validate(:custom, mock_task, value, options) - end - - it "returns the result from Utils::Call.invoke" do - result = registry.validate(:custom, mock_task, value, options) - - expect(result).to eq("validation_result") - end - end - end - end - - context "with non-hash options" do - let(:options) { true } - - it "uses options directly as condition" do - expect(CMDx::Utils::Condition).not_to receive(:evaluate) - expect(CMDx::Utils::Call).to receive(:invoke).with( - mock_task, mock_validator, value, options - ) - - registry.validate(:custom, mock_task, value, options) - end - - context "when options is false" do - let(:options) { false } - - it "does not call the validator" do - expect(CMDx::Utils::Call).not_to receive(:invoke) - - registry.validate(:custom, mock_task, value, options) - end - - it "returns nil" do - result = registry.validate(:custom, mock_task, value, options) - - expect(result).to be_nil - end - end - end - - context "with string type name" do - let(:initial_registry) { { "custom" => mock_validator } } - - it "works with string type names" do - expect(CMDx::Utils::Condition).to receive(:evaluate).with(mock_task, options, value) - expect(CMDx::Utils::Call).to receive(:invoke).with( - mock_task, mock_validator, value, options - ) - - registry.validate("custom", mock_task, value, options) + it "adds a custom validator" do + reg = described_class.new + klass = Class.new do + def self.call(_value, **) + "invalid" end end - end - context "when options are not provided" do - it "passes empty hash as options and evaluates condition" do - expect(CMDx::Utils::Condition).to receive(:evaluate).with(mock_task, {}, value) - expect(CMDx::Utils::Call).to receive(:invoke).with( - mock_task, mock_validator, value, {} - ) - - registry.validate(:custom, mock_task, value) - end + reg.register(:always_bad, klass) + expect(reg.resolve(:always_bad)).to be(klass) + expect(klass.call(nil)).to eq("invalid") end + end - context "when validator type does not exist" do - it "raises TypeError with descriptive message" do - expect { registry.validate(:nonexistent, mock_task, value) } - .to raise_error(TypeError, "unknown validator type :nonexistent") - end + describe "#for_child" do + it "duplicates the registry for copy-on-write" do + parent = described_class.new + parent.register(:custom, CMDx::Validators::Length) - it "raises TypeError for string type names" do - expect { registry.validate("string", mock_task, value) } - .to raise_error(TypeError, 'unknown validator type "string"') - end - end + child = parent.for_child + child.register(:other, CMDx::Validators::Numeric) - context "when type is nil" do - it "raises TypeError" do - expect { registry.validate(nil, mock_task, value) } - .to raise_error(TypeError, "unknown validator type nil") - end + expect(parent.registry.key?(:other)).to be(false) + expect(child.registry.key?(:other)).to be(true) end end end diff --git a/spec/cmdx/validators/absence_spec.rb b/spec/cmdx/validators/absence_spec.rb index 052b8bbbb..8aedfc277 100644 --- a/spec/cmdx/validators/absence_spec.rb +++ b/spec/cmdx/validators/absence_spec.rb @@ -2,153 +2,21 @@ require "spec_helper" -RSpec.describe CMDx::Validators::Absence, type: :unit do - subject(:validator) { described_class } - +RSpec.describe CMDx::Validators::Absence do describe ".call" do - context "when value is absent" do - context "with string values" do - it "does not raise error for empty strings" do - expect { validator.call("") }.not_to raise_error - end - - it "does not raise error for whitespace-only strings" do - expect { validator.call(" ") }.not_to raise_error - expect { validator.call("\t\n\r ") }.not_to raise_error - end - end - - context "with objects responding to empty?" do - it "does not raise error for empty arrays" do - expect { validator.call([]) }.not_to raise_error - end - - it "does not raise error for empty hashes" do - expect { validator.call({}) }.not_to raise_error - end - - it "does not raise error for empty string-like objects" do - empty_obj = Object.new - def empty_obj.empty? = true - expect { validator.call(empty_obj) }.not_to raise_error - end - end - - context "with nil values" do - it "does not raise error for nil" do - expect { validator.call(nil) }.not_to raise_error - end - end - end - - context "when value is present" do - context "with string values" do - it "raises ValidationError for non-whitespace strings" do - expect { validator.call("hello") } - .to raise_error(CMDx::ValidationError, "must be empty") - expect { validator.call("a") } - .to raise_error(CMDx::ValidationError, "must be empty") - expect { validator.call("123") } - .to raise_error(CMDx::ValidationError, "must be empty") - end - - it "raises ValidationError for strings with mixed whitespace and content" do - expect { validator.call(" hello ") } - .to raise_error(CMDx::ValidationError, "must be empty") - expect { validator.call("\thello\n") } - .to raise_error(CMDx::ValidationError, "must be empty") - expect { validator.call(" a ") } - .to raise_error(CMDx::ValidationError, "must be empty") - end - end - - context "with objects responding to empty?" do - it "raises ValidationError for non-empty arrays" do - expect { validator.call([1, 2, 3]) } - .to raise_error(CMDx::ValidationError, "must be empty") - expect { validator.call(["a"]) } - .to raise_error(CMDx::ValidationError, "must be empty") - end + let(:message) { CMDx::Locale.t("cmdx.validators.absence") } - it "raises ValidationError for non-empty hashes" do - expect { validator.call({ a: 1 }) } - .to raise_error(CMDx::ValidationError, "must be empty") - expect { validator.call({ "key" => "value" }) } - .to raise_error(CMDx::ValidationError, "must be empty") - end - - it "raises ValidationError for non-empty string-like objects" do - string_obj = Object.new - def string_obj.empty? = false - expect { validator.call(string_obj) } - .to raise_error(CMDx::ValidationError, "must be empty") - end - end - - context "with objects not responding to empty?" do - it "raises ValidationError for non-nil values" do - expect { validator.call(42) } - .to raise_error(CMDx::ValidationError, "must be empty") - expect { validator.call(true) } - .to raise_error(CMDx::ValidationError, "must be empty") - expect { validator.call(false) } - .to raise_error(CMDx::ValidationError, "must be empty") - expect { validator.call(0) } - .to raise_error(CMDx::ValidationError, "must be empty") - end - - it "raises ValidationError for objects" do - expect { validator.call(Object.new) } - .to raise_error(CMDx::ValidationError, "must be empty") - expect { validator.call(Date.today) } - .to raise_error(CMDx::ValidationError, "must be empty") - end - end - end - - context "with custom message option" do - let(:custom_message) { "must be blank" } - let(:options) { { message: custom_message } } - - it "uses custom message for present strings" do - expect { validator.call("hello", options) } - .to raise_error(CMDx::ValidationError, custom_message) - end - - it "uses custom message for present arrays" do - expect { validator.call([1], options) } - .to raise_error(CMDx::ValidationError, custom_message) - end - - it "uses custom message for present objects" do - expect { validator.call(true, options) } - .to raise_error(CMDx::ValidationError, custom_message) - end - - it "does not raise error for absent values" do - expect { validator.call(nil, options) }.not_to raise_error - expect { validator.call("", options) }.not_to raise_error - end + it "returns nil for nil" do + expect(described_class.call(nil)).to be_nil end - context "without options" do - it "does not raise error when no options provided for absent value" do - expect { validator.call("") }.not_to raise_error - end - - it "raises error with default message when no options provided for present value" do - expect { validator.call("hello") } - .to raise_error(CMDx::ValidationError, "must be empty") - end + it "returns nil for an empty string" do + expect(described_class.call("")).to be_nil end - context "with non-hash options" do - it "ignores non-hash options and uses default message" do - expect { validator.call("hello", "invalid_options") } - .to raise_error(CMDx::ValidationError, "must be empty") - expect { validator.call("hello", 123) } - .to raise_error(CMDx::ValidationError, "must be empty") - end + it "returns an error when a value is present" do + expect(described_class.call("x")).to eq(message) + expect(described_class.call([1])).to eq(message) end end end diff --git a/spec/cmdx/validators/exclusion_spec.rb b/spec/cmdx/validators/exclusion_spec.rb index db983f124..760b3820b 100644 --- a/spec/cmdx/validators/exclusion_spec.rb +++ b/spec/cmdx/validators/exclusion_spec.rb @@ -2,349 +2,22 @@ require "spec_helper" -RSpec.describe CMDx::Validators::Exclusion, type: :unit do - subject(:validator) { described_class } - +RSpec.describe CMDx::Validators::Exclusion do describe ".call" do - context "with array exclusions" do - context "when using :in option" do - let(:options) { { in: %w[admin root system] } } - - context "when value is excluded" do - it "raises ValidationError with default message" do - expect { validator.call("admin", options) } - .to raise_error(CMDx::ValidationError, 'must not be one of: "admin", "root", "system"') - end - - it "raises ValidationError for any excluded value" do - %w[admin root system].each do |excluded_value| - expect { validator.call(excluded_value, options) } - .to raise_error(CMDx::ValidationError, 'must not be one of: "admin", "root", "system"') - end - end - end - - context "when value is not excluded" do - it "does not raise error for allowed values" do - expect { validator.call("user", options) }.not_to raise_error - expect { validator.call("guest", options) }.not_to raise_error - expect { validator.call("", options) }.not_to raise_error - end - end - - context "with custom :of_message" do - let(:options) { { in: %w[reserved blocked], of_message: "is a reserved username" } } - - it "uses custom message" do - expect { validator.call("reserved", options) } - .to raise_error(CMDx::ValidationError, "is a reserved username") - end - end - - context "with custom :message" do - let(:options) { { in: %w[forbidden], message: "cannot be used" } } - - it "uses custom message" do - expect { validator.call("forbidden", options) } - .to raise_error(CMDx::ValidationError, "cannot be used") - end - end - - context "with message interpolation" do - let(:options) { { in: %w[bad evil], of_message: "value %s not allowed" } } - - it "interpolates values into custom message" do - expect { validator.call("bad", options) } - .to raise_error(CMDx::ValidationError, 'value "bad", "evil" not allowed') - end - end - end - - context "when using :within option" do - let(:options) { { within: %w[admin root] } } - - context "when value is excluded" do - it "raises ValidationError with default message" do - expect { validator.call("admin", options) } - .to raise_error(CMDx::ValidationError, 'must not be one of: "admin", "root"') - end - end - - context "when value is not excluded" do - it "does not raise error" do - expect { validator.call("user", options) }.not_to raise_error - end - end - end - - context "with different data types" do - context "with integers" do - let(:options) { { in: [1, 2, 3] } } - - it "excludes integer values" do - expect { validator.call(2, options) } - .to raise_error(CMDx::ValidationError, "must not be one of: 1, 2, 3") - end - - it "allows non-excluded integers" do - expect { validator.call(4, options) }.not_to raise_error - end - end - - context "with symbols" do - let(:options) { { in: %i[pending cancelled] } } - - it "excludes symbol values" do - expect { validator.call(:pending, options) } - .to raise_error(CMDx::ValidationError, "must not be one of: :pending, :cancelled") - end - - it "allows non-excluded symbols" do - expect { validator.call(:active, options) }.not_to raise_error - end - end - - context "with mixed types" do - let(:options) { { in: ["string", 42, :symbol] } } - - it "excludes string value" do - expect { validator.call("string", options) } - .to raise_error(CMDx::ValidationError, 'must not be one of: "string", 42, :symbol') - end - - it "excludes integer value" do - expect { validator.call(42, options) } - .to raise_error(CMDx::ValidationError, 'must not be one of: "string", 42, :symbol') - end - - it "excludes symbol value" do - expect { validator.call(:symbol, options) } - .to raise_error(CMDx::ValidationError, 'must not be one of: "string", 42, :symbol') - end - end - end - - context "with case-sensitive comparison" do - let(:options) { { in: %w[Admin ROOT] } } - - it "is case sensitive" do - expect { validator.call("admin", options) }.not_to raise_error - expect { validator.call("root", options) }.not_to raise_error - expect { validator.call("Admin", options) } - .to raise_error(CMDx::ValidationError, 'must not be one of: "Admin", "ROOT"') - end - end + it "rejects values in :of" do + forbidden = [1, 2, 3] + expect(described_class.call(0, of: forbidden)).to be_nil + expect(described_class.call(2, of: forbidden)).to eq( + CMDx::Locale.t("cmdx.validators.exclusion.of", values: forbidden.join(", ")) + ) end - context "with range exclusions" do - context "with integer range" do - let(:options) { { in: (1..10) } } - - context "when value is within range" do - it "raises ValidationError with default message" do - expect { validator.call(5, options) } - .to raise_error(CMDx::ValidationError, "must not be within 1 and 10") - end - - it "raises error for boundary values" do - expect { validator.call(1, options) } - .to raise_error(CMDx::ValidationError, "must not be within 1 and 10") - expect { validator.call(10, options) } - .to raise_error(CMDx::ValidationError, "must not be within 1 and 10") - end - end - - context "when value is outside range" do - it "does not raise error" do - expect { validator.call(0, options) }.not_to raise_error - expect { validator.call(11, options) }.not_to raise_error - expect { validator.call(-5, options) }.not_to raise_error - end - end - - context "with custom :in_message" do - let(:options) { { in: (18..65), in_message: "age restricted" } } - - it "uses custom message" do - expect { validator.call(25, options) } - .to raise_error(CMDx::ValidationError, "age restricted") - end - end - - context "with custom :within_message" do - let(:options) { { within: (1..5), within_message: "not in allowed range" } } - - it "uses custom message" do - expect { validator.call(3, options) } - .to raise_error(CMDx::ValidationError, "not in allowed range") - end - end - - context "with message interpolation" do - let(:options) { { in: (1..10), in_message: "between %s and %s not allowed" } } - - it "interpolates min and max into custom message" do - expect { validator.call(5, options) } - .to raise_error(CMDx::ValidationError, "between 1 and 10 not allowed") - end - end - end - - context "with exclusive range" do - let(:options) { { in: (1...10) } } - - it "excludes values within range but not the end" do - expect { validator.call(9, options) } - .to raise_error(CMDx::ValidationError, "must not be within 1 and 10") - expect { validator.call(10, options) }.not_to raise_error - end - end - - context "with string range" do - let(:options) { { in: ("a".."z") } } - - it "excludes values within string range" do - expect { validator.call("m", options) } - .to raise_error(CMDx::ValidationError, "must not be within a and z") - expect { validator.call("A", options) }.not_to raise_error - end - end - - context "with date range" do - let(:start_date) { Date.new(2023, 1, 1) } - let(:end_date) { Date.new(2023, 12, 31) } - let(:options) { { in: (start_date..end_date) } } - - it "excludes dates within range" do - test_date = Date.new(2023, 6, 15) - expect { validator.call(test_date, options) } - .to raise_error(CMDx::ValidationError, "must not be within #{start_date} and #{end_date}") - end - - it "allows dates outside range" do - expect { validator.call(Date.new(2022, 12, 31), options) }.not_to raise_error - expect { validator.call(Date.new(2024, 1, 1), options) }.not_to raise_error - end - end - end - - context "with edge cases" do - context "when exclusion list is empty" do - let(:options) { { in: [] } } - - it "does not raise error for any value" do - expect { validator.call("anything", options) }.not_to raise_error - expect { validator.call(123, options) }.not_to raise_error - expect { validator.call(nil, options) }.not_to raise_error - end - end - - context "when exclusion list is nil" do - let(:options) { { in: nil } } - - it "does not raise error for any value" do - expect { validator.call("anything", options) }.not_to raise_error - expect { validator.call(123, options) }.not_to raise_error - end - end - - context "when testing nil value" do - let(:options) { { in: [nil, "null"] } } - - it "excludes nil when explicitly included" do - expect { validator.call(nil, options) } - .to raise_error(CMDx::ValidationError, 'must not be one of: nil, "null"') - end - - it "allows nil when not in exclusion list" do - expect { validator.call(nil, { in: ["other"] }) }.not_to raise_error - end - end - - context "with object comparison using ===" do - let(:regex_pattern) { /admin/ } - let(:options) { { in: [regex_pattern] } } - - it "uses === for comparison with regex" do - expect { validator.call("admin_user", options) } - .to raise_error(CMDx::ValidationError) - expect { validator.call("user", options) }.not_to raise_error - end - end - - context "when no exclusion option provided" do - let(:options) { {} } - - it "does not raise error" do - expect { validator.call("anything", options) }.not_to raise_error - end - end - - context "with both :in and :within options" do - let(:options) { { in: %w[admin], within: %w[root] } } - - it "uses :in option when both are present" do - expect { validator.call("admin", options) } - .to raise_error(CMDx::ValidationError, 'must not be one of: "admin"') - expect { validator.call("root", options) }.not_to raise_error - end - end - end - - context "with custom message priority" do - context "when multiple message options are provided" do - let(:options) do - { - in: %w[test], - message: "generic message", - of_message: "specific of message" - } - end - - it "prioritizes of_message over message for arrays" do - expect { validator.call("test", options) } - .to raise_error(CMDx::ValidationError, "specific of message") - end - end - - context "with range and multiple message options" do - let(:options) do - { - in: (1..5), - message: "generic message", - in_message: "specific in message", - within_message: "specific within message" - } - end - - it "prioritizes in_message over within_message and message for ranges" do - expect { validator.call(3, options) } - .to raise_error(CMDx::ValidationError, "specific in message") - end - end - end - - context "with internationalization" do - it "calls Locale.t for default array exclusion message" do - expect(CMDx::Locale).to receive(:t).with( - "cmdx.validators.exclusion.of", - values: '"admin"' - ).and_return("localized message") - - expect { validator.call("admin", { in: %w[admin] }) } - .to raise_error(CMDx::ValidationError, "localized message") - end - - it "calls Locale.t for default range exclusion message" do - expect(CMDx::Locale).to receive(:t).with( - "cmdx.validators.exclusion.within", - min: 1, - max: 10 - ).and_return("localized range message") - - expect { validator.call(5, { in: (1..10) }) } - .to raise_error(CMDx::ValidationError, "localized range message") - end + it "rejects values inside :within" do + range = 1..10 + expect(described_class.call(11, within: range)).to be_nil + expect(described_class.call(5, within: range)).to eq( + CMDx::Locale.t("cmdx.validators.exclusion.within", min: range.min, max: range.max) + ) end end end diff --git a/spec/cmdx/validators/format_spec.rb b/spec/cmdx/validators/format_spec.rb index 61f154199..1e0a70ea9 100644 --- a/spec/cmdx/validators/format_spec.rb +++ b/spec/cmdx/validators/format_spec.rb @@ -2,278 +2,21 @@ require "spec_helper" -RSpec.describe CMDx::Validators::Format, type: :unit do - subject(:validator) { described_class } - +RSpec.describe CMDx::Validators::Format do describe ".call" do - context "with direct Regexp argument" do - it "validates value against the regex pattern" do - expect { validator.call("hello", /\A[a-z]+\z/) }.not_to raise_error - expect { validator.call("123", /\A\d+\z/) }.not_to raise_error - expect { validator.call("test@example.com", /\A[\w+\-.]+@[a-z\d-]+(\.[a-z\d-]+)*\.[a-z]+\z/i) }.not_to raise_error - end - - it "raises ValidationError when value doesn't match pattern" do - expect { validator.call("Hello", /\A[a-z]+\z/) } - .to raise_error(CMDx::ValidationError, "is an invalid format") - expect { validator.call("abc", /\A\d+\z/) } - .to raise_error(CMDx::ValidationError, "is an invalid format") - expect { validator.call("invalid-email", /\A[\w+\-.]+@[a-z\d-]+(\.[a-z\d-]+)*\.[a-z]+\z/i) } - .to raise_error(CMDx::ValidationError, "is an invalid format") - end - - it "handles complex regex patterns" do - phone_regex = /\A\+?1?[-.\s]?\(?[0-9]{3}\)?[-.\s]?[0-9]{3}[-.\s]?[0-9]{4}\z/ - expect { validator.call("123-456-7890", phone_regex) }.not_to raise_error - expect { validator.call("(123) 456-7890", phone_regex) }.not_to raise_error - expect { validator.call("123-45-6789", phone_regex) } - .to raise_error(CMDx::ValidationError, "is an invalid format") - end - - it "works with edge cases" do - expect { validator.call("", /\A.*\z/) }.not_to raise_error - expect { validator.call("", /\A.+\z/) } - .to raise_error(CMDx::ValidationError, "is an invalid format") - expect { validator.call(nil, /\A.+\z/) } - .to raise_error(CMDx::ValidationError, "is an invalid format") - end - end - - context "with :with option" do - let(:options) { { with: /\A[a-z]+\z/ } } - - context "when value matches pattern" do - it "does not raise error for matching values" do - expect { validator.call("hello", options) }.not_to raise_error - expect { validator.call("world", options) }.not_to raise_error - expect { validator.call("test", options) }.not_to raise_error - end - end - - context "when value does not match pattern" do - it "raises ValidationError with default message" do - expect { validator.call("Hello", options) } - .to raise_error(CMDx::ValidationError, "is an invalid format") - end - - it "raises ValidationError for various invalid formats" do - ["Hello", "123", "test_case", ""].each do |invalid_value| - expect { validator.call(invalid_value, options) } - .to raise_error(CMDx::ValidationError, "is an invalid format") - end - end - end - - context "with custom message" do - let(:options) { { with: /\A\d+\z/, message: "must contain only digits" } } - - it "uses custom message when validation fails" do - expect { validator.call("abc", options) } - .to raise_error(CMDx::ValidationError, "must contain only digits") - end - end - - context "with email pattern" do - let(:options) { { with: /\A[\w+\-.]+@[a-z\d-]+(\.[a-z\d-]+)*\.[a-z]+\z/i } } - - it "validates email format correctly" do - expect { validator.call("user@example.com", options) }.not_to raise_error - expect { validator.call("test.email+tag@domain.co.uk", options) }.not_to raise_error - end - - it "rejects invalid email formats" do - expect { validator.call("invalid-email", options) } - .to raise_error(CMDx::ValidationError) - expect { validator.call("@example.com", options) } - .to raise_error(CMDx::ValidationError) - end - end - end - - context "with :without option" do - let(:options) { { without: /[^a-zA-Z]/ } } - - context "when value does not match forbidden pattern" do - it "does not raise error for valid values" do - expect { validator.call("Hello", options) }.not_to raise_error - expect { validator.call("World", options) }.not_to raise_error - expect { validator.call("", options) }.not_to raise_error - end - end - - context "when value matches forbidden pattern" do - it "raises ValidationError with default message" do - expect { validator.call("hello123", options) } - .to raise_error(CMDx::ValidationError, "is an invalid format") - end - - it "raises ValidationError for various invalid formats" do - ["test_case", "hello!", "123", "hello world"].each do |invalid_value| - expect { validator.call(invalid_value, options) } - .to raise_error(CMDx::ValidationError, "is an invalid format") - end - end - end - - context "with custom message" do - let(:options) { { without: /\d/, message: "cannot contain numbers" } } - - it "uses custom message when validation fails" do - expect { validator.call("test123", options) } - .to raise_error(CMDx::ValidationError, "cannot contain numbers") - end - end - end - - context "with both :with and :without options" do - let(:options) { { with: /\A[a-zA-Z]+\z/, without: /[A-Z]{2,}/ } } + let(:pattern) { /\A[a-z]+\z/ } + let(:message) { CMDx::Locale.t("cmdx.validators.format") } - context "when value matches :with and does not match :without" do - it "does not raise error for valid values" do - expect { validator.call("Hello", options) }.not_to raise_error - expect { validator.call("world", options) }.not_to raise_error - expect { validator.call("Test", options) }.not_to raise_error - end - end - - context "when value does not match :with pattern" do - it "raises ValidationError" do - expect { validator.call("hello123", options) } - .to raise_error(CMDx::ValidationError, "is an invalid format") - expect { validator.call("test_case", options) } - .to raise_error(CMDx::ValidationError, "is an invalid format") - end - end - - context "when value matches :without pattern" do - it "raises ValidationError for forbidden patterns" do - expect { validator.call("HELLO", options) } - .to raise_error(CMDx::ValidationError, "is an invalid format") - expect { validator.call("TEST", options) } - .to raise_error(CMDx::ValidationError, "is an invalid format") - end - end - - context "when value fails both conditions" do - it "raises ValidationError" do - expect { validator.call("HELLO123", options) } - .to raise_error(CMDx::ValidationError, "is an invalid format") - end - end - - context "with custom message" do - let(:options) do - { - with: /\A[a-z]+\z/, - without: /test/, - message: "must be lowercase letters without 'test'" - } - end - - it "uses custom message when validation fails" do - expect { validator.call("testing", options) } - .to raise_error(CMDx::ValidationError, "must be lowercase letters without 'test'") - end - end + it "returns nil when the value matches" do + expect(described_class.call("abc", with: pattern)).to be_nil end - context "without any pattern options" do - let(:options) { {} } - - it "always raises ValidationError" do - expect { validator.call("anything", options) } - .to raise_error(CMDx::ValidationError, "is an invalid format") - expect { validator.call("", options) } - .to raise_error(CMDx::ValidationError, "is an invalid format") - end - - context "with custom message" do - let(:options) { { message: "no pattern specified" } } - - it "uses custom message" do - expect { validator.call("test", options) } - .to raise_error(CMDx::ValidationError, "no pattern specified") - end - end + it "returns an error when the value does not match" do + expect(described_class.call("Abc", with: pattern)).to eq(message) end - context "with complex regex patterns" do - context "when validating phone numbers" do - let(:options) { { with: /\A\+?1?[-.\s]?\(?[0-9]{3}\)?[-.\s]?[0-9]{3}[-.\s]?[0-9]{4}\z/ } } - - it "validates various phone number formats" do - valid_numbers = [ - "123-456-7890", - "(123) 456-7890", - "123.456.7890", - "1234567890", - "+1-123-456-7890" - ] - - valid_numbers.each do |number| - expect { validator.call(number, options) }.not_to raise_error - end - end - - it "rejects invalid phone number formats" do - invalid_numbers = %w[ - 123-45-6789 - abc-def-ghij - 123-456-78901 - ] - - invalid_numbers.each do |number| - expect { validator.call(number, options) } - .to raise_error(CMDx::ValidationError) - end - end - end - - context "when validating hexadecimal colors" do - let(:options) { { with: /\A#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})\z/ } } - - it "validates hex color codes" do - expect { validator.call("#FF0000", options) }.not_to raise_error - expect { validator.call("#fff", options) }.not_to raise_error - expect { validator.call("#123abc", options) }.not_to raise_error - end - - it "rejects invalid hex color codes" do - expect { validator.call("FF0000", options) } - .to raise_error(CMDx::ValidationError) - expect { validator.call("#gg0000", options) } - .to raise_error(CMDx::ValidationError) - end - end - end - - context "with string patterns" do - let(:options) { { with: "test" } } - - it "treats string patterns as regex" do - expect { validator.call("testing", options) }.not_to raise_error - expect { validator.call("retest", options) }.not_to raise_error - end - - it "raises error when string pattern not found" do - expect { validator.call("hello", options) } - .to raise_error(CMDx::ValidationError) - end - end - - context "with edge case values" do - let(:options) { { with: /\A.+\z/ } } - - it "handles empty strings" do - expect { validator.call("", { with: /\A.*\z/ }) }.not_to raise_error - expect { validator.call("", options) } - .to raise_error(CMDx::ValidationError) - end - - it "handles very long strings" do - long_string = "a" * 10_000 - expect { validator.call(long_string, options) }.not_to raise_error - end + it "skips validation when value is nil" do + expect(described_class.call(nil, with: pattern)).to be_nil end end end diff --git a/spec/cmdx/validators/inclusion_spec.rb b/spec/cmdx/validators/inclusion_spec.rb index 54350069f..9068edb66 100644 --- a/spec/cmdx/validators/inclusion_spec.rb +++ b/spec/cmdx/validators/inclusion_spec.rb @@ -2,358 +2,22 @@ require "spec_helper" -RSpec.describe CMDx::Validators::Inclusion, type: :unit do - subject(:validator) { described_class } - +RSpec.describe CMDx::Validators::Inclusion do describe ".call" do - context "with array inclusions" do - context "when using :in option" do - let(:options) { { in: %w[admin user guest] } } - - context "when value is included" do - it "does not raise error for included values" do - expect { validator.call("admin", options) }.not_to raise_error - expect { validator.call("user", options) }.not_to raise_error - expect { validator.call("guest", options) }.not_to raise_error - end - end - - context "when value is not included" do - it "raises ValidationError with default message" do - expect { validator.call("root", options) } - .to raise_error(CMDx::ValidationError, 'must be one of: "admin", "user", "guest"') - end - - it "raises ValidationError for any non-included value" do - %w[root system moderator].each do |excluded_value| - expect { validator.call(excluded_value, options) } - .to raise_error(CMDx::ValidationError, 'must be one of: "admin", "user", "guest"') - end - end - end - - context "with custom :of_message" do - let(:options) { { in: %w[active inactive], of_message: "must be a valid status" } } - - it "uses custom message" do - expect { validator.call("pending", options) } - .to raise_error(CMDx::ValidationError, "must be a valid status") - end - end - - context "with custom :message" do - let(:options) { { in: %w[red green blue], message: "invalid color" } } - - it "uses custom message" do - expect { validator.call("yellow", options) } - .to raise_error(CMDx::ValidationError, "invalid color") - end - end - - context "with message interpolation" do - let(:options) { { in: %w[small medium large], of_message: "size must be %s" } } - - it "interpolates values into custom message" do - expect { validator.call("extra_large", options) } - .to raise_error(CMDx::ValidationError, 'size must be "small", "medium", "large"') - end - end - end - - context "when using :within option" do - let(:options) { { within: %w[draft published] } } - - context "when value is included" do - it "does not raise error" do - expect { validator.call("draft", options) }.not_to raise_error - expect { validator.call("published", options) }.not_to raise_error - end - end - - context "when value is not included" do - it "raises ValidationError with default message" do - expect { validator.call("archived", options) } - .to raise_error(CMDx::ValidationError, 'must be one of: "draft", "published"') - end - end - end - - context "with different data types" do - context "with integers" do - let(:options) { { in: [1, 2, 3] } } - - it "includes integer values" do - expect { validator.call(2, options) }.not_to raise_error - end - - it "raises error for non-included integers" do - expect { validator.call(4, options) } - .to raise_error(CMDx::ValidationError, "must be one of: 1, 2, 3") - end - end - - context "with symbols" do - let(:options) { { in: %i[pending active completed] } } - - it "includes symbol values" do - expect { validator.call(:pending, options) }.not_to raise_error - end - - it "raises error for non-included symbols" do - expect { validator.call(:cancelled, options) } - .to raise_error(CMDx::ValidationError, "must be one of: :pending, :active, :completed") - end - end - - context "with mixed types" do - let(:options) { { in: ["string", 42, :symbol] } } - - it "includes string value" do - expect { validator.call("string", options) }.not_to raise_error - end - - it "includes integer value" do - expect { validator.call(42, options) }.not_to raise_error - end - - it "includes symbol value" do - expect { validator.call(:symbol, options) }.not_to raise_error - end - - it "raises error for non-included values" do - expect { validator.call("other", options) } - .to raise_error(CMDx::ValidationError, 'must be one of: "string", 42, :symbol') - end - end - end - - context "with case-sensitive comparison" do - let(:options) { { in: %w[Admin ROOT] } } - - it "is case sensitive" do - expect { validator.call("Admin", options) }.not_to raise_error - expect { validator.call("ROOT", options) }.not_to raise_error - expect { validator.call("admin", options) } - .to raise_error(CMDx::ValidationError, 'must be one of: "Admin", "ROOT"') - expect { validator.call("root", options) } - .to raise_error(CMDx::ValidationError, 'must be one of: "Admin", "ROOT"') - end - end + it "validates membership with :of" do + allowed = [1, 2, 3] + expect(described_class.call(2, of: allowed)).to be_nil + expect(described_class.call(9, of: allowed)).to eq( + CMDx::Locale.t("cmdx.validators.inclusion.of", values: allowed.join(", ")) + ) end - context "with range inclusions" do - context "with integer range" do - let(:options) { { in: (1..10) } } - - context "when value is within range" do - it "does not raise error for values in range" do - expect { validator.call(5, options) }.not_to raise_error - expect { validator.call(1, options) }.not_to raise_error - expect { validator.call(10, options) }.not_to raise_error - end - end - - context "when value is outside range" do - it "raises ValidationError with default message" do - expect { validator.call(0, options) } - .to raise_error(CMDx::ValidationError, "must be within 1 and 10") - expect { validator.call(11, options) } - .to raise_error(CMDx::ValidationError, "must be within 1 and 10") - expect { validator.call(-5, options) } - .to raise_error(CMDx::ValidationError, "must be within 1 and 10") - end - end - - context "with custom :in_message" do - let(:options) { { in: (18..65), in_message: "age must be valid" } } - - it "uses custom message" do - expect { validator.call(17, options) } - .to raise_error(CMDx::ValidationError, "age must be valid") - end - end - - context "with custom :within_message" do - let(:options) { { within: (1..5), within_message: "must be in allowed range" } } - - it "uses custom message" do - expect { validator.call(6, options) } - .to raise_error(CMDx::ValidationError, "must be in allowed range") - end - end - - context "with message interpolation" do - let(:options) { { in: (1..10), in_message: "must be between %s and %s" } } - - it "interpolates min and max into custom message" do - expect { validator.call(15, options) } - .to raise_error(CMDx::ValidationError, "must be between 1 and 10") - end - end - end - - context "with exclusive range" do - let(:options) { { in: (1...10) } } - - it "includes values within range but not the end" do - expect { validator.call(9, options) }.not_to raise_error - expect { validator.call(10, options) } - .to raise_error(CMDx::ValidationError, "must be within 1 and 10") - end - end - - context "with string range" do - let(:options) { { in: ("a".."z") } } - - it "includes values within string range" do - expect { validator.call("m", options) }.not_to raise_error - expect { validator.call("A", options) } - .to raise_error(CMDx::ValidationError, "must be within a and z") - end - end - - context "with date range" do - let(:start_date) { Date.new(2023, 1, 1) } - let(:end_date) { Date.new(2023, 12, 31) } - let(:options) { { in: (start_date..end_date) } } - - it "includes dates within range" do - test_date = Date.new(2023, 6, 15) - expect { validator.call(test_date, options) }.not_to raise_error - end - - it "raises error for dates outside range" do - expect { validator.call(Date.new(2022, 12, 31), options) } - .to raise_error(CMDx::ValidationError, "must be within #{start_date} and #{end_date}") - expect { validator.call(Date.new(2024, 1, 1), options) } - .to raise_error(CMDx::ValidationError, "must be within #{start_date} and #{end_date}") - end - end - end - - context "with edge cases" do - context "when inclusion list is empty" do - let(:options) { { in: [] } } - - it "raises error for any value" do - expect { validator.call("anything", options) } - .to raise_error(CMDx::ValidationError, "must be one of: ") - expect { validator.call(123, options) } - .to raise_error(CMDx::ValidationError, "must be one of: ") - expect { validator.call(nil, options) } - .to raise_error(CMDx::ValidationError, "must be one of: ") - end - end - - context "when inclusion list is nil" do - let(:options) { { in: nil } } - - it "raises error for any value" do - expect { validator.call("anything", options) } - .to raise_error(CMDx::ValidationError, "must be one of: ") - expect { validator.call(123, options) } - .to raise_error(CMDx::ValidationError, "must be one of: ") - end - end - - context "when testing nil value" do - let(:options) { { in: [nil, "null"] } } - - it "includes nil when explicitly included" do - expect { validator.call(nil, options) }.not_to raise_error - end - - it "raises error for nil when not in inclusion list" do - expect { validator.call(nil, { in: ["other"] }) } - .to raise_error(CMDx::ValidationError, 'must be one of: "other"') - end - end - - context "with object comparison using ===" do - let(:regex_pattern) { /admin/ } - let(:options) { { in: [regex_pattern] } } - - it "uses === for comparison with regex" do - expect { validator.call("admin_user", options) }.not_to raise_error - expect { validator.call("user", options) } - .to raise_error(CMDx::ValidationError) - end - end - - context "when no inclusion option provided" do - let(:options) { {} } - - it "raises error for any value" do - expect { validator.call("anything", options) } - .to raise_error(CMDx::ValidationError, "must be one of: ") - end - end - - context "with both :in and :within options" do - let(:options) { { in: %w[admin], within: %w[root] } } - - it "uses :in option when both are present" do - expect { validator.call("admin", options) }.not_to raise_error - expect { validator.call("root", options) } - .to raise_error(CMDx::ValidationError, 'must be one of: "admin"') - end - end - end - - context "with custom message priority" do - context "when multiple message options are provided" do - let(:options) do - { - in: %w[test], - message: "generic message", - of_message: "specific of message" - } - end - - it "prioritizes of_message over message for arrays" do - expect { validator.call("invalid", options) } - .to raise_error(CMDx::ValidationError, "specific of message") - end - end - - context "with range and multiple message options" do - let(:options) do - { - in: (1..5), - message: "generic message", - in_message: "specific in message", - within_message: "specific within message" - } - end - - it "prioritizes in_message over within_message and message for ranges" do - expect { validator.call(10, options) } - .to raise_error(CMDx::ValidationError, "specific in message") - end - end - end - - context "with internationalization" do - it "calls Locale.t for default array inclusion message" do - expect(CMDx::Locale).to receive(:t).with( - "cmdx.validators.inclusion.of", - values: '"valid"' - ).and_return("localized message") - - expect { validator.call("invalid", { in: %w[valid] }) } - .to raise_error(CMDx::ValidationError, "localized message") - end - - it "calls Locale.t for default range inclusion message" do - expect(CMDx::Locale).to receive(:t).with( - "cmdx.validators.inclusion.within", - min: 1, - max: 10 - ).and_return("localized range message") - - expect { validator.call(15, { in: (1..10) }) } - .to raise_error(CMDx::ValidationError, "localized range message") - end + it "validates range with :within" do + range = 1..10 + expect(described_class.call(5, within: range)).to be_nil + expect(described_class.call(11, within: range)).to eq( + CMDx::Locale.t("cmdx.validators.inclusion.within", min: range.min, max: range.max) + ) end end end diff --git a/spec/cmdx/validators/length_spec.rb b/spec/cmdx/validators/length_spec.rb index 8a6d296d5..f79819501 100644 --- a/spec/cmdx/validators/length_spec.rb +++ b/spec/cmdx/validators/length_spec.rb @@ -2,430 +2,40 @@ require "spec_helper" -RSpec.describe CMDx::Validators::Length, type: :unit do - subject(:validator) { described_class } - +RSpec.describe CMDx::Validators::Length do describe ".call" do - context "with :within option" do - let(:options) { { within: (3..10) } } - - context "when value length is within range" do - it "does not raise error for valid lengths" do - expect { validator.call("abc", options) }.not_to raise_error - expect { validator.call("test", options) }.not_to raise_error - expect { validator.call("1234567890", options) }.not_to raise_error - end - end - - context "when value length is outside range" do - it "raises ValidationError for lengths below minimum" do - expect { validator.call("ab", options) } - .to raise_error(CMDx::ValidationError, "length must be within 3 and 10") - end - - it "raises ValidationError for lengths above maximum" do - expect { validator.call("12345678901", options) } - .to raise_error(CMDx::ValidationError, "length must be within 3 and 10") - end - end - - context "with custom :within_message" do - let(:options) { { within: (5..15), within_message: "must be between %s and %s characters" } } - - it "uses custom message with interpolation" do - expect { validator.call("abc", options) } - .to raise_error(CMDx::ValidationError, "must be between 5 and 15 characters") - end - end - - context "with custom :message" do - let(:options) { { within: (2..5), message: "invalid length" } } - - it "uses custom message" do - expect { validator.call("toolong", options) } - .to raise_error(CMDx::ValidationError, "invalid length") - end - end - end - - context "with :not_within option" do - let(:options) { { not_within: (5..8) } } - - context "when value length is outside forbidden range" do - it "does not raise error for valid lengths" do - expect { validator.call("abc", options) }.not_to raise_error - expect { validator.call("123456789", options) }.not_to raise_error - end - end - - context "when value length is within forbidden range" do - it "raises ValidationError for forbidden lengths" do - expect { validator.call("12345", options) } - .to raise_error(CMDx::ValidationError, "length must not be within 5 and 8") - - expect { validator.call("123456", options) } - .to raise_error(CMDx::ValidationError, "length must not be within 5 and 8") - end - end - - context "with custom :not_within_message" do - let(:options) { { not_within: (3..6), not_within_message: "cannot be %s-%s chars" } } - - it "uses custom message with interpolation" do - expect { validator.call("test", options) } - .to raise_error(CMDx::ValidationError, "cannot be 3-6 chars") - end - end - end - - context "with :in option" do - let(:options) { { in: (2..7) } } - - context "when value length is in range" do - it "does not raise error for valid lengths" do - expect { validator.call("ab", options) }.not_to raise_error - expect { validator.call("1234567", options) }.not_to raise_error - end - end - - context "when value length is outside range" do - it "raises ValidationError for invalid lengths" do - expect { validator.call("a", options) } - .to raise_error(CMDx::ValidationError, "length must be within 2 and 7") - - expect { validator.call("12345678", options) } - .to raise_error(CMDx::ValidationError, "length must be within 2 and 7") - end - end - - context "with custom :in_message" do - let(:options) { { in: (1..3), in_message: "should be %s to %s long" } } - - it "uses custom message with interpolation" do - expect { validator.call("toolong", options) } - .to raise_error(CMDx::ValidationError, "should be 1 to 3 long") - end - end + it "validates :is" do + expect(described_class.call("abc", is: 3)).to be_nil + expect(described_class.call("ab", is: 3)).to eq(CMDx::Locale.t("cmdx.validators.length.is", is: 3)) end - context "with :not_in option" do - let(:options) { { not_in: (4..6) } } - - context "when value length is outside forbidden range" do - it "does not raise error for valid lengths" do - expect { validator.call("abc", options) }.not_to raise_error - expect { validator.call("1234567", options) }.not_to raise_error - end - end - - context "when value length is in forbidden range" do - it "raises ValidationError for forbidden lengths" do - expect { validator.call("1234", options) } - .to raise_error(CMDx::ValidationError, "length must not be within 4 and 6") - end - end - - context "with custom :not_in_message" do - let(:options) { { not_in: (2..4), not_in_message: "forbidden range %s-%s" } } - - it "uses custom message with interpolation" do - expect { validator.call("abc", options) } - .to raise_error(CMDx::ValidationError, "forbidden range 2-4") - end - end + it "validates :is_not" do + expect(described_class.call("ab", is_not: 3)).to be_nil + expect(described_class.call("abc", is_not: 3)).to eq( + CMDx::Locale.t("cmdx.validators.length.is_not", is_not: 3) + ) end - context "with :min and :max options" do - let(:options) { { min: 3, max: 8 } } - - context "when value length is within bounds" do - it "does not raise error for valid lengths" do - expect { validator.call("abc", options) }.not_to raise_error - expect { validator.call("test", options) }.not_to raise_error - expect { validator.call("12345678", options) }.not_to raise_error - end - end - - context "when value length is outside bounds" do - it "raises ValidationError for lengths below minimum" do - expect { validator.call("ab", options) } - .to raise_error(CMDx::ValidationError, "length must be within 3 and 8") - end - - it "raises ValidationError for lengths above maximum" do - expect { validator.call("123456789", options) } - .to raise_error(CMDx::ValidationError, "length must be within 3 and 8") - end - end + it "validates :min" do + expect(described_class.call("abc", min: 2)).to be_nil + expect(described_class.call("a", min: 2)).to eq(CMDx::Locale.t("cmdx.validators.length.min", min: 2)) end - context "with :min option only" do - let(:options) { { min: 5 } } - - context "when value length meets minimum" do - it "does not raise error for valid lengths" do - expect { validator.call("12345", options) }.not_to raise_error - expect { validator.call("123456789", options) }.not_to raise_error - end - end - - context "when value length is below minimum" do - it "raises ValidationError" do - expect { validator.call("1234", options) } - .to raise_error(CMDx::ValidationError, "length must be at least 5") - end - end - - context "with custom :min_message" do - let(:options) { { min: 3, min_message: "too short, needs %s+ chars" } } - - it "uses custom message with interpolation" do - expect { validator.call("ab", options) } - .to raise_error(CMDx::ValidationError, "too short, needs 3+ chars") - end - end + it "validates :max" do + expect(described_class.call("ab", max: 3)).to be_nil + expect(described_class.call("abcd", max: 3)).to eq(CMDx::Locale.t("cmdx.validators.length.max", max: 3)) end - context "with :max option only" do - let(:options) { { max: 6 } } - - context "when value length is within maximum" do - it "does not raise error for valid lengths" do - expect { validator.call("abc", options) }.not_to raise_error - expect { validator.call("123456", options) }.not_to raise_error - end - end - - context "when value length exceeds maximum" do - it "raises ValidationError" do - expect { validator.call("1234567", options) } - .to raise_error(CMDx::ValidationError, "length must be at most 6") - end - end - - context "with custom :max_message" do - let(:options) { { max: 4, max_message: "too long, max %s chars" } } - - it "uses custom message with interpolation" do - expect { validator.call("12345", options) } - .to raise_error(CMDx::ValidationError, "too long, max 4 chars") - end - end + it "validates :within" do + range = 2..4 + expect(described_class.call("abc", within: range)).to be_nil + expect(described_class.call("a", within: range)).to eq( + CMDx::Locale.t("cmdx.validators.length.within", min: range.min, max: range.max) + ) end - context "with :is option" do - let(:options) { { is: 5 } } - - context "when value length matches exactly" do - it "does not raise error for exact length" do - expect { validator.call("12345", options) }.not_to raise_error - expect { validator.call("hello", options) }.not_to raise_error - end - end - - context "when value length does not match" do - it "raises ValidationError for shorter values" do - expect { validator.call("1234", options) } - .to raise_error(CMDx::ValidationError, "length must be 5") - end - - it "raises ValidationError for longer values" do - expect { validator.call("123456", options) } - .to raise_error(CMDx::ValidationError, "length must be 5") - end - end - - context "with custom :is_message" do - let(:options) { { is: 3, is_message: "must be exactly %s characters" } } - - it "uses custom message with interpolation" do - expect { validator.call("ab", options) } - .to raise_error(CMDx::ValidationError, "must be exactly 3 characters") - end - end - end - - context "with :is_not option" do - let(:options) { { is_not: 4 } } - - context "when value length is not the forbidden length" do - it "does not raise error for different lengths" do - expect { validator.call("abc", options) }.not_to raise_error - expect { validator.call("12345", options) }.not_to raise_error - end - end - - context "when value length matches forbidden length" do - it "raises ValidationError" do - expect { validator.call("1234", options) } - .to raise_error(CMDx::ValidationError, "length must not be 4") - end - end - - context "with custom :is_not_message" do - let(:options) { { is_not: 2, is_not_message: "cannot be %s chars long" } } - - it "uses custom message with interpolation" do - expect { validator.call("ab", options) } - .to raise_error(CMDx::ValidationError, "cannot be 2 chars long") - end - end - end - - context "with custom message priority" do - context "when multiple message options are provided for :within" do - let(:options) do - { - within: (2..5), - message: "generic message", - within_message: "specific within message" - } - end - - it "prioritizes specific message over generic" do - expect { validator.call("a", options) } - .to raise_error(CMDx::ValidationError, "specific within message") - end - end - - context "when multiple message options are provided for :min" do - let(:options) do - { - min: 3, - message: "generic message", - min_message: "specific min message" - } - end - - it "prioritizes specific message over generic" do - expect { validator.call("ab", options) } - .to raise_error(CMDx::ValidationError, "specific min message") - end - end - end - - context "with edge cases" do - context "when value is empty string" do - it "validates length correctly" do - expect { validator.call("", { min: 1 }) } - .to raise_error(CMDx::ValidationError, "length must be at least 1") - - expect { validator.call("", { is: 0 }) }.not_to raise_error - - expect { validator.call("", { max: 5 }) }.not_to raise_error - end - end - - context "when value is array" do - it "validates array length" do - expect { validator.call([1, 2, 3], { min: 2 }) }.not_to raise_error - expect { validator.call([1], { min: 2 }) } - .to raise_error(CMDx::ValidationError, "length must be at least 2") - end - end - - context "when value is hash" do - it "validates hash length" do - expect { validator.call({ a: 1, b: 2 }, { max: 3 }) }.not_to raise_error - expect { validator.call({ a: 1, b: 2, c: 3, d: 4 }, { max: 3 }) } - .to raise_error(CMDx::ValidationError, "length must be at most 3") - end - end - - context "when range has equal bounds" do - let(:options) { { within: (5..5) } } - - it "validates single-value range correctly" do - expect { validator.call("12345", options) }.not_to raise_error - expect { validator.call("1234", options) } - .to raise_error(CMDx::ValidationError, "length must be within 5 and 5") - end - end - end - - context "with invalid options" do - it "raises ArgumentError for unknown options" do - expect { validator.call("test", { unknown: true }) } - .to raise_error(ArgumentError, "unknown length validator options given") - end - - it "raises ArgumentError for empty options" do - expect { validator.call("test", {}) } - .to raise_error(ArgumentError, "unknown length validator options given") - end - end - - context "with internationalization" do - it "calls Locale.t for default within message" do - expect(CMDx::Locale).to receive(:t).with( - "cmdx.validators.length.within", - min: 3, - max: 5 - ).and_return("localized within message") - - expect { validator.call("a", { within: (3..5) }) } - .to raise_error(CMDx::ValidationError, "localized within message") - end - - it "calls Locale.t for default not_within message" do - expect(CMDx::Locale).to receive(:t).with( - "cmdx.validators.length.not_within", - min: 3, - max: 5 - ).and_return("localized not_within message") - - expect { validator.call("test", { not_within: (3..5) }) } - .to raise_error(CMDx::ValidationError, "localized not_within message") - end - - it "calls Locale.t for default min message" do - expect(CMDx::Locale).to receive(:t).with( - "cmdx.validators.length.min", - min: 3 - ).and_return("localized min message") - - expect { validator.call("ab", { min: 3 }) } - .to raise_error(CMDx::ValidationError, "localized min message") - end - - it "calls Locale.t for default max message" do - expect(CMDx::Locale).to receive(:t).with( - "cmdx.validators.length.max", - max: 3 - ).and_return("localized max message") - - expect { validator.call("toolong", { max: 3 }) } - .to raise_error(CMDx::ValidationError, "localized max message") - end - - it "calls Locale.t for default is message" do - expect(CMDx::Locale).to receive(:t).with( - "cmdx.validators.length.is", - is: 5 - ).and_return("localized is message") - - expect { validator.call("ab", { is: 5 }) } - .to raise_error(CMDx::ValidationError, "localized is message") - end - - it "calls Locale.t for default is_not message" do - expect(CMDx::Locale).to receive(:t).with( - "cmdx.validators.length.is_not", - is_not: 4 - ).and_return("localized is_not message") - - expect { validator.call("test", { is_not: 4 }) } - .to raise_error(CMDx::ValidationError, "localized is_not message") - end - - context "when custom message is provided without interpolation" do - it "does not call string interpolation for custom message" do - custom_message = "fixed custom message" - - expect { validator.call("a", { min: 3, min_message: custom_message }) } - .to raise_error(CMDx::ValidationError, custom_message) - end - end + it "returns nil for nil value" do + expect(described_class.call(nil, is: 1)).to be_nil end end end diff --git a/spec/cmdx/validators/numeric_spec.rb b/spec/cmdx/validators/numeric_spec.rb index 51909b0a6..e17aef285 100644 --- a/spec/cmdx/validators/numeric_spec.rb +++ b/spec/cmdx/validators/numeric_spec.rb @@ -2,378 +2,44 @@ require "spec_helper" -RSpec.describe CMDx::Validators::Numeric, type: :unit do - subject(:validator) { described_class } - +RSpec.describe CMDx::Validators::Numeric do describe ".call" do - context "with within option" do - let(:options) { { within: 1..10 } } - - context "when value is within range" do - it "does not raise error for values within range" do - expect { validator.call(1, options) }.not_to raise_error - expect { validator.call(5, options) }.not_to raise_error - expect { validator.call(10, options) }.not_to raise_error - end - end - - context "when value is not within range" do - it "raises ValidationError with default message for below range" do - expect { validator.call(0, options) } - .to raise_error(CMDx::ValidationError, "must be within 1 and 10") - end - - it "raises ValidationError with default message for above range" do - expect { validator.call(11, options) } - .to raise_error(CMDx::ValidationError, "must be within 1 and 10") - end - end - - context "with custom within_message" do - let(:options) { { within: 1..10, within_message: "value must be between %s and %s" } } - - it "uses custom message with interpolation" do - expect { validator.call(15, options) } - .to raise_error(CMDx::ValidationError, "value must be between 1 and 10") - end - end - - context "with custom message" do - let(:options) { { within: 1..10, message: "invalid range" } } - - it "uses custom message without interpolation" do - expect { validator.call(15, options) } - .to raise_error(CMDx::ValidationError, "invalid range") - end - end - end - - context "with in option" do - let(:options) { { in: 5..15 } } - - context "when value is in range" do - it "does not raise error for values in range" do - expect { validator.call(5, options) }.not_to raise_error - expect { validator.call(10, options) }.not_to raise_error - expect { validator.call(15, options) }.not_to raise_error - end - end - - context "when value is not in range" do - it "raises ValidationError with default message" do - expect { validator.call(4, options) } - .to raise_error(CMDx::ValidationError, "must be within 5 and 15") - end - end - - context "with custom in_message" do - let(:options) { { in: 5..15, in_message: "must be from %s to %s" } } - - it "uses custom in_message with interpolation" do - expect { validator.call(20, options) } - .to raise_error(CMDx::ValidationError, "must be from 5 to 15") - end - end - end - - context "with not_within option" do - let(:options) { { not_within: 5..10 } } - - context "when value is not within excluded range" do - it "does not raise error for values outside range" do - expect { validator.call(4, options) }.not_to raise_error - expect { validator.call(11, options) }.not_to raise_error - expect { validator.call(1, options) }.not_to raise_error - expect { validator.call(100, options) }.not_to raise_error - end - end - - context "when value is within excluded range" do - it "raises ValidationError with default message" do - expect { validator.call(5, options) } - .to raise_error(CMDx::ValidationError, "must not be within 5 and 10") - expect { validator.call(7, options) } - .to raise_error(CMDx::ValidationError, "must not be within 5 and 10") - expect { validator.call(10, options) } - .to raise_error(CMDx::ValidationError, "must not be within 5 and 10") - end - end - - context "with custom not_within_message" do - let(:options) { { not_within: 5..10, not_within_message: "cannot be between %s and %s" } } - - it "uses custom not_within_message with interpolation" do - expect { validator.call(8, options) } - .to raise_error(CMDx::ValidationError, "cannot be between 5 and 10") - end - end - end - - context "with not_in option" do - let(:options) { { not_in: 20..30 } } - - context "when value is not in excluded range" do - it "does not raise error for values outside range" do - expect { validator.call(19, options) }.not_to raise_error - expect { validator.call(31, options) }.not_to raise_error - end - end - - context "when value is in excluded range" do - it "raises ValidationError with default message" do - expect { validator.call(25, options) } - .to raise_error(CMDx::ValidationError, "must not be within 20 and 30") - end - end - - context "with custom not_in_message" do - let(:options) { { not_in: 20..30, not_in_message: "value forbidden between %s and %s" } } - - it "uses custom not_in_message with interpolation" do - expect { validator.call(25, options) } - .to raise_error(CMDx::ValidationError, "value forbidden between 20 and 30") - end - end - end - - context "with min and max options" do - let(:options) { { min: 5, max: 15 } } - - context "when value is between min and max" do - it "does not raise error for values in range" do - expect { validator.call(5, options) }.not_to raise_error - expect { validator.call(10, options) }.not_to raise_error - expect { validator.call(15, options) }.not_to raise_error - end - end - - context "when value is below min" do - it "raises ValidationError with within message" do - expect { validator.call(4, options) } - .to raise_error(CMDx::ValidationError, "must be within 5 and 15") - end - end - - context "when value is above max" do - it "raises ValidationError with within message" do - expect { validator.call(16, options) } - .to raise_error(CMDx::ValidationError, "must be within 5 and 15") - end - end - end - - context "with min option only" do - let(:options) { { min: 10 } } - - context "when value meets minimum" do - it "does not raise error for values at or above minimum" do - expect { validator.call(10, options) }.not_to raise_error - expect { validator.call(15, options) }.not_to raise_error - expect { validator.call(100, options) }.not_to raise_error - end - end - - context "when value is below minimum" do - it "raises ValidationError with min message" do - expect { validator.call(9, options) } - .to raise_error(CMDx::ValidationError, "must be at least 10") - end - end - - context "with custom min_message" do - let(:options) { { min: 10, min_message: "cannot be less than %s" } } - - it "uses custom min_message with interpolation" do - expect { validator.call(5, options) } - .to raise_error(CMDx::ValidationError, "cannot be less than 10") - end - end - end - - context "with max option only" do - let(:options) { { max: 20 } } - - context "when value meets maximum" do - it "does not raise error for values at or below maximum" do - expect { validator.call(20, options) }.not_to raise_error - expect { validator.call(15, options) }.not_to raise_error - expect { validator.call(1, options) }.not_to raise_error - end - end - - context "when value exceeds maximum" do - it "raises ValidationError with max message" do - expect { validator.call(21, options) } - .to raise_error(CMDx::ValidationError, "must be at most 20") - end - end - - context "with custom max_message" do - let(:options) { { max: 20, max_message: "cannot exceed %s" } } - - it "uses custom max_message with interpolation" do - expect { validator.call(25, options) } - .to raise_error(CMDx::ValidationError, "cannot exceed 20") - end - end + it "validates :is" do + expect(described_class.call(3, is: 3)).to be_nil + expect(described_class.call(2, is: 3)).to eq(CMDx::Locale.t("cmdx.validators.numeric.is", is: 3)) end - context "with is option" do - let(:options) { { is: 42 } } - - context "when value equals expected value" do - it "does not raise error for exact match" do - expect { validator.call(42, options) }.not_to raise_error - end - end - - context "when value does not equal expected value" do - it "raises ValidationError with is message" do - expect { validator.call(41, options) } - .to raise_error(CMDx::ValidationError, "must be 42") - expect { validator.call(43, options) } - .to raise_error(CMDx::ValidationError, "must be 42") - end - end - - context "with custom is_message" do - let(:options) { { is: 42, is_message: "value must equal %s" } } - - it "uses custom is_message with interpolation" do - expect { validator.call(50, options) } - .to raise_error(CMDx::ValidationError, "value must equal 42") - end - end - end - - context "with is_not option" do - let(:options) { { is_not: 13 } } - - context "when value does not equal forbidden value" do - it "does not raise error for different values" do - expect { validator.call(12, options) }.not_to raise_error - expect { validator.call(14, options) }.not_to raise_error - expect { validator.call(100, options) }.not_to raise_error - end - end - - context "when value equals forbidden value" do - it "raises ValidationError with is_not message" do - expect { validator.call(13, options) } - .to raise_error(CMDx::ValidationError, "must not be 13") - end - end - - context "with custom is_not_message" do - let(:options) { { is_not: 13, is_not_message: "cannot be %s" } } - - it "uses custom is_not_message with interpolation" do - expect { validator.call(13, options) } - .to raise_error(CMDx::ValidationError, "cannot be 13") - end - end + it "validates :is_not" do + expect(described_class.call(2, is_not: 3)).to be_nil + expect(described_class.call(3, is_not: 3)).to eq( + CMDx::Locale.t("cmdx.validators.numeric.is_not", is_not: 3) + ) end - context "with decimal values" do - context "with within option" do - let(:options) { { within: 1.5..10.5 } } - - it "validates decimal values correctly" do - expect { validator.call(1.5, options) }.not_to raise_error - expect { validator.call(5.7, options) }.not_to raise_error - expect { validator.call(10.5, options) }.not_to raise_error - - expect { validator.call(1.4, options) } - .to raise_error(CMDx::ValidationError, "must be within 1.5 and 10.5") - expect { validator.call(10.6, options) } - .to raise_error(CMDx::ValidationError, "must be within 1.5 and 10.5") - end - end + it "validates :min" do + expect(described_class.call(3, min: 2)).to be_nil + expect(described_class.call(1, min: 2)).to eq(CMDx::Locale.t("cmdx.validators.numeric.min", min: 2)) end - context "with negative values" do - context "with min option" do - let(:options) { { min: -10 } } - - it "validates negative values correctly" do - expect { validator.call(-10, options) }.not_to raise_error - expect { validator.call(-5, options) }.not_to raise_error - expect { validator.call(0, options) }.not_to raise_error - - expect { validator.call(-11, options) } - .to raise_error(CMDx::ValidationError, "must be at least -10") - end - end + it "validates :max" do + expect(described_class.call(2, max: 3)).to be_nil + expect(described_class.call(4, max: 3)).to eq(CMDx::Locale.t("cmdx.validators.numeric.max", max: 3)) end - context "with unknown options" do - it "raises ArgumentError for unrecognized options" do - expect { validator.call(5, { unknown: "option" }) } - .to raise_error(ArgumentError, "unknown numeric validator options given") - end + it "validates :within" do + range = 1..10 + expect(described_class.call(5, within: range)).to be_nil + expect(described_class.call(11, within: range)).to eq( + CMDx::Locale.t("cmdx.validators.numeric.within", min: range.min, max: range.max) + ) end - context "with empty options" do - it "raises ArgumentError for empty options hash" do - expect { validator.call(5, {}) } - .to raise_error(ArgumentError, "unknown numeric validator options given") - end + it "returns nil for nil value" do + expect(described_class.call(nil, is: 1)).to be_nil end - context "with global custom message" do - context "when using within option" do - let(:options) { { within: 1..10, message: "global error message" } } - - it "uses global message when specific message not provided" do - expect { validator.call(15, options) } - .to raise_error(CMDx::ValidationError, "global error message") - end - end - - context "when using min option" do - let(:options) { { min: 10, message: "global min error" } } - - it "uses global message for min validation" do - expect { validator.call(5, options) } - .to raise_error(CMDx::ValidationError, "global min error") - end - end - - context "when using max option" do - let(:options) { { max: 10, message: "global max error" } } - - it "uses global message for max validation" do - expect { validator.call(15, options) } - .to raise_error(CMDx::ValidationError, "global max error") - end - end - - context "when using is option" do - let(:options) { { is: 42, message: "global is error" } } - - it "uses global message for is validation" do - expect { validator.call(50, options) } - .to raise_error(CMDx::ValidationError, "global is error") - end - end - - context "when using is_not option" do - let(:options) { { is_not: 13, message: "global is_not error" } } - - it "uses global message for is_not validation" do - expect { validator.call(13, options) } - .to raise_error(CMDx::ValidationError, "global is_not error") - end - end - - context "when using not_within option" do - let(:options) { { not_within: 5..10, message: "global not_within error" } } - - it "uses global message for not_within validation" do - expect { validator.call(7, options) } - .to raise_error(CMDx::ValidationError, "global not_within error") - end - end + it "returns nil for non-numeric values (no checks applied)" do + expect(described_class.call("3", min: 1)).to be_nil end end end diff --git a/spec/cmdx/validators/presence_spec.rb b/spec/cmdx/validators/presence_spec.rb index 2bb389dbd..ac71d0623 100644 --- a/spec/cmdx/validators/presence_spec.rb +++ b/spec/cmdx/validators/presence_spec.rb @@ -2,164 +2,22 @@ require "spec_helper" -RSpec.describe CMDx::Validators::Presence, type: :unit do - subject(:validator) { described_class } - +RSpec.describe CMDx::Validators::Presence do describe ".call" do - context "when value is present" do - context "with string values" do - it "does not raise error for non-whitespace strings" do - expect { validator.call("hello") }.not_to raise_error - expect { validator.call("a") }.not_to raise_error - expect { validator.call("123") }.not_to raise_error - end - - it "does not raise error for strings with mixed whitespace and content" do - expect { validator.call(" hello ") }.not_to raise_error - expect { validator.call("\thello\n") }.not_to raise_error - expect { validator.call(" a ") }.not_to raise_error - end - end - - context "with objects responding to empty?" do - it "does not raise error for non-empty arrays" do - expect { validator.call([1, 2, 3]) }.not_to raise_error - expect { validator.call(["a"]) }.not_to raise_error - end - - it "does not raise error for non-empty hashes" do - expect { validator.call({ a: 1 }) }.not_to raise_error - expect { validator.call({ "key" => "value" }) }.not_to raise_error - end - - it "does not raise error for non-empty string-like objects" do - string_obj = Object.new - def string_obj.empty? = false - expect { validator.call(string_obj) }.not_to raise_error - end - end - - context "with objects not responding to empty?" do - it "does not raise error for non-nil values" do - expect { validator.call(42) }.not_to raise_error - expect { validator.call(true) }.not_to raise_error - expect { validator.call(false) }.not_to raise_error - expect { validator.call(0) }.not_to raise_error - end - - it "does not raise error for objects" do - expect { validator.call(Object.new) }.not_to raise_error - expect { validator.call(Date.today) }.not_to raise_error - end - end - end - - context "when value is not present" do - context "with string values" do - it "raises ValidationError for empty strings" do - expect { validator.call("") } - .to raise_error(CMDx::ValidationError, "cannot be empty") - end - - it "raises ValidationError for whitespace-only strings" do - expect { validator.call(" ") } - .to raise_error(CMDx::ValidationError, "cannot be empty") - expect { validator.call("\t\n\r ") } - .to raise_error(CMDx::ValidationError, "cannot be empty") - end - end - - context "with objects responding to empty?" do - it "raises ValidationError for empty arrays" do - expect { validator.call([]) } - .to raise_error(CMDx::ValidationError, "cannot be empty") - end - - it "raises ValidationError for empty hashes" do - expect { validator.call({}) } - .to raise_error(CMDx::ValidationError, "cannot be empty") - end - - it "raises ValidationError for empty string-like objects" do - empty_obj = Object.new - def empty_obj.empty? = true - expect { validator.call(empty_obj) } - .to raise_error(CMDx::ValidationError, "cannot be empty") - end - end + let(:message) { CMDx::Locale.t("cmdx.validators.presence") } - context "with nil values" do - it "raises ValidationError for nil" do - expect { validator.call(nil) } - .to raise_error(CMDx::ValidationError, "cannot be empty") - end - end + it "returns an error for nil" do + expect(described_class.call(nil)).to eq(message) end - context "with custom message option" do - let(:custom_message) { "is required" } - let(:options) { { message: custom_message } } - - it "uses custom message for empty strings" do - expect { validator.call("", options) } - .to raise_error(CMDx::ValidationError, custom_message) - end - - it "uses custom message for whitespace-only strings" do - expect { validator.call(" ", options) } - .to raise_error(CMDx::ValidationError, custom_message) - end - - it "uses custom message for empty arrays" do - expect { validator.call([], options) } - .to raise_error(CMDx::ValidationError, custom_message) - end - - it "uses custom message for nil values" do - expect { validator.call(nil, options) } - .to raise_error(CMDx::ValidationError, custom_message) - end - - it "does not raise error for present values" do - expect { validator.call("hello", options) }.not_to raise_error - expect { validator.call([1], options) }.not_to raise_error - end + it "returns an error for an empty string" do + expect(described_class.call("")).to eq(message) end - context "without options" do - it "does not raise error when no options provided" do - expect { validator.call("hello") }.not_to raise_error - end - - it "raises error with default message when no options provided" do - expect { validator.call("") } - .to raise_error(CMDx::ValidationError, "cannot be empty") - end - end - - context "with non-hash options" do - it "ignores non-hash options and uses default message" do - expect { validator.call("", "invalid_options") } - .to raise_error(CMDx::ValidationError, "cannot be empty") - expect { validator.call("", 123) } - .to raise_error(CMDx::ValidationError, "cannot be empty") - end - end - - context "with edge cases" do - it "handles zero correctly (not empty)" do - expect { validator.call(0) }.not_to raise_error - end - - it "handles false correctly (not empty)" do - expect { validator.call(false) }.not_to raise_error - end - - it "handles objects not responding to empty?" do - custom_obj = Object.new - - expect { validator.call(custom_obj) }.not_to raise_error - end + it "returns nil when present" do + expect(described_class.call("a")).to be_nil + expect(described_class.call([1])).to be_nil + expect(described_class.call(0)).to be_nil end end end diff --git a/spec/cmdx/value_resolver_spec.rb b/spec/cmdx/value_resolver_spec.rb new file mode 100644 index 000000000..984b29adc --- /dev/null +++ b/spec/cmdx/value_resolver_spec.rb @@ -0,0 +1,150 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe CMDx::ValueResolver do + let(:context) { CMDx::Context.new(raw_email: " A@B.C ", other: 99) } + + describe ".source" do + it "reads from context[attr.name] when :from is not set" do + attr = CMDx::Attribute.new(:raw_email, :string) + task = Object.new + expect(described_class.source(attr, task, context)).to eq(" A@B.C ") + end + + it "reads from context[attr.from] when :from is set and task does not define that method" do + attr = CMDx::Attribute.new(:email, :string, from: :other) + task = Object.new + expect(described_class.source(attr, task, context)).to eq(99) + end + + it "calls the task method when :from matches a method (including private)" do + task = Class.new do + def user_id + 42 + end + private :user_id + end.new + + attr = CMDx::Attribute.new(:id, :integer, from: :user_id) + ctx = CMDx::Context.new({}) + expect(described_class.source(attr, task, ctx)).to eq(42) + end + end + + describe ".derive" do + it "returns the value unchanged when derive is nil" do + attr = CMDx::Attribute.new(:x) + expect(described_class.derive(attr, Object.new, 1)).to eq(1) + end + + it "calls a derive callable with the sourced value" do + task = Object.new + attr = CMDx::Attribute.new(:x, derive: ->(v) { v.to_s.strip.downcase }) + expect(described_class.derive(attr, task, " AbC ")).to eq("abc") + end + + it "supports callables that respond to #call" do + doubler = Class.new do + def call(v) + v * 2 + end + end.new + task = Object.new + attr = CMDx::Attribute.new(:x, derive: doubler) + expect(described_class.derive(attr, task, 3)).to eq(6) + end + end + + describe ".apply_default" do + it "returns the value when it is not nil" do + attr = CMDx::Attribute.new(:x, default: 1) + expect(described_class.apply_default(attr, Object.new, 0)).to eq(0) + end + + it "returns the literal default when value is nil" do + attr = CMDx::Attribute.new(:x, default: "fallback") + expect(described_class.apply_default(attr, Object.new, nil)).to eq("fallback") + end + + it "evaluates Proc defaults in the task instance" do + task = Class.new do + def multiplier + 3 + end + end.new + attr = CMDx::Attribute.new(:x, default: -> { multiplier * 2 }) + expect(described_class.apply_default(attr, task, nil)).to eq(6) + end + + it "dispatches Symbol defaults to the task" do + task = Class.new do + def default_name + "computed" + end + end.new + attr = CMDx::Attribute.new(:x, default: :default_name) + expect(described_class.apply_default(attr, task, nil)).to eq("computed") + end + end + + describe ".coerce" do + it "returns the value when type is nil" do + attr = CMDx::Attribute.new(:x) + expect(described_class.coerce(attr, "1")).to eq("1") + end + + it "returns nil without coercing when value is nil" do + attr = CMDx::Attribute.new(:x, :integer) + expect(described_class.coerce(attr, nil)).to be_nil + end + + it "runs the registered coercion for the attribute type" do + attr = CMDx::Attribute.new(:x, :integer) + expect(described_class.coerce(attr, "42")).to eq(42) + end + end + + describe ".transform" do + it "returns the value when transform is nil" do + attr = CMDx::Attribute.new(:x) + expect(described_class.transform(attr, Object.new, 1)).to eq(1) + end + + it "invokes the transform callable with the coerced value" do + task = Object.new + attr = CMDx::Attribute.new(:x, transform: ->(v) { v.to_s.upcase }) + expect(described_class.transform(attr, task, :ab)).to eq("AB") + end + end + + describe ".call" do + it "runs source → derive → default → coerce → transform" do + task = Class.new do + def default_offset + 1 + end + end.new + + attr = CMDx::Attribute.new( + :n, + :integer, + required: false, + default: :default_offset, + derive: ->(v) { v }, # keep nil so default applies + transform: ->(v) { v + 100 } + ) + + ctx = CMDx::Context.new(n: "5") + expect(described_class.call(attr, task, ctx)).to eq(105) + + ctx_nil = CMDx::Context.new(n: nil) + expect(described_class.call(attr, task, ctx_nil)).to eq(101) + end + + it "applies derive before coercion" do + attr = CMDx::Attribute.new(:n, :integer, required: false, derive: ->(v) { v.to_s.strip }) + expect(described_class.call(attr, Object.new, CMDx::Context.new(n: " 7 "))).to eq(7) + end + end +end diff --git a/spec/cmdx/workflow_spec.rb b/spec/cmdx/workflow_spec.rb index a65a5abe8..a7e18dd8e 100644 --- a/spec/cmdx/workflow_spec.rb +++ b/spec/cmdx/workflow_spec.rb @@ -2,351 +2,110 @@ require "spec_helper" -RSpec.describe CMDx::Workflow, type: :unit do - let(:workflow_class) { create_workflow_class(name: "TestWorkflow") } - let(:workflow) { workflow_class.new } - let(:context_hash) { { executed: [] } } - let(:workflow_with_context) { workflow_class.new(context_hash) } +RSpec.describe CMDx::Workflow do + describe "DSL and execution" do + let(:order) { [] } - describe "module inclusion" do - it "extends the class with ClassMethods" do - expect(workflow_class).to respond_to(:pipeline) - expect(workflow_class).to respond_to(:task) - expect(workflow_class).to respond_to(:tasks) - end - - it "includes workflow functionality in the instance" do - expect(workflow).to respond_to(:work) - end - end - - describe "ExecutionGroup" do - subject(:execution_group) { CMDx::Workflow::ExecutionGroup.new(tasks, options) } - - let(:tasks) { [create_successful_task] } - let(:options) { { if: true } } - - it "is a Struct with tasks and options" do - expect(execution_group.tasks).to eq(tasks) - expect(execution_group.options).to eq(options) - end - end - - describe "ClassMethods" do - describe "#method_added" do - context "when redefining work method" do - it "raises an error" do - expect do - workflow_class.class_eval do - def work - "custom work" - end - end - end.to raise_error(RuntimeError, /cannot redefine.*#work method/) - end - end - - context "when adding other methods" do - it "allows normal method definition" do - expect do - workflow_class.class_eval do - def custom_method - "allowed" - end - end - end.not_to raise_error - - expect(workflow_class.new).to respond_to(:custom_method) - end - end - end - - describe "#pipeline" do - it "initializes as empty array" do - expect(workflow_class.pipeline).to eq([]) - end - - it "memoizes the pipeline" do - groups = workflow_class.pipeline - - expect(workflow_class.pipeline).to be(groups) - end - end - - describe "#tasks" do - let(:task1) { create_successful_task(name: "Task1") } - let(:task2) { create_successful_task(name: "Task2") } - let(:options) { { if: true, breakpoints: [:failure] } } - - context "with valid CMDx::Task classes" do - it "adds execution group to pipeline" do - workflow_class.tasks(task1, task2, **options) - - expect(workflow_class.pipeline.size).to eq(1) - - group = workflow_class.pipeline.first - - expect(group.tasks).to eq([task1, task2]) - expect(group.options).to eq(options) - end - - it "supports multiple task declarations" do - workflow_class.tasks(task1, **options) - workflow_class.tasks(task2, if: false) - - expect(workflow_class.pipeline.size).to eq(2) - expect(workflow_class.pipeline[0].tasks).to eq([task1]) - expect(workflow_class.pipeline[1].tasks).to eq([task2]) - end - end - - context "with invalid task types" do - it "raises TypeError for non-Task classes" do - expect do - workflow_class.tasks(String, Integer) - end.to raise_error(TypeError, "must be a CMDx::Task") - end - - it "raises TypeError for regular objects" do - expect do - workflow_class.tasks("not a task") - end.to raise_error(TypeError, "must be a CMDx::Task") - end - end - - context "with mixed valid and invalid tasks" do - it "raises TypeError when any task is invalid" do - expect do - workflow_class.tasks(task1, String, task2) - end.to raise_error(TypeError, "must be a CMDx::Task") - end - end - end - - describe "#subtasks" do - let(:task1) { create_successful_task(name: "SubTask1") } - let(:task2) { create_successful_task(name: "SubTask2") } - let(:task3) { create_successful_task(name: "SubTask3") } - - it "returns empty array when pipeline is empty" do - expect(workflow_class.subtasks).to eq([]) - end - - it "returns tasks from a single execution group" do - workflow_class.tasks(task1, task2) - - expect(workflow_class.subtasks).to eq([task1, task2]) - end - - it "returns tasks flattened across multiple execution groups" do - workflow_class.tasks(task1) - workflow_class.tasks(task2, task3) - - expect(workflow_class.subtasks).to eq([task1, task2, task3]) - end - end - end - - describe "#work" do - let(:task1) { create_successful_task(name: "Task1") } - let(:task2) { create_successful_task(name: "Task2") } - let(:task3) { create_successful_task(name: "Task3") } - - before do - workflow_class.class_eval do - settings workflow_breakpoints: [] - end - end - - context "with single execution group" do - before do - workflow_class.tasks(task1, task2, task3) - end - - it "executes all tasks in sequence" do - workflow_with_context.work - - expect(workflow_with_context.context.executed).to eq(%i[success success success]) + let(:step_one) do + o = order + Class.new(CMDx::Task) do + define_method(:work) { o << :one } end end - context "with multiple execution groups" do - before do - workflow_class.tasks(task1) - workflow_class.tasks(task2, task3) - end - - it "executes all groups in sequence" do - workflow_with_context.work - - expect(workflow_with_context.context.executed).to eq(%i[success success success]) + let(:step_two) do + o = order + Class.new(CMDx::Task) do + define_method(:work) { o << :two } end end - context "with conditional execution" do - before do - workflow_class.tasks(task1, if: true) - workflow_class.tasks(task2, if: false) - workflow_class.tasks(task3, unless: false) - end - - it "only executes tasks when conditions are met" do - workflow_with_context.work - - expect(workflow_with_context.context.executed).to eq(%i[success success]) - end - end - - context "with breakpoints in group options" do - let(:failing_task) { create_failing_task(name: "FailingTask") } - - before do - workflow_class.tasks(task1, failing_task, task3, breakpoints: [:failed]) - end - - it "stops execution when task status matches breakpoint" do - expect { workflow_with_context.work }.to raise_error(CMDx::FailFault) - - expect(workflow_with_context.context.executed).to eq([:success]) - end - end - - context "with breakpoints in class settings" do - before do - workflow_class.class_eval do - settings workflow_breakpoints: [:skipped] - end - - workflow_class.tasks(task1, task2, task3) - end - - it "executes all tasks when no task matches breakpoints" do - workflow_with_context.work - - expect(workflow_with_context.context.executed).to eq(%i[success success success]) - end - end - - context "with group breakpoints overriding class breakpoints" do - before do - workflow_class.class_eval do - settings workflow_breakpoints: [:skipped] + let(:strict_fail) do + Class.new(CMDx::Task) do + def work + fail!("halt", halt: true, strict: true) end - - workflow_class.tasks(task1, task2, task3, breakpoints: [:failed]) - end - - it "uses group breakpoints instead of class breakpoints" do - workflow_with_context.work - - expect(workflow_with_context.context.executed).to eq(%i[success success success]) end end - context "when breakpoints is nil" do - before do - workflow_class.class_eval do - settings workflow_breakpoints: [:failed] + let(:soft_fail) do + Class.new(CMDx::Task) do + def work + fail!("soft", halt: true, strict: false) end - - workflow_class.tasks(task1, task2, task3, breakpoints: nil) - end - - it "uses class-level breakpoints" do - workflow_with_context.work - - expect(workflow_with_context.context.executed).to eq(%i[success success success]) end end - context "with different breakpoint types" do - let(:failing_task) { create_nested_task(strategy: :throw, status: :failure) } - - context "when breakpoints is a single symbol" do - before do - workflow_class.tasks(task1, failing_task, task3, breakpoints: :failed) - end + it ".task declares sequential steps and runs in order" do + s1 = step_one + s2 = step_two + wf = Class.new(CMDx::Task) do + include CMDx::Workflow - it "converts single breakpoint to array" do - expect(workflow_with_context).to receive(:throw!) - - workflow_with_context.work - end - end - - context "when breakpoints is a string" do - before do - workflow_class.tasks(task1, failing_task, task3, breakpoints: "failed") - end - - it "converts string breakpoint to array and compares as string" do - expect(workflow_with_context).to receive(:throw!) - - workflow_with_context.work - end - end - - context "when breakpoints contains duplicates" do - before do - workflow_class.tasks(task1, failing_task, task3, breakpoints: [:failed, :failed, "failed"]) - end - - it "removes duplicates and converts to strings" do - expect(workflow_with_context).to receive(:throw!) - - workflow_with_context.work - end + task s1 + task s2 end + wf.execute + expect(order).to eq(%i[one two]) end - context "when task status does not match breakpoints" do - let(:failing_task) { create_nested_task(strategy: :throw, status: :failure) } + it ".tasks declares parallel steps that all run" do + seen = [] + a = Class.new(CMDx::Task) { define_method(:work) { seen << :a } } + b = Class.new(CMDx::Task) { define_method(:work) { seen << :b } } + wf = Class.new(CMDx::Task) do + include CMDx::Workflow - before do - workflow_class.tasks(task1, failing_task, task3, breakpoints: [:skipped]) - end - - it "continues execution" do - workflow_with_context.work - - expect(workflow_with_context.context.executed).to eq(%i[success success]) + tasks a, b, pool_size: 2 end + wf.execute + expect(seen.sort).to eq(%i[a b]) end - context "when execution group condition evaluates to false" do - before do - workflow_class.tasks(task1, if: false) - workflow_class.tasks(task2, unless: true) - workflow_class.tasks(task3) - end + it "halts on strict failure" do + s1 = step_one + s2 = step_two + sf = strict_fail + wf = Class.new(CMDx::Task) do + include CMDx::Workflow - it "skips groups that do not meet conditions" do - workflow_with_context.work - - expect(workflow_with_context.context.executed).to eq([:success]) + task s1 + task sf + task s2 end + wf.execute + expect(order).to eq([:one]) end - context "with complex conditional scenarios" do - before do - workflow_class.tasks(task1, if: true) - workflow_class.tasks(task2, if: false) - workflow_class.tasks(task3, unless: false) - end - - it "evaluates conditions against workflow instance" do - workflow_with_context.work + it "continues when failure is not strict" do + s1 = step_one + s2 = step_two + sf = soft_fail + wf = Class.new(CMDx::Task) do + include CMDx::Workflow - expect(workflow_with_context.context.executed).to eq(%i[success success]) + task s1 + task sf + task s2 end + wf.execute + expect(order).to eq(%i[one two]) end - context "with empty execution groups" do - it "completes without executing any tasks" do - workflow_with_context.work + it "does not halt the pipeline when on_failure is :skip (even if the step fails strict)" do + s1 = step_one + s2 = step_two + sf = strict_fail + wf = Class.new(CMDx::Task) do + include CMDx::Workflow - expect(workflow_with_context.context.executed).to eq([]) + task s1 + task sf, on_failure: :skip + task s2 end + r = wf.execute + expect(r.success?).to be(true) + expect(order).to eq(%i[one two]) end end end diff --git a/spec/cmdx_spec.rb b/spec/cmdx_spec.rb deleted file mode 100644 index 556b4372d..000000000 --- a/spec/cmdx_spec.rb +++ /dev/null @@ -1,216 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx do - after do - described_class.reset_configuration! - end - - describe ".configuration" do - it "returns a Configuration instance" do - expect(described_class.configuration).to be_a(CMDx::Configuration) - end - - it "returns the same instance on subsequent calls" do - first_call = described_class.configuration - second_call = described_class.configuration - - expect(first_call).to be(second_call) - end - - context "when @configuration is already set" do - let(:custom_config) { CMDx::Configuration.new } - - before do - described_class.instance_variable_set(:@configuration, custom_config) - end - - after do - # Skip the automatic reset for this test - described_class.instance_variable_set(:@configuration, nil) - end - - it "returns the existing configuration" do - expect(described_class.configuration).to be(custom_config) - end - - it "reuses the existing instance" do - first_config = described_class.configuration - second_config = described_class.configuration - - expect(first_config).to be(second_config) - expect(first_config).to be(custom_config) - end - end - - context "when @configuration is nil" do - before do - described_class.instance_variable_set(:@configuration, nil) - end - - it "creates and returns a new Configuration instance" do - expect(described_class.configuration).to be_a(CMDx::Configuration) - end - end - end - - describe ".configure" do - it "yields the configuration object" do - expect { |b| described_class.configure(&b) }.to yield_with_args(CMDx::Configuration) - end - - it "returns the configuration object" do - result = described_class.configure { nil } - - expect(result).to be(described_class.configuration) - end - - it "allows configuration modification within the block" do - custom_breakpoints = %w[custom_failed timeout] - - described_class.configure do |config| - config.task_breakpoints = custom_breakpoints - end - - expect(described_class.configuration.task_breakpoints).to eq(custom_breakpoints) - end - - it "passes the same configuration instance to the block" do - original_config = described_class.configuration - - described_class.configure do |config| - expect(config).to be(original_config) - end - end - - context "without a block" do - it "raises ArgumentError with descriptive message" do - expect { described_class.configure }.to raise_error(ArgumentError, "block required") - end - end - - context "with multiple configuration changes" do - let(:custom_logger) { Logger.new($stderr, progname: "test") } - let(:custom_breakpoints) { %w[error timeout] } - - it "applies all changes correctly" do - described_class.configure do |config| - config.task_breakpoints = custom_breakpoints - config.logger = custom_logger - end - - config = described_class.configuration - expect(config.task_breakpoints).to eq(custom_breakpoints) - expect(config.logger).to eq(custom_logger) - end - end - end - - describe ".reset_configuration!" do - let(:custom_logger) { Logger.new($stderr, progname: "test") } - let(:custom_breakpoints) { %w[custom_failed] } - - before do - # Modify the configuration first - described_class.configure do |config| - config.task_breakpoints = custom_breakpoints - config.workflow_breakpoints = custom_breakpoints - config.logger = custom_logger - end - end - - after do - # Skip the automatic reset for these tests since we're testing reset - described_class.instance_variable_set(:@configuration, nil) - end - - it "creates a new Configuration instance" do - original_config = described_class.configuration - described_class.reset_configuration! - expect(described_class.configuration).not_to be(original_config) - expect(described_class.configuration).to be_a(CMDx::Configuration) - end - - it "resets all breakpoints to default values" do - described_class.reset_configuration! - config = described_class.configuration - - expect(config.task_breakpoints).to eq(%w[failed]) - expect(config.workflow_breakpoints).to eq(%w[failed]) - end - - it "resets logger to default configuration" do - described_class.reset_configuration! - logger = described_class.configuration.logger - - expect(logger.progname).to eq("cmdx") - expect(logger.formatter).to be_a(CMDx::LogFormatters::Line) - expect(logger.level).to eq(Logger::INFO) - end - - it "resets all registries to their default state" do - described_class.reset_configuration! - config = described_class.configuration - - expect(config.middlewares.registry).to be_empty - expect(config.callbacks.registry).to be_empty - expect(config.coercions.registry.keys).to include( - :array, :string, :integer, :boolean, :float, :hash - ) - expect(config.validators.registry.keys).to include( - :presence, :format, :inclusion, :exclusion, :length, :numeric - ) - end - - it "clears the memoized @configuration variable" do - old_object_id = described_class.configuration.object_id - described_class.reset_configuration! - - expect(described_class.configuration.object_id).not_to eq(old_object_id) - end - - context "when called multiple times" do - it "creates a fresh instance each time" do - described_class.reset_configuration! - first_reset = described_class.configuration - - described_class.reset_configuration! - second_reset = described_class.configuration - - expect(first_reset).not_to be(second_reset) - end - end - - context "when configuration is not modified" do - it "still creates a new instance" do - original_config = described_class.configuration - described_class.reset_configuration! - - expect(described_class.configuration).not_to be(original_config) - end - end - - context "when configuration has custom registries" do - let(:custom_middleware) { CMDx::MiddlewareRegistry.new } - let(:custom_callback) { CMDx::CallbackRegistry.new } - - before do - described_class.configure do |config| - config.middlewares = custom_middleware - config.callbacks = custom_callback - end - end - - it "resets to new default registry instances" do - described_class.reset_configuration! - config = described_class.configuration - - expect(config.middlewares).not_to be(custom_middleware) - expect(config.callbacks).not_to be(custom_callback) - expect(config.middlewares).to be_a(CMDx::MiddlewareRegistry) - expect(config.callbacks).to be_a(CMDx::CallbackRegistry) - end - end - end -end diff --git a/spec/examples.txt b/spec/examples.txt new file mode 100644 index 000000000..8fa6ce1d1 --- /dev/null +++ b/spec/examples.txt @@ -0,0 +1,265 @@ +example_id | status | run_time | +------------------------------------------------------ | ------ | --------------- | +./spec/cmdx/attribute_registry_spec.rb[1:1:1] | passed | 0.00004 seconds | +./spec/cmdx/attribute_registry_spec.rb[1:2:1] | passed | 0.00005 seconds | +./spec/cmdx/attribute_registry_spec.rb[1:3:1] | passed | 0.00035 seconds | +./spec/cmdx/attribute_registry_spec.rb[1:4:1] | passed | 0.00005 seconds | +./spec/cmdx/attribute_registry_spec.rb[1:5:1] | passed | 0.00006 seconds | +./spec/cmdx/attribute_spec.rb[1:1:1] | passed | 0.00008 seconds | +./spec/cmdx/attribute_spec.rb[1:1:2] | passed | 0.00004 seconds | +./spec/cmdx/attribute_spec.rb[1:1:3] | passed | 0.00004 seconds | +./spec/cmdx/attribute_spec.rb[1:2:1] | passed | 0.00004 seconds | +./spec/cmdx/attribute_spec.rb[1:2:2] | passed | 0.00004 seconds | +./spec/cmdx/attribute_spec.rb[1:3:1] | passed | 0.00006 seconds | +./spec/cmdx/attribute_spec.rb[1:3:2] | passed | 0.00008 seconds | +./spec/cmdx/attribute_spec.rb[1:4:1] | passed | 0.00004 seconds | +./spec/cmdx/attribute_spec.rb[1:4:2] | passed | 0.00004 seconds | +./spec/cmdx/attribute_spec.rb[1:4:3] | passed | 0.00004 seconds | +./spec/cmdx/attribute_spec.rb[1:4:4] | passed | 0.00004 seconds | +./spec/cmdx/attribute_spec.rb[1:4:5] | passed | 0.00004 seconds | +./spec/cmdx/attribute_spec.rb[1:5:1] | passed | 0.00019 seconds | +./spec/cmdx/callback_registry_spec.rb[1:1:1] | passed | 0.00005 seconds | +./spec/cmdx/callback_registry_spec.rb[1:2:1] | passed | 0.00006 seconds | +./spec/cmdx/callback_registry_spec.rb[1:2:2] | passed | 0.00006 seconds | +./spec/cmdx/callback_registry_spec.rb[1:2:3] | passed | 0.00019 seconds | +./spec/cmdx/callback_registry_spec.rb[1:3:1] | passed | 0.00004 seconds | +./spec/cmdx/callback_registry_spec.rb[1:4:1] | passed | 0.00004 seconds | +./spec/cmdx/chain_spec.rb[1:1:1] | passed | 0.00006 seconds | +./spec/cmdx/chain_spec.rb[1:2:1] | passed | 0.00011 seconds | +./spec/cmdx/chain_spec.rb[1:2:2] | passed | 0.00006 seconds | +./spec/cmdx/chain_spec.rb[1:3:1:1] | passed | 0.00006 seconds | +./spec/cmdx/chain_spec.rb[1:3:2:1] | passed | 0.00005 seconds | +./spec/cmdx/chain_spec.rb[1:3:3:1] | passed | 0.00006 seconds | +./spec/cmdx/chain_spec.rb[1:3:4:1] | passed | 0.00012 seconds | +./spec/cmdx/chain_spec.rb[1:3:5:1] | passed | 0.0001 seconds | +./spec/cmdx/coercion_registry_spec.rb[1:1:1] | passed | 0.00004 seconds | +./spec/cmdx/coercion_registry_spec.rb[1:1:2] | passed | 0.00004 seconds | +./spec/cmdx/coercion_registry_spec.rb[1:1:3] | passed | 0.00041 seconds | +./spec/cmdx/coercion_registry_spec.rb[1:2:1] | passed | 0.00004 seconds | +./spec/cmdx/coercion_registry_spec.rb[1:3:1] | passed | 0.00004 seconds | +./spec/cmdx/coercions/array_spec.rb[1:1:1] | passed | 0.00003 seconds | +./spec/cmdx/coercions/array_spec.rb[1:1:2] | passed | 0.00004 seconds | +./spec/cmdx/coercions/array_spec.rb[1:1:3] | passed | 0.00003 seconds | +./spec/cmdx/coercions/array_spec.rb[1:1:4] | passed | 0.00003 seconds | +./spec/cmdx/coercions/array_spec.rb[1:1:5] | passed | 0.00032 seconds | +./spec/cmdx/coercions/big_decimal_spec.rb[1:1:1] | passed | 0.00003 seconds | +./spec/cmdx/coercions/big_decimal_spec.rb[1:1:2] | passed | 0.00003 seconds | +./spec/cmdx/coercions/big_decimal_spec.rb[1:1:3] | passed | 0.00006 seconds | +./spec/cmdx/coercions/big_decimal_spec.rb[1:1:4] | passed | 0.00003 seconds | +./spec/cmdx/coercions/boolean_spec.rb[1:1:1] | passed | 0.00004 seconds | +./spec/cmdx/coercions/boolean_spec.rb[1:1:2] | passed | 0.00006 seconds | +./spec/cmdx/coercions/boolean_spec.rb[1:1:3] | passed | 0.00011 seconds | +./spec/cmdx/coercions/complex_spec.rb[1:1:1] | passed | 0.00003 seconds | +./spec/cmdx/coercions/complex_spec.rb[1:1:2] | passed | 0.00003 seconds | +./spec/cmdx/coercions/complex_spec.rb[1:1:3] | passed | 0.00004 seconds | +./spec/cmdx/coercions/complex_spec.rb[1:1:4] | passed | 0.00026 seconds | +./spec/cmdx/coercions/date_spec.rb[1:1:1] | passed | 0.00004 seconds | +./spec/cmdx/coercions/date_spec.rb[1:1:2] | passed | 0.00004 seconds | +./spec/cmdx/coercions/date_spec.rb[1:1:3] | passed | 0.00004 seconds | +./spec/cmdx/coercions/date_spec.rb[1:1:4] | passed | 0.00006 seconds | +./spec/cmdx/coercions/date_spec.rb[1:1:5] | passed | 0.00037 seconds | +./spec/cmdx/coercions/date_time_spec.rb[1:1:1] | passed | 0.00004 seconds | +./spec/cmdx/coercions/date_time_spec.rb[1:1:2] | passed | 0.00021 seconds | +./spec/cmdx/coercions/date_time_spec.rb[1:1:3] | passed | 0.00004 seconds | +./spec/cmdx/coercions/date_time_spec.rb[1:1:4] | passed | 0.00006 seconds | +./spec/cmdx/coercions/date_time_spec.rb[1:1:5] | passed | 0.00031 seconds | +./spec/cmdx/coercions/float_spec.rb[1:1:1] | passed | 0.00004 seconds | +./spec/cmdx/coercions/float_spec.rb[1:1:2] | passed | 0.00003 seconds | +./spec/cmdx/coercions/float_spec.rb[1:1:3] | passed | 0.00023 seconds | +./spec/cmdx/coercions/hash_spec.rb[1:1:1] | passed | 0.00004 seconds | +./spec/cmdx/coercions/hash_spec.rb[1:1:2] | passed | 0.00003 seconds | +./spec/cmdx/coercions/hash_spec.rb[1:1:3] | passed | 0.00004 seconds | +./spec/cmdx/coercions/hash_spec.rb[1:1:4] | passed | 0.00024 seconds | +./spec/cmdx/coercions/integer_spec.rb[1:1:1] | passed | 0.00006 seconds | +./spec/cmdx/coercions/integer_spec.rb[1:1:2] | passed | 0.00004 seconds | +./spec/cmdx/coercions/integer_spec.rb[1:1:3] | passed | 0.0017 seconds | +./spec/cmdx/coercions/rational_spec.rb[1:1:1] | passed | 0.00003 seconds | +./spec/cmdx/coercions/rational_spec.rb[1:1:2] | passed | 0.00004 seconds | +./spec/cmdx/coercions/rational_spec.rb[1:1:3] | passed | 0.00003 seconds | +./spec/cmdx/coercions/rational_spec.rb[1:1:4] | passed | 0.00026 seconds | +./spec/cmdx/coercions/string_spec.rb[1:1:1] | passed | 0.00004 seconds | +./spec/cmdx/coercions/string_spec.rb[1:1:2] | passed | 0.00003 seconds | +./spec/cmdx/coercions/symbol_spec.rb[1:1:1] | passed | 0.00004 seconds | +./spec/cmdx/coercions/time_spec.rb[1:1:1] | passed | 0.00004 seconds | +./spec/cmdx/coercions/time_spec.rb[1:1:2] | passed | 0.00008 seconds | +./spec/cmdx/coercions/time_spec.rb[1:1:3] | passed | 0.00005 seconds | +./spec/cmdx/coercions/time_spec.rb[1:1:4] | passed | 0.00004 seconds | +./spec/cmdx/coercions/time_spec.rb[1:1:5] | passed | 0.00026 seconds | +./spec/cmdx/configuration_spec.rb[1:1:1] | passed | 0.00004 seconds | +./spec/cmdx/configuration_spec.rb[1:1:2] | passed | 0.00004 seconds | +./spec/cmdx/configuration_spec.rb[1:2:1] | passed | 0.00005 seconds | +./spec/cmdx/configuration_spec.rb[1:3:1] | passed | 0.00004 seconds | +./spec/cmdx/configuration_spec.rb[1:4:1] | passed | 0.00006 seconds | +./spec/cmdx/context_spec.rb[1:1:1] | passed | 0.00036 seconds | +./spec/cmdx/context_spec.rb[1:1:2] | passed | 0.00004 seconds | +./spec/cmdx/context_spec.rb[1:2:1] | passed | 0.00003 seconds | +./spec/cmdx/context_spec.rb[1:2:2] | passed | 0.00004 seconds | +./spec/cmdx/context_spec.rb[1:3:1] | passed | 0.00004 seconds | +./spec/cmdx/context_spec.rb[1:4:1] | passed | 0.00005 seconds | +./spec/cmdx/context_spec.rb[1:4:2] | passed | 0.00005 seconds | +./spec/cmdx/context_spec.rb[1:4:3] | passed | 0.00004 seconds | +./spec/cmdx/context_spec.rb[1:5:1] | passed | 0.00004 seconds | +./spec/cmdx/context_spec.rb[1:5:2] | passed | 0.00004 seconds | +./spec/cmdx/context_spec.rb[1:5:3] | passed | 0.00005 seconds | +./spec/cmdx/context_spec.rb[1:6:1] | passed | 0.0008 seconds | +./spec/cmdx/context_spec.rb[1:7:1] | passed | 0.00049 seconds | +./spec/cmdx/context_spec.rb[1:8:1] | passed | 0.00004 seconds | +./spec/cmdx/context_spec.rb[1:8:2] | passed | 0.00004 seconds | +./spec/cmdx/context_spec.rb[1:9:1] | passed | 0.00005 seconds | +./spec/cmdx/deprecator_spec.rb[1:1:1] | passed | 0.00008 seconds | +./spec/cmdx/deprecator_spec.rb[1:1:2] | passed | 0.00006 seconds | +./spec/cmdx/deprecator_spec.rb[1:1:3] | passed | 0.00096 seconds | +./spec/cmdx/errors_spec.rb[1:1:1] | passed | 0.00083 seconds | +./spec/cmdx/errors_spec.rb[1:1:2] | passed | 0.00005 seconds | +./spec/cmdx/errors_spec.rb[1:2:1] | passed | 0.00004 seconds | +./spec/cmdx/errors_spec.rb[1:2:2] | passed | 0.00004 seconds | +./spec/cmdx/errors_spec.rb[1:3:1] | passed | 0.00006 seconds | +./spec/cmdx/errors_spec.rb[1:4:1] | passed | 0.00007 seconds | +./spec/cmdx/errors_spec.rb[1:5:1] | passed | 0.00004 seconds | +./spec/cmdx/errors_spec.rb[1:6:1] | passed | 0.00004 seconds | +./spec/cmdx/errors_spec.rb[1:7:1] | passed | 0.00005 seconds | +./spec/cmdx/identifier_spec.rb[1:1:1] | passed | 0.00044 seconds | +./spec/cmdx/identifier_spec.rb[1:1:2] | passed | 0.00014 seconds | +./spec/cmdx/log_formatters/json_spec.rb[1:1:1] | passed | 0.00018 seconds | +./spec/cmdx/log_formatters/json_spec.rb[1:1:2] | passed | 0.00007 seconds | +./spec/cmdx/log_formatters/key_value_spec.rb[1:1:1] | passed | 0.00011 seconds | +./spec/cmdx/log_formatters/key_value_spec.rb[1:1:2] | passed | 0.00004 seconds | +./spec/cmdx/log_formatters/line_spec.rb[1:1:1] | passed | 0.00312 seconds | +./spec/cmdx/log_formatters/line_spec.rb[1:1:2] | passed | 0.00005 seconds | +./spec/cmdx/log_formatters/logstash_spec.rb[1:1:1] | passed | 0.00012 seconds | +./spec/cmdx/log_formatters/logstash_spec.rb[1:1:2] | passed | 0.00037 seconds | +./spec/cmdx/log_formatters/raw_spec.rb[1:1:1] | passed | 0.00011 seconds | +./spec/cmdx/log_formatters/raw_spec.rb[1:1:2] | passed | 0.00004 seconds | +./spec/cmdx/middleware_registry_spec.rb[1:1:1] | passed | 0.00005 seconds | +./spec/cmdx/middleware_registry_spec.rb[1:2:1] | passed | 0.00006 seconds | +./spec/cmdx/middleware_registry_spec.rb[1:2:2] | passed | 0.00013 seconds | +./spec/cmdx/middleware_registry_spec.rb[1:2:3] | passed | 0.00013 seconds | +./spec/cmdx/middleware_registry_spec.rb[1:3:1] | passed | 0.00004 seconds | +./spec/cmdx/middleware_registry_spec.rb[1:4:1] | passed | 0.00004 seconds | +./spec/cmdx/middlewares/correlate_spec.rb[1:1] | passed | 0.00013 seconds | +./spec/cmdx/middlewares/correlate_spec.rb[1:2] | passed | 0.00008 seconds | +./spec/cmdx/middlewares/correlate_spec.rb[1:3] | passed | 0.00007 seconds | +./spec/cmdx/middlewares/runtime_tracker_spec.rb[1:1] | passed | 0.01344 seconds | +./spec/cmdx/middlewares/timeout_spec.rb[1:1] | passed | 0.00138 seconds | +./spec/cmdx/middlewares/timeout_spec.rb[1:2] | passed | 0.05472 seconds | +./spec/cmdx/outcome_spec.rb[1:1:1] | passed | 0.00007 seconds | +./spec/cmdx/outcome_spec.rb[1:2:1] | passed | 0.00005 seconds | +./spec/cmdx/outcome_spec.rb[1:3:1] | passed | 0.00087 seconds | +./spec/cmdx/outcome_spec.rb[1:3:2] | passed | 0.00006 seconds | +./spec/cmdx/outcome_spec.rb[1:3:3] | passed | 0.00035 seconds | +./spec/cmdx/outcome_spec.rb[1:3:4] | passed | 0.00028 seconds | +./spec/cmdx/result_spec.rb[1:1:1] | passed | 0.00044 seconds | +./spec/cmdx/result_spec.rb[1:2:1] | passed | 0.00007 seconds | +./spec/cmdx/result_spec.rb[1:3:1] | passed | 0.00005 seconds | +./spec/cmdx/result_spec.rb[1:4:1] | passed | 0.00009 seconds | +./spec/cmdx/result_spec.rb[1:5:1] | passed | 0.00007 seconds | +./spec/cmdx/result_spec.rb[1:6:1] | passed | 0.00006 seconds | +./spec/cmdx/result_spec.rb[1:7:1] | passed | 0.00005 seconds | +./spec/cmdx/result_spec.rb[1:8:1] | passed | 0.00005 seconds | +./spec/cmdx/result_spec.rb[1:8:2] | passed | 0.00006 seconds | +./spec/cmdx/retry_strategy_spec.rb[1:1:1] | passed | 0.00004 seconds | +./spec/cmdx/retry_strategy_spec.rb[1:2:1] | passed | 0.00005 seconds | +./spec/cmdx/retry_strategy_spec.rb[1:3:1] | passed | 0.03065 seconds | +./spec/cmdx/retry_strategy_spec.rb[1:3:2] | passed | 0.00004 seconds | +./spec/cmdx/runtime_spec.rb[1:1:1] | passed | 0.00013 seconds | +./spec/cmdx/runtime_spec.rb[1:1:2] | passed | 0.00007 seconds | +./spec/cmdx/runtime_spec.rb[1:1:3] | passed | 0.00007 seconds | +./spec/cmdx/runtime_spec.rb[1:1:4] | passed | 0.0001 seconds | +./spec/cmdx/runtime_spec.rb[1:1:5] | passed | 0.00007 seconds | +./spec/cmdx/runtime_spec.rb[1:1:6] | passed | 0.00024 seconds | +./spec/cmdx/runtime_spec.rb[1:1:7] | passed | 0.00029 seconds | +./spec/cmdx/runtime_spec.rb[1:1:8] | passed | 0.00006 seconds | +./spec/cmdx/settings_spec.rb[1:1:1] | passed | 0.00005 seconds | +./spec/cmdx/settings_spec.rb[1:2:1] | passed | 0.00004 seconds | +./spec/cmdx/settings_spec.rb[1:3:1] | passed | 0.00004 seconds | +./spec/cmdx/settings_spec.rb[1:4:1] | passed | 0.00005 seconds | +./spec/cmdx/settings_spec.rb[1:5:1] | passed | 0.00003 seconds | +./spec/cmdx/settings_spec.rb[1:5:2] | passed | 0.00004 seconds | +./spec/cmdx/settings_spec.rb[1:6:1] | passed | 0.00004 seconds | +./spec/cmdx/settings_spec.rb[1:6:2] | passed | 0.00004 seconds | +./spec/cmdx/signals_spec.rb[1:1:1] | passed | 0.00006 seconds | +./spec/cmdx/signals_spec.rb[1:1:2] | passed | 0.00004 seconds | +./spec/cmdx/signals_spec.rb[1:2:1] | passed | 0.00005 seconds | +./spec/cmdx/signals_spec.rb[1:2:2] | passed | 0.00004 seconds | +./spec/cmdx/signals_spec.rb[1:3:1] | passed | 0.00004 seconds | +./spec/cmdx/signals_spec.rb[1:3:2] | passed | 0.00004 seconds | +./spec/cmdx/signals_spec.rb[1:4:1] | passed | 0.00011 seconds | +./spec/cmdx/signals_spec.rb[1:5:1] | passed | 0.00006 seconds | +./spec/cmdx/task_spec.rb[1:1:1] | passed | 0.00008 seconds | +./spec/cmdx/task_spec.rb[1:1:2] | passed | 0.00008 seconds | +./spec/cmdx/task_spec.rb[1:1:3] | passed | 0.0001 seconds | +./spec/cmdx/task_spec.rb[1:2:1] | passed | 0.00005 seconds | +./spec/cmdx/task_spec.rb[1:3:1] | passed | 0.00004 seconds | +./spec/cmdx/task_spec.rb[1:3:2] | passed | 0.00005 seconds | +./spec/cmdx/task_spec.rb[1:4:1] | passed | 0.00006 seconds | +./spec/cmdx/task_spec.rb[1:4:2] | passed | 0.00007 seconds | +./spec/cmdx/task_spec.rb[1:4:3] | passed | 0.00005 seconds | +./spec/cmdx/task_spec.rb[1:5:1] | passed | 0.00006 seconds | +./spec/cmdx/task_spec.rb[1:5:2] | passed | 0.00009 seconds | +./spec/cmdx/task_spec.rb[1:6:1] | passed | 0.00012 seconds | +./spec/cmdx/task_spec.rb[1:7:1] | passed | 0.00011 seconds | +./spec/cmdx/task_spec.rb[1:8:1] | passed | 0.00031 seconds | +./spec/cmdx/task_spec.rb[1:9:1] | passed | 0.00013 seconds | +./spec/cmdx/validator_registry_spec.rb[1:1:1] | passed | 0.00004 seconds | +./spec/cmdx/validator_registry_spec.rb[1:1:2] | passed | 0.00005 seconds | +./spec/cmdx/validator_registry_spec.rb[1:2:1] | passed | 0.00004 seconds | +./spec/cmdx/validator_registry_spec.rb[1:3:1] | passed | 0.00005 seconds | +./spec/cmdx/validators/absence_spec.rb[1:1:1] | passed | 0.00005 seconds | +./spec/cmdx/validators/absence_spec.rb[1:1:2] | passed | 0.00063 seconds | +./spec/cmdx/validators/absence_spec.rb[1:1:3] | passed | 0.00939 seconds | +./spec/cmdx/validators/exclusion_spec.rb[1:1:1] | passed | 0.00024 seconds | +./spec/cmdx/validators/exclusion_spec.rb[1:1:2] | passed | 0.00026 seconds | +./spec/cmdx/validators/format_spec.rb[1:1:1] | passed | 0.00004 seconds | +./spec/cmdx/validators/format_spec.rb[1:1:2] | passed | 0.0004 seconds | +./spec/cmdx/validators/format_spec.rb[1:1:3] | passed | 0.00004 seconds | +./spec/cmdx/validators/inclusion_spec.rb[1:1:1] | passed | 0.00024 seconds | +./spec/cmdx/validators/inclusion_spec.rb[1:1:2] | passed | 0.00024 seconds | +./spec/cmdx/validators/length_spec.rb[1:1:1] | passed | 0.00023 seconds | +./spec/cmdx/validators/length_spec.rb[1:1:2] | passed | 0.00023 seconds | +./spec/cmdx/validators/length_spec.rb[1:1:3] | passed | 0.00349 seconds | +./spec/cmdx/validators/length_spec.rb[1:1:4] | passed | 0.00021 seconds | +./spec/cmdx/validators/length_spec.rb[1:1:5] | passed | 0.00021 seconds | +./spec/cmdx/validators/length_spec.rb[1:1:6] | passed | 0.00004 seconds | +./spec/cmdx/validators/numeric_spec.rb[1:1:1] | passed | 0.00046 seconds | +./spec/cmdx/validators/numeric_spec.rb[1:1:2] | passed | 0.00026 seconds | +./spec/cmdx/validators/numeric_spec.rb[1:1:3] | passed | 0.00031 seconds | +./spec/cmdx/validators/numeric_spec.rb[1:1:4] | passed | 0.00026 seconds | +./spec/cmdx/validators/numeric_spec.rb[1:1:5] | passed | 0.00028 seconds | +./spec/cmdx/validators/numeric_spec.rb[1:1:6] | passed | 0.00004 seconds | +./spec/cmdx/validators/numeric_spec.rb[1:1:7] | passed | 0.00006 seconds | +./spec/cmdx/validators/presence_spec.rb[1:1:1] | passed | 0.00025 seconds | +./spec/cmdx/validators/presence_spec.rb[1:1:2] | passed | 0.00021 seconds | +./spec/cmdx/validators/presence_spec.rb[1:1:3] | passed | 0.00004 seconds | +./spec/cmdx/value_resolver_spec.rb[1:1:1] | passed | 0.00006 seconds | +./spec/cmdx/value_resolver_spec.rb[1:1:2] | passed | 0.00006 seconds | +./spec/cmdx/value_resolver_spec.rb[1:1:3] | passed | 0.00018 seconds | +./spec/cmdx/value_resolver_spec.rb[1:2:1] | passed | 0.00004 seconds | +./spec/cmdx/value_resolver_spec.rb[1:2:2] | passed | 0.00004 seconds | +./spec/cmdx/value_resolver_spec.rb[1:2:3] | passed | 0.00004 seconds | +./spec/cmdx/value_resolver_spec.rb[1:3:1] | passed | 0.00004 seconds | +./spec/cmdx/value_resolver_spec.rb[1:3:2] | passed | 0.00003 seconds | +./spec/cmdx/value_resolver_spec.rb[1:3:3] | passed | 0.00004 seconds | +./spec/cmdx/value_resolver_spec.rb[1:3:4] | passed | 0.00004 seconds | +./spec/cmdx/value_resolver_spec.rb[1:4:1] | passed | 0.00003 seconds | +./spec/cmdx/value_resolver_spec.rb[1:4:2] | passed | 0.00003 seconds | +./spec/cmdx/value_resolver_spec.rb[1:4:3] | passed | 0.00004 seconds | +./spec/cmdx/value_resolver_spec.rb[1:5:1] | passed | 0.00004 seconds | +./spec/cmdx/value_resolver_spec.rb[1:5:2] | passed | 0.00004 seconds | +./spec/cmdx/value_resolver_spec.rb[1:6:1] | passed | 0.00006 seconds | +./spec/cmdx/value_resolver_spec.rb[1:6:2] | passed | 0.00004 seconds | +./spec/cmdx/workflow_spec.rb[1:1:1] | passed | 0.00061 seconds | +./spec/cmdx/workflow_spec.rb[1:1:2] | passed | 0.00078 seconds | +./spec/cmdx/workflow_spec.rb[1:1:3] | passed | 0.00024 seconds | +./spec/cmdx/workflow_spec.rb[1:1:4] | passed | 0.00027 seconds | +./spec/cmdx/workflow_spec.rb[1:1:5] | passed | 0.0002 seconds | +./spec/integration/tasks/basic_execution_spec.rb[1:1] | passed | 0.00008 seconds | +./spec/integration/tasks/basic_execution_spec.rb[1:2] | passed | 0.00009 seconds | +./spec/integration/tasks/basic_execution_spec.rb[1:3] | passed | 0.00007 seconds | +./spec/integration/tasks/basic_execution_spec.rb[1:4] | passed | 0.00006 seconds | +./spec/integration/tasks/basic_execution_spec.rb[1:5] | passed | 0.00009 seconds | +./spec/integration/tasks/basic_execution_spec.rb[1:6] | passed | 0.0001 seconds | +./spec/integration/tasks/basic_execution_spec.rb[1:7] | passed | 0.00033 seconds | +./spec/integration/tasks/basic_execution_spec.rb[1:8] | passed | 0.00007 seconds | +./spec/integration/tasks/basic_execution_spec.rb[1:9] | passed | 0.00006 seconds | +./spec/integration/tasks/basic_execution_spec.rb[1:10] | passed | 0.00011 seconds | +./spec/integration/tasks/basic_execution_spec.rb[1:11] | passed | 0.00007 seconds | diff --git a/spec/integration/tasks/attributes_spec.rb b/spec/integration/tasks/attributes_spec.rb deleted file mode 100644 index f02acf7d5..000000000 --- a/spec/integration/tasks/attributes_spec.rb +++ /dev/null @@ -1,482 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe "Task attributes", type: :feature do - context "when defining" do - context "without options" do - context "with no inputs" do - it "fails due to missing input" do - task = create_task_class do - attribute :plain_optional_attr - attributes :plain_required_attr, required: true - required :required_attr - optional :optional_attr - - def work - context.attrs = [plain_optional_attr, plain_required_attr, required_attr, optional_attr] - end - end - - result = task.execute - - expect(result).to have_failed( - reason: "Invalid", - cause: be_a(CMDx::FailFault) - ) - expect(result).to have_matching_metadata( - errors: { - full_message: "plain_required_attr must be accessible via the context source method. required_attr must be accessible via the context source method", - messages: { - plain_required_attr: ["must be accessible via the context source method"], - required_attr: ["must be accessible via the context source method"] - } - } - ) - end - end - - context "with minimum inputs" do - it "returns attributes defined as methods" do - task = create_task_class do - attribute :plain_optional_attr - attributes :plain_required_attr, required: true - required :required_attr - optional :optional_attr - - def work - context.attrs = [plain_optional_attr, plain_required_attr, required_attr, optional_attr] - end - end - - result = task.execute( - plain_required_attr: "plain_required", - required_attr: "required" - ) - - expect(result).to be_successful - expect(result).to have_matching_context(attrs: [nil, "plain_required", "required", nil]) - end - end - - context "with maximum inputs" do - it "returns attributes defined as methods" do - task = create_task_class do - attribute :plain_optional_attr - attributes :plain_required_attr, required: true - required :required_attr - optional :optional_attr - - def work - context.attrs = [plain_optional_attr, plain_required_attr, required_attr, optional_attr] - end - end - - result = task.execute( - plain_optional_attr: "plain_optional", - plain_required_attr: "plain_required", - required_attr: "required", - optional_attr: "optional" - ) - - expect(result).to be_successful - expect(result).to have_matching_context(attrs: %w[plain_optional plain_required required optional]) - end - end - end - - context "with source options" do - context "when source doesnt exist" do - it "fails with coercion error message" do - task = create_task_class do - attribute :raw_attr, source: :not_a_method - - def work = nil - end - - result = task.execute - - expect(result).to have_failed( - reason: "Invalid", - cause: be_a(CMDx::FailFault) - ) - expect(result).to have_matching_metadata( - errors: { - full_message: "raw_attr delegates to undefined method not_a_method", - messages: { raw_attr: ["delegates to undefined method not_a_method"] } - } - ) - end - end - end - - context "with type options" do - context "when attribute is optional" do - context "when cannot be coerced into type" do - it "fails with coercion error message" do - task = create_task_class do - attribute :raw_attr, type: :integer - - def work - context.attrs = [raw_attr] - end - end - - result = task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(attrs: [nil]) - end - end - - context "when cannot be coerced into any type" do - it "fails with coercion error message" do - task = create_task_class do - attribute :raw_attr, types: %i[float integer] - - def work - context.attrs = [raw_attr] - end - end - - result = task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(attrs: [nil]) - end - end - - context "when value can be coerced" do - it "coerces the value into the type" do - task = create_task_class do - attribute :raw_attr, type: :integer - - def work - context.attrs = [raw_attr] - end - end - - result = task.execute(raw_attr: "123") - - expect(result).to be_successful - expect(result).to have_matching_context(attrs: [123]) - end - end - end - - context "when attribute is required" do - context "when cannot be coerced into type" do - it "fails with coercion error message" do - task = create_task_class do - required :raw_attr, type: :hash do - required :raw_key, type: :integer - end - - def work = nil - end - - result = task.execute(raw_attr: { raw_key: nil }) - - expect(result).to have_failed( - reason: "Invalid", - cause: be_a(CMDx::FailFault) - ) - expect(result).to have_matching_metadata( - errors: { - full_message: "raw_key could not coerce into an integer", - messages: { raw_key: ["could not coerce into an integer"] } - } - ) - end - end - - context "when cannot be coerced into any type" do - it "fails with coercion error message" do - task = create_task_class do - required :raw_attr, type: :hash do - required :raw_key, type: %i[float integer] - end - - def work = nil - end - - result = task.execute(raw_attr: { raw_key: nil }) - - expect(result).to have_failed( - reason: "Invalid", - cause: be_a(CMDx::FailFault) - ) - expect(result).to have_matching_metadata( - errors: { - full_message: "raw_key could not coerce into one of: float, integer", - messages: { raw_key: ["could not coerce into one of: float, integer"] } - } - ) - end - end - - context "when nested attribute is missing" do - it "fails with coercion error message" do - task = create_task_class do - required :raw_attr, type: :hash do - optional :raw_key, type: :integer - end - - def work - context.attrs = [raw_key] - end - end - - result = task.execute(raw_attr: {}) - - expect(result).to be_successful - expect(result).to have_matching_context(attrs: [nil]) - end - end - - context "when value can be coerced" do - it "coerces the value into the type" do - task = create_task_class do - required :raw_attr, type: :hash do - required :raw_key, type: :integer - end - - def work - context.attrs = [raw_key] - end - end - - result = task.execute(raw_attr: { raw_key: "123" }) - - expect(result).to be_successful - expect(result).to have_matching_context(attrs: [123]) - end - end - end - end - - context "with default option" do - context "when derived value is nil" do - it "returns the default value" do - task = create_task_class do - attribute :raw_attr, default: 987 - - def work - context.attrs = [raw_attr] - end - end - - result = task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(attrs: [987]) - end - end - - context "when derived value is not nil" do - it "returns the derived value" do - task = create_task_class do - attribute :raw_attr, default: 987 - - def work - context.attrs = [raw_attr] - end - end - - result = task.execute(raw_attr: "123") - - expect(result).to be_successful - expect(result).to have_matching_context(attrs: ["123"]) - end - end - - context "when the default value cannot be coerced into the type" do - it "fails with coercion error message" do - task = create_task_class do - attribute :raw_attr, type: :integer, default: "abc" - - def work = nil - end - - result = task.execute - - expect(result).to have_failed( - reason: "Invalid", - cause: be_a(CMDx::FailFault) - ) - expect(result).to have_matching_metadata( - errors: { - full_message: "raw_attr could not coerce into an integer", - messages: { raw_attr: ["could not coerce into an integer"] } - } - ) - end - end - end - - context "with transformation options" do - context "when value is not valid" do - it "succeeds but does not transform the value" do - task = create_task_class do - attribute :raw_attr, transform: :downcase - - def work - context.attr = raw_attr - end - end - - result = task.execute(raw_attr: 123) - - expect(result).to be_successful - expect(result).to have_matching_context(attr: 123) - end - end - - context "when value is valid" do - it "succeeds and transforms the value" do - task = create_task_class do - attribute :raw_attr, transform: :upcase - - def work - context.attr = raw_attr - end - end - - result = task.execute(raw_attr: "abc") - - expect(result).to be_successful - expect(result).to have_matching_context(attr: "ABC") - end - end - end - - context "with validation options" do - context "when value is not valid" do - it "fails with validation error message" do - task = create_task_class do - attribute :raw_attr, format: { with: /^\d+$/ } - - def work = nil - end - - result = task.execute - - expect(result).to have_failed( - reason: "Invalid", - cause: be_a(CMDx::FailFault) - ) - expect(result).to have_matching_metadata( - errors: { - full_message: "raw_attr is an invalid format", - messages: { raw_attr: ["is an invalid format"] } - } - ) - end - end - end - - context "with naming options" do - context "when prefix is set" do - it "prefixes the attribute name" do - task = create_task_class do - attribute :raw_attr, prefix: :prefix_ - - def work - context.attrs = [prefix_raw_attr] - end - end - - result = task.execute(raw_attr: "123") - - expect(result).to be_successful - expect(result).to have_matching_context(attrs: ["123"]) - end - end - - context "when suffix is set" do - it "suffixes the attribute name" do - task = create_task_class do - attribute :raw_attr, suffix: :_suffix - - def work - context.attrs = [raw_attr_suffix] - end - end - - result = task.execute(raw_attr: "123") - - expect(result).to be_successful - expect(result).to have_matching_context(attrs: ["123"]) - end - end - - context "when as is set" do - it "sets the attribute name" do - task = create_task_class do - attribute :raw_attr, as: :raw_attr_as - - def work - context.attrs = [raw_attr_as] - end - end - - result = task.execute(raw_attr: "123") - - expect(result).to be_successful - expect(result).to have_matching_context(attrs: ["123"]) - end - end - end - end - - context "when undefining" do - it "raises a NameError" do - task = create_task_class do - attribute :raw_attr - - remove_attribute :raw_attr - - def work - context.attrs = [raw_attr] - end - end - - result = task.execute - - expect(result).to have_failed( - reason: start_with( - if RubyVersion.min?(3.4) - "[NameError] undefined local variable or method 'raw_attr' for an instance of" - else - "[NameError] undefined local variable or method `raw_attr' for" - end - ), - cause: be_a(NameError) - ) - end - end - - context "when inheriting" do - it "assumes the parents attributes" do - parent_task = create_task_class(name: "ParentTask") do - required :parent_attr - - def work = nil - end - child_task = create_task_class(base: parent_task, name: "ChildTask") do - optional :child_attr - - def work - context.executed ||= [] - context.executed << parent_attr - context.executed << child_attr - end - end - - result = child_task.execute(parent_attr: "parent123", child_attr: "child456") - - expect(result).to be_successful - expect(result).to have_matching_context(executed: %w[parent123 child456]) - end - end -end diff --git a/spec/integration/tasks/basic_execution_spec.rb b/spec/integration/tasks/basic_execution_spec.rb new file mode 100644 index 000000000..8b359d967 --- /dev/null +++ b/spec/integration/tasks/basic_execution_spec.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +require "spec_helper" + +# Integration-style examples across Task, Runtime, and middleware. +RSpec.describe "CMDx task integration", :integration do # rubocop:disable RSpec/DescribeClass + it "simple task succeeds" do + k = Class.new(CMDx::Task) do + def work + ctx[:x] = 1 + end + end + r = k.execute + expect(r.success?).to be(true) + expect(r.context[:x]).to eq(1) + end + + it "task with attributes validates and coerces" do + k = Class.new(CMDx::Task) do + required :n, :integer + def work + ctx[:doubled] = n * 2 + end + end + r = k.execute(n: "4") + expect(r.success?).to be(true) + expect(r.context[:doubled]).to eq(8) + end + + it "task with fail! returns failed result" do + k = Class.new(CMDx::Task) do + def work + fail!("no", halt: true) + end + end + r = k.execute + expect(r.failed?).to be(true) + expect(r.status).to eq("failed") + end + + it "task with skip! returns skipped result" do + k = Class.new(CMDx::Task) do + def work + skip!("later", halt: true) + end + end + r = k.execute + expect(r.skipped?).to be(true) + expect(r.status).to eq("skipped") + end + + it "task with callbacks fires in correct order" do + log = [] + k = Class.new(CMDx::Task) do + required :v, :integer + before_validation { log << :bv } + before_execution { log << :be } + on_success { log << :os } + on_complete { log << :oc } + on_executed { log << :ex } + define_method(:work) { log << :w } + end + k.execute(v: 1) + expect(log).to eq(%i[bv be w os oc ex]) + end + + it "task with middleware wraps execution" do + mw = Module.new do + def self.call(task, *) + task.ctx[:mw] = true + yield + end + end + k = Class.new(CMDx::Task) do + register mw + def work; end + end + r = k.execute + expect(r.context[:mw]).to be(true) + end + + it "task with returns verifies context keys" do + k = Class.new(CMDx::Task) do + returns :token + def work; end + end + expect(k.execute.failed?).to be(true) + ok = Class.new(k) do + def work + ctx[:token] = "abc" + end + end + expect(ok.execute.success?).to be(true) + end + + it "task with retries retries on exception" do + tries = [0] + k = Class.new(CMDx::Task) do + settings do |s| + s.retry_count = 1 + s.retry_delay = 0 + s.retry_jitter = 0 + s.retry_on = [RuntimeError] + end + define_method(:work) do + tries[0] += 1 + raise "x" if tries[0] < 2 + end + end + r = k.execute + expect(r.success?).to be(true) + expect(r.retries).to eq(1) + end + + it "task with rollback calls rollback on failure" do + rb = [false] + k = Class.new(CMDx::Task) do + define_method(:rollback) { rb[0] = true } + def work + fail!("x", halt: true) + end + end + k.execute + expect(rb[0]).to be(true) + end + + it "execute! raises FailFault or SkipFault" do + f = Class.new(CMDx::Task) { def work = fail!("f", halt: true) } + expect { f.execute! }.to raise_error(CMDx::FailFault) + s = Class.new(CMDx::Task) { def work = skip!("s", halt: true) } + expect { s.execute! }.to raise_error(CMDx::SkipFault) + end + + it "dry_run: true in context is visible on result" do + k = Class.new(CMDx::Task) { def work; end } + r = k.execute(dry_run: true) + expect(r.dry_run?).to be(true) + end +end diff --git a/spec/integration/tasks/callbacks_spec.rb b/spec/integration/tasks/callbacks_spec.rb deleted file mode 100644 index 13f72c117..000000000 --- a/spec/integration/tasks/callbacks_spec.rb +++ /dev/null @@ -1,536 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe "Task callbacks", type: :feature do - context "when defining lifecycle callbacks" do - context "with before_validation" do - it "executes before attribute validation" do - task = create_task_class do - before_validation :track_validation - - required :value - - def work - context.executed = true - end - - private - - def track_validation - (context.callbacks ||= []) << :before_validation - end - end - - result = task.execute(value: "test") - - expect(result).to be_successful - expect(result).to have_matching_context(callbacks: [:before_validation], executed: true) - end - end - - context "with before_execution" do - it "executes before the work method" do - task = create_task_class do - before_execution :setup_data - - def work - (context.callbacks ||= []) << :work - end - - private - - def setup_data - (context.callbacks ||= []) << :before_execution - end - end - - result = task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(callbacks: %i[before_execution work]) - end - end - - context "with on_complete" do - it "executes after successful completion" do - task = create_task_class do - on_complete :track_complete - - def work - (context.callbacks ||= []) << :work - end - - private - - def track_complete - (context.callbacks ||= []) << :on_complete - end - end - - result = task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(callbacks: %i[work on_complete]) - end - end - - context "with on_interrupted" do - it "executes after interruption" do - task = create_task_class do - on_interrupted :track_interrupted - - def work - fail!("Test failure") - end - - private - - def track_interrupted - (context.callbacks ||= []) << :on_interrupted - end - end - - result = task.execute - - expect(result).to have_failed(reason: "Test failure") - expect(result).to have_matching_context(callbacks: [:on_interrupted]) - end - end - - context "with on_executed" do - it "executes after any outcome" do - success_task = create_task_class do - on_executed :track_executed - - def work - (context.callbacks ||= []) << :success - end - - private - - def track_executed - (context.callbacks ||= []) << :on_executed - end - end - - failed_task = create_task_class do - on_executed :track_executed - - def work - fail!("Test") - end - - private - - def track_executed - (context.callbacks ||= []) << :on_executed - end - end - - success_result = success_task.execute - failed_result = failed_task.execute - - expect(success_result).to be_successful - expect(success_result).to have_matching_context(callbacks: %i[success on_executed]) - - expect(failed_result).to have_failed(reason: "Test") - expect(failed_result).to have_matching_context(callbacks: [:on_executed]) - end - end - - context "with on_success" do - it "executes only on success" do - task = create_task_class do - on_success :track_success - - def work - (context.callbacks ||= []) << :work - end - - private - - def track_success - (context.callbacks ||= []) << :on_success - end - end - - result = task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(callbacks: %i[work on_success]) - end - - it "does not execute on failure" do - task = create_task_class do - on_success :track_success - - def work - fail!("Test") - end - - private - - def track_success - (context.callbacks ||= []) << :on_success - end - end - - result = task.execute - - expect(result).to have_failed(reason: "Test") - expect(result).to have_empty_context - end - end - - context "with on_skipped" do - it "executes only when skipped" do - task = create_task_class do - on_skipped :track_skipped - - def work - skip!("Test skip") - end - - private - - def track_skipped - (context.callbacks ||= []) << :on_skipped - end - end - - result = task.execute - - expect(result).to have_skipped(reason: "Test skip") - expect(result).to have_matching_context(callbacks: [:on_skipped]) - end - end - - context "with on_failed" do - it "executes only on failure" do - task = create_task_class do - on_failed :track_failed - - def work - fail!("Test failure") - end - - private - - def track_failed - (context.callbacks ||= []) << :on_failed - end - end - - result = task.execute - - expect(result).to have_failed(reason: "Test failure") - expect(result).to have_matching_context(callbacks: [:on_failed]) - end - end - - context "with on_good" do - it "executes on success" do - task = create_task_class do - on_good :track_good - - def work - (context.callbacks ||= []) << :work - end - - private - - def track_good - (context.callbacks ||= []) << :on_good - end - end - - result = task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(callbacks: %i[work on_good]) - end - - it "executes when skipped" do - task = create_task_class do - on_good :track_good - - def work - skip!("Test") - end - - private - - def track_good - (context.callbacks ||= []) << :on_good - end - end - - result = task.execute - - expect(result).to have_skipped(reason: "Test") - expect(result).to have_matching_context(callbacks: [:on_good]) - end - end - - context "with on_bad" do - it "executes when skipped" do - task = create_task_class do - on_bad :track_bad - - def work - skip!("Test") - end - - private - - def track_bad - (context.callbacks ||= []) << :on_bad - end - end - - result = task.execute - - expect(result).to have_skipped(reason: "Test") - expect(result).to have_matching_context(callbacks: [:on_bad]) - end - - it "executes on failure" do - task = create_task_class do - on_bad :track_bad - - def work - fail!("Test") - end - - private - - def track_bad - (context.callbacks ||= []) << :on_bad - end - end - - result = task.execute - - expect(result).to have_failed(reason: "Test") - expect(result).to have_matching_context(callbacks: [:on_bad]) - end - end - end - - context "when using proc or lambda callbacks" do - it "executes proc callbacks" do - task = create_task_class do - before_execution proc { (context.callbacks ||= []) << :before_proc } - on_complete proc { (context.callbacks ||= []) << :complete_proc } - - def work = nil - end - - result = task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(callbacks: %i[before_proc complete_proc]) - end - - it "executes lambda callbacks" do - task = create_task_class do - before_execution -> { (context.callbacks ||= []) << :before_lambda } - on_complete -> { (context.callbacks ||= []) << :complete_lambda } - - def work = nil - end - - result = task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(callbacks: %i[before_lambda complete_lambda]) - end - - it "executes class-based callbacks" do - setup_callback = Class.new do - def call(task) - (task.context.callbacks ||= []) << :before_class - end - end - - complete_callback = Class.new do - def call(task) - (task.context.callbacks ||= []) << :complete_class - end - end - - task = create_task_class do - before_execution setup_callback.new - on_complete complete_callback.new - - def work = nil - end - - result = task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(callbacks: %i[before_class complete_class]) - end - end - - context "when using conditional callbacks" do - it "executes callbacks with if condition" do - task = create_task_class do - before_execution :setup_data, if: :should_setup? - - def work - (context.callbacks ||= []) << :work - end - - private - - def setup_data - (context.callbacks ||= []) << :before_execution - end - - def should_setup? - context.enable_setup == true - end - end - - enabled_result = task.execute(enable_setup: true) - disabled_result = task.execute(enable_setup: false) - - expect(enabled_result).to be_successful - expect(enabled_result).to have_matching_context(callbacks: %i[before_execution work]) - - expect(disabled_result).to be_successful - expect(disabled_result).to have_matching_context(callbacks: [:work]) - end - - it "executes callbacks with unless condition" do - task = create_task_class do - before_execution :setup_data, unless: :skip_setup? - - def work - (context.callbacks ||= []) << :work - end - - private - - def setup_data - (context.callbacks ||= []) << :before_execution - end - - def skip_setup? - context.skip_setup == true - end - end - - enabled_result = task.execute(skip_setup: false) - disabled_result = task.execute(skip_setup: true) - - expect(enabled_result).to be_successful - expect(enabled_result).to have_matching_context(callbacks: %i[before_execution work]) - - expect(disabled_result).to be_successful - expect(disabled_result).to have_matching_context(callbacks: [:work]) - end - - it "executes callbacks with combined if and unless" do - task = create_task_class do - before_execution :setup_data, if: :should_setup?, unless: :override_setup? - - def work - (context.callbacks ||= []) << :work - end - - private - - def setup_data - (context.callbacks ||= []) << :before_execution - end - - def should_setup? - context.enable_setup == true - end - - def override_setup? - context.override == true - end - end - - both_true = task.execute(enable_setup: true, override: true) - if_true_unless_false = task.execute(enable_setup: true, override: false) - - expect(both_true).to be_successful - expect(both_true).to have_matching_context(callbacks: [:work]) - - expect(if_true_unless_false).to be_successful - expect(if_true_unless_false).to have_matching_context(callbacks: %i[before_execution work]) - end - end - - context "when executing multiple callbacks" do - it "executes callbacks in declaration order (FIFO)" do - task = create_task_class do - before_execution :first_setup - before_execution :second_setup - on_complete :first_complete - on_complete :second_complete - - def work - (context.callbacks ||= []) << :work - end - - private - - def first_setup - (context.callbacks ||= []) << :first_setup - end - - def second_setup - (context.callbacks ||= []) << :second_setup - end - - def first_complete - (context.callbacks ||= []) << :first_complete - end - - def second_complete - (context.callbacks ||= []) << :second_complete - end - end - - result = task.execute - - expect(result).to be_successful - expect(result).to have_matching_context( - callbacks: %i[first_setup second_setup work first_complete second_complete] - ) - end - end - - context "when removing callbacks" do - it "removes symbol callbacks" do - parent_task = create_task_class(name: "ParentTask") do - before_execution :setup_data - - def work - (context.callbacks ||= []) << :work - end - - private - - def setup_data - (context.callbacks ||= []) << :before_execution - end - end - - child_task = create_task_class(base: parent_task, name: "ChildTask") do - deregister :callback, :before_execution, :setup_data - end - - result = child_task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(callbacks: [:work]) - end - end -end diff --git a/spec/integration/tasks/execution_spec.rb b/spec/integration/tasks/execution_spec.rb deleted file mode 100644 index ed459ca28..000000000 --- a/spec/integration/tasks/execution_spec.rb +++ /dev/null @@ -1,815 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe "Task execution", type: :feature do - context "when non-bang" do - subject(:result) { task.execute } - - context "when simple task" do - context "when successful" do - let(:task) { create_successful_task } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[success]) - end - end - - context "when skipping" do - let(:task) { create_skipping_task } - - it "returns skipped" do - expect(result).to have_skipped - expect(result).to have_empty_context - end - end - - context "when failing" do - let(:task) { create_failing_task } - - it "returns failure" do - expect(result).to have_failed - expect(result).to have_empty_context - end - end - - context "when erroring" do - let(:task) { create_erroring_task } - - it "returns failure" do - expect(result).to have_failed( - reason: "[CMDx::TestError] borked error", - cause: be_a(CMDx::TestError) - ) - expect(result).to have_empty_context - end - end - end - - context "with nested tasks" do - context "when swallowing" do - context "when successful" do - let(:task) { create_nested_task } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[inner middle outer]) - end - end - - context "when skipping" do - let(:task) { create_nested_task(status: :skipped) } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[middle outer]) - end - end - - context "when failing" do - let(:task) { create_nested_task(status: :failure) } - - it "returns failure" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[middle outer]) - end - end - - context "when erroring" do - let(:task) { create_nested_task(status: :error) } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[middle outer]) - end - end - end - - context "when throwing" do - context "when successful" do - let(:task) { create_nested_task(strategy: :throw) } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[inner middle outer]) - end - end - - context "when skipping" do - let(:task) { create_nested_task(strategy: :throw, status: :skipped) } - - it "returns skipped" do - expect(result).to have_skipped - expect(result).to have_empty_context - end - end - - context "when failing" do - let(:task) { create_nested_task(strategy: :throw, status: :failure) } - - it "returns failure" do - expect(result).to have_failed( - outcome: CMDx::Result::INTERRUPTED, - threw_failure: hash_including( - index: 1, - class: start_with("MiddleTask") - ), - caused_failure: hash_including( - index: 2, - class: start_with("InnerTask") - ) - ) - expect(result).to have_empty_context - end - end - - context "when erroring" do - let(:task) { create_nested_task(strategy: :throw, status: :error) } - - it "returns failure" do - expect(result).to have_failed( - outcome: CMDx::Result::INTERRUPTED, - reason: "[CMDx::TestError] borked error", - threw_failure: hash_including( - index: 1, - class: start_with("MiddleTask") - ), - caused_failure: hash_including( - index: 2, - class: start_with("InnerTask") - ) - ) - expect(result).to have_empty_context - end - end - end - - context "when raising" do - context "when successful" do - let(:task) { create_nested_task(strategy: :raise) } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[inner middle outer]) - end - end - - context "when skipping" do - let(:task) { create_nested_task(strategy: :raise, status: :skipped) } - - it "returns skipped" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[middle outer]) - end - end - - context "when failing" do - let(:task) { create_nested_task(strategy: :raise, status: :failure) } - - it "returns failure" do - expect(result).to have_failed( - outcome: CMDx::Result::INTERRUPTED, - threw_failure: hash_including( - index: 1, - class: start_with("MiddleTask") - ), - caused_failure: hash_including( - index: 2, - class: start_with("InnerTask") - ) - ) - expect(result).to have_empty_context - end - end - - context "when erroring" do - let(:task) { create_nested_task(strategy: :raise, status: :error) } - - it "returns failure" do - expect(result).to have_failed( - outcome: CMDx::Result::INTERRUPTED, - reason: "[CMDx::TestError] borked error", - cause: be_a(CMDx::TestError), - threw_failure: hash_including( - index: 1, - class: start_with("MiddleTask") - ), - caused_failure: hash_including( - index: 2, - class: start_with("InnerTask") - ) - ) - expect(result).to have_empty_context - end - end - end - - context "when throw to raise" do - context "when successful" do - let(:task) { create_nested_task(strategy: :throw_raise) } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[inner middle outer]) - end - end - - context "when skipping" do - let(:task) { create_nested_task(strategy: :throw_raise, status: :skipped) } - - it "returns skipped" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[middle outer]) - end - end - - context "when failing" do - let(:task) { create_nested_task(strategy: :throw_raise, status: :failure) } - - it "returns failure" do - expect(result).to have_failed( - outcome: CMDx::Result::INTERRUPTED, - threw_failure: hash_including( - index: 1, - class: start_with("MiddleTask") - ), - caused_failure: hash_including( - index: 2, - class: start_with("InnerTask") - ) - ) - expect(result).to have_empty_context - end - end - - context "when erroring" do - let(:task) { create_nested_task(strategy: :throw_raise, status: :error) } - - it "returns failure" do - expect(result).to have_failed( - outcome: CMDx::Result::INTERRUPTED, - reason: "[CMDx::TestError] borked error", - cause: be_a(CMDx::FailFault), - threw_failure: hash_including( - index: 1, - class: start_with("MiddleTask") - ), - caused_failure: hash_including( - index: 2, - class: start_with("InnerTask") - ) - ) - expect(result).to have_empty_context - end - end - end - - context "when raise to throw" do - context "when successful" do - let(:task) { create_nested_task(strategy: :raise_throw) } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[inner middle outer]) - end - end - - context "when skipping" do - let(:task) { create_nested_task(strategy: :raise_throw, status: :skipped) } - - it "returns skipped" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[outer]) - end - end - - context "when failing" do - let(:task) { create_nested_task(strategy: :raise_throw, status: :failure) } - - it "returns failure" do - expect(result).to have_failed( - outcome: CMDx::Result::INTERRUPTED, - threw_failure: hash_including( - index: 1, - class: start_with("MiddleTask") - ), - caused_failure: hash_including( - index: 2, - class: start_with("InnerTask") - ) - ) - expect(result).to have_empty_context - end - end - - context "when erroring" do - let(:task) { create_nested_task(strategy: :raise_throw, status: :error) } - - it "returns failure" do - expect(result).to have_failed( - outcome: CMDx::Result::INTERRUPTED, - reason: "[CMDx::TestError] borked error", - cause: be_a(CMDx::FailFault), - threw_failure: hash_including( - index: 1, - class: start_with("MiddleTask") - ), - caused_failure: hash_including( - index: 2, - class: start_with("InnerTask") - ) - ) - expect(result).to have_empty_context - end - end - end - end - end - - context "when bang" do - subject(:result) { task.execute! } - - context "when simple task" do - context "when successful" do - let(:task) { create_successful_task } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[success]) - end - end - - context "when skipping" do - let(:task) { create_skipping_task } - - it "returns skipped" do - expect(result).to have_skipped - expect(result).to have_empty_context - end - end - - context "when failing" do - let(:task) { create_failing_task } - - it "raise a CMDx::FailFault" do - expect { result }.to raise_error(CMDx::FailFault, "Unspecified") - end - end - - context "when erroring" do - let(:task) { create_erroring_task } - - it "raise a CMDx::TestError" do - expect { result }.to raise_error(CMDx::TestError, "borked error") - end - end - end - - context "with nested tasks" do - context "when swallowing" do - context "when successful" do - let(:task) { create_nested_task } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[inner middle outer]) - end - end - - context "when skipping" do - let(:task) { create_nested_task(status: :skipped) } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[middle outer]) - end - end - - context "when failing" do - let(:task) { create_nested_task(status: :failure) } - - it "returns failure" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[middle outer]) - end - end - - context "when erroring" do - let(:task) { create_nested_task(status: :error) } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[middle outer]) - end - end - end - - context "when throwing" do - context "when successful" do - let(:task) { create_nested_task(strategy: :throw) } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[inner middle outer]) - end - end - - context "when skipping" do - let(:task) { create_nested_task(strategy: :throw, status: :skipped) } - - it "returns skipped" do - expect(result).to have_skipped - expect(result).to have_empty_context - end - end - - context "when failing" do - let(:task) { create_nested_task(strategy: :throw, status: :failure) } - - it "raise a CMDx::FailFault" do - expect { result }.to raise_error(CMDx::FailFault, "Unspecified") - end - end - - context "when erroring" do - let(:task) { create_nested_task(strategy: :throw, status: :error) } - - it "raise a CMDx::FailFault" do - expect { result }.to raise_error(CMDx::FailFault, "[CMDx::TestError] borked error") - end - end - end - - context "when raising" do - context "when successful" do - let(:task) { create_nested_task(strategy: :raise) } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[inner middle outer]) - end - end - - context "when skipping" do - let(:task) { create_nested_task(strategy: :raise, status: :skipped) } - - it "returns skipped" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[middle outer]) - end - end - - context "when failing" do - let(:task) { create_nested_task(strategy: :raise, status: :failure) } - - it "raise a CMDx::FailFault" do - expect { result }.to raise_error(CMDx::FailFault, "Unspecified") - end - end - - context "when erroring" do - let(:task) { create_nested_task(strategy: :raise, status: :error) } - - it "raise a CMDx::TestError" do - expect { result }.to raise_error(CMDx::TestError, "borked error") - end - end - end - - context "when throw to raise" do - context "when successful" do - let(:task) { create_nested_task(strategy: :throw_raise) } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[inner middle outer]) - end - end - - context "when skipping" do - let(:task) { create_nested_task(strategy: :throw_raise, status: :skipped) } - - it "returns skipped" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[middle outer]) - end - end - - context "when failing" do - let(:task) { create_nested_task(strategy: :throw_raise, status: :failure) } - - it "raise a CMDx::FailFault" do - expect { result }.to raise_error(CMDx::FailFault, "Unspecified") - end - end - - context "when erroring" do - let(:task) { create_nested_task(strategy: :throw_raise, status: :error) } - - it "raise a CMDx::FailFault" do - expect { result }.to raise_error(CMDx::FailFault, "[CMDx::TestError] borked error") - end - end - end - - context "when raise to throw" do - context "when successful" do - let(:task) { create_nested_task(strategy: :raise_throw) } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[inner middle outer]) - end - end - - context "when skipping" do - let(:task) { create_nested_task(strategy: :raise_throw, status: :skipped) } - - it "returns skipped" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[outer]) - end - end - - context "when failing" do - let(:task) { create_nested_task(strategy: :raise_throw, status: :failure) } - - it "raise a CMDx::FailFault" do - expect { result }.to raise_error(CMDx::FailFault, "Unspecified") - end - end - - context "when erroring" do - let(:task) { create_nested_task(strategy: :raise_throw, status: :error) } - - it "raise a CMDx::FailFault" do - expect { result }.to raise_error(CMDx::FailFault, "[CMDx::TestError] borked error") - end - end - end - end - end - - context "when inheriting" do - context "when assuming the work method" do - it "captures the execution order" do - parent_task = create_task_class(name: "ParentTask") do - def work = (context.executed ||= []) << :parent - end - child_task = create_task_class(base: parent_task, name: "ChildTask") - - result = child_task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[parent]) - end - end - - context "when overriding the work method" do - it "captures the execution order" do - parent_task = create_task_class(name: "ParentTask") do - def work = (context.executed ||= []) << :parent - end - child_task = create_task_class(base: parent_task, name: "ChildTask") do - def work = (context.executed ||= []) << :child - end - - result = child_task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[child]) - end - end - - context "when super-ing the work method" do - it "captures the execution order" do - parent_task = create_task_class(name: "ParentTask") do - def work = (context.executed ||= []) << :parent - end - child_task = create_task_class(base: parent_task, name: "ChildTask") do - def work - super - (context.executed ||= []) << :child - end - end - - result = child_task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[parent child]) - end - end - end - - context "when using block" do - context "when class method" do - let(:task) { create_successful_task } - - it "yields the result" do - task.execute do |result| - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[success]) - end - end - end - - context "when instance method" do - let(:task) { create_successful_task.new } - - it "yields the result" do - task.execute do |result| - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[success]) - end - end - end - end - - describe "durability" do - context "with any exception" do - it "retries the task n times after first issue without rerunning the middlewares" do - counter = instance_double("counter", incr: nil) - - task = create_task_class do - settings retries: 2 - register :middleware, CMDx::Middlewares::Correlate, id: proc { - counter.incr - "abc-123" - } - - def work - context.retries ||= 0 - context.retries += 1 - raise CMDx::TestError, "borked error" unless self.class.settings.retries < context.retries - - (context.executed ||= []) << :success - end - end - - expect(counter).to receive(:incr).once - - result = task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(retries: 3, executed: %i[success]) - expect(result.retries).to eq(2) - end - end - - context "with a specific exception" do - it "skips retries if the exception is not in the retry_on setting" do - counter = instance_double("counter", incr: nil) - - task = create_task_class do - settings retries: 2, retry_on: RuntimeError - register :middleware, CMDx::Middlewares::Correlate, id: proc { - counter.incr - "abc-123" - } - - def work - context.retries ||= 0 - context.retries += 1 - raise CMDx::TestError, "borked error" unless self.class.settings.retries < context.retries - - (context.executed ||= []) << :success - end - end - - expect(counter).to receive(:incr).once - - result = task.execute - - expect(result).to have_failed( - reason: "[CMDx::TestError] borked error", - cause: be_a(CMDx::TestError) - ) - expect(result).to have_matching_context(retries: 1) - expect(result).to have_matching_metadata(source: :exception) - end - end - end - - describe "rollback" do - context "when rollback is configured" do - it "calls rollback on failure" do - task = create_task_class do - settings rollback_on: :failed - - def work - fail!("something went wrong") - end - - def rollback - (context.rolled_back ||= []) << :yes - end - end - - result = task.execute - - expect(result).to have_failed(reason: "something went wrong") - expect(result).to be_rolled_back - expect(result.context.rolled_back).to eq([:yes]) - end - - it "calls rollback on skip if configured" do - task = create_task_class do - settings rollback_on: %i[failed skipped] - - def work - skip!("skipping") - end - - def rollback - (context.rolled_back ||= []) << :yes - end - end - - result = task.execute - - expect(result).to have_skipped(reason: "skipping") - expect(result).to be_rolled_back - expect(result.context.rolled_back).to eq([:yes]) - end - end - - context "when rollback is explicitly disabled" do - it "does not call rollback" do - task = create_task_class do - settings rollback_on: [] - - def work - fail!("something went wrong") - end - - def rollback - (context.rolled_back ||= []) << :yes - end - end - - result = task.execute - - expect(result).to have_failed(reason: "something went wrong") - expect(result).not_to be_rolled_back - expect(result.context.rolled_back).to be_nil - end - end - - context "when rollback is configured but status does not match" do - it "does not call rollback" do - task = create_task_class do - settings rollback_on: %i[failed] - - def work - skip!("skipping") - end - - def rollback - (context.rolled_back ||= []) << :yes - end - end - - result = task.execute - - expect(result).to have_skipped(reason: "skipping") - expect(result).not_to be_rolled_back - expect(result.context.rolled_back).to be_nil - end - end - end - - describe "dry run" do - context "when enabled" do - it "allows conditional logic execution" do - task = create_task_class do - def work - context.result = dry_run? ? "mocked" : "real" - end - end - - result = task.execute(dry_run: true) - - expect(result).to be_successful - expect(result).to be_dry_run - expect(result.context.result).to eq("mocked") - end - end - - context "when disabled" do - it "executes real logic" do - task = create_task_class do - def work - context.result = dry_run? ? "mocked" : "real" - end - end - - result = task.execute - - expect(result).to be_successful - expect(result).not_to be_dry_run - expect(result.context.result).to eq("real") - end - end - end -end diff --git a/spec/integration/tasks/handlers_spec.rb b/spec/integration/tasks/handlers_spec.rb deleted file mode 100644 index b561b0c8a..000000000 --- a/spec/integration/tasks/handlers_spec.rb +++ /dev/null @@ -1,332 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe "Task handlers", type: :feature do - context "when using status-based handlers" do - it "executes on_success handler" do - task = create_successful_task - - result = task.execute - handled = [] - - result.on(:success) { handled << :on_success } - - expect(handled).to eq([:on_success]) - end - - it "executes on_failed handler" do - task = create_failing_task(reason: "Test failure") - - result = task.execute - handled = [] - - result.on(:failed) { handled << :on_failed } - - expect(handled).to eq([:on_failed]) - end - - it "executes on_skipped handler" do - task = create_skipping_task(reason: "Test skip") - - result = task.execute - handled = [] - - result.on(:skipped) { handled << :on_skipped } - - expect(handled).to eq([:on_skipped]) - end - - it "only executes the matching handler" do - success_task = create_successful_task - failed_task = create_failing_task - - success_result = success_task.execute - failed_result = failed_task.execute - - success_handled = [] - failed_handled = [] - - success_result - .on(:success) { success_handled << :success } - .on(:failed) { success_handled << :failed } - .on(:skipped) { success_handled << :skipped } - - failed_result - .on(:success) { failed_handled << :success } - .on(:failed) { failed_handled << :failed } - .on(:skipped) { failed_handled << :skipped } - - expect(success_handled).to eq([:success]) - expect(failed_handled).to eq([:failed]) - end - end - - context "when using state-based handlers" do - it "executes on_complete handler" do - task = create_successful_task - - result = task.execute - handled = [] - - result.on(:complete) { handled << :on_complete } - - expect(handled).to eq([:on_complete]) - end - - it "executes on_interrupted handler" do - task = create_failing_task - - result = task.execute - handled = [] - - result.on(:interrupted) { handled << :on_interrupted } - - expect(handled).to eq([:on_interrupted]) - end - - it "only executes the matching state handler" do - complete_task = create_successful_task - interrupted_task = create_failing_task - - complete_result = complete_task.execute - interrupted_result = interrupted_task.execute - - complete_handled = [] - interrupted_handled = [] - - complete_result - .on(:complete) { complete_handled << :complete } - .on(:interrupted) { complete_handled << :interrupted } - - interrupted_result - .on(:complete) { interrupted_handled << :complete } - .on(:interrupted) { interrupted_handled << :interrupted } - - expect(complete_handled).to eq([:complete]) - expect(interrupted_handled).to eq([:interrupted]) - end - end - - context "when using outcome-based handlers" do - it "executes on_good for success" do - task = create_successful_task - - result = task.execute - handled = [] - - result.on(:good) { handled << :on_good } - - expect(handled).to eq([:on_good]) - end - - it "executes on_good for skipped" do - task = create_skipping_task - - result = task.execute - handled = [] - - result.on(:good) { handled << :on_good } - - expect(handled).to eq([:on_good]) - end - - it "does not execute on_good for failed" do - task = create_failing_task - - result = task.execute - handled = [] - - result.on(:good) { handled << :on_good } - - expect(handled).to be_empty - end - - it "executes on_bad for skipped" do - task = create_skipping_task - - result = task.execute - handled = [] - - result.on(:bad) { handled << :on_bad } - - expect(handled).to eq([:on_bad]) - end - - it "executes on_bad for failed" do - task = create_failing_task - - result = task.execute - handled = [] - - result.on(:bad) { handled << :on_bad } - - expect(handled).to eq([:on_bad]) - end - - it "does not execute on_bad for success" do - task = create_successful_task - - result = task.execute - handled = [] - - result.on(:bad) { handled << :on_bad } - - expect(handled).to be_empty - end - end - - context "when chaining handlers" do - it "allows method chaining" do - task = create_successful_task - - result = task.execute - handled = [] - - result - .on(:success) { handled << :success } - .on(:complete) { handled << :complete } - .on(:good) { handled << :good } - - expect(handled).to eq(%i[success complete good]) - end - - it "chains handlers regardless of outcome" do - task = create_failing_task - - result = task.execute - handled = [] - - result - .on(:success) { handled << :success } - .on(:failed) { handled << :failed } - .on(:complete) { handled << :complete } - .on(:interrupted) { handled << :interrupted } - .on(:good) { handled << :good } - .on(:bad) { handled << :bad } - - expect(handled).to eq(%i[failed interrupted bad]) - end - end - - context "when accessing result data in handlers" do - it "provides access to result in handler" do - task = create_task_class do - def work - context.value = 42 - end - end - - result = task.execute - captured_value = nil - - result.on(:success) { |r| captured_value = r.context.value } - - expect(captured_value).to eq(42) - end - - it "provides access to metadata in handler" do - task = create_task_class do - def work - fail!("Test failure", error_code: "TEST.FAILED", retry_count: 3) - end - end - - result = task.execute - captured_metadata = nil - - result.on(:failed) { |r| captured_metadata = r.metadata } - - expect(captured_metadata).to include(error_code: "TEST.FAILED", retry_count: 3) - end - - it "provides access to task in handler" do - task = create_task_class(name: "TestTask") do - def work = nil - end - - result = task.execute - captured_task_class = nil - - result.on(:success) { |r| captured_task_class = r.task.class.name } - - expect(captured_task_class).to match(/TestTask/) - end - end - - context "when using handlers for control flow" do - it "uses handlers for conditional logic" do - success_task = create_successful_task - failed_task = create_failing_task - - success_outcome = nil - failed_outcome = nil - - success_task - .execute - .on(:success) { success_outcome = "success_path" } - .on(:failed) { success_outcome = "failure_path" } - - failed_task - .execute - .on(:success) { failed_outcome = "success_path" } - .on(:failed) { failed_outcome = "failure_path" } - - expect(success_outcome).to eq("success_path") - expect(failed_outcome).to eq("failure_path") - end - - it "uses handlers for side effects" do - task = create_task_class do - def work - context.value = 100 - end - end - - notifications = [] - - task - .execute - .on(:success) { |r| notifications << "Processed: #{r.context.value}" } - .on(:complete) { notifications << "Completed" } - - expect(notifications).to eq(["Processed: 100", "Completed"]) - end - end - - context "when combining with block execution" do - it "allows using both block and handlers" do - task = create_successful_task - - block_executed = false - handler_executed = false - captured_result = nil - - task.execute do |r| - block_executed = true - captured_result = r - end - - captured_result.on(:success) { handler_executed = true } - - expect(block_executed).to be(true) - expect(handler_executed).to be(true) - end - end - - context "when using handlers for cleanup" do - it "executes cleanup on any outcome" do - success_task = create_successful_task - failed_task = create_failing_task - skipped_task = create_skipping_task - - cleanup_count = 0 - - success_task.execute.on(:good) { cleanup_count += 1 } - failed_task.execute.on(:bad) { cleanup_count += 1 } - skipped_task.execute.on(:bad) { cleanup_count += 1 } - - expect(cleanup_count).to eq(3) - end - end -end diff --git a/spec/integration/tasks/middlewares_spec.rb b/spec/integration/tasks/middlewares_spec.rb deleted file mode 100644 index b9e79f696..000000000 --- a/spec/integration/tasks/middlewares_spec.rb +++ /dev/null @@ -1,56 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe "Task middlewares", type: :feature do - context "when using correlate middleware" do - it "assigns a correlation ID to the result metadata" do - task = create_successful_task do - register :middleware, CMDx::Middlewares::Correlate, id: proc { "abc-123" } - end - - result = task.execute - - expect(result.metadata[:correlation_id]).to eq("abc-123") - end - - it "resuses the correlation ID from the outer task" do - inner_task = create_successful_task(name: "InnerTask") do - register :middleware, CMDx::Middlewares::Correlate, id: proc { "abc-456" } - end - outer_task = create_task_class(name: "OuterTask") do - register :middleware, CMDx::Middlewares::Correlate, id: proc { "abc-123" } - end - outer_task.define_method(:work) { context.inner_result = inner_task.execute(context) } - - outer_result = outer_task.execute - inner_result = outer_result.context.inner_result - - expect(inner_result.metadata[:correlation_id]).to eq("abc-123") - expect(outer_result.metadata[:correlation_id]).to eq("abc-123") - end - end - - context "when using runtime middleware" do - it "assigns the runtime to the result metadata" do - task = create_successful_task do - register :middleware, CMDx::Middlewares::Runtime - end - - result = task.execute - - expect(result.metadata[:runtime]).to be_a(Integer) - end - end - - context "when using timeout middleware" do - it "raises a failure fault" do - task = create_task_class do - register :middleware, CMDx::Middlewares::Timeout, seconds: 0.001 - end - task.define_method(:work) { sleep(0.002) } - - expect { task.execute }.to raise_error(CMDx::FailFault, "[CMDx::TimeoutError] execution exceeded 0.001 seconds") - end - end -end diff --git a/spec/integration/tasks/returns_spec.rb b/spec/integration/tasks/returns_spec.rb deleted file mode 100644 index ec0ec9256..000000000 --- a/spec/integration/tasks/returns_spec.rb +++ /dev/null @@ -1,346 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe "Task returns", type: :feature do - context "when declaring" do - context "with no returns" do - it "does not validate returns" do - task = create_task_class do - def work - (context.executed ||= []) << :success - end - end - - result = task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[success]) - end - end - - context "with single return" do - context "when return is present" do - it "returns success" do - task = create_task_class do - returns :user - - def work - context.user = "John" - end - end - - result = task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(user: "John") - end - end - - context "when return is missing" do - it "returns failure" do - task = create_task_class do - returns :user - - def work - (context.executed ||= []) << :success - end - end - - result = task.execute - - expect(result).to have_failed( - reason: "Invalid", - cause: be_a(CMDx::FailFault) - ) - expect(result).to have_matching_metadata( - errors: { - full_message: "user must be set in the context", - messages: { user: ["must be set in the context"] } - } - ) - end - end - end - - context "with multiple returns" do - context "when all returns are present" do - it "returns success" do - task = create_task_class do - returns :user, :token - - def work - context.user = "John" - context.token = "abc123" - end - end - - result = task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(user: "John", token: "abc123") - end - end - - context "when some returns are missing" do - it "returns failure with all missing returns" do - task = create_task_class do - returns :user, :token - - def work - context.user = "John" - end - end - - result = task.execute - - expect(result).to have_failed( - reason: "Invalid", - cause: be_a(CMDx::FailFault) - ) - expect(result).to have_matching_metadata( - errors: { - full_message: "token must be set in the context", - messages: { token: ["must be set in the context"] } - } - ) - end - end - - context "when all returns are missing" do - it "returns failure with all missing returns" do - task = create_task_class do - returns :user, :token - - def work = nil - end - - result = task.execute - - expect(result).to have_failed( - reason: "Invalid", - cause: be_a(CMDx::FailFault) - ) - expect(result).to have_matching_metadata( - errors: { - full_message: "user must be set in the context. token must be set in the context", - messages: { - user: ["must be set in the context"], - token: ["must be set in the context"] - } - } - ) - end - end - end - end - - context "when task skips" do - it "does not validate returns" do - task = create_task_class do - returns :user - - def work - skip!("not needed") - end - end - - result = task.execute - - expect(result).to have_skipped(reason: "not needed") - end - end - - context "when task fails" do - it "does not validate returns" do - task = create_task_class do - returns :user - - def work - fail!("something went wrong") - end - end - - result = task.execute - - expect(result).to have_failed(reason: "something went wrong") - end - end - - context "when using bang execution" do - context "when return is missing" do - it "raises a CMDx::FailFault" do - task = create_task_class do - returns :user - - def work = nil - end - - expect { task.execute! }.to raise_error(CMDx::FailFault, "Invalid") - end - end - - context "when return is present" do - it "returns success" do - task = create_task_class do - returns :user - - def work - context.user = "John" - end - end - - result = task.execute! - - expect(result).to be_successful - expect(result).to have_matching_context(user: "John") - end - end - end - - context "when inheriting" do - it "inherits parent returns" do - parent_task = create_task_class(name: "ParentTask") do - returns :user - - def work - context.user = "John" - end - end - child_task = create_task_class(base: parent_task, name: "ChildTask") do - returns :token - - def work - super - context.token = "abc123" - end - end - - result = child_task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(user: "John", token: "abc123") - end - - it "fails when inherited return is missing" do - parent_task = create_task_class(name: "ParentTask") do - returns :user - - def work - context.user = "John" - end - end - child_task = create_task_class(base: parent_task, name: "ChildTask") do - returns :token - end - - result = child_task.execute - - expect(result).to have_failed( - reason: "Invalid", - cause: be_a(CMDx::FailFault) - ) - expect(result).to have_matching_metadata( - errors: { - full_message: "token must be set in the context", - messages: { token: ["must be set in the context"] } - } - ) - end - end - - context "when removing returns" do - it "removes inherited returns" do - parent_task = create_task_class(name: "ParentTask") do - returns :user, :token - - def work - context.user = "John" - context.token = "abc123" - end - end - child_task = create_task_class(base: parent_task, name: "ChildTask") do - remove_return :token - - def work - context.user = "John" - end - end - - result = child_task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(user: "John") - end - end - - context "with attributes and returns" do - context "when attribute validation fails" do - it "does not validate returns" do - task = create_task_class do - required :name - returns :user - - def work - context.user = name - end - end - - result = task.execute - - expect(result).to have_failed( - reason: "Invalid", - cause: be_a(CMDx::FailFault) - ) - expect(result).to have_matching_metadata( - errors: { - full_message: "name must be accessible via the context source method", - messages: { name: ["must be accessible via the context source method"] } - } - ) - end - end - - context "when attribute validation passes but return is missing" do - it "fails due to missing return" do - task = create_task_class do - required :name - returns :user - - def work = nil - end - - result = task.execute(name: "John") - - expect(result).to have_failed( - reason: "Invalid", - cause: be_a(CMDx::FailFault) - ) - expect(result).to have_matching_metadata( - errors: { - full_message: "user must be set in the context", - messages: { user: ["must be set in the context"] } - } - ) - end - end - - context "when both attribute and return are valid" do - it "returns success" do - task = create_task_class do - required :name - returns :user - - def work - context.user = { name: name } - end - end - - result = task.execute(name: "John") - - expect(result).to be_successful - expect(result).to have_matching_context(user: { name: "John" }) - end - end - end -end diff --git a/spec/integration/workflows/breakpoints_spec.rb b/spec/integration/workflows/breakpoints_spec.rb deleted file mode 100644 index 53c009e43..000000000 --- a/spec/integration/workflows/breakpoints_spec.rb +++ /dev/null @@ -1,190 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe "Workflow breakpoints", type: :feature do - context "with default breakpoints" do - it "continues on skipped tasks" do - task1 = create_successful_task(name: "Task1") - task2 = create_skipping_task(name: "Task2", reason: "Skipped") - task3 = create_successful_task(name: "Task3") - - workflow = create_workflow_class do - task task1 - task task2 - task task3 - end - - result = workflow.new.execute - - expect(result).to be_successful - expect(result.chain.results.size).to eq(4) - expect(result.chain.results[2].skipped?).to be(true) - end - - it "halts on failed tasks" do - task1 = create_successful_task(name: "Task1") - task2 = create_failing_task(name: "Task2", reason: "Failed") - task3 = create_successful_task(name: "Task3") - - workflow = create_workflow_class do - task task1 - task task2 - task task3 - end - - result = workflow.new.execute - - expect(result).to have_failed(reason: "Failed", outcome: "interrupted") - expect(result.chain.results.size).to eq(3) - expect(result.chain.results[2].failed?).to be(true) - end - end - - context "with custom workflow breakpoints" do - it "halts on both skipped and failed" do - task1 = create_successful_task(name: "Task1") - task2 = create_skipping_task(name: "Task2", reason: "Skipped") - task3 = create_successful_task(name: "Task3") - - workflow = create_workflow_class do - settings(workflow_breakpoints: %w[skipped failed]) - - task task1 - task task2 - task task3 - end - - result = workflow.new.execute - - expect(result).to have_skipped(reason: "Skipped") - expect(result.chain.results.size).to eq(3) - end - - it "never halts with empty breakpoints" do - task1 = create_successful_task(name: "Task1") - task2 = create_failing_task(name: "Task2", reason: "Failed") - task3 = create_skipping_task(name: "Task3", reason: "Skipped") - task4 = create_successful_task(name: "Task4") - - workflow = create_workflow_class do - settings(workflow_breakpoints: []) - - task task1 - task task2 - task task3 - task task4 - end - - result = workflow.new.execute - - expect(result).to be_successful - expect(result.chain.results.size).to eq(5) - end - end - - context "with group-level breakpoints" do - it "applies different breakpoints to different groups" do - critical_task1 = create_successful_task(name: "CriticalTask1") - critical_task2 = create_skipping_task(name: "CriticalTask2", reason: "Skipped") - optional_task1 = create_successful_task(name: "OptionalTask1") - optional_task2 = create_failing_task(name: "OptionalTask2", reason: "Failed") - - workflow = create_workflow_class do - tasks critical_task1, - critical_task2, - workflow_breakpoints: %w[skipped failed] - - tasks optional_task1, - optional_task2, - breakpoints: [] - end - - result = workflow.new.execute - - expect(result).to be_successful - expect(result.chain.results.size).to eq(5) - end - - it "respects group breakpoints independently" do - group1_task1 = create_successful_task(name: "Group1Task1") - group1_task2 = create_skipping_task(name: "Group1Task2", reason: "Skipped") - group2_task1 = create_successful_task(name: "Group2Task1") - group2_task2 = create_skipping_task(name: "Group2Task2", reason: "Skipped") - - workflow = create_workflow_class do - tasks group1_task1, - group1_task2, - breakpoints: %w[skipped] - - tasks group2_task1, - group2_task2, - breakpoints: [] - end - - result = workflow.new.execute - - expect(result).to have_skipped(reason: "Skipped") - expect(result.chain.results.size).to eq(3) - end - end - - context "when propagating failures" do - it "throws failures from nested tasks" do - inner_task = create_failing_task(name: "InnerTask", reason: "Inner failure") - - outer_task = create_task_class(name: "OuterTask") do - define_method(:work) do - result = inner_task.execute - throw!(result) if result.failed? - end - end - - workflow = create_workflow_class do - task outer_task - end - - result = workflow.new.execute - - expect(result).to have_failed(reason: "Inner failure", outcome: "interrupted") - expect(result.threw_failure?).to be(false) - end - end - - context "with bang execution" do - it "raises on workflow failure" do - task1 = create_successful_task(name: "Task1") - task2 = create_failing_task(name: "Task2", reason: "Failed") - - workflow = create_workflow_class do - task task1 - task task2 - end - - expect { workflow.new.execute(raise: true) }.to raise_error(CMDx::FailFault) - end - end - - context "when continuing after failures" do - it "executes all tasks when breakpoints are empty" do - task1 = create_successful_task(name: "Task1") - task2 = create_failing_task(name: "Task2", reason: "Failed") - task3 = create_successful_task(name: "Task3") - - workflow = create_workflow_class do - settings(workflow_breakpoints: []) - - task task1 - task task2 - task task3 - end - - result = workflow.new.execute - - expect(result).to be_successful - expect(result.chain.results.size).to eq(4) - expect(result.chain.results[2].failed?).to be(true) - expect(result.chain.results[3].success?).to be(true) - end - end -end diff --git a/spec/integration/workflows/conditionals_spec.rb b/spec/integration/workflows/conditionals_spec.rb deleted file mode 100644 index fa7c571e2..000000000 --- a/spec/integration/workflows/conditionals_spec.rb +++ /dev/null @@ -1,184 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe "Workflow conditionals", type: :feature do - context "when using if conditionals" do - it "executes task when condition is true" do - task1 = create_successful_task(name: "Task1") - - workflow = create_workflow_class do - task task1, if: :should_execute? - - def should_execute? - context.execute_task == true - end - end - - enabled_result = workflow.execute(execute_task: true) - disabled_result = workflow.execute(execute_task: false) - - expect(enabled_result).to be_successful - expect(enabled_result.chain.results.size).to eq(2) - - expect(disabled_result).to be_successful - expect(disabled_result.chain.results.size).to eq(1) - end - - it "uses proc for if condition" do - task1 = create_successful_task(name: "Task1") - - workflow = create_workflow_class do - task task1, if: -> { context.enabled == true } - end - - enabled_result = workflow.execute(enabled: true) - disabled_result = workflow.execute(enabled: false) - - expect(enabled_result.chain.results.size).to eq(2) - expect(disabled_result.chain.results.size).to eq(1) - end - - it "uses lambda for if condition" do - task1 = create_successful_task(name: "Task1") - - workflow = create_workflow_class do - task task1, if: proc { context.enabled == true } - end - - enabled_result = workflow.execute(enabled: true) - disabled_result = workflow.execute(enabled: false) - - expect(enabled_result.chain.results.size).to eq(2) - expect(disabled_result.chain.results.size).to eq(1) - end - end - - context "when using unless conditionals" do - it "executes task when condition is false" do - task1 = create_successful_task(name: "Task1") - - workflow = create_workflow_class do - task task1, unless: :should_skip? - - def should_skip? - context.skip_task == true - end - end - - enabled_result = workflow.execute(skip_task: false) - disabled_result = workflow.execute(skip_task: true) - - expect(enabled_result.chain.results.size).to eq(2) - expect(disabled_result.chain.results.size).to eq(1) - end - - it "uses proc for unless condition" do - task1 = create_successful_task(name: "Task1") - - workflow = create_workflow_class do - task task1, unless: -> { context.disabled == true } - end - - enabled_result = workflow.execute(disabled: false) - disabled_result = workflow.execute(disabled: true) - - expect(enabled_result.chain.results.size).to eq(2) - expect(disabled_result.chain.results.size).to eq(1) - end - end - - context "when combining if and unless" do - it "requires both conditions to be satisfied" do - task1 = create_successful_task(name: "Task1") - - workflow = create_workflow_class do - task task1, - if: :should_execute?, - unless: :should_skip? - - def should_execute? - context.enabled == true - end - - def should_skip? - context.override == true - end - end - - both_satisfied = workflow.execute(enabled: true, override: false) - if_false = workflow.execute(enabled: false, override: false) - unless_false = workflow.execute(enabled: true, override: true) - - expect(both_satisfied.chain.results.size).to eq(2) - expect(if_false.chain.results.size).to eq(1) - expect(unless_false.chain.results.size).to eq(1) - end - end - - context "when using conditionals with groups" do - it "applies condition to all tasks in group" do - task1 = create_successful_task(name: "Task1") - task2 = create_successful_task(name: "Task2") - task3 = create_successful_task(name: "Task3") - - workflow = create_workflow_class do - tasks task1, task2, task3, if: :group_enabled? - - def group_enabled? - context.enable_group == true - end - end - - enabled_result = workflow.execute(enable_group: true) - disabled_result = workflow.execute(enable_group: false) - - expect(enabled_result.chain.results.size).to eq(4) - expect(disabled_result.chain.results.size).to eq(1) - end - end - - context "when conditionals affect execution flow" do - it "skips tasks based on runtime conditions" do - setup_task = create_task_class(name: "SetupTask") do - def work - context.setup_complete = true - end - end - conditional_task = create_successful_task(name: "ConditionalTask") - final_task = create_successful_task(name: "FinalTask") - - workflow = create_workflow_class do - task setup_task - task conditional_task, if: proc { context.setup_complete == true } - task final_task - end - - result = workflow.execute - - expect(result).to be_successful - expect(result.chain.results.size).to eq(4) - expect(result).to have_matching_context(setup_complete: true) - end - end - - context "when using complex conditional logic" do - it "evaluates complex conditions" do - task1 = create_successful_task(name: "Task1") - - workflow = create_workflow_class do - task task1, if: proc { - context.env == "production" && context.feature_enabled == true - } - end - - prod_enabled = workflow.execute(env: "production", feature_enabled: true) - prod_disabled = workflow.execute(env: "production", feature_enabled: false) - dev_enabled = workflow.execute(env: "development", feature_enabled: true) - - expect(prod_enabled.chain.results.size).to eq(2) - expect(prod_disabled.chain.results.size).to eq(1) - expect(dev_enabled.chain.results.size).to eq(1) - end - end -end diff --git a/spec/integration/workflows/execution_spec.rb b/spec/integration/workflows/execution_spec.rb deleted file mode 100644 index dbe4485af..000000000 --- a/spec/integration/workflows/execution_spec.rb +++ /dev/null @@ -1,105 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe "Workflow execution", type: :feature do - context "when non-blocking" do - subject(:result) { workflow.execute } - - context "when successful" do - let(:workflow) { create_successful_workflow } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[success inner middle outer success]) - end - end - - context "when skipping" do - let(:workflow) { create_skipping_workflow } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[success success]) - end - end - - context "when failing" do - let(:workflow) { create_failing_workflow } - - it "returns failure" do - expect(result).to have_failed( - outcome: CMDx::Result::INTERRUPTED, - threw_failure: hash_including( - index: 2, - class: start_with("OuterTask") - ), - caused_failure: hash_including( - index: 4, - class: start_with("InnerTask") - ) - ) - expect(result).to have_matching_context(executed: %i[success]) - end - end - - context "when erroring" do - let(:workflow) { create_erroring_workflow } - - it "returns failure" do - expect(result).to have_failed( - outcome: CMDx::Result::INTERRUPTED, - reason: "[CMDx::TestError] borked error", - cause: be_a(CMDx::FailFault), - threw_failure: hash_including( - index: 2, - class: start_with("OuterTask") - ), - caused_failure: hash_including( - index: 4, - class: start_with("InnerTask") - ) - ) - expect(result).to have_matching_context(executed: %i[success]) - end - end - end - - context "when blocking" do - subject(:result) { workflow.execute! } - - context "when successful" do - let(:workflow) { create_successful_workflow } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[success inner middle outer success]) - end - end - - context "when skipping" do - let(:workflow) { create_skipping_workflow } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[success success]) - end - end - - context "when failing" do - let(:workflow) { create_failing_workflow } - - it "returns failure" do - expect { result }.to raise_error(CMDx::FailFault, "Unspecified") - end - end - - context "when erroring" do - let(:workflow) { create_erroring_workflow } - - it "returns failure" do - expect { result }.to raise_error(CMDx::FailFault, "[CMDx::TestError] borked error") - end - end - end -end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index ad31e20b0..9da434d01 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,53 +1,33 @@ # frozen_string_literal: true -ENV["TZ"] = "Etc/UTC" - -require "bundler/setup" -require "rspec" - require "cmdx" -require "cmdx/rspec" - -spec_path = Pathname.new(File.expand_path("../spec", File.dirname(__FILE__))) - -%w[config helpers].each do |dir| - Dir.glob(spec_path.join("support/#{dir}/**/*.rb")) - .sort_by { |f| [f.split("/").size, f] } - .each { |f| load(f) } -end RSpec.configure do |config| - config.shared_context_metadata_behavior = :apply_to_host_groups + config.expect_with :rspec do |expectations| + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end - # Enable flags like --only-failures and --next-failure - config.example_status_persistence_file_path = ".rspec_status" + config.mock_with :rspec do |mocks| + mocks.verify_partial_doubles = true + end - # Disable RSpec exposing methods globally on Module and main + config.shared_context_metadata_behavior = :apply_to_host_groups + config.filter_run_when_matching :focus + config.example_status_persistence_file_path = "spec/examples.txt" config.disable_monkey_patching! + config.warnings = true - config.define_derived_metadata do |meta| - meta[:aggregate_failures] = true - end - - config.expect_with :rspec do |c| - c.syntax = :expect - end + config.default_formatter = "doc" if config.files_to_run.one? - config.include CMDx::RSpec::Helpers - config.include CMDx::Testing::TaskBuilders - config.include CMDx::Testing::WorkflowBuilders + config.order = :random + Kernel.srand config.seed config.before do CMDx.reset_configuration! - CMDx.configuration.logger = Logger.new(nil) - CMDx.configuration.freeze_results = false - CMDx::Chain.clear - CMDx::Middlewares::Correlate.clear - end - - config.after do - CMDx.reset_configuration! + CMDx.configuration.logger = Logger.new(File::NULL) CMDx::Chain.clear - CMDx::Middlewares::Correlate.clear + CMDx::Locale.instance_variable_set(:@translations, nil) end end + +Dir[File.join(__dir__, "support/**/*.rb")].each { |f| require f } diff --git a/spec/support/config/i18n.rb b/spec/support/config/i18n.rb deleted file mode 100644 index 5f364cb8c..000000000 --- a/spec/support/config/i18n.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -require "i18n" - -locales = Dir[CMDx.gem_path.join("lib/locales/*.yml")] - -I18n.load_path += locales -I18n.available_locales = locales.map { |path| File.basename(path, ".yml").to_sym } -I18n.enforce_available_locales = true -I18n.reload! - -I18n.default_locale = :en -I18n.locale = :en diff --git a/spec/support/helpers/ruby_version.rb b/spec/support/helpers/ruby_version.rb deleted file mode 100644 index 71c1a27dd..000000000 --- a/spec/support/helpers/ruby_version.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -module RubyVersion - - extend self - - def version - @version ||= Gem::Version.new(RUBY_VERSION) - end - - def min?(min) - version >= Gem::Version.new(min.to_s) - end - - def max?(max) - version <= Gem::Version.new(max.to_s) - end - -end diff --git a/spec/support/helpers/task_builders.rb b/spec/support/helpers/task_builders.rb deleted file mode 100644 index d566c8e48..000000000 --- a/spec/support/helpers/task_builders.rb +++ /dev/null @@ -1,110 +0,0 @@ -# frozen_string_literal: true - -module CMDx - - TestError = Class.new(StandardError) - - module Testing - module TaskBuilders - - def mock_settings(**attrs) - settings = CMDx::Settings.allocate - attrs.each { |k, v| settings.public_send(:"#{k}=", v) } - settings - end - - # Base - - def create_task_class(base: CMDx::Task, name: "AnonymousTask", &) - task_class = Class.new(base) - task_class.define_singleton_method(:name) { @name ||= name.to_s + rand(9999).to_s.rjust(4, "0") } - task_class.class_eval(&) if block_given? - task_class - end - - # Simple - - def create_successful_task(base: CMDx::Task, name: "SuccessfulTask", &) - task_class = create_task_class(base:, name:) - task_class.class_eval(&) if block_given? - task_class.define_method(:work) { (context.executed ||= []) << :success } - task_class - end - - def create_skipping_task(base: CMDx::Task, name: "SkippingTask", reason: nil, **metadata, &) - task_class = create_task_class(base:, name:) - task_class.class_eval(&) if block_given? - task_class.define_method(:work) do - skip!(reason, **metadata) - (context.executed ||= []) << :skipped - end - task_class - end - - def create_failing_task(base: CMDx::Task, name: "FailingTask", reason: nil, **metadata, &) - task_class = create_task_class(base:, name:) - task_class.class_eval(&) if block_given? - task_class.define_method(:work) do - fail!(reason, **metadata) - (context.executed ||= []) << :failed - end - task_class - end - - def create_erroring_task(base: CMDx::Task, name: "ErroringTask", reason: nil, **_metadata, &) - task_class = create_task_class(base:, name:) - task_class.class_eval(&) if block_given? - task_class.define_method(:work) do - raise TestError, reason || "borked error" - (context.executed ||= []) << :errored # rubocop:disable Lint/UnreachableCode - end - task_class - end - - # Nested - - def create_nested_task(base: CMDx::Task, strategy: :swallow, status: :success, reason: nil, **metadata, &) - inner_task = create_task_class(base:, name: "InnerTask") - inner_task.class_eval(&) if block_given? - inner_task.define_method(:work) do - case status - when :success then (context.executed ||= []) << :inner - when :skipped then skip!(reason, **metadata) - when :failure then fail!(reason, **metadata) - when :error then raise TestError, reason || "borked error" - else raise "unknown status #{status}" - end - end - - middle_task = create_task_class(base:, name: "MiddleTask") - middle_task.class_eval(&) if block_given? - middle_task.define_method(:work) do - case strategy - when :swallow then inner_task.execute(context) - when :throw, :raise_throw then throw!(inner_task.execute(context)) - when :raise, :throw_raise then inner_task.execute!(context) - else raise "unknown strategy #{strategy}" - end - - (context.executed ||= []) << :middle - end - - outer_task = create_task_class(base:, name: "OuterTask") - outer_task.class_eval(&) if block_given? - outer_task.define_method(:work) do - case strategy - when :swallow then middle_task.execute(context) - when :throw, :throw_raise then throw!(middle_task.execute(context)) - when :raise, :raise_throw then middle_task.execute!(context) - else raise "unknown strategy #{strategy}" - end - - (context.executed ||= []) << :outer - end - outer_task - end - - end - end - -end diff --git a/spec/support/helpers/workflow_builders.rb b/spec/support/helpers/workflow_builders.rb deleted file mode 100644 index 4ca5d36c8..000000000 --- a/spec/support/helpers/workflow_builders.rb +++ /dev/null @@ -1,69 +0,0 @@ -# frozen_string_literal: true - -module CMDx - module Testing - module WorkflowBuilders - - # Base - - def create_workflow_class(base: CMDx::Task, name: "AnonymousWorkflow", &) - workflow_class = Class.new(base) - workflow_class.include(CMDx::Workflow) - workflow_class.define_singleton_method(:name) { @name ||= name.to_s + rand(9999).to_s.rjust(4, "0") } - workflow_class.class_eval(&) if block_given? - workflow_class - end - - # Simple - - def create_successful_workflow(base: CMDx::Task, name: "SuccessfulWorkflow", &block) - task1 = create_successful_task(base:, name: "SuccessfulTask1") - task2 = create_nested_task(base:, strategy: :throw, status: :success) - task3 = create_successful_task(base:, name: "SuccessfulTask3") - - create_workflow_class(base:, name:) do - tasks task1, task2, task3 - - class_eval(&block) if block_given? - end - end - - def create_skipping_workflow(base: CMDx::Task, name: "SkippingWorkflow", &block) - pre_skip_task = create_successful_task(base:, name: "PreSkipTask") - skipping_task = create_nested_task(base:, strategy: :throw, status: :skipped) - post_skip_task = create_successful_task(base:, name: "PostSkipTask") - - create_workflow_class(base:, name:) do - tasks pre_skip_task, skipping_task, post_skip_task - - class_eval(&block) if block_given? - end - end - - def create_failing_workflow(base: CMDx::Task, name: "FailingWorkflow", &block) - pre_fail_task = create_successful_task(base:, name: "PreFailTask") - failing_task = create_nested_task(base:, strategy: :throw, status: :failure) - post_fail_task = create_successful_task(base:, name: "PostFailTask") - - create_workflow_class(base:, name:) do - tasks pre_fail_task, failing_task, post_fail_task - - class_eval(&block) if block_given? - end - end - - def create_erroring_workflow(base: CMDx::Task, name: "ErroringWorkflow", &block) - pre_error_task = create_successful_task(base:, name: "PreErrorTask") - erroring_task = create_nested_task(base:, strategy: :raise, status: :error) - post_error_task = create_successful_task(base:, name: "PostErrorTask") - - create_workflow_class(base:, name:) do - tasks pre_error_task, erroring_task, post_error_task - - class_eval(&block) if block_given? - end - end - - end - end -end