Files
talemate/tests/test_nodes_registry.py
veguAI f5d41c04c8 0.37.0 (#267)
0.37.0

- **Director Planning** — Multi-step todo lists in director chat plus a Generate long progress action for multi-beat scene arcs.
- **Auto Narration** — Unified auto-narration replacing the old Narrate after Dialogue toggle, with a chance slider and weighted action mix.
- **LLM Prompt Templates Manager** — Dedicated UI tab for viewing, creating, editing, and deleting prompt templates.
- **Character Folders** — Collapsible folders in the World Editor character list, synced across linked scenes.
- **OpenAI Compatible TTS** — Connect any number of OpenAI-compatible TTS servers in parallel.
- **KoboldCpp TTS Auto-Setup** — KoboldCpp clients with a TTS model loaded register themselves as a TTS backend.
- **Model Testing Harness** — Bundled scene that runs basic capability tests against any connected LLM.

Plus 27 improvements and 28 bug fixes
2026-05-12 21:01:51 +03:00

404 lines
14 KiB
Python

"""Coverage-focused unit tests for talemate.game.engine.nodes.registry.
The registry is a process-global singleton (NODES). To avoid bleeding test
state into the real registry, every test that mutates NODES uses an
isolated `container` dict and the `register(..., container=...)` form, or
restores NODES after the test.
Filesystem-based importers (`import_scene_node_definitions`,
`import_talemate_node_definitions`) are exercised via tmp_path with real
JSON files.
"""
from __future__ import annotations
import json
import os
import pytest
from talemate.context import ActiveScene
# Import data nodes module so that the data/* registry entries are
# populated. We test discovery via `get_node("data/Sort")` below.
from talemate.game.engine.nodes import data as _data_module # noqa: F401
from talemate.game.engine.nodes import registry as registry_module
from talemate.game.engine.nodes.registry import (
NODES,
NodeNotFoundError,
export_node_definitions,
get_node,
get_nodes_by_base_type,
import_node_definition,
import_node_definitions,
import_scene_node_definitions,
import_talemate_node_definitions,
normalize_registry_name,
register,
validate_registry_path,
)
from talemate.tale_mate import Scene
# ---------------------------------------------------------------------------
# normalize_registry_name
# ---------------------------------------------------------------------------
class TestNormalizeRegistryName:
@pytest.mark.parametrize(
"raw, expected",
[
("my node", "myNode"),
("My Node", "myNode"),
("My-Node", "myNode"),
("My Other Node", "myOtherNode"),
("a", "a"),
("AB", "ab"),
("foo_bar", "fooBar"),
],
)
def test_normalizes_to_camel_case(self, raw, expected):
assert normalize_registry_name(raw) == expected
# ---------------------------------------------------------------------------
# register decorator (container override + as_base_type)
# ---------------------------------------------------------------------------
class TestRegisterDecorator:
def test_register_in_isolated_container(self):
# Use an isolated container so we don't mutate the global NODES.
from talemate.game.engine.nodes.core import Node
container = {}
@register("test/IsolatedRegister", container=container)
class FakeNode(Node):
pass
assert "test/IsolatedRegister" in container
assert container["test/IsolatedRegister"] is FakeNode
# _registry is set on the class
assert FakeNode._registry == "test/IsolatedRegister"
# Global registry is untouched
assert "test/IsolatedRegister" not in NODES
def test_register_as_base_type_also_registers_base_type(self):
from talemate.game.engine.nodes.base_types import BASE_TYPES, get_base_type
from talemate.game.engine.nodes.core import Node
container = {}
original_base = dict(BASE_TYPES)
try:
@register("test/BaseTypeRegister", as_base_type=True, container=container)
class FakeNode(Node):
pass
assert get_base_type("test/BaseTypeRegister") is FakeNode
finally:
# Restore BASE_TYPES so we don't leak state across tests.
BASE_TYPES.clear()
BASE_TYPES.update(original_base)
# ---------------------------------------------------------------------------
# get_node / NodeNotFoundError — depend on active_scene contextvar
# ---------------------------------------------------------------------------
@pytest.fixture
def stub_scene():
"""Scene used only as a holder for `_NODE_DEFINITIONS` lookups."""
s = Scene()
s._NODE_DEFINITIONS = {}
return s
class TestGetNode:
def test_returns_none_for_falsy_name(self, stub_scene):
with ActiveScene(stub_scene):
assert get_node("") is None
assert get_node(None) is None
def test_finds_in_global_registry(self, stub_scene):
with ActiveScene(stub_scene):
# Use a known-built-in node from the global registry.
cls = get_node("data/Sort")
assert cls is not None
assert cls.__name__ == "Sort"
def test_scene_node_takes_priority(self, stub_scene):
from talemate.game.engine.nodes.core import Node
class SceneSpecificNode(Node):
pass
stub_scene._NODE_DEFINITIONS["test/SceneOnly"] = SceneSpecificNode
with ActiveScene(stub_scene):
assert get_node("test/SceneOnly") is SceneSpecificNode
def test_unknown_name_raises(self, stub_scene):
with ActiveScene(stub_scene):
with pytest.raises(NodeNotFoundError, match="not found"):
get_node("test/DoesNotExist__definitely__")
# ---------------------------------------------------------------------------
# get_nodes_by_base_type
# ---------------------------------------------------------------------------
class TestGetNodesByBaseType:
def test_returns_classes_with_matching_base_type(self, stub_scene):
# Real built-in nodes have base types from BASE_TYPES — Graph nodes
# have base_type "core/Graph". Use it as a smoke filter that returns
# at least one match.
with ActiveScene(stub_scene):
results = get_nodes_by_base_type("core/Graph")
assert isinstance(results, list)
# All matches' _base_type must be "core/Graph"
assert all(cls._base_type == "core/Graph" for cls in results)
def test_scene_nodes_overlay_global(self, stub_scene):
from talemate.game.engine.nodes.core import Node
class SceneNode(Node):
_base_type = "test/Custom"
stub_scene._NODE_DEFINITIONS["test/SceneCustom"] = SceneNode
with ActiveScene(stub_scene):
results = get_nodes_by_base_type("test/Custom")
assert SceneNode in results
# ---------------------------------------------------------------------------
# validate_registry_path
# ---------------------------------------------------------------------------
class TestValidateRegistryPath:
def test_empty_raises(self):
with pytest.raises(ValueError, match="Empty"):
validate_registry_path("")
def test_single_part_raises(self):
with pytest.raises(ValueError, match="at least two parts"):
validate_registry_path("oneword")
def test_valid_path_passes(self):
# Two-part path is acceptable as long as it's not a prefix of an
# existing path.
validate_registry_path("test/Brand_New", node_definitions={"nodes": {}})
def test_prefix_collision_raises(self):
defs = {"nodes": {"foo/bar/baz": {}}}
with pytest.raises(ValueError, match="colliding"):
validate_registry_path("foo/bar", node_definitions=defs)
# ---------------------------------------------------------------------------
# import_node_definition / dynamic_node_import via the registry's import path
# ---------------------------------------------------------------------------
class TestImportNodeDefinition:
def test_creates_dynamic_node_class_when_missing(self):
container = {}
node_data = {
"registry": "test/DynamicReg",
"base_type": "core/Graph",
"title": "Dynamic",
"nodes": {},
"edges": {},
}
cls = import_node_definition(node_data, registry=container)
assert "test/DynamicReg" in container
assert cls is container["test/DynamicReg"]
assert cls._base_type == "core/Graph"
def test_uses_existing_class_in_container(self):
from talemate.game.engine.nodes.core import Graph
container = {"test/Existing": Graph}
node_data = {
"registry": "test/Existing",
"base_type": "core/Graph",
"title": "Reuse",
"nodes": {},
"edges": {},
}
cls = import_node_definition(node_data, registry=container)
# Existing class is reused.
assert cls is Graph
def test_unknown_base_type_raises(self):
container = {}
node_data = {
"registry": "test/Bad",
"base_type": "core/NotARealBaseType",
"title": "X",
"nodes": {},
"edges": {},
}
with pytest.raises(ValueError):
import_node_definition(node_data, registry=container)
class TestImportNodeDefinitions:
def test_imports_each_node(self):
# Patch into NODES temporarily for the test (import_node_definitions
# always writes to NODES — see registry source). Restore on exit.
try:
data = {
"nodes": [
{
"registry": "test/Bulk1",
"base_type": "core/Graph",
"title": "B1",
"nodes": {},
"edges": {},
},
{
"registry": "test/Bulk2",
"base_type": "core/Graph",
"title": "B2",
"nodes": {},
"edges": {},
},
]
}
import_node_definitions(data)
assert "test/Bulk1" in NODES
assert "test/Bulk2" in NODES
finally:
# Strip what we added so the global registry is unchanged.
for key in ("test/Bulk1", "test/Bulk2"):
NODES.pop(key, None)
assert "test/Bulk1" not in NODES
# ---------------------------------------------------------------------------
# import_scene_node_definitions — filesystem-based; uses tmp_path
# ---------------------------------------------------------------------------
class TestImportSceneNodeDefinitions:
@pytest.fixture
def fs_scene(self, tmp_path, monkeypatch):
# Use a Scene with a tmp save_dir so its `nodes_dir` is real.
monkeypatch.setattr(
Scene,
"scenes_dir",
classmethod(lambda cls: str(tmp_path)),
raising=True,
)
scene = Scene()
scene.project_name = "proj_node_defs"
os.makedirs(scene.nodes_dir, exist_ok=True)
return scene
def test_skips_when_nodes_dir_missing(self, fs_scene, monkeypatch):
# Override nodes_dir to a non-existent path; should early-return.
monkeypatch.setattr(
type(fs_scene),
"nodes_dir",
property(lambda self: "/no/such/dir"),
)
# _NODE_DEFINITIONS is created and remains empty
import_scene_node_definitions(fs_scene)
assert fs_scene._NODE_DEFINITIONS == {}
def test_loads_definitions_from_directory(self, fs_scene):
# Drop a JSON node definition next to the scene loop file.
node_def = {
"registry": "test/SceneNodeDef",
"base_type": "core/Graph",
"title": "Scene",
"nodes": {},
"edges": {},
}
path = os.path.join(fs_scene.nodes_dir, "node_def.json")
with open(path, "w") as f:
json.dump(node_def, f)
import_scene_node_definitions(fs_scene)
assert "test/SceneNodeDef" in fs_scene._NODE_DEFINITIONS
def test_skips_scene_loop_file(self, fs_scene):
# The file matching scene.nodes_filename must NOT be imported as a
# node definition.
scene_loop_data = {
"registry": "test/ShouldNotImport",
"base_type": "core/Graph",
"title": "Loop",
"nodes": {},
"edges": {},
}
loop_path = os.path.join(fs_scene.nodes_dir, fs_scene.nodes_filename)
with open(loop_path, "w") as f:
json.dump(scene_loop_data, f)
import_scene_node_definitions(fs_scene)
assert "test/ShouldNotImport" not in fs_scene._NODE_DEFINITIONS
def test_skips_definition_without_registry(self, fs_scene):
# JSON files without a `registry` field are warned about and skipped.
path = os.path.join(fs_scene.nodes_dir, "no_registry.json")
with open(path, "w") as f:
json.dump({"title": "anonymous"}, f)
import_scene_node_definitions(fs_scene)
assert fs_scene._NODE_DEFINITIONS == {}
# ---------------------------------------------------------------------------
# import_talemate_node_definitions — filesystem; uses monkeypatched SEARCH_PATHS
# ---------------------------------------------------------------------------
class TestImportTalemateNodeDefinitions:
def test_loads_definitions_from_search_paths(self, tmp_path, monkeypatch):
# Replace SEARCH_PATHS with our tmp dir and drop a JSON file in it.
custom_dir = tmp_path / "modules"
custom_dir.mkdir()
node_def = {
"registry": "test/SearchPathNode",
"base_type": "core/Graph",
"title": "X",
"nodes": {},
"edges": {},
}
(custom_dir / "node.json").write_text(json.dumps(node_def))
monkeypatch.setattr(registry_module, "SEARCH_PATHS", [str(custom_dir)])
try:
import_talemate_node_definitions()
assert "test/SearchPathNode" in NODES
cls = NODES["test/SearchPathNode"]
assert cls._module_path # set on success
finally:
NODES.pop("test/SearchPathNode", None)
# ---------------------------------------------------------------------------
# export_node_definitions — depends on active_scene
# ---------------------------------------------------------------------------
class TestExportNodeDefinitions:
def test_export_returns_node_dict_keyed_by_registry(self, stub_scene):
with ActiveScene(stub_scene):
export = export_node_definitions()
assert "nodes" in export
# Top-level value is keyed by registry path.
assert isinstance(export["nodes"], dict)
# All entries must include the marker fields populated by the
# exporter.
for reg_name, defn in export["nodes"].items():
assert defn["registry"] == reg_name
assert "fields" in defn
assert "selectable" in defn