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

286 lines
11 KiB
Python

"""
Unit tests for the director plan expand system.
Tests cover:
- Deliberate chunking (tension-valley splitting)
- Arc position metadata computation
- Leaked tag detection
- PlanMixin config properties
- Chat creation for generate_arc modes
"""
from talemate.agents.director.plan.expand import (
compute_chunks,
compute_arc_info,
has_leaked_tags,
MIN_CHUNK_BEATS,
)
from talemate.agents.director.plan.schema import Beat
from talemate.tale_mate import Scene
def _make_beats(tensions: list[float]) -> list[Beat]:
"""Helper to create Beat objects with given tension values."""
return [
Beat(
description=f"Beat {i + 1}",
order=i + 1,
tension=t,
pacing="moderate",
type="narration",
)
for i, t in enumerate(tensions)
]
class TestComputeChunks:
"""Tests for deliberate chunking at tension valleys."""
def test_single_chunk_when_under_max(self):
beats = _make_beats([0.2, 0.4, 0.6, 0.8])
chunks = compute_chunks(beats, max_chunk_size=8)
assert len(chunks) == 1
assert len(chunks[0]) == 4
def test_single_chunk_when_equal_to_max(self):
beats = _make_beats([0.2, 0.4, 0.6, 0.8, 1.0])
chunks = compute_chunks(beats, max_chunk_size=5)
assert len(chunks) == 1
assert len(chunks[0]) == 5
def test_splits_at_tension_valley(self):
# Tension rises to 0.7 then drops to 0.3 — should split at the valley
beats = _make_beats([0.2, 0.4, 0.7, 0.3, 0.5, 0.8, 1.0])
chunks = compute_chunks(beats, max_chunk_size=6)
assert len(chunks) == 2
# First chunk: beats 1-3 (tension rises to 0.7)
assert len(chunks[0]) == 3
# Second chunk: beats 4-7 (starts at 0.3)
assert len(chunks[1]) == 4
def test_respects_min_chunk_size(self):
# Valley at beat 2, but that would leave chunk 1 with only 2 beats
beats = _make_beats([0.5, 0.3, 0.4, 0.6, 0.8, 1.0])
chunks = compute_chunks(beats, max_chunk_size=5)
# Should not split at beat 2 because chunk would be < MIN_CHUNK_BEATS
assert all(len(c) >= MIN_CHUNK_BEATS for c in chunks)
def test_splits_at_max_size_when_no_valley(self):
# Monotonically increasing — no valleys, must split at max
beats = _make_beats([0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8])
chunks = compute_chunks(beats, max_chunk_size=4)
assert len(chunks) == 2
assert len(chunks[0]) == 4
assert len(chunks[1]) == 4
def test_merges_small_leftover(self):
# 7 beats, max 4 — would split to [4, 3], but if valley is at 5
# we might get [5, 2] which should merge to [7]
beats = _make_beats([0.2, 0.4, 0.6, 0.8, 0.5, 0.3, 0.2])
chunks = compute_chunks(beats, max_chunk_size=6)
# With valley at beat 4→5, should split to [4, 3]
# But leftover of 2 is < MIN_CHUNK_BEATS, so merges back
for chunk in chunks:
assert len(chunk) >= MIN_CHUNK_BEATS or len(chunks) == 1
def test_multiple_valleys(self):
beats = _make_beats([0.2, 0.5, 0.8, 0.3, 0.6, 0.9, 0.4, 0.7, 1.0])
chunks = compute_chunks(beats, max_chunk_size=8)
# Two valleys: beat 3→4 (0.8→0.3) and beat 6→7 (0.9→0.4)
assert len(chunks) == 3
def test_empty_beats(self):
chunks = compute_chunks([], max_chunk_size=5)
assert len(chunks) == 1
assert len(chunks[0]) == 0
class TestComputeArcInfo:
"""Tests for arc position metadata computation."""
def test_single_chunk_is_full_open_by_default(self):
# Default is continuation mode (close_arc=False) -> full_open
beats = _make_beats([0.2, 0.5, 0.8])
chunks = [beats]
infos = compute_arc_info(chunks, beats)
assert len(infos) == 1
assert infos[0].position == "full_open"
def test_single_chunk_is_full_when_closed(self):
beats = _make_beats([0.2, 0.5, 0.8])
chunks = [beats]
infos = compute_arc_info(chunks, beats, close_arc=True)
assert len(infos) == 1
assert infos[0].position == "full"
def test_two_chunks_opening_and_climax(self):
beats = _make_beats([0.2, 0.4, 0.6, 0.8, 0.9, 1.0])
chunks = [beats[:3], beats[3:]]
infos = compute_arc_info(chunks, beats)
assert infos[0].position == "opening"
assert infos[1].position == "climax"
def test_three_chunks_opening_rising_climax(self):
beats = _make_beats([0.2, 0.3, 0.5, 0.6, 0.8, 0.9, 1.0])
chunks = [beats[:2], beats[2:4], beats[4:]]
infos = compute_arc_info(chunks, beats)
assert infos[0].position == "opening"
assert infos[1].position == "rising"
assert infos[2].position == "climax"
def test_last_chunk_stays_rising_when_peak_not_in_last(self):
# Continuation mode: never emits `resolution`.
# Last chunk without peak is `rising`, not `resolution`.
beats = _make_beats([0.2, 0.5, 1.0, 0.8, 0.4, 0.3])
chunks = [beats[:3], beats[3:]]
infos = compute_arc_info(chunks, beats)
assert infos[0].position == "opening"
assert infos[1].position == "rising"
def test_resolution_when_peak_not_in_last_chunk_closed(self):
beats = _make_beats([0.2, 0.5, 1.0, 0.8, 0.4, 0.3])
chunks = [beats[:3], beats[3:]]
infos = compute_arc_info(chunks, beats, close_arc=True)
assert infos[0].position == "opening" # has peak but is first chunk
assert infos[1].position == "resolution"
def test_last_chunk_stays_climax_when_winds_down_continuation(self):
# Continuation mode: never emits `climax_and_resolution`.
# Even if tension winds down within the chunk, position stays `climax`.
beats = _make_beats([0.2, 0.5, 0.8, 1.0, 0.7, 0.5, 0.3])
chunks = [beats[:3], beats[3:]]
infos = compute_arc_info(chunks, beats)
assert infos[0].position == "opening"
assert infos[1].position == "climax"
def test_climax_and_resolution_when_winds_down_closed(self):
beats = _make_beats([0.2, 0.5, 0.8, 1.0, 0.7, 0.5, 0.3])
chunks = [beats[:3], beats[3:]]
infos = compute_arc_info(chunks, beats, close_arc=True)
assert infos[0].position == "opening"
# Second chunk has peak (1.0) but winds down to 0.3
assert infos[1].position == "climax_and_resolution"
def test_tension_range_computed_correctly(self):
beats = _make_beats([0.2, 0.8, 0.5])
chunks = [beats]
infos = compute_arc_info(chunks, beats)
assert infos[0].tension_range == (0.2, 0.8)
def test_has_peak_within_tolerance(self):
beats = _make_beats([0.2, 0.5, 0.95, 1.0])
chunks = [beats[:2], beats[2:]]
infos = compute_arc_info(chunks, beats)
# 0.95 is within 0.05 of peak 1.0
assert infos[1].has_peak is True
class TestHasLeakedTags:
"""Tests for leaked block tag detection."""
def test_clean_blocks(self):
blocks = [
{"type": "narrator", "content": "The ship drifted silently."},
{"type": "character", "name": "Elmer", "content": "Let's go."},
]
assert has_leaked_tags(blocks) is False
def test_leaked_narrator_tag(self):
blocks = [
{"type": "narrator", "content": "Some text <NARRATOR> more text"},
]
assert has_leaked_tags(blocks) is True
def test_leaked_character_tag(self):
blocks = [
{"type": "narrator", "content": 'Text <CHARACTER name="Kaira"> more'},
]
assert has_leaked_tags(blocks) is True
def test_leaked_closing_tag(self):
blocks = [
{"type": "character", "content": "Text </NARRATOR> more"},
]
assert has_leaked_tags(blocks) is True
def test_empty_blocks(self):
assert has_leaked_tags([]) is False
def test_empty_content(self):
blocks = [{"type": "narrator", "content": ""}]
assert has_leaked_tags(blocks) is False
class TestPlanMixinConfig:
"""Tests for PlanMixin configuration properties."""
def test_plan_action_registered(self):
from talemate.agents.director import DirectorAgent
actions = DirectorAgent.init_actions()
assert "plan" in actions
plan = actions["plan"]
assert plan.label == "Arc Generation"
assert plan.container is True
assert plan.icon == "mdi-movie-open"
def test_plan_config_keys(self):
from talemate.agents.director import DirectorAgent
actions = DirectorAgent.init_actions()
config = actions["plan"].config
assert "dialogue_ratio" in config
assert "expand_chunk_size" in config
assert "outline_critique" in config
assert "expand_critique" in config
def test_plan_config_defaults(self):
from talemate.agents.director import DirectorAgent
actions = DirectorAgent.init_actions()
config = actions["plan"].config
assert config["dialogue_ratio"].value == 0.4
assert config["expand_chunk_size"].value == 5
assert config["outline_critique"].value is True
assert config["expand_critique"].value is True
class TestChatCreateGenerateArc:
"""Tests for creating arc generation chats."""
@staticmethod
def _make_director():
from talemate.agents.director import DirectorAgent
director = DirectorAgent.__new__(DirectorAgent)
director.actions = DirectorAgent.init_actions()
director._chats = {}
director._last_active_chat_id = None
# Real ``Scene`` — ``chat_create_generate_arc`` reads ``scene.agent_state``
# (default ``{}``, persistence target for chats) and calls
# ``scene.agent_persona("director")`` (returns ``None`` on a fresh Scene
# because ``agent_persona_templates`` is an empty dict by default).
director.scene = Scene()
return director
def test_create_generate_arc_default_mode(self):
director = self._make_director()
chat = director.chat_create_generate_arc("Test instructions", 8)
assert chat.mode == "generate_arc"
assert chat.confirm_write_actions is False
assert len(chat.messages) == 2
def test_create_generate_arc_expand_mode(self):
director = self._make_director()
chat = director.chat_create_generate_arc(
"Test instructions", 8, mode="generate_arc_expand"
)
assert chat.mode == "generate_arc_expand"
def test_create_generate_arc_instructions_in_message(self):
director = self._make_director()
chat = director.chat_create_generate_arc("Write a horror scene", 12)
user_msg = chat.messages[1]
assert "Write a horror scene" in user_msg.message
assert "12 beats" in user_msg.message