From cdf5996601d429458a648c0ab39ff418125569dc Mon Sep 17 00:00:00 2001 From: Jim Gay Date: Wed, 24 Jun 2026 14:09:30 -0400 Subject: [PATCH 1/3] Add minitest-mock for minitest 6 compatibility --- Gemfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Gemfile b/Gemfile index eadf400..2802be4 100644 --- a/Gemfile +++ b/Gemfile @@ -1,6 +1,7 @@ source "https://rubygems.org" gem "minitest" +gem "minitest-mock" gem "simplecov" gem "casting" gem "async" From b840517863cc2c87e038bef3a1a48428c2f1fb4c Mon Sep 17 00:00:00 2001 From: Jim Gay Date: Wed, 24 Jun 2026 14:09:30 -0400 Subject: [PATCH 2/3] Add Ruby 4.0 to the CI matrix --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e913e77..13b3173 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,7 +12,7 @@ jobs: strategy: fail-fast: false matrix: - ruby: ["3.2", "3.3", "3.4"] + ruby: ["3.2", "3.3", "3.4", "4.0"] name: Ruby ${{ matrix.ruby }} steps: - uses: actions/checkout@v5 From 2ba2283b39b6056b0b7785fa2059019bc9f3f1ef Mon Sep 17 00:00:00 2001 From: Jim Gay Date: Wed, 24 Jun 2026 14:09:30 -0400 Subject: [PATCH 3/3] Resolve role players by identity; track assigned and applied players in RoleMap --- lib/surrounded/context.rb | 13 +++++---- lib/surrounded/context/role_builders.rb | 7 ++--- lib/surrounded/context/role_map.rb | 34 +++++++++++++++++---- lib/surrounded/context/seclusion.rb | 12 ++++++-- test/role_identity_test.rb | 39 +++++++++++++++++++++++++ 5 files changed, 86 insertions(+), 19 deletions(-) create mode 100644 test/role_identity_test.rb diff --git a/lib/surrounded/context.rb b/lib/surrounded/context.rb index 058ddec..4ed0b70 100644 --- a/lib/surrounded/context.rb +++ b/lib/surrounded/context.rb @@ -96,7 +96,7 @@ module InstanceMethods def role?(name, &block) return false unless role_map.role?(name) accessor = block.binding.eval("self") - role_map.role_player?(accessor) && role_map.assigned_player(name) + role_map.role_player?(accessor) && role_map.current_player(name) end # Check if a given object is a role player in the context. @@ -153,7 +153,6 @@ def map_role_collection(role, mod_name, collection) end def map_role(role, mod_name, object) - instance_variable_set("@#{role}", object) role_map.update(role, role_module_basename(mod_name), object) end @@ -168,7 +167,6 @@ def apply_behavior(role, behavior, object) end role_player = applicator.call(role_const(behavior), object) - map_role(role, behavior, role_player) end role_player || object end @@ -206,6 +204,7 @@ def remove_behavior(role, behavior, object) def apply_behaviors role_map.each do |role, mod_name, object| player = apply_behavior(role, mod_name, object) + role_map.apply(role, player) if player.respond_to?(:store_context, true) player.__send__(:store_context) {} end @@ -213,12 +212,14 @@ def apply_behaviors end def remove_behaviors - role_map.each do |role, mod_name, player| + role_map.each do |role, mod_name, object| + player = role_map.current_player(role) if player.respond_to?(:remove_context, true) player.__send__(:remove_context) {} end remove_behavior(role, mod_name, player) end + role_map.reset_applied end # List of possible methods to use to add behavior to an object from a module. @@ -284,8 +285,8 @@ def initialize(string, suffix = nil) .to_s .split("_") .map { |part| - part.capitalize - } + part.capitalize + } .join .sub(/_\d+/, "") + suffix.to_s end diff --git a/lib/surrounded/context/role_builders.rb b/lib/surrounded/context/role_builders.rb index b17cca4..eea43aa 100644 --- a/lib/surrounded/context/role_builders.rb +++ b/lib/surrounded/context/role_builders.rb @@ -46,11 +46,8 @@ def delegate_class(name, class_name, &block) # Create an object which will bind methods to the role player. # # This object will behave differently that a wrapper or delegate_class. - # The interface object should only be used for objects whose methods - # _will not_ call to the other objects in the context. - # Because the interface methods are applied individually to an object, - # that object is unaware of the other objects in the context and cannot - # access them from any of its methods. + # Interface methods run as the wrapped object itself, so they can access + # the other objects in the context only if that object includes Surrounded. def interface(name, &block) # AdminInterface interface_name = RoleName(name, "Interface") diff --git a/lib/surrounded/context/role_map.rb b/lib/surrounded/context/role_map.rb index 8dfed79..2ff0ed3 100644 --- a/lib/surrounded/context/role_map.rb +++ b/lib/surrounded/context/role_map.rb @@ -32,17 +32,41 @@ def role?(role) keys.include?(role) end - # Check if an object is playing a role in this map + # Record the behaviored player applied to a role for the duration of a + # trigger. The assigned domain object stays in the container; the player + # (a wrapper, or the same object for cast roles) is tracked here. + def apply(role, player) + applied[role] = player + end + + # The player a role currently presents: the applied player while a trigger + # is running, otherwise the assigned domain object. + def current_player(role) + applied.fetch(role) { assigned_player(role) } + end + + # Forget all applied players, e.g. after a trigger removes behaviors. + def reset_applied + applied.clear + end + + # Check if an object is playing a role in this map, by identity — whether + # it is the assigned domain object or the applied player wrapping it. def role_player?(object) - !values(object).empty? - rescue container.class::ItemNotPresent - false + values.any? { |player| player.equal?(object) } || + applied.values.any? { |player| player.equal?(object) } end - # Get the object playing the given role + # Get the domain object assigned to the given role def assigned_player(role) values(role).first end + + private + + def applied + @applied ||= {} + end end end end diff --git a/lib/surrounded/context/seclusion.rb b/lib/surrounded/context/seclusion.rb index 041974b..3bedafd 100644 --- a/lib/surrounded/context/seclusion.rb +++ b/lib/surrounded/context/seclusion.rb @@ -10,10 +10,16 @@ def private_const_set(name, const) const end - # Create attr_reader for the named methods and make them private + # Create readers for the named methods and make them private. Role names + # resolve to the current player (original object, or its applied wrapper + # during a trigger); any other name reads its instance variable. def private_attr_reader(*method_names) - attr_reader(*method_names) - private(*method_names) + method_names.each do |name| + define_method(name) do + role_map.role?(name) ? role_map.current_player(name) : instance_variable_get(:"@#{name}") + end + private(name) + end end end end diff --git a/test/role_identity_test.rb b/test/role_identity_test.rb new file mode 100644 index 0000000..ec92a17 --- /dev/null +++ b/test/role_identity_test.rb @@ -0,0 +1,39 @@ +require "test_helper" + +# Refuses value-equality so resolution must use object identity. +class IdentityResistant + include Surrounded + + def initialize(name) + @name = name + end + attr_reader :name + + def ==(other) = false + def eql?(other) = false + def hash = object_id +end + +class SpeakerContext + extend Surrounded::Context + + initialize :speaker, :listener + + interface :speaker do + def greet + listener.name + end + end + + trigger :speak do + speaker.greet + end +end + +describe "role identity independent of equality" do + it "resolves a sibling role by object identity, not ==" do + a = IdentityResistant.new("A") + b = IdentityResistant.new("B") + expect(SpeakerContext.new(speaker: a, listener: b).speak).must_equal "B" + end +end