// The offline "downloaded only" gate is a *backend* flag, so flipping it only // changes what the user sees if the listing is re-queried afterwards. Nothing // re-queried: the toggle greyed cards instantly (a pure frontend derivation in // MediaCard) while the item list stayed exactly as it was loaded, which is why // the filter read as "shows everything until I toggle, then greys some of it". // // `catalogFilterVersion` is the refetch signal, and it must bump only once the // backend has actually accepted the new flag — a reload racing the push would // re-query under the old gate and land back where it started. // // TRACES: UR-052 | DR-078, DR-143 | UT-137, UT-138, UT-139 import { describe, it, expect, vi, beforeEach } from "vitest"; import { get } from "svelte/store"; const h = vi.hoisted(() => { function shim(initial: T) { let value = initial; const subs = new Set<(v: T) => void>(); return { set(v: T) { value = v; subs.forEach((fn) => fn(value)); }, subscribe(fn: (v: T) => void) { subs.add(fn); fn(value); return () => subs.delete(fn); }, /** * Drop subscribers from previously imported copies of the module. * `vi.resetModules()` gives each test a fresh module instance, but this * store outlives them all — without this the stale instances keep * consuming `mockImplementationOnce` and pushing their own state. */ reset(v: T) { subs.clear(); value = v; }, }; } return { isConnectedStore: shim(true), // Resolution is deferred so a test can observe the window between "command // issued" and "command accepted". pending: [] as Array<() => void>, setShowServerCatalog: vi.fn( () => new Promise((resolve) => h.pending.push(() => resolve())), ), }; }); vi.mock("$lib/stores/connectivity", () => ({ isConnected: { subscribe: h.isConnectedStore.subscribe }, })); vi.mock("$lib/api/bindings", () => ({ commands: { setShowServerCatalog: h.setShowServerCatalog, syncFullCatalog: vi.fn(), resumeQueuedDownloads: vi.fn(), catalogSyncStatus: vi.fn(), }, })); vi.mock("$lib/stores/auth", () => ({ auth: { getRepository: () => ({ getHandle: () => "handle-1" }) }, })); /** Let every issued setShowServerCatalog settle. */ async function settle() { h.pending.splice(0).forEach((resolve) => resolve()); await Promise.resolve(); await Promise.resolve(); } describe("offline filter refetch signal (DR-143)", () => { beforeEach(() => { h.setShowServerCatalog.mockReset(); h.setShowServerCatalog.mockImplementation( () => new Promise((resolve) => h.pending.push(() => resolve())), ); h.pending.length = 0; h.isConnectedStore.reset(true); vi.resetModules(); }); it("bumps the version when going offline closes the gate", async () => { const mod = await import("./offlineCatalog"); await settle(); const before = get(mod.catalogFilterVersion); h.isConnectedStore.set(false); // include: true → false await settle(); expect(h.setShowServerCatalog).toHaveBeenLastCalledWith(false); expect(get(mod.catalogFilterVersion)).toBeGreaterThan(before); }); it("bumps only after the backend accepts the flag, never before", async () => { const mod = await import("./offlineCatalog"); await settle(); const before = get(mod.catalogFilterVersion); h.isConnectedStore.set(false); await Promise.resolve(); // command issued, not yet resolved expect(h.setShowServerCatalog).toHaveBeenLastCalledWith(false); expect(get(mod.catalogFilterVersion)).toBe(before); await settle(); expect(get(mod.catalogFilterVersion)).toBeGreaterThan(before); }); it("bumps both ways as the offline toggle is flipped", async () => { const mod = await import("./offlineCatalog"); h.isConnectedStore.set(false); await settle(); const offlineGated = get(mod.catalogFilterVersion); mod.showServerCatalog.set(true); // reveal the server catalog await settle(); expect(h.setShowServerCatalog).toHaveBeenLastCalledWith(true); const revealed = get(mod.catalogFilterVersion); expect(revealed).toBeGreaterThan(offlineGated); mod.showServerCatalog.set(false); // back to downloaded-only await settle(); expect(h.setShowServerCatalog).toHaveBeenLastCalledWith(false); expect(get(mod.catalogFilterVersion)).toBeGreaterThan(revealed); }); it("does not bump when the effective gate is unchanged", async () => { const mod = await import("./offlineCatalog"); await settle(); const before = get(mod.catalogFilterVersion); // Online, the toggle cannot close the gate — browsing reads the same cache. mod.showServerCatalog.set(true); await settle(); expect(get(mod.catalogFilterVersion)).toBe(before); }); it("retries the push after a failed one rather than latching the old gate", async () => { const mod = await import("./offlineCatalog"); await settle(); const before = get(mod.catalogFilterVersion); h.setShowServerCatalog.mockImplementationOnce(() => Promise.reject(new Error("ipc down"))); h.isConnectedStore.set(false); await settle(); expect(get(mod.catalogFilterVersion)).toBe(before); // nothing to reload for // The same transition must be attempted again, not skipped as "already sent". h.isConnectedStore.set(true); await settle(); h.isConnectedStore.set(false); await settle(); expect(h.setShowServerCatalog).toHaveBeenLastCalledWith(false); expect(get(mod.catalogFilterVersion)).toBeGreaterThan(before); }); });