Skip to content

Commit a31e411

Browse files
committed
Add canonical interface checker
Pairwise comparison verifies that every adjacent pair of classes in a list agree with each other, which works well when all classes are peers with no single authoritative definition. Canonical interface comparison takes a different approach: one class is designated as the reference, and every other class is checked against it directly. This is useful when the interface already has a clear owner — an abstract base, a well-established implementation, or a purpose-built interface class — and you want to confirm that all other implementations conform to that definition. Because all comparisons share the same right-hand side, failures from multiple classes are aggregated into a single result, making it easier to see the full picture at a glance. - Adds `CanonicalInterfaceChecker`, which compares every class in a list against a single canonical reference rather than in consecutive pairs. All failures are aggregated into one result, so a single assertion reports every non-conforming class at once. - Exposes `assert_canonical_interface_match(canonical, objects, **opts)` in the Minitest integration and `implement_canonical_interface(canonical, **opts)` in the RSpec integration. All existing options (`type:`, `methods:`, `strict:`, `name:`, `namespace:`) are supported. - Extracts shared checker initialization (argument validation, object resolution, name inference, checker construction) into `InterfaceSetup`, eliminating duplication between `BulkInterfaceChecker` and `CanonicalInterfaceChecker`.
1 parent 4228b44 commit a31e411

14 files changed

Lines changed: 610 additions & 35 deletions

File tree

README.md

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,54 @@ assert_interfaces_match namespace: Payments
168168
Duck Typer will resolve the module's constants and infer the
169169
interface name from the module name when `name:` is not given.
170170

171+
#### Canonical interface
172+
173+
Use `assert_canonical_interface_match` when you have a reference
174+
class and want to verify that every implementation conforms to it.
175+
The canonical class comes first — following the `assert_equal
176+
expected, actual` convention — followed by the list of classes to
177+
check:
178+
179+
```ruby
180+
def test_payment_processors_implement_canonical_interface
181+
assert_canonical_interface_match PaymentBase, [
182+
StripeProcessor,
183+
PaypalProcessor,
184+
BraintreeProcessor
185+
]
186+
end
187+
```
188+
189+
You can also pass a `namespace:` instead of a list:
190+
191+
```ruby
192+
assert_canonical_interface_match PaymentBase, namespace: Payments
193+
```
194+
195+
When multiple classes fail, a single assertion reports all of them
196+
at once:
197+
198+
```
199+
Expected all objects to implement compatible interfaces defined by
200+
PaymentBase, but the following method signatures differ:
201+
202+
StripeProcessor: charge(amount)
203+
PaymentBase: charge(amount, currency:)
204+
205+
PaypalProcessor: refund not defined
206+
PaymentBase: refund(transaction_id)
207+
```
208+
209+
The same `type:`, `methods:`, `strict:`, and `name:` options are
210+
supported:
211+
212+
```ruby
213+
assert_canonical_interface_match PaymentBase, [StripeProcessor, PaypalProcessor],
214+
methods: %i[charge refund],
215+
strict: true,
216+
name: "PaymentProcessor"
217+
```
218+
171219
### RSpec
172220

173221
Require the RSpec integration in your `spec_helper.rb`:
@@ -228,6 +276,40 @@ To check all classes in a module, pass it as a named subject:
228276
expect(namespace: Payments).to have_matching_interfaces
229277
```
230278

279+
#### Canonical interface
280+
281+
Use `implement_canonical_interface` when you have a reference class
282+
and want to verify that a list of classes conforms to it:
283+
284+
```ruby
285+
RSpec.describe "payment processors" do
286+
it "implement the canonical interface" do
287+
expect([StripeProcessor, PaypalProcessor, BraintreeProcessor])
288+
.to implement_canonical_interface(PaymentBase)
289+
end
290+
end
291+
```
292+
293+
You can also pass a namespace:
294+
295+
```ruby
296+
expect(namespace: Payments).to implement_canonical_interface(PaymentBase)
297+
```
298+
299+
The same `type:`, `methods:`, `strict:`, and `name:` options are
300+
supported:
301+
302+
```ruby
303+
expect([StripeProcessor, PaypalProcessor])
304+
.to implement_canonical_interface(PaymentBase,
305+
methods: %i[charge refund],
306+
strict: true,
307+
name: "PaymentProcessor")
308+
```
309+
310+
When multiple classes fail, the failure message reports all of them
311+
at once.
312+
231313
#### Shared example
232314

233315
If you prefer shared examples, register one in `spec_helper.rb`

lib/duck_typer.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
require_relative "duck_typer/version"
44
require_relative "duck_typer/interface_checker"
55
require_relative "duck_typer/bulk_interface_checker"
6+
require_relative "duck_typer/canonical_interface_checker"
67

78
# DuckTyper enforces duck-typed interfaces in Ruby by comparing the public
89
# method signatures of classes, surfacing mismatches through your test suite.
Lines changed: 5 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,21 @@
11
# frozen_string_literal: true
22

3+
require_relative "interface_setup"
4+
35
module DuckTyper
46
# Runs interface checks across all consecutive pairs of classes in a list.
57
class BulkInterfaceChecker
68
def initialize(objects = nil, namespace: nil, type: :instance_methods, methods: nil, strict: false, name: nil)
7-
raise ArgumentError, "cannot specify both objects and namespace" if objects && namespace
8-
raise ArgumentError, "objects or namespace is required" if objects.nil? && namespace.nil?
9-
10-
@objects = resolve_objects(objects, namespace)
11-
raise ArgumentError, "more than one object is required" if @objects.size < 2
12-
13-
name ||= namespace&.name
14-
@checker = InterfaceChecker.new(type:, methods:, strict:, name:)
9+
@setup = InterfaceSetup.new(objects, namespace:, type:, methods:, strict:, name:, minimum: 2)
1510
end
1611

1712
def call(&block)
18-
@objects.each_cons(2).map do |left, right|
19-
result = @checker.call(left, right)
13+
@setup.objects.each_cons(2).map do |left, right|
14+
result = @setup.checker.call(left, right)
2015
block&.call(result)
2116

2217
result
2318
end
2419
end
25-
26-
private
27-
28-
def resolve_objects(objects, namespace)
29-
namespace ? resolve_namespace(namespace) : Array(objects)
30-
end
31-
32-
def resolve_namespace(namespace)
33-
namespace
34-
.constants
35-
.map { |const| namespace.const_get(const) }
36-
.select { |const| const.is_a?(Module) }
37-
end
3820
end
3921
end
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# frozen_string_literal: true
2+
3+
require_relative "canonical_interface_checker/result"
4+
require_relative "interface_setup"
5+
6+
module DuckTyper
7+
# Compares each class in a list against a single canonical reference class.
8+
class CanonicalInterfaceChecker
9+
def initialize(objects = nil, canonical:, namespace: nil, type: :instance_methods, methods: nil, strict: false, name: nil)
10+
@setup = InterfaceSetup.new(objects, namespace:, type:, methods:, strict:, name:, minimum: 1)
11+
@canonical = canonical
12+
end
13+
14+
def call
15+
results = @setup.objects.map { |obj| @setup.checker.call(obj, @canonical) }
16+
17+
Result.new(canonical: @canonical, results:, name: @setup.name, strict: @setup.strict)
18+
end
19+
end
20+
end
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# frozen_string_literal: true
2+
3+
require_relative "../result_formatting"
4+
5+
module DuckTyper
6+
class CanonicalInterfaceChecker
7+
class Result
8+
include ResultFormatting
9+
10+
def initialize(canonical:, results:, name:, strict:)
11+
@canonical = canonical
12+
@results = results
13+
@name = name
14+
@strict = strict
15+
end
16+
17+
def match?
18+
@results.all?(&:match?)
19+
end
20+
21+
def failure_message
22+
return if match?
23+
24+
failing = @results.reject(&:match?)
25+
26+
<<~MSG
27+
Expected all objects to implement compatible \
28+
#{interface_label} defined by #{@canonical}, \
29+
but the following method signatures differ:#{strict_note}
30+
31+
#{failing.map(&:diff_message).join("\n")}
32+
MSG
33+
end
34+
end
35+
end
36+
end

lib/duck_typer/interface_checker/result.rb

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
# frozen_string_literal: true
22

3+
require_relative "../result_formatting"
4+
35
module DuckTyper
46
class InterfaceChecker
57
class Result
8+
include ResultFormatting
9+
610
attr_reader :left, :right
711

812
def initialize(left:, right:, match:, diff_message:, name:, strict:)
@@ -18,6 +22,10 @@ def match?
1822
@match.call
1923
end
2024

25+
def diff_message
26+
@diff_message.call
27+
end
28+
2129
def failure_message
2230
return if match?
2331

@@ -28,16 +36,6 @@ def failure_message
2836
#{@diff_message.call}
2937
MSG
3038
end
31-
32-
private
33-
34-
def interface_label
35-
@name ? %("#{@name}" interfaces) : "interfaces"
36-
end
37-
38-
def strict_note
39-
@strict ? " (strict mode: positional argument names must match)" : ""
40-
end
4139
end
4240
end
4341
end

lib/duck_typer/interface_setup.rb

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# frozen_string_literal: true
2+
3+
require_relative "interface_checker"
4+
5+
module DuckTyper
6+
class InterfaceSetup
7+
attr_reader :objects, :checker, :name, :strict
8+
9+
def initialize(objects, namespace:, type:, methods:, strict:, name:, minimum: 0)
10+
raise ArgumentError, "cannot specify both objects and namespace" if objects && namespace
11+
raise ArgumentError, "objects or namespace is required" if objects.nil? && namespace.nil?
12+
13+
@objects = resolve_objects(objects, namespace)
14+
raise ArgumentError, "at least #{minimum} object(s) required" if @objects.size < minimum
15+
16+
name ||= namespace&.name
17+
@name = name
18+
@strict = strict
19+
@checker = InterfaceChecker.new(type:, methods:, strict:, name:)
20+
end
21+
22+
private
23+
24+
def resolve_objects(objects, namespace)
25+
namespace ? resolve_namespace(namespace) : Array(objects)
26+
end
27+
28+
def resolve_namespace(namespace)
29+
namespace
30+
.constants
31+
.map { |const| namespace.const_get(const) }
32+
.select { |const| const.is_a?(Module) }
33+
end
34+
end
35+
end

lib/duck_typer/minitest.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,13 @@ def assert_interfaces_match(objects = nil, namespace: nil, type: :instance_metho
1414
end
1515

1616
alias_method :assert_duck_types_match, :assert_interfaces_match
17+
18+
def assert_canonical_interface_match(canonical, objects = nil, namespace: nil, type: :instance_methods, methods: nil, strict: false, name: nil)
19+
checker = CanonicalInterfaceChecker
20+
.new(objects, canonical:, namespace:, type:, methods:, strict:, name:)
21+
22+
result = checker.call
23+
assert result.match?, result.failure_message
24+
end
1725
end
1826
end
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# frozen_string_literal: true
2+
3+
module DuckTyper
4+
module ResultFormatting
5+
private
6+
7+
def interface_label
8+
@name ? %("#{@name}" interfaces) : "interfaces"
9+
end
10+
11+
def strict_note
12+
@strict ? " (strict mode: positional argument names must match)" : ""
13+
end
14+
end
15+
end

lib/duck_typer/rspec.rb

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,23 @@
2121

2222
RSpec::Matchers.alias_matcher :have_matching_duck_types, :have_matching_interfaces
2323

24+
RSpec::Matchers.define :implement_canonical_interface do |canonical, name: nil, type: :instance_methods, methods: nil, strict: false|
25+
match do |actual|
26+
namespace = actual.is_a?(Hash) ? actual[:namespace] : nil
27+
objects = namespace ? nil : actual
28+
29+
checker = DuckTyper::CanonicalInterfaceChecker
30+
.new(objects, canonical:, namespace:, type:, methods:, strict:, name:)
31+
32+
@result = checker.call
33+
@result.match?
34+
end
35+
36+
failure_message do
37+
@result.failure_message
38+
end
39+
end
40+
2441
module DuckTyper
2542
module RSpec
2643
def self.define_shared_example(name = "an interface")

0 commit comments

Comments
 (0)