diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c5ee2e1c..a416007a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [UNRELEASED] +## [2.0.0] - 2026-04-09 + +### Changed + +- **Breaking:** Rebuilt the runtime around immutable `Definition`, per-run `Session`, `Outcome`, and `ExecutionResult`. +- **Breaking:** Removed `Settings`, separate registry classes, `Result`/`Resolver`, `Chain`, `Attribute`/`AttributeValue`, `Parallelizer`, and built-in log formatter types; added `ExtensionSet`, `Trace`, `WorkflowRunner`, `Parallel::Threads`, and `Telemetry`. +- **Breaking:** Middleware uses `call(env, **options) { ... }` with `MiddlewareEnv`. +- **Breaking:** Removed `CMDx::Exception` alias; use `CMDx::Error`. +- **Breaking:** `Definition.for` renamed to `Definition.fetch` for Ruby keyword compatibility. + +### Added + +- `CMDx.reset_configuration!` for test isolation. +- `docs/v2/V1_AUDIT.md` and `UPGRADING.md`. + ## [1.21.0] - 2026-04-09 ### Added 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/README.md b/README.md index 72bca2226..0c536fdaf 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,9 @@ Say goodbye to messy service objects. CMDx helps you design business logic with > [!NOTE] > [Documentation](https://drexed.github.io/cmdx/getting_started/) reflects the latest code on `main`. For version-specific documentation, please refer to the `docs/` directory within that version's tag. +> [!IMPORTANT] +> **2.0** rewrote the framework (see [CHANGELOG.md](./CHANGELOG.md) and [UPGRADING.md](./UPGRADING.md)). The published MkDocs site may still describe 1.x until it is regenerated. + ## Requirements - Ruby: MRI 3.1+ or JRuby 9.4+ diff --git a/UPGRADING.md b/UPGRADING.md new file mode 100644 index 000000000..66822cc68 --- /dev/null +++ b/UPGRADING.md @@ -0,0 +1,40 @@ +# Upgrading to CMDx 2.0 + +Version 2.0 is a **breaking** rewrite: same goals (composable command objects, workflows, outcomes), different internals and several API changes. This guide maps concepts, not line-for-line APIs. + +## Mental model + +- **Definition** replaces `Settings` plus multiple registries. Coercions, validators, and middleware are merged through `ExtensionSet` on each task class. +- **Session** carries `context`, `errors`, `outcome`, and **Trace** (explicit correlation). Thread-local `Chain.current` is gone; pass `trace:` into `execute` when needed. +- **ExecutionResult** is what `execute` returns (not `Result`). Use `success?`, `failed?`, `context`, `outcome`, `trace`. +- **Outcome** holds state/status; `success!` / `skip!` / `fail!` still exist on the task and delegate to the session outcome. + +## Middleware + +Use Rack-style signatures: + +```ruby +module MyMw + def self.call(env, **options) + yield + end +end + +register :middleware, MyMw, if: :some_predicate? +``` + +`env` is `CMDx::MiddlewareEnv` (`session`, `handler`). + +## Removed or renamed pieces + +- `CMDx::Result`, `CMDx::Resolver`, `CMDx::Chain`, `CMDx::Settings`, registry classes, `Attribute`, `AttributeValue`, `Parallelizer`, built-in log formatters. +- `CMDx::Exception` alias removed; use `CMDx::Error`. +- `Definition.for` was renamed to `Definition.fetch` (Ruby 3.4+ reserves `for` syntax in some positions). + +## Configuration + +`CMDx.configure` still works. Registries are replaced by `config.extensions` (`ExtensionSet`) and per-class `register`. Use `CMDx.reset_configuration!` in tests to isolate state. + +## Further reading + +- [docs/v2/V1_AUDIT.md](docs/v2/V1_AUDIT.md) — what changed conceptually. diff --git a/benchmark/cmdx_benchmark.rb b/benchmark/cmdx_benchmark.rb new file mode 100644 index 000000000..be934c9ae --- /dev/null +++ b/benchmark/cmdx_benchmark.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "bundler/setup" +require_relative "../lib/cmdx" + +CMDx.configuration.logger = Logger.new(nil) + +klass = Class.new(CMDx::Task) do + required :n, type: :integer + + def work + context[:out] = n + 1 + end +end + +n = 20_000 +t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC) +n.times { klass.execute(n: 1) } +elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0 +puts format("Task.execute x%d: %.3fs (%.1f runs/s)", n: n, sec: elapsed, rps: n / elapsed) diff --git a/docs/v2/V1_AUDIT.md b/docs/v2/V1_AUDIT.md new file mode 100644 index 000000000..8eeea8592 --- /dev/null +++ b/docs/v2/V1_AUDIT.md @@ -0,0 +1,32 @@ +# V1 → V2 audit (concepts retained vs dropped) + +This document satisfies the v2 plan audit: what v1 provided and what v2 replaces or removes. It is **not** a parity checklist. + +## Retained concepts (CERO) + +- **Compose**: typed/command objects with declared inputs, optional workflows composing steps. +- **Execute**: single entry (`execute` / `execute!`) returning a rich outcome. +- **React**: predicate API on outcome (`success?`, `failed?`, `skipped?`) and context access. +- **Observe**: trace identifiers, structured telemetry hooks, optional logging. + +## Dropped or replaced + +| V1 | V2 | +|----|-----| +| `Settings` + five CoW registries | `Definition` + `ExtensionSet` (one merge model) | +| `Result` + `Resolver` + `instance_variable_set` | `Outcome` with explicit transitions | +| `Attribute#task` + dup-per-run | Frozen `AttributeSpec` + `AttributePipeline` | +| `Chain.current` thread/fiber default | `Trace` carried on `Session` (explicit correlation) | +| `CMDx::Exception = Error` | `CMDx::Error` only (no `Exception` alias) | +| Middleware `call(task, **opts, &)` | `call(env, &next)` with `MiddlewareEnv` | +| `Context#method_missing` as default hot path | Hash-backed `Context`; optional accessors | +| `AttributeValue` class | Pipeline stages on `AttributeSpec` | +| Many log formatter classes as core | `Telemetry` / `LogSink` adapter pattern | +| 80+ locale files in core | English defaults in `CMDx::V2::Locale`; optional full locales later | +| `throw(:cmdx_halt)` | `throw(:cmdx_v2_halt)` (internal); documented | + +## Optional / deferred + +- Full I18n parity for all validator strings (start with English + `I18n.t` when gem present). +- Rack/Rails-specific middleware ports beyond a correlate + runtime example. +- Non-thread parallel backends (protocol only: `Parallel::Backend`). diff --git a/lib/cmdx/attribute.rb b/lib/cmdx/attribute.rb deleted file mode 100644 index 67010afef..000000000 --- a/lib/cmdx/attribute.rb +++ /dev/null @@ -1,440 +0,0 @@ -# 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. - 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 - 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 - - # Returns the child attributes for nested structures. - # - # @return [Array] Array of child attributes - # - # @example - # attribute.children # => [#, #] - # - # @rbs @children: Array[Attribute] - attr_reader :children - - # 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 - - # 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 - - # 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 - - # 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) - - @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 - - # 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 - - # 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 - - end - - # Checks if the attribute is optional. - # - # @return [Boolean] true if the attribute is optional, false otherwise - # - # @example - # attribute.optional? # => true - # - # @rbs () -> bool - def optional? - !required? || !!options[:optional] - end - - # Checks if the attribute is required. - # - # @return [Boolean] true if the attribute is required, false otherwise - # - # @example - # attribute.required? # => true - # - # @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 - end - - # Generates the method name for accessing this attribute. - # - # @return [Symbol] The method name for the attribute - # - # @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 - end - - # Defines and verifies the entire attribute tree including nested children. - # - # @rbs () -> void - def define_and_verify_tree - define_and_verify - - children.each do |child| - child.task = task - child.define_and_verify_tree - end - end - - # Recursively clears the task reference from this attribute and all children. - # Prevents the class-level attribute from retaining the last-executed task instance. - # - # @rbs () -> void - def clear_task_tree! - @task = nil - children.each(&:clear_task_tree!) - end - - # @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: [] - # } - # - # @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) - } - 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 - # - # @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 - end - - attribute_value = AttributeValue.new(self) - attribute_value.generate - attribute_value.validate - end - - end -end diff --git a/lib/cmdx/attribute_pipeline.rb b/lib/cmdx/attribute_pipeline.rb new file mode 100644 index 000000000..32b5ba3bd --- /dev/null +++ b/lib/cmdx/attribute_pipeline.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +module CMDx + # Coerces and validates inputs using frozen {AttributeSpec}s and {ExtensionSet}. + class AttributePipeline + + # @param session [Session] + # @return [void] + def self.apply_all(session) + definition = session.handler.class.definition + extensions = definition.extensions + raw = session.raw_input + + definition.attribute_specs.each do |spec| + apply_spec(session, spec, raw, extensions) + end + + filter_strong_context!(session) if definition.strong_context + end + + # @param session [Session] + # @param spec [AttributeSpec] + # @param raw [Hash] + # @param extensions [ExtensionSet] + # @return [void] + def self.apply_spec(session, spec, raw, extensions) + key = spec.name + val = raw[key] || raw[spec.reader_name] + + val = spec.options[:default] if val.nil? && spec.options.key?(:default) + + if spec.required && missing?(val) + session.errors.add(spec.reader_name, Locale.t("cmdx.validators.presence")) + return + end + + return if val.nil? && !spec.required + + coerced = coerce_value(session, spec, val, extensions) + return if session.errors.for?(spec.reader_name) + + validate_value(session, spec, coerced, extensions) + return if session.errors.for?(spec.reader_name) + + session.context[key] = coerced + session.handler.write_attribute!(spec.reader_name, coerced) + end + + # @param val [Object] + # @return [Boolean] + def self.missing?(val) + val.nil? || (val.is_a?(String) && !/\S/.match?(val)) + end + + # @param session [Session] + # @param spec [AttributeSpec] + # @param val [Object] + # @param extensions [ExtensionSet] + # @return [Object, nil] + def self.coerce_value(session, spec, val, extensions) + type_keys = spec.type_keys + return val if type_keys.empty? + + key = type_keys.first + fn = extensions.coercions[key] + unless fn + session.errors.add(spec.reader_name, Locale.t("cmdx.coercions.unknown", type: key)) + return nil + end + + fn.call(val, context: session, attribute: spec, **spec.options) + rescue CoercionError => e + session.errors.add(spec.reader_name, e.message) + nil + end + + # @param session [Session] + # @param spec [AttributeSpec] + # @param val [Object] + # @param extensions [ExtensionSet] + # @return [void] + def self.validate_value(session, spec, val, extensions) + spec.validators.each do |v| + name = v[:name].to_sym + opts = (v[:options] || {}).dup + next unless validator_applies?(session.handler, session, val, opts) + + fn = extensions.validators[name] + unless fn + session.errors.add(spec.reader_name, "unknown validator #{name}") + next + end + + fn.call(val, context: session, attribute: spec, **opts) + rescue ValidationError => e + session.errors.add(spec.reader_name, e.message) + end + end + + # @param handler [Task] + # @param session [Session] + # @param value [Object] + # @param options [Hash] + # @return [Boolean] + def self.validator_applies?(handler, _session, value, options) + return true unless options.is_a?(Hash) + + return !options[:allow_nil] if options.key?(:allow_nil) && value.nil? + + cond_opts = options.slice(:if, :unless) + return true if cond_opts.empty? + + Utils::Condition.evaluate(handler, cond_opts, value) + end + + # @param session [Session] + # @return [void] + def self.filter_strong_context!(session) + definition = session.handler.class.definition + allowed = definition.attribute_specs.flat_map { |s| [s.name, s.reader_name] }.uniq + session.context.to_h.delete_if { |k, _| !allowed.include?(k) } + end + + end +end diff --git a/lib/cmdx/attribute_registry.rb b/lib/cmdx/attribute_registry.rb deleted file mode 100644 index ce7f9425a..000000000 --- a/lib/cmdx/attribute_registry.rb +++ /dev/null @@ -1,185 +0,0 @@ -# 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. - 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 - - # 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 - end - - # Creates a duplicate of this registry with copied attributes. - # - # @return [AttributeRegistry] A new registry with duplicated attributes - # - # @example - # new_registry = registry.dup - # - # @rbs () -> AttributeRegistry - def dup - self.class.new(registry.dup) - end - - # Registers one or more attributes to the registry. - # - # @param attributes [Attribute, Array] Attribute(s) to register - # - # @return [AttributeRegistry] Self for method chaining - # - # @example - # registry.register(attribute) - # registry.register([attr1, attr2]) - # - # @rbs (Attribute | Array[Attribute] attributes) -> self - def register(attributes) - @registry.concat(Utils::Wrap.array(attributes)) - self - 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 - # - # @return [AttributeRegistry] Self for method chaining - # - # @example - # registry.deregister(:name) - # registry.deregister(['name1', 'name2']) - # - # @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 - 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. - # - # @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) } - 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 - # - # @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) - - undefine_reader_tree!(task_class, attribute) - end - end - 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. - # - # @param task [Task] The task to verify attributes against - # - # @rbs (Task task) -> void - def define_and_verify(task) - registry.each do |attribute| - duplicate = attribute.dup - duplicate.task = task - duplicate.define_and_verify_tree - end - end - - private - - # Recursively defines reader methods for an attribute and its children - # when the method name is statically resolvable. - # - # @param task_class [Class] The task class to define readers on - # @param attribute [Attribute] The attribute to define a reader for - # - # @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. - - Use :as, :prefix, and/or :suffix attribute options to avoid conflicts with existing methods. - 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. - # - # @param task_class [Class] The task class to undefine readers on - # @param attribute [Attribute] The attribute whose readers to remove - # - # @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) } - 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 [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) } - end - - end -end diff --git a/lib/cmdx/attribute_spec.rb b/lib/cmdx/attribute_spec.rb new file mode 100644 index 000000000..e82417fbe --- /dev/null +++ b/lib/cmdx/attribute_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module CMDx + # Frozen description of one task input (no runtime +task+ binding). + class AttributeSpec + + # @return [Symbol] + attr_reader :name + + # @return [Symbol] reader method + attr_reader :reader_name + + # @return [Boolean] + attr_reader :required + + # @return [Array] coercion type keys (first wins) + attr_reader :type_keys + + # @return [Hash{Symbol => Object}] + attr_reader :options + + # @return [Array Object}>] each has +:name+ and optional +:options+, +:if+, +:unless+ + attr_reader :validators + + # @param name [Symbol] + # @param required [Boolean] + # @param type_keys [Array] + # @param reader_name [Symbol, nil] + # @param options [Hash] + # @param validators [Array] + # rubocop:disable Metrics/ParameterLists + def initialize(name:, required:, type_keys:, reader_name: nil, options: {}, validators: []) + @name = name.to_sym + @required = required + @type_keys = type_keys.map(&:to_sym).freeze + @reader_name = (reader_name || @name).to_sym + @options = options.freeze + @validators = validators.freeze + freeze + end + # rubocop:enable Metrics/ParameterLists + + 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 deleted file mode 100644 index 1724eab5e..000000000 --- a/lib/cmdx/callback_registry.rb +++ /dev/null @@ -1,169 +0,0 @@ -# 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. - 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_good - on_bad - ].freeze - - # @rbs TYPES_SET: Set[Symbol] - TYPES_SET = TYPES.to_set.freeze - private_constant :TYPES_SET - - 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 || {} - end - - # Sets up copy-on-write state when duplicated via dup. - # - # @param source [CallbackRegistry] The registry being duplicated - # - # @rbs (CallbackRegistry source) -> void - def initialize_dup(source) - @parent = source - @registry = nil - super - end - - # Returns the internal registry of callbacks organized by type. - # Delegates to the parent registry when not yet materialized. - # - # @return [Hash{Symbol => Set}] Hash mapping callback types to their registered callables - # - # @example - # registry.registry # => { before_execution: # } - # - # @rbs () -> Hash[Symbol, Set[Array[untyped]]] - def registry - @registry || @parent.registry - 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 - # - # @return [CallbackRegistry] self for method chaining - # - # @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? - - @registry[type] ||= Set.new - @registry[type] << [callables, options] - self - 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) - # - # @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 - 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 - # - # @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 - end - - private - - # Copies the parent's registry data into this instance, - # severing the copy-on-write link. - # - # @rbs () -> void - def materialize! - return if @registry - - @registry = @parent.registry.transform_values(&:dup) - @parent = nil - end - - end -end diff --git a/lib/cmdx/callback_runner.rb b/lib/cmdx/callback_runner.rb new file mode 100644 index 000000000..e6c6d2114 --- /dev/null +++ b/lib/cmdx/callback_runner.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module CMDx + # Invokes definition callbacks for a phase with optional +if+/+unless+. + class CallbackRunner + + # @param session [Session] + # @param phase [Symbol] + # @return [void] + def self.run(session, phase) + entries = session.definition.callbacks[phase] || [] + return if entries.empty? + + handler = session.handler + entries.each do |callable, options| + next unless Utils::Condition.evaluate(handler, options) + + if callable.is_a?(Symbol) + handler.send(callable) + else + callable.call(session, handler) + end + end + end + + end +end diff --git a/lib/cmdx/chain.rb b/lib/cmdx/chain.rb deleted file mode 100644 index 9f5016f5e..000000000 --- a/lib/cmdx/chain.rb +++ /dev/null @@ -1,216 +0,0 @@ -# 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. - 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" - # - # @rbs @id: String - attr_reader :id - - # Returns the collection of execution results in this chain. - # - # @return [Array] Array of task results - # - # @example - # chain.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 - def initialize(dry_run: false) - @mutex = Mutex.new - @id = Identifier.generate - @results = [] - @dry_run = !!dry_run - end - - 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 - # - # @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) - # - # @raise [TypeError] If result is not a CMDx::Result instance - # - # @example - # result = task.execute - # chain = Chain.build(result) - # puts "Chain size: #{chain.size}" - # - # @rbs (Result result) -> 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 - 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 - - # Thread-safe append of a result to the chain. - # Caches the result's index to avoid repeated O(n) lookups. - # - # @param result [Result] The result to append - # - # @return [Array] The updated results array - # - # @rbs (Result result) -> Array[Result] - def push(result) - @mutex.synchronize do - result.instance_variable_set(:@chain_index, @results.size) - @results << result - end - end - - # Thread-safe lookup of a result's position in the chain. - # - # @param result [Result] The result to find - # - # @return [Integer, nil] The zero-based index or nil if not found - # - # @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 - # - # @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 - # - # @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) - } - 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) - end - - end -end diff --git a/lib/cmdx/coercion_registry.rb b/lib/cmdx/coercion_registry.rb deleted file mode 100644 index 8affbf9b3..000000000 --- a/lib/cmdx/coercion_registry.rb +++ /dev/null @@ -1,138 +0,0 @@ -# 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. - 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 - - # 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 - - # 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 - 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 - # - # @return [CoercionRegistry] self for method chaining - # - # @example - # registry.register(:custom_type, CustomCoercion) - # registry.register("another_type", AnotherCoercion) - # - # @rbs ((Symbol | String) name, Class coercion) -> self - def register(name, coercion) - materialize! - - @registry[name.to_sym] = coercion - self - end - - # Remove a coercion handler for a type. - # - # @param name [Symbol, String] the type name to deregister - # - # @return [CoercionRegistry] self for method chaining - # - # @example - # registry.deregister(:custom_type) - # registry.deregister("another_type") - # - # @rbs ((Symbol | String) name) -> self - def deregister(name) - materialize! - - @registry.delete(name.to_sym) - self - 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 [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) - end - - private - - # Copies the parent's registry data into this instance, - # severing the copy-on-write link. - # - # @rbs () -> void - def materialize! - return if @registry - - @registry = @parent.registry.dup - @parent = nil - end - - end -end diff --git a/lib/cmdx/configuration.rb b/lib/cmdx/configuration.rb index 293538a91..567c1085b 100644 --- a/lib/cmdx/configuration.rb +++ b/lib/cmdx/configuration.rb @@ -2,279 +2,84 @@ module CMDx - # Configuration class that manages global settings for CMDx including middlewares, - # callbacks, coercions, validators, breakpoints, backtraces, and logging. + # Global defaults merged into each {Definition} root. class Configuration - # @rbs DEFAULT_BREAKPOINTS: Array[String] - DEFAULT_BREAKPOINTS = %w[failed].freeze + attr_accessor :logger, :telemetry, :freeze_results, :dump_context, :backtrace, :backtrace_cleaner, :exception_handler, :id_generator, :task_breakpoints, :workflow_breakpoints, :rollback_on, :sleep_impl - # @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 - # - # @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 - - # 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 - - # 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 - - # 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 + @logger = Logger.new($stdout) + @logger.progname = "CMDx" + @telemetry = nil @freeze_results = true - @default_locale = "en" - + @dump_context = false @backtrace = false @backtrace_cleaner = nil @exception_handler = nil + @id_generator = -> { Identifier.generate } + @task_breakpoints = [:failed].freeze + @workflow_breakpoints = [:failed].freeze + @rollback_on = [:failed].freeze + @sleep_impl = ->(seconds) { Kernel.sleep(seconds) } + @extensions = ExtensionSet.build_defaults + end + + # @return [ExtensionSet] + attr_reader :extensions - @logger = Logger.new( - $stdout, - progname: "cmdx", - formatter: LogFormatters::Line.new, - level: Logger::INFO - ) + # @param ext [ExtensionSet] + # @return [void] + def extensions=(ext) + @extensions = ext + reset_base_definition! 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 - } + # @return [Definition] + def base_definition + @base_definition ||= Definition.root(self) end - end + # @return [void] + def reset_base_definition! + remove_instance_variable(:@base_definition) if instance_variable_defined?(:@base_definition) + end + + class << self + + # @return [Configuration] + def instance + @instance ||= new + end - extend self + # @yieldparam config [Configuration] + # @return [void] + def configure + yield instance + instance.reset_base_definition! + end - # 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 + # @return [void] + def reset! + @instance = new + end + + end - @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? + # @return [Configuration] + def self.configuration + Configuration.instance + end - config = configuration - yield(config) - config + # @see Configuration#configure + def self.configure(&) + Configuration.configure(&) 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 + # @return [void] + def self.reset_configuration! + Configuration.reset! end end diff --git a/lib/cmdx/context.rb b/lib/cmdx/context.rb index 32e7adf4c..046353a75 100644 --- a/lib/cmdx/context.rb +++ b/lib/cmdx/context.rb @@ -1,43 +1,13 @@ # 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. + # Symbol-keyed bag for inputs and outputs. No +method_missing+ on the hot path. 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 } - # - # @rbs @table: Hash[Symbol, untyped] + # @return [Hash{Symbol => Object}] 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] 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" - # - # @rbs (untyped args) -> void + # @param args [Hash, #to_h] def initialize(args = {}) @table = if args.respond_to?(:to_hash) @@ -46,264 +16,52 @@ def initialize(args = {}) args.to_h else raise ArgumentError, "must respond to `to_h` or `to_hash`" - end.transform_keys(&:to_sym) + end.transform_keys(&:to_sym).dup 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 - # - # @return [Context] a Context instance, either new or reused - # - # @example - # existing = Context.new(name: "John") - # built = Context.build(existing) # reuses existing context - # built.object_id == existing.object_id # => true - # - # @rbs (untyped context) -> Context + # @param context [Context, Hash, Object] + # @return [Context] def self.build(context = {}) - if context.is_a?(self) && !context.frozen? - context - elsif context.respond_to?(:context) - build(context.context) - else - new(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 + return context if context.is_a?(self) - # 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 + new(context) 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 - table[key.to_sym] = block_given? ? yield : value - 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)) - self - 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 - self + # @param key [Symbol, String] + # @return [Object, nil] + def [](key) + @table[key.to_sym] 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) + # @param key [Symbol, String] + # @param value [Object] + # @return [Object] + def []=(key, value) + @table[key.to_sym] = value 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 + # @param key [Symbol, String] + # @return [Boolean] 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) + @table.key?(key.to_sym) 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" - # - # @rbs () -> String - def to_s - Utils::Format.to_str(to_h) + # @param other [Hash] + # @return [self] + def merge!(other) + @table.merge!(other.to_h.transform_keys(&:to_sym)) + self end - 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, &) - if method_name.end_with?("=") - store(method_name.name.chop, args.first) - else - table[method_name] - end + # @return [Hash{Symbol => Object}] + def to_h + @table 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 + # @return [void] + def freeze + @table.freeze + super end end diff --git a/lib/cmdx/definition.rb b/lib/cmdx/definition.rb new file mode 100644 index 000000000..6bd2776d6 --- /dev/null +++ b/lib/cmdx/definition.rb @@ -0,0 +1,228 @@ +# frozen_string_literal: true + +module CMDx + # Immutable compiled view of a task class (attributes, extensions, callbacks). + class Definition + + CALLBACK_PHASES = %i[ + before_validation before_execution + on_complete on_interrupted on_executed + on_success on_skipped on_failed on_good on_bad + ].freeze + + # @return [ExtensionSet] + attr_reader :extensions + + # @return [Array] + attr_reader :attribute_specs + + # @return [Array] + attr_reader :returns + + # @return [Hash{Symbol => Array}] + attr_reader :callbacks + + # @return [RetryPolicy, nil] + attr_reader :retry_policy + + # @return [Array] + attr_reader :rollback_on + + # @return [Array] + attr_reader :task_breakpoints + + # @return [Array] + attr_reader :workflow_breakpoints + + # @return [Boolean] + attr_reader :dump_context + + # @return [Boolean] + attr_reader :freeze_results + + # @return [Boolean] + attr_reader :backtrace + + # @return [Proc, nil] + attr_reader :backtrace_cleaner + + # @return [Proc, nil] + attr_reader :exception_handler + + # @return [Symbol, Proc, Boolean, nil] + attr_reader :deprecate + + # @return [Array] + attr_reader :tags + + # @return [Boolean] + attr_reader :strong_context + + # @return [Array] + attr_reader :workflow_pipeline + + # @return [Boolean] + attr_reader :workflow + + # @return [MiddlewareStack] + attr_reader :middleware_stack + + # @param extensions [ExtensionSet] + # @param attribute_specs [Array] + # @param returns [Array] + # @param callbacks [Hash] + # @param retry_policy [RetryPolicy, nil] + # @param rollback_on [Array] + # @param task_breakpoints [Array] + # @param workflow_breakpoints [Array] + # @param dump_context [Boolean] + # @param freeze_results [Boolean] + # @param backtrace [Boolean] + # @param backtrace_cleaner [Proc, nil] + # @param exception_handler [Proc, nil] + # @param deprecate [Object] + # @param tags [Array] + # @param strong_context [Boolean] + # @param workflow [Boolean] + # @param workflow_pipeline [Array] + def initialize( # rubocop:disable Metrics/ParameterLists + extensions:, + attribute_specs:, + returns:, + callbacks:, + retry_policy:, + rollback_on:, + task_breakpoints:, + workflow_breakpoints:, + dump_context:, + freeze_results:, + backtrace:, + backtrace_cleaner:, + exception_handler:, + deprecate:, + tags:, + strong_context: false, + workflow: false, + workflow_pipeline: [] + ) + @extensions = extensions + @attribute_specs = attribute_specs.freeze + @returns = returns.freeze + @callbacks = callbacks.freeze + @retry_policy = retry_policy + @rollback_on = rollback_on.freeze + @task_breakpoints = task_breakpoints.freeze + @workflow_breakpoints = workflow_breakpoints.freeze + @dump_context = dump_context + @freeze_results = freeze_results + @backtrace = backtrace + @backtrace_cleaner = backtrace_cleaner + @exception_handler = exception_handler + @deprecate = deprecate + @tags = tags.freeze + @strong_context = strong_context + @workflow = workflow + @workflow_pipeline = workflow_pipeline.freeze + @middleware_stack = MiddlewareStack.new(extensions.middleware) + freeze + end + + # @param config [Configuration] + # @return [Definition] + def self.root(config) + callbacks = CALLBACK_PHASES.to_h { |p| [p, [].freeze] } + new( + extensions: config.extensions, + attribute_specs: [], + returns: [], + callbacks:, + retry_policy: nil, + rollback_on: config.rollback_on.map(&:to_sym), + task_breakpoints: config.task_breakpoints.map(&:to_sym), + workflow_breakpoints: config.workflow_breakpoints.map(&:to_sym), + dump_context: config.dump_context, + freeze_results: config.freeze_results, + backtrace: config.backtrace, + backtrace_cleaner: config.backtrace_cleaner, + exception_handler: config.exception_handler, + deprecate: nil, + tags: [], + strong_context: false, + workflow: false, + workflow_pipeline: [] + ) + end + + # @param klass [Class] + # @return [Definition] + def self.fetch(klass) + klass.instance_variable_get(:@cmdx_definition) || klass.instance_variable_set(:@cmdx_definition, compile(klass)) + end + + # @param klass [Class] + # @return [Definition] + def self.compile(klass) + parent = + if klass.superclass.is_a?(Class) && klass.superclass < CMDx::Task && klass.superclass != CMDx::Task + fetch(klass.superclass) + else + CMDx.configuration.base_definition + end + + delta = klass.cmdx_extension_delta + extensions = parent.extensions.merge( + ExtensionSet.new( + coercions: delta[:coercions] || {}, + validators: delta[:validators] || {}, + middleware: delta[:middleware] || [] + ) + ) + + attrs = parent.attribute_specs.to_h { |s| [s.reader_name, s] } + klass.cmdx_declared_attributes.each { |s| attrs[s.reader_name] = s } + attribute_specs = attrs.values + + returns = (parent.returns + klass.cmdx_returns).uniq + + callbacks = parent.callbacks.transform_values(&:dup) + klass.cmdx_callback_deltas.each do |phase, entries| + cur = callbacks[phase] || [] + callbacks[phase] = cur + entries + end + + callbacks.transform_values!(&:freeze) + + retry_policy = ivar_or(klass, :@cmdx_retry_policy, parent.retry_policy) + + new( + extensions:, + attribute_specs:, + returns:, + callbacks:, + retry_policy:, + rollback_on: ivar_or(klass, :@cmdx_rollback_on, parent.rollback_on), + task_breakpoints: ivar_or(klass, :@cmdx_task_breakpoints, parent.task_breakpoints), + workflow_breakpoints: ivar_or(klass, :@cmdx_workflow_breakpoints, parent.workflow_breakpoints), + dump_context: ivar_or(klass, :@cmdx_dump_context, parent.dump_context), + freeze_results: ivar_or(klass, :@cmdx_freeze_results, parent.freeze_results), + backtrace: ivar_or(klass, :@cmdx_backtrace, parent.backtrace), + backtrace_cleaner: ivar_or(klass, :@cmdx_backtrace_cleaner, parent.backtrace_cleaner), + exception_handler: ivar_or(klass, :@cmdx_exception_handler, parent.exception_handler), + deprecate: ivar_or(klass, :@cmdx_deprecate, parent.deprecate), + tags: (parent.tags + Array(ivar_or(klass, :@cmdx_tags, []))).uniq, + strong_context: ivar_or(klass, :@cmdx_strong_context, parent.strong_context), + workflow: klass.cmdx_workflow? || parent.workflow, + workflow_pipeline: klass.cmdx_workflow? ? klass.cmdx_workflow_pipeline.dup.freeze : parent.workflow_pipeline + ) + end + + # @param klass [Class] + # @param ivar [Symbol] + # @param fallback [Object] + # @return [Object] + def self.ivar_or(klass, ivar, fallback) + klass.instance_variable_defined?(ivar) ? klass.instance_variable_get(ivar) : fallback + end + + end +end diff --git a/lib/cmdx/deprecator.rb b/lib/cmdx/deprecator.rb index ba1fac7d1..7600428e7 100644 --- a/lib/cmdx/deprecator.rb +++ b/lib/cmdx/deprecator.rb @@ -1,28 +1,20 @@ # frozen_string_literal: true 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. + # Task-level deprecation policy resolved from {Definition#deprecate}. 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 @@ -37,35 +29,14 @@ module Deprecator 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 - # - # @example - # class MyTask - # settings(deprecate: :warn) - # end - # - # MyTask.new # => [MyTask] DEPRECATED: migrate to a replacement or discontinue use - # - # @rbs (Task task) -> void + # @param task [Task] + # @return [void] def restrict(task) - setting = task.class.settings.deprecate + setting = task.class.definition.deprecate return unless setting case type = EVAL.call(task, setting) - when NilClass, FalseClass then nil # Do nothing + when NilClass, FalseClass then nil 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) diff --git a/lib/cmdx/error_detail.rb b/lib/cmdx/error_detail.rb new file mode 100644 index 000000000..137906e80 --- /dev/null +++ b/lib/cmdx/error_detail.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module CMDx + # Single validation or attribute error entry. + # + # @!attribute [r] attribute + # @return [Symbol] + # @!attribute [r] message + # @return [String] + # @!attribute [r] code + # @return [Symbol, nil] + class ErrorDetail + + attr_reader :attribute, :message, :code + + # @param attribute [Symbol] + # @param message [String] + # @param code [Symbol, nil] + def initialize(attribute, message, code = nil) + @attribute = attribute.to_sym + @message = message.to_s + @code = code + end + + # @return [String] + def full_message + "#{attribute} #{message}" + end + + end +end diff --git a/lib/cmdx/errors.rb b/lib/cmdx/errors.rb index eb907d72b..934c88492 100644 --- a/lib/cmdx/errors.rb +++ b/lib/cmdx/errors.rb @@ -1,116 +1,56 @@ # frozen_string_literal: true 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. + # Structured errors keyed by attribute, with optional codes. class Errors - extend Forwardable + # @return [Array] + attr_reader :details - # 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: # } - # - # @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 = {} + @details = [] 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") - # - # @rbs (Symbol attribute, String message) -> void - def add(attribute, message) - return if message.empty? + # @param attribute [Symbol] + # @param message [String] + # @param code [Symbol, nil] + # @return [void] + def add(attribute, message, code = nil) + return if message.nil? || message.empty? - messages[attribute] ||= Set.new - messages[attribute] << message + @details << ErrorDetail.new(attribute, message, code) end - # Check if there are any errors for a specific attribute. - # - # @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 - # - # @rbs (Symbol attribute) -> bool - def for?(attribute) - set = messages[attribute] - !set.nil? && !set.empty? + # @return [Boolean] + def empty? + @details.empty? end - # Convert errors to a hash format with arrays of full messages. - # - # @return [Hash{Symbol => Array}] Hash with attribute keys and message arrays - # - # @example - # errors.full_messages # => { email: ["email must be valid format", "email cannot be blank"] } - # - # @rbs () -> Hash[Symbol, Array[String]] - def full_messages - messages.each_with_object({}) do |(attribute, messages), hash| - hash[attribute] = messages.map { |message| "#{attribute} #{message}" } - end + # @return [Boolean] + def any? + !empty? 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"] } - # - # @rbs () -> Hash[Symbol, Array[String]] - def to_h - messages.transform_values(&:to_a) + # @return [void] + def clear + @details.clear end - # Convert errors to a hash format with optional full messages. - # - # @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"] } - # - # @rbs (?bool full) -> Hash[Symbol, Array[String]] - def to_hash(full = false) - full ? full_messages : to_h + # @param attribute [Symbol] + # @return [Boolean] + def for?(attribute) + sym = attribute.to_sym + @details.any? { |d| d.attribute == sym } + end + + # @return [Hash{Symbol => Array}] + def to_h + @details.group_by(&:attribute).transform_values { |list| list.map(&:message) } 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" - # - # @rbs () -> String + # @return [String] def to_s - full_messages.values.flatten.join(". ") + @details.map(&:full_message).join(", ") end end diff --git a/lib/cmdx/exception.rb b/lib/cmdx/exception.rb index 9d7151840..9cfe17dbc 100644 --- a/lib/cmdx/exception.rb +++ b/lib/cmdx/exception.rb @@ -2,45 +2,28 @@ 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) + # Root error type for CMDx. Does not alias Ruby's +Exception+. + class Error < StandardError + end + + # Raised when attribute coercion fails. + class CoercionError < Error + end + + # Raised when a deprecated task is used (see {Deprecator}). + class DeprecationError < Error + end + + # Raised when +work+ is not implemented on a concrete task. + class UndefinedMethodError < Error + end + + # Raised when attribute validation fails in exception-raising mode. + class ValidationError < Error + end + + # Raised when execution exceeds a timeout budget (middleware). + class TimeoutError < Interrupt + end end diff --git a/lib/cmdx/execution_result.rb b/lib/cmdx/execution_result.rb new file mode 100644 index 000000000..b61bebc4c --- /dev/null +++ b/lib/cmdx/execution_result.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +module CMDx + # Public object returned from {Task.execute}; read-only view of a finished run. + class ExecutionResult + + extend Forwardable + + # @return [Session] + attr_reader :session + + # @return [Task] + attr_reader :handler + + def_delegators :session, :context, :outcome, :trace, :errors + + def_delegators :outcome, :state, :status, :reason, :metadata, :cause, :retries, :rolled_back? + + # @param session [Session] + # @param handler [Task] + def initialize(session:, handler:) + @session = session + @handler = handler + end + + # @return [Task] + alias task handler + + # @return [Trace] v1 called this +chain+ + alias chain trace + + # @return [Boolean] + def success? + outcome.complete? && outcome.success? + end + + # @return [Boolean] + def failed? + outcome.interrupted? && outcome.failed? + end + + # @return [Boolean] + def skipped? + outcome.interrupted? && outcome.skipped? + end + + # @return [Boolean] + def executed? + outcome.executed? + end + + # @return [Boolean] + def good? + !failed? + end + + alias ok? good? + + # @return [Boolean] + def bad? + !success? + end + + # @return [Symbol] + def outcome_label + if outcome.complete? || outcome.interrupted? + outcome.status + else + outcome.state + end + end + + # @param states [Array] + # @yieldparam self [ExecutionResult] + # @return [self] + def on(*states, &block) + raise ArgumentError, "block required" unless block + + yield self if states.any? { |s| public_send(:"#{s}?") } + self + end + + # @return [Hash{Symbol => Object}] + def to_h + h = { + trace_id: trace.id, + type: handler.class.task_kind, + class: handler.class.name, + state: outcome.state, + status: outcome.status, + outcome: outcome_label, + reason: outcome.reason, + metadata: outcome.metadata.dup + } + h[:context] = context.to_h.dup if handler.class.definition.dump_context + h + end + + end +end diff --git a/lib/cmdx/executor.rb b/lib/cmdx/executor.rb index 5a2e2711b..93de5b04e 100644 --- a/lib/cmdx/executor.rb +++ b/lib/cmdx/executor.rb @@ -1,377 +1,228 @@ # 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. + # Phases: middleware, validation, work, returns, callbacks, telemetry, freeze. 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 - - # @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 - result.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) - task.class.settings.exception_handler&.call(task, e) - ensure - result.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 - result.throw!(e.result, halt: false, cause: e) - - if halt_execution?(e) - raise_exception(e) - else - result.executed! - post_execution! - end - rescue StandardError => e - retry if retry_execution?(e) - result.fail!(Utils::Normalize.exception(e), halt: false, cause: e, source: :exception) - raise_exception(e) - else - result.executed! - post_execution! - end + STATE_CALLBACKS = { + complete: :on_complete, + interrupted: :on_interrupted + }.freeze - verify_middleware_yield! - finalize_execution! - end + STATUS_CALLBACKS = { + success: :on_success, + skipped: :on_skipped, + failed: :on_failed + }.freeze - 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) + # @param handler [Task] + def initialize(handler) + @handler = handler 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? + # @param raise_on_fault [Boolean] + # @return [ExecutionResult] + def run(raise_on_fault: false) + Deprecator.restrict(@handler) + definition = Definition.fetch(@handler.class) + trace = @handler.execution_trace || Trace.root(id_generator: CMDx.configuration.id_generator) + logger = resolve_logger(definition) + raw = @handler.raw_input_hash + session = Session.new(definition:, handler: @handler, raw_input: raw, trace:, logger:) + @handler.setup_session!(session) - true - end + env = MiddlewareEnv.new(session:, handler: @handler) + definition.middleware_stack.call(env) { inner_run(session) } - # 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 + verify_middleware_completed!(session) + finalize!(session) + result = ExecutionResult.new(session:, handler: @handler) - # 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) + raise_fault!(result, raise_on_fault:) + result 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? - - result.fail!( - Locale.t("cmdx.faults.invalid"), - source: :validation, - errors: { - full_message: task.errors.to_s, - messages: task.errors.to_h - } - ) + # @param definition [Definition] + # @return [Logger] + def resolve_logger(_definition) + CMDx.configuration.logger 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) - - result.executing! - catch(:cmdx_halt) { task.work } + # @param session [Session] + # @return [void] + def inner_run(session) + CallbackRunner.run(session, :before_validation) + AttributePipeline.apply_all(session) + + if session.errors.empty? + CallbackRunner.run(session, :before_execution) + session.outcome.executing! + catch(Outcome::HALT_TAG) do + if session.definition.workflow + WorkflowRunner.run(session) + else + session.handler.work + end + end + verify_returns!(session) if session.outcome.success? + else + session.outcome.fail!( + Locale.t("cmdx.faults.invalid"), + halt: false, + source: :validation, + errors: session.errors.to_h + ) + end + rescue UndefinedMethodError => e + session.handler.class.reset_cmdx_definition! + raise e + rescue CMDx::Fault => e + raise e + rescue StandardError => e + retry if retry?(session, e) + + session.outcome.fail!( + Utils::Normalize.exception(e), + halt: false, + cause: e, + source: :exception + ) + session.definition.exception_handler&.call(session.handler, e) + ensure + session.outcome.executed! if session.outcome.executing? + post_execution_callbacks(session) 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) } + # @param session [Session] + # @return [void] + def verify_returns!(session) + missing = session.definition.returns.reject { |k| session.context.key?(k) } return if missing.empty? - missing.each { |name| task.errors.add(name, Locale.t("cmdx.returns.missing")) } - - result.fail!( + missing.each { |name| session.errors.add(name, Locale.t("cmdx.returns.missing")) } + session.outcome.fail!( Locale.t("cmdx.faults.invalid"), + halt: false, source: :context, - errors: { - full_message: task.errors.to_s, - messages: task.errors.to_h - } + errors: session.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? + # @param session [Session] + # @return [void] + def post_execution_callbacks(session) + outcome = session.outcome + return if session.definition.callbacks.values.all?(&:empty?) - invoke_callbacks(STATUS_CALLBACKS[result.status]) - invoke_callbacks(:on_good) if result.good? - invoke_callbacks(:on_bad) if result.bad? + CallbackRunner.run(session, STATE_CALLBACKS[outcome.state]) if STATE_CALLBACKS.key?(outcome.state) + CallbackRunner.run(session, :on_executed) if outcome.executed? + CallbackRunner.run(session, STATUS_CALLBACKS[outcome.status]) if STATUS_CALLBACKS.key?(outcome.status) + CallbackRunner.run(session, :on_good) if outcome.good? + CallbackRunner.run(session, :on_bad) if outcome.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? + # @param session [Session] + # @return [void] + def verify_middleware_completed!(session) + return unless session.outcome.initialized? - result.fail!( - Locale.t("cmdx.faults.invalid"), - halt: false, - source: :middleware - ) - result.executed! + session.outcome.fail!(Locale.t("cmdx.faults.invalid"), halt: false, source: :middleware) + session.outcome.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! + # @param session [Session] + # @return [void] + def finalize!(session) + emit_telemetry(session) + log_backtrace(session) if session.definition.backtrace && session.outcome.failed? + rollback_if_needed(session) + freeze_all!(session) end - # Logs the execution result at the configured log level. - # - # @rbs () -> void - def log_execution! - task.logger.info { result.to_h } + # @param session [Session] + # @return [void] + def emit_telemetry(session) + sink = CMDx.configuration.telemetry + if sink.is_a?(Telemetry) + sink.emit(:cmdx_execute, session.handler.to_h.merge(session.outcome.metadata)) + else + session.logger.info { session.handler.to_h.merge(session.outcome.metadata).inspect } + end 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) + # @param session [Session] + # @return [void] + def log_backtrace(session) + exc = session.outcome.cause + return if exc.nil? || exc.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 + session.logger.error do + Utils::Normalize.exception(exc) << "\n" << format_backtrace(session, exc) 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 + # @param session [Session] + # @param exc [Exception] + # @return [String] + def format_backtrace(session, exc) + if (cleaner = session.definition.backtrace_cleaner) + cleaner.call(Array(exc.backtrace)).join("\n\t") + else + exc.full_message(highlight: false) + end + end - task.freeze - result.freeze + # @param session [Session] + # @return [void] + def rollback_if_needed(session) + return if session.outcome.rolled_back? + return unless session.handler.respond_to?(:rollback) - # Freezing the context and chain can only be done once the outer-most - # task has completed. - return unless result.index.zero? + return unless session.definition.rollback_on.include?(session.outcome.status) - task.context.freeze - task.chain.freeze + session.outcome.rolled_back = true + session.handler.rollback 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 + # @param session [Session] + # @return [void] + def freeze_all!(session) + return unless session.definition.freeze_results + + session.handler.freeze + session.outcome.freeze + session.context.freeze + end + + # @param session [Session] + # @param exception [Exception] + # @return [Boolean] + def retry?(session, exception) + policy = session.definition.retry_policy + return false if policy.nil? || !policy.retry_exception?(exception) + return false unless session.outcome.retries < policy.max_attempts + + session.outcome.retries += 1 + session.errors.clear + wait = policy.wait_seconds(session) + CMDx.configuration.sleep_impl.call(wait) if wait.positive? + true end - # Rolls back the work of a task. - # - # @rbs () -> void - def rollback_execution! - return if result.rolled_back? - return unless task.respond_to?(:rollback) + # @param result [ExecutionResult] + # @param raise_on_fault [Boolean] + # @return [void] + def raise_fault!(result, raise_on_fault:) + return unless raise_on_fault + return if result.success? - @rollback_statuses ||= Utils::Normalize.statuses(task.class.settings.rollback_on).freeze - return unless @rollback_statuses.include?(result.status) + bps = result.handler.class.definition.task_breakpoints + return unless bps.include?(result.status) - result.rolled_back = true - task.rollback + fault_class = result.skipped? ? SkipFault : FailFault + raise fault_class, result end end diff --git a/lib/cmdx/extension_set.rb b/lib/cmdx/extension_set.rb new file mode 100644 index 000000000..4a7304d27 --- /dev/null +++ b/lib/cmdx/extension_set.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +module CMDx + # Coercions, validators, and middleware entries merged along the inheritance chain. + class ExtensionSet + + # @return [Hash{Symbol => Proc}] + attr_reader :coercions + + # @return [Hash{Symbol => Proc}] + attr_reader :validators + + # @return [Array] middleware callable and options pairs + attr_reader :middleware + + # @param coercions [Hash{Symbol => Proc}] + # @param validators [Hash{Symbol => Proc}] + # @param middleware [Array] + def initialize(coercions: {}, validators: {}, middleware: []) + @coercions = coercions.transform_keys(&:to_sym).freeze + @validators = validators.transform_keys(&:to_sym).freeze + @middleware = middleware.dup.freeze + freeze + end + + # @param other [ExtensionSet] + # @return [ExtensionSet] + def merge(other) + self.class.new( + coercions: @coercions.merge(other.coercions), + validators: @validators.merge(other.validators), + middleware: @middleware + other.middleware + ) + end + + # @return [ExtensionSet] + def self.build_defaults + new( + coercions: default_coercions, + validators: default_validators, + middleware: [] + ) + end + + # @return [Hash{Symbol => Proc}] + def self.default_coercions + { + array: wrap_coercion(Coercions::Array), + big_decimal: wrap_coercion(Coercions::BigDecimal), + boolean: wrap_coercion(Coercions::Boolean), + complex: wrap_coercion(Coercions::Complex), + date: wrap_coercion(Coercions::Date), + datetime: wrap_coercion(Coercions::DateTime), + float: wrap_coercion(Coercions::Float), + hash: wrap_coercion(Coercions::Hash), + integer: wrap_coercion(Coercions::Integer), + rational: wrap_coercion(Coercions::Rational), + string: wrap_coercion(Coercions::String), + symbol: wrap_coercion(Coercions::Symbol), + time: wrap_coercion(Coercions::Time) + } + end + + # @return [Hash{Symbol => Proc}] + def self.default_validators + { + absence: wrap_validator(Validators::Absence), + exclusion: wrap_validator(Validators::Exclusion), + format: wrap_validator(Validators::Format), + inclusion: wrap_validator(Validators::Inclusion), + length: wrap_validator(Validators::Length), + numeric: wrap_validator(Validators::Numeric), + presence: wrap_validator(Validators::Presence) + } + end + + # @param mod [Module] + # @return [Proc] + def self.wrap_coercion(mod) + lambda do |value, **kwargs| + mod.call(value, kwargs.except(:context, :attribute)) + end + end + + # @param mod [Module] + # @return [Proc] + def self.wrap_validator(mod) + lambda do |value, **kwargs| + mod.call(value, kwargs.except(:context, :attribute)) + end + end + + end +end diff --git a/lib/cmdx/fault.rb b/lib/cmdx/fault.rb index d43191754..3592cfd21 100644 --- a/lib/cmdx/fault.rb +++ b/lib/cmdx/fault.rb @@ -2,108 +2,58 @@ 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 fault for +execute!+ when outcome matches {Definition#task_breakpoints}. class Fault < Error extend Forwardable - # Returns the result that caused this fault. - # - # @return [Result] The result instance - # - # @example - # fault.result.reason # => "Validation failed" - # - # @rbs @result: Result + # @return [ExecutionResult] attr_reader :result - def_delegators :result, :task, :context, :chain + def_delegators :result, :task, :context, :trace - # 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" - # - # @rbs (Result result) -> void + # @param result [ExecutionResult] def initialize(result) @result = result - super(result.reason) end + # @return [Trace] v1 compatibility name + def chain + trace + 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 + # @param tasks [Array] + # @return [Class] def for?(*tasks) - temp_fault = Class.new(self) do + klass = 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) } + klass.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 + # @yieldparam fault [Fault] + # @return [Class] def matches?(&block) - raise ArgumentError, "block required" unless block_given? + raise ArgumentError, "block required" unless block - temp_fault = Class.new(self) do - def self.===(other) - other.is_a?(superclass) && @block.call(other) - end + blk = block + klass = Class.new(self) + klass.define_singleton_method(:===) do |other| + other.is_a?(Fault) && blk.call(other) end - - temp_fault.tap { |c| c.instance_variable_set(:@block, block) } + klass end 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) - - # 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) end diff --git a/lib/cmdx/locale.rb b/lib/cmdx/locale.rb index 5d1bf3847..9b4573949 100644 --- a/lib/cmdx/locale.rb +++ b/lib/cmdx/locale.rb @@ -1,77 +1,65 @@ # 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. + # Minimal i18n: +I18n+ when loaded, else English defaults from YAML when present. module Locale extend self - # 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. - # - # @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 - # - # @return [String] The translated message - # - # @raise [ArgumentError] When interpolation fails due to missing keys - # - # @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" - # - # @rbs ((String | Symbol) key, **untyped options) -> String + # @param key [String, Symbol] + # @param options [Hash] + # @return [String] def translate(key, **options) - options[:default] ||= translation_default(key) + options[:default] ||= default_for(key) return ::I18n.t(key, **options) if defined?(::I18n) case message = options.delete(:default) - when String then message % options + when String then format_string(message, options) when NilClass then "Translation missing: #{key}" else message 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 - # - # @return [String, nil] The resolved translation or nil - # - # @rbs ((String | Symbol) key) -> String? - def translation_default(key) - tkey = "#{CMDx.configuration.default_locale}.#{key}" + # @param message [String] + # @param options [Hash] + # @return [String] + def format_string(message, options) + return message if options.empty? + + message % options + end - @translation_defaults ||= {} - return @translation_defaults[tkey] if @translation_defaults.key?(tkey) + # @param key [String, Symbol] + # @return [String, nil] + def default_for(key) + root = translations["en"] || translations[:en] + return nil unless root.is_a?(Hash) - @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? + parts = key.to_s.split(".") + value = parts.reduce(root) do |h, p| + break nil unless h.is_a?(Hash) - YAML.load_file(path).freeze + h[p] || h[p.to_sym] end + value.is_a?(String) ? value : nil + end - @translation_defaults[tkey] = @default_translations.dig(*tkey.split(".")) + # @return [Hash] + def translations + @translations ||= begin + path = CMDx.gem_path.join("lib/locales/en.yml") + if path.file? + YAML.safe_load(path.read, permitted_classes: [Symbol], aliases: true) || {} + else + {} + end + end end end diff --git a/lib/cmdx/log_formatters/json.rb b/lib/cmdx/log_formatters/json.rb deleted file mode 100644 index 3a4b60672..000000000 --- a/lib/cmdx/log_formatters/json.rb +++ /dev/null @@ -1,40 +0,0 @@ -# frozen_string_literal: true - -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 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 - # - # @return [String] A JSON-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) - } - - ::JSON.dump(hash) << "\n" - end - - end - end -end diff --git a/lib/cmdx/log_formatters/key_value.rb b/lib/cmdx/log_formatters/key_value.rb deleted file mode 100644 index 87e0f4087..000000000 --- a/lib/cmdx/log_formatters/key_value.rb +++ /dev/null @@ -1,40 +0,0 @@ -# frozen_string_literal: true - -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. - class KeyValue - - # Formats a log entry as a key-value 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 - # - # @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) - } - - Utils::Format.to_str(hash) << "\n" - end - - end - end -end diff --git a/lib/cmdx/log_formatters/line.rb b/lib/cmdx/log_formatters/line.rb deleted file mode 100644 index a58b32ad4..000000000 --- a/lib/cmdx/log_formatters/line.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -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. - class Line - - # Formats a log entry as a single-line 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 - # - # @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" - end - - end - end -end diff --git a/lib/cmdx/log_formatters/logstash.rb b/lib/cmdx/log_formatters/logstash.rb deleted file mode 100644 index 4df2b9082..000000000 --- a/lib/cmdx/log_formatters/logstash.rb +++ /dev/null @@ -1,42 +0,0 @@ -# frozen_string_literal: true - -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. - 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 - # - # @return [String] A Logstash-compatible JSON-formatted log entry with a trailing newline - # - # @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), - "@version" => "1", - "@timestamp" => time.utc.iso8601(6) - } - - ::JSON.dump(hash) << "\n" - end - - end - end -end diff --git a/lib/cmdx/log_formatters/raw.rb b/lib/cmdx/log_formatters/raw.rb deleted file mode 100644 index 57a43fcdc..000000000 --- a/lib/cmdx/log_formatters/raw.rb +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal: true - -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. - class Raw - - # Formats a log entry as raw text - # - # @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] 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" - end - - end - end -end diff --git a/lib/cmdx/middleware_env.rb b/lib/cmdx/middleware_env.rb new file mode 100644 index 000000000..a28ae9ec8 --- /dev/null +++ b/lib/cmdx/middleware_env.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module CMDx + # Rack-style environment passed through the middleware chain. + class MiddlewareEnv + + # @return [Session] + attr_reader :session + + # @return [Task] + attr_reader :handler + + # @param session [Session] + # @param handler [Task] + def initialize(session:, handler:) + @session = session + @handler = handler + end + + end +end diff --git a/lib/cmdx/middleware_registry.rb b/lib/cmdx/middleware_registry.rb deleted file mode 100644 index a1d5070dc..000000000 --- a/lib/cmdx/middleware_registry.rb +++ /dev/null @@ -1,148 +0,0 @@ -# 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. - 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 - - # 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 - 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 - # - # @example - # registry.register(LoggingMiddleware, at: 0, log_level: :debug) - # registry.register(AuthMiddleware, at: -1, timeout: 30) - # - # @rbs (untyped middleware, ?at: Integer, **untyped options) -> self - def register(middleware, at: -1, **options) - materialize! - - @registry.insert(at, [middleware, options]) - self - end - - # Remove a middleware component from the registry. - # - # @param middleware [Object] The middleware object to remove - # - # @return [MiddlewareRegistry] Returns self for method chaining - # - # @example - # registry.deregister(LoggingMiddleware) - # - # @rbs (untyped middleware) -> self - def deregister(middleware) - materialize! - - @registry.reject! { |mw, _opts| mw == middleware } - self - 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 - # - # @raise [ArgumentError] When no block is provided - # - # @example - # result = registry.call!(my_task) do |processed_task| - # processed_task.execute - # end - # - # @rbs (untyped task) { (untyped) -> untyped } -> untyped - def call!(task, &) - raise ArgumentError, "block required" unless block_given? - - recursively_call_middleware(0, task, &) - end - - private - - # Copies the parent's registry data into this instance, - # severing the copy-on-write link. - # - # @rbs () -> void - def materialize! - return if @registry - - @registry = @parent.registry.map(&:dup) - @parent = nil - 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 - # - # @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) } - end - - end -end diff --git a/lib/cmdx/middleware_stack.rb b/lib/cmdx/middleware_stack.rb new file mode 100644 index 000000000..06e63eece --- /dev/null +++ b/lib/cmdx/middleware_stack.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module CMDx + # Iterative middleware chain; each middleware must yield exactly once to continue. + class MiddlewareStack + + # @param entries [Array] + def initialize(entries) + @entries = entries.freeze + end + + # @param env [MiddlewareEnv] + # @yield runs inner execution + # @return [Object] yield result + def call(env, &inner) + raise ArgumentError, "block required" unless inner + + dispatch(env, 0, &inner) + end + + private + + # @param env [MiddlewareEnv] + # @param index [Integer] + # @return [Object] + def dispatch(env, index, &inner) + return yield if index >= @entries.size + + callable, options = @entries[index] + callable.call(env, **options) { dispatch(env, index + 1, &inner) } + end + + end +end diff --git a/lib/cmdx/middlewares/correlate.rb b/lib/cmdx/middlewares/correlate.rb index aa9fcdf0f..c97e1b539 100644 --- a/lib/cmdx/middlewares/correlate.rb +++ b/lib/cmdx/middlewares/correlate.rb @@ -2,137 +2,32 @@ 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. + # Pushes +correlation_id+ (from option, handler method, proc, or trace) into metadata. module Correlate extend self - # @rbs CONCURRENCY_KEY: Symbol - CONCURRENCY_KEY = :cmdx_correlate + # @param env [MiddlewareEnv] + # @param options [Hash] + # @return [Object] + def call(env, **options) + handler = env.handler + return yield unless Utils::Condition.evaluate(handler, options) - # 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 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 - 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 || + correlation_id = case callable = options[:id] - when Symbol then task.send(callable) - when Proc then task.instance_eval(&callable) + when Symbol then handler.send(callable) + when Proc then handler.instance_eval(&callable) else if callable.respond_to?(:call) - callable.call(task) + callable.call(handler) else - callable || id || Identifier.generate + callable || env.session.trace.id 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 + env.session.outcome.merge_metadata!(correlation_id: correlation_id) + yield end end diff --git a/lib/cmdx/middlewares/runtime.rb b/lib/cmdx/middlewares/runtime.rb index 09d102ed6..5aacce312 100644 --- a/lib/cmdx/middlewares/runtime.rb +++ b/lib/cmdx/middlewares/runtime.rb @@ -2,76 +2,29 @@ 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. + # Records monotonic runtime and timestamps on the outcome metadata. 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) + # @param env [MiddlewareEnv] + # @param options [Hash] + # @return [Object] + def call(env, **options) + handler = env.handler + return yield unless Utils::Condition.evaluate(handler, options) + started_m = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) + started_u = Time.now.utc.iso8601 result = yield - task.result.metadata.merge!( - runtime: monotonic_time - mnow, - ended_at: utc_time, - started_at: unow + env.session.outcome.merge_metadata!( + runtime: Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) - started_m, + started_at: started_u, + ended_at: Time.now.utc.iso8601 ) 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/timeout.rb b/lib/cmdx/middlewares/timeout.rb index 8c40dee74..f1146bc2b 100644 --- a/lib/cmdx/middlewares/timeout.rb +++ b/lib/cmdx/middlewares/timeout.rb @@ -2,75 +2,40 @@ 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 wall-clock limits via Ruby +Timeout+ (optional middleware). 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 - # - # @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) + # @param env [MiddlewareEnv] + # @param options [Hash] + # @return [Object] + def call(env, **options, &) + handler = env.handler + return yield unless Utils::Condition.evaluate(handler, 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 + when Symbol then handler.send(callable) + when Proc then handler.instance_eval(&callable) + else callable.respond_to?(:call) ? callable.call(handler) : DEFAULT_LIMIT end limit = Float(limit) return yield unless limit.positive? - ::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 + ::Timeout.timeout(limit, CMDx::TimeoutError, "execution exceeded #{limit} seconds", &) + rescue CMDx::TimeoutError => e + env.session.outcome.fail!( + Utils::Normalize.exception(e), + halt: false, + cause: e, + source: :timeout, + limit: limit + ) end end diff --git a/lib/cmdx/outcome.rb b/lib/cmdx/outcome.rb new file mode 100644 index 000000000..b412e459b --- /dev/null +++ b/lib/cmdx/outcome.rb @@ -0,0 +1,181 @@ +# frozen_string_literal: true + +module CMDx + # Mutable execution outcome for a single run. Transitions are explicit. + class Outcome + + STATES = %i[initialized executing complete interrupted].freeze + STATUSES = %i[success skipped failed].freeze + + # Internal +catch+ tag for early exit from +work+. + HALT_TAG = :cmdx_v2_halt + + # @return [Symbol] + attr_reader :state + + # @return [Symbol] + attr_reader :status + + # @return [String, nil] + attr_reader :reason + + # @return [Exception, nil] + attr_reader :cause + + # @return [Hash{Symbol => Object}] + attr_reader :metadata + + # @return [Integer] + attr_accessor :retries + + # @return [Boolean] + attr_accessor :rolled_back + + # @return [Boolean] + def rolled_back? + !!@rolled_back + end + + def initialize + @state = :initialized + @status = :success + @reason = nil + @cause = nil + @metadata = {} + @retries = 0 + @rolled_back = false + end + + STATES.each do |s| + define_method(:"#{s}?") { @state == s } + end + + STATUSES.each do |s| + define_method(:"#{s}?") { @status == s } + end + + # @return [Boolean] + def executed? + complete? || interrupted? + end + + # @return [Boolean] + def good? + !failed? + end + + alias ok? good? + + # @return [Boolean] + def bad? + !success? + end + + # @return [void] + def executing! + return if executing? + + raise "invalid state #{@state}" unless initialized? + + @state = :executing + end + + # @return [void] + def complete! + return if complete? + + raise "invalid state #{@state}" unless executing? + + @state = :complete + end + + # @return [void] + def interrupt! + return if interrupted? + + raise "invalid state #{@state}" if complete? + + @state = :interrupted + end + + # @param reason [String, nil] + # @param halt [Boolean] + # @param metadata [Hash] + # @return [void] + def success!(reason = nil, halt: true, **metadata) + raise "invalid status #{@status}" unless success? + + @reason = reason if reason + merge_metadata!(metadata) + throw(HALT_TAG) if halt + end + + # @param reason [String, nil] + # @param halt [Boolean] + # @param cause [Exception, nil] + # @param metadata [Hash] + # @return [void] + def skip!(reason = nil, halt: true, cause: nil, **metadata) + return if skipped? + + raise "invalid status #{@status}" unless success? + + @state = :interrupted + @status = :skipped + @reason = reason || "Unspecified" + @cause = cause + merge_metadata!(metadata) + throw(HALT_TAG) if halt + end + + # @param reason [String, nil] + # @param halt [Boolean] + # @param cause [Exception, nil] + # @param metadata [Hash] + # @return [void] + def fail!(reason = nil, halt: true, cause: nil, **metadata) + return if failed? + + raise "invalid status #{@status}" unless success? + + @state = :interrupted + @status = :failed + @reason = reason || "Unspecified" + @cause = cause + merge_metadata!(metadata) + throw(HALT_TAG) if halt + end + + # @param other [Outcome] + # @param cause [Exception, nil] + # @param halt [Boolean] + # @param extra [Hash] + # @return [void] + def propagate_from!(other, cause: nil, halt: true, **extra) + @state = other.state + @status = other.status + @reason = other.reason + @cause = cause || other.cause + merge_metadata!(other.metadata.merge(extra)) + throw(HALT_TAG) if halt + end + + # @param hash [Hash{Symbol => Object}] + # @return [void] + def merge_metadata!(hash) + @metadata.merge!(hash) + end + + # @return [void] + def executed! + success? ? complete! : interrupt! + end + + # @return [self] + def freeze + @metadata.freeze + super + end + + end +end diff --git a/lib/cmdx/parallel.rb b/lib/cmdx/parallel.rb new file mode 100644 index 000000000..76bde3048 --- /dev/null +++ b/lib/cmdx/parallel.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module CMDx + # Pluggable concurrency; default MRI thread pool preserves submission order. + module Parallel + module Threads + + # @param items [Array] + # @param concurrency [Integer, nil] + # @yieldparam item [Object] + # @return [Array] + def self.call(items, concurrency: nil, &block) + pool_size = Integer(concurrency || items.size) + 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 + Thread.new do + while (entry = queue.pop) + item, index = entry + results[index] = yield(item) + end + end + end.each(&:join) + + results + end + + end + end +end diff --git a/lib/cmdx/parallelizer.rb b/lib/cmdx/parallelizer.rb deleted file mode 100644 index ea51f275f..000000000 --- a/lib/cmdx/parallelizer.rb +++ /dev/null @@ -1,100 +0,0 @@ -# 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. - 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 - # - # @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) - # - # @rbs (Array[untyped] items, ?pool_size: Integer) -> void - def initialize(items, pool_size: nil) - @items = items - @pool_size = Integer(pool_size || items.size) - end - - # Processes items concurrently and returns results in submission order. - # - # @param items [Array] the items to process concurrently - # @param pool_size [Integer] number of threads (defaults to item count) - # - # @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.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 - - # 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 - Thread.new do - while (entry = queue.pop) - item, index = entry - results[index] = block.call(item) # rubocop:disable Performance/RedundantBlockCall - end - end - end.each(&:join) - - results - end - - end -end diff --git a/lib/cmdx/pipeline.rb b/lib/cmdx/pipeline.rb deleted file mode 100644 index 40eeb6741..000000000 --- a/lib/cmdx/pipeline.rb +++ /dev/null @@ -1,169 +0,0 @@ -# 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. - 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 - # - # @example - # pipeline.workflow.context[:status] # => "processing" - # - # @rbs @workflow: Workflow - attr_reader :workflow - - # @param workflow [Workflow] The workflow to execute - # - # @return [Pipeline] A new pipeline instance - # - # @example - # pipeline = Pipeline.new(my_workflow) - # - # @rbs (Workflow workflow) -> void - def initialize(workflow) - @workflow = workflow - 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 - 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) - end - 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 - - # 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) - - workflow.throw!(task_result) - end - 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) - - results = Parallelizer.call(ctx_pairs, pool_size:) do |task, context| - Chain.current = workflow.chain - task.execute(context) - end - - contexts.each { |ctx| workflow.context.merge!(ctx) } - - faulted = results.select { |r| breakpoints.include?(r.status) } - return if faulted.empty? - - workflow.public_send( - :"#{faulted.last.status}!", - Locale.t("cmdx.reasons.unspecified"), - source: :parallel, - faults: faulted.map(&:to_h) - ) - end - - end -end diff --git a/lib/cmdx/result.rb b/lib/cmdx/result.rb deleted file mode 100644 index e13d213d1..000000000 --- a/lib/cmdx/result.rb +++ /dev/null @@ -1,636 +0,0 @@ -# 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. - 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 - - # @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 - - # Returns the task instance associated with this result. - # - # @return [CMDx::Task] The task instance - # - # @example - # result.task.id # => "users/create" - # - # @rbs @task: Task - attr_reader :task - - # Returns the current execution state of the result. - # - # @return [String] One of: "initialized", "executing", "complete", "interrupted" - # - # @example - # result.state # => "complete" - # - # @rbs @state: 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 - 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) - 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) - 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. - # When false, {CMDx::Executor#halt_execution?} returns false - # regardless of the task's breakpoint settings. - # - # @return [Boolean] Whether the result is strict - # - # @example - # result.strict? # => true - # - # @rbs @strict: bool - attr_reader :strict - - # Returns whether the result has been rolled back. - # - # @return [Boolean] Whether the result has been rolled back - # - # @example - # result.rolled_back? # => true - # - # @rbs @rolled_back: bool - attr_accessor :rolled_back - - def_delegators :task, :context, :chain, :errors, :dry_run? - alias ctx context - - # @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 - @retries = 0 - @strict = true - @rolled_back = false - 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 } - 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 - # result.executed? # => true if complete? || interrupted? - # - # @rbs () -> bool - 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 - # - # @example - # result.success? # => true - # result.failed? # => false - # - # @rbs () -> bool - define_method(:"#{s}?") { status == s } - end - - # @return [Boolean] Whether the task execution was successful (not failed) - # - # @example - # result.good? # => true if !failed? - # - # @rbs () -> bool - def good? - !failed? - 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? - 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? - - yield(self) if states_or_statuses.any? { |s| public_send(:"#{s}?") } - 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 - # cause = result.caused_failure - # puts "Caused by: #{cause.task.id}" if cause - # - # @rbs () -> Result? - def caused_failure - return unless failed? - - chain.results.reverse_each.find(&:failed?) - 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 - 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 - # - # @rbs () -> Result? - def threw_failure - return unless failed? - - current = index - last_failed = nil - - chain.results.each do |r| - next unless r.failed? - - return r if r.index > current - - last_failed = r - end - - last_failed - 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 - 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? - end - - # @return [Boolean] Whether the result has been retried - # - # @example - # result.retried? # => true - # - # @rbs () -> bool - def retried? - retries.positive? - end - - # @return [Boolean] Whether the result is strict - # - # @example - # result.strict? # => true - # - # @rbs () -> bool - def strict? - !!@strict - end - - # @return [Boolean] Whether the result has been rolled back - # - # @example - # result.rolled_back? # => true - # - # @rbs () -> bool - def rolled_back? - !!@rolled_back - 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) - end - - # @return [String] The outcome of the task execution - # - # @example - # result.outcome # => "success" or "interrupted" - # - # @rbs () -> String - def outcome - initialized? || thrown_failure? ? state : status - end - - # @return [Hash] Hash representation of the result - # - # @example - # result.to_h - # # => {state: "complete", status: "success", outcome: "success", reason: "Unspecified", metadata: {}} - # - # @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 - 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: state, - status: status, - reason: reason, - cause: cause, - metadata: metadata, - outcome: outcome, - executed: executed?, - good: good?, - bad: bad? - } - end - - 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_policy.rb b/lib/cmdx/retry_policy.rb new file mode 100644 index 000000000..7ca07d6c9 --- /dev/null +++ b/lib/cmdx/retry_policy.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module CMDx + # Declarative retry configuration resolved from definition / config. + class RetryPolicy + + # @return [Integer] + attr_reader :max_attempts + + # @return [Array] + attr_reader :retry_on + + # @return [Numeric, Proc, nil] + attr_reader :jitter + + # @param max_attempts [Integer] + # @param retry_on [Array] + # @param jitter [Numeric, Proc, nil] + def initialize(max_attempts:, retry_on: [], jitter: nil) + @max_attempts = Integer(max_attempts) + @retry_on = retry_on.freeze + @jitter = jitter + freeze + end + + # @param exception [Exception] + # @return [Boolean] + def retry_exception?(exception) + return false if @retry_on.empty? + + @retry_on.any? { |klass| exception.is_a?(klass) } + end + + # @param session [Session] + # @return [Numeric] seconds to sleep (may be 0) + def wait_seconds(session) + base = 0.0 + j = @jitter + case j + when Numeric then base + j.to_f + when Proc then session.handler.instance_exec(&j).to_f + else 0.0 + end + end + + end +end diff --git a/lib/cmdx/session.rb b/lib/cmdx/session.rb new file mode 100644 index 000000000..8136c0bbe --- /dev/null +++ b/lib/cmdx/session.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module CMDx + # One execution of a handler: holds context, outcome, trace, and raw input. + class Session + + # @return [Definition] + attr_reader :definition + + # @return [Task] + attr_reader :handler + + # @return [Context] + attr_reader :context + + # @return [Errors] + attr_reader :errors + + # @return [Outcome] + attr_reader :outcome + + # @return [Trace] + attr_reader :trace + + # @return [Hash{Symbol => Object}] + attr_reader :raw_input + + # @return [Logger] + attr_reader :logger + + # @param definition [Definition] + # @param handler [Task] + # @param raw_input [Hash] + # @param trace [Trace] + # @param logger [Logger] + def initialize(definition:, handler:, raw_input:, trace:, logger:) + @definition = definition + @handler = handler + @raw_input = raw_input.transform_keys(&:to_sym).freeze + @trace = trace + @errors = Errors.new + @outcome = Outcome.new + @context = Context.build(raw_input) + @logger = logger + end + + # @return [Telemetry, Logger] + def telemetry + CMDx.configuration.telemetry || CMDx.configuration.logger + end + + end +end diff --git a/lib/cmdx/settings.rb b/lib/cmdx/settings.rb deleted file mode 100644 index 920c20c6d..000000000 --- a/lib/cmdx/settings.rb +++ /dev/null @@ -1,226 +0,0 @@ -# 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. - class Settings - - class << self - - 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) - - value - end - end - end - - end - - # Returns the attribute registry for task parameters. - # - # @return [AttributeRegistry] The attribute registry - # - # @rbs @attributes: AttributeRegistry - attr_accessor :attributes - - # Returns the callback registry for task lifecycle hooks. - # - # @return [CallbackRegistry] The callback registry - # - # @rbs @callbacks: CallbackRegistry - attr_accessor :callbacks - - # Returns the coercion registry for type conversions. - # - # @return [CoercionRegistry] The coercion registry - # - # @rbs @coercions: CoercionRegistry - attr_accessor :coercions - - # Returns the middleware registry for task execution. - # - # @return [MiddlewareRegistry] The middleware registry - # - # @rbs @middlewares: MiddlewareRegistry - attr_accessor :middlewares - - # Returns the validator registry for attribute validation. - # - # @return [ValidatorRegistry] The validator registry - # - # @rbs @validators: ValidatorRegistry - attr_accessor :validators - - # Returns the expected return keys after execution. - # - # @return [Array] Expected return keys after execution - # - # @rbs @returns: Array[Symbol] - attr_accessor :returns - - # Returns the tags for task categorization. - # - # @return [Array] Tags for categorization - # - # @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 - - # 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 - # - # @example - # Settings.new(parent: ParentTask.settings, deprecate: true) - # - # @rbs (?parent: Settings?, **untyped overrides) -> void - def initialize(parent: nil, **overrides) - @parent = parent - - init_registries - init_collections - - overrides.each { |key, value| public_send(:"#{key}=", value) } - 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 - end - - # Initializes array-valued settings that need their own copy - # to avoid cross-class mutation. - # - # @rbs () -> void - def init_collections - @returns = @parent&.returns&.dup || EMPTY_ARRAY - @tags = @parent&.tags&.dup || EMPTY_ARRAY - end - - end -end diff --git a/lib/cmdx/task.rb b/lib/cmdx/task.rb index bd6d6e772..670c37b10 100644 --- a/lib/cmdx/task.rb +++ b/lib/cmdx/task.rb @@ -1,418 +1,412 @@ # 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. + # Command object: declare inputs with +required+ / +optional+, implement +work+. class Task - extend Forwardable + # @return [Session, nil] + attr_reader :session - # 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] + # @return [Hash{Symbol => Object}] 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 - 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. - # - # @return [Chain] The chain instance - # - # @example - # task.chain.results.size # => 3 - # - # @rbs @chain: Chain - attr_reader :chain - - def_delegators :result, :success!, :skip!, :fail!, :throw! - def_delegators :chain, :dry_run? - - # @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")) - # - # @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) - @chain = Chain.build(@result, dry_run: @context.delete(:dry_run)) + class << self - @attributes = {} - end + # @param sub [Class] + # @return [void] + def inherited(sub) + super + reset_cmdx_definition!(sub) + end - class << self + # @param klass [Class] + # @return [void] + def reset_cmdx_definition!(klass = self) + klass.remove_instance_variable(:@cmdx_definition) if klass.instance_variable_defined?(:@cmdx_definition) + end - # Returns the cached task type string for this class. - # - # @return [String] "Workflow" or "Task" - # - # @rbs () -> String - def type - @type ||= include?(Workflow) ? "Workflow" : "Task" - 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 - # - # @return [Settings] The settings instance for this task class - # - # @example - # class MyTask < Task - # settings deprecate: true, tags: [:experimental] - # end - # - # @rbs (**untyped overrides) -> Settings - def settings(**overrides) - @settings ||= begin - parent = superclass.settings if superclass.respond_to?(:settings) - Settings.new(parent:, **overrides) - end + # @return [Definition] + def definition + Definition.fetch(self) 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 + # @return [Boolean] + def cmdx_workflow? + false + end + + # @return [Array] + def cmdx_workflow_pipeline + @cmdx_workflow_pipeline ||= [] + end + + # @return [Hash] + def cmdx_extension_delta + @cmdx_extension_delta ||= { coercions: {}, validators: {}, middleware: [] } + end + + # @return [Array] + def cmdx_declared_attributes + @cmdx_declared_attributes ||= [] + end + + # @return [Array] + def cmdx_returns + @cmdx_returns ||= [] + end + + # @return [Hash{Symbol => Array}] + def cmdx_callback_deltas + @cmdx_callback_deltas ||= Hash.new { |h, k| h[k] = [] } + end + + # @return [String] + def task_kind + cmdx_workflow? ? "Workflow" : "Task" + end + + # @param input [Hash] + # @param trace [Trace, nil] + # @param raise_on_fault [Boolean] + # @return [ExecutionResult] + def execute(input = {}, trace: nil, raise_on_fault: false, **kwargs, &block) + merged = merge_input(input, kwargs) + handler = new(merged, trace: trace) + result = Executor.new(handler).run(raise_on_fault: raise_on_fault) + yield result if block_given? + result + end + + # @param input [Hash] + # @param trace [Trace, nil] + # @param kwargs [Hash] + # @return [ExecutionResult] + def execute!(input = {}, trace: nil, **kwargs, &block) + execute(input, trace: trace, raise_on_fault: true, **kwargs, &block) 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, ...) + # @param input [Object] + # @param kwargs [Hash] + # @return [Hash{Symbol => Object}] + def merge_input(input, kwargs) + base = input.is_a?(Hash) ? input : input.to_h + base.merge(kwargs).transform_keys(&:to_sym) + end + + # @param opts [Hash] + # @return [void] + def settings(**opts) + deprecate(opts[:deprecate]) if opts.key?(:deprecate) + tags(*opts[:tags]) if opts[:tags] + end + + # @param type [Symbol] + # @param args [Array] + # @param kwargs [Hash] + # @return [void] + def register(type, *args, **kwargs) 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}" + when :middleware + cmdx_extension_delta[:middleware] << [args.first, kwargs] + when :coercion + cmdx_extension_delta[:coercions][args.first.to_sym] = ExtensionSet.wrap_coercion(args.last) + when :validator + cmdx_extension_delta[:validators][args.first.to_sym] = ExtensionSet.wrap_validator(args.last) + else + raise ArgumentError, "unknown registry type #{type.inspect}" end + reset_cmdx_definition! end - # @example - # attributes :name, :email - # attributes :age, type: Integer, default: 18 - # - # @rbs (*untyped) -> void - def attributes(...) - register(:attribute, Attribute.build(...)) + # @param names [Array] + # @param options [Hash] + # @return [void] + def attributes(*names, **options) + names.flatten.each { |n| declare_attribute(n, required: false, **options) } end alias attribute attributes - # @example - # optional :description, :notes - # optional :priority, type: Symbol, default: :normal - # - # @rbs (*untyped) -> void - def optional(...) - register(:attribute, Attribute.optional(...)) - end - - # @example - # required :name, :email - # required :age, type: Integer, min: 0 - # - # @rbs (*untyped) -> void - def required(...) - register(:attribute, Attribute.required(...)) - end - - # @param names [Array] Names of attributes to remove - # - # @example - # remove_attributes :old_field, :deprecated_field - # - # @rbs (*Symbol names) -> void - def remove_attributes(*names) - deregister(:attribute, names) - 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. - # - # @param names [Array] Names of expected return keys in the context - # - # @example - # returns :user, :token - # - # @rbs (*untyped names) -> void + # @param names [Array] + # @param options [Hash] + # @return [void] + def optional(*names, **options) + names.flatten.each { |n| declare_attribute(n, required: false, **options) } + end + + # @param names [Array] + # @param options [Hash] + # @return [void] + def required(*names, **options) + names.flatten.each { |n| declare_attribute(n, required: true, **options) } + end + + # @param names [Array] + # @return [void] def returns(*names) - settings.returns |= names.map(&:to_sym) - end - - # Removes declared returns from the task. - # - # @param names [Array] Names of returns to remove - # - # @example - # remove_returns :old_return - # - # @rbs (*Symbol names) -> void - def remove_returns(*names) - settings.returns -= names.map(&:to_sym) - end - alias remove_return remove_returns - - # @return [Hash] Hash of attribute names to their configurations - # - # @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: [] } - # ] } - # } - # - # @rbs () -> Hash[Symbol, Hash[Symbol, untyped]] - def attributes_schema - Utils::Wrap.array(settings.attributes).to_h do |attr| - [attr.method_name, attr.to_h] + cmdx_returns.concat(names.map(&:to_sym)) + reset_cmdx_definition! + end + + # @param count [Integer] + # @param opts [Hash] + # @return [void] + def retries(count, **opts) + @cmdx_retry_policy = RetryPolicy.new( + max_attempts: count, + retry_on: Array(opts[:retry_on] || []), + jitter: opts[:retry_jitter] + ) + reset_cmdx_definition! + end + + # @param statuses [Array] + # @return [void] + def rollback_on(*statuses) + @cmdx_rollback_on = Utils::Normalize.statuses(statuses).map(&:to_sym) + reset_cmdx_definition! + end + + # @param statuses [Array] + # @return [void] + def task_breakpoints(*statuses) + @cmdx_task_breakpoints = Utils::Normalize.statuses(statuses).map(&:to_sym) + reset_cmdx_definition! + end + + # @param statuses [Array] + # @return [void] + def workflow_breakpoints(*statuses) + @cmdx_workflow_breakpoints = Utils::Normalize.statuses(statuses).map(&:to_sym) + reset_cmdx_definition! + end + + # @param tags [Array] + # @return [void] + def tags(*tags) + @cmdx_tags ||= [] + @cmdx_tags |= tags.flatten.map(&:to_sym) + reset_cmdx_definition! + end + + # @param value [Object] + # @return [void] + def deprecate(value) + @cmdx_deprecate = value + reset_cmdx_definition! + end + + # @param flag [Boolean] + # @return [void] + def strong_context(flag = true) + @cmdx_strong_context = flag + reset_cmdx_definition! + end + + # @param flag [Boolean] + # @return [void] + def dump_context(flag = true) + @cmdx_dump_context = flag + reset_cmdx_definition! + end + + # @param flag [Boolean] + # @return [void] + def freeze_results(flag = true) + @cmdx_freeze_results = flag + reset_cmdx_definition! + end + + # @param flag [Boolean] + # @return [void] + def backtrace(flag = true) + @cmdx_backtrace = flag + reset_cmdx_definition! + end + + # @param proc [Proc] + # @return [void] + def backtrace_cleaner(proc) + @cmdx_backtrace_cleaner = proc + reset_cmdx_definition! + end + + # @param proc [Proc] + # @return [void] + def exception_handler(proc) + @cmdx_exception_handler = proc + reset_cmdx_definition! + end + + Definition::CALLBACK_PHASES.each do |phase| + define_method(phase) do |*methods, **options, &block| + methods.each { |m| cmdx_callback_deltas[phase] << [m, options] } + cmdx_callback_deltas[phase] << [block, options] if block + Task.reset_cmdx_definition!(self) 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) + private + + # @param name [Symbol] + # @param required [Boolean] + # @param options [Hash] + # @return [void] + def declare_attribute(name, required:, **options) + type_keys = type_keys_from(options) + validators = validator_entries_from(options) + coerce_opts = coerce_options_only(options) + reader = (options[:as] || name).to_sym + + cmdx_declared_attributes << AttributeSpec.new( + name: name.to_sym, + required: required, + type_keys: type_keys, + reader_name: reader, + options: coerce_opts, + validators: validators + ) + define_attribute_reader!(reader) + reset_cmdx_definition! + end + + # @param options [Hash] + # @return [Array] + def type_keys_from(options) + t = options[:type] || options[:types] + return [] if t.nil? + + Array(t).map(&:to_sym) + end + + # @param options [Hash] + # @return [Array] + def validator_entries_from(options) + list = [] + %i[presence absence format inclusion exclusion length numeric].each do |k| + next unless options.key?(k) + + v = options[k] + list << { name: k, options: v.is_a?(Hash) ? v : { k => v } } end + list 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 - # - # @return [Result] The execution result - # - # @example - # result = MyTask.execute(name: "example") - # - # @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 - 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 - # - # @return [Result] The execution result - # - # @raise [ExecutionError] If the task execution fails - # - # @example - # result = MyTask.execute!(name: "example") - # - # @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 options [Hash] + # @return [Hash] + def coerce_options_only(options) + options.except( + :type, :types, :required, :optional, :as, + :presence, :absence, :format, :inclusion, :exclusion, :length, :numeric, + :if, :unless, :allow_nil + ) end + # @param reader_name [Symbol] + # @return [void] + def define_attribute_reader!(reader_name) + return if method_defined?(reader_name) + return if private_method_defined?(reader_name) + + class_eval <<~RUBY, __FILE__, __LINE__ + 1 + def #{reader_name} + @attributes[:#{reader_name}] + end + RUBY + end + + end + + # @param input [Hash] + # @param trace [Trace, nil] + def initialize(input = {}, trace: nil) + @raw_input = self.class.merge_input(input, {}) + @execution_trace = trace + @attributes = {} + @session = nil + end + + # @return [Hash{Symbol => Object}] + def raw_input_hash + @raw_input 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 + # @return [Trace, nil] + attr_reader :execution_trace + + # @param sess [Session] + # @return [void] + def setup_session!(sess) + @session = sess 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 + # @return [Context] + def context + session.context + end + + # @return [Errors] + def errors + session.errors + end + + # @param name [Symbol] + # @param value [Object] + # @return [void] + def write_attribute!(name, value) + @attributes[name.to_sym] = value + end + + # @param raise_on_fault [Boolean] + # @return [ExecutionResult] + def execute(raise_on_fault: false) + Executor.new(self).run(raise_on_fault: raise_on_fault) + end + + # @return [ExecutionResult] + def execute! + execute(raise_on_fault: true) + end + + # @raise [UndefinedMethodError] + # @return [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 + # @return [void] + def success!(...) + outcome.success!(...) + end - log_instance - end + # @return [void] + def skip!(...) + outcome.skip!(...) + end + + # @return [void] + def fail!(...) + outcome.fail!(...) + end + + # @return [Outcome] + def outcome + session&.outcome || raise("task is not executing") + end + + # @return [Logger] + def logger + session&.logger || CMDx.configuration.logger 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] + # @return [Hash{Symbol => Object}] def to_h { - index: result.index, - chain_id: chain.id, - type: self.class.type, + trace_id: session&.trace&.id, + type: self.class.task_kind, 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 - 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) + tags: self.class.definition.tags, + dry_run: false + } end end diff --git a/lib/cmdx/telemetry.rb b/lib/cmdx/telemetry.rb new file mode 100644 index 000000000..659725e49 --- /dev/null +++ b/lib/cmdx/telemetry.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module CMDx + # Structured observability hook; default implementation logs via +Logger+. + class Telemetry + + # @param logger [Logger] + # @param redact [Proc, nil] +(payload) -> payload+ with secrets stripped + def initialize(logger:, redact: nil) + @logger = logger + @redact = redact + end + + # @param name [Symbol] + # @param payload [Hash] + # @return [void] + def emit(name, payload) + data = @redact ? @redact.call(payload) : payload + @logger.info { "#{name} #{data.inspect}" } + end + + end +end diff --git a/lib/cmdx/trace.rb b/lib/cmdx/trace.rb new file mode 100644 index 000000000..9b8a63d2e --- /dev/null +++ b/lib/cmdx/trace.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module CMDx + # Correlates nested task executions without thread-local globals. + class Trace + + # @return [String] + attr_reader :id + + # @return [Trace, nil] + attr_reader :parent + + # @param id [String] + # @param parent [Trace, nil] + def initialize(id:, parent: nil) + @id = id + @parent = parent + end + + # @param id_generator [Proc] + # @return [Trace] + def self.root(id_generator: nil) + gen = id_generator || CMDx.configuration.id_generator + new(id: gen.call) + end + + # @return [Trace] + def child(id_generator: CMDx.configuration.id_generator) + self.class.new(id: id_generator.call, parent: self) + end + + end +end diff --git a/lib/cmdx/validator_registry.rb b/lib/cmdx/validator_registry.rb deleted file mode 100644 index 8b5381532..000000000 --- a/lib/cmdx/validator_registry.rb +++ /dev/null @@ -1,143 +0,0 @@ -# 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. - 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 - 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 - } - end - - # Sets up copy-on-write state when duplicated via dup. - # - # @param source [ValidatorRegistry] The registry being duplicated - # - # @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 [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 - - # 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 - end - - # Remove a validator from the registry by name. - # - # @param name [String, Symbol] The name of the validator to remove - # - # @return [ValidatorRegistry] Returns self for method chaining - # - # @example - # registry.deregister(:format) - # registry.deregister("presence") - # - # @rbs ((String | Symbol) name) -> self - def deregister(name) - materialize! - - @registry.delete(name.to_sym) - self - 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 - # - # @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) - end - - private - - # Copies the parent's registry data into this instance, - # severing the copy-on-write link. - # - # @rbs () -> void - def materialize! - return if @registry - - @registry = @parent.registry.dup - @parent = nil - end - - end -end diff --git a/lib/cmdx/version.rb b/lib/cmdx/version.rb index b07955686..61d34f8e4 100644 --- a/lib/cmdx/version.rb +++ b/lib/cmdx/version.rb @@ -5,6 +5,6 @@ module CMDx # @return [String] the version of the CMDx gem # # @rbs return: String - VERSION = "1.21.0" + VERSION = "2.0.0" end diff --git a/lib/cmdx/workflow.rb b/lib/cmdx/workflow.rb index 5a006e4a9..fb0ae3a74 100644 --- a/lib/cmdx/workflow.rb +++ b/lib/cmdx/workflow.rb @@ -1,134 +1,39 @@ # 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. + # Compose tasks in ordered groups; cannot define +work+ on the host class. 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 + def self.included(base) + base.extend(ClassMethods) + class << base - super - end + def cmdx_workflow? + true + end - # Returns the collection of execution groups for this workflow. - # - # @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 ||= [] 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 - # - # @raise [TypeError] If any task is not a CMDx::Task subclass - # - # @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 - ) - end - alias task tasks + module ClassMethods - # Returns all tasks in the pipeline. - # - # @return [Array] Array of task classes - # - # @example - # class MyWorkflow - # include CMDx::Workflow - # task Task1 - # task Task2 - # puts subtasks.size # => 2 - # end - # - # @rbs () -> Array[Class] - def subtasks - pipeline.flat_map(&:tasks) + # @param tasks [Array] + # @param options [Hash] + # @return [void] + def task(*tasks, **options) + cmdx_workflow_pipeline << { tasks: tasks.flatten, options: options } + CMDx::Task.reset_cmdx_definition!(self) end + alias tasks task - 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) + # @param method_name [Symbol] + # @return [void] + def method_added(method_name) + raise "cannot redefine #{name}##{method_name}" if method_name == :work - # 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 + super + 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 - # - # @rbs () -> void - def work - Pipeline.execute(self) end end diff --git a/lib/cmdx/workflow_runner.rb b/lib/cmdx/workflow_runner.rb new file mode 100644 index 000000000..f750588f1 --- /dev/null +++ b/lib/cmdx/workflow_runner.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +module CMDx + # Runs {Workflow} pipeline groups (sequential or parallel) with breakpoints. + module WorkflowRunner + + # @param session [Session] + # @return [void] + def self.run(session) + handler = session.handler + definition = handler.class.definition + default_bps = Utils::Normalize.statuses(definition.workflow_breakpoints).map(&:to_sym) + + definition.workflow_pipeline.each do |group| + tasks = group[:tasks] + opts = group[:options] + next unless Utils::Condition.evaluate(handler, opts) + + breakpoints = breakpoint_symbols(opts, default_bps) + strategy = opts[:strategy] + + if parallel?(strategy) + run_parallel(session, tasks, opts, breakpoints) + else + run_sequential(session, tasks, breakpoints) + end + end + end + + # @param strategy [Object] + # @return [Boolean] + def self.parallel?(strategy) + strategy.to_s == "parallel" + end + + # @param opts [Hash] + # @param defaults [Array] + # @return [Array] + def self.breakpoint_symbols(opts, defaults) + if opts.key?(:breakpoints) + Utils::Normalize.statuses(opts[:breakpoints]).map(&:to_sym) + else + defaults + end + end + + # @param session [Session] + # @param tasks [Array] + # @param breakpoints [Array] + # @return [void] + def self.run_sequential(session, tasks, breakpoints) + handler = session.handler + tasks.each do |task_class| + sub = task_class.execute(session.context.to_h, trace: session.trace) + session.context.merge!(sub.context.to_h) + next unless breakpoints.include?(sub.status) + + handler.session.outcome.propagate_from!(sub.outcome, halt: true) + end + end + + # @param session [Session] + # @param tasks [Array] + # @param opts [Hash] + # @param breakpoints [Array] + # @return [void] + def self.run_parallel(session, tasks, opts, breakpoints) + handler = session.handler + contexts = tasks.map { |_t| Context.new(session.context.to_h) } + pairs = tasks.zip(contexts) + pool = opts.fetch(:pool_size, pairs.size) + + results = Parallel::Threads.call(pairs, concurrency: pool) do |task_class, ctx| + task_class.execute(ctx.to_h, trace: session.trace) + end + + contexts.each { |ctx| session.context.merge!(ctx.to_h) } + + faulted = results.select { |r| breakpoints.include?(r.status) } + return if faulted.empty? + + last = faulted.last + handler.session.outcome.propagate_from!(last.outcome, halt: true) + end + + end +end diff --git a/lib/generators/cmdx/templates/install.rb b/lib/generators/cmdx/templates/install.rb index 9ebadffa8..896fbd8bf 100644 --- a/lib/generators/cmdx/templates/install.rb +++ b/lib/generators/cmdx/templates/install.rb @@ -1,65 +1,25 @@ # 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] + # Statuses for which +execute!+ raises {CMDx::FailFault} / {CMDx::SkipFault} + config.task_breakpoints = %i[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] + # Workflow groups stop when a subtask finishes with one of these statuses + config.workflow_breakpoints = %i[failed] - # Logger configuration - choose from multiple formatters - # See https://github.com/drexed/cmdx/blob/main/docs/logging.md for more details - # - # Available formatters: - # - CMDx::LogFormatters::Json - # - CMDx::LogFormatters::KeyValue - # - CMDx::LogFormatters::Line - # - CMDx::LogFormatters::Logstash - # - CMDx::LogFormatters::Raw - config.logger = Logger.new( - $stdout, - progname: "cmdx", - formatter: CMDx::LogFormatters::Line.new, - level: Logger::INFO - ) + config.logger = Logger.new($stdout, progname: "CMDx", 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] + # Statuses that call +rollback+ on the task when defined + config.rollback_on = %i[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" + # Optional: structured sink (defaults to +Logger#info+ with a hash inspect) + # config.telemetry = CMDx::Telemetry.new(logger: config.logger) - # 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 - - # 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 + # config.freeze_results = true # - # 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 + # Global defaults use {CMDx::ExtensionSet.build_defaults}. Override per task with +register+. end diff --git a/spec/cmdx/attribute_registry_spec.rb b/spec/cmdx/attribute_registry_spec.rb deleted file mode 100644 index 14b15c067..000000000 --- a/spec/cmdx/attribute_registry_spec.rb +++ /dev/null @@ -1,335 +0,0 @@ -# frozen_string_literal: true - -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) } - - describe "#initialize" do - context "without arguments" do - subject(:registry) { described_class.new } - - 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 - 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 "#to_a" do - subject(:registry) { described_class.new(initial_registry) } - - it "aliases to registry method" do - expect(registry.to_a).to eq(registry.registry) - expect(registry.to_a).to eq(initial_registry) - end - end - - describe "#dup" do - subject(:registry) { described_class.new(initial_registry) } - - let(:duplicated_registry) { registry.dup } - - 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) - - expect(duplicated_registry.registry).to include(new_attribute) - expect(registry.registry).not_to include(new_attribute) - 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 - 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 - - 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") - - expect { registry.define_and_verify(task) }.to raise_error(StandardError, "attribute error") - end - end - end -end diff --git a/spec/cmdx/attribute_spec.rb b/spec/cmdx/attribute_spec.rb deleted file mode 100644 index e19fa0455..000000000 --- a/spec/cmdx/attribute_spec.rb +++ /dev/null @@ -1,707 +0,0 @@ -# frozen_string_literal: true - -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) } - - 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 - 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 - 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 - 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 - - it "includes options without :if and :unless" do - result = attribute.to_h - - expect(result[:options]).to eq(default: "test") - end - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 deleted file mode 100644 index 14e0843e5..000000000 --- a/spec/cmdx/callback_registry_spec.rb +++ /dev/null @@ -1,396 +0,0 @@ -# frozen_string_literal: true - -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 - end - - describe "#registry" do - it "returns the internal registry" do - expect(registry.registry).to eq(initial_registry) - 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 - - it "materializes on write and does not affect the parent" do - duplicated = registry.dup - original_size = registry.registry[:before_execution].size - - duplicated.register(:before_execution, :new_callback) - - 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) - end - end - - context "when registry is empty" do - let(:initial_registry) { {} } - - it "returns a new instance with empty registry" do - duplicated = registry.dup - - expect(duplicated.registry).to eq({}) - expect(duplicated.registry).to be(registry.registry) - end - end - end - - describe "#register" do - it "returns self for method chaining" do - result = registry.register(:before_execution, callable_proc) - - expect(result).to be(registry) - 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], {}]) - 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) - - expect(registry.registry[:before_execution]).to include([[callable_proc, callable_symbol], {}]) - end - 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]) - 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) - - 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 - 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 - 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 - - it "processes all entries" do - expect(CMDx::Utils::Condition).to receive(:evaluate).twice - - 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 - end - end -end diff --git a/spec/cmdx/chain_spec.rb b/spec/cmdx/chain_spec.rb deleted file mode 100644 index a7e7735ce..000000000 --- a/spec/cmdx/chain_spec.rb +++ /dev/null @@ -1,355 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Chain, type: :unit do - subject(:chain) { described_class.new } - - 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 - 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 - 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 - 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 - 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: [] - } - ) - - 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) - 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 - 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 - 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 - 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 - 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 deleted file mode 100644 index f9787fe06..000000000 --- a/spec/cmdx/coercion_registry_spec.rb +++ /dev/null @@ -1,290 +0,0 @@ -# frozen_string_literal: true - -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) - 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(: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) - 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) - 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 - 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 - - 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 - - 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 - end - end -end diff --git a/spec/cmdx/coercions/array_spec.rb b/spec/cmdx/coercions/array_spec.rb deleted file mode 100644 index d39c7e571..000000000 --- a/spec/cmdx/coercions/array_spec.rb +++ /dev/null @@ -1,216 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Coercions::Array, type: :unit do - subject(:coercion) { described_class } - - 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 - 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 - 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 - 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 - 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]) - 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 - end - end -end diff --git a/spec/cmdx/coercions/big_decimal_spec.rb b/spec/cmdx/coercions/big_decimal_spec.rb deleted file mode 100644 index 7f7cd6428..000000000 --- a/spec/cmdx/coercions/big_decimal_spec.rb +++ /dev/null @@ -1,168 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Coercions::BigDecimal, type: :unit do - subject(:coercion) { described_class } - - 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 - 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 - 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 - 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 - end - end -end diff --git a/spec/cmdx/coercions/boolean_spec.rb b/spec/cmdx/coercions/boolean_spec.rb deleted file mode 100644 index cef3be908..000000000 --- a/spec/cmdx/coercions/boolean_spec.rb +++ /dev/null @@ -1,252 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Coercions::Boolean, type: :unit do - subject(:coercion) { described_class } - - 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) - end - 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") - end - 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 - end - end -end diff --git a/spec/cmdx/coercions/complex_spec.rb b/spec/cmdx/coercions/complex_spec.rb deleted file mode 100644 index 757171e39..000000000 --- a/spec/cmdx/coercions/complex_spec.rb +++ /dev/null @@ -1,220 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Coercions::Complex, type: :unit do - subject(:coercion) { described_class } - - 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 - 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 - 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 - 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 - end - end -end diff --git a/spec/cmdx/coercions/date_spec.rb b/spec/cmdx/coercions/date_spec.rb deleted file mode 100644 index 0a395d7a0..000000000 --- a/spec/cmdx/coercions/date_spec.rb +++ /dev/null @@ -1,231 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Coercions::Date, type: :unit do - subject(:coercion) { described_class } - - 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 - 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 - 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 - 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 - 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 - end - end -end diff --git a/spec/cmdx/coercions/date_time_spec.rb b/spec/cmdx/coercions/date_time_spec.rb deleted file mode 100644 index e44c545d4..000000000 --- a/spec/cmdx/coercions/date_time_spec.rb +++ /dev/null @@ -1,180 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Coercions::DateTime, type: :unit do - subject(:coercion) { described_class } - - 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 - 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 - 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 - 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 "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 - end - end -end diff --git a/spec/cmdx/coercions/float_spec.rb b/spec/cmdx/coercions/float_spec.rb deleted file mode 100644 index 86349be0e..000000000 --- a/spec/cmdx/coercions/float_spec.rb +++ /dev/null @@ -1,227 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Coercions::Float, type: :unit do - subject(:coercion) { described_class } - - 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 - 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 - 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 - end - end -end diff --git a/spec/cmdx/coercions/hash_spec.rb b/spec/cmdx/coercions/hash_spec.rb deleted file mode 100644 index 86d232701..000000000 --- a/spec/cmdx/coercions/hash_spec.rb +++ /dev/null @@ -1,287 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Coercions::Hash, type: :unit do - subject(:coercion) { described_class } - - 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 - 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 - 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" } - 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") - end - 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 - end - end -end diff --git a/spec/cmdx/coercions/integer_spec.rb b/spec/cmdx/coercions/integer_spec.rb deleted file mode 100644 index f9eac66b1..000000000 --- a/spec/cmdx/coercions/integer_spec.rb +++ /dev/null @@ -1,204 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Coercions::Integer, type: :unit do - subject(:coercion) { described_class } - - 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 - 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 - 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 - end - end -end diff --git a/spec/cmdx/coercions/rational_spec.rb b/spec/cmdx/coercions/rational_spec.rb deleted file mode 100644 index aed4f6439..000000000 --- a/spec/cmdx/coercions/rational_spec.rb +++ /dev/null @@ -1,256 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Coercions::Rational, type: :unit do - subject(:coercion) { described_class } - - 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 - 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 - 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 - 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 - end - end -end diff --git a/spec/cmdx/coercions/string_spec.rb b/spec/cmdx/coercions/string_spec.rb deleted file mode 100644 index 9a3e2ee02..000000000 --- a/spec/cmdx/coercions/string_spec.rb +++ /dev/null @@ -1,275 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Coercions::String, type: :unit do - subject(:coercion) { described_class } - - 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 - 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 - end - end -end diff --git a/spec/cmdx/coercions/symbol_spec.rb b/spec/cmdx/coercions/symbol_spec.rb deleted file mode 100644 index a82483913..000000000 --- a/spec/cmdx/coercions/symbol_spec.rb +++ /dev/null @@ -1,229 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Coercions::Symbol, type: :unit do - subject(:coercion) { described_class } - - 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 - end - end -end diff --git a/spec/cmdx/coercions/time_spec.rb b/spec/cmdx/coercions/time_spec.rb deleted file mode 100644 index c444d012a..000000000 --- a/spec/cmdx/coercions/time_spec.rb +++ /dev/null @@ -1,226 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Coercions::Time, type: :unit do - subject(:coercion) { described_class } - - 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 - 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 - 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 - 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 - 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 - end - end -end diff --git a/spec/cmdx/configuration_spec.rb b/spec/cmdx/configuration_spec.rb deleted file mode 100644 index 829f60d5f..000000000 --- a/spec/cmdx/configuration_spec.rb +++ /dev/null @@ -1,278 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Configuration, type: :unit do - subject(:configuration) { described_class.new } - - describe "#initialize" do - it "initializes middlewares registry" do - expect(configuration.middlewares).to be_a(CMDx::MiddlewareRegistry) - expect(configuration.middlewares.registry).to be_empty - 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) - 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] } - - it "allows setting and getting task_breakpoints" do - configuration.task_breakpoints = custom_breakpoints - - 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 - 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) - 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 - 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 - 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 - - 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 - end - end -end diff --git a/spec/cmdx/context_spec.rb b/spec/cmdx/context_spec.rb deleted file mode 100644 index 02a5a8f74..000000000 --- a/spec/cmdx/context_spec.rb +++ /dev/null @@ -1,465 +0,0 @@ -# frozen_string_literal: true - -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 - 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({}) - end - 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 - 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 - 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 - end - end - - describe "#store and #[]=" do - it "stores value with symbol key" do - context.store(:email, "john@example.com") - - expect(context[:email]).to eq("john@example.com") - end - - it "stores value with string key converted to symbol" do - context["phone"] = "555-1234" - - expect(context[:phone]).to eq("555-1234") - end - - it "overwrites existing value" do - context[:age] = 35 - - expect(context[:age]).to eq(35) - end - end - - describe "#fetch" do - it "retrieves existing value" do - expect(context.fetch(:name)).to eq("John") - end - - it "retrieves value with string key converted to symbol" do - expect(context.fetch("age")).to eq(30) - 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) - end - - it "raises KeyError for missing key without default" do - expect { context.fetch(:missing) }.to raise_error(KeyError) - 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 - - it "stores nil when no value or block provided" do - result = context.fetch_or_store(:status) - - 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) - 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") - end - end - - describe "#each" do - it "delegates to table" do - result = [] - context.each { |key, value| result << [key, value] } # rubocop:disable Style/MapIntoArray - - expect(result).to contain_exactly([:name, "John"], [:age, 30]) - 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") - 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 - - 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 - end - end -end diff --git a/spec/cmdx/deprecator_spec.rb b/spec/cmdx/deprecator_spec.rb deleted file mode 100644 index 35edc4a69..000000000 --- a/spec/cmdx/deprecator_spec.rb +++ /dev/null @@ -1,177 +0,0 @@ -# frozen_string_literal: true - -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 - 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 - 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 - 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 - end - end -end diff --git a/spec/cmdx/errors_spec.rb b/spec/cmdx/errors_spec.rb deleted file mode 100644 index 370746f40..000000000 --- a/spec/cmdx/errors_spec.rb +++ /dev/null @@ -1,425 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Errors, type: :unit 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 - 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 - 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 - 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 - 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 - - context "when errors have been added" do - before do - errors.add(:name, "is required") - end - - it "returns true" do - expect(errors.any?).to be(true) - end - 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 - 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 - 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 - 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 - end - end -end diff --git a/spec/cmdx/executor_spec.rb b/spec/cmdx/executor_spec.rb deleted file mode 100644 index 561fdc228..000000000 --- a/spec/cmdx/executor_spec.rb +++ /dev/null @@ -1,1159 +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.result).to receive(:complete!) - allow(task.result).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.result).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.result).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.result).to receive(:throw!).with(fault_result, halt: false, cause: fault) - - allow(task.result).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.result).to receive(:throw!) - - expect(task.result).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.result).to receive(:fail!).with("[StandardError] something went wrong", halt: false, cause: standard_error, source: :exception) - - allow(task.result).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.result).to receive(:fail!) - - expect(task.result).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.result).to receive(:fail!).with("[CMDx::TestError] test error", halt: false, cause: custom_error, source: :exception) - - allow(task.result).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.result).to receive(:complete!) - allow(task.result).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.result).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.result).to receive(:throw!).with(fault_result, halt: false, cause: fault) - expect(task.result).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.result).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.result).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_result) { instance_double(CMDx::Result, status: "failed", strict?: true, reason: "test failure") } - 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) } - - 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_result) { instance_double(CMDx::Result, status: "success", strict?: true, reason: "test success") } - 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.result).to receive(:fail!) - end - - it "calls fail! on result with error information" do - expect(task.result).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.result).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).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.result).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.result).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.result).to receive(:fail!).with( - CMDx::Locale.t("cmdx.faults.invalid"), - halt: false, - source: :middleware - ) - expect(task.result).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.result).not_to receive(:fail!) - expect(task.result).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 5e0a3fdc2..000000000 --- a/spec/cmdx/faults_spec.rb +++ /dev/null @@ -1,192 +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.result.tap do |r| - r.fail!("test failure reason", halt: false) - end - 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 - result_with_metadata = task.result.tap do |r| - r.fail!("failure", halt: false, metadata: { error_code: 500 }) - end - described_class.new(result_with_metadata) - 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 deleted file mode 100644 index eae822cb4..000000000 --- a/spec/cmdx/identifier_spec.rb +++ /dev/null @@ -1,49 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Identifier, type: :unit do - subject(:identifier) { described_class } - - 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 - 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 - 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 deleted file mode 100644 index 2eadc06fa..000000000 --- a/spec/cmdx/log_formatters/json_spec.rb +++ /dev/null @@ -1,243 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::LogFormatters::JSON, type: :unit do - subject(:formatter) { described_class.new } - - 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 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 - end - end -end diff --git a/spec/cmdx/log_formatters/key_value_spec.rb b/spec/cmdx/log_formatters/key_value_spec.rb deleted file mode 100644 index 52e859084..000000000 --- a/spec/cmdx/log_formatters/key_value_spec.rb +++ /dev/null @@ -1,385 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::LogFormatters::KeyValue, type: :unit do - subject(:formatter) { described_class.new } - - 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 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 - 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 - end - end -end diff --git a/spec/cmdx/log_formatters/line_spec.rb b/spec/cmdx/log_formatters/line_spec.rb deleted file mode 100644 index d8c5179d8..000000000 --- a/spec/cmdx/log_formatters/line_spec.rb +++ /dev/null @@ -1,285 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::LogFormatters::Line, type: :unit do - subject(:formatter) { described_class.new } - - 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") - end - - it "handles empty string message" do - result = formatter.call(severity, time, progname, "") - - expect(result).to include("TestApp: \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 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 - 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 - end - end -end diff --git a/spec/cmdx/log_formatters/logstash_spec.rb b/spec/cmdx/log_formatters/logstash_spec.rb deleted file mode 100644 index 49601c020..000000000 --- a/spec/cmdx/log_formatters/logstash_spec.rb +++ /dev/null @@ -1,254 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::LogFormatters::Logstash, type: :unit do - subject(:formatter) { described_class.new } - - 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 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 - end - end -end diff --git a/spec/cmdx/log_formatters/raw_spec.rb b/spec/cmdx/log_formatters/raw_spec.rb deleted file mode 100644 index e729326c8..000000000 --- a/spec/cmdx/log_formatters/raw_spec.rb +++ /dev/null @@ -1,203 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::LogFormatters::Raw, type: :unit do - subject(:formatter) { described_class.new } - - 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 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 - 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 - end - end -end diff --git a/spec/cmdx/middleware_registry_spec.rb b/spec/cmdx/middleware_registry_spec.rb deleted file mode 100644 index df5662468..000000000 --- a/spec/cmdx/middleware_registry_spec.rb +++ /dev/null @@ -1,453 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::MiddlewareRegistry, type: :unit do - subject(:registry) { described_class.new(initial_registry) } - - 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) } - - 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 - 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, {}]] } - - it "returns the registry array" do - expect(registry.to_a).to eq([[mock_middleware1, {}]]) - 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" }]] } - - 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) - 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 } } - - it "inserts at specified position with options" do - registry.register(mock_middleware2, at: 0, **options) - - expect(registry.registry).to eq( - [ - [mock_middleware2, options], - [mock_middleware1, {}] - ] - ) - 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 - 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 - 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 - 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 - - result = registry.call!(mock_task, &test_block) - - expect(result).to eq(block_result) - end - 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 - 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 - 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 - - it "propagates the error" do - expect { registry.call!(mock_task, &test_block) }.to raise_error(StandardError, "middleware error") - end - end - end -end diff --git a/spec/cmdx/middlewares/correlate_spec.rb b/spec/cmdx/middlewares/correlate_spec.rb deleted file mode 100644 index 024185e3e..000000000 --- a/spec/cmdx/middlewares/correlate_spec.rb +++ /dev/null @@ -1,444 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Middlewares::Correlate, type: :unit do - subject(:correlate) { described_class } - - 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 - 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 - 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 - 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 - 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/timeout_spec.rb b/spec/cmdx/middlewares/timeout_spec.rb deleted file mode 100644 index 4c91e157c..000000000 --- a/spec/cmdx/middlewares/timeout_spec.rb +++ /dev/null @@ -1,272 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -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(:result) { instance_double(CMDx::Result) } - 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) - 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(result).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) - 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) - - expect do - timeout_middleware.call(task, seconds: 5, unknown_option: "value", &test_block) - end.not_to raise_error - end - end - end - - describe "CMDx::TimeoutError" do - it "is a subclass of Interrupt" do - expect(CMDx::TimeoutError.superclass).to eq(Interrupt) - end - - it "can be instantiated with a message" do - error = CMDx::TimeoutError.new("test timeout") - expect(error.message).to eq("test timeout") - 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/result_spec.rb b/spec/cmdx/result_spec.rb deleted file mode 100644 index ad801e62b..000000000 --- a/spec/cmdx/result_spec.rb +++ /dev/null @@ -1,1135 +0,0 @@ -# frozen_string_literal: true - -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 } - - 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 - 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 - result.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 - result.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 - result.executing! - result.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 - result.executing! - result.interrupt! - - expect(result.interrupted?).to be(true) - end - end - end - - describe "state transitions" do - describe "#executing!" do - context "when initialized" do - it "transitions to executing state" do - result.executing! - - expect(result.state).to eq(CMDx::Result::EXECUTING) - end - - it "returns early if already executing" do - result.executing! - initial_state = result.state - result.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 - result.executing! - result.complete! - - expect { result.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! - - expect { result.executing! }.to raise_error(/can only transition to executing from initialized/) - end - end - end - - describe "#complete!" do - context "when executing" do - before { result.executing! } - - it "transitions to complete state" do - result.complete! - - expect(result.state).to eq(CMDx::Result::COMPLETE) - end - - it "returns early if already complete" do - result.complete! - initial_state = result.state - result.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 { result.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 - result.interrupt! - - expect(result.state).to eq(CMDx::Result::INTERRUPTED) - end - - it "transitions to interrupted from executing" do - result.executing! - result.interrupt! - - expect(result.state).to eq(CMDx::Result::INTERRUPTED) - end - - it "returns early if already interrupted" do - result.interrupt! - initial_state = result.state - result.interrupt! - - expect(result.state).to eq(initial_state) - end - end - - context "when complete" do - it "raises error when trying to transition from complete" do - result.executing! - result.complete! - - expect { result.interrupt! }.to raise_error(/cannot transition to interrupted from complete/) - end - end - 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 - result.skip!("test reason", halt: false) - - expect(result.success?).to be(false) - end - - it "returns false after fail!" do - result.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 - result.skip!("test reason", halt: false) - - 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 - result.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 - result.skip!("test reason", halt: false) - - expect(result.good?).to be(true) - end - - it "returns false when failed" do - result.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 - result.skip!("test reason", halt: false) - - expect(result.bad?).to be(true) - end - - it "returns true when failed" do - result.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) - 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 - end - end - - describe "execution methods" do - describe "#executed!" do - context "when successful" do - it "calls complete!" do - result.executing! - result.executed! - - expect(result.complete?).to be(true) - end - end - - context "when not successful" do - it "calls interrupt! when skipped" do - result.skip!("test reason", halt: false) - result.executed! - - expect(result.interrupted?).to be(true) - end - - it "calls interrupt! when failed" do - result.fail!("test reason", halt: false) - result.executed! - - 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 - result.executing! - result.complete! - - expect(result.executed?).to be(true) - end - - it "returns true when interrupted" do - result.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 - result.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 - 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 } - 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 } - - before do - chain.results.push(first_result, second_result, third_result) - - [first_result, second_result, third_result].each do |r| - r.instance_variable_set(:@chain, chain) - end - - second_result.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_result.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) - 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 - result.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) { result.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 - result.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 - result.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 - result.executing! - when CMDx::Result::COMPLETE - result.executing! - result.complete! - when CMDx::Result::INTERRUPTED - result.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 - result.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 - result.skip!("test", halt: false) - when CMDx::Result::FAILED - result.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 - result.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 - result.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 - result.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 - result.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 - result.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 - 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 - - it "freezes the array" do - expect(CMDx::Result::STATES).to be_frozen - end - 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 - 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/settings_spec.rb b/spec/cmdx/settings_spec.rb deleted file mode 100644 index 02bb98162..000000000 --- a/spec/cmdx/settings_spec.rb +++ /dev/null @@ -1,304 +0,0 @@ -# frozen_string_literal: true - -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 - 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 - 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 - 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 - - it "overrides parent values" do - expect(settings.retries).to eq(10) - expect(settings.deprecate).to eq(:warn) - end - 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 - 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 - 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) - - expect(child.retries).to eq(20) - end - end - end -end diff --git a/spec/cmdx/task_spec.rb b/spec/cmdx/task_spec.rb index caded1fad..1830f0ce8 100644 --- a/spec/cmdx/task_spec.rb +++ b/spec/cmdx/task_spec.rb @@ -2,609 +2,33 @@ 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 } } +RSpec.describe CMDx::Task do + let(:task_class) do + Class.new(described_class) do + required :name, type: :string, presence: true - 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) - 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) - 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) - 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 result" do - expect(task.result).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") - - task.skip!("reason", metadata: "data") - end - - it "delegates fail! to result" do - expect(task.result).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") - - 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 - 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) - 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"]) - 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 - - context "with unknown type" do - it "raises an error" do - expect { task_class.register(:unknown, "object") }.to raise_error("unknown registry type :unknown") - end - 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") - 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") - 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 - 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) - 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") - 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({}) - 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 - - task_class.public_send(callback_type, option: "value", &block) - 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 + def work + context[:greeting] = "Hello, #{name}!" end 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") - 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 - - context "with no arguments" do - it "defaults to raise: false" do - expect(CMDx::Executor).to receive(:execute).with(task, raise: false) - - task.execute - end - end - end - - describe "#work" do - it "raises UndefinedMethodError" do - expect { task.work }.to raise_error( - CMDx::UndefinedMethodError, - /undefined method.*#work/ - ) - end - - it "includes the class name in the error message" do - expect { task.work }.to raise_error( - CMDx::UndefinedMethodError, - /TestTask\d+#work/ - ) - 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) - end - - it "returns a different logger instance" do - expect(task.logger).not_to equal(shared_logger) - expect(task.logger.level).to eq(Logger::DEBUG) - end + it "returns success with coerced context" do + result = task_class.execute(name: "World") + expect(result.success?).to be true + expect(result.context[:greeting]).to eq("Hello, World!") 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) - end - - it "returns a different logger instance" do - expect(task.logger).not_to equal(shared_logger) - expect(task.logger.formatter).to eq(custom_formatter) - end + it "fails validation on blank name" do + result = task_class.execute(name: " ") + expect(result.failed?).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") - 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 - 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") + describe ".execute!" do + it "raises FailFault on failure" do + expect { task_class.execute!(name: "") }.to raise_error(CMDx::FailFault) 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 deleted file mode 100644 index cf0d308da..000000000 --- a/spec/cmdx/validator_registry_spec.rb +++ /dev/null @@ -1,433 +0,0 @@ -# frozen_string_literal: true - -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) - 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) - 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) - 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 - 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 - - 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 - - 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 - end - end -end diff --git a/spec/cmdx/validators/absence_spec.rb b/spec/cmdx/validators/absence_spec.rb deleted file mode 100644 index 052b8bbbb..000000000 --- a/spec/cmdx/validators/absence_spec.rb +++ /dev/null @@ -1,154 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Validators::Absence, type: :unit do - subject(:validator) { described_class } - - 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 - - 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 - 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 - 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 - end - end -end diff --git a/spec/cmdx/validators/exclusion_spec.rb b/spec/cmdx/validators/exclusion_spec.rb deleted file mode 100644 index db983f124..000000000 --- a/spec/cmdx/validators/exclusion_spec.rb +++ /dev/null @@ -1,350 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Validators::Exclusion, type: :unit do - subject(:validator) { described_class } - - 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 - 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 - end - end -end diff --git a/spec/cmdx/validators/format_spec.rb b/spec/cmdx/validators/format_spec.rb deleted file mode 100644 index 61f154199..000000000 --- a/spec/cmdx/validators/format_spec.rb +++ /dev/null @@ -1,279 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Validators::Format, type: :unit do - subject(:validator) { described_class } - - 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,}/ } } - - 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 - 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 - 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 - end - end -end diff --git a/spec/cmdx/validators/inclusion_spec.rb b/spec/cmdx/validators/inclusion_spec.rb deleted file mode 100644 index 54350069f..000000000 --- a/spec/cmdx/validators/inclusion_spec.rb +++ /dev/null @@ -1,359 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Validators::Inclusion, type: :unit do - subject(:validator) { described_class } - - 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 - 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 - end - end -end diff --git a/spec/cmdx/validators/length_spec.rb b/spec/cmdx/validators/length_spec.rb deleted file mode 100644 index 8a6d296d5..000000000 --- a/spec/cmdx/validators/length_spec.rb +++ /dev/null @@ -1,431 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Validators::Length, type: :unit do - subject(:validator) { described_class } - - 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 - 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 - 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 - 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 - 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 - 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 - end - end -end diff --git a/spec/cmdx/validators/numeric_spec.rb b/spec/cmdx/validators/numeric_spec.rb deleted file mode 100644 index 51909b0a6..000000000 --- a/spec/cmdx/validators/numeric_spec.rb +++ /dev/null @@ -1,379 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Validators::Numeric, type: :unit do - subject(:validator) { described_class } - - 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 - 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 - 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 - 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 - 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 - 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 - 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 - end - end -end diff --git a/spec/cmdx/validators/presence_spec.rb b/spec/cmdx/validators/presence_spec.rb deleted file mode 100644 index 2bb389dbd..000000000 --- a/spec/cmdx/validators/presence_spec.rb +++ /dev/null @@ -1,165 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Validators::Presence, type: :unit do - subject(:validator) { described_class } - - 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 - - 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 - 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 - 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 - end - end -end diff --git a/spec/cmdx/workflow_spec.rb b/spec/cmdx/workflow_spec.rb index a65a5abe8..de412de0b 100644 --- a/spec/cmdx/workflow_spec.rb +++ b/spec/cmdx/workflow_spec.rb @@ -2,351 +2,36 @@ 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) } - - 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]) +RSpec.describe "CMDx::Workflow" do + let(:step_one) do + Class.new(CMDx::Task) do + def work + context[:step] = 1 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]) - 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]) - 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]) + let(:step_two) do + Class.new(CMDx::Task) do + def work + context[:step] = context[:step].to_i + 1 end 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] - 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] - 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 "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 - end - end - - context "when task status does not match breakpoints" do - let(:failing_task) { create_nested_task(strategy: :throw, status: :failure) } - - 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]) - end - 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 "skips groups that do not meet conditions" do - workflow_with_context.work - - expect(workflow_with_context.context.executed).to eq([:success]) - end - 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 + let(:flow_class) do + s1 = step_one + s2 = step_two + Class.new(CMDx::Task) do + include CMDx::Workflow - expect(workflow_with_context.context.executed).to eq(%i[success success]) - end + task s1, s2 end + end - context "with empty execution groups" do - it "completes without executing any tasks" do - workflow_with_context.work - - expect(workflow_with_context.context.executed).to eq([]) - end - end + it "runs steps in order sharing context" do + result = flow_class.execute + expect(result.success?).to be true + expect(result.context[:step]).to eq(2) end end diff --git a/spec/cmdx_spec.rb b/spec/cmdx_spec.rb index 556b4372d..43b9945b8 100644 --- a/spec/cmdx_spec.rb +++ b/spec/cmdx_spec.rb @@ -3,55 +3,15 @@ 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 + first = described_class.configuration + second = described_class.configuration + expect(first.object_id).to eq(second.object_id) end end @@ -60,157 +20,20 @@ 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) + it "allows changing task breakpoints" do + described_class.configure do |c| + c.task_breakpoints = %i[failed skipped] 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 + expect(described_class.configuration.task_breakpoints).to eq(%i[failed skipped]) 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 + it "replaces the configuration singleton" do + before_id = described_class.configuration.object_id 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 + expect(described_class.configuration.object_id).not_to eq(before_id) end end end 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/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..b353fd4ff 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -3,26 +3,14 @@ ENV["TZ"] = "Etc/UTC" require "bundler/setup" +require "logger" 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 - - # Enable flags like --only-failures and --next-failure config.example_status_persistence_file_path = ".rspec_status" - - # Disable RSpec exposing methods globally on Module and main config.disable_monkey_patching! config.define_derived_metadata do |meta| @@ -33,21 +21,13 @@ c.syntax = :expect end - config.include CMDx::RSpec::Helpers - config.include CMDx::Testing::TaskBuilders - config.include CMDx::Testing::WorkflowBuilders - 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::Chain.clear - CMDx::Middlewares::Correlate.clear end end 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