#257: Add Load Game screen to main menu with sorted save list, loading overlay during quickload round-trip, and pending_load_path cross-scene flow. F5/F6 quicksave/quickload were already wired. #561: Move debug_overlay.gd from scripts/ui/ to ui/ for consistency with all other UI components. Update scene and test references. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
318 lines
9.9 KiB
GDScript
318 lines
9.9 KiB
GDScript
## Sprint 19 — Debug visualization overlay (#348)
|
|
## F3 toggle, world overlays, tick timing graph.
|
|
## Extends the existing debug_overlay.gd stub.
|
|
class_name TestDebugOverlaySprint19
|
|
extends GdUnitTestSuite
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
const DEBUG_SCENE_PATH: String = "res://scenes/main.tscn"
|
|
const DEBUG_SCRIPT_PATH: String = "res://ui/debug_overlay.gd"
|
|
|
|
|
|
func _make_overlay() -> Control:
|
|
## Instantiate a standalone DebugOverlay control for unit testing.
|
|
## Does not require the full main.tscn scene tree.
|
|
var script := load(DEBUG_SCRIPT_PATH)
|
|
if script == null:
|
|
push_warning("TestDebugOverlaySprint19: debug_overlay.gd not found — skip")
|
|
return null
|
|
var node := Control.new()
|
|
node.set_script(script)
|
|
add_child(node)
|
|
return node
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lifecycle
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func before_test() -> void:
|
|
GameState.visible_entities = []
|
|
GameState.visible_tiles = []
|
|
GameState.player_position = Vector2(10.0, 10.0)
|
|
GameState.player_facing = "North"
|
|
GameState.player_stance = "Walk"
|
|
GameState.current_tick = 1
|
|
GameState.player_knowledge = null
|
|
|
|
|
|
func after_test() -> void:
|
|
GameState.visible_entities = []
|
|
GameState.player_knowledge = null
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Script existence
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func test_debug_overlay_script_exists() -> void:
|
|
assert_bool(ResourceLoader.exists(DEBUG_SCRIPT_PATH)).override_failure_message(
|
|
"debug_overlay.gd must exist at res://ui/debug_overlay.gd (#348)"
|
|
).is_true()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Instantiation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func test_debug_overlay_instantiates_without_crash() -> void:
|
|
var ol := _make_overlay()
|
|
if ol == null: return
|
|
assert_that(ol).is_not_null()
|
|
ol.queue_free()
|
|
|
|
|
|
func test_debug_overlay_starts_hidden() -> void:
|
|
## Overlay starts hidden — only appears when F3 pressed.
|
|
var ol := _make_overlay()
|
|
if ol == null: return
|
|
assert_bool(ol.visible).override_failure_message(
|
|
"DebugOverlay must start hidden (visible=false)"
|
|
).is_false()
|
|
ol.queue_free()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Dev-only guard
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func test_update_from_state_exists() -> void:
|
|
var ol := _make_overlay()
|
|
if ol == null: return
|
|
assert_bool(ol.has_method("update_from_state")).override_failure_message(
|
|
"DebugOverlay must have update_from_state() method"
|
|
).is_true()
|
|
ol.queue_free()
|
|
|
|
|
|
func test_update_from_state_does_not_crash_when_hidden() -> void:
|
|
## update_from_state() called while hidden must not crash.
|
|
var ol := _make_overlay()
|
|
if ol == null: return
|
|
ol.visible = false
|
|
ol.update_from_state() # Should be a no-op, no crash
|
|
ol.queue_free()
|
|
|
|
|
|
func test_update_from_state_does_not_crash_when_visible() -> void:
|
|
var ol := _make_overlay()
|
|
if ol == null: return
|
|
ol.visible = true
|
|
# Simulate a minimal snapshot tick
|
|
GameState.current_tick = 42
|
|
ol.update_from_state()
|
|
ol.queue_free()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# NPC path tracking
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func test_npc_paths_field_exists() -> void:
|
|
var ol := _make_overlay()
|
|
if ol == null: return
|
|
assert_bool(ol.has("_npc_paths")).override_failure_message(
|
|
"DebugOverlay must have _npc_paths field for NPC movement history"
|
|
).is_true()
|
|
ol.queue_free()
|
|
|
|
|
|
func test_npc_paths_updated_on_state_update() -> void:
|
|
## After update_from_state with an NPC entity, _npc_paths should have an entry.
|
|
var ol := _make_overlay()
|
|
if ol == null: return
|
|
ol.visible = true
|
|
|
|
GameState.visible_entities = [{
|
|
"entity_id": 2,
|
|
"x": 12.0, "y": 9.0, "z": 0,
|
|
"kind": {"variant": "Npc", "data": null},
|
|
"relationship": "Unknown",
|
|
}]
|
|
GameState.current_tick = 100
|
|
ol.update_from_state()
|
|
assert_int(ol._npc_paths.size()).override_failure_message(
|
|
"_npc_paths must record NPC positions from visible_entities"
|
|
).is_greater(0)
|
|
ol.queue_free()
|
|
|
|
|
|
func test_npc_paths_not_populated_for_player_entity() -> void:
|
|
## Player entities must not appear in NPC path history.
|
|
var ol := _make_overlay()
|
|
if ol == null: return
|
|
ol.visible = true
|
|
GameState.visible_entities = [{
|
|
"entity_id": 1,
|
|
"x": 10.0, "y": 10.0, "z": 0,
|
|
"kind": {"variant": "Player", "data": null},
|
|
}]
|
|
GameState.current_tick = 101
|
|
ol.update_from_state()
|
|
assert_int(ol._npc_paths.size()).override_failure_message(
|
|
"Player entity must not appear in _npc_paths"
|
|
).is_equal(0)
|
|
ol.queue_free()
|
|
|
|
|
|
func test_npc_paths_max_length_respected() -> void:
|
|
## Path history must not grow beyond NPC_HISTORY_LEN entries.
|
|
var ol := _make_overlay()
|
|
if ol == null: return
|
|
ol.visible = true
|
|
# Simulate NPC moving each tick — inject 20 ticks of movement
|
|
for i in range(20):
|
|
GameState.visible_entities = [{
|
|
"entity_id": 5,
|
|
"x": float(12 + i), "y": 9.0, "z": 0,
|
|
"kind": {"variant": "Npc", "data": null},
|
|
"relationship": "Unknown",
|
|
}]
|
|
GameState.current_tick = 200 + i
|
|
ol.update_from_state()
|
|
var path: Array = ol._npc_paths.get(5, [])
|
|
assert_int(path.size()).override_failure_message(
|
|
"NPC path must not exceed NPC_HISTORY_LEN entries (cap at %d)" % ol.NPC_HISTORY_LEN
|
|
).is_less_equal(ol.NPC_HISTORY_LEN)
|
|
ol.queue_free()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tick timing ring
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func test_tick_deltas_field_exists() -> void:
|
|
var ol := _make_overlay()
|
|
if ol == null: return
|
|
assert_bool(ol.has("_tick_deltas")).override_failure_message(
|
|
"DebugOverlay must have _tick_deltas field for timing sparkline"
|
|
).is_true()
|
|
ol.queue_free()
|
|
|
|
|
|
func test_tick_deltas_accumulate_over_state_updates() -> void:
|
|
## Each new tick snapshot should add a delta to _tick_deltas.
|
|
var ol := _make_overlay()
|
|
if ol == null: return
|
|
ol.visible = true
|
|
for i in range(5):
|
|
GameState.current_tick = 300 + i
|
|
ol.update_from_state()
|
|
assert_int(ol._tick_deltas.size()).override_failure_message(
|
|
"_tick_deltas must accumulate entries from successive ticks"
|
|
).is_greater(0)
|
|
ol.queue_free()
|
|
|
|
|
|
func test_tick_deltas_max_length_respected() -> void:
|
|
## _tick_deltas must not grow beyond TICK_HISTORY_LEN.
|
|
var ol := _make_overlay()
|
|
if ol == null: return
|
|
ol.visible = true
|
|
for i in range(50):
|
|
GameState.current_tick = 400 + i
|
|
ol.update_from_state()
|
|
assert_int(ol._tick_deltas.size()).override_failure_message(
|
|
"_tick_deltas must not exceed TICK_HISTORY_LEN entries"
|
|
).is_less_equal(ol.TICK_HISTORY_LEN)
|
|
ol.queue_free()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Constants defined
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func test_npc_history_len_constant_exists() -> void:
|
|
var ol := _make_overlay()
|
|
if ol == null: return
|
|
assert_bool(ol.has("NPC_HISTORY_LEN")).override_failure_message(
|
|
"DebugOverlay must have NPC_HISTORY_LEN constant"
|
|
).is_true()
|
|
ol.queue_free()
|
|
|
|
|
|
func test_tick_history_len_constant_exists() -> void:
|
|
var ol := _make_overlay()
|
|
if ol == null: return
|
|
assert_bool(ol.has("TICK_HISTORY_LEN")).override_failure_message(
|
|
"DebugOverlay must have TICK_HISTORY_LEN constant"
|
|
).is_true()
|
|
ol.queue_free()
|
|
|
|
|
|
func test_tick_warn_ms_constant_defined() -> void:
|
|
var ol := _make_overlay()
|
|
if ol == null: return
|
|
assert_bool(ol.has("TICK_WARN_MS")).override_failure_message(
|
|
"DebugOverlay must have TICK_WARN_MS constant for sparkline warning threshold"
|
|
).is_true()
|
|
ol.queue_free()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Facing angle helper
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func test_facing_to_angle_north() -> void:
|
|
## North = -PI/2 in Godot 2D (up on screen)
|
|
var angle := _fetch_facing_angle("North")
|
|
assert_float(angle).override_failure_message(
|
|
"_facing_to_angle('North') must return -PI/2"
|
|
).is_equal_approx(-PI / 2.0, 0.001)
|
|
|
|
|
|
func test_facing_to_angle_east() -> void:
|
|
var angle := _fetch_facing_angle("East")
|
|
assert_float(angle).is_equal_approx(0.0, 0.001)
|
|
|
|
|
|
func test_facing_to_angle_south() -> void:
|
|
var angle := _fetch_facing_angle("South")
|
|
assert_float(angle).is_equal_approx(PI / 2.0, 0.001)
|
|
|
|
|
|
func test_facing_to_angle_west() -> void:
|
|
var angle := _fetch_facing_angle("West")
|
|
assert_float(angle).is_equal_approx(PI, 0.001)
|
|
|
|
|
|
func _fetch_facing_angle(facing: String) -> float:
|
|
## Helper: load script and call static method.
|
|
var script = load(DEBUG_SCRIPT_PATH)
|
|
if script == null:
|
|
return 0.0
|
|
# In GDScript 4, static methods can be called via an instance
|
|
var tmp := Control.new()
|
|
tmp.set_script(script)
|
|
add_child(tmp)
|
|
var result := tmp._facing_to_angle(facing)
|
|
tmp.queue_free()
|
|
return result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# In-scene placement: DebugOverlay on UILayer
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func test_debug_overlay_in_main_scene_ui_layer() -> void:
|
|
## DebugOverlay must be in UILayer (CanvasLayer 20), not InsertOverlay.
|
|
if not ResourceLoader.exists("res://scenes/main.tscn"):
|
|
push_warning("TestDebugOverlaySprint19: main.tscn not found — skip")
|
|
return
|
|
var scene: Node = load("res://scenes/main.tscn").instantiate()
|
|
auto_free(scene)
|
|
add_child(scene)
|
|
var ui_layer := scene.get_node_or_null("UILayer")
|
|
assert_that(ui_layer != null).override_failure_message(
|
|
"UILayer must exist in main.tscn"
|
|
).is_true()
|
|
if ui_layer == null: return
|
|
var overlay := ui_layer.get_node_or_null("DebugOverlay")
|
|
assert_that(overlay != null).override_failure_message(
|
|
"DebugOverlay must be a child of UILayer in main.tscn (#348)"
|
|
).is_true()
|