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.
This commit is contained in:
Josh Creek
2026-08-20 08:18:59 +01:00
parent e83bb4fa0c
commit 4533da34e0
30 changed files with 2509 additions and 12 deletions
+39
View File
@@ -0,0 +1,39 @@
extends "res://tests/test_case.gd"
const MatchNet = preload("res://scripts/match_net.gd")
# Adversarial-review regression: _hello's player_name used to be broadcast
# to every peer completely unvalidated — a multi-MB name head-of-line-
# blocked the reliable control channel hard enough that a concurrently-
# joining client's own _welcome never arrived. _sanitize_player_name() is
# the fix; these are pure-function tests for it, independent of the live
# two-process rejection test in tests/match_net_smoke.gd (--role=client-longname).
func test_normal_name_unchanged() -> void:
assert_eq(MatchNet._sanitize_player_name("Alice"), "Alice", "a normal name passes through unchanged")
func test_strips_control_characters() -> void:
var bell := String.chr(7) # a control char with no named GDScript escape
var raw := "Bad\nName\twith\rcontrol" + bell + "chars"
var clean := MatchNet._sanitize_player_name(raw)
assert_true(not clean.contains("\n"), "no newline")
assert_true(not clean.contains("\t"), "no tab")
assert_true(not clean.contains("\r"), "no carriage return")
assert_true(not clean.contains(bell), "no bell/control char")
func test_clamps_to_max_display_length() -> void:
var raw := "X".repeat(1000)
var clean := MatchNet._sanitize_player_name(raw)
assert_eq(clean.length(), MatchNet.MAX_PLAYER_NAME_LENGTH, "clamped to MAX_PLAYER_NAME_LENGTH")
func test_empty_or_whitespace_only_falls_back_to_default() -> void:
assert_eq(MatchNet._sanitize_player_name(""), "Player", "empty string falls back")
assert_eq(MatchNet._sanitize_player_name(" "), "Player", "whitespace-only falls back")
assert_eq(MatchNet._sanitize_player_name("\n\t\r"), "Player", "control-characters-only falls back")
func test_leading_trailing_whitespace_trimmed() -> void:
assert_eq(MatchNet._sanitize_player_name(" Bob "), "Bob", "surrounding whitespace trimmed")
+169
View File
@@ -0,0 +1,169 @@
extends "res://tests/test_case.gd"
const NetCodec = preload("res://scripts/net_codec.gd")
const ShipAction = preload("res://scripts/ship_action.gd")
const NetBodyState = preload("res://scripts/net_body_state.gd")
const POS_TOL := 0.01 # well under the ~1.95mm quantisation step's rounding
const VEL_TOL := 0.01
const QUAT_TOL := 0.001
const AVEL_TOL := 0.2 # BALL_AVEL_RANGE/127 half-step, scaled through the ship->ball rescale
const THRUST_TOL := 1.0 / 127.0 + 0.001
func _make_action(tx: float, ty: float, tz: float, rx: float, ry: float, rz: float, turbo: bool) -> ShipAction:
var a := ShipAction.new()
a.thrust = Vector3(tx, ty, tz)
a.rotation = Vector3(rx, ry, rz)
a.turbo = turbo
return a
func test_input_header_size_matches_spec() -> void:
assert_eq(NetCodec.INPUT_HEADER_SIZE, 12, "input header size")
assert_eq(NetCodec.INPUT_ENTRY_SIZE, 7, "input entry size")
func test_input_roundtrip_single_entry() -> void:
var actions := [_make_action(1.0, -1.0, 0.5, -0.25, 0.0, 1.0, true)]
var bytes := NetCodec.pack_input(12345, 999, 6000, actions)
assert_eq(bytes.size(), NetCodec.INPUT_HEADER_SIZE + NetCodec.INPUT_ENTRY_SIZE, "1-entry payload size")
var decoded := NetCodec.unpack_input(bytes)
assert_eq(decoded["seq"], 12345, "seq")
assert_eq(decoded["count"], 1, "count")
assert_eq(decoded["ack_snapshot_tick"], 999, "ack_snapshot_tick")
assert_eq(decoded["client_send_ms"], 6000, "client_send_ms")
var a: ShipAction = decoded["actions"][0]
assert_almost_eq(a.thrust.x, 1.0, THRUST_TOL, "thrust.x")
assert_almost_eq(a.thrust.y, -1.0, THRUST_TOL, "thrust.y")
assert_almost_eq(a.thrust.z, 0.5, THRUST_TOL, "thrust.z")
assert_almost_eq(a.rotation.x, -0.25, THRUST_TOL, "rotation.x")
assert_almost_eq(a.rotation.y, 0.0, THRUST_TOL, "rotation.y")
assert_almost_eq(a.rotation.z, 1.0, THRUST_TOL, "rotation.z")
assert_true(a.turbo, "turbo bit")
func test_input_roundtrip_max_redundancy_newest_first() -> void:
var actions := [
_make_action(1.0, 0.0, 0.0, 0.0, 0.0, 0.0, false),
_make_action(0.5, 0.0, 0.0, 0.0, 0.0, 0.0, false),
_make_action(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, false),
_make_action(-1.0, 0.0, 0.0, 0.0, 0.0, 0.0, true),
]
var bytes := NetCodec.pack_input(1, 0, 0, actions)
assert_eq(bytes.size(), NetCodec.INPUT_HEADER_SIZE + 4 * NetCodec.INPUT_ENTRY_SIZE, "4-entry payload size")
var decoded := NetCodec.unpack_input(bytes)
assert_eq(decoded["count"], 4, "count")
var decoded_actions: Array = decoded["actions"]
assert_almost_eq(decoded_actions[0].thrust.x, 1.0, THRUST_TOL, "entry 0 (newest) thrust.x")
assert_almost_eq(decoded_actions[3].thrust.x, -1.0, THRUST_TOL, "entry 3 (oldest) thrust.x")
assert_true(decoded_actions[3].turbo, "entry 3 turbo bit")
assert_true(not decoded_actions[0].turbo, "entry 0 turbo bit unset")
func test_input_redundancy_clamped_to_max() -> void:
var actions := []
for i in 6:
actions.append(_make_action(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, false))
var bytes := NetCodec.pack_input(1, 0, 0, actions)
assert_eq(bytes.size(), NetCodec.INPUT_HEADER_SIZE + NetCodec.MAX_REDUNDANCY * NetCodec.INPUT_ENTRY_SIZE, "clamped to MAX_REDUNDANCY entries")
func test_snapshot_sizes_match_spec() -> void:
assert_eq(NetCodec.SNAPSHOT_CLIENT_HEADER_SIZE, 7, "client header size")
assert_eq(NetCodec.SNAPSHOT_BODY_HEADER_SIZE, 8, "body header size")
assert_eq(NetCodec.SNAPSHOT_BODY_SIZE, 22, "per-body size")
func test_snapshot_roundtrip_seven_bodies() -> void:
var bodies: Array[NetBodyState] = []
for i in 7:
var b := NetBodyState.new()
b.position = Vector3(float(i) * 3.0 - 10.0, 1.0, -float(i) * 2.0)
b.rotation = Quaternion(Vector3.UP, float(i) * 0.3)
b.linear_velocity = Vector3(float(i), 0.0, -float(i) * 0.5)
b.angular_velocity = Vector3(0.1 * i, 0.0, 0.0)
b.frozen = (i % 2 == 0)
b.turbo = (i == 3)
b.thrust_z = -1.0 + 2.0 * float(i) / 6.0
b.stalled = (i == 5)
bodies.append(b)
var segment := NetCodec.pack_snapshot_body_segment(4242, 3, 7, bodies)
assert_eq(segment.size(), NetCodec.SNAPSHOT_BODY_HEADER_SIZE + 7 * NetCodec.SNAPSHOT_BODY_SIZE, "7-body segment size")
var packet := NetCodec.pack_snapshot(555, -2, 1234, segment)
assert_eq(packet.size(), NetCodec.SNAPSHOT_CLIENT_HEADER_SIZE + segment.size(), "full packet size")
assert_eq(packet.size(), 169, "matches multiplayer-todo.md §2.4's 169 B payload figure for 7 bodies")
var decoded := NetCodec.unpack_snapshot(packet)
assert_eq(decoded["last_input_seq"], 555, "last_input_seq")
assert_eq(decoded["input_buffer_depth"], -2, "input_buffer_depth (negative = starved)")
assert_eq(decoded["echo_client_send_ms"], 1234, "echo_client_send_ms")
assert_eq(decoded["server_tick"], 4242, "server_tick")
assert_eq(decoded["match_state"], 3, "match_state")
assert_eq(decoded["reset_gen"], 7, "reset_gen")
var decoded_bodies: Array = decoded["bodies"]
assert_eq(decoded_bodies.size(), 7, "body_count")
for i in 7:
var original: NetBodyState = bodies[i]
var b: NetBodyState = decoded_bodies[i]
assert_almost_eq(b.position.x, original.position.x, POS_TOL, "body %d position.x" % i)
assert_almost_eq(b.position.y, original.position.y, POS_TOL, "body %d position.y" % i)
assert_almost_eq(b.position.z, original.position.z, POS_TOL, "body %d position.z" % i)
assert_almost_eq(b.linear_velocity.x, original.linear_velocity.x, VEL_TOL, "body %d velocity.x" % i)
assert_almost_eq(b.rotation.x, original.rotation.x, QUAT_TOL, "body %d quat.x" % i)
assert_almost_eq(b.rotation.y, original.rotation.y, QUAT_TOL, "body %d quat.y" % i)
assert_almost_eq(b.rotation.z, original.rotation.z, QUAT_TOL, "body %d quat.z" % i)
assert_almost_eq(b.rotation.w, original.rotation.w, QUAT_TOL, "body %d quat.w (sign fold)" % i)
assert_eq(b.frozen, original.frozen, "body %d frozen" % i)
assert_eq(b.turbo, original.turbo, "body %d turbo" % i)
assert_eq(b.stalled, original.stalled, "body %d stalled" % i)
func test_snapshot_quaternion_negative_w_sign_survives() -> void:
# A quaternion whose w component is negative (same rotation as its
# positive-w twin, but exercises the sign-fold bit specifically).
var b := NetBodyState.new()
b.rotation = Quaternion(0.0, 0.0, 0.0, -1.0).normalized()
var segment := NetCodec.pack_snapshot_body_segment(0, 0, 0, [b])
var decoded := NetCodec.unpack_snapshot(NetCodec.pack_snapshot(0, 0, 0, segment))
var out: NetBodyState = decoded["bodies"][0]
assert_true(out.rotation.w < 0.0, "negative w sign must survive the round trip")
func test_thrust_z_bin_quantisation_covers_range() -> void:
assert_eq(NetCodec.quantize_thrust_z_bin(-1.0), 0, "thrust_z -1.0 -> bin 0")
assert_eq(NetCodec.quantize_thrust_z_bin(1.0), NetCodec.THRUST_Z_BIN_MAX, "thrust_z 1.0 -> max bin")
assert_almost_eq(NetCodec.dequantize_thrust_z_bin(0), -1.0, 0.001, "bin 0 -> -1.0")
assert_almost_eq(NetCodec.dequantize_thrust_z_bin(NetCodec.THRUST_Z_BIN_MAX), 1.0, 0.001, "max bin -> 1.0")
func test_ball_angular_velocity_rescale() -> void:
var ball := NetBodyState.new()
ball.avel_range = NetCodec.BALL_AVEL_RANGE
ball.angular_velocity = Vector3(20.0, -15.0, 5.0) # within ±32 rad/s, outside ship's ±4
var segment := NetCodec.pack_snapshot_body_segment(0, 0, 0, [ball])
var decoded := NetCodec.unpack_snapshot(NetCodec.pack_snapshot(0, 0, 0, segment))
var out: NetBodyState = decoded["bodies"][0]
# Decoded at the wrong (ship) range first, per unpack_snapshot's documented contract.
assert_almost_eq(out.angular_velocity.x, 20.0 / NetCodec.BALL_AVEL_RANGE * NetCodec.SHIP_AVEL_RANGE, AVEL_TOL, "undecoded-scale sanity check")
NetCodec.rescale_avel(out, NetCodec.BALL_AVEL_RANGE)
assert_almost_eq(out.angular_velocity.x, 20.0, AVEL_TOL, "rescaled avel.x")
assert_almost_eq(out.angular_velocity.y, -15.0, AVEL_TOL, "rescaled avel.y")
assert_almost_eq(out.angular_velocity.z, 5.0, AVEL_TOL, "rescaled avel.z")
func test_type_version_byte_roundtrip() -> void:
var tv := NetCodec.type_version_byte(NetCodec.PacketType.SNAPSHOT)
assert_eq(NetCodec.packet_type_of(tv), NetCodec.PacketType.SNAPSHOT, "packet type nibble")
assert_eq(NetCodec.protocol_version_of(tv), NetCodec.PROTOCOL_VERSION, "protocol version nibble")
func test_tick_hz_derives_from_sim_constants() -> void:
var SimConstants = preload("res://scripts/sim_constants.gd")
assert_eq(NetCodec.TICK_HZ, SimConstants.TICK_HZ, "NetCodec.TICK_HZ must track SimConstants.TICK_HZ")
+12
View File
@@ -0,0 +1,12 @@
extends "res://tests/test_case.gd"
# Proves the runner itself works: discovery, dispatch, pass/fail aggregation.
func test_true_is_true() -> void:
assert_true(true, "true should be true")
func test_addition() -> void:
assert_eq(2 + 2, 4, "2 + 2")
func test_almost_eq_tolerance() -> void:
assert_almost_eq(1.0001, 1.0, 0.001, "1.0001 within 0.001 of 1.0")
+148
View File
@@ -0,0 +1,148 @@
extends Node
# Manual two-process smoke test for NetworkManager's clock (task 1.8
# acceptance: "offset converges within 2s and stays within ±1 tick on a
# clean link"). Not part of tests/test_runner.tscn — needs real ENet peers
# and real wall-clock ping/pong cadence. Run:
#
# godot --headless --path Game res://tests/clock_smoke.tscn -- --role=host
# godot --headless --path Game res://tests/clock_smoke.tscn -- --role=client
#
# Adversarial-review regression: the original version only checked that
# later samples agreed with the first one (self-consistency) — a
# consistently-wrong offset (e.g. a missing /2 on RTT, or a sign flip)
# would converge just as cleanly and still pass. Both roles now also write/
# read an independent ground truth: each process's own OS wall-clock
# (Time.get_unix_time_from_system(), shared hardware clock, same machine)
# lets it compute "my Time.get_ticks_msec() minus real epoch time" — the
# TRUE required offset is just the difference of those two numbers between
# host and client, computed via a shared temp file since the two processes
# can't otherwise see each other's local variables. This is independent of
# NetworkManager's own ping/pong math entirely.
const PORT := 7801
const RUN_SECONDS := 6.0
const CONVERGE_BY_SEC := 2.0
const TICK_MS := 1000.0 / 60.0 # SimConstants.TICK_HZ, kept literal to avoid pulling in the whole project for one constant in a throwaway diagnostic
const EPOCH_FILE := "/tmp/cosmicclash_clock_smoke_epoch_offset.txt"
# Ground-truth tolerance is looser than the ±1-tick self-consistency check:
# Time.get_unix_time_from_system() itself is only second-resolution on some
# platforms and the two processes sample it at slightly different instants,
# so this bounds "is the offset even the right ballpark and sign" rather
# than chasing sub-tick precision the way the self-consistency check does.
const GROUND_TRUTH_TOLERANCE_MS := 250.0
var _role := ""
var _start_ms := 0
var _samples: Array[Dictionary] = [] # {t_sec, offset}
var _finished := false
func _epoch_offset_ms() -> float:
return Time.get_unix_time_from_system() * 1000.0 - float(Time.get_ticks_msec())
func _ready() -> void:
for arg in OS.get_cmdline_user_args():
if arg.begins_with("--role="):
_role = arg.substr("--role=".length())
match _role:
"host":
var err := NetworkManager.host(PORT)
if err != OK:
_finish(false, "host() failed: %s" % error_string(err))
return
var f := FileAccess.open(EPOCH_FILE, FileAccess.WRITE)
if f:
f.store_string(str(_epoch_offset_ms()))
f.close()
print("SMOKE: hosting on port %d" % PORT)
"client":
NetworkManager.clock_updated.connect(_on_clock_updated)
var err := NetworkManager.join("127.0.0.1", PORT)
if err != OK:
_finish(false, "join() failed: %s" % error_string(err))
return
print("SMOKE: joining ...")
_:
_finish(false, "missing or unrecognised --role=")
return
_start_ms = Time.get_ticks_msec()
get_tree().create_timer(RUN_SECONDS).timeout.connect(_on_run_complete)
func _process(_delta: float) -> void:
NetworkManager.poll()
func _physics_process(_delta: float) -> void:
NetworkManager.poll()
func _on_clock_updated(rtt_ms: float, offset_ms: float) -> void:
var t_sec := float(Time.get_ticks_msec() - _start_ms) / 1000.0
_samples.append({"t_sec": t_sec, "offset": offset_ms})
print("SMOKE clock sample t=%.2fs rtt=%.2fms offset=%.2fms" % [t_sec, rtt_ms, offset_ms])
func _on_run_complete() -> void:
if _role != "client":
_finish(true, "host ran for %.1fs" % RUN_SECONDS)
return
if _samples.is_empty():
_finish(false, "no clock samples received at all")
return
var converged_sample: Dictionary = {}
for s: Dictionary in _samples:
if s["t_sec"] <= CONVERGE_BY_SEC:
converged_sample = s
if converged_sample.is_empty():
_finish(false, "no sample landed by t=%.1fs (first sample at t=%.2fs)" % [CONVERGE_BY_SEC, _samples[0]["t_sec"]])
return
var reference: float = converged_sample["offset"]
var max_drift := 0.0
for s: Dictionary in _samples:
if s["t_sec"] < CONVERGE_BY_SEC:
continue
max_drift = maxf(max_drift, absf(s["offset"] - reference))
if max_drift > TICK_MS:
_finish(false, "offset drifted %.2fms after t=%.1fs (> 1 tick = %.2fms)" % [max_drift, CONVERGE_BY_SEC, TICK_MS])
return
# Self-consistency alone can't catch a systematically-wrong-but-stable
# offset (§9 gotcha, adversarial review) — cross-check against the OS
# wall clock, independent of NetworkManager's own math entirely.
if not FileAccess.file_exists(EPOCH_FILE):
_finish(false, "converged (%.2fms, drift %.2fms) but host's epoch-offset file was never found — ground truth unavailable" % [reference, max_drift])
return
var f := FileAccess.open(EPOCH_FILE, FileAccess.READ)
var server_epoch_offset := f.get_as_text().to_float()
f.close()
var client_epoch_offset := _epoch_offset_ms()
var true_offset := client_epoch_offset - server_epoch_offset
var ground_truth_error := absf(reference - true_offset)
if ground_truth_error > GROUND_TRUTH_TOLERANCE_MS:
_finish(false, "converged (%.2fms) but disagrees with OS-clock ground truth (%.2fms) by %.2fms (> %.1fms tolerance) — the offset math itself may be wrong, not just noisy" % [
reference, true_offset, ground_truth_error, GROUND_TRUTH_TOLERANCE_MS
])
else:
_finish(true, "offset converged by t=%.1fs (%.2fms), stayed within %.2fms (<= 1 tick = %.2fms) for the rest of the run across %d samples, AND agrees with independent OS-clock ground truth (%.2fms, error %.2fms <= %.1fms tolerance)" % [
CONVERGE_BY_SEC, reference, max_drift, TICK_MS, _samples.size(), true_offset, ground_truth_error, GROUND_TRUTH_TOLERANCE_MS
])
func _finish(success: bool, message: String) -> void:
if _finished:
return
_finished = true
print("SMOKE %s: %s" % ["PASS" if success else "FAIL", message])
await get_tree().create_timer(0.3).timeout
NetworkManager.shutdown()
get_tree().quit(0 if success else 1)
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/clock_smoke.gd" id="1_cs"]
[node name="ClockSmoke" type="Node"]
script = ExtResource("1_cs")
+79
View File
@@ -0,0 +1,79 @@
extends Node
# Manual two-process smoke test for lobby.tscn (task 1.5). BOTH roles load
# lobby.tscn as their actual current_scene via change_scene_to_file —
# matching how main_menu.gd's Host/Join flow (task 1.7) really gets a
# player there — rather than instantiating it as a child of this driver.
# That distinction matters: change_scene_to_file() operates on
# get_tree().current_scene, and calling it from a node that ISN'T an
# ancestor-chain match for current_scene (as an earlier draft of this test
# did, by add_child()-ing lobby.tscn under this driver) hung completely
# on disconnect — see multiplayer-todo.md §9 gotcha 27.
#
# The host role loading lobby.tscn is deliberate, not an oversight: a
# *dedicated* server (server_boot.tscn) never loads it, but a self-hosting
# player clicking main_menu.gd's Host button does — NetworkManager.host()
# then _leave_to_lobby(), landing them on lobby.gd's is_server branch (a
# read-only view of the roster, no team/ready controls). An earlier
# version of this test skipped that branch entirely on the mistaken
# assumption that "host" here meant "dedicated server"; adversarial review
# caught that it left a real, production-reachable code path untested.
#
# Not part of tests/test_runner.tscn — needs real ENet peers. Run:
#
# godot --headless --path Game res://tests/lobby_smoke.tscn -- --role=host
# godot --headless --path Game res://tests/lobby_smoke.tscn -- --role=client
const PORT := 7806
var _role := ""
func _ready() -> void:
for arg in OS.get_cmdline_user_args():
if arg.begins_with("--role="):
_role = arg.substr("--role=".length())
match _role:
"host":
var err := NetworkManager.host(PORT)
if err != OK:
print("SMOKE FAIL: host() failed: %s" % error_string(err))
get_tree().quit(1)
return
print("SMOKE: hosting on port %d" % PORT)
get_tree().change_scene_to_file.call_deferred("res://scenes/lobby.tscn")
var host_hooks := preload("res://tests/lobby_test_hooks.gd").new()
get_tree().root.add_child.call_deferred(host_hooks)
host_hooks.run_host_test.call_deferred()
"client":
MatchNet.local_player_name = "Carol"
var err := NetworkManager.join("127.0.0.1", PORT)
if err != OK:
print("SMOKE FAIL: join() failed: %s" % error_string(err))
get_tree().quit(1)
return
# Real usage (task 1.7): main_menu.gd will call this same
# change_scene_to_file after NetworkManager.join() succeeds — but
# not from this test's own _ready(), which the tree is still in
# the middle of processing (Godot rejects a synchronous
# change_scene_to_file mid node-add with "Parent node is busy").
# call_deferred sidesteps that; main_menu.gd's real button-press
# handler won't have this problem since it isn't called from
# inside _ready().
get_tree().change_scene_to_file.call_deferred("res://scenes/lobby.tscn")
var hooks := preload("res://tests/lobby_test_hooks.gd").new()
get_tree().root.add_child.call_deferred(hooks) # sibling of current_scene, not a child of it -- survives the swap above
hooks.run_client_test.call_deferred()
_:
print("SMOKE FAIL: missing or unrecognised --role=")
get_tree().quit(1)
return
func _process(_delta: float) -> void:
NetworkManager.poll()
func _physics_process(_delta: float) -> void:
NetworkManager.poll()
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/lobby_smoke.gd" id="1_ls"]
[node name="LobbySmoke" type="Node"]
script = ExtResource("1_ls")
+126
View File
@@ -0,0 +1,126 @@
extends Node
# Test-only helper (tests/lobby_smoke.gd). Not a project autoload —
# production code never references this. Exists because lobby.tscn is
# loaded via change_scene_to_file() in the real flow (matching
# main_menu.gd's future Host/Join UI), which frees whatever node initiated
# the load — a test driver can't keep orchestrating from a node that just
# got freed. The driver instead add_child()s this directly under
# get_tree().root (a sibling of current_scene, not a descendant of it), so
# it survives the scene swap and can drive the check from outside lobby.gd,
# which stays untouched by test concerns.
signal finished(success: bool, message: String)
const SETTLE_SECONDS := 3.0
const AFTER_PRESS_SECONDS := 1.0
# The host process starts ~1.5s before the client (see the shell
# invocation in both roles' header comments) and the client doesn't finish
# its own SETTLE_SECONDS + AFTER_PRESS_SECONDS flow (plus its own 0.3s
# _finish delay) until roughly 1.5 + 3.0 + 1.0 + 0.3 ≈ 5.8s into the
# host's own timeline. Verifying and quitting the instant the host's own
# checks pass (~2-2.5s in) would drop the connection out from under the
# client mid-flow. Print the result as soon as it's known, but hold the
# actual quit() open past the client's expected finish time.
const MIN_HOST_LIFETIME_SECONDS := 7.0
# Adversarial-review regression: the host role in lobby_smoke.gd used to
# never load lobby.tscn at all, so lobby.gd's is_server branch (the
# read-only view main_menu.gd's own Host button routes a self-hosting
# player into) had never actually run under this task's own test suite —
# only main_menu_test_hooks.gd's separate, non-permanent task-1.7 test had
# exercised it. This closes that gap for good.
func run_host_test() -> void:
var start_ms := Time.get_ticks_msec()
var deadline := start_ms + int((SETTLE_SECONDS + 5.0) * 1000.0)
while MatchNet.roster.is_empty() and Time.get_ticks_msec() < deadline:
await get_tree().process_frame
var lobby := get_tree().current_scene
if lobby == null or not lobby.has_method("_refresh"):
await _finish_and_quit(false, "current_scene is not the lobby scene", start_ms)
return
if MatchNet.roster.is_empty():
await _finish_and_quit(false, "no client joined before timeout", start_ms)
return
# Give _refresh() a beat to process the player_joined signal it just got.
await get_tree().create_timer(0.3).timeout
var controls_row: Control = lobby.get_node("%ControlsRow")
var team0: VBoxContainer = lobby.get_node("%Team0List")
var team1: VBoxContainer = lobby.get_node("%Team1List")
var status: Label = lobby.get_node("%StatusLabel")
var total_rows := team0.get_child_count() + team1.get_child_count()
# The server is never a roster member (§1.1 decision 2) — its own
# lobby.gd instance must show a read-only view, no team/ready controls.
var controls_hidden := not controls_row.visible
var rows_match_roster := total_rows == MatchNet.roster.size()
var status_ok := status.text.begins_with("Hosting")
var success := controls_hidden and rows_match_roster and status_ok
await _finish_and_quit(success, "controls_hidden=%s rows=%d roster=%d status='%s'" % [
str(controls_hidden), total_rows, MatchNet.roster.size(), status.text
], start_ms)
func _finish_and_quit(success: bool, message: String, start_ms: int) -> void:
finished.emit(success, message)
print("SMOKE %s: %s" % ["PASS" if success else "FAIL", message])
var elapsed_sec := float(Time.get_ticks_msec() - start_ms) / 1000.0
var remaining := MIN_HOST_LIFETIME_SECONDS - elapsed_sec
if remaining > 0.0:
await get_tree().create_timer(remaining).timeout
NetworkManager.shutdown()
get_tree().quit(0 if success else 1)
func run_client_test() -> void:
await get_tree().create_timer(SETTLE_SECONDS).timeout
var lobby := get_tree().current_scene
if lobby == null or not lobby.has_method("_refresh"):
finished.emit(false, "current_scene is not the lobby scene")
return
var switch_btn: Button = lobby.get_node("%SwitchTeamButton")
var ready_btn: CheckButton = lobby.get_node("%ReadyButton")
switch_btn.emit_signal("pressed")
ready_btn.button_pressed = true
ready_btn.emit_signal("toggled", true)
await get_tree().create_timer(AFTER_PRESS_SECONDS).timeout
var team0: VBoxContainer = lobby.get_node("%Team0List")
var team1: VBoxContainer = lobby.get_node("%Team1List")
var status: Label = lobby.get_node("%StatusLabel")
var total_rows := team0.get_child_count() + team1.get_child_count()
var my_id := multiplayer.get_unique_id()
var info: MatchNet.PlayerInfo = MatchNet.roster.get(my_id)
var info_str := "roster_size=%d team0_rows=%d team1_rows=%d status='%s'" % [
MatchNet.roster.size(), team0.get_child_count(), team1.get_child_count(), status.text
]
var team_ready_ok := false
if info != null:
info_str += " my_team=%d my_ready=%s" % [info.team, str(info.ready)]
# Started on whatever team balancing picked (0, since first
# joiner), pressed Switch Team once -> should now be on team 1,
# and pressed Ready -> should be true.
team_ready_ok = info.team == 1 and info.ready == true
print("SMOKE INFO: " + info_str)
var rows_match_roster := total_rows == MatchNet.roster.size()
var success := rows_match_roster and team_ready_ok
var message := "rows=%d roster=%d team_ready_ok=%s" % [total_rows, MatchNet.roster.size(), str(team_ready_ok)]
finished.emit(success, message)
# The driver that called run_client_test() is gone by now — it was
# get_tree().current_scene before the change_scene_to_file() that
# loaded the lobby, so it got freed in the swap, taking its signal
# connection to `finished` down with it (Godot auto-disconnects when
# either end of a connection is freed). This autoload outlives that
# swap, so it's what actually ends the process.
print("SMOKE %s: %s" % ["PASS" if success else "FAIL", message])
get_tree().quit(0 if success else 1)
+114
View File
@@ -0,0 +1,114 @@
extends Node
# Test-only helper (tests/main_menu_smoke.gd). Not referenced by production
# code. Registered as a temporary project autoload only while running this
# test — see the test's own header for why an autoload (rather than a
# scene-child driver) is needed: main_menu.tscn IS the real current_scene
# here (run directly via --path Game res://scenes/main_menu.tscn, exactly
# like production), so unlike lobby_smoke.gd's driver this doesn't even
# need to survive a scene swap — it just needs to exist independently of
# main_menu.gd so main_menu.gd itself stays untouched by test concerns.
var _role := ""
func _ready() -> void:
for arg in OS.get_cmdline_user_args():
if arg.begins_with("--role="):
_role = arg.substr("--role=".length())
if _role.is_empty():
return
# main_menu.gd's own _ready() (which builds %-unique-name refs) must run
# before we touch its nodes; autoloads run first, so wait a frame.
await get_tree().process_frame
await get_tree().process_frame
match _role:
"host":
_run_host()
"join_ok":
_run_join_ok()
"join_refused":
_run_join_refused()
"join_cancel":
_run_join_cancel()
func _run_host() -> void:
var menu := get_tree().current_scene
var host_btn: Button = menu.get_node("CenterContainer/VBoxContainer/HostButton")
host_btn.emit_signal("pressed")
await get_tree().create_timer(1.0).timeout
var scene := get_tree().current_scene
var ok := scene != null and scene.scene_file_path == "res://scenes/lobby.tscn"
print("SMOKE %s: host -> current_scene=%s" % ["PASS" if ok else "FAIL", scene.scene_file_path if scene else "null"])
if not ok:
_finish(false, "host transition check failed")
return
# Stay up long enough for a separate join_ok/join_refused/join_cancel
# process (started after this one) to actually exercise the host —
# unlike _finish()'s normal 0.3s beat, this test's whole point is being
# a live target for a while.
await get_tree().create_timer(6.0).timeout
_finish(true, "host ran and stayed up for a joiner")
func _run_join_ok() -> void:
# Give the host process (started first by the shell script) time to be listening.
await get_tree().create_timer(1.5).timeout
var menu := get_tree().current_scene
var address_edit: LineEdit = menu.get_node("%JoinAddressEdit")
address_edit.text = "127.0.0.1"
var join_btn: Button = menu.get_node("CenterContainer/VBoxContainer/JoinRow/JoinButton")
join_btn.emit_signal("pressed")
var overlay: Control = menu.get_node("%ConnectingOverlay")
print("SMOKE INFO: overlay visible right after Join press = %s" % str(overlay.visible))
await get_tree().create_timer(2.0).timeout
var scene := get_tree().current_scene
var ok := scene != null and scene.scene_file_path == "res://scenes/lobby.tscn"
_finish(ok, "join_ok -> current_scene=%s" % (scene.scene_file_path if scene else "null"))
func _run_join_refused() -> void:
var menu := get_tree().current_scene
var address_edit: LineEdit = menu.get_node("%JoinAddressEdit")
address_edit.text = "127.0.0.1"
var join_btn: Button = menu.get_node("CenterContainer/VBoxContainer/JoinRow/JoinButton")
join_btn.emit_signal("pressed")
var overlay: Control = menu.get_node("%ConnectingOverlay")
print("SMOKE INFO: overlay visible right after Join press (no server) = %s" % str(overlay.visible))
# main_menu.gd's own CONNECT_TIMEOUT_SECONDS (6.0) is what actually
# bounds this now — ENet's own connection_failed proved unbounded in
# practice against a genuinely refused loopback connection.
await get_tree().create_timer(8.0).timeout
var error_label: Label = menu.get_node("%MultiplayerErrorLabel")
var still_on_menu := get_tree().current_scene == menu
var ok := still_on_menu and not overlay.visible and error_label.visible
_finish(ok, "join_refused -> still_on_menu=%s overlay_visible=%s error_visible=%s error_text='%s'" % [
str(still_on_menu), str(overlay.visible), str(error_label.visible), error_label.text
])
func _run_join_cancel() -> void:
var menu := get_tree().current_scene
var address_edit: LineEdit = menu.get_node("%JoinAddressEdit")
address_edit.text = "10.255.255.1" # non-routable; connect attempt just hangs until timeout/cancel
var join_btn: Button = menu.get_node("CenterContainer/VBoxContainer/JoinRow/JoinButton")
join_btn.emit_signal("pressed")
var overlay: Control = menu.get_node("%ConnectingOverlay")
await get_tree().create_timer(0.5).timeout
var overlay_shown := overlay.visible
var cancel_btn: Button = menu.get_node("%ConnectingCancelButton")
cancel_btn.emit_signal("pressed")
await get_tree().create_timer(0.5).timeout
var still_on_menu := get_tree().current_scene == menu
var ok := overlay_shown and not overlay.visible and still_on_menu and not NetworkManager.is_client
_finish(ok, "join_cancel -> overlay_shown=%s overlay_now=%s still_on_menu=%s is_client=%s" % [
str(overlay_shown), str(overlay.visible), str(still_on_menu), str(NetworkManager.is_client)
])
func _finish(success: bool, message: String) -> void:
print("SMOKE %s: %s" % ["PASS" if success else "FAIL", message])
await get_tree().create_timer(0.3).timeout
NetworkManager.shutdown()
get_tree().quit(0 if success else 1)
+167
View File
@@ -0,0 +1,167 @@
extends Node
# Manual two/three-process smoke test for MatchNet (task 1.4 acceptance:
# "a mismatched client is rejected with a readable reason", plus the happy
# path: hello/welcome, roster sees player_joined on both sides). Not part of
# tests/test_runner.tscn — needs real ENet peers. Run:
#
# godot --headless --path Game res://tests/match_net_smoke.tscn -- --role=host
# godot --headless --path Game res://tests/match_net_smoke.tscn -- --role=client --name=Alice
# godot --headless --path Game res://tests/match_net_smoke.tscn -- --role=client-badversion
# godot --headless --path Game res://tests/match_net_smoke.tscn -- --role=host_recycle
# godot --headless --path Game res://tests/match_net_smoke.tscn -- --role=client --name=Bob (run once against host_recycle, then let it disconnect)
const PORT := 7800
const TIMEOUT_SECONDS := 5.0
var _role := ""
var _player_name := "TestPlayer"
var _finished := false
func _ready() -> void:
for arg in OS.get_cmdline_user_args():
if arg.begins_with("--role="):
_role = arg.substr("--role=".length())
elif arg.begins_with("--name="):
_player_name = arg.substr("--name=".length())
match _role:
"host":
MatchNet.player_joined.connect(_on_player_joined)
var err := NetworkManager.host(PORT)
if err != OK:
_finish(false, "host() failed: %s" % error_string(err))
return
print("SMOKE: hosting on port %d" % PORT)
"host_recycle":
_run_host_recycle()
return
"client":
MatchNet.local_player_name = _player_name
MatchNet.welcomed.connect(_on_welcomed)
MatchNet.rejected.connect(_on_rejected)
var err := NetworkManager.join("127.0.0.1", PORT)
if err != OK:
_finish(false, "join() failed: %s" % error_string(err))
return
print("SMOKE: joining as '%s' ..." % _player_name)
"client-badversion":
MatchNet._auto_hello = false
MatchNet.rejected.connect(_on_rejected)
MatchNet.welcomed.connect(_on_welcomed)
var err := NetworkManager.join("127.0.0.1", PORT)
if err != OK:
_finish(false, "join() failed: %s" % error_string(err))
return
NetworkManager.connected_to_server.connect(func():
var NetCodec = load("res://scripts/net_codec.gd")
MatchNet._hello.rpc_id(1, NetCodec.PROTOCOL_VERSION + 99, 60, "BadVersion")
)
print("SMOKE: joining with a deliberately wrong protocol version ...")
"client-longname":
# Adversarial-review regression: a client sending an oversized
# player_name used to be broadcast verbatim to every peer,
# head-of-line-blocking the reliable channel. Confirm it's
# rejected outright before ever reaching a broadcast.
MatchNet._auto_hello = false
MatchNet.rejected.connect(_on_rejected)
MatchNet.welcomed.connect(_on_welcomed)
var err := NetworkManager.join("127.0.0.1", PORT)
if err != OK:
_finish(false, "join() failed: %s" % error_string(err))
return
NetworkManager.connected_to_server.connect(func():
var NetCodec = load("res://scripts/net_codec.gd")
var SimConstants = load("res://scripts/sim_constants.gd")
var huge_name := "X".repeat(500000) # 500 KB, well past MAX_INPUT_LENGTH
MatchNet._hello.rpc_id(1, NetCodec.PROTOCOL_VERSION, SimConstants.TICK_HZ, huge_name)
)
print("SMOKE: joining with a deliberately oversized player name ...")
_:
_finish(false, "missing or unrecognised --role=")
return
get_tree().create_timer(TIMEOUT_SECONDS).timeout.connect(_on_timeout)
func _process(_delta: float) -> void:
NetworkManager.poll()
func _physics_process(_delta: float) -> void:
NetworkManager.poll()
func _on_player_joined(peer_id: int, player_name: String) -> void:
_finish(true, "host saw player_joined (peer_id=%d, name=%s)" % [peer_id, player_name])
# Adversarial-review regression (multiplayer-todo.md §9): MatchNet.roster
# used to have no path that cleared it when a HOST itself called
# NetworkManager.shutdown() — only the client-side disconnect signal did.
# Host -> client joins -> host leaves (shutdown) -> host again used to
# leave the first client permanently in roster. Run this role, then run
# `--role=client` once against it while it's up.
var _host_recycle_joined := false
func _on_host_recycle_player_joined(_peer_id: int, _name: String) -> void:
_host_recycle_joined = true
func _run_host_recycle() -> void:
MatchNet.player_joined.connect(_on_host_recycle_player_joined)
var err := NetworkManager.host(PORT)
if err != OK:
_finish(false, "host() failed: %s" % error_string(err))
return
print("SMOKE: host_recycle hosting on port %d, waiting for a client..." % PORT)
var deadline := Time.get_ticks_msec() + int(TIMEOUT_SECONDS * 1000.0)
while not _host_recycle_joined and Time.get_ticks_msec() < deadline:
await get_tree().process_frame
if not _host_recycle_joined:
_finish(false, "no client joined within %.1fs" % TIMEOUT_SECONDS)
return
print("SMOKE: host_recycle got a joiner (roster size=%d), now leaving and re-hosting..." % MatchNet.roster.size())
NetworkManager.shutdown()
err = NetworkManager.host(PORT)
if err != OK:
_finish(false, "re-host() failed: %s" % error_string(err))
return
await get_tree().process_frame
await get_tree().process_frame
var ok := MatchNet.roster.is_empty()
_finish(ok, "roster after re-host: size=%d (expected 0)" % MatchNet.roster.size())
func _on_welcomed() -> void:
if _role == "client-badversion" or _role == "client-longname":
_finish(false, "%s was welcomed, expected rejection" % _role)
else:
_finish(true, "client was welcomed")
func _on_rejected(reason: String) -> void:
if _role == "client-badversion" or _role == "client-longname":
_finish(true, "%s correctly rejected: %s" % [_role, reason])
else:
_finish(false, "client was rejected unexpectedly: %s" % reason)
func _on_timeout() -> void:
if not _finished:
_finish(false, "timed out")
func _finish(success: bool, message: String) -> void:
if _finished:
return
_finished = true
print("SMOKE %s: %s" % ["PASS" if success else "FAIL", message])
await get_tree().create_timer(0.5).timeout
NetworkManager.shutdown()
get_tree().quit(0 if success else 1)
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/match_net_smoke.gd" id="1_mns"]
[node name="MatchNetSmoke" type="Node"]
script = ExtResource("1_mns")
+112
View File
@@ -0,0 +1,112 @@
extends Node
# Manual two-process smoke test for NetworkManager (task 1.2 acceptance:
# "two peers connect and disconnect cleanly"; also exercises task 1.3's
# manual-poll-only regime — NetworkManager disables automatic multiplayer
# polling, so this script's own _process() polling is what makes the
# connection progress at all). Deliberately not part of the pure-function
# suite in tests/test_runner.tscn — an ENet handshake needs two real
# processes. Run:
#
# godot --headless --path Game res://tests/net_smoke.tscn -- --role=host
# godot --headless --path Game res://tests/net_smoke.tscn -- --role=client
#
# (start the host first). Each process prints one "SMOKE PASS/FAIL: ..."
# line and exits 0/1.
#
# Adversarial-review regression: this test used to only confirm each
# process exits cleanly on its own initiative — it never confirmed the
# OTHER peer actually observes the disconnect. The host role now waits for
# BOTH client_connected and client_disconnected before passing; the client
# explicitly disconnects mid-test (rather than only on process exit) and
# gives it a beat before quitting, same reasoning as §9 gotcha 26 for
# connects: a clean disconnect notice still needs a few poll() cycles to
# reach the wire, or the other side falls back to its ~5s peer timeout
# (§9 gotcha 11) instead of a prompt, clean disconnect.
const DEFAULT_PORT := 7799
const TIMEOUT_SECONDS := 8.0
var _role := ""
var _port := DEFAULT_PORT
var _finished := false
func _ready() -> void:
for arg in OS.get_cmdline_user_args():
if arg.begins_with("--role="):
_role = arg.substr("--role=".length())
elif arg.begins_with("--port="):
_port = int(arg.substr("--port=".length()))
if _role == "host":
NetworkManager.client_connected.connect(_on_host_client_connected)
NetworkManager.client_disconnected.connect(_on_host_client_disconnected)
var err := NetworkManager.host(_port)
if err != OK:
_finish(false, "host() failed: %s" % error_string(err))
return
print("SMOKE: hosting on port %d, waiting for a client..." % _port)
elif _role == "client":
NetworkManager.connected_to_server.connect(_on_client_connected)
NetworkManager.connection_failed.connect(_on_client_connection_failed)
var err := NetworkManager.join("127.0.0.1", _port)
if err != OK:
_finish(false, "join() failed: %s" % error_string(err))
return
print("SMOKE: joining 127.0.0.1:%d ..." % _port)
else:
_finish(false, "missing or unrecognised --role= (expected host|client)")
return
get_tree().create_timer(TIMEOUT_SECONDS).timeout.connect(_on_timeout)
func _process(_delta: float) -> void:
NetworkManager.poll()
func _physics_process(_delta: float) -> void:
NetworkManager.poll()
func _on_host_client_connected(peer_id: int) -> void:
print("SMOKE INFO: host saw client_connected (peer_id=%d), waiting for client_disconnected too..." % peer_id)
func _on_host_client_disconnected(peer_id: int) -> void:
_finish(true, "host saw client_connected AND client_disconnected (peer_id=%d)" % peer_id)
func _on_client_connected() -> void:
print("SMOKE INFO: client connected to host")
# §9 gotcha 26: give the host a beat to fully settle the connect
# handshake before we turn around and disconnect again.
await get_tree().create_timer(0.5).timeout
NetworkManager.shutdown()
# Same class of issue as gotcha 26, the disconnect leg: closing the
# peer queues ENet's own disconnect notice, which still needs a few
# more poll() cycles to actually reach the wire before this process
# exits — quit immediately and the host would fall back to its ~5s
# peer timeout (§9 gotcha 11) instead of a prompt, clean disconnect.
await get_tree().create_timer(1.0).timeout
_finish(true, "client connected then disconnected cleanly")
func _on_client_connection_failed() -> void:
_finish(false, "client connection_failed")
func _on_timeout() -> void:
if not _finished:
_finish(false, "timed out waiting for connect+disconnect confirmation")
func _finish(success: bool, message: String) -> void:
if _finished:
return
_finished = true
print("SMOKE %s: %s" % ["PASS" if success else "FAIL", message])
await get_tree().create_timer(0.3).timeout
NetworkManager.shutdown()
get_tree().quit(0 if success else 1)
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/net_smoke.gd" id="1_ns"]
[node name="NetSmoke" type="Node"]
script = ExtResource("1_ns")
+38
View File
@@ -0,0 +1,38 @@
extends RefCounted
# Base class for pure-function unit tests run by test_runner.gd. A test case
# script extends this and defines any number of test_*() methods; the runner
# discovers them by name, not by registration, so adding a test is just
# adding a method.
#
# Test case scripts should `extends "res://tests/test_case.gd"` (path-based),
# not `extends TestCase` (the bare class_name). On a fresh headless run the
# global script class cache isn't guaranteed populated yet, so a bare-name
# reference can fail to resolve; the path form and test_runner.gd's own
# `preload()` both sidestep that.
var failures: Array[String] = []
# Adversarial-review regression: GDScript has no exceptions, so a runtime
# error partway through a test method (e.g. a null dereference) just logs a
# SCRIPT ERROR and returns — `failures` stays empty exactly as if every
# assertion had passed, and the runner counted it as a PASS. assertions_made
# is incremented by every assert_* call; test_runner.gd now treats a test
# that completes with zero assertions as a failure in its own right, so a
# test that crashes before reaching its first assert_* can no longer read
# as a silent pass.
var assertions_made := 0
func assert_true(condition: bool, message: String) -> void:
assertions_made += 1
if not condition:
failures.append(message)
func assert_eq(actual, expected, message: String) -> void:
assertions_made += 1
if actual != expected:
failures.append("%s: expected %s, got %s" % [message, expected, actual])
func assert_almost_eq(actual: float, expected: float, tolerance: float, message: String) -> void:
assertions_made += 1
if absf(actual - expected) > tolerance:
failures.append("%s: expected %s ± %s, got %s" % [message, expected, tolerance, actual])
+81
View File
@@ -0,0 +1,81 @@
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
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/test_runner.gd" id="1_tr"]
[node name="TestRunner" type="Node"]
script = ExtResource("1_tr")