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

715 lines
25 KiB
Python

"""Unit tests for talemate.character: Character pydantic model, helpers, signals."""
import pytest
import talemate.emit.async_signals as async_signals
import talemate.instance as instance
from talemate.character import (
Character,
CharacterStatus,
VoiceChangedEvent,
activate_character,
deactivate_character,
list_characters,
set_voice,
)
from talemate.agents.tts.schema import Voice
from talemate.scene_message import CharacterMessage
from talemate.tale_mate import Scene
from conftest import bootstrap_engine
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def make_character():
"""Factory that builds a fully-formed Character with sensible defaults.
Each test gets to override only the bits it cares about. The defaults
populate enough attributes / details / dialogue examples to exercise
serialization, dialogue selection, and rename helpers.
"""
def _factory(**overrides):
defaults = dict(
name="Alice",
description="A curious girl from England.",
base_attributes={
"gender": "female",
"age": "12",
"scenario_context": "Wonderland",
},
details={"hair": "blonde", "eyes": "blue"},
example_dialogue=[
"Alice: Oh dear, oh dear!",
"Alice: Curiouser and curiouser!",
"Alice: I wonder what's down there.",
],
color="#abcdef",
)
defaults.update(overrides)
return Character(**defaults)
return _factory
@pytest.fixture
def isolated_voice_signal():
"""Replace the character.voice_changed receivers list and restore on
teardown so tests don't leak handlers into one another."""
sig = async_signals.get("character.voice_changed")
saved = list(sig.receivers)
sig.receivers.clear()
yield sig
sig.receivers.clear()
sig.receivers.extend(saved)
@pytest.fixture
def bootstrap_engine_isolated():
"""Bootstrap the agent registry but restore it afterwards."""
saved_agents = dict(instance.AGENTS)
instance.AGENTS.clear()
bootstrap_engine()
yield
instance.AGENTS.clear()
instance.AGENTS.update(saved_agents)
# ---------------------------------------------------------------------------
# Construction & basic attribute access
# ---------------------------------------------------------------------------
class TestCharacterBasics:
def test_minimal_construction_uses_defaults(self):
c = Character(name="Bob")
assert c.name == "Bob"
assert c.description == ""
assert c.color == "#fff"
assert c.is_player is False
assert c.base_attributes == {}
assert c.details == {}
assert c.example_dialogue == []
assert c.voice is None
def test_str_repr_use_character_name(self):
c = Character(name="Bob")
assert str(c) == "Character: Bob"
assert repr(c) == "Character: Bob"
def test_hash_is_based_on_name(self):
a = Character(name="Bob")
b = Character(name="Bob")
c = Character(name="Eve")
assert hash(a) == hash(b)
assert hash(a) != hash(c)
def test_acting_instructions_alias_for_dialogue_instructions(self):
c = Character(name="Bob")
c.acting_instructions = "Speak softly"
assert c.dialogue_instructions == "Speak softly"
assert c.acting_instructions == "Speak softly"
def test_acting_instructions_validation_alias_on_init(self):
# The pydantic AliasChoices accepts either alias.
c = Character(name="Bob", acting_instructions="be brave")
assert c.dialogue_instructions == "be brave"
def test_gender_property_reads_from_base_attributes(self, make_character):
c = make_character()
assert c.gender == "female"
def test_gender_defaults_to_empty_string(self):
c = Character(name="Bob")
assert c.gender == ""
# ---------------------------------------------------------------------------
# Color helpers
# ---------------------------------------------------------------------------
class TestSetColor:
def test_set_color_assigns_explicit_value(self):
c = Character(name="Bob")
c.set_color("#123456")
assert c.color == "#123456"
def test_set_color_with_none_picks_random_color(self):
c = Character(name="Bob")
original = c.color
c.set_color(None)
# The randomly chosen color must be a non-empty string
assert c.color != original or len(c.color) > 0
assert isinstance(c.color, str)
assert c.color.startswith("#")
# ---------------------------------------------------------------------------
# sheet / sheet_filtered / filtered_sheet
# ---------------------------------------------------------------------------
class TestSheet:
def test_sheet_lists_all_base_attributes(self, make_character):
c = make_character()
out = c.sheet
assert "gender: female" in out
assert "age: 12" in out
assert "scenario_context: Wonderland" in out
def test_sheet_falls_back_to_name_and_description_when_no_attrs(self):
c = Character(name="Bob", description="A man")
out = c.sheet
assert "name: Bob" in out
assert "description: A man" in out
def test_sheet_filtered_excludes_named_keys_case_insensitive(self, make_character):
c = make_character()
out = c.sheet_filtered("AGE", "Gender")
assert "age" not in out.lower()
assert "gender" not in out.lower()
assert "scenario_context" in out
def test_filtered_sheet_includes_only_listed_attributes(self, make_character):
c = make_character()
out = c.filtered_sheet(["age"])
assert out == "age: 12"
# ---------------------------------------------------------------------------
# Dialogue example helpers
# ---------------------------------------------------------------------------
class TestDialogueExamples:
def test_random_dialogue_example_returns_one_of_examples(self, make_character):
c = make_character()
chosen = c.random_dialogue_example
assert chosen in c.example_dialogue
def test_random_dialogue_example_empty_returns_empty_string(self):
c = Character(name="Bob")
assert c.random_dialogue_example == ""
def test_random_dialogue_examples_returns_unique_subset(self, make_character):
c = make_character()
examples = c._random_dialogue_examples(num=2)
assert len(examples) == 2
# No duplicates
assert len(set(examples)) == 2
assert all(e in c.example_dialogue for e in examples)
def test_random_dialogue_examples_caps_at_available(self, make_character):
c = make_character()
# asking for 10 when only 3 exist must return at most 3
examples = c._random_dialogue_examples(num=10)
assert len(examples) == 3
def test_random_dialogue_examples_strips_name_when_requested(self, make_character):
c = make_character()
examples = c._random_dialogue_examples(num=3, strip_name=True)
for e in examples:
assert not e.startswith("Alice:")
def test_random_dialogue_examples_empty_returns_empty_list(self):
c = Character(name="Bob")
assert c._random_dialogue_examples(num=3) == []
@pytest.mark.asyncio
async def test_add_example_dialogue_appends_trimmed(self):
c = Character(name="Bob")
await c.add_example_dialogue(" Bob: Hello ")
assert c.example_dialogue == ["Bob: Hello"]
@pytest.mark.asyncio
async def test_add_example_dialogue_skips_empty(self):
c = Character(name="Bob")
await c.add_example_dialogue(" ")
await c.add_example_dialogue(None) # type: ignore[arg-type]
assert c.example_dialogue == []
@pytest.mark.asyncio
async def test_set_example_dialogue_item_replaces_at_index(self, make_character):
c = make_character()
await c.set_example_dialogue_item(0, "Alice: New line")
assert c.example_dialogue[0] == "Alice: New line"
@pytest.mark.asyncio
async def test_set_example_dialogue_item_out_of_range_is_noop(self, make_character):
c = make_character()
original = list(c.example_dialogue)
await c.set_example_dialogue_item(99, "Alice: nope")
await c.set_example_dialogue_item(-1, "Alice: nope")
assert c.example_dialogue == original
@pytest.mark.asyncio
async def test_set_example_dialogue_item_with_empty_deletes(self, make_character):
c = make_character()
original_len = len(c.example_dialogue)
await c.set_example_dialogue_item(0, " ")
assert len(c.example_dialogue) == original_len - 1
@pytest.mark.asyncio
async def test_remove_example_dialogue_deletes_at_index(self, make_character):
c = make_character()
first = c.example_dialogue[0]
await c.remove_example_dialogue(0)
assert first not in c.example_dialogue
@pytest.mark.asyncio
async def test_remove_example_dialogue_out_of_range_is_noop(self, make_character):
c = make_character()
original = list(c.example_dialogue)
await c.remove_example_dialogue(99)
await c.remove_example_dialogue(-1)
assert c.example_dialogue == original
@pytest.mark.asyncio
async def test_set_acting_instructions_normalizes_empty_to_none(self):
c = Character(name="Bob")
await c.set_acting_instructions("")
assert c.dialogue_instructions is None
await c.set_acting_instructions(None)
assert c.dialogue_instructions is None
@pytest.mark.asyncio
async def test_set_acting_instructions_stores_value(self):
c = Character(name="Bob")
await c.set_acting_instructions("Be solemn.")
assert c.dialogue_instructions == "Be solemn."
# ---------------------------------------------------------------------------
# random_dialogue_examples (using a real Scene history)
# ---------------------------------------------------------------------------
class TestRandomDialogueExamplesFromScene:
def test_short_history_uses_prepared_examples(self, make_character):
c = make_character()
scene = Scene()
# No history -> below threshold; falls back to prepared examples.
result = c.random_dialogue_examples(scene, num=2)
assert len(result) == 2
for r in result:
assert "Alice" in r or "wonder" in r.lower() or "curious" in r.lower()
def test_history_threshold_pulls_from_history(self, make_character):
c = make_character()
scene = Scene()
# Inflate history past threshold (15) with character messages
for i in range(20):
scene.history.append(CharacterMessage(message=f"Alice: line {i}"))
result = c.random_dialogue_examples(scene, num=3)
assert len(result) == 3
# All should derive from the history lines (prefix stripped).
for r in result:
assert r.startswith("line ")
def test_history_examples_filtered_by_character_name(self, make_character):
c = make_character()
scene = Scene()
# 20 messages, but only some belong to Alice
for i in range(20):
scene.history.append(CharacterMessage(message=f"Bob: irrelevant {i}"))
# No Alice messages in history -> falls through to prepared examples
result = c.random_dialogue_examples(scene, num=2)
assert len(result) == 2
for r in result:
# These are pulled from prepared example_dialogue which all start with "Alice:"
# but max_length truncation may trim the trailing portion.
assert "Alice" in r or "wonder" in r.lower() or "curious" in r.lower()
def test_max_length_truncates_history_examples(self, make_character):
# max_length truncation only applies to the history-based path
# (after the threshold). The prepared-example fallback returns
# examples verbatim. Build enough history to push past threshold
# so we exercise the truncation branch.
c = make_character()
scene = Scene()
long_line = "Alice: " + "y" * 500
for _ in range(20):
scene.history.append(CharacterMessage(message=long_line))
result = c.random_dialogue_examples(scene, num=1, max_length=20)
assert len(result) == 1
# strip_partial_sentences may drop incomplete sentences (returning ""),
# but the slice itself must be at most max_length.
assert len(result[0]) <= 20
# ---------------------------------------------------------------------------
# rename / introduce_main_character
# ---------------------------------------------------------------------------
class TestRename:
def test_rename_updates_name(self):
c = Character(name="Bob", description="Bob is brave.")
c.rename("Robert")
assert c.name == "Robert"
assert c.description == "Robert is brave."
assert c.memory_dirty
def test_rename_replaces_in_base_attributes(self):
c = Character(name="Bob", base_attributes={"bio": "Bob lives here"})
c.rename("Robert")
assert c.base_attributes["bio"] == "Robert lives here"
def test_rename_replaces_in_details(self):
c = Character(name="Bob", details={"intro": "Bob from town"})
c.rename("Robert")
assert c.details["intro"] == "Robert from town"
def test_rename_from_you_does_not_replace_in_text(self):
# When original name is "You", the helper short-circuits to avoid
# mangling references to the player throughout the description.
c = Character(name="You", description="You sing songs.")
c.rename("Alice")
assert c.name == "Alice"
# Description must be untouched
assert c.description == "You sing songs."
class TestIntroduceMainCharacter:
def test_replaces_user_token_in_description(self):
c = Character(
name="Bob",
description="{{user}} entered the room.",
greeting_text="Hello, {{user}}!",
)
main = Character(name="Alice")
c.introduce_main_character(main)
assert c.description == "Alice entered the room."
assert c.greeting_text == "Hello, Alice!"
def test_replacement_is_case_insensitive(self):
c = Character(name="Bob", description="{{USER}} and {{User}} chatted.")
main = Character(name="Alice")
c.introduce_main_character(main)
assert c.description == "Alice and Alice chatted."
def test_replaces_in_example_dialogue(self):
c = Character(
name="Bob",
example_dialogue=["Bob: Welcome, {{user}}!", "Bob: Goodbye {{user}}"],
)
main = Character(name="Alice")
c.introduce_main_character(main)
assert c.example_dialogue == [
"Bob: Welcome, Alice!",
"Bob: Goodbye Alice",
]
# ---------------------------------------------------------------------------
# update / get/set helpers
# ---------------------------------------------------------------------------
class TestUpdate:
def test_update_assigns_attributes_and_marks_dirty(self):
c = Character(name="Bob")
c.update(description="updated", color="#000")
assert c.description == "updated"
assert c.color == "#000"
assert c.memory_dirty
def test_update_sets_voice_from_dict(self):
c = Character(name="Bob")
c.update(
voice={"label": "Bob Voice", "provider": "kokoro", "provider_id": "v1"}
)
assert isinstance(c.voice, Voice)
assert c.voice.label == "Bob Voice"
def test_update_clears_voice_when_value_falsy(self):
c = Character(
name="Bob",
voice=Voice(label="x", provider="kokoro", provider_id="v"),
)
c.update(voice=None)
assert c.voice is None
def test_set_detail_defer_does_not_touch_memory(self):
c = Character(name="Bob")
c.set_detail_defer("hair", "brown")
assert c.details == {"hair": "brown"}
assert c.memory_dirty
def test_get_detail_returns_value(self, make_character):
c = make_character()
assert c.get_detail("hair") == "blonde"
assert c.get_detail("nonexistent") is None
def test_set_base_attribute_defer(self):
c = Character(name="Bob")
c.set_base_attribute_defer("age", "30")
assert c.base_attributes == {"age": "30"}
assert c.memory_dirty
def test_get_base_attribute(self, make_character):
c = make_character()
assert c.get_base_attribute("age") == "12"
assert c.get_base_attribute("nope") is None
# ---------------------------------------------------------------------------
# Shared context helpers
# ---------------------------------------------------------------------------
class TestSharedContext:
@pytest.mark.asyncio
async def test_set_shared_true_seeds_attributes_and_details(self, make_character):
c = make_character()
await c.set_shared(True)
assert c.shared is True
assert set(c.shared_attributes) == set(c.base_attributes.keys())
assert set(c.shared_details) == set(c.details.keys())
@pytest.mark.asyncio
async def test_set_shared_false_clears_attributes(self, make_character):
c = make_character()
await c.set_shared(True)
await c.set_shared(False)
assert c.shared is False
assert c.shared_attributes == []
# set_shared_details is reseeded at the end regardless; verify it
# contains the current details keys (a known oddity of the impl).
assert set(c.shared_details) == set(c.details.keys())
@pytest.mark.asyncio
async def test_set_shared_attribute_adds_and_removes(self, make_character):
c = make_character()
await c.set_shared_attribute("age", True)
assert "age" in c.shared_attributes
await c.set_shared_attribute("age", False)
assert "age" not in c.shared_attributes
# Removing again is a no-op
await c.set_shared_attribute("age", False)
@pytest.mark.asyncio
async def test_set_shared_detail_adds_and_removes(self, make_character):
c = make_character()
await c.set_shared_detail("hair", True)
assert "hair" in c.shared_details
await c.set_shared_detail("hair", False)
assert "hair" not in c.shared_details
@pytest.mark.asyncio
async def test_apply_shared_context_copies_marked_attrs(self, make_character):
a = make_character() # source
b = Character(name="Alice") # destination
await a.set_shared(True)
# Restrict shared scope to only "age" to verify selective copying
a.shared_attributes = ["age"]
a.shared_details = ["hair"]
# Move shared lists onto destination so apply_shared_context picks them up
b.shared_attributes = ["age"]
b.shared_details = ["hair"]
await b.apply_shared_context(a)
# Selectively shared keys propagate
assert b.base_attributes == {"age": "12"}
assert b.details == {"hair": "blonde"}
# Top-level scalar fields are mirrored
assert b.description == a.description
assert b.memory_dirty
@pytest.mark.asyncio
async def test_apply_shared_context_propagates_folder_even_when_none(
self, make_character
):
a = make_character(folder=None)
b = Character(name="Alice", folder="some/folder")
await b.apply_shared_context(a)
# `folder` is explicitly assigned even when source value is None,
# because exclude_none=True would otherwise drop it.
assert b.folder is None
# ---------------------------------------------------------------------------
# voice signal: set_voice + VoiceChangedEvent
# ---------------------------------------------------------------------------
class TestSetVoice:
@pytest.mark.asyncio
async def test_set_voice_updates_attribute(self, isolated_voice_signal):
c = Character(name="Bob")
new_voice = Voice(label="x", provider="kokoro", provider_id="v")
emission = await set_voice(c, new_voice)
assert c.voice is new_voice
assert isinstance(emission, VoiceChangedEvent)
assert emission.character is c
assert emission.voice is new_voice
assert emission.auto is False
@pytest.mark.asyncio
async def test_set_voice_clears_when_none(self, isolated_voice_signal):
c = Character(
name="Bob",
voice=Voice(label="x", provider="kokoro", provider_id="v"),
)
emission = await set_voice(c, None)
assert c.voice is None
assert emission.voice is None
@pytest.mark.asyncio
async def test_set_voice_fires_async_signal(self, isolated_voice_signal):
received = []
async def handler(event: VoiceChangedEvent):
received.append(event)
isolated_voice_signal.connect(handler)
c = Character(name="Bob")
v = Voice(label="x", provider="kokoro", provider_id="v")
await set_voice(c, v, auto=True)
assert len(received) == 1
assert received[0].character is c
assert received[0].voice is v
assert received[0].auto is True
# ---------------------------------------------------------------------------
# activate / deactivate / list_characters
# ---------------------------------------------------------------------------
class TestActivateDeactivate:
@pytest.mark.asyncio
async def test_activate_npc_adds_actor_and_marks_active(
self, bootstrap_engine_isolated
):
scene = Scene()
char = Character(name="Bob", description="An NPC")
scene.character_data["Bob"] = char
result = await activate_character(scene, char)
assert result is None or result is True
assert "Bob" in scene.active_characters
assert any(a.character is char for a in scene.actors)
# NPC actor uses the conversation agent
for actor in scene.actors:
if actor.character is char:
assert actor.agent is instance.AGENTS["conversation"]
@pytest.mark.asyncio
async def test_activate_player_uses_player_class(self, bootstrap_engine_isolated):
scene = Scene()
player = Character(name="Player", is_player=True)
scene.character_data["Player"] = player
await activate_character(scene, player)
assert "Player" in scene.active_characters
# The player actor has no agent
for actor in scene.actors:
if actor.character is player:
assert actor.agent is None
@pytest.mark.asyncio
async def test_activate_already_active_returns_false(
self, bootstrap_engine_isolated
):
scene = Scene()
char = Character(name="Bob")
scene.character_data["Bob"] = char
scene.active_characters.append("Bob")
result = await activate_character(scene, char)
assert result is False
@pytest.mark.asyncio
async def test_activate_with_string_name_resolves_via_scene(
self, bootstrap_engine_isolated
):
scene = Scene()
char = Character(name="Bob")
scene.character_data["Bob"] = char
await activate_character(scene, "Bob")
assert "Bob" in scene.active_characters
@pytest.mark.asyncio
async def test_deactivate_returns_false_when_not_active(
self, bootstrap_engine_isolated
):
scene = Scene()
char = Character(name="Bob")
scene.character_data["Bob"] = char
result = await deactivate_character(scene, char)
assert result is False
@pytest.mark.asyncio
async def test_deactivate_removes_actor_and_active_entry(
self, bootstrap_engine_isolated
):
scene = Scene()
char = Character(name="Bob")
scene.character_data["Bob"] = char
await activate_character(scene, char)
assert "Bob" in scene.active_characters
await deactivate_character(scene, char)
assert "Bob" not in scene.active_characters
class TestListCharacters:
@pytest.mark.asyncio
async def test_list_characters_returns_status_objects(
self, bootstrap_engine_isolated, make_character
):
scene = Scene()
bob = make_character(name="Bob")
alice = Character(name="Alice", is_player=True)
scene.character_data["Bob"] = bob
scene.character_data["Alice"] = alice
await activate_character(scene, bob)
result = await list_characters(scene)
assert len(result) == 2
by_name = {s.name: s for s in result}
assert isinstance(by_name["Bob"], CharacterStatus)
assert by_name["Bob"].active is True
assert by_name["Bob"].is_player is False
assert by_name["Alice"].active is False
assert by_name["Alice"].is_player is True
@pytest.mark.asyncio
async def test_list_characters_truncates_long_descriptions(
self, bootstrap_engine_isolated
):
scene = Scene()
long_desc = "x" * 500
scene.character_data["Bob"] = Character(name="Bob", description=long_desc)
result = await list_characters(scene, max_description_length=10)
assert result[0].description.endswith("...")
# original 10 chars + 3 dots
assert len(result[0].description) == 13