Files
chat/tests/test_drawer_edits_extended.py
T
Joseph Doherty 607d0971c4 feat: drawer witness flag inline-edit (T72.3)
Memories grow per-flag witness checkboxes (you / host / guest) that
auto-submit on change via HTMX. The new POST route emits a manual_edit
event with target_kind=memory_witness and a {flag, value} payload;
prior_value mirrors the same shape so an inverse edit restores the
flag. The drawer's recent-memories query now selects the three
witness columns alongside the existing fields so the template can
render checkbox state without a second query per row.
2026-04-26 17:28:25 -04:00

404 lines
13 KiB
Python

"""T72: deferred v1 drawer edits + witness flag inline-edit.
T25 shipped affinity / significance / pin. T72.1 fills in the rest of the
§6.4 editable surface whose ``manual_edit`` projector dispatch was already
in place (or, for ``edge_knowledge_fact``, added alongside the route):
* ``POST /chats/{chat_id}/drawer/edge/trust`` — slider 0..100.
* ``POST /chats/{chat_id}/drawer/edge/summary`` — textarea, capped 2000.
* ``POST /chats/{chat_id}/drawer/memory/pov-summary`` — textarea, capped.
* ``POST /chats/{chat_id}/drawer/edge/knowledge-facts`` — add/remove fact.
T72.3 layers a witness-flag toggle on top:
* ``POST /chats/{chat_id}/drawer/memory/witness`` — ``manual_edit`` with
``target_kind`` = ``memory_witness`` and a ``{flag, value}`` payload.
Each test asserts (a) the ``manual_edit`` event lands in the log,
(b) the projected table reflects the new value, and (c) the response is
the refreshed drawer partial.
"""
from __future__ import annotations
import json
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_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 _seed(db: Path) -> None:
"""Seed a chat with one host bot, one host->you edge with a fact and
summary already set, and one memory authored by ``bot_a`` witnessed by
all three roles. Tests reach into projected state to verify edits.
"""
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": "",
},
)
# Materialise edge bot_a -> you with a knowledge_fact already on it
# so the remove path has something to consume.
append_event(
conn,
kind="edge_update",
payload={
"source_id": "bot_a",
"target_id": "you",
"chat_id": "chat_bot_a",
"affinity_delta": 0,
"knowledge_facts": ["studied physics together"],
},
)
append_event(
conn,
kind="memory_written",
payload={
"owner_id": "bot_a",
"chat_id": "chat_bot_a",
"pov_summary": "Original summary text.",
"witness_you": 1,
"witness_host": 1,
"witness_guest": 0,
"significance": 1,
},
)
project(conn)
# --- T72.1 tests ----------------------------------------------------------
def test_edit_edge_trust_emits_manual_edit_and_updates(client, tmp_path):
_seed(tmp_path / "test.db")
response = client.post(
"/chats/chat_bot_a/drawer/edge/trust",
data={"source_id": "bot_a", "target_id": "you", "new_value": "73"},
)
assert response.status_code == 200
# Refresh shows the new trust value somewhere in the partial.
assert "73" in response.text
with open_db(tmp_path / "test.db") as conn:
rows = conn.execute(
"SELECT payload_json FROM event_log WHERE kind = 'manual_edit'"
).fetchall()
assert len(rows) == 1
payload = json.loads(rows[0][0])
assert payload["target_kind"] == "edge_trust"
assert payload["prior_value"] == 50
assert payload["new_value"] == 73
assert payload["target_id"] == {
"source_id": "bot_a",
"target_id": "you",
}
from chat.state.edges import get_edge
edge = get_edge(conn, "bot_a", "you")
assert edge["trust"] == 73
def test_edit_edge_trust_400_on_out_of_range(client, tmp_path):
_seed(tmp_path / "test.db")
response = client.post(
"/chats/chat_bot_a/drawer/edge/trust",
data={"source_id": "bot_a", "target_id": "you", "new_value": "150"},
)
assert response.status_code == 400
def test_edit_edge_summary_emits_manual_edit_and_updates(client, tmp_path):
_seed(tmp_path / "test.db")
response = client.post(
"/chats/chat_bot_a/drawer/edge/summary",
data={
"source_id": "bot_a",
"target_id": "you",
"new_summary": "BotA respects you and shares lab notes.",
},
)
assert response.status_code == 200
with open_db(tmp_path / "test.db") as conn:
rows = conn.execute(
"SELECT payload_json FROM event_log WHERE kind = 'manual_edit'"
).fetchall()
assert len(rows) == 1
payload = json.loads(rows[0][0])
assert payload["target_kind"] == "edge_summary"
assert payload["new_value"].startswith("BotA respects")
assert payload["target_id"] == {
"source_id": "bot_a",
"target_id": "you",
}
summary = conn.execute(
"SELECT summary FROM edges "
"WHERE source_id = ? AND target_id = ?",
("bot_a", "you"),
).fetchone()[0]
assert "respects" in summary
# And the refreshed partial echoes the new summary back.
assert "respects" in response.text
def test_edit_edge_summary_400_on_overflow(client, tmp_path):
_seed(tmp_path / "test.db")
response = client.post(
"/chats/chat_bot_a/drawer/edge/summary",
data={
"source_id": "bot_a",
"target_id": "you",
"new_summary": "x" * 2001,
},
)
assert response.status_code == 400
def test_edit_memory_pov_summary_emits_manual_edit_and_updates(
client, tmp_path
):
_seed(tmp_path / "test.db")
with open_db(tmp_path / "test.db") as conn:
memory_id = conn.execute("SELECT id FROM memories LIMIT 1").fetchone()[0]
response = client.post(
"/chats/chat_bot_a/drawer/memory/pov-summary",
data={
"memory_id": str(memory_id),
"new_summary": "Cleaner per-POV restatement of the moment.",
},
)
assert response.status_code == 200
with open_db(tmp_path / "test.db") as conn:
rows = conn.execute(
"SELECT payload_json FROM event_log WHERE kind = 'manual_edit'"
).fetchall()
assert len(rows) == 1
payload = json.loads(rows[0][0])
assert payload["target_kind"] == "memory_pov_summary"
assert payload["prior_value"] == "Original summary text."
assert payload["new_value"].startswith("Cleaner per-POV")
assert payload["target_id"] == memory_id
pov = conn.execute(
"SELECT pov_summary FROM memories WHERE id = ?", (memory_id,)
).fetchone()[0]
assert pov.startswith("Cleaner per-POV")
assert "Cleaner per-POV" in response.text
def test_edit_memory_pov_summary_404_when_wrong_chat(client, tmp_path):
_seed(tmp_path / "test.db")
with open_db(tmp_path / "test.db") as conn:
memory_id = conn.execute("SELECT id FROM memories LIMIT 1").fetchone()[0]
# Re-home the memory to a different chat to confirm the route's
# cross-chat guard fires.
conn.execute(
"UPDATE memories SET chat_id = 'other_chat' WHERE id = ?",
(memory_id,),
)
conn.commit()
response = client.post(
"/chats/chat_bot_a/drawer/memory/pov-summary",
data={"memory_id": str(memory_id), "new_summary": "..."},
)
assert response.status_code == 404
def test_edit_edge_knowledge_facts_add_emits_event_and_appends(client, tmp_path):
_seed(tmp_path / "test.db")
response = client.post(
"/chats/chat_bot_a/drawer/edge/knowledge-facts",
data={
"source_id": "bot_a",
"target_id": "you",
"action": "add",
"fact": "lent you a textbook",
},
)
assert response.status_code == 200
with open_db(tmp_path / "test.db") as conn:
rows = conn.execute(
"SELECT payload_json FROM event_log WHERE kind = 'manual_edit'"
).fetchall()
assert len(rows) == 1
payload = json.loads(rows[0][0])
assert payload["target_kind"] == "edge_knowledge_fact"
assert payload["new_value"] == {
"action": "add",
"fact": "lent you a textbook",
}
# Prior value snapshots the entire knowledge list before the edit.
assert payload["prior_value"] == ["studied physics together"]
from chat.state.edges import get_edge
edge = get_edge(conn, "bot_a", "you")
assert "lent you a textbook" in edge["knowledge"]
assert "studied physics together" in edge["knowledge"]
assert "lent you a textbook" in response.text
def test_edit_edge_knowledge_facts_remove_drops_matching_fact(client, tmp_path):
_seed(tmp_path / "test.db")
response = client.post(
"/chats/chat_bot_a/drawer/edge/knowledge-facts",
data={
"source_id": "bot_a",
"target_id": "you",
"action": "remove",
"fact": "studied physics together",
},
)
assert response.status_code == 200
with open_db(tmp_path / "test.db") as conn:
from chat.state.edges import get_edge
edge = get_edge(conn, "bot_a", "you")
assert "studied physics together" not in edge["knowledge"]
rows = conn.execute(
"SELECT payload_json FROM event_log WHERE kind = 'manual_edit'"
).fetchall()
payload = json.loads(rows[0][0])
assert payload["target_kind"] == "edge_knowledge_fact"
assert payload["new_value"]["action"] == "remove"
def test_edit_edge_knowledge_facts_400_on_bad_action(client, tmp_path):
_seed(tmp_path / "test.db")
response = client.post(
"/chats/chat_bot_a/drawer/edge/knowledge-facts",
data={
"source_id": "bot_a",
"target_id": "you",
"action": "delete",
"fact": "x",
},
)
assert response.status_code == 400
# --- T72.3 tests (witness flag inline-edit) -------------------------------
def test_witness_flag_toggle_updates_memory_row(client, tmp_path):
"""Memory seeded with witness [you=1, host=1, guest=0]; toggling
``guest`` to 1 lands as ``witness_guest = 1`` after projection.
"""
_seed(tmp_path / "test.db")
with open_db(tmp_path / "test.db") as conn:
memory_id = conn.execute("SELECT id FROM memories LIMIT 1").fetchone()[0]
response = client.post(
"/chats/chat_bot_a/drawer/memory/witness",
data={
"memory_id": str(memory_id),
"flag": "guest",
"new_value": "1",
},
)
assert response.status_code == 200
with open_db(tmp_path / "test.db") as conn:
row = conn.execute(
"SELECT witness_you, witness_host, witness_guest "
"FROM memories WHERE id = ?",
(memory_id,),
).fetchone()
assert row == (1, 1, 1)
def test_witness_flag_toggle_emits_manual_edit_event(client, tmp_path):
_seed(tmp_path / "test.db")
with open_db(tmp_path / "test.db") as conn:
memory_id = conn.execute("SELECT id FROM memories LIMIT 1").fetchone()[0]
response = client.post(
"/chats/chat_bot_a/drawer/memory/witness",
data={
"memory_id": str(memory_id),
"flag": "guest",
"new_value": "1",
},
)
assert response.status_code == 200
with open_db(tmp_path / "test.db") as conn:
rows = conn.execute(
"SELECT payload_json FROM event_log WHERE kind = 'manual_edit'"
).fetchall()
assert len(rows) == 1
payload = json.loads(rows[0][0])
assert payload["target_kind"] == "memory_witness"
assert payload["target_id"] == memory_id
assert payload["prior_value"] == {"flag": "guest", "value": 0}
assert payload["new_value"] == {"flag": "guest", "value": 1}
def test_witness_flag_toggle_400_on_bad_flag(client, tmp_path):
_seed(tmp_path / "test.db")
with open_db(tmp_path / "test.db") as conn:
memory_id = conn.execute("SELECT id FROM memories LIMIT 1").fetchone()[0]
response = client.post(
"/chats/chat_bot_a/drawer/memory/witness",
data={
"memory_id": str(memory_id),
"flag": "narrator",
"new_value": "1",
},
)
assert response.status_code == 400