## 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) ## ## 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}]} ## ## 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 # --------------------------------------------------------------------------- # 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_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, "name": "Kael Davan", "confidence": "KnowsOf", "source": "DirectObservation", "state": "Active", "relationship": "PersonOfInterest", "last_observed_tick": 1024, } base.merge(overrides, true) return base 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" ) # --------------------------------------------------------------------------- # Lifecycle # --------------------------------------------------------------------------- func before_test() -> void: GameState.player_knowledge = null GameState.current_dialogue = null GameState.dialogue_active = false GameState.current_tick = 0 func after_test() -> void: GameState.player_knowledge = null GameState.current_dialogue = null GameState.dialogue_active = false # --------------------------------------------------------------------------- # GameState: player_knowledge snapshot parsing # --------------------------------------------------------------------------- func test_gamestate_player_knowledge_field_exists() -> void: ## 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)" ).is_true() func test_gamestate_player_knowledge_null_by_default() -> void: GameState.player_knowledge = null assert_that(GameState.player_knowledge).is_null() func test_gamestate_player_knowledge_set_from_snapshot() -> void: GameState.apply_snapshot({ "tick": 10, "player_knowledge": _make_player_knowledge(), }) assert_that(GameState.player_knowledge).is_not_null() assert_bool(GameState.player_knowledge.has("entities")).is_true() 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_not_null() func test_gamestate_player_knowledge_null_when_non_dict() -> void: ## 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"}) # 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_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": {"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) # --------------------------------------------------------------------------- # Journal panel: scene and API # --------------------------------------------------------------------------- func test_journal_panel_scene_exists() -> void: assert_bool(ResourceLoader.exists(JOURNAL_SCENE_PATH)).override_failure_message( "Journal panel scene must exist at res://ui/journal_panel.tscn" ).is_true() func test_journal_panel_instantiates_without_crash() -> void: var panel := _make_journal_panel() 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: entry rendering # --------------------------------------------------------------------------- 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_entity({"name": "Kael Davan"}), ]) panel.toggle() # calls _show_panel() -> _rebuild_entries() 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_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 panel.toggle() var container := _entries_container(panel) if container == null: panel.queue_free(); return ## 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 panel: mutual exclusion with dialogue # --------------------------------------------------------------------------- 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 panel.toggle() # open journal assert_bool(panel.is_open()).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: confidence and source label keys (D-042 — now via UIStrings) ## CONFIDENCE_LABELS and SOURCE_LABELS dicts were removed from journal_panel.gd. ## Labels now come from UIStrings: knowledge_panel.confidence_* / knowledge_panel.source_* # --------------------------------------------------------------------------- func test_ui_strings_confidence_direct_exists() -> void: ## D-042: confidence label for "Direct" tier must be in UIStrings. assert_bool(UIStrings.has_key("knowledge_panel.confidence_direct")).override_failure_message( "UIStrings must have 'knowledge_panel.confidence_direct' (D-042)" ).is_true() func test_ui_strings_confidence_knowsdetails_exists() -> void: assert_bool(UIStrings.has_key("knowledge_panel.confidence_knowsdetails")).override_failure_message( "UIStrings must have 'knowledge_panel.confidence_knowsdetails' (D-042)" ).is_true() func test_ui_strings_confidence_knowsof_exists() -> void: assert_bool(UIStrings.has_key("knowledge_panel.confidence_knowsof")).override_failure_message( "UIStrings must have 'knowledge_panel.confidence_knowsof' (D-042)" ).is_true() func test_ui_strings_confidence_suspects_exists() -> void: assert_bool(UIStrings.has_key("knowledge_panel.confidence_suspects")).override_failure_message( "UIStrings must have 'knowledge_panel.confidence_suspects' (D-042)" ).is_true() func test_ui_strings_all_confidence_keys_non_empty() -> void: ## All four confidence label values must be non-empty strings. var keys := [ "knowledge_panel.confidence_direct", "knowledge_panel.confidence_knowsdetails", "knowledge_panel.confidence_knowsof", "knowledge_panel.confidence_suspects", ] for key in keys: if not UIStrings.has_key(key): continue assert_bool(UIStrings.get_text(key).length() > 0).override_failure_message( "UIStrings key '%s' must be non-empty" % key ).is_true() func test_ui_strings_source_directobservation_exists() -> void: assert_bool(UIStrings.has_key("knowledge_panel.source_directobservation")).override_failure_message( "UIStrings must have 'knowledge_panel.source_directobservation' (D-042)" ).is_true() func test_ui_strings_source_toldby_exists() -> void: assert_bool(UIStrings.has_key("knowledge_panel.source_toldby")).override_failure_message( "UIStrings must have 'knowledge_panel.source_toldby' (D-042)" ).is_true() func test_ui_strings_source_heard_exists() -> void: assert_bool(UIStrings.has_key("knowledge_panel.source_heard")).override_failure_message( "UIStrings must have 'knowledge_panel.source_heard' (D-042)" ).is_true() # --------------------------------------------------------------------------- # 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_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_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() # --------------------------------------------------------------------------- # D-042: CONFIDENCE_LABELS/SOURCE_LABELS now via UIStrings — FIXED (2026-02-25) ## Previously filed as a gap: journal_panel.gd had hardcoded CONFIDENCE_LABELS dict. ## Fixed by Stig: dicts removed, all labels now use UIStrings.get_text("knowledge_panel.*"). ## Regression guard: verify the dicts are gone and UIStrings fallback works. # --------------------------------------------------------------------------- func test_d042_fixed_panel_has_no_confidence_labels_dict() -> void: ## Regression: CONFIDENCE_LABELS dict must NOT exist on journal_panel — it was removed. ## If this test fails, the hardcoded dict was accidentally re-introduced. var panel := _make_journal_panel() if panel == null: return assert_bool(panel.get("CONFIDENCE_LABELS") == null).override_failure_message( "D-042 regression: CONFIDENCE_LABELS dict must be removed from journal_panel.gd" ).is_true() panel.queue_free() func test_d042_fixed_panel_has_no_source_labels_dict() -> void: ## Regression: SOURCE_LABELS dict must NOT exist on journal_panel — it was removed. var panel := _make_journal_panel() if panel == null: return assert_bool(panel.get("SOURCE_LABELS") == null).override_failure_message( "D-042 regression: SOURCE_LABELS dict must be removed from journal_panel.gd" ).is_true() panel.queue_free() func test_d042_uistrings_fallback_for_unknown_confidence() -> void: ## UIStrings falls back to the key string itself for missing keys. ## journal_panel.gd relies on this for graceful degradation. var fallback := UIStrings.get_text("knowledge_panel.confidence_nonexistent_level") assert_that(fallback).override_failure_message( "UIStrings fallback must return the key string itself for unknown keys" ).is_equal("knowledge_panel.confidence_nonexistent_level") # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- func test_canvas_insert_constant_is_10() -> void: assert_int(Constants.CANVAS_INSERT).is_equal(10) 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()