/** * Regression: `/search` must not fight the user's typing when the page was * seeded from the URL (`?q=`), i.e. when the desktop header search navigated * here. * * The original seeding effect read `$library.searchQuery`, so every store write * re-ran it and re-asserted the URL's `q` over whatever had been typed since. * Typing one more character therefore snapped the input back to the query the * header had sent, and picking a scope chip snapped back to the URL's scope. * Arriving from the bottom-nav Search tab (no `?q=`) hit the effect's * empty-query early return, so the same component behaved correctly — the bug * only showed up on the header's route into the page. * * TRACES: UR-049 | DR-147 */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/svelte"; const { pageStore, libState, search, clearSearch, goto } = vi.hoisted(() => { // svelte/store is safe to import inside a hoisted factory (no test-file TDZ). const { writable } = require("svelte/store"); return { pageStore: writable({ url: new URL("http://localhost/search") }), libState: writable({ searchQuery: "", searchResults: [], loadingCount: 0 }), search: vi.fn(), clearSearch: vi.fn(), goto: 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: libState.subscribe, search, clearSearch }, })); import Page from "./+page.svelte"; /** The store write `library.search` performs — the trigger for the old loop. */ function primeSearch() { search.mockImplementation(async (query: string) => { libState.update((s: Record) => ({ ...s, searchQuery: query })); }); clearSearch.mockImplementation(() => { libState.update((s: Record) => ({ ...s, searchQuery: "", searchResults: [] })); }); } /** Wait past Search.svelte's 300ms input debounce, then let effects settle. */ const afterDebounce = () => new Promise((resolve) => setTimeout(resolve, 450)); function input(): HTMLInputElement { return screen.getByPlaceholderText("Search your library...") as HTMLInputElement; } beforeEach(() => { vi.clearAllMocks(); libState.set({ searchQuery: "", searchResults: [], loadingCount: 0 }); pageStore.set({ url: new URL("http://localhost/search") }); primeSearch(); // Model SvelteKit: a goto publishes the new URL through the page store, so a // scope chip's URL round trip really runs through the seeding effect. goto.mockImplementation(async (url: string) => { pageStore.set({ url: new URL(url, "http://localhost") }); }); }); describe("/search seeded from the URL", () => { it("keeps the text the user types on top of the seeded query", async () => { pageStore.set({ url: new URL("http://localhost/search?q=abc") }); render(Page); await waitFor(() => expect(search).toHaveBeenCalledWith("abc", "all")); await fireEvent.input(input(), { target: { value: "abcd" } }); await afterDebounce(); expect(input().value).toBe("abcd"); expect(search).toHaveBeenLastCalledWith("abcd", "all"); }); it("keeps a scope chip the user picks instead of snapping back to the URL", async () => { pageStore.set({ url: new URL("http://localhost/search?q=abc&scope=music") }); render(Page); await waitFor(() => expect(search).toHaveBeenCalledWith("abc", "music")); await fireEvent.click(screen.getByRole("radio", { name: "All" })); await afterDebounce(); expect(screen.getByRole("radio", { name: "All" }).getAttribute("aria-checked")).toBe("true"); expect(search).toHaveBeenLastCalledWith("abc", "all"); }); it("re-seeds when a fresh query arrives in the URL (header search on this page)", async () => { pageStore.set({ url: new URL("http://localhost/search?q=abc") }); render(Page); await waitFor(() => expect(search).toHaveBeenCalledWith("abc", "all")); pageStore.set({ url: new URL("http://localhost/search?q=zeppelin") }); await waitFor(() => expect(search).toHaveBeenLastCalledWith("zeppelin", "all")); expect(input().value).toBe("zeppelin"); }); it("clears when the URL query is emptied", async () => { pageStore.set({ url: new URL("http://localhost/search?q=abc") }); render(Page); await waitFor(() => expect(search).toHaveBeenCalledWith("abc", "all")); pageStore.set({ url: new URL("http://localhost/search") }); await waitFor(() => expect(input().value).toBe("")); expect(screen.getByText("Search your entire library")).toBeTruthy(); }); }); describe("/search entered with no query (bottom-nav Search tab)", () => { it("searches what the user types and leaves it alone", async () => { render(Page); await fireEvent.input(input(), { target: { value: "moon" } }); await afterDebounce(); expect(input().value).toBe("moon"); expect(search).toHaveBeenLastCalledWith("moon", "all"); }); });