From a6f938a0dac01f902dc2d1cf5c49b00b31a91690 Mon Sep 17 00:00:00 2001 From: Richard Aasa Date: Sat, 5 Sep 2026 00:06:18 +0300 Subject: [PATCH] Add CI workflow and reusable test-harness foundation Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 52 ++++++++++++++++++++++++++++++++ tests/README.md | 63 +++++++++++++++++++++++++++++++++++++++ tests/harness_selftest.gd | 27 +++++++++++++++++ tests/test_helpers.gd | 47 +++++++++++++++++++++++++++++ 4 files changed, 189 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 tests/README.md create mode 100644 tests/harness_selftest.gd create mode 100644 tests/test_helpers.gd diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3659d66 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +name: CI + +on: + push: + pull_request: + +jobs: + test: + name: Lint & headless tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + # Mirror the local toolchain (godot --version → 4.7.beta). If this exact tag is not published, + # pin it to the 4.7 beta the team actually runs; setup-godot maps this to a Godot release tag. + - name: Install Godot 4.7 + uses: chickensoft-games/setup-godot@v2 + with: + version: 4.7.0-beta + use-dotnet: false + include-templates: false + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install gdlint (gdtoolkit) + run: pip install "gdtoolkit==4.*" + + # First run imports assets and builds the .godot cache (gitignored), so the headless tests can + # actually load the .glb/.png resources they instance. Import can exit non-zero on warnings. + - name: Import resources + run: godot --headless --import + continue-on-error: true + + - name: gdlint + run: gdlint *.gd levels/*.gd tests/*.gd + + - name: Headless tests + env: + GODOT: godot + run: bash run_tests.sh --no-gameplay + + - name: Upload test output on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test-output + path: tests/output/ + if-no-files-found: ignore diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..0ac50c1 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,63 @@ +# Tests + +Headless, deterministic tests that run in CI (`.github/workflows/ci.yml`) and locally via +`bash run_tests.sh`. Nothing here needs a GPU — even the UI checks assert computed geometry +instead of comparing pixels, so the whole suite runs in plain headless Godot. + +## Run + +```bash +bash run_tests.sh # lint + every headless test (+ gameplay assertions) +bash run_tests.sh --no-gameplay # skip the slower full-scene gameplay pass (what CI runs) + +# a single test: +godot --headless --script tests/.gd +``` + +Set `GODOT` to point at a specific binary: `GODOT=/path/to/godot bash run_tests.sh`. + +## The six layers + +Order is cheapest first. The middle layers are where "don't break interactions/UI by accident" +is actually won — none of them need a screenshot. + +| Layer | What it drives | Example here | +|---|---|---| +| **Scene smoke** | Instantiate a scene headless, assert no errors | `level_switch_test.gd` (level modules load, no geometry leaks) | +| **Unit / logic** | Pure functions, no scene tree | `logic_test.gd`; `harness_selftest.gd` (proves `test_helpers.gd`) | +| **Signal contract** | Emit a signal, assert args/count | *to add:* `died` / `health_changed` / `all_defeated` fire correctly | +| **Interaction** | Feed synthetic input, step frames, assert state | `touch_controls_test.gd`, `matador_charge_test.gd` | +| **Scenario / replay** | Scripted inputs + fixed seed over a whole fight | `difficulty_sim.gd` (a report), `bear_boss_test.gd` | +| **UI layout** | Resize the viewport, query `Control` rects | `ui_layout_test.gd` (on-screen, text fits, tap size, scroll) | + +## Shared harness + +`test_helpers.gd` (`class_name TestHelpers`) holds the primitives every test used to re-implement: + +- `TestHelpers.make_rng(seed)` — a **seeded** `RandomNumberGenerator`. Determinism is the + precondition for the interaction/scenario layers; seed the RNG here instead of the global + `randf()`/`randi()` so a test replays identically. (Reworking gameplay code to accept an injected + RNG is tracked in the refactor plan's Workstream 2.) +- `TestHelpers.approx(a, b, eps)` / `vec_approx(a, b, eps)` — float/vector comparisons with + tolerance, because computed positions never compare exactly equal. +- A pass/fail tally: `t.ok(cond, msg)` and `t.report()`, which prints `Results: X passed, Y failed` + and returns the process exit code. + +New tests follow the existing idiom: `extends SceneTree`, `_init()` → `_run.call_deferred()`, +print ` PASS:` / ` FAIL:`, and `quit(t.report())`. + +## Next step: GdUnit4 + +The hand-rolled harness is enough for CI today. The plan's Workstream 5 adopts +[GdUnit4](https://github.com/MikeSchulze/gdUnit4) for a fluent assertion API, its **scene runner** +(synthetic input + frame stepping — purpose-built for the interaction layer), and HTML/JUnit +reports. Adoption is incremental, so the current tests keep running throughout: + +1. Add it under `addons/gdUnit4` (AssetLib in-editor, or a git submodule) and enable the plugin in + Project Settings → Plugins. +2. Point CI at GdUnit4's own GitHub Action (it publishes the JUnit report to the PR) alongside the + existing `run_tests.sh` step. +3. Port suites one at a time — `test_helpers.gd`'s primitives map directly onto GdUnit4 assertions, + so a ported test is smaller, not larger. + +Do **not** vendor GdUnit4 into this repo by hand; install it through AssetLib or a submodule. diff --git a/tests/harness_selftest.gd b/tests/harness_selftest.gd new file mode 100644 index 0000000..76b2d13 --- /dev/null +++ b/tests/harness_selftest.gd @@ -0,0 +1,27 @@ +extends SceneTree +## Proves tests/test_helpers.gd behaves before the rest of the suite leans on it: seeded RNGs are +## reproducible, the approx comparisons accept tiny error and reject real gaps, and the tally maps +## onto the exit code. It dogfoods the tally it is testing. +## Run: godot --headless --script tests/harness_selftest.gd + +const TH := preload("res://tests/test_helpers.gd") + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + var t := TH.new() + + var same_a := TH.make_rng(42) + var same_b := TH.make_rng(42) + t.ok(same_a.randf() == same_b.randf(), "same seed yields the same draw") + t.ok(TH.make_rng(42).randi() != TH.make_rng(7).randi(), "different seeds diverge") + + t.ok(TH.approx(0.1 + 0.2, 0.3), "approx tolerates float rounding error") + t.ok(not TH.approx(1.0, 1.5, 0.1), "approx rejects a real gap") + t.ok(TH.vec_approx(Vector3(1, 2, 3), Vector3(1, 2, 3.00001)), "vec_approx tolerates a tiny offset") + t.ok(not TH.vec_approx(Vector3.ZERO, Vector3(0, 0, 1)), "vec_approx rejects a real offset") + + quit(t.report()) diff --git a/tests/test_helpers.gd b/tests/test_helpers.gd new file mode 100644 index 0000000..2158777 --- /dev/null +++ b/tests/test_helpers.gd @@ -0,0 +1,47 @@ +class_name TestHelpers +extends RefCounted +## Shared primitives for the headless test suite so each test stops re-implementing them: a seeded +## RNG for determinism, float/vector approximate comparisons, and a small pass/fail tally whose +## report() doubles as the process exit code. Use the tally directly, or the static helpers alone. + +var _pass: int = 0 +var _fail: int = 0 + + +# Determinism is the precondition for repeatable AI/physics tests: seed the RNG here instead of the +# global randf()/randi() so a test replays identically every run. +static func make_rng(seed_value: int) -> RandomNumberGenerator: + var rng := RandomNumberGenerator.new() + rng.seed = seed_value + return rng + + +static func approx(a: float, b: float, eps: float = 0.0001) -> bool: + return absf(a - b) <= eps + + +static func vec_approx(a: Vector3, b: Vector3, eps: float = 0.0001) -> bool: + return a.distance_to(b) <= eps + + +func ok(cond: bool, msg: String) -> void: + if cond: + _pass += 1 + print(" PASS: ", msg) + else: + _fail += 1 + print(" FAIL: ", msg) + + +func passed() -> int: + return _pass + + +func failed() -> int: + return _fail + + +## Prints the tally and returns the process exit code (0 = everything passed). +func report() -> int: + print("Results: %d passed, %d failed" % [_pass, _fail]) + return 1 if _fail > 0 else 0