feat: rewind with impact preview, pre-rewind snapshot, undo toast
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user