/** * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * Project: Groupe Ka / Ka Maps */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { BoundsQueryScheduler, stableStringify, } from "../src/services/boundsQuery.js"; import type { BoundsQuery, BoundsQueryResult, KaDataAdapter, MapProperty, } from "../src/types/index.js"; const BOX = { west: -71.4, south: 46.7, east: -71.1, north: 46.9 }; function prop(id: string): MapProperty { return { id, appSource: "lou-ka", latitude: 46.8, longitude: -71.2, kind: "listing", price: 1000, }; } function makeAdapter( impl: (q: BoundsQuery) => Promise, ): KaDataAdapter { return { id: "test", appSource: "lou-ka", fetchInBounds: impl }; } beforeEach(() => vi.useFakeTimers()); afterEach(() => vi.useRealTimers()); describe("BoundsQueryScheduler", () => { it("debounces bursts into one fetch", async () => { const calls: BoundsQuery[] = []; const s = new BoundsQueryScheduler( makeAdapter(async (q) => { calls.push(q); return { properties: [prop("a")] }; }), { debounceMs: 100 }, ); s.request({ bbox: BOX, zoom: 12 }); s.request({ bbox: BOX, zoom: 12.5 }); s.request({ bbox: BOX, zoom: 13 }); await vi.advanceTimersByTimeAsync(150); expect(calls).toHaveLength(1); expect(calls[0]!.zoom).toBe(13); s.destroy(); }); it("aborts the stale request and never delivers old results late", async () => { const results: string[] = []; let call = 0; const s = new BoundsQueryScheduler( makeAdapter((q) => { const mine = ++call; return new Promise((resolve, reject) => { q.signal.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")), ); // First call resolves slowly, second quickly. setTimeout( () => resolve({ properties: [prop(`r${mine}`)] }), mine === 1 ? 500 : 10, ); }); }), { debounceMs: 0, cacheTtlMs: 0 }, ); s.onResult((r) => results.push(r.properties[0]!.id)); s.requestNow({ bbox: BOX, zoom: 10 }); await vi.advanceTimersByTimeAsync(5); s.requestNow({ bbox: { ...BOX, north: 47.0 }, zoom: 11 }); await vi.advanceTimersByTimeAsync(600); expect(results).toEqual(["r2"]); s.destroy(); }); it("serves identical queries from cache", async () => { let fetches = 0; const s = new BoundsQueryScheduler( makeAdapter(async () => { fetches++; return { properties: [prop("a")], totalCount: 1 }; }), { debounceMs: 0 }, ); const seen: number[] = []; s.onResult((r) => seen.push(r.totalCount ?? 0)); s.requestNow({ bbox: BOX, zoom: 12, filters: { city: "Québec" } }); await vi.advanceTimersByTimeAsync(10); s.requestNow({ bbox: BOX, zoom: 12, filters: { city: "Québec" } }); await vi.advanceTimersByTimeAsync(10); expect(fetches).toBe(1); expect(seen).toHaveLength(2); s.destroy(); }); it("reports errors only for the newest request", async () => { const errors: unknown[] = []; const s = new BoundsQueryScheduler( makeAdapter(async () => { throw new Error("boom"); }), { debounceMs: 0 }, ); s.onError((e) => errors.push(e)); s.requestNow({ bbox: BOX, zoom: 12 }); await vi.advanceTimersByTimeAsync(10); expect(errors).toHaveLength(1); s.destroy(); }); }); describe("stableStringify", () => { it("is key-order independent", () => { expect(stableStringify({ b: 1, a: { d: 2, c: 3 } })).toBe( stableStringify({ a: { c: 3, d: 2 }, b: 1 }), ); }); });