Files
talemate/tests/test_character_folder.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

374 lines
14 KiB
Python

"""
Tests for the character "folder" organization feature introduced in prep-0.37.0.
Covers four targets:
1. ``talemate.util.strings.normalize_name`` — generic string normalizer used for
free-form user-entered names (trim, empty-collapse, truncate).
2. ``WorldStateManager.update_character_folder`` — sets/clears a single
character's folder.
3. ``WorldStateManager.rename_character_folder`` — bulk-renames a folder across
every character (active and inactive) currently assigned to it.
4. ``Character.apply_shared_context`` — regression: folder must propagate from
the source character even when being *cleared* to ``None``, which the
``exclude_none=True`` model dump used for the rest of shared context would
otherwise drop.
"""
import pytest
from conftest import MockScene, bootstrap_scene
from talemate.character import Character
from talemate.tale_mate import Actor
from talemate.util.strings import normalize_name
from talemate.world_state.manager import WorldStateManager
# ---------------------------------------------------------------------------
# Shared fixtures / helpers
# ---------------------------------------------------------------------------
@pytest.fixture
def scene():
"""A real MockScene wired up with all agents."""
mock_scene = MockScene()
bootstrap_scene(mock_scene)
return mock_scene
def _add_active_character(scene, name: str, folder: str | None = None) -> Character:
"""Create a Character and register it as an active actor in the scene.
Active characters are visible through ``scene.get_characters()`` AND
``scene.character_data``.
"""
character = Character(name=name, folder=folder)
actor = Actor(character=character, agent=None)
scene.actors.append(actor)
scene.character_data[character.name] = character
return character
def _add_inactive_character(scene, name: str, folder: str | None = None) -> Character:
"""Create a Character and register it as inactive (present in
``character_data`` but not in ``active_characters``).
"""
character = Character(name=name, folder=folder)
scene.character_data[character.name] = character
# Active/inactive partition is derived from ``active_characters``; leaving
# the name out of that list marks the character as inactive.
if character.name in scene.active_characters:
scene.active_characters.remove(character.name)
return character
@pytest.fixture
def manager(scene):
return WorldStateManager(scene)
# ---------------------------------------------------------------------------
# 1. normalize_name
# ---------------------------------------------------------------------------
class TestNormalizeName:
"""
``normalize_name`` is the shared helper that backs the
``_normalize_folder_name`` wrapper used by the websocket handlers. It
trims whitespace, collapses empty / whitespace-only input to ``None``,
and truncates to ``max_length`` characters.
"""
def test_none_input_returns_none(self):
assert normalize_name(None, 29) is None
def test_empty_string_returns_none(self):
assert normalize_name("", 29) is None
def test_whitespace_only_returns_none(self):
assert normalize_name(" \t\n ", 29) is None
def test_simple_string_returned_unchanged(self):
assert normalize_name("Adventurers", 29) == "Adventurers"
def test_leading_and_trailing_whitespace_stripped(self):
assert normalize_name(" Adventurers ", 29) == "Adventurers"
def test_internal_whitespace_preserved(self):
"""Only edge whitespace is trimmed; inner spaces are content."""
assert normalize_name(" The Council ", 29) == "The Council"
def test_truncates_when_over_max_length(self):
raw = "a" * 50
result = normalize_name(raw, 29)
assert result == "a" * 29
assert len(result) == 29
def test_preserves_length_at_exact_max(self):
raw = "x" * 29
assert normalize_name(raw, 29) == raw
def test_preserves_length_just_under_max(self):
raw = "y" * 28
assert normalize_name(raw, 29) == raw
def test_trim_happens_before_truncation(self):
"""Leading/trailing whitespace should not count toward the length
budget: ``" abc "`` with ``max_length=3`` must return ``"abc"``,
not the first three whitespace characters.
"""
assert normalize_name(" abc ", 3) == "abc"
def test_long_input_with_surrounding_whitespace(self):
"""Combined behaviour: strip then truncate."""
raw = " " + ("q" * 40) + " "
result = normalize_name(raw, 10)
assert result == "q" * 10
def test_max_length_zero_collapses_to_empty_string(self):
"""A ``max_length`` of zero is legal and produces an empty-string
slice. The function does NOT re-collapse to ``None`` after
truncation — it only collapses before truncation.
"""
assert normalize_name("abc", 0) == ""
# ---------------------------------------------------------------------------
# 2. WorldStateManager.update_character_folder
# ---------------------------------------------------------------------------
class TestUpdateCharacterFolder:
@pytest.mark.asyncio
async def test_assigns_folder_to_existing_character(self, scene, manager):
character = _add_active_character(scene, "Alice")
assert character.folder is None
await manager.update_character_folder("Alice", "Heroes")
assert character.folder == "Heroes"
@pytest.mark.asyncio
async def test_clears_folder_when_given_none(self, scene, manager):
character = _add_active_character(scene, "Alice", folder="Heroes")
await manager.update_character_folder("Alice", None)
assert character.folder is None
@pytest.mark.asyncio
async def test_overwrites_existing_folder(self, scene, manager):
character = _add_active_character(scene, "Alice", folder="Heroes")
await manager.update_character_folder("Alice", "Villains")
assert character.folder == "Villains"
@pytest.mark.asyncio
async def test_unknown_character_is_noop(self, scene, manager):
"""Unknown names are logged and silently ignored — must not raise."""
existing = _add_active_character(scene, "Alice", folder="Heroes")
# Should not raise, and should leave unrelated state alone.
await manager.update_character_folder("DoesNotExist", "Heroes")
assert existing.folder == "Heroes"
@pytest.mark.asyncio
async def test_only_targeted_character_is_affected(self, scene, manager):
alice = _add_active_character(scene, "Alice", folder="Heroes")
bob = _add_active_character(scene, "Bob", folder="Heroes")
await manager.update_character_folder("Alice", "Villains")
assert alice.folder == "Villains"
assert bob.folder == "Heroes"
@pytest.mark.asyncio
async def test_can_update_inactive_character(self, scene, manager):
"""``get_character`` resolves inactive characters as well, so the
update path must work for them too.
"""
inactive = _add_inactive_character(scene, "Ghost")
await manager.update_character_folder("Ghost", "Spirits")
assert inactive.folder == "Spirits"
# ---------------------------------------------------------------------------
# 3. WorldStateManager.rename_character_folder
# ---------------------------------------------------------------------------
class TestRenameCharacterFolder:
@pytest.mark.asyncio
async def test_renames_single_matching_character(self, scene, manager):
alice = _add_active_character(scene, "Alice", folder="Heroes")
await manager.rename_character_folder("Heroes", "Champions")
assert alice.folder == "Champions"
@pytest.mark.asyncio
async def test_renames_all_matching_characters(self, scene, manager):
alice = _add_active_character(scene, "Alice", folder="Heroes")
bob = _add_active_character(scene, "Bob", folder="Heroes")
carol = _add_active_character(scene, "Carol", folder="Heroes")
await manager.rename_character_folder("Heroes", "Champions")
assert alice.folder == "Champions"
assert bob.folder == "Champions"
assert carol.folder == "Champions"
@pytest.mark.asyncio
async def test_only_renames_exact_folder_matches(self, scene, manager):
"""Non-matching folders and None folders must be left untouched."""
hero = _add_active_character(scene, "Alice", folder="Heroes")
villain = _add_active_character(scene, "Mallory", folder="Villains")
loose = _add_active_character(scene, "Eve", folder=None)
await manager.rename_character_folder("Heroes", "Champions")
assert hero.folder == "Champions"
assert villain.folder == "Villains"
assert loose.folder is None
@pytest.mark.asyncio
async def test_rename_covers_inactive_characters(self, scene, manager):
"""``rename_character_folder`` iterates ``scene.character_data`` which
contains BOTH active and inactive characters — both must be renamed.
"""
active = _add_active_character(scene, "Alice", folder="Heroes")
inactive = _add_inactive_character(scene, "Ghost", folder="Heroes")
await manager.rename_character_folder("Heroes", "Champions")
assert active.folder == "Champions"
assert inactive.folder == "Champions"
@pytest.mark.asyncio
async def test_no_matches_is_noop(self, scene, manager):
alice = _add_active_character(scene, "Alice", folder="Heroes")
await manager.rename_character_folder("NonexistentFolder", "Whatever")
assert alice.folder == "Heroes"
@pytest.mark.asyncio
async def test_rename_with_none_old_matches_unorganized(self, scene, manager):
"""Documented behavior of the low-level manager method: passing
``None`` as ``old_name`` will match every character whose folder is
also ``None`` (because ``None == None``). The handler layer
(``handle_rename_character_folder``) guards against this by bailing
out when ``old_name`` normalizes to ``None``, but the manager method
itself has no such guard — it is the raw bulk updater.
"""
unorganized = _add_active_character(scene, "Alice", folder=None)
already_tagged = _add_active_character(scene, "Bob", folder="Heroes")
await manager.rename_character_folder(None, "Spirits")
# Unorganized character gets swept into the new folder...
assert unorganized.folder == "Spirits"
# ...but characters with a different, non-None folder are untouched.
assert already_tagged.folder == "Heroes"
@pytest.mark.asyncio
async def test_rename_to_same_name_is_idempotent(self, scene, manager):
alice = _add_active_character(scene, "Alice", folder="Heroes")
await manager.rename_character_folder("Heroes", "Heroes")
assert alice.folder == "Heroes"
# ---------------------------------------------------------------------------
# 4. Character.apply_shared_context — folder propagation regression
# ---------------------------------------------------------------------------
class TestApplySharedContextFolder:
"""
Regression coverage: ``apply_shared_context`` dumps the source character
with ``exclude_none=True`` and applies the result via ``update(**)``. That
dump drops ``None`` values — which means clearing ``folder`` on the shared
source would silently fail to propagate. The fix assigns ``self.folder =
other_character.folder`` explicitly AFTER the update call.
"""
@pytest.mark.asyncio
async def test_folder_propagates_when_set_on_source(self):
source = Character(name="Alice", folder="Heroes")
target = Character(name="Alice")
await target.apply_shared_context(source)
assert target.folder == "Heroes"
@pytest.mark.asyncio
async def test_folder_cleared_when_source_is_none(self):
"""The regression case: target had a folder, source has ``None`` —
the target's folder MUST be cleared.
"""
source = Character(name="Alice", folder=None)
target = Character(name="Alice", folder="Heroes")
await target.apply_shared_context(source)
assert target.folder is None
@pytest.mark.asyncio
async def test_folder_overwritten_when_both_set(self):
source = Character(name="Alice", folder="Villains")
target = Character(name="Alice", folder="Heroes")
await target.apply_shared_context(source)
assert target.folder == "Villains"
@pytest.mark.asyncio
async def test_folder_remains_none_when_neither_set(self):
source = Character(name="Alice", folder=None)
target = Character(name="Alice", folder=None)
await target.apply_shared_context(source)
assert target.folder is None
@pytest.mark.asyncio
async def test_folder_propagation_does_not_break_other_shared_state(self):
"""Sanity: propagating ``folder`` mustn't clobber the rest of the
shared context machinery.
``apply_shared_context`` first dumps the source (which carries the
``shared_attributes`` / ``shared_details`` whitelists). That dump is
applied to the target, and only THEN is the whitelist walked against
the source's ``base_attributes`` and ``details`` — so the whitelist
must live on the source, not the target.
"""
source = Character(
name="Alice",
folder="Heroes",
description="A brave warrior",
base_attributes={"strength": "15", "class": "Knight"},
details={"appearance": "Tall", "background": "Noble"},
shared_attributes=["strength"],
shared_details=["appearance"],
)
target = Character(name="Alice")
await target.apply_shared_context(source)
assert target.folder == "Heroes"
assert target.description == "A brave warrior"
assert target.base_attributes.get("strength") == "15"
# Non-shared attribute should NOT be copied
assert "class" not in target.base_attributes
assert target.details.get("appearance") == "Tall"
# Non-shared detail should NOT be copied
assert "background" not in target.details
assert target.memory_dirty is True