Files
petal/internal/docs/search_test.go
prosolis 9e141e4169 Phase 10: organization & polish — cross-doc search, tags, touch, warm failures
Cross-document FTS5 search (trigram tokenizer for EN + space-free CJK, kept in
sync by triggers, back-filled from existing docs). GET /api/search uses the FTS
index for queries >=3 runes and a LIKE fallback for 1-2 (so 2-char Chinese words
resolve); snippets are built in Go with rune-aware boundaries and sentinel
highlights.

Tags: user-scoped tags + document_tags join (both cascade), idempotent
create/assign, per-tag doc counts. Doc list and search carry each doc's tags
(one tagsByDoc query). Frontend: useTags, TagChip/TagPicker/SearchBox, rewritten
DocList with chips + filter bar + search.

Tablet/touch: responsive sidebar drawer (hamburger + scrim <768px), coarse-
pointer tap targets, tap-to-open + outside-pointerdown-close for suggestion
cards.

Warm LLM-down state: useCheckpoint llmDown flag drives a gentle bilingual
StatusBar note (writing still saves locally).

Migration 0004 (tags + FTS). Tests: tags lifecycle, search EN/CJK/LIKE/update-
reindex. go build/vet/test, tsc, vite all clean; verified live on deployment host.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 06:29:56 -07:00

112 lines
3.3 KiB
Go

package docs
import (
"encoding/json"
"net/http"
"strings"
"testing"
)
func TestSearch(t *testing.T) {
srv := newFullServer(t)
createDoc(t, srv, "My Essay", "The quick brown fox jumps over the lazy dog")
createDoc(t, srv, "我的日记", "今天天气很好,我去公园散步,心情非常愉快")
createDoc(t, srv, "Notes", "groceries and errands for the weekend")
search := func(q string) []searchResult {
t.Helper()
rec := do(t, srv, http.MethodGet, "/search?q="+urlQuery(q), "")
if rec.Code != http.StatusOK {
t.Fatalf("search %q: %d %s", q, rec.Code, rec.Body)
}
var out []searchResult
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode search %q: %v", q, err)
}
return out
}
// English, ≥3 chars → FTS path. Snippet wraps the match in sentinels.
res := search("quick")
if len(res) != 1 || res[0].Title != "My Essay" {
t.Fatalf("english search: %+v", res)
}
if !strings.Contains(res[0].Snippet, hlStart+"quick"+hlEnd) {
t.Fatalf("snippet not highlighted: %q", res[0].Snippet)
}
// Chinese, ≥3 chars → FTS path (trigram handles CJK).
res = search("天气很好")
if len(res) != 1 || res[0].Title != "我的日记" {
t.Fatalf("chinese fts search: %+v", res)
}
if !strings.Contains(res[0].Snippet, hlStart+"天气很好"+hlEnd) {
t.Fatalf("cjk snippet not highlighted: %q", res[0].Snippet)
}
// 2-character Chinese word → LIKE fallback (below the trigram minimum).
res = search("公园")
if len(res) != 1 || res[0].Title != "我的日记" {
t.Fatalf("cjk short (LIKE) search: %+v", res)
}
if !strings.Contains(res[0].Snippet, hlStart+"公园"+hlEnd) {
t.Fatalf("short cjk snippet not highlighted: %q", res[0].Snippet)
}
// Title-only match still returns the doc, highlighting the title.
res = search("Notes")
if len(res) != 1 || res[0].Title != "Notes" {
t.Fatalf("title search: %+v", res)
}
// Case-insensitive.
if got := search("QUICK"); len(got) != 1 {
t.Fatalf("case-insensitive search: %+v", got)
}
// No match → empty (non-nil) array.
if got := search("zzzznotthere"); len(got) != 0 {
t.Fatalf("expected no results, got %+v", got)
}
// Empty query → empty array, no error.
rec := do(t, srv, http.MethodGet, "/search?q=", "")
if rec.Code != http.StatusOK || strings.TrimSpace(rec.Body.String()) != "[]" {
t.Fatalf("empty query: %d %s", rec.Code, rec.Body)
}
// An edit re-indexes via the update trigger: the old term stops matching, the
// new term starts.
res = search("quick")
docID := res[0].ID
body := `{"content":"{}","content_text":"the slow purple turtle ambles along","word_count":6}`
do(t, srv, http.MethodPut, "/docs/"+docID, body)
if got := search("quick"); len(got) != 0 {
t.Fatalf("stale term still matches after edit: %+v", got)
}
if got := search("purple turtle"); len(got) != 1 || got[0].ID != docID {
t.Fatalf("new term not indexed after edit: %+v", got)
}
}
// urlQuery percent-encodes a search term for the query string.
func urlQuery(s string) string {
var b strings.Builder
for _, r := range []byte(s) {
if r == ' ' {
b.WriteByte('+')
} else if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
b.WriteByte(r)
} else {
b.WriteString(percentByte(r))
}
}
return b.String()
}
func percentByte(b byte) string {
const hex = "0123456789ABCDEF"
return string([]byte{'%', hex[b>>4], hex[b&0xf]})
}