test(client): T-1146 — full-suite isolation for the occlusion-gate suites

Unit suite: pin facing to North via the T-1088 facing_angle_provider seam.
D-054 makes movement facing-relative, and _update_facing_from_mouse() derives
facing from engine/session globals (viewport mouse position vs the
GameState.player_position anchor) — in full-suite order 'press W' resolved to
MOVE_NORTHWEST (the gate failure at line 187).

Live suite, three independent leaks from the shared autoloads, each of which
passed in isolation and failed in gate order:
- snapshot starvation: poll_snapshot() is consume-and-clear; a node leaked
  into the gdUnit tree by an earlier suite steals every snapshot during our
  awaits. Capture snapshot_received (fires on every consume, ours or theirs)
  alongside a direct poll + explicit SimBridge._process pump.
- tick poisoning: SimBridge left test_mode=true + CONNECTED lets the leaked
  poller apply TestHarness mock snapshots (~tick 15+) into
  GameState.current_tick during our spawn awaits; the fresh server's own
  ticks (1..5) then stamp backward and its InputQueue panics ('tick ordering
  violated', last=16 new=3). Neutralize SimBridge before the first await,
  zero current_tick, clear _last_snapshot/_outbound_buffer both directions.
- crash-on-assert-failure: gdUnit asserts don't halt; guard the player-entity
  dereferences so a missing Player reports cleanly instead of a runtime error.

Full suite: 3368/3368.
This commit is contained in:
2026-07-21 19:49:42 +02:00
parent cd52fe8843
commit 4af3dd2607
2 changed files with 87 additions and 4 deletions
+75 -3
View File
@@ -28,6 +28,15 @@ const TEST_APP := "implant/t1146_live_gate_test"
var _server_pid: int = -1
var _test_port: int = 0
# SimBridge.poll_snapshot() is consume-and-clear on a shared autoload: any node
# an earlier suite left in the gdUnit scene tree that polls SimBridge each frame
# (main.tscn instances etc.) steals _last_snapshot during our awaits, so a
# direct poll here can starve forever in full-suite order while passing in
# isolation. poll_snapshot() emits snapshot_received on every consume — ours or
# a thief's — so a signal capture sees every snapshot regardless of who wins
# the poll race. _await_next_snapshot() checks both paths.
var _captured_snapshot: Variant = null
# Autoload state to restore in after_test() — SimBridge/InputMapper/HudGroups
# are persistent singletons shared with every other suite in the run.
var _saved_test_mode: bool = true
@@ -35,6 +44,7 @@ var _saved_state: int = 0
var _saved_bridge = null
var _saved_server_path: String = ""
var _saved_server_port: int = 9876
var _saved_current_tick: int = 0
func _server_binary_path() -> String:
@@ -71,7 +81,36 @@ func before_test() -> void:
_saved_bridge = SimBridge._bridge
_saved_server_path = SimBridge.server_path
_saved_server_port = SimBridge.server_port
# send_input stamps GameState.current_tick into every wire entry, and the
# server's InputQueue panics on non-monotonic ticks ("tick ordering
# violated"). A stale current_tick from an earlier suite's (or the previous
# test's) server — advanced whenever any leaked node applies snapshots —
# would stamp this test's first inputs above the fresh server's tick counter
# and then drop backward once new snapshots apply. Start each test at 0.
# Neutralize SimBridge BEFORE this test's first await (the server-spawn
# timer): earlier suites leave it test_mode=true + CONNECTED, and a node
# leaked into the gdUnit tree polls SimBridge every frame — in that ambient
# state poll_snapshot() serves TestHarness mock snapshots whose ticks (~15+)
# get applied into GameState.current_tick during our awaits, out-running the
# fresh server's own ticks (1..5). send_input stamps current_tick into every
# wire entry and the server's InputQueue panics on non-monotonic ticks
# ("tick ordering violated"), so a single poisoned stamp kills the whole
# connection. Disconnected + live mode makes poll_snapshot() return null for
# the leaked poller until OUR server connects, after which the only applied
# snapshots are its own — monotonic by construction.
SimBridge.test_mode = false
SimBridge.state = SimBridge.ConnectionState.DISCONNECTED
SimBridge._bridge = null
_saved_current_tick = GameState.current_tick
GameState.current_tick = 0
# reset_test_state() clears neither of these, and both poison a fresh
# server connection the same way: a stale _last_snapshot gets consumed and
# applied right after connect, and stale _outbound_buffer entries flush to
# the new server carrying old tick stamps.
SimBridge._last_snapshot = null
SimBridge._outbound_buffer.clear()
InputMapper.input_queue.clear()
InputMapper._last_sent_octant = InputMapper.facing_octant
InputMapper._gameplay_occluded = false
InputMapper._suppress_move_until_release = false
_release_all_movement_keys()
@@ -84,12 +123,20 @@ func after_test() -> void:
OS.kill(_server_pid)
_server_pid = -1
if SimBridge.snapshot_received.is_connected(_on_snapshot_received):
SimBridge.snapshot_received.disconnect(_on_snapshot_received)
_captured_snapshot = null
SimBridge.test_mode = _saved_test_mode
SimBridge.state = _saved_state
SimBridge._bridge = _saved_bridge
SimBridge.server_path = _saved_server_path
SimBridge.server_port = _saved_server_port
SimBridge.reset_test_state()
# Don't let MY servers' snapshots/inputs poison later suites either.
SimBridge._last_snapshot = null
SimBridge._outbound_buffer.clear()
GameState.current_tick = _saved_current_tick
InputMapper.input_queue.clear()
InputMapper._gameplay_occluded = false
@@ -124,6 +171,8 @@ func _connect_live(server_path: String) -> bool:
SimBridge.server_port = _test_port
SimBridge._bridge = null
SimBridge.state = SimBridge.ConnectionState.DISCONNECTED
if not SimBridge.snapshot_received.is_connected(_on_snapshot_received):
SimBridge.snapshot_received.connect(_on_snapshot_received)
SimBridge.connect_to_sim()
var deadline_ms: int = Time.get_ticks_msec() + int(CONNECT_TIMEOUT * 1000.0) * 2
@@ -144,18 +193,31 @@ static func _find_player(snapshot: Dictionary) -> Dictionary:
return {}
## Poll SimBridge.poll_snapshot() until one arrives (or the timeout elapses).
## Returns the last-seen snapshot Dictionary, or {} on timeout.
## Wait for a snapshot (or the timeout). Two arrival paths, both required for
## full-suite robustness (see _captured_snapshot): a direct poll_snapshot()
## (wins when nothing else consumes), and the snapshot_received capture (wins
## when a leaked node from an earlier suite consumes first). The explicit
## SimBridge._process() pump keeps the TCP drain going even if ambient autoload
## processing is starved; double-pumping alongside the ambient call is a no-op
## (poll_message just drains an empty buffer).
func _await_next_snapshot(timeout: float = RESPONSE_TIMEOUT) -> Dictionary:
var deadline_ms: int = Time.get_ticks_msec() + int(timeout * 1000.0)
while Time.get_ticks_msec() < deadline_ms:
SimBridge._process(0.016)
var snap: Variant = SimBridge.poll_snapshot()
if snap == null and _captured_snapshot != null:
snap = _captured_snapshot
_captured_snapshot = null
if snap != null:
return snap
await get_tree().process_frame
return {}
func _on_snapshot_received(snapshot: Variant) -> void:
_captured_snapshot = snapshot
## Mirrors main.gd's dispatch loop exactly for the actions this test exercises
## (movement + SET_FACING): flush InputMapper's queue and forward each entry
## to SimBridge.send_input(). main.gd's client-only continues (BUG_REPORT,
@@ -199,6 +261,8 @@ func test_wasd_held_during_implant_occlusion_does_not_move_player(
assert_that(start_player.size()).override_failure_message(
"must receive at least one snapshot with a Player entity before testing"
).is_greater(0)
if start_player.is_empty():
return # gdUnit asserts don't halt — bail before dereferencing
var start_x: float = start_player.x
var start_y: float = start_player.y
assert_float(start_x).is_equal_approx(16.5, 0.001)
@@ -225,6 +289,8 @@ func test_wasd_held_during_implant_occlusion_does_not_move_player(
settle = baseline
var after_hold := _find_player(settle)
assert_that(after_hold.size()).is_greater(0)
if after_hold.is_empty():
return # gdUnit asserts don't halt — bail before dereferencing
assert_float(after_hold.x).override_failure_message(
(
"player must NOT move while WASD is held during implant occlusion — x drifted from %.2f to %.2f"
@@ -258,7 +324,11 @@ func test_close_while_held_requires_repress_before_movement_resumes_live(
var baseline: Dictionary = await _await_next_snapshot()
var start_player := _find_player(baseline)
assert_that(start_player.size()).is_greater(0)
assert_that(start_player.size()).override_failure_message(
"must receive at least one snapshot with a Player entity before testing"
).is_greater(0)
if start_player.is_empty():
return # gdUnit asserts don't halt — bail before dereferencing
var start_x: float = start_player.x
var start_y: float = start_player.y
@@ -283,6 +353,8 @@ func test_close_while_held_requires_repress_before_movement_resumes_live(
settle_still_held = baseline
var still_held_player := _find_player(settle_still_held)
assert_that(still_held_player.size()).is_greater(0)
if still_held_player.is_empty():
return # gdUnit asserts don't halt — bail before dereferencing
assert_float(still_held_player.x).override_failure_message(
"player must NOT resume moving while W stays held through the close transition (no re-press yet)"
).is_equal_approx(start_x, 0.001)
@@ -25,7 +25,17 @@ func before_test() -> void:
InputMapper._gameplay_occluded = false
InputMapper._suppress_move_until_release = false
InputMapper._last_move_msec = 0
InputMapper._last_sent_octant = InputMapper.facing_octant
# D-054 movement is facing-relative, and _update_facing_from_mouse() derives
# facing from engine/session globals (viewport mouse position vs the
# GameState.player_position screen anchor under the live canvas transform) at
# the top of every _process. Earlier suites move all three, so in full-suite
# order "press W" can resolve to a diagonal (gate run: MOVE_NORTHWEST).
# Pin facing to North through the T-1088 provider seam so poll assertions
# are order-independent.
InputMapper.facing_angle_provider = func() -> float: return -PI / 2.0
InputMapper.facing_angle = -PI / 2.0
InputMapper.facing_octant = "North"
InputMapper._last_sent_octant = "North"
_release_all_movement_keys()
@@ -33,6 +43,7 @@ func after_test() -> void:
InputMapper.input_queue.clear()
InputMapper._gameplay_occluded = false
InputMapper._suppress_move_until_release = false
InputMapper.facing_angle_provider = Callable()
_release_all_movement_keys()
# Restore HudGroups to a clean gameplay state regardless of what a test left
# behind — mirrors test_implant_app_lifecycle.gd's after_test convention.