fix(client): address PR #65 review — POI key, ToldBy parsing, KG dirty flag
- minimap.gd: fix "poi_category" → "category" key mismatch so POI colors and shapes render correctly - journal_panel.gd: parse ToldBy(N) source format, resolve entity names from player_knowledge; move confidence/source labels to UIStrings per D-042 - observer/mod.rs: add Changed<KnowledgeGraph> dirty flag to skip per-tick KG serialization when unchanged - types.rs: fix stale version doc comment (13 → 14) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -125,6 +125,17 @@ knowledge_panel:
|
||||
confidence_medium: "Likely"
|
||||
confidence_low: "Unconfirmed"
|
||||
confidence_rumor: "Hearsay"
|
||||
# D-041 KnowledgeConfidence levels — displayed in journal panel
|
||||
confidence_direct: "Confirmed"
|
||||
confidence_knowsdetails: "Detailed"
|
||||
confidence_knowsof: "Known"
|
||||
confidence_suspects: "Unconfirmed"
|
||||
# D-041 KnowledgeSource labels — displayed in journal panel
|
||||
source_directobservation: "Observed"
|
||||
source_toldby: "Told"
|
||||
source_heard: "Overheard"
|
||||
source_inferred: "Inferred"
|
||||
source_background: "Prior"
|
||||
|
||||
# ============================================================
|
||||
# TUTORIAL TEXT (DIEGETIC)
|
||||
|
||||
@@ -2,25 +2,13 @@
|
||||
## 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."
|
||||
## Tests now run against live Stig implementation.
|
||||
## Wire format per game_state.gd v14:
|
||||
## player_knowledge: {entities: [{entity_id, name, confidence, source, state, relationship, last_observed_tick}]}
|
||||
##
|
||||
## 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)
|
||||
## NOTE: player_knowledge PERSISTS between snapshots (no-clear behavior, by design).
|
||||
## The server sends KG updates only when the graph changes — absence = no change.
|
||||
## Contrast with current_examine_result which DOES clear each snapshot.
|
||||
class_name TestJournalSprint18
|
||||
extends GdUnitTestSuite
|
||||
|
||||
@@ -40,31 +28,32 @@ func _make_journal_panel() -> Control:
|
||||
return node
|
||||
|
||||
|
||||
func _make_kg_fact(overrides: Dictionary = {}) -> Dictionary:
|
||||
## Build a KG fact dictionary matching the wire format from joint.md.
|
||||
func _make_kg_entity(overrides: Dictionary = {}) -> Dictionary:
|
||||
## Wire format per game_state.gd v14 / Stig's Stig confirmation (2026-02-25).
|
||||
## entities: [{entity_id, name, confidence, source, state, relationship, last_observed_tick}]
|
||||
var base: Dictionary = {
|
||||
"entity_id": 42,
|
||||
"entity_name": "Kael Davan",
|
||||
"fact_text": "Operates cargo bay 7.",
|
||||
"name": "Kael Davan",
|
||||
"confidence": "KnowsOf",
|
||||
"source": "DirectObservation",
|
||||
"state": "Active",
|
||||
"last_tick": 1024,
|
||||
"relationship": "PersonOfInterest",
|
||||
"last_observed_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,
|
||||
}
|
||||
func _make_player_knowledge(entities: Array = []) -> Dictionary:
|
||||
if entities.is_empty():
|
||||
entities = [_make_kg_entity()]
|
||||
return {"entities": entities}
|
||||
|
||||
|
||||
func _entries_container(panel: Control) -> Node:
|
||||
return panel.get_node_or_null(
|
||||
"PanelContainer/MarginContainer/VBoxContainer/ScrollContainer/EntriesContainer"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -72,336 +61,505 @@ func _make_player_knowledge(facts: Array = []) -> Dictionary:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func before_test() -> void:
|
||||
if GameState.has("player_knowledge"):
|
||||
GameState.player_knowledge = null
|
||||
GameState.player_knowledge = null
|
||||
GameState.current_dialogue = null
|
||||
GameState.dialogue_active = false
|
||||
GameState.current_tick = 0
|
||||
|
||||
func after_test() -> void:
|
||||
if GameState.has("player_knowledge"):
|
||||
GameState.player_knowledge = null
|
||||
GameState.player_knowledge = null
|
||||
GameState.current_dialogue = null
|
||||
GameState.dialogue_active = false
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GameState: player_knowledge field (test-first — add to game_state.gd)
|
||||
# GameState: player_knowledge snapshot parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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.
|
||||
## GameState must have player_knowledge field (v14, #264).
|
||||
assert_bool(GameState.has("player_knowledge")).override_failure_message(
|
||||
"GameState must have 'player_knowledge' field (Sprint 18 #264 — add to game_state.gd)"
|
||||
"GameState must have 'player_knowledge' field (Sprint 18 #264)"
|
||||
).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()
|
||||
assert_bool(GameState.player_knowledge.has("entities")).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
|
||||
func test_gamestate_player_knowledge_persists_when_absent() -> void:
|
||||
## IMPORTANT: player_knowledge does NOT clear when absent from snapshot.
|
||||
## Server sends KG updates only on change — absence means "no change since last tick".
|
||||
## This is intentional behavior (journal should not flash empty every tick).
|
||||
GameState.player_knowledge = _make_player_knowledge()
|
||||
GameState.apply_snapshot({"tick": 11})
|
||||
assert_that(GameState.player_knowledge).is_null()
|
||||
assert_that(GameState.player_knowledge).is_not_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
|
||||
## Malformed player_knowledge (non-dict) must be rejected.
|
||||
## First set a valid value, then try to overwrite with invalid
|
||||
GameState.player_knowledge = _make_player_knowledge()
|
||||
GameState.apply_snapshot({"tick": 1, "player_knowledge": "bad-value"})
|
||||
assert_that(GameState.player_knowledge).is_null()
|
||||
# Non-dict is rejected — previous value preserved (or null if first time)
|
||||
# The implementation only updates on Dictionary type, so value persists
|
||||
assert_that(GameState.player_knowledge).is_not_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"}),
|
||||
func test_gamestate_player_knowledge_entities_survive_roundtrip() -> void:
|
||||
var entities := [
|
||||
_make_kg_entity({"name": "Kael Davan", "state": "Active"}),
|
||||
_make_kg_entity({"name": "Lysa Orin", "state": "Contradicted", "entity_id": 55}),
|
||||
]
|
||||
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")
|
||||
GameState.apply_snapshot({"tick": 5, "player_knowledge": {"entities": entities}})
|
||||
var parsed_entities: Array = GameState.player_knowledge.get("entities", [])
|
||||
assert_int(parsed_entities.size()).is_equal(2)
|
||||
assert_that(parsed_entities[0].get("name")).is_equal("Kael Davan")
|
||||
assert_that(parsed_entities[1].get("state")).is_equal("Contradicted")
|
||||
|
||||
|
||||
func test_gamestate_player_knowledge_updated_when_new_data_arrives() -> void:
|
||||
## When server sends a new player_knowledge, it replaces the previous value.
|
||||
GameState.apply_snapshot({"tick": 1, "player_knowledge": _make_player_knowledge([
|
||||
_make_kg_entity({"name": "Person A"}),
|
||||
])})
|
||||
GameState.apply_snapshot({"tick": 2, "player_knowledge": _make_player_knowledge([
|
||||
_make_kg_entity({"name": "Person A"}),
|
||||
_make_kg_entity({"name": "Person B", "entity_id": 99}),
|
||||
])})
|
||||
var entities: Array = GameState.player_knowledge.get("entities", [])
|
||||
assert_int(entities.size()).is_equal(2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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)
|
||||
# Journal panel: scene and API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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)"
|
||||
"Journal panel scene must exist at res://ui/journal_panel.tscn"
|
||||
).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
|
||||
if panel == null: return
|
||||
assert_that(panel).is_not_null()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_has_toggle_method() -> void:
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
assert_bool(panel.has_method("toggle")).override_failure_message(
|
||||
"JournalPanel must have toggle() method"
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_has_close_method() -> void:
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
assert_bool(panel.has_method("close")).override_failure_message(
|
||||
"JournalPanel must have close() method"
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_has_is_open_method() -> void:
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
assert_bool(panel.has_method("is_open")).override_failure_message(
|
||||
"JournalPanel must have is_open() method"
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_has_update_from_state_method() -> void:
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
assert_bool(panel.has_method("update_from_state")).override_failure_message(
|
||||
"JournalPanel must have update_from_state() method (called from main.gd)"
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_closed_on_init() -> void:
|
||||
## Panel starts hidden — not open by default.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
assert_bool(panel.is_open()).override_failure_message(
|
||||
"JournalPanel must be closed on _ready()"
|
||||
).is_false()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_toggle_opens() -> void:
|
||||
## First toggle() opens the panel.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
panel.toggle()
|
||||
assert_bool(panel.is_open()).override_failure_message(
|
||||
"toggle() must set is_open() = true"
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_toggle_closes() -> void:
|
||||
## Second toggle() closes the panel.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
panel.toggle() # open
|
||||
panel.toggle() # close
|
||||
assert_bool(panel.is_open()).override_failure_message(
|
||||
"Second toggle() must close the panel"
|
||||
).is_false()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_close_when_already_closed_is_safe() -> void:
|
||||
## close() on an already-closed panel must not crash.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
panel.close()
|
||||
assert_bool(panel.is_open()).is_false()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Journal panel: render behavior (test-first)
|
||||
# Journal panel: entry rendering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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
|
||||
func test_journal_panel_entries_container_exists() -> void:
|
||||
## EntriesContainer is the VBoxContainer that holds entity entries.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
var container := _entries_container(panel)
|
||||
assert_that(container != null).override_failure_message(
|
||||
"EntriesContainer must exist at PanelContainer/MarginContainer/VBoxContainer/ScrollContainer/EntriesContainer"
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_shows_entries_when_knowledge_populated() -> void:
|
||||
## Opening panel with player_knowledge set creates entry nodes in EntriesContainer.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
|
||||
GameState.player_knowledge = _make_player_knowledge([
|
||||
_make_kg_fact({"fact_text": "Operates cargo bay 7."}),
|
||||
_make_kg_entity({"name": "Kael Davan"}),
|
||||
])
|
||||
panel.toggle() # calls _show_panel() -> _rebuild_entries()
|
||||
|
||||
# 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")
|
||||
var container := _entries_container(panel)
|
||||
if container == null: panel.queue_free(); return
|
||||
|
||||
assert_int(container.get_child_count()).override_failure_message(
|
||||
"EntriesContainer must have children when player_knowledge is populated"
|
||||
).is_greater(0)
|
||||
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
|
||||
func test_journal_panel_shows_empty_state_when_no_knowledge() -> void:
|
||||
## Empty state Label is shown when player_knowledge is null.
|
||||
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()
|
||||
panel.toggle()
|
||||
|
||||
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")
|
||||
var container := _entries_container(panel)
|
||||
if container == null: panel.queue_free(); return
|
||||
|
||||
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")
|
||||
## Empty state = exactly 1 child (the "Nothing logged yet." label)
|
||||
assert_int(container.get_child_count()).override_failure_message(
|
||||
"EntriesContainer should have 1 child (empty state label) when knowledge is null"
|
||||
).is_equal(1)
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_two_entities_create_more_entries() -> void:
|
||||
## Two entities create more entries than one (header + detail each, plus spacers).
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
|
||||
GameState.player_knowledge = _make_player_knowledge([
|
||||
_make_kg_entity({"name": "Entity A", "entity_id": 1}),
|
||||
_make_kg_entity({"name": "Entity B", "entity_id": 2}),
|
||||
])
|
||||
panel.toggle()
|
||||
|
||||
var container := _entries_container(panel)
|
||||
if container == null: panel.queue_free(); return
|
||||
|
||||
## Each entity: header_rtl + detail_rtl + spacer = 3 nodes. Two entities = 6 min.
|
||||
assert_int(container.get_child_count()).override_failure_message(
|
||||
"Two entities must create at least 6 child nodes (2 × [header + detail + spacer])"
|
||||
).is_greater_equal(6)
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_contradicted_entity_uses_strikethrough() -> void:
|
||||
## D-041: Contradicted entities must have strikethrough in their header BBCode.
|
||||
## journal_panel.gd renders [s]Name[/s] for Contradicted state.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
|
||||
GameState.player_knowledge = _make_player_knowledge([
|
||||
_make_kg_entity({"name": "Bad Guy", "state": "Contradicted"}),
|
||||
])
|
||||
panel.toggle()
|
||||
|
||||
var container := _entries_container(panel)
|
||||
if container == null: panel.queue_free(); return
|
||||
|
||||
## First child should be the header RichTextLabel with [s]...[/s]
|
||||
if container.get_child_count() == 0:
|
||||
push_warning("test_journal_panel_contradicted_entity_uses_strikethrough: no entries — skip")
|
||||
panel.queue_free(); return
|
||||
|
||||
var first_child := container.get_child(0)
|
||||
if first_child is RichTextLabel:
|
||||
assert_that(first_child.text).override_failure_message(
|
||||
"Contradicted entity header must contain [s] (strikethrough) BBCode"
|
||||
).contains("[s]")
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_active_entity_no_strikethrough() -> void:
|
||||
## Active entity must NOT have strikethrough in its header.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
|
||||
GameState.player_knowledge = _make_player_knowledge([
|
||||
_make_kg_entity({"name": "Good Guy", "state": "Active"}),
|
||||
])
|
||||
panel.toggle()
|
||||
|
||||
var container := _entries_container(panel)
|
||||
if container == null: panel.queue_free(); return
|
||||
|
||||
if container.get_child_count() == 0:
|
||||
push_warning("test_journal_panel_active_entity_no_strikethrough: no entries — skip")
|
||||
panel.queue_free(); return
|
||||
|
||||
var first_child := container.get_child(0)
|
||||
if first_child is RichTextLabel:
|
||||
assert_bool(first_child.text.contains("[s]")).override_failure_message(
|
||||
"Active entity header must NOT have strikethrough — only Contradicted gets [s]"
|
||||
).is_false()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Journal/dialogue mutual exclusion (sprint-18/client.md)
|
||||
## "The journal panel must close when dialogue opens and vice versa."
|
||||
# Journal panel: mutual exclusion with dialogue
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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")
|
||||
func test_update_from_state_closes_journal_when_dialogue_active() -> void:
|
||||
## Sprint briefing: journal must close when dialogue opens.
|
||||
## update_from_state() is called from main.gd on each snapshot.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
|
||||
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
|
||||
panel.toggle() # open journal
|
||||
assert_bool(panel.is_open()).is_true()
|
||||
|
||||
# This test always passes as a documentation checkpoint
|
||||
assert_bool(true).is_true()
|
||||
GameState.dialogue_active = true
|
||||
panel.update_from_state()
|
||||
|
||||
assert_bool(panel.is_open()).override_failure_message(
|
||||
"Journal must close when GameState.dialogue_active = true (update_from_state() called)"
|
||||
).is_false()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_update_from_state_does_not_close_when_dialogue_inactive() -> void:
|
||||
## update_from_state() must NOT close journal when dialogue is not active.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
|
||||
panel.toggle() # open journal
|
||||
GameState.dialogue_active = false
|
||||
panel.update_from_state()
|
||||
|
||||
assert_bool(panel.is_open()).override_failure_message(
|
||||
"Journal must stay open when dialogue is inactive"
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# UIStrings: journal keys (D-042)
|
||||
## All journal labels must use UIStrings autoload.
|
||||
# Journal panel: CONFIDENCE_LABELS and SOURCE_LABELS constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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)"
|
||||
func test_confidence_labels_covers_all_four_levels() -> void:
|
||||
## D-041: Four confidence tiers. CONFIDENCE_LABELS must map all four.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
var labels: Dictionary = panel.CONFIDENCE_LABELS
|
||||
for level in ["Direct", "KnowsDetails", "KnowsOf", "Suspects"]:
|
||||
assert_bool(labels.has(level)).override_failure_message(
|
||||
"CONFIDENCE_LABELS must include '%s'" % level
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_source_labels_covers_known_sources() -> void:
|
||||
## D-041: Source types. SOURCE_LABELS must map core sources.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
var labels: Dictionary = panel.SOURCE_LABELS
|
||||
for source in ["DirectObservation", "ToldBy", "Heard"]:
|
||||
assert_bool(labels.has(source)).override_failure_message(
|
||||
"SOURCE_LABELS must include '%s'" % source
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_confidence_labels_all_non_empty() -> void:
|
||||
## All CONFIDENCE_LABELS values must be non-empty strings.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
for key in panel.CONFIDENCE_LABELS:
|
||||
var val: String = panel.CONFIDENCE_LABELS[key]
|
||||
assert_bool(val.length() > 0).override_failure_message(
|
||||
"CONFIDENCE_LABELS['%s'] must be non-empty" % key
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Journal panel: _state_color() contract
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_state_color_contradicted_is_amber() -> void:
|
||||
## D-041: Contradicted → amber tint (ENTITY_COLOR_POI) — THE FRIEND arc surface.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
var color: Color = panel._state_color("Contradicted")
|
||||
assert_that(color).override_failure_message(
|
||||
"_state_color('Contradicted') must return ENTITY_COLOR_POI (amber)"
|
||||
).is_equal(Constants.ENTITY_COLOR_POI)
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_state_color_stale_is_dimmed() -> void:
|
||||
## Stale → dimmed text color (IMPLANT_TEXT_DIM).
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
var color: Color = panel._state_color("Stale")
|
||||
assert_that(color).override_failure_message(
|
||||
"_state_color('Stale') must return IMPLANT_TEXT_DIM"
|
||||
).is_equal(Constants.IMPLANT_TEXT_DIM)
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_state_color_active_is_normal() -> void:
|
||||
## Active → normal insert text color (INSERT_COLOR_TEXT).
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
var color: Color = panel._state_color("Active")
|
||||
assert_that(color).override_failure_message(
|
||||
"_state_color('Active') must return INSERT_COLOR_TEXT"
|
||||
).is_equal(Constants.INSERT_COLOR_TEXT)
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_state_color_contradicted_differs_from_active() -> void:
|
||||
## Contradicted and Active must have visually distinct colors.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
var contradicted := panel._state_color("Contradicted")
|
||||
var active := panel._state_color("Active")
|
||||
assert_that(contradicted).is_not_equal(active)
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_state_color_stale_differs_from_active() -> void:
|
||||
## Stale and Active must have visually distinct colors.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
var stale := panel._state_color("Stale")
|
||||
var active := panel._state_color("Active")
|
||||
assert_that(stale).is_not_equal(active)
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# UIStrings: knowledge_panel keys (D-042)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_ui_strings_knowledge_panel_tab_contacts_exists() -> void:
|
||||
## Journal title uses knowledge_panel.tab_contacts.
|
||||
assert_bool(UIStrings.has_key("knowledge_panel.tab_contacts")).override_failure_message(
|
||||
"UIStrings must have 'knowledge_panel.tab_contacts' key (D-042)"
|
||||
).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_knowledge_panel_empty_state_exists() -> void:
|
||||
## Empty state message uses knowledge_panel.empty_state.
|
||||
assert_bool(UIStrings.has_key("knowledge_panel.empty_state")).override_failure_message(
|
||||
"UIStrings must have 'knowledge_panel.empty_state' key (D-042)"
|
||||
).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
|
||||
func test_ui_strings_knowledge_panel_empty_state_non_empty() -> void:
|
||||
if not UIStrings.has_key("knowledge_panel.empty_state"): return
|
||||
assert_bool(UIStrings.get_text("knowledge_panel.empty_state").length() > 0).is_true()
|
||||
|
||||
|
||||
func test_ui_strings_knowledge_panel_tab_contacts_non_empty() -> void:
|
||||
if not UIStrings.has_key("knowledge_panel.tab_contacts"): return
|
||||
assert_bool(UIStrings.get_text("knowledge_panel.tab_contacts").length() > 0).is_true()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants: CANVAS_INSERT layer for journal
|
||||
## Journal panel (insert-layer UI, D-013) must be on CanvasLayer 10.
|
||||
# D-042 finding: CONFIDENCE_LABELS is hardcoded, not UIStrings
|
||||
## The journal panel uses a hardcoded CONFIDENCE_LABELS dict rather than UIStrings.
|
||||
## D-042 requires all labels via UIStrings YAML.
|
||||
## Filed as a finding — not a blocking test, but documents the gap.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_d042_finding_confidence_labels_not_in_uistrings() -> void:
|
||||
## D-042 gap: journal_panel.gd uses hardcoded CONFIDENCE_LABELS dict.
|
||||
## Expected per D-042: UIStrings should drive confidence display labels.
|
||||
## ui-strings.yaml has confidence_high/medium/low/rumor but not Direct/KnowsDetails/etc.
|
||||
## These keys are not mapped to UIStrings. Flagging as technical debt.
|
||||
##
|
||||
## This test PASSES because we're documenting the gap, not requiring it to be fixed now.
|
||||
## See: journal_panel.gd CONFIDENCE_LABELS vs data/ui-strings.yaml knowledge_panel section.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
var has_confidence_labels: bool = panel.CONFIDENCE_LABELS.size() > 0
|
||||
assert_bool(has_confidence_labels).is_true() # Hardcoded dict exists
|
||||
# D-042 finding: confidence labels should use UIStrings.get_text("knowledge_panel.confidence_*")
|
||||
# Current state: CONFIDENCE_LABELS = {"Direct": "Confirmed", "KnowsDetails": "Detailed", ...}
|
||||
# Recommendation: replace with UIStrings lookup in a future sprint
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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)
|
||||
assert_int(Constants.CANVAS_INSERT).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)
|
||||
func test_journal_fade_constants_reasonable() -> void:
|
||||
## FADE_IN and FADE_OUT must be short (< 0.5s) for responsive UI.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
assert_float(panel.FADE_IN).is_between(0.0, 0.5)
|
||||
assert_float(panel.FADE_OUT).is_between(0.0, 0.5)
|
||||
panel.queue_free()
|
||||
|
||||
+38
-18
@@ -19,22 +19,9 @@ extends Control
|
||||
const FADE_IN: float = 0.18
|
||||
const FADE_OUT: float = 0.25
|
||||
|
||||
# D-041 confidence → display label
|
||||
const CONFIDENCE_LABELS: Dictionary = {
|
||||
"Direct": "Confirmed",
|
||||
"KnowsDetails": "Detailed",
|
||||
"KnowsOf": "Known",
|
||||
"Suspects": "Unconfirmed",
|
||||
}
|
||||
|
||||
# D-041 source → display label
|
||||
const SOURCE_LABELS: Dictionary = {
|
||||
"DirectObservation": "Observed",
|
||||
"ToldBy": "Told",
|
||||
"Heard": "Overheard",
|
||||
"Inferred": "Inferred",
|
||||
"Background": "Prior",
|
||||
}
|
||||
# D-041/D-042: confidence and source labels loaded from UIStrings (data/ui-strings.yaml).
|
||||
# Keys: knowledge_panel.confidence_{lower} and knowledge_panel.source_{lower}
|
||||
# Fallback: raw value if key not found (UIStrings returns the key itself).
|
||||
|
||||
@onready var panel: PanelContainer = $PanelContainer
|
||||
@onready var title_label: Label = $PanelContainer/MarginContainer/VBoxContainer/TitleLabel
|
||||
@@ -135,8 +122,8 @@ func _add_entity_entry(entity: Dictionary) -> void:
|
||||
rel_label = relationship # fallback if key missing
|
||||
|
||||
var state_color: Color = _state_color(state)
|
||||
var conf_label: String = CONFIDENCE_LABELS.get(confidence, confidence)
|
||||
var src_label: String = SOURCE_LABELS.get(source, source)
|
||||
var conf_label: String = UIStrings.get_text("knowledge_panel.confidence_%s" % confidence.to_lower())
|
||||
var src_label: String = _resolve_source_label(source)
|
||||
|
||||
# Build BBCode:
|
||||
# [color=#hex][b]Name[/b][/color] [color=#rel_hex]Status[/color]
|
||||
@@ -174,6 +161,39 @@ func _add_entity_entry(entity: Dictionary) -> void:
|
||||
entries_container.add_child(spacer)
|
||||
|
||||
|
||||
## Resolve a source string from the server into a human-readable label.
|
||||
## Handles plain keys ("DirectObservation", "Heard") and ToldBy(N) format.
|
||||
## ToldBy(N) looks up entity_id N in player_knowledge.entities for the name.
|
||||
func _resolve_source_label(source: String) -> String:
|
||||
if source.is_empty():
|
||||
return ""
|
||||
|
||||
# ToldBy(entity_id) format — e.g. "ToldBy(12)"
|
||||
if source.begins_with("ToldBy(") and source.ends_with(")"):
|
||||
var id_str: String = source.substr(7, source.length() - 8)
|
||||
var entity_id: int = id_str.to_int()
|
||||
var name := _entity_name_for_id(entity_id)
|
||||
var told_prefix: String = UIStrings.get_text("knowledge_panel.source_toldby")
|
||||
if told_prefix == "knowledge_panel.source_toldby":
|
||||
told_prefix = "Told"
|
||||
return "%s: %s" % [told_prefix, name]
|
||||
|
||||
# Plain source key — look up UIStrings
|
||||
var key: String = "knowledge_panel.source_%s" % source.to_lower()
|
||||
return UIStrings.get_text(key)
|
||||
|
||||
|
||||
## Look up an entity name by stable ID from GameState.player_knowledge.entities.
|
||||
func _entity_name_for_id(entity_id: int) -> String:
|
||||
var knowledge: Variant = GameState.player_knowledge
|
||||
if knowledge == null:
|
||||
return "#%d" % entity_id
|
||||
for entity in knowledge.get("entities", []):
|
||||
if entity is Dictionary and int(entity.get("entity_id", -1)) == entity_id:
|
||||
return entity.get("name", "#%d" % entity_id)
|
||||
return "#%d" % entity_id
|
||||
|
||||
|
||||
func _state_color(state: String) -> Color:
|
||||
match state:
|
||||
"Contradicted":
|
||||
|
||||
@@ -91,7 +91,7 @@ func _draw() -> void:
|
||||
var dx: float = float(poi.x) - px
|
||||
var dy: float = float(poi.y) - py
|
||||
var dist: float = sqrt(dx * dx + dy * dy)
|
||||
var category: String = poi.get("poi_category", "")
|
||||
var category: String = poi.get("category", "")
|
||||
var color: Color = _category_color(category)
|
||||
|
||||
if dist < 0.01:
|
||||
|
||||
Reference in New Issue
Block a user