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 _server_pid: int = -1
|
||||||
var _test_port: int = 0
|
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
|
# Autoload state to restore in after_test() — SimBridge/InputMapper/HudGroups
|
||||||
# are persistent singletons shared with every other suite in the run.
|
# are persistent singletons shared with every other suite in the run.
|
||||||
var _saved_test_mode: bool = true
|
var _saved_test_mode: bool = true
|
||||||
@@ -35,6 +44,7 @@ var _saved_state: int = 0
|
|||||||
var _saved_bridge = null
|
var _saved_bridge = null
|
||||||
var _saved_server_path: String = ""
|
var _saved_server_path: String = ""
|
||||||
var _saved_server_port: int = 9876
|
var _saved_server_port: int = 9876
|
||||||
|
var _saved_current_tick: int = 0
|
||||||
|
|
||||||
|
|
||||||
func _server_binary_path() -> String:
|
func _server_binary_path() -> String:
|
||||||
@@ -71,7 +81,36 @@ func before_test() -> void:
|
|||||||
_saved_bridge = SimBridge._bridge
|
_saved_bridge = SimBridge._bridge
|
||||||
_saved_server_path = SimBridge.server_path
|
_saved_server_path = SimBridge.server_path
|
||||||
_saved_server_port = SimBridge.server_port
|
_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.input_queue.clear()
|
||||||
|
InputMapper._last_sent_octant = InputMapper.facing_octant
|
||||||
InputMapper._gameplay_occluded = false
|
InputMapper._gameplay_occluded = false
|
||||||
InputMapper._suppress_move_until_release = false
|
InputMapper._suppress_move_until_release = false
|
||||||
_release_all_movement_keys()
|
_release_all_movement_keys()
|
||||||
@@ -84,12 +123,20 @@ func after_test() -> void:
|
|||||||
OS.kill(_server_pid)
|
OS.kill(_server_pid)
|
||||||
_server_pid = -1
|
_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.test_mode = _saved_test_mode
|
||||||
SimBridge.state = _saved_state
|
SimBridge.state = _saved_state
|
||||||
SimBridge._bridge = _saved_bridge
|
SimBridge._bridge = _saved_bridge
|
||||||
SimBridge.server_path = _saved_server_path
|
SimBridge.server_path = _saved_server_path
|
||||||
SimBridge.server_port = _saved_server_port
|
SimBridge.server_port = _saved_server_port
|
||||||
SimBridge.reset_test_state()
|
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.input_queue.clear()
|
||||||
InputMapper._gameplay_occluded = false
|
InputMapper._gameplay_occluded = false
|
||||||
@@ -124,6 +171,8 @@ func _connect_live(server_path: String) -> bool:
|
|||||||
SimBridge.server_port = _test_port
|
SimBridge.server_port = _test_port
|
||||||
SimBridge._bridge = null
|
SimBridge._bridge = null
|
||||||
SimBridge.state = SimBridge.ConnectionState.DISCONNECTED
|
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()
|
SimBridge.connect_to_sim()
|
||||||
|
|
||||||
var deadline_ms: int = Time.get_ticks_msec() + int(CONNECT_TIMEOUT * 1000.0) * 2
|
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 {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
## Poll SimBridge.poll_snapshot() until one arrives (or the timeout elapses).
|
## Wait for a snapshot (or the timeout). Two arrival paths, both required for
|
||||||
## Returns the last-seen snapshot Dictionary, or {} on timeout.
|
## 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:
|
func _await_next_snapshot(timeout: float = RESPONSE_TIMEOUT) -> Dictionary:
|
||||||
var deadline_ms: int = Time.get_ticks_msec() + int(timeout * 1000.0)
|
var deadline_ms: int = Time.get_ticks_msec() + int(timeout * 1000.0)
|
||||||
while Time.get_ticks_msec() < deadline_ms:
|
while Time.get_ticks_msec() < deadline_ms:
|
||||||
|
SimBridge._process(0.016)
|
||||||
var snap: Variant = SimBridge.poll_snapshot()
|
var snap: Variant = SimBridge.poll_snapshot()
|
||||||
|
if snap == null and _captured_snapshot != null:
|
||||||
|
snap = _captured_snapshot
|
||||||
|
_captured_snapshot = null
|
||||||
if snap != null:
|
if snap != null:
|
||||||
return snap
|
return snap
|
||||||
await get_tree().process_frame
|
await get_tree().process_frame
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
func _on_snapshot_received(snapshot: Variant) -> void:
|
||||||
|
_captured_snapshot = snapshot
|
||||||
|
|
||||||
|
|
||||||
## Mirrors main.gd's dispatch loop exactly for the actions this test exercises
|
## Mirrors main.gd's dispatch loop exactly for the actions this test exercises
|
||||||
## (movement + SET_FACING): flush InputMapper's queue and forward each entry
|
## (movement + SET_FACING): flush InputMapper's queue and forward each entry
|
||||||
## to SimBridge.send_input(). main.gd's client-only continues (BUG_REPORT,
|
## 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(
|
assert_that(start_player.size()).override_failure_message(
|
||||||
"must receive at least one snapshot with a Player entity before testing"
|
"must receive at least one snapshot with a Player entity before testing"
|
||||||
).is_greater(0)
|
).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_x: float = start_player.x
|
||||||
var start_y: float = start_player.y
|
var start_y: float = start_player.y
|
||||||
assert_float(start_x).is_equal_approx(16.5, 0.001)
|
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
|
settle = baseline
|
||||||
var after_hold := _find_player(settle)
|
var after_hold := _find_player(settle)
|
||||||
assert_that(after_hold.size()).is_greater(0)
|
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(
|
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"
|
"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 baseline: Dictionary = await _await_next_snapshot()
|
||||||
var start_player := _find_player(baseline)
|
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_x: float = start_player.x
|
||||||
var start_y: float = start_player.y
|
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
|
settle_still_held = baseline
|
||||||
var still_held_player := _find_player(settle_still_held)
|
var still_held_player := _find_player(settle_still_held)
|
||||||
assert_that(still_held_player.size()).is_greater(0)
|
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(
|
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)"
|
"player must NOT resume moving while W stays held through the close transition (no re-press yet)"
|
||||||
).is_equal_approx(start_x, 0.001)
|
).is_equal_approx(start_x, 0.001)
|
||||||
|
|||||||
@@ -25,7 +25,17 @@ func before_test() -> void:
|
|||||||
InputMapper._gameplay_occluded = false
|
InputMapper._gameplay_occluded = false
|
||||||
InputMapper._suppress_move_until_release = false
|
InputMapper._suppress_move_until_release = false
|
||||||
InputMapper._last_move_msec = 0
|
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()
|
_release_all_movement_keys()
|
||||||
|
|
||||||
|
|
||||||
@@ -33,6 +43,7 @@ func after_test() -> void:
|
|||||||
InputMapper.input_queue.clear()
|
InputMapper.input_queue.clear()
|
||||||
InputMapper._gameplay_occluded = false
|
InputMapper._gameplay_occluded = false
|
||||||
InputMapper._suppress_move_until_release = false
|
InputMapper._suppress_move_until_release = false
|
||||||
|
InputMapper.facing_angle_provider = Callable()
|
||||||
_release_all_movement_keys()
|
_release_all_movement_keys()
|
||||||
# Restore HudGroups to a clean gameplay state regardless of what a test left
|
# Restore HudGroups to a clean gameplay state regardless of what a test left
|
||||||
# behind — mirrors test_implant_app_lifecycle.gd's after_test convention.
|
# behind — mirrors test_implant_app_lifecycle.gd's after_test convention.
|
||||||
|
|||||||
Reference in New Issue
Block a user