feat: drawer branching UI (T98.1)
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
"""T98 (Phase 4): drawer phase-4 bundle.
|
||||
|
||||
Five sub-features extending the chat drawer:
|
||||
|
||||
* T98.1 — branching UI (create / switch / from-turn).
|
||||
* T98.2 — significance-review panel (distribution + significance edits).
|
||||
* T98.3 — hide-from-view toggle (per-turn, via ``manual_edit`` projector
|
||||
branch ``turn_hidden``).
|
||||
* T98.4 — surgical delete with cascade preview (preview modal +
|
||||
rewind execution against a target turn).
|
||||
* T98.5 — remaining v1 edits (chat narrative_anchor + weather).
|
||||
|
||||
Tests follow the T59 pattern in ``tests/test_drawer_events_threads_skip.py``
|
||||
— a TestClient against the real FastAPI app with a per-test temp DB.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from chat.app import app
|
||||
from chat.db.connection import open_db
|
||||
from chat.eventlog.log import append_and_apply, append_event
|
||||
from chat.eventlog.projector import project
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path, monkeypatch):
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text('featherless_api_key = "test"\n')
|
||||
monkeypatch.setenv("CHAT_CONFIG_PATH", str(cfg))
|
||||
db = tmp_path / "test.db"
|
||||
monkeypatch.setenv("CHAT_DB_PATH", str(db))
|
||||
with TestClient(app) as c:
|
||||
if hasattr(app.state, "background_worker"):
|
||||
app.state.background_worker.enabled = False
|
||||
yield c
|
||||
|
||||
|
||||
def _bot_payload(bot_id: str, name: str) -> dict:
|
||||
return {
|
||||
"id": bot_id,
|
||||
"name": name,
|
||||
"persona": "...",
|
||||
"voice_samples": [],
|
||||
"traits": [],
|
||||
"backstory": "",
|
||||
"initial_relationship_to_you": "",
|
||||
"kickoff_prose": "",
|
||||
}
|
||||
|
||||
|
||||
def _seed_chat(db: Path, *, with_scene: bool = True) -> int:
|
||||
"""Seed a chat hosted by ``bot_a``; return the latest event id (chat_created)."""
|
||||
with open_db(db) as conn:
|
||||
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_a", "BotA"))
|
||||
append_event(
|
||||
conn,
|
||||
kind="you_authored",
|
||||
payload={"name": "Me", "pronouns": "they/them", "persona": ""},
|
||||
)
|
||||
chat_event_id = 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": "",
|
||||
},
|
||||
)
|
||||
if with_scene:
|
||||
append_event(
|
||||
conn,
|
||||
kind="scene_opened",
|
||||
payload={
|
||||
"chat_id": "chat_bot_a",
|
||||
"container_id": None,
|
||||
"started_at": "2026-04-26T20:00:00+00:00",
|
||||
"participants": ["you", "bot_a"],
|
||||
},
|
||||
)
|
||||
project(conn)
|
||||
return chat_event_id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T98.1 — branching UI.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_t98_1_create_branch_emits_branch_created_and_renders(client, tmp_path):
|
||||
db = tmp_path / "test.db"
|
||||
seed_id = _seed_chat(db)
|
||||
|
||||
response = client.post(
|
||||
"/chats/chat_bot_a/drawer/branch/create",
|
||||
data={"name": "experiment_a", "origin_event_id": str(seed_id)},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
with open_db(db) as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT COUNT(*) FROM event_log WHERE kind = 'branch_created'"
|
||||
).fetchone()
|
||||
assert rows[0] == 1
|
||||
from chat.state.branches import get_branch
|
||||
|
||||
b = get_branch(conn, "experiment_a")
|
||||
assert b is not None
|
||||
assert b["origin_event_id"] == seed_id
|
||||
assert b["chat_id"] == "chat_bot_a"
|
||||
|
||||
# Drawer partial lists the new branch.
|
||||
body = response.text
|
||||
assert "<h3>Branches</h3>" in body
|
||||
assert "experiment_a" in body
|
||||
|
||||
|
||||
def test_t98_1_switch_branch_marks_active_and_unknown_400s(client, tmp_path):
|
||||
db = tmp_path / "test.db"
|
||||
seed_id = _seed_chat(db)
|
||||
|
||||
# Create branch directly via the service so this test focuses on switch.
|
||||
with open_db(db) as conn:
|
||||
from chat.services.branching import branch_from_event
|
||||
|
||||
branch_from_event(
|
||||
conn, name="experiment_b", origin_event_id=seed_id, chat_id="chat_bot_a"
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/chats/chat_bot_a/drawer/branch/switch",
|
||||
data={"name": "experiment_b"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
with open_db(db) as conn:
|
||||
from chat.state.branches import active_branch
|
||||
|
||||
active = active_branch(conn)
|
||||
assert active is not None
|
||||
assert active["name"] == "experiment_b"
|
||||
|
||||
# Unknown branch -> 400.
|
||||
bad = client.post(
|
||||
"/chats/chat_bot_a/drawer/branch/switch",
|
||||
data={"name": "ghost_branch"},
|
||||
)
|
||||
assert bad.status_code == 400
|
||||
|
||||
|
||||
def test_t98_1_branch_from_turn_emits_branch_created(client, tmp_path):
|
||||
db = tmp_path / "test.db"
|
||||
seed_id = _seed_chat(db)
|
||||
|
||||
# Append an extra turn so we can branch from it specifically.
|
||||
with open_db(db) as conn:
|
||||
turn_id = append_event(
|
||||
conn,
|
||||
kind="user_turn",
|
||||
payload={"chat_id": "chat_bot_a", "prose": "hi", "segments": []},
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
f"/chats/chat_bot_a/drawer/branch/from-turn/{turn_id}",
|
||||
data={"name": "fork_at_turn"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
with open_db(db) as conn:
|
||||
from chat.state.branches import get_branch
|
||||
|
||||
b = get_branch(conn, "fork_at_turn")
|
||||
assert b is not None
|
||||
assert b["origin_event_id"] == turn_id
|
||||
assert b["chat_id"] == "chat_bot_a"
|
||||
|
||||
# Duplicate name -> 400 from service ValueError.
|
||||
dup = client.post(
|
||||
f"/chats/chat_bot_a/drawer/branch/from-turn/{turn_id}",
|
||||
data={"name": "fork_at_turn"},
|
||||
)
|
||||
assert dup.status_code == 400
|
||||
assert seed_id < turn_id # sanity: turn is after chat_created
|
||||
Reference in New Issue
Block a user