Files
talemate/tests/test_nodes_validation.py
veguAI f5d41c04c8 0.37.0 (#267)
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
2026-05-12 21:01:51 +03:00

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