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:
@@ -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
|
||||
Reference in New Issue
Block a user