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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user