Last updated: 2026-09-19

U
Undergraduate level

BDD framework: unit, integration, Gherkin

PatLang's own self-hosted Gherkin-style BDD framework (lib/test.patlang: t_init/check/step/run_feature_tagged), running against the point of sale: unit assertions, event-driven integration checks, and a Gherkin feature whose steps dispatch through apply. The same framework drives PatLang's own self-hosted regression suites (self_hosting/reflect_transpile_selftest.patlang, self_hosting/examples/pos_tests.patlang), each wired into cargo test via a thin Rust cucumber step that just asserts on this framework's own ALL TESTS PASSED output.

PatLang source
# Self-hosted mirror of rust-runtime/src/preprocess.rs's expand_includes:
# expands `include "relative/path.patlang"` lines by splicing the
# referenced file's contents in place, resolving paths relative to the
# including file's own directory, recursively.
#
# Why this exists as PatLang, not just Rust: `expand_includes` was
# previously a NATIVE-ONLY preprocessing step (main.rs, run before the
# frontend ever sees the source) -- patc1.exe's own self-hosted lexer/
# parser never learned to do this, so any .patlang file using `include`
# could only be compiled via the native pat.exe frontend (`--ir-run`/
# `--patc`), never handed directly to patc1.exe, which is why every
# multi-file portfolio demo in build_portfolio.patlang manually
# concatenates dependency files (read_file(lexer) + chr(10) + ...) instead
# of using `include`. This closes that gap so `include` works identically
# everywhere -- interpreted, natively compiled, and self-hosted-compiled --
# matching this session's usual bar of "verified across all three paths."
#
# Kept as its own small library (not folded directly into patc1_main.patlang)
# so any self-hosted driver can `include "lib/includes.patlang"` and use it.
# str_trim(s) -> s with leading/trailing space/tab/\r/\n stripped.
# GitHub #22: \n was deliberately excluded here originally; audited every
# call site before changing this shared utility's semantics (per the
# issue's own explicit request not to "fix" it without checking callers
# first). Every current caller either trims an already-line-split string
# (no embedded \n to lose) or explicitly WANTS trailing newlines stripped
# (the two run_benchmarks.patlang/webcrawler.patlang callers comparing a
# captured-output blob across execution paths -- the original bug report
# that surfaced this: str_trim() alone didn't close a trailing-newline
# difference, needing a separate local helper to finish the job). No
# caller relies on \n being preserved through a trim call.
make a function called str_trim takes s returns trimmed
  let n = s.length
  let start = 0
  while (start < n) and is_ws_char(char_code(s, start)) do
    let start = start + 1
  end
  let end = n
  while (end > start) and is_ws_char(char_code(s, end - 1)) do
    let end = end - 1
  end
  return substr(s, start, end - start)
end
make a function called is_ws_char takes code returns is_ws
  return (code == 32) or (code == 9) or (code == 13) or (code == 10)
end
# str_starts_with(s, prefix) -> bool
make a function called str_starts_with takes s, prefix returns matches
  if prefix.length > s.length then
    return false
  end
  return substr(s, 0, prefix.length) == prefix
end
# split_lines(s) -> list of lines, split on \n (a trailing \r on each line,
# from CRLF source files, is stripped too).
make a function called split_lines takes s returns lines
  let out = []
  let n = s.length
  let start = 0
  let i = 0
  while i < n do
    if char_code(s, i) == 10 then
      let raw = substr(s, start, i - start)
      let out = list_push(out, strip_trailing_cr(raw))
      let start = i + 1
    end
    let i = i + 1
  end
  if start < n then
    let out = list_push(out, strip_trailing_cr(substr(s, start, n - start)))
  end
  return out
end
make a function called strip_trailing_cr takes line returns stripped
  let n = line.length
  if (n > 0) and (char_code(line, n - 1) == 13) then
    return substr(line, 0, n - 1)
  end
  return line
end
# path_dirname(path) -> everything before the last '/' or '\', or "." if
# the path has no directory component. Handles both separators since
# build_portfolio.patlang and friends run on Windows but write forward
# slashes in string literals.
make a function called path_dirname takes path returns dir
  let n = path.length
  let i = n - 1
  let last_sep = -1
  while i >= 0 do
    let c = char_code(path, i)
    if (c == 47) or (c == 92) then
      let last_sep = i
      let i = -1
    else
      let i = i - 1
    end
  end
  if last_sep < 0 then
    return "."
  end
  return substr(path, 0, last_sep)
end
# path_basename(path) -> everything after the last '/' or '\', or the
# whole path if it has no directory component -- the complement of
# path_dirname above (same separator-scanning loop, opposite half kept).
make a function called path_basename takes path returns base
  let n = path.length
  let i = n - 1
  let last_sep = -1
  while i >= 0 do
    let c = char_code(path, i)
    if (c == 47) or (c == 92) then
      let last_sep = i
      let i = -1
    else
      let i = i - 1
    end
  end
  if last_sep < 0 then
    return path
  end
  return substr(path, last_sep + 1, n - last_sep - 1)
end
# path_join(base, rel) -> base + "/" + rel, tolerating a trailing slash on
# base and an empty base (meaning "current directory"). If `rel` is itself
# absolute (leading '/'/'\', or a Windows drive letter like "C:"), it's
# returned unchanged, ignoring base -- matches Rust's PathBuf::join, which
# preprocess.rs's native expand_includes relies on for the same case.
make a function called path_join takes base, rel returns joined
  if is_absolute_path(rel) then
    return rel
  end
  if (base == "") or (base == ".") then
    return rel
  end
  let n = base.length
  if (n > 0) and ((char_code(base, n - 1) == 47) or (char_code(base, n - 1) == 92)) then
    return base + rel
  end
  return base + "/" + rel
end
make a function called is_absolute_path takes p returns is_abs
  if p.length == 0 then
    return false
  end
  let c0 = char_code(p, 0)
  if (c0 == 47) or (c0 == 92) then
    return true
  end
  if (p.length >= 2) and (char_code(p, 1) == 58) then
    return true
  end
  return false
end
# expand_includes(source, base_dir) -> source with every `include "path"`
# line recursively replaced by that file's own (recursively expanded)
# contents, paths resolved relative to base_dir (the including file's own
# directory) at each level, exactly matching preprocess.rs's semantics.
#
# The depth cap (16, matching preprocess.rs's MAX_DEPTH) is inlined as a
# literal below rather than a top-level `let` constant referenced from
# inside expand_includes_at_depth -- patc1.exe was found, while building
# this, to NOT make top-level `let` constants visible inside function
# bodies at all (confirmed via a minimal repro: the value silently reads
# as empty/unset, not an error) even though both --ir-run and native
# --patc handle this correctly. That's a real, previously-unknown
# self-hosted-compiler bug, logged separately in the backlog for its own
# dedicated fix -- this file just avoids relying on the broken behavior.
make a function called expand_includes takes source, base_dir returns expanded
  return expand_includes_at_depth(source, base_dir, 0)
end
make a function called expand_includes_at_depth takes source, base_dir, depth returns expanded
  if depth > 16 then
    print("include: nesting deeper than 16 levels (cycle?)")
    return source
  end
  let lines = split_lines(source)
  let out = sb_new()
  let i = 0
  let n = to_num(list_len(lines))
  while i < n do
    let line = lines[i]
    let t = str_trim(line)
    if str_starts_with(t, "include ") and (str_starts_with(t, "#") == false) then
      let rel = str_trim(substr(t, 8, t.length - 8))
      let rel = strip_quotes(rel)
      let path = path_join(base_dir, rel)
      let inner = read_file(path)
      let inner_base = path_dirname(path)
      sb_push(out, expand_includes_at_depth(inner, inner_base, depth + 1))
      sb_push(out, chr(10))
    else
      sb_push(out, line)
      sb_push(out, chr(10))
    end
    let i = i + 1
  end
  return sb_str(out)
end
# strip_quotes("\"path\"") -> "path" -- include lines are always written
# with double-quoted paths, same as the native preprocessor expects.
make a function called strip_quotes takes s returns unquoted
  let n = s.length
  if (n >= 2) and (char_code(s, 0) == 34) and (char_code(s, n - 1) == 34) then
    return substr(s, 1, n - 2)
  end
  return s
end

# Gherkin-driven contract clauses for GOAP synthesis (GitHub issue #12).
#
# Adds "And require <expr>" / "And ensure <expr>" as real, checked steps
# in a .feature scenario, WITHOUT encoding them as GOAP GroundFacts: a
# scalar comparison like `x < 10` isn't a unifiable ground predicate the
# way action_add's preconditions/effects are (see hosts.rs's
# parse_ground_facts/ground_action_instances) -- inventing an infinite
# family of numeric facts to represent every possible comparison isn't
# how the search space works, and isn't what require/ensure mean
# elsewhere in the language either (a runtime-evaluated boolean over live
# values, not a search-time predicate).
#
# Instead: a require/ensure clause is parsed into [var, op, literal],
# looked up against the SAME global __vars store set_var/get already use
# (get("__vars", var) -- the convention a Given step establishes: "Given
# an integer x" binds the global variable named exactly "x"), and
# evaluated as a plain boolean guard. Convention, not coincidence: this
# mirrors how ordinary require/ensure statements in hand-written PatLang
# functions are evaluated against real bound values, just triggered from
# a Gherkin step instead of a function body.
#
# Deliberately narrow grammar (see the design discussion in GitHub issue
# #12 and the plan that shipped this): <ident> <op> <literal>, exactly
# three space-separated tokens, <op> one of < <= > >= == !=, <literal> an
# integer. This does NOT reuse parser.patlang's full expression grammar --
# that would be a large, unwarranted dependency from the Gherkin runner
# onto the compiler's own parser internals for a single binary comparison.
make a function called gc_split_ws takes s returns parts
  let parts = []
  let cur = sb_new()
  let i = 0
  while i < s.length do
    let c = char_code(s, i)
    if c == 32 then
      if sb_str(cur).length > 0 then
        let parts = list_push(parts, sb_str(cur))
        let cur = sb_new()
      end
    else
      sb_push(cur, s[i])
    end
    let i = i + 1
  end
  if sb_str(cur).length > 0 then
    let parts = list_push(parts, sb_str(cur))
  end
  return parts
end
# parse_contract_clause("x < 10") -> ["x", "<", "10"]
# On malformed input (not exactly 3 tokens), returns ["ERR", message].
make a function called parse_contract_clause takes text returns clause
  let tokens = gc_split_ws(text)
  if to_num(list_len(tokens)) != 3 then
    return ["ERR", "malformed contract clause (expected '<ident> <op> <literal>'): " + text]
  end
  return tokens
end
make a function called eval_cmp takes op, lhs, rhs returns ok
  if op == "<" then
    return lhs < rhs
  end
  if op == "<=" then
    return lhs <= rhs
  end
  if op == ">" then
    return lhs > rhs
  end
  if op == ">=" then
    return lhs >= rhs
  end
  if op == "==" then
    return lhs == rhs
  end
  if op == "!=" then
    return lhs != rhs
  end
  return false
end
# GitHub #49: representation/type invariants (e.g. `ensure x_kind == "bigint"`)
# compare a bound value against a NON-numeric literal, so the ident/op/literal
# grammar can't blindly to_num() both sides the way the original numeric-only
# design did. Ordering-only (< <= > >=) still requires numbers -- there's no
# sensible non-numeric ordering here -- but == and != fall back to raw string
# comparison whenever either side fails to parse as a number.
make a function called looks_numeric takes s returns ok
  return to_num(s) or (s == "0")
end
make a function called eval_cmp_typed takes op, lhs_raw, rhs_raw returns ok
  if (op == "==") or (op == "!=") then
    if looks_numeric(lhs_raw) and looks_numeric(rhs_raw) then
      return eval_cmp(op, to_num(lhs_raw), to_num(rhs_raw))
    else
      return eval_cmp(op, lhs_raw, rhs_raw)
    end
  end
  return eval_cmp(op, to_num(lhs_raw), to_num(rhs_raw))
end
# handle_contract_step(kind, clause_text): kind is "require" or "ensure",
# clause_text is the raw text after that keyword (e.g. "x < 10").
#
# Two modes, chosen automatically by whether the clause's variable is
# already bound:
#  - IMMEDIATE: a prior Given step already set_var'd this exact variable
#    name (the hand-written-function case, Slice 1) -- evaluated right
#    away against that bound value, exactly like an ordinary require/
#    ensure statement would be. A violation here records a real t_fail,
#    same convention as an undefined step.
#  - DEFERRED: the variable isn't bound yet (a GOAP synthesis hasn't run
#    yet -- Slice 2's case, where the value only becomes known from the
#    winning plan's own bindings). The parsed clause is stashed onto
#    "t_pending_contracts" for a later goap_verify_contracts call to
#    consume, once real bindings exist. Deliberately does NOT touch
#    t_fail here -- whether a deferred clause holding or not is itself
#    the very thing a scenario may be testing (see goap_verify_contracts),
#    so recording pass/fail is left to the scenario's own explicit check.
make a function called handle_contract_step takes kind, clause_text returns done
  let clause = parse_contract_clause(clause_text)
  if list_get(clause, 0) == "ERR" then
    set_var("t_fail", get("__vars", "t_fail") + 1)
    print("  FAIL: " + list_get(clause, 1))
    return true
  end
  let var_name = list_get(clause, 0)
  let op = list_get(clause, 1)
  let literal = list_get(clause, 2)
  let already_bound = get("__vars", var_name)
  if already_bound then
    let ok = eval_cmp_typed(op, already_bound, literal)
    if ok then
      print("  ok: " + kind + " " + clause_text)
    else
      set_var("t_fail", get("__vars", "t_fail") + 1)
      print("  FAIL: " + kind + " violated: " + clause_text)
    end
  else
    let pending = get("__vars", "t_pending_contracts")
    if pending then
      let pending = list_push(pending, [kind, var_name, op, literal])
    else
      let pending = [[kind, var_name, op, literal]]
    end
    set_var("t_pending_contracts", pending)
    print("  (deferred) " + kind + " " + clause_text)
  end
  return true
end
make a function called gc_find_substr takes hay, needle returns idx
  if needle.length == 0 then
    return 0
  end
  let i = 0
  while i <= (hay.length - needle.length) do
    if substr(hay, i, needle.length) == needle then
      return i
    end
    let i = i + 1
  end
  return -1
end
# Extract the bound value of `var_name` from a GOAP plan-step label like
# "scale(X=5)" or "assemble(X=final,Y=base)" (see action_instance_label,
# rust-runtime/src/ir/hosts.rs). Returns "" if that label doesn't bind
# this variable at all.
make a function called gc_extract_binding takes label, var_name returns value
  let needle = var_name + "="
  let idx = gc_find_substr(label, needle)
  if idx < 0 then
    return ""
  end
  let start = idx + needle.length
  let i = start
  let scanning = true
  while (i < label.length) and scanning do
    let c = label[i]
    if (c == ",") or (c == ")") then
      let scanning = false
    else
      let i = i + 1
    end
  end
  return substr(label, start, i - start)
end
# goap_verify_contracts(clauses, plan_labels) -> ok
#
# clauses: a list of ["require"|"ensure", var, op, literal] tuples, e.g.
# what handle_contract_step stashes onto "t_pending_contracts" while
# deferred. plan_labels: the list of strings plan() returns.
#
# Evaluates each clause against whichever plan step's label actually
# binds that variable -- the guard is checked once against the concrete
# value GOAP's search settled on, BEFORE that candidate is accepted or
# any code is generated for it (see the design note at the top of this
# file). A clause referencing a variable no step binds at all is treated
# as a failure (there is nothing real to check it against). Prints an
# ok/FAIL diagnostic per clause but deliberately does NOT touch t_fail
# itself -- the caller decides, via its own check(...), whether the
# returned boolean was the outcome that scenario expected (a deliberately
# violating candidate being correctly rejected is itself a PASSING test).
make a function called goap_verify_contracts takes clauses, plan_labels returns ok
  let all_ok = true
  let ci = 0
  let cn = to_num(list_len(clauses))
  while ci < cn do
    let clause = list_get(clauses, ci)
    let kind = list_get(clause, 0)
    let var_name = list_get(clause, 1)
    let op = list_get(clause, 2)
    let literal = to_num(list_get(clause, 3))
    let found = false
    let li = 0
    let ln = to_num(list_len(plan_labels))
    while (li < ln) and (found == false) do
      let label = list_get(plan_labels, li)
      let raw = gc_extract_binding(label, var_name)
      if raw != "" then
        let found = true
        let bound = to_num(raw)
        let ok = eval_cmp(op, bound, literal)
        if ok then
          print("  ok: " + kind + " " + var_name + " " + op + " " + list_get(clause, 3) + " (from " + label + ")")
        else
          print("  FAIL: " + kind + " violated by synthesized plan: " + label)
          let all_ok = false
        end
      end
      let li = li + 1
    end
    if found == false then
      print("  FAIL: " + kind + " references a variable no plan step binds: " + var_name)
      let all_ok = false
    end
    let ci = ci + 1
  end
  return all_ok
end

# =============================================================================
# Test framework (Stage 1 dialect): unit assertions plus a Gherkin-style
# feature runner. Step definitions are registered in the object store keyed
# by their text; features are plain text dispatched line by line, so the
# same framework covers unit, integration, and behaviour tests.
# =============================================================================
# run_feature hands `require`/`ensure` steps to handle_contract_step, which
# lives in gherkin_contracts.patlang.
make a function called t_init returns done
  set_var("t_pass", 0)
  set_var("t_fail", 0)
  set_var("t_tagfilter", "")
  set_var("t_pending_tags", "")
  set_var("t_skipping", 0)
  return true
end
make a function called contains_text takes hay, needle returns r
  if needle.length > hay.length then
    return false
  end
  let i = 0
  while i <= hay.length - needle.length do
    if substr(hay, i, needle.length) == needle then
      return true
    end
    let i = i + 1
  end
  return false
end
make a function called check takes label, actual, expected returns ok
  if actual == expected then
    set_var("t_pass", get("__vars", "t_pass") + 1)
    print("  ok: " + label)
    return true
  else
    set_var("t_fail", get("__vars", "t_fail") + 1)
    print("  FAIL: " + label + " (got " + actual + ", want " + expected + ")")
    return false
  end
end
make a function called t_report returns ok
  let p = get("__vars", "t_pass")
  let f = get("__vars", "t_fail")
  print("tests: " + p + " passed, " + f + " failed")
  if f == 0 then
    print("ALL TESTS PASSED")
    return true
  else
    print("TESTS FAILED")
    return false
  end
end
# ---- Gherkin runner ----
# Register a step: step("a fresh till", "st_fresh_till")
make a function called step takes text, fname returns done
  new("Step", text)
  send(text, "set", "fn", fname)
  return true
end
make a function called starts_with takes s, prefix returns r
  if s.length < prefix.length then
    return false
  end
  return substr(s, 0, prefix.length) == prefix
end
make a function called trim_left takes s returns out
  let i = 0
  let scanning = true
  while (i < s.length) and scanning do
    let c = char_code(s, i)
    if (c == 32) or (c == 9) then
      let i = i + 1
    else
      let scanning = false
    end
  end
  return substr(s, i, s.length - i)
end
# Strip a Gherkin keyword; returns the step text or "" if not a step line
make a function called step_text takes line returns out
  if starts_with(line, "Given ") then
    return substr(line, 6, line.length - 6)
  end
  if starts_with(line, "When ") then
    return substr(line, 5, line.length - 5)
  end
  if starts_with(line, "Then ") then
    return substr(line, 5, line.length - 5)
  end
  if starts_with(line, "And ") then
    return substr(line, 4, line.length - 4)
  end
  return ""
end
# Run only scenarios whose preceding @tag line contains `tag` ("" = all)
make a function called run_feature_tagged takes feature, tag returns ok
  set_var("t_tagfilter", tag)
  return run_feature(feature)
end
make a function called run_feature_file takes path returns ok
  return run_feature(read_file(path))
end
make a function called run_feature takes feature returns ok
  let h = str_intern(feature)
  let n = sc_len(h)
  let i = 0
  let line = sb_new()
  while i <= n do
    let c = sc_code(h, i)
    if (c == 10) or (c == -1) then
      let raw = trim_left(sb_str(line))
      let line = sb_new()
      if starts_with(raw, "@") then
        set_var("t_pending_tags", raw)
      end
      if starts_with(raw, "Feature:") then
        print(raw)
      end
      if starts_with(raw, "Scenario:") then
        let filter = get("__vars", "t_tagfilter")
        let tags = get("__vars", "t_pending_tags")
        set_var("t_pending_tags", "")
        if filter then
          if tags then
            if contains_text(tags, filter) then
              set_var("t_skipping", 0)
              print(raw + "  [" + tags + "]")
            else
              set_var("t_skipping", 1)
              print(raw + "  [skipped: needs " + filter + "]")
            end
          else
            set_var("t_skipping", 1)
            print(raw + "  [skipped: needs " + filter + "]")
          end
        else
          set_var("t_skipping", 0)
          print(raw)
        end
      else
        let text = step_text(raw)
        if (text != "") and (get("__vars", "t_skipping") != 1) then
          if starts_with(text, "require ") then
            handle_contract_step("require", substr(text, 8, text.length - 8))
          else
            if starts_with(text, "ensure ") then
              handle_contract_step("ensure", substr(text, 7, text.length - 7))
            else
              let fname = get(text, "fn")
              if fname then
                apply(fname)
              else
                set_var("t_fail", get("__vars", "t_fail") + 1)
                print("  FAIL: undefined step: " + text)
              end
            end
          end
        end
      end
      let i = i + 1
    else
      if c == 13 then
        let i = i + 1
      else
        sb_push(line, sc_char(h, i))
        let i = i + 1
      end
    end
  end
  return true
end

# =============================================================================
# Point-of-sale library (Stage 1 dialect): event-driven checkout combining
# events (scan/pay), OO (product catalog in the object store), and logic
# (dairy facts drive a discount rule). Prices are integer pence.
# =============================================================================

make a function called pos_setup returns done
  new("Product", "apple")
  send("apple", "set", "price", 30)
  new("Product", "banana")
  send("banana", "set", "price", 25)
  new("Product", "milk")
  send("milk", "set", "price", 120)
  fact("dairy", "milk", "yes")
  fact("dairy", "cheese", "yes")
  set_var("total", 0)
  set_var("items", 0)
  set_var("receipt", "")
  goal("serve_customer", "till-1")
  return true
end

make a function called pos_scan takes item returns price
  let price = get(item, "price")
  if not price then
    print("  unknown item: " + item)
    return 0
  end
  if query("dairy", item, 0) > 0 then
    # dairy discount: 10% off
    let price = price * 9 / 10
  end
  set_var("total", get("__vars", "total") + price)
  set_var("items", get("__vars", "items") + 1)
  set_var("receipt", get("__vars", "receipt") + "  " + item + "  " + price + "p\n")
  return price
end

make a function called pos_total returns t
  return get("__vars", "total")
end

make a function called pos_pay takes tendered returns change
  let total = get("__vars", "total")
  let change = tendered - total
  print("--- RECEIPT ---")
  print(get("__vars", "receipt") + "  items: " + get("__vars", "items"))
  print("  total: " + total + "p, tendered: " + tendered + "p, change: " + change + "p")
  return change
end

when scan do
  pos_scan(event_data)
end

when pay do
  pos_pay(event_data)
end

# Test suite for the point-of-sale library (concatenate after lib/test.patlang
# and lib/pos.patlang). Shows unit tests, event-driven integration tests, and
# a Gherkin feature exercising the same code.

t_init()

# ---- unit tests ----
print("unit: catalog and pricing")
pos_setup()
check("apple price", get("apple", "price"), 30)
check("milk is dairy", query("dairy", "milk", 0), 1)
check("banana is not dairy", query("dairy", "banana", 0), 0)
check("scan returns discounted dairy price", pos_scan("milk"), 108)
check("scan returns full price", pos_scan("banana"), 25)
check("running total", pos_total(), 133)

# ---- integration test: the same flow through events ----
print("integration: checkout via events")
pos_setup()
emit("scan", "apple")
emit("scan", "apple")
emit("scan", "milk")
check("event-driven total", pos_total(), 168)
check("change from 2 pounds", pos_pay(200), 32)

# ---- Gherkin feature ----

make a function called st_fresh_till returns done
  pos_setup()
  return true
end

make a function called st_scan_two_apples returns done
  emit("scan", "apple")
  emit("scan", "apple")
  return true
end

make a function called st_scan_milk returns done
  emit("scan", "milk")
  return true
end

make a function called st_total_is_168 returns done
  return check("the total is 168p", pos_total(), 168)
end

make a function called st_dairy_discount_applied returns done
  return check("dairy discount applied", get("milk", "price") - 12, 108)
end

step("a fresh till", "st_fresh_till")
step("the customer buys two apples", "st_scan_two_apples")
step("the customer buys a bottle of milk", "st_scan_milk")
step("the total is 168 pence", "st_total_is_168")
step("the dairy discount was applied", "st_dairy_discount_applied")

let feature = "Feature: checkout
@smoke @till
Scenario: apples and discounted milk
Given a fresh till
When the customer buys two apples
And the customer buys a bottle of milk
Then the total is 168 pence
And the dairy discount was applied
@audit
Scenario: nightly stock audit
Given a fresh till
Then the total is 168 pence
"
# run only @smoke scenarios: the @audit scenario (which would fail) is skipped
run_feature_tagged(feature, "@smoke")

t_report()

(not run yet)

Native run on the build machine:

Output (click to expand)
unit: catalog and pricing
  ok: apple price
  ok: milk is dairy
  ok: banana is not dairy
  ok: scan returns discounted dairy price
  ok: scan returns full price
  ok: running total
integration: checkout via events
  ok: event-driven total
--- RECEIPT ---
  apple  30p
  apple  30p
  milk  108p
  items: 3
  total: 168p, tendered: 200p, change: 32p
  ok: change from 2 pounds
Feature: checkout
Scenario: apples and discounted milk  [@smoke @till]
  ok: the total is 168p
  ok: dairy discount applied
Scenario: nightly stock audit  [skipped: needs @smoke]
tests: 10 passed, 0 failed
ALL TESTS PASSED
true