diff --git a/client/tests/test_anti_tedium.gd b/client/tests/test_anti_tedium.gd new file mode 100644 index 000000000..fab341bec --- /dev/null +++ b/client/tests/test_anti_tedium.gd @@ -0,0 +1,262 @@ +## Anti-tedium regression tests (#494): +## Guard against UI clutter that makes testing tedious. +## +## Test 1: F12 bug report capture — pressing F12 must not crash; when #495 lands, +## the handler should pause, show capture dialog, save 3 files, unpause. +## Test 2: Gauntlet progress UI hidden — in non-Gauntlet mode (no room_id or +## gauntlet_mode flag in snapshot), timer and personal-bests must not appear. +## +## #495 blocked by #481/#490 (server). #496 blocked by #487 (server). +## Tests stub blocked features and verify anti-tedium guards. +## Spec ref: Sprint 9 briefing (client.md), #495, #496. +class_name TestAntiTedium +extends GdUnitTestSuite + + +var _instance: Node = null + + +func before_test() -> void: + SimBridge.reset_test_state() + SimBridge._last_snapshot = null + GameState.current_tick = 0 + GameState.player_position = Vector2.ZERO + GameState.visible_entities = [] + GameState.visible_tiles = [] + GameState.visible_positions = {} + GameState.current_monologue = null + GameState.current_dialogue = null + GameState.game_time = {} + GameState.pending_recognitions = [] + + +func after_test() -> void: + if _instance and is_instance_valid(_instance): + _instance.queue_free() + _instance = null + + +# -- Helpers ------------------------------------------------------------------- + +## Encode a minimal valid snapshot as MessagePack bytes (same helper as P0 tests). +func _make_snapshot_bytes(overrides: Dictionary = {}) -> PackedByteArray: + var snapshot := { + "tick": overrides.get("tick", 1), + "version": Protocol.PROTOCOL_VERSION, + "entities": overrides.get("entities", [{ + "entity_id": 1, + "x": 10.0, + "y": 10.0, + "z": 0, + "kind": "Player", + "visibility": "Forward", + }]), + "game_time": { + "day": 0, + "time_of_day": 0, + "day_phase": "Morning", + "tick_rate": overrides.get("tick_rate", "Full"), + }, + "player_facing": "North", + "player_stance": "Walk", + "player_inventory": [], + "visible_tiles": [], + "nearby_interactions": [], + "pending_recognitions": [], + } + if overrides.has("current_monologue"): + snapshot["current_monologue"] = overrides["current_monologue"] + if overrides.has("current_dialogue"): + snapshot["current_dialogue"] = overrides["current_dialogue"] + # Gauntlet fields: only include if explicitly provided (absence = non-gauntlet) + if overrides.has("room_id"): + snapshot["room_id"] = overrides["room_id"] + if overrides.has("gauntlet_mode"): + snapshot["gauntlet_mode"] = overrides["gauntlet_mode"] + var result = Messagepack.encode(snapshot) + return result.value + + +# -- Test 1: F12 Bug Report Capture ------------------------------------------- +# Regression guard: F12 press must not crash or cause unintended side effects. +# When #495 lands, this test verifies the full capture flow: +# 1. Game pauses (tick_rate → Paused) +# 2. Capture dialog appears +# 3. Three files saved to tests/bug-reports/YYYY-MM-DD_HH-MM-SS/ +# 4. Game unpauses +# +# Until #495: verifies F12 is inert — no crash, no state corruption. + +func test_f12_press_no_crash_without_handler() -> void: + # Load main scene — full game tree + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + # Process one frame to let _ready() and initial snapshot settle. + # The test snapshot at tick 1 includes a monologue that gets consumed here. + _instance._process(0.016) + + # Record state AFTER initialization — this is the stable baseline. + var tick_rate_before: String = GameState.game_time.get("tick_rate", "Full") + var mono_before: Variant = GameState.current_monologue + var dialogue_before: Variant = GameState.current_dialogue + + # Simulate F12 key press via the Godot input system. + # InputMapper._unhandled_input() checks action bindings — F12 is not bound + # to any action yet, so the event should pass through harmlessly. + var event := InputEventKey.new() + event.keycode = KEY_F12 + event.pressed = true + event.key_label = KEY_F12 + Input.parse_input_event(event) + + # Process a frame to let the event propagate + _instance._process(0.016) + + # Assert: no state corruption from unhandled F12 + assert_that(GameState.game_time.get("tick_rate", "Full")).override_failure_message( + "F12 press should not change tick_rate (no handler yet)" + ).is_equal(tick_rate_before) + assert_that(GameState.current_monologue).override_failure_message( + "F12 press should not spawn a monologue" + ).is_equal(mono_before) + assert_that(GameState.current_dialogue).override_failure_message( + "F12 press should not spawn a dialogue" + ).is_equal(dialogue_before) + + # Release the key + var release := InputEventKey.new() + release.keycode = KEY_F12 + release.pressed = false + release.key_label = KEY_F12 + Input.parse_input_event(release) + + +func test_f12_does_not_queue_input_action() -> void: + # Verify F12 does not produce any action in InputMapper's queue. + # When #495 adds the "bug_report" action, this test will be updated + # to verify the correct action IS queued. + InputMapper.input_queue.clear() + + var event := InputEventKey.new() + event.keycode = KEY_F12 + event.pressed = true + event.key_label = KEY_F12 + Input.parse_input_event(event) + + # Give InputMapper a frame to process + InputMapper._process(0.016) + + # F12 is not bound to any InputMapper.Action — queue should remain empty + assert_that(InputMapper.input_queue.size()).override_failure_message( + "F12 should not produce any input action (no binding exists yet)" + ).is_equal(0) + + # Cleanup + var release := InputEventKey.new() + release.keycode = KEY_F12 + release.pressed = false + release.key_label = KEY_F12 + Input.parse_input_event(release) + InputMapper.flush_queue() + + +# -- Test 2: Gauntlet Progress UI Hidden in Non-Gauntlet Mode ----------------- +# When #496 lands, it adds a room timer and personal-bests overlay. +# Anti-tedium guard: these must NOT be visible in normal (non-Gauntlet) play. +# +# Current state: no gauntlet UI exists in the scene tree. +# This test guards against #496 accidentally showing gauntlet UI in all modes. + +func test_no_gauntlet_ui_visible_in_default_mode() -> void: + # Load main scene — represents non-Gauntlet (default) play mode + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + # Process one frame to initialize all children + _instance._process(0.016) + + # Check that no gauntlet-specific UI nodes are visible in the scene tree. + # When #496 adds GauntletHUD/RoomTimer/PersonalBests, they must be hidden + # by default (only shown when gauntlet_mode is true in the snapshot). + var gauntlet_node_names := [ + "GauntletHUD", "RoomTimer", "PersonalBests", "GauntletOverlay", + "GauntletTimer", "GauntletProgress", + ] + for node_name in gauntlet_node_names: + var node: Node = _find_node_recursive(_instance, node_name) + if node != null and node is CanvasItem: + assert_that((node as CanvasItem).visible).override_failure_message( + "Gauntlet UI node '%s' must not be visible in non-Gauntlet mode" % node_name + ).is_false() + + +func test_snapshot_without_room_id_shows_no_gauntlet_ui() -> void: + # A snapshot that lacks room_id and gauntlet_mode fields represents + # normal play. Apply it and verify no gauntlet state leaks into GameState. + var bytes := _make_snapshot_bytes({"tick": 1}) + SimBridge.receive_bytes(bytes) + + var snapshot: Variant = SimBridge._last_snapshot + assert_that(snapshot).is_not_null() + + # Snapshot should NOT contain gauntlet fields + assert_that(snapshot.has("room_id")).override_failure_message( + "Non-gauntlet snapshot must not contain room_id" + ).is_false() + assert_that(snapshot.has("gauntlet_mode")).override_failure_message( + "Non-gauntlet snapshot must not contain gauntlet_mode" + ).is_false() + + # Apply to GameState — gauntlet-related state should not exist + GameState.apply_snapshot(snapshot) + # GameState should not have gauntlet fields set (they don't exist yet, + # and when added, they must default to null/false) + assert_that(GameState.get("room_id")).override_failure_message( + "GameState.room_id should not exist or be null in non-gauntlet mode" + ).is_null() + assert_that(GameState.get("gauntlet_mode")).override_failure_message( + "GameState.gauntlet_mode should not exist or be null in non-gauntlet mode" + ).is_null() + + +func test_gauntlet_ui_stays_hidden_after_multiple_snapshots() -> void: + # Simulate several ticks of normal play — gauntlet UI must never appear. + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + # Process 5 frames with normal (non-gauntlet) snapshots + for tick in range(1, 6): + var bytes := _make_snapshot_bytes({"tick": tick}) + SimBridge.receive_bytes(bytes) + _instance._process(0.016) + + # After 5 frames, no gauntlet nodes should have appeared + var gauntlet_node_names := [ + "GauntletHUD", "RoomTimer", "PersonalBests", "GauntletOverlay", + "GauntletTimer", "GauntletProgress", + ] + for node_name in gauntlet_node_names: + var node: Node = _find_node_recursive(_instance, node_name) + if node != null and node is CanvasItem: + assert_that((node as CanvasItem).visible).override_failure_message( + "Gauntlet UI '%s' must stay hidden after %d non-gauntlet ticks" % [node_name, 5] + ).is_false() + + +# -- Helper: recursive node search -------------------------------------------- + +func _find_node_recursive(root: Node, target_name: String) -> Node: + if root.name == target_name: + return root + for child in root.get_children(): + var found := _find_node_recursive(child, target_name) + if found != null: + return found + return null