mirror of
https://github.com/Horizontal42/summarum.git
synced 2026-11-10 23:51:59 +03:00
Merge d51ad297a1 into 37cfd2144b
This commit is contained in:
commit
0d2d289260
1 changed files with 188 additions and 2 deletions
|
|
@ -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 { parseResultQuery, searchAllSheets, initSearch, 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,189 @@ describe("searchAllSheets", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("initSearch", () => {
|
||||||
|
class MockElement {
|
||||||
|
public classList = {
|
||||||
|
classes: new Set<string>(),
|
||||||
|
add: (c: string) => this.classList.classes.add(c),
|
||||||
|
remove: (c: string) => this.classList.classes.delete(c),
|
||||||
|
contains: (c: string) => this.classList.classes.has(c),
|
||||||
|
};
|
||||||
|
public value = "";
|
||||||
|
public className = "";
|
||||||
|
public textContent = "";
|
||||||
|
public children: MockElement[] = [];
|
||||||
|
public listeners: Record<string, ((e: any) => void)[]> = {};
|
||||||
|
|
||||||
|
constructor(public tag?: string) {}
|
||||||
|
|
||||||
|
replaceChildren() {
|
||||||
|
this.children = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
appendChild(child: MockElement) {
|
||||||
|
if (child instanceof MockDocumentFragment) {
|
||||||
|
this.children.push(...child.children);
|
||||||
|
} else {
|
||||||
|
this.children.push(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
append(...nodes: MockElement[]) {
|
||||||
|
this.children.push(...nodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
addEventListener(event: string, handler: (e: any) => void) {
|
||||||
|
if (!this.listeners[event]) this.listeners[event] = [];
|
||||||
|
this.listeners[event].push(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatchEvent(event: string, e: any = {}) {
|
||||||
|
for (const h of this.listeners[event] || []) {
|
||||||
|
h(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
click() {
|
||||||
|
this.dispatchEvent("click");
|
||||||
|
}
|
||||||
|
|
||||||
|
focus() {}
|
||||||
|
|
||||||
|
querySelector(sel: string): MockElement | null {
|
||||||
|
if (sel === ".search-item") {
|
||||||
|
return this.children.find((c) => c.className === "search-item") || null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockDocumentFragment extends MockElement {}
|
||||||
|
|
||||||
|
let mockOverlay: MockElement;
|
||||||
|
let mockInput: MockElement;
|
||||||
|
let mockResults: MockElement;
|
||||||
|
let deps: SearchDeps;
|
||||||
|
|
||||||
|
const mockDocument = {
|
||||||
|
querySelector(sel: string) {
|
||||||
|
if (sel === "#search-overlay") return mockOverlay;
|
||||||
|
if (sel === "#search-input") return mockInput;
|
||||||
|
if (sel === "#search-results") return mockResults;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
createElement(tag: string) {
|
||||||
|
return new MockElement(tag);
|
||||||
|
},
|
||||||
|
createDocumentFragment() {
|
||||||
|
return new MockDocumentFragment();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockOverlay = new MockElement("div");
|
||||||
|
mockInput = new MockElement("input");
|
||||||
|
mockResults = new MockElement("div");
|
||||||
|
vi.stubGlobal("document", mockDocument);
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
deps = {
|
||||||
|
engine: new SumEngine(),
|
||||||
|
workspace: {} as any,
|
||||||
|
docs: () => [],
|
||||||
|
t: (k: string) => `trans_${k}`,
|
||||||
|
onOpen: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the search overlay and focuses input", () => {
|
||||||
|
const controller = initSearch(deps);
|
||||||
|
mockOverlay.classList.add("hidden");
|
||||||
|
|
||||||
|
controller.open();
|
||||||
|
|
||||||
|
expect(mockOverlay.classList.contains("hidden")).toBe(false);
|
||||||
|
expect(mockInput.value).toBe("");
|
||||||
|
expect(mockResults.children).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes on Escape key", () => {
|
||||||
|
initSearch(deps);
|
||||||
|
mockInput.dispatchEvent("keydown", { key: "Escape" });
|
||||||
|
expect(mockOverlay.classList.contains("hidden")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes on overlay mousedown", () => {
|
||||||
|
initSearch(deps);
|
||||||
|
mockOverlay.dispatchEvent("mousedown", { target: mockOverlay });
|
||||||
|
expect(mockOverlay.classList.contains("hidden")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not close on mousedown inside overlay", () => {
|
||||||
|
initSearch(deps);
|
||||||
|
mockOverlay.dispatchEvent("mousedown", { target: new MockElement() });
|
||||||
|
expect(mockOverlay.classList.contains("hidden")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders empty state for query with no hits", () => {
|
||||||
|
initSearch(deps);
|
||||||
|
mockInput.value = "no results";
|
||||||
|
mockInput.dispatchEvent("input");
|
||||||
|
vi.advanceTimersByTime(150);
|
||||||
|
|
||||||
|
expect(mockResults.children).toHaveLength(1);
|
||||||
|
expect(mockResults.children[0].className).toBe("search-empty");
|
||||||
|
expect(mockResults.children[0].textContent).toBe("trans_searchEmpty");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders hits and handles click selection", () => {
|
||||||
|
deps.docs = () => [{ id: "doc1", title: "Doc 1", text: "hello world" }];
|
||||||
|
initSearch(deps);
|
||||||
|
mockInput.value = "hello";
|
||||||
|
mockInput.dispatchEvent("input");
|
||||||
|
vi.advanceTimersByTime(150);
|
||||||
|
|
||||||
|
expect(mockResults.children).toHaveLength(1);
|
||||||
|
const item = mockResults.children[0];
|
||||||
|
expect(item.className).toBe("search-item");
|
||||||
|
expect(item.children[0].textContent).toBe("Doc 1");
|
||||||
|
expect(item.children[1].textContent).toBe("hello world");
|
||||||
|
|
||||||
|
item.click();
|
||||||
|
expect(deps.onOpen).toHaveBeenCalledWith("doc1", 1);
|
||||||
|
expect(mockOverlay.classList.contains("hidden")).toBe(true); // closes after open
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles Enter key selection", () => {
|
||||||
|
deps.docs = () => [{ id: "doc2", title: "Doc 2", text: "hit" }];
|
||||||
|
initSearch(deps);
|
||||||
|
mockInput.value = "hit";
|
||||||
|
mockInput.dispatchEvent("input");
|
||||||
|
vi.advanceTimersByTime(150);
|
||||||
|
|
||||||
|
mockInput.dispatchEvent("keydown", { key: "Enter" });
|
||||||
|
expect(deps.onOpen).toHaveBeenCalledWith("doc2", 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("debounces search input", () => {
|
||||||
|
const renderSpy = vi.spyOn(mockResults, "replaceChildren");
|
||||||
|
initSearch(deps);
|
||||||
|
|
||||||
|
mockInput.value = "h";
|
||||||
|
mockInput.dispatchEvent("input");
|
||||||
|
mockInput.value = "he";
|
||||||
|
mockInput.dispatchEvent("input");
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
expect(renderSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(50);
|
||||||
|
expect(renderSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue