···44 * Ensures ClientCardProvider and ServerCardProvider return identical data
55 */
6677-import { readFile } from "node:fs/promises";
88-import { join } from "node:path";
97import { beforeAll, describe, expect, it, vi } from "vitest";
108import type { CardDataProvider } from "../card-data-provider";
119import { ClientCardProvider } from "../cards-client-provider";
1210import { ServerCardProvider } from "../cards-server-provider";
1311import { asOracleId, asScryfallId } from "../scryfall-types";
1414-1515-const PUBLIC_DIR = join(process.cwd(), "public");
1212+import { mockFetchFromPublicDir } from "./test-helpers";
16131714// Mock cards-worker-client to use real worker code without Comlink/Worker
1815vi.mock("../cards-worker-client", () => {
···110107 let serverProvider: ServerCardProvider;
111108112109 beforeAll(async () => {
113113- // Mock fetch to serve cards.json from filesystem
114114- vi.stubGlobal(
115115- "fetch",
116116- vi.fn(async (input: RequestInfo | URL) => {
117117- const url = typeof input === "string" ? input : input.toString();
118118-119119- if (url.startsWith("/data/")) {
120120- const filePath = join(PUBLIC_DIR, url);
121121- try {
122122- const content = await readFile(filePath, "utf-8");
123123- return new Response(content, {
124124- status: 200,
125125- headers: { "Content-Type": "application/json" },
126126- });
127127- } catch {}
128128- }
129129-130130- return new Response(null, { status: 404 });
131131- }),
132132- );
110110+ mockFetchFromPublicDir();
133111134112 serverProvider = new ServerCardProvider();
135113 clientProvider = new ClientCardProvider();
136114 await clientProvider.initialize();
137137- });
115115+ }, 20_000);
138116139117 describe.each([
140118 ["ServerCardProvider", () => serverProvider],
···295273 it("supports searchCards", async () => {
296274 expect(clientProvider.searchCards).toBeDefined();
297275298298- const results = await clientProvider.searchCards("forest", 10);
276276+ const results = await clientProvider.searchCards("forest", undefined, 10);
299277 expect(Array.isArray(results)).toBe(true);
300278 expect(results.length).toBeGreaterThan(0);
301279
+130
src/lib/__tests__/deck-types.test.ts
···11+import { describe, expect, it } from "vitest";
22+import type { Deck } from "../deck-types";
33+import { getCommanderColorIdentity } from "../deck-types";
44+import type { Card } from "../scryfall-types";
55+import { asOracleId, asScryfallId } from "../scryfall-types";
66+77+function mockCard(overrides: Partial<Card> = {}): Card {
88+ return {
99+ id: asScryfallId("00000000-0000-0000-0000-000000000000"),
1010+ oracle_id: asOracleId("00000000-0000-0000-0000-000000000000"),
1111+ name: "Test Card",
1212+ ...overrides,
1313+ } as Card;
1414+}
1515+1616+function mockDeck(commanderIds: string[]): Deck {
1717+ return {
1818+ $type: "com.deckbelcher.deck.list",
1919+ name: "Test Deck",
2020+ format: "commander",
2121+ cards: commanderIds.map((id) => ({
2222+ scryfallId: asScryfallId(id),
2323+ quantity: 1,
2424+ section: "commander" as const,
2525+ tags: [],
2626+ })),
2727+ createdAt: new Date().toISOString(),
2828+ };
2929+}
3030+3131+describe("getCommanderColorIdentity", () => {
3232+ it("returns empty array for colorless commander", () => {
3333+ const deck = mockDeck(["kozilek-id"]);
3434+ const cardLookup = () => mockCard({ color_identity: [] });
3535+3636+ const result = getCommanderColorIdentity(deck, cardLookup);
3737+3838+ expect(result).toEqual([]);
3939+ });
4040+4141+ it("returns single color for mono-color commander", () => {
4242+ const deck = mockDeck(["blue-commander"]);
4343+ const cardLookup = () => mockCard({ color_identity: ["U"] });
4444+4545+ const result = getCommanderColorIdentity(deck, cardLookup);
4646+4747+ expect(result).toEqual(["U"]);
4848+ });
4949+5050+ it("returns multiple colors for multi-color commander", () => {
5151+ const deck = mockDeck(["azorius-commander"]);
5252+ const cardLookup = () => mockCard({ color_identity: ["W", "U"] });
5353+5454+ const result = getCommanderColorIdentity(deck, cardLookup);
5555+5656+ expect(result).toEqual(["U", "W"]); // Sorted
5757+ });
5858+5959+ it("merges colors from partner commanders", () => {
6060+ const deck = mockDeck(["white-partner", "blue-partner"]);
6161+ const cards: Record<string, Card> = {
6262+ "white-partner": mockCard({ color_identity: ["W"] }),
6363+ "blue-partner": mockCard({ color_identity: ["U"] }),
6464+ };
6565+ const cardLookup = (id: string) => cards[id];
6666+6767+ const result = getCommanderColorIdentity(deck, cardLookup);
6868+6969+ expect(result).toEqual(["U", "W"]); // Combined and sorted
7070+ });
7171+7272+ it("deduplicates overlapping colors from partners", () => {
7373+ const deck = mockDeck(["partner1", "partner2"]);
7474+ const cards: Record<string, Card> = {
7575+ partner1: mockCard({ color_identity: ["W", "U", "B"] }),
7676+ partner2: mockCard({ color_identity: ["U", "B", "R"] }),
7777+ };
7878+ const cardLookup = (id: string) => cards[id];
7979+8080+ const result = getCommanderColorIdentity(deck, cardLookup);
8181+8282+ expect(result).toEqual(["B", "R", "U", "W"]); // Deduped and sorted
8383+ });
8484+8585+ it("returns empty array when no commanders", () => {
8686+ const deck: Deck = {
8787+ $type: "com.deckbelcher.deck.list",
8888+ name: "Test Deck",
8989+ format: "commander",
9090+ cards: [],
9191+ createdAt: new Date().toISOString(),
9292+ };
9393+ const cardLookup = () => undefined;
9494+9595+ const result = getCommanderColorIdentity(deck, cardLookup);
9696+9797+ expect(result).toEqual([]);
9898+ });
9999+100100+ it("handles missing card data gracefully", () => {
101101+ const deck = mockDeck(["missing-commander"]);
102102+ const cardLookup = () => undefined;
103103+104104+ const result = getCommanderColorIdentity(deck, cardLookup);
105105+106106+ expect(result).toEqual([]);
107107+ });
108108+109109+ it("handles card with undefined color_identity", () => {
110110+ const deck = mockDeck(["commander"]);
111111+ const cardLookup = () => mockCard({ color_identity: undefined });
112112+113113+ const result = getCommanderColorIdentity(deck, cardLookup);
114114+115115+ expect(result).toEqual([]);
116116+ });
117117+118118+ it("returns empty array when commander data isn't loaded yet", () => {
119119+ // This simulates the case where a deck has a commander but the card
120120+ // data hasn't been fetched from the query cache yet
121121+ const deck = mockDeck(["unloaded-commander"]);
122122+ const cardLookup = () => undefined; // Card not in cache yet
123123+124124+ const result = getCommanderColorIdentity(deck, cardLookup);
125125+126126+ // Empty array means colorless-only, which would incorrectly filter out
127127+ // all colored cards. This is a bug we need to handle in the UI layer.
128128+ expect(result).toEqual([]);
129129+ });
130130+});
+31
src/lib/__tests__/test-helpers.ts
···11+import { readFile } from "node:fs/promises";
22+import { join } from "node:path";
33+import { vi } from "vitest";
44+55+const PUBLIC_DIR = join(process.cwd(), "public");
66+77+/**
88+ * Mock global fetch to serve files from public directory
99+ * Use this in tests that need to load cards.json or other static assets
1010+ */
1111+export function mockFetchFromPublicDir() {
1212+ vi.stubGlobal(
1313+ "fetch",
1414+ vi.fn(async (input: RequestInfo | URL) => {
1515+ const url = typeof input === "string" ? input : input.toString();
1616+1717+ if (url.startsWith("/data/")) {
1818+ const filePath = join(PUBLIC_DIR, url);
1919+ try {
2020+ const content = await readFile(filePath, "utf-8");
2121+ return new Response(content, {
2222+ status: 200,
2323+ headers: { "Content-Type": "application/json" },
2424+ });
2525+ } catch {}
2626+ }
2727+2828+ return new Response(null, { status: 404 });
2929+ }),
3030+ );
3131+}
+12-3
src/lib/card-data-provider.ts
···77 */
8899import { ClientCardProvider } from "./cards-client-provider";
1010-import type { Card, OracleId, ScryfallId } from "./scryfall-types";
1010+import type {
1111+ Card,
1212+ OracleId,
1313+ ScryfallId,
1414+ SearchRestrictions,
1515+} from "./scryfall-types";
11161217export interface CardDataProvider {
1318 /**
···3136 getCanonicalPrinting(oracleId: OracleId): Promise<ScryfallId | undefined>;
32373338 /**
3434- * Search cards by name (optional - may not be available on all providers)
3939+ * Search cards by name with optional restrictions (optional - may not be available on all providers)
3540 */
3636- searchCards?(query: string, limit?: number): Promise<Card[]>;
4141+ searchCards?(
4242+ query: string,
4343+ restrictions?: SearchRestrictions,
4444+ maxResults?: number,
4545+ ): Promise<Card[]>;
3746}
38473948let providerPromise: Promise<CardDataProvider> | null = null;