Skip to content
Merged
8 changes: 7 additions & 1 deletion __mocks__/@capacitor/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ import { vi } from "vitest";
export const Capacitor = {
isNativePlatform: vi.fn().mockReturnValue(false),
getPlatform: vi.fn().mockReturnValue("web"),
isPluginAvailable: vi.fn().mockReturnValue(false),
// Preferences ships a web implementation and is mocked globally, so it is
// available on every platform. Modules loaded by the test setup keep this
// instance even when a test file mocks @capacitor/core as native, so a
// missing Preferences plugin would fail preference hydration there.
isPluginAvailable: vi.fn(
(pluginName: string) => pluginName === "Preferences",
),
convertFileSrc: vi.fn((filePath: string) => filePath),
};
4 changes: 4 additions & 0 deletions src/__tests__/integration/index-route.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,10 @@ vi.mock("@/lib/toastUtils", () => ({
// Mock NFC cancel session
vi.mock("@/lib/nfc", () => ({
cancelSession: vi.fn(),
sessionManager: {
setShouldRestart: vi.fn(),
setLaunchOnScan: vi.fn(),
},
}));

// Use vi.hoisted for Capacitor mock
Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/unit/components/MediaSearchModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ vi.mock("@capacitor/app", () => ({

vi.mock("@capacitor/preferences", () => ({
Preferences: {
get: vi.fn().mockResolvedValue({ value: "all" }),
get: vi.fn().mockResolvedValue({ value: null }),
set: vi.fn(),
},
}));
Expand Down
37 changes: 37 additions & 0 deletions src/__tests__/unit/components/library/FavoriteButton.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { QueryClient } from "@tanstack/react-query";
import { render, screen, waitFor } from "@/test-utils";
import { FavoriteButton } from "@/components/library/FavoriteButton";
import { CoreAPI } from "@/lib/coreApi";
import { LIBRARY_QUERY_KEYS } from "@/lib/libraryMedia";
import { logger } from "@/lib/logger";
import type { MediaBrowseEntry } from "@/lib/models";
import { useStatusStore } from "@/lib/store";
Expand Down Expand Up @@ -68,6 +70,41 @@ describe("FavoriteButton", () => {
).toBeInTheDocument();
});

it("should refresh letter cursors before browse lists after a change", async () => {
vi.spyOn(CoreAPI, "mediaTagsUpdate").mockResolvedValue({
tags: [{ type: "user", tag: "favorite" }],
});
const invalidateSpy = vi.spyOn(QueryClient.prototype, "invalidateQueries");
const user = userEvent.setup();

render(
<FavoriteButton
entry={mediaEntry()}
fallbackSystemId="SNES"
deviceKey="device-a"
/>,
);
await user.click(
screen.getByRole("button", { name: "library.addFavorite" }),
);

await waitFor(() => expect(invalidateSpy).toHaveBeenCalled());
const invalidated = invalidateSpy.mock.calls.map(
([filters]) => filters?.queryKey,
);
const indexAt = invalidated.findIndex(
(key) => key?.[0] === LIBRARY_QUERY_KEYS.browseIndex,
);
const browseAt = invalidated.findIndex(
(key) => key?.[0] === LIBRARY_QUERY_KEYS.browse,
);
expect(invalidated[indexAt]).toEqual([
LIBRARY_QUERY_KEYS.browseIndex,
"device-a",
]);
expect(indexAt).toBeLessThan(browseAt);
});

it("should remove a favorite using system and path fallback", async () => {
const updateSpy = vi.spyOn(CoreAPI, "mediaTagsUpdate").mockResolvedValue({
tags: [],
Expand Down
137 changes: 129 additions & 8 deletions src/__tests__/unit/components/library/LibraryBrowseList.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { createRef, type ComponentProps } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { findA11yViolations, fireEvent, render, screen } from "@/test-utils";
import { useVirtualizer } from "@tanstack/react-virtual";
import {
act,
findA11yViolations,
fireEvent,
render,
screen,
waitFor,
} from "@/test-utils";
import {
LibraryBrowseList,
type LibraryBrowseListHandle,
Expand All @@ -16,14 +24,32 @@ vi.mock("@/hooks/useScreenReaderEnabled", () => ({
useScreenReaderEnabled: () => false,
}));

function mockVirtualRows(indices: number[], isScrolling = false) {
vi.mocked(useVirtualizer).mockImplementation(
() =>
({
getVirtualItems: () =>
indices.map((index) => ({
index,
key: `row-${index}`,
start: index * 88,
size: 88,
})),
getTotalSize: () => 176,
measureElement: vi.fn(),
measure: mockMeasure,
scrollToIndex: mockScrollToIndex,
isScrolling,
range:
indices.length > 0
? { startIndex: indices[0]!, endIndex: indices.at(-1)! }
: null,
}) as unknown as ReturnType<typeof useVirtualizer>,
);
}

vi.mock("@tanstack/react-virtual", () => ({
useVirtualizer: vi.fn(() => ({
getVirtualItems: () => [{ index: 0, key: "row-0", start: 0, size: 88 }],
getTotalSize: () => 176,
measureElement: vi.fn(),
measure: mockMeasure,
scrollToIndex: mockScrollToIndex,
})),
useVirtualizer: vi.fn(),
}));

vi.mock("@/components/library/LibraryArtwork", () => ({
Expand Down Expand Up @@ -75,6 +101,7 @@ function renderList(
describe("LibraryBrowseList", () => {
beforeEach(() => {
vi.clearAllMocks();
mockVirtualRows([0]);
useStatusStore.setState({ corePlatform: null });
usePreferencesStore.setState({
accessibleLists: false,
Expand Down Expand Up @@ -179,4 +206,98 @@ describe("LibraryBrowseList", () => {
expect(scrollIntoView).toHaveBeenCalledWith({ block: "start" });
expect(mockScrollToIndex).not.toHaveBeenCalled();
});
it("should request the first visible unloaded row once scrolling settles", () => {
vi.useFakeTimers();
mockVirtualRows([0, 1, 2]);
const onLoadIndex = vi.fn();
renderList({
entries: [undefined, undefined, entries[0]!],
onLoadIndex,
});

expect(screen.getAllByRole("listitem")).toHaveLength(1);
expect(onLoadIndex).not.toHaveBeenCalled();

act(() => {
vi.advanceTimersByTime(150);
});

expect(onLoadIndex).toHaveBeenCalledTimes(1);
expect(onLoadIndex).toHaveBeenCalledWith(0);
});

it("should not request unloaded rows while the list is scrolling", () => {
vi.useFakeTimers();
mockVirtualRows([0, 1, 2], true);
const onLoadIndex = vi.fn();
renderList({
entries: [undefined, undefined, entries[0]!],
onLoadIndex,
});

act(() => {
vi.advanceTimersByTime(500);
});

expect(onLoadIndex).not.toHaveBeenCalled();
});

it("should offer a retry instead of reloading a failed range", () => {
vi.useFakeTimers();
mockVirtualRows([0, 1, 2]);
const onLoadIndex = vi.fn();
renderList({
entries: [undefined, undefined, entries[0]!],
failedRanges: [{ start: 0, end: 2 }],
onLoadIndex,
});

act(() => {
vi.advanceTimersByTime(150);
});
expect(onLoadIndex).not.toHaveBeenCalled();

fireEvent.click(
screen.getByRole("button", { name: "library.loadEarlier" }),
);
expect(onLoadIndex).toHaveBeenCalledWith(1, { retry: true });
});

it("should load earlier rows on request in accessible mode", async () => {
usePreferencesStore.setState({ accessibleLists: true });
const onLoadIndex = vi.fn();
const { baseElement, rerender } = renderList({
entries: [undefined, undefined, entries[1]!],
onLoadIndex,
});

expect(await findA11yViolations(baseElement)).toEqual([]);
fireEvent.click(
screen.getByRole("button", { name: "library.loadEarlier" }),
);
expect(onLoadIndex).toHaveBeenCalledWith(1, { retry: true });

rerender(
<LibraryBrowseList
entries={entries.concat(entries[1]!)}
systemId="snes"
deviceKey="device-a"
scrollRef={{ current: document.createElement("div") }}
hasNextPage={false}
isFetchingNextPage={false}
imagesPaused={false}
interactionDisabled={false}
onFetchMore={vi.fn()}
onSelect={vi.fn()}
onLoadIndex={onLoadIndex}
/>,
);

expect(
screen.queryByRole("button", { name: "library.loadEarlier" }),
).not.toBeInTheDocument();
await waitFor(() =>
expect(screen.getByRole("button", { name: "Game One" })).toHaveFocus(),
);
});
});
Loading