## 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()