Files
chat/tests/test_search_ux.py
T
Joseph Doherty 9987da2c07 feat: cross-chat search deep-links to turn via memories.event_id (T111.2)
Add ``m.event_id`` (T109's nullable column from migration 0014) to
``search_all_memories``'s SELECT, propagate it through the route's
template context, and have ``search.html`` build result links as
``/chats/{chat_id}#turn-{event_id}`` — matching the ``id="turn-{event_id}"``
anchor that Phase 3.5 T86 stamps on each turn DOM node so the chat page
scrolls to the originating turn on load. Memory rows projected before
the 0014 migration ran read NULL ``event_id``; the template falls back
to a chat-level link in that case so we never emit ``#turn-None``.

Pre-existing tests that asserted on the bare ``href="/chats/{chat_id}"``
contract are updated to assert on the ``href="/chats/{chat_id}#turn-``
prefix to reflect the new deep-link.
2026-04-27 05:42:17 -04:00

202 lines
8.0 KiB
Python

"""T100 (Phase 4): cross-chat search UX (top-bar + results page).
Verifies the FastAPI ``/search`` route that wraps T93's
``search_all_memories`` service:
* ``/search?q=...`` returns 200 + an HTML page that lists matches drawn
from MULTIPLE chats (not just the current one) and links each result
back to ``/chats/{chat_id}``.
* ``/search`` with no query renders the page in its empty state with a
"enter a query" placeholder and no result rows (avoids hitting the
FTS index with an invalid empty MATCH).
* Result links navigate to the originating chat so users can pick up
the thread where the memory came from.
"""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
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
import chat.state.memory # noqa: F401 (registers memory_written handler)
@pytest.fixture
def client(tmp_path, monkeypatch):
config_path = tmp_path / "config.toml"
config_path.write_text('featherless_api_key = "test"\n')
monkeypatch.setenv("CHAT_CONFIG_PATH", str(config_path))
monkeypatch.setenv("CHAT_DB_PATH", str(tmp_path / "test.db"))
with TestClient(app) as c:
yield c
def _seed_two_chats_with_memories(db_path: Path) -> None:
"""Seed: a ``you_entity``, two bots, two chats, and one ``rabbit``
memory per chat. Two-chat seeding lets the cross-chat assertion
actually distinguish "both chats appear" from "only the current
one does"."""
with open_db(db_path) as conn:
append_event(
conn,
kind="you_authored",
payload={"name": "Me", "pronouns": "", "persona": ""},
)
for bot_id, chat_id in (("bot_a", "chat_a"), ("bot_b", "chat_b")):
append_event(
conn,
kind="bot_authored",
payload={
"id": bot_id,
"name": bot_id.upper(),
"persona": "thoughtful",
"voice_samples": [],
"traits": [],
"backstory": "",
"initial_relationship_to_you": "friend",
"kickoff_prose": "kickoff",
},
)
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="memory_written",
payload={
"owner_id": bot_id,
"chat_id": chat_id,
"pov_summary": f"the rabbit darted across {chat_id}",
"witness_you": 1,
"witness_host": 1,
"witness_guest": 0,
"source": "direct",
"reliability": 1.0,
"significance": 1,
"pinned": 0,
"auto_pinned": 0,
},
)
project(conn)
def test_search_returns_results_from_multiple_chats(client, tmp_path):
"""A single ``/search?q=rabbit`` must surface matches from BOTH
chats — the whole point of the cross-chat search box is that it
isn't owner-scoped."""
_seed_two_chats_with_memories(tmp_path / "test.db")
resp = client.get("/search?q=rabbit")
assert resp.status_code == 200
body = resp.text
# Both chats' memory snippets must appear in the rendered page.
assert "chat_a" in body
assert "chat_b" in body
assert "rabbit" in body.lower()
def test_empty_query_renders_placeholder_not_results(client, tmp_path):
"""``/search`` with no query renders the page in its empty state.
The placeholder copy is a contract with the user — they should see
"enter a query" rather than an empty result list that looks like a
no-match. Also: the FTS short-circuit means there are no result
rows to leak into the body."""
_seed_two_chats_with_memories(tmp_path / "test.db")
resp = client.get("/search")
assert resp.status_code == 200
body = resp.text.lower()
assert "enter a query" in body
# Seeded "rabbit" memories must NOT appear: empty query => no results.
assert "the rabbit darted" not in resp.text
def test_result_links_navigate_to_chat(client, tmp_path):
"""Each result links back to its originating chat so the user can
reopen the thread where the memory was first witnessed.
Post-T111.2: the link now includes a turn anchor when the memory
row carries an ``event_id`` (T109's nullable column is populated for
rows projected after migration 0014 ran). We assert on the chat-id
portion of the href because the exact event id is autoincrement and
depends on seed order; the dedicated
``test_search_result_link_includes_turn_anchor`` test below pins the
anchor format itself."""
_seed_two_chats_with_memories(tmp_path / "test.db")
resp = client.get("/search?q=rabbit")
assert resp.status_code == 200
assert 'href="/chats/chat_a' in resp.text
def test_search_results_include_fts_snippet_with_highlight(client, tmp_path):
"""T111.1: FTS snippet() wraps each match in ``<mark>...</mark>`` so
the result row visually highlights the term that matched.
The seeded ``pov_summary`` is ``the rabbit darted across chat_a``;
SQLite's ``snippet()`` returns the column text with each match token
wrapped — searching for ``rabbit`` yields a snippet containing
``<mark>rabbit</mark>``. Assertion is just that the marker appears
(the snippet may be truncated with an ellipsis when the indexed text
runs longer than the configured token window)."""
_seed_two_chats_with_memories(tmp_path / "test.db")
resp = client.get("/search?q=rabbit")
assert resp.status_code == 200
assert "<mark>rabbit</mark>" in resp.text
def test_search_result_link_includes_turn_anchor(client, tmp_path):
"""T111.2: result links deep-link to the originating turn via the
chat-page anchor stamped by Phase 3.5 T86 (``id="turn-{event_id}"``).
The seeded ``memory_written`` events are projected with
``memories.event_id`` populated (T109); the route exposes that id and
the template builds the link as ``/chats/{chat_id}#turn-{event_id}``.
We don't assert a specific event id (it's an autoincrement that
depends on seed order), only that *some* turn anchor is present for
the chat link the user is about to click."""
_seed_two_chats_with_memories(tmp_path / "test.db")
resp = client.get("/search?q=rabbit")
assert resp.status_code == 200
assert "/chats/chat_a#turn-" in resp.text
def test_search_results_use_batched_lookups(client, tmp_path):
"""T106: hydration must not fan out to per-row ``get_bot``/
``get_chat``/``get_scene`` calls.
The previous implementation called each helper once per result row
(worst case 50 rows x 3 helpers = 150 individual queries). The
batched implementation collects distinct ids and issues at most one
query per entity kind via ``WHERE id IN (...)``, so the per-row
helpers should not be invoked at all when there are matches.
We seed two chats (so both ``get_bot`` and ``get_chat`` would have
been hit pre-T106) and assert each helper sees zero per-row calls.
"""
_seed_two_chats_with_memories(tmp_path / "test.db")
with (
patch("chat.web.search.get_bot") as mock_get_bot,
patch("chat.web.search.get_chat") as mock_get_chat,
patch("chat.web.search.get_scene") as mock_get_scene,
):
resp = client.get("/search?q=rabbit")
assert resp.status_code == 200
# Batched IN-list queries replace the per-row helpers entirely.
assert mock_get_bot.call_count == 0
assert mock_get_chat.call_count == 0
assert mock_get_scene.call_count == 0