diff --git a/client/tests/test_examine_display_sprint18.gd b/client/tests/test_examine_display_sprint18.gd new file mode 100644 index 000000000..d5479e9d3 --- /dev/null +++ b/client/tests/test_examine_display_sprint18.gd @@ -0,0 +1,334 @@ +## Sprint 18 — Examine result display (#174) +## Spec refs: D-061 (adjacent to dialogue spec), D-041 (character-filtered observation) +## +## ExamineDisplay: non-interactive overlay, diegetic, auto-dismisses after DISMISS_DELAY. +## Positioned in InsertOverlay (CanvasLayer 10). +## GameState.current_examine_result: cleared every snapshot (unlike player_knowledge). +## +## Tests run against live Stig implementation (examine_display.gd). +class_name TestExamineDisplaySprint18 +extends GdUnitTestSuite + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +const EXAMINE_SCENE_PATH: String = "res://ui/examine_display.tscn" + +func _make_examine_display() -> Control: + if not ResourceLoader.exists(EXAMINE_SCENE_PATH): + push_warning("TestExamineDisplaySprint18: examine_display.tscn not found — skip") + return null + var node: Control = load(EXAMINE_SCENE_PATH).instantiate() + add_child(node) + return node + + +func _make_result(overrides: Dictionary = {}) -> Dictionary: + var base: Dictionary = { + "entity_id": 42, + "text": "Kael Davan — nervous energy. He's scanning exits.", + "confidence": "KnowsOf", + } + base.merge(overrides, true) + return base + + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + +func before_test() -> void: + GameState.current_examine_result = null + +func after_test() -> void: + GameState.current_examine_result = null + + +# --------------------------------------------------------------------------- +# GameState: current_examine_result parsing +## (Now tests real implementation — not test-first stubs) +# --------------------------------------------------------------------------- + +func test_gamestate_examine_result_field_exists() -> void: + assert_bool(GameState.has("current_examine_result")).override_failure_message( + "GameState must have 'current_examine_result' field (#174)" + ).is_true() + + +func test_gamestate_examine_result_null_by_default() -> void: + GameState.current_examine_result = null + assert_that(GameState.current_examine_result).is_null() + + +func test_gamestate_examine_result_set_from_snapshot() -> void: + GameState.apply_snapshot({"tick": 5, "examine_result": _make_result()}) + assert_that(GameState.current_examine_result).is_not_null() + assert_that(GameState.current_examine_result.get("text")).contains("Kael Davan") + + +func test_gamestate_examine_result_null_when_absent() -> void: + ## CONTRAST with player_knowledge: examine_result DOES clear each snapshot. + ## The overlay must auto-dismiss — the server never re-sends the same result. + GameState.current_examine_result = _make_result() + GameState.apply_snapshot({"tick": 6}) + assert_that(GameState.current_examine_result).is_null() + + +func test_gamestate_examine_result_null_when_non_dict() -> void: + GameState.apply_snapshot({"tick": 1, "examine_result": "bad-value"}) + assert_that(GameState.current_examine_result).is_null() + + +func test_gamestate_examine_result_entity_id_survives_roundtrip() -> void: + GameState.apply_snapshot({"tick": 1, "examine_result": _make_result({"entity_id": 99})}) + assert_int(GameState.current_examine_result.get("entity_id", -1)).is_equal(99) + + +func test_gamestate_examine_result_confidence_survives_roundtrip() -> void: + GameState.apply_snapshot({"tick": 1, "examine_result": _make_result({"confidence": "Direct"})}) + assert_that(GameState.current_examine_result.get("confidence")).is_equal("Direct") + + +func test_gamestate_examine_result_replaced_on_next_snapshot() -> void: + ## Two examine results in sequence — second replaces first. + GameState.apply_snapshot({"tick": 1, "examine_result": _make_result({"text": "First observation."})}) + GameState.apply_snapshot({"tick": 2, "examine_result": _make_result({"text": "Second observation."})}) + assert_that(GameState.current_examine_result.get("text")).is_equal("Second observation.") + + +# --------------------------------------------------------------------------- +# ExamineDisplay scene +# --------------------------------------------------------------------------- + +func test_examine_display_scene_exists() -> void: + assert_bool(ResourceLoader.exists(EXAMINE_SCENE_PATH)).override_failure_message( + "ExamineDisplay scene must exist at res://ui/examine_display.tscn" + ).is_true() + + +func test_examine_display_instantiates_without_crash() -> void: + var display := _make_examine_display() + if display == null: return + assert_that(display).is_not_null() + display.queue_free() + + +func test_examine_display_has_show_result_method() -> void: + var display := _make_examine_display() + if display == null: return + assert_bool(display.has_method("show_result")).override_failure_message( + "ExamineDisplay must have show_result(result: Dictionary) method" + ).is_true() + display.queue_free() + + +func test_examine_display_has_dismiss_method() -> void: + var display := _make_examine_display() + if display == null: return + assert_bool(display.has_method("dismiss")).override_failure_message( + "ExamineDisplay must have dismiss() method" + ).is_true() + display.queue_free() + + +func test_examine_display_has_is_active_method() -> void: + var display := _make_examine_display() + if display == null: return + assert_bool(display.has_method("is_active")).override_failure_message( + "ExamineDisplay must have is_active() method" + ).is_true() + display.queue_free() + + +# --------------------------------------------------------------------------- +# ExamineDisplay behavior +# --------------------------------------------------------------------------- + +func test_examine_display_not_active_on_init() -> void: + var display := _make_examine_display() + if display == null: return + assert_bool(display.is_active()).override_failure_message( + "ExamineDisplay must start inactive (no result showing)" + ).is_false() + display.queue_free() + + +func test_examine_display_not_visible_on_init() -> void: + var display := _make_examine_display() + if display == null: return + assert_bool(display.visible).override_failure_message( + "ExamineDisplay must start invisible" + ).is_false() + display.queue_free() + + +func test_examine_display_active_after_show_result() -> void: + ## show_result() with valid text sets is_active() = true. + var display := _make_examine_display() + if display == null: return + display.show_result(_make_result()) + assert_bool(display.is_active()).override_failure_message( + "show_result() with text must set is_active() = true" + ).is_true() + display.queue_free() + + +func test_examine_display_visible_after_show_result() -> void: + var display := _make_examine_display() + if display == null: return + display.show_result(_make_result()) + assert_bool(display.visible).override_failure_message( + "show_result() must set visible = true" + ).is_true() + display.queue_free() + + +func test_examine_display_empty_text_ignored() -> void: + ## show_result() with empty text must not activate (D-041: no empty observations). + var display := _make_examine_display() + if display == null: return + display.show_result({"entity_id": 1, "text": "", "confidence": "KnowsOf"}) + assert_bool(display.is_active()).override_failure_message( + "show_result() with empty text must not activate the display" + ).is_false() + display.queue_free() + + +func test_examine_display_inactive_after_dismiss() -> void: + ## dismiss() immediately starts fade-out and sets _active = false. + var display := _make_examine_display() + if display == null: return + display.show_result(_make_result()) + assert_bool(display.is_active()).is_true() + display.dismiss() + assert_bool(display.is_active()).override_failure_message( + "dismiss() must set is_active() = false immediately" + ).is_false() + display.queue_free() + + +func test_examine_display_dismiss_when_inactive_no_crash() -> void: + ## dismiss() on an inactive display must be safe (no crash, no state corruption). + var display := _make_examine_display() + if display == null: return + display.dismiss() # called when not active + assert_bool(display.is_active()).is_false() + display.queue_free() + + +func test_examine_display_show_replaces_previous() -> void: + ## Second show_result() replaces first (only one result at a time). + var display := _make_examine_display() + if display == null: return + display.show_result(_make_result({"text": "First observation."})) + display.show_result(_make_result({"text": "Second observation."})) + assert_bool(display.is_active()).override_failure_message( + "show_result() called twice must leave display active" + ).is_true() + ## Text label should reflect the second result + var text_label := display.get_node_or_null("PanelContainer/MarginContainer/TextLabel") + if text_label is RichTextLabel: + assert_that(text_label.text).override_failure_message( + "Second show_result() must replace the displayed text" + ).is_equal("Second observation.") + display.queue_free() + + +# --------------------------------------------------------------------------- +# ExamineDisplay: DISMISS_DELAY within spec +# --------------------------------------------------------------------------- + +func test_dismiss_delay_within_spec() -> void: + ## Spec (sprint-18/client.md): auto-dismisses after 4–6 seconds. + var display := _make_examine_display() + if display == null: return + assert_float(display.DISMISS_DELAY).override_failure_message( + "DISMISS_DELAY must be 4–6 seconds per spec" + ).is_between(4.0, 6.0) + display.queue_free() + + +# --------------------------------------------------------------------------- +# ExamineDisplay: CONFIDENCE_ALPHA — confidence-based alpha modulation +# --------------------------------------------------------------------------- + +func test_confidence_alpha_dict_covers_all_levels() -> void: + ## All four confidence levels must have alpha mappings. + var display := _make_examine_display() + if display == null: return + var alpha_dict: Dictionary = display.CONFIDENCE_ALPHA + for level in ["Direct", "KnowsDetails", "KnowsOf", "Suspects"]: + assert_bool(alpha_dict.has(level)).override_failure_message( + "CONFIDENCE_ALPHA must map '%s'" % level + ).is_true() + display.queue_free() + + +func test_confidence_alpha_direct_is_highest() -> void: + ## Direct confidence = brightest (alpha 1.0). Character fully trusts this observation. + var display := _make_examine_display() + if display == null: return + var alpha_dict: Dictionary = display.CONFIDENCE_ALPHA + assert_float(alpha_dict.get("Direct", 0.0)).override_failure_message( + "Direct confidence must have alpha 1.0 (brightest)" + ).is_equal_approx(1.0, 0.001) + display.queue_free() + + +func test_confidence_alpha_suspects_is_lowest() -> void: + ## Suspects = most dimmed (lowest alpha). Uncertainty is visually represented. + var display := _make_examine_display() + if display == null: return + var alpha_dict: Dictionary = display.CONFIDENCE_ALPHA + var suspects_alpha: float = alpha_dict.get("Suspects", 1.0) + var direct_alpha: float = alpha_dict.get("Direct", 0.0) + assert_float(suspects_alpha).override_failure_message( + "Suspects alpha must be less than Direct alpha (dimmer = less certain)" + ).is_less(direct_alpha) + display.queue_free() + + +func test_confidence_alpha_all_values_valid() -> void: + ## All alpha values must be in [0.0, 1.0]. + var display := _make_examine_display() + if display == null: return + for key in display.CONFIDENCE_ALPHA: + var alpha: float = display.CONFIDENCE_ALPHA[key] + assert_float(alpha).override_failure_message( + "CONFIDENCE_ALPHA['%s'] = %.2f must be in [0, 1]" % [key, alpha] + ).is_between(0.0, 1.0) + display.queue_free() + + +# --------------------------------------------------------------------------- +# Non-interactive: mouse_filter must be IGNORE +# --------------------------------------------------------------------------- + +func test_examine_display_mouse_filter_ignore() -> void: + ## ExamineDisplay is non-interactive — must not consume mouse events. + var display := _make_examine_display() + if display == null: return + assert_int(display.mouse_filter).override_failure_message( + "ExamineDisplay must have mouse_filter=IGNORE (non-interactive overlay)" + ).is_equal(Control.MOUSE_FILTER_IGNORE) + display.queue_free() + + +# --------------------------------------------------------------------------- +# Fade constants +# --------------------------------------------------------------------------- + +func test_fade_in_is_short() -> void: + var display := _make_examine_display() + if display == null: return + assert_float(display.FADE_IN).is_between(0.0, 0.5) + display.queue_free() + + +func test_fade_out_is_short() -> void: + var display := _make_examine_display() + if display == null: return + assert_float(display.FADE_OUT).is_between(0.0, 0.5) + display.queue_free() diff --git a/client/tests/test_minimap_sprint18.gd b/client/tests/test_minimap_sprint18.gd new file mode 100644 index 000000000..5013ed5e6 --- /dev/null +++ b/client/tests/test_minimap_sprint18.gd @@ -0,0 +1,320 @@ +## 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" + +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 = load("res://scenes/main.tscn").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 = load("res://scenes/main.tscn").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)