feat(client): add 5 anti-tedium regression tests (#494)

Regression guards for Sprint 9 QA features:
- F12 bug report (2): no-crash without handler, no queued input action
  (stubs for when #495 WRONG button lands)
- Gauntlet UI hidden (3): no gauntlet nodes visible in default mode,
  no room_id/gauntlet_mode in normal snapshots, stays hidden across
  multiple ticks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-18 02:14:46 +01:00
co-authored by Claude Opus 4.6
parent f82f97afcb
commit 4648ce62bb
+262
View File
@@ -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