diff --git a/src/lib/services/offlineCatalog.test.ts b/src/lib/services/offlineCatalog.test.ts new file mode 100644 index 00000000..e863c129 --- /dev/null +++ b/src/lib/services/offlineCatalog.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// UT-068: `pushCatalogVisibility` resolves `serverReachable || showCatalog` and +// pushes the result to the backend whenever either input changes. +// See docs/specs/offline-downloaded-only-filter.md (DR-078/DR-079). +// +// `isConnected` now tracks server reachability alone (DR-079), so from this +// service's perspective its input is "is the server reachable". We drive it and +// the `showServerCatalog` toggle and assert what gets pushed via +// `commands.setShowServerCatalog`. + +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); + }, + }; + } + return { + isConnectedStore: shim(true), + setShowServerCatalog: vi.fn(async () => {}), + }; +}); + +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" }) }, +})); + +describe("pushCatalogVisibility resolves reachable || showCatalog (UT-068)", () => { + beforeEach(() => { + h.setShowServerCatalog.mockClear(); + h.isConnectedStore.set(true); + vi.resetModules(); + }); + + it("pushes include=true while the server is reachable (initial subscribe)", async () => { + const { showServerCatalog } = await import("./offlineCatalog"); + // Initial subscription with reachable=true, showCatalog=false ⇒ include=true. + expect(h.setShowServerCatalog).toHaveBeenLastCalledWith(true); + // Keep the import binding referenced so tree-shaking never elides it. + expect(showServerCatalog).toBeDefined(); + }); + + it("pushes include=false when unreachable and the toggle is off", async () => { + h.isConnectedStore.set(false); + await import("./offlineCatalog"); + // Fresh module subscribes with reachable=false, showCatalog=false ⇒ false. + expect(h.setShowServerCatalog).toHaveBeenLastCalledWith(false); + }); + + it("re-pushes include=true when the toggle flips on while unreachable", async () => { + h.isConnectedStore.set(false); + const { showServerCatalog } = await import("./offlineCatalog"); + h.setShowServerCatalog.mockClear(); + + showServerCatalog.set(true); // showCatalog input changes ⇒ include flips to true + expect(h.setShowServerCatalog).toHaveBeenLastCalledWith(true); + + h.setShowServerCatalog.mockClear(); + showServerCatalog.set(false); // back off ⇒ include flips to false + expect(h.setShowServerCatalog).toHaveBeenLastCalledWith(false); + }); + + it("does not re-push when the resolved value is unchanged", async () => { + // reachable=true ⇒ include already true. Turning the toggle on keeps it true. + const { showServerCatalog } = await import("./offlineCatalog"); + h.setShowServerCatalog.mockClear(); + + showServerCatalog.set(true); // include stays true (true || true) ⇒ no push + expect(h.setShowServerCatalog).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/services/offlineCatalog.ts b/src/lib/services/offlineCatalog.ts index b61c7470..93cff869 100644 --- a/src/lib/services/offlineCatalog.ts +++ b/src/lib/services/offlineCatalog.ts @@ -11,7 +11,7 @@ // It also owns the `showServerCatalog` UI flag (the offline banner toggle that // reveals greyed-out, non-downloaded server media). // -// TRACES: UR-002 +// TRACES: UR-002, UR-052 | DR-078 import { writable, type Writable } from "svelte/store"; import { commands } from "$lib/api/bindings"; diff --git a/src/lib/stores/connectivity.test.ts b/src/lib/stores/connectivity.test.ts new file mode 100644 index 00000000..ba2acb76 --- /dev/null +++ b/src/lib/stores/connectivity.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { get } from "svelte/store"; + +// UT-069: `isConnected` must follow backend server reachability ALONE, never +// navigator.onLine. See docs/specs/offline-downloaded-only-filter.md (DR-079). +// +// The connectivity store registers a `connectivity:changed` listener at import +// time and mirrors its payload into `isServerReachable`. We capture that +// listener via the mocked `listen`, then drive reachability directly while +// pinning navigator.onLine to the *opposite* value to prove it is ignored. + +const h = vi.hoisted(() => { + const listeners = new Map void>(); + return { listeners }; +}); + +vi.mock("@tauri-apps/api/event", () => ({ + listen: vi.fn(async (name: string, cb: (event: { payload: unknown }) => void) => { + h.listeners.set(name, cb); + return () => h.listeners.delete(name); + }), +})); + +vi.mock("$lib/api/bindings", () => ({ + commands: { + connectivityCheckServer: vi.fn(async () => true), + connectivityGetStatus: vi.fn(async () => ({ + isServerReachable: true, + lastChecked: null, + connectionError: null, + isChecking: false, + })), + connectivitySetServerUrl: vi.fn(async () => {}), + connectivityStartMonitoring: vi.fn(async () => {}), + connectivityStopMonitoring: vi.fn(async () => {}), + }, +})); + +vi.mock("$app/environment", () => ({ browser: true })); + +function setNavigatorOnLine(value: boolean) { + Object.defineProperty(window.navigator, "onLine", { + configurable: true, + get: () => value, + }); +} + +function emitReachable(isReachable: boolean) { + const cb = h.listeners.get("connectivity:changed"); + if (!cb) throw new Error("connectivity:changed listener was not registered"); + cb({ payload: { isReachable } }); +} + +describe("isConnected follows server reachability alone (UT-069)", () => { + beforeEach(() => { + h.listeners.clear(); + vi.resetModules(); // fresh connectivity module ⇒ re-registers its listener + }); + + it("is false when the server is unreachable even though navigator.onLine is true", async () => { + setNavigatorOnLine(true); + const { isConnected } = await import("./connectivity"); + + emitReachable(false); + expect(get(isConnected)).toBe(false); + }); + + it("is true when the server is reachable even though navigator.onLine is false", async () => { + setNavigatorOnLine(false); + const { isConnected } = await import("./connectivity"); + + emitReachable(true); + expect(get(isConnected)).toBe(true); + }); +}); diff --git a/src/lib/stores/connectivity.ts b/src/lib/stores/connectivity.ts index 1c852cba..8b91385c 100644 --- a/src/lib/stores/connectivity.ts +++ b/src/lib/stores/connectivity.ts @@ -7,7 +7,7 @@ // mirrors status; it does not decide reachability itself. navigator.onLine is // advisory and triggers a recheck rather than forcing offline. // See docs/architecture/07-connectivity.md. -// TRACES: UR-002 | DR-013 +// TRACES: UR-002, UR-043, UR-052 | DR-013, DR-055, DR-079 import { writable, derived } from "svelte/store"; import { browser } from "$app/environment"; @@ -231,8 +231,20 @@ export const connectivity = createConnectivityStore(); // Derived stores for convenience export const isOnline = derived(connectivity, ($c) => $c.isOnline); export const isServerReachable = derived(connectivity, ($c) => $c.isServerReachable); +// "Connected" follows backend reachability ALONE — not navigator.onLine. +// The Rust ConnectivityMonitor (fed by real repository traffic) is the source +// of truth (DR-055); navigator.onLine is advisory and can be wrong (server +// unreachable on a live device link — server down, wrong LAN, dropped VPN — +// still reports online). Folding it in kept `isConnected` true in exactly those +// cases and prevented the offline "downloaded only" gate from ever closing. +// navigator.onLine stays a *trigger* for a recheck (the online/offline +// listeners call checkServerReachable), never a *term* in this decision. +// The startup default (isServerReachable: true) is intentionally optimistic — +// a brief full-catalog flash before the first probe beats flipping the app to +// "offline" on launch. See docs/architecture/07-connectivity.md. +// TRACES: UR-052 | DR-079 export const isConnected = derived( connectivity, - ($c) => $c.isOnline && $c.isServerReachable + ($c) => $c.isServerReachable ); export const connectionError = derived(connectivity, ($c) => $c.connectionError);