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

686 lines
25 KiB
Python

"""Unit tests for talemate.scene_message message dataclasses and helpers."""
import pytest
from talemate.scene_message import (
CharacterMessage,
ContextInvestigationMessage,
DIRECTOR_INPUT_PREFIX,
DIRECTOR_INPUT_PREFIX_YIELD,
DirectorMessage,
Flags,
MESSAGES,
NarratorMessage,
ReinforcementMessage,
SceneMessage,
TimePassageMessage,
get_message_id,
reset_message_id,
)
# ---------------------------------------------------------------------------
# Fixtures / helpers
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _reset_message_id():
"""Each test starts with a clean message id counter."""
reset_message_id()
yield
reset_message_id()
# ---------------------------------------------------------------------------
# Module-level constants & helpers
# ---------------------------------------------------------------------------
class TestDirectorInputPrefixes:
def test_yield_prefix_is_two_hashes(self):
# The yield variant must be checked first since it is a superset of the
# single-character prefix.
assert DIRECTOR_INPUT_PREFIX_YIELD == "##"
assert DIRECTOR_INPUT_PREFIX == "#"
assert DIRECTOR_INPUT_PREFIX_YIELD.startswith(DIRECTOR_INPUT_PREFIX)
class TestMessageIdHelpers:
def test_get_message_id_increments(self):
first = get_message_id()
second = get_message_id()
third = get_message_id()
assert (first, second, third) == (1, 2, 3)
def test_reset_message_id_returns_counter_to_zero(self):
get_message_id()
get_message_id()
reset_message_id()
assert get_message_id() == 1
def test_default_factory_uses_global_counter(self):
# SceneMessage default factory pulls the next id from the helper
m1 = SceneMessage(message="hello")
m2 = SceneMessage(message="world")
assert m2.id == m1.id + 1
class TestMessagesRegistry:
def test_known_types_route_to_correct_class(self):
assert MESSAGES["scene"] is SceneMessage
assert MESSAGES["character"] is CharacterMessage
assert MESSAGES["narrator"] is NarratorMessage
assert MESSAGES["director"] is DirectorMessage
assert MESSAGES["time"] is TimePassageMessage
assert MESSAGES["reinforcement"] is ReinforcementMessage
assert MESSAGES["context_investigation"] is ContextInvestigationMessage
def test_typ_attr_matches_registry_key(self):
for key, cls in MESSAGES.items():
assert cls.typ == key, f"{cls.__name__}.typ != registry key {key!r}"
# ---------------------------------------------------------------------------
# Flags
# ---------------------------------------------------------------------------
class TestFlags:
def test_none_flag_is_zero(self):
assert int(Flags.NONE) == 0
def test_hidden_flag_set_via_hide(self):
msg = SceneMessage(message="hi")
assert msg.hidden == Flags.NONE
msg.hide()
assert msg.flags & Flags.HIDDEN
assert bool(msg.hidden)
def test_unhide_clears_hidden_flag(self):
msg = SceneMessage(message="hi", flags=Flags.HIDDEN)
assert msg.hidden
msg.unhide()
assert not msg.hidden
assert msg.flags == Flags.NONE
def test_hide_is_idempotent(self):
msg = SceneMessage(message="hi")
msg.hide()
msg.hide()
# Setting the same bit twice keeps the value identical.
assert msg.flags == Flags.HIDDEN
# ---------------------------------------------------------------------------
# SceneMessage base class
# ---------------------------------------------------------------------------
class TestSceneMessageBase:
def test_str_returns_message(self):
m = SceneMessage(message="hello world")
assert str(m) == "hello world"
def test_int_returns_id(self):
m = SceneMessage(message="x")
assert int(m) == m.id
def test_len_returns_message_length(self):
m = SceneMessage(message="hello")
assert len(m) == 5
def test_iter_yields_characters(self):
m = SceneMessage(message="ab")
assert list(iter(m)) == ["a", "b"]
def test_split_delegates_to_message(self):
m = SceneMessage(message="a:b:c")
assert m.split(":") == ["a", "b", "c"]
def test_startswith_endswith_delegate_to_message(self):
m = SceneMessage(message="hello world")
assert m.startswith("hello")
assert m.endswith("world")
assert not m.startswith("world")
def test_contains_message_in_other(self):
m = SceneMessage(message="cat")
# __contains__ is overridden to test self.message in other (reversed
# semantics from the typical container usage).
assert "the cat sat".__contains__ # sanity
assert "the cat sat".__contains__ is not None
# The dataclass override returns True when the message is contained
# within `other`.
assert m.__contains__("the cat sat") is True
assert m.__contains__("dog only") is False
def test_dict_returns_serialisable_payload(self):
m = SceneMessage(message="hi", source="ai", flags=Flags.HIDDEN)
d = m.__dict__()
assert d["message"] == "hi"
assert d["typ"] == "scene"
assert d["source"] == "ai"
assert d["flags"] == int(Flags.HIDDEN)
assert d["rev"] == 0
assert "id" in d
assert "meta" not in d # omitted when None
def test_dict_includes_meta_when_set(self):
m = SceneMessage(message="hi", meta={"agent": "narrator"})
d = m.__dict__()
assert d["meta"] == {"agent": "narrator"}
def test_raw_returns_message_as_string(self):
m = SceneMessage(message="hello")
assert m.raw == "hello"
def test_secondary_source_falls_back_to_source(self):
m = SceneMessage(message="hi", source="ai")
assert m.secondary_source == "ai"
def test_set_source_populates_meta(self):
m = SceneMessage(message="hi")
m.set_source("narrator", "narrate_scene", topic="weather")
assert m.meta == {
"agent": "narrator",
"function": "narrate_scene",
"arguments": {"topic": "weather"},
}
assert m.source_agent == "narrator"
assert m.source_function == "narrate_scene"
assert m.source_arguments == {"topic": "weather"}
def test_set_meta_merges_into_existing_meta(self):
m = SceneMessage(message="hi")
m.set_meta(a=1)
m.set_meta(b=2)
assert m.meta == {"a": 1, "b": 2}
def test_source_helpers_default_when_meta_missing(self):
m = SceneMessage(message="hi")
assert m.source_agent is None
assert m.source_function is None
assert m.source_arguments == {}
def test_meta_hash_changes_with_meta(self):
m = SceneMessage(message="hi")
h1 = m.meta_hash
m.set_meta(x=1)
h2 = m.meta_hash
assert h1 != h2
def test_fingerprint_is_stable_for_same_message(self):
a = SceneMessage(message="same")
b = SceneMessage(message="same")
assert a.fingerprint == b.fingerprint
assert len(a.fingerprint) <= 16
def test_fingerprint_differs_for_different_messages(self):
a = SceneMessage(message="alpha")
b = SceneMessage(message="beta")
assert a.fingerprint != b.fingerprint
def test_as_format_movie_script_appends_newline(self):
m = SceneMessage(message="hello\n\n")
# rstrips trailing newlines then adds exactly one.
assert m.as_format("movie_script") == "hello\n"
assert m.as_format("ai_aware") == "hello\n"
def test_as_format_narrative_strips_whitespace(self):
m = SceneMessage(message=" hello \n")
assert m.as_format("narrative") == "hello"
def test_as_format_unknown_returns_raw_message(self):
m = SceneMessage(message="raw message")
assert m.as_format("nonsense") == "raw message"
# ---------------------------------------------------------------------------
# CharacterMessage
# ---------------------------------------------------------------------------
class TestCharacterMessage:
def test_character_name_extracts_prefix(self):
m = CharacterMessage(message="Alice: Hello there!")
assert m.character_name == "Alice"
assert m.secondary_source == "Alice"
def test_without_name_returns_dialogue_part(self):
m = CharacterMessage(message="Alice: Hello there!")
assert m.without_name == " Hello there!"
def test_raw_strips_quotes_and_asterisks(self):
m = CharacterMessage(message='Alice: "Hello" *waves*')
# The raw property removes asterisks and quotes from the dialogue.
assert m.raw == "Hello waves"
def test_as_movie_script_uppercases_name(self):
m = CharacterMessage(message="Alice: Hello")
assert m.as_movie_script == "\nALICE\nHello\nEND-OF-LINE\n"
def test_as_movie_script_handles_missing_colon_gracefully(self):
m = CharacterMessage(message="just text without a colon")
# When the message does not contain a colon, character_name returns
# the entire message and the script falls back to using it whole.
result = m.as_movie_script
assert "END-OF-LINE" in result
assert "JUST TEXT WITHOUT A COLON" in result
def test_as_format_movie_script_returns_movie_script(self):
m = CharacterMessage(message="Alice: Hello")
assert m.as_format("movie_script") == m.as_movie_script
assert m.as_format("ai_aware") == m.as_movie_script
def test_as_format_narrative_returns_dialogue_only(self):
m = CharacterMessage(message="Alice: Hello there")
assert m.as_format("narrative") == "Hello there"
def test_as_format_default_returns_full_message(self):
m = CharacterMessage(message="Alice: Hello")
assert m.as_format("chat") == "Alice: Hello"
def test_dict_includes_optional_fields_when_set(self):
m = CharacterMessage(
message="Alice: hi",
from_choice="choice-1",
asset_id="asset-123",
asset_type="avatar",
)
d = m.__dict__()
assert d["from_choice"] == "choice-1"
assert d["asset_id"] == "asset-123"
assert d["asset_type"] == "avatar"
def test_dict_omits_unset_optional_fields(self):
m = CharacterMessage(message="Alice: hi")
d = m.__dict__()
assert "from_choice" not in d
assert "asset_id" not in d
assert "asset_type" not in d
def test_default_source_is_ai(self):
m = CharacterMessage(message="Alice: hi")
assert m.source == "ai"
assert m.typ == "character"
# ---------------------------------------------------------------------------
# NarratorMessage
# ---------------------------------------------------------------------------
class TestNarratorMessage:
def test_default_typ_is_narrator(self):
m = NarratorMessage(message="The sun rises.")
assert m.typ == "narrator"
assert m.source == "ai"
def test_source_to_meta_paraphrase(self):
m = NarratorMessage(message="x", source="paraphrase:original text")
meta = m.source_to_meta()
assert meta == {
"agent": "narrator",
"function": "paraphrase",
"arguments": {"narration": "original text"},
}
def test_source_to_meta_narrate_character_entry(self):
m = NarratorMessage(message="x", source="narrate_character_entry:Bob")
meta = m.source_to_meta()
assert meta["function"] == "narrate_character_entry"
assert meta["arguments"] == {"character": "Bob"}
def test_source_to_meta_narrate_query(self):
m = NarratorMessage(message="x", source="narrate_query:What time is it?")
meta = m.source_to_meta()
assert meta["function"] == "narrate_query"
assert meta["arguments"] == {"query": "What time is it?"}
def test_source_to_meta_narrate_time_passage(self):
m = NarratorMessage(
message="x", source="narrate_time_passage:1h:1 hour later:they slept"
)
meta = m.source_to_meta()
assert meta["function"] == "narrate_time_passage"
assert meta["arguments"] == {
"duration": "1h",
"time_passed": "1 hour later",
"narrative": "they slept",
}
def test_source_to_meta_progress_story(self):
m = NarratorMessage(message="x", source="progress_story:advance plot")
meta = m.source_to_meta()
assert meta["function"] == "progress_story"
assert meta["arguments"] == {"narrative_direction": "advance plot"}
def test_source_to_meta_unknown_action_returns_empty_arguments(self):
m = NarratorMessage(message="x", source="unknown_action:foo")
meta = m.source_to_meta()
assert meta["function"] == "unknown_action"
assert meta["arguments"] == {}
def test_migrate_source_to_meta_populates_meta(self):
m = NarratorMessage(
message="The sun rises.", source="paraphrase:earlier paraphrase"
)
assert m.meta is None
m.migrate_source_to_meta()
assert m.meta is not None
assert m.meta["agent"] == "narrator"
assert m.meta["function"] == "paraphrase"
def test_migrate_source_to_meta_skips_when_meta_already_set(self):
existing = {"agent": "narrator", "function": "preset", "arguments": {}}
m = NarratorMessage(
message="x", source="paraphrase:would clobber", meta=dict(existing)
)
m.migrate_source_to_meta()
assert m.meta == existing
def test_migrate_source_to_meta_handles_malformed_source(self):
# narrate_time_passage requires three colon-separated parts; missing
# parts should be caught by the migration helper and the meta should
# remain unset rather than raising.
m = NarratorMessage(message="x", source="narrate_time_passage:only one part")
m.migrate_source_to_meta()
assert m.meta is None
def test_dict_includes_asset_fields(self):
m = NarratorMessage(
message="text", asset_id="img-1", asset_type="scene_illustration"
)
d = m.__dict__()
assert d["asset_id"] == "img-1"
assert d["asset_type"] == "scene_illustration"
# ---------------------------------------------------------------------------
# DirectorMessage
# ---------------------------------------------------------------------------
class TestDirectorMessage:
def test_character_name_pulled_from_meta(self):
m = DirectorMessage(
message="be brave",
meta={"agent": "director", "function": "x", "character": "Alice"},
)
assert m.character_name == "Alice"
def test_character_name_none_when_meta_missing(self):
m = DirectorMessage(message="be brave")
assert m.character_name is None
def test_instructions_returns_message(self):
m = DirectorMessage(message="instructions go here")
assert m.instructions == "instructions go here"
def test_as_inner_monologue_replaces_pronouns(self):
m = DirectorMessage(
message="You should ready your sword and trust yourself",
meta={
"agent": "director",
"function": "actor_instruction",
"character": "Alice",
},
)
result = m.as_inner_monologue
assert "Alice thinks: I should" in result
# word boundary replacement
assert " i should " in result
assert "myself" in result
assert "my sword" in result
assert "yourself" not in result
assert "your" not in result
def test_as_inner_monologue_without_character_returns_lowercase_instructions(self):
m = DirectorMessage(message="You Stand Tall")
# No character name -> just lowercases without prefix
assert m.as_inner_monologue == "you stand tall"
def test_as_story_progression_includes_character_and_instructions(self):
m = DirectorMessage(
message="charge ahead",
meta={
"agent": "director",
"function": "actor_instruction",
"character": "Bob",
},
)
assert m.as_story_progression == "Bob's next action: charge ahead"
def test_str_uses_chat_format(self):
m = DirectorMessage(
message="charge",
meta={
"agent": "director",
"function": "actor_instruction",
"character": "Bob",
},
)
# Default format is chat -> "# {as_story_progression}"
assert str(m) == "# Bob's next action: charge"
def test_as_format_internal_monologue_chat(self):
m = DirectorMessage(
message="trust yourself",
meta={
"agent": "director",
"function": "actor_instruction",
"character": "Bob",
},
)
result = m.as_format("chat", mode="internal_monologue")
assert result.startswith("# ")
assert "Bob thinks" in result
def test_as_format_movie_script_default_mode(self):
m = DirectorMessage(
message="advance",
meta={
"agent": "director",
"function": "actor_instruction",
"character": "Bob",
},
)
result = m.as_format("movie_script")
assert result == "\n(Bob's next action: advance)\n"
def test_as_format_returns_empty_for_blank_message(self):
m = DirectorMessage(message=" ")
assert m.as_format("chat") == ""
assert m.as_format("movie_script") == ""
def test_migrate_legacy_director_instructs_message(self):
m = DirectorMessage(message="Director instructs Alice: be brave")
m.migrate_message_to_meta()
assert m.message == "be brave"
assert m.source == "player"
assert m.meta["agent"] == "director"
assert m.meta["function"] == "actor_instruction"
# The legacy migration stores the character under arguments rather
# than at the top of meta, so character_name (which reads from
# meta["character"]) does not pick it up. Verify both behaviours:
assert m.meta["arguments"]["character"] == "Alice"
assert m.character_name is None
def test_character_name_reads_top_level_meta_key(self):
# character_name pulls directly from meta["character"], not from the
# nested arguments dict that set_source populates.
m = DirectorMessage(
message="x", meta={"agent": "director", "function": "y", "character": "Eve"}
)
assert m.character_name == "Eve"
def test_migrate_backfills_subtype_for_user_direction(self):
m = DirectorMessage(message="proceed", action="user_direction")
assert m.subtype is None
m.migrate_message_to_meta()
assert m.subtype == "user_direction"
def test_dict_includes_action_and_subtype_when_set(self):
m = DirectorMessage(
message="x", action="user_direction", subtype="user_direction"
)
d = m.__dict__()
assert d["action"] == "user_direction"
assert d["subtype"] == "user_direction"
# ---------------------------------------------------------------------------
# TimePassageMessage
# ---------------------------------------------------------------------------
class TestTimePassageMessage:
def test_default_attrs(self):
m = TimePassageMessage(message="A day later")
assert m.typ == "time"
assert m.source == "manual"
assert m.ts == "PT0S"
def test_dict_includes_ts(self):
m = TimePassageMessage(message="A day later", ts="P1D")
d = m.__dict__()
assert d["ts"] == "P1D"
assert d["typ"] == "time"
# ---------------------------------------------------------------------------
# ReinforcementMessage
# ---------------------------------------------------------------------------
class TestReinforcementMessage:
def test_character_name_and_question_from_meta(self):
m = ReinforcementMessage(
message="They are loyal",
meta={
"agent": "world_state",
"function": "update_reinforcement",
"arguments": {"character": "Alice", "question": "are they loyal?"},
},
)
assert m.character_name == "Alice"
assert m.question == "are they loyal?"
def test_character_name_default_when_meta_missing(self):
m = ReinforcementMessage(message="x")
assert m.character_name == "character"
assert m.question == "question"
def test_str_formats_internal_note(self):
m = ReinforcementMessage(
message="They are loyal",
meta={
"agent": "world_state",
"function": "update_reinforcement",
"arguments": {"character": "Alice", "question": "are they loyal?"},
},
)
s = str(m)
assert s.startswith("# Internal note for Alice - are they loyal?\n")
assert s.endswith("They are loyal")
def test_as_format_narrative_wraps_in_parens(self):
m = ReinforcementMessage(
message="They are loyal",
meta={
"agent": "world_state",
"function": "update_reinforcement",
"arguments": {"character": "Alice", "question": "are they loyal?"},
},
)
# narrative format strips leading "# " and wraps in newlines/parens
result = m.as_format("narrative")
assert result.startswith("\n(")
assert result.endswith(")\n")
assert "Internal note for Alice" in result
def test_as_format_default_just_pads_message(self):
m = ReinforcementMessage(message="hello")
assert m.as_format("chat") == "\nhello\n"
def test_source_to_meta_populates_meta(self):
m = ReinforcementMessage(
message="They are loyal", source="are they loyal?:Alice"
)
m.source_to_meta()
assert m.meta == {
"agent": "world_state",
"function": "update_reinforcement",
"arguments": {"character": "Alice", "question": "are they loyal?"},
}
# ---------------------------------------------------------------------------
# ContextInvestigationMessage
# ---------------------------------------------------------------------------
class TestContextInvestigationMessage:
def test_title_for_visual_character_subtype(self):
m = ContextInvestigationMessage(
message="Alice wears a red cloak",
sub_type="visual-character",
meta={
"agent": "x",
"function": "y",
"arguments": {"character": "Alice"},
},
)
assert m.title == "Visual description of Alice in the current moment"
def test_title_for_visual_scene_subtype(self):
m = ContextInvestigationMessage(message="A field", sub_type="visual-scene")
assert m.title == "Visual description of the current moment"
def test_title_for_query_subtype(self):
m = ContextInvestigationMessage(
message="response",
sub_type="query",
meta={
"agent": "x",
"function": "y",
"arguments": {"query": "Why is the sky blue?"},
},
)
assert m.title == "Query: Why is the sky blue?"
def test_title_default_when_unknown_subtype(self):
m = ContextInvestigationMessage(message="x", sub_type="other")
assert m.title == "Internal note"
def test_str_combines_title_and_message(self):
m = ContextInvestigationMessage(message="content", sub_type="visual-scene")
assert str(m) == "# Visual description of the current moment: content"
def test_as_format_narrative_strips_asterisks(self):
m = ContextInvestigationMessage(message="con*tent*", sub_type="visual-scene")
result = m.as_format("narrative")
assert "*" not in result
assert "Visual description of the current moment" in result
def test_as_format_default_pads_with_newlines(self):
m = ContextInvestigationMessage(message="content")
assert m.as_format("chat") == "\ncontent\n"
def test_dict_includes_sub_type_always(self):
m = ContextInvestigationMessage(message="x", sub_type="visual-scene")
d = m.__dict__()
assert d["sub_type"] == "visual-scene"
def test_dict_includes_assets_when_set(self):
m = ContextInvestigationMessage(
message="x",
sub_type="visual-scene",
asset_id="img-1",
asset_type="scene_illustration",
)
d = m.__dict__()
assert d["asset_id"] == "img-1"
assert d["asset_type"] == "scene_illustration"