Wanderland

ShapeMatcher

Deep comparison engine for verifying actual output against expected shapes. Used by the Scenario infrastructure to verify boundary outputs in YAML-driven tests.

Matchers are registered strategies. The built-in set covers common assertions. Custom matchers can be registered for domain-specific checks.

How It Works

ShapeMatcher walks two trees — actual and expected — in parallel. Plain values are compared directly. Hashes are compared key by key. Arrays are compared element by element.

When a key in the expected hash matches a registered matcher name, it's dispatched to that matcher's strategy instead of being treated as a field comparison. This means matchers compose naturally with field checks in the same expected block:

expected:
  repos:
    count: 3
    first:
      name: kremis
  status: ok

Here count and first are matchers applied to the repos array. status is a plain field comparison. name inside first is a plain comparison on the first element. The matcher registry decides which keys are matchers and which are fields.

Built-in Matchers

Collection Size

Matcher Operand Checks
count integer actual.size == operand
items:
  count: 3

Numeric Comparisons

Work on numeric values directly, or on .size for collections.

Matcher Checks
gte actual >= operand
lte actual <= operand
gt actual > operand
lt actual < operand
score:
  gte: 50
  lte: 100

String Matching

Matcher Operand Checks
matches regex string actual.to_s.match?(Regexp.new(operand))
version:
  matches: "^v\\d+\\.\\d+"

Array Membership

Matcher Operand Checks
contains single value actual.include?(operand)
includes array of values all values present in actual
excludes array of values no values present in actual
colors:
  contains: red
  includes: [red, blue]
  excludes: [yellow]

Array Position

Matcher Operand Checks
first expected shape actual.first matches the shape
last expected shape actual.last matches the shape

The operand is itself a shape — it recurses. You can nest matchers inside first/last.

stages:
  first: build
  last:
    has_key: status

Array Search

Matcher Operand Checks
any expected shape at least one element in actual matches the shape
users:
  any:
    role: admin

Hash Structure

Matcher Operand Checks
keys array of strings actual.keys matches exactly (order-independent)
has_key single key actual contains this key
config:
  has_key: host
  keys: [host, port]

Negation

Matcher Operand Checks
not expected shape passes when the inner match fails
status:
  not: stopped

Emptiness

Matcher Operand Checks
empty true/false true = must be empty, false = must be non-empty
errors:
  empty: true
results:
  empty: false

Registering Custom Matchers

A matcher is a callable registered by name. It receives five arguments:

Argument What it is
actual The value being tested
operand The value from the expected YAML
path Dot-separated path for error messages
failures Array to append failure strings to
matcher The ShapeMatcher instance (for recursive checks)

In a spec helper

Create a file that registers your matchers and require it from spec_helper.rb:

# spec/support/custom_matchers.rb

require "wanderland/shape_matcher"

Wanderland::ShapeMatcher.register(:between) do |actual, operand, path, failures, _|
  min = operand["min"] || operand[:min]
  max = operand["max"] || operand[:max]
  val = actual.is_a?(Numeric) ? actual : actual.to_f
  unless val >= min && val <= max
    failures << "#{path}: expected between #{min}..#{max}, got #{val}"
  end
end

Wanderland::ShapeMatcher.register(:type) do |actual, operand, path, failures, _|
  expected_type = operand.to_s
  actual_type = actual.class.name.downcase
  unless actual_type == expected_type || actual.is_a?(Object.const_get(operand))
    failures << "#{path}: expected type #{expected_type}, got #{actual_type}"
  end
end
# spec/spec_helper.rb

require "wanderland-core"
require_relative "support/custom_matchers"

Then use them in YAML scenarios like any built-in:

name: "custom matcher test"
operation: passthrough
input:
  temperature: 36.7
expected:
  temperature:
    between:
      min: 35.0
      max: 42.0

At runtime

Same register call works anywhere. Matchers registered after boot are available to all subsequent ShapeMatcher instances.

Wanderland::ShapeMatcher.register(:starts_with) do |actual, operand, path, failures, _|
  unless actual.to_s.start_with?(operand.to_s)
    failures << "#{path}: expected to start with #{operand.inspect}, got #{actual.inspect}"
  end
end