mirror of
https://github.com/vegu-ai/talemate.git
synced 2026-05-18 05:05:39 +02:00
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
258 lines
9.3 KiB
Python
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)
|