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