mirror of
https://github.com/vegu-ai/talemate.git
synced 2026-05-18 05:05:39 +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
715 lines
25 KiB
Python
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
|