From a7541aa27b9b5a9fc68412cc86991443a41a8582 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 25 Feb 2026 11:45:39 +0100 Subject: [PATCH] test(client): scene testing utilities and GameState tests (#206) SceneHelper class for gdUnit4: load scenes into test tree with assert_node_exists, assert_signal_emitted, get_node_at helpers. 14 tests for GameState.apply_snapshot() covering v2+ fields. Co-Authored-By: Claude Opus 4.6 --- client/tests/test_game_state.gd | 156 ++++++++++++++++++++++++++++++ client/tests/util/scene_helper.gd | 99 +++++++++++++++++++ 2 files changed, 255 insertions(+) create mode 100644 client/tests/test_game_state.gd create mode 100644 client/tests/util/scene_helper.gd diff --git a/client/tests/test_game_state.gd b/client/tests/test_game_state.gd new file mode 100644 index 000000000..48b3c69cf --- /dev/null +++ b/client/tests/test_game_state.gd @@ -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() diff --git a/client/tests/util/scene_helper.gd b/client/tests/util/scene_helper.gd new file mode 100644 index 000000000..e0c6a9bb5 --- /dev/null +++ b/client/tests/util/scene_helper.gd @@ -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 ":" 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]