Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
source "https://rubygems.org"

gem "minitest"
gem "minitest-mock"
gem "simplecov"
gem "casting"
gem "async"
Expand Down
13 changes: 7 additions & 6 deletions lib/surrounded/context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -206,19 +204,22 @@ 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
end
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.
Expand Down Expand Up @@ -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
Expand Down
7 changes: 2 additions & 5 deletions lib/surrounded/context/role_builders.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
34 changes: 29 additions & 5 deletions lib/surrounded/context/role_map.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
12 changes: 9 additions & 3 deletions lib/surrounded/context/seclusion.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions test/role_identity_test.rb
Original file line number Diff line number Diff line change
@@ -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