From 440794e6e2d3ee6e0fcf62bd644ee18edc7ea6fe Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 08:43:02 +0000 Subject: [PATCH] test: Add tests for initSearch function Added unit tests for the previously untested initSearch function in src/ui/search.ts. A manual DOM mock was implemented to verify UI behaviors such as opening, closing, debouncing inputs, rendering results, and reacting to user actions without relying on jsdom. Co-authored-by: Horizontal42 <75383519+Horizontal42@users.noreply.github.com> --- src/ui/search.test.ts | 201 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 199 insertions(+), 2 deletions(-) diff --git a/src/ui/search.test.ts b/src/ui/search.test.ts index 86b4287..3b84095 100644 --- a/src/ui/search.test.ts +++ b/src/ui/search.test.ts @@ -1,6 +1,6 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { SumEngine } from "../engine"; -import { parseResultQuery, searchAllSheets, SearchDeps, SearchDoc } from "./search"; +import { initSearch, parseResultQuery, searchAllSheets, SearchDeps, SearchDoc } from "./search"; import { Workspace } from "../workspace"; import Decimal from "decimal.js"; @@ -241,3 +241,200 @@ describe("searchAllSheets", () => { }); }); }); + +describe("initSearch", () => { + class MockElement { + className: string = ""; + textContent: string = ""; + value: string = ""; + classList = { + add: vi.fn(), + remove: vi.fn(), + }; + children: MockElement[] = []; + listeners: Record = {}; + + replaceChildren() { + this.children = []; + } + + appendChild(child: MockElement) { + if (child.isFragment) { + this.children.push(...child.children); + } else { + this.children.push(child); + } + } + + append(...children: MockElement[]) { + this.children.push(...children); + } + + addEventListener(event: string, handler: Function) { + if (!this.listeners[event]) this.listeners[event] = []; + this.listeners[event].push(handler); + } + + dispatchEvent(event: any) { + const handlers = this.listeners[event.type] || []; + for (const h of handlers) h(event); + } + + focus = vi.fn(); + + click() { + this.dispatchEvent({ type: "click" }); + } + + querySelector(sel: string): MockElement | undefined { + if (sel === ".search-item") { + return this.children.find(c => c.className === "search-item"); + } + return undefined; + } + + isFragment = false; + } + + let elements: Record; + + beforeEach(() => { + elements = { + "#search-overlay": new MockElement(), + "#search-input": new MockElement(), + "#search-results": new MockElement(), + }; + + vi.stubGlobal("document", { + querySelector: vi.fn((sel: string) => elements[sel]), + createElement: vi.fn((tag: string) => new MockElement()), + createDocumentFragment: vi.fn(() => { + const frag = new MockElement(); + frag.isFragment = true; + return frag; + }), + }); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it("opens search and focuses input", () => { + const deps = { t: vi.fn(), onOpen: vi.fn() } as unknown as SearchDeps; + const ctrl = initSearch(deps); + + ctrl.open(); + + expect(elements["#search-overlay"].classList.remove).toHaveBeenCalledWith("hidden"); + expect(elements["#search-input"].value).toBe(""); + expect(elements["#search-input"].focus).toHaveBeenCalled(); + expect(elements["#search-results"].children).toHaveLength(0); + }); + + it("closes search on Escape key", () => { + const deps = { t: vi.fn(), onOpen: vi.fn() } as unknown as SearchDeps; + initSearch(deps); + + elements["#search-input"].dispatchEvent({ type: "keydown", key: "Escape" }); + + expect(elements["#search-overlay"].classList.add).toHaveBeenCalledWith("hidden"); + }); + + it("closes search on clicking overlay", () => { + const deps = { t: vi.fn(), onOpen: vi.fn() } as unknown as SearchDeps; + initSearch(deps); + + elements["#search-overlay"].dispatchEvent({ type: "mousedown", target: elements["#search-overlay"] }); + + expect(elements["#search-overlay"].classList.add).toHaveBeenCalledWith("hidden"); + }); + + it("renders empty state", () => { + const deps = { + t: vi.fn().mockReturnValue("No results"), + docs: () => [], + } as unknown as SearchDeps; + initSearch(deps); + + elements["#search-input"].value = "test query"; + elements["#search-input"].dispatchEvent({ type: "input" }); + + vi.runAllTimers(); + + const results = elements["#search-results"]; + expect(results.children).toHaveLength(1); + expect(results.children[0].className).toBe("search-empty"); + expect(results.children[0].textContent).toBe("No results"); + }); + + it("renders search hits and opens them on click", () => { + const deps = { + docs: () => [{ id: "doc1", title: "Doc 1", text: "test match" }], + onOpen: vi.fn(), + engine: new SumEngine(), + } as unknown as SearchDeps; + initSearch(deps); + + elements["#search-input"].value = "test"; + elements["#search-input"].dispatchEvent({ type: "input" }); + + vi.runAllTimers(); + + const results = elements["#search-results"]; + expect(results.children).toHaveLength(1); + + const item = results.children[0]; + expect(item.className).toBe("search-item"); + expect(item.children).toHaveLength(2); + expect(item.children[0].className).toBe("doc"); + expect(item.children[0].textContent).toBe("Doc 1"); + expect(item.children[1].className).toBe("line"); + expect(item.children[1].textContent).toBe("test match"); + + item.click(); + expect(deps.onOpen).toHaveBeenCalledWith("doc1", 1); + expect(elements["#search-overlay"].classList.add).toHaveBeenCalledWith("hidden"); + }); + + it("opens first result on Enter key", () => { + const deps = { + docs: () => [{ id: "doc1", title: "Doc 1", text: "test match" }], + onOpen: vi.fn(), + engine: new SumEngine(), + } as unknown as SearchDeps; + initSearch(deps); + + elements["#search-input"].value = "test"; + elements["#search-input"].dispatchEvent({ type: "input" }); + + vi.runAllTimers(); + + elements["#search-input"].dispatchEvent({ type: "keydown", key: "Enter" }); + + expect(deps.onOpen).toHaveBeenCalledWith("doc1", 1); + }); + + it("debounces search inputs", () => { + const deps = { + docs: vi.fn().mockReturnValue([]), + t: vi.fn(), + } as unknown as SearchDeps; + initSearch(deps); + + elements["#search-input"].value = "t"; + elements["#search-input"].dispatchEvent({ type: "input" }); + elements["#search-input"].value = "te"; + elements["#search-input"].dispatchEvent({ type: "input" }); + elements["#search-input"].value = "tes"; + elements["#search-input"].dispatchEvent({ type: "input" }); + + expect(deps.docs).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(150); + + expect(deps.docs).toHaveBeenCalledTimes(1); + }); +});