Files
settled-reach/client/tests/test_journal_sprint18.gd
T
jpmschweitzerandClaude Opus 4.6 776ba2e55f fix(client): address PR #65 round 2 — broken tests, BBCode escape, expiry loop
- test_journal_sprint18.gd: replace references to removed
  CONFIDENCE_LABELS/SOURCE_LABELS with UIStrings key tests and
  regression guards
- dialogue_box.gd: escape ] as [rb] in _escape_bbcode for complete
  BBCode injection protection
- dialogue_box.gd: fix _expire_entries to skip pinned entries with
  continue instead of break, cleaning expired entries behind pins

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 09:44:42 +01:00

602 lines
22 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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()