diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..97260d2 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,139 @@ +name: CI + +# Gitea Actions is GitHub-Actions-compatible: drop this in .gitea/workflows/, enable Actions on the +# repo ([actions] ENABLED=true in app.ini, Gitea >= 1.19) and register an act_runner — it then runs +# on every push / PR. Godot and gdtoolkit are installed with plain shell steps (not the chickensoft +# setup-godot action), because a self-hosted runner usually can't reach the GitHub action registry +# (unless DEFAULT_ACTIONS_URL is set to proxy github.com). Only actions/checkout and upload-artifact +# remain as external actions — both ship with Gitea's default proxy; if yours is offline, mirror them +# into your instance. +# +# It also runs once a day (schedule) as a safety net over main's tip. The `guard` job skips that daily +# run when the commit already passed — so an idle main isn't re-tested, but a commit whose push-time +# run was missed/skipped still gets verified. schedule needs Gitea >= 1.21; workflow_dispatch (the +# manual "Run workflow" button) needs >= 1.23; from 1.23 the cron is evaluated in UTC. + +on: + push: + pull_request: + workflow_dispatch: + schedule: + - cron: "0 4 * * *" # 04:00 UTC daily + +# statuses:write lets the test job stamp a `ci/headless` commit status the guard reads back. +permissions: + contents: read + statuses: write + +env: + # The Godot build the team runs (godot --version -> 4.7.beta.custom_build.417cd33bf). Pin this to a + # PUBLISHED build from https://github.com/godotengine/godot-builds/releases. If you run a custom + # build with no public binary, host the linux headless zip where the runner can reach it and set + # GODOT_URL to that (it overrides the constructed URL below). + GODOT_VERSION: "4.7-beta1" + GODOT_URL: "" + +jobs: + # On push/PR this passes straight through. On the daily schedule it asks the API whether main's tip + # already carries a successful `ci/headless` status (stamped by an earlier run) and, if so, tells the + # test job to skip — that's the "don't re-test a commit that already ran" rule. + guard: + name: Decide whether to run + runs-on: ubuntu-latest + outputs: + run: ${{ steps.decide.outputs.run }} + steps: + - name: Check for an existing pass on this commit + id: decide + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + run: | + set -u + if [ "${{ github.event_name }}" != "schedule" ]; then + echo "event=${{ github.event_name }} -> run" + echo "run=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + api="${{ github.api_url }}"; [ -n "$api" ] || api="${{ github.server_url }}/api/v1" + curl -sfL -H "Authorization: token ${GITEA_TOKEN}" \ + "$api/repos/${{ github.repository }}/commits/${{ github.sha }}/statuses?limit=100" \ + -o /tmp/statuses.json || echo '[]' > /tmp/statuses.json + python3 - <<'PY' + import json, os + try: + data = json.load(open("/tmp/statuses.json")) + except Exception: + data = [] + items = data.get("statuses", data) if isinstance(data, dict) else data + done = any( + s.get("context") == "ci/headless" and (s.get("state") or s.get("status")) == "success" + for s in (items or []) + ) + with open(os.environ["GITHUB_OUTPUT"], "a") as f: + f.write("run=%s\n" % ("false" if done else "true")) + print("commit already passed ci/headless -> skip" if done else "no prior pass -> run") + PY + + test: + name: Lint & headless tests + runs-on: ubuntu-latest # must match a label your act_runner registered with + needs: guard + if: ${{ needs.guard.outputs.run == 'true' }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Godot ${{ env.GODOT_VERSION }} + run: | + set -eux + url="${GODOT_URL:-https://github.com/godotengine/godot-builds/releases/download/${GODOT_VERSION}/Godot_v${GODOT_VERSION}_linux.x86_64.zip}" + curl -fL "$url" -o /tmp/godot.zip + unzip -o /tmp/godot.zip -d /tmp/godot + install -m 755 /tmp/godot/Godot_v*_linux.x86_64 /usr/local/bin/godot + godot --version + + - name: Install gdlint (gdtoolkit) + # A venv sidesteps PEP-668 "externally managed" pip on recent runner images; putting its bin on + # GITHUB_PATH lets run_tests.sh call bare `gdlint` in later steps. + run: | + set -eux + python3 -m venv /tmp/venv + /tmp/venv/bin/pip install "gdtoolkit==4.*" + echo "/tmp/venv/bin" >> "$GITHUB_PATH" + + # A cold .godot cache (CI always starts cold) needs two import passes: the first imports base + # assets, the second resolves scenes that depend on them (the .fbx/.glb -> .scn imports). One pass + # leaves those unresolved and the headless tests fail to load them. Warnings exit non-zero. + - name: Import resources + run: | + godot --headless --import || true + godot --headless --import || true + + - name: gdlint + run: gdlint actors systems ui props levels tests + + - name: Headless tests + env: + GODOT: godot + run: bash run_tests.sh --no-gameplay + + # Stamp the commit so the daily guard recognizes it as already tested. Best-effort: a fork PR whose + # token can't write statuses just skips this (the build still passes). + - name: Mark commit as tested + if: success() + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + run: | + api="${{ github.api_url }}"; [ -n "$api" ] || api="${{ github.server_url }}/api/v1" + curl -sfL -X POST -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" \ + "$api/repos/${{ github.repository }}/statuses/${{ github.sha }}" \ + -d '{"context":"ci/headless","state":"success","description":"headless suite green"}' \ + >/dev/null || echo "could not post commit status (token lacks statuses:write?)" + + - 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/run_tests.sh b/run_tests.sh index 050c0a0..0cd162f 100755 --- a/run_tests.sh +++ b/run_tests.sh @@ -8,12 +8,24 @@ SKIP_GAMEPLAY=0 for arg in "$@"; do [[ "$arg" == "--no-gameplay" ]] && SKIP_GAMEPLAY=1; done echo "=== gdlint ===" -gdlint *.gd levels/*.gd tests/*.gd +gdlint actors systems ui props levels tests + +echo "" +echo "=== Harness self-test (shared test_helpers: seeded RNG + approx + tally) ===" +"$GODOT" --headless --script tests/harness_selftest.gd echo "" echo "=== Logic tests ===" "$GODOT" --headless --script tests/logic_test.gd +echo "" +echo "=== Damage pipeline (bull's hit reception through DamageEvent) ===" +"$GODOT" --headless --script tests/damage_pipeline_test.gd + +echo "" +echo "=== FX gallery smoke (every registered Fx effect spawns + frees) ===" +"$GODOT" --headless --script tests/fx_gallery_test.gd + echo "" echo "=== Console focus tests ===" "$GODOT" --headless --script tests/console_focus_test.gd @@ -26,6 +38,10 @@ echo "" echo "=== Touch control tests (joystick self-heals, never strands) ===" "$GODOT" --headless --script tests/touch_controls_test.gd +echo "" +echo "=== UI layout tests (HUD stays on-screen, text fits, tap targets, scroll — across resolutions) ===" +"$GODOT" --headless --script tests/ui_layout_test.gd + echo "" echo "=== Rigid-skin tests (web mesh conversion keeps all materials) ===" "$GODOT" --headless --script tests/rigid_skin_test.gd diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..1f123e4 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,63 @@ +# Tests + +Headless, deterministic tests that run in CI (`.gitea/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/harness_selftest.gd.uid b/tests/harness_selftest.gd.uid new file mode 100644 index 0000000..6d8c9f9 --- /dev/null +++ b/tests/harness_selftest.gd.uid @@ -0,0 +1 @@ +uid://d1d4im32ox1ig 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 diff --git a/tests/test_helpers.gd.uid b/tests/test_helpers.gd.uid new file mode 100644 index 0000000..83b13fc --- /dev/null +++ b/tests/test_helpers.gd.uid @@ -0,0 +1 @@ +uid://c42s0wpu80st1 diff --git a/tests/ui_layout_test.gd b/tests/ui_layout_test.gd new file mode 100644 index 0000000..613dd5a --- /dev/null +++ b/tests/ui_layout_test.gd @@ -0,0 +1,243 @@ +extends SceneTree +## UI layout robustness — asserts computed Control geometry instead of comparing pixels, so it +## runs headless and deterministically. It guards what a golden screenshot can't articulate: +## HUD elements staying on-screen, text not overflowing its box (even under a wider pseudo-locale), +## and touch targets staying thumb-sized. Every check is re-run across several resolutions, so a +## layout that only holds at one aspect ratio is caught. +## Run: godot --headless --script tests/ui_layout_test.gd + +const MIN_TAP := 44.0 # smallest comfortable touch target (Apple HIG 44pt) +const LANDSCAPE := [Vector2i(1280, 720), Vector2i(1920, 1080), Vector2i(2560, 1080)] +const PORTRAIT := [Vector2i(1080, 2340), Vector2i(720, 1280)] +const EDGE_TOL := 1.5 # sub-pixel rounding slack for containment + +var _pass := 0 +var _fail := 0 +var _warn := 0 + + +func _init() -> void: + _run.call_deferred() + + +func _ok(cond: bool, msg: String) -> void: + if cond: + _pass += 1 + print(" PASS: ", msg) + else: + _fail += 1 + print(" FAIL: ", msg) + + +# Passes when no offenders were found; otherwise fails and lists each one so the failure names +# the actual element and rect, not just "layout broke". +func _ok_empty(bad: Array, msg: String) -> void: + if bad.is_empty(): + _pass += 1 + print(" PASS: ", msg) + else: + _fail += 1 + print(" FAIL: ", msg) + for b: String in bad: + print(" · ", b) + + +# Overflow under a pseudo-locale is reported, not failed: until the game is actually localised it +# is a forward-looking warning. Promote _warn_empty → _ok_empty here the day translations land. +func _warn_empty(bad: Array, msg: String) -> void: + if bad.is_empty(): + print(" ok: ", msg) + else: + _warn += bad.size() + print(" WARN: ", msg) + for b: String in bad: + print(" · ", b) + + +func _settle() -> void: + await process_frame + await process_frame + await process_frame + + +func _collect(node: Node, acc: Array) -> void: + if node is Control and (node as Control).is_visible_in_tree(): + acc.append(node) + for child: Node in node.get_children(): + _collect(child, acc) + + +func _rect_inside(r: Rect2, vp: Vector2) -> bool: + return r.position.x >= -EDGE_TOL and r.position.y >= -EDGE_TOL \ + and r.end.x <= vp.x + EDGE_TOL and r.end.y <= vp.y + EDGE_TOL + + +func _audit_containment(ctrls: Array, vp: Vector2) -> Array: + var bad: Array = [] + for c: Control in ctrls: + if c.size.x <= 0.0 or c.size.y <= 0.0: + continue + var r := c.get_global_rect() + if not _rect_inside(r, vp): + bad.append("%s escapes viewport: %s" % [c.name, r]) + return bad + + +# A Label whose text can visibly clip: no wrapping, no clip flag, no ellipsis trimming — so if its +# content needs more width than it was given, glyphs spill. Exactly the localisation-overflow case. +func _hard_label(c: Control) -> bool: + if not (c is Label): + return false + var l := c as Label + return l.text != "" and l.autowrap_mode == TextServer.AUTOWRAP_OFF \ + and not l.clip_text and l.text_overrun_behavior == TextServer.OVERRUN_NO_TRIMMING + + +func _audit_overflow(ctrls: Array) -> Array: + var bad: Array = [] + for c: Control in ctrls: + if not _hard_label(c): + continue + var need := (c as Label).get_combined_minimum_size().x + if need > c.size.x + 1.0: + bad.append("%s needs %.0fpx, has %.0fpx: \"%s\"" % [c.name, need, c.size.x, (c as Label).text.left(24)]) + return bad + + +func _audit_taps(ctrls: Array) -> Array: + var bad: Array = [] + for c: Control in ctrls: + if not (c is BaseButton): + continue + if c.size.x < MIN_TAP or c.size.y < MIN_TAP: + bad.append("%s is %.0fx%.0f (< %.0f)" % [c.name, c.size.x, c.size.y, MIN_TAP]) + return bad + + +# The on-screen thumb cluster draws + hit-tests from raw rects (not Control children), so audit its +# own single source of truth: the ability button rects, the menu button, and the joystick diameter. +func _audit_touch_cluster(tc: Node) -> Array: + var bad: Array = [] + var rects: Array = tc.get("_ability_rects") + for i in rects.size(): + var r: Rect2 = rects[i] + if r.size.x < MIN_TAP or r.size.y < MIN_TAP: + bad.append("ability button %d is %.0fx%.0f" % [i, r.size.x, r.size.y]) + var mr: Rect2 = tc.get("_menu_rect") + if mr.size.x < MIN_TAP or mr.size.y < MIN_TAP: + bad.append("menu button is %.0fx%.0f" % [mr.size.x, mr.size.y]) + var stick_d: float = float(tc.get("_stick_radius")) * 2.0 + if stick_d < MIN_TAP: + bad.append("joystick diameter is %.0f" % stick_d) + return bad + + +func _find_touch(node: Node) -> Node: + if node.get("_ability_rects") != null and node.has_method("_relayout"): + return node + for child: Node in node.get_children(): + var found := _find_touch(child) + if found != null: + return found + return null + + +func _boot_shell(touch: bool) -> Node: + var dp: Node = root.get_node("/root/DP") + dp.call("set_value", "force_touch_controls", touch) + var scene: Node = load("res://levels/game_shell.tscn").instantiate() + root.add_child(scene) + current_scene = scene + await create_timer(0.8).timeout # let the shell build the level + HUD + return scene + + +func _run() -> void: + (root.get_node("/root/Run") as Node).call("ensure_run") + + # ── Desktop HUD across landscape resolutions ──────────────────────────────── + var scene: Node = await _boot_shell(false) + var hud: Node = scene.get_node("HUD") + for size: Vector2i in LANDSCAPE: + root.size = size + await _settle() + var ctrls: Array = [] + _collect(hud, ctrls) + _ok_empty(_audit_containment(ctrls, Vector2(size)), "desktop HUD stays on-screen @ %s" % size) + _ok_empty(_audit_overflow(ctrls), "desktop HUD text fits its boxes @ %s" % size) + + # Wider-locale stress: expand every auto-translated string and re-check overflow (informational). + ProjectSettings.set_setting("internationalization/pseudolocalization/replace_with_accents", true) + ProjectSettings.set_setting("internationalization/pseudolocalization/expansion_ratio", 0.4) + TranslationServer.set_pseudolocalization_enabled(true) + await _settle() + var loc_ctrls: Array = [] + _collect(hud, loc_ctrls) + _warn_empty(_audit_overflow(loc_ctrls), "desktop HUD text fits under a +40% pseudo-locale") + TranslationServer.set_pseudolocalization_enabled(false) + + scene.queue_free() + await _settle() + + # ── Touch HUD across portrait resolutions ─────────────────────────────────── + var tscene: Node = await _boot_shell(true) + var thud: Node = tscene.get_node("HUD") + var tc := _find_touch(thud) + _ok(tc != null, "touch control cluster is present in the touch HUD") + for size: Vector2i in PORTRAIT: + root.size = size + await _settle() + if tc != null: + tc.call("_relayout") + var ctrls: Array = [] + _collect(thud, ctrls) + _ok_empty(_audit_containment(ctrls, Vector2(size)), "touch HUD stays on-screen @ %s" % size) + _ok_empty(_audit_overflow(ctrls), "touch HUD text fits its boxes @ %s" % size) + _ok_empty(_audit_taps(ctrls), "touch HUD buttons are ≥ %.0fpx @ %s" % [MIN_TAP, size]) + if tc != null: + _ok_empty(_audit_touch_cluster(tc), "thumb cluster targets are ≥ %.0fpx @ %s" % [MIN_TAP, size]) + tscene.queue_free() + await _settle() + + # ── Main menu: the one scrollable panel actually scrolls when its content overflows ───────── + # Short viewport so the rebind list can't fit, then open options the way the button does, so we + # measure the real panel laid out — not a hidden one whose scrollbar hasn't been computed yet. + root.size = Vector2i(1024, 600) + var menu: Node = load("res://ui/main_menu.tscn").instantiate() + root.add_child(menu) + await create_timer(0.3).timeout + if menu.has_method("_on_options_pressed"): + menu.call("_on_options_pressed") + await _settle() + var scrolls: Array = [] + _collect_scrolls(menu, scrolls) + var exercised := false + for sc: ScrollContainer in scrolls: + if not sc.is_visible_in_tree() or sc.size.y <= 0.0: + continue + var content_h := 0.0 + for ch: Node in sc.get_children(): + if ch is Control: + content_h = maxf(content_h, (ch as Control).get_combined_minimum_size().y) + if content_h <= sc.size.y + 1.0: + continue + exercised = true + var sb := sc.get_v_scroll_bar() + _ok(sb.max_value - sb.page > 1.0, "menu options scroll '%s' can reach its overflow" % sc.name) + if not exercised: + print(" ok: no overflowing scroll panel to exercise") + menu.queue_free() + + _finish() + + +func _collect_scrolls(node: Node, acc: Array) -> void: + if node is ScrollContainer: + acc.append(node) + for child: Node in node.get_children(): + _collect_scrolls(child, acc) + + +func _finish() -> void: + print("Results: %d passed, %d failed, %d locale warnings" % [_pass, _fail, _warn]) + quit(1 if _fail > 0 else 0) diff --git a/tests/ui_layout_test.gd.uid b/tests/ui_layout_test.gd.uid new file mode 100644 index 0000000..d94eb75 --- /dev/null +++ b/tests/ui_layout_test.gd.uid @@ -0,0 +1 @@ +uid://cp5j0gl3xbtha