Files
chat/tests/test_streaming_ux.py

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