From 6d9aee67ba2a7ba8ae1b12b8104a382771e9265f Mon Sep 17 00:00:00 2001 From: swipelab Date: Thu, 4 Jun 2026 18:57:21 +0300 Subject: [PATCH] P0.4: wire the logic verification gate (sx assert helper + snapshot runner) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a real logic-test gate so future pure-sx game logic fails the build on a bad assertion: - tests/test.sx: `expect(cond, msg)` assert helper — prints a greppable `FAIL :: ` and exits non-zero via process.exit on failure. - tools/run_tests.sh: snapshot runner mirroring sx/tests/run_examples.sh; runs each tests/.sx and diffs stdout + exit code against tests/expected/. Exits 0 iff all tests pass. - tests/arith.sx (+ expected snapshots): seed passing sanity test. - README.md: document both halves of the gate — logic runner and the reproducible ios-sim build/launch sequence (with device discovery). --- README.md | 50 ++++++++++++++++ tests/arith.sx | 13 +++++ tests/expected/arith.exit | 1 + tests/expected/arith.stdout | 1 + tests/test.sx | 15 +++++ tools/run_tests.sh | 112 ++++++++++++++++++++++++++++++++++++ 6 files changed, 192 insertions(+) create mode 100644 tests/arith.sx create mode 100644 tests/expected/arith.exit create mode 100644 tests/expected/arith.stdout create mode 100644 tests/test.sx create mode 100755 tools/run_tests.sh diff --git a/README.md b/README.md index d011b96..6befa29 100644 --- a/README.md +++ b/README.md @@ -7,3 +7,53 @@ A match-3 game written entirely in the **sx** language, targeting **iOS** first. - Verification gate: sx logic tests pass AND the iOS app builds & launches in the Simulator. Development is driven by the multi-agent `flow` (Product Owner → Worker → Reviewer → Observer). + +## Verification gate + +The gate has two halves. Both must pass. The sx compiler used below lives at +`/Users/agra/projects/sx/zig-out/bin/sx` (override the runner's binary with the +`SX` env var). Run everything from the repo root. + +### 1. Logic tests + +Pure-sx logic tests run under `sx` and have their stdout + exit code diffed +against committed snapshots in `tests/expected/`. A failed assertion exits the +process non-zero, so it fails the runner (and the gate). + +```bash +bash tools/run_tests.sh +``` + +- A test is any `tests/.sx` that has a `tests/expected/.exit` + marker; `tests/test.sx` (the `expect` assert helper) has no marker, so it is + not itself run. +- Regenerate snapshots after an intentional change: `bash tools/run_tests.sh --update`. + +### 2. iOS Simulator build + launch + +Build the app for the simulator, then install/launch it on an available device +and screenshot the rendered scene (blue background + a centered orange quad). + +```bash +# Build the .app bundle (sx-out/ios/M3te.app): +/Users/agra/projects/sx/zig-out/bin/sx build --target ios-sim main.sx + +# Discover an available simulator — do NOT hardcode a udid: +xcrun simctl list devices available +# e.g. capture the first available device's UDID into $udid: +udid=$(xcrun simctl list devices available | grep -Eo '[0-9A-Fa-f-]{36}' | head -1) + +# Boot it (skip if already "Booted") and bring the Simulator window up: +xcrun simctl boot "$udid" || true +open -a Simulator + +# Install, launch (bundle id co.swipelab.m3te), and screenshot: +xcrun simctl install booted sx-out/ios/M3te.app +xcrun simctl launch booted co.swipelab.m3te +xcrun simctl io booted screenshot /tmp/m3te.png +``` + +The screenshot should match `goldens/p0_quad.png` (a centered orange quad over a +blue clear), modulo the status-bar clock — pixel-exact equality is not required. +A tap on the quad flips its color (orange ↔ green); see +`goldens/p0_input_before.png` / `goldens/p0_input_after.png`. diff --git a/tests/arith.sx b/tests/arith.sx new file mode 100644 index 0000000..1505493 --- /dev/null +++ b/tests/arith.sx @@ -0,0 +1,13 @@ +// Trivial logic-gate sanity check: exercises the `expect` helper on passing +// assertions and prints a stable summary line for the snapshot runner to diff. +// Real board-state tests arrive in Phase 1. +#import "modules/std.sx"; +t :: #import "test.sx"; + +main :: () -> s32 { + t.expect(2 + 2 == 4, "two plus two is four"); + t.expect(7 % 3 == 1, "seven mod three is one"); + t.expect(10 - 4 == 6, "ten minus four is six"); + print("ok: arithmetic\n"); + return 0; +} diff --git a/tests/expected/arith.exit b/tests/expected/arith.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tests/expected/arith.exit @@ -0,0 +1 @@ +0 diff --git a/tests/expected/arith.stdout b/tests/expected/arith.stdout new file mode 100644 index 0000000..a72d2e8 --- /dev/null +++ b/tests/expected/arith.stdout @@ -0,0 +1 @@ +ok: arithmetic diff --git a/tests/test.sx b/tests/test.sx new file mode 100644 index 0000000..6d3bfdf --- /dev/null +++ b/tests/test.sx @@ -0,0 +1,15 @@ +// m3te logic-test assert helper. +// +// `expect(cond, msg)` is a no-op when `cond` holds. On a false condition it +// prints a single greppable `FAIL :: ` line to stdout and +// terminates the process NON-ZERO (exit 1) via process.exit, so a broken +// assertion fails `tools/run_tests.sh` and the build gate. +#import "modules/std.sx"; +proc :: #import "modules/process.sx"; + +expect :: (cond: bool, msg: string, loc: Source_Location = #caller_location) { + if !cond { + print("FAIL {}:{}: {}\n", loc.file, loc.line, msg); + proc.exit(1); + } +} diff --git a/tools/run_tests.sh b/tools/run_tests.sh new file mode 100755 index 0000000..e5a7f6b --- /dev/null +++ b/tools/run_tests.sh @@ -0,0 +1,112 @@ +#!/bin/bash +# m3te logic-test gate. Mirrors sx/tests/run_examples.sh: run each sx logic +# test, diff its stdout + exit code against committed expectations. Exits 0 iff +# ALL tests pass, NON-ZERO otherwise (so a broken assertion fails the build gate). +# +# Usage: bash tools/run_tests.sh [--update] +# --update: regenerate the expected .stdout/.exit from current output. +# +# Layout (a test is any tests/.sx with a tests/expected/.exit marker; +# helper modules such as tests/test.sx have no marker, so they are not run): +# tests/.sx +# tests/expected/.stdout # normalized stdout +# tests/expected/.exit # process exit code +# +# SX binary: override with the SX env var; defaults to the dev build. + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +SX="${SX:-/Users/agra/projects/sx/zig-out/bin/sx}" +TESTS_DIR="$ROOT_DIR/tests" +EXPECTED_DIR="$TESTS_DIR/expected" +TIMEOUT=30 + +PASS=0 +FAIL=0 +SKIP=0 +TIMEOUT_COUNT=0 +UPDATE=0 + +if [[ "${1:-}" == "--update" ]]; then + UPDATE=1 +fi + +if [[ ! -x "$SX" ]]; then + echo "sx binary not found/executable at: $SX (override with SX=/path/to/sx)" >&2 + exit 2 +fi + +# Collapse the absolute test-file path back to its repo-relative form so the +# `FAIL :` lines printed by tests/test.sx are checkout-location +# independent. Applied identically to expected and actual, so it can only +# reconcile location noise, never desync an otherwise-matching pair. +normalize() { + sed -E -e "s#${ROOT_DIR}/##g" +} + +TMP_ERR="$(mktemp)" +trap 'rm -f "$TMP_ERR"' EXIT + +shopt -s nullglob +markers=("$EXPECTED_DIR"/*.exit) +if [[ ${#markers[@]} -eq 0 ]]; then + echo "no tests found under $EXPECTED_DIR (expected .exit markers)" >&2 + exit 2 +fi + +for exit_file in "${markers[@]}"; do + name=$(basename "$exit_file" .exit) + sx_file="$TESTS_DIR/${name}.sx" + out_file="$EXPECTED_DIR/${name}.stdout" + + if [[ ! -f "$sx_file" ]]; then + printf " %-40s SKIP (missing %s)\n" "$name" "tests/${name}.sx" + SKIP=$((SKIP + 1)) + continue + fi + + printf " %-40s" "$name" + actual_out=$(timeout "$TIMEOUT" "$SX" run "$sx_file" 2>"$TMP_ERR" | normalize) + actual_exit=${PIPESTATUS[0]} + + if [[ $actual_exit -eq 124 ]]; then + TIMEOUT_COUNT=$((TIMEOUT_COUNT + 1)) + echo "TIMEOUT (>${TIMEOUT}s)" + continue + fi + + if [[ $UPDATE -eq 1 ]]; then + printf '%s\n' "$actual_out" > "$out_file" + printf '%s\n' "$actual_exit" > "$exit_file" + echo "updated (exit=$actual_exit)" + continue + fi + + expected_out=$(normalize < "$out_file" 2>/dev/null) + expected_exit=$(cat "$exit_file") + + out_ok=true; exit_ok=true + [[ "$actual_out" == "$expected_out" ]] || out_ok=false + [[ "$actual_exit" == "$expected_exit" ]] || exit_ok=false + + if $out_ok && $exit_ok; then + PASS=$((PASS + 1)) + echo "ok" + else + FAIL=$((FAIL + 1)) + echo "FAIL" + $out_ok || { echo " --- stdout diff (expected vs actual) ---"; diff <(printf '%s\n' "$expected_out") <(printf '%s\n' "$actual_out") || true; } + $exit_ok || echo " exit code: expected=$expected_exit actual=$actual_exit" + [[ -s "$TMP_ERR" ]] && { echo " --- stderr ---"; cat "$TMP_ERR"; } + fi +done + +if [[ $UPDATE -eq 1 ]]; then + echo "Updated all expected output files." + exit 0 +fi + +echo "$PASS passed, $FAIL failed, $SKIP skipped, $TIMEOUT_COUNT timed out" +[[ $FAIL -eq 0 && $TIMEOUT_COUNT -eq 0 ]]