## 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 const MAIN_SCENE = preload("res://scenes/main.tscn") var GauntletHUDScript = load("res://ui/gauntlet_hud.gd") var BugReportDialogScript = load("res://ui/bug_report_dialog.gd") 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 = [] GameState.room_id = null GameState.gauntlet_mode = false 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 func _make_gauntlet_hud() -> Control: var hud = Control.new() hud.set_script(GauntletHUDScript) auto_free(hud) add_child(hud) hud._personal_bests = {} # Clear any stats loaded from disk return hud func _make_bug_report_dialog() -> Control: var dialog = Control.new() dialog.set_script(BugReportDialogScript) auto_free(dialog) add_child(dialog) return dialog # -- Test 1: F12 Bug Report Capture ------------------------------------------- # #495: BUG_REPORT action triggers the WRONG button capture flow. # Verifies: dialog exists, activates on action, no state corruption. # Note: Input.parse_input_event + _unhandled_input is unreliable in headless # test mode. Tests drive the input queue directly for deterministic coverage. func test_bug_report_dialog_exists_in_scene() -> void: # Verify the BugReportDialog node is present and hidden by default. var scene := MAIN_SCENE _instance = scene.instantiate() auto_free(_instance) add_child(_instance) _instance._process(0.016) var dialog: Node = _find_node_recursive(_instance, "BugReportDialog") assert_that(dialog).override_failure_message( "BugReportDialog node should exist in scene tree" ).is_not_null() if dialog is CanvasItem: assert_that((dialog as CanvasItem).visible).override_failure_message( "BugReportDialog should be hidden by default" ).is_false() func test_bug_report_activates_on_action() -> void: # Inject BUG_REPORT action directly into the queue and verify main.gd # triggers the dialog. This tests the full main._process() handling path. var scene := MAIN_SCENE _instance = scene.instantiate() auto_free(_instance) add_child(_instance) _instance._process(0.016) # Record baseline state var mono_before: Variant = GameState.current_monologue var dialogue_before: Variant = GameState.current_dialogue # Inject BUG_REPORT action into InputMapper queue InputMapper.input_queue.append({ "action": InputMapper.Action.BUG_REPORT, "timestamp_msec": Time.get_ticks_msec(), }) # Process a frame — main.gd flushes the queue and triggers dialog _instance._process(0.016) # Assert: dialog should be active var dialog: Node = _find_node_recursive(_instance, "BugReportDialog") assert_that(dialog).is_not_null() if dialog and dialog.has_method("is_active"): assert_that(dialog.is_active()).override_failure_message( "BugReportDialog should be active after BUG_REPORT action" ).is_true() # Assert: no unintended state corruption assert_that(GameState.current_monologue).override_failure_message( "BUG_REPORT should not spawn a monologue" ).is_equal(mono_before) assert_that(GameState.current_dialogue).override_failure_message( "BUG_REPORT should not spawn a dialogue" ).is_equal(dialogue_before) # -- 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 := MAIN_SCENE _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) # #496: GameState now has room_id (Variant, null) and gauntlet_mode (bool, false). # A non-gauntlet snapshot must leave these at their defaults. assert_that(GameState.room_id).override_failure_message( "GameState.room_id should be null in non-gauntlet mode" ).is_null() assert_that(GameState.gauntlet_mode).override_failure_message( "GameState.gauntlet_mode should be false in non-gauntlet mode" ).is_false() func test_gauntlet_ui_stays_hidden_after_multiple_snapshots() -> void: # Simulate several ticks of normal play — gauntlet UI must never appear. var scene := MAIN_SCENE _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() # -- GauntletHUD Feature Tests (#496) ---------------------------------------- # Timer lifecycle, personal bests, room change, visibility. # Spec: sprint-9/client.md #496. Ticket: tooling/db/ticket show 496. func test_format_time_zero() -> void: # Static utility: 0 seconds → "00:00" var hud := _make_gauntlet_hud() assert_that(hud._format_time(0.0)).is_equal("00:00") func test_format_time_sub_minute() -> void: # 47.9 seconds (truncates, no rounding) → "00:47" var hud := _make_gauntlet_hud() assert_that(hud._format_time(47.9)).is_equal("00:47") func test_format_time_over_minute() -> void: # 98.3 seconds → 1 min 38 sec → "01:38" var hud := _make_gauntlet_hud() assert_that(hud._format_time(98.3)).is_equal("01:38") func test_gauntlet_hud_shows_when_gauntlet_mode_active() -> void: # HUD must become visible when gauntlet_mode is true in the snapshot. var hud := _make_gauntlet_hud() assert_that(hud.visible).is_false() GameState.gauntlet_mode = true GameState.room_id = "room_1" hud.update_from_state() assert_that(hud.visible).override_failure_message( "GauntletHUD must be visible when gauntlet_mode is true" ).is_true() func test_gauntlet_hud_hides_when_gauntlet_mode_deactivates() -> void: # HUD must hide when gauntlet_mode transitions from true to false. var hud := _make_gauntlet_hud() GameState.gauntlet_mode = true GameState.room_id = "room_1" hud.update_from_state() assert_that(hud.visible).is_true() # Switch to non-gauntlet GameState.gauntlet_mode = false GameState.room_id = null hud.update_from_state() assert_that(hud.visible).override_failure_message( "GauntletHUD must hide when gauntlet_mode becomes false" ).is_false() func test_gauntlet_hud_timer_starts_on_room_entry() -> void: # Timer starts running and tracks room_id after room entry. var hud := _make_gauntlet_hud() GameState.gauntlet_mode = true GameState.room_id = "room_1" hud.update_from_state() assert_that(hud.is_timer_running()).override_failure_message( "Timer should be running after room entry" ).is_true() assert_that(hud.get_current_room_id()).is_equal("room_1") func test_gauntlet_hud_timer_increments_with_delta() -> void: # Timer accumulates real time via _process(delta). var hud := _make_gauntlet_hud() GameState.gauntlet_mode = true GameState.room_id = "room_1" hud.update_from_state() hud._process(0.5) assert_that(hud.get_timer_seconds()).override_failure_message( "Timer should increment by delta (0.5s)" ).is_equal_approx(0.5, 0.01) hud._process(1.0) assert_that(hud.get_timer_seconds()).override_failure_message( "Timer should accumulate (0.5 + 1.0 = 1.5s)" ).is_equal_approx(1.5, 0.01) func test_gauntlet_hud_timer_resets_on_room_change() -> void: # Changing room_id resets the timer to 0 and starts counting for the new room. var hud := _make_gauntlet_hud() GameState.gauntlet_mode = true GameState.room_id = "room_1" hud.update_from_state() hud._process(5.0) assert_that(hud.get_timer_seconds() > 4.0).is_true() # Change room GameState.room_id = "room_2" hud.update_from_state() assert_that(hud.get_timer_seconds()).override_failure_message( "Timer should reset to 0 on room change" ).is_equal_approx(0.0, 0.01) assert_that(hud.get_current_room_id()).is_equal("room_2") assert_that(hud.is_timer_running()).is_true() func test_gauntlet_hud_records_personal_best() -> void: # Completing a room (by entering a new room) records a personal best. var hud := _make_gauntlet_hud() GameState.gauntlet_mode = true GameState.room_id = "room_1" hud.update_from_state() hud._process(10.0) # Entering room_2 triggers _record_room_completion for room_1 GameState.room_id = "room_2" hud.update_from_state() assert_that(hud.get_personal_best("room_1")).override_failure_message( "PB for room_1 should be ~10.0s after first completion" ).is_equal_approx(10.0, 0.1) func test_gauntlet_hud_non_pb_does_not_overwrite() -> void: # A worse time must not overwrite an existing personal best. var hud := _make_gauntlet_hud() hud._personal_bests["room_1"] = 5.0 # Existing PB of 5 seconds GameState.gauntlet_mode = true GameState.room_id = "room_1" hud.update_from_state() hud._process(10.0) # Worse time than existing PB GameState.room_id = "room_2" hud.update_from_state() assert_that(hud.get_personal_best("room_1")).override_failure_message( "PB should remain 5.0 when new time (10.0) is worse" ).is_equal_approx(5.0, 0.01) func test_gauntlet_hud_pb_overwrites_when_faster() -> void: # A faster time must overwrite an existing personal best. var hud := _make_gauntlet_hud() hud._personal_bests["room_1"] = 15.0 # Existing PB of 15 seconds GameState.gauntlet_mode = true GameState.room_id = "room_1" hud.update_from_state() hud._process(8.0) # Better time GameState.room_id = "room_2" hud.update_from_state() assert_that(hud.get_personal_best("room_1")).override_failure_message( "PB should update to 8.0 when new time beats old PB (15.0)" ).is_equal_approx(8.0, 0.1) func test_gauntlet_hud_null_room_in_gauntlet_mode() -> void: # Gauntlet mode active but no room_id yet — HUD visible, timer stopped. var hud := _make_gauntlet_hud() GameState.gauntlet_mode = true GameState.room_id = null hud.update_from_state() assert_that(hud.visible).override_failure_message( "HUD should be visible in gauntlet mode even without room_id" ).is_true() assert_that(hud.is_timer_running()).override_failure_message( "Timer should NOT run when room_id is null" ).is_false() func test_gauntlet_hud_timer_paused_when_not_visible() -> void: # Timer should not accumulate when HUD is not visible (non-gauntlet mode). var hud := _make_gauntlet_hud() GameState.gauntlet_mode = true GameState.room_id = "room_1" hud.update_from_state() hud._process(2.0) assert_that(hud.get_timer_seconds()).is_equal_approx(2.0, 0.01) # Switch to non-gauntlet (hides HUD) GameState.gauntlet_mode = false hud.update_from_state() hud._process(5.0) # Should NOT accumulate assert_that(hud.get_timer_seconds()).override_failure_message( "Timer must not accumulate when HUD is hidden" ).is_equal_approx(2.0, 0.01) func test_gauntlet_hud_finalize_records_current_room() -> void: # finalize() records the current room's time and stops the timer. # Called on disconnect via _on_connection_state_changed. var hud := _make_gauntlet_hud() GameState.gauntlet_mode = true GameState.room_id = "room_1" hud.update_from_state() hud._process(7.5) hud.finalize() assert_that(hud.is_timer_running()).override_failure_message( "Timer should stop after finalize" ).is_false() assert_that(hud.get_personal_best("room_1")).override_failure_message( "Finalize should record current room's time as PB" ).is_equal_approx(7.5, 0.1) func test_gauntlet_hud_session_tracks_attempts() -> void: # Each room entry increments the attempt counter in _session_rooms. var hud := _make_gauntlet_hud() GameState.gauntlet_mode = true GameState.room_id = "room_1" hud.update_from_state() hud._process(3.0) # Re-enter same room (via room change and back) GameState.room_id = "room_2" hud.update_from_state() GameState.room_id = "room_1" hud.update_from_state() assert_that(hud._session_rooms.has("room_1")).is_true() assert_that(hud._session_rooms["room_1"]["attempts"]).override_failure_message( "Room should show 2 attempts after two entries" ).is_equal(2) func test_gauntlet_snapshot_roundtrip_via_apply() -> void: # Snapshot with gauntlet fields applied to GameState, consumed by HUD. # End-to-end: bytes → decode → apply → update_from_state. var bytes := _make_snapshot_bytes({ "tick": 10, "gauntlet_mode": true, "room_id": "warehouse_01", }) SimBridge.receive_bytes(bytes) GameState.apply_snapshot(SimBridge._last_snapshot) assert_that(GameState.gauntlet_mode).is_true() assert_that(GameState.room_id).is_equal("warehouse_01") var hud := _make_gauntlet_hud() hud.update_from_state() assert_that(hud.visible).is_true() assert_that(hud.get_current_room_id()).is_equal("warehouse_01") assert_that(hud.is_timer_running()).is_true() # -- BugReportDialog Feature Tests (#495) ------------------------------------ # Pause/unpause lifecycle, wire guard, text render, state machine. # Spec: sprint-9/client.md #495. Ticket: tooling/db/ticket show 495. func test_bug_report_sends_pause_on_open() -> void: # start_capture() must send Pause to the server. SimBridge.connect_to_sim() SimBridge._test_input_queue.clear() var dialog := _make_bug_report_dialog() dialog.start_capture() assert_that(dialog.is_active()).is_true() assert_that(SimBridge._test_input_queue.has("Pause")).override_failure_message( "Opening bug report should send Pause to server" ).is_true() func test_bug_report_sends_unpause_on_close() -> void: # _close() must send Unpause to the server. SimBridge.connect_to_sim() var dialog := _make_bug_report_dialog() dialog.start_capture() SimBridge._test_input_queue.clear() dialog._close() assert_that(dialog.is_active()).is_false() assert_that(SimBridge._test_input_queue.has("Unpause")).override_failure_message( "Closing bug report should send Unpause to server" ).is_true() func test_bug_report_not_double_activatable() -> void: # Calling start_capture() twice must not double-activate or send duplicate Pause. SimBridge.connect_to_sim() var dialog := _make_bug_report_dialog() dialog.start_capture() assert_that(dialog.is_active()).is_true() SimBridge._test_input_queue.clear() dialog.start_capture() # Second call — should be blocked assert_that(SimBridge._test_input_queue.size()).override_failure_message( "Double-activation should be blocked (no second Pause sent)" ).is_equal(0) func test_bug_report_action_not_on_wire() -> void: # BUG_REPORT is client-only — must not produce a wire-format action name. var wire_name := SimBridge.action_enum_to_wire(InputMapper.Action.BUG_REPORT) assert_that(wire_name).override_failure_message( "BUG_REPORT must not produce a wire action name (client-only)" ).is_equal("") func test_bug_report_render_text_contains_tick_and_entities() -> void: # _render_snapshot_text() must include tick number and entity data from GameState. GameState.current_tick = 42 GameState.player_position = Vector2(5.0, 7.0) GameState.player_facing = "South" GameState.player_stance = "Crouch" GameState.visible_entities = [{ "entity_id": 1, "x": 5.0, "y": 7.0, "z": 0, "kind": {"variant": "Player", "data": null}, "visibility": "Forward", }] GameState.visible_tiles = [] GameState.game_time = {"day": 1, "day_phase": "Evening", "tick_rate": "Full"} GameState.current_monologue = null GameState.current_dialogue = null var dialog := _make_bug_report_dialog() var text: String = dialog._render_snapshot_text() assert_that(text.contains("t42")).override_failure_message( "Render text should contain tick number" ).is_true() assert_that(text.contains("Entities (1)")).override_failure_message( "Render text should show entity count" ).is_true() assert_that(text.contains("Player")).override_failure_message( "Render text should contain entity kind" ).is_true() assert_that(text.contains("South")).override_failure_message( "Render text should contain player facing" ).is_true() func test_bug_report_render_text_with_monologue_and_dialogue() -> void: # _render_snapshot_text() should include active monologue and dialogue. GameState.current_tick = 10 GameState.player_position = Vector2(3.0, 3.0) GameState.player_facing = "North" GameState.player_stance = "Walk" GameState.visible_entities = [] GameState.visible_tiles = [] GameState.game_time = {"day": 0, "day_phase": "Morning", "tick_rate": "Full"} GameState.current_monologue = {"text": "Something is wrong here."} GameState.current_dialogue = {"npc_name": "Kael", "speech": "Who are you?"} var dialog := _make_bug_report_dialog() var text: String = dialog._render_snapshot_text() assert_that(text.contains("Something is wrong here.")).override_failure_message( "Render text should include monologue text" ).is_true() assert_that(text.contains("Kael")).override_failure_message( "Render text should include dialogue NPC name" ).is_true() assert_that(text.contains("Who are you?")).override_failure_message( "Render text should include dialogue speech" ).is_true() func test_bug_report_render_text_empty_snapshot() -> void: # Edge case: F12 pressed before any snapshot data arrives. GameState.current_tick = 0 GameState.player_position = Vector2.ZERO GameState.player_facing = "North" GameState.player_stance = "Walk" GameState.visible_entities = [] GameState.visible_tiles = [] GameState.game_time = {} GameState.current_monologue = null GameState.current_dialogue = null var dialog := _make_bug_report_dialog() var text: String = dialog._render_snapshot_text() # Should not crash, should produce some output assert_that(text.length() > 0).override_failure_message( "Render text should not be empty even with no snapshot data" ).is_true() assert_that(text.contains("Entities (0)")).override_failure_message( "Empty snapshot should show 0 entities" ).is_true() # -- 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