248 lines
8.5 KiB
Python
248 lines
8.5 KiB
Python
"""Streaming UX tests (T34): cancel route, recent-dialogue user_turn_edit
|
|
inclusion, and the chat-shell embeds the streaming JS hooks.
|
|
|
|
The cancel route is exercised at the no-op level only — the full mid-stream
|
|
cancel path is covered indirectly by T19's CancelledError handling. We
|
|
verify here that the route itself is registered and silently 204s when no
|
|
in-flight task exists, since the JS Stop button fires unconditionally.
|
|
|
|
The user_turn_edit inclusion test is the T29 follow-up fix: without it,
|
|
the original user_turn drops out of the timeline (correctly) but the
|
|
edited prose never lands (incorrectly), so the rendered chat detail is
|
|
missing the user's most recent words.
|
|
"""
|
|
|
|
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_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:
|
|
# Disable the lifespan-managed background worker so it doesn't
|
|
# try to score significance through Featherless with the fake key.
|
|
worker = getattr(app.state, "background_worker", None)
|
|
if worker is not None:
|
|
worker.enabled = False
|
|
yield c
|
|
|
|
|
|
def _seed_chat(
|
|
db_path: Path,
|
|
bot_id: str = "bot_a",
|
|
chat_id: str = "chat_bot_a",
|
|
) -> None:
|
|
"""Seed a bot + chat with the activity rows the prompt assembler expects."""
|
|
with open_db(db_path) as conn:
|
|
append_event(
|
|
conn,
|
|
kind="bot_authored",
|
|
payload={
|
|
"id": bot_id,
|
|
"name": "BotA",
|
|
"persona": "...",
|
|
"voice_samples": [],
|
|
"traits": [],
|
|
"backstory": "",
|
|
"initial_relationship_to_you": "",
|
|
"kickoff_prose": "...",
|
|
},
|
|
)
|
|
append_event(
|
|
conn,
|
|
kind="chat_created",
|
|
payload={
|
|
"id": chat_id,
|
|
"host_bot_id": bot_id,
|
|
"initial_time": "2026-04-26T20:00:00+00:00",
|
|
"narrative_anchor": "Day 1",
|
|
"weather": "",
|
|
},
|
|
)
|
|
append_event(
|
|
conn,
|
|
kind="edge_update",
|
|
payload={
|
|
"source_id": bot_id,
|
|
"target_id": "you",
|
|
"chat_id": chat_id,
|
|
},
|
|
)
|
|
append_event(
|
|
conn,
|
|
kind="edge_update",
|
|
payload={
|
|
"source_id": "you",
|
|
"target_id": bot_id,
|
|
"chat_id": chat_id,
|
|
},
|
|
)
|
|
append_event(
|
|
conn,
|
|
kind="activity_change",
|
|
payload={
|
|
"entity_id": "you",
|
|
"posture": "sitting",
|
|
"action": {"verb": "talking"},
|
|
},
|
|
)
|
|
append_event(
|
|
conn,
|
|
kind="activity_change",
|
|
payload={
|
|
"entity_id": bot_id,
|
|
"posture": "sitting",
|
|
"action": {"verb": "listening"},
|
|
},
|
|
)
|
|
project(conn)
|
|
|
|
|
|
def test_cancel_route_no_op_when_no_in_flight(client, tmp_path):
|
|
"""Hitting cancel with nothing streaming returns 204 silently."""
|
|
_seed_chat(tmp_path / "test.db")
|
|
response = client.post("/chats/chat_bot_a/turns/cancel")
|
|
assert response.status_code == 204
|
|
|
|
|
|
def test_user_turn_edit_appears_in_recent_dialogue(client, tmp_path):
|
|
"""The chat-detail timeline includes a user_turn_edit's prose.
|
|
|
|
Original user_turn is superseded by the edit, so it drops out, but
|
|
the edit's prose should render in its place.
|
|
"""
|
|
db_path = tmp_path / "test.db"
|
|
_seed_chat(db_path)
|
|
with open_db(db_path) as conn:
|
|
ut_id = append_event(
|
|
conn,
|
|
kind="user_turn",
|
|
payload={
|
|
"chat_id": "chat_bot_a",
|
|
"prose": "OriginalUserText",
|
|
"segments": [],
|
|
},
|
|
)
|
|
edit_id = append_event(
|
|
conn,
|
|
kind="user_turn_edit",
|
|
payload={
|
|
"chat_id": "chat_bot_a",
|
|
"prose": "EditedUserText",
|
|
"supersedes_user_turn_id": ut_id,
|
|
},
|
|
)
|
|
conn.execute(
|
|
"UPDATE event_log SET superseded_by = ? WHERE id = ?",
|
|
(edit_id, ut_id),
|
|
)
|
|
conn.commit()
|
|
# No project() call — user_turn / user_turn_edit have no projector
|
|
# handlers (transcript-only kinds), and re-projecting would replay
|
|
# chat_created and trip its UNIQUE constraint.
|
|
|
|
response = client.get("/chats/chat_bot_a")
|
|
assert response.status_code == 200
|
|
body = response.text
|
|
assert "EditedUserText" in body
|
|
# The original (now-superseded) prose must not render.
|
|
assert "OriginalUserText" not in body
|
|
|
|
|
|
def test_chat_html_includes_stop_streaming_script(client, tmp_path):
|
|
"""The chat shell embeds the streaming-JS hooks (Stop button + send-lock)."""
|
|
_seed_chat(tmp_path / "test.db")
|
|
response = client.get("/chats/chat_bot_a")
|
|
assert response.status_code == 200
|
|
body = response.text
|
|
# Either the CSS class for the Stop button or the JS state flag must
|
|
# appear in the embedded script — both are load-bearing for T34.
|
|
assert "stop-streaming" in body or "isStreaming" in body
|
|
# Cancel route reference must be wired so the Stop button can call it.
|
|
assert "/turns/cancel" in body
|
|
|
|
|
|
def test_chat_html_has_turn_html_replace_listener(client, tmp_path):
|
|
"""T86: the chat shell wires a JS handler for the ``turn_html_replace``
|
|
SSE event so regenerate-driven swaps land in connected tabs without a
|
|
page refresh.
|
|
|
|
This is a presence / string-check test: it verifies the handler is
|
|
embedded in the rendered template but does NOT drive a real browser
|
|
(no headless runner is wired into this test environment). The end-to-
|
|
end behaviour — receiving the event over SSE and replacing the prior
|
|
turn's DOM node — is therefore not exercised here; a manual smoke
|
|
check or future browser-driven test would close that gap.
|
|
"""
|
|
_seed_chat(tmp_path / "test.db")
|
|
response = client.get("/chats/chat_bot_a")
|
|
assert response.status_code == 200
|
|
body = response.text
|
|
# The handler must be wired against the SSE event name the backend
|
|
# publishes (chat.services.regenerate -> "turn_html_replace").
|
|
assert "turn_html_replace" in body
|
|
# Confirm the handler reads the JSON payload's ``supersedes_id`` so
|
|
# it can locate the prior turn node. The exact lookup mechanism may
|
|
# vary, but the field name is part of the contract with the backend.
|
|
assert "supersedes_id" in body
|
|
|
|
|
|
def test_rendered_turn_html_includes_event_id(client, tmp_path):
|
|
"""T86 follow-up: the chat-detail Jinja loop stamps
|
|
``id="turn-<event_id>"`` on every rendered turn DIV. Without this id
|
|
the ``turn_html_replace`` SSE handler's ``getElementById`` lookup
|
|
misses, falls through to ``insertAdjacentHTML('beforeend', …)``, and
|
|
the regenerated turn appears APPENDED instead of swapped in-place
|
|
(rendering the primary handler path dead code — exactly the gap the
|
|
T86 reviewer flagged).
|
|
|
|
Seed a user_turn + assistant_turn, GET the chat page, and assert the
|
|
response body carries both turns' event ids on the wrapper DIVs.
|
|
"""
|
|
db_path = tmp_path / "test.db"
|
|
_seed_chat(db_path)
|
|
with open_db(db_path) as conn:
|
|
ut_id = append_event(
|
|
conn,
|
|
kind="user_turn",
|
|
payload={
|
|
"chat_id": "chat_bot_a",
|
|
"prose": "hello bot",
|
|
"segments": [],
|
|
},
|
|
)
|
|
at_id = append_event(
|
|
conn,
|
|
kind="assistant_turn",
|
|
payload={
|
|
"chat_id": "chat_bot_a",
|
|
"speaker_id": "bot_a",
|
|
"text": "Hi there.",
|
|
"truncated": False,
|
|
"user_turn_id": ut_id,
|
|
},
|
|
)
|
|
conn.commit()
|
|
|
|
response = client.get("/chats/chat_bot_a")
|
|
assert response.status_code == 200
|
|
body = response.text
|
|
# Both seeded turns must carry ``id="turn-<event_id>"`` so the SSE
|
|
# in-place swap can find them.
|
|
assert f'id="turn-{ut_id}"' in body
|
|
assert f'id="turn-{at_id}"' in body
|