Add CI workflow and reusable test-harness foundation
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
@@ -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/<name>.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.
|
||||||
@@ -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())
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user