diff --git a/Steepfile b/Steepfile index 61c1f49..ede6dc6 100644 --- a/Steepfile +++ b/Steepfile @@ -22,7 +22,7 @@ target :lib do # Data.define with block causes Steep type checking issues # See: https://github.com/ruby/rbs/blob/master/docs/data_and_struct.md - ignore "lib/stroma/hooks/hook.rb" + ignore "lib/stroma/entry.rb" # Complex splat delegation (*args) in fetch method causes type checking issues ignore "lib/stroma/settings/setting.rb" @@ -30,4 +30,6 @@ target :lib do # Dynamic module generation via Module.new causes type checking issues # Steep can't analyze methods inside Module.new blocks ignore "lib/stroma/dsl/generator.rb" + ignore "lib/stroma/hooks/applier.rb" + ignore "lib/stroma/phase/resolver.rb" end diff --git a/lib/stroma.rb b/lib/stroma.rb index 76f73fc..f254120 100644 --- a/lib/stroma.rb +++ b/lib/stroma.rb @@ -2,8 +2,6 @@ require "zeitwerk" -require "active_support/all" - loader = Zeitwerk::Loader.for_gem loader.ignore("#{__dir__}/stroma/test_kit/rspec") loader.inflector.inflect( diff --git a/lib/stroma/dsl/generator.rb b/lib/stroma/dsl/generator.rb index 365a336..d1fc72a 100644 --- a/lib/stroma/dsl/generator.rb +++ b/lib/stroma/dsl/generator.rb @@ -8,6 +8,8 @@ module DSL # # Creates a module that: # - Stores matrix reference on the module itself + # - Defines no-op phase stubs for all entries + # - Defines an orchestrator that calls phase methods sequentially # - Defines ClassMethods for service classes # - Handles inheritance with state duplication # @@ -52,20 +54,32 @@ def initialize(matrix) # Generates the DSL module. # - # Creates a module with ClassMethods that provides: - # - stroma_matrix accessor for matrix reference - # - stroma accessor for per-class state - # - inherited hook for state duplication - # - extensions DSL for registering hooks + # Creates a module with: + # - No-op phase stubs for all entries (overridden by workspace modules) + # - An orchestrator method that calls phases sequentially + # - ClassMethods for stroma_matrix, stroma, inherited, extensions # # @return [Module] The generated DSL module def generate # rubocop:disable Metrics/AbcSize, Metrics/MethodLength matrix = @matrix class_methods = build_class_methods + orchestrator_method = :"_#{matrix.name}_phases_perform!" + entries = matrix.entries + phase_methods = entries.map(&:phase_method).freeze mod = Module.new do @stroma_matrix = matrix + private + + entries.each do |entry| + define_method(entry.phase_method) { |**| nil } + end + + define_method(orchestrator_method) do |**args| + phase_methods.each { |pm| send(pm, **args) } + end + class << self attr_reader :stroma_matrix @@ -82,32 +96,20 @@ def included(base) const_set(:ClassMethods, class_methods) end - label_module(mod, "Stroma::DSL(#{matrix.name})") - label_module(class_methods, "Stroma::DSL(#{matrix.name})::ClassMethods") + Utils.name_module(mod, "Stroma::DSL(#{matrix.name})") + Utils.name_module(class_methods, "Stroma::DSL(#{matrix.name})::ClassMethods") mod end private - # Assigns a descriptive label to an anonymous module for debugging. - # Uses set_temporary_name (Ruby 3.3+) when available. - # - # TODO: Remove the else branch when Ruby 3.2 support is dropped. - # The define_singleton_method fallback is a temporary workaround - # that only affects #inspect and #to_s. Unlike set_temporary_name, - # it does not set #name, so the module remains technically anonymous. - def label_module(mod, label) - if mod.respond_to?(:set_temporary_name) - mod.set_temporary_name(label) - else - mod.define_singleton_method(:inspect) { label } - mod.define_singleton_method(:to_s) { label } - end - end - # Builds the ClassMethods module. # + # NOTE: `inherited` fires before the child class body is evaluated. + # Wraps declared via `extensions {}` on a class only take effect + # for its subclasses (where `inherited` re-applies the hooks). + # # @return [Module] The ClassMethods module def build_class_methods # rubocop:disable Metrics/MethodLength, Metrics/AbcSize Module.new do diff --git a/lib/stroma/entry.rb b/lib/stroma/entry.rb index 1893c9f..bc1b6af 100644 --- a/lib/stroma/entry.rb +++ b/lib/stroma/entry.rb @@ -6,25 +6,38 @@ module Stroma # ## Purpose # # Immutable value object that holds information about a DSL module - # registered in the Stroma system. Each entry has a unique key - # and references a Module that will be included in service classes. + # registered in the Stroma system. Each entry has a unique key, + # references a Module that will be included in service classes, + # and knows its owning matrix name for phase method generation. # # ## Attributes # # - `key` (Symbol): Unique identifier for the DSL module (:inputs, :outputs, :actions) # - `extension` (Module): The actual DSL module to be included + # - `matrix_name` (Symbol): Name of the owning matrix # # ## Usage # # Entries are created internally by Registry.register: # # ```ruby - # Stroma::Registry.register(:inputs, MyInputsDSL) - # # Creates: Entry.new(key: :inputs, extension: MyInputsDSL) + # registry = Stroma::Registry.new(:my_lib) + # registry.register(:inputs, MyInputsDSL) + # # Creates: Entry.new(key: :inputs, extension: MyInputsDSL, matrix_name: :my_lib) # ``` # # ## Immutability # # Entry is immutable (Data object) - once created, it cannot be modified. - Entry = Data.define(:key, :extension) + Entry = Data.define(:key, :extension, :matrix_name) do + # Returns the phase method name for this entry. + # + # Computed from matrix_name and key. Used by the orchestrator + # to call each entry's phase in sequence. + # + # @return [Symbol] The phase method name (e.g., :_my_lib_phase_inputs!) + def phase_method + :"_#{matrix_name}_phase_#{key}!" + end + end end diff --git a/lib/stroma/exceptions/base.rb b/lib/stroma/exceptions/base.rb index 25302d7..00a2145 100644 --- a/lib/stroma/exceptions/base.rb +++ b/lib/stroma/exceptions/base.rb @@ -36,7 +36,7 @@ module Exceptions # - RegistryNotFinalized - Raised when accessing registry before finalization # - KeyAlreadyRegistered - Raised when registering a duplicate key # - UnknownHookTarget - Raised when using an invalid hook target key - # - InvalidHookType - Raised when using an invalid hook type (:before/:after) + # - InvalidMatrixName - Raised when matrix name does not match the valid pattern class Base < StandardError end end diff --git a/lib/stroma/exceptions/invalid_hook_type.rb b/lib/stroma/exceptions/invalid_hook_type.rb deleted file mode 100644 index 212ee15..0000000 --- a/lib/stroma/exceptions/invalid_hook_type.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -module Stroma - module Exceptions - # Raised when an invalid hook type is provided. - # - # ## Purpose - # - # Ensures that only valid hook types (:before, :after) are used - # when creating Stroma::Hooks::Hook objects. Provides fail-fast - # behavior during class definition rather than silent failures at runtime. - # - # ## Usage - # - # ```ruby - # # This will raise InvalidHookType: - # Stroma::Hooks::Hook.new( - # type: :invalid, - # target_key: :actions, - # extension: MyModule - # ) - # # => Stroma::Exceptions::InvalidHookType: - # # Invalid hook type: :invalid. Valid types: :before, :after - # ``` - class InvalidHookType < Base; end - end -end diff --git a/lib/stroma/exceptions/invalid_matrix_name.rb b/lib/stroma/exceptions/invalid_matrix_name.rb new file mode 100644 index 0000000..77c3c8a --- /dev/null +++ b/lib/stroma/exceptions/invalid_matrix_name.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Stroma + module Exceptions + # Raised when a matrix name does not match the valid pattern. + # + # ## Purpose + # + # Ensures that matrix names are valid Ruby identifiers suitable + # for use in method names. Names must match /\A[a-z_][a-z0-9_]*\z/. + # + # ## Usage + # + # ```ruby + # # This will raise InvalidMatrixName: + # Stroma::Matrix.define("123invalid") do + # register :inputs, MyModule + # end + # # => Stroma::Exceptions::InvalidMatrixName: + # # Invalid matrix name: "123invalid". Must match /\A[a-z_][a-z0-9_]*\z/ + # ``` + class InvalidMatrixName < Base; end + end +end diff --git a/lib/stroma/hooks/applier.rb b/lib/stroma/hooks/applier.rb index 33e3418..db073f1 100644 --- a/lib/stroma/hooks/applier.rb +++ b/lib/stroma/hooks/applier.rb @@ -2,44 +2,82 @@ module Stroma module Hooks - # Applies registered hooks to a target class. + # Applies registered wraps to a target class via per-entry towers. # # ## Purpose # - # Includes hook extension modules into target class. - # Maintains order based on matrix registry entries. - # For each entry: before hooks first, then after hooks. + # For each entry with wraps, builds a tower module that contains + # all wrap extensions. Towers are prepended to the target class. + # ClassMethods from wrap extensions are extended directly. + # + # ## Tower convention + # + # Wrap extensions follow the ClassMethods/InstanceMethods convention: + # - `ClassMethods` — extended directly on target_class (for DSL methods) + # - `InstanceMethods` — included in tower module, prepended on target_class + # - Phase::Resolver — checked first for generic wrappable extensions + # - Plain module (no nested constants) — included in tower as-is + # + # ## Caching + # + # Towers are cached by [matrix_name, entry_key, extensions] + # since they are built at boot time and reused across subclasses. # # ## Usage # # ```ruby # # Called internally during class inheritance - # applier = Stroma::Hooks::Applier.new(ChildService, hooks, matrix) - # applier.apply! + # Stroma::Hooks::Applier.apply!(ChildService, hooks, matrix) # ``` # # ## Integration # # Called by DSL::Generator's inherited hook. - # Creates a temporary instance that is garbage collected after apply!. class Applier class << self - # Applies all registered hooks to the target class. + # Applies all registered wraps to the target class. # - # Convenience class method that creates an applier and applies hooks. - # - # @param target_class [Class] The class to apply hooks to + # @param target_class [Class] The class to apply wraps to # @param hooks [Collection] The hooks collection to apply # @param matrix [Matrix] The matrix providing registry entries # @return [void] def apply!(target_class, hooks, matrix) new(target_class, hooks, matrix).apply! end + + # Fetches a cached tower or builds a new one via the given block. + # + # Thread-safety note: tower building occurs during class body evaluation + # (inherited hook), which is single-threaded in standard Ruby boot. + # No synchronization is needed for boot-time-only usage. + # + # @param cache_key [Array] The cache key + # @yield Block that builds the tower when not cached + # @return [Module] The tower module + def fetch_or_build_tower(cache_key) + tower_cache[cache_key] ||= yield + end + + # Clears the tower cache. Intended for test cleanup. + # + # @return [void] + def reset_tower_cache! + @tower_cache = {} + end + + private + + # Returns the tower cache, lazily initialized. + # + # @return [Hash] The cache mapping [matrix_name, key, extensions] to tower modules + def tower_cache + @tower_cache ||= {} + end end - # Creates a new applier for applying hooks to a class. + # Creates a new applier for applying wraps to a class. # - # @param target_class [Class] The class to apply hooks to + # @param target_class [Class] The class to apply wraps to # @param hooks [Collection] The hooks collection to apply # @param matrix [Matrix] The matrix providing registry entries def initialize(target_class, hooks, matrix) @@ -48,19 +86,63 @@ def initialize(target_class, hooks, matrix) @matrix = matrix end - # Applies all registered hooks to the target class. + # Applies all registered wraps to the target class. # - # For each registry entry, includes before hooks first, - # then after hooks. Does nothing if hooks collection is empty. + # For each entry with wraps: + # - Extends ClassMethods directly on target class + # - Builds/fetches a tower module and prepends it # # @return [void] - def apply! + def apply! # rubocop:disable Metrics/MethodLength return if @hooks.empty? @matrix.entries.each do |entry| - @hooks.before(entry.key).each { |hook| @target_class.include(hook.extension) } - @hooks.after(entry.key).each { |hook| @target_class.include(hook.extension) } + wraps_for_entry = @hooks.for(entry.key) + next if wraps_for_entry.empty? + + wraps_for_entry.each do |wrap| + ext = wrap.extension + @target_class.extend(ext::ClassMethods) if ext.const_defined?(:ClassMethods, false) + end + + tower = resolve_tower(entry, wraps_for_entry) + @target_class.prepend(tower) + end + end + + private + + # Fetches a cached tower or builds a new one. + # + # @param entry [Entry] The entry to build tower for + # @param wraps [Array] The wraps for this entry + # @return [Module] The tower module + def resolve_tower(entry, wraps) + cache_key = [entry.matrix_name, entry.key, wraps.map(&:extension)] + self.class.fetch_or_build_tower(cache_key) { build_tower(entry, wraps) } + end + + # Builds a tower module from wraps for a specific entry. + # + # @param entry [Entry] The entry to build tower for + # @param wraps [Array] The wraps for this entry + # @return [Module] The tower module + def build_tower(entry, wraps) # rubocop:disable Metrics/MethodLength + tower = Module.new do + wraps.reverse_each do |wrap| + ext = wrap.extension + resolved = Phase::Resolver.resolve(ext, entry) + if resolved + include resolved + elsif ext.const_defined?(:InstanceMethods, false) + include ext::InstanceMethods + else + include ext + end + end end + Utils.name_module(tower, "Stroma::Tower(#{entry.matrix_name}:#{entry.key})") + tower end end end diff --git a/lib/stroma/hooks/collection.rb b/lib/stroma/hooks/collection.rb index 1b1cdcc..336d5e7 100644 --- a/lib/stroma/hooks/collection.rb +++ b/lib/stroma/hooks/collection.rb @@ -2,53 +2,52 @@ module Stroma module Hooks - # Mutable collection manager for Hook objects. + # Mutable collection manager for Wrap objects. # # ## Purpose # - # Stores Hook objects and provides query methods to retrieve hooks - # by type and target key. Supports proper duplication during class + # Stores Wrap objects and provides query methods to retrieve wraps + # by target key. Supports proper duplication during class # inheritance to ensure configuration isolation. # # ## Usage # # ```ruby # hooks = Stroma::Hooks::Collection.new - # hooks.add(:before, :actions, MyModule) - # hooks.add(:after, :actions, AnotherModule) + # hooks.add(:actions, MyModule) + # hooks.add(:actions, AnotherModule) # - # hooks.before(:actions) # => [Hook(...)] - # hooks.after(:actions) # => [Hook(...)] - # hooks.empty? # => false - # hooks.size # => 2 + # hooks.for(:actions) # => [Wrap(...)] + # hooks.empty? # => false + # hooks.size # => 2 # ``` # # ## Integration # # Stored in Stroma::State and used by - # Stroma::Hooks::Applier to apply hooks to classes. + # Stroma::Hooks::Applier to apply wraps to classes. # Properly duplicated during class inheritance via initialize_dup. class Collection extend Forwardable # @!method each - # Iterates over all hooks in the collection. - # @yield [Hook] Each hook in the collection + # Iterates over all wraps in the collection. + # @yield [Wrap] Each wrap in the collection # @!method map - # Maps over all hooks in the collection. - # @yield [Hook] Each hook in the collection + # Maps over all wraps in the collection. + # @yield [Wrap] Each wrap in the collection # @return [Array] Mapped results # @!method size - # Returns the number of hooks in the collection. - # @return [Integer] Number of hooks + # Returns the number of wraps in the collection. + # @return [Integer] Number of wraps # @!method empty? # Checks if the collection is empty. - # @return [Boolean] true if no hooks registered + # @return [Boolean] true if no wraps registered def_delegators :@collection, :each, :map, :size, :empty? - # Creates a new hooks collection. + # Creates a new wraps collection. # - # @param collection [Set] Initial collection of hooks (default: empty Set) + # @param collection [Set] Initial collection of wraps (default: empty Set) def initialize(collection = Set.new) @collection = collection end @@ -62,39 +61,30 @@ def initialize_dup(original) @collection = original.collection.dup end - # Adds a new hook to the collection. + # Adds a new wrap to the collection. # - # @param type [Symbol] Hook type (:before or :after) - # @param target_key [Symbol] Registry key to hook into + # @param target_key [Symbol] Registry key to wrap # @param extension [Module] Extension module to include # @return [Set] The updated collection # - # @example - # hooks.add(:before, :actions, ValidationModule) - def add(type, target_key, extension) - @collection << Hook.new(type:, target_key:, extension:) - end - - # Returns all before hooks for a given key. - # - # @param key [Symbol] The target key to filter by - # @return [Array] Hooks that run before the target + # Idempotent: duplicate wraps (same target_key + extension) are + # silently ignored due to Set-based storage with Data.define equality. # # @example - # hooks.before(:actions) # => [Hook(type: :before, ...)] - def before(key) - @collection.select { |hook| hook.before? && hook.target_key == key } + # hooks.add(:actions, ValidationModule) + def add(target_key, extension) + @collection << Wrap.new(target_key:, extension:) end - # Returns all after hooks for a given key. + # Returns all wraps for a given key. # # @param key [Symbol] The target key to filter by - # @return [Array] Hooks that run after the target + # @return [Array] Wraps targeting the given key # # @example - # hooks.after(:actions) # => [Hook(type: :after, ...)] - def after(key) - @collection.select { |hook| hook.after? && hook.target_key == key } + # hooks.for(:actions) # => [Wrap(...)] + def for(key) + @collection.select { |wrap| wrap.target_key == key } end protected diff --git a/lib/stroma/hooks/factory.rb b/lib/stroma/hooks/factory.rb index 8656109..ffb704b 100644 --- a/lib/stroma/hooks/factory.rb +++ b/lib/stroma/hooks/factory.rb @@ -2,11 +2,11 @@ module Stroma module Hooks - # DSL interface for registering hooks in extensions block. + # DSL interface for registering wraps in extensions block. # # ## Purpose # - # Provides before/after DSL methods for hook registration. + # Provides wrap DSL method for hook registration. # Validates target keys against the matrix's registry. # Delegates to Hooks::Collection for storage. # @@ -15,8 +15,7 @@ module Hooks # ```ruby # class MyService < MyLib::Base # extensions do - # before :actions, ValidationModule, AuthModule - # after :outputs, LoggingModule + # wrap :actions, ValidationModule, AuthModule # end # end # ``` @@ -26,7 +25,7 @@ module Hooks # Created by DSL::Generator's extensions method. # Cached as @stroma_hooks_factory on each service class. class Factory - # Creates a new factory for registering hooks. + # Creates a new factory for registering wraps. # # @param hooks [Collection] The hooks collection to add to # @param matrix [Matrix] The matrix providing valid keys @@ -35,32 +34,18 @@ def initialize(hooks, matrix) @matrix = matrix end - # Registers one or more before hooks for a target key. + # Registers one or more wraps for a target key. # - # @param key [Symbol] The registry key to hook before + # @param key [Symbol] The registry key to wrap # @param extensions [Array] Extension modules to include # @raise [Exceptions::UnknownHookTarget] If key is not registered # @return [void] # # @example - # before :actions, ValidationModule, AuthorizationModule - def before(key, *extensions) + # wrap :actions, ValidationModule, AuthorizationModule + def wrap(key, *extensions) validate_key!(key) - extensions.each { |extension| @hooks.add(:before, key, extension) } - end - - # Registers one or more after hooks for a target key. - # - # @param key [Symbol] The registry key to hook after - # @param extensions [Array] Extension modules to include - # @raise [Exceptions::UnknownHookTarget] If key is not registered - # @return [void] - # - # @example - # after :outputs, LoggingModule, AuditModule - def after(key, *extensions) - validate_key!(key) - extensions.each { |extension| @hooks.add(:after, key, extension) } + extensions.each { |extension| @hooks.add(key, extension) } end private diff --git a/lib/stroma/hooks/hook.rb b/lib/stroma/hooks/hook.rb deleted file mode 100644 index 0617d6c..0000000 --- a/lib/stroma/hooks/hook.rb +++ /dev/null @@ -1,72 +0,0 @@ -# frozen_string_literal: true - -module Stroma - module Hooks - # Valid hook types for Hook validation. - VALID_HOOK_TYPES = %i[before after].freeze - private_constant :VALID_HOOK_TYPES - - # Immutable value object representing a hook configuration. - # - # ## Purpose - # - # Defines when and where an extension module should be included - # relative to a registered DSL module. Hook is immutable - once - # created, it cannot be modified. - # - # ## Attributes - # - # @!attribute [r] type - # @return [Symbol] Either :before or :after - # @!attribute [r] target_key - # @return [Symbol] Key of the DSL module to hook into (:inputs, :actions, etc.) - # @!attribute [r] extension - # @return [Module] The module to include at the hook point - # - # ## Usage - # - # ```ruby - # hook = Stroma::Hooks::Hook.new( - # type: :before, - # target_key: :actions, - # extension: MyExtension - # ) - # hook.before? # => true - # hook.after? # => false - # ``` - # - # ## Immutability - # - # Hook is a Data object - frozen and immutable after creation. - Hook = Data.define(:type, :target_key, :extension) do - # Initializes a new Hook with validation. - # - # @param type [Symbol] Hook type (:before or :after) - # @param target_key [Symbol] Registry key to hook into - # @param extension [Module] Extension module to include - # @raise [Exceptions::InvalidHookType] If type is invalid - def initialize(type:, target_key:, extension:) - if VALID_HOOK_TYPES.exclude?(type) - raise Exceptions::InvalidHookType, - "Invalid hook type: #{type.inspect}. Valid types: #{VALID_HOOK_TYPES.map(&:inspect).join(', ')}" - end - - super - end - - # Checks if this is a before hook. - # - # @return [Boolean] true if type is :before - def before? - type == :before - end - - # Checks if this is an after hook. - # - # @return [Boolean] true if type is :after - def after? - type == :after - end - end - end -end diff --git a/lib/stroma/hooks/wrap.rb b/lib/stroma/hooks/wrap.rb new file mode 100644 index 0000000..a398cfa --- /dev/null +++ b/lib/stroma/hooks/wrap.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Stroma + module Hooks + # Immutable value object representing a wrap configuration. + # + # ## Purpose + # + # Binds an extension module to a specific registry entry key. + # The extension will be applied as a tower module wrapping + # the entry's phase method. + # + # ## Attributes + # + # @!attribute [r] target_key + # @return [Symbol] Key of the DSL module to wrap (:inputs, :actions, etc.) + # @!attribute [r] extension + # @return [Module] The module to include in the tower + # + # ## Usage + # + # ```ruby + # wrap = Stroma::Hooks::Wrap.new( + # target_key: :actions, + # extension: MyExtension + # ) + # ``` + # + # ## Immutability + # + # Wrap is a Data object - frozen and immutable after creation. + Wrap = Data.define(:target_key, :extension) + end +end diff --git a/lib/stroma/matrix.rb b/lib/stroma/matrix.rb index fc2423b..e1a4aa3 100644 --- a/lib/stroma/matrix.rb +++ b/lib/stroma/matrix.rb @@ -35,6 +35,9 @@ module Stroma # Stored as a constant in the library's namespace. # Owns the Registry and generates DSL module via DSL::Generator. class Matrix + VALID_NAME_PATTERN = /\A[a-z_][a-z0-9_]*\z/ + private_constant :VALID_NAME_PATTERN + class << self # Defines a new Matrix with given name. # @@ -72,6 +75,7 @@ def define(name, &block) # @yield Block for registering DSL modules def initialize(name, &block) @name = name.to_sym + validate_name! @registry = Registry.new(@name) instance_eval(&block) if block_given? @@ -110,5 +114,18 @@ def keys def key?(key) registry.key?(key) end + + private + + # Validates that the matrix name matches the required pattern. + # + # @raise [Exceptions::InvalidMatrixName] If name is invalid + # @return [void] + def validate_name! + return if VALID_NAME_PATTERN.match?(@name) + + raise Exceptions::InvalidMatrixName, + "Invalid matrix name: #{@name.inspect}. Must match #{VALID_NAME_PATTERN.inspect}" + end end end diff --git a/lib/stroma/phase/resolver.rb b/lib/stroma/phase/resolver.rb new file mode 100644 index 0000000..767f2b5 --- /dev/null +++ b/lib/stroma/phase/resolver.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Stroma + module Phase + # Resolves generic phase wraps into concrete modules. + # + # ## Purpose + # + # Takes an extension that uses Phase::Wrappable and an entry, + # finds matching wraps, and generates a module that overrides + # the phase method with wrap logic. + # + # ## Usage + # + # ```ruby + # mod = Stroma::Phase::Resolver.resolve(extension, entry) + # # => Module with phase method override, or nil + # ``` + # + # ## Integration + # + # Called by Hooks::Applier during tower building. + # Returns nil if the extension has no wraps for the given entry. + class Resolver + # Resolves an extension's phase wraps for a specific entry. + # + # @param extension [Module] The extension (with Phase::Wrappable) + # @param entry [Entry] The entry to resolve wraps for + # @return [Module, nil] A module with phase method override, or nil + def self.resolve(extension, entry) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize + return nil unless extension.respond_to?(:stroma_phase_wraps) + + wraps = extension.stroma_phase_wraps.select { |w| w.target_key == entry.key } + return nil if wraps.empty? + + phase_method = entry.phase_method + + mod = Module.new do + # When multiple wraps target the same key within one extension, + # each define_method overwrites the previous — last-wins semantics. + wraps.each do |wrap| + blk = wrap.block + define_method(phase_method) do |**kwargs| + phase = ->(**kw) { super(**kw) } + instance_exec(phase, **kwargs, &blk) + end + end + end + + Utils.name_module(mod, "Stroma::Phase::Resolved(#{entry.matrix_name}:#{entry.key})") + mod + end + end + end +end diff --git a/lib/stroma/phase/wrap.rb b/lib/stroma/phase/wrap.rb new file mode 100644 index 0000000..c878d60 --- /dev/null +++ b/lib/stroma/phase/wrap.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Stroma + module Phase + # Immutable value object representing a generic phase wrap blueprint. + # + # ## Purpose + # + # Stores a target key and a block that will be used to generate + # a concrete module wrapping a phase method. Used by extensions + # that extend Phase::Wrappable. + # + # ## Distinction from Hooks::Wrap + # + # - `Hooks::Wrap` = concrete binding of a module to an entry (target_key + module) + # - `Phase::Wrap` = blueprint of a generic wrap with a block (target_key + block) + # + # ## Attributes + # + # @!attribute [r] target_key + # @return [Symbol] Key of the entry to wrap + # @!attribute [r] block + # @return [Proc] Block that wraps the phase method + Wrap = Data.define(:target_key, :block) + end +end diff --git a/lib/stroma/phase/wrappable.rb b/lib/stroma/phase/wrappable.rb new file mode 100644 index 0000000..37ef72f --- /dev/null +++ b/lib/stroma/phase/wrappable.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Stroma + module Phase + # Extend-module for generic extensions that wrap phase methods. + # + # ## Purpose + # + # Provides `wrap_phase` DSL for extensions that need to wrap + # specific phase methods with custom logic. The wraps are + # resolved at tower-build time by Phase::Resolver. + # + # ## Usage + # + # ```ruby + # module MyExtension + # extend Stroma::Phase::Wrappable + # + # wrap_phase(:actions) do |phase, **kwargs| + # # before logic + # phase.call(**kwargs) + # # after logic + # end + # end + # ``` + module Wrappable + # Registers a phase wrap for the given entry key. + # + # @param key [Symbol] The entry key to wrap + # @yield [phase, **kwargs] Block that wraps the phase method + # @yieldparam phase [Method] The original phase method (call via phase.call) + # @return [void] + def wrap_phase(key, &block) + stroma_phase_wraps << Wrap.new(target_key: key, block:) + end + + # Returns all registered phase wraps for this extension. + # + # @return [Array] The registered wraps + def stroma_phase_wraps + @stroma_phase_wraps ||= [] + end + end + end +end diff --git a/lib/stroma/registry.rb b/lib/stroma/registry.rb index 6258882..0d6018e 100644 --- a/lib/stroma/registry.rb +++ b/lib/stroma/registry.rb @@ -58,7 +58,7 @@ def register(key, extension) "Key #{key.inspect} already registered in #{@matrix_name.inspect}" end - @entries << Entry.new(key:, extension:) + @entries << Entry.new(key:, extension:, matrix_name: @matrix_name) end # Finalizes the registry, preventing further registrations. @@ -70,6 +70,7 @@ def finalize! return if @finalized @entries.freeze + @keys = @entries.map(&:key).freeze @finalized = true end @@ -88,7 +89,7 @@ def entries # @return [Array] The registry keys def keys ensure_finalized! - @entries.map(&:key) + @keys end # Checks if a key is registered. @@ -98,7 +99,7 @@ def keys # @return [Boolean] true if the key is registered def key?(key) ensure_finalized! - @entries.any? { |e| e.key == key.to_sym } + @keys.include?(key.to_sym) end private diff --git a/lib/stroma/state.rb b/lib/stroma/state.rb index 3b59301..0ad634e 100644 --- a/lib/stroma/state.rb +++ b/lib/stroma/state.rb @@ -6,7 +6,7 @@ module Stroma # ## Purpose # # Central container that stores: - # - Hooks collection for before/after extension points + # - Hooks collection for wrap extension points # - Settings collection for extension-specific configuration # # Each service class has its own State instance, duplicated during @@ -29,7 +29,7 @@ module Stroma # class MyLib::Base # include MyLib::DSL # - # stroma.hooks.before(:actions) + # stroma.hooks.for(:actions) # stroma.settings[:actions][:authorization][:method_name] # end # ``` @@ -40,7 +40,7 @@ module Stroma # Duplicated in DSL.inherited to provide inheritance isolation. class State # @!attribute [r] hooks - # @return [Hooks::Collection] The hooks collection for this class + # @return [Hooks::Collection] The wraps collection for this class # @!attribute [r] settings # @return [Settings::Collection] The settings collection for this class attr_reader :hooks, :settings diff --git a/lib/stroma/utils.rb b/lib/stroma/utils.rb new file mode 100644 index 0000000..9f70501 --- /dev/null +++ b/lib/stroma/utils.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Stroma + # Shared utility methods for the Stroma framework. + # + # ## Purpose + # + # Provides common helper methods used across multiple Stroma components. + # All methods are module functions - callable as both module methods + # and instance methods when included. + module Utils + module_function + + # Assigns a temporary name to an anonymous module for debugging clarity. + # Uses set_temporary_name (Ruby 3.3+) when available. + # + # TODO: Remove the else branch when Ruby 3.2 support is dropped. + # The define_singleton_method fallback is a temporary workaround + # that only affects #inspect and #to_s. Unlike set_temporary_name, + # it does not set #name, so the module remains technically anonymous. + # + # @param mod [Module] The module to name + # @param name [String] The temporary name + # @return [void] + def name_module(mod, name) + if mod.respond_to?(:set_temporary_name) + mod.set_temporary_name(name) + else + mod.define_singleton_method(:inspect) { name } + mod.define_singleton_method(:to_s) { name } + end + end + end +end diff --git a/sig/lib/stroma/dsl/generator.rbs b/sig/lib/stroma/dsl/generator.rbs index 6411754..8953dbe 100644 --- a/sig/lib/stroma/dsl/generator.rbs +++ b/sig/lib/stroma/dsl/generator.rbs @@ -11,8 +11,6 @@ module Stroma private - def label_module: (Module mod, String label) -> void - def build_class_methods: () -> Module end end diff --git a/sig/lib/stroma/entry.rbs b/sig/lib/stroma/entry.rbs index 22c004b..fe34b3f 100644 --- a/sig/lib/stroma/entry.rbs +++ b/sig/lib/stroma/entry.rbs @@ -1,11 +1,14 @@ module Stroma - # Note: Entry uses Data.define but RBS should NOT inherit from Data - # per RBS documentation for proper type checking + # NOTE: Entry uses Data.define but RBS should NOT inherit from Data + # per RBS documentation for proper type checking class Entry attr_reader key: Symbol attr_reader extension: Module + attr_reader matrix_name: Symbol - def initialize: (Symbol key, Module extension) -> void - | (key: Symbol, extension: Module) -> void + def initialize: (Symbol key, Module extension, Symbol matrix_name) -> void + | (key: Symbol, extension: Module, matrix_name: Symbol) -> void + + def phase_method: () -> Symbol end end diff --git a/sig/lib/stroma/exceptions/invalid_hook_type.rbs b/sig/lib/stroma/exceptions/invalid_matrix_name.rbs similarity index 59% rename from sig/lib/stroma/exceptions/invalid_hook_type.rbs rename to sig/lib/stroma/exceptions/invalid_matrix_name.rbs index d4b904f..af80cbd 100644 --- a/sig/lib/stroma/exceptions/invalid_hook_type.rbs +++ b/sig/lib/stroma/exceptions/invalid_matrix_name.rbs @@ -1,6 +1,6 @@ module Stroma module Exceptions - class InvalidHookType < Base + class InvalidMatrixName < Base end end end diff --git a/sig/lib/stroma/hooks/applier.rbs b/sig/lib/stroma/hooks/applier.rbs index 628e090..d3b6459 100644 --- a/sig/lib/stroma/hooks/applier.rbs +++ b/sig/lib/stroma/hooks/applier.rbs @@ -1,15 +1,30 @@ module Stroma module Hooks class Applier + self.@tower_cache: Hash[Array[untyped], Module] + @target_class: Class @hooks: Collection @matrix: Matrix def self.apply!: (Class target_class, Collection hooks, Matrix matrix) -> void + def self.fetch_or_build_tower: (Array[untyped] cache_key) ?{ () -> Module } -> Module + + def self.reset_tower_cache!: () -> void + + # Private class method; declared here for Steep visibility + def self.tower_cache: () -> Hash[Array[untyped], Module] + def initialize: (Class target_class, Collection hooks, Matrix matrix) -> void def apply!: () -> void + + private + + def resolve_tower: (Entry entry, Array[Wrap] wraps) -> Module + + def build_tower: (Entry entry, Array[Wrap] wraps) -> Module end end end diff --git a/sig/lib/stroma/hooks/collection.rbs b/sig/lib/stroma/hooks/collection.rbs index 733c753..780e55a 100644 --- a/sig/lib/stroma/hooks/collection.rbs +++ b/sig/lib/stroma/hooks/collection.rbs @@ -3,28 +3,26 @@ module Stroma class Collection extend Forwardable - @collection: Set[Hook] + @collection: Set[Wrap] - def initialize: (?Set[Hook] collection) -> void + def initialize: (?Set[Wrap] collection) -> void def initialize_dup: (Collection original) -> void - def add: (Symbol type, Symbol target_key, Module extension) -> Set[Hook] + def add: (Symbol target_key, Module extension) -> Set[Wrap] - def before: (Symbol key) -> Array[Hook] + def for: (Symbol key) -> Array[Wrap] - def after: (Symbol key) -> Array[Hook] + def each: () { (Wrap) -> void } -> Set[Wrap] - def each: () { (Hook) -> void } -> Set[Hook] - - def map: [T] () { (Hook) -> T } -> Array[T] + def map: [T] () { (Wrap) -> T } -> Array[T] def empty?: () -> bool def size: () -> Integer # Protected in Ruby; declared here for Steep visibility in initialize_dup - attr_reader collection: Set[Hook] + attr_reader collection: Set[Wrap] end end end diff --git a/sig/lib/stroma/hooks/factory.rbs b/sig/lib/stroma/hooks/factory.rbs index 4afec3a..2b7d84f 100644 --- a/sig/lib/stroma/hooks/factory.rbs +++ b/sig/lib/stroma/hooks/factory.rbs @@ -6,9 +6,7 @@ module Stroma def initialize: (Collection hooks, Matrix matrix) -> void - def before: (Symbol key, *Module extensions) -> void - - def after: (Symbol key, *Module extensions) -> void + def wrap: (Symbol key, *Module extensions) -> void private diff --git a/sig/lib/stroma/hooks/hook.rbs b/sig/lib/stroma/hooks/hook.rbs deleted file mode 100644 index 3f6cb3b..0000000 --- a/sig/lib/stroma/hooks/hook.rbs +++ /dev/null @@ -1,20 +0,0 @@ -module Stroma - module Hooks - VALID_HOOK_TYPES: Array[Symbol] - - # Note: Hook uses Data.define but RBS should NOT inherit from Data - # per RBS documentation for proper type checking - class Hook - attr_reader type: Symbol - attr_reader target_key: Symbol - attr_reader extension: Module - - def initialize: (Symbol type, Symbol target_key, Module extension) -> void - | (type: Symbol, target_key: Symbol, extension: Module) -> void - - def before?: () -> bool - - def after?: () -> bool - end - end -end diff --git a/sig/lib/stroma/hooks/wrap.rbs b/sig/lib/stroma/hooks/wrap.rbs new file mode 100644 index 0000000..9e56c49 --- /dev/null +++ b/sig/lib/stroma/hooks/wrap.rbs @@ -0,0 +1,13 @@ +module Stroma + module Hooks + # NOTE: Wrap uses Data.define but RBS should NOT inherit from Data + # per RBS documentation for proper type checking + class Wrap + attr_reader target_key: Symbol + attr_reader extension: Module + + def initialize: (Symbol target_key, Module extension) -> void + | (target_key: Symbol, extension: Module) -> void + end + end +end diff --git a/sig/lib/stroma/matrix.rbs b/sig/lib/stroma/matrix.rbs index e80c883..bec9858 100644 --- a/sig/lib/stroma/matrix.rbs +++ b/sig/lib/stroma/matrix.rbs @@ -1,5 +1,7 @@ module Stroma class Matrix + VALID_NAME_PATTERN: Regexp + @name: Symbol @registry: Registry @dsl: Module @@ -19,5 +21,9 @@ module Stroma def keys: () -> Array[Symbol] def key?: (Symbol key) -> bool + + private + + def validate_name!: () -> void end end diff --git a/sig/lib/stroma/phase/resolver.rbs b/sig/lib/stroma/phase/resolver.rbs new file mode 100644 index 0000000..a1f3648 --- /dev/null +++ b/sig/lib/stroma/phase/resolver.rbs @@ -0,0 +1,7 @@ +module Stroma + module Phase + class Resolver + def self.resolve: (Module extension, Entry entry) -> Module? + end + end +end diff --git a/sig/lib/stroma/phase/wrap.rbs b/sig/lib/stroma/phase/wrap.rbs new file mode 100644 index 0000000..df8d387 --- /dev/null +++ b/sig/lib/stroma/phase/wrap.rbs @@ -0,0 +1,13 @@ +module Stroma + module Phase + # NOTE: Wrap uses Data.define but RBS should NOT inherit from Data + # per RBS documentation for proper type checking + class Wrap + attr_reader target_key: Symbol + attr_reader block: Proc + + def initialize: (Symbol target_key, Proc block) -> void + | (target_key: Symbol, block: Proc) -> void + end + end +end diff --git a/sig/lib/stroma/phase/wrappable.rbs b/sig/lib/stroma/phase/wrappable.rbs new file mode 100644 index 0000000..2d3cbe7 --- /dev/null +++ b/sig/lib/stroma/phase/wrappable.rbs @@ -0,0 +1,11 @@ +module Stroma + module Phase + module Wrappable + @stroma_phase_wraps: Array[Wrap] + + def wrap_phase: (Symbol key) { (Method, **untyped) -> void } -> void + + def stroma_phase_wraps: () -> Array[Wrap] + end + end +end diff --git a/sig/lib/stroma/registry.rbs b/sig/lib/stroma/registry.rbs index a631fa8..4e3e13b 100644 --- a/sig/lib/stroma/registry.rbs +++ b/sig/lib/stroma/registry.rbs @@ -2,6 +2,7 @@ module Stroma class Registry @matrix_name: Symbol @entries: Array[Entry] + @keys: Array[Symbol] @finalized: bool attr_reader matrix_name: Symbol diff --git a/sig/lib/stroma/settings/setting.rbs b/sig/lib/stroma/settings/setting.rbs index c4520dd..ee5a35f 100644 --- a/sig/lib/stroma/settings/setting.rbs +++ b/sig/lib/stroma/settings/setting.rbs @@ -27,7 +27,7 @@ module Stroma def to_h: () -> Hash[Symbol, untyped] - # Note: Ruby implementation uses splat args: fetch(key, *args, &block) + # NOTE: Ruby implementation uses splat args: fetch(key, *args, &block) def fetch: (Symbol key) -> untyped | (Symbol key, untyped default) -> untyped | (Symbol key) { (Symbol) -> untyped } -> untyped diff --git a/sig/lib/stroma/utils.rbs b/sig/lib/stroma/utils.rbs new file mode 100644 index 0000000..cd660d5 --- /dev/null +++ b/sig/lib/stroma/utils.rbs @@ -0,0 +1,9 @@ +module Stroma + module Utils + def self.name_module: (Module mod, String name) -> void + + private + + def name_module: (Module mod, String name) -> void + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 3a81ee2..0abbc99 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -11,6 +11,8 @@ Dir[File.join(__dir__, "support", "**", "*.rb")].each { |file| require file } RSpec.configure do |config| + config.after { Stroma::Hooks::Applier.reset_tower_cache! } + # Enable flags like --only-failures and --next-failure config.example_status_persistence_file_path = ".rspec_status" diff --git a/spec/stroma/dsl/generator_spec.rb b/spec/stroma/dsl/generator_spec.rb index c2f5b2c..cc2b5fb 100644 --- a/spec/stroma/dsl/generator_spec.rb +++ b/spec/stroma/dsl/generator_spec.rb @@ -13,6 +13,10 @@ end end + def find_tower(target) + target.ancestors.find { |a| a.inspect.include?("Stroma::Tower") } + end + describe ".call" do let(:dsl_module) { described_class.call(matrix) } @@ -35,12 +39,6 @@ expect(class_methods.inspect).to include("Stroma::DSL(test)") expect(class_methods.inspect).to include("ClassMethods") end - - if Module.new.respond_to?(:set_temporary_name) - it "sets module name via set_temporary_name" do - expect(dsl_module.name).to eq("Stroma::DSL(test)") - end - end end end @@ -70,12 +68,133 @@ end end + describe "phase stubs and orchestrator" do + let(:base_class) do + mtx = matrix + Class.new { include mtx.dsl } + end + + it "defines phase methods as private" do + instance = base_class.new + expect(instance.private_methods).to include(:_test_phase_inputs!, :_test_phase_outputs!) + end + + it "defines orchestrator as private" do + instance = base_class.new + expect(instance.private_methods).to include(:_test_phases_perform!) + end + + it "orchestrator calls all phases in order" do + call_order = [] + co = call_order + + inputs_mod = Module.new do + define_method(:_test_phase_inputs!) do |**| + co << :inputs + end + end + + outputs_mod = Module.new do + define_method(:_test_phase_outputs!) do |**| + co << :outputs + end + end + + mtx = Stroma::Matrix.define(:test) do + register :inputs, inputs_mod + register :outputs, outputs_mod + end + + service_class = Class.new { include mtx.dsl } + service_class.new.send(:_test_phases_perform!) + + expect(call_order).to eq(%i[inputs outputs]) + end + + it "phase stubs are no-ops by default" do + expect { base_class.new.send(:_test_phase_inputs!) }.not_to raise_error + end + + it "orchestrator passes kwargs to phases" do + received_kwargs = {} + rk = received_kwargs + + inputs_mod = Module.new do + define_method(:_test_phase_inputs!) do |**kwargs| + rk.merge!(kwargs) + end + end + + mtx = Stroma::Matrix.define(:test) do + register :inputs, inputs_mod + end + + service_class = Class.new { include mtx.dsl } + service_class.new.send(:_test_phases_perform!, foo: :bar) + + expect(received_kwargs).to eq(foo: :bar) + end + + context "with wrap extensions" do + let(:call_order) { [] } + + let(:wrap_extension) do + co = call_order + Module.new do + extend Stroma::Phase::Wrappable + + wrap_phase(:inputs) do |phase, **kwargs| + co << :wrap_before + phase.call(**kwargs) + co << :wrap_after + end + end + end + + let(:matrix) do + co = call_order + inputs_mod = Module.new do + define_method(:_test_phase_inputs!) { |**| co << :inputs_phase } + end + outputs_mod = Module.new do + define_method(:_test_phase_outputs!) { |**| co << :outputs_phase } + end + + Stroma::Matrix.define(:test) do + register :inputs, inputs_mod + register :outputs, outputs_mod + end + end + + let(:base_class) do + mtx = matrix + ext = wrap_extension + Class.new do + include mtx.dsl + + extensions do + wrap :inputs, ext + end + end + end + + it "orchestrator calls wrapped phases in correct order" do + service_class = Class.new(base_class) + service_class.new.send(:_test_phases_perform!) + + expect(call_order).to eq(%i[wrap_before inputs_phase wrap_after outputs_phase]) + end + end + end + describe "inheritance" do let(:extension_module) do Module.new do - def self.included(base) - base.define_singleton_method(:extension_method) { :extension_result } - end + const_set(:ClassMethods, Module.new do + def extension_method + :extension_result + end + end) end end @@ -86,18 +205,18 @@ def self.included(base) include mtx.dsl extensions do - before :inputs, ext + wrap :inputs, ext end end end let(:child_class) { Class.new(base_class) } - it "applies hooks to child class" do - expect(child_class.ancestors).to include(extension_module) + it "applies wraps to child class" do + expect(find_tower(child_class)).not_to be_nil end - it "child has extension method", :aggregate_failures do + it "child has extension ClassMethods", :aggregate_failures do expect(child_class).to respond_to(:extension_method) expect(child_class.extension_method).to eq(:extension_result) end @@ -122,7 +241,7 @@ def self.included(base) include mtx.dsl extensions do - before :inputs, ext + wrap :inputs, ext end end end @@ -134,30 +253,30 @@ def self.included(base) child_class.class_eval do extensions do - after :outputs, child_extension + wrap :outputs, child_extension end end - expect(parent_class.stroma.hooks.after(:outputs)).to be_empty - expect(child_class.stroma.hooks.after(:outputs)).not_to be_empty + expect(parent_class.stroma.hooks.for(:outputs)).to be_empty + expect(child_class.stroma.hooks.for(:outputs)).not_to be_empty end it "child inherits parent hooks", :aggregate_failures do - expect(child_class.stroma.hooks.before(:inputs).size).to eq(1) - expect(child_class.ancestors).to include(extension_module) + expect(child_class.stroma.hooks.for(:inputs).size).to eq(1) + expect(find_tower(child_class)).not_to be_nil end it "parent modifications after child creation do not affect child" do - child_before_count = child_class.stroma.hooks.before(:outputs).size + child_before_count = child_class.stroma.hooks.for(:outputs).size new_extension = Module.new parent_class.class_eval do extensions do - before :outputs, new_extension + wrap :outputs, new_extension end end - expect(child_class.stroma.hooks.before(:outputs).size).to eq(child_before_count) + expect(child_class.stroma.hooks.for(:outputs).size).to eq(child_before_count) end end end diff --git a/spec/stroma/entry_spec.rb b/spec/stroma/entry_spec.rb index 52da467..cc21b7d 100644 --- a/spec/stroma/entry_spec.rb +++ b/spec/stroma/entry_spec.rb @@ -1,13 +1,25 @@ # frozen_string_literal: true RSpec.describe Stroma::Entry do - subject(:entry) { described_class.new(key: :test, extension: test_module) } + subject(:entry) { described_class.new(key: :test, extension: test_module, matrix_name: :my_lib) } let(:test_module) { Module.new } describe ".new" do it { expect(entry.key).to eq(:test) } it { expect(entry.extension).to eq(test_module) } + it { expect(entry.matrix_name).to eq(:my_lib) } + end + + describe "#phase_method" do + it "returns symbol combining matrix_name and key" do + expect(entry.phase_method).to eq(:_my_lib_phase_test!) + end + + it "handles different matrix names" do + other = described_class.new(key: :actions, extension: test_module, matrix_name: :servactory) + expect(other.phase_method).to eq(:_servactory_phase_actions!) + end end describe "immutability" do @@ -17,12 +29,15 @@ end describe "equality" do - let(:same_entry) { described_class.new(key: :test, extension: test_module) } - let(:different_key_entry) { described_class.new(key: :other, extension: test_module) } + let(:same_entry) { described_class.new(key: :test, extension: test_module, matrix_name: :my_lib) } + let(:different_key_entry) { described_class.new(key: :other, extension: test_module, matrix_name: :my_lib) } let(:different_module) { Module.new } - let(:different_extension_entry) { described_class.new(key: :test, extension: different_module) } + let(:different_extension_entry) do + described_class.new(key: :test, extension: different_module, matrix_name: :my_lib) + end + let(:different_matrix_entry) { described_class.new(key: :test, extension: test_module, matrix_name: :other_lib) } - it "is equal to entry with same key and extension" do + it "is equal to entry with same key, extension, and matrix_name" do expect(entry).to eq(same_entry) end @@ -34,6 +49,10 @@ expect(entry).not_to eq(different_extension_entry) end + it "is not equal to entry with different matrix_name" do + expect(entry).not_to eq(different_matrix_entry) + end + it "has same hash for equal entries" do expect(entry.hash).to eq(same_entry.hash) end diff --git a/spec/stroma/exceptions/invalid_hook_type_spec.rb b/spec/stroma/exceptions/invalid_matrix_name_spec.rb similarity index 82% rename from spec/stroma/exceptions/invalid_hook_type_spec.rb rename to spec/stroma/exceptions/invalid_matrix_name_spec.rb index 1de0ef9..d968b7a 100644 --- a/spec/stroma/exceptions/invalid_hook_type_spec.rb +++ b/spec/stroma/exceptions/invalid_matrix_name_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.describe Stroma::Exceptions::InvalidHookType do +RSpec.describe Stroma::Exceptions::InvalidMatrixName do it "inherits from Base" do expect(described_class.superclass).to eq(Stroma::Exceptions::Base) end diff --git a/spec/stroma/hooks/applier_spec.rb b/spec/stroma/hooks/applier_spec.rb index 93b98d6..337fbab 100644 --- a/spec/stroma/hooks/applier_spec.rb +++ b/spec/stroma/hooks/applier_spec.rb @@ -16,67 +16,173 @@ let(:target_class) { Class.new } let(:applier) { described_class.new(target_class, hooks, matrix) } + def find_tower(target) + target.ancestors.find { |a| a.inspect.include?("Stroma::Tower") } + end + describe ".apply!" do - let(:before_extension) { Module.new } + let(:wrap_extension) { Module.new } before do - hooks.add(:before, :inputs, before_extension) + hooks.add(:inputs, wrap_extension) end - it "applies hooks via class method" do + it "applies wraps via class method" do described_class.apply!(target_class, hooks, matrix) - expect(target_class.ancestors).to include(before_extension) + expect(target_class.ancestors).to include(wrap_extension) end end describe "#apply!" do it "does nothing when hooks empty" do + ancestors_before = target_class.ancestors.dup applier.apply! - expect(target_class.ancestors).not_to include(inputs_dsl) + expect(target_class.ancestors).to eq(ancestors_before) end - context "with before hooks" do - let(:before_extension) { Module.new } + context "with plain module wraps" do + let(:wrap_extension) { Module.new } before do - hooks.add(:before, :inputs, before_extension) + hooks.add(:inputs, wrap_extension) end - it "includes before hook extension" do + it "prepends tower containing the extension" do applier.apply! - expect(target_class.ancestors).to include(before_extension) + expect(target_class.ancestors).to include(wrap_extension) end end - context "with after hooks" do - let(:after_extension) { Module.new } + context "with ClassMethods convention" do + let(:class_methods_mod) do + Module.new do + def class_dsl_method + :class_result + end + end + end + + let(:wrap_extension) do + cm = class_methods_mod + Module.new do + const_set(:ClassMethods, cm) + end + end before do - hooks.add(:after, :outputs, after_extension) + hooks.add(:inputs, wrap_extension) end - it "includes after hook extension" do + it "extends ClassMethods on target class" do applier.apply! - expect(target_class.ancestors).to include(after_extension) + expect(target_class).to respond_to(:class_dsl_method) + end + + it "ClassMethods method works" do + applier.apply! + expect(target_class.class_dsl_method).to eq(:class_result) end end - context "with multiple hooks" do # rubocop:disable RSpec/MultipleMemoizedHelpers - let(:before_inputs) { Module.new } - let(:after_inputs) { Module.new } - let(:before_outputs) { Module.new } + context "with InstanceMethods convention" do + let(:instance_methods_mod) do + Module.new do + def instance_wrap_method + :instance_result + end + end + end + + let(:wrap_extension) do + im = instance_methods_mod + Module.new do + const_set(:InstanceMethods, im) + end + end before do - hooks.add(:before, :inputs, before_inputs) - hooks.add(:after, :inputs, after_inputs) - hooks.add(:before, :outputs, before_outputs) + hooks.add(:inputs, wrap_extension) end - it "applies all hooks", :aggregate_failures do + it "includes InstanceMethods in tower" do applier.apply! - expect(target_class.ancestors).to include(before_inputs) - expect(target_class.ancestors).to include(after_inputs) - expect(target_class.ancestors).to include(before_outputs) + expect(target_class.new).to respond_to(:instance_wrap_method) + end + end + + context "with multiple wraps for same entry" do + let(:first_extension) { Module.new } + let(:second_extension) { Module.new } + + before do + hooks.add(:inputs, first_extension) + hooks.add(:inputs, second_extension) + end + + it "applies all wraps", :aggregate_failures do + applier.apply! + expect(target_class.ancestors).to include(first_extension) + expect(target_class.ancestors).to include(second_extension) + end + end + + context "with wraps for different entries" do + let(:inputs_extension) { Module.new } + let(:outputs_extension) { Module.new } + + before do + hooks.add(:inputs, inputs_extension) + hooks.add(:outputs, outputs_extension) + end + + it "builds separate towers", :aggregate_failures do + applier.apply! + expect(target_class.ancestors).to include(inputs_extension) + expect(target_class.ancestors).to include(outputs_extension) + end + end + + describe "tower labeling" do + let(:wrap_extension) { Module.new } + + before do + hooks.add(:inputs, wrap_extension) + end + + it "labels tower modules" do + applier.apply! + expect(find_tower(target_class).inspect).to eq("Stroma::Tower(test:inputs)") + end + end + + describe "tower caching" do + let(:wrap_extension) { Module.new } + + before do + hooks.add(:inputs, wrap_extension) + end + + it "reuses cached tower for same wraps" do + first_class = Class.new + second_class = Class.new + + described_class.apply!(first_class, hooks, matrix) + described_class.apply!(second_class, hooks, matrix) + + expect(find_tower(first_class)).to equal(find_tower(second_class)) + end + + it "builds different towers for different wraps" do + other_hooks = Stroma::Hooks::Collection.new + other_hooks.add(:inputs, Module.new) + + first_class = Class.new + second_class = Class.new + + described_class.apply!(first_class, hooks, matrix) + described_class.apply!(second_class, other_hooks, matrix) + + expect(find_tower(first_class)).not_to equal(find_tower(second_class)) end end end diff --git a/spec/stroma/hooks/collection_spec.rb b/spec/stroma/hooks/collection_spec.rb index 6f005c8..487a6e4 100644 --- a/spec/stroma/hooks/collection_spec.rb +++ b/spec/stroma/hooks/collection_spec.rb @@ -6,50 +6,32 @@ let(:second_module) { Module.new } describe "#add" do - it "adds a hook to the collection" do - hooks.add(:before, :actions, first_module) - expect(hooks.before(:actions).size).to eq(1) + it "adds a wrap to the collection" do + hooks.add(:actions, first_module) + expect(hooks.for(:actions).size).to eq(1) end - it "allows multiple hooks for the same key" do - hooks.add(:before, :actions, first_module) - hooks.add(:before, :actions, second_module) - expect(hooks.before(:actions).size).to eq(2) + it "allows multiple wraps for the same key" do + hooks.add(:actions, first_module) + hooks.add(:actions, second_module) + expect(hooks.for(:actions).size).to eq(2) end end - describe "#before" do + describe "#for" do before do - hooks.add(:before, :actions, first_module) - hooks.add(:after, :actions, second_module) - hooks.add(:before, :outputs, second_module) + hooks.add(:actions, first_module) + hooks.add(:outputs, second_module) end - it "returns only before hooks for the specified key", :aggregate_failures do - result = hooks.before(:actions) + it "returns wraps for the specified key", :aggregate_failures do + result = hooks.for(:actions) expect(result.size).to eq(1) expect(result.first.extension).to eq(first_module) end - it "returns empty array for key without before hooks" do - expect(hooks.before(:inputs)).to eq([]) - end - end - - describe "#after" do - before do - hooks.add(:before, :actions, first_module) - hooks.add(:after, :actions, second_module) - end - - it "returns only after hooks for the specified key", :aggregate_failures do - result = hooks.after(:actions) - expect(result.size).to eq(1) - expect(result.first.extension).to eq(second_module) - end - - it "returns empty array for key without after hooks" do - expect(hooks.after(:inputs)).to eq([]) + it "returns empty array for key without wraps" do + expect(hooks.for(:inputs)).to eq([]) end end @@ -58,8 +40,8 @@ expect(hooks.empty?).to be(true) end - it "returns false after adding a hook" do - hooks.add(:before, :actions, first_module) + it "returns false after adding a wrap" do + hooks.add(:actions, first_module) expect(hooks.empty?).to be(false) end end @@ -68,19 +50,19 @@ let(:copy) { hooks.dup } before do - hooks.add(:before, :actions, first_module) - hooks.add(:after, :outputs, second_module) + hooks.add(:actions, first_module) + hooks.add(:outputs, second_module) end - it "creates a copy with the same hooks", :aggregate_failures do - expect(copy.before(:actions).size).to eq(1) - expect(copy.after(:outputs).size).to eq(1) + it "creates a copy with the same wraps", :aggregate_failures do + expect(copy.for(:actions).size).to eq(1) + expect(copy.for(:outputs).size).to eq(1) end it "creates an independent copy", :aggregate_failures do - copy.add(:before, :inputs, second_module) - expect(hooks.before(:inputs)).to be_empty - expect(copy.before(:inputs).size).to eq(1) + copy.add(:inputs, second_module) + expect(hooks.for(:inputs)).to be_empty + expect(copy.for(:inputs).size).to eq(1) end end @@ -89,25 +71,25 @@ expect(hooks.size).to eq(0) end - it "returns count of hooks" do - hooks.add(:before, :actions, first_module) - hooks.add(:after, :actions, second_module) + it "returns count of wraps" do + hooks.add(:actions, first_module) + hooks.add(:actions, second_module) expect(hooks.size).to eq(2) end end describe "#each" do before do - hooks.add(:before, :actions, first_module) - hooks.add(:after, :outputs, second_module) + hooks.add(:actions, first_module) + hooks.add(:outputs, second_module) end - it "yields each hook" do + it "yields each wrap" do expect(hooks.map(&:itself).size).to eq(2) end - it "yields Hook objects" do - expect(hooks.map(&:itself)).to all(be_a(Stroma::Hooks::Hook)) + it "yields Wrap objects" do + expect(hooks.map(&:itself)).to all(be_a(Stroma::Hooks::Wrap)) end end diff --git a/spec/stroma/hooks/factory_spec.rb b/spec/stroma/hooks/factory_spec.rb index ed59922..1072e8d 100644 --- a/spec/stroma/hooks/factory_spec.rb +++ b/spec/stroma/hooks/factory_spec.rb @@ -13,40 +13,20 @@ let(:first_module) { Module.new } let(:second_module) { Module.new } - describe "#before" do - it "adds a before hook", :aggregate_failures do - factory.before(:actions, first_module) - expect(hooks.before(:actions).size).to eq(1) - expect(hooks.before(:actions).first.extension).to eq(first_module) + describe "#wrap" do + it "adds a wrap", :aggregate_failures do + factory.wrap(:actions, first_module) + expect(hooks.for(:actions).size).to eq(1) + expect(hooks.for(:actions).first.extension).to eq(first_module) end it "adds multiple modules at once" do - factory.before(:actions, first_module, second_module) - expect(hooks.before(:actions).size).to eq(2) + factory.wrap(:actions, first_module, second_module) + expect(hooks.for(:actions).size).to eq(2) end it "raises UnknownHookTarget for unknown key" do - expect { factory.before(:unknown, first_module) }.to raise_error( - Stroma::Exceptions::UnknownHookTarget, - "Unknown hook target :unknown for :test. Valid: :inputs, :outputs, :actions" - ) - end - end - - describe "#after" do - it "adds an after hook", :aggregate_failures do - factory.after(:outputs, first_module) - expect(hooks.after(:outputs).size).to eq(1) - expect(hooks.after(:outputs).first.extension).to eq(first_module) - end - - it "adds multiple modules at once" do - factory.after(:outputs, first_module, second_module) - expect(hooks.after(:outputs).size).to eq(2) - end - - it "raises UnknownHookTarget for unknown key" do - expect { factory.after(:unknown, first_module) }.to raise_error( + expect { factory.wrap(:unknown, first_module) }.to raise_error( Stroma::Exceptions::UnknownHookTarget, "Unknown hook target :unknown for :test. Valid: :inputs, :outputs, :actions" ) @@ -56,7 +36,7 @@ describe "valid keys" do it "accepts all registered keys" do %i[inputs outputs actions].each do |key| - expect { factory.before(key, first_module) }.not_to raise_error + expect { factory.wrap(key, first_module) }.not_to raise_error end end end diff --git a/spec/stroma/hooks/hook_spec.rb b/spec/stroma/hooks/hook_spec.rb deleted file mode 100644 index 344fd8f..0000000 --- a/spec/stroma/hooks/hook_spec.rb +++ /dev/null @@ -1,69 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Stroma::Hooks::Hook do - let(:test_module) { Module.new } - - describe ".new" do - subject(:hook) { described_class.new(type: :before, target_key: :actions, extension: test_module) } - - it { expect(hook.type).to eq(:before) } - it { expect(hook.target_key).to eq(:actions) } - it { expect(hook.extension).to eq(test_module) } - end - - describe "#before?" do - it "returns true for before type" do - hook = described_class.new(type: :before, target_key: :actions, extension: test_module) - expect(hook.before?).to be(true) - end - - it "returns false for after type" do - hook = described_class.new(type: :after, target_key: :actions, extension: test_module) - expect(hook.before?).to be(false) - end - end - - describe "#after?" do - it "returns true for after type" do - hook = described_class.new(type: :after, target_key: :outputs, extension: test_module) - expect(hook.after?).to be(true) - end - - it "returns false for before type" do - hook = described_class.new(type: :before, target_key: :outputs, extension: test_module) - expect(hook.after?).to be(false) - end - end - - describe "immutability" do - it "is immutable (Data object)" do - hook = described_class.new(type: :before, target_key: :actions, extension: test_module) - expect { hook.instance_variable_set(:@type, :after) }.to raise_error(FrozenError) - end - end - - describe "type validation" do - it "accepts :before type" do - expect { described_class.new(type: :before, target_key: :actions, extension: test_module) } - .not_to raise_error - end - - it "accepts :after type" do - expect { described_class.new(type: :after, target_key: :actions, extension: test_module) } - .not_to raise_error - end - - it "raises InvalidHookType for invalid type" do - expect { described_class.new(type: :invalid, target_key: :actions, extension: test_module) } - .to raise_error( - Stroma::Exceptions::InvalidHookType, - "Invalid hook type: :invalid. Valid types: :before, :after" - ) - end - - it "raises InvalidHookType for nil type" do - expect { described_class.new(type: nil, target_key: :actions, extension: test_module) } - .to raise_error(Stroma::Exceptions::InvalidHookType) - end - end -end diff --git a/spec/stroma/hooks/wrap_spec.rb b/spec/stroma/hooks/wrap_spec.rb new file mode 100644 index 0000000..75ae00d --- /dev/null +++ b/spec/stroma/hooks/wrap_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +RSpec.describe Stroma::Hooks::Wrap do + subject(:wrap) { described_class.new(target_key: :actions, extension: test_module) } + + let(:test_module) { Module.new } + + describe ".new" do + it { expect(wrap.target_key).to eq(:actions) } + it { expect(wrap.extension).to eq(test_module) } + end + + describe "immutability" do + it "is frozen" do + expect(wrap).to be_frozen + end + end + + describe "equality" do + let(:same_wrap) { described_class.new(target_key: :actions, extension: test_module) } + let(:different_key_wrap) { described_class.new(target_key: :inputs, extension: test_module) } + let(:different_module) { Module.new } + let(:different_extension_wrap) { described_class.new(target_key: :actions, extension: different_module) } + + it "is equal to wrap with same key and extension" do + expect(wrap).to eq(same_wrap) + end + + it "is not equal to wrap with different key" do + expect(wrap).not_to eq(different_key_wrap) + end + + it "is not equal to wrap with different extension" do + expect(wrap).not_to eq(different_extension_wrap) + end + end +end diff --git a/spec/stroma/matrix_spec.rb b/spec/stroma/matrix_spec.rb index 7c7293e..5ef48c1 100644 --- a/spec/stroma/matrix_spec.rb +++ b/spec/stroma/matrix_spec.rb @@ -47,6 +47,52 @@ end end + describe "validate_name!" do + it "accepts valid lowercase names" do + expect { described_class.new(:test) }.not_to raise_error + end + + it "accepts names with underscores" do + expect { described_class.new(:my_lib) }.not_to raise_error + end + + it "accepts names starting with underscore" do + expect { described_class.new(:_private) }.not_to raise_error + end + + it "accepts names with digits" do + expect { described_class.new(:lib2) }.not_to raise_error # rubocop:disable Naming/VariableNumber + end + + it "raises InvalidMatrixName for names starting with digit" do + expect { described_class.new(:"123invalid") }.to raise_error( + Stroma::Exceptions::InvalidMatrixName, + "Invalid matrix name: :\"123invalid\". Must match /\\A[a-z_][a-z0-9_]*\\z/" + ) + end + + it "raises InvalidMatrixName for names with uppercase" do + expect { described_class.new(:MyLib) }.to raise_error( + Stroma::Exceptions::InvalidMatrixName, + "Invalid matrix name: :MyLib. Must match /\\A[a-z_][a-z0-9_]*\\z/" + ) + end + + it "raises InvalidMatrixName for names with dashes" do + expect { described_class.new(:"my-lib") }.to raise_error( + Stroma::Exceptions::InvalidMatrixName, + "Invalid matrix name: :\"my-lib\". Must match /\\A[a-z_][a-z0-9_]*\\z/" + ) + end + + it "raises InvalidMatrixName for empty name" do + expect { described_class.new(:"") }.to raise_error( + Stroma::Exceptions::InvalidMatrixName, + "Invalid matrix name: :\"\". Must match /\\A[a-z_][a-z0-9_]*\\z/" + ) + end + end + describe "#register" do it "delegates to registry" do matrix = described_class.new(:test) do diff --git a/spec/stroma/phase/resolver_spec.rb b/spec/stroma/phase/resolver_spec.rb new file mode 100644 index 0000000..67c4ba6 --- /dev/null +++ b/spec/stroma/phase/resolver_spec.rb @@ -0,0 +1,152 @@ +# frozen_string_literal: true + +RSpec.describe Stroma::Phase::Resolver do + let(:entry) { Stroma::Entry.new(key: :actions, extension: Module.new, matrix_name: :test) } + + describe ".resolve" do + context "when extension does not respond to stroma_phase_wraps" do + let(:extension) { Module.new } + + it "returns nil" do + expect(described_class.resolve(extension, entry)).to be_nil + end + end + + context "when extension has no wraps for the entry" do + let(:extension) do + Module.new do + extend Stroma::Phase::Wrappable + + wrap_phase(:inputs) { |phase, **kwargs| phase.call(**kwargs) } + end + end + + it "returns nil" do + expect(described_class.resolve(extension, entry)).to be_nil + end + end + + context "when extension has wraps for the entry" do + let(:call_log) { [] } + + let(:extension) do + log = call_log + Module.new do + extend Stroma::Phase::Wrappable + + wrap_phase(:actions) do |phase, **kwargs| + log << :before + phase.call(**kwargs) + log << :after + end + end + end + + let(:resolved) { described_class.resolve(extension, entry) } + + it "returns a module" do + expect(resolved).to be_a(Module) + end + + it "labels the module" do + expect(resolved.inspect).to include("Stroma::Phase::Resolved(test:actions)") + end + + it "defines the phase method" do + expect(resolved.instance_methods(false)).to include(:_test_phase_actions!) + end + + it "executes the wrap block around the phase" do + target_class = Class.new do + define_method(:_test_phase_actions!) { |**| } # rubocop:disable Lint/EmptyBlock + end + target_class.prepend(resolved) + + target_class.new.send(:_test_phase_actions!) + expect(call_log).to eq(%i[before after]) + end + end + + context "when multiple resolved modules are combined in a tower" do + let(:call_log) { [] } + + let(:first_extension) do + log = call_log + Module.new do + extend Stroma::Phase::Wrappable + + wrap_phase(:actions) do |phase, **kwargs| + log << :first_before + phase.call(**kwargs) + log << :first_after + end + end + end + + let(:second_extension) do + log = call_log + Module.new do + extend Stroma::Phase::Wrappable + + wrap_phase(:actions) do |phase, **kwargs| + log << :second_before + phase.call(**kwargs) + log << :second_after + end + end + end + + let(:tower) do + first_resolved = described_class.resolve(first_extension, entry) + second_resolved = described_class.resolve(second_extension, entry) + Module.new do + include second_resolved + include first_resolved + end + end + + it "chains correctly without infinite recursion" do + target_class = Class.new do + define_method(:_test_phase_actions!) { |**| } # rubocop:disable Lint/EmptyBlock + end + target_class.prepend(tower) + + target_class.new.send(:_test_phase_actions!) + expect(call_log).to eq(%i[first_before second_before second_after first_after]) + end + end + + context "when multiple wraps target the same key (last-wins)" do + let(:call_log) { [] } + + let(:extension) do + log = call_log + Module.new do + extend Stroma::Phase::Wrappable + + wrap_phase(:actions) do |phase, **kwargs| + log << :first + phase.call(**kwargs) + end + + wrap_phase(:actions) do |phase, **kwargs| + log << :second + phase.call(**kwargs) + end + end + end + + let(:resolved) { described_class.resolve(extension, entry) } + + it "only executes the last wrap block" do + target_class = Class.new do + define_method(:_test_phase_actions!) { |**| } # rubocop:disable Lint/EmptyBlock + end + target_class.prepend(resolved) + + target_class.new.send(:_test_phase_actions!) + expect(call_log).to eq(%i[second]) + end + end + end +end diff --git a/spec/stroma/phase/wrap_spec.rb b/spec/stroma/phase/wrap_spec.rb new file mode 100644 index 0000000..ea87df6 --- /dev/null +++ b/spec/stroma/phase/wrap_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +RSpec.describe Stroma::Phase::Wrap do + subject(:wrap) { described_class.new(target_key: :actions, block: test_block) } + + let(:test_block) { proc { |phase, **kwargs| phase.call(**kwargs) } } + + describe ".new" do + it { expect(wrap.target_key).to eq(:actions) } + it { expect(wrap.block).to eq(test_block) } + end + + describe "immutability" do + it "is frozen" do + expect(wrap).to be_frozen + end + end +end diff --git a/spec/stroma/phase/wrappable_spec.rb b/spec/stroma/phase/wrappable_spec.rb new file mode 100644 index 0000000..b3e3657 --- /dev/null +++ b/spec/stroma/phase/wrappable_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +RSpec.describe Stroma::Phase::Wrappable do + let(:extension) do + Module.new do + extend Stroma::Phase::Wrappable + end + end + + describe "#wrap_phase" do + it "registers a phase wrap" do + extension.wrap_phase(:actions) { |phase, **kwargs| phase.call(**kwargs) } + expect(extension.stroma_phase_wraps.size).to eq(1) + end + + it "creates a Phase::Wrap with correct target_key" do + extension.wrap_phase(:actions) { |phase, **kwargs| phase.call(**kwargs) } + expect(extension.stroma_phase_wraps.first.target_key).to eq(:actions) + end + + it "stores the block" do + extension.wrap_phase(:actions) { |phase, **kwargs| phase.call(**kwargs) } + expect(extension.stroma_phase_wraps.first.block).to be_a(Proc) + end + + it "allows multiple wraps for different keys" do + extension.wrap_phase(:actions) { |phase, **kwargs| phase.call(**kwargs) } + extension.wrap_phase(:inputs) { |phase, **kwargs| phase.call(**kwargs) } + expect(extension.stroma_phase_wraps.size).to eq(2) + end + end + + describe "#stroma_phase_wraps" do + it "returns empty array by default" do + expect(extension.stroma_phase_wraps).to eq([]) + end + end +end diff --git a/spec/stroma/registry_spec.rb b/spec/stroma/registry_spec.rb index 269982a..1a446a5 100644 --- a/spec/stroma/registry_spec.rb +++ b/spec/stroma/registry_spec.rb @@ -19,6 +19,14 @@ expect(registry.keys).to eq([:inputs]) end + it "creates entry with matrix_name" do + extension = Module.new + registry.register(:inputs, extension) + registry.finalize! + + expect(registry.entries.first.matrix_name).to eq(:test) + end + it "raises KeyAlreadyRegistered for duplicate key" do registry.register(:inputs, Module.new) diff --git a/spec/stroma/state_spec.rb b/spec/stroma/state_spec.rb index 2b13a27..746f028 100644 --- a/spec/stroma/state_spec.rb +++ b/spec/stroma/state_spec.rb @@ -27,12 +27,12 @@ let(:copy) { state.dup } before do - state.hooks.add(:before, :actions, test_module) + state.hooks.add(:actions, test_module) state.settings[:actions][:authorization][:method_name] = :authorize end it "creates a copy with the same hooks" do - expect(copy.hooks.before(:actions).size).to eq(1) + expect(copy.hooks.for(:actions).size).to eq(1) end it "creates a copy with the same settings" do @@ -40,10 +40,10 @@ end it "creates an independent copy for hooks", :aggregate_failures do - copy.hooks.add(:after, :outputs, test_module) + copy.hooks.add(:outputs, test_module) - expect(state.hooks.after(:outputs)).to be_empty - expect(copy.hooks.after(:outputs).size).to eq(1) + expect(state.hooks.for(:outputs)).to be_empty + expect(copy.hooks.for(:outputs).size).to eq(1) end it "creates an independent copy for settings", :aggregate_failures do diff --git a/spec/stroma/utils_spec.rb b/spec/stroma/utils_spec.rb new file mode 100644 index 0000000..858926b --- /dev/null +++ b/spec/stroma/utils_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +RSpec.describe Stroma::Utils do + describe ".name_module" do + let(:mod) { Module.new } + let(:name) { "Stroma::Test(example)" } + + before { described_class.name_module(mod, name) } + + it "sets inspect to the name" do + expect(mod.inspect).to eq(name) + end + + it "sets to_s to the name" do + expect(mod.to_s).to eq(name) + end + end +end diff --git a/spec/stroma/version_spec.rb b/spec/stroma/version_spec.rb index 8d63ca7..ae7e3bb 100644 --- a/spec/stroma/version_spec.rb +++ b/spec/stroma/version_spec.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true RSpec.describe Stroma::VERSION do - it { expect(Stroma::VERSION::STRING).to be_present } + it { expect(Stroma::VERSION::STRING).not_to be_nil } end