Replace 3 direct GameState.dialogue_active mutations and all AudioManager.apply_dip/clear_dip calls with signals: dialogue_state_changed, audio_dip_requested, audio_dip_cleared. dialogue_box.gd now has zero references to GameState or AudioManager. main.gd wires coordinator handlers in _ready() (D-020). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
673 lines
25 KiB
GDScript
673 lines
25 KiB
GDScript
## 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
|