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
This commit is contained in:
111
internal/docs/search_test.go
Normal file
111
internal/docs/search_test.go
Normal file
@@ -0,0 +1,111 @@
|
||||
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]})
|
||||
}
|
||||
Reference in New Issue
Block a user