feat(search): SearchStore fuer Live-Search mit Web-Fallback
Extrahiert die duplizierte Such-Logik aus +page.svelte und +layout.svelte in eine gemeinsame Klasse. Pure Datenschicht mit injizierbarem fetch — UI-Concerns (URL-Sync, Dropdown, Snapshot) bleiben in den Komponenten. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
238
src/lib/client/search.svelte.ts
Normal file
238
src/lib/client/search.svelte.ts
Normal file
@@ -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<SearchHit[]>([]);
|
||||||
|
webHits = $state<WebHit[]>([]);
|
||||||
|
searching = $state(false);
|
||||||
|
webSearching = $state(false);
|
||||||
|
webError = $state<string | null>(null);
|
||||||
|
searchedFor = $state<string | null>(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<typeof setTimeout> | 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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
220
tests/unit/search-store.test.ts
Normal file
220
tests/unit/search-store.test.ts
Normal file
@@ -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<typeof vi.fn>;
|
||||||
|
|
||||||
|
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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user