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>
This commit is contained in:
google-labs-jules[bot] 2026-09-10 08:43:02 +00:00
parent 37cfd2144b
commit 440794e6e2

View file

@ -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 { SumEngine } from "../engine";
import { parseResultQuery, searchAllSheets, SearchDeps, SearchDoc } from "./search"; import { initSearch, parseResultQuery, searchAllSheets, SearchDeps, SearchDoc } from "./search";
import { Workspace } from "../workspace"; import { Workspace } from "../workspace";
import Decimal from "decimal.js"; 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<string, Function[]> = {};
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<string, MockElement>;
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);
});
});