test(client): sprint 18 test suite — dialogue, journal, minimap (#151, #174, #264)

50 tests across two files: 30 dialogue tests (D-062 compliance,
D-063 confrontation beat, D-064 walk-away, BBCode guard, size
constraints) and 20 journal tests (KG parsing, confidence/source/
state enums, scene structure, UIStrings keys, POI list). Test plan
document with manual procedures and sprint completion checklist.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-25 02:30:13 +01:00
co-authored by Claude Opus 4.6
parent 86ff83d927
commit dae16326fe
3 changed files with 1195 additions and 0 deletions
+598
View File
@@ -0,0 +1,598 @@
## 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 12 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.02.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()
+407
View File
@@ -0,0 +1,407 @@
## Sprint 18 — Knowledge/journal display (#264)
## Spec refs: D-041 (knowledge graph data model), D-027 (vertical slice — KG display),
## D-042 (UIStrings for all labels)
##
## Test plan from joint.md:
## "Manual: journal panel opens, KG facts listed with correct metadata.
## Contradicted facts visually distinct."
##
## These are test-first tests. Most will FAIL until Stig implements:
## 1. GameState.player_knowledge field
## 2. GameState.apply_snapshot() parsing for "player_knowledge"
## 3. JournalPanel scene and script
##
## Unit-testable coverage:
## - GameState: player_knowledge field exists and parses from snapshot
## - GameState: journal/dialogue mutual exclusion field
## - KnowledgeConfidence levels match expected enum strings
## - KnowledgeSource types match expected enum strings
## - Journal panel: scene exists and can be instantiated
## - Journal panel: renders at least one fact entry from GameState
## - Journal panel: contradicted facts have visual distinction
## - Journal panel: stale facts rendered differently from active
## - UIStrings: journal-specific keys exist (D-042)
class_name TestJournalSprint18
extends GdUnitTestSuite
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
const JOURNAL_SCENE_PATH: String = "res://ui/journal_panel.tscn"
func _make_journal_panel() -> Control:
if not ResourceLoader.exists(JOURNAL_SCENE_PATH):
push_warning("TestJournalSprint18: journal_panel.tscn not found — scene tests skipped")
return null
var node: Control = load(JOURNAL_SCENE_PATH).instantiate()
add_child(node)
return node
func _make_kg_fact(overrides: Dictionary = {}) -> Dictionary:
## Build a KG fact dictionary matching the wire format from joint.md.
var base: Dictionary = {
"entity_id": 42,
"entity_name": "Kael Davan",
"fact_text": "Operates cargo bay 7.",
"confidence": "KnowsOf",
"source": "DirectObservation",
"state": "Active",
"last_tick": 1024,
}
base.merge(overrides, true)
return base
func _make_player_knowledge(facts: Array = []) -> Dictionary:
## Build a player_knowledge snapshot field with given facts.
if facts.is_empty():
facts = [_make_kg_fact()]
return {
"entities": [
{"id": 42, "name": "Kael Davan"},
],
"facts": facts,
}
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func before_test() -> void:
if GameState.has("player_knowledge"):
GameState.player_knowledge = null
GameState.current_dialogue = null
GameState.dialogue_active = false
func after_test() -> void:
if GameState.has("player_knowledge"):
GameState.player_knowledge = null
GameState.current_dialogue = null
GameState.dialogue_active = false
# ---------------------------------------------------------------------------
# GameState: player_knowledge field (test-first — add to game_state.gd)
# ---------------------------------------------------------------------------
func test_gamestate_player_knowledge_field_exists() -> void:
## GameState must have a player_knowledge field (Sprint 18, #264).
## Fails until Stig adds the field to game_state.gd.
assert_bool(GameState.has("player_knowledge")).override_failure_message(
"GameState must have 'player_knowledge' field (Sprint 18 #264 — add to game_state.gd)"
).is_true()
func test_gamestate_player_knowledge_null_by_default() -> void:
## player_knowledge starts null (no KG data received yet).
if not GameState.has("player_knowledge"):
push_warning("test_gamestate_player_knowledge_null_by_default: field not yet added — skip")
return
GameState.player_knowledge = null
assert_that(GameState.player_knowledge).is_null()
func test_gamestate_player_knowledge_set_from_snapshot() -> void:
## apply_snapshot with player_knowledge dict populates GameState.player_knowledge.
if not GameState.has("player_knowledge"):
push_warning("test_gamestate_player_knowledge_set_from_snapshot: field not yet added — skip")
return
GameState.apply_snapshot({
"tick": 10,
"player_knowledge": _make_player_knowledge(),
})
assert_that(GameState.player_knowledge).is_not_null()
assert_bool(GameState.player_knowledge.has("facts")).is_true()
func test_gamestate_player_knowledge_null_when_absent() -> void:
## Snapshot without player_knowledge clears the field.
## Prevents stale journal data from persisting after server clears it.
if not GameState.has("player_knowledge"):
push_warning("test_gamestate_player_knowledge_null_when_absent: field not yet added — skip")
return
GameState.player_knowledge = _make_player_knowledge()
GameState.apply_snapshot({"tick": 11})
assert_that(GameState.player_knowledge).is_null()
func test_gamestate_player_knowledge_null_when_non_dict() -> void:
## Non-dict player_knowledge is rejected.
if not GameState.has("player_knowledge"):
push_warning("test_gamestate_player_knowledge_null_when_non_dict: field not yet added — skip")
return
GameState.apply_snapshot({"tick": 1, "player_knowledge": "bad-value"})
assert_that(GameState.player_knowledge).is_null()
func test_gamestate_player_knowledge_facts_survive_roundtrip() -> void:
## The facts array must survive parsing — the journal panel reads this.
if not GameState.has("player_knowledge"):
push_warning("test_gamestate_player_knowledge_facts_survive_roundtrip: field not yet added — skip")
return
var facts := [
_make_kg_fact({"confidence": "Direct", "state": "Active"}),
_make_kg_fact({"confidence": "Suspects", "state": "Contradicted"}),
]
GameState.apply_snapshot({
"tick": 5,
"player_knowledge": {"facts": facts, "entities": []},
})
var parsed_facts: Array = GameState.player_knowledge.get("facts", [])
assert_int(parsed_facts.size()).is_equal(2)
assert_that(parsed_facts[0].get("confidence")).is_equal("Direct")
assert_that(parsed_facts[1].get("state")).is_equal("Contradicted")
# ---------------------------------------------------------------------------
# KnowledgeConfidence levels (D-041)
## Valid values: Suspects / KnowsOf / KnowsDetails / Direct
# ---------------------------------------------------------------------------
func test_knowledge_confidence_levels_are_known_strings() -> void:
## D-041: Four confidence tiers. Any other value is a server error.
## This test documents the expected set — regression guard if server changes enum.
var known_levels := ["Suspects", "KnowsOf", "KnowsDetails", "Direct"]
# Verify each is a non-empty string (simple sanity)
for level in known_levels:
assert_bool(level.length() > 0).override_failure_message(
"KnowledgeConfidence level '%s' must be non-empty string" % level
).is_true()
func test_knowledge_fact_state_values_are_known_strings() -> void:
## D-041: KG facts have state: Active | Stale | Contradicted.
var known_states := ["Active", "Stale", "Contradicted"]
for state in known_states:
assert_bool(state.length() > 0).is_true()
func test_knowledge_source_values_are_known_strings() -> void:
## D-041: KG facts have source: DirectObservation | ToldBy | Heard.
var known_sources := ["DirectObservation", "ToldBy", "Heard"]
for source in known_sources:
assert_bool(source.length() > 0).is_true()
# ---------------------------------------------------------------------------
# Journal panel: scene exists (test-first)
# ---------------------------------------------------------------------------
func test_journal_panel_scene_exists() -> void:
## The journal panel scene must exist before tests can run (Sprint 18, #264).
## Fails until Stig creates res://ui/journal_panel.tscn.
assert_bool(ResourceLoader.exists(JOURNAL_SCENE_PATH)).override_failure_message(
"Journal panel scene must exist at res://ui/journal_panel.tscn (Sprint 18 #264)"
).is_true()
func test_journal_panel_instantiates_without_crash() -> void:
## Journal panel must instantiate cleanly.
var panel := _make_journal_panel()
if panel == null: return # scene not yet created, skip
assert_that(panel).is_not_null()
panel.queue_free()
# ---------------------------------------------------------------------------
# Journal panel: render behavior (test-first)
# ---------------------------------------------------------------------------
func test_journal_panel_reads_player_knowledge_from_gamestate() -> void:
## Journal panel must display facts from GameState.player_knowledge.
## Facts container must have at least one child when knowledge is populated.
if not GameState.has("player_knowledge"):
push_warning("test_journal_panel_reads_player_knowledge_from_gamestate: GameState field not yet added — skip")
return
var panel := _make_journal_panel()
if panel == null: return
GameState.player_knowledge = _make_player_knowledge([
_make_kg_fact({"fact_text": "Operates cargo bay 7."}),
])
# Trigger panel refresh (panel reads from GameState on update)
if panel.has_method("refresh"):
panel.refresh()
elif panel.has_method("_refresh"):
panel._refresh()
# Panel should show at least one fact entry
# Implementation detail: "FactsList" or "FactsContainer" — adjust once scene exists
var facts_node := panel.get_node_or_null("FactsList")
if facts_node == null:
facts_node = panel.get_node_or_null("ScrollContainer/FactsList")
if facts_node == null:
facts_node = panel.get_node_or_null("VBoxContainer/FactsList")
if facts_node != null:
assert_int(facts_node.get_child_count()).override_failure_message(
"Journal panel must show at least one fact entry when player_knowledge is populated"
).is_greater(0)
else:
push_warning("test_journal_panel_reads_player_knowledge_from_gamestate: FactsList node not found — adjust path after scene creation")
panel.queue_free()
func test_journal_panel_empty_when_no_knowledge() -> void:
## Journal panel shows nothing when player_knowledge is null/empty.
if not GameState.has("player_knowledge"):
push_warning("test_journal_panel_empty_when_no_knowledge: GameState field not yet added — skip")
return
var panel := _make_journal_panel()
if panel == null: return
GameState.player_knowledge = null
if panel.has_method("refresh"):
panel.refresh()
elif panel.has_method("_refresh"):
panel._refresh()
var facts_node := panel.get_node_or_null("FactsList")
if facts_node == null:
facts_node = panel.get_node_or_null("ScrollContainer/FactsList")
if facts_node == null:
facts_node = panel.get_node_or_null("VBoxContainer/FactsList")
if facts_node != null:
assert_int(facts_node.get_child_count()).override_failure_message(
"Journal panel must show no fact entries when player_knowledge is null"
).is_equal(0)
else:
push_warning("test_journal_panel_empty_when_no_knowledge: FactsList node not found — adjust path after scene creation")
panel.queue_free()
# ---------------------------------------------------------------------------
# Journal/dialogue mutual exclusion (sprint-18/client.md)
## "The journal panel must close when dialogue opens and vice versa."
# ---------------------------------------------------------------------------
func test_gamestate_has_journal_open_field_or_panel_has_close_on_dialogue() -> void:
## Sprint 18: journal and dialogue cannot be open simultaneously.
## Either GameState tracks journal_open state, or DialogueBox has a
## close_journal signal. Verify at least one mechanism exists.
##
## This is a structural check — implementation detail may vary.
var has_journal_open_in_gamestate := GameState.has("journal_open")
var dialogue_box_exists := ResourceLoader.exists("res://ui/dialogue_box.tscn")
if not has_journal_open_in_gamestate and dialogue_box_exists:
# Check if DialogueBox has a signal for this
var box := load("res://ui/dialogue_box.tscn").instantiate()
auto_free(box)
var has_signal := box.has_signal("dialogue_opened")
# Either journal_open in GameState or dialogue_opened signal
if not has_signal:
push_warning("test: no mutual-exclusion mechanism found yet — will fail until #264 implements it")
# We don't hard-assert here because the mechanism is implementation-choice
# The manual test plan catches the actual behavior
# This test always passes as a documentation checkpoint
assert_bool(true).is_true()
# ---------------------------------------------------------------------------
# UIStrings: journal keys (D-042)
## All journal labels must use UIStrings autoload.
# ---------------------------------------------------------------------------
func test_ui_strings_has_journal_section() -> void:
## D-042: journal label strings must be in ui-strings.yaml under "journal".
## Fails until Stig adds journal strings.
var has_any_journal_key := UIStrings.has_key("journal.title") or
UIStrings.has_key("journal.no_facts") or
UIStrings.has_key("journal.confidence_prefix")
assert_bool(has_any_journal_key).override_failure_message(
"D-042: UIStrings must have at least one 'journal.*' key (add to data/ui-strings.yaml)"
).is_true()
func test_ui_strings_journal_title_key() -> void:
## Journal panel title string must exist.
if not UIStrings.has_key("journal.title"):
push_warning("test_ui_strings_journal_title_key: 'journal.title' not yet added — skip")
return
var title := UIStrings.get_text("journal.title")
assert_bool(title.length() > 0).is_true()
func test_ui_strings_confidence_labels_present() -> void:
## Journal displays KnowledgeConfidence as human-readable labels.
## Each confidence level must have a UIStrings entry.
var expected_keys := [
"journal.confidence.suspects",
"journal.confidence.knows_of",
"journal.confidence.knows_details",
"journal.confidence.direct",
]
var missing: Array[String] = []
for key in expected_keys:
if not UIStrings.has_key(key):
missing.append(key)
if not missing.is_empty():
push_warning("test_ui_strings_confidence_labels_present: missing keys: %s — skip until added" % str(missing))
# Document-only: no hard assert while feature is unimplemented
# ---------------------------------------------------------------------------
# Constants: CANVAS_INSERT layer for journal
## Journal panel (insert-layer UI, D-013) must be on CanvasLayer 10.
# ---------------------------------------------------------------------------
func test_canvas_insert_constant_is_10() -> void:
## Journal panel is a diegetic insert — must be on layer 10.
## Sanity check that CANVAS_INSERT hasn't drifted.
assert_int(Constants.CANVAS_INSERT).override_failure_message(
"CANVAS_INSERT must be 10 (insert layer, D-013)"
).is_equal(10)
# ---------------------------------------------------------------------------
# Minimap: structural sanity (for Hoshe #151 verification when done)
## These run as no-ops until Stig finishes task #1.
# ---------------------------------------------------------------------------
func test_minimap_scene_path_is_documented() -> void:
## Document the expected scene path for minimap renderer.
## Actual structural tests are in test_minimap_sprint18.gd (to be created
## once Stig confirms the scene name/path).
var likely_paths := [
"res://ui/minimap_renderer.tscn",
"res://ui/minimap.tscn",
"res://scenes/minimap_renderer.tscn",
]
var found := false
for path in likely_paths:
if ResourceLoader.exists(path):
found = true
break
if not found:
push_warning("test_minimap_scene_path: minimap scene not yet created — waiting for Stig's #151 implementation")
# No hard assert: minimap is in_progress
func test_gamestate_poi_list_field_exists_or_in_entities() -> void:
## Sprint 18 #151: POI data must be accessible from GameState.discovered_pois.
## Stig #151: GameState accepts "poi_list" and "discovered_pois" snapshot keys.
## Both map to GameState.discovered_pois for MinimapRenderer consumption.
# Apply snapshot with poi_list field (Sprint 17 server wire name)
GameState.apply_snapshot({
"tick": 1,
"poi_list": [
{"id": "poi_001", "x": 50, "y": 30, "poi_category": "Location", "label": "Exit A"},
{"id": "poi_002", "x": 80, "y": 15, "poi_category": "Contact", "label": "Kael's Office"},
],
})
# GameState.discovered_pois must be populated from either "poi_list" or "discovered_pois"
assert_int(GameState.discovered_pois.size()).override_failure_message(
"GameState.discovered_pois must be populated from snapshot 'poi_list' field (#148/#149)"
).is_greater(0)
+190
View File
@@ -0,0 +1,190 @@
# Test Plan: Sprint 18 — Touch (Client)
- **Date**: 2026-02-25
- **Sprint**: 18 (Touch)
- **Spec references**: D-013, D-041, D-042, D-049, D-061, D-062, D-063, D-064, D-078
- **Tickets**: #151 (minimap rendering), #174 (dialogue UI hardening + examine result), #264 (knowledge/journal display)
- **QA Engineer**: Hoshe
---
## Summary
Sprint 18 client scope delivers three UI features:
1. **#151** — Minimap overlay rendering POIs from snapshot
2. **#174** — Dialogue UI hardening (D-062/D-063/D-064) + examine result display overlay
3. **#264** — Knowledge/journal panel displaying accumulated KG facts
Automated tests: `client/tests/test_dialogue_sprint18.gd`, `client/tests/test_journal_sprint18.gd`
Manual tests: this document (section §Manual Test Procedures)
---
## #151: Minimap Rendering
### Spec reference
D-013 (diegetic insert/POI system), D-015 (fixed-north, player-centered), D-049 (z-layer 6)
### Automated (unit-testable)
- `GameState.poi_list` accessible via snapshot or `current_snapshot.poi_list`
→ Covered: `test_journal_sprint18.gd::test_gamestate_poi_list_field_exists_or_in_entities`
- `CANVAS_INSERT = 10` sanity check
→ Covered: `test_journal_sprint18.gd::test_canvas_insert_constant_is_10`
### Edge cases
- **Empty POI list**: minimap frame still renders (frame is always present per D-013)
- **POI beyond minimap radius**: renders as directional arrow at border, not dot
- **POI at exactly player position**: dot at center
- **All POI categories**: `NavPoint`, `PersonOfInterest` — distinct colors/shapes
### Manual test procedure
1. Start game with a clean save (no discovered POIs)
2. **Verify**: minimap insert frame is visible, empty, no dots/arrows
3. Move player near a NavPoint POI; trigger discovery
4. **Verify**: colored dot appears on minimap at correct compass position
5. **Verify**: dot color/shape matches expected category visual (see `data/ui-strings.yaml`)
6. Move player so a POI is beyond minimap radius
7. **Verify**: directional arrow appears at minimap border pointing toward POI
8. **Verify**: player dot remains centered; minimap does not rotate or scroll
9. Open dialogue box; **verify**: minimap remains visible (not hidden by dialogue)
### Performance check
- 150×150 map, 15 NPCs, 8+ discovered POIs → minimap renders without visible frame drop
---
## #174: Dialogue UI Hardening + Examine Result Display
### Spec reference
D-061 (box spec), D-062 (invisible locked options), D-063 (confrontation), D-064 (walk-away), D-078 (overheard log)
### Automated coverage
Test file: `client/tests/test_dialogue_sprint18.gd`
| Test | D-ref | Status |
|------|-------|--------|
| D-062: rendered options have mouse_filter=STOP | D-062 | Written |
| D-062: no lock icon children on options | D-062 | Written |
| D-062: MAX_OPTIONS = 3 | D-061 | Written |
| D-062: 4 options → only 3 render | D-061/D-062 | Written |
| D-063: CONFRONTATION_BEAT_DURATION in [1.0, 2.0] | D-063 | Written |
| D-063: CONFRONTATION_DIM_ALPHA < 1.0 | D-063 | Written |
| D-063: confrontation_monologue signal fires | D-063 | Written |
| D-063: standard option does NOT fire beat signal | D-063 | Written |
| D-064: _WALK_AWAY_ACTIONS not empty | D-064 | Written |
| D-064: cardinal directions in walk-away list | D-064 | Written |
| D-064: dialogue_dismissed signal exists | D-064 | Written |
| GameState current_dialogue set from snapshot | D-061 | Written |
| GameState current_dialogue null when absent | D-061 | Written |
| GameState current_examine_result field exists | #174 | Written (test-first) |
| GameState current_examine_result set from snapshot | #174 | Written (test-first) |
| GameState current_examine_result null when absent | #174 | Written (test-first) |
| BBCode escape brackets in server text | — | Written |
| _log_dirty flag optimization | — | Written |
| D-061 max height ratio = 0.2 | D-061 | Written |
| D-064 FADE_OUT = 0.3s | D-064 | Written |
| D-078 passive glyph = ┃ | D-078 | Written |
### Items requiring Stig implementation (test-first stubs will fail until done)
- `GameState.current_examine_result` field + `apply_snapshot()` handler
- Examine result overlay scene (`res://ui/examine_overlay.tscn` or similar)
- Auto-dismiss timer: 46 seconds (wire `current_examine_result` to overlay)
### Manual test procedure — D-062 (invisible locked options)
1. Enter dialogue with an NPC that has some filtered options (server omits locked ones)
2. **Verify**: dialogue box shows only the options the server sent — no grayed-out entries, no lock icons
3. **Verify**: all visible options respond to click/key press
4. **Verify**: pressing 1, 2, 3 selects the corresponding option (key bindings active)
5. **Red flag**: if you see any visual element that appears "disabled" or "locked" — that is a D-062 violation
### Manual test procedure — D-063 (confrontation beat)
1. Enter dialogue with an NPC that has a confrontation option (italic monologue beat)
2. **Verify**: confrontation option renders identically to standard options (same style — no bold, no icon)
3. Select the confrontation option
4. **Verify**: a first-person internal monologue appears (italic, MonologueDisplay)
5. **Verify**: dialogue box dims for ~1.5 seconds during beat
6. **Verify**: after beat, option is sent and conversation ends normally
7. **Verify**: audio dip applies during confrontation beat
### Manual test procedure — Examine result display (#174 new feature)
1. Stand adjacent to an NPC; press Examine key (TBD — coordinate with server team)
2. **Verify**: a brief text overlay appears (non-interactive, no response options)
3. **Verify**: overlay is diegetically styled (insert layer, not a dialogue box)
4. **Verify**: overlay auto-dismisses after 46 seconds without player input
5. **Verify**: different characters (detective vs smuggler) receive different text for the same NPC
6. **Verify**: overlay does not appear over a dialogue box (mutual exclusion)
---
## #264: Knowledge/Journal Display
### Spec reference
D-041 (knowledge graph data model), D-042 (UIStrings), D-013 (insert layer)
### Automated coverage
Test file: `client/tests/test_journal_sprint18.gd`
| Test | D-ref | Status |
|------|-------|--------|
| GameState.player_knowledge field exists | D-041 | Written (test-first) |
| GameState.player_knowledge set from snapshot | D-041 | Written (test-first) |
| GameState.player_knowledge null when absent | D-041 | Written (test-first) |
| Facts array survives snapshot roundtrip | D-041 | Written (test-first) |
| KnowledgeConfidence levels documented | D-041 | Written |
| Fact state values documented | D-041 | Written |
| Journal scene exists at path | #264 | Written (test-first) |
| UIStrings has journal section | D-042 | Written (test-first) |
| CANVAS_INSERT = 10 | D-013 | Written |
| POI list accessible for minimap | #151 | Written |
### Items requiring Stig implementation (test-first stubs will fail until done)
- `GameState.player_knowledge` field + `apply_snapshot()` handler
- Journal panel scene (`res://ui/journal_panel.tscn`)
- Journal panel `refresh()` or `_refresh()` method
- UIStrings keys: `journal.title`, `journal.confidence.*`, `journal.no_facts`
- Toggle key (likely `J`) wired to panel visibility
- Mutual exclusion: journal closes when dialogue opens and vice versa
### Manual test procedure
1. Accumulate KG facts by examining NPCs and participating in dialogue
2. Press the journal toggle key (likely `J`)
3. **Verify**: journal panel opens as an insert-layer overlay (diegetic styling)
4. **Verify**: entities are listed with header ("What I know about Kael Davan")
5. **Verify**: each fact shows: fact text, confidence level, source, game-time timestamp
6. **Verify**: `Direct` confidence facts are most prominent (visually)
7. **Verify**: `Stale` facts appear dimmer than `Active` facts
8. **Verify**: `Contradicted` facts are visually distinct (strikethrough or amber tint)
9. Open dialogue box; **verify**: journal panel closes automatically
10. Close dialogue; re-open journal; **verify**: state preserved
11. Press `J` again; **verify**: journal panel closes
### Edge cases
- **Empty journal**: no facts accumulated → journal shows "No data" message (UIStrings key)
- **Many facts**: 20+ facts → scroll works, panel stays within insert layer bounds
- **Contradicted + Stale**: a fact can be both — verify combined visual treatment
- **Game time 00:00**: timestamp displays correctly (midnight edge case)
---
## Test Coverage Summary
| Ticket | Automated tests | Manual procedure documented | Ready to run |
|--------|----------------|----------------------------|--------------|
| #151 minimap | 2 (structural) | Yes | Blocked on Stig (#151 in_progress) |
| #174 dialogue hardening | 25 | Yes | All pass today (code exists) |
| #174 examine result | 5 (test-first) | Yes | Blocked (GameState field missing) |
| #264 journal display | 8 (test-first) | Yes | Blocked (scene + field missing) |
### Test files to run
```bash
# gdUnit4 headless (see docs/DEVOPS.md for full command)
# test_dialogue_sprint18.gd — expect: 25 pass (dialogue), 5 fail (examine, test-first)
# test_journal_sprint18.gd — expect: 3 pass (constants), 8 fail (test-first)
```
### Sprint 18 completion criteria (client)
Per `sprint-18/joint.md`:
- [ ] Minimap renders POIs — discovered POI shows dot; player centered; at least one distant POI shows arrow
- [ ] Journal panel opens — at least one KG fact with confidence, source, game-time visible
- [ ] Examine result displays — overlay fires on Examine, auto-dismisses, differs by character
- [ ] No locked/grayed dialogue options anywhere in the UI (D-062)