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.
407 lines
18 KiB
GDScript
407 lines
18 KiB
GDScript
## T-1146: implant-occlusion gate — InputMapper must not queue gameplay-sim-bound
|
|
## actions (movement, SET_FACING, and every gated _unhandled_input entry) while
|
|
## HudGroups.gameplay_occluded is true (a FULLSCREEN implant screen is open).
|
|
## Implant/app-level keys (OPEN_MENU/BUG_REPORT/OPEN_JOURNAL) are the explicit
|
|
## exception — they must keep working regardless of occlusion, since OPEN_MENU
|
|
## is how the implant closes.
|
|
##
|
|
## Unit/state tier (this file): drives InputMapper._process()/_unhandled_input()
|
|
## directly with real HudGroups.open_app()/close_app() transitions and real
|
|
## Input.action_press()/action_release() engine-global state — no live server,
|
|
## no scene tree beyond the autoloads. The LIVE tier (test_input_gate_live.gd,
|
|
## sibling file) proves the same behavior end-to-end against a spawned server.
|
|
class_name TestInputMapperOcclusionGate
|
|
extends GdUnitTestSuite
|
|
|
|
const TEST_APP := "implant/t1146_occlusion_test"
|
|
|
|
# Ambient SimBridge state to restore in after_test — see the neutralization
|
|
# comment in before_test.
|
|
var _saved_sim_state: int = 0
|
|
|
|
|
|
func before_test() -> void:
|
|
GameState.player_stance = "Walk"
|
|
GameState.dialogue_active = false
|
|
GameState.free_camera_mode = false
|
|
GameState.gauntlet_mode = false
|
|
# This suite never touches SimBridge, but every real open_app()/close_app()
|
|
# transition below fires HudGroups' global auto-pause handler, which calls
|
|
# SimBridge.send_named_action("AutoPause"/"AutoResume") — a silent append to
|
|
# the shared _outbound_buffer whenever ambient state is CONNECTED (which
|
|
# other suites leave behind, e.g. test_hub_teleport.gd sets CONNECTED with
|
|
# no after_test). Force DISCONNECTED for the duration so those sends no-op,
|
|
# and clear any entries already leaked into the buffer (Hoshe, PR #190).
|
|
_saved_sim_state = SimBridge.state
|
|
SimBridge.state = SimBridge.ConnectionState.DISCONNECTED
|
|
SimBridge._outbound_buffer.clear()
|
|
InputMapper.input_queue.clear()
|
|
InputMapper._gameplay_occluded = false
|
|
InputMapper._suppress_move_until_release = false
|
|
InputMapper._last_move_msec = 0
|
|
# 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()
|
|
|
|
|
|
func after_test() -> void:
|
|
SimBridge.state = _saved_sim_state
|
|
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.
|
|
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")
|
|
|
|
|
|
# A throttle-safe "now" — MOVE_INTERVAL_MS["Walk"] is 400ms; setting
|
|
# _last_move_msec far enough in the past guarantees the next poll's interval
|
|
# check passes regardless of real wall-clock time elapsed during the test.
|
|
func _prime_throttle_ready() -> void:
|
|
InputMapper._last_move_msec = Time.get_ticks_msec() - 1000
|
|
|
|
|
|
# =============================================================================
|
|
# Occluded -> poll produces nothing (movement)
|
|
# =============================================================================
|
|
|
|
|
|
func test_occluded_movement_poll_queues_nothing() -> void:
|
|
HudGroups.open_app(TEST_APP, HudGroups.Mode.FULLSCREEN)
|
|
assert_bool(InputMapper._gameplay_occluded).override_failure_message(
|
|
"HudGroups.open_app(FULLSCREEN) must occlude — gate precondition not met"
|
|
).is_true()
|
|
_prime_throttle_ready()
|
|
Input.action_press("move_north")
|
|
InputMapper._process(0.016)
|
|
assert_int(InputMapper.input_queue.size()).override_failure_message(
|
|
"InputMapper must not enqueue MoveNorth while gameplay is occluded"
|
|
).is_equal(0)
|
|
|
|
|
|
func test_occluded_set_facing_poll_queues_nothing() -> void:
|
|
# Force a facing_octant change so the (ungated in isolation) SET_FACING send
|
|
# branch would fire if the occlusion gate were not in place.
|
|
InputMapper.facing_angle = 0.0 # East
|
|
InputMapper.facing_octant = "East"
|
|
InputMapper._last_sent_octant = "North"
|
|
HudGroups.open_app(TEST_APP, HudGroups.Mode.FULLSCREEN)
|
|
InputMapper._process(0.016)
|
|
assert_int(InputMapper.input_queue.size()).override_failure_message(
|
|
"InputMapper must not enqueue SetFacing while gameplay is occluded"
|
|
).is_equal(0)
|
|
|
|
|
|
# =============================================================================
|
|
# Occluded -> _unhandled_input produces nothing for gameplay-sim-bound actions
|
|
# =============================================================================
|
|
|
|
|
|
func test_occluded_interact_press_queues_nothing() -> void:
|
|
HudGroups.open_app(TEST_APP, HudGroups.Mode.FULLSCREEN)
|
|
var fake_event := InputEventAction.new()
|
|
fake_event.action = "interact"
|
|
fake_event.pressed = true
|
|
InputMapper._unhandled_input(fake_event)
|
|
assert_int(InputMapper.input_queue.size()).override_failure_message(
|
|
"InputMapper must not enqueue Interact while gameplay is occluded"
|
|
).is_equal(0)
|
|
|
|
|
|
func test_occluded_stance_up_press_queues_nothing() -> void:
|
|
HudGroups.open_app(TEST_APP, HudGroups.Mode.FULLSCREEN)
|
|
var fake_event := InputEventAction.new()
|
|
fake_event.action = "stance_up"
|
|
fake_event.pressed = true
|
|
InputMapper._unhandled_input(fake_event)
|
|
assert_int(InputMapper.input_queue.size()).override_failure_message(
|
|
"InputMapper must not enqueue TOGGLE_STANCE_UP while gameplay is occluded"
|
|
).is_equal(0)
|
|
|
|
|
|
func test_occluded_quicksave_press_queues_nothing() -> void:
|
|
GameState.current_game_id = "t1146-test-session"
|
|
HudGroups.open_app(TEST_APP, HudGroups.Mode.FULLSCREEN)
|
|
var fake_event := InputEventAction.new()
|
|
fake_event.action = "quicksave"
|
|
fake_event.pressed = true
|
|
InputMapper._unhandled_input(fake_event)
|
|
assert_int(InputMapper.input_queue.size()).override_failure_message(
|
|
"InputMapper must not enqueue SaveGame while gameplay is occluded"
|
|
).is_equal(0)
|
|
GameState.current_game_id = ""
|
|
|
|
|
|
# =============================================================================
|
|
# Exempt actions — never gated, regardless of occlusion
|
|
# =============================================================================
|
|
|
|
|
|
func test_occluded_open_menu_press_still_queues() -> void:
|
|
# OPEN_MENU is how the implant CLOSES (Esc). Gating it would make the
|
|
# implant impossible to close with the keyboard.
|
|
HudGroups.open_app(TEST_APP, HudGroups.Mode.FULLSCREEN)
|
|
var fake_event := InputEventAction.new()
|
|
fake_event.action = "open_menu"
|
|
fake_event.pressed = true
|
|
InputMapper._unhandled_input(fake_event)
|
|
assert_int(InputMapper.input_queue.size()).override_failure_message(
|
|
"OPEN_MENU must still queue while gameplay is occluded — it's how the implant closes"
|
|
).is_equal(1)
|
|
assert_int(InputMapper.input_queue[0]["action"]).is_equal(InputMapper.Action.OPEN_MENU)
|
|
|
|
|
|
func test_occluded_bug_report_press_still_queues() -> void:
|
|
# #495: client-only, never reaches send_input — no reason to gate it.
|
|
HudGroups.open_app(TEST_APP, HudGroups.Mode.FULLSCREEN)
|
|
var fake_event := InputEventAction.new()
|
|
fake_event.action = "bug_report"
|
|
fake_event.pressed = true
|
|
InputMapper._unhandled_input(fake_event)
|
|
assert_int(InputMapper.input_queue.size()).override_failure_message(
|
|
"BUG_REPORT must still queue while gameplay is occluded — it's client-only"
|
|
).is_equal(1)
|
|
assert_int(InputMapper.input_queue[0]["action"]).is_equal(InputMapper.Action.BUG_REPORT)
|
|
|
|
|
|
func test_occluded_open_journal_press_still_queues() -> void:
|
|
# #264: client-only, never reaches send_input — no reason to gate it.
|
|
HudGroups.open_app(TEST_APP, HudGroups.Mode.FULLSCREEN)
|
|
var fake_event := InputEventAction.new()
|
|
fake_event.action = "open_journal"
|
|
fake_event.pressed = true
|
|
InputMapper._unhandled_input(fake_event)
|
|
assert_int(InputMapper.input_queue.size()).override_failure_message(
|
|
"OPEN_JOURNAL must still queue while gameplay is occluded — it's client-only"
|
|
).is_equal(1)
|
|
assert_int(InputMapper.input_queue[0]["action"]).is_equal(InputMapper.Action.OPEN_JOURNAL)
|
|
|
|
|
|
# =============================================================================
|
|
# Unoccluded -> poll produces normally
|
|
# =============================================================================
|
|
|
|
|
|
func test_unoccluded_movement_poll_queues_normally() -> void:
|
|
assert_bool(InputMapper._gameplay_occluded).is_false()
|
|
_prime_throttle_ready()
|
|
Input.action_press("move_north")
|
|
InputMapper._process(0.016)
|
|
assert_int(InputMapper.input_queue.size()).override_failure_message(
|
|
"InputMapper must enqueue MoveNorth normally while gameplay is not occluded"
|
|
).is_equal(1)
|
|
assert_int(InputMapper.input_queue[0]["action"]).is_equal(InputMapper.Action.MOVE_NORTH)
|
|
|
|
|
|
func test_unoccluded_interact_press_queues_normally() -> void:
|
|
var fake_event := InputEventAction.new()
|
|
fake_event.action = "interact"
|
|
fake_event.pressed = true
|
|
InputMapper._unhandled_input(fake_event)
|
|
assert_int(InputMapper.input_queue.size()).override_failure_message(
|
|
"InputMapper must enqueue Interact normally while gameplay is not occluded"
|
|
).is_equal(1)
|
|
assert_int(InputMapper.input_queue[0]["action"]).is_equal(InputMapper.Action.INTERACT)
|
|
|
|
|
|
# =============================================================================
|
|
# T-1088 click-to-move seams share the same suppression predicate — all three
|
|
# input_queue producers in the module are gated (Tyre, PR #190 review)
|
|
# =============================================================================
|
|
|
|
|
|
func test_occluded_queue_move_step_queues_nothing() -> void:
|
|
InputMapper._gameplay_occluded = true
|
|
_prime_throttle_ready()
|
|
var queued := InputMapper.queue_move_step(Vector2i(0, -1))
|
|
assert_bool(queued).is_false()
|
|
assert_int(InputMapper.input_queue.size()).override_failure_message(
|
|
"queue_move_step must not enqueue while gameplay is occluded"
|
|
).is_equal(0)
|
|
|
|
|
|
func test_occluded_queue_stance_toggle_queues_nothing() -> void:
|
|
InputMapper._gameplay_occluded = true
|
|
InputMapper.queue_stance_toggle(true)
|
|
assert_int(InputMapper.input_queue.size()).override_failure_message(
|
|
"queue_stance_toggle must not enqueue while gameplay is occluded"
|
|
).is_equal(0)
|
|
|
|
|
|
func test_unoccluded_seams_queue_normally() -> void:
|
|
_prime_throttle_ready()
|
|
var queued := InputMapper.queue_move_step(Vector2i(0, -1))
|
|
assert_bool(queued).is_true()
|
|
InputMapper.queue_stance_toggle(true)
|
|
assert_int(InputMapper.input_queue.size()).override_failure_message(
|
|
"both seams must keep queueing normally while gameplay is not occluded"
|
|
).is_equal(2)
|
|
assert_int(InputMapper.input_queue[0]["action"]).is_equal(InputMapper.Action.MOVE_NORTH)
|
|
assert_int(InputMapper.input_queue[1]["action"]).is_equal(InputMapper.Action.TOGGLE_STANCE_UP)
|
|
|
|
|
|
# =============================================================================
|
|
# INSERT mode must NOT occlude/gate — e.g. main.gd's "implant/economics" panel
|
|
# (D-181/#824) renders alongside still-playable gameplay.
|
|
# =============================================================================
|
|
|
|
|
|
func test_insert_mode_does_not_occlude() -> void:
|
|
HudGroups.open_app(TEST_APP, HudGroups.Mode.INSERT)
|
|
assert_bool(InputMapper._gameplay_occluded).override_failure_message(
|
|
"INSERT mode must not set gameplay_occluded — it's a side panel, gameplay stays playable"
|
|
).is_false()
|
|
|
|
|
|
func test_insert_mode_movement_poll_queues_normally() -> void:
|
|
HudGroups.open_app(TEST_APP, HudGroups.Mode.INSERT)
|
|
_prime_throttle_ready()
|
|
Input.action_press("move_east")
|
|
InputMapper._process(0.016)
|
|
assert_int(InputMapper.input_queue.size()).override_failure_message(
|
|
"WASD must still move the character while an INSERT-mode panel (e.g. economics monitor) is open"
|
|
).is_equal(1)
|
|
|
|
|
|
# =============================================================================
|
|
# Held-state transitions (T-1146 requirement #2)
|
|
# =============================================================================
|
|
|
|
|
|
## Open-while-held: a movement key already down when the implant opens must
|
|
## stop producing on the very next poll — no separate latch needed, the
|
|
## occlusion gate alone is sufficient since poll-site means nothing is ever
|
|
## enqueued once occluded.
|
|
func test_open_while_held_stops_movement_immediately() -> void:
|
|
_prime_throttle_ready()
|
|
Input.action_press("move_north")
|
|
InputMapper._process(0.016)
|
|
assert_int(InputMapper.input_queue.size()).override_failure_message(
|
|
"sanity: movement must queue before occlusion begins"
|
|
).is_equal(1)
|
|
InputMapper.input_queue.clear()
|
|
|
|
# Implant opens WHILE W is still held.
|
|
HudGroups.open_app(TEST_APP, HudGroups.Mode.FULLSCREEN)
|
|
InputMapper._process(0.016)
|
|
assert_int(InputMapper.input_queue.size()).override_failure_message(
|
|
"movement must stop the instant occlusion begins, even with the key still held"
|
|
).is_equal(0)
|
|
|
|
|
|
## Close-while-held: a movement key still down at the moment the implant
|
|
## closes must NOT resume movement on the very next poll — a re-press is
|
|
## required. This is the release-latch (_suppress_move_until_release).
|
|
func test_close_while_held_does_not_resume_without_release() -> void:
|
|
HudGroups.open_app(TEST_APP, HudGroups.Mode.FULLSCREEN)
|
|
_prime_throttle_ready()
|
|
Input.action_press("move_north") # held throughout the whole close transition
|
|
InputMapper._process(0.016)
|
|
assert_int(InputMapper.input_queue.size()).override_failure_message(
|
|
"sanity: nothing should queue yet — still occluded"
|
|
).is_equal(0)
|
|
|
|
HudGroups.close_app()
|
|
assert_bool(InputMapper._suppress_move_until_release).override_failure_message(
|
|
"the release-latch must arm the instant occlusion ends"
|
|
).is_true()
|
|
|
|
_prime_throttle_ready()
|
|
InputMapper._process(0.016) # W is STILL held — must not resume
|
|
assert_int(InputMapper.input_queue.size()).override_failure_message(
|
|
"W held through the close transition must NOT resume movement without a re-press"
|
|
).is_equal(0)
|
|
|
|
# Still held on a second poll — latch must not self-clear from time alone.
|
|
_prime_throttle_ready()
|
|
InputMapper._process(0.016)
|
|
assert_int(InputMapper.input_queue.size()).override_failure_message(
|
|
"the latch must not clear on its own while the key stays held"
|
|
).is_equal(0)
|
|
|
|
# Release, then re-press — movement must resume normally.
|
|
Input.action_release("move_north")
|
|
InputMapper._process(0.016) # observes the release, clears the latch
|
|
assert_bool(InputMapper._suppress_move_until_release).override_failure_message(
|
|
"the latch must clear once the poll observes every movement key released"
|
|
).is_false()
|
|
|
|
_prime_throttle_ready()
|
|
Input.action_press("move_north")
|
|
InputMapper._process(0.016)
|
|
assert_int(InputMapper.input_queue.size()).override_failure_message(
|
|
"a fresh press after release must resume movement normally"
|
|
).is_equal(1)
|
|
|
|
|
|
## Facing is exempt from the release-latch — turning to look on the frame the
|
|
## implant closes is expected (re-sync), not a "lurch". Installs the T-1088
|
|
## facing_angle_provider seam so _update_facing_from_mouse() cannot silently
|
|
## overwrite the test's facing_octant from the headless-runner's real (and
|
|
## arbitrary) mouse/viewport state before the SET_FACING check runs.
|
|
func test_close_while_held_still_allows_facing_update_after_movement_key_release() -> void:
|
|
InputMapper.facing_angle_provider = func() -> float: return -PI / 2.0 # North
|
|
HudGroups.open_app(TEST_APP, HudGroups.Mode.FULLSCREEN)
|
|
InputMapper._process(0.016) # settle facing_octant to North, consume any pending SET_FACING
|
|
InputMapper.input_queue.clear()
|
|
HudGroups.close_app()
|
|
|
|
# No movement key held at all — latch arms but has nothing to suppress;
|
|
# a facing change must still send normally on the very next poll.
|
|
InputMapper.facing_angle_provider = func() -> float: return 0.0 # East
|
|
InputMapper._process(0.016)
|
|
assert_int(InputMapper.input_queue.size()).override_failure_message(
|
|
"SET_FACING must send normally right after occlusion ends when no movement key is held"
|
|
).is_equal(1)
|
|
assert_int(InputMapper.input_queue[0]["action"]).is_equal(InputMapper.Action.SET_FACING)
|
|
InputMapper.facing_angle_provider = Callable()
|
|
|
|
|
|
## The latch is scoped to raw_dir == ZERO across ALL four movement keys, not
|
|
## just the one that was held when occlusion began — releasing a DIFFERENT
|
|
## key than the one held at close-time must not falsely clear the latch while
|
|
## another movement key is still down.
|
|
func test_close_while_held_latch_requires_every_movement_key_released() -> void:
|
|
HudGroups.open_app(TEST_APP, HudGroups.Mode.FULLSCREEN)
|
|
Input.action_press("move_north")
|
|
Input.action_press("move_east")
|
|
HudGroups.close_app()
|
|
|
|
# Release only one of the two held keys.
|
|
Input.action_release("move_north")
|
|
_prime_throttle_ready()
|
|
InputMapper._process(0.016)
|
|
assert_bool(InputMapper._suppress_move_until_release).override_failure_message(
|
|
"the latch must stay armed while ANY movement key is still held"
|
|
).is_true()
|
|
assert_int(InputMapper.input_queue.size()).override_failure_message(
|
|
"movement must stay suppressed while any movement key is still held"
|
|
).is_equal(0)
|
|
|
|
# Release the second key — now all movement keys are up.
|
|
Input.action_release("move_east")
|
|
InputMapper._process(0.016)
|
|
assert_bool(InputMapper._suppress_move_until_release).override_failure_message(
|
|
"the latch must clear once every movement key has been released"
|
|
).is_false()
|