9987da2c07
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.
202 lines
8.0 KiB
Python
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
|