import { describe, it, expect, beforeEach } from "vitest"; import * as search from "./search"; import { serializeNoteContent } from "./editor/content"; const body = (text: string) => serializeNoteContent({ type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text }] }], }); const seed = () => { search.removeEntry("a"); search.removeEntry("b"); search.removeEntry("c"); search.setEntry("a", "Haushalt", body("Der Kühlschrank braucht eine neue Dichtung.")); search.setEntry("b", "Finanzen", body("Steuererklärung abgeben, Belege liegen im Ordner.")); search.setEntry("c", "Dichtung", body("Nur der Titel enthält das Wort.")); }; beforeEach(seed); describe("search", () => { it("returns null for an empty query, meaning 'no search'", () => { expect(search.search("")).toBeNull(); expect(search.search(" ")).toBeNull(); }); it("finds a word that appears only in the body", () => { const hits = search.search("Belege"); expect(hits?.map((h) => h.id)).toEqual(["b"]); }); it("ranks a title match above a body match", () => { const hits = search.search("Dichtung"); expect(hits?.map((h) => h.id)).toEqual(["c", "a"]); }); it("requires every term to match", () => { expect(search.search("Belege Ordner")?.map((h) => h.id)).toEqual(["b"]); expect(search.search("Belege Kühlschrank")).toEqual([]); }); it("ignores case", () => { expect(search.search("BELEGE")?.map((h) => h.id)).toEqual(["b"]); }); it("ignores diacritics, so an ASCII keyboard still finds umlauts", () => { expect(search.search("kuhlschrank")?.map((h) => h.id)).toEqual(["a"]); expect(search.search("Steuererklarung")?.map((h) => h.id)).toEqual(["b"]); }); it("returns a snippet around the match", () => { const hit = search.search("Dichtung")?.find((h) => h.id === "a"); expect(hit?.snippet).toMatch(/Dichtung/); }); it("respects the result limit", () => { for (let i = 0; i < 30; i++) search.setEntry(`x${i}`, `Note ${i}`, body("common word")); expect(search.search("common", { limit: 5 })?.length).toBe(5); for (let i = 0; i < 30; i++) search.removeEntry(`x${i}`); }); it("stops matching a note once it is removed from the index", () => { expect(search.search("Belege")?.length).toBe(1); search.removeEntry("b"); expect(search.search("Belege")).toEqual([]); }); it("reflects an updated body immediately", () => { search.setEntry("a", "Haushalt", body("Völlig anderer Inhalt jetzt.")); expect(search.search("Kühlschrank")).toEqual([]); expect(search.search("anderer")?.map((h) => h.id)).toEqual(["a"]); }); }); describe("attached document text", () => { /* A PDF note has two sources of text, written at different moments by * different code. Before these, whichever ran last was the only searchable * half — typing a note dropped the document, re-reading it dropped the note. */ it("finds a note by a word from its attached document", () => { search.setEntry("p1", "Handbuch", body("Meine eigene Notiz.")); search.setAttachedText("p1", "Handbuch", "Der Motor hat eine Zündkerze."); expect(search.search("Zündkerze")?.map((h) => h.id)).toEqual(["p1"]); search.removeEntry("p1"); }); it("and by a word the user typed beside it", () => { search.setEntry("p1", "Handbuch", body("Meine eigene Notiz.")); search.setAttachedText("p1", "Handbuch", "Der Motor hat eine Zündkerze."); expect(search.search("eigene")?.map((h) => h.id)).toEqual(["p1"]); search.removeEntry("p1"); }); it("keeps the document text when the note is edited afterwards", () => { search.setAttachedText("p1", "Handbuch", "Der Motor hat eine Zündkerze."); search.setEntry("p1", "Handbuch", body("Ganz neuer Notiztext.")); expect(search.search("Zündkerze")?.map((h) => h.id)).toEqual(["p1"]); expect(search.search("neuer")?.map((h) => h.id)).toEqual(["p1"]); search.removeEntry("p1"); }); it("keeps the note when the document is re-read afterwards", () => { search.setEntry("p1", "Handbuch", body("Meine eigene Notiz.")); search.setAttachedText("p1", "Handbuch", "Der Motor hat eine Zündkerze."); search.setAttachedText("p1", "Handbuch", "Der Motor hat eine Zündkerze und einen Riemen."); expect(search.search("eigene")?.map((h) => h.id)).toEqual(["p1"]); expect(search.search("Riemen")?.map((h) => h.id)).toEqual(["p1"]); search.removeEntry("p1"); }); it("shows the sentence a document hit was found in, not an unrelated excerpt", () => { search.setEntry("p1", "Handbuch", body("Kurze Notiz.")); search.setAttachedText("p1", "Handbuch", "Vorher. Der Motor hat eine Zündkerze. Nachher."); const hit = search.search("Zündkerze")?.[0]; expect(hit?.snippet).toMatch(/Zündkerze/); search.removeEntry("p1"); }); it("clears the attached text when it is set to empty", () => { search.setEntry("p1", "Handbuch", body("Notiz.")); search.setAttachedText("p1", "Handbuch", "Zündkerze"); search.setEntry("p1", "Handbuch", body("Notiz."), ""); expect(search.search("Zündkerze")).toEqual([]); search.removeEntry("p1"); }); }); describe("contentToText", () => { it("reads a v2 document", () => { expect(search.contentToText(body("hello world"))).toBe("hello world"); }); it("reads a v1 body, HTML and task list alike", () => { const v1 = JSON.stringify({ html: "
legacy text
", tasks: [{ id: "t", text: "buy milk", done: false }], }); const text = search.contentToText(v1); expect(text).toContain("legacy"); expect(text).toContain("buy milk"); }); it("does not execute or leak markup from a v1 body", () => { const text = search.contentToText(JSON.stringify({ html: "safe
" })); expect(text).toContain("safe"); expect(text).not.toContain("