Files
CosmicClash/Game/tests/test_runner.gd
T
Josh Creek 4533da34e0 feat(multiplayer): Phase 1 transport, connection, and lobby
Lands tasks 1.0-1.8 of multiplayer-todo.md: the pure-function test runner,
net_codec (wire format quantizers/pack-unpack), NetworkManager (ENet
transport, manual polling, min-RTT clock sync), MatchNet (handshake,
protocol/tick-rate gating, roster with team+ready state), lobby.tscn (team
columns, switch team, ready toggle), server_boot.tscn (headless dedicated
server with structured logging and an overrun watchdog), and main_menu.gd's
Host/Join-by-IP UI (connecting overlay, cancel, bounded failure path).

Followed by an adversarial review (Opus subagent) that found and fixed two
real bugs - an unvalidated player_name broadcast that let one client's
oversized name head-of-line-block the reliable channel for everyone, and a
server-side roster leak across a host/re-host cycle - plus three gaps in
the test suite itself where a claim of "verified" wasn't actually backed
by what the test checked. All five two-process smoke tests plus the
pure-function suite are green with the strengthened assertions in place.
2026-08-20 08:18:59 +01:00

82 lines
3.1 KiB
GDScript

extends Node
# Headless test runner (task 1.0). Discovers every *.gd under tests/cases/,
# instances it, and calls every test_*() method by name — a test case is
# picked up by dropping a file in that folder, not by registering it here.
# Run with: godot --headless --path Game res://tests/test_runner.tscn
const CASES_DIR := "res://tests/cases"
const TestCase = preload("res://tests/test_case.gd")
func _ready() -> void:
var case_paths := _discover_case_paths()
var total := 0
var failed := 0
var failure_messages: Array[String] = []
for path in case_paths:
# Adversarial-review regression: a case file with a parse/compile
# error used to hang the whole runner forever. load() on a broken
# script does NOT return null here — it returns a non-null but
# uninstantiable GDScript resource, so a plain null check doesn't
# catch it; calling .new() on it throws "Invalid call: Nonexistent
# function 'new'", severe enough to abort _ready() entirely without
# ever reaching quit(). can_instantiate() is the real guard.
var script: GDScript = load(path)
if script == null or not script.can_instantiate():
failed += 1
failure_messages.append("%s: failed to load (parse/compile error — see SCRIPT ERROR above)" % path.get_file())
continue
var instance = script.new()
if instance == null:
failed += 1
failure_messages.append("%s: script.new() returned null" % path.get_file())
continue
for method in instance.get_method_list():
var method_name: String = method["name"]
if not method_name.begins_with("test_"):
continue
total += 1
instance.failures.clear()
instance.assertions_made = 0
instance.call(method_name)
# Adversarial-review regression: GDScript has no exceptions, so
# a runtime error partway through a test (before it reaches its
# first assert_*) just logs a SCRIPT ERROR and returns —
# `failures` stays empty exactly as if every assertion passed,
# and this used to count as a PASS. A test that completes
# having made zero assertions is itself a failure: it proved
# nothing, whether because it crashed early or was just never
# written to assert anything.
if instance.assertions_made == 0:
failed += 1
failure_messages.append("%s.%s: made no assertions (crashed before the first assert_*, or the test itself is incomplete)" % [path.get_file(), method_name])
elif not instance.failures.is_empty():
failed += 1
for f in instance.failures:
failure_messages.append("%s.%s: %s" % [path.get_file(), method_name, f])
print("Ran %d tests from %d case file(s), %d failed" % [total, case_paths.size(), failed])
for message in failure_messages:
print(" FAIL: " + message)
get_tree().quit(1 if failed > 0 else 0)
func _discover_case_paths() -> Array[String]:
var paths: Array[String] = []
var dir := DirAccess.open(CASES_DIR)
if dir == null:
push_error("Cannot open " + CASES_DIR)
return paths
dir.list_dir_begin()
var file_name := dir.get_next()
while file_name != "":
if file_name.ends_with(".gd") and not dir.current_is_dir():
paths.append(CASES_DIR + "/" + file_name)
file_name = dir.get_next()
dir.list_dir_end()
paths.sort()
return paths