mirror of
https://github.com/vegu-ai/talemate.git
synced 2026-05-18 13:16:15 +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
308 lines
11 KiB
Python
308 lines
11 KiB
Python
"""Coverage-focused unit tests for talemate.game.engine.nodes.validation.
|
|
|
|
ValidateNode subclasses raise InputValueError when their validation
|
|
predicate fails and pass the input through otherwise. Each subclass is
|
|
exercised with positive and negative paths.
|
|
|
|
Context-ID and asset-ID validators require a real Scene; we use a plain
|
|
Scene plus a tmp save_dir for the asset case.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
|
|
import pytest
|
|
|
|
from _node_test_helpers import run_node
|
|
from talemate.character import Character
|
|
from talemate.game.engine.nodes.core import InputValueError, UNRESOLVED
|
|
from talemate.game.engine.nodes.validation import (
|
|
ValidateAssetID,
|
|
ValidateCharacter,
|
|
ValidateContextIDItem,
|
|
ValidateContextIDString,
|
|
ValidateValueContained,
|
|
ValidateValueIsNotSet,
|
|
ValidateValueIsSet,
|
|
)
|
|
|
|
# Importing the context_id package wires up CONTEXT_ID_PATH_HANDLERS for the
|
|
# Validate* nodes that walk that registry.
|
|
import talemate.game.engine.context_id # noqa: F401
|
|
from talemate.tale_mate import Scene
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def fs_scene(tmp_path, monkeypatch):
|
|
"""Scene with a tmp save_dir so SceneAssets is real."""
|
|
monkeypatch.setattr(
|
|
Scene,
|
|
"scenes_dir",
|
|
classmethod(lambda cls: str(tmp_path)),
|
|
raising=True,
|
|
)
|
|
s = Scene()
|
|
s.project_name = "proj_validation"
|
|
os.makedirs(s.save_dir, exist_ok=True)
|
|
s.emit_status = lambda *a, **kw: None
|
|
return s
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ValidateValueIsSet
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestValidateValueIsSet:
|
|
@pytest.mark.asyncio
|
|
async def test_passes_through_truthy_value(self):
|
|
out = await run_node(ValidateValueIsSet(), inputs={"value": "x"})
|
|
assert out["value"] == "x"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_zero_is_passed_through(self):
|
|
# 0 is falsy in Python but neither None, UNRESOLVED, nor "" — must pass.
|
|
out = await run_node(ValidateValueIsSet(), inputs={"value": 0})
|
|
assert out["value"] == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_none_raises(self):
|
|
with pytest.raises(InputValueError):
|
|
await run_node(ValidateValueIsSet(), inputs={"value": None})
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unresolved_raises(self):
|
|
with pytest.raises(InputValueError):
|
|
await run_node(ValidateValueIsSet(), inputs={"value": UNRESOLVED})
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_blank_string_raises_when_flag_true(self):
|
|
node = ValidateValueIsSet()
|
|
node.set_property("blank_string_is_unset", True)
|
|
with pytest.raises(InputValueError):
|
|
await run_node(node, inputs={"value": ""})
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_blank_string_passes_when_flag_false(self):
|
|
node = ValidateValueIsSet()
|
|
node.set_property("blank_string_is_unset", False)
|
|
out = await run_node(node, inputs={"value": ""})
|
|
assert out["value"] == ""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_custom_error_message_uses_format_string(self):
|
|
# The error message format is interpolated with {value}.
|
|
with pytest.raises(InputValueError, match="The literal: hello-world"):
|
|
# Trigger by setting a value that fails (None) but interpolates a
|
|
# custom string. The {value} is replaced with the failing value.
|
|
node = ValidateValueIsSet()
|
|
node.set_property("error_message", "The literal: hello-world {value}")
|
|
await run_node(node, inputs={"value": None})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ValidateValueIsNotSet
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestValidateValueIsNotSet:
|
|
@pytest.mark.asyncio
|
|
async def test_none_passes(self):
|
|
out = await run_node(ValidateValueIsNotSet(), inputs={"value": None})
|
|
assert out["value"] is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unresolved_passes(self):
|
|
out = await run_node(ValidateValueIsNotSet(), inputs={"value": UNRESOLVED})
|
|
assert out["value"] is UNRESOLVED
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_empty_string_passes(self):
|
|
out = await run_node(ValidateValueIsNotSet(), inputs={"value": ""})
|
|
assert out["value"] == ""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_value_raises(self):
|
|
with pytest.raises(InputValueError):
|
|
await run_node(ValidateValueIsNotSet(), inputs={"value": "x"})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ValidateValueContained
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestValidateValueContained:
|
|
@pytest.mark.asyncio
|
|
async def test_in_list_passes(self):
|
|
out = await run_node(
|
|
ValidateValueContained(),
|
|
inputs={"value": "a", "list": ["a", "b"]},
|
|
)
|
|
assert out["value"] == "a"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_not_in_list_raises(self):
|
|
with pytest.raises(InputValueError):
|
|
await run_node(
|
|
ValidateValueContained(),
|
|
inputs={"value": "z", "list": ["a", "b"]},
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_in_dict_keys_passes(self):
|
|
out = await run_node(
|
|
ValidateValueContained(),
|
|
inputs={"value": "a", "list": {"a": 1, "b": 2}},
|
|
)
|
|
assert out["value"] == "a"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ValidateContextIDString
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestValidateContextIDString:
|
|
@pytest.mark.asyncio
|
|
async def test_unhandled_type_raises_input_value_error(self, fs_scene):
|
|
# An int has no .strip method — the validator wraps the AttributeError
|
|
# in InputValueError describing the bad type.
|
|
with pytest.raises(InputValueError, match="Invalid type"):
|
|
await run_node(
|
|
ValidateContextIDString(), scene=fs_scene, inputs={"value": 12345}
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unknown_handler_raises(self, fs_scene):
|
|
# A context-id-like string with an unregistered prefix raises.
|
|
with pytest.raises(InputValueError):
|
|
await run_node(
|
|
ValidateContextIDString(),
|
|
scene=fs_scene,
|
|
inputs={"value": "no_handler_for_this:foo"},
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ValidateContextIDItem
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestValidateContextIDItem:
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_input_type_raises(self, fs_scene):
|
|
with pytest.raises(InputValueError, match="Invalid type"):
|
|
await run_node(ValidateContextIDItem(), scene=fs_scene, inputs={"value": 1})
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unhandled_string_raises(self, fs_scene):
|
|
with pytest.raises(InputValueError):
|
|
await run_node(
|
|
ValidateContextIDItem(),
|
|
scene=fs_scene,
|
|
inputs={"value": "no_handler_for_this:foo"},
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ValidateCharacter
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _add_actor_min(scene, name: str) -> Character:
|
|
"""Add a Character + Actor to a scene without going through the
|
|
`add_actor` async pipeline (which would try to introduce the main
|
|
character etc.). Mutates `scene.actors` and `scene.character_data`."""
|
|
char = Character(name=name)
|
|
actor = scene.Actor(char, None)
|
|
actor.scene = scene
|
|
scene.actors.append(actor)
|
|
scene.character_data[name] = char
|
|
return char
|
|
|
|
|
|
class TestValidateCharacter:
|
|
@pytest.mark.asyncio
|
|
async def test_existing_active_character_passes_through(self, fs_scene):
|
|
char = _add_actor_min(fs_scene, "Alice")
|
|
fs_scene.active_characters.append("Alice")
|
|
|
|
node = ValidateCharacter()
|
|
node.set_property("character_status", "all")
|
|
out = await run_node(node, scene=fs_scene, inputs={"value": "Alice"})
|
|
assert out["value"] == "Alice"
|
|
assert out["character"] is char
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_missing_character_raises(self, fs_scene):
|
|
with pytest.raises(InputValueError):
|
|
await run_node(
|
|
ValidateCharacter(),
|
|
scene=fs_scene,
|
|
inputs={"value": "Ghost"},
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_placeholder_returns_new_character(self, fs_scene):
|
|
# When the character is missing AND create_placeholder=True, the
|
|
# validator creates a placeholder Character and emits it on
|
|
# `character`. The character must NOT have been added to the scene.
|
|
node = ValidateCharacter()
|
|
node.set_property("create_placeholder", True)
|
|
node.set_property("character_status", "all")
|
|
out = await run_node(node, scene=fs_scene, inputs={"value": "Bob"})
|
|
assert isinstance(out["character"], Character)
|
|
assert out["character"].name == "Bob"
|
|
# Not added to actor list / character_data
|
|
assert "Bob" not in fs_scene.character_data
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_active_required_raises_for_inactive(self, fs_scene):
|
|
# An "inactive" character is one in `character_data` but not present
|
|
# in `actors` — `inactive_characters` is derived from character_data
|
|
# minus the active_characters list. Do not register an actor.
|
|
char = Character(name="Carol")
|
|
fs_scene.character_data["Carol"] = char
|
|
# Note: do NOT add an actor or update active_characters.
|
|
node = ValidateCharacter()
|
|
node.set_property("character_status", "active")
|
|
with pytest.raises(InputValueError, match="not active"):
|
|
await run_node(node, scene=fs_scene, inputs={"value": "Carol"})
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_inactive_required_raises_for_active(self, fs_scene):
|
|
_add_actor_min(fs_scene, "Dora")
|
|
fs_scene.active_characters.append("Dora")
|
|
node = ValidateCharacter()
|
|
node.set_property("character_status", "inactive")
|
|
with pytest.raises(InputValueError, match="is active"):
|
|
await run_node(node, scene=fs_scene, inputs={"value": "Dora"})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ValidateAssetID
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestValidateAssetID:
|
|
@pytest.mark.asyncio
|
|
async def test_existing_id_passes_and_emits_asset(self, fs_scene):
|
|
a = await fs_scene.assets.add_asset(b"x", "png", "image/png")
|
|
out = await run_node(ValidateAssetID(), scene=fs_scene, inputs={"value": a.id})
|
|
assert out["value"] == a.id
|
|
assert out["asset"].id == a.id
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_missing_id_raises(self, fs_scene):
|
|
with pytest.raises(InputValueError):
|
|
await run_node(
|
|
ValidateAssetID(), scene=fs_scene, inputs={"value": "absent"}
|
|
)
|