Wanderland

ShapeMatcher

Deep comparison engine for verifying actual output against expected shapes. The single comparison primitive every scenario lens funnels through — --type test, the X-Wanderland-Verify HTTP probe, the --wanderland-verify CLI probe, and Scenario.rspec_describe! all reach it through the verify_route boundary. Also used directly by the file-based Scenario infrastructure for boundary-level YAML tests in wanderland-core's own suite.

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.

Where ShapeMatcher runs

Every route-level verify path funnels through the same ShapeMatcher call:

One matcher instance per comparison. Failure strings are identical across every lens — the ShapeMatcher output that --type test prints is the same string rspec surfaces and the same string the 422 body carries.

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)

Register once, match everywhere

Matchers register into the global Wanderland::ShapeMatcher registry. Every lens builds ShapeMatcher instances against the same registry — register the matcher before the first dispatch and all four lenses pick it up automatically.

Register at boot. Put the registration in a file the service loads before the runtime starts serving — typically alongside lib/<app>.rb:

# lib/my_app/matchers.rb

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(: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
# lib/my_app.rb
require "wanderland-core"
require_relative "my_app/matchers"

module MyApp
end

Use in the scenarios: block like any built-in:

scenarios:
  sensor_read:
    healthy_range:
      description: "Reading within operational bounds"
      input:
        params: { reading: 36.7 }
      expected:
        temperature:
          between:
            min: 35.0
            max: 42.0
        status:
          starts_with: "OK"

Every lens sees the matcher:

# Batch — custom matchers land in every scenario comparison
bundle exec wanderland --type test config.yml

# Live HTTP probe
curl -H 'X-Wanderland-Verify: healthy_range' http://localhost:9294/sensor_read

# CLI probe
bundle exec wanderland --type cli config.yml sensor_read reading=36.7 --wanderland-verify=healthy_range

# RSpec via the shared helper
bundle exec rspec

Because Scenario#verify(runtime:) dispatches through the normal chain, the registered matcher fires inside verify_route regardless of which lens initiated the request.

RSpec-only matchers

A matcher that's only needed at test time — a fixture-specific helper, a comparison that has no production use — registers in spec/spec_helper.rb so it exists during bundle exec rspec but not when the HTTP server is running:

# spec/spec_helper.rb
require "wanderland-core"

Wanderland::ShapeMatcher.register(:fixture_uuid) do |actual, operand, path, failures, _|
  unless actual.to_s.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/)
    failures << "#{path}: expected UUID, got #{actual.inspect}"
  end
end

The rspec lens picks it up; the other three don't.

Register at runtime

The same register call works anywhere. Matchers registered after boot are available to all subsequent ShapeMatcher instances — useful for integration tests that exercise registration itself.

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

Site Audit

wanderland.dev

oculus-view: fence: fence execute HTTP 404