From ca2474992f2045a6cae2edb0843f2b0efff3314f Mon Sep 17 00:00:00 2001 From: vegu-ai-tools <152010387+vegu-ai-tools@users.noreply.github.com> Date: Sun, 10 May 2026 12:18:01 +0300 Subject: [PATCH] use real scene objects --- tests/_changelog_test_helpers.py | 80 +++++++++++++++++++++++++++ tests/prompts/_groups_test_helpers.py | 43 ++++++++++++++ tests/prompts/test_groups.py | 26 ++++----- tests/prompts/test_groups_extras.py | 26 ++++----- tests/server/test_prompts.py | 12 ++-- tests/test_changelog.py | 74 ++++++++++++------------- tests/test_changelog_extras.py | 21 +++---- tests/test_plan_expand.py | 12 ++-- 8 files changed, 201 insertions(+), 93 deletions(-) create mode 100644 tests/_changelog_test_helpers.py create mode 100644 tests/prompts/_groups_test_helpers.py diff --git a/tests/_changelog_test_helpers.py b/tests/_changelog_test_helpers.py new file mode 100644 index 00000000..2d227f4b --- /dev/null +++ b/tests/_changelog_test_helpers.py @@ -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 diff --git a/tests/prompts/_groups_test_helpers.py b/tests/prompts/_groups_test_helpers.py new file mode 100644 index 00000000..b28128b1 --- /dev/null +++ b/tests/prompts/_groups_test_helpers.py @@ -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) diff --git a/tests/prompts/test_groups.py b/tests/prompts/test_groups.py index adf49aaf..520d1097 100644 --- a/tests/prompts/test_groups.py +++ b/tests/prompts/test_groups.py @@ -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) diff --git a/tests/prompts/test_groups_extras.py b/tests/prompts/test_groups_extras.py index d7a46cc4..afa8dee8 100644 --- a/tests/prompts/test_groups_extras.py +++ b/tests/prompts/test_groups_extras.py @@ -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) diff --git a/tests/server/test_prompts.py b/tests/server/test_prompts.py index 4fcf8f63..33ddd3e1 100644 --- a/tests/server/test_prompts.py +++ b/tests/server/test_prompts.py @@ -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' diff --git a/tests/test_changelog.py b/tests/test_changelog.py index f66dd116..138b6e83 100644 --- a/tests/test_changelog.py +++ b/tests/test_changelog.py @@ -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) diff --git a/tests/test_changelog_extras.py b/tests/test_changelog_extras.py index 5449e6a3..efb09d1a 100644 --- a/tests/test_changelog_extras.py +++ b/tests/test_changelog_extras.py @@ -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) # --------------------------------------------------------------------------- diff --git a/tests/test_plan_expand.py b/tests/test_plan_expand.py index a3927d69..e90b323d 100644 --- a/tests/test_plan_expand.py +++ b/tests/test_plan_expand.py @@ -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):