fix(offline): gate library listing to downloaded-only when offline (#10)
The connectivity store now drives the "downloaded only" view so an offline library page shows just on-device media, with the server catalog revealed only when "Show all server media" is toggled. TRACES: UR-052 | DR-078, DR-079
This commit is contained in:
@@ -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<T>(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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
// It also owns the `showServerCatalog` UI flag (the offline banner toggle that
|
// It also owns the `showServerCatalog` UI flag (the offline banner toggle that
|
||||||
// reveals greyed-out, non-downloaded server media).
|
// 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 { writable, type Writable } from "svelte/store";
|
||||||
import { commands } from "$lib/api/bindings";
|
import { commands } from "$lib/api/bindings";
|
||||||
|
|||||||
@@ -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<string, (event: { payload: unknown }) => 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
// mirrors status; it does not decide reachability itself. navigator.onLine is
|
// mirrors status; it does not decide reachability itself. navigator.onLine is
|
||||||
// advisory and triggers a recheck rather than forcing offline.
|
// advisory and triggers a recheck rather than forcing offline.
|
||||||
// See docs/architecture/07-connectivity.md.
|
// 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 { writable, derived } from "svelte/store";
|
||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
@@ -231,8 +231,20 @@ export const connectivity = createConnectivityStore();
|
|||||||
// Derived stores for convenience
|
// Derived stores for convenience
|
||||||
export const isOnline = derived(connectivity, ($c) => $c.isOnline);
|
export const isOnline = derived(connectivity, ($c) => $c.isOnline);
|
||||||
export const isServerReachable = derived(connectivity, ($c) => $c.isServerReachable);
|
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(
|
export const isConnected = derived(
|
||||||
connectivity,
|
connectivity,
|
||||||
($c) => $c.isOnline && $c.isServerReachable
|
($c) => $c.isServerReachable
|
||||||
);
|
);
|
||||||
export const connectionError = derived(connectivity, ($c) => $c.connectionError);
|
export const connectionError = derived(connectivity, ($c) => $c.connectionError);
|
||||||
|
|||||||
Reference in New Issue
Block a user