From a2926743420b42df1d3a34abf49369a024d9c084 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 18 Feb 2026 10:53:26 +0100 Subject: [PATCH] feat(client): add 24 gauntlet + bug report tests (#495, #496) Replace stub F12 tests with BugReportDialog integration tests (dialog exists, activates on action, pause/unpause, wire guard, text render with entities/monologue/dialogue, empty snapshot edge case). Add 16 GauntletHUD tests (format_time, visibility toggle, timer lifecycle, room change reset, personal bests record/overwrite/preserve, null room, timer paused when hidden, finalize, session attempts, snapshot roundtrip). Anti-tedium assertions now falsifiable against real GameState.room_id and gauntlet_mode properties. Co-Authored-By: Claude Opus 4.6 --- client/tests/test_anti_tedium.gd | 501 ++++++++++++++++++++++++++----- 1 file changed, 424 insertions(+), 77 deletions(-) diff --git a/client/tests/test_anti_tedium.gd b/client/tests/test_anti_tedium.gd index a97703a32..f9c493d09 100644 --- a/client/tests/test_anti_tedium.gd +++ b/client/tests/test_anti_tedium.gd @@ -14,6 +14,8 @@ extends GdUnitTestSuite var _instance: Node = null +var GauntletHUDScript = load("res://ui/gauntlet_hud.gd") +var BugReportDialogScript = load("res://ui/bug_report_dialog.gd") func before_test() -> void: @@ -28,6 +30,8 @@ func before_test() -> void: GameState.current_dialogue = null GameState.game_time = {} GameState.pending_recognitions = [] + GameState.room_id = null + GameState.gauntlet_mode = false func after_test() -> void: @@ -77,91 +81,85 @@ func _make_snapshot_bytes(overrides: Dictionary = {}) -> PackedByteArray: 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 _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 test_f12_press_no_crash_without_handler() -> void: - # Load main scene — full game tree + +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 := 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 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 := load("res://scenes/main.tscn") + _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 - # 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) + # 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 to let the event propagate + # Process a frame — main.gd flushes the queue and triggers dialog _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: 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( - "F12 press should not spawn a monologue" + "BUG_REPORT should not spawn a monologue" ).is_equal(mono_before) assert_that(GameState.current_dialogue).override_failure_message( - "F12 press should not spawn a dialogue" + "BUG_REPORT 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. @@ -214,17 +212,14 @@ func test_snapshot_without_room_id_shows_no_gauntlet_ui() -> void: # Apply to GameState — gauntlet-related state should not exist GameState.apply_snapshot(snapshot) - # Guard: when #496 adds GameState.room_id / gauntlet_mode properties, - # these assertions become falsifiable — they'll catch any code path that - # sets gauntlet state from a non-gauntlet snapshot. Currently Object.get() - # returns null for nonexistent properties, so this passes trivially until - # the properties are defined. - 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" + # #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: @@ -253,6 +248,358 @@ func test_gauntlet_ui_stays_hidden_after_multiple_snapshots() -> void: ).is_false() +# -- GauntletHUD Feature Tests (#496) ---------------------------------------- +# Timer lifecycle, personal bests, room change, visibility. +# Spec: sprint-9/client.md #496. Ticket: db/connectors/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: db/connectors/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: