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
377 lines
14 KiB
Python
377 lines
14 KiB
Python
"""Unit tests for talemate.export.
|
|
|
|
Tests exercise both export formats (talemate JSON and talemate_complete ZIP)
|
|
against a minimal but real Scene-like fixture rooted in tmp_path. We use a
|
|
duck-typed scene object instead of constructing a full talemate.Scene because
|
|
export only reads scene.json, scene.reset, scene.assets.asset_directory,
|
|
scene.save_dir, scene.restore_from, and scene.name — all easy to provide.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import io
|
|
import json
|
|
import os
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from talemate.export import (
|
|
ExportFormat,
|
|
ExportOptions,
|
|
export,
|
|
export_talemate,
|
|
export_talemate_complete,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Minimal Scene fixture: duck-types the surface that export reads
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _StubAssets:
|
|
def __init__(self, scene):
|
|
self.scene = scene
|
|
|
|
@property
|
|
def asset_directory(self):
|
|
return os.path.join(self.scene.save_dir, "assets")
|
|
|
|
|
|
class _StubScene:
|
|
"""Mimics the surface of talemate.tale_mate.Scene that export uses."""
|
|
|
|
def __init__(self, save_dir: str, name: str = "test-scene"):
|
|
self.save_dir = save_dir
|
|
self.name = name
|
|
self.filename = "scene.json"
|
|
self.restore_from = None
|
|
self.assets = _StubAssets(self)
|
|
self._payload: dict = {"name": name, "history": []}
|
|
self.reset_called_count = 0
|
|
|
|
def reset(self):
|
|
self.reset_called_count += 1
|
|
# Mirror real Scene.reset's effect: clear history.
|
|
self._payload["history"] = []
|
|
|
|
@property
|
|
def serialize(self) -> dict:
|
|
return dict(self._payload)
|
|
|
|
@property
|
|
def json(self) -> str:
|
|
return json.dumps(self.serialize)
|
|
|
|
|
|
@pytest.fixture
|
|
def scene_dir(tmp_path):
|
|
"""Create scene save_dir with predictable structure."""
|
|
save_dir = tmp_path / "scenes" / "test-scene"
|
|
save_dir.mkdir(parents=True)
|
|
return save_dir
|
|
|
|
|
|
@pytest.fixture
|
|
def scene(scene_dir):
|
|
return _StubScene(save_dir=str(scene_dir))
|
|
|
|
|
|
def _make_subdir(parent: Path, name: str, files: dict[str, str]) -> Path:
|
|
"""Helper: create a subdirectory with given files mapping (relpath -> content)."""
|
|
sub = parent / name
|
|
sub.mkdir(exist_ok=True)
|
|
for rel, content in files.items():
|
|
path = sub / rel
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(content)
|
|
return sub
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ExportOptions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestExportOptions:
|
|
def test_defaults(self):
|
|
opts = ExportOptions(name="x")
|
|
assert opts.name == "x"
|
|
assert opts.format == ExportFormat.talemate
|
|
assert opts.reset_progress is True
|
|
assert opts.include_assets is True
|
|
assert opts.include_nodes is True
|
|
assert opts.include_info is True
|
|
assert opts.include_templates is True
|
|
|
|
def test_format_enum_values(self):
|
|
assert ExportFormat.talemate.value == "talemate"
|
|
assert ExportFormat.talemate_complete.value == "talemate_complete"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# export() dispatcher
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestExportDispatch:
|
|
@pytest.mark.asyncio
|
|
async def test_dispatches_to_talemate_format(self, scene):
|
|
opts = ExportOptions(name="x", format=ExportFormat.talemate)
|
|
result = await export(scene, opts)
|
|
# talemate format returns a base64 string
|
|
assert isinstance(result, str)
|
|
# Should decode to valid JSON
|
|
decoded = base64.b64decode(result).decode()
|
|
json.loads(decoded) # raises if not valid JSON
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dispatches_to_talemate_complete_format(self, scene):
|
|
opts = ExportOptions(name="x", format=ExportFormat.talemate_complete)
|
|
result = await export(scene, opts)
|
|
# talemate_complete returns bytes (a ZIP)
|
|
assert isinstance(result, bytes)
|
|
# Should be a valid ZIP
|
|
with zipfile.ZipFile(io.BytesIO(result)) as zf:
|
|
assert "scene.json" in zf.namelist()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# export_talemate (legacy JSON-only base64)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestExportTalemate:
|
|
@pytest.mark.asyncio
|
|
async def test_returns_base64_encoded_json(self, scene):
|
|
# Skip the reset path so the payload is faithfully round-tripped.
|
|
scene._payload = {"name": "test", "value": 42}
|
|
result = await export_talemate(
|
|
scene, ExportOptions(name="x", reset_progress=False)
|
|
)
|
|
|
|
decoded = base64.b64decode(result).decode()
|
|
assert json.loads(decoded) == {"name": "test", "value": 42}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resets_progress_when_option_enabled(self, scene):
|
|
opts = ExportOptions(name="x", reset_progress=True)
|
|
await export_talemate(scene, opts)
|
|
assert scene.reset_called_count == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skips_reset_when_option_disabled(self, scene):
|
|
opts = ExportOptions(name="x", reset_progress=False)
|
|
await export_talemate(scene, opts)
|
|
assert scene.reset_called_count == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# export_talemate_complete (ZIP format)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestExportTalemateComplete:
|
|
@pytest.mark.asyncio
|
|
async def test_zip_always_contains_scene_json(self, scene):
|
|
opts = ExportOptions(name="x")
|
|
zip_bytes = await export_talemate_complete(scene, opts)
|
|
|
|
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
|
|
assert "scene.json" in zf.namelist()
|
|
content = zf.read("scene.json").decode()
|
|
assert json.loads(content) == scene.serialize
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_includes_assets_when_enabled_and_directory_exists(
|
|
self, scene, scene_dir
|
|
):
|
|
# Create the assets directory + a file inside it
|
|
assets_dir = scene_dir / "assets"
|
|
assets_dir.mkdir()
|
|
(assets_dir / "library.json").write_text('{"assets": {}}')
|
|
(assets_dir / "image.png").write_bytes(b"\x89PNG fake")
|
|
|
|
opts = ExportOptions(name="x", include_assets=True)
|
|
zip_bytes = await export_talemate_complete(scene, opts)
|
|
|
|
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
|
|
names = zf.namelist()
|
|
assert "assets/library.json" in names
|
|
assert "assets/image.png" in names
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_excludes_assets_when_option_disabled(self, scene, scene_dir):
|
|
assets_dir = scene_dir / "assets"
|
|
assets_dir.mkdir()
|
|
(assets_dir / "library.json").write_text('{"assets": {}}')
|
|
|
|
opts = ExportOptions(name="x", include_assets=False)
|
|
zip_bytes = await export_talemate_complete(scene, opts)
|
|
|
|
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
|
|
names = zf.namelist()
|
|
assert not any(n.startswith("assets/") for n in names)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_includes_nodes_directory_when_present(self, scene, scene_dir):
|
|
_make_subdir(scene_dir, "nodes", {"main.json": '{"nodes": {}}'})
|
|
|
|
opts = ExportOptions(name="x", include_nodes=True)
|
|
zip_bytes = await export_talemate_complete(scene, opts)
|
|
|
|
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
|
|
assert "nodes/main.json" in zf.namelist()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skips_nodes_directory_when_option_disabled(self, scene, scene_dir):
|
|
_make_subdir(scene_dir, "nodes", {"main.json": "{}"})
|
|
|
|
opts = ExportOptions(name="x", include_nodes=False)
|
|
zip_bytes = await export_talemate_complete(scene, opts)
|
|
|
|
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
|
|
assert not any(n.startswith("nodes/") for n in zf.namelist())
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_includes_info_directory(self, scene, scene_dir):
|
|
_make_subdir(scene_dir, "info", {"about.md": "# About"})
|
|
|
|
zip_bytes = await export_talemate_complete(
|
|
scene, ExportOptions(name="x", include_info=True)
|
|
)
|
|
|
|
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
|
|
assert "info/about.md" in zf.namelist()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_excludes_info_directory_when_disabled(self, scene, scene_dir):
|
|
_make_subdir(scene_dir, "info", {"about.md": "# About"})
|
|
|
|
zip_bytes = await export_talemate_complete(
|
|
scene, ExportOptions(name="x", include_info=False)
|
|
)
|
|
|
|
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
|
|
assert not any(n.startswith("info/") for n in zf.namelist())
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_includes_templates_directory(self, scene, scene_dir):
|
|
_make_subdir(scene_dir, "templates", {"world.j2": "{{ scene.name }}"})
|
|
|
|
zip_bytes = await export_talemate_complete(
|
|
scene, ExportOptions(name="x", include_templates=True)
|
|
)
|
|
|
|
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
|
|
assert "templates/world.j2" in zf.namelist()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_excludes_templates_directory_when_disabled(self, scene, scene_dir):
|
|
_make_subdir(scene_dir, "templates", {"world.j2": "x"})
|
|
|
|
zip_bytes = await export_talemate_complete(
|
|
scene, ExportOptions(name="x", include_templates=False)
|
|
)
|
|
|
|
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
|
|
assert not any(n.startswith("templates/") for n in zf.namelist())
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resets_when_option_enabled(self, scene):
|
|
await export_talemate_complete(
|
|
scene, ExportOptions(name="x", reset_progress=True)
|
|
)
|
|
assert scene.reset_called_count == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skips_reset_when_option_disabled(self, scene):
|
|
await export_talemate_complete(
|
|
scene, ExportOptions(name="x", reset_progress=False)
|
|
)
|
|
assert scene.reset_called_count == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_includes_restore_file_when_set(self, scene, scene_dir):
|
|
# Create a restore file in the save_dir
|
|
restore_filename = "old-version.json"
|
|
(scene_dir / restore_filename).write_text('{"restored": true}')
|
|
scene.restore_from = restore_filename
|
|
|
|
zip_bytes = await export_talemate_complete(scene, ExportOptions(name="x"))
|
|
|
|
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
|
|
names = zf.namelist()
|
|
assert restore_filename in names
|
|
content = zf.read(restore_filename).decode()
|
|
assert json.loads(content) == {"restored": true_value()}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handles_missing_restore_file_gracefully(self, scene):
|
|
# restore_from is set but the file does not exist
|
|
scene.restore_from = "nonexistent.json"
|
|
|
|
# Should not raise — only emit a warning log; ZIP still has scene.json
|
|
zip_bytes = await export_talemate_complete(scene, ExportOptions(name="x"))
|
|
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
|
|
assert "scene.json" in zf.namelist()
|
|
assert "nonexistent.json" not in zf.namelist()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handles_missing_assets_directory_gracefully(self, scene):
|
|
# No assets directory exists — export should still succeed.
|
|
opts = ExportOptions(name="x", include_assets=True)
|
|
zip_bytes = await export_talemate_complete(scene, opts)
|
|
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
|
|
assert "scene.json" in zf.namelist()
|
|
assert not any(n.startswith("assets/") for n in zf.namelist())
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_full_zip_roundtrip_includes_all_directories(self, scene, scene_dir):
|
|
# Set up a fully-populated scene with all auxiliary directories.
|
|
assets_dir = scene_dir / "assets"
|
|
assets_dir.mkdir()
|
|
(assets_dir / "library.json").write_text('{"assets":{}}')
|
|
_make_subdir(scene_dir, "nodes", {"a.json": "1"})
|
|
_make_subdir(scene_dir, "info", {"b.md": "2"})
|
|
_make_subdir(scene_dir, "templates", {"c.j2": "3"})
|
|
|
|
scene.restore_from = "previous.json"
|
|
(scene_dir / "previous.json").write_text('{"prev": true}')
|
|
|
|
zip_bytes = await export_talemate_complete(scene, ExportOptions(name="x"))
|
|
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
|
|
names = set(zf.namelist())
|
|
assert "scene.json" in names
|
|
assert "assets/library.json" in names
|
|
assert "nodes/a.json" in names
|
|
assert "info/b.md" in names
|
|
assert "templates/c.j2" in names
|
|
assert "previous.json" in names
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# export() unsupported format
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestExportUnsupportedFormat:
|
|
@pytest.mark.asyncio
|
|
async def test_raises_for_unknown_format(self, scene, monkeypatch):
|
|
# Build an ExportOptions instance and bypass enum constraint by
|
|
# directly substituting a foreign value to exercise the raise branch.
|
|
opts = ExportOptions(name="x")
|
|
opts.format = "unknown_fmt" # type: ignore[assignment]
|
|
|
|
with pytest.raises(ValueError, match="Unsupported export format"):
|
|
await export(scene, opts)
|
|
|
|
|
|
# Helper used in test_includes_restore_file_when_set so we don't repeat ourselves.
|
|
def true_value():
|
|
"""Returns Python True — exists only to keep the JSON fixture above readable."""
|
|
return True
|