Skip to content

The Case Format

Daniel Hokanson edited this page Aug 30, 2026 · 1 revision

A case in this library is a fenced ```yaml block sitting inside a Markdown file. The Markdown is the wrapper — a heading, sometimes a paragraph of context — and exists so the file renders as readable documentation on GitHub and diffs cleanly in a pull request. The YAML block inside it is the case. The compiler in test-bed/scripts/build-content.mjs does exactly one thing to find cases: it regex-extracts every ```yaml fence from a Markdown file and parses each one as a case. Prose between the fences is invisible to the build.

That has a consequence worth stating plainly: a YAML fence in a docs file is a case. If you paste a schema example into a file the compiler walks, it becomes a case and either fails validation or lands in the library. Examples belong in docs/01-schema.md, docs/test-scenarios.md and the suite manifests' prose — files the compiler treats as schema or manifest sources, not case sources.

Why YAML rather than JSON: cases are prose. Multi-line steps, embedded quotes, notes to the tester. YAML block scalars keep the source writable by the people writing cases and reviewable by the people reviewing them. The runner never sees YAML; it sees the compiled JSON.

Written against the industry, not against the screen

This is the load-bearing convention in the repo and the reason the library is worth reading even if you never run it.

A case describes what a competent person doing that job in any manufacturer would expect to be able to do. It does not describe Forge's menus, does not name Forge's buttons, and does not soften a step to match what the application currently supports. If a case says to filter a lot list by expiration date and the application cannot, the application has the bug and the case stays as written.

The authoring process enforces this structurally rather than by good intentions. docs/authoring-prompt.md is handed to an author with no knowledge of the application and an explicit instruction not to acquire any — not to read the product's source, even when pointed at it. The deliberate ignorance is the feature: an author who knows how the screen is built writes cases that the screen passes.

The convention shows up in the step text as the "find and open" phrasing. Steps say "find and open the company settings area" rather than "click Settings → Company → General". Two reasons. Locking a step to a menu path makes every case brittle against a UI reorganisation. And a tester who cannot find what the step describes has themselves discovered a usability bug — the application is supposed to be navigable without tribal knowledge.

The matching discipline on language: jargon in a case is treated as a defect in the case. A tester should not need to know what a chart-of-accounts mapping is to execute a step about accounting setup. Terms that genuinely cannot be avoided are defined once in docs/glossary.md, written for someone who has never set foot in a shop. (That glossary is for testers; the product's own vocabulary is on the hub's Glossary.)

The shape of a case

docs/01-schema.md is the field-by-field source of truth. What follows is the structure and the reasoning, not a re-listing.

Every case carries an id, a title that makes sense out of context, a one-sentence goal in plain English, the roles that own the work, preconditions, ordered steps, an expected_overall, and a pass_criteria. The compiler enforces exactly this set — plus non-empty roles and non-empty steps — and fails the build with the file path and the offending id if any is missing. Tutorial cases are the one exemption from roles: they are universal, so the validator skips that check for them.

The smallest unit of discipline is the step, and a step is always a pair:

steps:
  - n: 2
    action: |
      Open one recommendation. Verify the math: required quantity =
      gross demand - on-hand - on-order + safety stock, with required
      date.
    expected: |
      Recommendation matches the hand calculation.

Both halves are required even when the expected result feels obvious. The habit of looking is the point. pass_criteria is separate from expected_overall on purpose: expected_overall is the picture of "done" the tester reads before starting, while pass_criteria is the single specific thing that decides pass or fail without a judgement call.

Preconditions describe state, never actions. "The user is signed in as an admin" is a precondition; "sign in as an admin" is a step. They are also expected to be specific enough to be mechanically checkable — "a customer exists" is too loose, "Customer ACME-001 exists in active status with a credit limit of $50,000" is right — because the runner's skip-ahead feature asks the tester to confirm them literally.

The optional fields that carry weight

Field What it is for
flows Cross-phase business journeys this case is part of (quote-to-cash, vendor-to-asset, …), from docs/flows.md. Lets a tester run a slice toward one outcome instead of everything for a role.
capabilities Capability IDs the case actively demonstrates in its steps. See below.
optional_module Gates the whole case behind a module the tester opted into at session start.
prerequisite_cases Case IDs that must pass first; drives dependency display and skip-ahead.
seed_data The records that must exist, in structured form, so skip-ahead can present them for confirmation.
negative_variants Failure-mode variants of this same case, inline.
scale_tags / modality small-shop / mid-market / enterprise; keyboard / touch / scanner / manual-entry. Absent means "applies to all".
why_this_matters The business reason, for the tester who does not already know it. Deliberately used on a minority of cases — if it is on all of them, the goal field is being underused.
est_minutes Rough duration, so a tester can plan a sitting.

Negative variants live inside their parent case, as a negative_variants list, not in a separate negative-only document. The reasoning is behavioural: separate negative documents get skipped, and inline variants get run because the tester is already in the case. A variant inherits the parent's preconditions and prior steps and declares only its own action, expected result and pass criteria. The four failure modes they are meant to cover are validation gates, permission boundaries, state conflicts, and data integrity — roughly, "if a developer would have written a unit test for this error case, the case needs a variant for it".

IDs are PHASE-AREA-NNN (P0-TENANT-001), TUT-NNN for tutorial cases, <parent>-N<n> for variants, and each suite defines its own convention in its manifest (RPT-{REPORT}-NNN, PERM-{ROLE}-{CAP}-NNN, EDGE-{CATEGORY}-{SCENARIO}-NNN, and so on). IDs are stable forever: never renumbered, never reordered, never recycled after a deletion. Recorded results reference them across releases of the library, so renumbering would silently invalidate a tester's history. The compiler enforces global uniqueness and fails the build naming both source files on a collision.

Branching, modules and fixtures

Cases fork in three different ways, and they are easy to confuse.

Step-level branches are declared on a case and referenced by branch_id on individual steps. The runner asks the branch's prompt, records the answer, and from then on serves only the steps tagged with the chosen option plus the unbranched ones. Use it when one workflow briefly forks — integrated accounting versus manual entry, for instance.

Manifest-level branches are declared in a phase manifest and change which cases later phases serve. A choice made in P0 about accounting mode or shipping mode propagates into P4. The rule of thumb: a few divergent steps go in the case, a divergent sequence goes in the manifest. Which accounting arrangements actually exist is a product question — the hub's Accounting Modes is the answer, and the cases assume it rather than restate it.

optional_module is a whole-case gate against the catalogue in test-bed/public/assets/data/modules.json. The tester ticks the modules their install has at session start and the runner drops every case tagged with a module they did not enable. This is how the library carries cases for things Forge may not have: the full-GL module's own catalogue entry states that no application currently implements it and that it exists so those cases can be filtered cleanly. Read that alongside the hub's Capability Gating — capability gating is the product's runtime mechanism, optional_module is the library's authoring-time one, and they are not the same thing.

Fixtures supply the concrete values. A case that needs a company name writes {{company_name}} and a fixture provides it, so the same case content reads correctly for a three-person shop and a multi-site manufacturer. The default fixture is a fictional precision sheet-metal fabricator, deliberately fictional so a tester never confuses scenario data with their own.

Trap: placeholder substitution is specified but not implemented. docs/test-scenarios.md and docs/runner-platform.md both describe the runner substituting fixture values at render time, and the build correctly preserves {{placeholder}} syntax verbatim as specified. But the runner has no substitution step — a tester sees the raw {{primary_location}} on screen. It affects only a handful of steps today, which is exactly why it has stayed unnoticed. Multi-fixture support is affected the same way: the runner loads a single fixture.json and every session uses it.

Capability tags and the coverage query

capabilities: cross-references a case to the application's authoritative catalogue, CapabilityCatalog.cs in forge-api. It exists so a coverage question can be answered offline: which cases exercise which capability, and which capabilities have nothing written against them.

The binding rule is narrow and worth quoting, because getting it wrong makes the coverage query useless:

A case's capabilities: array lists every capability the case actively demonstrates in its steps: — meaning that if that capability were disabled at install time, the case would fail at a step, not at a precondition.

So a flow case tags everything its steps touch, in any order. A case that creates a quote against a customer that already exists does not tag customer master data, because no step exercises it. Bootstrap and foundation cases that exercise no feature use an explicit capabilities: [], which distinguishes "considered, intentionally none" from "not yet tagged". Cross-cutting CAP-CROSS-* capabilities normally do not tag at all — they are system-wide guarantees rather than flows — unless the case is specifically about that guarantee.

Trap: nothing validates the IDs. The schema says unknown capability IDs are an error and that reviewers verify by grep; the build does not check, and the drift has already happened. Cases in the P4 file and under docs/cases/P5/ still tag CAP-HR-TRAINING, which was removed from the catalogue when the training LMS became an always-on core feature. Those tags now match nothing. A validation pass in the content compiler that intersects tagged IDs against the catalogue would have caught it, and would be the single highest-value addition to the build.

Above the case: manifests, suites, stories

A case is portable and readable on its own. Everything that orders cases lives outside them.

Phase manifests (docs/04-phase-0-manifest.md and its siblings) declare the default sequence, which cases are required, the checkpoints a tester may skip ahead to, the branch declarations and their downstream effects, per-scale notes, and the phase's completion criteria. Splitting orchestration out of the cases is what keeps a case readable as documentation.

Suite manifests (docs/suites/<name>/manifest.md) do the same job for a cross-cutting suite, and additionally carry the suite's premise and ID convention in prose. A suite manifest enumerates the case IDs it plans; the compiler intersects that plan against the cases that actually exist and emits three lists — what will be served, what was planned, and what is planned-but-unwritten — so the runner can show "N pending" honestly instead of rendering broken links. Every suite currently has all of its planned cases authored.

Flows are tags; stories are sequences. A story is an ordered, chaptered walk through existing cases with an explicit role at each scene, so the runner can prompt a tester to sign out and back in at a handoff. Stories are supposed to compose only cases already on the "gold path" — the curated must-work subset — and a candidate story needing a case outside it triggers a review for promotion instead of a quiet exception. The compiler does verify that every scene references a real case and fails the build otherwise.

Trap: the gold path is referenced but not in this repo. Both docs/stories.md and docs/test-scenarios.md §13 cite a phase-1-output/1D-gold-path/gold-path-proposal.md as the canonical must-work subset. No such file exists here. The composition rule is therefore unenforceable as written, and "is this case on the gold path?" cannot currently be answered from the repo.

Two more things the docs get wrong

Worth knowing before you trust a reference document over the code:

  • docs/expansion-plan.md opens with a case count from a much earlier state of the library and prioritises work — the permissions matrix, the reports suite, the negative-variant sweep — that has since been done and shipped as suites. Its Priority 2 and 3 categories are still live; its framing is not.
  • The runner's TypeScript Case type is a subset of the schema. capabilities, seed_data, localized_strings, i18n_check and accessibility_check are all in 01-schema.md and all absent from test-bed/src/app/data/types.ts. The compiler passes the whole parsed YAML through, so the data is in the compiled JSON and nothing is lost — but the runner does not read it, and nothing in the UI reflects it. Conversely optional_module, phase and uses_practice_app exist in the runner type and are used by real cases without appearing in the schema document. Treat 01-schema.md as the authoring contract and types.ts as the record of what the runner currently honours.

The localisation fields are the sharpest instance of this. The schema defines localized_strings per language, the runner declares a LanguageCode union and stores a language on every session — and then hard-codes it. There is no language picker and no dedicated i18n or accessibility suite yet, though both are described as planned in test-scenarios.md §8.

Next: Running the Suite.