Add CI pipeline and reusable test harness
A Gitea Actions workflow (.gitea/workflows/ci.yml) running the headless test suite on push, plus shared test scaffolding: seeded-RNG / approx / tally helpers (test_helpers.gd), a harness self-test, and a resolution + pseudo-locale UI-layout robustness guard. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CCyaJPTHCH9EDthpf4A5Sj
This commit is contained in:
@@ -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
|
||||||
+17
-1
@@ -8,12 +8,24 @@ SKIP_GAMEPLAY=0
|
|||||||
for arg in "$@"; do [[ "$arg" == "--no-gameplay" ]] && SKIP_GAMEPLAY=1; done
|
for arg in "$@"; do [[ "$arg" == "--no-gameplay" ]] && SKIP_GAMEPLAY=1; done
|
||||||
|
|
||||||
echo "=== gdlint ==="
|
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 ""
|
||||||
echo "=== Logic tests ==="
|
echo "=== Logic tests ==="
|
||||||
"$GODOT" --headless --script tests/logic_test.gd
|
"$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 ""
|
||||||
echo "=== Console focus tests ==="
|
echo "=== Console focus tests ==="
|
||||||
"$GODOT" --headless --script tests/console_focus_test.gd
|
"$GODOT" --headless --script tests/console_focus_test.gd
|
||||||
@@ -26,6 +38,10 @@ echo ""
|
|||||||
echo "=== Touch control tests (joystick self-heals, never strands) ==="
|
echo "=== Touch control tests (joystick self-heals, never strands) ==="
|
||||||
"$GODOT" --headless --script tests/touch_controls_test.gd
|
"$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 ""
|
||||||
echo "=== Rigid-skin tests (web mesh conversion keeps all materials) ==="
|
echo "=== Rigid-skin tests (web mesh conversion keeps all materials) ==="
|
||||||
"$GODOT" --headless --script tests/rigid_skin_test.gd
|
"$GODOT" --headless --script tests/rigid_skin_test.gd
|
||||||
|
|||||||
@@ -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/<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 @@
|
|||||||
|
uid://d1d4im32ox1ig
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://c42s0wpu80st1
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cp5j0gl3xbtha
|
||||||
Reference in New Issue
Block a user