From fc47c783973c5c98850761bfceb50307999d93ff Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 19 Apr 2026 12:41:43 +0200 Subject: [PATCH] fix(search): Race-Guard-Test korrekt auf in-flight abzielen Der vorherige Test setzte query NACH dem Fetch-Abschluss und erzwang dafuer einen setter-Side-Effect, der bei normalem Tippen die Treffer waehrend des Debounce-Fensters fuer 300ms leer geblitzt haette. Jetzt: echter Race-Test mit manuell aufloesbarem fetch. Setter-Nebenwirkung entfernt, query ist wieder plain \$state. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/client/search.svelte.ts | 17 +-------------- tests/unit/search-store.test.ts | 38 ++++++++++++++++++++++++++++----- 2 files changed, 34 insertions(+), 21 deletions(-) diff --git a/src/lib/client/search.svelte.ts b/src/lib/client/search.svelte.ts index a9c0786..4121ac9 100644 --- a/src/lib/client/search.svelte.ts +++ b/src/lib/client/search.svelte.ts @@ -22,7 +22,7 @@ export type SearchStoreOptions = { }; export class SearchStore { - #query = $state(''); + query = $state(''); hits = $state([]); webHits = $state([]); searching = $state(false); @@ -34,21 +34,6 @@ export class SearchStore { 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; diff --git a/tests/unit/search-store.test.ts b/tests/unit/search-store.test.ts index ca5f4fc..83689c5 100644 --- a/tests/unit/search-store.test.ts +++ b/tests/unit/search-store.test.ts @@ -66,18 +66,46 @@ describe('SearchStore', () => { expect(store.webPageno).toBe(1); }); - it('races-guards: stale response discarded when query changed mid-flight', async () => { + it('race-guard: fetch 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 }] } } - ]); + let resolveFetch!: (v: Response) => void; + const fetchImpl = vi.fn( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }) + ); const store = new SearchStore({ fetchImpl, debounceMs: 10 }); store.query = 'stale-query'; store.runDebounced(store.query); await vi.advanceTimersByTimeAsync(15); + expect(fetchImpl).toHaveBeenCalledTimes(1); + // User keeps typing BEFORE the response arrives — race-guard should kick in + // when the fetch finally resolves. store.query = 'different'; - await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalled()); + resolveFetch({ + ok: true, + status: 200, + json: async () => ({ + hits: [ + { + id: 99, + title: 'Stale', + description: null, + image_path: null, + source_domain: null, + avg_stars: null, + last_cooked_at: null + } + ] + }) + } as Response); + // Flush microtasks so the awaited response + race-guard run. + await vi.runOnlyPendingTimersAsync(); + await Promise.resolve(); + await Promise.resolve(); expect(store.hits).toEqual([]); + expect(store.searchedFor).toBeNull(); }); it('loadMore: drains local first (offset pagination)', async () => {