changes for stuff
This commit is contained in:
516
tests/test_room_navigation.py
Normal file
516
tests/test_room_navigation.py
Normal 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)
|
||||
Reference in New Issue
Block a user