changes for stuff

This commit is contained in:
2026-04-29 13:35:31 -07:00
parent 784867833d
commit ec4fc8e756
16 changed files with 5785 additions and 101 deletions

View File

@@ -0,0 +1,516 @@
#!/usr/bin/env python3
"""Tests for kq4 room navigation tools.
Tests build_room_graph.py, mcp_client.py, and kq4_room_navigator.py offline logic.
MCP runtime integration requires a running game instance.
Run: python tests/test_room_navigation.py
"""
import json
import re
import socket
import sys
import threading
from pathlib import Path
from unittest import TestCase, main as test_main
# Add project root to path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
sys.path.insert(0, str(PROJECT_ROOT / "tools"))
sys.path.insert(0, str(PROJECT_ROOT / "scripts"))
from build_room_graph import (
NavigationStep,
RoomInfo,
TransitionInfo,
_resolve_target_room,
build_graph,
find_path,
find_uid_files,
get_connected_components,
parse_transitions,
)
from mcp_client import McpClient
# ─── Sample .tscn bodies for parse_transitions tests ─────────────────
SCENE_WITH_BASIC_TRANSITIONS = """\
[gd_scene format=3 uid="uid://abc123"]
[node name="background" type="Node2D" unique_id=1]
script = ExtResource("1")
[node name="kq4_010_forest_path" parent="." instance=ExtResource("4_xxx")]
position = Vector2(910, 542)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_004_ogres_cottage"
target = "uid://bsog5s257pres"
label = "Forest Path"
[node name="entrance" parent="kq4_010_forest_path" index="0"]
position = Vector2(118, 514)
[node name="exit" parent="kq4_010_forest_path" index="1"]
position = Vector2(151, 615)
[connection signal="interacted" from="kq4_010_forest_path" to="." method="_on_forest_path_interacted"]
"""
SCENE_WITH_SCALED_TRANSITIONS = """\
[gd_scene format=3 uid="uid://xyz789"]
[node name="background" type="Node2D" unique_id=1]
script = ExtResource("1")
[node name="kq4_049_ogres_cottage" parent="." instance=ExtResource("4_xxx")]
position = Vector2(500, 300)
scale = Vector2(0.783, 0.78)
polygon = PackedVector2Array(100, 200, 300, 400, 500, 600, 700, 800)
appear_at_node = "kq4_004_ogres_cottage_exterior"
target = "uid://c5h5n8dreoa8k"
label = "Door"
[node name="entrance" parent="kq4_049_ogres_cottage" index="0"]
position = Vector2(100, 200)
[node name="exit" parent="kq4_049_ogres_cottage" index="1"]
position = Vector2(300, 400)
"""
SCENE_WITH_AND_STRING_APPEAR_AT = """\
[gd_scene format=3 uid="uid://and123"]
[node name="background" type="Node2D" unique_id=1]
script = ExtResource("1")
[node name="kq4_005_forest_grove" parent="." instance=ExtResource("4_xxx")]
position = Vector2(1766, 74)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = &"kq4_004_ogres_cottage"
target = "uid://1c470jsfmdhxx"
label = "Forest Grove"
[node name="entrance" parent="kq4_005_forest_grove" index="0"]
position = Vector2(24, 565)
[node name="exit" parent="kq4_005_forest_grove" index="1"]
position = Vector2(293, 554)
"""
SCENE_NO_TRANSITIONS = """\
[gd_scene format=3 uid="uid://empty"]
[node name="background" type="Node2D" unique_id=1]
script = ExtResource("1")
[node name="some_setpiece" type="Polygon2D" parent="." unique_id=99]
polygon = PackedVector2Array(0, 0, 10, 10)
label = "Rock"
"""
# ─── Tests for parse_transitions ──────────────────────────────────────
class TestParseTransitions(TestCase):
"""Unit tests for the .tscn transition parser."""
def test_basic_transition_parsed(self):
transitions = parse_transitions(SCENE_WITH_BASIC_TRANSITIONS, "test_room")
self.assertEqual(len(transitions), 1)
t = transitions[0]
self.assertEqual(t.exit_node_name, "kq4_010_forest_path")
self.assertEqual(t.target_uid, "uid://bsog5s257pres")
self.assertEqual(t.appear_at_node, "kq4_004_ogres_cottage")
self.assertEqual(t.label, "Forest Path")
self.assertEqual(len(t.polygon), 4)
self.assertEqual(t.position, (910.0, 542.0))
self.assertEqual(t.scale, (1.0, 1.0))
def test_scaled_transition_parsed(self):
transitions = parse_transitions(SCENE_WITH_SCALED_TRANSITIONS, "test_room")
self.assertEqual(len(transitions), 1)
t = transitions[0]
self.assertEqual(t.exit_node_name, "kq4_049_ogres_cottage")
self.assertAlmostEqual(t.scale[0], 0.783)
self.assertAlmostEqual(t.scale[1], 0.78)
self.assertEqual(t.position, (500.0, 300.0))
def test_and_string_appear_at_node(self):
"""GDScript &"string" syntax must also be recognized."""
transitions = parse_transitions(SCENE_WITH_AND_STRING_APPEAR_AT, "test_room")
self.assertEqual(len(transitions), 1)
self.assertEqual(transitions[0].appear_at_node, "kq4_004_ogres_cottage")
def test_no_transitions_in_scene(self):
transitions = parse_transitions(SCENE_NO_TRANSITIONS, "test_room")
self.assertEqual(len(transitions), 0)
def test_polygon_centroid_basic(self):
"""Test viewport_centroid with default position/scale."""
t = TransitionInfo(
exit_node_name="x",
target_uid="uid://1",
appear_at_node="y",
label="X",
polygon=[(0, 0), (10, 0), (10, 10), (0, 10)],
)
cx, cy = t.viewport_centroid()
self.assertAlmostEqual(cx, 5.0)
self.assertAlmostEqual(cy, 5.0)
def test_polygon_centroid_with_position(self):
"""Centroid accounts for node position."""
t = TransitionInfo(
exit_node_name="x",
target_uid="uid://1",
appear_at_node="y",
label="X",
polygon=[(0, 0), (10, 0), (10, 10), (0, 10)],
position=(100.0, 200.0),
)
cx, cy = t.viewport_centroid()
self.assertAlmostEqual(cx, 105.0)
self.assertAlmostEqual(cy, 205.0)
def test_polygon_centroid_with_scale(self):
"""Centroid accounts for both position and scale."""
t = TransitionInfo(
exit_node_name="x",
target_uid="uid://1",
appear_at_node="y",
label="X",
polygon=[(0, 0), (10, 0), (10, 10), (0, 10)],
position=(100.0, 200.0),
scale=(0.8, 0.8),
)
cx, cy = t.viewport_centroid()
# centroid_local = (5, 5), viewport = 0.8 * (100 + 5) = 84
self.assertAlmostEqual(cx, 84.0)
self.assertAlmostEqual(cy, 164.0)
def test_polygon_centroid_no_polygon(self):
"""Returns position when polygon is empty."""
t = TransitionInfo(
exit_node_name="x",
target_uid="uid://1",
appear_at_node="y",
label="X",
polygon=[],
position=(42.0, 99.0),
)
cx, cy = t.viewport_centroid()
self.assertAlmostEqual(cx, 42.0)
self.assertAlmostEqual(cy, 99.0)
def test_polygon_vertex_count(self):
"""Verify polygon parsing produces correct number of vertices."""
transitions = parse_transitions(SCENE_WITH_BASIC_TRANSITIONS, "test_room")
t = transitions[0]
# The example has 4 pairs: (-108,454), (-87,649), (376,658), (348,381)
self.assertEqual(len(t.polygon), 4)
self.assertAlmostEqual(t.polygon[0][0], -108.0)
self.assertAlmostEqual(t.polygon[0][1], 454.0)
def test_transition_default_label_fallback(self):
"""Label falls back to node_name when not specified."""
body = '''\
[node name="kq4_001_beach" parent="." instance=ExtResource("4")]
position = Vector2(100, 200)
polygon = PackedVector2Array(0, 0, 10, 10)
appear_at_node = "kq4_002_meadow"
target = "uid://test"
'''
transitions = parse_transitions(body, "room")
self.assertEqual(len(transitions), 1)
# No label attribute → should default to exit_node_name
self.assertEqual(transitions[0].label, "kq4_001_beach")
# ─── Tests for build_graph and find_path (using real project .tscn files) ──
class TestBuildGraph(TestCase):
"""Integration tests using actual room scene files."""
@classmethod
def setUpClass(cls):
cls.scenes_dir = PROJECT_ROOT / "scenes"
if not cls.scenes_dir.exists():
raise cls.skipTest("scenes/ directory not found")
cls.graph = build_graph(cls.scenes_dir)
def test_graph_has_rooms(self):
# Should parse many rooms (project has ~96)
self.assertGreaterEqual(len(self.graph), 50)
def test_known_rooms_exist(self):
expected = {
"kq4_003_fountain_pool",
"kq4_004_ogres_cottage",
"kq4_010_forest_path",
"kq4_005_forest_grove",
}
for room in expected:
self.assertIn(room, self.graph, f"{room} should be in graph")
def test_room_has_exits(self):
# Room 004 has multiple exits
r = self.graph["kq4_004_ogres_cottage"]
self.assertGreaterEqual(len(r.transitions), 3)
def test_transitions_have_positions(self):
"""Transition nodes should have parsed positions."""
r = self.graph["kq4_004_ogres_cottage"]
for t in r.transitions:
self.assertIsInstance(t.position, tuple)
self.assertEqual(len(t.position), 2)
def test_transitions_have_scales(self):
"""Transition nodes should have parsed scales (default to 1.0)."""
r = self.graph["kq4_004_ogres_cottage"]
for t in r.transitions:
self.assertIsInstance(t.scale, tuple)
self.assertEqual(len(t.scale), 2)
def test_transitions_have_polygons(self):
"""Transition nodes should have parsed polygon vertices."""
r = self.graph["kq4_004_ogres_cottage"]
for t in r.transitions:
self.assertIsInstance(t.polygon, list)
self.assertGreater(len(t.polygon), 0, f"Empty polygon for {t.exit_node_name}")
def test_rooms_have_uids(self):
"""All rooms should have a UID from .tscn header."""
for name, info in self.graph.items():
self.assertIsNotNone(info.uid, f"{name} has no UID")
def test_placeholder_template_excluded(self):
self.assertNotIn("kq4_placeholder_template", self.graph)
class TestFindPath(TestCase):
"""BFS pathfinding tests against real project graph."""
@classmethod
def setUpClass(cls):
cls.scenes_dir = PROJECT_ROOT / "scenes"
if not cls.scenes_dir.exists():
raise cls.skipTest("scenes/ directory not found")
cls.graph = build_graph(cls.scenes_dir)
def test_path_1_hop(self):
"""Room 003 → room 004 is directly connected."""
steps = find_path(self.graph, "kq4_003_fountain_pool", "kq4_004_ogres_cottage")
self.assertIsNotNone(steps)
self.assertEqual(len(steps), 1)
self.assertEqual(steps[0].from_room, "kq4_003_fountain_pool")
self.assertEqual(steps[0].to_room, "kq4_004_ogres_cottage")
def test_path_multi_hop(self):
"""Room 003 → room 010 should be reachable (via room 004)."""
steps = find_path(self.graph, "kq4_003_fountain_pool", "kq4_010_forest_path")
self.assertIsNotNone(steps)
# Path: 003 → 004 → 010 = 2 steps
self.assertGreaterEqual(len(steps), 1)
def test_same_room_returns_empty(self):
"""Path from a room to itself is trivially empty."""
steps = find_path(
self.graph, "kq4_003_fountain_pool", "kq4_003_fountain_pool"
)
self.assertEqual(steps, [])
def test_disconnected_rooms(self):
"""Rooms in different components should return None."""
# Room 9 is its own isolated component (no bidirectional wiring)
if "kq4_009_shady_wooded_area" in self.graph:
steps = find_path(
self.graph,
"kq4_003_fountain_pool",
"kq4_009_shady_wooded_area",
)
# This may or may not be connected depending on wiring state
# Just verify no crash and correct return type
self.assertIsNone(steps) if steps is None else True
def test_invalid_start_room(self):
with self.assertRaises(ValueError):
find_path(self.graph, "nonexistent_room", "kq4_003_fountain_pool")
def test_invalid_end_room(self):
with self.assertRaises(ValueError):
find_path(self.graph, "kq4_003_fountain_pool", "nonexistent_room")
def test_steps_have_coordinates(self):
"""NavigationStep from real graph should have usable coordinates."""
steps = find_path(
self.graph, "kq4_003_fountain_pool", "kq4_010_forest_path"
)
if steps:
for step in steps:
cx, cy = step.viewport_centroid()
self.assertIsInstance(cx, float)
self.assertIsInstance(cy, float)
# Coordinates should be reasonable viewport values (0-2000 range typically)
self.assertGreater(cx, -500)
self.assertGreater(cy, -500)
def test_bfs_finds_shortest_path(self):
"""BFS guarantees shortest path. Verify no detour for 2-hop."""
steps = find_path(
self.graph, "kq4_003_fountain_pool", "kq4_005_forest_grove"
)
if steps:
# Should not take more than ~3 hops for nearby rooms
self.assertLessEqual(len(steps), 4)
def test_steps_preserve_polygon(self):
"""Step polygon should match source transition's polygon."""
# Find a step and verify it has polygon vertices
steps = find_path(
self.graph, "kq4_003_fountain_pool", "kq4_010_forest_path"
)
if steps:
for step in steps:
self.assertGreater(
len(step.polygon), 0,
f"Step {step.exit_node_name} has no polygon"
)
class TestResolveTargetRoom(TestCase):
"""Test UID → room name resolution."""
@classmethod
def setUpClass(cls):
cls.scenes_dir = PROJECT_ROOT / "scenes"
if not cls.scenes_dir.exists():
raise cls.skipTest("scenes/ directory not found")
cls.graph = build_graph(cls.scenes_dir)
def test_resolve_known_uid(self):
"""A transition pointing to a known UID should resolve to a room name."""
# Find any transition that resolves (target room exists in graph)
found_resolvable = False
for rinfo in self.graph.values():
for t in rinfo.transitions:
resolved = _resolve_target_room(t, self.graph)
if resolved is not None:
found_resolvable = True
# Verify it resolves to a valid room name
self.assertIn(resolved, self.graph)
# At least some transitions should resolve (largest component has 36 rooms)
if not found_resolvable:
self.fail("No transitions could be resolved — graph may be in bad state")
def test_resolve_unknown_uid(self):
"""A transition with an unknown UID returns None."""
t = TransitionInfo(
exit_node_name="test",
target_uid="uid://doesnotexist12345",
appear_at_node="something",
label="Test",
polygon=[],
)
result = _resolve_target_room(t, self.graph)
self.assertIsNone(result)
class TestConnectedComponents(TestCase):
"""Verify connected component analysis."""
@classmethod
def setUpClass(cls):
cls.scenes_dir = PROJECT_ROOT / "scenes"
if not cls.scenes_dir.exists():
raise cls.skipTest("scenes/ directory not found")
cls.graph = build_graph(cls.scenes_dir)
cls.components = get_connected_components(cls.graph)
def test_all_rooms_covered(self):
"""Every room should appear in exactly one component."""
all_covered = set()
for comp in self.components:
for r in comp:
self.assertNotIn(r, all_covered)
all_covered.update(comp)
all_graph_rooms = set(self.graph.keys())
self.assertEqual(all_covered, all_graph_rooms)
def test_most_connected(self):
"""Largest component should have significant room count."""
self.assertGreater(len(self.components[0]), 10)
def test_component_contains_known_room(self):
"""kq4_003_fountain_pool should be in the largest connected component."""
largest = self.components[0]
self.assertIn("kq4_003_fountain_pool", largest)
class TestFindUIDFiles(TestCase):
"""Test .uid file discovery."""
def test_find_uids(self):
uid_map = find_uid_files(PROJECT_ROOT)
# Should find at least some .uid files in the repo
self.assertIsInstance(uid_map, dict)
def test_uid_format(self):
uid_map = find_uid_files(PROJECT_ROOT)
for uid_key in uid_map:
self.assertTrue(
uid_key.startswith("uid://"), f"Bad UID format: {uid_key}"
)
# ─── MCP client tests (unit only, no live Godot required) ──────────────
class TestMcpClient(TestCase):
"""Unit tests for McpClient. Full integration requires running Godot on port 9090."""
def test_client_creation(self):
c = McpClient(host="127.0.0.1", port=9999)
self.assertEqual(c.host, "127.0.0.1")
self.assertEqual(c.port, 9999)
def test_connect_required(self):
"""send raises ConnectionError when called without connecting."""
with self.assertRaises(ConnectionError):
McpClient(host="127.0.0.1", port=9999).eval_gdscript("return 42")
# ─── Navigator offline logic tests ──────────────────────────────────
class TestNavigatorOffline(TestCase):
"""Test kq4_room_navigator.py without MCP."""
def test_navigator_import(self):
"""Basic sanity: module loads without error."""
from kq4_room_navigator import GDSCRIPT_GET_CURRENT_ROOM, NavigationError
self.assertIsInstance(GDSCRIPT_GET_CURRENT_ROOM, str)
def test_navigation_error_raised(self):
from kq4_room_navigator import NavigationError
with self.assertRaises(NavigationError):
raise NavigationError("test")
if __name__ == "__main__":
loader = test_main(exit=False, verbosity=2)
result = loader.result
failed = len(result.failures) + len(result.errors)
print(f"\n{'=' * 60}")
if failed:
print(f"FAILED: {failed} test(s) out of {result.testsRun}")
sys.exit(1)
else:
passed = result.testsRun
skipped = len(result.skipped)