"""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