## Sprint 18 — Dialogue UI hardening + examine result display (#174) ## Spec refs: D-061 (dialogue box), D-062 (invisible locked options), D-063 (confrontation), ## D-064 (walk-away), D-078 (overheard log) ## ## Test plan from joint.md: ## "Manual: examine result appears as overlay, auto-dismisses. ## Dialogue options confirmed: no locked/grayed options visible. ## Confrontation option in italic voice." ## ## Unit-testable coverage here: ## - D-062: no locked/grayed option mechanism in dialogue_box.gd ## - D-063: confrontation beat duration, signal, dim alpha ## - D-064: walk-away fires dialogue_dismissed signal ## - GameState: current_dialogue parsing ## - GameState: current_examine_result parsing (test-first — impl TBD in #174) ## - BBCode: escape contract (Hoshe #2 regression guard) ## - Dirty flag: _log_dirty optimization (Hoshe #1 regression guard) class_name TestDialogueSprint18 extends GdUnitTestSuite # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- func _make_dialogue_box() -> Control: if not ResourceLoader.exists("res://ui/dialogue_box.tscn"): push_warning("TestDialogueSprint18: dialogue_box.tscn not found — scene tests skipped") return null var node: Control = load("res://ui/dialogue_box.tscn").instantiate() add_child(node) return node func _make_options(texts: Array[String], confrontation_flags: Array[bool] = []) -> Array: var opts: Array = [] for i in range(texts.size()): var opt: Dictionary = { "text": texts[i], "response_id": "r%d" % i, "priority": i, } if confrontation_flags.size() > i: opt["confrontation"] = confrontation_flags[i] opts.append(opt) return opts # --------------------------------------------------------------------------- # Lifecycle # --------------------------------------------------------------------------- func before_test() -> void: GameState.current_dialogue = null GameState.dialogue_active = false if GameState.has("current_examine_result"): GameState.current_examine_result = null func after_test() -> void: GameState.current_dialogue = null GameState.dialogue_active = false if GameState.has("current_examine_result"): GameState.current_examine_result = null # --------------------------------------------------------------------------- # D-062: No locked/grayed options ## Per D-062: locked options are INVISIBLE — not shown at all. ## The client renders all received options as active, clickable labels. ## Server responsibility: omit locked options from the array. ## Test verifies: no disabled/locked styling is applied to any rendered option. # --------------------------------------------------------------------------- func test_d062_rendered_options_have_no_disabled_state() -> void: ## D-062: All options from server render as active controls. ## No option should have mouse_filter=IGNORE (which would indicate disabled). var box := _make_dialogue_box() if box == null: return box.show_dialogue("NPC", "Hello.", _make_options(["Option A", "Option B", "Option C"])) var options_container := box.get_node_or_null("PanelContainer/MarginContainer/VBoxContainer/OptionsContainer") assert_that(options_container != null).override_failure_message("OptionsContainer must exist").is_true() var labels := options_container.get_children() assert_int(labels.size()).override_failure_message("3 options must render as 3 labels").is_equal(3) for label in labels: # MOUSE_FILTER_STOP = 0: active and clickable — correct for D-062 # MOUSE_FILTER_IGNORE = 2: would indicate disabled — D-062 violation assert_int(label.mouse_filter).override_failure_message( "Option '%s' must be mouse_filter=STOP (0), not IGNORE (2) — D-062 requires no locked options" % label.text ).is_not_equal(Control.MOUSE_FILTER_IGNORE) box.queue_free() func test_d062_no_lock_icon_children_on_options() -> void: ## D-062: Options must have no lock icon children (no TextureRect/Sprite2D children). ## Any child node on an option label would indicate a locked-option indicator. var box := _make_dialogue_box() if box == null: return box.show_dialogue("NPC", "Speech.", _make_options(["Only option"])) var options_container := box.get_node_or_null("PanelContainer/MarginContainer/VBoxContainer/OptionsContainer") if options_container == null: box.queue_free(); return var labels := options_container.get_children() assert_int(labels.size()).is_greater(0) for label in labels: assert_int(label.get_child_count()).override_failure_message( "Option label must have no child nodes — no lock icons, no decorators (D-062)" ).is_equal(0) box.queue_free() func test_d062_max_options_constant_is_three() -> void: ## D-061: max 3 response options. D-062: if >3 options arrive, only top 3 by priority ## are shown — server must omit locked ones, client only truncates to 3. ## Verify MAX_OPTIONS constant is locked at 3. var box := _make_dialogue_box() if box == null: return assert_int(box.MAX_OPTIONS).override_failure_message( "MAX_OPTIONS must be 3 per D-061 spec" ).is_equal(3) box.queue_free() func test_d062_server_sends_four_options_only_three_render() -> void: ## D-061/D-062: If server sends 4 options (shouldn't happen but guard), ## only top 3 by priority render. No 4th option appears. var box := _make_dialogue_box() if box == null: return var opts := _make_options(["A", "B", "C", "D"]) # Assign explicit priorities so sort is deterministic for i in range(opts.size()): opts[i]["priority"] = i box.show_dialogue("NPC", "Speech.", opts) var options_container := box.get_node_or_null("PanelContainer/MarginContainer/VBoxContainer/OptionsContainer") if options_container == null: box.queue_free(); return assert_int(options_container.get_child_count()).override_failure_message( "Only 3 options must render even when server sends 4 (D-061 truncation)" ).is_equal(3) box.queue_free() # --------------------------------------------------------------------------- # D-063: Confrontation beat ## Confrontation options trigger a 1.5s pre-delivery monologue beat. ## Panel dims during beat. confrontation_monologue signal fires. # --------------------------------------------------------------------------- func test_d063_beat_duration_within_spec() -> void: ## D-063: The beat duration must be 1–2 seconds per spec. ## Current implementation: CONFRONTATION_BEAT_DURATION = 1.5s. var box := _make_dialogue_box() if box == null: return assert_float(box.CONFRONTATION_BEAT_DURATION).override_failure_message( "D-063: confrontation beat must be 1.0–2.0 seconds" ).is_between(1.0, 2.0) box.queue_free() func test_d063_dim_alpha_is_set() -> void: ## D-063: The dialogue box dims during the confrontation beat. ## CONFRONTATION_DIM_ALPHA must be below 1.0 (not full opacity). var box := _make_dialogue_box() if box == null: return assert_float(box.CONFRONTATION_DIM_ALPHA).override_failure_message( "D-063: confrontation dim alpha must be < 1.0 (panel visibly dims)" ).is_less(1.0) assert_float(box.CONFRONTATION_DIM_ALPHA).override_failure_message( "D-063: confrontation dim alpha must be > 0.0 (panel still visible)" ).is_greater(0.0) box.queue_free() func test_d063_confrontation_signal_fires_on_confrontation_option() -> void: ## D-063: Selecting a confrontation option fires confrontation_monologue signal. ## This delivers the 1-2 second internal monologue beat to MonologueDisplay. var box := _make_dialogue_box() if box == null: return var signal_fired := false var received_text := "" box.confrontation_monologue.connect(func(text: String, _dur: float): signal_fired = true received_text = text ) # Show dialogue with one confrontation option var opts := _make_options(["I know what you did."], [true]) box.show_dialogue("NPC", "Everything is fine.", opts) # Press option 1 (index 0) box._on_option_pressed(0) assert_bool(signal_fired).override_failure_message( "D-063: confrontation_monologue signal must fire when confrontation option is selected" ).is_true() box.queue_free() func test_d063_non_confrontation_option_does_not_fire_beat_signal() -> void: ## D-063: Standard (non-confrontation) options must NOT fire confrontation_monologue. var box := _make_dialogue_box() if box == null: return var signal_fired := false box.confrontation_monologue.connect(func(_text: String, _dur: float): signal_fired = true ) var opts := _make_options(["A normal response."], [false]) box.show_dialogue("NPC", "Hello.", opts) box._on_option_pressed(0) assert_bool(signal_fired).override_failure_message( "D-063: confrontation_monologue must NOT fire for standard options" ).is_false() box.queue_free() # --------------------------------------------------------------------------- # D-064: Walk-away mechanic ## WASD during active conversation fires dialogue_dismissed signal. # --------------------------------------------------------------------------- func test_d064_walk_away_actions_constant_not_empty() -> void: ## D-064: _WALK_AWAY_ACTIONS must include at least the 8 movement directions. ## Prevents accidental empty-array regression. var box := _make_dialogue_box() if box == null: return assert_int(box._WALK_AWAY_ACTIONS.size()).override_failure_message( "D-064: _WALK_AWAY_ACTIONS must list movement directions (minimum 4)" ).is_greater_equal(4) box.queue_free() func test_d064_walk_away_actions_include_cardinal_directions() -> void: ## D-064: All four cardinal directions (WASD) must be walk-away triggers. var box := _make_dialogue_box() if box == null: return var actions: Array = box._WALK_AWAY_ACTIONS for required in [&"move_north", &"move_south", &"move_east", &"move_west"]: assert_bool(required in actions).override_failure_message( "D-064: '%s' must be in _WALK_AWAY_ACTIONS" % required ).is_true() box.queue_free() func test_d064_dialogue_dismissed_signal_connection() -> void: ## D-064: dialogue_dismissed signal must exist on DialogueBox. ## The signal drives walk-away behavior in main.gd. var box := _make_dialogue_box() if box == null: return assert_bool(box.has_signal("dialogue_dismissed")).override_failure_message( "D-064: dialogue_dismissed signal must exist on DialogueBox" ).is_true() box.queue_free() # --------------------------------------------------------------------------- # GameState: current_dialogue snapshot parsing ## D-061: current_dialogue is set from snapshot, null when absent. # --------------------------------------------------------------------------- func test_gamestate_current_dialogue_set_from_snapshot() -> void: ## apply_snapshot with current_dialogue dict populates the field. GameState.apply_snapshot({ "tick": 1, "current_dialogue": { "npc_name": "Kael Davan", "npc_entity_id": 42, "speech": "You don't belong here.", "options": [{"text": "I'm just passing through.", "response_id": "r001", "priority": 1}], }, }) assert_that(GameState.current_dialogue).is_not_null() assert_that(GameState.current_dialogue.get("npc_name")).is_equal("Kael Davan") func test_gamestate_current_dialogue_null_when_absent() -> void: ## apply_snapshot without current_dialogue clears the field. ## Prevents stale dialogue from persisting across ticks. GameState.current_dialogue = {"npc_name": "Ghost", "speech": "Stale."} GameState.apply_snapshot({"tick": 2}) assert_that(GameState.current_dialogue).is_null() func test_gamestate_current_dialogue_null_when_non_dict() -> void: ## Non-dict current_dialogue is rejected — defensive against malformed server data. GameState.apply_snapshot({"tick": 1, "current_dialogue": "not-a-dict"}) assert_that(GameState.current_dialogue).is_null() func test_gamestate_current_dialogue_options_survive_roundtrip() -> void: ## The options array must survive snapshot parsing for DialogueBox to render them. var options := [ {"text": "A", "response_id": "r1", "priority": 1}, {"text": "B", "response_id": "r2", "priority": 2}, ] GameState.apply_snapshot({ "tick": 1, "current_dialogue": { "npc_name": "NPC", "npc_entity_id": 1, "speech": "Choose.", "options": options, }, }) assert_that(GameState.current_dialogue).is_not_null() var parsed_opts: Array = GameState.current_dialogue.get("options", []) assert_int(parsed_opts.size()).is_equal(2) assert_that(parsed_opts[0].get("response_id")).is_equal("r1") # --------------------------------------------------------------------------- # GameState: current_examine_result snapshot parsing (test-first, #174) ## Sprint 18: examine verb returns character-filtered observation text. ## Field: examine_result: {entity_id: int, text: String, confidence: String} | null ## GameState must expose current_examine_result for the overlay display node. # --------------------------------------------------------------------------- func test_gamestate_examine_result_field_exists() -> void: ## GameState must have a current_examine_result field (Sprint 18, #174). ## Fails until Stig adds the field to game_state.gd. assert_bool(GameState.has("current_examine_result")).override_failure_message( "GameState must have 'current_examine_result' field (Sprint 18 #174 — add to game_state.gd)" ).is_true() func test_gamestate_examine_result_null_by_default() -> void: ## current_examine_result defaults to null (no examine active). if not GameState.has("current_examine_result"): push_warning("test_gamestate_examine_result_null_by_default: field not yet added — skip") return GameState.current_examine_result = null assert_that(GameState.current_examine_result).is_null() func test_gamestate_examine_result_set_from_snapshot() -> void: ## apply_snapshot with examine_result dict populates current_examine_result. ## Wire format (joint.md): {entity_id: int, text: String, confidence: String} if not GameState.has("current_examine_result"): push_warning("test_gamestate_examine_result_set_from_snapshot: field not yet added — skip") return GameState.apply_snapshot({ "tick": 5, "examine_result": { "entity_id": 12, "text": "Kael Davan — nervous energy. He's scanning exits.", "confidence": "KnowsOf", }, }) 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: ## apply_snapshot without examine_result must clear the field. ## Prevents stale examine overlay persisting beyond auto-dismiss window. if not GameState.has("current_examine_result"): push_warning("test_gamestate_examine_result_null_when_absent: field not yet added — skip") return GameState.current_examine_result = {"entity_id": 5, "text": "Stale.", "confidence": "Suspects"} GameState.apply_snapshot({"tick": 6}) assert_that(GameState.current_examine_result).is_null() func test_gamestate_examine_result_null_when_non_dict() -> void: ## Malformed examine_result (not a dict) must be rejected. if not GameState.has("current_examine_result"): push_warning("test_gamestate_examine_result_null_when_non_dict: field not yet added — skip") return 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: ## entity_id is needed to anchor the overlay above the correct entity. if not GameState.has("current_examine_result"): push_warning("test_gamestate_examine_result_entity_id_survives_roundtrip: field not yet added — skip") return GameState.apply_snapshot({ "tick": 1, "examine_result": {"entity_id": 99, "text": "Observed.", "confidence": "Direct"}, }) assert_int(GameState.current_examine_result.get("entity_id", -1)).is_equal(99) # --------------------------------------------------------------------------- # BBCode injection guard (regression: Hoshe #2) ## Server-sourced text containing BBCode brackets must be escaped. ## Note: dialogue_box.gd has no class_name — call _escape_bbcode via instance. # --------------------------------------------------------------------------- func test_escape_bbcode_brackets_in_server_text() -> void: ## _escape_bbcode must convert '[' to '[lb]' to prevent BBCode injection. ## Regression test: a malicious NPC name like "[wave]Evil[/wave]" must render ## as plain text in the dialogue log. var box := _make_dialogue_box() if box == null: return var escaped: String = box._escape_bbcode("[wave]Evil NPC[/wave]") assert_that(escaped).is_not_equal("[wave]Evil NPC[/wave]") assert_that(escaped).contains("[lb]") assert_bool(escaped.begins_with("[")).is_false() box.queue_free() func test_escape_bbcode_plain_text_unchanged() -> void: ## Non-BBCode text must not be modified by _escape_bbcode. var box := _make_dialogue_box() if box == null: return var plain := "Kael Davan" assert_that(box._escape_bbcode(plain)).is_equal(plain) box.queue_free() func test_escape_bbcode_multiple_brackets() -> void: ## Multiple '[' characters all get escaped. var box := _make_dialogue_box() if box == null: return var text := "[b]Bold[/b] and [i]italic[/i]" var escaped: String = box._escape_bbcode(text) assert_bool(escaped.contains("[b]")).is_false() assert_bool(escaped.contains("[i]")).is_false() box.queue_free() # --------------------------------------------------------------------------- # Log dirty flag optimization (regression: Hoshe #1) ## _log_dirty prevents per-frame O(n) BBCode rebuilds. # --------------------------------------------------------------------------- func test_log_dirty_false_on_init() -> void: ## _log_dirty starts false — no rebuild needed before first line. var box := _make_dialogue_box() if box == null: return assert_bool(box._log_dirty).override_failure_message( "_log_dirty must be false on init — no unnecessary rebuild" ).is_false() box.queue_free() func test_log_dirty_set_after_append_line() -> void: ## Appending a line sets _log_dirty = true. var box := _make_dialogue_box() if box == null: return box.append_line("NPC", "Player", "Hello.", false) assert_bool(box._log_dirty).override_failure_message( "_log_dirty must be true after append_line to trigger rebuild next _process" ).is_true() box.queue_free() func test_log_dirty_cleared_after_process() -> void: ## After _process(), _log_dirty is cleared (rebuild done). var box := _make_dialogue_box() if box == null: return box.append_line("NPC", "Player", "One line.", false) assert_bool(box._log_dirty).is_true() box._process(0.0) assert_bool(box._log_dirty).override_failure_message( "_log_dirty must be false after _process (rebuild consumed the flag)" ).is_false() box.queue_free() # --------------------------------------------------------------------------- # D-061: Dialogue box size constraints # --------------------------------------------------------------------------- func test_d061_max_height_ratio_is_twenty_percent() -> void: ## D-061: dialogue box must occupy max 20% viewport height. var box := _make_dialogue_box() if box == null: return assert_float(box.MAX_HEIGHT_RATIO).override_failure_message( "D-061: MAX_HEIGHT_RATIO must be 0.2 (20% viewport height)" ).is_equal_approx(0.2, 0.001) box.queue_free() func test_d061_fade_in_is_200ms() -> void: ## D-061: fade-in on dialogue appearance is 0.2s. var box := _make_dialogue_box() if box == null: return assert_float(box.FADE_IN).override_failure_message( "D-061: FADE_IN must be 0.2s" ).is_equal_approx(0.2, 0.001) box.queue_free() func test_d064_fade_out_is_300ms() -> void: ## D-064: walk-away fade-out is 300ms per spec. var box := _make_dialogue_box() if box == null: return assert_float(box.FADE_OUT).override_failure_message( "D-064: FADE_OUT must be 0.3s (300ms walk-away fade)" ).is_equal_approx(0.3, 0.001) box.queue_free() # --------------------------------------------------------------------------- # Passive log entry (D-078: overheard NPC-NPC) # --------------------------------------------------------------------------- func test_passive_entry_uses_bar_glyph() -> void: ## D-078: Overheard NPC-NPC lines get ┃ prefix (PASSIVE_GLYPH). ## Verifies the glyph constant is the correct Unicode bar character. var box := _make_dialogue_box() if box == null: return assert_that(box.PASSIVE_GLYPH).override_failure_message( "D-078: PASSIVE_GLYPH must be ┃ (U+2503) + space" ).is_equal("\u2503 ") box.queue_free() func test_passive_entry_appended_and_marked_is_passive() -> void: ## D-078: append_conversation_event creates a passive log entry. var box := _make_dialogue_box() if box == null: return var event := { "speaker_id": 10, "target_id": 11, "speaker_name": "Guard A", "target_name": "Guard B", "occluded_line": "Did you see the detective?", "speaker_color_index": 0, "target_color_index": 1, } box.append_conversation_event(event) assert_int(box._log_entries.size()).is_equal(1) assert_bool(box._log_entries[0].is_passive).override_failure_message( "D-078: overheard entry must be marked is_passive = true" ).is_true() box.queue_free() func test_passive_entry_empty_text_not_appended() -> void: ## D-078: Conversation event with empty occluded_line is silently dropped. var box := _make_dialogue_box() if box == null: return box.append_conversation_event({ "speaker_id": 1, "target_id": 2, "speaker_name": "A", "target_name": "B", "occluded_line": "", }) assert_int(box._log_entries.size()).override_failure_message( "D-078: empty occluded_line must not produce a log entry" ).is_equal(0) box.queue_free() # --------------------------------------------------------------------------- # has_active_entries / is_dialogue_active # --------------------------------------------------------------------------- func test_has_active_entries_false_on_init() -> void: var box := _make_dialogue_box() if box == null: return assert_bool(box.has_active_entries()).is_false() box.queue_free() func test_has_active_entries_true_after_append() -> void: var box := _make_dialogue_box() if box == null: return box.append_line("NPC", "Player", "Hey.", false) assert_bool(box.has_active_entries()).is_true() box.queue_free() func test_is_dialogue_active_false_on_init() -> void: var box := _make_dialogue_box() if box == null: return assert_bool(box.is_dialogue_active()).is_false() box.queue_free() func test_is_dialogue_active_true_after_show_dialogue() -> void: var box := _make_dialogue_box() if box == null: return box.show_dialogue("NPC", "Speech.", _make_options(["Reply"])) assert_bool(box.is_dialogue_active()).is_true() box.queue_free() # --------------------------------------------------------------------------- # D-020 (#558): Signal decoupling — dialogue_box emits signals instead of # directly mutating GameState or calling AudioManager. # --------------------------------------------------------------------------- func test_dialogue_state_changed_emits_true_on_show() -> void: ## D-020: show_dialogue() must emit dialogue_state_changed(true). var box := _make_dialogue_box() if box == null: return var received: Array = [] box.dialogue_state_changed.connect(func(active): received.append(active)) box.show_dialogue("NPC", "Speech.", _make_options(["Reply"])) assert_bool(received.has(true)).override_failure_message( "dialogue_state_changed(true) must be emitted on show_dialogue" ).is_true() box.queue_free() func test_dialogue_state_changed_emits_false_on_hide() -> void: ## D-020: hide_dialogue() must emit dialogue_state_changed(false). var box := _make_dialogue_box() if box == null: return var received: Array = [] box.dialogue_state_changed.connect(func(active): received.append(active)) box.show_dialogue("NPC", "Speech.", _make_options(["Reply"])) box.hide_dialogue() assert_bool(received.has(false)).override_failure_message( "dialogue_state_changed(false) must be emitted on hide_dialogue" ).is_true() box.queue_free() func test_audio_dip_requested_emits_dialogue_on_show() -> void: ## D-020: show_dialogue() must emit audio_dip_requested("dialogue"). var box := _make_dialogue_box() if box == null: return var received: Array = [] box.audio_dip_requested.connect(func(profile): received.append(profile)) box.show_dialogue("NPC", "Speech.", _make_options(["Reply"])) assert_bool(received.has("dialogue")).override_failure_message( "audio_dip_requested('dialogue') must be emitted on show_dialogue" ).is_true() box.queue_free() func test_audio_dip_cleared_emits_on_hide() -> void: ## D-020: hide_dialogue() must emit audio_dip_cleared. var box := _make_dialogue_box() if box == null: return var cleared := [false] box.audio_dip_cleared.connect(func(): cleared[0] = true) box.show_dialogue("NPC", "Speech.", _make_options(["Reply"])) box.hide_dialogue() assert_bool(cleared[0]).override_failure_message( "audio_dip_cleared must be emitted on hide_dialogue" ).is_true() box.queue_free() func test_no_direct_game_state_mutation() -> void: ## D-020: dialogue_box must not directly mutate GameState.dialogue_active. ## After show_dialogue, GameState.dialogue_active should remain unchanged ## (only the coordinator updates it via signal handler). var box := _make_dialogue_box() if box == null: return GameState.dialogue_active = false box.show_dialogue("NPC", "Speech.", _make_options(["Reply"])) assert_bool(GameState.dialogue_active).override_failure_message( "GameState.dialogue_active must NOT be mutated directly by dialogue_box" ).is_false() box.queue_free() GameState.dialogue_active = false