165 lines
5.9 KiB
Python
165 lines
5.9 KiB
Python
"""Tests for Task 28 — rewind with snapshot, impact preview, and re-projection.
|
|
|
|
Per Requirements §10.1, rewind must:
|
|
|
|
* take a pre-rewind snapshot of all projected tables (so the user can recover),
|
|
* truncate the event log past a chosen event id,
|
|
* clear projected tables and re-project from the truncated log so live state
|
|
matches "what the world looked like at turn N" (no stale rows from rewound
|
|
events).
|
|
|
|
These tests cover the functional core. The HTTP route surface is left to the
|
|
plan's polish pass — tests exercise via direct service calls.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
from chat.db.connection import open_db
|
|
from chat.db.migrate import apply_migrations
|
|
from chat.eventlog.log import append_event
|
|
from chat.eventlog.projector import project
|
|
from chat.services.rewind import compute_rewind_preview, execute_rewind
|
|
from chat.services.snapshot import take_snapshot
|
|
|
|
# Importing the state modules registers their projector handlers as a
|
|
# side effect — the test would otherwise see an unprojected db after
|
|
# re-projection because the registry would be empty.
|
|
import chat.state.entities # noqa: F401
|
|
import chat.state.edges # noqa: F401
|
|
import chat.state.memory # noqa: F401
|
|
import chat.state.world # noqa: F401
|
|
import chat.state.manual_edit # noqa: F401
|
|
|
|
|
|
def _seed_5_turns(db):
|
|
"""Seed: bot + chat + 5 mock user/assistant turn pairs.
|
|
|
|
user_turn / assistant_turn have no projector handlers — they live in
|
|
the event_log purely for transcript rendering — so the only
|
|
projection-bearing events are bot_authored and chat_created. That
|
|
makes the post-rewind invariants easy to assert without needing the
|
|
real classifier pass.
|
|
"""
|
|
apply_migrations(db)
|
|
with open_db(db) as conn:
|
|
append_event(
|
|
conn,
|
|
kind="bot_authored",
|
|
payload={
|
|
"id": "bot_a",
|
|
"name": "BotA",
|
|
"persona": "...",
|
|
"voice_samples": [],
|
|
"traits": [],
|
|
"backstory": "",
|
|
"initial_relationship_to_you": "",
|
|
"kickoff_prose": "",
|
|
},
|
|
)
|
|
append_event(
|
|
conn,
|
|
kind="chat_created",
|
|
payload={
|
|
"id": "chat_bot_a",
|
|
"host_bot_id": "bot_a",
|
|
"initial_time": "2026-04-26T20:00:00+00:00",
|
|
"narrative_anchor": "Day 1",
|
|
"weather": "",
|
|
},
|
|
)
|
|
for i in range(5):
|
|
append_event(
|
|
conn,
|
|
kind="user_turn",
|
|
payload={
|
|
"chat_id": "chat_bot_a",
|
|
"prose": f"turn {i}",
|
|
"segments": [],
|
|
},
|
|
)
|
|
append_event(
|
|
conn,
|
|
kind="assistant_turn",
|
|
payload={
|
|
"chat_id": "chat_bot_a",
|
|
"speaker_id": "bot_a",
|
|
"text": f"reply {i}",
|
|
"truncated": False,
|
|
"user_turn_id": i,
|
|
},
|
|
)
|
|
project(conn)
|
|
|
|
|
|
def test_take_snapshot_writes_file_to_disk(tmp_path):
|
|
db = tmp_path / "t.db"
|
|
_seed_5_turns(db)
|
|
with open_db(db) as conn:
|
|
snapshot_path = take_snapshot(
|
|
conn, data_dir=tmp_path / "data", kind="rewind"
|
|
)
|
|
assert snapshot_path.exists()
|
|
assert snapshot_path.parent == tmp_path / "data" / "snapshots" / "rewind"
|
|
data = json.loads(snapshot_path.read_text())
|
|
assert "event_log" in data
|
|
assert "bots" in data
|
|
assert "chats" in data
|
|
# Bot is in the dump
|
|
assert any(b["id"] == "bot_a" for b in data["bots"])
|
|
|
|
|
|
def test_compute_rewind_preview_counts_kinds(tmp_path):
|
|
db = tmp_path / "t.db"
|
|
_seed_5_turns(db)
|
|
with open_db(db) as conn:
|
|
# The 1st assistant_turn is the 4th event:
|
|
# 1 bot_authored, 2 chat_created, 3 user_turn, 4 assistant_turn.
|
|
# Everything past it should be in the preview (4 user + 4 assistant = 8).
|
|
first_assistant = conn.execute(
|
|
"SELECT id FROM event_log WHERE kind='assistant_turn' "
|
|
"ORDER BY id LIMIT 1"
|
|
).fetchone()[0]
|
|
preview = compute_rewind_preview(conn, after_event_id=first_assistant)
|
|
assert preview["after_event_id"] == first_assistant
|
|
assert preview["total_events"] > 0
|
|
# by_kind sums to total_events.
|
|
assert sum(preview["by_kind"].values()) == preview["total_events"]
|
|
# We rewound past assistant_turn #1, so 4 user + 4 assistant remain.
|
|
assert preview["by_kind"].get("user_turn") == 4
|
|
assert preview["by_kind"].get("assistant_turn") == 4
|
|
|
|
|
|
def test_execute_rewind_truncates_and_reprojects(tmp_path):
|
|
db = tmp_path / "t.db"
|
|
data_dir = tmp_path / "data"
|
|
_seed_5_turns(db)
|
|
with open_db(db) as conn:
|
|
first_assistant = conn.execute(
|
|
"SELECT id FROM event_log WHERE kind='assistant_turn' "
|
|
"ORDER BY id LIMIT 1"
|
|
).fetchone()[0]
|
|
|
|
snapshot_path = execute_rewind(
|
|
db_path=db, data_dir=data_dir, after_event_id=first_assistant
|
|
)
|
|
# Snapshot is written under data/snapshots/rewind/.
|
|
assert snapshot_path.exists()
|
|
assert snapshot_path.parent == data_dir / "snapshots" / "rewind"
|
|
|
|
# Verify event_log truncated and projected state matches state-at-turn.
|
|
with open_db(db) as conn:
|
|
max_id = conn.execute("SELECT MAX(id) FROM event_log").fetchone()[0]
|
|
assert max_id == first_assistant
|
|
# Bot still exists (re-projected from preserved bot_authored event).
|
|
bot = conn.execute(
|
|
"SELECT id FROM bots WHERE id = 'bot_a'"
|
|
).fetchone()
|
|
assert bot is not None
|
|
# Chat still exists (re-projected from preserved chat_created event).
|
|
chat = conn.execute(
|
|
"SELECT id FROM chats WHERE id = 'chat_bot_a'"
|
|
).fetchone()
|
|
assert chat is not None
|