Files
talemate/tests/test_character.py
veguAI 0d8ae90769 fix: strip smart quotes from character example dialogue (#173) (#180)
* fix: strip smart quotes from character example dialogue (#173)

* fix: complete smart-quote pairs and normalize persist_character examples (#173 review)

* docs: correct stale verbatim wording and comment/log accuracy (#173 review)
2026-07-29 19:47:06 +03:00

729 lines
26 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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_add_example_dialogue_replaces_smart_quotes(self):
c = Character(name="Bob")
await c.add_example_dialogue("Bob: “Hello!” He waves. Really.")
assert c.example_dialogue == ["Bob: \"Hello!\" He waves. 'Really.'"]
@pytest.mark.asyncio
async def test_set_example_dialogue_item_replaces_smart_quotes(
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_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