PR #190 review (Hoshe + Tyre), all five findings addressed: - Tyre: queue_move_step/queue_stance_toggle (the T-1088 click-to-move seams) now share the full suppression predicate — all three input_queue producers in the module are gated, making the header's 'every action this file can produce' claim true (previously the seams relied on the sandbox caller's own freeze). +3 regression tests. - Hoshe: the live suite's settle=baseline timeout fallback compared baseline to itself, vacuously passing the no-movement claim — a timeout now fails loudly (both tests). - Hoshe: the unit suite's real open_app/close_app transitions fire the global auto-pause handler; with ambient SimBridge.state left CONNECTED by earlier suites (test_hub_teleport has no after_test) each transition silently appended AutoPause/AutoResume to the shared _outbound_buffer. before_test now forces DISCONNECTED (restored in after_test) and clears the buffer. - Hoshe: documented why the live suite's reset_test_state() call is inert but still correct. Full suite: 3374/3374.
402 lines
17 KiB
GDScript
402 lines
17 KiB
GDScript
## T-1146: implant-occlusion gate — LIVE tier. Connects the real SimBridge/
|
|
## InputMapper/HudGroups autoloads (not stubs, not TestHarness) to a spawned
|
|
## server binary and proves the actual client-side pipeline end to end: WASD
|
|
## held while a FULLSCREEN implant screen is open must not move the character
|
|
## on the server, and closing the screen must require a re-press before
|
|
## movement resumes.
|
|
##
|
|
## Reuses test_input_roundtrip.gd's/test_sprint2_proof.gd's hard-won live-
|
|
## server conventions verbatim (port-collision retry, wall-clock deadlines,
|
|
## COOLDOWN_TICKS pacing, handshake dance) — see their own header comments for
|
|
## the "why" behind each one. The one structural difference from those files:
|
|
## this test drives input through the REAL client pipeline (InputMapper poll +
|
|
## SimBridge.send_input, exactly mirroring main.gd's dispatch loop) instead of
|
|
## LocalBridge.send_message() directly — the gate under test lives in that
|
|
## pipeline, so a raw-protocol test would not exercise it at all.
|
|
class_name TestInputGateLive
|
|
extends GdUnitTestSuite
|
|
|
|
const CONNECT_TIMEOUT: float = 3.0
|
|
const RESPONSE_TIMEOUT: float = 10.0
|
|
# Server move cooldown: the proof-room player is Walk stance (ticks_per_move =
|
|
# 2, server stance.rs) — see test_input_roundtrip.gd's own header comment for
|
|
# the full "why" (a dropped move stays dropped no matter how long the wait).
|
|
const COOLDOWN_TICKS: int = 3
|
|
const MAX_PORT_ATTEMPTS: int = 5
|
|
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
|
|
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:
|
|
var project_dir := ProjectSettings.globalize_path("res://")
|
|
return project_dir.path_join("../server/target/debug/settled-reach-server")
|
|
|
|
|
|
static func _random_test_port() -> int:
|
|
return 49152 + (randi() % (65535 - 49152 + 1))
|
|
|
|
|
|
## Spawn the server binary directly (not through SimBridge.connect_to_sim's
|
|
## own spawn path) so this test controls port retry exactly like the sibling
|
|
## live-server tests — SimBridge is then pointed at the already-bound port
|
|
## with server_path left empty so it skips its own subprocess spawn and goes
|
|
## straight to the TCP-connect retry loop in its _process().
|
|
func _spawn_server(server_path: String) -> bool:
|
|
for _attempt in range(MAX_PORT_ATTEMPTS):
|
|
_test_port = _random_test_port()
|
|
var addr := "127.0.0.1:%d" % _test_port
|
|
_server_pid = OS.create_process(server_path, [addr])
|
|
if _server_pid <= 0:
|
|
continue
|
|
await get_tree().create_timer(0.15).timeout
|
|
if OS.is_process_running(_server_pid):
|
|
return true
|
|
_server_pid = -1
|
|
return false
|
|
|
|
|
|
func before_test() -> void:
|
|
_saved_test_mode = SimBridge.test_mode
|
|
_saved_state = SimBridge.state
|
|
_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()
|
|
|
|
|
|
func after_test() -> void:
|
|
if SimBridge._bridge != null and SimBridge._bridge != _saved_bridge:
|
|
SimBridge._bridge.disconnect_from_server()
|
|
if _server_pid > 0 and OS.is_process_running(_server_pid):
|
|
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
|
|
# reset_test_state() touches only mock-side state (harness.reset() when a
|
|
# harness exists, star-map flag, SystemIndex cache) — inert for this live
|
|
# suite, but it restores whatever ambient mock the run left for later suites.
|
|
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
|
|
InputMapper._suppress_move_until_release = false
|
|
_release_all_movement_keys()
|
|
|
|
HudGroups._active_app = ""
|
|
HudGroups._active_mode = HudGroups.Mode.GAMEPLAY
|
|
HudGroups._groups.erase(TEST_APP)
|
|
|
|
|
|
func _release_all_movement_keys() -> void:
|
|
Input.action_release("move_north")
|
|
Input.action_release("move_south")
|
|
Input.action_release("move_east")
|
|
Input.action_release("move_west")
|
|
|
|
|
|
## Drives SimBridge from DISCONNECTED all the way to CONNECTED against a
|
|
## freshly spawned server, awaiting real frames so SimBridge's own _process()
|
|
## (autoload, ticks automatically) does its TCP-connect + handshake dance.
|
|
func _connect_live(server_path: String) -> bool:
|
|
var spawned := await _spawn_server(server_path)
|
|
assert_bool(spawned).override_failure_message(
|
|
"server spawn failed after %d port attempts" % MAX_PORT_ATTEMPTS
|
|
).is_true()
|
|
if not spawned:
|
|
return false
|
|
|
|
SimBridge.test_mode = false
|
|
SimBridge.server_path = "" # already spawned ourselves — SimBridge must not spawn again
|
|
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
|
|
while Time.get_ticks_msec() < deadline_ms:
|
|
if SimBridge.state == SimBridge.ConnectionState.CONNECTED:
|
|
return true
|
|
if SimBridge.state == SimBridge.ConnectionState.ERROR:
|
|
break
|
|
await get_tree().process_frame
|
|
return SimBridge.state == SimBridge.ConnectionState.CONNECTED
|
|
|
|
|
|
## Find the player entity in a decoded snapshot Dictionary.
|
|
static func _find_player(snapshot: Dictionary) -> Dictionary:
|
|
for entity in snapshot.get("entities", []):
|
|
if entity.kind.variant == "Player":
|
|
return entity
|
|
return {}
|
|
|
|
|
|
## 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,
|
|
## OPEN_JOURNAL, OPEN_MENU, INTERACT's target-resolution) are irrelevant here
|
|
## since this test never presses those keys — nothing to special-case.
|
|
func _flush_input_to_server() -> void:
|
|
var inputs: Array[Dictionary] = InputMapper.flush_queue()
|
|
for input in inputs:
|
|
SimBridge.send_input(input)
|
|
|
|
|
|
## Drive InputMapper._process() + the flush-to-server forwarding for one
|
|
## simulated frame, then await a real engine frame so SimBridge's own
|
|
## _process() gets a chance to actually transmit the outbound buffer.
|
|
func _tick(delta: float = 0.05) -> void:
|
|
InputMapper._process(delta)
|
|
_flush_input_to_server()
|
|
await get_tree().process_frame
|
|
|
|
|
|
# -- Live: WASD held while a FULLSCREEN implant is open must not move the player --
|
|
|
|
func test_wasd_held_during_implant_occlusion_does_not_move_player(
|
|
_do_skip := not FileAccess.file_exists(_server_binary_path()),
|
|
_skip_reason := "server binary not built — run `cargo build` in server/"
|
|
) -> void:
|
|
var server_path := _server_binary_path()
|
|
var connected := await _connect_live(server_path)
|
|
assert_bool(connected).override_failure_message(
|
|
"live SimBridge connection to spawned server failed within timeout"
|
|
).is_true()
|
|
if not connected:
|
|
return
|
|
|
|
GameState.player_stance = "Walk"
|
|
InputMapper._last_move_msec = Time.get_ticks_msec() - 1000
|
|
|
|
# Baseline: proof-room player starts at (16,16) -> render coords (16.5, 16.5).
|
|
var baseline: Dictionary = await _await_next_snapshot()
|
|
var start_player := _find_player(baseline)
|
|
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)
|
|
assert_float(start_y).is_equal_approx(16.5, 0.001)
|
|
|
|
# Open a FULLSCREEN implant screen — gameplay is now occluded.
|
|
HudGroups.open_app(TEST_APP, HudGroups.Mode.FULLSCREEN)
|
|
assert_bool(InputMapper._gameplay_occluded).override_failure_message(
|
|
"gate precondition: HudGroups.open_app(FULLSCREEN) must occlude gameplay"
|
|
).is_true()
|
|
|
|
# Hold W (move_north) and drive several ticks — comfortably past the Walk
|
|
# stance's 400ms move interval, so if the gate were absent this would have
|
|
# produced multiple MoveNorth sends by now.
|
|
Input.action_press("move_north")
|
|
for _i in range(20):
|
|
await _tick(0.05)
|
|
Input.action_release("move_north")
|
|
|
|
# Drain whatever the server actually streamed during the hold and confirm
|
|
# the player never left the starting tile. A timeout here is a FAILURE, not
|
|
# a fallback case: substituting baseline would compare baseline to itself
|
|
# and vacuously pass the very claim under test (Hoshe, PR #190 review).
|
|
var settle: Dictionary = await _await_next_snapshot(2.0)
|
|
assert_bool(not settle.is_empty()).override_failure_message(
|
|
"no snapshot arrived within 2s after the hold — cannot verify the no-movement claim"
|
|
).is_true()
|
|
if settle.is_empty():
|
|
return
|
|
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"
|
|
% [start_x, after_hold.x]
|
|
)
|
|
).is_equal_approx(start_x, 0.001)
|
|
assert_float(after_hold.y).override_failure_message(
|
|
(
|
|
"player must NOT move while WASD is held during implant occlusion — y drifted from %.2f to %.2f"
|
|
% [start_y, after_hold.y]
|
|
)
|
|
).is_equal_approx(start_y, 0.001)
|
|
|
|
|
|
# -- Live: close-while-held must not resume movement without a re-press --------
|
|
|
|
func test_close_while_held_requires_repress_before_movement_resumes_live(
|
|
_do_skip := not FileAccess.file_exists(_server_binary_path()),
|
|
_skip_reason := "server binary not built — run `cargo build` in server/"
|
|
) -> void:
|
|
var server_path := _server_binary_path()
|
|
var connected := await _connect_live(server_path)
|
|
assert_bool(connected).override_failure_message(
|
|
"live SimBridge connection to spawned server failed within timeout"
|
|
).is_true()
|
|
if not connected:
|
|
return
|
|
|
|
GameState.player_stance = "Walk"
|
|
InputMapper._last_move_msec = Time.get_ticks_msec() - 1000
|
|
|
|
var baseline: Dictionary = await _await_next_snapshot()
|
|
var start_player := _find_player(baseline)
|
|
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
|
|
|
|
# Open the implant, hold W THROUGH the close transition (W is still down
|
|
# when close_app() fires — this is the "forgot their finger on W" case).
|
|
HudGroups.open_app(TEST_APP, HudGroups.Mode.FULLSCREEN)
|
|
Input.action_press("move_north")
|
|
for _i in range(6):
|
|
await _tick(0.05)
|
|
|
|
HudGroups.close_app()
|
|
assert_bool(InputMapper._suppress_move_until_release).override_failure_message(
|
|
"the release-latch must arm the instant the implant closes"
|
|
).is_true()
|
|
|
|
# Still held for several more ticks post-close — must NOT resume.
|
|
for _i in range(10):
|
|
await _tick(0.05)
|
|
|
|
# Timeout is a failure, not a fallback — see the sibling test's comment.
|
|
var settle_still_held: Dictionary = await _await_next_snapshot(2.0)
|
|
assert_bool(not settle_still_held.is_empty()).override_failure_message(
|
|
"no snapshot arrived within 2s after the held-through-close phase — cannot verify the no-resume claim"
|
|
).is_true()
|
|
if settle_still_held.is_empty():
|
|
return
|
|
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)
|
|
assert_float(still_held_player.y).override_failure_message(
|
|
"player must NOT resume moving while W stays held through the close transition (no re-press yet)"
|
|
).is_equal_approx(start_y, 0.001)
|
|
|
|
# Release, then re-press — movement must resume normally now.
|
|
Input.action_release("move_north")
|
|
await _tick(0.05) # observes the release, clears the latch
|
|
assert_bool(InputMapper._suppress_move_until_release).override_failure_message(
|
|
"the latch must clear once every movement key has been observed released"
|
|
).is_false()
|
|
|
|
InputMapper._last_move_msec = Time.get_ticks_msec() - 1000
|
|
Input.action_press("move_north")
|
|
var resumed := false
|
|
for _i in range(30):
|
|
await _tick(0.05)
|
|
var snap: Dictionary = await _await_next_snapshot(0.5)
|
|
if not snap.is_empty():
|
|
var p := _find_player(snap)
|
|
if (
|
|
p.size() > 0
|
|
and not (is_equal_approx(p.x, start_x) and is_equal_approx(p.y, start_y))
|
|
):
|
|
resumed = true
|
|
break
|
|
Input.action_release("move_north")
|
|
assert_bool(resumed).override_failure_message(
|
|
"movement must resume after a fresh re-press following the close-while-held transition"
|
|
).is_true()
|