mirror of
https://github.com/vegu-ai/talemate.git
synced 2026-07-10 12:28:50 +02:00
- **Per-Scene Agent Overrides** — Override agent configuration for a single scene without touching your global config. - **Message Revision History** — Browse and continue from previous regenerations / revisions. - **Scene Perspective Overrides** — Set distinct narrative perspectives for the player, NPCs, and narrator. - **Durable World State Snapshot** — The world state snapshot now persists and updates entities across refreshes. - **World State Highlights** — Characters, objects and places marked by the current world state snapshot are now clickable in the recent message(s) for additional detail. Plus 30 improvements and 17 bug fixes
266 lines
8.9 KiB
Python
266 lines
8.9 KiB
Python
"""
|
|
Unit tests for the scene_message websocket plugin.
|
|
|
|
The plugin exposes three actions that map to three distinct scene-level
|
|
operations:
|
|
|
|
- ``edit`` → ``Scene.edit_message`` (replace active version's text)
|
|
- ``append_version`` → ``Scene.append_message_version`` (grow the stack)
|
|
- ``swap_revision`` → ``Scene.set_message_active_version`` (move pointer)
|
|
|
|
The editor agent's ``cleanup_character_message`` branch in ``handle_edit``
|
|
is bypassed by stubbing the agent to ``enabled=False``; that branch is
|
|
exercised in its own agent tests and isn't the contract under test here.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from talemate.scene_message import CharacterMessage, reset_message_id
|
|
from talemate.server.scene_message import SceneMessagePlugin
|
|
from talemate.tale_mate import Scene
|
|
|
|
|
|
class _MockWebsocketHandler:
|
|
"""Minimal handler stand-in: exposes ``scene`` and a queue_put list."""
|
|
|
|
def __init__(self, scene):
|
|
self._scene = scene
|
|
self.messages: list[dict] = []
|
|
|
|
@property
|
|
def scene(self):
|
|
return self._scene
|
|
|
|
def queue_put(self, data):
|
|
self.messages.append(data)
|
|
|
|
|
|
class _DisabledEditor:
|
|
"""Stub editor agent; the plugin checks ``enabled`` and short-circuits."""
|
|
|
|
enabled = False
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_message_ids():
|
|
reset_message_id()
|
|
yield
|
|
reset_message_id()
|
|
|
|
|
|
@pytest.fixture
|
|
def scene_with_message():
|
|
"""Real Scene carrying one CharacterMessage. Tests target its id."""
|
|
scene = Scene()
|
|
msg = CharacterMessage(message="Alice: Hello.")
|
|
scene.history.append(msg)
|
|
return scene, msg
|
|
|
|
|
|
@pytest.fixture
|
|
def plugin(scene_with_message, monkeypatch):
|
|
"""SceneMessagePlugin wired to a real Scene with editor cleanup disabled.
|
|
|
|
Scene method calls are captured via spies so tests can assert which
|
|
of the three scene-level operations was dispatched and with what
|
|
arguments — no emit bus runs.
|
|
"""
|
|
scene, _ = scene_with_message
|
|
handler = _MockWebsocketHandler(scene=scene)
|
|
plug = SceneMessagePlugin(handler)
|
|
|
|
monkeypatch.setattr(
|
|
"talemate.server.scene_message.instance.get_agent",
|
|
lambda name: _DisabledEditor(),
|
|
)
|
|
|
|
calls: dict[str, list[dict]] = {
|
|
"edit": [],
|
|
"append_version": [],
|
|
"swap_revision": [],
|
|
"delete": [],
|
|
}
|
|
# Linear log capturing inter-call ordering across spies (e.g. mutate
|
|
# then autosave). Each entry is a (name, payload) tuple.
|
|
order: list[tuple[str, object]] = []
|
|
|
|
def _spy_edit(message_id, text):
|
|
entry = {"message_id": message_id, "text": text}
|
|
calls["edit"].append(entry)
|
|
order.append(("edit", entry))
|
|
|
|
def _spy_append(message_id, text, source, reason=None):
|
|
entry = {
|
|
"message_id": message_id,
|
|
"text": text,
|
|
"source": source,
|
|
"reason": reason,
|
|
}
|
|
calls["append_version"].append(entry)
|
|
order.append(("append_version", entry))
|
|
|
|
def _spy_swap(message_id, index):
|
|
entry = {"message_id": message_id, "index": index}
|
|
calls["swap_revision"].append(entry)
|
|
order.append(("swap_revision", entry))
|
|
|
|
def _spy_delete(message_id):
|
|
calls["delete"].append(message_id)
|
|
order.append(("delete", message_id))
|
|
|
|
async def _spy_auto_save():
|
|
order.append(("auto_save", None))
|
|
|
|
monkeypatch.setattr(scene, "edit_message", _spy_edit)
|
|
monkeypatch.setattr(scene, "append_message_version", _spy_append)
|
|
monkeypatch.setattr(scene, "set_message_active_version", _spy_swap)
|
|
monkeypatch.setattr(scene, "delete_message", _spy_delete)
|
|
monkeypatch.setattr(scene, "attempt_auto_save", _spy_auto_save)
|
|
plug._spy_calls = calls
|
|
plug._spy_order = order
|
|
return plug
|
|
|
|
|
|
class TestSceneMessagePluginHandleEdit:
|
|
@pytest.mark.asyncio
|
|
async def test_plain_edit_replaces_active_version(self, plugin, scene_with_message):
|
|
"""``handle_edit`` dispatches to ``Scene.edit_message`` — the
|
|
active version's text is rewritten in place, no new stack
|
|
entry is created."""
|
|
_, msg = scene_with_message
|
|
await plugin.handle_edit({"id": msg.id, "text": "Alice: Hi."})
|
|
|
|
assert plugin._spy_calls["edit"] == [
|
|
{"message_id": msg.id, "text": "Alice: Hi."}
|
|
]
|
|
assert plugin._spy_calls["append_version"] == []
|
|
assert plugin._spy_calls["swap_revision"] == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_drops_extra_payload_fields(self, plugin, scene_with_message):
|
|
"""``EditPayload`` only declares ``id`` and ``text``; any other
|
|
fields a client tries to inject (legacy ``reason`` /
|
|
``mutation_source``, hypothetical ``source``, etc.) are dropped
|
|
by pydantic before reaching ``Scene.edit_message``."""
|
|
_, msg = scene_with_message
|
|
await plugin.handle_edit(
|
|
{
|
|
"id": msg.id,
|
|
"text": "Alice: Hi.",
|
|
"source": "custom",
|
|
"reason": "should not propagate",
|
|
}
|
|
)
|
|
|
|
assert plugin._spy_calls["edit"] == [
|
|
{"message_id": msg.id, "text": "Alice: Hi."}
|
|
]
|
|
|
|
|
|
class TestSceneMessagePluginHandleAppendVersion:
|
|
@pytest.mark.asyncio
|
|
async def test_append_forwards_source_and_reason(self, plugin, scene_with_message):
|
|
"""``handle_append_version`` dispatches to
|
|
``Scene.append_message_version`` with the wire-supplied
|
|
``source`` and optional ``reason``."""
|
|
_, msg = scene_with_message
|
|
await plugin.handle_append_version(
|
|
{
|
|
"id": msg.id,
|
|
"text": "Alice: Hi. How are you?",
|
|
"source": "continue",
|
|
}
|
|
)
|
|
|
|
assert plugin._spy_calls["append_version"] == [
|
|
{
|
|
"message_id": msg.id,
|
|
"text": "Alice: Hi. How are you?",
|
|
"source": "continue",
|
|
"reason": None,
|
|
}
|
|
]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_append_defaults_source_to_custom(self, plugin, scene_with_message):
|
|
"""Omitting ``source`` defaults to ``"custom"`` — the catch-all
|
|
bucket for client-driven mutations that don't fit the closed
|
|
revision/regenerate/continue set."""
|
|
_, msg = scene_with_message
|
|
await plugin.handle_append_version(
|
|
{"id": msg.id, "text": "Alice: rewritten", "reason": "manual override"}
|
|
)
|
|
|
|
assert plugin._spy_calls["append_version"] == [
|
|
{
|
|
"message_id": msg.id,
|
|
"text": "Alice: rewritten",
|
|
"source": "custom",
|
|
"reason": "manual override",
|
|
}
|
|
]
|
|
|
|
|
|
class TestSceneMessagePluginHandleSwapRevision:
|
|
@pytest.mark.asyncio
|
|
async def test_swap_forwards_index(self, plugin, scene_with_message):
|
|
"""``handle_swap_revision`` takes an index and forwards it to
|
|
``Scene.set_message_active_version`` — the canonical text follows
|
|
the pointer rather than being set explicitly by the client."""
|
|
_, msg = scene_with_message
|
|
await plugin.handle_swap_revision({"id": msg.id, "index": 2})
|
|
|
|
assert plugin._spy_calls["swap_revision"] == [
|
|
{"message_id": msg.id, "index": 2}
|
|
]
|
|
|
|
|
|
class TestSceneMessagePluginHandleDelete:
|
|
@pytest.mark.asyncio
|
|
async def test_delete_forwards_id(self, plugin, scene_with_message):
|
|
_, msg = scene_with_message
|
|
await plugin.handle_delete({"id": msg.id})
|
|
|
|
assert plugin._spy_calls["delete"] == [msg.id]
|
|
|
|
|
|
class TestSceneMessagePluginAutosavesAfterMutation:
|
|
"""All four mutation handlers must autosave AFTER their mutation so the
|
|
change reaches the on-disk changelog — forks taken from the changelog
|
|
UI read from disk, not from scene memory.
|
|
"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete(self, plugin, scene_with_message):
|
|
_, msg = scene_with_message
|
|
await plugin.handle_delete({"id": msg.id})
|
|
|
|
names = [name for name, _ in plugin._spy_order]
|
|
assert names == ["delete", "auto_save"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit(self, plugin, scene_with_message):
|
|
_, msg = scene_with_message
|
|
await plugin.handle_edit({"id": msg.id, "text": "Alice: Hi."})
|
|
|
|
names = [name for name, _ in plugin._spy_order]
|
|
assert names == ["edit", "auto_save"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_append_version(self, plugin, scene_with_message):
|
|
_, msg = scene_with_message
|
|
await plugin.handle_append_version(
|
|
{"id": msg.id, "text": "Alice: more.", "source": "continue"}
|
|
)
|
|
|
|
names = [name for name, _ in plugin._spy_order]
|
|
assert names == ["append_version", "auto_save"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_swap_revision(self, plugin, scene_with_message):
|
|
_, msg = scene_with_message
|
|
await plugin.handle_swap_revision({"id": msg.id, "index": 2})
|
|
|
|
names = [name for name, _ in plugin._spy_order]
|
|
assert names == ["swap_revision", "auto_save"]
|