Files
talemate/tests/test_save.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

258 lines
9.3 KiB
Python

"""Unit tests for talemate.save: combine_paths, SceneEncoder, save_node_module."""
import json
import os
import pytest
from talemate.save import SceneEncoder, combine_paths, save_node_module
from talemate.scene_message import (
CharacterMessage,
DirectorMessage,
Flags,
NarratorMessage,
SceneMessage,
reset_message_id,
)
from talemate.game.engine.nodes.core import UNRESOLVED, Graph
from talemate.game.engine.nodes.scene import SceneLoop
from talemate.tale_mate import Scene
# ---------------------------------------------------------------------------
# combine_paths
# ---------------------------------------------------------------------------
class TestCombinePaths:
def test_takes_filename_only_from_relative_path(self):
# The implementation deliberately drops directory components from the
# relative path and joins only the final component to the absolute
# base. This guards against accidental nested directories.
result = combine_paths("/abs/base", "some/nested/file.json")
assert result == os.path.join("/abs/base", "file.json")
def test_passes_through_bare_filename(self):
result = combine_paths("/abs/base", "file.json")
assert result == os.path.join("/abs/base", "file.json")
def test_normalizes_redundant_segments_in_relative(self):
result = combine_paths("/abs/base", "./sub/../file.json")
# os.path.normpath collapses the redundant traversal
assert result == os.path.join("/abs/base", "file.json")
def test_does_not_modify_absolute_base(self):
# The absolute path is concatenated as-is (no normalization)
result = combine_paths("/a/b/c", "x.json")
assert result.startswith("/a/b/c")
assert result.endswith("x.json")
# ---------------------------------------------------------------------------
# SceneEncoder
# ---------------------------------------------------------------------------
class TestSceneEncoder:
@pytest.fixture(autouse=True)
def _reset_message_id(self):
reset_message_id()
yield
reset_message_id()
def test_encodes_scene_message_via_dict_method(self):
msg = SceneMessage(message="hello", source="ai")
encoded = json.dumps(msg, cls=SceneEncoder)
decoded = json.loads(encoded)
assert decoded["message"] == "hello"
assert decoded["typ"] == "scene"
assert decoded["source"] == "ai"
def test_encodes_character_message_with_subclass_fields(self):
msg = CharacterMessage(
message="Alice: hi",
from_choice="choice-1",
asset_id="img-1",
asset_type="avatar",
)
decoded = json.loads(json.dumps(msg, cls=SceneEncoder))
assert decoded["from_choice"] == "choice-1"
assert decoded["asset_id"] == "img-1"
assert decoded["typ"] == "character"
def test_encodes_director_message_action(self):
msg = DirectorMessage(message="proceed", action="user_direction")
decoded = json.loads(json.dumps(msg, cls=SceneEncoder))
assert decoded["action"] == "user_direction"
def test_encodes_narrator_message_with_meta(self):
msg = NarratorMessage(
message="The sun rises",
meta={
"agent": "narrator",
"function": "progress_story",
"arguments": {"narrative_direction": "advance"},
},
)
decoded = json.loads(json.dumps(msg, cls=SceneEncoder))
assert decoded["meta"]["function"] == "progress_story"
def test_unresolved_sentinel_serialised_as_null(self):
# The custom encoder normalises UNRESOLVED placeholders to null so
# downstream consumers see plain JSON without sentinel objects.
encoded = json.dumps({"value": UNRESOLVED}, cls=SceneEncoder)
assert json.loads(encoded) == {"value": None}
def test_falls_through_to_default_for_unknown_types(self):
# Unknown non-serialisable objects must still raise TypeError to
# match the standard json.JSONEncoder contract.
class Opaque:
pass
with pytest.raises(TypeError):
json.dumps(Opaque(), cls=SceneEncoder)
def test_encodes_list_of_messages(self):
msgs = [
SceneMessage(message="a"),
CharacterMessage(message="Alice: hi"),
NarratorMessage(message="The wind blows"),
]
encoded = json.dumps(msgs, cls=SceneEncoder)
decoded = json.loads(encoded)
assert [m["typ"] for m in decoded] == ["scene", "character", "narrator"]
def test_encodes_hidden_flag_as_int(self):
msg = SceneMessage(message="secret", flags=Flags.HIDDEN)
decoded = json.loads(json.dumps(msg, cls=SceneEncoder))
assert decoded["flags"] == int(Flags.HIDDEN)
# ---------------------------------------------------------------------------
# save_node_module
# ---------------------------------------------------------------------------
@pytest.fixture
def scene_factory(tmp_path, monkeypatch):
"""Factory that builds a real `Scene` whose nodes directory points into tmp_path.
Uses production `Scene` so changes to its `nodes_dir`/`nodes_filename`/
`nodes_filepath` API surface in these tests. `Scene.scenes_dir` is
monkeypatched to redirect filesystem writes into the tmp_path.
"""
monkeypatch.setattr(
Scene, "scenes_dir", classmethod(lambda cls: str(tmp_path)), raising=True
)
def _make(project: str = "save_node_test", nodes_filename: str = "scene-loop.json"):
scene = Scene()
scene.project_name = project
scene.nodes_filename = (
nodes_filename if nodes_filename != "scene-loop.json" else ""
)
# Don't pre-create scene.nodes_dir — `save_node_module` is responsible
# for creating it, and one of the tests verifies that contract.
return scene
return _make
class TestSaveNodeModule:
@pytest.mark.asyncio
async def test_creates_nodes_directory_if_missing(self, scene_factory):
scene = scene_factory()
# Build a minimal SceneLoop graph (real type, no LLM client needed)
graph = SceneLoop()
assert not os.path.exists(scene.nodes_dir)
await save_node_module(scene, graph, set_as_main=True)
assert os.path.isdir(scene.nodes_dir)
@pytest.mark.asyncio
async def test_scene_loop_with_set_as_main_writes_to_default_filename(
self, scene_factory
):
scene = scene_factory()
graph = SceneLoop()
result = await save_node_module(scene, graph, set_as_main=True)
# Default filename is "scene-loop.json"
assert scene.nodes_filename == "scene-loop.json"
assert result.endswith("scene-loop.json")
assert os.path.isfile(result)
# The written file should be valid JSON containing graph data
with open(result) as f:
data = json.load(f)
assert isinstance(data, dict)
assert "nodes" in data
@pytest.mark.asyncio
async def test_scene_loop_with_set_as_main_uses_explicit_filename(
self, scene_factory
):
scene = scene_factory()
graph = SceneLoop()
result = await save_node_module(
scene, graph, filename="custom.json", set_as_main=True
)
# set_as_main + filename overrides the default
assert scene.nodes_filename == "custom.json"
assert result.endswith("custom.json")
assert os.path.isfile(result)
@pytest.mark.asyncio
async def test_non_scene_loop_requires_filename(self, scene_factory):
scene = scene_factory()
graph = Graph() # Graph (not SceneLoop)
with pytest.raises(ValueError, match="filename is required"):
await save_node_module(scene, graph, filename=None)
@pytest.mark.asyncio
async def test_non_scene_loop_strips_relative_path_components(self, scene_factory):
scene = scene_factory()
graph = Graph()
# The filename includes a relative directory; combine_paths strips it
result = await save_node_module(
scene, graph, filename="some/nested/widget.json"
)
# Only the final component is used, joined to nodes_dir
assert result == os.path.join(scene.nodes_dir, "widget.json")
assert os.path.isfile(result)
@pytest.mark.asyncio
async def test_scene_loop_without_set_as_main_falls_through_to_filename_branch(
self, scene_factory
):
# When set_as_main is False, even a SceneLoop must go through the
# explicit-filename branch and a missing filename must raise.
scene = scene_factory()
graph = SceneLoop()
with pytest.raises(ValueError, match="filename is required"):
await save_node_module(scene, graph, filename=None, set_as_main=False)
@pytest.mark.asyncio
async def test_scene_loop_without_set_as_main_writes_to_named_file(
self, scene_factory
):
scene = scene_factory()
graph = SceneLoop()
result = await save_node_module(
scene, graph, filename="loop-fragment.json", set_as_main=False
)
# nodes_filename should NOT be updated when set_as_main is False
assert scene.nodes_filename == "scene-loop.json" # unchanged default
assert result.endswith("loop-fragment.json")
assert os.path.isfile(result)