test: add frontend test suite with 54 tests
Build & Deploy / build-and-deploy (push) Successful in 23s

- Install vitest, @testing-library/svelte, jsdom
- Configure vitest in vite.config.js with browser conditions for Svelte 5
- Pure function tests for api.js (artworkUrl, bundleUrl, setupBundleUrl)
- Router tests (push, replace hash manipulation)
- GameCard component tests (title, year, genre, cover, badge, click)
- Library page tests (loading, empty, game grid, year/genre filters)
- GameDetail page tests (loading, error, display, edit mode, badges)
- Play page tests (loading, not-ready, unsupported, booting, error)
This commit is contained in:
Hermes Agent
2026-06-10 11:48:10 +02:00
parent 2a72c0a3aa
commit 63f11452cd
9 changed files with 2977 additions and 3 deletions
@@ -0,0 +1,71 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/svelte";
import GameCard from "../GameCard.svelte";
function makeGame(overrides = {}) {
return {
id: "test-1",
title: "Test Game",
year: 1995,
genre: "Adventure",
platform: "dos",
has_cover: false,
...overrides,
};
}
describe("GameCard", () => {
it("renders the game title", () => {
render(GameCard, { props: { game: makeGame() } });
expect(screen.getByText("Test Game")).toBeTruthy();
});
it("renders the year when provided", () => {
render(GameCard, { props: { game: makeGame({ year: 1997 }) } });
expect(screen.getByText("1997")).toBeTruthy();
});
it("does not render year when absent", () => {
render(GameCard, { props: { game: makeGame({ year: null }) } });
expect(screen.queryByText("1995")).toBeNull();
});
it("renders the genre when provided", () => {
render(GameCard, { props: { game: makeGame({ genre: "RPG" }) } });
expect(screen.getByText("RPG")).toBeTruthy();
});
it("shows a cover image when has_cover is true", () => {
const game = makeGame({ has_cover: true });
render(GameCard, { props: { game } });
const img = screen.getByRole("img");
expect(img).toBeTruthy();
expect(img.getAttribute("src")).toBe(`/games/${game.id}/cover.jpg`);
expect(img.getAttribute("alt")).toBe(game.title);
});
it("shows a placeholder when has_cover is false", () => {
render(GameCard, { props: { game: makeGame({ has_cover: false }) } });
expect(screen.getByText("💾")).toBeTruthy();
});
it("shows Windows badge for windows platform", () => {
render(GameCard, {
props: { game: makeGame({ platform: "windows" }) },
});
expect(screen.getByText(/🪟/)).toBeTruthy();
});
it("does not show Windows badge for DOS platform", () => {
render(GameCard, { props: { game: makeGame({ platform: "dos" }) } });
expect(screen.queryByText(/🪟/)).toBeNull();
});
it("navigates on click", async () => {
window.location.hash = "";
render(GameCard, { props: { game: makeGame() } });
const btn = screen.getByRole("button");
btn.click();
expect(window.location.hash).toBe("#/game/test-1");
});
});
+68
View File
@@ -0,0 +1,68 @@
import { describe, it, expect } from "vitest";
import { artworkUrl, bundleUrl, setupBundleUrl } from "../api.js";
describe("artworkUrl", () => {
it("returns null for null input", () => {
expect(artworkUrl(null)).toBeNull();
});
it("returns null for undefined input", () => {
expect(artworkUrl(undefined)).toBeNull();
});
it("passes through absolute HTTP URLs", () => {
expect(artworkUrl("https://example.com/cover.jpg")).toBe(
"https://example.com/cover.jpg"
);
});
it("proxies relative paths through /artwork/", () => {
expect(artworkUrl("images/game.png")).toBe("/artwork/images/game.png");
});
it("preserves nested relative paths", () => {
expect(artworkUrl("some/deep/path/file.webp")).toBe(
"/artwork/some/deep/path/file.webp"
);
});
});
describe("bundleUrl", () => {
it("returns null for null game", () => {
expect(bundleUrl(null)).toBeNull();
});
it("returns null for undefined game", () => {
expect(bundleUrl(undefined)).toBeNull();
});
it("builds URL from bundle_file", () => {
expect(bundleUrl({ bundle_file: "mygame.zip" })).toBe("/games/mygame.zip");
});
it("preserves subdirectory bundle paths", () => {
expect(bundleUrl({ bundle_file: "subdir/game.zip" })).toBe(
"/games/subdir/game.zip"
);
});
});
describe("setupBundleUrl", () => {
it("returns null for null game", () => {
expect(setupBundleUrl(null)).toBeNull();
});
it("returns null for undefined game", () => {
expect(setupBundleUrl(undefined)).toBeNull();
});
it("builds API setup URL from game id", () => {
expect(setupBundleUrl({ id: "game-1" })).toBe(
"/api/games/game-1/setup-bundle"
);
});
it("handles games with numeric id", () => {
expect(setupBundleUrl({ id: 42 })).toBe("/api/games/42/setup-bundle");
});
});
+33
View File
@@ -0,0 +1,33 @@
import { describe, it, expect, vi } from "vitest";
import { push, replace } from "../router.js";
describe("router", () => {
describe("push", () => {
it("sets window.location.hash with # prefix", () => {
window.location.hash = "";
push("/game/123");
expect(window.location.hash).toBe("#/game/123");
});
it("handles root path", () => {
window.location.hash = "";
push("/");
expect(window.location.hash).toBe("#/");
});
it("replaces hash on subsequent calls", () => {
window.location.hash = "";
push("/first");
push("/second");
expect(window.location.hash).toBe("#/second");
});
});
describe("replace", () => {
it("sets hash via replace (no back-navigation allowed)", () => {
window.location.hash = "#/old";
replace("/new");
expect(window.location.hash).toBe("#/new");
});
});
});
@@ -0,0 +1,185 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/svelte";
import GameDetail from "../GameDetail.svelte";
// Mock api.js
vi.mock("../../lib/api.js", () => ({
getGame: vi.fn(),
deleteGame: vi.fn(),
updateGame: vi.fn(),
searchIGDB: vi.fn(),
scrapeIGDB: vi.fn(),
downloadGame: vi.fn(),
bundleUrl: vi.fn(),
setupBundleUrl: vi.fn(),
artworkUrl: vi.fn(),
}));
// Mock router
vi.mock("../../lib/router.js", () => ({
push: vi.fn(),
}));
import * as api from "../../lib/api.js";
function makeGame(overrides = {}) {
return {
id: "game-1",
title: "Commander Keen",
year: 1991,
genre: "Platformer",
developer: "id Software",
publisher: "Apogee",
description: "A classic DOS platformer.",
platform: "dos",
ready: true,
has_cover: false,
has_setup: false,
bundle_file: "commander-keen.zip",
bundle_size: 512000,
executables: ["keen.exe"],
executable: null,
videos: [],
screenshots: [],
...overrides,
};
}
describe("GameDetail", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows loading state initially", () => {
api.getGame.mockReturnValue(new Promise(() => {}));
render(GameDetail, { props: { id: "game-1" } });
expect(screen.getByText("Loading...")).toBeTruthy();
});
it("shows error when game is not found", async () => {
api.getGame.mockRejectedValue(new Error("Game not found"));
render(GameDetail, { props: { id: "missing" } });
await vi.waitFor(() => {
expect(screen.getByText("Game not found")).toBeTruthy();
});
});
it("renders game title", async () => {
api.getGame.mockResolvedValue(makeGame());
render(GameDetail, { props: { id: "game-1" } });
await vi.waitFor(() => {
expect(screen.getByText("Commander Keen")).toBeTruthy();
});
});
it("renders developer and publisher", async () => {
api.getGame.mockResolvedValue(
makeGame({ developer: "id Software", publisher: "Apogee" })
);
render(GameDetail, { props: { id: "game-1" } });
await vi.waitFor(() => {
expect(screen.getByText(/id Software/)).toBeTruthy();
expect(screen.getByText(/Apogee/)).toBeTruthy();
});
});
it("renders year and genre as links", async () => {
api.getGame.mockResolvedValue(
makeGame({ year: 1991, genre: "Platformer" })
);
render(GameDetail, { props: { id: "game-1" } });
await vi.waitFor(() => {
expect(screen.getByText("1991").closest("a")).toBeTruthy();
expect(screen.getByText("Platformer").closest("a")).toBeTruthy();
});
});
it("shows DOS badge for dos platform", async () => {
api.getGame.mockResolvedValue(makeGame({ platform: "dos" }));
render(GameDetail, { props: { id: "game-1" } });
await vi.waitFor(() => {
expect(screen.getByText("💾 DOS")).toBeTruthy();
});
});
it("shows Windows badge for windows platform", async () => {
api.getGame.mockResolvedValue(makeGame({ platform: "windows" }));
render(GameDetail, { props: { id: "game-1" } });
await vi.waitFor(() => {
expect(screen.getByText(/Windows 3\.1/)).toBeTruthy();
});
});
it("shows Play button for ready DOS games", async () => {
api.getGame.mockResolvedValue(
makeGame({ ready: true, platform: "dos" })
);
render(GameDetail, { props: { id: "game-1" } });
await vi.waitFor(() => {
expect(screen.getByText("▶ Play")).toBeTruthy();
});
});
it("shows disabled Play button for Windows games", async () => {
api.getGame.mockResolvedValue(
makeGame({ ready: true, platform: "windows" })
);
render(GameDetail, { props: { id: "game-1" } });
await vi.waitFor(() => {
expect(screen.getByText("▶ Unplayable")).toBeTruthy();
});
});
it("shows Setup button when has_setup is true", async () => {
api.getGame.mockResolvedValue(
makeGame({ has_setup: true })
);
render(GameDetail, { props: { id: "game-1" } });
await vi.waitFor(() => {
expect(screen.getByText("🛠 Setup")).toBeTruthy();
});
});
it("shows Edit, Download, Delete buttons", async () => {
api.getGame.mockResolvedValue(makeGame());
render(GameDetail, { props: { id: "game-1" } });
await vi.waitFor(() => {
expect(screen.getByText("✏ Edit")).toBeTruthy();
expect(screen.getByText("⬇ Download")).toBeTruthy();
expect(screen.getByText("✕ Delete")).toBeTruthy();
});
});
it("enters edit mode when Edit button is clicked", async () => {
api.getGame.mockResolvedValue(makeGame());
render(GameDetail, { props: { id: "game-1" } });
await vi.waitFor(() => {
expect(screen.getByText("✏ Edit")).toBeTruthy();
});
screen.getByText("✏ Edit").click();
await vi.waitFor(() => {
expect(screen.getByText("Save")).toBeTruthy();
expect(screen.getByText("Cancel")).toBeTruthy();
});
});
it("shows cover placeholder when no cover", async () => {
api.getGame.mockResolvedValue(makeGame({ has_cover: false }));
render(GameDetail, { props: { id: "game-1" } });
await vi.waitFor(() => {
expect(screen.getByText("💾")).toBeTruthy();
});
});
it("shows description when provided", async () => {
api.getGame.mockResolvedValue(
makeGame({ description: "A classic DOS platformer." })
);
render(GameDetail, { props: { id: "game-1" } });
await vi.waitFor(() => {
expect(
screen.getByText("A classic DOS platformer.")
).toBeTruthy();
});
});
});
@@ -0,0 +1,143 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/svelte";
import Library from "../Library.svelte";
// Mock the API module that Library imports
vi.mock("../../lib/api.js", () => ({
listGames: vi.fn(),
uploadGame: vi.fn(),
searchIGDB: vi.fn(),
}));
import * as api from "../../lib/api.js";
function makeGame(overrides = {}) {
return {
id: "g1",
title: "Test Game",
year: 1995,
genre: "Adventure",
platform: "dos",
has_cover: false,
...overrides,
};
}
describe("Library", () => {
beforeEach(() => {
vi.clearAllMocks();
window.location.hash = "#/";
});
it("shows loading state initially", () => {
api.listGames.mockReturnValue(new Promise(() => {})); // never resolves
render(Library);
expect(screen.getByText("Scanning library...")).toBeTruthy();
});
it("shows empty state when no games", async () => {
api.listGames.mockResolvedValue({ games: [] });
render(Library);
await vi.waitFor(() => {
expect(screen.getByText("No games yet")).toBeTruthy();
});
});
it("renders game cards when games load", async () => {
api.listGames.mockResolvedValue({
games: [
makeGame({ id: "g1", title: "Game One" }),
makeGame({ id: "g2", title: "Game Two" }),
],
});
render(Library);
await vi.waitFor(() => {
expect(screen.getByText("Game One")).toBeTruthy();
expect(screen.getByText("Game Two")).toBeTruthy();
});
});
it("shows game count", async () => {
api.listGames.mockResolvedValue({
games: [
makeGame({ id: "g1", title: "One" }),
makeGame({ id: "g2", title: "Two" }),
],
});
render(Library);
await vi.waitFor(() => {
expect(screen.getByText("2 games")).toBeTruthy();
});
});
it("shows singular game count for one game", async () => {
api.listGames.mockResolvedValue({
games: [makeGame({ id: "g1", title: "Only" })],
});
render(Library);
await vi.waitFor(() => {
expect(screen.getByText("1 game")).toBeTruthy();
});
});
describe("filters", () => {
it("shows filter options derived from loaded games", async () => {
api.listGames.mockResolvedValue({
games: [
makeGame({ id: "g1", genre: "RPG", year: 1997 }),
makeGame({ id: "g2", genre: "RPG", year: 1998 }),
makeGame({ id: "g3", genre: "Adventure", year: 1995 }),
],
});
render(Library);
await vi.waitFor(() => {
// Year labels appear both as filter options and in game cards
expect(screen.getAllByText("1995").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("1997").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("1998").length).toBeGreaterThanOrEqual(1);
});
});
it("applies genre filter from props", async () => {
api.listGames.mockResolvedValue({
games: [
makeGame({ id: "g1", title: "RPG Game", genre: "RPG" }),
makeGame({ id: "g2", title: "Adventure Game", genre: "Adventure" }),
],
});
render(Library, { props: { genreFilter: "RPG" } });
await vi.waitFor(() => {
expect(screen.getByText("RPG Game")).toBeTruthy();
expect(screen.queryByText("Adventure Game")).toBeNull();
});
});
it("applies year filter from props", async () => {
api.listGames.mockResolvedValue({
games: [
makeGame({ id: "g1", title: "Old Game", year: 1993 }),
makeGame({ id: "g2", title: "New Game", year: 1997 }),
],
});
render(Library, { props: { yearFilter: "1997" } });
await vi.waitFor(() => {
expect(screen.getByText("New Game")).toBeTruthy();
expect(screen.queryByText("Old Game")).toBeNull();
});
});
it("shows filter-empty message when no filter options exist", async () => {
api.listGames.mockResolvedValue({
games: [
makeGame({ id: "g1", genre: null, year: null }),
makeGame({ id: "g2", genre: null, year: null }),
],
});
render(Library);
await vi.waitFor(() => {
const dashes = screen.getAllByText("—");
expect(dashes.length).toBeGreaterThanOrEqual(2);
});
});
});
});
+77
View File
@@ -0,0 +1,77 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/svelte";
import Play from "../Play.svelte";
// Mock the API module that Play imports
vi.mock("../../lib/api.js", () => ({
getGame: vi.fn(),
bundleUrl: vi.fn(),
setupBundleUrl: vi.fn(),
}));
// Mock router
vi.mock("../../lib/router.js", () => ({
push: vi.fn(),
}));
import * as api from "../../lib/api.js";
function makeGame(overrides = {}) {
return {
id: "game-1",
title: "Commander Keen",
platform: "dos",
ready: true,
...overrides,
};
}
describe("Play", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows loading state initially", () => {
api.getGame.mockReturnValue(new Promise(() => {}));
render(Play, { props: { id: "game-1" } });
expect(screen.getByText("Loading game data...")).toBeTruthy();
});
it("shows not-ready state when game is not processed", async () => {
api.getGame.mockResolvedValue(makeGame({ ready: false }));
render(Play, { props: { id: "game-1" } });
await vi.waitFor(() => {
expect(screen.getByText("Not ready")).toBeTruthy();
});
});
it("shows unsupported warning for Windows games", async () => {
api.getGame.mockResolvedValue(
makeGame({ platform: "windows", ready: true })
);
render(Play, { props: { id: "game-1" } });
await vi.waitFor(() => {
expect(screen.getByText("Windows game")).toBeTruthy();
});
});
it("shows error box when game fetch fails", async () => {
api.getGame.mockRejectedValue(new Error("Game not found"));
render(Play, { props: { id: "missing" } });
// The error should appear in the overlay as well
await vi.waitFor(() => {
expect(screen.getByText("⚠️")).toBeTruthy();
});
});
it("shows emulator booting state for ready DOS games", async () => {
api.getGame.mockResolvedValue(
makeGame({ platform: "dos", ready: true })
);
api.bundleUrl.mockReturnValue("/games/game-1.jsdos");
render(Play, { props: { id: "game-1" } });
await vi.waitFor(() => {
expect(screen.getByText("Starting emulator...")).toBeTruthy();
});
});
});