diff --git a/src/lib/client/search.svelte.ts b/src/lib/client/search.svelte.ts new file mode 100644 index 0000000..a9c0786 --- /dev/null +++ b/src/lib/client/search.svelte.ts @@ -0,0 +1,238 @@ +import type { SearchHit } from '$lib/server/recipes/search-local'; +import type { WebHit } from '$lib/server/search/searxng'; + +export type SearchSnapshot = { + query: string; + hits: SearchHit[]; + webHits: WebHit[]; + searchedFor: string | null; + webError: string | null; + localExhausted: boolean; + webPageno: number; + webExhausted: boolean; +}; + +export type SearchStoreOptions = { + pageSize?: number; + debounceMs?: number; + filterDebounceMs?: number; + minQueryLength?: number; + filterParam?: () => string; + fetchImpl?: typeof fetch; +}; + +export class SearchStore { + #query = $state(''); + hits = $state([]); + webHits = $state([]); + searching = $state(false); + webSearching = $state(false); + webError = $state(null); + searchedFor = $state(null); + localExhausted = $state(false); + webPageno = $state(0); + webExhausted = $state(false); + loadingMore = $state(false); + + get query(): string { + return this.#query; + } + + set query(v: string) { + this.#query = v; + // Race-guard: when the query diverges from the last committed search, + // any currently shown hits are stale and must be cleared immediately. + if (this.searchedFor !== null && v.trim() !== this.searchedFor) { + this.hits = []; + this.webHits = []; + this.searchedFor = null; + } + } + + private readonly pageSize: number; + private readonly debounceMs: number; + private readonly filterDebounceMs: number; + private readonly minQueryLength: number; + private readonly filterParam: () => string; + private readonly fetchImpl: typeof fetch; + private debounceTimer: ReturnType | null = null; + private skipNextDebounce = false; + + constructor(opts: SearchStoreOptions = {}) { + this.pageSize = opts.pageSize ?? 30; + this.debounceMs = opts.debounceMs ?? 300; + this.filterDebounceMs = opts.filterDebounceMs ?? 150; + this.minQueryLength = opts.minQueryLength ?? 4; + this.filterParam = opts.filterParam ?? (() => ''); + this.fetchImpl = opts.fetchImpl ?? ((...a) => fetch(...a)); + } + + runDebounced(_q: string): void { + if (this.debounceTimer) clearTimeout(this.debounceTimer); + if (this.skipNextDebounce) { + this.skipNextDebounce = false; + return; + } + const q = this.query.trim(); + if (q.length < this.minQueryLength) { + this.resetResults(); + return; + } + this.searching = true; + this.webHits = []; + this.webSearching = false; + this.webError = null; + this.debounceTimer = setTimeout(() => { + void this.runSearch(q); + }, this.debounceMs); + } + + async runSearch(q: string): Promise { + this.localExhausted = false; + this.webPageno = 0; + this.webExhausted = false; + try { + const res = await this.fetchImpl( + `/api/recipes/search?q=${encodeURIComponent(q)}&limit=${this.pageSize}${this.filterParam()}` + ); + const body = (await res.json()) as { hits: SearchHit[] }; + if (this.query.trim() !== q) return; + this.hits = body.hits; + this.searchedFor = q; + if (this.hits.length < this.pageSize) this.localExhausted = true; + if (this.hits.length === 0) { + await this.runWebSearch(q, 1); + } + } finally { + if (this.query.trim() === q) this.searching = false; + } + } + + private async runWebSearch(q: string, pageno: number): Promise { + this.webSearching = true; + try { + const res = await this.fetchImpl( + `/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=${pageno}${this.filterParam()}` + ); + if (this.query.trim() !== q) return; + if (!res.ok) { + const err = (await res.json().catch(() => ({}))) as { message?: string }; + this.webError = err.message ?? `HTTP ${res.status}`; + this.webExhausted = true; + return; + } + const body = (await res.json()) as { hits: WebHit[] }; + this.webHits = pageno === 1 ? body.hits : [...this.webHits, ...body.hits]; + this.webPageno = pageno; + if (body.hits.length === 0) this.webExhausted = true; + } finally { + if (this.query.trim() === q) this.webSearching = false; + } + } + + async loadMore(): Promise { + if (this.loadingMore) return; + const q = this.query.trim(); + if (!q) return; + this.loadingMore = true; + try { + if (!this.localExhausted) { + const res = await this.fetchImpl( + `/api/recipes/search?q=${encodeURIComponent(q)}&limit=${this.pageSize}&offset=${this.hits.length}${this.filterParam()}` + ); + const body = (await res.json()) as { hits: SearchHit[] }; + if (this.query.trim() !== q) return; + const more = body.hits; + const seen = new Set(this.hits.map((h) => h.id)); + const deduped = more.filter((h) => !seen.has(h.id)); + this.hits = [...this.hits, ...deduped]; + if (more.length < this.pageSize) this.localExhausted = true; + } else if (!this.webExhausted) { + const nextPage = this.webPageno + 1; + const wasEmpty = this.webHits.length === 0; + if (wasEmpty) this.webSearching = true; + try { + const res = await this.fetchImpl( + `/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=${nextPage}${this.filterParam()}` + ); + if (this.query.trim() !== q) return; + if (!res.ok) { + const err = (await res.json().catch(() => ({}))) as { message?: string }; + this.webError = err.message ?? `HTTP ${res.status}`; + this.webExhausted = true; + return; + } + const body = (await res.json()) as { hits: WebHit[] }; + const more = body.hits; + const seen = new Set(this.webHits.map((h) => h.url)); + const deduped = more.filter((h) => !seen.has(h.url)); + if (deduped.length === 0) { + this.webExhausted = true; + } else { + this.webHits = [...this.webHits, ...deduped]; + this.webPageno = nextPage; + } + } finally { + if (this.query.trim() === q) this.webSearching = false; + } + } + } finally { + this.loadingMore = false; + } + } + + reSearch(): void { + const q = this.query.trim(); + if (q.length < this.minQueryLength) return; + if (this.debounceTimer) clearTimeout(this.debounceTimer); + this.searching = true; + this.webHits = []; + this.webSearching = false; + this.webError = null; + this.debounceTimer = setTimeout(() => void this.runSearch(q), this.filterDebounceMs); + } + + reset(): void { + if (this.debounceTimer) clearTimeout(this.debounceTimer); + this.debounceTimer = null; + this.query = ''; + this.resetResults(); + } + + private resetResults(): void { + this.hits = []; + this.webHits = []; + this.searchedFor = null; + this.searching = false; + this.webSearching = false; + this.webError = null; + this.localExhausted = false; + this.webPageno = 0; + this.webExhausted = false; + } + + captureSnapshot(): SearchSnapshot { + return { + query: this.query, + hits: this.hits, + webHits: this.webHits, + searchedFor: this.searchedFor, + webError: this.webError, + localExhausted: this.localExhausted, + webPageno: this.webPageno, + webExhausted: this.webExhausted + }; + } + + restoreSnapshot(s: SearchSnapshot): void { + this.skipNextDebounce = true; + this.query = s.query; + this.hits = s.hits; + this.webHits = s.webHits; + this.searchedFor = s.searchedFor; + this.webError = s.webError; + this.localExhausted = s.localExhausted; + this.webPageno = s.webPageno; + this.webExhausted = s.webExhausted; + } +} diff --git a/tests/unit/search-store.test.ts b/tests/unit/search-store.test.ts new file mode 100644 index 0000000..ca5f4fc --- /dev/null +++ b/tests/unit/search-store.test.ts @@ -0,0 +1,220 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SearchStore, type SearchSnapshot } from '../../src/lib/client/search.svelte'; + +type FetchMock = ReturnType; + +function mockFetch(responses: Array<{ ok?: boolean; status?: number; body: unknown }>): FetchMock { + const calls = [...responses]; + return vi.fn(async () => { + const r = calls.shift(); + if (!r) throw new Error('fetch called more times than expected'); + return { + ok: r.ok ?? true, + status: r.status ?? 200, + json: async () => r.body + } as Response; + }); +} + +describe('SearchStore', () => { + beforeEach(() => { + vi.useRealTimers(); + }); + + it('keeps results empty while query is <= 3 chars (debounced)', async () => { + vi.useFakeTimers(); + const fetchImpl = mockFetch([]); + const store = new SearchStore({ fetchImpl, debounceMs: 50 }); + store.query = 'abc'; + store.runDebounced(store.query); + await vi.advanceTimersByTimeAsync(100); + expect(store.searching).toBe(false); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('fires local search after debounce when query > 3 chars', async () => { + vi.useFakeTimers(); + const fetchImpl = mockFetch([ + { body: { hits: [{ id: 1, title: 'Pasta', description: null, image_path: null, source_domain: null, avg_stars: null, last_cooked_at: null }] } } + ]); + const store = new SearchStore({ fetchImpl, debounceMs: 50, pageSize: 30 }); + store.query = 'pasta'; + store.runDebounced(store.query); + expect(store.searching).toBe(true); + await vi.advanceTimersByTimeAsync(100); + await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalled()); + expect(fetchImpl.mock.calls[0][0]).toMatch(/\/api\/recipes\/search\?q=pasta&limit=30/); + expect(store.hits).toHaveLength(1); + expect(store.searchedFor).toBe('pasta'); + expect(store.localExhausted).toBe(true); + }); + + it('falls back to web search when local returns zero hits', async () => { + vi.useFakeTimers(); + const fetchImpl = mockFetch([ + { body: { hits: [] } }, + { body: { hits: [{ url: 'https://chefkoch.de/x', title: 'Foo', domain: 'chefkoch.de', snippet: null, thumbnail: null }] } } + ]); + const store = new SearchStore({ fetchImpl, debounceMs: 50 }); + store.query = 'pizza'; + store.runDebounced(store.query); + await vi.advanceTimersByTimeAsync(100); + await vi.waitFor(() => expect(store.webHits).toHaveLength(1)); + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(fetchImpl.mock.calls[1][0]).toMatch(/\/api\/recipes\/search\/web\?q=pizza&pageno=1/); + expect(store.webPageno).toBe(1); + }); + + it('races-guards: stale response discarded when query changed mid-flight', async () => { + vi.useFakeTimers(); + const fetchImpl = mockFetch([ + { body: { hits: [{ id: 99, title: 'Stale', description: null, image_path: null, source_domain: null, avg_stars: null, last_cooked_at: null }] } } + ]); + const store = new SearchStore({ fetchImpl, debounceMs: 10 }); + store.query = 'stale-query'; + store.runDebounced(store.query); + await vi.advanceTimersByTimeAsync(15); + store.query = 'different'; + await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalled()); + expect(store.hits).toEqual([]); + }); + + it('loadMore: drains local first (offset pagination)', async () => { + vi.useFakeTimers(); + const page1 = Array.from({ length: 30 }, (_, i) => ({ id: i, title: `r${i}`, description: null, image_path: null, source_domain: null, avg_stars: null, last_cooked_at: null })); + const page2 = Array.from({ length: 5 }, (_, i) => ({ id: i + 30, title: `r${i + 30}`, description: null, image_path: null, source_domain: null, avg_stars: null, last_cooked_at: null })); + const fetchImpl = mockFetch([ + { body: { hits: page1 } }, + { body: { hits: page2 } } + ]); + const store = new SearchStore({ fetchImpl, debounceMs: 10, pageSize: 30 }); + store.query = 'meal'; + store.runDebounced(store.query); + await vi.advanceTimersByTimeAsync(15); + await vi.waitFor(() => expect(store.hits).toHaveLength(30)); + expect(store.localExhausted).toBe(false); + await store.loadMore(); + expect(store.hits).toHaveLength(35); + expect(fetchImpl.mock.calls[1][0]).toMatch(/offset=30/); + expect(store.localExhausted).toBe(true); + }); + + it('loadMore: switches to web pagination after local exhausted', async () => { + vi.useFakeTimers(); + const local = [{ id: 1, title: 'local', description: null, image_path: null, source_domain: null, avg_stars: null, last_cooked_at: null }]; + const webP1 = [{ url: 'https://a.com', title: 'A', domain: 'a.com', snippet: null, thumbnail: null }]; + const webP2 = [{ url: 'https://b.com', title: 'B', domain: 'b.com', snippet: null, thumbnail: null }]; + const fetchImpl = mockFetch([ + { body: { hits: local } }, + { body: { hits: webP1 } }, + { body: { hits: webP2 } } + ]); + const store = new SearchStore({ fetchImpl, debounceMs: 10, pageSize: 30 }); + store.query = 'soup'; + store.runDebounced(store.query); + await vi.advanceTimersByTimeAsync(15); + await vi.waitFor(() => expect(store.hits).toHaveLength(1)); + expect(store.localExhausted).toBe(true); + await store.loadMore(); + expect(store.webHits).toHaveLength(1); + await store.loadMore(); + expect(store.webHits).toHaveLength(2); + expect(store.webPageno).toBe(2); + }); + + it('web search error sets webError and marks webExhausted', async () => { + vi.useFakeTimers(); + const fetchImpl = mockFetch([ + { body: { hits: [] } }, + { ok: false, status: 502, body: { message: 'SearXNG unreachable' } } + ]); + const store = new SearchStore({ fetchImpl, debounceMs: 10 }); + store.query = 'anything'; + store.runDebounced(store.query); + await vi.advanceTimersByTimeAsync(15); + await vi.waitFor(() => expect(store.webError).toBe('SearXNG unreachable')); + expect(store.webExhausted).toBe(true); + }); + + it('reset(): clears query, results, and pending debounce', async () => { + vi.useFakeTimers(); + const fetchImpl = mockFetch([]); + const store = new SearchStore({ fetchImpl, debounceMs: 100 }); + store.query = 'foobar'; + store.runDebounced(store.query); + store.reset(); + await vi.advanceTimersByTimeAsync(200); + expect(store.query).toBe(''); + expect(store.hits).toEqual([]); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('captureSnapshot / restoreSnapshot: round-trips without re-fetching', async () => { + vi.useFakeTimers(); + const fetchImpl = mockFetch([]); + const store = new SearchStore({ fetchImpl, debounceMs: 50 }); + const snap: SearchSnapshot = { + query: 'lasagne', + hits: [{ id: 7, title: 'Lasagne', description: null, image_path: null, source_domain: null, avg_stars: null, last_cooked_at: null }], + webHits: [], + searchedFor: 'lasagne', + webError: null, + localExhausted: true, + webPageno: 0, + webExhausted: false + }; + store.restoreSnapshot(snap); + expect(store.query).toBe('lasagne'); + expect(store.hits).toHaveLength(1); + store.runDebounced(store.query); + await vi.advanceTimersByTimeAsync(100); + expect(fetchImpl).not.toHaveBeenCalled(); + const round = store.captureSnapshot(); + expect(round).toEqual(snap); + }); + + it('filterParam option: gets appended to both local and web requests', async () => { + vi.useFakeTimers(); + const fetchImpl = mockFetch([ + { body: { hits: [] } }, + { body: { hits: [] } } + ]); + const store = new SearchStore({ + fetchImpl, + debounceMs: 10, + filterParam: () => '&domains=chefkoch.de' + }); + store.query = 'curry'; + store.runDebounced(store.query); + await vi.advanceTimersByTimeAsync(15); + await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(2)); + expect(fetchImpl.mock.calls[0][0]).toMatch(/&domains=chefkoch\.de/); + expect(fetchImpl.mock.calls[1][0]).toMatch(/&domains=chefkoch\.de/); + }); + + it('reSearch: immediate re-run with current query on filter change', async () => { + vi.useFakeTimers(); + let filter = ''; + const fetchImpl = mockFetch([ + { body: { hits: [] } }, + { body: { hits: [] } }, + { body: { hits: [{ id: 1, title: 'filtered', description: null, image_path: null, source_domain: null, avg_stars: null, last_cooked_at: null }] } } + ]); + const store = new SearchStore({ + fetchImpl, + debounceMs: 10, + filterDebounceMs: 5, + filterParam: () => filter + }); + store.query = 'broth'; + store.runDebounced(store.query); + await vi.advanceTimersByTimeAsync(15); + filter = '&domains=chefkoch.de'; + store.reSearch(); + await vi.advanceTimersByTimeAsync(10); + await vi.waitFor(() => expect(store.hits).toHaveLength(1)); + const last = fetchImpl.mock.calls.at(-1)?.[0] as string; + expect(last).toMatch(/&domains=chefkoch\.de/); + }); +});