Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { act, render, screen, waitFor, within } from "@/test-utils";
import { CoreAPI } from "@/lib/coreApi";
import type { MediaBrowseEntry, MediaMetaResponse } from "@/lib/models";
import { usePreferencesStore } from "@/lib/preferencesStore";
import { useStatusStore } from "@/lib/store";
import { ConnectionState, useStatusStore } from "@/lib/store";
import { LibraryMediaDetailsModal } from "@/components/library/LibraryMediaDetailsModal";

const mockRequestLibraryImage = vi.fn();
Expand Down Expand Up @@ -102,6 +102,7 @@ describe("LibraryMediaDetailsModal", () => {
vi.spyOn(CoreAPI, "hasWriteCapableReader").mockResolvedValue(false);
useStatusStore.setState({
connected: true,
connectionState: ConnectionState.CONNECTED,
coreVersion: "2.15.0",
coreVersionPending: false,
});
Expand Down Expand Up @@ -314,6 +315,21 @@ describe("LibraryMediaDetailsModal", () => {
).toBeInTheDocument();
});

it("should not launch while Core is reconnecting", async () => {
const user = userEvent.setup();
const runSpy = vi.spyOn(CoreAPI, "run").mockResolvedValue();
useStatusStore.getState().setConnectionState(ConnectionState.RECONNECTING);
renderModal();

const launch = await screen.findByRole("button", {
name: "library.launch",
});
expect(launch).toBeDisabled();
await user.click(launch);

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

it("should offer ZapScript and relative path before NFC writing", async () => {
usePreferencesStore.setState({ nfcAvailable: true });
const user = userEvent.setup();
Expand Down Expand Up @@ -644,7 +660,7 @@ describe("LibraryMediaDetailsModal", () => {
});

it("should disable launch while disconnected", async () => {
useStatusStore.setState({ connected: false });
useStatusStore.getState().setConnectionState(ConnectionState.DISCONNECTED);
renderModal();

expect(
Expand Down
47 changes: 47 additions & 0 deletions src/__tests__/unit/coreApi.internals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,53 @@ describe("CoreAPI Internals", () => {
expect(callSpy).toHaveBeenCalledWith(Method.Systems, { all: true });
});

it("should keep virtual launchables when the caller opts in", async () => {
const steam = {
id: "virtual:steam",
name: "Steam",
mediaCount: 0,
zapScript: "**launch.system:steam",
};
const callSpy = vi.spyOn(CoreAPI, "call").mockResolvedValue({
systems: [
{ id: "snes", name: "Super Nintendo", mediaCount: 12 },
steam,
],
});

await expect(
CoreAPI.systems(undefined, { includeLaunchables: true }),
).resolves.toEqual({
systems: [
{ id: "snes", name: "Super Nintendo", mediaCount: 12 },
steam,
],
});
expect(callSpy).toHaveBeenCalledWith(Method.Systems, undefined);
});

it.each([
{ name: "by default", options: undefined },
{
name: "when the caller opts in",
options: { includeLaunchables: true },
},
])(
"should drop virtual systems with a blank ZapScript $name",
async ({ options }) => {
vi.spyOn(CoreAPI, "call").mockResolvedValue({
systems: [
{ id: "snes", name: "Super Nintendo", mediaCount: 12 },
{ id: "virtual:blank", name: "Blank", zapScript: " " },
],
});

await expect(CoreAPI.systems(undefined, options)).resolves.toEqual({
systems: [{ id: "snes", name: "Super Nintendo", mediaCount: 12 }],
});
},
);

it.each([
{
name: "systems",
Expand Down
35 changes: 35 additions & 0 deletions src/__tests__/unit/lib/systemFilters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { System } from "@/lib/models";
import {
filterSystemCatalog,
systemHasIndexedMedia,
systemIsLaunchable,
systemManufacturers,
systemReleaseYear,
systemSubtitle,
Expand Down Expand Up @@ -62,6 +63,40 @@ describe("system filters", () => {
expect(systemHasIndexedMedia({})).toBe(true);
});

it("should keep virtual systems only when launchables are included", () => {
const countedSystems: System[] = [
{ id: "SNES", name: "Super Nintendo", mediaCount: 25 },
{ id: "3DO", name: "3DO", mediaCount: 0 },
{
id: "winamp",
name: "Winamp",
mediaCount: 0,
zapScript: "zaparoo://winamp/Winamp",
},
];

const indexed = filterSystemCatalog(countedSystems, {
category: "all",
query: "",
});
const withLaunchables = filterSystemCatalog(countedSystems, {
category: "all",
query: "",
includeLaunchables: true,
});

expect(indexed.systems.map((system) => system.id)).toEqual(["SNES"]);
expect(withLaunchables.systems.map((system) => system.id)).toEqual([
"SNES",
"winamp",
]);
expect(systemIsLaunchable({ zapScript: "zaparoo://winamp/Winamp" })).toBe(
true,
);
expect(systemIsLaunchable({ zapScript: " " })).toBe(false);
expect(systemIsLaunchable({})).toBe(false);
});

it("should combine manufacturer and release-period filters", () => {
const result = filterSystemCatalog(systems, {
category: "all",
Expand Down
183 changes: 179 additions & 4 deletions src/__tests__/unit/routes/library.index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { render, screen } from "@/test-utils";
import { act, render, screen, waitFor, within } from "@/test-utils";
import { seedActiveDevice } from "@/test-utils/deviceRegistry";
import { deviceRegistry } from "@/lib/devices/deviceRegistry";
import { CoreAPI } from "@/lib/coreApi";
import { useStatusStore } from "@/lib/store";
import { ConnectionState, useStatusStore } from "@/lib/store";
import {
libraryBrowseScrollKey,
useLibrarySessionStore,
Expand All @@ -13,7 +13,8 @@ import { useTabSessionStore } from "@/lib/tabSessionStore";
import { usePreferencesStore } from "@/lib/preferencesStore";
import { Library } from "@/routes/library.index";

const { mockNavigate } = vi.hoisted(() => ({
const { mockErrorToast, mockNavigate } = vi.hoisted(() => ({
mockErrorToast: vi.fn(),
mockNavigate: vi.fn(),
}));

Expand Down Expand Up @@ -56,17 +57,35 @@ vi.mock("@/hooks/useHaptics", () => ({
useHaptics: () => ({ impact: vi.fn() }),
}));

vi.mock("@/lib/toastUtils", () => ({
showRateLimitedErrorToast: mockErrorToast,
}));

const WINAMP = {
id: "ab3cdefghijklmnopqrstuvwxy",
name: "Winamp",
category: "Other",
mediaCount: 0,
zapScript: "zaparoo://ab3cdefghijklmnopqrstuvwxy/Winamp",
};

describe("Library index route", () => {
beforeEach(async () => {
vi.restoreAllMocks();
CoreAPI.reset();
mockNavigate.mockClear();
usePreferencesStore.setState({ systemNameRegion: "auto" });
mockErrorToast.mockClear();
usePreferencesStore.setState({
systemNameRegion: "auto",
nfcAvailable: false,
});
useStatusStore.getState().setWriteQueue("");
useLibrarySessionStore.getState().reset();
useTabSessionStore.getState().reset();
await seedActiveDevice({ recordId: "device-a" });
useStatusStore.setState({
connected: true,
connectionState: ConnectionState.CONNECTED,
coreVersion: "2.15.0",
coreVersionPending: false,
gamesIndex: {
Expand Down Expand Up @@ -174,6 +193,162 @@ describe("Library index route", () => {
).not.toBeInTheDocument();
});

it("should list virtual systems as actions without listing empty systems", async () => {
const systemsSpy = vi.spyOn(CoreAPI, "systems").mockResolvedValue({
systems: [
{ id: "SNES", name: "Super Nintendo", mediaCount: 150 },
{ id: "3DO", name: "3DO", mediaCount: 0 },
WINAMP,
],
});

render(<Library />);

expect(
await screen.findByRole("button", { name: "Winamp" }),
).toBeInTheDocument();
expect(screen.getByRole("link", { name: "SNES" })).toBeInTheDocument();
expect(
screen.queryByRole("link", { name: "Winamp" }),
).not.toBeInTheDocument();
expect(screen.queryByText("3DO")).not.toBeInTheDocument();
expect(systemsSpy).toHaveBeenCalledWith(undefined, {
includeLaunchables: true,
});
});

it("should not show the empty state when only virtual systems exist", async () => {
vi.spyOn(CoreAPI, "systems").mockResolvedValue({
systems: [{ id: "3DO", name: "3DO", mediaCount: 0 }, WINAMP],
});

render(<Library />);

expect(
await screen.findByRole("button", { name: "Winamp" }),
).toBeInTheDocument();
expect(screen.queryByText("library.noSystems")).not.toBeInTheDocument();
});

it("should launch a virtual system from its actions", async () => {
const user = userEvent.setup();
vi.spyOn(CoreAPI, "systems").mockResolvedValue({ systems: [WINAMP] });
vi.spyOn(CoreAPI, "hasWriteCapableReader").mockResolvedValue(false);
const runSpy = vi.spyOn(CoreAPI, "run").mockResolvedValue();

render(<Library />);
await user.click(await screen.findByRole("button", { name: "Winamp" }));
const dialog = await screen.findByRole("dialog", { name: "Winamp" });
await user.click(
within(dialog).getByRole("button", { name: "library.launch" }),
);

expect(runSpy).toHaveBeenCalledWith({ text: WINAMP.zapScript });
expect(mockErrorToast).not.toHaveBeenCalled();
});

it("should not launch a virtual system while Core is reconnecting", async () => {
const user = userEvent.setup();
vi.spyOn(CoreAPI, "systems").mockResolvedValue({ systems: [WINAMP] });
vi.spyOn(CoreAPI, "hasWriteCapableReader").mockResolvedValue(false);
const runSpy = vi.spyOn(CoreAPI, "run").mockResolvedValue();

render(<Library />);
await user.click(await screen.findByRole("button", { name: "Winamp" }));
const dialog = await screen.findByRole("dialog", { name: "Winamp" });
act(() => {
useStatusStore
.getState()
.setConnectionState(ConnectionState.RECONNECTING);
});
const launch = within(dialog).getByRole("button", {
name: "library.launch",
});
expect(launch).toBeDisabled();
await user.click(launch);

expect(runSpy).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: "Winamp" })).toBeInTheDocument();
});

it("should report a failed virtual system launch", async () => {
const user = userEvent.setup();
vi.spyOn(CoreAPI, "systems").mockResolvedValue({ systems: [WINAMP] });
vi.spyOn(CoreAPI, "hasWriteCapableReader").mockResolvedValue(false);
vi.spyOn(CoreAPI, "run").mockRejectedValue(new Error("launch failed"));

render(<Library />);
await user.click(await screen.findByRole("button", { name: "Winamp" }));
const dialog = await screen.findByRole("dialog", { name: "Winamp" });
await user.click(
within(dialog).getByRole("button", { name: "library.launch" }),
);

await waitFor(() =>
expect(mockErrorToast).toHaveBeenCalledWith("library.launchSystemError"),
);
expect(
within(dialog).getByRole("button", { name: "library.launch" }),
).toBeEnabled();
});

it("should write a virtual system's ZapScript to a token", async () => {
const user = userEvent.setup();
usePreferencesStore.setState({ nfcAvailable: true });
vi.spyOn(CoreAPI, "systems").mockResolvedValue({ systems: [WINAMP] });

render(<Library />);
await user.click(await screen.findByRole("button", { name: "Winamp" }));
const dialog = await screen.findByRole("dialog", { name: "Winamp" });
await user.click(
within(dialog).getByRole("button", { name: "library.write" }),
);

expect(useStatusStore.getState().writeQueue).toBe(WINAMP.zapScript);
});

it("should disable writing a virtual system without a writer", async () => {
const user = userEvent.setup();
vi.spyOn(CoreAPI, "systems").mockResolvedValue({ systems: [WINAMP] });
const writerSpy = vi
.spyOn(CoreAPI, "hasWriteCapableReader")
.mockResolvedValue(false);

render(<Library />);
await user.click(await screen.findByRole("button", { name: "Winamp" }));
const dialog = await screen.findByRole("dialog", { name: "Winamp" });

await waitFor(() => expect(writerSpy).toHaveBeenCalled());
expect(
within(dialog).getByRole("button", { name: "library.write" }),
).toBeDisabled();
expect(useStatusStore.getState().writeQueue).toBe("");
});

it("should disable writing a virtual system to a Core reader while reconnecting", async () => {
const user = userEvent.setup();
vi.spyOn(CoreAPI, "systems").mockResolvedValue({ systems: [WINAMP] });
vi.spyOn(CoreAPI, "hasWriteCapableReader").mockResolvedValue(true);

render(<Library />);
await user.click(await screen.findByRole("button", { name: "Winamp" }));
const dialog = await screen.findByRole("dialog", { name: "Winamp" });
const write = within(dialog).getByRole("button", {
name: "library.write",
});
await waitFor(() => expect(write).toBeEnabled());

act(() => {
useStatusStore
.getState()
.setConnectionState(ConnectionState.RECONNECTING);
});
expect(write).toBeDisabled();
await user.click(write);

expect(useStatusStore.getState().writeQueue).toBe("");
});

it("should use the preferred regional system names", async () => {
usePreferencesStore.setState({ systemNameRegion: "eu" });
vi.spyOn(CoreAPI, "systems").mockResolvedValue({
Expand Down
Loading