mirror of
https://github.com/vegu-ai/talemate.git
synced 2026-08-29 10:08:58 +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
309 lines
11 KiB
Python
309 lines
11 KiB
Python
"""Unit tests for talemate.agents.director.generate_choices.GenerateChoicesMixin.
|
|
|
|
Covers:
|
|
- Config property helpers (generate_choices_enabled, chance, num_choices,
|
|
never_auto_progress, instructions).
|
|
- on_player_turn_start: skip-when-disabled, skip-after-recent-player-message,
|
|
randomized triggering of generate_choices.
|
|
- generate_choices: end-to-end with stubbed Prompt.request — covers
|
|
extraction and limit-by-num_choices, no-actions early return,
|
|
list-extraction failure path.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from conftest import MockScene, bootstrap_scene
|
|
|
|
import talemate.instance as instance
|
|
from talemate.events import GameLoopStartEvent
|
|
from talemate.scene_message import CharacterMessage, NarratorMessage
|
|
|
|
from _director_test_helpers import (
|
|
add_character_to_scene as _add_character,
|
|
patch_prompt_request_in,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def scene():
|
|
s = MockScene()
|
|
bootstrap_scene(s)
|
|
return s
|
|
|
|
|
|
@pytest.fixture
|
|
def director(scene):
|
|
return instance.get_agent("director")
|
|
|
|
|
|
@pytest.fixture
|
|
def stub_prompt(monkeypatch):
|
|
"""Replace the real ``Prompt.request`` classmethod with a queued response source.
|
|
|
|
Patches the canonical ``talemate.prompts.base.Prompt`` class with
|
|
``raising=True`` so a rename or removal of ``Prompt.request`` fails.
|
|
"""
|
|
from talemate.agents.director import generate_choices as gc_mod
|
|
|
|
return patch_prompt_request_in(monkeypatch, gc_mod)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Config properties
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGenerateChoicesConfig:
|
|
def test_enabled_default_true(self, director):
|
|
assert director.generate_choices_enabled is True
|
|
|
|
def test_chance_default_0_3(self, director):
|
|
assert director.generate_choices_chance == 0.3
|
|
|
|
def test_num_choices_default_3(self, director):
|
|
assert director.generate_choices_num_choices == 3
|
|
|
|
def test_never_auto_progress_default_false(self, director):
|
|
assert director.generate_choices_never_auto_progress is False
|
|
|
|
def test_instructions_default_empty(self, director):
|
|
assert director.generate_choices_instructions == ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# on_player_turn_start
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestOnPlayerTurnStart:
|
|
@pytest.mark.asyncio
|
|
async def test_no_op_when_director_disabled(self, scene, director):
|
|
director.is_enabled = False
|
|
try:
|
|
event = GameLoopStartEvent(scene=scene, event_type="player_turn_start")
|
|
calls = []
|
|
|
|
async def fake_generate_choices(**kwargs):
|
|
calls.append(kwargs)
|
|
|
|
with patch.object(
|
|
director, "generate_choices", side_effect=fake_generate_choices
|
|
):
|
|
await director.on_player_turn_start(event)
|
|
assert calls == []
|
|
finally:
|
|
director.is_enabled = True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_op_when_generate_choices_disabled(self, scene, director):
|
|
director.actions["_generate_choices"].enabled = False
|
|
try:
|
|
event = GameLoopStartEvent(scene=scene, event_type="player_turn_start")
|
|
calls = []
|
|
|
|
async def fake_generate_choices(**kwargs):
|
|
calls.append(kwargs)
|
|
|
|
with patch.object(
|
|
director, "generate_choices", side_effect=fake_generate_choices
|
|
):
|
|
await director.on_player_turn_start(event)
|
|
assert calls == []
|
|
finally:
|
|
director.actions["_generate_choices"].enabled = True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skips_when_recent_player_message(self, scene, director):
|
|
# Player message is the most-recent character/narrator message
|
|
scene.history.append(NarratorMessage("intro"))
|
|
scene.history.append(CharacterMessage("Hero: hi", source="player"))
|
|
|
|
event = GameLoopStartEvent(scene=scene, event_type="player_turn_start")
|
|
with patch("random.random", return_value=0.0): # always pass chance
|
|
calls = []
|
|
|
|
async def fake_generate_choices(**kwargs):
|
|
calls.append(kwargs)
|
|
|
|
with patch.object(
|
|
director, "generate_choices", side_effect=fake_generate_choices
|
|
):
|
|
await director.on_player_turn_start(event)
|
|
assert calls == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_triggers_when_chance_passes(self, scene, director):
|
|
# Last message is a narrator → chance roll determines if it fires.
|
|
scene.history.append(NarratorMessage("scene start"))
|
|
event = GameLoopStartEvent(scene=scene, event_type="player_turn_start")
|
|
|
|
calls = []
|
|
|
|
async def fake_generate_choices(**kwargs):
|
|
calls.append(kwargs)
|
|
|
|
with patch.object(
|
|
director, "generate_choices", side_effect=fake_generate_choices
|
|
):
|
|
with patch("random.random", return_value=0.0): # below default 0.3
|
|
await director.on_player_turn_start(event)
|
|
assert len(calls) == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_call_when_chance_fails(self, scene, director):
|
|
scene.history.append(NarratorMessage("scene start"))
|
|
event = GameLoopStartEvent(scene=scene, event_type="player_turn_start")
|
|
|
|
calls = []
|
|
|
|
async def fake_generate_choices(**kwargs):
|
|
calls.append(kwargs)
|
|
|
|
with patch.object(
|
|
director, "generate_choices", side_effect=fake_generate_choices
|
|
):
|
|
with patch("random.random", return_value=0.99): # above 0.3
|
|
await director.on_player_turn_start(event)
|
|
assert calls == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_triggers_with_no_history(self, scene, director):
|
|
# No history at all — the loop terminates without finding a player msg
|
|
# and proceeds to the chance roll.
|
|
event = GameLoopStartEvent(scene=scene, event_type="player_turn_start")
|
|
calls = []
|
|
|
|
async def fake_generate_choices(**kwargs):
|
|
calls.append(kwargs)
|
|
|
|
with patch.object(
|
|
director, "generate_choices", side_effect=fake_generate_choices
|
|
):
|
|
with patch("random.random", return_value=0.0):
|
|
await director.on_player_turn_start(event)
|
|
assert len(calls) == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# generate_choices — end-to-end via stubbed Prompt.request
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGenerateChoices:
|
|
@pytest.mark.asyncio
|
|
async def test_returns_response_and_emits_choices(
|
|
self, scene, director, stub_prompt
|
|
):
|
|
await _add_character(scene, "Hero", is_player=True)
|
|
# Response simulates a numbered list extracted by util.extract_list
|
|
actions_text = "1. Open the door\n2. Look around\n3. Run away"
|
|
stub_prompt(
|
|
{"director.generate-choices": [("raw response", {"actions": actions_text})]}
|
|
)
|
|
result = await director.generate_choices()
|
|
assert result == "raw response"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_returns_none_when_no_actions_block(
|
|
self, scene, director, stub_prompt
|
|
):
|
|
await _add_character(scene, "Hero", is_player=True)
|
|
stub_prompt(
|
|
{"director.generate-choices": [("raw response", {"actions": None})]}
|
|
)
|
|
result = await director.generate_choices()
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_uses_specific_character_when_provided(
|
|
self, scene, director, stub_prompt
|
|
):
|
|
await _add_character(scene, "Alice")
|
|
await _add_character(scene, "Hero", is_player=True)
|
|
stub = stub_prompt(
|
|
{"director.generate-choices": [("ok", {"actions": "1. Wait\n2. Speak"})]}
|
|
)
|
|
await director.generate_choices(character="Alice")
|
|
# Alice should be the character passed to the prompt
|
|
assert stub.calls[0]["vars"]["character"].name == "Alice"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_falls_back_to_player_character(self, scene, director, stub_prompt):
|
|
await _add_character(scene, "Hero", is_player=True)
|
|
stub = stub_prompt(
|
|
{"director.generate-choices": [("ok", {"actions": "1. A\n2. B"})]}
|
|
)
|
|
await director.generate_choices()
|
|
assert stub.calls[0]["vars"]["character"].name == "Hero"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_uses_explicit_instructions_over_config(
|
|
self, scene, director, stub_prompt
|
|
):
|
|
await _add_character(scene, "Hero", is_player=True)
|
|
director.actions["_generate_choices"].config[
|
|
"instructions"
|
|
].value = "config instructions"
|
|
stub = stub_prompt({"director.generate-choices": [("ok", {"actions": "1. A"})]})
|
|
await director.generate_choices(instructions="explicit override")
|
|
assert stub.calls[0]["vars"]["instructions"] == "explicit override"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_uses_config_instructions_when_none_passed(
|
|
self, scene, director, stub_prompt
|
|
):
|
|
await _add_character(scene, "Hero", is_player=True)
|
|
director.actions["_generate_choices"].config[
|
|
"instructions"
|
|
].value = "config-default"
|
|
stub = stub_prompt({"director.generate-choices": [("ok", {"actions": "1. A"})]})
|
|
try:
|
|
await director.generate_choices()
|
|
assert stub.calls[0]["vars"]["instructions"] == "config-default"
|
|
finally:
|
|
director.actions["_generate_choices"].config["instructions"].value = ""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_passes_num_choices_to_prompt(self, scene, director, stub_prompt):
|
|
await _add_character(scene, "Hero", is_player=True)
|
|
director.actions["_generate_choices"].config["num_choices"].value = 5
|
|
try:
|
|
stub = stub_prompt(
|
|
{"director.generate-choices": [("ok", {"actions": "1. A"})]}
|
|
)
|
|
await director.generate_choices()
|
|
assert stub.calls[0]["vars"]["num_choices"] == 5
|
|
finally:
|
|
director.actions["_generate_choices"].config["num_choices"].value = 3
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_returns_none_when_extract_list_fails(
|
|
self, scene, director, stub_prompt
|
|
):
|
|
await _add_character(scene, "Hero", is_player=True)
|
|
stub_prompt(
|
|
{
|
|
"director.generate-choices": [
|
|
("garbage", {"actions": "garbage that fails extraction"})
|
|
]
|
|
}
|
|
)
|
|
# Patch util.extract_list inside the module to raise
|
|
import talemate.agents.director.generate_choices as gc_mod
|
|
|
|
def _raise(*args, **kwargs):
|
|
raise ValueError("bad extraction")
|
|
|
|
with patch.object(gc_mod.util, "extract_list", side_effect=_raise):
|
|
result = await director.generate_choices()
|
|
assert result is None
|