## Sprint 18 — Minimap rendering (#151) ## Spec refs: D-013 (diegetic insert/POI system), D-015 (fixed-north, player-centered), ## D-049 (z-layer 6 = InsertOverlay) ## ## MinimapRenderer: circular insert overlay, always renders frame, draws discovered POIs. ## Scene: res://ui/minimap.tscn (class_name MinimapRenderer) ## Positioned at InsertOverlay/Minimap in main.tscn. ## ## Tests run against live Stig implementation (minimap.gd). class_name TestMinimapSprint18 extends GdUnitTestSuite # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- const MINIMAP_SCENE_PATH: String = "res://ui/minimap.tscn" const MAIN_SCENE = preload("res://scenes/main.tscn") func _make_minimap() -> Control: if not ResourceLoader.exists(MINIMAP_SCENE_PATH): push_warning("TestMinimapSprint18: minimap.tscn not found — skip") return null var node: Control = load(MINIMAP_SCENE_PATH).instantiate() add_child(node) return node func _make_poi(overrides: Dictionary = {}) -> Dictionary: var base: Dictionary = { "id": "poi_test_001", "x": 20, "y": 15, "poi_category": "location", "label": "Exit A", } base.merge(overrides, true) return base # --------------------------------------------------------------------------- # Lifecycle # --------------------------------------------------------------------------- func before_test() -> void: GameState.discovered_pois = [] GameState.player_position = Vector2(10.0, 10.0) GameState.insert_active = true func after_test() -> void: GameState.discovered_pois = [] GameState.player_position = Vector2.ZERO GameState.insert_active = true # --------------------------------------------------------------------------- # Scene and class # --------------------------------------------------------------------------- func test_minimap_scene_exists() -> void: assert_bool(ResourceLoader.exists(MINIMAP_SCENE_PATH)).override_failure_message( "Minimap scene must exist at res://ui/minimap.tscn (#151)" ).is_true() func test_minimap_instantiates_without_crash() -> void: var mm := _make_minimap() if mm == null: return assert_that(mm).is_not_null() mm.queue_free() func test_minimap_is_minimap_renderer_class() -> void: ## class_name MinimapRenderer in minimap.gd. var mm := _make_minimap() if mm == null: return assert_bool(mm is MinimapRenderer).override_failure_message( "Minimap node must be a MinimapRenderer instance (check class_name in minimap.gd)" ).is_true() mm.queue_free() # --------------------------------------------------------------------------- # Constants: D-015, visual parameters # --------------------------------------------------------------------------- func test_minimap_radius_constant() -> void: ## MINIMAP_RADIUS defines the sim-tile distance of visible POI area. ## Value is tuned to 24 tiles — reasonable coverage without map reveal. assert_float(MinimapRenderer.MINIMAP_RADIUS).override_failure_message( "MinimapRenderer.MINIMAP_RADIUS must be 24.0" ).is_equal_approx(24.0, 0.01) func test_player_dot_radius_defined() -> void: ## Player dot must be visible (> 0) and distinct from POI dot. assert_float(MinimapRenderer.PLAYER_DOT_RADIUS).is_greater(0.0) func test_poi_dot_radius_defined() -> void: ## POI dot must be visible (> 0). assert_float(MinimapRenderer.POI_DOT_RADIUS).is_greater(0.0) func test_player_dot_larger_than_poi_dot() -> void: ## D-015: Player is always centered and visually distinct. ## Player dot should be at least as large as POI dot. assert_float(MinimapRenderer.PLAYER_DOT_RADIUS).is_greater_equal(MinimapRenderer.POI_DOT_RADIUS) # --------------------------------------------------------------------------- # _category_color() — D-013 POI category color mapping # --------------------------------------------------------------------------- func test_category_color_danger_is_hostile_color() -> void: ## "danger", "threat", "hostile" → ENTITY_COLOR_HOSTILE (red) for cat in ["danger", "threat", "hostile"]: var color: Color = MinimapRenderer._category_color(cat) assert_that(color).override_failure_message( "Category '%s' must map to ENTITY_COLOR_HOSTILE" % cat ).is_equal(Constants.ENTITY_COLOR_HOSTILE) func test_category_color_evidence_is_poi_color() -> void: ## "evidence", "note", "clue" → ENTITY_COLOR_POI (amber) for cat in ["evidence", "note", "clue"]: var color: Color = MinimapRenderer._category_color(cat) assert_that(color).override_failure_message( "Category '%s' must map to ENTITY_COLOR_POI (amber)" % cat ).is_equal(Constants.ENTITY_COLOR_POI) func test_category_color_contact_is_unknown_color() -> void: ## "contact", "npc", "person" → ENTITY_COLOR_UNKNOWN (teal) for cat in ["contact", "npc", "person"]: var color: Color = MinimapRenderer._category_color(cat) assert_that(color).override_failure_message( "Category '%s' must map to ENTITY_COLOR_UNKNOWN (teal)" % cat ).is_equal(Constants.ENTITY_COLOR_UNKNOWN) func test_category_color_unknown_category_defaults_to_insert_text() -> void: ## Unknown/unspecified categories → INSERT_COLOR_TEXT (white-blue default) var color: Color = MinimapRenderer._category_color("some_unknown_type") assert_that(color).override_failure_message( "Unknown category must default to INSERT_COLOR_TEXT" ).is_equal(Constants.INSERT_COLOR_TEXT) func test_category_color_empty_string_defaults() -> void: ## Empty category string → default color, no crash. var color: Color = MinimapRenderer._category_color("") assert_that(color).is_equal(Constants.INSERT_COLOR_TEXT) func test_category_color_case_insensitive() -> void: ## Category matching is case-insensitive (uses to_lower()). var danger_lower := MinimapRenderer._category_color("danger") var danger_upper := MinimapRenderer._category_color("DANGER") var danger_mixed := MinimapRenderer._category_color("Danger") assert_that(danger_lower).is_equal(danger_upper) assert_that(danger_lower).is_equal(danger_mixed) # --------------------------------------------------------------------------- # set_insert_active() — D-049: insert layer visibility # --------------------------------------------------------------------------- func test_set_insert_active_false_hides_minimap() -> void: ## When insert is inactive, minimap must be hidden. var mm := _make_minimap() if mm == null: return mm.set_insert_active(false) assert_bool(mm.visible).override_failure_message( "set_insert_active(false) must hide the minimap" ).is_false() mm.queue_free() func test_set_insert_active_true_shows_minimap() -> void: ## When insert is active, minimap must be visible. var mm := _make_minimap() if mm == null: return mm.set_insert_active(false) mm.set_insert_active(true) assert_bool(mm.visible).override_failure_message( "set_insert_active(true) must show the minimap" ).is_true() mm.queue_free() # --------------------------------------------------------------------------- # Main scene structural check: InsertOverlay/Minimap # --------------------------------------------------------------------------- func test_minimap_in_main_scene_on_insert_overlay() -> void: ## D-049: Minimap must be in InsertOverlay (CanvasLayer 10), not UILayer. ## Scene path: Game/InsertOverlay/Minimap or InsertOverlay/Minimap. if not ResourceLoader.exists("res://scenes/main.tscn"): push_warning("TestMinimapSprint18: main.tscn not found — scene tree test skipped") return var scene: Node = MAIN_SCENE.instantiate() auto_free(scene) add_child(scene) # Check for Minimap in InsertOverlay var insert_overlay := scene.get_node_or_null("InsertOverlay") assert_that(insert_overlay != null).override_failure_message( "InsertOverlay (CanvasLayer 10) must exist in main.tscn" ).is_true() if insert_overlay == null: return var minimap := insert_overlay.get_node_or_null("Minimap") assert_that(minimap != null).override_failure_message( "Minimap must be a child of InsertOverlay in main.tscn (D-049: insert layer)" ).is_true() if minimap == null: return assert_bool(minimap is MinimapRenderer).override_failure_message( "InsertOverlay/Minimap must be a MinimapRenderer instance" ).is_true() func test_insert_overlay_is_canvas_layer_10() -> void: ## InsertOverlay must be CanvasLayer 10 (CANVAS_INSERT per D-049). if not ResourceLoader.exists("res://scenes/main.tscn"): push_warning("TestMinimapSprint18: main.tscn not found — canvas layer test skipped") return var scene: Node = MAIN_SCENE.instantiate() auto_free(scene) add_child(scene) var insert_overlay := scene.get_node_or_null("InsertOverlay") as CanvasLayer if insert_overlay == null: return assert_int(insert_overlay.layer).override_failure_message( "InsertOverlay must be CanvasLayer %d (CANVAS_INSERT)" % Constants.CANVAS_INSERT ).is_equal(Constants.CANVAS_INSERT) # --------------------------------------------------------------------------- # GameState.discovered_pois integration # --------------------------------------------------------------------------- func test_discovered_pois_field_exists_in_gamestate() -> void: assert_bool(GameState.has("discovered_pois")).override_failure_message( "GameState must have 'discovered_pois' field (#151)" ).is_true() func test_discovered_pois_set_from_poi_list_snapshot() -> void: ## Snapshot with "poi_list" key (Sprint 17 server wire name) populates discovered_pois. GameState.apply_snapshot({ "tick": 1, "poi_list": [ _make_poi({"id": "p1", "x": 50, "y": 30, "poi_category": "location"}), _make_poi({"id": "p2", "x": 80, "y": 15, "poi_category": "contact"}), ], }) assert_int(GameState.discovered_pois.size()).override_failure_message( "discovered_pois must be populated from snapshot 'poi_list' field" ).is_equal(2) func test_discovered_pois_set_from_discovered_pois_snapshot() -> void: ## Snapshot with "discovered_pois" key also works. GameState.apply_snapshot({ "tick": 2, "discovered_pois": [_make_poi()], }) assert_int(GameState.discovered_pois.size()).is_equal(1) func test_discovered_pois_persists_when_absent_from_snapshot() -> void: ## Like player_knowledge: POI list persists when server doesn't send an update. GameState.discovered_pois = [_make_poi()] GameState.apply_snapshot({"tick": 3}) assert_int(GameState.discovered_pois.size()).override_failure_message( "discovered_pois must persist when absent from snapshot (not cleared each tick)" ).is_equal(1) func test_discovered_pois_poi_category_field_present() -> void: ## MinimapRenderer reads poi_category to determine shape/color. ## Verify the wire format includes this field. GameState.apply_snapshot({ "tick": 1, "poi_list": [_make_poi({"poi_category": "danger"})], }) assert_int(GameState.discovered_pois.size()).is_greater(0) var first_poi: Dictionary = GameState.discovered_pois[0] assert_bool(first_poi.has("poi_category")).override_failure_message( "POI entries must have 'poi_category' field for MinimapRenderer shape selection" ).is_true() func test_discovered_pois_x_y_fields_present() -> void: ## MinimapRenderer reads x, y for position calculation. GameState.apply_snapshot({ "tick": 1, "poi_list": [_make_poi({"x": 42, "y": 17})], }) assert_int(GameState.discovered_pois.size()).is_greater(0) var first_poi: Dictionary = GameState.discovered_pois[0] assert_bool(first_poi.has("x") and first_poi.has("y")).override_failure_message( "POI entries must have 'x' and 'y' coordinate fields" ).is_true() # --------------------------------------------------------------------------- # Color constants: all distinct # --------------------------------------------------------------------------- func test_category_colors_are_distinct() -> void: ## All three primary category color groups must be visually distinct. var danger_color := MinimapRenderer._category_color("danger") var evidence_color := MinimapRenderer._category_color("evidence") var contact_color := MinimapRenderer._category_color("contact") assert_that(danger_color).is_not_equal(evidence_color) assert_that(evidence_color).is_not_equal(contact_color) assert_that(danger_color).is_not_equal(contact_color)