/** * The header search bar is the single md+ search input. * * Off /search it navigates there (the only surface that renders results); on * /search it stays put and republishes the query into the URL, so the user goes * on typing in the same box instead of being handed to a second input owned by * the page. * * TRACES: UR-049, UR-054 | DR-147 */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/svelte"; const { pageStore, goto, clearSearch } = vi.hoisted(() => { const { writable } = require("svelte/store"); return { pageStore: writable({ url: new URL("http://localhost/library/music") }), goto: vi.fn(), clearSearch: vi.fn(), }; }); vi.mock("$app/stores", () => ({ page: pageStore, navigating: { subscribe: () => () => {} } })); vi.mock("$app/navigation", () => ({ goto, afterNavigate: vi.fn(), beforeNavigate: vi.fn() })); vi.mock("$lib/stores/library", () => ({ library: { subscribe: () => () => {}, search: vi.fn(), clearSearch }, })); import HeaderSearch from "./HeaderSearch.svelte"; const afterDebounce = () => new Promise((resolve) => setTimeout(resolve, 450)); function input(): HTMLInputElement { return screen.getByPlaceholderText("Search your library...") as HTMLInputElement; } beforeEach(() => { vi.clearAllMocks(); pageStore.set({ url: new URL("http://localhost/library/music") }); }); describe("from a library route", () => { it("routes to /search with the query and the route's scope", async () => { render(HeaderSearch); await fireEvent.input(input(), { target: { value: "jazz" } }); await afterDebounce(); expect(goto).toHaveBeenCalledWith("/search?q=jazz&scope=music"); }); it("clears the results rather than navigating on an empty query", async () => { render(HeaderSearch); await fireEvent.input(input(), { target: { value: "jazz" } }); await afterDebounce(); goto.mockClear(); await fireEvent.input(input(), { target: { value: "" } }); await afterDebounce(); expect(goto).not.toHaveBeenCalled(); expect(clearSearch).toHaveBeenCalled(); }); }); describe("on /search", () => { beforeEach(() => { pageStore.set({ url: new URL("http://localhost/search?q=jazz&scope=music") }); }); it("carries the query over from the URL and puts the caret back in the box", async () => { render(HeaderSearch); await waitFor(() => expect(document.activeElement).toBe(input())); expect(input().value).toBe("jazz"); expect(input().selectionStart).toBe(4); }); it("republishes in place instead of pushing a second results screen", async () => { render(HeaderSearch); await fireEvent.input(input(), { target: { value: "jazzy" } }); await afterDebounce(); expect(goto).toHaveBeenCalledWith("/search?q=jazzy&scope=music", { replaceState: true, keepFocus: true, noScroll: true, }); // The box the user is typing in keeps its text — nothing re-seeds it. expect(input().value).toBe("jazzy"); }); it("searches with the scope the page's chips published, not the route default", async () => { // A chip pick lands in the URL; the bar must adopt it or the next keystroke // would silently widen the search back to All. pageStore.set({ url: new URL("http://localhost/search?q=jazz&scope=tv") }); render(HeaderSearch); await fireEvent.input(input(), { target: { value: "jazzy" } }); await afterDebounce(); expect(goto).toHaveBeenCalledWith( "/search?q=jazzy&scope=tv", expect.objectContaining({ replaceState: true }), ); }); });