From 4af3dd2607ff7b8bc00eb01e2ed64b9da2635762 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Tue, 21 Jul 2026 19:49:42 +0200 Subject: [PATCH] =?UTF-8?q?test(client):=20T-1146=20=E2=80=94=20full-suite?= =?UTF-8?q?=20isolation=20for=20the=20occlusion-gate=20suites?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- client/tests/test_input_gate_live.gd | 78 ++++++++++++++++++- .../tests/test_input_mapper_occlusion_gate.gd | 13 +++- 2 files changed, 87 insertions(+), 4 deletions(-) diff --git a/client/tests/test_input_gate_live.gd b/client/tests/test_input_gate_live.gd index bd8c7ce58..c2c5b1b45 100644 --- a/client/tests/test_input_gate_live.gd +++ b/client/tests/test_input_gate_live.gd @@ -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) diff --git a/client/tests/test_input_mapper_occlusion_gate.gd b/client/tests/test_input_mapper_occlusion_gate.gd index aa51e29e0..f53082ed0 100644 --- a/client/tests/test_input_mapper_occlusion_gate.gd +++ b/client/tests/test_input_mapper_occlusion_gate.gd @@ -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.