#!/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)