import { describe, it, expect } from "vitest"; import { parseQuery, tokenize, addFilter, removeFilter, stripFilters, highlightSegments, makeSnippet, stripMarkdown, prefixTsQuery, parseDateToken, escapeLike } from "@/lib/search/query"; const NOW = new Date("2026-09-11T15:30:00Z"); describe("tokenize", () => { it("splits on whitespace and keeps quoted phrases together", () => { expect(tokenize('hello world "new york" model:"gpt 5"')).toEqual(["hello", "world", '"new york"', 'model:"gpt 5"']); }); it("tolerates an unterminated quote", () => { expect(tokenize('foo "bar baz')).toEqual(["foo", '"bar baz']); }); }); describe("parseQuery", () => { it("returns free text only when there are no filters", () => { const q = parseQuery("rate limits gemini", { now: NOW }); expect(q.text).toBe("rate limits gemini"); expect(q.words).toEqual(["rate", "limits", "gemini"]); expect(q.hasFilters).toBe(false); expect(q.tokens).toEqual([]); }); it("extracts every filter kind", () => { const q = parseQuery('budget model:claude provider:Anthropic project:research folder:Clients after:2026-08-01 before:2026-09-01 role:user is:pinned is:shared "exact phrase"', { now: NOW }); expect(q.words).toEqual(["budget"]); expect(q.phrases).toEqual(["exact phrase"]); expect(q.text).toBe('budget "exact phrase"'); expect(q.terms).toEqual(["exact phrase", "budget"]); expect(q.filters.model).toEqual(["claude"]); expect(q.filters.provider).toEqual(["anthropic"]); expect(q.filters.project).toEqual(["research"]); expect(q.filters.folder).toEqual(["Clients"]); expect(q.filters.after?.toISOString()).toBe("2026-08-01T00:00:00.000Z"); expect(q.filters.before?.toISOString()).toBe("2026-09-02T00:00:00.000Z"); expect(q.filters.role).toBe("user"); expect(q.filters.is).toEqual(["pinned", "shared"]); expect(q.hasFilters).toBe(true); expect(q.tokens.map((t) => t.key)).toEqual(["model", "provider", "project", "folder", "after", "before", "role", "is", "is"]); }); it("supports quoted filter values and case-insensitive keys", () => { const q = parseQuery('Project:"Q3 Research" MODEL:"gpt 5"', { now: NOW }); expect(q.filters.project).toEqual(["Q3 Research"]); expect(q.filters.model).toEqual(["gpt 5"]); expect(q.text).toBe(""); }); it("keeps unknown keys and invalid filter values as free text", () => { const q = parseQuery("meeting at 10:30 https://example.com role:pirate after:someday", { now: NOW }); expect(q.filters.role).toBeNull(); expect(q.filters.after).toBeNull(); expect(q.words).toEqual(["meeting", "at", "10:30", "https://example.com", "role:pirate", "after:someday"]); }); it("maps role aliases", () => { expect(parseQuery("role:ai").filters.role).toBe("assistant"); expect(parseQuery("role:me").filters.role).toBe("user"); }); it("dedupes words and lower-cases them", () => { const q = parseQuery("Claude claude CLAUDE"); expect(q.words).toEqual(["claude"]); }); it("parses relative dates", () => { expect(parseQuery("after:7d", { now: NOW }).filters.after?.toISOString()).toBe("2026-09-04T00:00:00.000Z"); expect(parseQuery("after:today", { now: NOW }).filters.after?.toISOString()).toBe("2026-09-11T00:00:00.000Z"); expect(parseQuery("before:yesterday", { now: NOW }).filters.before?.toISOString()).toBe("2026-09-11T00:00:00.000Z"); expect(parseQuery("after:2026-08", { now: NOW }).filters.after?.toISOString()).toBe("2026-08-01T00:00:00.000Z"); expect(parseQuery("before:2026-08", { now: NOW }).filters.before?.toISOString()).toBe("2026-09-01T00:00:00.000Z"); expect(parseDateToken("2026-13-01", NOW, "start")).toBeNull(); }); }); describe("filter helpers", () => { it("appends filters and replaces single-valued ones", () => { expect(addFilter("hello", "model", "claude")).toBe("hello model:claude"); expect(addFilter("hello model:claude", "model", "claude")).toBe("hello model:claude"); expect(addFilter("hello model:claude", "model", "gpt")).toBe("hello model:claude model:gpt"); expect(addFilter("hello after:2026-01-01", "after", "7d")).toBe("hello after:7d"); expect(addFilter("x", "project", "Q3 Research")).toBe('x project:"Q3 Research"'); }); it("removes a token and strips all filters", () => { const q = parseQuery("hello model:claude is:pinned"); expect(removeFilter(q.raw, q.tokens[0])).toBe("hello is:pinned"); expect(stripFilters("hello model:claude is:pinned world")).toBe("hello world"); }); }); describe("highlighting", () => { it("marks case-insensitive, non-overlapping hits, longest term first", () => { const segs = highlightSegments("The Claude model and claude-sonnet", ["claude", "claude-sonnet"]); expect(segs).toEqual([ { text: "The ", hit: false }, { text: "Claude", hit: true }, { text: " model and ", hit: false }, { text: "claude-sonnet", hit: true }, ]); }); it("escapes regex metacharacters and handles empty input", () => { expect(highlightSegments("a+b (c)", ["a+b", "(c)"])).toEqual([ { text: "a+b", hit: true }, { text: " ", hit: false }, { text: "(c)", hit: true }, ]); expect(highlightSegments("", ["x"])).toEqual([]); expect(highlightSegments("plain", [])).toEqual([{ text: "plain", hit: false }]); }); }); describe("snippets", () => { it("strips markdown", () => { expect(stripMarkdown("# Title\n\n- **bold** and _em_ with `code` and [link](https://x.y)\n```ts\nlet a = 1\n```")).toBe("Title bold and em with code and link let a = 1"); }); it("returns short text untouched and centres long text on the first hit", () => { expect(makeSnippet("short text", ["text"])).toBe("short text"); const long = `${"lorem ipsum ".repeat(30)}NEEDLE here ${"dolor sit ".repeat(30)}`; const s = makeSnippet(long, ["needle"], 120); expect(s.startsWith("…")).toBe(true); expect(s.endsWith("…")).toBe(true); expect(s.toLowerCase()).toContain("needle"); expect(s.length).toBeLessThanOrEqual(160); }); it("falls back to the beginning when nothing matches", () => { const s = makeSnippet("x".repeat(500), ["zzz"], 100); expect(s).toBe("x".repeat(100) + "…"); }); }); describe("sql helpers", () => { it("builds a prefix tsquery with quoted lexemes", () => { expect(prefixTsQuery(["rate", "lim"])).toBe("'rate':* & 'lim':*"); expect(prefixTsQuery(["a", "it's"])).toBe("'its':*"); expect(prefixTsQuery(["a"])).toBeNull(); }); it("escapes LIKE wildcards", () => { expect(escapeLike("100%_done\\")).toBe("100\\%\\_done\\\\"); }); });