Files
jellytau/src/lib/services/offlineCatalog.reload.test.ts
T
dtourolle ad48d89dfe chore(format): run prettier over src/ and scripts/
Formatting was configured but never enforced: `bun run format:check`
reported 199 unformatted files and ran in no workflow and in no git hook,
so .prettierrc (printWidth 100, trailing commas) described an intention
rather than the tree.

This is the one-time sweep that makes the check gateable. Whitespace and
token-reflow only -- no behavioural change: `bun run check` reports 0
errors and all 1053 frontend tests pass before and after.

Kept out of every other commit on purpose. A 199-file diff mixed with
real changes is unreviewable, and the next commit turns format:check
into a hard CI gate so this cannot silently accumulate again.
2026-08-21 17:41:44 +02:00

163 lines
5.6 KiB
TypeScript

// 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<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);
},
/**
* 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<void>((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<void>((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);
});
});