fix(offline): one server-only rule for both library views
Two defects with one cause: "server only" was a private $derived inside MediaCard. The list view (what LibraryGrid renders when the stored view preference is list) had no notion of it at all, so a library browsed as a list offline showed every revealed item as an ordinary tappable row that plays nothing, with no way to queue it. And the rule asked the downloads store whether *this item id* was downloaded — but only a playable leaf (Audio, Movie, Episode) ever has a download row. An album's tracks carry them, the album does not, so a fully downloaded album greyed itself out and offered to queue what was already on the device. The rule moves to the pure $lib/utils/serverOnly and both views call it. The container half is answered by the backend rather than guessed at: get_download_disk_usage().sizes already carries container subtotals beside leaf sizes (DR-085), so deviceContentIds is membership in a Rust-computed map, not a frontend list of which item types are containers. That map was loaded only by the Downloads page, so the shell primes it at startup and re-reads it whenever the offline gate settles. Queueing is shared too, since the list view had no copy to diverge from. TRACES: UR-052, UR-055 | DR-292 | UT-257, UT-258
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* The list view must grey and offer to queue server-only media exactly as the
|
||||
* grid does. It did neither: switching a library to list view offline turned
|
||||
* every non-downloaded item back into a normal, tappable row that plays
|
||||
* nothing.
|
||||
*
|
||||
* TRACES: UR-052 | DR-292 | UT-258
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/svelte";
|
||||
|
||||
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),
|
||||
showServerCatalogStore: shim(false),
|
||||
downloadsStore: shim({ downloads: {} as Record<string, unknown> }),
|
||||
deviceContentIdsStore: shim(new Set<string>()),
|
||||
downloadItem: vi.fn(async () => 1),
|
||||
getUserId: vi.fn(() => "user-1"),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("$lib/stores/connectivity", () => ({
|
||||
isConnected: { subscribe: h.isConnectedStore.subscribe },
|
||||
}));
|
||||
|
||||
vi.mock("$lib/services/offlineCatalog", () => ({
|
||||
showServerCatalog: { subscribe: h.showServerCatalogStore.subscribe },
|
||||
}));
|
||||
|
||||
vi.mock("$lib/services/downloadedCatalog", () => ({
|
||||
deviceContentIds: { subscribe: h.deviceContentIdsStore.subscribe },
|
||||
}));
|
||||
|
||||
vi.mock("$lib/stores/downloads", () => ({
|
||||
downloads: { subscribe: h.downloadsStore.subscribe, downloadItem: h.downloadItem },
|
||||
}));
|
||||
|
||||
vi.mock("$lib/stores/auth", () => ({
|
||||
auth: { getUserId: h.getUserId },
|
||||
}));
|
||||
|
||||
vi.mock("$lib/components/common/CachedImage.svelte", async () => ({
|
||||
default: (await import("./__mocks__/StubImage.svelte")).default,
|
||||
}));
|
||||
|
||||
import LibraryListView from "./LibraryListView.svelte";
|
||||
|
||||
const movie = {
|
||||
id: "movie-1",
|
||||
name: "Some Film",
|
||||
type: "Movie" as const,
|
||||
serverId: "server-1",
|
||||
productionYear: 1999,
|
||||
};
|
||||
|
||||
const album = {
|
||||
id: "album-1",
|
||||
name: "Some Album",
|
||||
type: "MusicAlbum" as const,
|
||||
serverId: "server-1",
|
||||
};
|
||||
|
||||
describe("LibraryListView server-only rows", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
h.isConnectedStore.set(true);
|
||||
h.showServerCatalogStore.set(false);
|
||||
h.downloadsStore.set({ downloads: {} });
|
||||
h.deviceContentIdsStore.set(new Set());
|
||||
});
|
||||
|
||||
it("shows no queue control while online", () => {
|
||||
render(LibraryListView, { props: { items: [movie] } });
|
||||
expect(screen.queryByLabelText(/Queue download/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("offers to queue a server-only row when offline with the reveal on", () => {
|
||||
h.isConnectedStore.set(false);
|
||||
h.showServerCatalogStore.set(true);
|
||||
render(LibraryListView, { props: { items: [movie] } });
|
||||
expect(screen.getByLabelText(/Queue download for Some Film/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("queues the row's item on click", async () => {
|
||||
h.isConnectedStore.set(false);
|
||||
h.showServerCatalogStore.set(true);
|
||||
render(LibraryListView, { props: { items: [movie] } });
|
||||
|
||||
await fireEvent.click(screen.getByLabelText(/Queue download for Some Film/i));
|
||||
|
||||
expect(h.downloadItem).toHaveBeenCalledTimes(1);
|
||||
const args = h.downloadItem.mock.calls[0] as unknown as unknown[];
|
||||
expect(args[0]).toBe("movie-1");
|
||||
expect(args[1]).toBe("user-1");
|
||||
});
|
||||
|
||||
it("makes a server-only row inert: tapping it cannot start playback", async () => {
|
||||
h.isConnectedStore.set(false);
|
||||
h.showServerCatalogStore.set(true);
|
||||
const onItemClick = vi.fn();
|
||||
render(LibraryListView, { props: { items: [movie], onItemClick } });
|
||||
|
||||
await fireEvent.click(screen.getByText("Some Film"));
|
||||
|
||||
expect(onItemClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves a downloaded row alone", () => {
|
||||
h.isConnectedStore.set(false);
|
||||
h.showServerCatalogStore.set(true);
|
||||
h.downloadsStore.set({
|
||||
downloads: { "movie-1": { itemId: "movie-1", status: "completed", progress: 1 } },
|
||||
});
|
||||
render(LibraryListView, { props: { items: [movie] } });
|
||||
expect(screen.queryByLabelText(/Queue download/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves a container alone when the device holds its children", () => {
|
||||
h.isConnectedStore.set(false);
|
||||
h.showServerCatalogStore.set(true);
|
||||
h.deviceContentIdsStore.set(new Set(["album-1"]));
|
||||
render(LibraryListView, { props: { items: [album] } });
|
||||
expect(screen.queryByLabelText(/Queue download/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a Queued badge instead of the button for a pending row", () => {
|
||||
h.isConnectedStore.set(false);
|
||||
h.showServerCatalogStore.set(true);
|
||||
h.downloadsStore.set({
|
||||
downloads: { "movie-1": { itemId: "movie-1", status: "pending", progress: 0 } },
|
||||
});
|
||||
render(LibraryListView, { props: { items: [movie] } });
|
||||
|
||||
expect(screen.getByText(/Queued/i)).toBeTruthy();
|
||||
expect(screen.queryByLabelText(/Queue download/i)).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user