Wanderland

How to Write Wanderland Scenarios

Quick reference for writing YAML scenario files that work with the Wanderland::Scenario loader, ShapeMatcher verifier, and Game engine.

YAML Format

name: "Human-readable scenario name"
description: |
  What this scenario tests and why.
  Multi-line is fine.

operation: boundary_name       # maps to a registered Wanderland::Boundary

level:
  world: 1                     # grouping (1-based)
  number: 1                    # ordering within world
  hint: "A hint for game mode"

input:                         # passed to the boundary's call(input)
  params:
    repo: kremis
    kind: method
    name: execute
  context:                     # optional — simulates upstream chain output
    repo_path: /mnt/repos/kremis
    grammars: [ruby]

expected:                      # verified against boundary output via ShapeMatcher
  symbols:
    count: 1
    first:
      name: execute
      kind: method

blanks:                        # optional — for game mode (fill-in-the-blank)
  symbol_name:
    path: "symbols.0.name"
    answer: "execute"
    hint: "The method that runs a pipeline stage"
  file_path:
    path: "symbols.0.path"
    answer: "lib/kremis/pipeline.rb"
    hint: "Where does the pipeline live?"

Shape Matchers

The expected section supports exact values and matcher keys. Matchers are special hash keys that trigger comparison logic instead of literal matching.

Exact Match

Scalar values compare directly. Strings, numbers, booleans, null.

expected:
  status: ok
  count: 42
  enabled: true

Collection Size

expected:
  files:
    count: 17          # exactly 17
  annotations:
    gte: 1             # at least 1
    lte: 100           # at most 100
  symbols:
    gt: 0              # more than 0
    lt: 50             # fewer than 50

Emptiness

expected:
  errors:
    empty: true        # must be empty
  results:
    empty: false       # must NOT be empty

Array Membership

expected:
  grammars:
    includes: [ruby, python]    # must include ALL of these
    excludes: [cobol]           # must include NONE of these
    contains: ruby              # must include this single value

Partial Shape on Array Elements

expected:
  annotations:
    first:                      # first element must match this shape
      comment_text: "# @requirement:DL-URS-001"
    last:
      path: "lib/dark_lantern/engine.rb"
    any:                        # at least one element must match
      kind: class
      name: Pipeline

Partial matching means only the specified fields are checked. Unspecified fields are ignored. This is critical — you don't need to match the entire object.

String Pattern

expected:
  content:
    matches: "def execute"     # regex match against string value

Key Presence

expected:
  context:
    has_key: repo_path         # hash must contain this key
    keys: [repo, repo_path, grammars]  # exact key set (sorted)

Negation

expected:
  result:
    not:
      status: error            # result must NOT match this shape

Nesting

Matchers compose. You can nest exact values inside shape matchers:

expected:
  annotations:
    count: 3
    any:
      comment_text:
        matches: "@requirement:DL-URS-010"
      code_text:
        matches: "class AnnotationQuery"

File Organization

Scenarios live in spec/scenarios/ organized by boundary:

spec/scenarios/
├── resolve_repo/
│   ├── 01_known_repo.yml
│   ├── 02_unknown_repo.yml
│   └── 03_local_vs_remote.yml
├── file_serve/
│   ├── 01_full_file.yml
│   └── 02_line_range.yml
└── annotation_query/
    ├── 01_single_annotation.yml
    └── 05_self_trace.yml

Numbering controls order. The game engine sorts by filename.

Loading and Running

# Load all scenarios
scenarios = Wanderland::Scenario.load_all("spec/scenarios")

# Load by operation (boundary name)
scenarios = Wanderland::Scenario.load_by_operation("spec/scenarios", :resolve_repo)

# Run and verify
scenario = scenarios.first
scenario.run                    # calls Wanderland::Boundary.execute(operation, input)
failures = scenario.verify      # returns array of failure strings (empty = pass)
scenario.passed?                # true if no failures

# Game mode
result = scenario.check_answer("symbol_name", "execute")
# => { blank: "symbol_name", user: "execute", expected: "execute",
#      actual: "execute", user_correct: true, engine_correct: true }

Registering Boundaries

Scenarios dispatch to boundaries by name. The boundary must be registered before running:

# Class-based (auto-registers via the boundary declaration)
class ResolveRepo
  include Wanderland::Boundary
  boundary :resolve_repo
  def call(input) ... end
end

# Proc-based
Wanderland::Boundary.register(:resolve_repo) { |input| ... }

In dark-lantern, boundaries auto-discover from the boundary_path config directory at boot. Drop a .rb file with boundary :name, it registers.

Writing Good Scenarios

Each scenario should test one thing. Use exact values for critical fields, matchers for structure. The description should explain what boundary behavior is being verified and why it matters.

For game mode, blanks should ask about the interesting parts — the fields that require understanding, not just reading. The hint should teach, not give away the answer.

The scenario is both a test case and a teaching tool. Write it for both audiences.

Gotcha: Matcher Key Collisions

ShapeMatcher reserves these key names as matchers: count, gte, lte, gt, lt, matches, contains, includes, excludes, first, last, any, keys, has_key, not, empty.

If your boundary output has a field named count, first, last, any, keys, empty, or not, ShapeMatcher will interpret it as a matcher, not a field assertion. The expected block count: 37 means "the parent has 37 elements", not "the field count equals 37".

Avoid naming output fields after matcher keywords. If you inherit a field name you can't change, assert on it through blanks (which uses dot-path dig, not ShapeMatcher) or restructure the expected block to avoid the collision."