Merge remote-tracking branch 'origin/client'

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
2026-02-25 13:07:00 +01:00
21 changed files with 2548 additions and 373 deletions
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env -S godot -s
## gdUnit4 CI runner for the Settled Reach client.
##
## Run all client tests headlessly:
## godot --headless --path client/ \
## -s res://tests/run_gdunit4.gd \
## -- --ignoreHeadlessMode -a res://tests/
##
## Run a specific test file:
## godot --headless --path client/ \
## -s res://tests/run_gdunit4.gd \
## -- --ignoreHeadlessMode -a res://tests/test_protocol.gd
##
## Exit code: 0 = all pass, non-zero = failures.
##
## D-030 (architecture.md): gdUnit4 is the confirmed Godot test framework.
## Test output format: JSON summary per D-030 sub-decision #6.
extends SceneTree
var _cli_runner: GdUnitTestCIRunner
func _initialize() -> void:
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_MINIMIZED)
_cli_runner = GdUnitTestCIRunner.new()
root.add_child(_cli_runner)
func _finalize() -> void:
queue_delete(_cli_runner)
+317
View File
@@ -0,0 +1,317 @@
## Sprint 19 — Debug visualization overlay (#348)
## F3 toggle, world overlays, tick timing graph.
## Extends the existing debug_overlay.gd stub.
class_name TestDebugOverlaySprint19
extends GdUnitTestSuite
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
const DEBUG_SCENE_PATH: String = "res://scenes/main.tscn"
const DEBUG_SCRIPT_PATH: String = "res://scripts/ui/debug_overlay.gd"
func _make_overlay() -> Control:
## Instantiate a standalone DebugOverlay control for unit testing.
## Does not require the full main.tscn scene tree.
var script := load(DEBUG_SCRIPT_PATH)
if script == null:
push_warning("TestDebugOverlaySprint19: debug_overlay.gd not found — skip")
return null
var node := Control.new()
node.set_script(script)
add_child(node)
return node
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func before_test() -> void:
GameState.visible_entities = []
GameState.visible_tiles = []
GameState.player_position = Vector2(10.0, 10.0)
GameState.player_facing = "North"
GameState.player_stance = "Walk"
GameState.current_tick = 1
GameState.player_knowledge = null
func after_test() -> void:
GameState.visible_entities = []
GameState.player_knowledge = null
# ---------------------------------------------------------------------------
# Script existence
# ---------------------------------------------------------------------------
func test_debug_overlay_script_exists() -> void:
assert_bool(ResourceLoader.exists(DEBUG_SCRIPT_PATH)).override_failure_message(
"debug_overlay.gd must exist at res://scripts/ui/debug_overlay.gd (#348)"
).is_true()
# ---------------------------------------------------------------------------
# Instantiation
# ---------------------------------------------------------------------------
func test_debug_overlay_instantiates_without_crash() -> void:
var ol := _make_overlay()
if ol == null: return
assert_that(ol).is_not_null()
ol.queue_free()
func test_debug_overlay_starts_hidden() -> void:
## Overlay starts hidden — only appears when F3 pressed.
var ol := _make_overlay()
if ol == null: return
assert_bool(ol.visible).override_failure_message(
"DebugOverlay must start hidden (visible=false)"
).is_false()
ol.queue_free()
# ---------------------------------------------------------------------------
# Dev-only guard
# ---------------------------------------------------------------------------
func test_update_from_state_exists() -> void:
var ol := _make_overlay()
if ol == null: return
assert_bool(ol.has_method("update_from_state")).override_failure_message(
"DebugOverlay must have update_from_state() method"
).is_true()
ol.queue_free()
func test_update_from_state_does_not_crash_when_hidden() -> void:
## update_from_state() called while hidden must not crash.
var ol := _make_overlay()
if ol == null: return
ol.visible = false
ol.update_from_state() # Should be a no-op, no crash
ol.queue_free()
func test_update_from_state_does_not_crash_when_visible() -> void:
var ol := _make_overlay()
if ol == null: return
ol.visible = true
# Simulate a minimal snapshot tick
GameState.current_tick = 42
ol.update_from_state()
ol.queue_free()
# ---------------------------------------------------------------------------
# NPC path tracking
# ---------------------------------------------------------------------------
func test_npc_paths_field_exists() -> void:
var ol := _make_overlay()
if ol == null: return
assert_bool(ol.has("_npc_paths")).override_failure_message(
"DebugOverlay must have _npc_paths field for NPC movement history"
).is_true()
ol.queue_free()
func test_npc_paths_updated_on_state_update() -> void:
## After update_from_state with an NPC entity, _npc_paths should have an entry.
var ol := _make_overlay()
if ol == null: return
ol.visible = true
GameState.visible_entities = [{
"entity_id": 2,
"x": 12.0, "y": 9.0, "z": 0,
"kind": {"variant": "Npc", "data": null},
"relationship": "Unknown",
}]
GameState.current_tick = 100
ol.update_from_state()
assert_int(ol._npc_paths.size()).override_failure_message(
"_npc_paths must record NPC positions from visible_entities"
).is_greater(0)
ol.queue_free()
func test_npc_paths_not_populated_for_player_entity() -> void:
## Player entities must not appear in NPC path history.
var ol := _make_overlay()
if ol == null: return
ol.visible = true
GameState.visible_entities = [{
"entity_id": 1,
"x": 10.0, "y": 10.0, "z": 0,
"kind": {"variant": "Player", "data": null},
}]
GameState.current_tick = 101
ol.update_from_state()
assert_int(ol._npc_paths.size()).override_failure_message(
"Player entity must not appear in _npc_paths"
).is_equal(0)
ol.queue_free()
func test_npc_paths_max_length_respected() -> void:
## Path history must not grow beyond NPC_HISTORY_LEN entries.
var ol := _make_overlay()
if ol == null: return
ol.visible = true
# Simulate NPC moving each tick — inject 20 ticks of movement
for i in range(20):
GameState.visible_entities = [{
"entity_id": 5,
"x": float(12 + i), "y": 9.0, "z": 0,
"kind": {"variant": "Npc", "data": null},
"relationship": "Unknown",
}]
GameState.current_tick = 200 + i
ol.update_from_state()
var path: Array = ol._npc_paths.get(5, [])
assert_int(path.size()).override_failure_message(
"NPC path must not exceed NPC_HISTORY_LEN entries (cap at %d)" % ol.NPC_HISTORY_LEN
).is_less_equal(ol.NPC_HISTORY_LEN)
ol.queue_free()
# ---------------------------------------------------------------------------
# Tick timing ring
# ---------------------------------------------------------------------------
func test_tick_deltas_field_exists() -> void:
var ol := _make_overlay()
if ol == null: return
assert_bool(ol.has("_tick_deltas")).override_failure_message(
"DebugOverlay must have _tick_deltas field for timing sparkline"
).is_true()
ol.queue_free()
func test_tick_deltas_accumulate_over_state_updates() -> void:
## Each new tick snapshot should add a delta to _tick_deltas.
var ol := _make_overlay()
if ol == null: return
ol.visible = true
for i in range(5):
GameState.current_tick = 300 + i
ol.update_from_state()
assert_int(ol._tick_deltas.size()).override_failure_message(
"_tick_deltas must accumulate entries from successive ticks"
).is_greater(0)
ol.queue_free()
func test_tick_deltas_max_length_respected() -> void:
## _tick_deltas must not grow beyond TICK_HISTORY_LEN.
var ol := _make_overlay()
if ol == null: return
ol.visible = true
for i in range(50):
GameState.current_tick = 400 + i
ol.update_from_state()
assert_int(ol._tick_deltas.size()).override_failure_message(
"_tick_deltas must not exceed TICK_HISTORY_LEN entries"
).is_less_equal(ol.TICK_HISTORY_LEN)
ol.queue_free()
# ---------------------------------------------------------------------------
# Constants defined
# ---------------------------------------------------------------------------
func test_npc_history_len_constant_exists() -> void:
var ol := _make_overlay()
if ol == null: return
assert_bool(ol.has("NPC_HISTORY_LEN")).override_failure_message(
"DebugOverlay must have NPC_HISTORY_LEN constant"
).is_true()
ol.queue_free()
func test_tick_history_len_constant_exists() -> void:
var ol := _make_overlay()
if ol == null: return
assert_bool(ol.has("TICK_HISTORY_LEN")).override_failure_message(
"DebugOverlay must have TICK_HISTORY_LEN constant"
).is_true()
ol.queue_free()
func test_tick_warn_ms_constant_defined() -> void:
var ol := _make_overlay()
if ol == null: return
assert_bool(ol.has("TICK_WARN_MS")).override_failure_message(
"DebugOverlay must have TICK_WARN_MS constant for sparkline warning threshold"
).is_true()
ol.queue_free()
# ---------------------------------------------------------------------------
# Facing angle helper
# ---------------------------------------------------------------------------
func test_facing_to_angle_north() -> void:
## North = -PI/2 in Godot 2D (up on screen)
var angle := _fetch_facing_angle("North")
assert_float(angle).override_failure_message(
"_facing_to_angle('North') must return -PI/2"
).is_equal_approx(-PI / 2.0, 0.001)
func test_facing_to_angle_east() -> void:
var angle := _fetch_facing_angle("East")
assert_float(angle).is_equal_approx(0.0, 0.001)
func test_facing_to_angle_south() -> void:
var angle := _fetch_facing_angle("South")
assert_float(angle).is_equal_approx(PI / 2.0, 0.001)
func test_facing_to_angle_west() -> void:
var angle := _fetch_facing_angle("West")
assert_float(angle).is_equal_approx(PI, 0.001)
func _fetch_facing_angle(facing: String) -> float:
## Helper: load script and call static method.
var script = load(DEBUG_SCRIPT_PATH)
if script == null:
return 0.0
# In GDScript 4, static methods can be called via an instance
var tmp := Control.new()
tmp.set_script(script)
add_child(tmp)
var result := tmp._facing_to_angle(facing)
tmp.queue_free()
return result
# ---------------------------------------------------------------------------
# In-scene placement: DebugOverlay on UILayer
# ---------------------------------------------------------------------------
func test_debug_overlay_in_main_scene_ui_layer() -> void:
## DebugOverlay must be in UILayer (CanvasLayer 20), not InsertOverlay.
if not ResourceLoader.exists("res://scenes/main.tscn"):
push_warning("TestDebugOverlaySprint19: main.tscn not found — skip")
return
var scene: Node = load("res://scenes/main.tscn").instantiate()
auto_free(scene)
add_child(scene)
var ui_layer := scene.get_node_or_null("UILayer")
assert_that(ui_layer != null).override_failure_message(
"UILayer must exist in main.tscn"
).is_true()
if ui_layer == null: return
var overlay := ui_layer.get_node_or_null("DebugOverlay")
assert_that(overlay != null).override_failure_message(
"DebugOverlay must be a child of UILayer in main.tscn (#348)"
).is_true()
@@ -0,0 +1,334 @@
## Sprint 18 — Examine result display (#174)
## Spec refs: D-061 (adjacent to dialogue spec), D-041 (character-filtered observation)
##
## ExamineDisplay: non-interactive overlay, diegetic, auto-dismisses after DISMISS_DELAY.
## Positioned in InsertOverlay (CanvasLayer 10).
## GameState.current_examine_result: cleared every snapshot (unlike player_knowledge).
##
## Tests run against live Stig implementation (examine_display.gd).
class_name TestExamineDisplaySprint18
extends GdUnitTestSuite
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
const EXAMINE_SCENE_PATH: String = "res://ui/examine_display.tscn"
func _make_examine_display() -> Control:
if not ResourceLoader.exists(EXAMINE_SCENE_PATH):
push_warning("TestExamineDisplaySprint18: examine_display.tscn not found — skip")
return null
var node: Control = load(EXAMINE_SCENE_PATH).instantiate()
add_child(node)
return node
func _make_result(overrides: Dictionary = {}) -> Dictionary:
var base: Dictionary = {
"entity_id": 42,
"text": "Kael Davan — nervous energy. He's scanning exits.",
"confidence": "KnowsOf",
}
base.merge(overrides, true)
return base
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func before_test() -> void:
GameState.current_examine_result = null
func after_test() -> void:
GameState.current_examine_result = null
# ---------------------------------------------------------------------------
# GameState: current_examine_result parsing
## (Now tests real implementation — not test-first stubs)
# ---------------------------------------------------------------------------
func test_gamestate_examine_result_field_exists() -> void:
assert_bool(GameState.has("current_examine_result")).override_failure_message(
"GameState must have 'current_examine_result' field (#174)"
).is_true()
func test_gamestate_examine_result_null_by_default() -> void:
GameState.current_examine_result = null
assert_that(GameState.current_examine_result).is_null()
func test_gamestate_examine_result_set_from_snapshot() -> void:
GameState.apply_snapshot({"tick": 5, "examine_result": _make_result()})
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:
## CONTRAST with player_knowledge: examine_result DOES clear each snapshot.
## The overlay must auto-dismiss — the server never re-sends the same result.
GameState.current_examine_result = _make_result()
GameState.apply_snapshot({"tick": 6})
assert_that(GameState.current_examine_result).is_null()
func test_gamestate_examine_result_null_when_non_dict() -> void:
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:
GameState.apply_snapshot({"tick": 1, "examine_result": _make_result({"entity_id": 99})})
assert_int(GameState.current_examine_result.get("entity_id", -1)).is_equal(99)
func test_gamestate_examine_result_confidence_survives_roundtrip() -> void:
GameState.apply_snapshot({"tick": 1, "examine_result": _make_result({"confidence": "Direct"})})
assert_that(GameState.current_examine_result.get("confidence")).is_equal("Direct")
func test_gamestate_examine_result_replaced_on_next_snapshot() -> void:
## Two examine results in sequence — second replaces first.
GameState.apply_snapshot({"tick": 1, "examine_result": _make_result({"text": "First observation."})})
GameState.apply_snapshot({"tick": 2, "examine_result": _make_result({"text": "Second observation."})})
assert_that(GameState.current_examine_result.get("text")).is_equal("Second observation.")
# ---------------------------------------------------------------------------
# ExamineDisplay scene
# ---------------------------------------------------------------------------
func test_examine_display_scene_exists() -> void:
assert_bool(ResourceLoader.exists(EXAMINE_SCENE_PATH)).override_failure_message(
"ExamineDisplay scene must exist at res://ui/examine_display.tscn"
).is_true()
func test_examine_display_instantiates_without_crash() -> void:
var display := _make_examine_display()
if display == null: return
assert_that(display).is_not_null()
display.queue_free()
func test_examine_display_has_show_result_method() -> void:
var display := _make_examine_display()
if display == null: return
assert_bool(display.has_method("show_result")).override_failure_message(
"ExamineDisplay must have show_result(result: Dictionary) method"
).is_true()
display.queue_free()
func test_examine_display_has_dismiss_method() -> void:
var display := _make_examine_display()
if display == null: return
assert_bool(display.has_method("dismiss")).override_failure_message(
"ExamineDisplay must have dismiss() method"
).is_true()
display.queue_free()
func test_examine_display_has_is_active_method() -> void:
var display := _make_examine_display()
if display == null: return
assert_bool(display.has_method("is_active")).override_failure_message(
"ExamineDisplay must have is_active() method"
).is_true()
display.queue_free()
# ---------------------------------------------------------------------------
# ExamineDisplay behavior
# ---------------------------------------------------------------------------
func test_examine_display_not_active_on_init() -> void:
var display := _make_examine_display()
if display == null: return
assert_bool(display.is_active()).override_failure_message(
"ExamineDisplay must start inactive (no result showing)"
).is_false()
display.queue_free()
func test_examine_display_not_visible_on_init() -> void:
var display := _make_examine_display()
if display == null: return
assert_bool(display.visible).override_failure_message(
"ExamineDisplay must start invisible"
).is_false()
display.queue_free()
func test_examine_display_active_after_show_result() -> void:
## show_result() with valid text sets is_active() = true.
var display := _make_examine_display()
if display == null: return
display.show_result(_make_result())
assert_bool(display.is_active()).override_failure_message(
"show_result() with text must set is_active() = true"
).is_true()
display.queue_free()
func test_examine_display_visible_after_show_result() -> void:
var display := _make_examine_display()
if display == null: return
display.show_result(_make_result())
assert_bool(display.visible).override_failure_message(
"show_result() must set visible = true"
).is_true()
display.queue_free()
func test_examine_display_empty_text_ignored() -> void:
## show_result() with empty text must not activate (D-041: no empty observations).
var display := _make_examine_display()
if display == null: return
display.show_result({"entity_id": 1, "text": "", "confidence": "KnowsOf"})
assert_bool(display.is_active()).override_failure_message(
"show_result() with empty text must not activate the display"
).is_false()
display.queue_free()
func test_examine_display_inactive_after_dismiss() -> void:
## dismiss() immediately starts fade-out and sets _active = false.
var display := _make_examine_display()
if display == null: return
display.show_result(_make_result())
assert_bool(display.is_active()).is_true()
display.dismiss()
assert_bool(display.is_active()).override_failure_message(
"dismiss() must set is_active() = false immediately"
).is_false()
display.queue_free()
func test_examine_display_dismiss_when_inactive_no_crash() -> void:
## dismiss() on an inactive display must be safe (no crash, no state corruption).
var display := _make_examine_display()
if display == null: return
display.dismiss() # called when not active
assert_bool(display.is_active()).is_false()
display.queue_free()
func test_examine_display_show_replaces_previous() -> void:
## Second show_result() replaces first (only one result at a time).
var display := _make_examine_display()
if display == null: return
display.show_result(_make_result({"text": "First observation."}))
display.show_result(_make_result({"text": "Second observation."}))
assert_bool(display.is_active()).override_failure_message(
"show_result() called twice must leave display active"
).is_true()
## Text label should reflect the second result
var text_label := display.get_node_or_null("PanelContainer/MarginContainer/TextLabel")
if text_label is RichTextLabel:
assert_that(text_label.text).override_failure_message(
"Second show_result() must replace the displayed text"
).is_equal("Second observation.")
display.queue_free()
# ---------------------------------------------------------------------------
# ExamineDisplay: DISMISS_DELAY within spec
# ---------------------------------------------------------------------------
func test_dismiss_delay_within_spec() -> void:
## Spec (sprint-18/client.md): auto-dismisses after 4–6 seconds.
var display := _make_examine_display()
if display == null: return
assert_float(display.DISMISS_DELAY).override_failure_message(
"DISMISS_DELAY must be 4–6 seconds per spec"
).is_between(4.0, 6.0)
display.queue_free()
# ---------------------------------------------------------------------------
# ExamineDisplay: CONFIDENCE_ALPHA — confidence-based alpha modulation
# ---------------------------------------------------------------------------
func test_confidence_alpha_dict_covers_all_levels() -> void:
## All four confidence levels must have alpha mappings.
var display := _make_examine_display()
if display == null: return
var alpha_dict: Dictionary = display.CONFIDENCE_ALPHA
for level in ["Direct", "KnowsDetails", "KnowsOf", "Suspects"]:
assert_bool(alpha_dict.has(level)).override_failure_message(
"CONFIDENCE_ALPHA must map '%s'" % level
).is_true()
display.queue_free()
func test_confidence_alpha_direct_is_highest() -> void:
## Direct confidence = brightest (alpha 1.0). Character fully trusts this observation.
var display := _make_examine_display()
if display == null: return
var alpha_dict: Dictionary = display.CONFIDENCE_ALPHA
assert_float(alpha_dict.get("Direct", 0.0)).override_failure_message(
"Direct confidence must have alpha 1.0 (brightest)"
).is_equal_approx(1.0, 0.001)
display.queue_free()
func test_confidence_alpha_suspects_is_lowest() -> void:
## Suspects = most dimmed (lowest alpha). Uncertainty is visually represented.
var display := _make_examine_display()
if display == null: return
var alpha_dict: Dictionary = display.CONFIDENCE_ALPHA
var suspects_alpha: float = alpha_dict.get("Suspects", 1.0)
var direct_alpha: float = alpha_dict.get("Direct", 0.0)
assert_float(suspects_alpha).override_failure_message(
"Suspects alpha must be less than Direct alpha (dimmer = less certain)"
).is_less(direct_alpha)
display.queue_free()
func test_confidence_alpha_all_values_valid() -> void:
## All alpha values must be in [0.0, 1.0].
var display := _make_examine_display()
if display == null: return
for key in display.CONFIDENCE_ALPHA:
var alpha: float = display.CONFIDENCE_ALPHA[key]
assert_float(alpha).override_failure_message(
"CONFIDENCE_ALPHA['%s'] = %.2f must be in [0, 1]" % [key, alpha]
).is_between(0.0, 1.0)
display.queue_free()
# ---------------------------------------------------------------------------
# Non-interactive: mouse_filter must be IGNORE
# ---------------------------------------------------------------------------
func test_examine_display_mouse_filter_ignore() -> void:
## ExamineDisplay is non-interactive — must not consume mouse events.
var display := _make_examine_display()
if display == null: return
assert_int(display.mouse_filter).override_failure_message(
"ExamineDisplay must have mouse_filter=IGNORE (non-interactive overlay)"
).is_equal(Control.MOUSE_FILTER_IGNORE)
display.queue_free()
# ---------------------------------------------------------------------------
# Fade constants
# ---------------------------------------------------------------------------
func test_fade_in_is_short() -> void:
var display := _make_examine_display()
if display == null: return
assert_float(display.FADE_IN).is_between(0.0, 0.5)
display.queue_free()
func test_fade_out_is_short() -> void:
var display := _make_examine_display()
if display == null: return
assert_float(display.FADE_OUT).is_between(0.0, 0.5)
display.queue_free()
+156
View File
@@ -0,0 +1,156 @@
## GameState.apply_snapshot() tests — v2+ field coverage.
##
## Complements test_snapshot_parsing.gd (which covers v1 basics: tick, entities,
## player_position). This file covers v2+ fields and derived state.
##
## D-030: fixture-based, server-free, no subprocess required.
class_name TestGameState
extends GdUnitTestSuite
func before_each() -> void:
# Reset fields touched by these tests to known defaults.
GameState.current_tick = 0
GameState.player_position = Vector2.ZERO
GameState.player_facing = "North"
GameState.game_time = {}
GameState.nearby_interactions = []
GameState.current_monologue = null
GameState.player_stance = "Walk"
GameState.player_inventory = []
GameState.stationary_ticks = 0
GameState.insert_active = true
# -- v2: game_time (D-031) -------------------------------------------------
func test_apply_snapshot_sets_game_time() -> void:
var snapshot := {
"tick": 10,
"entities": [],
"game_time": {"day": 3, "time_of_day": 480, "day_phase": "Morning", "tick_rate": 1},
}
GameState.apply_snapshot(snapshot)
assert_that(GameState.game_time).is_not_null()
assert_that(GameState.game_time.get("day")).is_equal(3)
assert_that(GameState.game_time.get("time_of_day")).is_equal(480)
func test_apply_snapshot_game_time_missing_keeps_previous() -> void:
GameState.game_time = {"day": 2, "time_of_day": 360}
GameState.apply_snapshot({"tick": 5, "entities": []})
# No "game_time" key → field unchanged
assert_that(GameState.game_time.get("day")).is_equal(2)
# -- v2: player_facing (D-015) --------------------------------------------
func test_apply_snapshot_sets_player_facing() -> void:
var snapshot := {
"tick": 1,
"entities": [],
"player_facing": "Southeast",
}
GameState.apply_snapshot(snapshot)
assert_that(GameState.player_facing).is_equal("Southeast")
func test_apply_snapshot_player_facing_missing_keeps_default() -> void:
GameState.player_facing = "West"
GameState.apply_snapshot({"tick": 1, "entities": []})
assert_that(GameState.player_facing).is_equal("West")
# -- v4: nearby_interactions (#404/#405) ----------------------------------
func test_apply_snapshot_sets_nearby_interactions() -> void:
var interactions := [
{"entity_id": 5, "entity_type": "Npc", "distance": 1.2, "verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}]},
]
GameState.apply_snapshot({"tick": 1, "entities": [], "nearby_interactions": interactions})
assert_that(GameState.nearby_interactions.size()).is_equal(1)
assert_that(GameState.nearby_interactions[0].get("entity_id")).is_equal(5)
func test_apply_snapshot_nearby_interactions_absent_clears_list() -> void:
GameState.nearby_interactions = [{"entity_id": 1}]
GameState.apply_snapshot({"tick": 2, "entities": []})
assert_that(GameState.nearby_interactions.size()).is_equal(0)
# -- v5: current_monologue (#414) -----------------------------------------
func test_apply_snapshot_sets_monologue() -> void:
var monologue := {"id": "m1", "text": "Something is off here.", "duration_seconds": 4.0, "priority": 1, "is_urgent": false}
GameState.apply_snapshot({"tick": 1, "entities": [], "current_monologue": monologue})
assert_that(GameState.current_monologue).is_not_null()
assert_that(GameState.current_monologue.get("text")).is_equal("Something is off here.")
func test_apply_snapshot_monologue_absent_clears_field() -> void:
GameState.current_monologue = {"id": "old", "text": "Old line."}
GameState.apply_snapshot({"tick": 2, "entities": []})
assert_that(GameState.current_monologue).is_null()
# -- v6: player_stance (#449, D-053) --------------------------------------
func test_apply_snapshot_sets_player_stance() -> void:
GameState.apply_snapshot({"tick": 1, "entities": [], "player_stance": "Crouch"})
assert_that(GameState.player_stance).is_equal("Crouch")
# -- v6: player_inventory (#449, D-065) -----------------------------------
func test_apply_snapshot_sets_player_inventory() -> void:
var inventory := [{"item_id": 42, "name": "Security pass", "slot": 0}]
GameState.apply_snapshot({"tick": 1, "entities": [], "player_inventory": inventory})
assert_that(GameState.player_inventory.size()).is_equal(1)
assert_that(GameState.player_inventory[0].get("name")).is_equal("Security pass")
func test_apply_snapshot_inventory_absent_clears_list() -> void:
GameState.player_inventory = [{"item_id": 1}]
GameState.apply_snapshot({"tick": 2, "entities": []})
assert_that(GameState.player_inventory.size()).is_equal(0)
# -- Stationary tick counter (D-071) -------------------------------------
func test_stationary_ticks_increments_when_player_position_unchanged() -> void:
var snapshot := {
"tick": 1,
"entities": [{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": null}}],
}
GameState.apply_snapshot(snapshot) # first call: position changes from ZERO
GameState.apply_snapshot(snapshot) # second call: position unchanged → +1
assert_int(GameState.stationary_ticks).is_greater(0)
func test_stationary_ticks_resets_on_player_movement() -> void:
var s1 := {
"tick": 1,
"entities": [{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": null}}],
}
var s2 := {
"tick": 2,
"entities": [{"entity_id": 1, "x": 11.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": null}}],
}
GameState.apply_snapshot(s1)
GameState.apply_snapshot(s1) # stationary
assert_int(GameState.stationary_ticks).is_greater(0)
GameState.apply_snapshot(s2) # moved → reset
assert_int(GameState.stationary_ticks).is_equal(0)
# -- insert_active (OQ-07, #522) -----------------------------------------
func test_apply_snapshot_insert_active_false() -> void:
GameState.apply_snapshot({"tick": 1, "entities": [], "insert_active": false})
assert_bool(GameState.insert_active).is_false()
func test_apply_snapshot_insert_active_defaults_true_when_absent() -> void:
GameState.insert_active = false
GameState.apply_snapshot({"tick": 2, "entities": []})
assert_bool(GameState.insert_active).is_true()
+320
View File
@@ -0,0 +1,320 @@
## Sprint 18 — Minimap rendering (#151)
## Spec refs: D-013 (diegetic insert/POI system), D-015 (fixed-north, player-centered),
## D-049 (z-layer 6 = InsertOverlay)
##
## MinimapRenderer: circular insert overlay, always renders frame, draws discovered POIs.
## Scene: res://ui/minimap.tscn (class_name MinimapRenderer)
## Positioned at InsertOverlay/Minimap in main.tscn.
##
## Tests run against live Stig implementation (minimap.gd).
class_name TestMinimapSprint18
extends GdUnitTestSuite
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
const MINIMAP_SCENE_PATH: String = "res://ui/minimap.tscn"
func _make_minimap() -> Control:
if not ResourceLoader.exists(MINIMAP_SCENE_PATH):
push_warning("TestMinimapSprint18: minimap.tscn not found — skip")
return null
var node: Control = load(MINIMAP_SCENE_PATH).instantiate()
add_child(node)
return node
func _make_poi(overrides: Dictionary = {}) -> Dictionary:
var base: Dictionary = {
"id": "poi_test_001",
"x": 20,
"y": 15,
"poi_category": "location",
"label": "Exit A",
}
base.merge(overrides, true)
return base
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func before_test() -> void:
GameState.discovered_pois = []
GameState.player_position = Vector2(10.0, 10.0)
GameState.insert_active = true
func after_test() -> void:
GameState.discovered_pois = []
GameState.player_position = Vector2.ZERO
GameState.insert_active = true
# ---------------------------------------------------------------------------
# Scene and class
# ---------------------------------------------------------------------------
func test_minimap_scene_exists() -> void:
assert_bool(ResourceLoader.exists(MINIMAP_SCENE_PATH)).override_failure_message(
"Minimap scene must exist at res://ui/minimap.tscn (#151)"
).is_true()
func test_minimap_instantiates_without_crash() -> void:
var mm := _make_minimap()
if mm == null: return
assert_that(mm).is_not_null()
mm.queue_free()
func test_minimap_is_minimap_renderer_class() -> void:
## class_name MinimapRenderer in minimap.gd.
var mm := _make_minimap()
if mm == null: return
assert_bool(mm is MinimapRenderer).override_failure_message(
"Minimap node must be a MinimapRenderer instance (check class_name in minimap.gd)"
).is_true()
mm.queue_free()
# ---------------------------------------------------------------------------
# Constants: D-015, visual parameters
# ---------------------------------------------------------------------------
func test_minimap_radius_constant() -> void:
## MINIMAP_RADIUS defines the sim-tile distance of visible POI area.
## Value is tuned to 24 tiles — reasonable coverage without map reveal.
assert_float(MinimapRenderer.MINIMAP_RADIUS).override_failure_message(
"MinimapRenderer.MINIMAP_RADIUS must be 24.0"
).is_equal_approx(24.0, 0.01)
func test_player_dot_radius_defined() -> void:
## Player dot must be visible (> 0) and distinct from POI dot.
assert_float(MinimapRenderer.PLAYER_DOT_RADIUS).is_greater(0.0)
func test_poi_dot_radius_defined() -> void:
## POI dot must be visible (> 0).
assert_float(MinimapRenderer.POI_DOT_RADIUS).is_greater(0.0)
func test_player_dot_larger_than_poi_dot() -> void:
## D-015: Player is always centered and visually distinct.
## Player dot should be at least as large as POI dot.
assert_float(MinimapRenderer.PLAYER_DOT_RADIUS).is_greater_equal(MinimapRenderer.POI_DOT_RADIUS)
# ---------------------------------------------------------------------------
# _category_color() — D-013 POI category color mapping
# ---------------------------------------------------------------------------
func test_category_color_danger_is_hostile_color() -> void:
## "danger", "threat", "hostile" → ENTITY_COLOR_HOSTILE (red)
for cat in ["danger", "threat", "hostile"]:
var color: Color = MinimapRenderer._category_color(cat)
assert_that(color).override_failure_message(
"Category '%s' must map to ENTITY_COLOR_HOSTILE" % cat
).is_equal(Constants.ENTITY_COLOR_HOSTILE)
func test_category_color_evidence_is_poi_color() -> void:
## "evidence", "note", "clue" → ENTITY_COLOR_POI (amber)
for cat in ["evidence", "note", "clue"]:
var color: Color = MinimapRenderer._category_color(cat)
assert_that(color).override_failure_message(
"Category '%s' must map to ENTITY_COLOR_POI (amber)" % cat
).is_equal(Constants.ENTITY_COLOR_POI)
func test_category_color_contact_is_unknown_color() -> void:
## "contact", "npc", "person" → ENTITY_COLOR_UNKNOWN (teal)
for cat in ["contact", "npc", "person"]:
var color: Color = MinimapRenderer._category_color(cat)
assert_that(color).override_failure_message(
"Category '%s' must map to ENTITY_COLOR_UNKNOWN (teal)" % cat
).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
func test_category_color_unknown_category_defaults_to_insert_text() -> void:
## Unknown/unspecified categories → INSERT_COLOR_TEXT (white-blue default)
var color: Color = MinimapRenderer._category_color("some_unknown_type")
assert_that(color).override_failure_message(
"Unknown category must default to INSERT_COLOR_TEXT"
).is_equal(Constants.INSERT_COLOR_TEXT)
func test_category_color_empty_string_defaults() -> void:
## Empty category string → default color, no crash.
var color: Color = MinimapRenderer._category_color("")
assert_that(color).is_equal(Constants.INSERT_COLOR_TEXT)
func test_category_color_case_insensitive() -> void:
## Category matching is case-insensitive (uses to_lower()).
var danger_lower := MinimapRenderer._category_color("danger")
var danger_upper := MinimapRenderer._category_color("DANGER")
var danger_mixed := MinimapRenderer._category_color("Danger")
assert_that(danger_lower).is_equal(danger_upper)
assert_that(danger_lower).is_equal(danger_mixed)
# ---------------------------------------------------------------------------
# set_insert_active() — D-049: insert layer visibility
# ---------------------------------------------------------------------------
func test_set_insert_active_false_hides_minimap() -> void:
## When insert is inactive, minimap must be hidden.
var mm := _make_minimap()
if mm == null: return
mm.set_insert_active(false)
assert_bool(mm.visible).override_failure_message(
"set_insert_active(false) must hide the minimap"
).is_false()
mm.queue_free()
func test_set_insert_active_true_shows_minimap() -> void:
## When insert is active, minimap must be visible.
var mm := _make_minimap()
if mm == null: return
mm.set_insert_active(false)
mm.set_insert_active(true)
assert_bool(mm.visible).override_failure_message(
"set_insert_active(true) must show the minimap"
).is_true()
mm.queue_free()
# ---------------------------------------------------------------------------
# Main scene structural check: InsertOverlay/Minimap
# ---------------------------------------------------------------------------
func test_minimap_in_main_scene_on_insert_overlay() -> void:
## D-049: Minimap must be in InsertOverlay (CanvasLayer 10), not UILayer.
## Scene path: Game/InsertOverlay/Minimap or InsertOverlay/Minimap.
if not ResourceLoader.exists("res://scenes/main.tscn"):
push_warning("TestMinimapSprint18: main.tscn not found — scene tree test skipped")
return
var scene: Node = load("res://scenes/main.tscn").instantiate()
auto_free(scene)
add_child(scene)
# Check for Minimap in InsertOverlay
var insert_overlay := scene.get_node_or_null("InsertOverlay")
assert_that(insert_overlay != null).override_failure_message(
"InsertOverlay (CanvasLayer 10) must exist in main.tscn"
).is_true()
if insert_overlay == null: return
var minimap := insert_overlay.get_node_or_null("Minimap")
assert_that(minimap != null).override_failure_message(
"Minimap must be a child of InsertOverlay in main.tscn (D-049: insert layer)"
).is_true()
if minimap == null: return
assert_bool(minimap is MinimapRenderer).override_failure_message(
"InsertOverlay/Minimap must be a MinimapRenderer instance"
).is_true()
func test_insert_overlay_is_canvas_layer_10() -> void:
## InsertOverlay must be CanvasLayer 10 (CANVAS_INSERT per D-049).
if not ResourceLoader.exists("res://scenes/main.tscn"):
push_warning("TestMinimapSprint18: main.tscn not found — canvas layer test skipped")
return
var scene: Node = load("res://scenes/main.tscn").instantiate()
auto_free(scene)
add_child(scene)
var insert_overlay := scene.get_node_or_null("InsertOverlay") as CanvasLayer
if insert_overlay == null: return
assert_int(insert_overlay.layer).override_failure_message(
"InsertOverlay must be CanvasLayer %d (CANVAS_INSERT)" % Constants.CANVAS_INSERT
).is_equal(Constants.CANVAS_INSERT)
# ---------------------------------------------------------------------------
# GameState.discovered_pois integration
# ---------------------------------------------------------------------------
func test_discovered_pois_field_exists_in_gamestate() -> void:
assert_bool(GameState.has("discovered_pois")).override_failure_message(
"GameState must have 'discovered_pois' field (#151)"
).is_true()
func test_discovered_pois_set_from_poi_list_snapshot() -> void:
## Snapshot with "poi_list" key (Sprint 17 server wire name) populates discovered_pois.
GameState.apply_snapshot({
"tick": 1,
"poi_list": [
_make_poi({"id": "p1", "x": 50, "y": 30, "poi_category": "location"}),
_make_poi({"id": "p2", "x": 80, "y": 15, "poi_category": "contact"}),
],
})
assert_int(GameState.discovered_pois.size()).override_failure_message(
"discovered_pois must be populated from snapshot 'poi_list' field"
).is_equal(2)
func test_discovered_pois_set_from_discovered_pois_snapshot() -> void:
## Snapshot with "discovered_pois" key also works.
GameState.apply_snapshot({
"tick": 2,
"discovered_pois": [_make_poi()],
})
assert_int(GameState.discovered_pois.size()).is_equal(1)
func test_discovered_pois_persists_when_absent_from_snapshot() -> void:
## Like player_knowledge: POI list persists when server doesn't send an update.
GameState.discovered_pois = [_make_poi()]
GameState.apply_snapshot({"tick": 3})
assert_int(GameState.discovered_pois.size()).override_failure_message(
"discovered_pois must persist when absent from snapshot (not cleared each tick)"
).is_equal(1)
func test_discovered_pois_poi_category_field_present() -> void:
## MinimapRenderer reads poi_category to determine shape/color.
## Verify the wire format includes this field.
GameState.apply_snapshot({
"tick": 1,
"poi_list": [_make_poi({"poi_category": "danger"})],
})
assert_int(GameState.discovered_pois.size()).is_greater(0)
var first_poi: Dictionary = GameState.discovered_pois[0]
assert_bool(first_poi.has("poi_category")).override_failure_message(
"POI entries must have 'poi_category' field for MinimapRenderer shape selection"
).is_true()
func test_discovered_pois_x_y_fields_present() -> void:
## MinimapRenderer reads x, y for position calculation.
GameState.apply_snapshot({
"tick": 1,
"poi_list": [_make_poi({"x": 42, "y": 17})],
})
assert_int(GameState.discovered_pois.size()).is_greater(0)
var first_poi: Dictionary = GameState.discovered_pois[0]
assert_bool(first_poi.has("x") and first_poi.has("y")).override_failure_message(
"POI entries must have 'x' and 'y' coordinate fields"
).is_true()
# ---------------------------------------------------------------------------
# Color constants: all distinct
# ---------------------------------------------------------------------------
func test_category_colors_are_distinct() -> void:
## All three primary category color groups must be visually distinct.
var danger_color := MinimapRenderer._category_color("danger")
var evidence_color := MinimapRenderer._category_color("evidence")
var contact_color := MinimapRenderer._category_color("contact")
assert_that(danger_color).is_not_equal(evidence_color)
assert_that(evidence_color).is_not_equal(contact_color)
assert_that(danger_color).is_not_equal(contact_color)
@@ -0,0 +1,208 @@
## Sprint 19 — Game session management (#258, D-085)
## Per-game save directories: created on New Game, resumed via game-id.
## SessionManager autoload: new_game(), resume_game(), list_game_dirs().
class_name TestSessionManagerSprint19
extends GdUnitTestSuite
# Game IDs created during the current test — deleted in after_test().
var _created_ids: Array = []
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func before_test() -> void:
GameState.current_game_id = ""
_created_ids = []
func after_test() -> void:
for game_id in _created_ids:
var path := "user://saves/" + game_id
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
_created_ids.clear()
GameState.current_game_id = ""
# ---------------------------------------------------------------------------
# Helper: call new_game() and track the created directory for cleanup.
# ---------------------------------------------------------------------------
func _new_game() -> String:
var game_id := SessionManager.new_game()
if not game_id.is_empty():
_created_ids.append(game_id)
return game_id
# ---------------------------------------------------------------------------
# GameState.current_game_id field
# ---------------------------------------------------------------------------
func test_current_game_id_field_exists() -> void:
## D-085: GameState must have current_game_id field.
assert_bool(GameState.has("current_game_id")).override_failure_message(
"GameState must have 'current_game_id' field (D-085 #258)"
).is_true()
func test_current_game_id_default_is_empty_string() -> void:
## Before any session starts, current_game_id is empty.
GameState.current_game_id = ""
assert_str(GameState.current_game_id).override_failure_message(
"GameState.current_game_id default must be empty string"
).is_empty()
# ---------------------------------------------------------------------------
# SessionManager autoload exists
# ---------------------------------------------------------------------------
func test_session_manager_autoload_exists() -> void:
## SessionManager must be registered as an autoload.
var sm := Engine.get_singleton("SessionManager")
assert_that(sm != null).override_failure_message(
"SessionManager must be registered as autoload in project.godot (#258)"
).is_true()
# ---------------------------------------------------------------------------
# new_game() — game-id format and GameState update
# ---------------------------------------------------------------------------
func test_new_game_returns_non_empty_string() -> void:
var game_id := _new_game()
assert_str(game_id).override_failure_message(
"SessionManager.new_game() must return a non-empty game-id string"
).is_not_empty()
func test_new_game_sets_current_game_id_on_gamestate() -> void:
var game_id := _new_game()
assert_str(GameState.current_game_id).override_failure_message(
"new_game() must set GameState.current_game_id"
).is_equal(game_id)
func test_new_game_id_format_has_two_dashes() -> void:
## Format: <YYYYMMDD>-<HHMMSS>-<hex6> — two separator dashes.
var game_id := _new_game()
var parts := game_id.split("-")
assert_int(parts.size()).override_failure_message(
"game-id must have format <YYYYMMDD>-<HHMMSS>-<hex6> (3 parts separated by '-')"
).is_equal(3)
func test_new_game_id_first_part_is_8_digits() -> void:
## First part is YYYYMMDD — 8 decimal digits.
var game_id := _new_game()
var parts := game_id.split("-")
assert_int(parts[0].length()).override_failure_message(
"game-id first part (date) must be 8 characters (YYYYMMDD)"
).is_equal(8)
func test_new_game_id_second_part_is_6_digits() -> void:
## Second part is HHMMSS — 6 decimal digits.
var game_id := _new_game()
var parts := game_id.split("-")
assert_int(parts[1].length()).override_failure_message(
"game-id second part (time) must be 6 characters (HHMMSS)"
).is_equal(6)
func test_new_game_id_third_part_is_6_hex_chars() -> void:
## Third part is 6 hex characters (RNG seed).
var game_id := _new_game()
var parts := game_id.split("-")
assert_int(parts[2].length()).override_failure_message(
"game-id third part (hex seed) must be 6 characters"
).is_equal(6)
func test_new_game_ids_are_unique() -> void:
## Two rapid new_game() calls should produce different IDs
## (different RNG seeds; same-second timestamps are valid but seeds differ).
var id1 := _new_game()
var id2 := _new_game()
# Check that hex seeds differ (they almost certainly will)
var seed1 := id1.split("-")[2]
var seed2 := id2.split("-")[2]
assert_str(seed1).override_failure_message(
"Successive new_game() calls should have different RNG seeds"
).is_not_equal(seed2)
# ---------------------------------------------------------------------------
# resume_game() — sets GameState.current_game_id
# ---------------------------------------------------------------------------
func test_resume_game_sets_current_game_id() -> void:
var test_id := "20260225-143022-a7b3f1"
SessionManager.resume_game(test_id)
assert_str(GameState.current_game_id).override_failure_message(
"resume_game() must set GameState.current_game_id to the given id"
).is_equal(test_id)
func test_resume_game_overwrites_previous_game_id() -> void:
SessionManager.resume_game("20260225-100000-aabbcc")
SessionManager.resume_game("20260225-120000-112233")
assert_str(GameState.current_game_id).is_equal("20260225-120000-112233")
# ---------------------------------------------------------------------------
# Main menu scene
# ---------------------------------------------------------------------------
func test_main_menu_scene_exists() -> void:
assert_bool(ResourceLoader.exists("res://scenes/main_menu.tscn")).override_failure_message(
"Main menu scene must exist at res://scenes/main_menu.tscn (#258)"
).is_true()
func test_main_menu_instantiates_without_crash() -> void:
if not ResourceLoader.exists("res://scenes/main_menu.tscn"):
push_warning("TestSessionManagerSprint19: main_menu.tscn not found — skip")
return
var scene: Node = load("res://scenes/main_menu.tscn").instantiate()
auto_free(scene)
add_child(scene)
assert_that(scene).is_not_null()
func test_main_menu_has_new_game_button() -> void:
if not ResourceLoader.exists("res://scenes/main_menu.tscn"):
return
var scene: Node = load("res://scenes/main_menu.tscn").instantiate()
auto_free(scene)
add_child(scene)
var btn := scene.get_node_or_null("VBox/NewGameBtn")
assert_that(btn != null).override_failure_message(
"Main menu must have VBox/NewGameBtn (#258)"
).is_true()
func test_main_menu_has_continue_button() -> void:
if not ResourceLoader.exists("res://scenes/main_menu.tscn"):
return
var scene: Node = load("res://scenes/main_menu.tscn").instantiate()
auto_free(scene)
add_child(scene)
var btn := scene.get_node_or_null("VBox/ContinueBtn")
assert_that(btn != null).override_failure_message(
"Main menu must have VBox/ContinueBtn (#258)"
).is_true()
# ---------------------------------------------------------------------------
# Project main scene changed to main_menu.tscn
# ---------------------------------------------------------------------------
func test_project_main_scene_is_main_menu() -> void:
## D-085: project boots to main menu, not directly to game scene.
var scene_path: String = ProjectSettings.get_setting("application/run/main_scene", "")
assert_str(scene_path).override_failure_message(
"project.godot run/main_scene must be res://scenes/main_menu.tscn (#258)"
).is_equal("res://scenes/main_menu.tscn")
+99
View File
@@ -0,0 +1,99 @@
## Scene testing utilities for gdUnit4 tests.
##
## Loads a scene, instantiates it into the test suite's node tree,
## and provides helpers for node existence, signal, and node-path queries.
##
## Usage (from a GdUnitTestSuite subclass):
## var helper := SceneHelper.create(self, "res://scenes/main.tscn")
## helper.assert_node_exists("World")
## var world := helper.get_node_at("World")
## helper.monitor_signal(world, "ready")
## # ... trigger something ...
## helper.assert_signal_emitted(world, "ready")
##
## Design constraints (D-030): server-free, no running autoload dependencies.
class_name SceneHelper
extends RefCounted
var _suite # GdUnitTestSuite — untyped to avoid load-order dependency
var _scene: Node
# signal_key -> int. Key is "<node_instance_id>:<signal_name>" for uniqueness.
var _signal_hits: Dictionary = {}
## Load, instantiate, and attach a scene to the test suite's node tree.
## The scene node is registered for auto-free by gdUnit4.
## Returns a helper instance; fails the test if the scene cannot be loaded.
static func create(suite: GdUnitTestSuite, scene_path: String) -> SceneHelper:
var helper := SceneHelper.new()
helper._suite = suite
var packed: PackedScene = load(scene_path)
if packed == null:
suite.assert_that(packed).override_failure_message(
"SceneHelper: could not load scene at '%s'" % scene_path
).is_not_null()
return helper
helper._scene = packed.instantiate()
suite.auto_free(helper._scene)
suite.add_child(helper._scene)
return helper
## Returns the scene root node.
func scene() -> Node:
return _scene
## Assert that a node at node_path exists under the scene root.
## Fails the current test if the node is absent.
func assert_node_exists(node_path: String) -> void:
var node := _scene.get_node_or_null(NodePath(node_path))
_suite.assert_that(node).override_failure_message(
"SceneHelper: expected node at path '%s' — not found" % node_path
).is_not_null()
## Return the node at node_path under the scene root, or null if absent.
func get_node_at(node_path: String) -> Node:
return _scene.get_node_or_null(NodePath(node_path))
## Begin tracking emissions of signal_name on node.
## Must be called before the action that triggers the signal.
## Fails the test if node does not have the named signal.
func monitor_signal(node: Node, signal_name: String) -> void:
if not node.has_signal(signal_name):
_suite.assert_that(false).override_failure_message(
"SceneHelper: node '%s' has no signal '%s'" % [node.name, signal_name]
).is_true()
return
var key := _signal_key(node, signal_name)
_signal_hits[key] = 0
# Lambda accepts up to 4 positional args to tolerate signals with up to 4 params.
# GDScript default-param lambdas handle being called with fewer args correctly.
node.connect(signal_name, func(a := null, b := null, c := null, d := null):
_signal_hits[key] = _signal_hits.get(key, 0) + 1
)
## Assert that signal_name was emitted at least once since monitor_signal().
## Fails the test if monitor_signal() was not called first, or if count is zero.
func assert_signal_emitted(node: Node, signal_name: String) -> void:
var key := _signal_key(node, signal_name)
if not _signal_hits.has(key):
_suite.assert_that(false).override_failure_message(
"SceneHelper: '%s' was not monitored — call monitor_signal() first" % signal_name
).is_true()
return
var count: int = _signal_hits[key]
_suite.assert_int(count).override_failure_message(
"SceneHelper: signal '%s' on '%s' was not emitted (count=%d)" % [
signal_name, node.name, count
]
).is_greater(0)
static func _signal_key(node: Node, signal_name: String) -> String:
return "%d:%s" % [node.get_instance_id(), signal_name]