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

711 lines
25 KiB
Python

"""
Tests for time passage operations:
- insert_time_passage / delete_time_passage: archive-index-based (WSM view)
- insert_time_passage_after_message / delete_time_passage_by_id / update_time_passage_by_id:
message-id-based (live scene view)
"""
import pytest
from talemate.history import (
insert_time_passage,
delete_time_passage,
insert_time_passage_after_message,
delete_time_passage_by_id,
update_time_passage_by_id,
)
from talemate.scene_message import CharacterMessage, TimePassageMessage
from talemate.tale_mate import Scene
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _msg(text: str) -> CharacterMessage:
return CharacterMessage(message=text, source="ai")
def _time(ts: str) -> TimePassageMessage:
return TimePassageMessage(ts=ts, message=f"{ts} later")
def make_scene(
history: list,
archived_history: list,
layered_history: list | None = None,
ts: str = "PT0S",
):
"""Build a real `Scene` populated with the requested timeline state.
Uses the production `Scene` class so changes to its `fix_time` /
`message_index` semantics are caught here.
"""
scene = Scene()
scene.ts = ts
scene.history = history
scene.archived_history = archived_history
scene.layered_history = layered_history or []
return scene
# ---------------------------------------------------------------------------
# Tests: basic insertion
# ---------------------------------------------------------------------------
class TestInsertTimePassageBasic:
"""Verify basic insertion, index shifting, and timestamp recalculation."""
def test_insert_before_middle_entry(self):
"""Insert a time passage before the second summarized entry."""
scene = make_scene(
history=[
_msg("A"), # 0
_msg("B"), # 1
_msg("C"), # 2
_msg("D"), # 3
],
archived_history=[
{"text": "Sum 0-1", "start": 0, "end": 1, "ts": "PT0S", "id": "a1"},
{"text": "Sum 2-3", "start": 2, "end": 3, "ts": "PT0S", "id": "a2"},
],
)
tp = insert_time_passage(scene, archive_index=1, amount=2, unit="hours")
# TimePassageMessage inserted at original start=2
assert isinstance(scene.history[2], TimePassageMessage)
assert tp.ts == "PT2H"
# First entry: start=0 < insertion_index=2, end=1 < 2 => unchanged
assert scene.archived_history[0]["start"] == 0
assert scene.archived_history[0]["end"] == 1
# Second entry: start=2 >= 2 => 3, end=3 >= 2 => 4
assert scene.archived_history[1]["start"] == 3
assert scene.archived_history[1]["end"] == 4
# fix_time should set timestamp on second entry
assert scene.archived_history[1]["ts"] == "PT2H"
def test_insert_before_first_entry(self):
"""Insert before the very first summarized entry (start=0)."""
scene = make_scene(
history=[
_msg("A"), # 0
_msg("B"), # 1
_msg("C"), # 2
_msg("D"), # 3
],
archived_history=[
{"text": "Sum 0-1", "start": 0, "end": 1, "ts": "PT0S", "id": "a1"},
{"text": "Sum 2-3", "start": 2, "end": 3, "ts": "PT0S", "id": "a2"},
],
)
insert_time_passage(scene, archive_index=0, amount=30, unit="minutes")
# Inserted at index 0
assert isinstance(scene.history[0], TimePassageMessage)
assert len(scene.history) == 5
# Both entries bumped
assert scene.archived_history[0]["start"] == 1
assert scene.archived_history[0]["end"] == 2
assert scene.archived_history[1]["start"] == 3
assert scene.archived_history[1]["end"] == 4
# Time passage at index 0, both entries end > 0 => both get PT30M
assert scene.archived_history[0]["ts"] == "PT30M"
assert scene.archived_history[1]["ts"] == "PT30M"
def test_insert_before_last_entry(self):
"""Insert before the last entry only shifts that entry's indices."""
scene = make_scene(
history=[
_msg("A"), # 0
_msg("B"), # 1
_msg("C"), # 2
_msg("D"), # 3
],
archived_history=[
{"text": "Sum 0-1", "start": 0, "end": 1, "ts": "PT0S", "id": "a1"},
{"text": "Sum 2-3", "start": 2, "end": 3, "ts": "PT0S", "id": "a2"},
],
)
insert_time_passage(scene, archive_index=1, amount=1, unit="days")
# First entry unchanged
assert scene.archived_history[0]["start"] == 0
assert scene.archived_history[0]["end"] == 1
# Second entry shifted
assert scene.archived_history[1]["start"] == 3
assert scene.archived_history[1]["end"] == 4
def test_history_length_increases(self):
"""Scene history should have one more element after insertion."""
scene = make_scene(
history=[_msg("A"), _msg("B")],
archived_history=[
{"text": "Sum", "start": 0, "end": 1, "ts": "PT0S", "id": "a1"},
],
)
insert_time_passage(scene, archive_index=0, amount=1, unit="hours")
assert len(scene.history) == 3
# ---------------------------------------------------------------------------
# Tests: validation
# ---------------------------------------------------------------------------
class TestInsertTimePassageValidation:
"""Verify error handling for invalid inputs."""
def test_static_entry_raises(self):
"""Cannot insert time passage before a static (manual) entry."""
scene = make_scene(
history=[_msg("A")],
archived_history=[
{"text": "Static", "ts": "PT0S", "id": "s1"},
],
)
with pytest.raises(ValueError, match="not a summarized entry"):
insert_time_passage(scene, archive_index=0, amount=1, unit="hours")
def test_negative_index_raises(self):
"""Negative archive_index should raise IndexError."""
scene = make_scene(
history=[_msg("A")],
archived_history=[
{"text": "Sum", "start": 0, "end": 0, "ts": "PT0S", "id": "a1"},
],
)
with pytest.raises(IndexError):
insert_time_passage(scene, archive_index=-1, amount=1, unit="hours")
def test_out_of_bounds_index_raises(self):
"""archive_index beyond list length should raise IndexError."""
scene = make_scene(
history=[_msg("A")],
archived_history=[
{"text": "Sum", "start": 0, "end": 0, "ts": "PT0S", "id": "a1"},
],
)
with pytest.raises(IndexError):
insert_time_passage(scene, archive_index=5, amount=1, unit="hours")
# ---------------------------------------------------------------------------
# Tests: interaction with existing time passages
# ---------------------------------------------------------------------------
class TestInsertTimePassageWithExisting:
"""Verify correct behaviour when time passages already exist."""
def test_insert_alongside_existing_time_passage(self):
"""
When scene.history already has TimePassageMessages, inserting a new one
should produce correct cumulative timestamps.
"""
scene = make_scene(
history=[
_msg("A"), # 0
_msg("B"), # 1
_time("PT1H"), # 2 — existing 1h passage
_msg("C"), # 3
_msg("D"), # 4
],
archived_history=[
{"text": "Sum 0-1", "start": 0, "end": 1, "ts": "PT0S", "id": "a1"},
{"text": "Sum 3-4", "start": 3, "end": 4, "ts": "PT1H", "id": "a2"},
],
)
# Insert 2h before the second entry (start=3)
insert_time_passage(scene, archive_index=1, amount=2, unit="hours")
# scene.history should now be:
# [msg, msg, time(1h), time(2h), msg, msg]
assert isinstance(scene.history[2], TimePassageMessage)
assert scene.history[2].ts == "PT1H" # original
assert isinstance(scene.history[3], TimePassageMessage)
assert scene.history[3].ts == "PT2H" # newly inserted
# Indices: existing time passage at idx 2 is < insertion_index=3, unaffected
# Second entry: start 3->4, end 4->5
assert scene.archived_history[1]["start"] == 4
assert scene.archived_history[1]["end"] == 5
# Cumulative: PT1H + PT2H = PT3H
assert scene.archived_history[1]["ts"] == "PT3H"
def test_insert_before_entry_that_precedes_existing_passage(self):
"""
Insert before the first entry when a time passage exists after it.
The new passage should come first in the cumulative chain.
"""
scene = make_scene(
history=[
_msg("A"), # 0
_msg("B"), # 1
_time("PT3H"), # 2
_msg("C"), # 3
_msg("D"), # 4
],
archived_history=[
{"text": "Sum 0-1", "start": 0, "end": 1, "ts": "PT0S", "id": "a1"},
{"text": "Sum 3-4", "start": 3, "end": 4, "ts": "PT3H", "id": "a2"},
],
)
# Insert 30min before first entry
insert_time_passage(scene, archive_index=0, amount=30, unit="minutes")
# New passage at index 0, everything else shifted
assert isinstance(scene.history[0], TimePassageMessage)
assert scene.history[0].ts == "PT30M"
# First entry: start 0->1, end 1->2
assert scene.archived_history[0]["start"] == 1
assert scene.archived_history[0]["end"] == 2
# Second entry: start 3->4, end 4->5
assert scene.archived_history[1]["start"] == 4
assert scene.archived_history[1]["end"] == 5
# Cumulative: PT30M (idx 0) + PT3H (idx 3) = PT3H30M
assert scene.archived_history[1]["ts"] == "PT3H30M"
# ---------------------------------------------------------------------------
# Tests: layered history is not corrupted
# ---------------------------------------------------------------------------
class TestInsertTimePassageLayeredHistory:
"""Verify layered_history indices are untouched but timestamps update."""
def test_layered_history_indices_unchanged(self):
"""
Layered history references archived_history indices, not scene.history.
Inserting into scene.history should not change layered start/end.
"""
scene = make_scene(
history=[
_msg("A"), # 0
_msg("B"), # 1
_msg("C"), # 2
_msg("D"), # 3
],
archived_history=[
{"text": "Sum 0-1", "start": 0, "end": 1, "ts": "PT0S", "id": "a1"},
{"text": "Sum 2-3", "start": 2, "end": 3, "ts": "PT0S", "id": "a2"},
],
layered_history=[
[
{
"text": "L0 covers both",
"start": 0,
"end": 1,
"ts": "STALE",
"ts_start": "STALE",
"ts_end": "STALE",
"id": "l0a",
},
],
],
)
insert_time_passage(scene, archive_index=1, amount=2, unit="hours")
# Layered history indices must remain the same
assert scene.layered_history[0][0]["start"] == 0
assert scene.layered_history[0][0]["end"] == 1
# But timestamps should be updated via fix_time
assert scene.layered_history[0][0]["ts"] == "PT0S"
assert scene.layered_history[0][0]["ts_start"] == "PT0S"
assert scene.layered_history[0][0]["ts_end"] == "PT2H"
# ---------------------------------------------------------------------------
# Tests: delete_time_passage
# ---------------------------------------------------------------------------
class TestDeleteTimePassageBasic:
"""Verify deletion, index shifting, and timestamp recalculation."""
def test_delete_middle_time_passage(self):
"""Delete a time passage between two summarized entries."""
scene = make_scene(
history=[
_msg("A"), # 0
_msg("B"), # 1
_time("PT2H"), # 2
_msg("C"), # 3
_msg("D"), # 4
],
archived_history=[
{"text": "Sum 0-1", "start": 0, "end": 1, "ts": "PT0S", "id": "a1"},
{"text": "Sum 3-4", "start": 3, "end": 4, "ts": "PT2H", "id": "a2"},
],
)
delete_time_passage(scene, history_index=2)
# Time passage removed
assert len(scene.history) == 4
assert not isinstance(scene.history[2], TimePassageMessage)
# First entry: start=0, end=1 — unchanged (both < 2)
assert scene.archived_history[0]["start"] == 0
assert scene.archived_history[0]["end"] == 1
# Second entry: start=3->2, end=4->3 (both > 2)
assert scene.archived_history[1]["start"] == 2
assert scene.archived_history[1]["end"] == 3
# No time passages left — fix_time returns early without updating
# individual entry timestamps, but scene.ts is set to starting_time
assert scene.ts == "PT0S"
def test_delete_first_time_passage(self):
"""Delete a time passage at index 0."""
scene = make_scene(
history=[
_time("PT1H"), # 0
_msg("A"), # 1
_msg("B"), # 2
],
archived_history=[
{"text": "Sum 1-2", "start": 1, "end": 2, "ts": "PT1H", "id": "a1"},
],
)
delete_time_passage(scene, history_index=0)
assert len(scene.history) == 2
assert scene.archived_history[0]["start"] == 0
assert scene.archived_history[0]["end"] == 1
def test_delete_preserves_other_time_passages(self):
"""Delete one of multiple time passages; remaining should still work."""
scene = make_scene(
history=[
_msg("A"), # 0
_time("PT1H"), # 1
_msg("B"), # 2
_time("PT2H"), # 3
_msg("C"), # 4
],
archived_history=[
{"text": "Sum 0", "start": 0, "end": 0, "ts": "PT0S", "id": "a1"},
{"text": "Sum 2", "start": 2, "end": 2, "ts": "PT1H", "id": "a2"},
{"text": "Sum 4", "start": 4, "end": 4, "ts": "PT3H", "id": "a3"},
],
)
# Delete the first time passage (index 1)
delete_time_passage(scene, history_index=1)
# History: [msg, msg, time(2h), msg]
assert len(scene.history) == 4
assert isinstance(scene.history[2], TimePassageMessage)
assert scene.history[2].ts == "PT2H"
# Indices shifted: entries after idx 1 decremented
assert scene.archived_history[0]["start"] == 0
assert scene.archived_history[0]["end"] == 0
assert scene.archived_history[1]["start"] == 1
assert scene.archived_history[1]["end"] == 1
assert scene.archived_history[2]["start"] == 3
assert scene.archived_history[2]["end"] == 3
# Only PT2H remains, affects third entry
assert scene.archived_history[2]["ts"] == "PT2H"
class TestDeleteTimePassageValidation:
"""Verify error handling for invalid deletion inputs."""
def test_not_a_time_passage_raises(self):
"""Cannot delete a non-TimePassageMessage."""
scene = make_scene(
history=[_msg("A"), _msg("B")],
archived_history=[
{"text": "Sum", "start": 0, "end": 1, "ts": "PT0S", "id": "a1"},
],
)
with pytest.raises(ValueError, match="not a TimePassageMessage"):
delete_time_passage(scene, history_index=0)
def test_out_of_bounds_raises(self):
"""Out-of-bounds history_index should raise IndexError."""
scene = make_scene(
history=[_msg("A")],
archived_history=[],
)
with pytest.raises(IndexError):
delete_time_passage(scene, history_index=5)
def test_negative_index_raises(self):
"""Negative history_index should raise IndexError."""
scene = make_scene(
history=[_time("PT1H"), _msg("A")],
archived_history=[],
)
with pytest.raises(IndexError):
delete_time_passage(scene, history_index=-1)
class TestDeleteTimePassageRoundTrip:
"""Verify insert followed by delete restores original state."""
def test_insert_then_delete_restores_indices(self):
"""Inserting and then deleting should restore original indices."""
scene = make_scene(
history=[
_msg("A"), # 0
_msg("B"), # 1
_msg("C"), # 2
_msg("D"), # 3
],
archived_history=[
{"text": "Sum 0-1", "start": 0, "end": 1, "ts": "PT0S", "id": "a1"},
{"text": "Sum 2-3", "start": 2, "end": 3, "ts": "PT0S", "id": "a2"},
],
)
# Insert a 2h passage before second entry
insert_time_passage(scene, archive_index=1, amount=2, unit="hours")
# Now the time passage is at index 2
assert isinstance(scene.history[2], TimePassageMessage)
assert scene.archived_history[1]["start"] == 3
assert scene.archived_history[1]["end"] == 4
# Delete it
delete_time_passage(scene, history_index=2)
# Indices should be back to original
assert len(scene.history) == 4
assert scene.archived_history[0]["start"] == 0
assert scene.archived_history[0]["end"] == 1
assert scene.archived_history[1]["start"] == 2
assert scene.archived_history[1]["end"] == 3
# ---------------------------------------------------------------------------
# Tests: insert_time_passage_after_message (message-id-based)
# ---------------------------------------------------------------------------
class TestInsertTimePassageAfterMessage:
"""Verify inserting a time passage after a message identified by id."""
def test_insert_after_first_message(self):
"""Insert time passage after the first message."""
msgs = [_msg("A"), _msg("B"), _msg("C")]
scene = make_scene(
history=msgs,
archived_history=[
{"text": "Sum 0-1", "start": 0, "end": 1, "ts": "PT0S", "id": "a1"},
{"text": "Sum 2", "start": 2, "end": 2, "ts": "PT0S", "id": "a2"},
],
)
tp = insert_time_passage_after_message(
scene, msgs[0].id, amount=1, unit="hours"
)
# Inserted at index 1 (after msg A at 0)
assert scene.history[1] is tp
assert isinstance(tp, TimePassageMessage)
assert tp.ts == "PT1H"
assert len(scene.history) == 4
# Indices shifted: entries with start/end >= 1 get bumped
assert scene.archived_history[0]["start"] == 0
assert scene.archived_history[0]["end"] == 2 # was 1, >= 1, bumped
assert scene.archived_history[1]["start"] == 3 # was 2, bumped
assert scene.archived_history[1]["end"] == 3 # was 2, bumped
def test_insert_after_last_message(self):
"""Insert time passage after the last message in history."""
msgs = [_msg("A"), _msg("B")]
scene = make_scene(
history=msgs,
archived_history=[
{"text": "Sum 0-1", "start": 0, "end": 1, "ts": "PT0S", "id": "a1"},
],
)
tp = insert_time_passage_after_message(scene, msgs[1].id, amount=3, unit="days")
# Inserted at index 2 (after end of list)
assert len(scene.history) == 3
assert scene.history[2] is tp
assert tp.ts == "P3D"
# Archived entries: start=0, end=1, both < 2 => unchanged
assert scene.archived_history[0]["start"] == 0
assert scene.archived_history[0]["end"] == 1
def test_invalid_message_id_raises(self):
"""Non-existent message id raises ValueError."""
scene = make_scene(
history=[_msg("A")],
archived_history=[],
)
with pytest.raises(ValueError, match="not found"):
insert_time_passage_after_message(
scene, message_id=99999, amount=1, unit="hours"
)
def test_timestamps_updated(self):
"""fix_time is called and timestamps reflect the new passage."""
msgs = [_msg("A"), _msg("B"), _msg("C")]
scene = make_scene(
history=msgs,
archived_history=[
{"text": "Sum 0", "start": 0, "end": 0, "ts": "PT0S", "id": "a1"},
{"text": "Sum 1-2", "start": 1, "end": 2, "ts": "PT0S", "id": "a2"},
],
)
insert_time_passage_after_message(scene, msgs[0].id, amount=2, unit="hours")
# Time passage at index 1, second entry now starts at 2
assert scene.archived_history[1]["ts"] == "PT2H"
# ---------------------------------------------------------------------------
# Tests: delete_time_passage_by_id (message-id-based)
# ---------------------------------------------------------------------------
class TestDeleteTimePassageById:
"""Verify deleting a time passage by its message id."""
def test_delete_by_id(self):
"""Delete a time passage using its message id."""
tp = _time("PT2H")
msgs = [_msg("A"), tp, _msg("B")]
scene = make_scene(
history=msgs,
archived_history=[
{"text": "Sum 0", "start": 0, "end": 0, "ts": "PT0S", "id": "a1"},
{"text": "Sum 2", "start": 2, "end": 2, "ts": "PT2H", "id": "a2"},
],
)
delete_time_passage_by_id(scene, tp.id)
assert len(scene.history) == 2
assert not any(isinstance(m, TimePassageMessage) for m in scene.history)
# Second entry shifted: start=2->1, end=2->1
assert scene.archived_history[1]["start"] == 1
assert scene.archived_history[1]["end"] == 1
def test_invalid_message_id_raises(self):
"""Non-existent message id raises ValueError."""
scene = make_scene(
history=[_msg("A")],
archived_history=[],
)
with pytest.raises(ValueError, match="not found"):
delete_time_passage_by_id(scene, message_id=99999)
def test_non_time_passage_raises(self):
"""Trying to delete a non-TimePassageMessage by id raises ValueError."""
msgs = [_msg("A")]
scene = make_scene(
history=msgs,
archived_history=[],
)
with pytest.raises(ValueError, match="not a TimePassageMessage"):
delete_time_passage_by_id(scene, msgs[0].id)
# ---------------------------------------------------------------------------
# Tests: update_time_passage_by_id (message-id-based)
# ---------------------------------------------------------------------------
class TestUpdateTimePassageById:
"""Verify updating a time passage's duration by its message id."""
def test_update_duration(self):
"""Update a time passage from 2h to 30 minutes."""
tp = _time("PT2H")
msgs = [_msg("A"), tp, _msg("B")]
scene = make_scene(
history=msgs,
archived_history=[
{"text": "Sum 0", "start": 0, "end": 0, "ts": "PT0S", "id": "a1"},
{"text": "Sum 2", "start": 2, "end": 2, "ts": "PT2H", "id": "a2"},
],
)
update_time_passage_by_id(scene, tp.id, amount=30, unit="minutes")
assert tp.ts == "PT30M"
assert "30 minutes" in tp.message.lower()
# fix_time recalculates: second entry should now be PT30M
assert scene.archived_history[1]["ts"] == "PT30M"
def test_update_preserves_position(self):
"""Updating does not change the position or history length."""
tp = _time("PT1H")
msgs = [_msg("A"), tp, _msg("B")]
scene = make_scene(
history=msgs,
archived_history=[],
)
update_time_passage_by_id(scene, tp.id, amount=3, unit="days")
assert len(scene.history) == 3
assert scene.history[1] is tp
assert tp.ts == "P3D"
def test_invalid_message_id_raises(self):
"""Non-existent message id raises ValueError."""
scene = make_scene(
history=[_msg("A")],
archived_history=[],
)
with pytest.raises(ValueError, match="not found"):
update_time_passage_by_id(scene, message_id=99999, amount=1, unit="hours")
def test_non_time_passage_raises(self):
"""Trying to update a non-TimePassageMessage raises ValueError."""
msgs = [_msg("A")]
scene = make_scene(
history=msgs,
archived_history=[],
)
with pytest.raises(ValueError, match="not a TimePassageMessage"):
update_time_passage_by_id(scene, msgs[0].id, amount=1, unit="hours")