use real scene objects

This commit is contained in:
vegu-ai-tools
2026-05-10 12:18:01 +03:00
parent 3bc7fb9455
commit ca2474992f
8 changed files with 201 additions and 93 deletions

View File

@@ -0,0 +1,80 @@
"""
Shared helpers for changelog tests (test_changelog.py, test_changelog_extras.py).
The changelog module reads four scene-shaped attributes — ``filename``,
``save_dir``, ``changelog_dir`` (cascades off ``save_dir``) and
``serialize`` — and walks the disk under ``save_dir``. The tests need to
drive ``serialize`` with arbitrary payloads (not a real scene serialization
shape) so a small ``Scene`` subclass overrides ``serialize`` with a settable
attribute. This is still a real ``Scene`` with real validation/wiring; if
someone renames or removes ``filename``/``save_dir``/``changelog_dir`` on
``Scene`` the tests will fail.
"""
from __future__ import annotations
import os
from talemate.tale_mate import Scene
class ChangelogScene(Scene):
"""Real ``Scene`` subclass exposing ``serialize`` and ``save_dir`` as
settable plain attributes for changelog tests.
The base ``Scene`` exposes both as ``@property`` descriptors.
``Scene.serialize`` returns the *real* serialized scene shape (with
``character_data``, ``world_state``, etc.); the changelog tests need to
drive arbitrary payloads (often ``{"history": ..., "characters": ...}``)
to verify changelog logic in isolation. ``Scene.save_dir`` joins the
classmethod ``scenes_dir()`` with ``project_name`` and creates the
directory; tests instead want a tempdir directly.
Overriding both with plain class attributes (``None`` here so subclass
instances initialize them in ``__init__``) shadows the parent's
``@property`` descriptors. ``changelog_dir``/``backups_dir`` are still
real ``@property`` on the parent and cascade off ``save_dir`` correctly.
"""
# Shadow the parent's @property descriptors with plain class attrs.
# On instance creation these get assigned in __init__. Critical: this
# MUST happen at class-body level so the descriptor lookup finds the
# plain attribute first.
serialize = None # type: ignore[assignment]
save_dir = None # type: ignore[assignment]
def __init__(
self,
*,
save_dir: str,
filename: str = "scene.json",
initial_serialize: dict | None = None,
):
super().__init__()
self.filename = filename
self.save_dir = save_dir
self.serialize = (
initial_serialize
if initial_serialize is not None
else {"history": [], "characters": []}
)
def make_changelog_scene(
tmp_dir: str,
*,
filename: str = "scene.json",
initial_serialize: dict | None = None,
) -> ChangelogScene:
"""Build a ``ChangelogScene`` whose ``save_dir`` is ``tmp_dir`` directly.
``changelog_dir`` and ``backups_dir`` cascade off ``save_dir`` via the
real properties on ``Scene``.
"""
scene = ChangelogScene(
save_dir=tmp_dir,
filename=filename,
initial_serialize=initial_serialize,
)
os.makedirs(tmp_dir, exist_ok=True)
return scene

View File

@@ -0,0 +1,43 @@
"""
Shared helpers for prompt-groups tests (test_groups.py, test_groups_extras.py).
The ``talemate.prompts.groups`` module reads exactly one scene-shaped attribute,
``scene.template_dir``. The tests need to drive arbitrary values into it
(strings, ``Path`` objects, ``None``, or a real path) to exercise the
``isinstance(template_dir, str)`` validation branch in
``get_scene_template_path``.
The base ``Scene.template_dir`` is a ``@property`` that always returns a
``str`` joined from ``save_dir`` + ``"templates"``. To drive the invalid-type
branches, this helper exposes a ``Scene`` subclass that shadows the
``@property`` with a plain class attribute. Renames or removals of
``template_dir`` on the parent will still surface as test failures (the
attribute is still named ``template_dir`` and used in tests via ``scene.template_dir``).
"""
from __future__ import annotations
from typing import Any
from talemate.tale_mate import Scene
class TemplateDirScene(Scene):
"""Real ``Scene`` subclass exposing ``template_dir`` as a settable attribute.
The shadowed class-level ``template_dir`` (initialized to ``None``) replaces
the parent's ``@property`` descriptor on this subclass; ``__init__`` then
accepts whatever value the test wants — ``str``, ``Path``, ``None``, or a
missing-attribute-equivalent (use ``del scene.template_dir`` to simulate).
"""
template_dir: Any = None # type: ignore[assignment]
def __init__(self, *, template_dir: Any = None):
super().__init__()
self.template_dir = template_dir
def make_template_dir_scene(template_dir: Any) -> TemplateDirScene:
"""Build a real ``TemplateDirScene`` with ``template_dir`` set to ``value``."""
return TemplateDirScene(template_dir=template_dir)

View File

@@ -14,6 +14,8 @@ import pytest
from talemate.prompts import groups
from ._groups_test_helpers import make_template_dir_scene
class TestGetDefaultTemplatePath:
"""Tests for get_default_template_path()."""
@@ -73,8 +75,7 @@ class TestGetSceneTemplatePath:
def test_agent_subdirectory_preferred(self, tmp_path):
"""Agent-specific subdirectory is preferred when it exists."""
scene = Mock()
scene.template_dir = str(tmp_path)
scene = make_template_dir_scene(template_dir=str(tmp_path))
# Create agent subdirectory with template
agent_dir = tmp_path / "narrator"
@@ -87,8 +88,7 @@ class TestGetSceneTemplatePath:
def test_falls_back_to_flat_structure(self, tmp_path):
"""Falls back to flat structure when agent subdir doesn't exist."""
scene = Mock()
scene.template_dir = str(tmp_path)
scene = make_template_dir_scene(template_dir=str(tmp_path))
# Create flat template (no agent subdir)
flat_template = tmp_path / "test.jinja2"
@@ -99,8 +99,7 @@ class TestGetSceneTemplatePath:
def test_returns_expected_path_even_if_missing(self, tmp_path):
"""Returns the expected path even if template doesn't exist."""
scene = Mock()
scene.template_dir = str(tmp_path)
scene = make_template_dir_scene(template_dir=str(tmp_path))
path = groups.get_scene_template_path(scene, "narrator", "missing")
# Should return flat path since no agent subdir exists
@@ -121,8 +120,7 @@ class TestResolveTemplate:
def test_scene_has_highest_priority(self, tmp_path, mock_config):
"""Scene templates always take priority over everything else."""
scene = Mock()
scene.template_dir = str(tmp_path)
scene = make_template_dir_scene(template_dir=str(tmp_path))
# Create scene template
scene_template = tmp_path / "test.jinja2"
@@ -223,8 +221,7 @@ class TestResolveTemplate:
def test_scene_override_beats_explicit_source(self, tmp_path, mock_config):
"""Scene templates override even explicit template_sources."""
scene = Mock()
scene.template_dir = str(tmp_path / "scene")
scene = make_template_dir_scene(template_dir=str(tmp_path / "scene"))
mock_config.prompts.template_sources = {"narrator.test": "my-group"}
@@ -307,8 +304,7 @@ class TestListGroups:
def test_includes_scene_group_when_scene_provided(self, tmp_path, mock_config):
"""Scene group is included only when scene is provided."""
scene = Mock()
scene.template_dir = str(tmp_path / "scene")
scene = make_template_dir_scene(template_dir=str(tmp_path / "scene"))
(tmp_path / "scene").mkdir()
with patch.object(groups, "_get_config", return_value=mock_config):
@@ -398,8 +394,7 @@ class TestGetTemplateContent:
def test_reads_scene_template(self, tmp_path):
"""Reads template from scene when group is 'scene'."""
scene = Mock()
scene.template_dir = str(tmp_path)
scene = make_template_dir_scene(template_dir=str(tmp_path))
# Create template in agent subdir
agent_dir = tmp_path / "narrator"
@@ -437,8 +432,7 @@ class TestWriteTemplate:
def test_writes_to_scene(self, tmp_path):
"""Writes template to scene directory."""
scene = Mock()
scene.template_dir = str(tmp_path)
scene = make_template_dir_scene(template_dir=str(tmp_path))
groups.write_template("scene", "narrator", "test", "scene content", scene)

View File

@@ -17,6 +17,8 @@ import pytest
from talemate.prompts import groups
from ._groups_test_helpers import make_template_dir_scene
# ---------------------------------------------------------------------------
# Shared helpers
@@ -86,8 +88,7 @@ class TestGetSceneTemplatePathInvalidDir:
"""Cover the early-return for a non-string ``template_dir``."""
def test_returns_none_when_template_dir_is_none(self):
scene = Mock()
scene.template_dir = None
scene = make_template_dir_scene(template_dir=None)
result = groups.get_scene_template_path(scene, "narrator", "anything")
@@ -96,8 +97,7 @@ class TestGetSceneTemplatePathInvalidDir:
def test_returns_none_when_template_dir_is_path_object(self, tmp_path):
# The function specifically checks ``isinstance(template_dir, str)`` —
# a Path (which is otherwise valid) hits the early return.
scene = Mock()
scene.template_dir = tmp_path # Path, not str
scene = make_template_dir_scene(template_dir=tmp_path) # Path, not str
result = groups.get_scene_template_path(scene, "narrator", "anything")
@@ -304,8 +304,7 @@ class TestListTemplatesScene:
scene_dir = isolated_groups_dirs["scene"]
_write_template(scene_dir / "narrator" / "scene-only.jinja2")
scene = Mock()
scene.template_dir = str(scene_dir)
scene = make_template_dir_scene(template_dir=str(scene_dir))
result = groups.list_templates(scene=scene)
@@ -320,8 +319,7 @@ class TestListTemplatesScene:
scene_dir = isolated_groups_dirs["scene"]
_write_template(scene_dir / "flat-only.jinja2")
scene = Mock()
scene.template_dir = str(scene_dir)
scene = make_template_dir_scene(template_dir=str(scene_dir))
result = groups.list_templates(scene=scene)
@@ -340,8 +338,7 @@ class TestListTemplatesScene:
_write_template(scene_dir / "narrator" / "shared.jinja2")
_write_template(scene_dir / "shared.jinja2") # flat
scene = Mock()
scene.template_dir = str(scene_dir)
scene = make_template_dir_scene(template_dir=str(scene_dir))
result = groups.list_templates(scene=scene)
@@ -440,8 +437,7 @@ class TestListTemplatesScenePriorityForOverrideMtime:
_write_template(d / "narrator" / "ovr.jinja2", mtime=1_000_000.0)
_write_template(scene_dir / "narrator" / "ovr.jinja2", mtime=3_000_000.0)
scene = Mock()
scene.template_dir = str(scene_dir)
scene = make_template_dir_scene(template_dir=str(scene_dir))
result = groups.list_templates(scene=scene)
info = next(t for t in result if t.uid == "narrator.ovr")
@@ -463,8 +459,7 @@ class TestDeleteTemplateSceneFlatFallback:
def test_falls_back_to_flat_scene_path_when_agent_subdir_empty(self, tmp_path):
# Only a flat-structure scene template exists; no narrator/ subdir.
scene = Mock()
scene.template_dir = str(tmp_path)
scene = make_template_dir_scene(template_dir=str(tmp_path))
flat_template = tmp_path / "flat.jinja2"
flat_template.write_text("flat content")
@@ -478,8 +473,7 @@ class TestDeleteTemplateSceneFlatFallback:
def test_returns_false_when_neither_path_exists(self, tmp_path):
"""No agent subdir AND no flat file → returns False, raises nothing."""
scene = Mock()
scene.template_dir = str(tmp_path)
scene = make_template_dir_scene(template_dir=str(tmp_path))
result = groups.delete_template("scene", "narrator", "missing", scene=scene)

View File

@@ -12,6 +12,7 @@ from talemate.server.prompts import (
parse_template_uid,
validate_jinja2_syntax,
)
from talemate.tale_mate import Scene
class TestParseTemplateUid:
@@ -138,11 +139,14 @@ class TestPromptsPluginListGroups:
@pytest.mark.asyncio
async def test_lists_groups_with_scene(self, tmp_path):
"""Lists groups when scene is loaded."""
mock_scene = Mock()
mock_scene.name = "test-scene"
mock_scene.template_dir = str(tmp_path)
# Real ``Scene`` — ``handle_list_groups`` reads ``scene.name`` to decide
# whether scene context is loaded (truthy => scene_loaded True), then
# delegates to the patched ``list_groups``. ``template_dir`` is never
# touched here because ``list_groups`` itself is patched out.
scene = Scene()
scene.name = "test-scene"
handler = MockWebsocketHandler(scene=mock_scene)
handler = MockWebsocketHandler(scene=scene)
plugin = PromptsPlugin(handler)
# Create mock groups - need to use MagicMock and configure_mock for 'name'

View File

@@ -3,8 +3,8 @@ import json
import tempfile
import shutil
import pytest
from unittest.mock import Mock
from _changelog_test_helpers import make_changelog_scene
from talemate.changelog import (
save_changelog,
append_scene_delta,
@@ -30,6 +30,7 @@ from talemate.changelog import (
_get_latest_changelog_file,
_get_overall_latest_revision,
_get_file_size,
_SceneRef,
MAX_CHANGELOG_FILE_SIZE,
InMemoryChangelog,
)
@@ -45,16 +46,20 @@ def temp_dir():
@pytest.fixture
def mock_scene(temp_dir):
"""Create a mock scene object."""
scene = Mock()
scene.filename = "test_scene.json"
scene.save_dir = temp_dir
scene.changelog_dir = os.path.join(temp_dir, "changelog")
scene.backups_dir = os.path.join(temp_dir, "backups")
scene.serialize = {"characters": [], "entries": [], "metadata": {"version": "1.0"}}
scene.rev = 0 # Initialize revision to 0
scene._changelog = None # Explicitly set to None to avoid Mock auto-creation
return scene
# Real ``Scene`` subclass (see _changelog_test_helpers.ChangelogScene) with
# ``serialize`` exposed as a settable attribute so tests can drive arbitrary
# payloads. ``filename``/``save_dir``/``changelog_dir``/``rev``/``_changelog``
# remain the real ``Scene`` fields/properties — renames or removals will
# break these tests.
return make_changelog_scene(
temp_dir,
filename="test_scene.json",
initial_serialize={
"characters": [],
"entries": [],
"metadata": {"version": "1.0"},
},
)
def test_changelog_log_path(mock_scene):
@@ -1307,14 +1312,12 @@ async def test_delete_changelog_files_with_wrong_scene_reference(temp_dir):
delete_changelog_files(self.scene) is called instead of constructing
a proper scene reference from the deleted file path.
"""
# Create a scene with changelogs
scene1 = Mock()
scene1.filename = "scene_to_delete.json"
scene1.save_dir = os.path.join(temp_dir, "project1")
scene1.changelog_dir = os.path.join(scene1.save_dir, "changelog")
scene1.serialize = {"characters": [], "data": "scene1"}
scene1.rev = 0
scene1._changelog = None
# Real ``Scene`` (subclass) for the scene whose changelog we'll create.
scene1 = make_changelog_scene(
os.path.join(temp_dir, "project1"),
filename="scene_to_delete.json",
initial_serialize={"characters": [], "data": "scene1"},
)
# Initialize changelog for scene1
await save_changelog(scene1)
@@ -1324,11 +1327,12 @@ async def test_delete_changelog_files_with_wrong_scene_reference(temp_dir):
assert os.path.exists(_latest_path(scene1))
# Now simulate the bug: calling delete_changelog_files with a different scene
# (or None, which would be self.scene when no scene is loaded)
wrong_scene = Mock()
wrong_scene.filename = "different_scene.json" # Wrong filename!
wrong_scene.save_dir = os.path.join(temp_dir, "project2") # Wrong directory!
wrong_scene.changelog_dir = os.path.join(wrong_scene.save_dir, "changelog")
# (or None, which would be self.scene when no scene is loaded). Real Scene
# again — wrong filename and wrong save_dir.
wrong_scene = make_changelog_scene(
os.path.join(temp_dir, "project2"),
filename="different_scene.json",
)
# Try to delete with wrong scene reference
result = delete_changelog_files(wrong_scene)
@@ -1343,19 +1347,15 @@ async def test_delete_changelog_files_with_wrong_scene_reference(temp_dir):
"Latest file should still exist due to bug"
)
# Now show the correct way: construct scene reference from the file path
scene_path = os.path.join(scene1.save_dir, scene1.filename)
scene_dir = os.path.dirname(scene_path)
scene_filename = os.path.basename(scene_path)
correct_scene_ref = type(
"Scene",
(),
{
"save_dir": scene_dir,
"filename": scene_filename,
"changelog_dir": os.path.join(scene_dir, "changelog"),
},
)()
# Now show the correct way: construct a real ``_SceneRef`` (the type
# production code uses for this exact purpose — a lightweight scene
# reference for changelog operations). This is the same type
# ``ensure_changelogs_for_all_scenes`` builds internally.
correct_scene_ref = _SceneRef(
filename=scene1.filename,
save_dir=scene1.save_dir,
data={},
)
# Delete with correct reference
result = delete_changelog_files(correct_scene_ref)

View File

@@ -17,10 +17,10 @@ import os
import shutil
import tempfile
from pathlib import Path
from unittest.mock import Mock
import pytest
from _changelog_test_helpers import make_changelog_scene
from talemate.changelog import (
InMemoryChangelog,
_apply_delta,
@@ -51,21 +51,14 @@ def temp_dir():
shutil.rmtree(d, ignore_errors=True)
def _make_mock_scene(temp_dir: str, name: str = "scene.json") -> Mock:
scene = Mock()
scene.filename = name
scene.save_dir = temp_dir
scene.changelog_dir = os.path.join(temp_dir, "changelog")
scene.backups_dir = os.path.join(temp_dir, "backups")
scene.serialize = {"history": [], "characters": []}
scene.rev = 0
scene._changelog = None
return scene
@pytest.fixture
def mock_scene(temp_dir):
return _make_mock_scene(temp_dir)
# Real ``Scene`` subclass (see _changelog_test_helpers.ChangelogScene) with
# ``serialize`` exposed as a settable attribute so tests can drive arbitrary
# payloads. ``filename``/``save_dir``/``changelog_dir``/``rev``/``_changelog``
# remain the real ``Scene`` fields/properties — renames or removals will
# break these tests.
return make_changelog_scene(temp_dir)
# ---------------------------------------------------------------------------

View File

@@ -9,8 +9,6 @@ Tests cover:
- Chat creation for generate_arc modes
"""
from unittest.mock import Mock
from talemate.agents.director.plan.expand import (
compute_chunks,
compute_arc_info,
@@ -18,6 +16,7 @@ from talemate.agents.director.plan.expand import (
MIN_CHUNK_BEATS,
)
from talemate.agents.director.plan.schema import Beat
from talemate.tale_mate import Scene
def _make_beats(tensions: list[float]) -> list[Beat]:
@@ -257,10 +256,11 @@ class TestChatCreateGenerateArc:
director.actions = DirectorAgent.init_actions()
director._chats = {}
director._last_active_chat_id = None
# Mock scene with agent_state for chat persistence
director.scene = Mock()
director.scene.agent_state = {"director": {}}
director.scene.agent_persona.return_value = None
# Real ``Scene`` — ``chat_create_generate_arc`` reads ``scene.agent_state``
# (default ``{}``, persistence target for chats) and calls
# ``scene.agent_persona("director")`` (returns ``None`` on a fresh Scene
# because ``agent_persona_templates`` is an empty dict by default).
director.scene = Scene()
return director
def test_create_generate_arc_default_mode(self):