diff --git a/app/_actions/__tests__/export.test.ts b/app/_actions/__tests__/export.test.ts
index e807188..1c24175 100644
--- a/app/_actions/__tests__/export.test.ts
+++ b/app/_actions/__tests__/export.test.ts
@@ -38,7 +38,7 @@ const DECK_ID = "deck-1";
function makeCard(
name: string,
zone: "MAINBOARD" | "SIDEBOARD" | "COMMANDER" | "CONSIDERING",
- category: string | null = null,
+ categories: string[] = [],
) {
return {
id: name,
@@ -46,7 +46,7 @@ function makeCard(
cardId: name,
quantity: 1,
zone,
- category,
+ categories,
printingId: null,
isFoil: false,
card: { name },
@@ -62,8 +62,9 @@ const MOCK_DECK = {
description: null,
cards: [
makeCard("Sol Ring", "COMMANDER"),
- makeCard("Lightning Bolt", "MAINBOARD", "Removal"),
- makeCard("Cultivate", "MAINBOARD", "Ramp"),
+ makeCard("Lightning Bolt", "MAINBOARD", ["Removal"]),
+ makeCard("Cultivate", "MAINBOARD", ["Ramp"]),
+ makeCard("Nature's Lore", "MAINBOARD", ["Removal", "Ramp"]),
makeCard("Forest", "MAINBOARD"),
makeCard("Snapcaster Mage", "SIDEBOARD"),
makeCard("Ponder", "CONSIDERING"),
@@ -104,7 +105,7 @@ describe("getDeckExports", () => {
await getDeckExports(DECK_ID);
const textAdapter = serializers.find((a) => a.id === "text")!;
const calledDeck = vi.mocked(textAdapter.serialize).mock.calls[0]?.[0];
- expect(calledDeck?.cards).toHaveLength(6);
+ expect(calledDeck?.cards).toHaveLength(7);
});
it("zone filter: MAINBOARD only → excludes sideboard/considering/commander", async () => {
@@ -113,7 +114,7 @@ describe("getDeckExports", () => {
const textAdapter = serializers.find((a) => a.id === "text")!;
const calledDeck = vi.mocked(textAdapter.serialize).mock.calls[0]?.[0];
expect(calledDeck?.cards.every((c: { zone: string }) => c.zone === "MAINBOARD")).toBe(true);
- expect(calledDeck?.cards).toHaveLength(3);
+ expect(calledDeck?.cards).toHaveLength(4);
});
it("zone filter: COMMANDER + MAINBOARD → excludes sideboard and considering", async () => {
@@ -134,9 +135,12 @@ describe("getDeckExports", () => {
});
const textAdapter = serializers.find((a) => a.id === "text")!;
const calledDeck = vi.mocked(textAdapter.serialize).mock.calls[0]?.[0];
- // Cultivate (Ramp) + Forest (null category, passes through) remain
+ // Cultivate (Ramp) + Forest (uncategorized, passes through) remain.
expect(calledDeck?.cards.find((c: { card: { name: string } }) => c.card.name === "Lightning Bolt")).toBeUndefined();
expect(calledDeck?.cards.find((c: { card: { name: string } }) => c.card.name === "Cultivate")).toBeDefined();
+ // A multi-category card passes when ANY of its memberships is selected,
+ // even if its primary is a filtered-out category.
+ expect(calledDeck?.cards.find((c: { card: { name: string } }) => c.card.name === "Nature's Lore")).toBeDefined();
});
it("category filter passes uncategorized cards", async () => {
diff --git a/app/_actions/__tests__/inventory.test.ts b/app/_actions/__tests__/inventory.test.ts
index e7f7ec5..53955a0 100644
--- a/app/_actions/__tests__/inventory.test.ts
+++ b/app/_actions/__tests__/inventory.test.ts
@@ -22,6 +22,10 @@ vi.mock("@/lib/db", () => ({
create: vi.fn(),
deleteMany: vi.fn(),
},
+ deckCategory: {
+ findFirst: vi.fn(),
+ upsert: vi.fn(),
+ },
deck: { findFirst: vi.fn() },
},
}));
@@ -45,6 +49,8 @@ const mockHoldingDeleteMany = vi.mocked(prisma.holding.deleteMany);
const mockDeckCardFindFirst = vi.mocked(prisma.deckCard.findFirst);
const mockDeckCardCreate = vi.mocked(prisma.deckCard.create);
const mockDeckCardDeleteMany = vi.mocked(prisma.deckCard.deleteMany);
+const mockDeckCategoryFindFirst = vi.mocked(prisma.deckCategory.findFirst);
+const mockDeckCategoryUpsert = vi.mocked(prisma.deckCategory.upsert);
const mockDeckFindFirst = vi.mocked(prisma.deck.findFirst);
const mockGetOrCreateWishlistDeck = vi.mocked(getOrCreateWishlistDeck);
const mockUpdateTag = vi.mocked(updateTag);
@@ -182,7 +188,6 @@ describe("setWishlist", () => {
printingId: PRINTING_ID,
isFoil: false,
zone: "MAINBOARD",
- category: null,
quantity: 1,
},
});
@@ -202,6 +207,8 @@ describe("setWishlist", () => {
mockDeckCardFindFirst.mockResolvedValue(null);
mockDeckCardCreate.mockResolvedValue({} as never);
mockDeckFindFirst.mockResolvedValue({ name: "Krenko Goblins" } as never);
+ mockDeckCategoryFindFirst.mockResolvedValue(null);
+ mockDeckCategoryUpsert.mockResolvedValue({ id: "cat-krenko" } as never);
await setWishlist(PRINTING_ID, false, true, "deck-99");
@@ -209,6 +216,20 @@ describe("setWishlist", () => {
where: { id: "deck-99", userId: USER_ID },
select: { name: true },
});
+ // The source deck's name is registered (normalized) in the wishlist's
+ // category registry on first use.
+ expect(mockDeckCategoryUpsert).toHaveBeenCalledWith({
+ where: {
+ deckId_name: { deckId: WISHLIST_DECK_ID, name: "krenko goblins" },
+ },
+ create: {
+ deckId: WISHLIST_DECK_ID,
+ name: "krenko goblins",
+ sortOrder: 0,
+ },
+ update: {},
+ select: { id: true },
+ });
expect(mockDeckCardCreate).toHaveBeenCalledWith({
data: {
deckId: WISHLIST_DECK_ID,
@@ -216,22 +237,82 @@ describe("setWishlist", () => {
printingId: PRINTING_ID,
isFoil: false,
zone: "MAINBOARD",
- category: "Krenko Goblins",
quantity: 1,
+ categoryLinks: {
+ create: [{ deckCategoryId: "cat-krenko", position: 0 }],
+ },
},
});
});
- it("leaves the category null when no source deck is given (non-deck context)", async () => {
+ it("appends the new category after existing registry entries (sortOrder = max+1)", async () => {
+ mockDeckCardFindFirst.mockResolvedValue(null);
+ mockDeckCardCreate.mockResolvedValue({} as never);
+ mockDeckFindFirst.mockResolvedValue({ name: "Krenko Goblins" } as never);
+ mockDeckCategoryFindFirst.mockResolvedValue({ sortOrder: 3 } as never);
+ mockDeckCategoryUpsert.mockResolvedValue({ id: "cat-krenko" } as never);
+
+ await setWishlist(PRINTING_ID, false, true, "deck-99");
+
+ expect(mockDeckCategoryUpsert).toHaveBeenCalledWith(
+ expect.objectContaining({
+ create: expect.objectContaining({ sortOrder: 4 }),
+ }),
+ );
+ });
+
+ it("truncates a max-length source deck name to the category cap", async () => {
+ mockDeckCardFindFirst.mockResolvedValue(null);
+ mockDeckCardCreate.mockResolvedValue({} as never);
+ const longName = "x".repeat(100); // DECK_NAME_MAX
+ mockDeckFindFirst.mockResolvedValue({ name: longName } as never);
+ mockDeckCategoryFindFirst.mockResolvedValue(null);
+ mockDeckCategoryUpsert.mockResolvedValue({ id: "cat-long" } as never);
+
+ await setWishlist(PRINTING_ID, false, true, "deck-99");
+
+ const expected = "x".repeat(50); // CATEGORY_NAME_MAX
+ expect(mockDeckCategoryUpsert).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: {
+ deckId_name: { deckId: WISHLIST_DECK_ID, name: expected },
+ },
+ create: expect.objectContaining({ name: expected }),
+ }),
+ );
+ });
+
+ it("leaves the card uncategorized when no source deck is given (non-deck context)", async () => {
mockDeckCardFindFirst.mockResolvedValue(null);
mockDeckCardCreate.mockResolvedValue({} as never);
await setWishlist(PRINTING_ID, false, true);
expect(mockDeckFindFirst).not.toHaveBeenCalled();
+ expect(mockDeckCategoryUpsert).not.toHaveBeenCalled();
+ expect(mockDeckCardCreate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.not.objectContaining({ categoryLinks: expect.anything() }),
+ }),
+ );
+ });
+
+ it("leaves the card uncategorized when the source deck name normalizes to empty", async () => {
+ mockDeckCardFindFirst.mockResolvedValue(null);
+ mockDeckCardCreate.mockResolvedValue({} as never);
+ mockDeckFindFirst.mockResolvedValue({ name: " " } as never);
+
+ await setWishlist(PRINTING_ID, false, true, "deck-99");
+
+ expect(mockDeckFindFirst).toHaveBeenCalledWith({
+ where: { id: "deck-99", userId: USER_ID },
+ select: { name: true },
+ });
+ expect(mockDeckCategoryFindFirst).not.toHaveBeenCalled();
+ expect(mockDeckCategoryUpsert).not.toHaveBeenCalled();
expect(mockDeckCardCreate).toHaveBeenCalledWith(
expect.objectContaining({
- data: expect.objectContaining({ category: null }),
+ data: expect.not.objectContaining({ categoryLinks: expect.anything() }),
}),
);
});
@@ -243,9 +324,10 @@ describe("setWishlist", () => {
await setWishlist(PRINTING_ID, false, true, WISHLIST_DECK_ID);
expect(mockDeckFindFirst).not.toHaveBeenCalled();
+ expect(mockDeckCategoryUpsert).not.toHaveBeenCalled();
expect(mockDeckCardCreate).toHaveBeenCalledWith(
expect.objectContaining({
- data: expect.objectContaining({ category: null }),
+ data: expect.not.objectContaining({ categoryLinks: expect.anything() }),
}),
);
});
@@ -257,9 +339,10 @@ describe("setWishlist", () => {
await setWishlist(PRINTING_ID, false, true, "deck-gone");
+ expect(mockDeckCategoryUpsert).not.toHaveBeenCalled();
expect(mockDeckCardCreate).toHaveBeenCalledWith(
expect.objectContaining({
- data: expect.objectContaining({ category: null }),
+ data: expect.not.objectContaining({ categoryLinks: expect.anything() }),
}),
);
});
diff --git a/app/_actions/deck/__tests__/bulk-edit.test.ts b/app/_actions/deck/__tests__/bulk-edit.test.ts
index 9158b0a..477f873 100644
--- a/app/_actions/deck/__tests__/bulk-edit.test.ts
+++ b/app/_actions/deck/__tests__/bulk-edit.test.ts
@@ -9,13 +9,20 @@ vi.mock("next/navigation", () => ({
vi.mock("@/lib/auth/session", () => ({
requireSession: vi.fn(),
}));
-vi.mock("@/lib/db", () => ({
- prisma: {
- deck: { findUnique: vi.fn() },
- card: { findMany: vi.fn() },
- deckCard: { findMany: vi.fn() },
- },
-}));
+vi.mock("@/lib/db", () => {
+ const deckCategory = { findMany: vi.fn(), createMany: vi.fn() };
+ return {
+ prisma: {
+ deck: { findUnique: vi.fn() },
+ card: { findMany: vi.fn() },
+ deckCard: { findMany: vi.fn() },
+ deckCategory,
+ $transaction: vi.fn(async (fn: (tx: unknown) => unknown) =>
+ fn({ deckCategory }),
+ ),
+ },
+ };
+});
vi.mock("@/lib/deck/mutation", async () => {
const actual = await vi.importActual
(
"@/lib/deck/mutation",
@@ -61,7 +68,7 @@ function resolved(
zone: Zone = Zone.MAINBOARD,
): ResolvedCard {
return {
- parsed: { name, quantity, zone, category: null, isFoil: false },
+ parsed: { name, quantity, zone, categories: [], isFoil: false },
cardId,
matchedName: name,
match: { kind: "exact" },
@@ -75,9 +82,8 @@ function existing(
cardId: number,
quantity: number,
zone: Zone = Zone.MAINBOARD,
- category: string | null = null,
): ExistingDeckCard {
- return { deckCardId, cardId, zone, category, quantity };
+ return { deckCardId, cardId, zone, quantity };
}
beforeEach(() => {
@@ -109,10 +115,10 @@ describe("diffDeck", () => {
]);
});
- it("preserves category by no-op when quantity is unchanged", () => {
+ it("emits no ops (preserving memberships) when quantity is unchanged", () => {
const changes = diffDeck(
[resolved(1, "Sol Ring", 1)],
- [existing("dc-1", 1, 1, Zone.MAINBOARD, "Ramp")],
+ [existing("dc-1", 1, 1, Zone.MAINBOARD)],
);
expect(changes).toEqual([]);
});
@@ -124,7 +130,7 @@ describe("diffDeck", () => {
]);
});
- it("emits an add op with category: null for a brand-new line", () => {
+ it("emits an add op with categories: [] for a brand-new line", () => {
const changes = diffDeck([resolved(2, "Sol Ring", 1)], []);
expect(changes).toEqual([
{
@@ -132,7 +138,7 @@ describe("diffDeck", () => {
cardId: 2,
quantity: 1,
zone: Zone.MAINBOARD,
- category: null,
+ categories: [],
},
]);
});
@@ -140,7 +146,7 @@ describe("diffDeck", () => {
it("treats a cross-zone move as remove + add (printing/foil are dropped)", () => {
const changes = diffDeck(
[resolved(1, "Force of Will", 1, Zone.SIDEBOARD)],
- [existing("dc-1", 1, 1, Zone.MAINBOARD, "Counters")],
+ [existing("dc-1", 1, 1, Zone.MAINBOARD)],
);
expect(changes).toContainEqual({
op: "remove",
@@ -151,7 +157,7 @@ describe("diffDeck", () => {
cardId: 1,
quantity: 1,
zone: Zone.SIDEBOARD,
- category: null,
+ categories: [],
});
expect(changes).toHaveLength(2);
});
@@ -167,7 +173,7 @@ describe("diffDeck", () => {
cardId: 1,
quantity: 5,
zone: Zone.MAINBOARD,
- category: null,
+ categories: [],
},
]);
});
@@ -178,7 +184,7 @@ describe("diffDeck", () => {
name: "Not A Real Card",
quantity: 1,
zone: Zone.MAINBOARD,
- category: null,
+ categories: [],
isFoil: false,
},
cardId: null,
@@ -191,18 +197,18 @@ describe("diffDeck", () => {
expect(changes).toEqual([]);
});
- it("prefers the categorized row as primary and removes uncategorized duplicates", () => {
- // Same (cardId, zone) but two existing rows: one in 'Ramp', one uncategorized.
- // Quantity unchanged → categorized row survives, extra is removed.
+ it("keeps the lowest-id row as primary and removes duplicate rows for the same (card, zone)", () => {
+ // Same (cardId, zone) but two existing rows. Quantity unchanged → the
+ // deterministic primary (lowest deckCardId) survives, the extra is removed.
const changes = diffDeck(
[resolved(1, "Sol Ring", 1)],
[
- existing("dc-uncat", 1, 1, Zone.MAINBOARD, null),
- existing("dc-ramp", 1, 1, Zone.MAINBOARD, "Ramp"),
+ existing("dc-b", 1, 1, Zone.MAINBOARD),
+ existing("dc-a", 1, 1, Zone.MAINBOARD),
],
);
expect(changes).toEqual([
- { op: "remove", deckCardId: "dc-uncat" },
+ { op: "remove", deckCardId: "dc-b" },
]);
});
});
@@ -225,7 +231,7 @@ describe("bulkReplaceDeck", () => {
{ id: 1, name: "Forest" },
] as never);
mockDeckCardFindMany.mockResolvedValue([
- { id: "dc-1", cardId: 1, zone: Zone.MAINBOARD, category: null, quantity: 1 },
+ { id: "dc-1", cardId: 1, zone: Zone.MAINBOARD, quantity: 1, categoryLinks: [] },
] as never);
const result = await bulkReplaceDeck(DECK_ID, "1 Forest");
@@ -250,7 +256,7 @@ describe("bulkReplaceDeck", () => {
{ id: 2, name: "Sol Ring" },
] as never);
mockDeckCardFindMany.mockResolvedValue([
- { id: "dc-1", cardId: 1, zone: Zone.MAINBOARD, category: null, quantity: 1 },
+ { id: "dc-1", cardId: 1, zone: Zone.MAINBOARD, quantity: 1, categoryLinks: [] },
] as never);
const result = await bulkReplaceDeck(DECK_ID, "4 Forest\n1 Sol Ring");
@@ -267,7 +273,7 @@ describe("bulkReplaceDeck", () => {
cardId: 2,
quantity: 1,
zone: Zone.MAINBOARD,
- category: null,
+ categories: [],
});
expect(result.added).toBe(1);
expect(result.updated).toBe(1);
@@ -297,7 +303,7 @@ describe("bulkReplaceDeck", () => {
cardId: 1,
quantity: 1,
zone: Zone.MAINBOARD,
- category: null,
+ categories: [],
},
]);
expect(result.added).toBe(1);
diff --git a/app/_actions/deck/__tests__/categories.test.ts b/app/_actions/deck/__tests__/categories.test.ts
index 4f7f56f..312f61f 100644
--- a/app/_actions/deck/__tests__/categories.test.ts
+++ b/app/_actions/deck/__tests__/categories.test.ts
@@ -28,16 +28,13 @@ vi.mock("@/lib/db", () => ({
findFirst: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
+ createMany: vi.fn(),
delete: vi.fn(),
update: vi.fn(),
},
deckCard: {
findMany: vi.fn(),
findUnique: vi.fn(),
- update: vi.fn(),
- updateMany: vi.fn(),
- delete: vi.fn(),
- deleteMany: vi.fn(),
},
$transaction: vi.fn(),
},
@@ -52,12 +49,12 @@ import {
autogenerateCategories,
createCategory,
deleteCategory,
- moveCardSubcategory,
moveCardTo,
moveCardZone,
moveCategoryCards,
renameCategory,
reorderCategories,
+ setCardCategories,
} from "../categories";
const mockSession = vi.mocked(requireSession);
@@ -66,12 +63,11 @@ const mockCategoryFindMany = vi.mocked(prisma.deckCategory.findMany);
const mockCategoryFindFirst = vi.mocked(prisma.deckCategory.findFirst);
const mockCategoryFindUnique = vi.mocked(prisma.deckCategory.findUnique);
const mockCategoryCreate = vi.mocked(prisma.deckCategory.create);
+const mockCategoryCreateMany = vi.mocked(prisma.deckCategory.createMany);
const mockCategoryDelete = vi.mocked(prisma.deckCategory.delete);
const mockCategoryUpdate = vi.mocked(prisma.deckCategory.update);
const mockCardFindMany = vi.mocked(prisma.deckCard.findMany);
const mockCardFindUnique = vi.mocked(prisma.deckCard.findUnique);
-const mockCardUpdateMany = vi.mocked(prisma.deckCard.updateMany);
-const mockCardDeleteMany = vi.mocked(prisma.deckCard.deleteMany);
const mockTransaction = vi.mocked(prisma.$transaction);
const mockUpdateTag = vi.mocked(updateTag);
const mockApply = vi.mocked(applyChanges);
@@ -79,6 +75,29 @@ const mockApply = vi.mocked(applyChanges);
const DECK_ID = "deck-1";
const USER_ID = "user-1";
+/** DeckCard row shaped like the `categoryLinks` select the actions issue. */
+function cardRow(
+ id: string,
+ zone: Zone,
+ categories: string[],
+ deckId = DECK_ID,
+) {
+ return {
+ id,
+ deckId,
+ zone,
+ categoryLinks: categories.map((name) => ({ deckCategory: { name } })),
+ };
+}
+
+/** Membership rows for the bulk `deckCard.findMany` member loads. */
+function memberRow(id: string, categories: string[]) {
+ return {
+ id,
+ categoryLinks: categories.map((name) => ({ deckCategory: { name } })),
+ };
+}
+
function moveChange(): PlannedChange {
expect(mockApply).toHaveBeenCalledTimes(1);
const [, , changes] = mockApply.mock.calls[0]!;
@@ -91,6 +110,23 @@ beforeEach(() => {
mockSession.mockResolvedValue({ userId: USER_ID, email: "test@test.com" } as never);
mockDeckFindUnique.mockResolvedValue({ userId: USER_ID } as never);
mockApply.mockResolvedValue(undefined);
+ // Interactive transactions run their callback against a tx client backed by
+ // the same mocks; array form (reorderCategories) awaits the batched calls.
+ mockTransaction.mockImplementation(async (arg: unknown) => {
+ if (typeof arg === "function") {
+ return arg({
+ deckCard: { findMany: mockCardFindMany },
+ deckCategory: {
+ findMany: mockCategoryFindMany,
+ findFirst: mockCategoryFindFirst,
+ create: mockCategoryCreate,
+ createMany: mockCategoryCreateMany,
+ delete: mockCategoryDelete,
+ },
+ });
+ }
+ return Promise.all(arg as Promise[]);
+ });
});
describe("createCategory", () => {
@@ -167,155 +203,152 @@ describe("createCategory", () => {
});
describe("deleteCategory", () => {
- it("nulls out category on matching Mainboard cards and deletes the subcategory", async () => {
+ it("uncategorize mode deletes only the DeckCategory row (FK cascade removes memberships)", async () => {
const categoryId = "cat-custom";
mockCategoryFindUnique.mockResolvedValue({ id: categoryId } as never);
- mockTransaction.mockImplementation(async (fn: unknown) => {
- if (typeof fn === "function") {
- const tx = {
- deckCard: {
- updateMany: mockCardUpdateMany,
- },
- deckCategory: {
- delete: mockCategoryDelete,
- },
- };
- return fn(tx);
- }
- });
-
- await deleteCategory(DECK_ID, "Ramp");
+ await deleteCategory(DECK_ID, "ramp");
- expect(mockCardUpdateMany).toHaveBeenCalledWith({
- where: { deckId: DECK_ID, zone: "MAINBOARD", category: "Ramp" },
- data: { category: null },
- });
expect(mockCategoryDelete).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: categoryId } }),
);
+ // No member load, no card mutation: the cascade handles memberships.
+ expect(mockCardFindMany).not.toHaveBeenCalled();
+ expect(mockApply).not.toHaveBeenCalled();
expect(mockUpdateTag).toHaveBeenCalledWith(`deck:${DECK_ID}`);
});
- it("does NOT touch cards in non-mainboard zones that reference the same name (stale reference preserved)", async () => {
- mockCategoryFindUnique.mockResolvedValue({ id: "cat-custom" } as never);
-
- const seenWheres: unknown[] = [];
- mockCardUpdateMany.mockImplementation(((args: unknown) => {
- seenWheres.push((args as { where: unknown }).where);
- return Promise.resolve({ count: 0 }) as never;
- }) as never);
- mockTransaction.mockImplementation(async (fn: unknown) => {
- if (typeof fn === "function") {
- const tx = {
- deckCard: { updateMany: mockCardUpdateMany },
- deckCategory: { delete: mockCategoryDelete },
- };
- return fn(tx);
- }
- });
-
- await deleteCategory(DECK_ID, "Ramp");
-
- // Every updateMany call must filter zone=MAINBOARD
- for (const where of seenWheres) {
- expect(where).toMatchObject({ zone: "MAINBOARD" });
- }
- });
-
it("throws when category does not exist", async () => {
mockCategoryFindUnique.mockResolvedValue(null as never);
await expect(deleteCategory(DECK_ID, "NonExistent")).rejects.toThrow(
'Category "NonExistent" not found',
);
- expect(mockTransaction).not.toHaveBeenCalled();
+ expect(mockCategoryDelete).not.toHaveBeenCalled();
});
it("throws when requester does not own the deck", async () => {
mockDeckFindUnique.mockResolvedValue({ userId: "other-user" } as never);
- await expect(deleteCategory(DECK_ID, "Ramp")).rejects.toThrow(
+ await expect(deleteCategory(DECK_ID, "ramp")).rejects.toThrow(
"NEXT_NOT_FOUND",
);
- expect(mockTransaction).not.toHaveBeenCalled();
+ expect(mockCategoryDelete).not.toHaveBeenCalled();
});
- it("deleteCards mode removes MAINBOARD rows, uncategorizes other zones, deletes the category row", async () => {
- const categoryId = "cat-custom";
+ it("deleteCards mode removes only primary members via applyChanges, then deletes the row", async () => {
+ const categoryId = "cat-ramp";
mockCategoryFindUnique.mockResolvedValue({ id: categoryId } as never);
+ mockCardFindMany.mockResolvedValue([
+ memberRow("dc-primary", ["ramp", "draw"]),
+ memberRow("dc-secondary", ["draw", "ramp"]),
+ ] as never);
- mockTransaction.mockImplementation(async (fn: unknown) => {
- if (typeof fn === "function") {
- const tx = {
- deckCard: {
- deleteMany: mockCardDeleteMany,
- updateMany: mockCardUpdateMany,
- },
- deckCategory: {
- delete: mockCategoryDelete,
- },
- };
- return fn(tx);
- }
- });
-
- await deleteCategory(DECK_ID, "Ramp", "deleteCards");
+ await deleteCategory(DECK_ID, "ramp", "deleteCards");
- expect(mockCardDeleteMany).toHaveBeenCalledWith({
- where: { deckId: DECK_ID, zone: "MAINBOARD", category: "Ramp" },
- });
- expect(mockCardUpdateMany).toHaveBeenCalledWith({
+ // Member load is MAINBOARD-scoped and membership-filtered.
+ expect(mockCardFindMany).toHaveBeenCalledWith({
where: {
deckId: DECK_ID,
- zone: { not: "MAINBOARD" },
- category: "Ramp",
+ zone: Zone.MAINBOARD,
+ categoryLinks: { some: { deckCategory: { name: "ramp" } } },
+ },
+ select: {
+ id: true,
+ categoryLinks: {
+ select: { deckCategory: { select: { name: true } } },
+ orderBy: { position: "asc" },
+ },
},
- data: { category: null },
});
+
+ // Only the card whose PRIMARY membership is "ramp" is removed.
+ expect(mockApply).toHaveBeenCalledTimes(1);
+ const [deckId, userId, changes] = mockApply.mock.calls[0]!;
+ expect(deckId).toBe(DECK_ID);
+ expect(userId).toBe(USER_ID);
+ expect(changes).toEqual([
+ { op: "remove", deckCardId: "dc-primary" },
+ ]);
+
expect(mockCategoryDelete).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: categoryId } }),
);
expect(mockUpdateTag).toHaveBeenCalledWith(`deck:${DECK_ID}`);
});
+ it("deleteCards mode skips applyChanges when no card has the category as primary", async () => {
+ mockCategoryFindUnique.mockResolvedValue({ id: "cat-ramp" } as never);
+ mockCardFindMany.mockResolvedValue([
+ memberRow("dc-secondary", ["draw", "ramp"]),
+ ] as never);
+
+ await deleteCategory(DECK_ID, "ramp", "deleteCards");
+
+ expect(mockApply).not.toHaveBeenCalled();
+ expect(mockCategoryDelete).toHaveBeenCalled();
+ });
+
+ it("deleteCards mode runs removals and the registry delete in one transaction", async () => {
+ mockCategoryFindUnique.mockResolvedValue({ id: "cat-ramp" } as never);
+ mockCardFindMany.mockResolvedValue([
+ memberRow("dc-primary", ["ramp"]),
+ ] as never);
+
+ await deleteCategory(DECK_ID, "ramp", "deleteCards");
+
+ expect(mockTransaction).toHaveBeenCalledTimes(1);
+ const [, , , opts] = mockApply.mock.calls[0]!;
+ expect(opts).toHaveProperty("tx");
+ });
+
+ it("deleteCards mode propagates a registry-delete failure so removals roll back with it", async () => {
+ mockCategoryFindUnique.mockResolvedValue({ id: "cat-ramp" } as never);
+ mockCardFindMany.mockResolvedValue([
+ memberRow("dc-primary", ["ramp"]),
+ ] as never);
+ mockCategoryDelete.mockRejectedValue(new Error("registry delete failed"));
+
+ await expect(
+ deleteCategory(DECK_ID, "ramp", "deleteCards"),
+ ).rejects.toThrow("registry delete failed");
+ // The card removals ran inside the same transaction, so a real client
+ // rolls them back when the delete fails.
+ const [, , , opts] = mockApply.mock.calls[0]!;
+ expect(opts).toHaveProperty("tx");
+ });
+
it("rejects an invalid mode value", async () => {
mockCategoryFindUnique.mockResolvedValue({ id: "cat-1" } as never);
await expect(
- deleteCategory(DECK_ID, "Ramp", "nuke" as never),
+ deleteCategory(DECK_ID, "ramp", "nuke" as never),
).rejects.toThrow();
- expect(mockTransaction).not.toHaveBeenCalled();
+ expect(mockCategoryDelete).not.toHaveBeenCalled();
});
});
describe("renameCategory", () => {
- it("renames the subcategory row and all DeckCard rows (any zone) referencing it (new name lowercased)", async () => {
+ it("renames only the DeckCategory row (memberships follow the id), new name lowercased", async () => {
mockCategoryFindUnique
.mockResolvedValueOnce({ id: "cat-ramp" } as never) // old exists
.mockResolvedValueOnce(null as never); // no conflict
- mockTransaction.mockImplementation(async (ops: unknown) => {
- if (Array.isArray(ops)) return Promise.all(ops);
- });
-
await renameCategory(DECK_ID, "ramp", "Acceleration");
expect(mockCategoryUpdate).toHaveBeenCalledWith({
where: { id: "cat-ramp" },
data: { name: "acceleration" },
});
- expect(mockCardUpdateMany).toHaveBeenCalledWith({
- where: { deckId: DECK_ID, category: "ramp" },
- data: { category: "acceleration" },
- });
+ // No DeckCard writes: memberships reference DeckCategory.id.
+ expect(mockApply).not.toHaveBeenCalled();
expect(mockUpdateTag).toHaveBeenCalledWith(`deck:${DECK_ID}`);
});
it("is a no-op when new name equals old name (after lowercasing)", async () => {
await renameCategory(DECK_ID, "ramp", "Ramp");
- // No DB write: the body returns early before opening a transaction.
- expect(mockTransaction).not.toHaveBeenCalled();
+ // No DB write: the body returns early.
+ expect(mockCategoryUpdate).not.toHaveBeenCalled();
// The mutation runner still emits the deck tag on successful return; a
// benign cache bust is preferable to a body opt-out signal.
expect(mockUpdateTag).toHaveBeenCalledWith(`deck:${DECK_ID}`);
@@ -384,37 +417,29 @@ describe("reorderCategories", () => {
});
describe("moveCardZone", () => {
- it("preserves the original category string when moving out of MAINBOARD", async () => {
+ it("clears all memberships when moving out of MAINBOARD", async () => {
mockCardFindUnique.mockResolvedValue({
id: "dc-1",
deckId: DECK_ID,
- cardId: 42,
- quantity: 2,
zone: Zone.MAINBOARD,
- category: "Ramp",
} as never);
await moveCardZone(DECK_ID, "dc-1", Zone.SIDEBOARD);
- // Category snaps to null for non-MAINBOARD zones (subcategories are mainboard-only).
expect(moveChange()).toEqual({
op: "move",
deckCardId: "dc-1",
zone: Zone.SIDEBOARD,
- category: null,
+ categories: [],
});
});
- it("falls back to category=null when returning to MAINBOARD with a stale subcategory", async () => {
+ it("moves back into MAINBOARD uncategorized (no membership snap-back)", async () => {
mockCardFindUnique.mockResolvedValue({
id: "dc-1",
deckId: DECK_ID,
- cardId: 42,
- quantity: 1,
zone: Zone.SIDEBOARD,
- category: "DeletedRamp",
} as never);
- mockCategoryFindUnique.mockResolvedValue(null as never);
await moveCardZone(DECK_ID, "dc-1", Zone.MAINBOARD);
@@ -422,28 +447,7 @@ describe("moveCardZone", () => {
op: "move",
deckCardId: "dc-1",
zone: Zone.MAINBOARD,
- category: null,
- });
- });
-
- it("snaps back to original subcategory on Mainboard return when subcategory still exists", async () => {
- mockCardFindUnique.mockResolvedValue({
- id: "dc-1",
- deckId: DECK_ID,
- cardId: 42,
- quantity: 1,
- zone: Zone.SIDEBOARD,
- category: "Ramp",
- } as never);
- mockCategoryFindUnique.mockResolvedValue({ id: "cat-ramp" } as never);
-
- await moveCardZone(DECK_ID, "dc-1", Zone.MAINBOARD);
-
- expect(moveChange()).toEqual({
- op: "move",
- deckCardId: "dc-1",
- zone: Zone.MAINBOARD,
- category: "Ramp",
+ categories: [],
});
});
@@ -451,10 +455,7 @@ describe("moveCardZone", () => {
mockCardFindUnique.mockResolvedValue({
id: "dc-1",
deckId: DECK_ID,
- cardId: 42,
- quantity: 1,
zone: Zone.MAINBOARD,
- category: null,
} as never);
await moveCardZone(DECK_ID, "dc-1", Zone.MAINBOARD);
@@ -467,10 +468,7 @@ describe("moveCardZone", () => {
mockCardFindUnique.mockResolvedValue({
id: "dc-1",
deckId: "other-deck",
- cardId: 42,
- quantity: 1,
zone: Zone.MAINBOARD,
- category: null,
} as never);
await expect(moveCardZone(DECK_ID, "dc-1", Zone.SIDEBOARD)).rejects.toThrow(
@@ -480,130 +478,139 @@ describe("moveCardZone", () => {
});
});
-describe("moveCardSubcategory", () => {
- it("changes subcategory on a MAINBOARD card and lowercases the target", async () => {
- mockCardFindUnique.mockResolvedValue({
- id: "dc-1",
- deckId: DECK_ID,
- cardId: 42,
- quantity: 2,
- zone: Zone.MAINBOARD,
- category: null,
- } as never);
- mockCategoryFindUnique.mockResolvedValue({ id: "cat-ramp" } as never);
+describe("setCardCategories", () => {
+ it("replaces memberships wholesale with the normalized ordered list", async () => {
+ mockCardFindUnique.mockResolvedValue(
+ cardRow("dc-1", Zone.MAINBOARD, ["ramp"]) as never,
+ );
+ mockCategoryFindMany.mockResolvedValue([
+ { name: "removal" },
+ { name: "draw" },
+ ] as never);
- await moveCardSubcategory(DECK_ID, "dc-1", "Ramp");
+ await setCardCategories(DECK_ID, "dc-1", ["Removal", " Draw "]);
- expect(mockCategoryFindUnique).toHaveBeenCalledWith(
- expect.objectContaining({
- where: { deckId_name: { deckId: DECK_ID, name: "ramp" } },
- }),
- );
+ expect(mockCategoryFindMany).toHaveBeenCalledWith({
+ where: { deckId: DECK_ID, name: { in: ["removal", "draw"] } },
+ select: { name: true },
+ });
expect(moveChange()).toEqual({
op: "move",
deckCardId: "dc-1",
zone: Zone.MAINBOARD,
- category: "ramp",
+ categories: ["removal", "draw"],
});
});
- it("allows passing null to uncategorize", async () => {
- mockCardFindUnique.mockResolvedValue({
- id: "dc-1",
- deckId: DECK_ID,
- cardId: 42,
- quantity: 1,
+ it("dedupes repeats and drops empty names while preserving order", async () => {
+ mockCardFindUnique.mockResolvedValue(
+ cardRow("dc-1", Zone.MAINBOARD, []) as never,
+ );
+ mockCategoryFindMany.mockResolvedValue([
+ { name: "ramp" },
+ { name: "draw" },
+ ] as never);
+
+ await setCardCategories(DECK_ID, "dc-1", ["Ramp", "ramp", " ", "Draw"]);
+
+ expect(moveChange()).toEqual({
+ op: "move",
+ deckCardId: "dc-1",
zone: Zone.MAINBOARD,
- category: "ramp",
- } as never);
+ categories: ["ramp", "draw"],
+ });
+ });
+
+ it("uncategorizes with an empty array (skips the registry lookup)", async () => {
+ mockCardFindUnique.mockResolvedValue(
+ cardRow("dc-1", Zone.MAINBOARD, ["ramp"]) as never,
+ );
- await moveCardSubcategory(DECK_ID, "dc-1", null);
+ await setCardCategories(DECK_ID, "dc-1", []);
+ expect(mockCategoryFindMany).not.toHaveBeenCalled();
expect(moveChange()).toEqual({
op: "move",
deckCardId: "dc-1",
zone: Zone.MAINBOARD,
- category: null,
+ categories: [],
});
});
it("rejects calls on non-MAINBOARD cards", async () => {
- mockCardFindUnique.mockResolvedValue({
- id: "dc-1",
- deckId: DECK_ID,
- cardId: 42,
- quantity: 1,
- zone: Zone.SIDEBOARD,
- category: null,
- } as never);
+ mockCardFindUnique.mockResolvedValue(
+ cardRow("dc-1", Zone.SIDEBOARD, []) as never,
+ );
await expect(
- moveCardSubcategory(DECK_ID, "dc-1", "Ramp"),
+ setCardCategories(DECK_ID, "dc-1", ["ramp"]),
).rejects.toThrow("Subcategories only apply to MAINBOARD cards");
expect(mockApply).not.toHaveBeenCalled();
});
- it("throws when target subcategory does not exist in the deck", async () => {
- mockCardFindUnique.mockResolvedValue({
- id: "dc-1",
- deckId: DECK_ID,
- cardId: 42,
- quantity: 1,
- zone: Zone.MAINBOARD,
- category: null,
- } as never);
- mockCategoryFindUnique.mockResolvedValue(null as never);
+ it("throws when a category is not in the deck registry", async () => {
+ mockCardFindUnique.mockResolvedValue(
+ cardRow("dc-1", Zone.MAINBOARD, []) as never,
+ );
+ mockCategoryFindMany.mockResolvedValue([{ name: "ramp" }] as never);
await expect(
- moveCardSubcategory(DECK_ID, "dc-1", "Ghost"),
+ setCardCategories(DECK_ID, "dc-1", ["Ramp", "Ghost"]),
).rejects.toThrow('Category "ghost" not found in deck');
expect(mockApply).not.toHaveBeenCalled();
});
- it("is a no-op when card already has the target subcategory", async () => {
- mockCardFindUnique.mockResolvedValue({
- id: "dc-1",
- deckId: DECK_ID,
- cardId: 42,
- quantity: 1,
- zone: Zone.MAINBOARD,
- category: "ramp",
- } as never);
- mockCategoryFindUnique.mockResolvedValue({ id: "cat-ramp" } as never);
+ it("is a no-op when the card already has exactly these memberships in order", async () => {
+ mockCardFindUnique.mockResolvedValue(
+ cardRow("dc-1", Zone.MAINBOARD, ["ramp", "draw"]) as never,
+ );
+ mockCategoryFindMany.mockResolvedValue([
+ { name: "ramp" },
+ { name: "draw" },
+ ] as never);
- await moveCardSubcategory(DECK_ID, "dc-1", "Ramp");
+ await setCardCategories(DECK_ID, "dc-1", ["Ramp", "Draw"]);
expect(mockApply).not.toHaveBeenCalled();
expect(mockUpdateTag).not.toHaveBeenCalled();
});
- it("throws when the deck card belongs to a different deck", async () => {
- mockCardFindUnique.mockResolvedValue({
- id: "dc-1",
- deckId: "other-deck",
- cardId: 42,
- quantity: 1,
+ it("emits a move when only the order (primary) changes", async () => {
+ mockCardFindUnique.mockResolvedValue(
+ cardRow("dc-1", Zone.MAINBOARD, ["ramp", "draw"]) as never,
+ );
+ mockCategoryFindMany.mockResolvedValue([
+ { name: "ramp" },
+ { name: "draw" },
+ ] as never);
+
+ await setCardCategories(DECK_ID, "dc-1", ["Draw", "Ramp"]);
+
+ expect(moveChange()).toEqual({
+ op: "move",
+ deckCardId: "dc-1",
zone: Zone.MAINBOARD,
- category: null,
- } as never);
+ categories: ["draw", "ramp"],
+ });
+ });
+
+ it("throws when the deck card belongs to a different deck", async () => {
+ mockCardFindUnique.mockResolvedValue(
+ cardRow("dc-1", Zone.MAINBOARD, [], "other-deck") as never,
+ );
await expect(
- moveCardSubcategory(DECK_ID, "dc-1", "Ramp"),
+ setCardCategories(DECK_ID, "dc-1", ["ramp"]),
).rejects.toThrow("Card not found or unauthorized");
expect(mockApply).not.toHaveBeenCalled();
});
});
describe("moveCardTo", () => {
- it("forwards a move to MAINBOARD with the requested subcategory (lowercased)", async () => {
- mockCardFindUnique.mockResolvedValue({
- id: "dc-1",
- deckId: DECK_ID,
- cardId: 42,
- quantity: 1,
- zone: Zone.SIDEBOARD,
- category: null,
- } as never);
+ it("promotes the target category to primary and preserves other memberships", async () => {
+ mockCardFindUnique.mockResolvedValue(
+ cardRow("dc-1", Zone.MAINBOARD, ["draw", "ramp"]) as never,
+ );
mockCategoryFindUnique.mockResolvedValue({ id: "cat-ramp" } as never);
await moveCardTo(DECK_ID, "dc-1", Zone.MAINBOARD, "Ramp");
@@ -617,7 +624,39 @@ describe("moveCardTo", () => {
op: "move",
deckCardId: "dc-1",
zone: Zone.MAINBOARD,
- category: "ramp",
+ categories: ["ramp", "draw"],
+ });
+ });
+
+ it("moves into MAINBOARD with the requested subcategory (lowercased)", async () => {
+ mockCardFindUnique.mockResolvedValue(
+ cardRow("dc-1", Zone.SIDEBOARD, []) as never,
+ );
+ mockCategoryFindUnique.mockResolvedValue({ id: "cat-ramp" } as never);
+
+ await moveCardTo(DECK_ID, "dc-1", Zone.MAINBOARD, "Ramp");
+
+ expect(moveChange()).toEqual({
+ op: "move",
+ deckCardId: "dc-1",
+ zone: Zone.MAINBOARD,
+ categories: ["ramp"],
+ });
+ });
+
+ it("clears every membership when dropped on the Uncategorized bucket", async () => {
+ mockCardFindUnique.mockResolvedValue(
+ cardRow("dc-1", Zone.MAINBOARD, ["ramp", "draw"]) as never,
+ );
+
+ await moveCardTo(DECK_ID, "dc-1", Zone.MAINBOARD, null);
+
+ expect(mockCategoryFindUnique).not.toHaveBeenCalled();
+ expect(moveChange()).toEqual({
+ op: "move",
+ deckCardId: "dc-1",
+ zone: Zone.MAINBOARD,
+ categories: [],
});
});
@@ -630,14 +669,9 @@ describe("moveCardTo", () => {
});
it("throws when the target subcategory does not exist", async () => {
- mockCardFindUnique.mockResolvedValue({
- id: "dc-1",
- deckId: DECK_ID,
- cardId: 42,
- quantity: 1,
- zone: Zone.SIDEBOARD,
- category: null,
- } as never);
+ mockCardFindUnique.mockResolvedValue(
+ cardRow("dc-1", Zone.SIDEBOARD, []) as never,
+ );
mockCategoryFindUnique.mockResolvedValue(null as never);
await expect(
@@ -646,15 +680,10 @@ describe("moveCardTo", () => {
expect(mockApply).not.toHaveBeenCalled();
});
- it("is a no-op when card is already in the target zone and category", async () => {
- mockCardFindUnique.mockResolvedValue({
- id: "dc-1",
- deckId: DECK_ID,
- cardId: 42,
- quantity: 1,
- zone: Zone.MAINBOARD,
- category: "ramp",
- } as never);
+ it("is a no-op when the card is already in the target zone with the target primary", async () => {
+ mockCardFindUnique.mockResolvedValue(
+ cardRow("dc-1", Zone.MAINBOARD, ["ramp", "draw"]) as never,
+ );
mockCategoryFindUnique.mockResolvedValue({ id: "cat-ramp" } as never);
await moveCardTo(DECK_ID, "dc-1", Zone.MAINBOARD, "Ramp");
@@ -664,14 +693,9 @@ describe("moveCardTo", () => {
});
it("throws when the deck card belongs to a different deck", async () => {
- mockCardFindUnique.mockResolvedValue({
- id: "dc-1",
- deckId: "other-deck",
- cardId: 42,
- quantity: 1,
- zone: Zone.MAINBOARD,
- category: null,
- } as never);
+ mockCardFindUnique.mockResolvedValue(
+ cardRow("dc-1", Zone.MAINBOARD, [], "other-deck") as never,
+ );
await expect(
moveCardTo(DECK_ID, "dc-1", Zone.MAINBOARD, null),
@@ -679,55 +703,70 @@ describe("moveCardTo", () => {
expect(mockApply).not.toHaveBeenCalled();
});
- it("moves a MAINBOARD card to a non-MAINBOARD zone with null category (skips category-existence check)", async () => {
- mockCardFindUnique.mockResolvedValue({
- id: "dc-1",
- deckId: DECK_ID,
- cardId: 42,
- quantity: 1,
- zone: Zone.MAINBOARD,
- category: "Ramp",
- } as never);
+ it("moves a MAINBOARD card to a non-MAINBOARD zone clearing memberships (skips category-existence check)", async () => {
+ mockCardFindUnique.mockResolvedValue(
+ cardRow("dc-1", Zone.MAINBOARD, ["ramp"]) as never,
+ );
await moveCardTo(DECK_ID, "dc-1", Zone.SIDEBOARD, null);
- // The category lookup is mainboard-only, so it should not be queried here.
expect(mockCategoryFindUnique).not.toHaveBeenCalled();
- expect(mockApply).toHaveBeenCalledTimes(1);
- const [, , changes] = mockApply.mock.calls[0]!;
- expect(changes[0]).toEqual({
+ expect(moveChange()).toEqual({
op: "move",
deckCardId: "dc-1",
zone: Zone.SIDEBOARD,
- category: null,
+ categories: [],
});
});
});
describe("moveCategoryCards", () => {
- it("moves every mainboard card in the source category to another category (one move op per card)", async () => {
+ it("moves primary members to the target category, swapping primary and keeping secondaries", async () => {
mockCategoryFindUnique.mockResolvedValue({ id: "cat-removal" } as never);
mockCardFindMany.mockResolvedValue([
- { id: "dc-1" },
- { id: "dc-2" },
+ memberRow("dc-1", ["ramp"]),
+ memberRow("dc-2", ["ramp", "draw"]),
+ memberRow("dc-3", ["draw", "ramp"]), // secondary member: untouched
] as never);
await moveCategoryCards(DECK_ID, "ramp", Zone.MAINBOARD, "Removal");
expect(mockCardFindMany).toHaveBeenCalledWith({
- where: { deckId: DECK_ID, zone: Zone.MAINBOARD, category: "ramp" },
- select: { id: true },
+ where: {
+ deckId: DECK_ID,
+ zone: Zone.MAINBOARD,
+ categoryLinks: { some: { deckCategory: { name: "ramp" } } },
+ },
+ select: {
+ id: true,
+ categoryLinks: {
+ select: { deckCategory: { select: { name: true } } },
+ orderBy: { position: "asc" },
+ },
+ },
});
expect(mockApply).toHaveBeenCalledTimes(1);
const [, , changes] = mockApply.mock.calls[0]!;
expect(changes).toEqual([
- { op: "move", deckCardId: "dc-1", zone: Zone.MAINBOARD, category: "removal" },
- { op: "move", deckCardId: "dc-2", zone: Zone.MAINBOARD, category: "removal" },
+ {
+ op: "move",
+ deckCardId: "dc-1",
+ zone: Zone.MAINBOARD,
+ categories: ["removal"],
+ },
+ {
+ op: "move",
+ deckCardId: "dc-2",
+ zone: Zone.MAINBOARD,
+ categories: ["removal", "draw"],
+ },
]);
});
- it("moves cards to Uncategorized (null category, stays MAINBOARD)", async () => {
- mockCardFindMany.mockResolvedValue([{ id: "dc-1" }] as never);
+ it("moves cards to Uncategorized (clears memberships, stays MAINBOARD)", async () => {
+ mockCardFindMany.mockResolvedValue([
+ memberRow("dc-1", ["ramp", "draw"]),
+ ] as never);
await moveCategoryCards(DECK_ID, "ramp", Zone.MAINBOARD, null);
@@ -735,19 +774,21 @@ describe("moveCategoryCards", () => {
expect(mockCategoryFindUnique).not.toHaveBeenCalled();
const [, , changes] = mockApply.mock.calls[0]!;
expect(changes).toEqual([
- { op: "move", deckCardId: "dc-1", zone: Zone.MAINBOARD, category: null },
+ { op: "move", deckCardId: "dc-1", zone: Zone.MAINBOARD, categories: [] },
]);
});
- it("forces category=null when moving to a non-mainboard zone", async () => {
- mockCardFindMany.mockResolvedValue([{ id: "dc-1" }] as never);
+ it("clears all memberships when moving to a non-mainboard zone", async () => {
+ mockCardFindMany.mockResolvedValue([
+ memberRow("dc-1", ["ramp", "draw"]),
+ ] as never);
await moveCategoryCards(DECK_ID, "ramp", Zone.SIDEBOARD, null);
expect(mockCategoryFindUnique).not.toHaveBeenCalled();
const [, , changes] = mockApply.mock.calls[0]!;
expect(changes).toEqual([
- { op: "move", deckCardId: "dc-1", zone: Zone.SIDEBOARD, category: null },
+ { op: "move", deckCardId: "dc-1", zone: Zone.SIDEBOARD, categories: [] },
]);
});
@@ -778,9 +819,22 @@ describe("moveCategoryCards", () => {
expect(mockApply).not.toHaveBeenCalled();
});
+ it("is a no-op when every member is only a secondary of the source category", async () => {
+ mockCategoryFindUnique.mockResolvedValue({ id: "cat-removal" } as never);
+ mockCardFindMany.mockResolvedValue([
+ memberRow("dc-1", ["draw", "ramp"]),
+ ] as never);
+
+ await moveCategoryCards(DECK_ID, "ramp", Zone.MAINBOARD, "Removal");
+
+ expect(mockApply).not.toHaveBeenCalled();
+ });
+
it("is a no-op when destination zone+category equals the source", async () => {
mockCategoryFindUnique.mockResolvedValue({ id: "cat-ramp" } as never);
- mockCardFindMany.mockResolvedValue([{ id: "dc-1" }] as never);
+ mockCardFindMany.mockResolvedValue([
+ memberRow("dc-1", ["ramp"]),
+ ] as never);
await moveCategoryCards(DECK_ID, "ramp", Zone.MAINBOARD, "Ramp");
@@ -803,17 +857,23 @@ describe("autogenerateCategories", () => {
};
}
- it("byType: groups every mainboard card by mainType, creates categories, and bulk-assigns them", async () => {
+ type MoveOp = Extract;
+
+ function appliedMoves(): MoveOp[] {
+ expect(mockApply).toHaveBeenCalledTimes(1);
+ const [, , changes] = mockApply.mock.calls[0]!;
+ return changes as MoveOp[];
+ }
+
+ it("byType: groups every mainboard card by mainType, creates categories, and bulk-assigns them via move ops", async () => {
mockCardFindMany.mockResolvedValue([
mainboardRow("dc-1", { mainType: "Creature" }),
mainboardRow("dc-2", { mainType: "Creature" }),
mainboardRow("dc-3", { mainType: "Instant" }),
mainboardRow("dc-4", { mainType: "Land" }),
] as never);
- mockCategoryFindUnique.mockResolvedValue(null);
- mockCategoryFindFirst.mockResolvedValue(null);
- mockCategoryCreate.mockResolvedValue({ id: "any" } as never);
- mockCardUpdateMany.mockResolvedValue({ count: 0 } as never);
+ mockCategoryFindMany.mockResolvedValue([] as never);
+ mockCategoryCreateMany.mockResolvedValue({ count: 3 } as never);
await autogenerateCategories(DECK_ID, "byType");
@@ -822,34 +882,45 @@ describe("autogenerateCategories", () => {
where: { deckId: DECK_ID, zone: Zone.MAINBOARD },
}),
);
- const createdNames = mockCategoryCreate.mock.calls.map(
- ([arg]) => (arg as { data: { name: string } }).data.name,
- );
- expect(createdNames.sort()).toEqual(["creatures", "instants", "lands"]);
-
- const updates = mockCardUpdateMany.mock.calls.map(([arg]) => arg);
- const creaturesUpdate = updates.find(
- (u) => (u as { data: { category: string } }).data.category === "creatures",
- ) as { where: { id: { in: string[] } } } | undefined;
- expect(creaturesUpdate?.where.id.in.sort()).toEqual(["dc-1", "dc-2"]);
+ expect(mockCategoryCreateMany).toHaveBeenCalledTimes(1);
+ const [createManyArg] = mockCategoryCreateMany.mock.calls[0]!;
+ const createdNames = (createManyArg as { data: { name: string }[] }).data
+ .map((d) => d.name);
+ expect([...createdNames].sort()).toEqual(["creatures", "instants", "lands"]);
+
+ const moves = appliedMoves();
+ expect(moves).toHaveLength(4);
+ for (const m of moves) {
+ expect(m.op).toBe("move");
+ expect(m.zone).toBe(Zone.MAINBOARD);
+ expect(m.categories).toHaveLength(1);
+ }
+ const creatureIds = moves
+ .filter((m) => m.categories[0] === "creatures")
+ .map((m) => m.deckCardId);
+ expect(creatureIds.sort()).toEqual(["dc-1", "dc-2"]);
expect(mockUpdateTag).toHaveBeenCalledWith(`deck:${DECK_ID}`);
});
- it("byType: overwrites existing categories so switching presets reorganizes the deck", async () => {
+ it("byType: overwrites existing memberships so switching presets reorganizes the deck", async () => {
mockCardFindMany.mockResolvedValue([
mainboardRow("dc-1", { mainType: "Creature" }),
] as never);
- mockCategoryFindUnique.mockResolvedValue({ id: "cat-existing" } as never);
- mockCardUpdateMany.mockResolvedValue({ count: 1 } as never);
+ mockCategoryFindMany.mockResolvedValue([
+ { name: "creatures", sortOrder: 0 },
+ ] as never);
await autogenerateCategories(DECK_ID, "byType");
- const [updateArg] = mockCardUpdateMany.mock.calls[0]!;
- expect(updateArg).toEqual({
- where: { id: { in: ["dc-1"] }, deckId: DECK_ID },
- data: { category: "creatures" },
- });
+ expect(appliedMoves()).toEqual([
+ {
+ op: "move",
+ deckCardId: "dc-1",
+ zone: Zone.MAINBOARD,
+ categories: ["creatures"],
+ },
+ ]);
});
it("byType: skips cards with exotic mainType (classifier returns null)", async () => {
@@ -859,45 +930,44 @@ describe("autogenerateCategories", () => {
await autogenerateCategories(DECK_ID, "byType");
- expect(mockCategoryCreate).not.toHaveBeenCalled();
- expect(mockCardUpdateMany).not.toHaveBeenCalled();
+ expect(mockCategoryCreateMany).not.toHaveBeenCalled();
+ expect(mockApply).not.toHaveBeenCalled();
});
it("byType: reuses an existing DeckCategory row instead of creating a duplicate", async () => {
mockCardFindMany.mockResolvedValue([
mainboardRow("dc-1", { mainType: "Creature" }),
] as never);
- mockCategoryFindUnique.mockResolvedValue({ id: "cat-existing" } as never);
- mockCardUpdateMany.mockResolvedValue({ count: 1 } as never);
+ mockCategoryFindMany.mockResolvedValue([
+ { name: "creatures", sortOrder: 0 },
+ ] as never);
await autogenerateCategories(DECK_ID, "byType");
- expect(mockCategoryFindUnique).toHaveBeenCalledWith({
- where: { deckId_name: { deckId: DECK_ID, name: "creatures" } },
- select: { id: true },
- });
- expect(mockCategoryCreate).not.toHaveBeenCalled();
- expect(mockCardUpdateMany).toHaveBeenCalledTimes(1);
+ expect(mockCategoryCreateMany).not.toHaveBeenCalled();
+ expect(mockApply).toHaveBeenCalledTimes(1);
});
it("byType: assigns sortOrder = max+1 when creating a new category alongside existing ones", async () => {
mockCardFindMany.mockResolvedValue([
mainboardRow("dc-1", { mainType: "Creature" }),
] as never);
- mockCategoryFindUnique.mockResolvedValue(null);
- mockCategoryFindFirst.mockResolvedValue({ sortOrder: 4 } as never);
- mockCategoryCreate.mockResolvedValue({ id: "cat-new" } as never);
- mockCardUpdateMany.mockResolvedValue({ count: 1 } as never);
+ mockCategoryFindMany.mockResolvedValue([
+ { name: "other", sortOrder: 4 },
+ ] as never);
+ mockCategoryCreateMany.mockResolvedValue({ count: 1 } as never);
await autogenerateCategories(DECK_ID, "byType");
- expect(mockCategoryCreate).toHaveBeenCalledWith(
+ expect(mockCategoryCreateMany).toHaveBeenCalledWith(
expect.objectContaining({
- data: expect.objectContaining({
- deckId: DECK_ID,
- name: "creatures",
- sortOrder: 5,
- }),
+ data: [
+ expect.objectContaining({
+ deckId: DECK_ID,
+ name: "creatures",
+ sortOrder: 5,
+ }),
+ ],
}),
);
});
@@ -926,25 +996,20 @@ describe("autogenerateCategories", () => {
oracleText: "Flying.",
}),
] as never);
- mockCategoryFindUnique.mockResolvedValue(null);
- mockCategoryFindFirst.mockResolvedValue(null);
- mockCategoryCreate.mockResolvedValue({ id: "any" } as never);
- mockCardUpdateMany.mockResolvedValue({ count: 1 } as never);
+ mockCategoryFindMany.mockResolvedValue([] as never);
+ mockCategoryCreateMany.mockResolvedValue({ count: 6 } as never);
await autogenerateCategories(DECK_ID, "commanderTemplate");
- const updates = mockCardUpdateMany.mock.calls.map(([arg]) => arg) as Array<{
- where: { id: { in: string[] } };
- data: { category: string };
- }>;
- const byCategory = new Map(updates.map((u) => [u.data.category, u.where.id.in]));
-
- expect(byCategory.get("lands")).toEqual(["dc-land"]);
- expect(byCategory.get("ramp")).toEqual(["dc-ramp"]);
- expect(byCategory.get("boardwipes")).toEqual(["dc-wipe"]);
- expect(byCategory.get("removal")).toEqual(["dc-removal"]);
- expect(byCategory.get("card advantage")).toEqual(["dc-draw"]);
- expect(byCategory.get("gameplan")).toEqual(["dc-misc"]);
+ const byCategory = new Map(
+ appliedMoves().map((m) => [m.categories[0], m.deckCardId]),
+ );
+ expect(byCategory.get("lands")).toBe("dc-land");
+ expect(byCategory.get("ramp")).toBe("dc-ramp");
+ expect(byCategory.get("boardwipes")).toBe("dc-wipe");
+ expect(byCategory.get("removal")).toBe("dc-removal");
+ expect(byCategory.get("card advantage")).toBe("dc-draw");
+ expect(byCategory.get("gameplan")).toBe("dc-misc");
});
it("returns early without writes when there are no mainboard cards", async () => {
@@ -952,9 +1017,8 @@ describe("autogenerateCategories", () => {
await autogenerateCategories(DECK_ID, "byType");
- expect(mockCategoryFindUnique).not.toHaveBeenCalled();
- expect(mockCategoryCreate).not.toHaveBeenCalled();
- expect(mockCardUpdateMany).not.toHaveBeenCalled();
+ expect(mockCategoryCreateMany).not.toHaveBeenCalled();
+ expect(mockApply).not.toHaveBeenCalled();
});
it("returns early without category writes when every card classifies to null", async () => {
@@ -965,9 +1029,8 @@ describe("autogenerateCategories", () => {
await autogenerateCategories(DECK_ID, "byType");
- expect(mockCategoryFindUnique).not.toHaveBeenCalled();
- expect(mockCategoryCreate).not.toHaveBeenCalled();
- expect(mockCardUpdateMany).not.toHaveBeenCalled();
+ expect(mockCategoryCreateMany).not.toHaveBeenCalled();
+ expect(mockApply).not.toHaveBeenCalled();
});
it("throws when the requester does not own the deck", async () => {
diff --git a/app/_actions/deck/__tests__/collaboration.test.ts b/app/_actions/deck/__tests__/collaboration.test.ts
index a72ac03..9bbfbad 100644
--- a/app/_actions/deck/__tests__/collaboration.test.ts
+++ b/app/_actions/deck/__tests__/collaboration.test.ts
@@ -37,6 +37,9 @@ vi.mock("@/lib/db", () => {
deckCard: {
findMany: vi.fn(),
},
+ deckCategory: {
+ findMany: vi.fn(),
+ },
card: {
findMany: vi.fn(),
},
@@ -66,6 +69,7 @@ const mockDeckFindUnique = vi.mocked(prisma.deck.findUnique);
const mockDeckUpdate = vi.mocked(prisma.deck.update);
const mockFollowFindUnique = vi.mocked(prisma.follow.findUnique);
const mockDeckCardFindMany = vi.mocked(prisma.deckCard.findMany);
+const mockDeckCategoryFindMany = vi.mocked(prisma.deckCategory.findMany);
const mockCardFindMany = vi.mocked(prisma.card.findMany);
const mockProposalCreate = vi.mocked(prisma.deckProposal.create);
const mockProposalFindMany = vi.mocked(prisma.deckProposal.findMany);
@@ -102,13 +106,14 @@ const addSolRing = [
cardId: 1,
cardName: "Sol Ring",
zone: Zone.MAINBOARD,
- category: null,
+ categories: [] as string[],
delta: 1,
},
];
beforeEach(() => {
vi.clearAllMocks();
+ mockDeckCategoryFindMany.mockResolvedValue([] as never);
mockCardFindMany.mockResolvedValue([
{
id: 1,
@@ -167,6 +172,22 @@ describe("submitDeckProposal", () => {
});
});
+ it("stores a null message when none is given, or it is blank", async () => {
+ mockGetSession.mockResolvedValue({ userId: PROPOSER_ID } as never);
+ mockDeckFindUnique.mockResolvedValue(deckRow() as never);
+ mockFollowFindUnique.mockResolvedValue({ followerId: OWNER_ID } as never);
+ mockDeckCardFindMany.mockResolvedValue([] as never);
+ mockProposalCreate.mockResolvedValue({ id: "prop-1" } as never);
+
+ await submitDeckProposal(DECK_ID, addSolRing, " ");
+
+ expect(mockProposalCreate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({ message: null }),
+ }),
+ );
+ });
+
it("404s a submission from a viewer collaboration is disabled for", async () => {
mockGetSession.mockResolvedValue({ userId: PROPOSER_ID } as never);
mockDeckFindUnique.mockResolvedValue(
@@ -190,16 +211,21 @@ describe("submitDeckProposal", () => {
it("rejects a delta that pairs a category with a non-mainboard zone", async () => {
mockGetSession.mockResolvedValue({ userId: PROPOSER_ID } as never);
- mockDeckFindUnique.mockResolvedValue(deckRow() as never);
+ mockDeckFindUnique.mockResolvedValue(
+ deckRow({ categories: [{ name: "ramp" }] }) as never,
+ );
mockFollowFindUnique.mockResolvedValue({ followerId: OWNER_ID } as never);
mockDeckCardFindMany.mockResolvedValue([] as never);
+ // "ramp" is a known category, so it survives the known-category filter
+ // and the structural zone check is what rejects it.
+ mockDeckCategoryFindMany.mockResolvedValue([{ name: "ramp" }] as never);
const badDelta = [
{
cardId: 1,
cardName: "Sol Ring",
zone: Zone.SIDEBOARD,
- category: "Ramp",
+ categories: ["ramp"],
delta: 1,
},
];
@@ -220,7 +246,6 @@ describe("submitDeckProposal", () => {
id: "dc-stale",
cardId: 1,
zone: Zone.MAINBOARD,
- category: null,
quantity: 1,
},
] as never);
@@ -230,7 +255,7 @@ describe("submitDeckProposal", () => {
cardId: 1,
cardName: "Sol Ring",
zone: Zone.MAINBOARD,
- category: null,
+ categories: [] as string[],
delta: -1,
},
];
@@ -381,7 +406,7 @@ describe("approveDeckProposal on a stale or already-resolved proposal", () => {
cardId: 1,
cardName: "Sol Ring",
zone: Zone.MAINBOARD,
- category: null,
+ categories: [],
delta: -1,
},
],
diff --git a/app/_actions/deck/__tests__/duplicate.test.ts b/app/_actions/deck/__tests__/duplicate.test.ts
index dc60625..cb602f4 100644
--- a/app/_actions/deck/__tests__/duplicate.test.ts
+++ b/app/_actions/deck/__tests__/duplicate.test.ts
@@ -12,6 +12,13 @@ vi.mock("@/lib/db", () => ({
},
deckCard: {
createMany: vi.fn(),
+ findMany: vi.fn(),
+ },
+ deckCardCategory: {
+ createMany: vi.fn(),
+ },
+ deckCategory: {
+ findMany: vi.fn(),
},
$transaction: vi.fn(),
$queryRaw: vi.fn(),
@@ -28,6 +35,9 @@ const mockSession = vi.mocked(requireSession);
const mockDeckFindUnique = vi.mocked(prisma.deck.findUnique);
const mockDeckCreate = vi.mocked(prisma.deck.create);
const mockCardCreateMany = vi.mocked(prisma.deckCard.createMany);
+const mockCardFindMany = vi.mocked(prisma.deckCard.findMany);
+const mockLinkCreateMany = vi.mocked(prisma.deckCardCategory.createMany);
+const mockCategoryFindMany = vi.mocked(prisma.deckCategory.findMany);
const mockTransaction = vi.mocked(prisma.$transaction);
const mockQueryRaw = vi.mocked(prisma.$queryRaw);
const mockUpdateTag = vi.mocked(updateTag);
@@ -49,25 +59,28 @@ function makeDeck(visibility: Visibility, userId = OWNER_ID) {
cardId: 1,
quantity: 2,
zone: "MAINBOARD",
- category: "Ramp",
isFoil: false,
printingId: null,
+ categoryLinks: [
+ { position: 0, deckCategory: { name: "Ramp" } },
+ { position: 1, deckCategory: { name: "Removal" } },
+ ],
},
{
cardId: 2,
quantity: 1,
zone: "SIDEBOARD",
- category: null,
isFoil: true,
printingId: 5,
+ categoryLinks: [],
},
{
cardId: 3,
quantity: 1,
zone: "COMMANDER",
- category: null,
isFoil: false,
printingId: null,
+ categoryLinks: [],
},
],
categories: [
@@ -82,13 +95,27 @@ function setupTransaction() {
if (typeof fn === "function") {
const tx = {
deck: { create: mockDeckCreate },
- deckCard: { createMany: mockCardCreateMany },
+ deckCard: { createMany: mockCardCreateMany, findMany: mockCardFindMany },
+ deckCardCategory: { createMany: mockLinkCreateMany },
+ deckCategory: { findMany: mockCategoryFindMany },
};
return fn(tx);
}
});
mockDeckCreate.mockResolvedValue({ id: NEW_DECK_ID } as never);
+ // The copy's own registry rows, as created by the nested createMany above.
+ mockCategoryFindMany.mockResolvedValue([
+ { id: "new-cat-ramp", name: "Ramp" },
+ { id: "new-cat-removal", name: "Removal" },
+ ] as never);
mockCardCreateMany.mockResolvedValue({ count: 3 } as never);
+ // The freshly bulk-created copies, re-selected for identity-tuple matching.
+ mockCardFindMany.mockResolvedValue([
+ { id: "copy-1", cardId: 1, zone: "MAINBOARD", printingId: null, isFoil: false },
+ { id: "copy-2", cardId: 2, zone: "SIDEBOARD", printingId: 5, isFoil: true },
+ { id: "copy-3", cardId: 3, zone: "COMMANDER", printingId: null, isFoil: false },
+ ] as never);
+ mockLinkCreateMany.mockResolvedValue({ count: 2 } as never);
}
beforeEach(() => {
@@ -127,27 +154,27 @@ describe("duplicateDeck", () => {
}),
);
+ // One bulk create for all DeckCards; memberships are remapped onto the
+ // copy's own DeckCategory rows with positions preserved ([0] = primary).
+ expect(mockCardCreateMany).toHaveBeenCalledTimes(1);
expect(mockCardCreateMany).toHaveBeenCalledWith({
- data: expect.arrayContaining([
+ data: [
expect.objectContaining({
+ deckId: NEW_DECK_ID,
cardId: 1,
quantity: 2,
zone: "MAINBOARD",
- category: "Ramp",
}),
- expect.objectContaining({
- cardId: 2,
- quantity: 1,
- zone: "SIDEBOARD",
- category: null,
- }),
- expect.objectContaining({
- cardId: 3,
- quantity: 1,
- zone: "COMMANDER",
- category: null,
- }),
- ]),
+ expect.objectContaining({ cardId: 2, quantity: 1, zone: "SIDEBOARD" }),
+ expect.objectContaining({ cardId: 3, quantity: 1, zone: "COMMANDER" }),
+ ],
+ });
+ expect(mockLinkCreateMany).toHaveBeenCalledTimes(1);
+ expect(mockLinkCreateMany).toHaveBeenCalledWith({
+ data: [
+ { deckCardId: "copy-1", deckCategoryId: "new-cat-ramp", position: 0 },
+ { deckCardId: "copy-1", deckCategoryId: "new-cat-removal", position: 1 },
+ ],
});
expect(mockUpdateTag).toHaveBeenCalledWith("deck-list");
@@ -249,7 +276,42 @@ describe("duplicateDeck", () => {
});
});
- it("skips deckCard.createMany when the source deck has no cards", async () => {
+ it("skips a category link whose category didn't carry over to the copy", async () => {
+ mockSession.mockResolvedValue({ userId: OWNER_ID, email: "owner@test.com" } as never);
+ const deck = makeDeck(Visibility.PRIVATE);
+ deck.cards[0]!.categoryLinks = [
+ { position: 0, deckCategory: { name: "Ramp" } },
+ { position: 1, deckCategory: { name: "Deleted Category" } },
+ ];
+ mockDeckFindUnique.mockResolvedValue(deck as never);
+ setupTransaction();
+ // Destination registry only carries over "Ramp" — "Deleted Category"
+ // isn't in the copy's own DeckCategory rows.
+ mockCategoryFindMany.mockResolvedValue([
+ { id: "new-cat-ramp", name: "Ramp" },
+ ] as never);
+
+ await duplicateDeck(DECK_ID);
+
+ expect(mockLinkCreateMany).toHaveBeenCalledWith({
+ data: [{ deckCardId: "copy-1", deckCategoryId: "new-cat-ramp", position: 0 }],
+ });
+ });
+
+ it("skips the category-link bulk create when no copied card has any category link", async () => {
+ mockSession.mockResolvedValue({ userId: OWNER_ID, email: "owner@test.com" } as never);
+ const deck = makeDeck(Visibility.PRIVATE);
+ deck.cards = deck.cards.map((c) => ({ ...c, categoryLinks: [] }));
+ mockDeckFindUnique.mockResolvedValue(deck as never);
+ setupTransaction();
+
+ await duplicateDeck(DECK_ID);
+
+ expect(mockCardCreateMany).toHaveBeenCalledTimes(1);
+ expect(mockLinkCreateMany).not.toHaveBeenCalled();
+ });
+
+ it("skips card copies when the source deck has no cards", async () => {
mockSession.mockResolvedValue({ userId: OWNER_ID, email: "owner@test.com" } as never);
const emptyDeck = { ...makeDeck(Visibility.PRIVATE), cards: [] };
mockDeckFindUnique.mockResolvedValue(emptyDeck as never);
@@ -260,5 +322,8 @@ describe("duplicateDeck", () => {
expect(result).toEqual({ id: NEW_DECK_ID });
expect(mockDeckCreate).toHaveBeenCalled();
expect(mockCardCreateMany).not.toHaveBeenCalled();
+ expect(mockLinkCreateMany).not.toHaveBeenCalled();
+ // The registry remap load is skipped too — nothing to remap.
+ expect(mockCategoryFindMany).not.toHaveBeenCalled();
});
});
diff --git a/app/_actions/deck/__tests__/import-invariant.test.ts b/app/_actions/deck/__tests__/import-invariant.test.ts
index dbd781c..17ad6c9 100644
--- a/app/_actions/deck/__tests__/import-invariant.test.ts
+++ b/app/_actions/deck/__tests__/import-invariant.test.ts
@@ -9,21 +9,28 @@ vi.mock("next/navigation", () => ({
vi.mock("@/lib/auth/session", () => ({
requireSession: vi.fn(),
}));
-vi.mock("@/lib/db", () => ({
- prisma: {
- deck: {
- findUnique: vi.fn(),
- create: vi.fn(),
- delete: vi.fn(),
- },
- card: {
- findMany: vi.fn(),
- },
- printing: {
- findMany: vi.fn(),
+vi.mock("@/lib/db", () => {
+ const deckCategory = { findMany: vi.fn(), createMany: vi.fn() };
+ return {
+ prisma: {
+ deck: {
+ findUnique: vi.fn(),
+ create: vi.fn(),
+ delete: vi.fn(),
+ },
+ card: {
+ findMany: vi.fn(),
+ },
+ printing: {
+ findMany: vi.fn(),
+ },
+ deckCategory,
+ $transaction: vi.fn(async (fn: (tx: unknown) => unknown) =>
+ fn({ deckCategory }),
+ ),
},
- },
-}));
+ };
+});
vi.mock("@/lib/deck/mutation", async () => {
const actual = await vi.importActual(
"@/lib/deck/mutation",
diff --git a/app/_actions/deck/__tests__/import.test.ts b/app/_actions/deck/__tests__/import.test.ts
index dacf932..1e0445a 100644
--- a/app/_actions/deck/__tests__/import.test.ts
+++ b/app/_actions/deck/__tests__/import.test.ts
@@ -17,6 +17,7 @@ vi.mock("@/lib/db", () => ({
deck: {
findUnique: vi.fn(),
create: vi.fn(),
+ delete: vi.fn(),
},
deckCard: {
findFirst: vi.fn(),
@@ -24,6 +25,14 @@ vi.mock("@/lib/db", () => ({
update: vi.fn(),
delete: vi.fn(),
},
+ deckCategory: {
+ findMany: vi.fn(),
+ createMany: vi.fn(),
+ },
+ deckCardCategory: {
+ deleteMany: vi.fn(),
+ createMany: vi.fn(),
+ },
deckRevision: {
findFirst: vi.fn(),
create: vi.fn(),
@@ -53,6 +62,14 @@ const mockDeckCardFindFirst = vi.mocked(prisma.deckCard.findFirst);
const mockDeckCardCreate = vi.mocked(prisma.deckCard.create);
const mockDeckCardUpdate = vi.mocked(prisma.deckCard.update);
const mockDeckCardDelete = vi.mocked(prisma.deckCard.delete);
+const mockDeckCategoryFindMany = vi.mocked(prisma.deckCategory.findMany);
+const mockDeckCategoryCreateMany = vi.mocked(prisma.deckCategory.createMany);
+const mockDeckCardCategoryDeleteMany = vi.mocked(
+ prisma.deckCardCategory.deleteMany,
+);
+const mockDeckCardCategoryCreateMany = vi.mocked(
+ prisma.deckCardCategory.createMany,
+);
const mockDeckRevisionFindFirst = vi.mocked(prisma.deckRevision.findFirst);
const mockDeckRevisionCreate = vi.mocked(prisma.deckRevision.create);
const mockDeckRevisionUpdate = vi.mocked(prisma.deckRevision.update);
@@ -82,6 +99,9 @@ function txPassthrough() {
mockTransaction.mockImplementation(async (fn: unknown) => {
if (typeof fn === "function") {
const tx = {
+ // Snapshot loads run through the shared intake transaction.
+ deck: { findUnique: mockDeckFindUnique },
+ card: { findMany: mockCardFindMany },
deckCard: {
findFirst: mockDeckCardFindFirst,
create: mockDeckCardCreate,
@@ -94,6 +114,14 @@ function txPassthrough() {
update: mockDeckRevisionUpdate,
delete: mockDeckRevisionDelete,
},
+ deckCategory: {
+ findMany: mockDeckCategoryFindMany,
+ createMany: mockDeckCategoryCreateMany,
+ },
+ deckCardCategory: {
+ deleteMany: mockDeckCardCategoryDeleteMany,
+ createMany: mockDeckCardCategoryCreateMany,
+ },
};
return fn(tx);
}
@@ -105,13 +133,17 @@ beforeEach(() => {
mockSession.mockResolvedValue({ userId: USER_ID, email: "t@t.com" } as never);
mockDeckFindUnique.mockResolvedValue(snapshotDeck() as never);
mockDeckCardFindFirst.mockResolvedValue(null);
- mockDeckCardCreate.mockResolvedValue({} as never);
+ mockDeckCardCreate.mockResolvedValue({ id: "dc-new" } as never);
mockDeckCardUpdate.mockResolvedValue({} as never);
mockDeckCardDelete.mockResolvedValue({} as never);
mockDeckRevisionFindFirst.mockResolvedValue(null);
mockDeckRevisionCreate.mockResolvedValue({} as never);
mockDeckRevisionUpdate.mockResolvedValue({} as never);
mockDeckRevisionDelete.mockResolvedValue({} as never);
+ mockDeckCategoryFindMany.mockResolvedValue([] as never);
+ mockDeckCategoryCreateMany.mockResolvedValue({ count: 0 } as never);
+ mockDeckCardCategoryDeleteMany.mockResolvedValue({ count: 0 } as never);
+ mockDeckCardCategoryCreateMany.mockResolvedValue({ count: 0 } as never);
mockPrintingFindMany.mockResolvedValue([] as never);
// Default response for loadSnapshotForDeck's "fetch missing card meta" call;
// resolveCards' own card.findMany calls are queued via mockResolvedValueOnce.
@@ -187,9 +219,11 @@ describe("importDeck", () => {
await importDeck(DECK_ID, "2 Lightning Bolt\n1 Lightning Bolt");
expect(mockDeckCardCreate).toHaveBeenCalledTimes(1);
- expect(mockDeckCardCreate).toHaveBeenCalledWith({
- data: expect.objectContaining({ cardId: 1, quantity: 3 }),
- });
+ expect(mockDeckCardCreate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({ cardId: 1, quantity: 3 }),
+ }),
+ );
expect(mockDeckCardUpdate).not.toHaveBeenCalled();
});
@@ -202,7 +236,7 @@ describe("importDeck", () => {
cardId: 1,
quantity: 1,
zone: Zone.MAINBOARD,
- category: null,
+ categoryLinks: [],
printingId: null,
isFoil: false,
card: {
@@ -260,13 +294,15 @@ describe("importDeck", () => {
await importDeck(DECK_ID, "1 Earthbender Ascension (TLA) 175 *F*");
- expect(mockDeckCardCreate).toHaveBeenCalledWith({
- data: expect.objectContaining({
- cardId: 1,
- printingId: 99,
- isFoil: true,
+ expect(mockDeckCardCreate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ cardId: 1,
+ printingId: 99,
+ isFoil: true,
+ }),
}),
- });
+ );
});
it("dedupe treats different printings as separate rows", async () => {
@@ -352,7 +388,7 @@ describe("importDeck", () => {
cardId: 1,
quantity: 1,
zone: Zone.MAINBOARD,
- category: null,
+ categoryLinks: [],
printingId: 50,
isFoil: false,
card: {
@@ -445,14 +481,16 @@ describe("createDeckWithImport", () => {
description: null,
},
});
- expect(mockDeckCardCreate).toHaveBeenCalledWith({
- data: expect.objectContaining({
- deckId: NEW_DECK_ID,
- cardId: 1,
- quantity: 4,
- zone: Zone.MAINBOARD,
+ expect(mockDeckCardCreate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ deckId: NEW_DECK_ID,
+ cardId: 1,
+ quantity: 4,
+ zone: Zone.MAINBOARD,
+ }),
}),
- });
+ );
});
it("invalidates both deck-list and the new deck's tag", async () => {
@@ -519,14 +557,16 @@ describe("createDeckWithImport", () => {
importText: "1 Earthbender Ascension (TLA) 175 *F*",
});
- expect(mockDeckCardCreate).toHaveBeenCalledWith({
- data: expect.objectContaining({
- deckId: NEW_DECK_ID,
- cardId: 1,
- printingId: 99,
- isFoil: true,
+ expect(mockDeckCardCreate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ deckId: NEW_DECK_ID,
+ cardId: 1,
+ printingId: 99,
+ isFoil: true,
+ }),
}),
- });
+ );
});
it("increments an existing row rather than creating a duplicate", async () => {
@@ -540,7 +580,7 @@ describe("createDeckWithImport", () => {
cardId: 1,
quantity: 1,
zone: Zone.MAINBOARD,
- category: null,
+ categoryLinks: [],
printingId: null,
isFoil: false,
card: {
diff --git a/app/_actions/deck/__tests__/manabase.test.ts b/app/_actions/deck/__tests__/manabase.test.ts
index aec6465..3199b68 100644
--- a/app/_actions/deck/__tests__/manabase.test.ts
+++ b/app/_actions/deck/__tests__/manabase.test.ts
@@ -85,7 +85,7 @@ describe("addLandsToDeck", () => {
cardId: 101,
quantity: 2,
zone: Zone.MAINBOARD,
- category: null,
+ categories: [],
},
]);
});
@@ -98,8 +98,8 @@ describe("addLandsToDeck", () => {
expect(mockBasicIds).toHaveBeenCalledTimes(1);
expect(changes()).toEqual([
- { op: "add", cardId: 22, quantity: 6, zone: Zone.MAINBOARD, category: null },
- { op: "add", cardId: 33, quantity: 3, zone: Zone.MAINBOARD, category: null },
+ { op: "add", cardId: 22, quantity: 6, zone: Zone.MAINBOARD, categories: [] },
+ { op: "add", cardId: 33, quantity: 3, zone: Zone.MAINBOARD, categories: [] },
]);
});
@@ -113,8 +113,8 @@ describe("addLandsToDeck", () => {
});
expect(changes()).toEqual([
- { op: "add", cardId: 102, quantity: 1, zone: Zone.MAINBOARD, category: null },
- { op: "add", cardId: 44, quantity: 4, zone: Zone.MAINBOARD, category: null },
+ { op: "add", cardId: 102, quantity: 1, zone: Zone.MAINBOARD, categories: [] },
+ { op: "add", cardId: 44, quantity: 4, zone: Zone.MAINBOARD, categories: [] },
]);
});
diff --git a/app/_actions/deck/__tests__/revisions.test.ts b/app/_actions/deck/__tests__/revisions.test.ts
index bc039e2..25e75bb 100644
--- a/app/_actions/deck/__tests__/revisions.test.ts
+++ b/app/_actions/deck/__tests__/revisions.test.ts
@@ -10,6 +10,9 @@ vi.mock("@/lib/db", () => ({
deckCard: {
findMany: vi.fn(),
},
+ deckCategory: {
+ findMany: vi.fn(),
+ },
},
}));
vi.mock("@/lib/auth/deck-access", () => ({
@@ -43,16 +46,18 @@ import { listDeckRevisions, revertDeckRevision } from "../revisions";
const mockFindMany = vi.mocked(prisma.deckRevision.findMany);
const mockFindUnique = vi.mocked(prisma.deckRevision.findUnique);
const mockDeckCardFindMany = vi.mocked(prisma.deckCard.findMany);
+const mockDeckCategoryFindMany = vi.mocked(prisma.deckCategory.findMany);
const mockRequireViewable = vi.mocked(requireDeckViewable);
const mockApplyChanges = vi.mocked(applyChanges);
beforeEach(() => {
vi.clearAllMocks();
mockRequireViewable.mockResolvedValue({ isOwner: true });
+ mockDeckCategoryFindMany.mockResolvedValue([] as never);
});
describe("listDeckRevisions", () => {
- it("returns parsed revisions for a viewable deck", async () => {
+ it("returns parsed revisions for a viewable deck (legacy category payloads normalized)", async () => {
const createdAt = new Date("2026-01-01T00:00:00Z");
const updatedAt = new Date("2026-01-02T00:00:00Z");
mockFindMany.mockResolvedValue([
@@ -61,6 +66,7 @@ describe("listDeckRevisions", () => {
createdAt,
updatedAt,
changes: [
+ // Legacy single-category payload as stored pre-migration.
{
cardId: 1,
cardName: "Sol Ring",
@@ -68,6 +74,14 @@ describe("listDeckRevisions", () => {
category: null,
delta: 1,
},
+ // Modern multi-category payload.
+ {
+ cardId: 2,
+ cardName: "Arcane Signet",
+ zone: Zone.MAINBOARD,
+ categories: ["ramp"],
+ delta: 1,
+ },
],
},
] as never);
@@ -84,7 +98,14 @@ describe("listDeckRevisions", () => {
cardId: 1,
cardName: "Sol Ring",
zone: Zone.MAINBOARD,
- category: null,
+ categories: [],
+ delta: 1,
+ },
+ {
+ cardId: 2,
+ cardName: "Arcane Signet",
+ zone: Zone.MAINBOARD,
+ categories: ["ramp"],
delta: 1,
},
],
@@ -140,6 +161,36 @@ describe("revertDeckRevision", () => {
expect(changes.length).toBeGreaterThan(0);
});
+ it("passes the deck's known category names through to deltasToBulkChanges", async () => {
+ mockFindUnique.mockResolvedValue({
+ deckId: "deck-1",
+ changes: [
+ {
+ cardId: 7,
+ cardName: "Sol Ring",
+ zone: Zone.MAINBOARD,
+ categories: ["ramp"],
+ delta: 2,
+ },
+ ],
+ } as never);
+ mockDeckCardFindMany.mockResolvedValue([
+ {
+ id: "dc-1",
+ cardId: 7,
+ zone: Zone.MAINBOARD,
+ quantity: 2,
+ },
+ ] as never);
+ mockDeckCategoryFindMany.mockResolvedValue([
+ { name: "ramp" },
+ ] as never);
+
+ await revertDeckRevision("deck-1", "rev-1");
+
+ expect(mockApplyChanges).toHaveBeenCalledTimes(1);
+ });
+
it("throws when the revision doesn't belong to the deck", async () => {
mockFindUnique.mockResolvedValue({
deckId: "other-deck",
@@ -201,7 +252,7 @@ describe("revertDeckRevision", () => {
mockDeckCardFindMany.mockResolvedValue(existingRows as never);
await revertDeckRevision("deck-1", "rev-1", [
- `7|${Zone.MAINBOARD}|`,
+ `7|${Zone.MAINBOARD}`,
]);
expect(mockApplyChanges).toHaveBeenCalledTimes(1);
@@ -222,7 +273,7 @@ describe("revertDeckRevision", () => {
mockFindUnique.mockResolvedValue(twoDeltaRevision as never);
mockDeckCardFindMany.mockResolvedValue(existingRows as never);
- await revertDeckRevision("deck-1", "rev-1", ["999|MAINBOARD|"]);
+ await revertDeckRevision("deck-1", "rev-1", ["999|MAINBOARD"]);
expect(mockApplyChanges).not.toHaveBeenCalled();
});
@@ -231,8 +282,8 @@ describe("revertDeckRevision", () => {
mockDeckCardFindMany.mockResolvedValue(existingRows as never);
await revertDeckRevision("deck-1", "rev-1", [
- `7|${Zone.MAINBOARD}|`,
- `8|${Zone.MAINBOARD}|`,
+ `7|${Zone.MAINBOARD}`,
+ `8|${Zone.MAINBOARD}`,
]);
expect(mockApplyChanges).toHaveBeenCalledTimes(1);
@@ -275,7 +326,7 @@ describe("revertDeckRevision", () => {
mockDeckCardFindMany.mockResolvedValue([] as never);
// Zero delta survives the filter but deltasToBulkChanges skips it → empty.
- await revertDeckRevision("deck-1", "rev-1", [`999|${Zone.MAINBOARD}|`]);
+ await revertDeckRevision("deck-1", "rev-1", [`999|${Zone.MAINBOARD}`]);
expect(mockApplyChanges).not.toHaveBeenCalled();
});
diff --git a/app/_actions/deck/categories.ts b/app/_actions/deck/categories.ts
index f307983..1e80e8d 100644
--- a/app/_actions/deck/categories.ts
+++ b/app/_actions/deck/categories.ts
@@ -2,6 +2,8 @@
import { prisma } from "@/lib/db";
import { Zone } from "@/lib/generated/prisma/client";
+import { normalizeCategory } from "@/lib/deck/constants";
+import { ensureDeckCategories } from "@/lib/deck/category-registry";
import { applyChanges, runOwnerDeckMutation } from "@/lib/deck/mutation";
import {
categoryDeleteModeSchema,
@@ -14,8 +16,50 @@ import {
type AutogenPreset,
} from "@/lib/deck/category-autogen";
-const normalizeCategory = (name: string | null) =>
- name === null ? null : name.trim().toLowerCase();
+/**
+ * A MAINBOARD card's ordered memberships, `[0]` = primary. Cards can belong to
+ * several subcategories; the primary is where the card renders in full (and
+ * what section counts tally) — secondaries render ghosted.
+ */
+type MemberRow = { id: string; categories: string[] };
+
+async function loadCategoryMembers(
+ deckId: string,
+ categoryName: string,
+ client: Pick = prisma,
+): Promise {
+ const rows = await client.deckCard.findMany({
+ where: {
+ deckId,
+ zone: Zone.MAINBOARD,
+ categoryLinks: { some: { deckCategory: { name: categoryName } } },
+ },
+ select: {
+ id: true,
+ categoryLinks: {
+ select: { deckCategory: { select: { name: true } } },
+ orderBy: { position: "asc" },
+ },
+ },
+ });
+ return rows.map((r) => ({
+ id: r.id,
+ categories: r.categoryLinks.map((l) => l.deckCategory.name),
+ }));
+}
+
+async function assertCategoryExists(
+ deckId: string,
+ name: string,
+): Promise {
+ const exists = await prisma.deckCategory.findUnique({
+ where: { deckId_name: { deckId, name } },
+ select: { id: true },
+ });
+ if (!exists) {
+ throw new Error(`Category "${name}" not found in deck`);
+ }
+}
/** Create a Mainboard subcategory. Subcategories only apply to MAINBOARD zone. */
export const createCategory = runOwnerDeckMutation(
@@ -48,18 +92,19 @@ export const createCategory = runOwnerDeckMutation(
/**
* Delete a Mainboard subcategory. Behavior depends on `mode`:
- * - `"uncategorize"` (default): cards in MAINBOARD pointing at this subcategory
- * get their `category` nulled out (they stay in Mainboard, just uncategorized).
- * - `"deleteCards"`: MAINBOARD rows with this subcategory are deleted; cards in
- * other zones that still reference this name are kept but uncategorized.
- *
- * In both modes, the DeckCategory row itself is deleted in the same transaction.
+ * - `"uncategorize"` (default): the DeckCategory row is deleted and the FK
+ * cascade removes every membership. Cards keep their other memberships;
+ * for cards whose primary was this category, the next membership (if any)
+ * auto-promotes on read.
+ * - `"deleteCards"`: DeckCards whose *primary* membership is this category are
+ * removed from the deck (through `applyChanges`, so the revision history
+ * records it). Secondary members just lose the membership via the cascade.
*/
export const deleteCategory = runOwnerDeckMutation(
"deck.deleteCategory",
"category",
async (
- { deckId },
+ { deckId, userId },
categoryName: string,
mode: CategoryDeleteMode = "uncategorize",
): Promise => {
@@ -74,31 +119,38 @@ export const deleteCategory = runOwnerDeckMutation(
throw new Error(`Category "${categoryName}" not found`);
}
- await prisma.$transaction(async (tx) => {
- if (parsedMode === "deleteCards") {
- await tx.deckCard.deleteMany({
- where: { deckId, zone: Zone.MAINBOARD, category: categoryName },
- });
- await tx.deckCard.updateMany({
- where: {
+ if (parsedMode === "deleteCards") {
+ // One transaction: if the registry delete fails, the card removals roll
+ // back with it instead of leaving the deck gutted but the category alive.
+ await prisma.$transaction(async (tx) => {
+ const members = await loadCategoryMembers(deckId, categoryName, tx);
+ const primaryMembers = members.filter(
+ (m) => m.categories[0] === categoryName,
+ );
+ if (primaryMembers.length > 0) {
+ await applyChanges(
deckId,
- zone: { not: Zone.MAINBOARD },
- category: categoryName,
- },
- data: { category: null },
- });
- } else {
- await tx.deckCard.updateMany({
- where: { deckId, zone: Zone.MAINBOARD, category: categoryName },
- data: { category: null },
- });
- }
- await tx.deckCategory.delete({ where: { id: category.id } });
- });
+ userId,
+ primaryMembers.map((m) => ({
+ op: "remove" as const,
+ deckCardId: m.id,
+ })),
+ { tx },
+ );
+ }
+ await tx.deckCategory.delete({ where: { id: category.id } });
+ });
+ return;
+ }
+
+ await prisma.deckCategory.delete({ where: { id: category.id } });
},
);
-/** Atomically rename a subcategory and every DeckCard row that references it. */
+/**
+ * Rename a subcategory. Memberships follow the `DeckCategory.id`, so only the
+ * registry row changes.
+ */
export const renameCategory = runOwnerDeckMutation(
"deck.renameCategory",
"category",
@@ -126,16 +178,10 @@ export const renameCategory = runOwnerDeckMutation(
throw new Error(`Category "${trimmed}" already exists`);
}
- await prisma.$transaction([
- prisma.deckCategory.update({
- where: { id: category.id },
- data: { name: trimmed },
- }),
- prisma.deckCard.updateMany({
- where: { deckId, category: oldName },
- data: { category: trimmed },
- }),
- ]);
+ await prisma.deckCategory.update({
+ where: { id: category.id },
+ data: { name: trimmed },
+ });
},
);
@@ -170,10 +216,8 @@ export const reorderCategories = runOwnerDeckMutation(
);
/**
- * Move a card between zones. Preserves the card's `category` string so a
- * Mainboard→Sideboard→Mainboard roundtrip snaps back to the original subcategory.
- * When returning to MAINBOARD, falls back to `category = null` if the preserved
- * subcategory no longer exists in this deck.
+ * Move a card between zones. Leaving MAINBOARD clears all category
+ * memberships (subcategories are MAINBOARD-only).
*/
export const moveCardZone = runOwnerDeckMutation(
"deck.moveCardZone",
@@ -185,7 +229,7 @@ export const moveCardZone = runOwnerDeckMutation(
): Promise => {
const sourceCard = await prisma.deckCard.findUnique({
where: { id: deckCardId },
- select: { id: true, deckId: true, zone: true, category: true },
+ select: { id: true, deckId: true, zone: true },
});
if (!sourceCard || sourceCard.deckId !== deckId) {
@@ -194,28 +238,17 @@ export const moveCardZone = runOwnerDeckMutation(
if (sourceCard.zone === nextZone) return;
- let nextCategory = sourceCard.category;
- if (nextZone === Zone.MAINBOARD && nextCategory !== null) {
- const exists = await prisma.deckCategory.findUnique({
- where: { deckId_name: { deckId, name: nextCategory } },
- select: { id: true },
- });
- if (!exists) nextCategory = null;
- }
- if (nextZone !== Zone.MAINBOARD) {
- // Non-mainboard zones can't carry a subcategory.
- nextCategory = null;
- }
-
await applyChanges(deckId, userId, [
- { op: "move", deckCardId, zone: nextZone, category: nextCategory },
+ { op: "move", deckCardId, zone: nextZone, categories: [] },
]);
},
);
/**
* Move a card to a specific zone and (for MAINBOARD) subcategory. Thin wrapper
- * so drag-and-drop callers route through one entrypoint.
+ * so drag-and-drop callers route through one entrypoint. A MAINBOARD target
+ * category becomes the card's primary while other memberships are preserved;
+ * a null MAINBOARD target (the Uncategorized bucket) clears every membership.
*/
export const moveCardTo = runOwnerDeckMutation(
"deck.moveCardTo",
@@ -226,7 +259,8 @@ export const moveCardTo = runOwnerDeckMutation(
nextZone: Zone,
nextCategory: string | null,
): Promise => {
- const normalizedCategory = normalizeCategory(nextCategory);
+ const normalizedCategory =
+ nextCategory === null ? null : normalizeCategory(nextCategory);
if (normalizedCategory !== null && nextZone !== Zone.MAINBOARD) {
throw new Error("Subcategories only apply to MAINBOARD cards");
@@ -234,7 +268,15 @@ export const moveCardTo = runOwnerDeckMutation(
const sourceCard = await prisma.deckCard.findUnique({
where: { id: deckCardId },
- select: { id: true, deckId: true, zone: true, category: true },
+ select: {
+ id: true,
+ deckId: true,
+ zone: true,
+ categoryLinks: {
+ select: { deckCategory: { select: { name: true } } },
+ orderBy: { position: "asc" },
+ },
+ },
});
if (!sourceCard || sourceCard.deckId !== deckId) {
@@ -242,45 +284,62 @@ export const moveCardTo = runOwnerDeckMutation(
}
if (nextZone === Zone.MAINBOARD && normalizedCategory !== null) {
- const exists = await prisma.deckCategory.findUnique({
- where: { deckId_name: { deckId, name: normalizedCategory } },
- select: { id: true },
- });
- if (!exists) {
- throw new Error(`Category "${normalizedCategory}" not found in deck`);
- }
+ await assertCategoryExists(deckId, normalizedCategory);
}
+ const current = sourceCard.categoryLinks.map((l) => l.deckCategory.name);
+ const nextCategories =
+ nextZone !== Zone.MAINBOARD || normalizedCategory === null
+ ? []
+ : [
+ normalizedCategory,
+ ...current.filter((name) => name !== normalizedCategory),
+ ];
+
if (
sourceCard.zone === nextZone &&
- sourceCard.category === normalizedCategory
+ current.join("\u0000") === nextCategories.join("\u0000")
) {
return;
}
await applyChanges(deckId, userId, [
- { op: "move", deckCardId, zone: nextZone, category: normalizedCategory },
+ { op: "move", deckCardId, zone: nextZone, categories: nextCategories },
]);
},
);
/**
- * Change a MAINBOARD card's subcategory. Passing `null` makes it uncategorized.
- * Validates that the subcategory exists in this deck.
+ * Replace a MAINBOARD card's category memberships wholesale. Order matters:
+ * `categories[0]` is the primary. An empty array uncategorizes the card.
+ * Validates every name against the deck's category registry.
*/
-export const moveCardSubcategory = runOwnerDeckMutation(
- "deck.moveCardSubcategory",
+export const setCardCategories = runOwnerDeckMutation(
+ "deck.setCardCategories",
"none",
async (
{ deckId, userId },
deckCardId: string,
- nextCategory: string | null,
+ categories: string[],
): Promise => {
- const normalizedCategory = normalizeCategory(nextCategory);
+ const normalized: string[] = [];
+ for (const raw of categories) {
+ const name = normalizeCategory(raw);
+ if (name.length === 0) continue;
+ if (!normalized.includes(name)) normalized.push(name);
+ }
const sourceCard = await prisma.deckCard.findUnique({
where: { id: deckCardId },
- select: { id: true, deckId: true, zone: true, category: true },
+ select: {
+ id: true,
+ deckId: true,
+ zone: true,
+ categoryLinks: {
+ select: { deckCategory: { select: { name: true } } },
+ orderBy: { position: "asc" },
+ },
+ },
});
if (!sourceCard || sourceCard.deckId !== deckId) {
@@ -291,34 +350,40 @@ export const moveCardSubcategory = runOwnerDeckMutation(
throw new Error("Subcategories only apply to MAINBOARD cards");
}
- if (normalizedCategory !== null) {
- const exists = await prisma.deckCategory.findUnique({
- where: { deckId_name: { deckId, name: normalizedCategory } },
- select: { id: true },
+ if (normalized.length > 0) {
+ const known = await prisma.deckCategory.findMany({
+ where: { deckId, name: { in: normalized } },
+ select: { name: true },
});
- if (!exists) {
- throw new Error(`Category "${normalizedCategory}" not found in deck`);
+ const knownNames = new Set(known.map((c) => c.name));
+ for (const name of normalized) {
+ if (!knownNames.has(name)) {
+ throw new Error(`Category "${name}" not found in deck`);
+ }
}
}
- if (sourceCard.category === normalizedCategory) return;
+ const current = sourceCard.categoryLinks.map((l) => l.deckCategory.name);
+ if (current.join("\u0000") === normalized.join("\u0000")) return;
await applyChanges(deckId, userId, [
{
op: "move",
deckCardId,
zone: Zone.MAINBOARD,
- category: normalizedCategory,
+ categories: normalized,
},
]);
},
);
/**
- * Bulk-move every MAINBOARD DeckCard in `sourceCategory` to a destination
- * zone+subcategory in one mutation. Non-mainboard destinations clear the
- * subcategory (categories are MAINBOARD-only). The empty source DeckCategory
- * row is intentionally kept — deletion is a separate, explicit action.
+ * Bulk-move every DeckCard whose *primary* membership is `sourceCategory` to a
+ * destination zone+subcategory in one mutation. A MAINBOARD destination swaps
+ * the primary and keeps secondary memberships; non-mainboard destinations
+ * clear all memberships (categories are MAINBOARD-only). The empty source
+ * DeckCategory row is intentionally kept — deletion is a separate, explicit
+ * action.
*/
export const moveCategoryCards = runOwnerDeckMutation(
"deck.moveCategoryCards",
@@ -329,41 +394,46 @@ export const moveCategoryCards = runOwnerDeckMutation(
targetZone: Zone,
targetCategory: string | null,
): Promise => {
- const normalizedTarget = normalizeCategory(targetCategory);
+ const normalizedTarget =
+ targetCategory === null ? null : normalizeCategory(targetCategory);
if (normalizedTarget !== null && targetZone !== Zone.MAINBOARD) {
throw new Error("Subcategories only apply to MAINBOARD cards");
}
- const nextCategory = targetZone === Zone.MAINBOARD ? normalizedTarget : null;
+ const nextPrimary =
+ targetZone === Zone.MAINBOARD ? normalizedTarget : null;
- if (nextCategory !== null) {
- const exists = await prisma.deckCategory.findUnique({
- where: { deckId_name: { deckId, name: nextCategory } },
- select: { id: true },
- });
- if (!exists) {
- throw new Error(`Category "${nextCategory}" not found in deck`);
- }
+ if (nextPrimary !== null) {
+ await assertCategoryExists(deckId, nextPrimary);
}
- // Only MAINBOARD cards carry a category, so this is the full membership.
- const sourceCards = await prisma.deckCard.findMany({
- where: { deckId, zone: Zone.MAINBOARD, category: sourceCategory },
- select: { id: true },
- });
- if (sourceCards.length === 0) return;
+ const members = await loadCategoryMembers(deckId, sourceCategory);
+ const primaryMembers = members.filter(
+ (m) => m.categories[0] === sourceCategory,
+ );
+ if (primaryMembers.length === 0) return;
- // No-op: cards already in the destination zone+category.
- if (targetZone === Zone.MAINBOARD && nextCategory === sourceCategory) return;
+ // No-op: cards already lead with the destination category.
+ if (targetZone === Zone.MAINBOARD && nextPrimary === sourceCategory) {
+ return;
+ }
await applyChanges(
deckId,
userId,
- sourceCards.map((dc) => ({
+ primaryMembers.map((m) => ({
op: "move" as const,
- deckCardId: dc.id,
+ deckCardId: m.id,
zone: targetZone,
- category: nextCategory,
+ categories:
+ nextPrimary === null
+ ? []
+ : [
+ nextPrimary,
+ ...m.categories.filter(
+ (name) => name !== sourceCategory && name !== nextPrimary,
+ ),
+ ],
})),
);
},
@@ -371,8 +441,9 @@ export const moveCategoryCards = runOwnerDeckMutation(
/**
* Automatically assign categories to MAINBOARD DeckCards by reclassifying
- * every card under the chosen preset. Existing assignments are overwritten so
- * switching presets reorganizes the deck as the user expects.
+ * every card under the chosen preset. Existing memberships are overwritten so
+ * switching presets reorganizes the deck as the user expects; cards the preset
+ * can't classify keep their current memberships.
*
* Two presets:
* - `"byType"` — buckets by `Card.mainType` (Creatures, Instants, …)
@@ -382,7 +453,7 @@ export const moveCategoryCards = runOwnerDeckMutation(
export const autogenerateCategories = runOwnerDeckMutation(
"deck.autogenerateCategories",
"category",
- async ({ deckId }, preset: AutogenPreset): Promise => {
+ async ({ deckId, userId }, preset: AutogenPreset): Promise => {
const mainboardCards = await prisma.deckCard.findMany({
where: { deckId, zone: Zone.MAINBOARD },
select: {
@@ -405,7 +476,7 @@ export const autogenerateCategories = runOwnerDeckMutation(
const categoryName = classifyCard(dc.card, preset);
if (categoryName === null) continue;
- const normalized = categoryName.trim().toLowerCase();
+ const normalized = normalizeCategory(categoryName);
const ids = assignments.get(normalized) ?? [];
ids.push(dc.id);
assignments.set(normalized, ids);
@@ -413,33 +484,23 @@ export const autogenerateCategories = runOwnerDeckMutation(
if (assignments.size === 0) return;
- for (const name of assignments.keys()) {
- const existing = await prisma.deckCategory.findUnique({
- where: { deckId_name: { deckId, name } },
- select: { id: true },
- });
-
- if (!existing) {
- const last = await prisma.deckCategory.findFirst({
- where: { deckId },
- select: { sortOrder: true },
- orderBy: { sortOrder: "desc" },
- });
- await prisma.deckCategory.create({
- data: {
- deckId,
- name,
- sortOrder: (last?.sortOrder ?? -1) + 1,
- },
- });
- }
- }
-
- for (const [name, ids] of assignments) {
- await prisma.deckCard.updateMany({
- where: { id: { in: ids }, deckId },
- data: { category: name },
- });
- }
+ // Registry creation and the membership writes commit or roll back
+ // together, so a mid-flight failure can't leave phantom categories.
+ await prisma.$transaction(async (tx) => {
+ await ensureDeckCategories(tx, deckId, [...assignments.keys()]);
+ await applyChanges(
+ deckId,
+ userId,
+ [...assignments].flatMap(([name, ids]) =>
+ ids.map((deckCardId) => ({
+ op: "move" as const,
+ deckCardId,
+ zone: Zone.MAINBOARD,
+ categories: [name],
+ })),
+ ),
+ { tx },
+ );
+ });
},
);
diff --git a/app/_actions/deck/collaboration.ts b/app/_actions/deck/collaboration.ts
index 2f703de..71e04f4 100644
--- a/app/_actions/deck/collaboration.ts
+++ b/app/_actions/deck/collaboration.ts
@@ -55,22 +55,26 @@ async function planProposalChanges(
tx?: Prisma.TransactionClient,
): Promise {
const client = tx ?? prisma;
- const rows = await client.deckCard.findMany({
- where: { deckId },
- select: {
- id: true,
- cardId: true,
- zone: true,
- category: true,
- quantity: true,
- },
- });
+ const [rows, categories] = await Promise.all([
+ client.deckCard.findMany({
+ where: { deckId },
+ select: {
+ id: true,
+ cardId: true,
+ zone: true,
+ quantity: true,
+ },
+ }),
+ client.deckCategory.findMany({
+ where: { deckId },
+ select: { name: true },
+ }),
+ ]);
const existing: ExistingDeckCard[] = rows.map((r) => ({
deckCardId: r.id,
cardId: r.cardId,
zone: r.zone,
- category: r.category,
quantity: r.quantity,
}));
@@ -83,7 +87,11 @@ async function planProposalChanges(
}
}
- return deltasToBulkChanges(deltas, existing);
+ return deltasToBulkChanges(
+ deltas,
+ existing,
+ new Set(categories.map((c) => c.name)),
+ );
}
export const submitDeckProposal = withActionLogging(
diff --git a/app/_actions/deck/duplicate.ts b/app/_actions/deck/duplicate.ts
index b69184b..97ffbbf 100644
--- a/app/_actions/deck/duplicate.ts
+++ b/app/_actions/deck/duplicate.ts
@@ -29,9 +29,14 @@ export const duplicateDeck = withActionLogging(
cardId: true,
quantity: true,
zone: true,
- category: true,
isFoil: true,
printingId: true,
+ categoryLinks: {
+ select: {
+ position: true,
+ deckCategory: { select: { name: true } },
+ },
+ },
},
},
categories: {
@@ -79,17 +84,81 @@ export const duplicateDeck = withActionLogging(
});
if (original.cards.length > 0) {
+ // Memberships point at the copy's own DeckCategory rows, so map the
+ // original's category names onto the freshly created registry.
+ const newCategories = await tx.deckCategory.findMany({
+ where: { deckId: deck.id },
+ select: { id: true, name: true },
+ });
+ const categoryIdByName = new Map(
+ newCategories.map((c) => [c.name, c.id]),
+ );
+
+ // Bulk-create the rows, then re-select and match copies to originals
+ // by identity tuple — createMany can't return ids, and a per-row
+ // create loop stalls the 5s interactive transaction on large decks.
await tx.deckCard.createMany({
data: original.cards.map((c) => ({
deckId: deck.id,
cardId: c.cardId,
quantity: c.quantity,
zone: c.zone,
- category: c.category,
isFoil: c.isFoil,
printingId: c.printingId,
})),
});
+ const copies = await tx.deckCard.findMany({
+ where: { deckId: deck.id },
+ select: {
+ id: true,
+ cardId: true,
+ zone: true,
+ printingId: true,
+ isFoil: true,
+ },
+ });
+
+ const tupleKey = (r: {
+ cardId: number;
+ zone: string;
+ printingId: number | null;
+ isFoil: boolean;
+ }) => `${r.cardId}|${r.zone}|${r.printingId ?? "-"}|${r.isFoil}`;
+ const copyIdsByKey = new Map();
+ for (const r of copies) {
+ const key = tupleKey(r);
+ const ids = copyIdsByKey.get(key) ?? [];
+ ids.push(r.id);
+ copyIdsByKey.set(key, ids);
+ }
+
+ // Zip same-tuple duplicates in order; the rows are otherwise
+ // identical, so any pairing yields the same deck state. Positions are
+ // copied verbatim — reads order by position, so gaps are fine.
+ const cursor = new Map();
+ const linkRows: {
+ deckCardId: string;
+ deckCategoryId: string;
+ position: number;
+ }[] = [];
+ for (const c of original.cards) {
+ const key = tupleKey(c);
+ const idx = cursor.get(key) ?? 0;
+ cursor.set(key, idx + 1);
+ const copyId = copyIdsByKey.get(key)?.[idx];
+ /* c8 ignore next -- every original row was just copied */
+ if (copyId === undefined) continue;
+ for (const link of c.categoryLinks) {
+ const deckCategoryId = categoryIdByName.get(
+ link.deckCategory.name,
+ );
+ if (deckCategoryId === undefined) continue;
+ linkRows.push({ deckCardId: copyId, deckCategoryId, position: link.position });
+ }
+ }
+ if (linkRows.length > 0) {
+ await tx.deckCardCategory.createMany({ data: linkRows });
+ }
}
return deck;
diff --git a/app/_actions/deck/export.ts b/app/_actions/deck/export.ts
index c0433df..78a7af3 100644
--- a/app/_actions/deck/export.ts
+++ b/app/_actions/deck/export.ts
@@ -42,11 +42,13 @@ export async function getDeckExports(
options?.zones || options?.categories
? deck.cards.filter((c) => {
if (options.zones && !options.zones.includes(c.zone)) return false;
+ // A card passes the category filter if ANY of its memberships is
+ // selected; uncategorized cards always pass.
if (
c.zone === "MAINBOARD" &&
options.categories &&
- c.category !== null &&
- !options.categories.includes(c.category)
+ c.categories.length > 0 &&
+ !c.categories.some((name) => options.categories!.includes(name))
)
return false;
return true;
diff --git a/app/_actions/deck/manabase.ts b/app/_actions/deck/manabase.ts
index bbd97a7..5e3ed26 100644
--- a/app/_actions/deck/manabase.ts
+++ b/app/_actions/deck/manabase.ts
@@ -40,7 +40,7 @@ export const addLandsToDeck = runOwnerDeckMutation(
cardId: pick.cardId,
quantity: pick.quantity,
zone: Zone.MAINBOARD,
- category: null,
+ categories: [],
});
}
@@ -55,7 +55,7 @@ export const addLandsToDeck = runOwnerDeckMutation(
cardId: basicIds[color],
quantity,
zone: Zone.MAINBOARD,
- category: null,
+ categories: [],
});
}
}
diff --git a/app/_actions/deck/revisions.ts b/app/_actions/deck/revisions.ts
index b98de03..1196b19 100644
--- a/app/_actions/deck/revisions.ts
+++ b/app/_actions/deck/revisions.ts
@@ -71,26 +71,34 @@ export const revertDeckRevision = runOwnerDeckMutation(
const inverted = invertDeltas(deltas);
- const rows = await prisma.deckCard.findMany({
- where: { deckId },
- select: {
- id: true,
- cardId: true,
- zone: true,
- category: true,
- quantity: true,
- },
- });
+ const [rows, categories] = await Promise.all([
+ prisma.deckCard.findMany({
+ where: { deckId },
+ select: {
+ id: true,
+ cardId: true,
+ zone: true,
+ quantity: true,
+ },
+ }),
+ prisma.deckCategory.findMany({
+ where: { deckId },
+ select: { name: true },
+ }),
+ ]);
const existing = rows.map((r) => ({
deckCardId: r.id,
cardId: r.cardId,
zone: r.zone,
- category: r.category,
quantity: r.quantity,
}));
- const changes = deltasToBulkChanges(inverted, existing);
+ const changes = deltasToBulkChanges(
+ inverted,
+ existing,
+ new Set(categories.map((c) => c.name)),
+ );
if (changes.length === 0) return;
await applyChanges(deckId, userId, changes, { skipMerge: true });
diff --git a/app/_actions/inventory.ts b/app/_actions/inventory.ts
index 4fd942a..ff51441 100644
--- a/app/_actions/inventory.ts
+++ b/app/_actions/inventory.ts
@@ -9,6 +9,7 @@ import {
invalidateTags,
viewerHoldingsTag,
} from "@/lib/deck/cache-tags";
+import { CATEGORY_NAME_MAX, normalizeCategory } from "@/lib/deck/constants";
import { getOrCreateWishlistDeck } from "@/lib/deck/wishlist-deck";
const setHoldingSchema = z.object({
@@ -136,12 +137,44 @@ export const setWishlist = withActionLogging(
// wishlisted from, so the wishlist mirrors the user's decks. The wishlist
// deck itself is excluded (no self-referential "Wishlist" category) and a
// missing/deleted source deck falls back to uncategorized.
- const category = await resolveWishlistCategory(
+ const categoryName = await resolveWishlistCategory(
args.sourceDeckId,
wishlistDeckId,
session.userId,
);
+ // Memberships need a DeckCategory row, so register the source deck's
+ // name in the wishlist's category registry on first use.
+ let categoryId: string | null = null;
+ if (categoryName !== null) {
+ // Deck names allow 100 chars but category names cap at 50 — truncate
+ // so a long source-deck name can't violate the registry constraint.
+ const normalized = normalizeCategory(categoryName).slice(
+ 0,
+ CATEGORY_NAME_MAX,
+ );
+ if (normalized.length > 0) {
+ const last = await prisma.deckCategory.findFirst({
+ where: { deckId: wishlistDeckId },
+ select: { sortOrder: true },
+ orderBy: { sortOrder: "desc" },
+ });
+ const category = await prisma.deckCategory.upsert({
+ where: {
+ deckId_name: { deckId: wishlistDeckId, name: normalized },
+ },
+ create: {
+ deckId: wishlistDeckId,
+ name: normalized,
+ sortOrder: (last?.sortOrder ?? -1) + 1,
+ },
+ update: {},
+ select: { id: true },
+ });
+ categoryId = category.id;
+ }
+ }
+
const existing = await prisma.deckCard.findFirst({
where: {
deckId: wishlistDeckId,
@@ -159,8 +192,12 @@ export const setWishlist = withActionLogging(
printingId: args.printingId,
isFoil: args.isFoil,
zone: "MAINBOARD",
- category,
quantity: 1,
+ ...(categoryId !== null && {
+ categoryLinks: {
+ create: [{ deckCategoryId: categoryId, position: 0 }],
+ },
+ }),
},
});
}
diff --git a/app/_components/builder/card-browser/deck-browser-context.tsx b/app/_components/builder/card-browser/deck-browser-context.tsx
index 3c726c3..9924141 100644
--- a/app/_components/builder/card-browser/deck-browser-context.tsx
+++ b/app/_components/builder/card-browser/deck-browser-context.tsx
@@ -117,7 +117,7 @@ export function DeckBrowserProvider({
await addCardToDeck(deckId, card.id, {
quantity: qty,
zone: Zone.MAINBOARD,
- category: target,
+ categories: target === null ? [] : [target],
});
});
},
@@ -172,7 +172,7 @@ export function DeckBrowserProvider({
await addCardsToDeck(
deckId,
picked.map((c) => ({ cardId: c.id })),
- { zone: Zone.MAINBOARD, category: dest },
+ { zone: Zone.MAINBOARD, categories: dest === null ? [] : [dest] },
);
setSelected(new Map());
setSelectModeState(false);
diff --git a/app/_components/builder/card-row-sortable.tsx b/app/_components/builder/card-row-sortable.tsx
index a7750c6..39e2a3b 100644
--- a/app/_components/builder/card-row-sortable.tsx
+++ b/app/_components/builder/card-row-sortable.tsx
@@ -121,7 +121,8 @@ export function CardRowSortable({
viewerId,
ownership,
viewOptions = DEFAULT_DECK_VIEW_OPTIONS,
-}: Omit) {
+ sortableId,
+}: Omit & { sortableId?: string }) {
const [isPending, startTransition] = useTransition();
const [printingPickerOpen, setPrintingPickerOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
@@ -136,8 +137,10 @@ export function CardRowSortable({
transition,
isDragging,
} = useSortable({
- id: dc.id,
- data: { kind: "card", zone: dc.zone, category: dc.category },
+ id: sortableId ?? dc.id,
+ // Ghost (secondary-membership) entries are display-only: not draggable.
+ disabled: dc.isSecondary ?? false,
+ data: { kind: "card", zone: dc.zone, category: dc.categories[0] ?? null },
});
const dragStyle = {
@@ -179,15 +182,14 @@ export function CardRowSortable({
function moveToZone(nextZone: Zone) {
if (nextZone === dc.zone) return;
- const nextCategory = nextZone === "MAINBOARD" ? dc.category : null;
startTransition(async () => {
dispatch({
type: "move",
deckCardId: dc.id,
zone: nextZone,
- category: nextCategory,
+ categories: [],
});
- await moveCardTo(deckId, dc.id, nextZone, nextCategory);
+ await moveCardTo(deckId, dc.id, nextZone, null);
});
}
@@ -213,6 +215,7 @@ export function CardRowSortable({
className={cn(
"group/row @container/row flex items-center gap-1 text-sm py-0.5 cursor-default break-inside-avoid hover:bg-accent/20 hover:ring-1 hover:ring-ring hover:rounded-sm focus-visible:outline-none focus-visible:bg-accent/20 focus-visible:ring-1 focus-visible:ring-ring focus-visible:rounded-sm",
isPending && "opacity-50",
+ dc.isSecondary && "opacity-50",
searchClasses,
)}
onMouseEnter={() => preview?.preview(previewPayload)}
@@ -225,15 +228,19 @@ export function CardRowSortable({
}}
onKeyDown={onRowKeyDown}
>
-
-
-
+ {dc.isSecondary ? (
+
+ ) : (
+
+
+
+ )}
{cards.map((dc, index) => (
c.id === deckCardId);
if (!source) return;
- if (source.zone === target.zone && source.category === target.category)
+ const sourcePrimary = source.categories[0] ?? null;
+ if (source.zone === target.zone && sourcePrimary === target.category)
return;
+ // Dropping on a category section makes it the primary while preserving
+ // the other memberships; dropping on Uncategorized (or another zone)
+ // clears them. Mirrors the `moveCardTo` server action.
+ const nextCategories =
+ target.zone !== "MAINBOARD" || target.category === null
+ ? []
+ : [
+ target.category,
+ ...source.categories.filter((name) => name !== target.category),
+ ];
+
startTransition(async () => {
dispatch({
type: "move",
deckCardId,
zone: target.zone,
- category: target.category,
+ categories: nextCategories,
});
await moveCardTo(deck.id, deckCardId, target.zone, target.category);
});
diff --git a/app/_components/builder/decklist-dnd.tsx b/app/_components/builder/decklist-dnd.tsx
index 1546b26..61218c7 100644
--- a/app/_components/builder/decklist-dnd.tsx
+++ b/app/_components/builder/decklist-dnd.tsx
@@ -93,10 +93,14 @@ function DroppableCategorySection(props: DroppableCategorySectionProps) {
!(source?.zone === props.zone && (source?.category ?? null) === props.dropCategory);
function renderCards(cards: DeckCard[], bodyId: string) {
+ // Secondary (ghost) entries repeat a DeckCard across sections, and dnd-kit
+ // ids must be unique per DndContext — suffix them with the section body id.
+ const sortableId = (dc: DeckCard) =>
+ dc.isSecondary ? `${dc.id}::${bodyId}` : dc.id;
if (props.view === "stack") {
return (
dc.id)}
+ items={cards.map(sortableId)}
strategy={verticalListSortingStrategy}
>
dc.id)}
+ items={cards.map(sortableId)}
strategy={verticalListSortingStrategy}
>
@@ -121,7 +125,8 @@ function DroppableCategorySection(props: DroppableCategorySectionProps) {
: undefined;
return (
dc.id)}
+ cards={section.cards
+ .filter((dc) => !dc.isSecondary)
+ .map((dc) => ({ id: dc.id, categories: dc.categories }))}
onReorder={handleReorder}
dispatch={dispatch}
/>
@@ -342,7 +349,8 @@ interface CategoryActionsMenuProps {
total: number;
categoryNames: readonly string[];
isEmpty: boolean;
- cardIds: string[];
+ /** Primary members only (ghost fan-out copies excluded), with memberships. */
+ cards: { id: string; categories: string[] }[];
onReorder: (movedName: string, nextOrder: string[]) => void;
dispatch: (a: ZoneAction) => void;
}
@@ -363,7 +371,7 @@ function CategoryActionsMenu({
total,
categoryNames,
isEmpty,
- cardIds,
+ cards,
onReorder,
dispatch,
}: CategoryActionsMenuProps) {
@@ -385,8 +393,20 @@ function CategoryActionsMenu({
function moveAll(zone: Zone, category: string | null) {
startTransition(async () => {
- for (const id of cardIds) {
- dispatch({ type: "move", deckCardId: id, zone, category });
+ // Mirror moveCategoryCards: a MAINBOARD target swaps the primary and
+ // keeps secondary memberships; a zone target clears them. Diverging
+ // here makes the ghosts jump and snap back when the server responds.
+ for (const dc of cards) {
+ const categories =
+ zone === "MAINBOARD" && category !== null
+ ? [
+ category,
+ ...dc.categories.filter(
+ (name) => name !== dbName && name !== category,
+ ),
+ ]
+ : [];
+ dispatch({ type: "move", deckCardId: dc.id, zone, categories });
}
await moveCategoryCards(deckId, dbName, zone, category);
});
diff --git a/app/_components/builder/decklist-section.tsx b/app/_components/builder/decklist-section.tsx
index 93a69fc..c4822b3 100644
--- a/app/_components/builder/decklist-section.tsx
+++ b/app/_components/builder/decklist-section.tsx
@@ -246,6 +246,8 @@ export function CategorySectionView({
renderCards,
}: CategorySectionViewProps) {
const [editing, setEditing] = useState(false);
+ // Every membership counts: ghost (secondary) entries contribute to their
+ // section's tally, so a multi-category card appears in each section's count.
const total = cards.reduce((sum, dc) => sum + dc.quantity, 0);
const canManage = isOwner && kind === "category" && !!dbName && !!onRename;
const bodyId = `section-body-${droppableId}`;
diff --git a/app/_components/builder/decklist.test.tsx b/app/_components/builder/decklist.test.tsx
index 3a81edf..335fde8 100644
--- a/app/_components/builder/decklist.test.tsx
+++ b/app/_components/builder/decklist.test.tsx
@@ -65,7 +65,7 @@ function mainboardCard(id: string, category: string, quantity = 1): DeckCard {
cardId: 1,
quantity,
zone: "MAINBOARD",
- category,
+ categories: [category],
printingId: null,
isFoil: false,
createdAt: new Date(),
@@ -129,6 +129,30 @@ describe("Decklist - Command Zone template targets", () => {
});
});
+describe("Decklist - multi-category tallies", () => {
+ it("counts a multi-category card in every member section", () => {
+ const deck = makeDeck(["Ramp", "Removal"]);
+ const card = mainboardCard("dc-1", "Ramp");
+ (card as { categories: string[] }).categories = ["Ramp", "Removal"];
+
+ renderWithDnd(
+ ,
+ );
+
+ // Full render tallies under the primary...
+ const ramp = screen.getByRole("region", { name: /^ramp \(1\)$/i });
+ expect(within(ramp).getByText("Llanowar Elves")).toBeInTheDocument();
+ // ...and the secondary section counts the ghost too.
+ const removal = screen.getByRole("region", { name: /^removal \(1\)$/i });
+ expect(within(removal).getByText("Llanowar Elves")).toBeInTheDocument();
+ });
+});
+
describe("Decklist - category controls", () => {
it("has no a11y violations", async () => {
const deck = makeDeck(["Ramp", "Removal"]);
@@ -146,7 +170,7 @@ describe("Decklist - category controls", () => {
cardId: 1,
quantity: 1,
zone: "MAINBOARD",
- category: null,
+ categories: [],
printingId: null,
isFoil: false,
createdAt: new Date(),
@@ -241,7 +265,7 @@ describe("Decklist - category controls", () => {
cardId: 1,
quantity: 1,
zone: "MAINBOARD",
- category: "Ramp",
+ categories: ["Ramp"],
printingId: null,
isFoil: false,
createdAt: new Date(),
diff --git a/app/_components/builder/decklist.tsx b/app/_components/builder/decklist.tsx
index 912f56d..1ca00ed 100644
--- a/app/_components/builder/decklist.tsx
+++ b/app/_components/builder/decklist.tsx
@@ -142,8 +142,8 @@ export function useDecklistState(
const displayCards = useMemo(
() =>
cards.map((c) =>
- c.category && renames[c.category]
- ? { ...c, category: renames[c.category]! }
+ c.categories.some((name) => renames[name])
+ ? { ...c, categories: c.categories.map((name) => renames[name] ?? name) }
: c,
),
[cards, renames],
diff --git a/app/_components/builder/move-card-menu.tsx b/app/_components/builder/move-card-menu.tsx
index 30ba05e..e2f2c98 100644
--- a/app/_components/builder/move-card-menu.tsx
+++ b/app/_components/builder/move-card-menu.tsx
@@ -1,7 +1,7 @@
"use client";
-import { useState, useTransition } from "react";
-import { MoreVertical, Check, Layers, Minus, Plus } from "lucide-react";
+import { useRef, useState, useTransition } from "react";
+import { MoreVertical, Check, Layers, Minus, Plus, Star } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -16,7 +16,7 @@ import BottomSheet from "@/app/_components/bottom-sheet";
import { useMenuShortcuts } from "@/app/_components/hotkeys/use-menu-shortcuts";
import { useInventoryActions } from "@/app/_components/builder/inventory-actions";
import { cn, toTitleCase } from "@/lib/utils";
-import { moveCardTo } from "@/app/_actions/deck/categories";
+import { moveCardTo, setCardCategories } from "@/app/_actions/deck/categories";
import type { ZoneAction } from "@/lib/deck/zone-view";
import type { OwnershipState } from "@/lib/inventory/state";
import type { Zone } from "@/lib/generated/prisma/client";
@@ -27,7 +27,8 @@ interface MoveCardMenuProps {
cardName: string;
currentZone: Zone;
commanderSet: boolean;
- currentSubcategory: string | null;
+ /** Ordered category memberships; `[0]` is the primary. */
+ currentCategories: string[];
subcategories: string[];
quantity: number;
onQuantityChange: (next: number) => void;
@@ -73,7 +74,7 @@ export function MoveCardMenu({
cardName,
currentZone,
commanderSet,
- currentSubcategory,
+ currentCategories,
subcategories,
quantity,
onQuantityChange,
@@ -85,6 +86,15 @@ export function MoveCardMenu({
}: MoveCardMenuProps) {
const [isPending, startTransition] = useTransition();
const [sheetOpen, setSheetOpen] = useState(false);
+ // Rapid toggles (number keys) fire overlapping server mutations whose
+ // read-modify-write bodies can interleave and drop memberships. Optimistic
+ // dispatch stays immediate; only the server calls serialize through here.
+ const mutationQueueRef = useRef>(Promise.resolve());
+ function enqueueMutation(fn: () => Promise): Promise {
+ const next = mutationQueueRef.current.then(fn, fn);
+ mutationQueueRef.current = next;
+ return next;
+ }
const [desktopOpenInternal, setDesktopOpenInternal] = useState(false);
const [tab, setTab] = useState("actions");
@@ -121,29 +131,60 @@ export function MoveCardMenu({
setSheetOpen(next);
}
- function move(nextZone: Zone, nextCategory: string | null) {
+ function handleZoneMove(nextZone: Zone) {
+ if (nextZone === currentZone) return;
+ startTransition(async () => {
+ dispatch({ type: "move", deckCardId, zone: nextZone, categories: [] });
+ await enqueueMutation(() => moveCardTo(deckId, deckCardId, nextZone, null));
+ });
+ }
+
+ /**
+ * Replace the card's memberships wholesale (order matters — `[0]` is the
+ * primary). Cards outside MAINBOARD are moved there first, since categories
+ * are MAINBOARD-only.
+ */
+ function applyCategories(next: string[]) {
startTransition(async () => {
dispatch({
type: "move",
deckCardId,
- zone: nextZone,
- category: nextCategory,
+ zone: "MAINBOARD",
+ categories: next,
});
- await moveCardTo(deckId, deckCardId, nextZone, nextCategory);
+ if (currentZone === "MAINBOARD") {
+ await enqueueMutation(() => setCardCategories(deckId, deckCardId, next));
+ } else {
+ await enqueueMutation(() =>
+ moveCardTo(deckId, deckCardId, "MAINBOARD", next[0] ?? null),
+ );
+ }
});
}
- function handleZoneMove(nextZone: Zone) {
- if (nextZone === currentZone) return;
- const nextCategory = nextZone === "MAINBOARD" ? currentSubcategory : null;
- move(nextZone, nextCategory);
- }
-
- function handleSubcategoryMove(nextSubcategory: string | null) {
- if (currentZone === "MAINBOARD" && currentSubcategory === nextSubcategory) {
+ /**
+ * Toggle membership. The first category added becomes the primary; removing
+ * the primary promotes the next membership.
+ */
+ function toggleCategory(name: string) {
+ if (currentZone !== "MAINBOARD") {
+ applyCategories([name]);
return;
}
- move("MAINBOARD", nextSubcategory);
+ const next = currentCategories.includes(name)
+ ? currentCategories.filter((c) => c !== name)
+ : [...currentCategories, name];
+ applyCategories(next);
+ }
+
+ function promoteCategory(name: string) {
+ if (currentZone !== "MAINBOARD" || currentCategories[0] === name) return;
+ applyCategories([name, ...currentCategories.filter((c) => c !== name)]);
+ }
+
+ function clearCategories() {
+ if (currentZone === "MAINBOARD" && currentCategories.length === 0) return;
+ applyCategories([]);
}
const triggerButton = (
@@ -162,7 +203,7 @@ export function MoveCardMenu({
);
const isMainboardUncategorized =
- currentZone === "MAINBOARD" && currentSubcategory === null;
+ currentZone === "MAINBOARD" && currentCategories.length === 0;
const onMenuKeyDown = useMenuShortcuts([
{
@@ -207,17 +248,24 @@ export function MoveCardMenu({
key: "0",
disabled: isMainboardUncategorized,
action: () => {
- handleSubcategoryMove(null);
- setDesktopOpen(false);
+ clearCategories();
},
},
+ // Shift+digit promotes a membership to primary; listed before the plain
+ // digit toggles so shifted presses can't fall through to them.
...subcategories.slice(0, 9).map((name, idx) => ({
key: String(idx + 1),
- disabled:
- currentZone === "MAINBOARD" && currentSubcategory === name,
+ shift: true,
action: () => {
- handleSubcategoryMove(name);
- setDesktopOpen(false);
+ promoteCategory(name);
+ },
+ })),
+ // Number keys toggle membership without closing, so several categories
+ // can be assigned in one menu visit.
+ ...subcategories.slice(0, 9).map((name, idx) => ({
+ key: String(idx + 1),
+ action: () => {
+ toggleCategory(name);
},
})),
]);
@@ -348,7 +396,8 @@ export function MoveCardMenu({
handleSubcategoryMove(null)}
+ closeOnClick={false}
+ onClick={() => clearCategories()}
className="gap-2"
>
{isMainboardUncategorized && (
@@ -365,23 +414,56 @@ export function MoveCardMenu({
0
{subcategories.map((name, idx) => {
- const isCurrent =
+ const isMember =
+ currentZone === "MAINBOARD" &&
+ currentCategories.includes(name);
+ const isPrimary =
currentZone === "MAINBOARD" &&
- currentSubcategory === name;
+ currentCategories[0] === name;
const shortcut = idx < 9 ? String(idx + 1) : null;
return (
handleSubcategoryMove(name)}
+ closeOnClick={false}
+ onClick={() => toggleCategory(name)}
+ aria-label={
+ isMember && !isPrimary
+ ? `${toTitleCase(name)} — Enter toggles membership, Shift+${shortcut ?? ""} makes it primary`
+ : undefined
+ }
+ {...(shortcut && {
+ "aria-keyshortcuts": isMember && !isPrimary
+ ? `${shortcut} Shift+${shortcut}`
+ : shortcut,
+ })}
className="gap-2"
>
- {isCurrent && (
+ {isMember && (
)}
-
+
{toTitleCase(name)}
+ {isPrimary ? (
+
+ ) : isMember ? (
+ // Not a nested — menuitems can't contain
+ // interactive children. Mouse promotes via this span;
+ // keyboard promotes via Shift+digit.
+ {
+ e.stopPropagation();
+ promoteCategory(name);
+ }}
+ className="shrink-0 cursor-pointer text-muted-foreground hover:text-foreground"
+ >
+
+
+ ) : null}
{shortcut && (
{shortcut}
)}
@@ -554,10 +636,7 @@ export function MoveCardMenu({
{
- setSheetOpen(false);
- handleSubcategoryMove(null);
- }}
+ onClick={() => clearCategories()}
className={cn(
"w-full flex items-center gap-2 rounded-md pl-6 pr-3 min-h-9 text-sm text-left transition-colors italic",
isMainboardUncategorized
@@ -574,32 +653,45 @@ export function MoveCardMenu({
{subcategories.map((name) => {
- const isCurrent =
+ const isMember =
currentZone === "MAINBOARD" &&
- currentSubcategory === name;
+ currentCategories.includes(name);
+ const isPrimary =
+ currentZone === "MAINBOARD" &&
+ currentCategories[0] === name;
return (
- {
- setSheetOpen(false);
- handleSubcategoryMove(name);
- }}
- className={cn(
- "w-full flex items-center gap-2 rounded-md pl-6 pr-3 min-h-9 text-sm text-left transition-colors",
- isCurrent
- ? "text-muted-foreground cursor-default"
- : "hover:bg-accent hover:text-accent-foreground",
- )}
+
- {isCurrent && (
-
- )}
-
- {toTitleCase(name)}
-
-
+ toggleCategory(name)}
+ className="flex flex-1 items-center gap-2 text-left min-h-9"
+ >
+ {isMember && (
+
+ )}
+
+ {toTitleCase(name)}
+
+
+ {isPrimary ? (
+
+ ) : isMember ? (
+ promoteCategory(name)}
+ className="shrink-0 p-2 -m-2 text-muted-foreground hover:text-foreground"
+ >
+
+
+ ) : null}
+
);
})}
diff --git a/app/_components/deck/deck-detail-sheet.test.tsx b/app/_components/deck/deck-detail-sheet.test.tsx
index 1d7f11d..85feb68 100644
--- a/app/_components/deck/deck-detail-sheet.test.tsx
+++ b/app/_components/deck/deck-detail-sheet.test.tsx
@@ -56,7 +56,7 @@ function zoneCard(
cardId: id,
quantity: 1,
zone,
- category: null,
+ categories: [],
printingId: null,
isFoil: false,
createdAt: new Date(),
diff --git a/app/_components/deck/deck-proposal-review-list.tsx b/app/_components/deck/deck-proposal-review-list.tsx
index 345db12..0f313f6 100644
--- a/app/_components/deck/deck-proposal-review-list.tsx
+++ b/app/_components/deck/deck-proposal-review-list.tsx
@@ -10,6 +10,7 @@ import {
type DeckProposalView,
} from "@/app/_actions/deck/collaboration";
import { groupDeltasByZone } from "@/lib/deck/group-deltas";
+import { squashDeltas } from "@/lib/deck/revision";
import type { Zone } from "@/lib/generated/prisma/enums";
interface DeckProposalReviewListProps {
@@ -87,7 +88,7 @@ function ProposalCard({
proposal: DeckProposalView;
}) {
const router = useRouter();
- const grouped = groupDeltasByZone(proposal.changes);
+ const grouped = groupDeltasByZone(squashDeltas(proposal.changes));
return (
@@ -139,7 +140,7 @@ function ProposalCard({
{deltas.map((d) => (
0 ? `+${d.delta}` : d.delta}
{d.cardName || `Card #${d.cardId}`}
- {d.category && (
+ {d.categories.length > 0 && (
- ({d.category})
+ ({d.categories.join(", ")})
)}
diff --git a/app/_components/deck/deck-propose-draft.tsx b/app/_components/deck/deck-propose-draft.tsx
index 561817b..becb040 100644
--- a/app/_components/deck/deck-propose-draft.tsx
+++ b/app/_components/deck/deck-propose-draft.tsx
@@ -13,7 +13,8 @@ import { deltaKey, type RevisionDelta } from "@/lib/deck/revision";
interface ExistingMainboardCard {
cardId: number;
cardName: string;
- category: string | null;
+ /** Ordered category memberships; `[0]` is the primary. */
+ categories: string[];
quantity: number;
}
@@ -22,8 +23,8 @@ interface DeckProposeDraftProps {
existingCards: ExistingMainboardCard[];
}
-function mainboardKey(c: Pick) {
- return deltaKey({ cardId: c.cardId, zone: "MAINBOARD", category: c.category });
+function mainboardKey(c: Pick) {
+ return deltaKey({ cardId: c.cardId, zone: "MAINBOARD" });
}
export function DeckProposeDraft({
@@ -92,7 +93,7 @@ export function DeckProposeDraft({
cardId: c.cardId,
cardName: c.cardName,
zone: "MAINBOARD",
- category: c.category,
+ categories: c.categories,
delta,
});
}
@@ -102,7 +103,7 @@ export function DeckProposeDraft({
cardId,
cardName: entry.cardName,
zone: "MAINBOARD",
- category: null,
+ categories: [],
delta: entry.quantity,
});
}
diff --git a/app/_components/deck/revision-diff.tsx b/app/_components/deck/revision-diff.tsx
index 998ab7d..e8d0ec5 100644
--- a/app/_components/deck/revision-diff.tsx
+++ b/app/_components/deck/revision-diff.tsx
@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
-import { deltaKey, type RevisionDelta } from "@/lib/deck/revision";
+import { deltaKey, squashDeltas, type RevisionDelta } from "@/lib/deck/revision";
import { groupDeltasByZone } from "@/lib/deck/group-deltas";
import type { Zone } from "@/lib/generated/prisma/enums";
@@ -11,6 +11,10 @@ const ZONE_LABEL: Record = {
COMPANION: "Companion",
};
+function formatCategoryList(names: readonly string[]): string {
+ return names.length > 0 ? names.join(", ") : "uncategorized";
+}
+
export function RevisionDiff({
deltas,
renderRowStart,
@@ -18,7 +22,7 @@ export function RevisionDiff({
deltas: readonly RevisionDelta[];
renderRowStart?: ((delta: RevisionDelta, key: string) => ReactNode) | undefined;
}) {
- const grouped = groupDeltasByZone(deltas);
+ const grouped = groupDeltasByZone(squashDeltas(deltas));
return (
@@ -32,6 +36,20 @@ export function RevisionDiff({
{zoneDeltas.map((d) => {
const key = deltaKey(d);
+ // A zero-quantity delta is a pure recategorization: render the
+ // membership change instead of a ±N count.
+ if (d.delta === 0 && d.previousCategories !== undefined) {
+ return (
+
+ {renderRowStart?.(d, key)}
+ {d.cardName || `Card #${d.cardId}`}
+
+ {formatCategoryList(d.previousCategories)} →{" "}
+ {formatCategoryList(d.categories)}
+
+
+ );
+ }
return (
{renderRowStart?.(d, key)}
@@ -45,9 +63,9 @@ export function RevisionDiff({
{d.delta > 0 ? `+${d.delta}` : d.delta}
{d.cardName || `Card #${d.cardId}`}
- {d.category && (
+ {d.categories.length > 0 && (
- ({d.category})
+ ({d.categories.join(", ")})
)}
diff --git a/app/_components/header-search/deck-mode-bar.test.tsx b/app/_components/header-search/deck-mode-bar.test.tsx
index 652f23a..fdff6f9 100644
--- a/app/_components/header-search/deck-mode-bar.test.tsx
+++ b/app/_components/header-search/deck-mode-bar.test.tsx
@@ -123,7 +123,7 @@ function makeCommanderWithKeywords(keywords: string[]) {
cardId: 1,
quantity: 1,
zone: "COMMANDER",
- category: null,
+ categories: [],
printingId: null,
isFoil: false,
card: {
diff --git a/app/_components/header-search/deck-mode-bar.tsx b/app/_components/header-search/deck-mode-bar.tsx
index 8995719..f6cf1b5 100644
--- a/app/_components/header-search/deck-mode-bar.tsx
+++ b/app/_components/header-search/deck-mode-bar.tsx
@@ -409,7 +409,11 @@ export function DeckModeBar({ deckRoute }: { deckRoute: DeckRouteSignal }) {
const zone = targetZone;
const category = zone === Zone.MAINBOARD ? targetCategory : null;
startTransition(async () => {
- await addCardToDeck(deckId, card.id, { quantity: qty, zone, category });
+ await addCardToDeck(deckId, card.id, {
+ quantity: qty,
+ zone,
+ categories: category === null ? [] : [category],
+ });
closeAndReset();
inputRef.current?.focus();
});
@@ -423,7 +427,11 @@ export function DeckModeBar({ deckRoute }: { deckRoute: DeckRouteSignal }) {
const qty = staged.quantity;
const cardId = staged.card.id;
startTransition(async () => {
- await addCardToDeck(deckId, cardId, { quantity: qty, zone, category });
+ await addCardToDeck(deckId, cardId, {
+ quantity: qty,
+ zone,
+ categories: category === null ? [] : [category],
+ });
closeAndReset();
inputRef.current?.focus();
});
@@ -1065,7 +1073,7 @@ function ListView({
{it.dc.card.typeLine && (
{ZONE_LABEL[it.dc.zone]}
- {it.dc.category ? ` · ${it.dc.category}` : ""} ·{" "}
+ {it.dc.categories[0] ? ` · ${it.dc.categories[0]}` : ""} ·{" "}
{it.dc.card.typeLine}
)}
@@ -1224,7 +1232,7 @@ function MoreView({
{it.dc.card.typeLine && (
{ZONE_LABEL[it.dc.zone]}
- {it.dc.category ? ` · ${it.dc.category}` : ""} ·{" "}
+ {it.dc.categories[0] ? ` · ${it.dc.categories[0]}` : ""} ·{" "}
{it.dc.card.typeLine}
)}
diff --git a/app/_components/hotkeys/__tests__/use-menu-shortcuts.test.tsx b/app/_components/hotkeys/__tests__/use-menu-shortcuts.test.tsx
index f27aba4..5c1ec80 100644
--- a/app/_components/hotkeys/__tests__/use-menu-shortcuts.test.tsx
+++ b/app/_components/hotkeys/__tests__/use-menu-shortcuts.test.tsx
@@ -58,6 +58,45 @@ describe("useMenuShortcuts", () => {
expect(enabled).toHaveBeenCalledTimes(1);
});
+ it("matches a shift shortcut via event.code", () => {
+ const action = vi.fn();
+ const { getByTestId } = render(
+ ,
+ );
+ fireEvent.keyDown(getByTestId("popup"), {
+ key: "!",
+ code: "Digit1",
+ shiftKey: true,
+ });
+ expect(action).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not match a shift shortcut when shift is not held", () => {
+ const action = vi.fn();
+ const { getByTestId } = render(
+ ,
+ );
+ fireEvent.keyDown(getByTestId("popup"), {
+ key: "1",
+ code: "Digit1",
+ shiftKey: false,
+ });
+ expect(action).not.toHaveBeenCalled();
+ });
+
+ it("does not match a shift shortcut when the code differs", () => {
+ const action = vi.fn();
+ const { getByTestId } = render(
+ ,
+ );
+ fireEvent.keyDown(getByTestId("popup"), {
+ key: "!",
+ code: "Digit2",
+ shiftKey: true,
+ });
+ expect(action).not.toHaveBeenCalled();
+ });
+
it("ignores events whose default has already been prevented", () => {
const action = vi.fn();
function PrePrevented() {
diff --git a/app/_components/hotkeys/registry.ts b/app/_components/hotkeys/registry.ts
index bf5c910..213aa22 100644
--- a/app/_components/hotkeys/registry.ts
+++ b/app/_components/hotkeys/registry.ts
@@ -45,7 +45,8 @@ const SHORTCUTS: ShortcutEntry[] = [
{ id: "move.sideboard", keys: ["s"], label: "Sideboard zone", group: "Move card menu" },
{ id: "move.considering", keys: ["i"], label: "Considering zone", group: "Move card menu" },
{ id: "move.uncategorized", keys: ["0"], label: "Uncategorized", group: "Move card menu" },
- { id: "move.category", keys: ["1", "…", "9"], label: "Pick category", group: "Move card menu" },
+ { id: "move.category", keys: ["1", "…", "9"], label: "Toggle category", group: "Move card menu" },
+ { id: "move.category.promote", keys: ["⇧", "1", "…", "9"], label: "Make category primary", group: "Move card menu" },
{ id: "deck.bulkEdit", keys: ["b"], label: "Bulk edit", group: "Deck actions menu" },
{ id: "deck.export", keys: ["e"], label: "Export", group: "Deck actions menu" },
diff --git a/app/_components/hotkeys/use-menu-shortcuts.ts b/app/_components/hotkeys/use-menu-shortcuts.ts
index 1c2a190..44d79f0 100644
--- a/app/_components/hotkeys/use-menu-shortcuts.ts
+++ b/app/_components/hotkeys/use-menu-shortcuts.ts
@@ -5,6 +5,12 @@ import { useCallback } from "react";
interface MenuShortcut {
/** Single character matched against `event.key` (case-insensitive). */
key: string;
+ /**
+ * Require Shift. Digit keys emit layout-dependent characters when shifted
+ * ("!" for Shift+1), so shift bindings match `event.code` (`Digit`)
+ * instead of `event.key`.
+ */
+ shift?: boolean;
/** What to fire on press. */
action: () => void;
/** When true, the binding is silently skipped. */
@@ -21,9 +27,11 @@ export function useMenuShortcuts(shortcuts: MenuShortcut[]) {
(event: React.KeyboardEvent) => {
if (event.defaultPrevented) return;
const pressed = event.key.toLowerCase();
- const match = shortcuts.find(
- (s) => !s.disabled && s.key.toLowerCase() === pressed,
- );
+ const match = shortcuts.find((s) => {
+ if (s.disabled) return false;
+ if (s.shift) return event.shiftKey && event.code === `Digit${s.key}`;
+ return s.key.toLowerCase() === pressed;
+ });
if (!match) return;
event.preventDefault();
event.stopPropagation();
diff --git a/app/_components/stats/deck-stats.test.tsx b/app/_components/stats/deck-stats.test.tsx
index e063709..6f65ee9 100644
--- a/app/_components/stats/deck-stats.test.tsx
+++ b/app/_components/stats/deck-stats.test.tsx
@@ -18,13 +18,13 @@ function makeDeckCard(
cmc: number,
quantity: number,
opts: { typeLine?: string; category?: string | null; zone?: Zone } = {},
-): DeckCardWithRelations & { category: string | null; id: string } {
+): DeckCardWithRelations & { categories: string[]; id: string } {
_id += 1;
return {
id: `dc-${_id}`,
quantity,
zone: opts.zone ?? "MAINBOARD",
- category: opts.category ?? null,
+ categories: opts.category ? [opts.category] : [],
printing: null,
card: {
name: `Card ${_id}`,
@@ -35,7 +35,7 @@ function makeDeckCard(
cmc,
colors: [],
},
- } as unknown as DeckCardWithRelations & { category: string | null; id: string };
+ } as unknown as DeckCardWithRelations & { categories: string[]; id: string };
}
// A representative Commander-ish deck: spells across several types + 24 lands.
diff --git a/app/_components/stats/role-bar.test.tsx b/app/_components/stats/role-bar.test.tsx
new file mode 100644
index 0000000..832e0c7
--- /dev/null
+++ b/app/_components/stats/role-bar.test.tsx
@@ -0,0 +1,67 @@
+import { describe, expect, it } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { RoleBar } from "./role-bar";
+
+type RoleBarCards = Parameters[0]["cards"];
+
+function card(
+ name: string,
+ categories: string[],
+ quantity = 1,
+): RoleBarCards[number] {
+ return {
+ card: {
+ name,
+ mainType: "Creature",
+ colors: [],
+ cmc: 1,
+ },
+ printing: null,
+ categories,
+ quantity,
+ };
+}
+
+describe("RoleBar — category grouping", () => {
+ it("counts a multi-category card once, under its primary category", () => {
+ render(
+ ,
+ );
+
+ // Primary tallies the copy...
+ expect(screen.getByText("Ramp")).toBeInTheDocument();
+ expect(screen.getByTitle("Ramp: 1")).toBeInTheDocument();
+ // ...the secondary section holds only the ghost, nets to zero, and is
+ // dropped from the bar and legend entirely.
+ expect(screen.queryByText("Removal")).toBeNull();
+ expect(screen.queryByText("0")).toBeNull();
+ });
+
+ it("sums quantities for primary members alongside single-category cards", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByTitle("Ramp: 2")).toBeInTheDocument();
+ // Doom Blade's primary is removal; the Elves ghost adds nothing.
+ expect(screen.getByTitle("Removal: 1")).toBeInTheDocument();
+ });
+
+ it("shows the empty state when there are no cards", () => {
+ render( );
+ expect(
+ screen.getByText("Add cards to see distribution."),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/app/_components/stats/role-bar.tsx b/app/_components/stats/role-bar.tsx
index 6229beb..29bdaec 100644
--- a/app/_components/stats/role-bar.tsx
+++ b/app/_components/stats/role-bar.tsx
@@ -77,15 +77,19 @@ function segmentColor(group: GroupBy, key: string, index: number): string {
}
export function RoleBar({ cards, group, categoryOrder }: RoleBarProps) {
- const sections = groupCards(cards, group, categoryOrder).filter(
- (s) => s.cards.length > 0,
- );
-
- const counts = sections.map((s) => ({
- key: s.key,
- label: toTitleCase(s.label),
- count: s.cards.reduce((sum, dc) => sum + dc.quantity, 0),
- }));
+ // Secondary-membership fan-out copies are display-only; counting them
+ // would double-count multi-category cards. A section holding only ghosts
+ // nets to zero and drops out of the bar and legend entirely.
+ const counts = groupCards(cards, group, categoryOrder)
+ .map((s) => ({
+ key: s.key,
+ label: toTitleCase(s.label),
+ count: s.cards.reduce(
+ (sum, dc) => (dc.isSecondary ? sum : sum + dc.quantity),
+ 0,
+ ),
+ }))
+ .filter((s) => s.count > 0);
const total = counts.reduce((sum, s) => sum + s.count, 0);
if (total === 0) {
diff --git a/lib/card/__tests__/printing-heuristics.test.ts b/lib/card/__tests__/printing-heuristics.test.ts
index cdbe093..718fc4c 100644
--- a/lib/card/__tests__/printing-heuristics.test.ts
+++ b/lib/card/__tests__/printing-heuristics.test.ts
@@ -225,6 +225,18 @@ describe("selectPrintingId — no-universes-beyond", () => {
expect(selectPrintingId(ps, "no-universes-beyond", null, false)).toBe(2);
});
+ it("keeps the running lowest-id printing when a later unpriced candidate has a higher id", () => {
+ // The unpriced non-UB fallback's reduce needs a case where the *later*
+ // candidate does NOT beat the running minimum, so it exercises the
+ // "keep lo" branch rather than always swapping.
+ const ps = [
+ printing({ id: 1, setCode: "ltr", priceUsd: 5 }), // current pin, UB
+ printing({ id: 2, setCode: "war" }), // unpriced non-UB, lower id
+ printing({ id: 5, setCode: "dom" }), // unpriced non-UB, higher id
+ ];
+ expect(selectPrintingId(ps, "no-universes-beyond", 1, false)).toBe(2);
+ });
+
it("ranks non-UB candidates by the nonfoil basis, ignoring cheaper foil/etched", () => {
const ps = [
printing({ id: 1, setCode: "ltr", priceUsd: 5 }),
diff --git a/lib/deck/__tests__/editor-actions.test.ts b/lib/deck/__tests__/editor-actions.test.ts
index 38dd536..b8612c5 100644
--- a/lib/deck/__tests__/editor-actions.test.ts
+++ b/lib/deck/__tests__/editor-actions.test.ts
@@ -76,23 +76,42 @@ beforeEach(() => {
// ---------------------------------------------------------------------------
describe("addCardToDeck", () => {
- it("forwards an add op with the requested zone, category, and quantity", async () => {
+ it("forwards an add op with the requested zone, categories, and quantity", async () => {
asOwner();
- await addCardToDeck(DECK_ID, 42, { quantity: 3, category: "Ramp" });
+ await addCardToDeck(DECK_ID, 42, { quantity: 3, categories: ["Ramp"] });
+ // Category names are normalized (trimmed, lowercased) at the boundary.
expect(changesPassedToApply()).toEqual([
{
op: "add",
cardId: 42,
quantity: 3,
zone: Zone.MAINBOARD,
- category: "Ramp",
+ categories: ["ramp"],
},
]);
});
- it("defaults to MAINBOARD/null category and quantity 1", async () => {
+ it("dedupes and drops empty category names", async () => {
+ asOwner();
+
+ await addCardToDeck(DECK_ID, 42, {
+ categories: ["Ramp", " ramp ", "", "Rocks"],
+ });
+
+ expect(changesPassedToApply()).toEqual([
+ {
+ op: "add",
+ cardId: 42,
+ quantity: 1,
+ zone: Zone.MAINBOARD,
+ categories: ["ramp", "rocks"],
+ },
+ ]);
+ });
+
+ it("defaults to MAINBOARD/no categories and quantity 1", async () => {
asOwner();
await addCardToDeck(DECK_ID, 42);
@@ -103,7 +122,7 @@ describe("addCardToDeck", () => {
cardId: 42,
quantity: 1,
zone: Zone.MAINBOARD,
- category: null,
+ categories: [],
},
]);
});
@@ -130,7 +149,7 @@ describe("addCardToDeck", () => {
asOwner();
await expect(
- addCardToDeck(DECK_ID, 42, { zone: Zone.SIDEBOARD, category: "Ramp" }),
+ addCardToDeck(DECK_ID, 42, { zone: Zone.SIDEBOARD, categories: ["Ramp"] }),
).rejects.toThrow("Subcategories only apply to MAINBOARD cards");
expect(mockApply).not.toHaveBeenCalled();
});
@@ -157,39 +176,39 @@ describe("addCardsToDeck", () => {
]);
expect(changesPassedToApply()).toEqual([
- { op: "add", cardId: 1, quantity: 2, zone: Zone.MAINBOARD, category: null },
- { op: "add", cardId: 2, quantity: 1, zone: Zone.MAINBOARD, category: null },
+ { op: "add", cardId: 1, quantity: 2, zone: Zone.MAINBOARD, categories: [] },
+ { op: "add", cardId: 2, quantity: 1, zone: Zone.MAINBOARD, categories: [] },
]);
});
- it("per-card zone/category win over the shared opts fallback", async () => {
+ it("per-card zone/categories win over the shared opts fallback", async () => {
asOwner();
await addCardsToDeck(
DECK_ID,
[
{ cardId: 1, zone: Zone.SIDEBOARD },
- { cardId: 2, category: "Ramp" },
+ { cardId: 2, categories: ["Ramp"] },
],
{ zone: Zone.MAINBOARD },
);
expect(changesPassedToApply()).toEqual([
- { op: "add", cardId: 1, quantity: 1, zone: Zone.SIDEBOARD, category: null },
- { op: "add", cardId: 2, quantity: 1, zone: Zone.MAINBOARD, category: "Ramp" },
+ { op: "add", cardId: 1, quantity: 1, zone: Zone.SIDEBOARD, categories: [] },
+ { op: "add", cardId: 2, quantity: 1, zone: Zone.MAINBOARD, categories: ["ramp"] },
]);
});
- it("falls back to opts zone/category when a card omits them", async () => {
+ it("falls back to opts zone/categories when a card omits them", async () => {
asOwner();
await addCardsToDeck(DECK_ID, [{ cardId: 7 }], {
zone: Zone.MAINBOARD,
- category: "Lands",
+ categories: ["Lands"],
});
expect(changesPassedToApply()).toEqual([
- { op: "add", cardId: 7, quantity: 1, zone: Zone.MAINBOARD, category: "Lands" },
+ { op: "add", cardId: 7, quantity: 1, zone: Zone.MAINBOARD, categories: ["lands"] },
]);
});
@@ -197,7 +216,9 @@ describe("addCardsToDeck", () => {
asOwner();
await expect(
- addCardsToDeck(DECK_ID, [{ cardId: 1, zone: Zone.SIDEBOARD, category: "Ramp" }]),
+ addCardsToDeck(DECK_ID, [
+ { cardId: 1, zone: Zone.SIDEBOARD, categories: ["Ramp"] },
+ ]),
).rejects.toThrow("Subcategories only apply to MAINBOARD cards");
expect(mockApply).not.toHaveBeenCalled();
});
@@ -301,7 +322,7 @@ describe("bulkUpdateDeck", () => {
asOwner();
const changes: PlannedChange[] = [
- { op: "add", cardId: 99, quantity: 1, zone: Zone.MAINBOARD, category: null },
+ { op: "add", cardId: 99, quantity: 1, zone: Zone.MAINBOARD, categories: [] },
{ op: "remove", deckCardId: "dc-1" },
{ op: "update", deckCardId: "dc-2", quantity: 4 },
];
@@ -311,31 +332,26 @@ describe("bulkUpdateDeck", () => {
expect(mockApply).toHaveBeenCalledWith(DECK_ID, USER_ID, changes);
});
- // Invariant hard-block is currently disabled in `applyChanges`; re-enable
- // this test alongside the gate in lib/deck/mutation/apply.ts.
- // it("propagates InvariantViolation (no longer silently allows singleton breaches)", async () => {
- // asOwner();
- // mockApply.mockRejectedValueOnce(
- // new InvariantViolation([
- // {
- // code: "singleton_violation",
- // message: "Sol Ring: Singleton format — 2 copies in deck",
- // },
- // ]),
- // );
- // await expect(
- // bulkUpdateDeck(DECK_ID, [
- // { op: "add", cardId: 7, quantity: 2, zone: Zone.MAINBOARD, category: null },
- // ]),
- // ).rejects.toBeInstanceOf(InvariantViolation);
- // });
+ it("propagates InvariantViolation (no longer silently allows singleton breaches)", async () => {
+ asOwner();
+ mockApply.mockRejectedValueOnce(
+ new InvariantViolation([
+ { kind: "singleton_violation", cardName: "Sol Ring", quantity: 2 },
+ ]),
+ );
+ await expect(
+ bulkUpdateDeck(DECK_ID, [
+ { op: "add", cardId: 7, quantity: 2, zone: Zone.MAINBOARD, categories: [] },
+ ]),
+ ).rejects.toBeInstanceOf(InvariantViolation);
+ });
it("404s for non-owners", async () => {
asOutsider();
await expect(
bulkUpdateDeck(DECK_ID, [
- { op: "add", cardId: 1, quantity: 1, zone: Zone.MAINBOARD, category: null },
+ { op: "add", cardId: 1, quantity: 1, zone: Zone.MAINBOARD, categories: [] },
]),
).rejects.toThrow("NEXT_NOT_FOUND");
expect(mockApply).not.toHaveBeenCalled();
diff --git a/lib/deck/__tests__/external-fetch.test.ts b/lib/deck/__tests__/external-fetch.test.ts
index 53141c2..22c451b 100644
--- a/lib/deck/__tests__/external-fetch.test.ts
+++ b/lib/deck/__tests__/external-fetch.test.ts
@@ -145,7 +145,7 @@ describe("buildComparableDeckFromText", () => {
quantity: 1,
isFoil: false,
zone: "MAINBOARD" as never,
- category: null,
+ categories: [] as string[],
};
const resolvedCard = {
diff --git a/lib/deck/__tests__/group-deltas.test.ts b/lib/deck/__tests__/group-deltas.test.ts
index 47bd43b..a337388 100644
--- a/lib/deck/__tests__/group-deltas.test.ts
+++ b/lib/deck/__tests__/group-deltas.test.ts
@@ -7,13 +7,13 @@ function delta(
cardId: number,
cardName: string,
d: number,
- opts: { zone?: Zone; category?: string | null } = {},
+ opts: { zone?: Zone; categories?: string[] } = {},
): RevisionDelta {
return {
cardId,
cardName,
zone: opts.zone ?? Zone.MAINBOARD,
- category: opts.category ?? null,
+ categories: opts.categories ?? [],
delta: d,
};
}
diff --git a/lib/deck/__tests__/group-sort.test.ts b/lib/deck/__tests__/group-sort.test.ts
index 3d66c7a..294be22 100644
--- a/lib/deck/__tests__/group-sort.test.ts
+++ b/lib/deck/__tests__/group-sort.test.ts
@@ -20,7 +20,7 @@ function makeCard(overrides: Partial = {}): TestCard {
cmc: 0,
},
printing: null,
- category: null,
+ categories: [],
...overrides,
};
}
@@ -67,10 +67,10 @@ describe("groupCards", () => {
describe("category", () => {
it("orders by categoryOrder, then extras, then uncategorized", () => {
const cards = [
- makeCard({ id: "a", category: "Ramp" }),
- makeCard({ id: "b", category: null }),
- makeCard({ id: "c", category: "Draw" }),
- makeCard({ id: "d", category: "Kill" }),
+ makeCard({ id: "a", categories: ["Ramp"] }),
+ makeCard({ id: "b", categories: [] }),
+ makeCard({ id: "c", categories: ["Draw"] }),
+ makeCard({ id: "d", categories: ["Kill"] }),
];
const sections = groupCards(cards, "category", ["Ramp", "Draw"]);
expect(sections.map((s) => s.key)).toEqual([
@@ -85,7 +85,7 @@ describe("groupCards", () => {
it("includes empty user categories", () => {
const sections = groupCards(
- [makeCard({ id: "a", category: null })],
+ [makeCard({ id: "a", categories: [] })],
"category",
["Ramp"],
);
@@ -98,12 +98,46 @@ describe("groupCards", () => {
it("omits uncategorized section when empty", () => {
const sections = groupCards(
- [makeCard({ id: "a", category: "Ramp" })],
+ [makeCard({ id: "a", categories: ["Ramp"] })],
"category",
["Ramp"],
);
expect(sections.map((s) => s.key)).toEqual(["Ramp"]);
});
+
+ it("fans a multi-category card out into every member section", () => {
+ const cards = [makeCard({ id: "a", categories: ["ramp", "removal"] })];
+ const sections = groupCards(cards, "category", ["ramp", "removal"]);
+
+ const ramp = sections.find((s) => s.key === "ramp")!;
+ const removal = sections.find((s) => s.key === "removal")!;
+ expect(ramp.cards.map((c) => c.id)).toEqual(["a"]);
+ expect(removal.cards.map((c) => c.id)).toEqual(["a"]);
+ });
+
+ it("marks only non-primary fan-out entries as secondary", () => {
+ const cards = [makeCard({ id: "a", categories: ["ramp", "removal"] })];
+ const sections = groupCards(cards, "category", ["ramp", "removal"]);
+
+ const rampEntry = sections.find((s) => s.key === "ramp")!.cards[0]!;
+ const removalEntry = sections.find((s) => s.key === "removal")!.cards[0]!;
+ expect(rampEntry.isSecondary).toBeUndefined();
+ expect(removalEntry.isSecondary).toBe(true);
+ });
+
+ it("puts a zero-membership card only in Uncategorized, never as secondary", () => {
+ const cards = [
+ makeCard({ id: "a", categories: [] }),
+ makeCard({ id: "b", categories: ["ramp", "removal"] }),
+ ];
+ const sections = groupCards(cards, "category", ["ramp", "removal"]);
+
+ const uncategorized = sections.find(
+ (s) => s.key === "__uncategorized__",
+ )!;
+ expect(uncategorized.cards.map((c) => c.id)).toEqual(["a"]);
+ expect(uncategorized.cards[0]!.isSecondary).toBeUndefined();
+ });
});
describe("type", () => {
diff --git a/lib/deck/__tests__/queries.test.ts b/lib/deck/__tests__/queries.test.ts
index 58d6c48..2eaa053 100644
--- a/lib/deck/__tests__/queries.test.ts
+++ b/lib/deck/__tests__/queries.test.ts
@@ -812,6 +812,10 @@ describe("getDeckById", () => {
cards: [
{
id: "dc-1",
+ categoryLinks: [
+ { deckCategory: { name: "Ramp" } },
+ { deckCategory: { name: "Artifacts" } },
+ ],
printing: {
priceUsd: decimal(1.5),
priceUsdFoil: decimal(3.25),
@@ -821,6 +825,7 @@ describe("getDeckById", () => {
},
{
id: "dc-2",
+ categoryLinks: [],
printing: null,
},
],
@@ -836,6 +841,17 @@ describe("getDeckById", () => {
expect(printing.priceEur).toBe(1.1);
expect(printing.priceEurFoil).toBeNull();
+ // Category links flatten to ordered membership names; [0] is the primary.
+ // The select orders by position asc, so gapped positions (a cascade-
+ // deleted primary) still promote the next membership.
+ expect(result!.cards[0]!.categories).toEqual(["Ramp", "Artifacts"]);
+ const cardsArg = mockFindUnique.mock.calls[0]![0]!.select!.cards as {
+ select: { categoryLinks: unknown };
+ };
+ expect(cardsArg.select.categoryLinks).toMatchObject({
+ orderBy: { position: "asc" },
+ });
+ expect(result!.cards[1]!.categories).toEqual([]);
// Null printings pass through untouched.
expect(result!.cards[1]!.printing).toBeNull();
});
@@ -851,6 +867,7 @@ describe("getDeckById", () => {
cards: [
{
id: "dc-1",
+ categoryLinks: [],
card: {
id: 7,
name: "Lightning Bolt",
@@ -899,6 +916,7 @@ describe("getDeckById", () => {
cards: [
{
id: "dc-1",
+ categoryLinks: [],
printing: {
priceUsd: decimal(1.5),
priceUsdFoil: decimal(3.25),
@@ -908,6 +926,7 @@ describe("getDeckById", () => {
},
{
id: "dc-2",
+ categoryLinks: [],
printing: {
priceUsd: null,
priceUsdFoil: null,
diff --git a/lib/deck/__tests__/revision.test.ts b/lib/deck/__tests__/revision.test.ts
index 2c6ded6..65f96f7 100644
--- a/lib/deck/__tests__/revision.test.ts
+++ b/lib/deck/__tests__/revision.test.ts
@@ -5,6 +5,8 @@ import {
deltasToBulkChanges,
invertDeltas,
mergeDeltas,
+ parseRevisionDeltas,
+ squashDeltas,
summarizeDeltas,
type RevisionDelta,
} from "@/lib/deck/revision";
@@ -14,31 +16,111 @@ function delta(
cardId: number,
cardName: string,
d: number,
- opts: { zone?: Zone; category?: string | null } = {},
+ opts: {
+ zone?: Zone;
+ categories?: string[];
+ previousCategories?: string[];
+ } = {},
): RevisionDelta {
return {
cardId,
cardName,
zone: opts.zone ?? Zone.MAINBOARD,
- category: opts.category ?? null,
+ categories: opts.categories ?? [],
+ ...(opts.previousCategories !== undefined
+ ? { previousCategories: opts.previousCategories }
+ : {}),
delta: d,
};
}
describe("deltaKey", () => {
- it("composes cardId|zone|category", () => {
- expect(deltaKey({ cardId: 1, zone: Zone.MAINBOARD, category: null })).toBe(
- "1|MAINBOARD|",
+ it("composes cardId|zone", () => {
+ expect(deltaKey({ cardId: 1, zone: Zone.MAINBOARD })).toBe("1|MAINBOARD");
+ expect(deltaKey({ cardId: 1, zone: Zone.SIDEBOARD })).toBe("1|SIDEBOARD");
+ });
+
+ it("distinguishes zones but not categories", () => {
+ expect(deltaKey({ cardId: 1, zone: Zone.MAINBOARD })).not.toBe(
+ deltaKey({ cardId: 1, zone: Zone.SIDEBOARD }),
);
- expect(
- deltaKey({ cardId: 1, zone: Zone.MAINBOARD, category: "Ramp" }),
- ).toBe("1|MAINBOARD|Ramp");
+ });
+});
+
+describe("parseRevisionDeltas", () => {
+ it("parses modern-shape payloads as-is", () => {
+ const parsed = parseRevisionDeltas([
+ {
+ cardId: 1,
+ cardName: "Sol Ring",
+ zone: Zone.MAINBOARD,
+ categories: ["ramp", "rocks"],
+ delta: 1,
+ },
+ ]);
+ expect(parsed).toEqual([
+ expect.objectContaining({ cardId: 1, categories: ["ramp", "rocks"] }),
+ ]);
+ });
+
+ it("normalizes legacy single-category payloads to categories arrays", () => {
+ const legacyPayload = [
+ {
+ cardId: 1,
+ cardName: "Sol Ring",
+ zone: Zone.MAINBOARD,
+ category: "ramp",
+ delta: 1,
+ },
+ {
+ cardId: 2,
+ cardName: "Counterspell",
+ zone: Zone.MAINBOARD,
+ category: null,
+ delta: -1,
+ },
+ ];
+ expect(parseRevisionDeltas(legacyPayload)).toEqual([
+ {
+ cardId: 1,
+ cardName: "Sol Ring",
+ zone: Zone.MAINBOARD,
+ categories: ["ramp"],
+ delta: 1,
+ },
+ {
+ cardId: 2,
+ cardName: "Counterspell",
+ zone: Zone.MAINBOARD,
+ categories: [],
+ delta: -1,
+ },
+ ]);
});
- it("distinguishes zone and category", () => {
- expect(
- deltaKey({ cardId: 1, zone: Zone.MAINBOARD, category: null }),
- ).not.toBe(deltaKey({ cardId: 1, zone: Zone.SIDEBOARD, category: null }));
+ it("parses modern payloads with previousCategories", () => {
+ const parsed = parseRevisionDeltas([
+ {
+ cardId: 1,
+ cardName: "Sol Ring",
+ zone: Zone.MAINBOARD,
+ categories: ["rocks"],
+ previousCategories: ["ramp"],
+ delta: 0,
+ },
+ ]);
+ expect(parsed).toEqual([
+ expect.objectContaining({
+ categories: ["rocks"],
+ previousCategories: ["ramp"],
+ delta: 0,
+ }),
+ ]);
+ });
+
+ it("returns [] for malformed payloads", () => {
+ expect(parseRevisionDeltas([{ bad: "data" }])).toEqual([]);
+ expect(parseRevisionDeltas("not an array")).toEqual([]);
});
});
@@ -86,12 +168,105 @@ describe("mergeDeltas", () => {
expect(merged).toHaveLength(2);
});
- it("treats different categories as distinct keys", () => {
+ it("merges across categories — same card+zone nets out regardless of membership", () => {
const merged = mergeDeltas(
- [delta(1, "Forest", 1, { category: "Ramp" })],
- [delta(1, "Forest", -1, { category: null })],
+ [delta(1, "Forest", 1, { categories: ["Ramp"] })],
+ [delta(1, "Forest", -1, { categories: [] })],
+ );
+ expect(merged).toEqual([]);
+ });
+
+ it("takes the incoming side's categories when merging", () => {
+ const merged = mergeDeltas(
+ [delta(1, "Forest", 1, { categories: ["Ramp"] })],
+ [delta(1, "Forest", 1, { categories: ["Rocks"] })],
+ );
+ expect(merged).toEqual([
+ expect.objectContaining({ delta: 2, categories: ["Rocks"] }),
+ ]);
+ });
+
+ it("keeps zero-delta recategorization entries", () => {
+ const merged = mergeDeltas(
+ [],
+ [
+ delta(1, "Sol Ring", 0, {
+ categories: ["Rocks"],
+ previousCategories: ["Ramp"],
+ }),
+ ],
+ );
+ expect(merged).toEqual([
+ expect.objectContaining({
+ delta: 0,
+ categories: ["Rocks"],
+ previousCategories: ["Ramp"],
+ }),
+ ]);
+ });
+
+ it("keeps the earliest previousCategories across merged recategorizations", () => {
+ const merged = mergeDeltas(
+ [
+ delta(1, "Sol Ring", 0, {
+ categories: ["Rocks"],
+ previousCategories: ["Ramp"],
+ }),
+ ],
+ [
+ delta(1, "Sol Ring", 0, {
+ categories: ["Removal"],
+ previousCategories: ["Rocks"],
+ }),
+ ],
+ );
+ expect(merged).toEqual([
+ expect.objectContaining({
+ delta: 0,
+ categories: ["Removal"],
+ previousCategories: ["Ramp"],
+ }),
+ ]);
+ });
+
+ it("drops a recategorization that nets back to the original memberships", () => {
+ const merged = mergeDeltas(
+ [
+ delta(1, "Sol Ring", 0, {
+ categories: ["Rocks"],
+ previousCategories: ["Ramp"],
+ }),
+ ],
+ [
+ delta(1, "Sol Ring", 0, {
+ categories: ["Ramp"],
+ previousCategories: ["Rocks"],
+ }),
+ ],
+ );
+ expect(merged).toEqual([]);
+ });
+});
+
+describe("squashDeltas", () => {
+ it("nets repeated (cardId, zone) keys from legacy per-category payloads", () => {
+ const squashed = squashDeltas([
+ delta(1, "Sol Ring", 1, { categories: ["Rocks"] }),
+ delta(1, "Sol Ring", 1, { categories: ["Ramp"] }),
+ ]);
+ expect(squashed).toEqual([
+ expect.objectContaining({ cardId: 1, delta: 2 }),
+ ]);
+ });
+
+ it("leaves already-net deltas unchanged", () => {
+ const deltas = [delta(1, "Forest", 1), delta(2, "Island", -1)];
+ expect(squashDeltas(deltas)).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ cardId: 1, delta: 1 }),
+ expect.objectContaining({ cardId: 2, delta: -1 }),
+ ]),
);
- expect(merged).toHaveLength(2);
});
});
@@ -131,41 +306,83 @@ describe("invertDeltas", () => {
expect.objectContaining({ cardId: 2, delta: 3 }),
]);
});
+
+ it("swaps categories and previousCategories on recategorization entries", () => {
+ const inverted = invertDeltas([
+ delta(1, "Sol Ring", 0, {
+ categories: ["Rocks"],
+ previousCategories: ["Ramp"],
+ }),
+ ]);
+ expect(inverted).toEqual([
+ expect.objectContaining({
+ delta: 0,
+ categories: ["Ramp"],
+ previousCategories: ["Rocks"],
+ }),
+ ]);
+ });
});
describe("deltasToBulkChanges", () => {
+ const NO_CATEGORIES: ReadonlySet = new Set();
+
const existing: ExistingDeckCard[] = [
{
deckCardId: "dc1",
cardId: 1,
zone: Zone.MAINBOARD,
- category: null,
quantity: 2,
},
];
it("emits add when positive delta targets a missing row", () => {
- const changes = deltasToBulkChanges([delta(42, "Forest", 2)], []);
+ const changes = deltasToBulkChanges([delta(42, "Forest", 2)], [], NO_CATEGORIES);
expect(changes).toEqual([
{
op: "add",
cardId: 42,
quantity: 2,
zone: Zone.MAINBOARD,
- category: null,
+ categories: [],
+ },
+ ]);
+ });
+
+ it("restores memberships on add, filtered to categories that still exist", () => {
+ const changes = deltasToBulkChanges(
+ [delta(42, "Forest", 2, { categories: ["Ramp", "Gone"] })],
+ [],
+ new Set(["Ramp"]),
+ );
+ expect(changes).toEqual([
+ {
+ op: "add",
+ cardId: 42,
+ quantity: 2,
+ zone: Zone.MAINBOARD,
+ categories: ["Ramp"],
},
]);
});
it("emits update when positive delta targets an existing row", () => {
- const changes = deltasToBulkChanges([delta(1, "Forest", 1)], existing);
+ const changes = deltasToBulkChanges(
+ [delta(1, "Forest", 1)],
+ existing,
+ NO_CATEGORIES,
+ );
expect(changes).toEqual([
{ op: "update", deckCardId: "dc1", quantity: 3 },
]);
});
it("emits remove when negative delta zeroes out the quantity", () => {
- const changes = deltasToBulkChanges([delta(1, "Forest", -2)], existing);
+ const changes = deltasToBulkChanges(
+ [delta(1, "Forest", -2)],
+ existing,
+ NO_CATEGORIES,
+ );
expect(changes).toEqual([{ op: "remove", deckCardId: "dc1" }]);
});
@@ -173,32 +390,132 @@ describe("deltasToBulkChanges", () => {
const existingThree: ExistingDeckCard[] = [
{ ...existing[0]!, quantity: 3 },
];
- const changes = deltasToBulkChanges([delta(1, "Forest", -1)], existingThree);
+ const changes = deltasToBulkChanges(
+ [delta(1, "Forest", -1)],
+ existingThree,
+ NO_CATEGORIES,
+ );
expect(changes).toEqual([
{ op: "update", deckCardId: "dc1", quantity: 2 },
]);
});
- it("skips zero-delta entries", () => {
- const changes = deltasToBulkChanges([delta(1, "Forest", 0)], existing);
+ it("skips zero-delta entries with no membership change", () => {
+ const changes = deltasToBulkChanges(
+ [delta(1, "Forest", 0)],
+ existing,
+ NO_CATEGORIES,
+ );
+ expect(changes).toEqual([]);
+ });
+
+ it("emits setCategories for a zero-delta recategorization entry", () => {
+ const changes = deltasToBulkChanges(
+ [
+ delta(1, "Sol Ring", 0, {
+ categories: ["ramp", "gone"],
+ previousCategories: ["rocks"],
+ }),
+ ],
+ existing,
+ new Set(["ramp", "rocks"]),
+ );
+ expect(changes).toEqual([
+ {
+ op: "setCategories",
+ cardId: 1,
+ zone: Zone.MAINBOARD,
+ categories: ["ramp"],
+ },
+ ]);
+ });
+
+ it("skips a zero-delta recategorization against a missing row", () => {
+ const changes = deltasToBulkChanges(
+ [
+ delta(999, "Gone", 0, {
+ categories: ["ramp"],
+ previousCategories: ["rocks"],
+ }),
+ ],
+ existing,
+ new Set(["ramp"]),
+ );
expect(changes).toEqual([]);
});
+ it("restores memberships alongside a quantity update", () => {
+ const changes = deltasToBulkChanges(
+ [
+ delta(1, "Sol Ring", 1, {
+ categories: ["ramp"],
+ previousCategories: ["rocks"],
+ }),
+ ],
+ existing,
+ new Set(["ramp", "rocks"]),
+ );
+ expect(changes).toEqual([
+ { op: "update", deckCardId: "dc1", quantity: 3 },
+ {
+ op: "setCategories",
+ cardId: 1,
+ zone: Zone.MAINBOARD,
+ categories: ["ramp"],
+ },
+ ]);
+ });
+
it("caps negative deltas at current quantity — removes instead of throwing", () => {
- const changes = deltasToBulkChanges([delta(1, "Forest", -99)], existing);
+ const changes = deltasToBulkChanges(
+ [delta(1, "Forest", -99)],
+ existing,
+ NO_CATEGORIES,
+ );
expect(changes).toEqual([{ op: "remove", deckCardId: "dc1" }]);
});
it("silently drops negative deltas against already-missing rows", () => {
- const changes = deltasToBulkChanges([delta(999, "Gone", -1)], existing);
+ const changes = deltasToBulkChanges(
+ [delta(999, "Gone", -1)],
+ existing,
+ NO_CATEGORIES,
+ );
expect(changes).toEqual([]);
});
- it("matches rows by zone and category", () => {
+ it("matches rows by zone", () => {
const changes = deltasToBulkChanges(
[delta(1, "Forest", -1, { zone: Zone.SIDEBOARD })],
existing,
+ NO_CATEGORIES,
);
expect(changes).toEqual([]);
});
+
+ it("nets a legacy recategorization pair to zero changes", () => {
+ // Pre-multi-category recategorizations were stored as a +1/-1 pair on the
+ // same card+zone with different `category` values. Parsed and merged under
+ // the modern (cardId, zone) key, they cancel to nothing.
+ const legacyPair = parseRevisionDeltas([
+ {
+ cardId: 1,
+ cardName: "Sol Ring",
+ zone: Zone.MAINBOARD,
+ category: "ramp",
+ delta: 1,
+ },
+ {
+ cardId: 1,
+ cardName: "Sol Ring",
+ zone: Zone.MAINBOARD,
+ category: "removal",
+ delta: -1,
+ },
+ ]);
+ expect(legacyPair).toHaveLength(2);
+
+ const changes = deltasToBulkChanges(legacyPair, existing, new Set(["ramp"]));
+ expect(changes).toEqual([]);
+ });
});
diff --git a/lib/deck/__tests__/shuffle.test.ts b/lib/deck/__tests__/shuffle.test.ts
index a922050..1993802 100644
--- a/lib/deck/__tests__/shuffle.test.ts
+++ b/lib/deck/__tests__/shuffle.test.ts
@@ -25,10 +25,14 @@ function makeCard(overrides: Partial & { id: number; name: string }): Card
} as Card;
}
-type TestDeckCard = DeckCard & { card: Card; printing: Printing | null };
+type TestDeckCard = DeckCard & {
+ card: Card;
+ printing: Printing | null;
+ categories: string[];
+};
function makeDeckCard(
- overrides: Partial & {
+ overrides: Partial & {
id: string;
cardId: number;
quantity: number;
@@ -40,7 +44,7 @@ function makeDeckCard(
deckId: "deck-1",
printingId: null,
isFoil: false,
- category: null,
+ categories: [],
createdAt: new Date(),
updatedAt: new Date(),
printing: null,
@@ -145,20 +149,20 @@ describe("expandQuantities", () => {
expect(expandQuantities(dc)).toEqual([]);
});
- it("preserves subcategory strings on Mainboard cards", () => {
+ it("preserves category memberships on Mainboard cards", () => {
const dc = [
makeDeckCard({
id: "a",
cardId: 1,
quantity: 2,
zone: "MAINBOARD",
- category: "Ramp",
+ categories: ["Ramp", "Artifacts"],
card: bolt,
}),
];
const result = expandQuantities(dc);
expect(result).toHaveLength(2);
- expect(result[0]!.category).toBe("Ramp");
+ expect(result[0]!.categories).toEqual(["Ramp", "Artifacts"]);
});
});
diff --git a/lib/deck/__tests__/zone-view.test.ts b/lib/deck/__tests__/zone-view.test.ts
index 8fae55f..db30b89 100644
--- a/lib/deck/__tests__/zone-view.test.ts
+++ b/lib/deck/__tests__/zone-view.test.ts
@@ -6,7 +6,7 @@ function makeCard(overrides: Partial = {}): DeckCard {
return {
id: "dc-1",
zone: "MAINBOARD",
- category: null,
+ categories: [],
quantity: 1,
isFoil: false,
card: {
@@ -56,15 +56,17 @@ describe("applyZoneOptimistic", () => {
});
describe('action "move"', () => {
- it("updates zone and category on the matching card", () => {
- const cards = [makeCard({ id: "dc-1", zone: "MAINBOARD", category: "Ramp" })];
+ it("updates zone and categories on the matching card", () => {
+ const cards = [
+ makeCard({ id: "dc-1", zone: "MAINBOARD", categories: ["Ramp"] }),
+ ];
const result = applyZoneOptimistic(cards, {
type: "move",
deckCardId: "dc-1",
zone: "SIDEBOARD",
- category: null,
+ categories: [],
});
- expect(result[0]).toMatchObject({ zone: "SIDEBOARD", category: null });
+ expect(result[0]).toMatchObject({ zone: "SIDEBOARD", categories: [] });
});
it("leaves other cards untouched", () => {
@@ -73,7 +75,7 @@ describe("applyZoneOptimistic", () => {
type: "move",
deckCardId: "dc-1",
zone: "SIDEBOARD",
- category: null,
+ categories: [],
});
expect(result[1]!.zone).toBe("MAINBOARD");
});
diff --git a/lib/deck/category-registry.ts b/lib/deck/category-registry.ts
new file mode 100644
index 0000000..cdde7b7
--- /dev/null
+++ b/lib/deck/category-registry.ts
@@ -0,0 +1,30 @@
+import type { Prisma } from "@/lib/generated/prisma/client";
+
+/**
+ * Create any missing `DeckCategory` rows for `names`, appended after the
+ * deck's current max sortOrder in the order given. Existing names are left
+ * untouched. Runs against the caller's transaction so registry creation
+ * commits or rolls back with the writes that depend on it.
+ */
+export async function ensureDeckCategories(
+ tx: Prisma.TransactionClient,
+ deckId: string,
+ names: readonly string[],
+): Promise {
+ if (names.length === 0) return;
+
+ const existing = await tx.deckCategory.findMany({
+ where: { deckId },
+ select: { name: true, sortOrder: true },
+ });
+ const known = new Set(existing.map((c) => c.name));
+ const missing = [...new Set(names)].filter((name) => !known.has(name));
+ if (missing.length === 0) return;
+
+ let nextOrder =
+ existing.reduce((max, c) => Math.max(max, c.sortOrder), -1) + 1;
+ await tx.deckCategory.createMany({
+ data: missing.map((name) => ({ deckId, name, sortOrder: nextOrder++ })),
+ skipDuplicates: true,
+ });
+}
diff --git a/lib/deck/constants.ts b/lib/deck/constants.ts
index adcdef9..519de92 100644
--- a/lib/deck/constants.ts
+++ b/lib/deck/constants.ts
@@ -8,3 +8,11 @@ export const CATEGORY_NAME_MAX = 50;
export const IMPORT_TEXT_MAX = 100_000;
export type CategoryDeleteMode = "uncategorize" | "deleteCards";
+
+/**
+ * Canonical category-name normalization applied at every write boundary.
+ * Registry rows are stored in this form, so membership names must be
+ * normalized before comparison or persistence.
+ */
+export const normalizeCategory = (name: string): string =>
+ name.trim().toLowerCase();
diff --git a/lib/deck/editor-actions.ts b/lib/deck/editor-actions.ts
index e6be8d4..69501bc 100644
--- a/lib/deck/editor-actions.ts
+++ b/lib/deck/editor-actions.ts
@@ -1,6 +1,7 @@
"use server";
import { Zone } from "@/lib/generated/prisma/client";
+import { normalizeCategory } from "@/lib/deck/constants";
import {
applyChanges,
InvariantViolation,
@@ -10,25 +11,36 @@ import {
export type BulkChange = PlannedChange;
+/** Normalize and dedupe caller-supplied membership names, dropping empties. */
+function normalizeCategories(raw: readonly string[]): string[] {
+ const out: string[] = [];
+ for (const name of raw) {
+ const normalized = normalizeCategory(name);
+ if (normalized.length === 0) continue;
+ if (!out.includes(normalized)) out.push(normalized);
+ }
+ return out;
+}
+
export const addCardToDeck = runOwnerDeckMutation(
"deck.addCard",
"none",
async (
{ deckId, userId },
cardId: number,
- opts?: { quantity?: number; zone?: Zone; category?: string | null },
+ opts?: { quantity?: number; zone?: Zone; categories?: string[] },
): Promise => {
const quantity = opts?.quantity ?? 1;
const zone = opts?.zone ?? Zone.MAINBOARD;
- const category = opts?.category ?? null;
+ const categories = normalizeCategories(opts?.categories ?? []);
- if (category !== null && zone !== Zone.MAINBOARD) {
+ if (categories.length > 0 && zone !== Zone.MAINBOARD) {
throw new Error("Subcategories only apply to MAINBOARD cards");
}
try {
await applyChanges(deckId, userId, [
- { op: "add", cardId, quantity, zone, category },
+ { op: "add", cardId, quantity, zone, categories },
]);
} catch (err) {
if (err instanceof InvariantViolation) return;
@@ -42,16 +54,16 @@ export const addCardsToDeck = runOwnerDeckMutation(
"none",
async (
{ deckId, userId },
- cards: { cardId: number; quantity?: number; zone?: Zone; category?: string | null }[],
- opts?: { zone?: Zone; category?: string | null },
+ cards: { cardId: number; quantity?: number; zone?: Zone; categories?: string[] }[],
+ opts?: { zone?: Zone; categories?: string[] },
): Promise => {
const changes = cards.map((c) => {
const zone = c.zone ?? opts?.zone ?? Zone.MAINBOARD;
- const category = c.category ?? opts?.category ?? null;
- if (category !== null && zone !== Zone.MAINBOARD) {
+ const categories = normalizeCategories(c.categories ?? opts?.categories ?? []);
+ if (categories.length > 0 && zone !== Zone.MAINBOARD) {
throw new Error("Subcategories only apply to MAINBOARD cards");
}
- return { op: "add" as const, cardId: c.cardId, quantity: c.quantity ?? 1, zone, category };
+ return { op: "add" as const, cardId: c.cardId, quantity: c.quantity ?? 1, zone, categories };
});
try {
diff --git a/lib/deck/group-deltas.ts b/lib/deck/group-deltas.ts
index a3f8e4a..05e1529 100644
--- a/lib/deck/group-deltas.ts
+++ b/lib/deck/group-deltas.ts
@@ -26,7 +26,8 @@ export function groupDeltasByZone(
}
return ZONE_ORDER.filter((z) => byZone.has(z)).map((zone) => ({
zone,
- deltas: (byZone.get(zone) ?? [])
+ deltas: byZone
+ .get(zone)!
.slice()
.sort((a, b) => {
const signDiff = Math.sign(b.delta) - Math.sign(a.delta);
diff --git a/lib/deck/group-sort.ts b/lib/deck/group-sort.ts
index cba6cc5..83af2cc 100644
--- a/lib/deck/group-sort.ts
+++ b/lib/deck/group-sort.ts
@@ -39,8 +39,15 @@ export type GroupSortCard = {
priceUsdFoil?: number | null;
rarity: string | null;
} | null;
- category: string | null;
+ /** Ordered category memberships; `[0]` is the primary. */
+ categories: string[];
isFoil?: boolean;
+ /**
+ * Set by `groupByCategory` on fan-out copies: the card appears in this
+ * section through a non-primary membership. Secondary entries render
+ * ghosted, are excluded from section counts, and are not draggable.
+ */
+ isSecondary?: boolean;
};
interface GroupedSection {
@@ -235,9 +242,16 @@ function groupByCategory(
for (const name of categoryOrder) map.set(name, []);
map.set(UNCATEGORIZED_KEY, []);
for (const dc of cards) {
- const key = dc.category ?? UNCATEGORIZED_KEY;
- if (!map.has(key)) map.set(key, []);
- map.get(key)!.push(dc);
+ // Fan out: the card appears in every member section — in full under its
+ // primary (categories[0]), ghosted under each secondary membership.
+ // Zero memberships land in Uncategorized.
+ const memberships =
+ dc.categories.length === 0 ? [UNCATEGORIZED_KEY] : dc.categories;
+ for (const key of memberships) {
+ if (!map.has(key)) map.set(key, []);
+ const isSecondary = key !== memberships[0];
+ map.get(key)!.push(isSecondary ? { ...dc, isSecondary: true } : dc);
+ }
}
const sections: GroupedSection[] = [];
for (const name of categoryOrder) {
diff --git a/lib/deck/io/__tests__/card-resolver.test.ts b/lib/deck/io/__tests__/card-resolver.test.ts
index 5fdb7fc..3f8bbb4 100644
--- a/lib/deck/io/__tests__/card-resolver.test.ts
+++ b/lib/deck/io/__tests__/card-resolver.test.ts
@@ -23,7 +23,7 @@ function parsed(name: string, overrides: Partial = {}): ParsedCard {
quantity: 1,
isFoil: false,
zone: Zone.MAINBOARD,
- category: null,
+ categories: [],
...overrides,
};
}
diff --git a/lib/deck/io/__tests__/intake.test.ts b/lib/deck/io/__tests__/intake.test.ts
index 94a9d49..509555d 100644
--- a/lib/deck/io/__tests__/intake.test.ts
+++ b/lib/deck/io/__tests__/intake.test.ts
@@ -7,6 +7,8 @@ vi.mock("@/lib/db", () => ({
card: { findMany: vi.fn() },
printing: { findMany: vi.fn() },
deckCard: { findMany: vi.fn() },
+ deckCategory: { findMany: vi.fn(), createMany: vi.fn() },
+ $transaction: vi.fn(),
},
}));
@@ -28,13 +30,30 @@ import { MAX_CARD_LINES } from "../consts";
const mockCardFindMany = vi.mocked(prisma.card.findMany);
const mockPrintingFindMany = vi.mocked(prisma.printing.findMany);
const mockDeckCardFindMany = vi.mocked(prisma.deckCard.findMany);
+const mockCategoryFindMany = vi.mocked(prisma.deckCategory.findMany);
+const mockCategoryCreateMany = vi.mocked(prisma.deckCategory.createMany);
+const mockTransaction = vi.mocked(prisma.$transaction);
const mockApplyChanges = vi.mocked(applyChanges);
beforeEach(() => {
vi.clearAllMocks();
mockPrintingFindMany.mockResolvedValue([] as never);
mockDeckCardFindMany.mockResolvedValue([] as never);
+ mockCategoryFindMany.mockResolvedValue([] as never);
+ mockCategoryCreateMany.mockResolvedValue({ count: 0 } as never);
mockApplyChanges.mockResolvedValue(undefined);
+ // ensureCategories + applyChanges run inside one interactive transaction,
+ // backed by the same mocks here.
+ mockTransaction.mockImplementation(async (fn: unknown) =>
+ typeof fn === "function"
+ ? fn({
+ deckCategory: {
+ findMany: mockCategoryFindMany,
+ createMany: mockCategoryCreateMany,
+ },
+ })
+ : undefined,
+ );
});
describe("intakeDecklist — append mode", () => {
@@ -61,7 +80,7 @@ describe("intakeDecklist — append mode", () => {
zone: Zone.MAINBOARD,
}),
],
- undefined,
+ expect.objectContaining({ tx: expect.anything() }),
);
expect(result.added).toBe(1);
expect(result.applied).toBe(1);
@@ -153,7 +172,7 @@ describe("intakeDecklist — append mode", () => {
"deck-1",
"user-1",
expect.any(Array),
- { skipRevision: true },
+ expect.objectContaining({ skipRevision: true, tx: expect.anything() }),
);
});
@@ -194,6 +213,108 @@ describe("intakeDecklist — append mode", () => {
});
});
+describe("intakeDecklist — category registry", () => {
+ const jsonWithCategories = JSON.stringify({
+ name: "Deck",
+ format: "COMMANDER",
+ visibility: "PRIVATE",
+ description: null,
+ cards: [
+ {
+ name: "Sol Ring",
+ quantity: 1,
+ zone: "MAINBOARD",
+ isFoil: false,
+ categories: ["ramp"],
+ },
+ ],
+ categories: [
+ { name: "ramp", sortOrder: 0 },
+ { name: "empty-bucket", sortOrder: 1 },
+ ],
+ });
+
+ it("creates registry rows inside the apply transaction, including empty categories in export order", async () => {
+ mockCardFindMany.mockResolvedValueOnce([
+ { id: 1, name: "Sol Ring" },
+ ] as never);
+
+ await intakeDecklist({
+ deckId: "deck-1",
+ userId: "user-1",
+ text: jsonWithCategories,
+ mode: "append",
+ });
+
+ expect(mockTransaction).toHaveBeenCalledTimes(1);
+ expect(mockCategoryCreateMany).toHaveBeenCalledWith({
+ data: [
+ { deckId: "deck-1", name: "ramp", sortOrder: 0 },
+ { deckId: "deck-1", name: "empty-bucket", sortOrder: 1 },
+ ],
+ skipDuplicates: true,
+ });
+ expect(mockApplyChanges).toHaveBeenCalledWith(
+ "deck-1",
+ "user-1",
+ [expect.objectContaining({ op: "add", categories: ["ramp"] })],
+ expect.objectContaining({ tx: expect.anything() }),
+ );
+ });
+
+ it("shares the transaction with applyChanges so a failed batch leaves no phantom categories", async () => {
+ mockCardFindMany.mockResolvedValueOnce([
+ { id: 1, name: "Sol Ring" },
+ ] as never);
+ mockApplyChanges.mockRejectedValueOnce(
+ new InvariantViolation([{ kind: "deck_size", expected: 100, actual: 1 }]),
+ );
+
+ const result = await intakeDecklist({
+ deckId: "deck-1",
+ userId: "user-1",
+ text: jsonWithCategories,
+ mode: "append",
+ });
+
+ // Registry creation ran inside the same transaction the failure aborts,
+ // so a real client rolls the phantom rows back.
+ expect(mockTransaction).toHaveBeenCalledTimes(1);
+ expect(mockCategoryCreateMany).toHaveBeenCalledTimes(1);
+ expect(result.applied).toBe(0);
+ expect(result.warnings).toContain(
+ "Deck must have exactly 100 cards (currently 1)",
+ );
+ });
+
+ it("replace mode carries imported categories onto add ops", async () => {
+ mockCardFindMany.mockResolvedValueOnce([
+ { id: 1, name: "Sol Ring" },
+ ] as never);
+
+ await intakeDecklist({
+ deckId: "deck-1",
+ userId: "user-1",
+ text: jsonWithCategories,
+ mode: "replace",
+ });
+
+ expect(mockApplyChanges).toHaveBeenCalledWith(
+ "deck-1",
+ "user-1",
+ [
+ expect.objectContaining({
+ op: "add",
+ cardId: 1,
+ zone: Zone.MAINBOARD,
+ categories: ["ramp"],
+ }),
+ ],
+ expect.objectContaining({ tx: expect.anything() }),
+ );
+ });
+});
+
describe("intakeDecklist — line cap", () => {
it("truncates to MAX_CARD_LINES and pushes a warning when input exceeds the limit", async () => {
// Build a 5000-line input of unique card names so they all pass through parse.
@@ -231,7 +352,7 @@ describe("intakeDecklist — replace mode", () => {
id: "dc-1",
cardId: 1,
zone: Zone.MAINBOARD,
- category: null,
+ categoryLinks: [],
quantity: 4,
},
] as never);
@@ -263,14 +384,14 @@ describe("intakeDecklist — replace mode", () => {
id: "dc-1",
cardId: 1,
zone: Zone.MAINBOARD,
- category: null,
+ categoryLinks: [],
quantity: 4,
},
{
id: "dc-2",
cardId: 99,
zone: Zone.MAINBOARD,
- category: null,
+ categoryLinks: [],
quantity: 1,
},
] as never);
@@ -294,7 +415,7 @@ describe("intakeDecklist — replace mode", () => {
id: "dc-1",
cardId: 1,
zone: Zone.MAINBOARD,
- category: null,
+ categoryLinks: [],
quantity: 4,
},
] as never);
diff --git a/lib/deck/io/__tests__/parse.test.ts b/lib/deck/io/__tests__/parse.test.ts
index 7eab8af..3affe3d 100644
--- a/lib/deck/io/__tests__/parse.test.ts
+++ b/lib/deck/io/__tests__/parse.test.ts
@@ -23,7 +23,7 @@ describe("parseDecklist — section detection", () => {
it("defaults to MAINBOARD zone when no section header is present", () => {
const { cards } = autoParse("4 Lightning Bolt");
expect(cards[0]!.zone).toBe("MAINBOARD");
- expect(cards[0]!.category).toBeNull();
+ expect(cards[0]!.categories).toEqual([]);
});
it("assigns SIDEBOARD zone after //Sideboard header", () => {
@@ -63,7 +63,7 @@ describe("parseDecklist — section detection", () => {
const input = ["Commander:", "1 Atraxa, Praetors' Voice"].join("\n");
const { cards } = autoParse(input);
expect(cards[0]!.zone).toBe("COMMANDER");
- expect(cards[0]!.category).toBeNull();
+ expect(cards[0]!.categories).toEqual([]);
});
it("re-uses the last set zone across multiple cards", () => {
@@ -80,10 +80,10 @@ describe("parseDecklist — section detection", () => {
expect(cards.filter((c) => c.zone === "SIDEBOARD")).toHaveLength(2);
});
- it("always emits category: null on parse (subcategories not serialized in text)", () => {
+ it("always emits categories: [] on parse (subcategories not serialized in text)", () => {
const { cards } = autoParse("4 Lightning Bolt\n//Sideboard\n2 Duress");
for (const c of cards) {
- expect(c.category).toBeNull();
+ expect(c.categories).toEqual([]);
}
});
});
diff --git a/lib/deck/io/__tests__/resolve.test.ts b/lib/deck/io/__tests__/resolve.test.ts
index a0b04a6..9148333 100644
--- a/lib/deck/io/__tests__/resolve.test.ts
+++ b/lib/deck/io/__tests__/resolve.test.ts
@@ -25,7 +25,7 @@ function parsed(name: string, overrides: Partial = {}): ParsedCard {
quantity: 1,
isFoil: false,
zone: Zone.MAINBOARD,
- category: null,
+ categories: [],
...overrides,
};
}
diff --git a/lib/deck/io/__tests__/serialize.test.ts b/lib/deck/io/__tests__/serialize.test.ts
index 19b4614..dd80914 100644
--- a/lib/deck/io/__tests__/serialize.test.ts
+++ b/lib/deck/io/__tests__/serialize.test.ts
@@ -6,104 +6,59 @@ import {
stripCommentHeaders,
} from "../serialize";
import { detectFormat, parseDecklist } from "../parse";
-import type {
- Deck,
- DeckCard,
- Card,
- DeckCategory,
- Zone,
-} from "@/lib/generated/prisma/client";
+import type { Zone } from "@/lib/generated/prisma/client";
import type { SerializedPrinting } from "@/lib/deck/queries";
+import type { DeckCardWithDetails, DeckWithCards } from "../adapters/types";
type Printing = SerializedPrinting;
-function makeCard(overrides: Partial & { id: number; name: string }): Card {
- return {
- nameSlug: null,
- mainType: "Instant" as Card["mainType"],
- typeLine: null,
- oracleText: null,
- manaCost: null,
- cmc: null,
- colors: [],
- colorIdentity: [],
- keywords: [],
- power: null,
- toughness: null,
- games: [],
- legalities: {},
- reserved: false,
- gameChanger: false,
- version: null,
- updatedAt: new Date(),
- ...overrides,
- };
+function makeCard(overrides: { id: number; name: string }): { name: string } {
+ return { name: overrides.name };
}
-type DeckCardInput = Partial & {
+type DeckCardInput = {
id: string;
deckId: string;
cardId: number;
- card: Card;
+ card: { name: string };
printing?: Printing | null;
+ printingId?: number | null;
zone?: Zone;
+ quantity?: number;
+ isFoil?: boolean;
+ /** Ordered memberships; `[0]` is the primary. */
+ categories?: string[];
};
-function makeDeckCard(
- overrides: DeckCardInput,
-): DeckCard & { card: Card; printing: Printing | null } {
- const { printing = null, ...rest } = overrides;
- return {
- quantity: 1,
- zone: "MAINBOARD",
- category: null,
- printingId: null,
- isFoil: false,
- createdAt: new Date(),
- updatedAt: new Date(),
- ...rest,
- printing,
- } as DeckCard & { card: Card; printing: Printing | null };
+function makeDeckCard(overrides: DeckCardInput): DeckCardWithDetails {
+ const {
+ card,
+ printing = null,
+ printingId = null,
+ zone = "MAINBOARD",
+ quantity = 1,
+ isFoil = false,
+ categories = [],
+ } = overrides;
+ return { quantity, zone, categories, isFoil, printingId, card, printing };
}
function makeCategory(
name: string,
sortOrder: number,
- deckId = "deck1",
-): DeckCategory {
- return {
- id: `cat-${name}`,
- deckId,
- name,
- sortOrder,
- createdAt: new Date(),
- } as DeckCategory;
+): { name: string; sortOrder: number } {
+ return { name, sortOrder };
}
function makeDeck(
- cards: (DeckCard & { card: Card; printing: Printing | null })[],
- categories: DeckCategory[] = [],
-): Deck & {
- cards: (DeckCard & { card: Card; printing: Printing | null })[];
- categories: DeckCategory[];
-} {
+ cards: DeckCardWithDetails[],
+ categories: { name: string; sortOrder: number }[] = [],
+): DeckWithCards {
return {
- id: "deck1",
- userId: "user1",
name: "Test Deck",
+ format: "COMMANDER",
+ visibility: "PRIVATE",
description: null,
- format: "COMMANDER" as Deck["format"],
- visibility: "PRIVATE" as Deck["visibility"],
- kind: "DECK" as Deck["kind"],
- collaborationEnabled: false,
- manualBracket: null,
- forkedFromId: null,
- externalSource: null,
- externalId: null,
- externalVersion: null,
- releasedAt: null,
- createdAt: new Date(),
- updatedAt: new Date(),
cards,
categories,
};
@@ -194,7 +149,7 @@ describe("toPlainText", () => {
card: boltCard,
quantity: 4,
zone: "MAINBOARD",
- category: "Burn",
+ categories: ["Burn"],
}),
makeDeckCard({
id: "dc2",
@@ -203,7 +158,7 @@ describe("toPlainText", () => {
card: solRingCard,
quantity: 1,
zone: "MAINBOARD",
- category: "Ramp",
+ categories: ["Ramp"],
}),
],
[makeCategory("Ramp", 0), makeCategory("Burn", 1)],
@@ -226,7 +181,7 @@ describe("toPlainText", () => {
card: boltCard,
quantity: 4,
zone: "MAINBOARD",
- category: "Burn",
+ categories: ["Burn"],
}),
makeDeckCard({
id: "dc2",
@@ -235,7 +190,7 @@ describe("toPlainText", () => {
card: solRingCard,
quantity: 1,
zone: "MAINBOARD",
- category: null,
+ categories: [],
}),
],
[makeCategory("Burn", 0)],
@@ -247,6 +202,42 @@ describe("toPlainText", () => {
expect(lines).toContain("1 Sol Ring");
});
+ it("emits a multi-category card once, under its primary category only", () => {
+ const deck = makeDeck(
+ [
+ makeDeckCard({
+ id: "dc1",
+ deckId: "deck1",
+ cardId: 2,
+ card: solRingCard,
+ quantity: 1,
+ zone: "MAINBOARD",
+ categories: ["Ramp", "Burn"],
+ }),
+ makeDeckCard({
+ id: "dc2",
+ deckId: "deck1",
+ cardId: 1,
+ card: boltCard,
+ quantity: 4,
+ zone: "MAINBOARD",
+ categories: ["Burn"],
+ }),
+ ],
+ [makeCategory("Ramp", 0), makeCategory("Burn", 1)],
+ );
+ const lines = toPlainText(deck).split("\n");
+
+ // Sol Ring appears exactly once, in the Ramp (primary) section.
+ const solRingLines = lines.filter((l) => l === "1 Sol Ring");
+ expect(solRingLines).toHaveLength(1);
+ const rampIdx = lines.indexOf("// Ramp");
+ const burnIdx = lines.indexOf("// Burn");
+ const solRingIdx = lines.indexOf("1 Sol Ring");
+ expect(solRingIdx).toBeGreaterThan(rampIdx);
+ expect(solRingIdx).toBeLessThan(burnIdx);
+ });
+
it("emits Mainboard flat when no cards have subcategories", () => {
const deck = makeDeck([
makeDeckCard({
@@ -256,7 +247,7 @@ describe("toPlainText", () => {
card: boltCard,
quantity: 4,
zone: "MAINBOARD",
- category: null,
+ categories: [],
}),
]);
const result = toPlainText(deck);
@@ -265,6 +256,65 @@ describe("toPlainText", () => {
});
});
+describe("composed round-trip — multi-category cards", () => {
+ const deck = makeDeck(
+ [
+ makeDeckCard({
+ id: "dc1",
+ deckId: "deck1",
+ cardId: 2,
+ card: solRingCard,
+ quantity: 1,
+ categories: ["ramp", "rocks"],
+ }),
+ makeDeckCard({
+ id: "dc2",
+ deckId: "deck1",
+ cardId: 3,
+ card: duressCard,
+ quantity: 2,
+ zone: "SIDEBOARD",
+ }),
+ ],
+ [makeCategory("ramp", 0), makeCategory("rocks", 1)],
+ );
+
+ it("text: a multi-category card serializes once (under its primary) and re-imports as one row with its original quantity", () => {
+ const text = toPlainText(deck);
+ // One line only — a per-membership line would double the quantity on
+ // re-import.
+ expect(text.match(/Sol Ring/g)).toHaveLength(1);
+
+ const parsed = parseDecklist(text, detectFormat(text));
+ const solRing = parsed.cards.filter((c) => c.name === "Sol Ring");
+ expect(solRing).toHaveLength(1);
+ expect(solRing[0]).toMatchObject({
+ quantity: 1,
+ zone: "MAINBOARD",
+ // Text is a lossy format: memberships (including the primary) drop on
+ // re-import; only the JSON round-trip is lossless.
+ categories: [],
+ });
+ expect(
+ parsed.cards.find((c) => c.name === "Duress"),
+ ).toMatchObject({ quantity: 2, zone: "SIDEBOARD" });
+ });
+
+ it("arena: a multi-category card serializes once and re-imports as one row with its original quantity", () => {
+ const text = toArena(deck);
+ expect(text.match(/Sol Ring/g)).toHaveLength(1);
+
+ const parsed = parseDecklist(text, detectFormat(text));
+ const solRing = parsed.cards.filter((c) => c.name === "Sol Ring");
+ expect(solRing).toHaveLength(1);
+ expect(solRing[0]).toMatchObject({
+ quantity: 1,
+ zone: "MAINBOARD",
+ categories: [],
+ });
+ });
+});
+
describe("stripCommentHeaders", () => {
it("removes zone headers", () => {
const input = [
@@ -330,7 +380,7 @@ describe("stripCommentHeaders", () => {
card: boltCard,
quantity: 4,
zone: "MAINBOARD",
- category: "Burn",
+ categories: ["Burn"],
}),
makeDeckCard({
id: "dc2",
@@ -339,7 +389,7 @@ describe("stripCommentHeaders", () => {
card: solRingCard,
quantity: 1,
zone: "MAINBOARD",
- category: "Ramp",
+ categories: ["Ramp"],
}),
],
[makeCategory("Ramp", 0), makeCategory("Burn", 1)],
@@ -645,7 +695,7 @@ describe("toMaindeckJson", () => {
]);
});
- it("emits zone + nullable category per card", () => {
+ it("emits zone + ordered categories per card", () => {
const deck = makeDeck([
makeDeckCard({
id: "dc1",
@@ -654,7 +704,7 @@ describe("toMaindeckJson", () => {
card: boltCard,
quantity: 4,
zone: "MAINBOARD",
- category: "Burn",
+ categories: ["Burn", "Removal"],
}),
makeDeckCard({
id: "dc2",
@@ -663,7 +713,7 @@ describe("toMaindeckJson", () => {
card: duressCard,
quantity: 2,
zone: "SIDEBOARD",
- category: null,
+ categories: [],
}),
]);
const parsed = JSON.parse(toMaindeckJson(deck));
@@ -674,8 +724,8 @@ describe("toMaindeckJson", () => {
(c: { name: string }) => c.name === "Duress",
);
expect(bolt.zone).toBe("MAINBOARD");
- expect(bolt.category).toBe("Burn");
+ expect(bolt.categories).toEqual(["Burn", "Removal"]);
expect(duress.zone).toBe("SIDEBOARD");
- expect(duress.category).toBeNull();
+ expect(duress.categories).toEqual([]);
});
});
diff --git a/lib/deck/io/adapters/__tests__/json.test.ts b/lib/deck/io/adapters/__tests__/json.test.ts
index b1f2e1f..e07f909 100644
--- a/lib/deck/io/adapters/__tests__/json.test.ts
+++ b/lib/deck/io/adapters/__tests__/json.test.ts
@@ -14,7 +14,7 @@ function makeDeck(overrides: Partial = {}): DeckWithCards {
{
quantity: 1,
zone: Zone.COMMANDER,
- category: null,
+ categories: [],
isFoil: false,
printingId: null,
card: { name: "Atraxa, Praetors' Voice" },
@@ -23,7 +23,7 @@ function makeDeck(overrides: Partial = {}): DeckWithCards {
{
quantity: 4,
zone: Zone.MAINBOARD,
- category: "Ramp",
+ categories: ["ramp"],
isFoil: true,
printingId: 999,
card: { name: "Sol Ring" },
@@ -32,7 +32,7 @@ function makeDeck(overrides: Partial = {}): DeckWithCards {
{
quantity: 2,
zone: Zone.SIDEBOARD,
- category: null,
+ categories: [],
isFoil: false,
printingId: null,
card: { name: "Duress" },
@@ -41,14 +41,14 @@ function makeDeck(overrides: Partial = {}): DeckWithCards {
{
quantity: 1,
zone: Zone.CONSIDERING,
- category: null,
+ categories: [],
isFoil: false,
printingId: null,
card: { name: "Brainstorm" },
printing: null,
},
],
- categories: [{ name: "Ramp", sortOrder: 0 }],
+ categories: [{ name: "ramp", sortOrder: 0 }],
...overrides,
};
}
@@ -82,14 +82,14 @@ describe("jsonAdapter.parse — round-trip fidelity", () => {
quantity: 1,
zone: Zone.COMMANDER,
isFoil: false,
- category: null,
+ categories: [],
});
const solRing = result.cards.find((c) => c.name === "Sol Ring");
expect(solRing).toMatchObject({
quantity: 4,
zone: Zone.MAINBOARD,
- category: "Ramp",
+ categories: ["ramp"],
isFoil: true,
set: "C21",
collectorNumber: "263",
@@ -101,6 +101,206 @@ describe("jsonAdapter.parse — round-trip fidelity", () => {
const considering = result.cards.find((c) => c.zone === Zone.CONSIDERING);
expect(considering).toMatchObject({ name: "Brainstorm", quantity: 1 });
});
+
+ it("round-trips a multi-category card preserving membership order ([0] = primary)", () => {
+ const deck = makeDeck({
+ cards: [
+ {
+ quantity: 1,
+ zone: Zone.MAINBOARD,
+ categories: ["ramp", "artifacts", "win-cons"],
+ isFoil: false,
+ printingId: null,
+ card: { name: "Sol Ring" },
+ printing: null,
+ },
+ ],
+ categories: [
+ { name: "ramp", sortOrder: 0 },
+ { name: "artifacts", sortOrder: 1 },
+ { name: "win-cons", sortOrder: 2 },
+ ],
+ });
+
+ const result = jsonAdapter.parse(toMaindeckJson(deck));
+
+ expect(result.warnings).toHaveLength(0);
+ expect(result.cards).toHaveLength(1);
+ expect(result.cards[0]).toMatchObject({
+ name: "Sol Ring",
+ categories: ["ramp", "artifacts", "win-cons"],
+ });
+ });
+});
+
+describe("jsonAdapter.parse — non-MAINBOARD categories", () => {
+ it("drops categories on a sideboard card with a warning instead of failing the batch", () => {
+ const result = jsonAdapter.parse(
+ JSON.stringify({
+ name: "Deck",
+ format: "COMMANDER",
+ visibility: "PRIVATE",
+ description: null,
+ cards: [
+ {
+ name: "Duress",
+ quantity: 2,
+ zone: Zone.SIDEBOARD,
+ isFoil: false,
+ categories: ["discard"],
+ },
+ {
+ name: "Sol Ring",
+ quantity: 1,
+ zone: Zone.MAINBOARD,
+ isFoil: false,
+ categories: ["ramp"],
+ },
+ ],
+ categories: [{ name: "ramp", sortOrder: 0 }],
+ }),
+ );
+
+ expect(result.cards).toHaveLength(2);
+ expect(result.cards.find((c) => c.name === "Duress")).toMatchObject({
+ zone: Zone.SIDEBOARD,
+ categories: [],
+ });
+ expect(result.cards.find((c) => c.name === "Sol Ring")).toMatchObject({
+ categories: ["ramp"],
+ });
+ expect(result.warnings).toEqual([
+ `Ignored categories on "Duress": SIDEBOARD cards can't have categories`,
+ ]);
+ });
+});
+
+describe("jsonAdapter.parse — category registry", () => {
+ it("carries the export's registry through, normalized and in sortOrder order", () => {
+ const result = jsonAdapter.parse(
+ JSON.stringify({
+ name: "Deck",
+ format: "COMMANDER",
+ visibility: "PRIVATE",
+ description: null,
+ cards: [],
+ categories: [
+ { name: " Removal ", sortOrder: 2 },
+ { name: "Ramp", sortOrder: 0 },
+ { name: "empty-bucket", sortOrder: 1 },
+ { name: "ramp", sortOrder: 3 }, // dupe post-normalization
+ { name: " ", sortOrder: 4 }, // blank → dropped
+ ],
+ }),
+ );
+
+ expect(result.categoryRegistry).toEqual([
+ { name: "ramp", sortOrder: 0 },
+ { name: "empty-bucket", sortOrder: 1 },
+ { name: "removal", sortOrder: 2 },
+ ]);
+ });
+
+ it("caps category names at CATEGORY_NAME_MAX via schema validation", () => {
+ const result = jsonAdapter.parse(
+ JSON.stringify({
+ name: "Deck",
+ format: "COMMANDER",
+ visibility: "PRIVATE",
+ description: null,
+ cards: [
+ {
+ name: "Sol Ring",
+ quantity: 1,
+ zone: Zone.MAINBOARD,
+ isFoil: false,
+ categories: ["x".repeat(51)],
+ },
+ ],
+ categories: [],
+ }),
+ );
+
+ expect(result.cards).toHaveLength(0);
+ expect(result.warnings[0]).toMatch(/failed validation/);
+ });
+});
+
+describe("jsonAdapter.parse — category normalization", () => {
+ it("lowercases, trims, and dedupes category names preserving first-seen order", () => {
+ const result = jsonAdapter.parse(
+ JSON.stringify({
+ name: "Deck",
+ format: "commander",
+ visibility: "PRIVATE",
+ description: null,
+ cards: [
+ {
+ name: "Sol Ring",
+ quantity: 1,
+ zone: Zone.MAINBOARD,
+ categories: [" Ramp ", "ramp", "Artifacts", ""],
+ isFoil: false,
+ },
+ ],
+ categories: [],
+ }),
+ );
+
+ expect(result.warnings).toHaveLength(0);
+ expect(result.cards[0]!.categories).toEqual(["ramp", "artifacts"]);
+ });
+});
+
+describe("jsonAdapter.parse — legacy single-category payloads", () => {
+ it("parses a legacy card with category string into a one-element categories array", () => {
+ const result = jsonAdapter.parse(
+ JSON.stringify({
+ name: "Deck",
+ format: "commander",
+ visibility: "PRIVATE",
+ description: null,
+ cards: [
+ {
+ name: "Sol Ring",
+ quantity: 1,
+ zone: Zone.MAINBOARD,
+ category: "Ramp",
+ isFoil: false,
+ },
+ ],
+ categories: [{ name: "ramp", sortOrder: 0 }],
+ }),
+ );
+
+ expect(result.warnings).toHaveLength(0);
+ expect(result.cards).toHaveLength(1);
+ expect(result.cards[0]!.categories).toEqual(["ramp"]);
+ });
+
+ it("parses a legacy card with category null into an empty categories array", () => {
+ const result = jsonAdapter.parse(
+ JSON.stringify({
+ name: "Deck",
+ format: "commander",
+ visibility: "PRIVATE",
+ description: null,
+ cards: [
+ {
+ name: "Duress",
+ quantity: 2,
+ zone: Zone.SIDEBOARD,
+ category: null,
+ isFoil: false,
+ },
+ ],
+ categories: [],
+ }),
+ );
+
+ expect(result.warnings).toHaveLength(0);
+ expect(result.cards[0]!.categories).toEqual([]);
+ });
});
describe("jsonAdapter.parse — error handling", () => {
@@ -136,7 +336,7 @@ describe("jsonAdapter.parse — error handling", () => {
name: "Sol Ring",
quantity: 999999,
zone: Zone.MAINBOARD,
- category: null,
+ categories: [],
isFoil: false,
},
],
diff --git a/lib/deck/io/adapters/_shared.ts b/lib/deck/io/adapters/_shared.ts
index cc9a10a..15980d4 100644
--- a/lib/deck/io/adapters/_shared.ts
+++ b/lib/deck/io/adapters/_shared.ts
@@ -52,7 +52,7 @@ export function parseLineBased(
const parsed = parseDeckList(line);
if (parsed.length > 0) {
for (const card of parsed) {
- cards.push({ ...card, zone: currentZone, category: null });
+ cards.push({ ...card, zone: currentZone, categories: [] });
}
continue;
}
@@ -114,8 +114,11 @@ export function groupBySubcategory(
let hasAny = false;
for (const dc of cards) {
- const key = dc.category ?? uncategorizedKey;
- if (dc.category) hasAny = true;
+ // Text-style exports group by the primary membership only — fanning a
+ // card into every member section would corrupt quantities on re-import.
+ const primary = dc.categories[0];
+ const key = primary ?? uncategorizedKey;
+ if (primary) hasAny = true;
if (!grouped.has(key)) {
grouped.set(key, []);
ordered.push(key);
diff --git a/lib/deck/io/adapters/dek.ts b/lib/deck/io/adapters/dek.ts
index d9dae0b..fc43bbd 100644
--- a/lib/deck/io/adapters/dek.ts
+++ b/lib/deck/io/adapters/dek.ts
@@ -29,7 +29,7 @@ function parse(input: string): ParsedDecklist {
quantity,
isFoil: false,
zone: sideboard === "true" ? Zone.SIDEBOARD : Zone.MAINBOARD,
- category: null,
+ categories: [],
});
}
diff --git a/lib/deck/io/adapters/json.ts b/lib/deck/io/adapters/json.ts
index c19fffe..ec091b0 100644
--- a/lib/deck/io/adapters/json.ts
+++ b/lib/deck/io/adapters/json.ts
@@ -1,27 +1,60 @@
import { z } from "zod";
import { Zone } from "@/lib/generated/prisma/enums";
+import { CATEGORY_NAME_MAX, normalizeCategory } from "@/lib/deck/constants";
import type { ParsedCard, ParsedDecklist } from "../parse";
import { MAX_CARD_QTY } from "../consts";
import type { DecklistParser } from "./types";
-const JsonCardSchema = z.object({
+/** Generous per-card membership cap — bounds payloads, not UX. */
+const MAX_CARD_CATEGORIES = 20;
+/** Registry cap, matching `reorderCategoriesSchema`'s bound. */
+const MAX_REGISTRY_CATEGORIES = 200;
+
+const jsonCardBase = {
name: z.string(),
quantity: z.number().int().positive().max(MAX_CARD_QTY),
zone: z.nativeEnum(Zone),
- category: z.string().nullable(),
set: z.string().optional(),
collectorNumber: z.string().optional(),
isFoil: z.boolean(),
printingId: z.number().int().optional(),
+};
+
+const modernJsonCardSchema = z.object({
+ ...jsonCardBase,
+ /** Ordered category memberships; `[0]` is the primary. */
+ categories: z
+ .array(z.string().max(CATEGORY_NAME_MAX))
+ .max(MAX_CARD_CATEGORIES),
});
+/** Pre-multi-category exports carried a single nullable `category`. */
+const legacyJsonCardSchema = z
+ .object({
+ ...jsonCardBase,
+ category: z.string().max(CATEGORY_NAME_MAX).nullable(),
+ })
+ .transform(({ category, ...rest }) => ({
+ ...rest,
+ categories: category === null ? [] : [category],
+ }));
+
+const JsonCardSchema = z.union([modernJsonCardSchema, legacyJsonCardSchema]);
+
export const MaindeckJsonSchema = z.object({
name: z.string(),
format: z.string(),
visibility: z.string(),
description: z.string().nullable(),
cards: z.array(JsonCardSchema),
- categories: z.array(z.object({ name: z.string(), sortOrder: z.number() })),
+ categories: z
+ .array(
+ z.object({
+ name: z.string().max(CATEGORY_NAME_MAX),
+ sortOrder: z.number(),
+ }),
+ )
+ .max(MAX_REGISTRY_CATEGORIES),
});
export type MaindeckJson = z.infer;
@@ -50,17 +83,50 @@ function parse(input: string): ParsedDecklist {
return { format: "json", cards: [], unmatchedLines: [], warnings };
}
- const cards: ParsedCard[] = result.data.cards.map((c) => ({
- name: c.name,
- quantity: c.quantity,
- zone: c.zone,
- category: c.category,
- ...(c.set !== undefined && { set: c.set }),
- ...(c.collectorNumber !== undefined && { collectorNumber: c.collectorNumber }),
- isFoil: c.isFoil,
- }));
+ const cards: ParsedCard[] = result.data.cards.map((c) => {
+ // Normalize to the registry convention and dedupe, preserving order.
+ let categories: string[] = [];
+ for (const raw of c.categories) {
+ const name = normalizeCategory(raw);
+ if (name.length > 0 && !categories.includes(name)) {
+ categories.push(name);
+ }
+ }
+ // Categories are MAINBOARD-only; a hand-edited payload that puts them on
+ // another zone loses just the memberships, not the whole import.
+ if (categories.length > 0 && c.zone !== Zone.MAINBOARD) {
+ warnings.push(
+ `Ignored categories on "${c.name}": ${c.zone} cards can't have categories`,
+ );
+ categories = [];
+ }
+ return {
+ name: c.name,
+ quantity: c.quantity,
+ zone: c.zone,
+ categories,
+ ...(c.set !== undefined && { set: c.set }),
+ ...(c.collectorNumber !== undefined && {
+ collectorNumber: c.collectorNumber,
+ }),
+ isFoil: c.isFoil,
+ };
+ });
+
+ // Carry the export's registry through so a round-trip restores empty
+ // categories and sortOrder, not just the memberships in use.
+ const categoryRegistry: { name: string; sortOrder: number }[] = [];
+ const seen = new Set();
+ for (const c of [...result.data.categories].sort(
+ (a, b) => a.sortOrder - b.sortOrder,
+ )) {
+ const name = normalizeCategory(c.name);
+ if (name.length === 0 || seen.has(name)) continue;
+ seen.add(name);
+ categoryRegistry.push({ name, sortOrder: c.sortOrder });
+ }
- return { format: "json", cards, unmatchedLines: [], warnings };
+ return { format: "json", cards, unmatchedLines: [], warnings, categoryRegistry };
}
export const jsonAdapter: DecklistParser = {
diff --git a/lib/deck/io/adapters/types.ts b/lib/deck/io/adapters/types.ts
index 50cf5ce..bcba303 100644
--- a/lib/deck/io/adapters/types.ts
+++ b/lib/deck/io/adapters/types.ts
@@ -14,7 +14,8 @@ type SerializePrinting = {
export type DeckCardWithDetails = {
quantity: number;
zone: Zone;
- category: string | null;
+ /** Ordered category memberships; `[0]` is the primary. */
+ categories: string[];
isFoil: boolean;
printingId: number | null | undefined;
card: SerializeCard;
diff --git a/lib/deck/io/intake.ts b/lib/deck/io/intake.ts
index e305d88..e1b503b 100644
--- a/lib/deck/io/intake.ts
+++ b/lib/deck/io/intake.ts
@@ -1,6 +1,8 @@
import "server-only";
import { prisma } from "@/lib/db";
+import type { Prisma } from "@/lib/generated/prisma/client";
+import { ensureDeckCategories } from "@/lib/deck/category-registry";
import {
applyChanges,
diffDeck,
@@ -42,12 +44,50 @@ function asAdds(resolved: ResolvedDecklist): PlannedChange[] {
cardId: r.cardId,
quantity: r.parsed.quantity,
zone: r.parsed.zone,
- category: r.parsed.category,
+ categories: r.parsed.categories,
printingId: r.printingId,
isFoil: r.isFoil,
}));
}
+/**
+ * Register any imported category names missing from the deck's registry, so
+ * a JSON round-trip is lossless. Names are normalized to the registry
+ * convention (trimmed, lowercased). When the source carried its registry
+ * (JSON exports), missing rows are created in the export's sortOrder order —
+ * including empty categories — appended after the deck's existing entries;
+ * otherwise membership names are created alphabetically.
+ */
+async function ensureCategories(
+ tx: Prisma.TransactionClient,
+ deckId: string,
+ changes: readonly PlannedChange[],
+ registry?: readonly { name: string; sortOrder: number }[],
+): Promise {
+ const names = new Set();
+ for (const c of changes) {
+ if (c.op === "add" || c.op === "move" || c.op === "setCategories") {
+ for (const name of c.categories) names.add(name);
+ }
+ }
+
+ let ordered: string[];
+ if (registry !== undefined) {
+ const fromRegistry = [...registry]
+ .sort((a, b) => a.sortOrder - b.sortOrder)
+ .map((r) => r.name);
+ const carried = new Set(fromRegistry);
+ ordered = [
+ ...fromRegistry,
+ ...[...names].filter((name) => !carried.has(name)).sort(),
+ ];
+ } else {
+ ordered = [...names].sort();
+ }
+
+ await ensureDeckCategories(tx, deckId, ordered);
+}
+
async function buildReplaceChanges(
deckId: string,
resolved: ResolvedDecklist,
@@ -58,16 +98,16 @@ async function buildReplaceChanges(
id: true,
cardId: true,
zone: true,
- category: true,
quantity: true,
+ categoryLinks: { take: 1, select: { deckCardId: true } },
},
});
const existing: ExistingDeckCard[] = rows.map((e) => ({
deckCardId: e.id,
cardId: e.cardId,
zone: e.zone,
- category: e.category,
quantity: e.quantity,
+ hasCategories: e.categoryLinks.length > 0,
}));
return diffDeck(resolved.cards, existing);
}
@@ -125,7 +165,12 @@ export async function intakeDecklist(input: IntakeInput): Promise
}
try {
- await applyChanges(deckId, userId, changes, applyOptions);
+ // Registry creation and the card writes commit or roll back together —
+ // a batch that fails validation can't leave phantom categories behind.
+ await prisma.$transaction(async (tx) => {
+ await ensureCategories(tx, deckId, changes, parsed.categoryRegistry);
+ await applyChanges(deckId, userId, changes, { ...applyOptions, tx });
+ });
} catch (err) {
// InvariantViolation = legality/structural issues; surface as warnings, drop the batch.
if (err instanceof InvariantViolation) {
diff --git a/lib/deck/io/parse.ts b/lib/deck/io/parse.ts
index 9cfbaf6..843428e 100644
--- a/lib/deck/io/parse.ts
+++ b/lib/deck/io/parse.ts
@@ -9,7 +9,8 @@ export type ParsedCard = {
collectorNumber?: string;
isFoil: boolean;
zone: Zone;
- category: string | null;
+ /** Ordered category memberships; `[0]` is the primary. Normalized names. */
+ categories: string[];
};
export type ParsedDecklist = {
@@ -17,6 +18,12 @@ export type ParsedDecklist = {
cards: ParsedCard[];
unmatchedLines: string[];
warnings: string[];
+ /**
+ * The export's category registry (normalized names, export order), when the
+ * source format carries one (JSON). Lets intake restore empty categories
+ * and relative order instead of inferring the registry from memberships.
+ */
+ categoryRegistry?: { name: string; sortOrder: number }[];
};
export function detectFormat(input: string): AdapterId {
diff --git a/lib/deck/io/serialize.ts b/lib/deck/io/serialize.ts
index 69f6f30..2415557 100644
--- a/lib/deck/io/serialize.ts
+++ b/lib/deck/io/serialize.ts
@@ -28,7 +28,7 @@ export function toMaindeckJson(deck: DeckWithCards): string {
name: dc.card.name,
quantity: dc.quantity,
zone: dc.zone,
- category: dc.category,
+ categories: dc.categories,
...(dc.printing?.setCode !== undefined && { set: dc.printing.setCode.toUpperCase() }),
...(dc.printing?.collectorNumber !== undefined && { collectorNumber: dc.printing.collectorNumber }),
isFoil: dc.isFoil,
diff --git a/lib/deck/legality.test.ts b/lib/deck/legality.test.ts
index c2f7af1..b8e31c7 100644
--- a/lib/deck/legality.test.ts
+++ b/lib/deck/legality.test.ts
@@ -20,7 +20,7 @@ type MinimalDeckCard = {
id: string;
quantity: number;
zone: Zone;
- category: string | null;
+ categories: string[];
card: MinimalCard;
printing: null;
};
@@ -52,7 +52,7 @@ function makeDeckCard(
id: `dc-${name}-${zone}`,
quantity,
zone,
- category: null,
+ categories: [],
card: makeCard(name, legalities, typeLine, colorIdentity),
printing: null,
};
diff --git a/lib/deck/legality/__tests__/brawl.test.ts b/lib/deck/legality/__tests__/brawl.test.ts
index 1a0d2ff..b99f902 100644
--- a/lib/deck/legality/__tests__/brawl.test.ts
+++ b/lib/deck/legality/__tests__/brawl.test.ts
@@ -17,7 +17,7 @@ function dc(
cardName: name,
quantity,
zone,
- category: null,
+ categories: [],
typeLine: "Creature — Human",
colorIdentity: [],
legalities: { brawl: "legal" },
diff --git a/lib/deck/legality/__tests__/commander.test.ts b/lib/deck/legality/__tests__/commander.test.ts
index d0f8a3d..57f421d 100644
--- a/lib/deck/legality/__tests__/commander.test.ts
+++ b/lib/deck/legality/__tests__/commander.test.ts
@@ -19,7 +19,7 @@ function dc(
cardName: name,
quantity,
zone,
- category: null,
+ categories: [],
typeLine,
colorIdentity,
legalities: { commander: "legal" },
diff --git a/lib/deck/legality/__tests__/companions.test.ts b/lib/deck/legality/__tests__/companions.test.ts
index df6ef47..44d3e6e 100644
--- a/lib/deck/legality/__tests__/companions.test.ts
+++ b/lib/deck/legality/__tests__/companions.test.ts
@@ -23,7 +23,7 @@ function card(
cardName: name,
quantity: 1,
zone: Zone.MAINBOARD,
- category: null,
+ categories: [],
typeLine: "Creature — Human",
colorIdentity: [],
legalities: {},
diff --git a/lib/deck/legality/__tests__/oathbreaker.test.ts b/lib/deck/legality/__tests__/oathbreaker.test.ts
index 731ac9a..f46eb21 100644
--- a/lib/deck/legality/__tests__/oathbreaker.test.ts
+++ b/lib/deck/legality/__tests__/oathbreaker.test.ts
@@ -18,7 +18,7 @@ function dc(
cardName: name,
quantity,
zone,
- category: null,
+ categories: [],
typeLine: "Creature — Human",
colorIdentity,
legalities: { oathbreaker: "legal" },
diff --git a/lib/deck/legality/__tests__/shared.test.ts b/lib/deck/legality/__tests__/shared.test.ts
index af1bf08..2b75bec 100644
--- a/lib/deck/legality/__tests__/shared.test.ts
+++ b/lib/deck/legality/__tests__/shared.test.ts
@@ -77,6 +77,12 @@ describe("formatLegalityIssue", () => {
"Subcategories only apply to MAINBOARD cards",
);
});
+
+ it("formats duplicate_category", () => {
+ expect(
+ formatLegalityIssue({ kind: "duplicate_category", category: "Ramp" }),
+ ).toBe('Category "Ramp" listed more than once');
+ });
});
describe("legalityKindForStatus", () => {
diff --git a/lib/deck/legality/__tests__/sixty-card.test.ts b/lib/deck/legality/__tests__/sixty-card.test.ts
index d783e88..153b9c0 100644
--- a/lib/deck/legality/__tests__/sixty-card.test.ts
+++ b/lib/deck/legality/__tests__/sixty-card.test.ts
@@ -19,7 +19,7 @@ function dc(
cardName: name,
quantity,
zone,
- category: null,
+ categories: [],
typeLine: "Creature — Human",
colorIdentity: [],
legalities,
diff --git a/lib/deck/legality/shared.ts b/lib/deck/legality/shared.ts
index 76cde38..a53cb63 100644
--- a/lib/deck/legality/shared.ts
+++ b/lib/deck/legality/shared.ts
@@ -51,6 +51,10 @@ export function formatLegalityIssue(issue: LegalityIssue): string {
return `${issue.cardName}: Companion restriction not met — ${issue.reason}`;
case "category_zone_mismatch":
return "Subcategories only apply to MAINBOARD cards";
+ case "unknown_category":
+ return `Category "${issue.category}" not found in deck`;
+ case "duplicate_category":
+ return `Category "${issue.category}" listed more than once`;
}
}
diff --git a/lib/deck/mutation/__tests__/apply.test.ts b/lib/deck/mutation/__tests__/apply.test.ts
index 723ac14..9af24d7 100644
--- a/lib/deck/mutation/__tests__/apply.test.ts
+++ b/lib/deck/mutation/__tests__/apply.test.ts
@@ -11,6 +11,11 @@ vi.mock("@/lib/db", () => ({
update: vi.fn(),
delete: vi.fn(),
},
+ deckCategory: { findMany: vi.fn() },
+ deckCardCategory: {
+ deleteMany: vi.fn(),
+ createMany: vi.fn(),
+ },
deckRevision: {
findFirst: vi.fn(),
create: vi.fn(),
@@ -31,6 +36,9 @@ const mockDeckCardFindFirst = vi.mocked(prisma.deckCard.findFirst);
const mockDeckCardCreate = vi.mocked(prisma.deckCard.create);
const mockDeckCardUpdate = vi.mocked(prisma.deckCard.update);
const mockDeckCardDelete = vi.mocked(prisma.deckCard.delete);
+const mockCategoryFindMany = vi.mocked(prisma.deckCategory.findMany);
+const mockLinkDeleteMany = vi.mocked(prisma.deckCardCategory.deleteMany);
+const mockLinkCreateMany = vi.mocked(prisma.deckCardCategory.createMany);
const mockRevisionFindFirst = vi.mocked(prisma.deckRevision.findFirst);
const mockRevisionCreate = vi.mocked(prisma.deckRevision.create);
const mockTransaction = vi.mocked(prisma.$transaction);
@@ -48,6 +56,11 @@ function txPassthrough() {
update: mockDeckCardUpdate,
delete: mockDeckCardDelete,
},
+ deckCategory: { findMany: mockCategoryFindMany },
+ deckCardCategory: {
+ deleteMany: mockLinkDeleteMany,
+ createMany: mockLinkCreateMany,
+ },
deckRevision: {
findFirst: mockRevisionFindFirst,
create: mockRevisionCreate,
@@ -68,7 +81,9 @@ function commanderDeck(
quantity: number;
zone?: Zone;
typeLine?: string | null;
+ categories?: string[];
}>,
+ categoryNames: string[] = [],
) {
mockDeckFindUnique.mockResolvedValue({
id: DECK,
@@ -78,7 +93,9 @@ function commanderDeck(
cardId: c.cardId,
quantity: c.quantity,
zone: c.zone ?? Zone.MAINBOARD,
- category: null,
+ categoryLinks: (c.categories ?? []).map((name) => ({
+ deckCategory: { name },
+ })),
printingId: null,
isFoil: false,
card: {
@@ -88,16 +105,19 @@ function commanderDeck(
legalities: { commander: "legal" },
},
})),
- categories: [],
+ categories: categoryNames.map((name) => ({ name })),
} as never);
}
beforeEach(() => {
vi.clearAllMocks();
mockDeckCardFindFirst.mockResolvedValue(null);
- mockDeckCardCreate.mockResolvedValue({} as never);
+ mockDeckCardCreate.mockResolvedValue({ id: "dc-new" } as never);
mockDeckCardUpdate.mockResolvedValue({} as never);
mockDeckCardDelete.mockResolvedValue({} as never);
+ mockCategoryFindMany.mockResolvedValue([] as never);
+ mockLinkDeleteMany.mockResolvedValue({ count: 0 } as never);
+ mockLinkCreateMany.mockResolvedValue({ count: 0 } as never);
mockRevisionFindFirst.mockResolvedValue(null);
mockRevisionCreate.mockResolvedValue({} as never);
mockCardFindMany.mockResolvedValue([] as never);
@@ -105,8 +125,8 @@ beforeEach(() => {
});
describe("applyChanges — invariant gating", () => {
- it("throws InvariantViolation when category is set on non-MAINBOARD", async () => {
- commanderDeck([]);
+ it("throws InvariantViolation when categories are set on non-MAINBOARD", async () => {
+ commanderDeck([], ["Ramp"]);
mockCardFindMany.mockResolvedValue([
{
id: 1,
@@ -124,7 +144,34 @@ describe("applyChanges — invariant gating", () => {
cardId: 1,
quantity: 1,
zone: Zone.SIDEBOARD,
- category: "Ramp",
+ categories: ["Ramp"],
+ },
+ ]),
+ ).rejects.toThrow(InvariantViolation);
+
+ expect(mockTransaction).not.toHaveBeenCalled();
+ });
+
+ it("throws InvariantViolation for a category name not on the deck", async () => {
+ commanderDeck([], ["Ramp"]);
+ mockCardFindMany.mockResolvedValue([
+ {
+ id: 1,
+ name: "Sol Ring",
+ typeLine: "Artifact",
+ colorIdentity: [],
+ legalities: { commander: "legal" },
+ },
+ ] as never);
+
+ await expect(
+ applyChanges(DECK, USER, [
+ {
+ op: "add",
+ cardId: 1,
+ quantity: 1,
+ zone: Zone.MAINBOARD,
+ categories: ["Ghost"],
},
]),
).rejects.toThrow(InvariantViolation);
@@ -145,7 +192,7 @@ describe("applyChanges — invariant gating", () => {
] as never);
await applyChanges(DECK, USER, [
- { op: "add", cardId: 1, quantity: 1, zone: Zone.MAINBOARD, category: null },
+ { op: "add", cardId: 1, quantity: 1, zone: Zone.MAINBOARD, categories: [] },
]);
expect(mockTransaction).toHaveBeenCalledTimes(1);
@@ -165,7 +212,7 @@ describe("applyChanges — invariant gating", () => {
]);
await applyChanges(DECK, USER, [
- { op: "add", cardId: 1, quantity: 4, zone: Zone.MAINBOARD, category: null },
+ { op: "add", cardId: 1, quantity: 4, zone: Zone.MAINBOARD, categories: [] },
]);
expect(mockTransaction).toHaveBeenCalledTimes(1);
@@ -209,7 +256,7 @@ describe("applyChanges — revision atomicity", () => {
await applyChanges(
DECK,
USER,
- [{ op: "add", cardId: 1, quantity: 1, zone: Zone.MAINBOARD, category: null }],
+ [{ op: "add", cardId: 1, quantity: 1, zone: Zone.MAINBOARD, categories: [] }],
{ skipMerge: true },
);
@@ -233,7 +280,7 @@ describe("applyChanges — revision atomicity", () => {
await applyChanges(
DECK,
USER,
- [{ op: "add", cardId: 1, quantity: 1, zone: Zone.MAINBOARD, category: null }],
+ [{ op: "add", cardId: 1, quantity: 1, zone: Zone.MAINBOARD, categories: [] }],
{ skipRevision: true },
);
@@ -271,6 +318,9 @@ describe("applyChanges — basic ops", () => {
data: { quantity: 7 },
});
expect(mockDeckCardDelete).not.toHaveBeenCalled();
+ // Quantity-only update never touches membership rows.
+ expect(mockLinkDeleteMany).not.toHaveBeenCalled();
+ expect(mockLinkCreateMany).not.toHaveBeenCalled();
});
it("add hitting an existing matching row updates to the merged quantity instead of creating", async () => {
@@ -279,7 +329,7 @@ describe("applyChanges — basic ops", () => {
]);
await applyChanges(DECK, USER, [
- { op: "add", cardId: 1, quantity: 2, zone: Zone.MAINBOARD, category: null },
+ { op: "add", cardId: 1, quantity: 2, zone: Zone.MAINBOARD, categories: [] },
]);
expect(mockDeckCardUpdate).toHaveBeenCalledWith({
@@ -289,7 +339,46 @@ describe("applyChanges — basic ops", () => {
expect(mockDeckCardCreate).not.toHaveBeenCalled();
});
- it("move with no target row updates the row's zone/category in place", async () => {
+ it("categorized add creates the row and its membership links with positions 0..n-1", async () => {
+ commanderDeck([], ["Ramp", "Rocks"]);
+ mockCardFindMany.mockResolvedValue([
+ {
+ id: 1,
+ name: "Sol Ring",
+ typeLine: "Artifact",
+ colorIdentity: [],
+ legalities: { commander: "legal" },
+ },
+ ] as never);
+ mockCategoryFindMany.mockResolvedValue([
+ { id: "cat-ramp", name: "Ramp" },
+ { id: "cat-rocks", name: "Rocks" },
+ ] as never);
+ mockDeckCardCreate.mockResolvedValue({ id: "dc-new" } as never);
+
+ await applyChanges(DECK, USER, [
+ {
+ op: "add",
+ cardId: 1,
+ quantity: 1,
+ zone: Zone.MAINBOARD,
+ categories: ["Rocks", "Ramp"],
+ },
+ ]);
+
+ expect(mockCategoryFindMany).toHaveBeenCalledWith({
+ where: { deckId: DECK, name: { in: expect.arrayContaining(["Rocks", "Ramp"]) } },
+ select: { id: true, name: true },
+ });
+ expect(mockLinkCreateMany).toHaveBeenCalledWith({
+ data: [
+ { deckCardId: "dc-new", deckCategoryId: "cat-rocks", position: 0 },
+ { deckCardId: "dc-new", deckCategoryId: "cat-ramp", position: 1 },
+ ],
+ });
+ });
+
+ it("move with no target row updates the row's zone in place", async () => {
commanderDeck([
{
id: "dc-1",
@@ -300,7 +389,7 @@ describe("applyChanges — basic ops", () => {
},
]);
await applyChanges(DECK, USER, [
- { op: "move", deckCardId: "dc-1", zone: Zone.SIDEBOARD, category: null },
+ { op: "move", deckCardId: "dc-1", zone: Zone.SIDEBOARD, categories: [] },
]);
expect(mockDeckCardUpdate).toHaveBeenCalledWith({
@@ -310,26 +399,149 @@ describe("applyChanges — basic ops", () => {
expect(mockDeckCardDelete).not.toHaveBeenCalled();
});
- it("move that only changes the category writes data.category", async () => {
- commanderDeck([
+ it("move that only changes memberships replaces the link rows, not the deckCard", async () => {
+ commanderDeck(
+ [
+ {
+ id: "dc-1",
+ cardId: 1,
+ name: "Sol Ring",
+ quantity: 1,
+ zone: Zone.MAINBOARD,
+ typeLine: "Artifact",
+ categories: ["Rocks"],
+ },
+ ],
+ ["Ramp", "Rocks"],
+ );
+ mockCategoryFindMany.mockResolvedValue([
+ { id: "cat-ramp", name: "Ramp" },
+ ] as never);
+
+ await applyChanges(DECK, USER, [
+ { op: "move", deckCardId: "dc-1", zone: Zone.MAINBOARD, categories: ["Ramp"] },
+ ]);
+
+ // Category-only edit still touches the row so @updatedAt reflects it.
+ expect(mockDeckCardUpdate).toHaveBeenCalledWith({
+ where: { id: "dc-1" },
+ data: {},
+ });
+ expect(mockLinkDeleteMany).toHaveBeenCalledWith({
+ where: { deckCardId: "dc-1" },
+ });
+ expect(mockLinkCreateMany).toHaveBeenCalledWith({
+ data: [{ deckCardId: "dc-1", deckCategoryId: "cat-ramp", position: 0 }],
+ });
+ // Recategorization is recorded as a zero-delta revision entry.
+ expect(mockRevisionCreate).toHaveBeenCalledWith({
+ data: {
+ deckId: DECK,
+ userId: USER,
+ changes: [
+ {
+ cardId: 1,
+ cardName: "Sol Ring",
+ zone: Zone.MAINBOARD,
+ categories: ["Ramp"],
+ previousCategories: ["Rocks"],
+ delta: 0,
+ },
+ ],
+ },
+ });
+ });
+
+ it("replacing memberships renumbers positions 0..n-1", async () => {
+ commanderDeck(
+ [
+ {
+ id: "dc-1",
+ cardId: 1,
+ name: "Sol Ring",
+ quantity: 1,
+ zone: Zone.MAINBOARD,
+ typeLine: "Artifact",
+ categories: ["Ramp"],
+ },
+ ],
+ ["Ramp", "Rocks", "Staples"],
+ );
+ mockCategoryFindMany.mockResolvedValue([
+ { id: "cat-ramp", name: "Ramp" },
+ { id: "cat-rocks", name: "Rocks" },
+ { id: "cat-staples", name: "Staples" },
+ ] as never);
+
+ await applyChanges(DECK, USER, [
{
- id: "dc-1",
- cardId: 1,
- name: "Sol Ring",
- quantity: 1,
+ op: "move",
+ deckCardId: "dc-1",
zone: Zone.MAINBOARD,
- typeLine: "Artifact",
+ categories: ["Staples", "Ramp", "Rocks"],
},
]);
+ expect(mockLinkDeleteMany).toHaveBeenCalledWith({
+ where: { deckCardId: "dc-1" },
+ });
+ expect(mockLinkCreateMany).toHaveBeenCalledWith({
+ data: [
+ { deckCardId: "dc-1", deckCategoryId: "cat-staples", position: 0 },
+ { deckCardId: "dc-1", deckCategoryId: "cat-ramp", position: 1 },
+ { deckCardId: "dc-1", deckCategoryId: "cat-rocks", position: 2 },
+ ],
+ });
+ });
+
+ it("clearing memberships deletes the link rows without recreating any", async () => {
+ commanderDeck(
+ [
+ {
+ id: "dc-1",
+ cardId: 1,
+ name: "Sol Ring",
+ quantity: 1,
+ zone: Zone.MAINBOARD,
+ typeLine: "Artifact",
+ categories: ["Ramp"],
+ },
+ ],
+ ["Ramp"],
+ );
+
await applyChanges(DECK, USER, [
- { op: "move", deckCardId: "dc-1", zone: Zone.MAINBOARD, category: "Ramp" },
+ { op: "move", deckCardId: "dc-1", zone: Zone.MAINBOARD, categories: [] },
]);
- expect(mockDeckCardUpdate).toHaveBeenCalledWith({
- where: { id: "dc-1" },
- data: { category: "Ramp" },
+ expect(mockLinkDeleteMany).toHaveBeenCalledWith({
+ where: { deckCardId: "dc-1" },
});
+ expect(mockLinkCreateMany).not.toHaveBeenCalled();
+ });
+
+ it("throws when a category referenced by an op has vanished from the deck mid-flight", async () => {
+ commanderDeck(
+ [
+ {
+ id: "dc-1",
+ cardId: 1,
+ name: "Sol Ring",
+ quantity: 1,
+ zone: Zone.MAINBOARD,
+ typeLine: "Artifact",
+ },
+ ],
+ ["Ramp"],
+ );
+ // Structural check passed against the snapshot, but the tx lookup misses.
+ mockCategoryFindMany.mockResolvedValue([] as never);
+
+ await expect(
+ applyChanges(DECK, USER, [
+ { op: "move", deckCardId: "dc-1", zone: Zone.MAINBOARD, categories: ["Ramp"] },
+ ]),
+ ).rejects.toThrow('Category "Ramp" not found in deck');
});
it("move that lands on an existing target merges quantity and deletes the source", async () => {
@@ -352,7 +564,7 @@ describe("applyChanges — basic ops", () => {
},
]);
await applyChanges(DECK, USER, [
- { op: "move", deckCardId: "dc-source", zone: Zone.SIDEBOARD, category: null },
+ { op: "move", deckCardId: "dc-source", zone: Zone.SIDEBOARD, categories: [] },
]);
expect(mockDeckCardUpdate).toHaveBeenCalledWith({
@@ -375,7 +587,7 @@ describe("applyChanges — basic ops", () => {
expect(mockTransaction).not.toHaveBeenCalled();
});
- it("move to the same zone+category produces no delta but still runs the tx", async () => {
+ it("move to the same zone+categories produces no delta but still runs the tx", async () => {
commanderDeck([
{
id: "dc-1",
@@ -389,7 +601,7 @@ describe("applyChanges — basic ops", () => {
mockDeckCardFindFirst.mockResolvedValueOnce(null);
await applyChanges(DECK, USER, [
- { op: "move", deckCardId: "dc-1", zone: Zone.MAINBOARD, category: null },
+ { op: "move", deckCardId: "dc-1", zone: Zone.MAINBOARD, categories: [] },
]);
expect(mockTransaction).toHaveBeenCalledTimes(1);
@@ -416,7 +628,7 @@ describe("applyChanges — basic ops", () => {
await applyChanges(
DECK,
USER,
- [{ op: "add", cardId: 1, quantity: 1, zone: Zone.MAINBOARD, category: null }],
+ [{ op: "add", cardId: 1, quantity: 1, zone: Zone.MAINBOARD, categories: [] }],
{ skipCacheInvalidation: true },
);
@@ -440,7 +652,7 @@ describe("applyChanges — external tx passthrough", () => {
cardId: c.cardId,
quantity: c.quantity,
zone: Zone.MAINBOARD,
- category: null,
+ categoryLinks: [],
printingId: null,
isFoil: false,
card: {
@@ -466,10 +678,15 @@ describe("applyChanges — external tx passthrough", () => {
},
deckCard: {
findFirst: vi.fn().mockResolvedValue(null),
- create: vi.fn().mockResolvedValue({}),
+ create: vi.fn().mockResolvedValue({ id: "dc-ext" }),
update: vi.fn().mockResolvedValue({}),
delete: vi.fn().mockResolvedValue({}),
},
+ deckCategory: { findMany: vi.fn().mockResolvedValue([]) },
+ deckCardCategory: {
+ deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
+ createMany: vi.fn().mockResolvedValue({ count: 0 }),
+ },
deckRevision: {
findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue({}),
@@ -485,7 +702,7 @@ describe("applyChanges — external tx passthrough", () => {
await applyChanges(
DECK,
USER,
- [{ op: "add", cardId: 1, quantity: 1, zone: Zone.MAINBOARD, category: null }],
+ [{ op: "add", cardId: 1, quantity: 1, zone: Zone.MAINBOARD, categories: [] }],
{ tx: tx as never },
);
@@ -513,7 +730,7 @@ describe("applyChanges — external tx passthrough", () => {
] as never);
await applyChanges(DECK, USER, [
- { op: "add", cardId: 1, quantity: 1, zone: Zone.MAINBOARD, category: null },
+ { op: "add", cardId: 1, quantity: 1, zone: Zone.MAINBOARD, categories: [] },
]);
expect(mockTransaction).toHaveBeenCalledTimes(1);
diff --git a/lib/deck/mutation/__tests__/diff-snapshots.test.ts b/lib/deck/mutation/__tests__/diff-snapshots.test.ts
index 5af01d2..3b85e08 100644
--- a/lib/deck/mutation/__tests__/diff-snapshots.test.ts
+++ b/lib/deck/mutation/__tests__/diff-snapshots.test.ts
@@ -11,7 +11,7 @@ function dc(
name: string,
quantity: number,
zone: Zone = Zone.MAINBOARD,
- category: string | null = null,
+ categories: string[] = [],
): SnapshotCard {
return {
id,
@@ -19,7 +19,7 @@ function dc(
cardName: name,
quantity,
zone,
- category,
+ categories,
typeLine: "Creature — Human",
colorIdentity: [],
legalities: { commander: "legal" },
@@ -43,10 +43,10 @@ function applied(
}
describe("diffSnapshots", () => {
- it("emits a create for an add with no existing match", () => {
+ it("emits create for an add with no existing match", () => {
const ops = applied(
[],
- [{ op: "add", cardId: 1, quantity: 1, zone: Zone.MAINBOARD, category: null }],
+ [{ op: "add", cardId: 1, quantity: 1, zone: Zone.MAINBOARD, categories: [] }],
[{ cardId: 1, name: "Counterspell", typeLine: "Instant" }],
);
expect(ops).toEqual([
@@ -55,48 +55,67 @@ describe("diffSnapshots", () => {
cardId: 1,
quantity: 1,
zone: Zone.MAINBOARD,
- category: null,
+ categories: [],
printingId: null,
isFoil: false,
},
]);
});
- it("emits a quantity update when an add merges into an existing row", () => {
+ it("carries ordered categories on a categorized create", () => {
const ops = applied(
- [dc("dc-1", 1, "Forest", 4)],
- [{ op: "add", cardId: 1, quantity: 2, zone: Zone.MAINBOARD, category: null }],
+ [],
+ [
+ {
+ op: "add",
+ cardId: 1,
+ quantity: 1,
+ zone: Zone.MAINBOARD,
+ categories: ["Ramp", "Rocks"],
+ },
+ ],
+ [{ cardId: 1, name: "Sol Ring", typeLine: "Artifact" }],
);
expect(ops).toEqual([
- { kind: "update", deckCardId: "dc-1", quantity: 6 },
+ {
+ kind: "create",
+ cardId: 1,
+ quantity: 1,
+ zone: Zone.MAINBOARD,
+ categories: ["Ramp", "Rocks"],
+ printingId: null,
+ isFoil: false,
+ },
]);
});
- it("emits a delete when update drops quantity to zero", () => {
+ it("emits quantity update when an add merges into an existing row", () => {
const ops = applied(
[dc("dc-1", 1, "Forest", 4)],
- [{ op: "update", deckCardId: "dc-1", quantity: 0 }],
+ [{ op: "add", cardId: 1, quantity: 2, zone: Zone.MAINBOARD, categories: [] }],
);
- expect(ops).toEqual([{ kind: "delete", deckCardId: "dc-1" }]);
+ expect(ops).toEqual([
+ { kind: "update", deckCardId: "dc-1", quantity: 6 },
+ ]);
});
- it("emits only the changed zone field on an in-place move", () => {
+ it("emits only changed zone field on an in-place move", () => {
const ops = applied(
[dc("dc-1", 1, "Sol Ring", 1, Zone.MAINBOARD)],
- [{ op: "move", deckCardId: "dc-1", zone: Zone.SIDEBOARD, category: null }],
+ [{ op: "move", deckCardId: "dc-1", zone: Zone.SIDEBOARD, categories: [] }],
);
expect(ops).toEqual([
{ kind: "update", deckCardId: "dc-1", zone: Zone.SIDEBOARD },
]);
});
- it("expresses a merging move as a quantity update plus a source delete", () => {
+ it("expresses merging move as quantity update plus source delete", () => {
const ops = applied(
[
dc("dc-src", 1, "Sol Ring", 2, Zone.MAINBOARD),
dc("dc-tgt", 1, "Sol Ring", 1, Zone.SIDEBOARD),
],
- [{ op: "move", deckCardId: "dc-src", zone: Zone.SIDEBOARD, category: null }],
+ [{ op: "move", deckCardId: "dc-src", zone: Zone.SIDEBOARD, categories: [] }],
);
expect(ops).toContainEqual({
kind: "update",
@@ -110,18 +129,57 @@ describe("diffSnapshots", () => {
it("returns no ops when nothing changed", () => {
const ops = applied(
[dc("dc-1", 1, "Sol Ring", 1, Zone.MAINBOARD)],
- [{ op: "move", deckCardId: "dc-1", zone: Zone.MAINBOARD, category: null }],
+ [{ op: "move", deckCardId: "dc-1", zone: Zone.MAINBOARD, categories: [] }],
);
expect(ops).toEqual([]);
});
- it("emits a category update when a card moves between mainboard categories", () => {
+ it("emits a whole-array categories update when memberships change", () => {
const ops = applied(
- [dc("dc-1", 1, "Sol Ring", 1, Zone.MAINBOARD, "Ramp")],
- [{ op: "move", deckCardId: "dc-1", zone: Zone.MAINBOARD, category: "Rocks" }],
+ [dc("dc-1", 1, "Sol Ring", 1, Zone.MAINBOARD, ["Ramp"])],
+ [
+ {
+ op: "move",
+ deckCardId: "dc-1",
+ zone: Zone.MAINBOARD,
+ categories: ["Rocks"],
+ },
+ ],
+ );
+ expect(ops).toEqual([
+ { kind: "update", deckCardId: "dc-1", categories: ["Rocks"] },
+ ]);
+ });
+
+ it("emits a categories update when only the order changes", () => {
+ const ops = applied(
+ [dc("dc-1", 1, "Sol Ring", 1, Zone.MAINBOARD, ["Ramp", "Rocks"])],
+ [
+ {
+ op: "move",
+ deckCardId: "dc-1",
+ zone: Zone.MAINBOARD,
+ categories: ["Rocks", "Ramp"],
+ },
+ ],
);
expect(ops).toEqual([
- { kind: "update", deckCardId: "dc-1", category: "Rocks" },
+ { kind: "update", deckCardId: "dc-1", categories: ["Rocks", "Ramp"] },
]);
});
+
+ it("emits no op when categories are unchanged element-wise", () => {
+ const ops = applied(
+ [dc("dc-1", 1, "Sol Ring", 1, Zone.MAINBOARD, ["Ramp", "Rocks"])],
+ [
+ {
+ op: "move",
+ deckCardId: "dc-1",
+ zone: Zone.MAINBOARD,
+ categories: ["Ramp", "Rocks"],
+ },
+ ],
+ );
+ expect(ops).toEqual([]);
+ });
});
diff --git a/lib/deck/mutation/__tests__/diff.test.ts b/lib/deck/mutation/__tests__/diff.test.ts
index f125a7e..c6dfcc9 100644
--- a/lib/deck/mutation/__tests__/diff.test.ts
+++ b/lib/deck/mutation/__tests__/diff.test.ts
@@ -15,7 +15,6 @@ function resolved(
name,
quantity,
zone,
- category: null,
isFoil: false,
},
printingId: null,
@@ -27,10 +26,9 @@ function existing(
deckCardId: string,
cardId: number,
quantity: number,
- category: string | null = null,
zone: Zone = Zone.MAINBOARD,
): ExistingDeckCard {
- return { deckCardId, cardId, zone, category, quantity };
+ return { deckCardId, cardId, zone, quantity };
}
describe("diffDeck", () => {
@@ -42,7 +40,7 @@ describe("diffDeck", () => {
cardId: 1,
quantity: 4,
zone: Zone.MAINBOARD,
- category: null,
+ categories: [],
},
]);
});
@@ -81,89 +79,106 @@ describe("diffDeck", () => {
cardId: 1,
quantity: 4,
zone: Zone.MAINBOARD,
- category: null,
+ categories: [],
},
]);
});
- it("uses the primary (categorized first) and removes duplicate extras when desired matches the primary", () => {
- // Two existing rows for cardId=1 in MAINBOARD: one categorized, one not.
- // Sort puts categorized first as primary. Desired qty=2 matches no-cat row's qty,
- // but primary (cat=Ramp, qty=1) takes precedence — emits update + remove.
+ it("picks the lowest deckCardId as primary and removes duplicate extras", () => {
+ // Two existing rows for cardId=1 in MAINBOARD. Sort is by deckCardId, so
+ // dc-a is primary. Desired qty=2 matches dc-b's quantity, but primary
+ // takes precedence — emits update on dc-a + remove of dc-b.
const changes = diffDeck(
[resolved(1, "Forest", 2)],
- [
- existing("dc-no-cat", 1, 2, null),
- existing("dc-ramp", 1, 1, "Ramp"),
- ],
+ [existing("dc-b", 1, 2), existing("dc-a", 1, 1)],
);
- // Primary is dc-ramp (categorized → sort key 0). qty 1 → 2: update.
- // Extras = [dc-no-cat]: remove.
expect(changes).toContainEqual({
op: "update",
- deckCardId: "dc-ramp",
+ deckCardId: "dc-a",
quantity: 2,
});
- expect(changes).toContainEqual({ op: "remove", deckCardId: "dc-no-cat" });
+ expect(changes).toContainEqual({ op: "remove", deckCardId: "dc-b" });
});
it("removes existing primary AND its extras when no desired entry matches the key", () => {
const changes = diffDeck(
[],
- [
- existing("dc-primary", 1, 2, "Ramp"),
- existing("dc-extra", 1, 1, null),
- ],
+ [existing("dc-primary", 1, 2), existing("dc-extra", 1, 1)],
);
expect(changes).toContainEqual({ op: "remove", deckCardId: "dc-primary" });
expect(changes).toContainEqual({ op: "remove", deckCardId: "dc-extra" });
});
- it("sorts existing duplicates with the same categorized-status by category name", () => {
- // Both rows have non-null categories — primary should be the lexicographically
- // first one ("Burn" before "Ramp"); the other becomes an extra to remove.
+ it("sorts existing duplicates deterministically by deckCardId regardless of input order", () => {
const changes = diffDeck(
[resolved(1, "Forest", 5)],
- [
- existing("dc-ramp", 1, 1, "Ramp"),
- existing("dc-burn", 1, 1, "Burn"),
- ],
+ [existing("dc-z", 1, 1), existing("dc-a", 1, 2)],
);
expect(changes).toContainEqual({
op: "update",
- deckCardId: "dc-burn",
+ deckCardId: "dc-a",
quantity: 5,
});
- expect(changes).toContainEqual({ op: "remove", deckCardId: "dc-ramp" });
+ expect(changes).toContainEqual({ op: "remove", deckCardId: "dc-z" });
+ expect(changes.filter((c) => c.op === "update")).toHaveLength(1);
+ expect(changes.filter((c) => c.op === "remove")).toHaveLength(1);
});
- it("sorts existing duplicates with both categories null via the empty-string fallback", () => {
- // Both rows have category=null and same key, so the categorized-status sort
- // is a no-op and both `?? ""` fallbacks are exercised before localeCompare.
+ it("carries first-occurrence categories onto MAINBOARD add ops", () => {
+ const r = resolved(1, "Sol Ring", 1);
+ (r.parsed as { categories?: string[] }).categories = ["ramp", "rocks"];
+ const changes = diffDeck([r], []);
+ expect(changes).toEqual([
+ {
+ op: "add",
+ cardId: 1,
+ quantity: 1,
+ zone: Zone.MAINBOARD,
+ categories: ["ramp", "rocks"],
+ },
+ ]);
+ });
+
+ it("clears categories on non-MAINBOARD add ops", () => {
+ const r = resolved(2, "Duress", 1, Zone.SIDEBOARD);
+ (r.parsed as { categories?: string[] }).categories = ["discard"];
+ const changes = diffDeck([r], []);
+ expect(changes).toEqual([
+ {
+ op: "add",
+ cardId: 2,
+ quantity: 1,
+ zone: Zone.SIDEBOARD,
+ categories: [],
+ },
+ ]);
+ });
+
+ it("keeps the categorized duplicate even when its deckCardId sorts later", () => {
const changes = diffDeck(
- [resolved(1, "Forest", 5)],
+ [resolved(1, "Forest", 2)],
[
- existing("dc-a", 1, 1, null),
- existing("dc-b", 1, 2, null),
+ existing("dc-a", 1, 1),
+ { ...existing("dc-z", 1, 2), hasCategories: true },
],
);
- // After sort, one is primary and the other is extra. Quantity goes to 5,
- // and the extra is removed regardless of which becomes primary.
- expect(changes.filter((c) => c.op === "update")).toHaveLength(1);
+ expect(changes).toContainEqual({ op: "remove", deckCardId: "dc-a" });
expect(changes.filter((c) => c.op === "remove")).toHaveLength(1);
+ // dc-z survives as primary; its quantity already matches → no update.
+ expect(changes.filter((c) => c.op === "update")).toHaveLength(0);
});
it("treats different zones for the same card as distinct keys", () => {
const changes = diffDeck(
[resolved(1, "Forest", 2, Zone.MAINBOARD)],
- [existing("dc1", 1, 1, null, Zone.SIDEBOARD)],
+ [existing("dc1", 1, 1, Zone.SIDEBOARD)],
);
expect(changes).toContainEqual({
op: "add",
cardId: 1,
quantity: 2,
zone: Zone.MAINBOARD,
- category: null,
+ categories: [],
});
expect(changes).toContainEqual({ op: "remove", deckCardId: "dc1" });
});
diff --git a/lib/deck/mutation/__tests__/invariants.test.ts b/lib/deck/mutation/__tests__/invariants.test.ts
index 50c314f..9f5f7cb 100644
--- a/lib/deck/mutation/__tests__/invariants.test.ts
+++ b/lib/deck/mutation/__tests__/invariants.test.ts
@@ -12,6 +12,8 @@ function dc(
quantity: number,
zone: Zone = Zone.MAINBOARD,
typeLine: string | null = "Creature — Human",
+ categories: string[] = [],
+ printingId: number | null = null,
): SnapshotCard {
return {
id,
@@ -19,11 +21,11 @@ function dc(
cardName: name,
quantity,
zone,
- category: null,
+ categories,
typeLine,
colorIdentity: [],
legalities: { commander: "legal" },
- printingId: null,
+ printingId,
isFoil: false,
};
}
@@ -36,7 +38,7 @@ describe("projectChanges", () => {
extraMeta: [{ cardId: 1, name: "Counterspell", typeLine: "Instant" }],
});
const changes: PlannedChange[] = [
- { op: "add", cardId: 1, quantity: 1, zone: Zone.MAINBOARD, category: null },
+ { op: "add", cardId: 1, quantity: 1, zone: Zone.MAINBOARD, categories: [] },
];
const after = projectChanges(before, changes);
expect(after.cards).toHaveLength(1);
@@ -49,13 +51,48 @@ describe("projectChanges", () => {
cards: [dc("dc-1", 1, "Forest", 4)],
});
const changes: PlannedChange[] = [
- { op: "add", cardId: 1, quantity: 2, zone: Zone.MAINBOARD, category: null },
+ { op: "add", cardId: 1, quantity: 2, zone: Zone.MAINBOARD, categories: [] },
];
const after = projectChanges(before, changes);
expect(after.cards).toHaveLength(1);
expect(after.cards[0]!.quantity).toBe(6);
});
+ it("categorized add merging into an existing row replaces its memberships", () => {
+ const before = snapshotFromCards({
+ format: Format.COMMANDER,
+ cards: [
+ dc("dc-1", 1, "Sol Ring", 1, Zone.MAINBOARD, "Artifact", ["Rocks"]),
+ ],
+ });
+ const after = projectChanges(before, [
+ {
+ op: "add",
+ cardId: 1,
+ quantity: 1,
+ zone: Zone.MAINBOARD,
+ categories: ["Ramp", "Artifacts"],
+ },
+ ]);
+ expect(after.cards).toHaveLength(1);
+ expect(after.cards[0]!.quantity).toBe(2);
+ expect(after.cards[0]!.categories).toEqual(["Ramp", "Artifacts"]);
+ });
+
+ it("plain add (no categories) merging into an existing row keeps its memberships", () => {
+ const before = snapshotFromCards({
+ format: Format.COMMANDER,
+ cards: [
+ dc("dc-1", 1, "Sol Ring", 1, Zone.MAINBOARD, "Artifact", ["Rocks"]),
+ ],
+ });
+ const after = projectChanges(before, [
+ { op: "add", cardId: 1, quantity: 1, zone: Zone.MAINBOARD, categories: [] },
+ ]);
+ expect(after.cards).toHaveLength(1);
+ expect(after.cards[0]!.categories).toEqual(["Rocks"]);
+ });
+
it("removes a row on remove op", () => {
const before = snapshotFromCards({
format: Format.COMMANDER,
@@ -108,7 +145,7 @@ describe("projectChanges", () => {
cards: [dc("dc-1", 1, "Sol Ring", 1)],
});
const after = projectChanges(before, [
- { op: "move", deckCardId: "missing", zone: Zone.SIDEBOARD, category: null },
+ { op: "move", deckCardId: "missing", zone: Zone.SIDEBOARD, categories: [] },
]);
expect(after.cards).toHaveLength(1);
expect(after.cards[0]!.zone).toBe(Zone.MAINBOARD);
@@ -120,12 +157,33 @@ describe("projectChanges", () => {
cards: [dc("dc-1", 1, "Sol Ring", 1, Zone.MAINBOARD)],
});
const after = projectChanges(before, [
- { op: "move", deckCardId: "dc-1", zone: Zone.SIDEBOARD, category: null },
+ { op: "move", deckCardId: "dc-1", zone: Zone.SIDEBOARD, categories: [] },
]);
expect(after.cards).toHaveLength(1);
expect(after.cards[0]!.zone).toBe(Zone.SIDEBOARD);
});
+ it("membership-change move keeps the row's id and replaces its categories", () => {
+ const before = snapshotFromCards({
+ format: Format.COMMANDER,
+ cards: [
+ dc("dc-1", 1, "Sol Ring", 1, Zone.MAINBOARD, "Artifact", ["Ramp"]),
+ ],
+ });
+ const after = projectChanges(before, [
+ {
+ op: "move",
+ deckCardId: "dc-1",
+ zone: Zone.MAINBOARD,
+ categories: ["Rocks", "Ramp"],
+ },
+ ]);
+ expect(after.cards).toHaveLength(1);
+ expect(after.cards[0]!.id).toBe("dc-1");
+ expect(after.cards[0]!.zone).toBe(Zone.MAINBOARD);
+ expect(after.cards[0]!.categories).toEqual(["Rocks", "Ramp"]);
+ });
+
it("remove against a missing deckCardId is a no-op", () => {
const before = snapshotFromCards({
format: Format.COMMANDER,
@@ -146,10 +204,124 @@ describe("projectChanges", () => {
],
});
const after = projectChanges(before, [
- { op: "move", deckCardId: "dc-1", zone: Zone.MAINBOARD, category: null },
+ { op: "move", deckCardId: "dc-1", zone: Zone.MAINBOARD, categories: [] },
+ ]);
+ expect(after.cards).toHaveLength(1);
+ expect(after.cards[0]!.quantity).toBe(2);
+ });
+
+ it("categorized move merging into a target promote-merges memberships", () => {
+ const before = snapshotFromCards({
+ format: Format.COMMANDER,
+ cards: [
+ dc("dc-1", 1, "Sol Ring", 1, Zone.SIDEBOARD),
+ dc("dc-2", 1, "Sol Ring", 1, Zone.MAINBOARD, "Artifact", ["Rocks"]),
+ ],
+ });
+ const after = projectChanges(before, [
+ {
+ op: "move",
+ deckCardId: "dc-1",
+ zone: Zone.MAINBOARD,
+ categories: ["Ramp"],
+ },
+ ]);
+ expect(after.cards).toHaveLength(1);
+ expect(after.cards[0]!.id).toBe("dc-2");
+ expect(after.cards[0]!.categories).toEqual(["Ramp", "Rocks"]);
+ });
+
+ it("plain move (no categories) merging into a categorized target keeps its memberships", () => {
+ const before = snapshotFromCards({
+ format: Format.COMMANDER,
+ cards: [
+ dc("dc-1", 1, "Sol Ring", 1, Zone.SIDEBOARD),
+ dc("dc-2", 1, "Sol Ring", 1, Zone.MAINBOARD, "Artifact", ["Rocks"]),
+ ],
+ });
+ const after = projectChanges(before, [
+ { op: "move", deckCardId: "dc-1", zone: Zone.MAINBOARD, categories: [] },
]);
expect(after.cards).toHaveLength(1);
+ expect(after.cards[0]!.id).toBe("dc-2");
expect(after.cards[0]!.quantity).toBe(2);
+ expect(after.cards[0]!.categories).toEqual(["Rocks"]);
+ });
+
+ it("categorized move merging dedupes shared names, move's order first", () => {
+ const before = snapshotFromCards({
+ format: Format.COMMANDER,
+ cards: [
+ dc("dc-1", 1, "Sol Ring", 1, Zone.SIDEBOARD),
+ dc("dc-2", 1, "Sol Ring", 1, Zone.MAINBOARD, "Artifact", [
+ "Rocks",
+ "Ramp",
+ ]),
+ ],
+ });
+ const after = projectChanges(before, [
+ {
+ op: "move",
+ deckCardId: "dc-1",
+ zone: Zone.MAINBOARD,
+ categories: ["Ramp"],
+ },
+ ]);
+ expect(after.cards[0]!.categories).toEqual(["Ramp", "Rocks"]);
+ });
+
+ it("setCategories replaces memberships on the (cardId, zone) match", () => {
+ const before = snapshotFromCards({
+ format: Format.COMMANDER,
+ cards: [
+ dc("dc-1", 1, "Sol Ring", 1, Zone.MAINBOARD, "Artifact", ["Ramp"]),
+ ],
+ });
+ const after = projectChanges(before, [
+ {
+ op: "setCategories",
+ cardId: 1,
+ zone: Zone.MAINBOARD,
+ categories: ["Rocks"],
+ },
+ ]);
+ expect(after.cards).toHaveLength(1);
+ expect(after.cards[0]!.id).toBe("dc-1");
+ expect(after.cards[0]!.categories).toEqual(["Rocks"]);
+ });
+
+ it("setCategories against a missing (cardId, zone) is a no-op", () => {
+ const before = snapshotFromCards({
+ format: Format.COMMANDER,
+ cards: [dc("dc-1", 1, "Sol Ring", 1, Zone.MAINBOARD)],
+ });
+ const after = projectChanges(before, [
+ {
+ op: "setCategories",
+ cardId: 1,
+ zone: Zone.SIDEBOARD,
+ categories: ["Ramp"],
+ },
+ ]);
+ expect(after.cards[0]!.categories).toEqual([]);
+ });
+
+ it("move does NOT merge rows with different printings (printing-pin-safe)", () => {
+ const before = snapshotFromCards({
+ format: Format.COMMANDER,
+ cards: [
+ dc("dc-1", 1, "Sol Ring", 1, Zone.SIDEBOARD, "Artifact", [], 10),
+ dc("dc-2", 1, "Sol Ring", 1, Zone.MAINBOARD, "Artifact", [], 20),
+ ],
+ });
+ const after = projectChanges(before, [
+ { op: "move", deckCardId: "dc-1", zone: Zone.MAINBOARD, categories: [] },
+ ]);
+ expect(after.cards).toHaveLength(2);
+ const moved = after.cards.find((c) => c.id === "dc-1");
+ expect(moved).toMatchObject({ zone: Zone.MAINBOARD, printingId: 10, quantity: 1 });
+ const untouched = after.cards.find((c) => c.id === "dc-2");
+ expect(untouched).toMatchObject({ printingId: 20, quantity: 1 });
});
});
@@ -163,7 +335,7 @@ describe("fullLegality — singleton", () => {
cards: [dc("dc-1", 1, "Sol Ring", 1)],
});
const projected = projectChanges(before, [
- { op: "add", cardId: 1, quantity: 1, zone: Zone.MAINBOARD, category: null },
+ { op: "add", cardId: 1, quantity: 1, zone: Zone.MAINBOARD, categories: [] },
]);
const violations = fullLegality(projected).filter(
(i) => i.kind === "singleton_violation",
@@ -177,7 +349,7 @@ describe("fullLegality — singleton", () => {
cards: [dc("dc-1", 1, "Forest", 1, Zone.MAINBOARD, "Basic Land — Forest")],
});
const projected = projectChanges(before, [
- { op: "add", cardId: 1, quantity: 5, zone: Zone.MAINBOARD, category: null },
+ { op: "add", cardId: 1, quantity: 5, zone: Zone.MAINBOARD, categories: [] },
]);
expect(
fullLegality(projected).filter((i) => i.kind === "singleton_violation"),
@@ -190,7 +362,7 @@ describe("fullLegality — singleton", () => {
cards: [dc("dc-1", 1, "Lightning Bolt", 4)],
});
const projected = projectChanges(before, [
- { op: "add", cardId: 1, quantity: 4, zone: Zone.MAINBOARD, category: null },
+ { op: "add", cardId: 1, quantity: 4, zone: Zone.MAINBOARD, categories: [] },
]);
expect(
fullLegality(projected).filter((i) => i.kind === "singleton_violation"),
@@ -199,30 +371,91 @@ describe("fullLegality — singleton", () => {
});
describe("checkStructural — structural", () => {
- it("rejects category != null for non-MAINBOARD add", () => {
+ it("rejects nonempty categories on a non-MAINBOARD add", () => {
const changes: PlannedChange[] = [
{
op: "add",
cardId: 1,
quantity: 1,
zone: Zone.SIDEBOARD,
- category: "Counters",
+ categories: ["Counters"],
},
];
- const structural = checkStructural(changes);
+ const structural = checkStructural(changes, ["Counters"]);
expect(structural.some((i) => i.kind === "category_zone_mismatch")).toBe(true);
});
- it("rejects category != null on move to non-MAINBOARD", () => {
+ it("rejects nonempty categories on a move to non-MAINBOARD", () => {
const changes: PlannedChange[] = [
{
op: "move",
deckCardId: "dc-1",
zone: Zone.SIDEBOARD,
- category: "Ramp",
+ categories: ["Ramp"],
},
];
- const structural = checkStructural(changes);
+ const structural = checkStructural(changes, ["Ramp"]);
expect(structural.some((i) => i.kind === "category_zone_mismatch")).toBe(true);
});
+
+ it("emits unknown_category per name not in the deck's categories", () => {
+ const changes: PlannedChange[] = [
+ {
+ op: "add",
+ cardId: 1,
+ quantity: 1,
+ zone: Zone.MAINBOARD,
+ categories: ["Ramp", "Ghost", "Phantom"],
+ },
+ ];
+ const structural = checkStructural(changes, ["Ramp"]);
+ expect(structural).toEqual([
+ { kind: "unknown_category", category: "Ghost" },
+ { kind: "unknown_category", category: "Phantom" },
+ ]);
+ });
+
+ it("emits duplicate_category when a change repeats a name", () => {
+ const changes: PlannedChange[] = [
+ {
+ op: "add",
+ cardId: 1,
+ quantity: 1,
+ zone: Zone.MAINBOARD,
+ categories: ["Ramp", "Ramp"],
+ },
+ ];
+ const structural = checkStructural(changes, ["Ramp"]);
+ expect(structural).toEqual([
+ { kind: "duplicate_category", category: "Ramp" },
+ ]);
+ });
+
+ it("applies category rules to setCategories changes", () => {
+ const changes: PlannedChange[] = [
+ {
+ op: "setCategories",
+ cardId: 1,
+ zone: Zone.SIDEBOARD,
+ categories: ["Ghost"],
+ },
+ ];
+ const structural = checkStructural(changes, ["Ramp"]);
+ expect(structural.some((i) => i.kind === "category_zone_mismatch")).toBe(
+ true,
+ );
+ expect(structural.some((i) => i.kind === "unknown_category")).toBe(true);
+ });
+
+ it("accepts known categories on MAINBOARD without issues", () => {
+ const changes: PlannedChange[] = [
+ {
+ op: "move",
+ deckCardId: "dc-1",
+ zone: Zone.MAINBOARD,
+ categories: ["Ramp"],
+ },
+ ];
+ expect(checkStructural(changes, ["Ramp"])).toEqual([]);
+ });
});
diff --git a/lib/deck/mutation/__tests__/plan.test.ts b/lib/deck/mutation/__tests__/plan.test.ts
index 729d5f9..f92b61c 100644
--- a/lib/deck/mutation/__tests__/plan.test.ts
+++ b/lib/deck/mutation/__tests__/plan.test.ts
@@ -11,6 +11,7 @@ function dc(
quantity: number,
zone: Zone = Zone.MAINBOARD,
typeLine: string | null = "Creature — Human",
+ categories: string[] = [],
): SnapshotCard {
return {
id,
@@ -18,7 +19,7 @@ function dc(
cardName: name,
quantity,
zone,
- category: null,
+ categories,
typeLine,
colorIdentity: [],
legalities: { commander: "legal" },
@@ -35,7 +36,7 @@ describe("planMutation — op kinds", () => {
extraMeta: [{ cardId: 1, name: "Counterspell", typeLine: "Instant" }],
});
const plan = planMutation(before, [
- { op: "add", cardId: 1, quantity: 1, zone: Zone.MAINBOARD, category: null },
+ { op: "add", cardId: 1, quantity: 1, zone: Zone.MAINBOARD, categories: [] },
]);
expect(plan.structural).toHaveLength(0);
@@ -43,7 +44,7 @@ describe("planMutation — op kinds", () => {
expect(plan.ops).toHaveLength(1);
expect(plan.ops[0]).toMatchObject({ kind: "create", cardId: 1, quantity: 1 });
expect(plan.deltas).toEqual([
- expect.objectContaining({ cardId: 1, delta: 1 }),
+ expect.objectContaining({ cardId: 1, delta: 1, categories: [] }),
]);
});
@@ -53,7 +54,7 @@ describe("planMutation — op kinds", () => {
cards: [dc("dc-1", 1, "Forest", 4)],
});
const plan = planMutation(before, [
- { op: "add", cardId: 1, quantity: 2, zone: Zone.MAINBOARD, category: null },
+ { op: "add", cardId: 1, quantity: 2, zone: Zone.MAINBOARD, categories: [] },
]);
expect(plan.ops).toEqual([
@@ -64,16 +65,19 @@ describe("planMutation — op kinds", () => {
]);
});
- it("remove → delete op + negative delta", () => {
+ it("remove → delete op + negative delta carrying the before-side categories", () => {
const before = snapshotFromCards({
format: Format.COMMANDER,
- cards: [dc("dc-1", 1, "Sol Ring", 4, Zone.MAINBOARD, "Artifact")],
+ cards: [
+ dc("dc-1", 1, "Sol Ring", 4, Zone.MAINBOARD, "Artifact", ["Ramp"]),
+ ],
+ categoryNames: ["Ramp"],
});
const plan = planMutation(before, [{ op: "remove", deckCardId: "dc-1" }]);
expect(plan.ops).toEqual([{ kind: "delete", deckCardId: "dc-1" }]);
expect(plan.deltas).toEqual([
- expect.objectContaining({ cardId: 1, delta: -4 }),
+ expect.objectContaining({ cardId: 1, delta: -4, categories: ["Ramp"] }),
]);
});
@@ -115,7 +119,7 @@ describe("planMutation — op kinds", () => {
cards: [dc("dc-1", 1, "Sol Ring", 1, Zone.MAINBOARD, "Artifact")],
});
const plan = planMutation(before, [
- { op: "move", deckCardId: "dc-1", zone: Zone.SIDEBOARD, category: null },
+ { op: "move", deckCardId: "dc-1", zone: Zone.SIDEBOARD, categories: [] },
]);
expect(plan.ops).toEqual([
@@ -129,18 +133,121 @@ describe("planMutation — op kinds", () => {
);
});
- it("no-op move (same zone+category) → no ops, no deltas", () => {
+ it("no-op move (same zone + same categories) → no ops, no deltas", () => {
const before = snapshotFromCards({
format: Format.COMMANDER,
cards: [dc("dc-1", 1, "Sol Ring", 1, Zone.MAINBOARD, "Artifact")],
});
const plan = planMutation(before, [
- { op: "move", deckCardId: "dc-1", zone: Zone.MAINBOARD, category: null },
+ { op: "move", deckCardId: "dc-1", zone: Zone.MAINBOARD, categories: [] },
]);
expect(plan.ops).toEqual([]);
expect(plan.deltas).toEqual([]);
});
+
+ it("category-only change → categories update op + zero-delta recategorization entry", () => {
+ const before = snapshotFromCards({
+ format: Format.COMMANDER,
+ cards: [
+ dc("dc-1", 1, "Sol Ring", 1, Zone.MAINBOARD, "Artifact", ["Ramp"]),
+ ],
+ categoryNames: ["Ramp", "Rocks"],
+ });
+ const plan = planMutation(before, [
+ {
+ op: "move",
+ deckCardId: "dc-1",
+ zone: Zone.MAINBOARD,
+ categories: ["Rocks"],
+ },
+ ]);
+
+ expect(plan.structural).toEqual([]);
+ expect(plan.ops).toEqual([
+ { kind: "update", deckCardId: "dc-1", categories: ["Rocks"] },
+ ]);
+ expect(plan.deltas).toEqual([
+ {
+ cardId: 1,
+ cardName: "Sol Ring",
+ zone: Zone.MAINBOARD,
+ categories: ["Rocks"],
+ previousCategories: ["Ramp"],
+ delta: 0,
+ },
+ ]);
+ });
+
+ it("quantity change with unchanged categories carries no previousCategories", () => {
+ const before = snapshotFromCards({
+ format: Format.COMMANDER,
+ cards: [
+ dc("dc-1", 1, "Sol Ring", 1, Zone.MAINBOARD, "Artifact", ["Ramp"]),
+ ],
+ categoryNames: ["Ramp"],
+ });
+ const plan = planMutation(before, [
+ { op: "update", deckCardId: "dc-1", quantity: 2 },
+ ]);
+
+ expect(plan.deltas).toEqual([
+ expect.objectContaining({ cardId: 1, delta: 1, categories: ["Ramp"] }),
+ ]);
+ expect(plan.deltas[0]).not.toHaveProperty("previousCategories");
+ });
+
+ it("two before-side rows sharing (cardId, zone) accumulate delta without the second row overwriting previousCategories", () => {
+ // dc-1 and dc-2 are distinct DeckCard rows (e.g. different printings)
+ // that collapse onto the same computeDeltas accumulator key
+ // (`${cardId}|${zone}`). The second before-side bump must add to the
+ // existing entry's delta without touching its categories/previousCategories
+ // — that update only happens on after-side bumps.
+ const before = snapshotFromCards({
+ format: Format.COMMANDER,
+ cards: [
+ dc("dc-1", 1, "Sol Ring", 2, Zone.MAINBOARD, "Artifact", ["Ramp"]),
+ dc("dc-2", 1, "Sol Ring", 3, Zone.MAINBOARD, "Artifact", ["Rocks"]),
+ ],
+ extraMeta: [{ cardId: 2, name: "Forest", typeLine: "Basic Land — Forest" }],
+ categoryNames: ["Ramp", "Rocks"],
+ });
+ const plan = planMutation(before, [
+ { op: "add", cardId: 2, quantity: 1, zone: Zone.MAINBOARD, categories: [] },
+ ]);
+
+ expect(plan.deltas).toContainEqual({
+ cardId: 1,
+ cardName: "Sol Ring",
+ zone: Zone.MAINBOARD,
+ categories: ["Rocks"],
+ previousCategories: ["Ramp"],
+ delta: 0,
+ });
+ });
+
+ it("categorized add carries no previousCategories (no before-state)", () => {
+ const before = snapshotFromCards({
+ format: Format.COMMANDER,
+ cards: [],
+ extraMeta: [{ cardId: 1, name: "Sol Ring", typeLine: "Artifact" }],
+ categoryNames: ["Ramp"],
+ });
+ const plan = planMutation(before, [
+ {
+ op: "add",
+ cardId: 1,
+ quantity: 1,
+ zone: Zone.MAINBOARD,
+ categories: ["Ramp"],
+ },
+ ]);
+
+ expect(plan.deltas).toEqual([
+ expect.objectContaining({ delta: 1, categories: ["Ramp"] }),
+ ]);
+ expect(plan.deltas[0]).not.toHaveProperty("previousCategories");
+ });
});
describe("planMutation — opts matrix", () => {
@@ -177,17 +284,33 @@ describe("planMutation — guards", () => {
expect(plan.deltas).toEqual([]);
});
- it("structural issues are surfaced (category on non-MAINBOARD)", () => {
+ it("structural issues are surfaced (categories on non-MAINBOARD)", () => {
const before = snapshotFromCards({
format: Format.COMMANDER,
cards: [dc("dc-1", 1, "Sol Ring", 1, Zone.MAINBOARD, "Artifact")],
+ categoryNames: ["Ramp"],
});
const plan = planMutation(before, [
- { op: "move", deckCardId: "dc-1", zone: Zone.SIDEBOARD, category: "Ramp" },
+ { op: "move", deckCardId: "dc-1", zone: Zone.SIDEBOARD, categories: ["Ramp"] },
]);
expect(
plan.structural.some((i) => i.kind === "category_zone_mismatch"),
).toBe(true);
});
+
+ it("structural issues are surfaced (unknown category name)", () => {
+ const before = snapshotFromCards({
+ format: Format.COMMANDER,
+ cards: [dc("dc-1", 1, "Sol Ring", 1, Zone.MAINBOARD, "Artifact")],
+ categoryNames: ["Ramp"],
+ });
+ const plan = planMutation(before, [
+ { op: "move", deckCardId: "dc-1", zone: Zone.MAINBOARD, categories: ["Ghost"] },
+ ]);
+
+ expect(plan.structural).toEqual([
+ { kind: "unknown_category", category: "Ghost" },
+ ]);
+ });
});
diff --git a/lib/deck/mutation/__tests__/revision.test.ts b/lib/deck/mutation/__tests__/revision.test.ts
index 8e27ab3..3c558c6 100644
--- a/lib/deck/mutation/__tests__/revision.test.ts
+++ b/lib/deck/mutation/__tests__/revision.test.ts
@@ -14,7 +14,7 @@ const delta: RevisionDelta = {
cardId: 1,
cardName: "Lightning Bolt",
zone: Zone.MAINBOARD,
- category: null,
+ categories: [],
delta: 1,
};
@@ -127,7 +127,7 @@ describe("recordDeckRevisionTx", () => {
cardId: 1,
cardName: "Lightning Bolt",
zone: Zone.MAINBOARD,
- category: null,
+ categories: [],
delta: 1,
},
];
@@ -151,7 +151,7 @@ describe("recordDeckRevisionTx", () => {
cardId: 1,
cardName: "Lightning Bolt",
zone: Zone.MAINBOARD,
- category: null,
+ categories: [],
delta: 3,
},
],
@@ -160,6 +160,47 @@ describe("recordDeckRevisionTx", () => {
expect(tx.deckRevision.create).not.toHaveBeenCalled();
});
+ it("normalizes stored legacy single-category payloads before merging", async () => {
+ const recentDate = new Date(Date.now() - 30 * 1000);
+ // Pre-multi-category rows carried `category: string | null`.
+ const storedChanges = [
+ {
+ cardId: 1,
+ cardName: "Lightning Bolt",
+ zone: Zone.MAINBOARD,
+ category: "Burn",
+ delta: 1,
+ },
+ ];
+ const tx = makeTx({
+ findFirst: {
+ id: "rev-legacy",
+ updatedAt: recentDate,
+ changes: storedChanges,
+ },
+ });
+
+ const incomingDelta: RevisionDelta = { ...delta, delta: 2 };
+ await recordDeckRevisionTx(tx, DECK_ID, USER_ID, [incomingDelta]);
+
+ // Legacy category normalizes to ["Burn"]; the incoming delta merges on
+ // the same (cardId, zone) key and overwrites the memberships.
+ expect(tx.deckRevision.update).toHaveBeenCalledWith({
+ where: { id: "rev-legacy" },
+ data: {
+ changes: [
+ {
+ cardId: 1,
+ cardName: "Lightning Bolt",
+ zone: Zone.MAINBOARD,
+ categories: [],
+ delta: 3,
+ },
+ ],
+ },
+ });
+ });
+
it("deletes the revision when merging results in zero net delta", async () => {
const recentDate = new Date(Date.now() - 30 * 1000);
const storedChanges = [
@@ -167,7 +208,7 @@ describe("recordDeckRevisionTx", () => {
cardId: 1,
cardName: "Lightning Bolt",
zone: Zone.MAINBOARD,
- category: null,
+ categories: [],
delta: 1,
},
];
diff --git a/lib/deck/mutation/__tests__/snapshot-pure.test.ts b/lib/deck/mutation/__tests__/snapshot-pure.test.ts
index eb795e5..2b7d740 100644
--- a/lib/deck/mutation/__tests__/snapshot-pure.test.ts
+++ b/lib/deck/mutation/__tests__/snapshot-pure.test.ts
@@ -10,7 +10,7 @@ function makeDeckCard(
cardId: number;
quantity: number;
zone: Zone;
- category: string | null;
+ categories: string[];
printingId: number | null;
isFoil: boolean;
cardName: string;
@@ -24,7 +24,7 @@ function makeDeckCard(
cardId = 1,
quantity = 1,
zone = Zone.MAINBOARD,
- category = null,
+ categories = [],
printingId = null,
isFoil = false,
cardName = "Lightning Bolt",
@@ -37,7 +37,7 @@ function makeDeckCard(
cardId,
quantity,
zone,
- category,
+ categories,
printingId,
isFoil,
card: { name: cardName, typeLine, colorIdentity, legalities },
@@ -103,7 +103,7 @@ describe("snapshotFromDeck", () => {
cardId: 1,
quantity: 1,
zone: Zone.MAINBOARD,
- category: null,
+ categories: [],
printingId: null,
isFoil: false,
card: {
@@ -128,7 +128,7 @@ describe("snapshotFromCards", () => {
cardId: 1,
cardName: "Forest",
zone: Zone.MAINBOARD,
- category: null,
+ categories: [],
quantity: 1,
typeLine: "Basic Land — Forest",
colorIdentity: ["G"],
diff --git a/lib/deck/mutation/__tests__/snapshot.test.ts b/lib/deck/mutation/__tests__/snapshot.test.ts
index efda52f..d7bb7ba 100644
--- a/lib/deck/mutation/__tests__/snapshot.test.ts
+++ b/lib/deck/mutation/__tests__/snapshot.test.ts
@@ -37,7 +37,7 @@ describe("loadSnapshotForDeck", () => {
cardId: 1,
quantity: 4,
zone: Zone.MAINBOARD,
- category: null,
+ categoryLinks: [{ deckCategory: { name: "Burn" } }],
printingId: null,
isFoil: false,
card: {
@@ -58,11 +58,77 @@ describe("loadSnapshotForDeck", () => {
expect(snap.cards[0]).toMatchObject({
cardName: "Lightning Bolt",
quantity: 4,
+ categories: ["Burn"],
});
expect(snap.categoryNames).toEqual(["Burn"]);
expect(snap.cardMeta.get(1)).toMatchObject({ name: "Lightning Bolt" });
});
+ it("maps categoryLinks to an ordered categories array", async () => {
+ mockFindUnique.mockResolvedValueOnce({
+ id: "deck-1",
+ format: Format.MODERN,
+ cards: [
+ {
+ id: "dc-1",
+ cardId: 1,
+ quantity: 1,
+ zone: Zone.MAINBOARD,
+ categoryLinks: [
+ { deckCategory: { name: "ramp" } },
+ { deckCategory: { name: "draw" } },
+ ],
+ printingId: null,
+ isFoil: false,
+ card: {
+ name: "Sol Ring",
+ typeLine: "Artifact",
+ colorIdentity: [],
+ legalities: { commander: "legal" },
+ },
+ },
+ ],
+ categories: [{ name: "ramp" }, { name: "draw" }],
+ } as never);
+
+ const snap = await loadSnapshotForDeck("deck-1");
+ expect(snap.cards[0]!.categories).toEqual(["ramp", "draw"]);
+ });
+
+ it("promotes the next membership when position 0 was cascade-deleted", async () => {
+ // The query orders links by position asc; a deleted primary leaves gapped
+ // positions [1, 2] and the flattening must treat the first surviving link
+ // as the new primary.
+ mockFindUnique.mockResolvedValueOnce({
+ id: "deck-1",
+ format: Format.MODERN,
+ cards: [
+ {
+ id: "dc-1",
+ cardId: 1,
+ quantity: 1,
+ zone: Zone.MAINBOARD,
+ categoryLinks: [
+ { deckCategory: { name: "draw" } }, // position 1
+ { deckCategory: { name: "burn" } }, // position 2
+ ],
+ printingId: null,
+ isFoil: false,
+ card: {
+ name: "Sol Ring",
+ typeLine: "Artifact",
+ colorIdentity: [],
+ legalities: { commander: "legal" },
+ },
+ },
+ ],
+ categories: [{ name: "draw" }, { name: "burn" }],
+ } as never);
+
+ const snap = await loadSnapshotForDeck("deck-1");
+ expect(snap.cards[0]!.categories[0]).toBe("draw");
+ });
+
it("loads extra metadata for cards introduced by add changes", async () => {
mockFindUnique.mockResolvedValueOnce({
id: "deck-1",
@@ -86,7 +152,7 @@ describe("loadSnapshotForDeck", () => {
cardId: 42,
quantity: 1,
zone: Zone.MAINBOARD,
- category: null,
+ categories: [],
},
]);
@@ -111,7 +177,7 @@ describe("loadSnapshotForDeck", () => {
cardId: 7,
quantity: 1,
zone: Zone.MAINBOARD,
- category: null,
+ categoryLinks: [],
printingId: null,
isFoil: false,
card: {
@@ -131,7 +197,7 @@ describe("loadSnapshotForDeck", () => {
cardId: 7,
quantity: 1,
zone: Zone.MAINBOARD,
- category: null,
+ categories: [],
},
]);
@@ -148,7 +214,7 @@ describe("loadSnapshotForDeck", () => {
cardId: 1,
quantity: 1,
zone: Zone.MAINBOARD,
- category: null,
+ categoryLinks: [],
printingId: null,
isFoil: false,
card: {
@@ -190,7 +256,7 @@ describe("loadSnapshotForDeck", () => {
cardId: 99,
quantity: 1,
zone: Zone.MAINBOARD,
- category: null,
+ categories: [],
},
]);
expect(snap.cardMeta.get(99)?.legalities).toEqual({});
diff --git a/lib/deck/mutation/apply.ts b/lib/deck/mutation/apply.ts
index deb9e9b..4715466 100644
--- a/lib/deck/mutation/apply.ts
+++ b/lib/deck/mutation/apply.ts
@@ -12,32 +12,115 @@ import { loadSnapshotForDeck } from "./snapshot";
import { recordDeckRevisionTx } from "./revision";
import type { PlannedChange } from "./types";
+/**
+ * Resolve the category names referenced by `ops` to `DeckCategory` ids in one
+ * query. Structural validation already rejected unknown names against the
+ * snapshot, so a miss here means the category was deleted mid-flight — throw
+ * rather than silently drop a membership.
+ */
+async function resolveCategoryIds(
+ tx: Prisma.TransactionClient,
+ deckId: string,
+ ops: readonly DbOp[],
+): Promise> {
+ const names = new Set();
+ for (const op of ops) {
+ if (op.kind === "create" || op.kind === "update") {
+ for (const name of op.categories ?? []) names.add(name);
+ }
+ }
+ if (names.size === 0) return new Map();
+
+ const rows = await tx.deckCategory.findMany({
+ where: { deckId, name: { in: [...names] } },
+ select: { id: true, name: true },
+ });
+ const byName = new Map(rows.map((r) => [r.name, r.id]));
+ for (const name of names) {
+ if (!byName.has(name)) {
+ throw new Error(`Category "${name}" not found in deck`);
+ }
+ }
+ return byName;
+}
+
+/**
+ * Replace a row's memberships wholesale. Delete-then-create renumbers
+ * positions 0..n-1, so the write is idempotent and position gaps left by
+ * cascade deletes never accumulate.
+ */
+async function replaceCategoryLinks(
+ tx: Prisma.TransactionClient,
+ deckCardId: string,
+ categories: readonly string[],
+ categoryIdByName: Map,
+): Promise {
+ await tx.deckCardCategory.deleteMany({ where: { deckCardId } });
+ if (categories.length === 0) return;
+ await tx.deckCardCategory.createMany({
+ data: categories.map((name, position) => ({
+ deckCardId,
+ deckCategoryId: categoryIdByName.get(name)!,
+ position,
+ })),
+ });
+}
+
async function applyOps(
tx: Prisma.TransactionClient,
deckId: string,
ops: readonly DbOp[],
): Promise {
+ const categoryIdByName = await resolveCategoryIds(tx, deckId, ops);
+
for (const op of ops) {
if (op.kind === "create") {
- await tx.deckCard.create({
+ const created = await tx.deckCard.create({
data: {
deckId,
cardId: op.cardId,
quantity: op.quantity,
zone: op.zone,
- category: op.category,
printingId: op.printingId,
isFoil: op.isFoil,
},
+ select: { id: true },
});
+ if (op.categories.length > 0) {
+ await replaceCategoryLinks(
+ tx,
+ created.id,
+ op.categories,
+ categoryIdByName,
+ );
+ }
} else if (op.kind === "delete") {
await tx.deckCard.delete({ where: { id: op.deckCardId } });
} else {
const data: Prisma.DeckCardUpdateInput = {};
if (op.quantity !== undefined) data.quantity = op.quantity;
if (op.zone !== undefined) data.zone = op.zone;
- if ("category" in op) data.category = op.category;
- await tx.deckCard.update({ where: { id: op.deckCardId }, data });
+ // A category-only update still touches the row so `@updatedAt` reflects
+ // the membership change (the links live on a separate table).
+ /* v8 ignore next 6 -- diffSnapshots only ever emits an "update" op when
+ at least one of quantity/zone/categories changed, so this condition
+ is always true for ops reaching applyOps; the false path is
+ unreachable through applyChanges. */
+ if (
+ op.quantity !== undefined ||
+ op.zone !== undefined ||
+ op.categories !== undefined
+ ) {
+ await tx.deckCard.update({ where: { id: op.deckCardId }, data });
+ }
+ if (op.categories !== undefined) {
+ await replaceCategoryLinks(
+ tx,
+ op.deckCardId,
+ op.categories,
+ categoryIdByName,
+ );
+ }
}
}
}
diff --git a/lib/deck/mutation/diff-snapshots.ts b/lib/deck/mutation/diff-snapshots.ts
index fa96e52..f76e833 100644
--- a/lib/deck/mutation/diff-snapshots.ts
+++ b/lib/deck/mutation/diff-snapshots.ts
@@ -12,7 +12,8 @@ export type DbOp =
cardId: number;
quantity: number;
zone: Zone;
- category: string | null;
+ /** Ordered category memberships; `[0]` is the primary. */
+ categories: string[];
printingId: number | null;
isFoil: boolean;
}
@@ -22,15 +23,23 @@ export type DbOp =
deckCardId: string;
quantity?: number;
zone?: Zone;
- category?: string | null;
+ /** When present, replaces the row's memberships wholesale. */
+ categories?: string[];
};
+export function sameCategories(
+ a: readonly string[],
+ b: readonly string[],
+): boolean {
+ return a.length === b.length && a.every((name, i) => name === b[i]);
+}
+
/**
* Structural diff of two snapshots keyed by `SnapshotCard.id`:
*
* - row flagged `isNew` in `after` → create
* - id in `before` but gone from `after` → delete
- * - same id, quantity/zone/category changed → update (only changed fields)
+ * - same id, quantity/zone/categories changed → update (only changed fields)
*
* Because `projectChanges` already merged add/move targets into existing rows,
* those merges surface here as plain quantity/zone updates plus a delete of the
@@ -51,7 +60,7 @@ export function diffSnapshots(
cardId: a.cardId,
quantity: a.quantity,
zone: a.zone,
- category: a.category,
+ categories: [...a.categories],
printingId: a.printingId ?? null,
isFoil: a.isFoil,
});
@@ -73,8 +82,8 @@ export function diffSnapshots(
op.zone = a.zone;
changed = true;
}
- if (a.category !== b.category) {
- op.category = a.category;
+ if (!sameCategories(a.categories, b.categories)) {
+ op.categories = [...a.categories];
changed = true;
}
if (changed) ops.push(op);
diff --git a/lib/deck/mutation/diff.ts b/lib/deck/mutation/diff.ts
index ff1c00e..0e9fbac 100644
--- a/lib/deck/mutation/diff.ts
+++ b/lib/deck/mutation/diff.ts
@@ -6,11 +6,22 @@ export type ExistingDeckCard = {
deckCardId: string;
cardId: number;
zone: Zone;
- category: string | null;
quantity: number;
+ /**
+ * Whether the row has category memberships. Optional because callers that
+ * never merge duplicate rows (revert, proposals) don't load it; treated as
+ * `false` when absent.
+ */
+ hasCategories?: boolean;
};
-type DesiredEntry = { cardId: number; zone: Zone; quantity: number };
+type DesiredEntry = {
+ cardId: number;
+ zone: Zone;
+ quantity: number;
+ /** First-occurrence memberships for the key; MAINBOARD-only. */
+ categories: string[];
+};
function keyOf(cardId: number, zone: Zone): string {
return `${cardId}|${zone}`;
@@ -28,7 +39,13 @@ function buildDesired(
if (prior) {
prior.quantity += quantity;
} else {
- map.set(key, { cardId: r.cardId, zone, quantity });
+ map.set(key, {
+ cardId: r.cardId,
+ zone,
+ quantity,
+ categories:
+ zone === Zone.MAINBOARD ? [...(r.parsed.categories ?? [])] : [],
+ });
}
}
return map;
@@ -50,12 +67,13 @@ function buildExisting(
{ primary: ExistingDeckCard; extras: ExistingDeckCard[] }
>();
for (const [key, list] of buckets) {
- const sorted = [...list].sort((a, b) => {
- const aHasCat = a.category !== null ? 0 : 1;
- const bHasCat = b.category !== null ? 0 : 1;
- if (aHasCat !== bHasCat) return aHasCat - bHasCat;
- return (a.category ?? "").localeCompare(b.category ?? "");
- });
+ // Categorized rows win the keeper slot so a replace-import that collapses
+ // duplicate rows never deletes the one carrying memberships.
+ const sorted = [...list].sort(
+ (a, b) =>
+ Number(b.hasCategories ?? false) - Number(a.hasCategories ?? false) ||
+ a.deckCardId.localeCompare(b.deckCardId),
+ );
const [primary, ...extras] = sorted;
map.set(key, { primary: primary!, extras });
}
@@ -78,7 +96,7 @@ export function diffDeck(
cardId: want.cardId,
quantity: want.quantity,
zone: want.zone,
- category: null,
+ categories: want.categories,
});
continue;
}
diff --git a/lib/deck/mutation/invariants.ts b/lib/deck/mutation/invariants.ts
index f1e6d06..efa848e 100644
--- a/lib/deck/mutation/invariants.ts
+++ b/lib/deck/mutation/invariants.ts
@@ -16,22 +16,14 @@ function makeDeckCardId(): string {
return Math.random().toString(36).slice(2);
}
-function findRow(
- cards: SnapshotCard[],
- cardId: number,
- zone: Zone,
- category: string | null,
-): SnapshotCard | undefined {
- return cards.find(
- (c) => c.cardId === cardId && c.zone === zone && c.category === category,
- );
-}
-
-function findAddTarget(
+/**
+ * Category memberships are not part of a DeckCard's identity — a merge target
+ * is matched purely on (cardId, zone, printingId, isFoil).
+ */
+function findMergeTarget(
cards: SnapshotCard[],
cardId: number,
zone: Zone,
- category: string | null,
printingId: number | null,
isFoil: boolean,
): SnapshotCard | undefined {
@@ -39,7 +31,6 @@ function findAddTarget(
(c) =>
c.cardId === cardId &&
c.zone === zone &&
- c.category === category &&
(c.printingId ?? null) === printingId &&
c.isFoil === isFoil,
);
@@ -50,16 +41,20 @@ function applyAdd(
before: DeckSnapshot,
change: Extract,
): void {
- const existing = findAddTarget(
+ const existing = findMergeTarget(
cards,
change.cardId,
change.zone,
- change.category,
change.printingId ?? null,
change.isFoil ?? false,
);
if (existing) {
existing.quantity += change.quantity;
+ // A categorized add restates the row's memberships; a plain add (no
+ // categories picked) leaves the existing categorization alone.
+ if (change.categories.length > 0) {
+ existing.categories = [...change.categories];
+ }
return;
}
const meta = before.cardMeta.get(change.cardId);
@@ -68,7 +63,7 @@ function applyAdd(
cardId: change.cardId,
cardName: meta?.name ?? `card:${change.cardId}`,
zone: change.zone,
- category: change.category,
+ categories: [...change.categories],
quantity: change.quantity,
typeLine: meta?.typeLine ?? null,
colorIdentity: meta?.colorIdentity ?? [],
@@ -100,19 +95,45 @@ function applyUpdate(
}
}
+function applySetCategories(
+ cards: SnapshotCard[],
+ change: Extract,
+): void {
+ const row = cards.find(
+ (c) => c.cardId === change.cardId && c.zone === change.zone,
+ );
+ if (!row) return;
+ row.categories = [...change.categories];
+}
+
function applyMove(
cards: SnapshotCard[],
change: Extract,
): void {
const row = cards.find((c) => c.id === change.deckCardId);
if (!row) return;
- const target = findRow(cards, row.cardId, change.zone, change.category);
- if (target && target.id !== row.id) {
+ const target = findMergeTarget(
+ cards.filter((c) => c.id !== row.id),
+ row.cardId,
+ change.zone,
+ row.printingId ?? null,
+ row.isFoil,
+ );
+ if (target) {
target.quantity += row.quantity;
+ // Mirror applyAdd: a plain move (no categories picked) leaves the merge
+ // target's categorization alone; a categorized move promote-merges — the
+ // move's memberships lead, the target's extras follow.
+ if (change.categories.length > 0) {
+ target.categories = [
+ ...change.categories,
+ ...target.categories.filter((n) => !change.categories.includes(n)),
+ ];
+ }
cards.splice(cards.indexOf(row), 1);
} else {
row.zone = change.zone;
- row.category = change.category;
+ row.categories = [...change.categories];
}
}
@@ -120,7 +141,10 @@ export function projectChanges(
before: DeckSnapshot,
changes: readonly PlannedChange[],
): DeckSnapshot {
- const cards: SnapshotCard[] = before.cards.map((c) => ({ ...c }));
+ const cards: SnapshotCard[] = before.cards.map((c) => ({
+ ...c,
+ categories: [...c.categories],
+ }));
for (const change of changes) {
switch (change.op) {
case "add":
@@ -135,6 +159,9 @@ export function projectChanges(
case "move":
applyMove(cards, change);
break;
+ case "setCategories":
+ applySetCategories(cards, change);
+ break;
}
}
return { ...before, cards };
@@ -142,13 +169,29 @@ export function projectChanges(
export function checkStructural(
changes: readonly PlannedChange[],
+ categoryNames: readonly string[],
): LegalityIssue[] {
+ const known = new Set(categoryNames);
const issues: LegalityIssue[] = [];
for (const change of changes) {
- if (change.op === "add" || change.op === "move") {
- if (change.category !== null && change.zone !== Zone.MAINBOARD) {
+ if (
+ change.op === "add" ||
+ change.op === "move" ||
+ change.op === "setCategories"
+ ) {
+ if (change.categories.length > 0 && change.zone !== Zone.MAINBOARD) {
issues.push({ kind: "category_zone_mismatch" });
}
+ const seen = new Set();
+ for (const name of change.categories) {
+ if (!known.has(name)) {
+ issues.push({ kind: "unknown_category", category: name });
+ }
+ if (seen.has(name)) {
+ issues.push({ kind: "duplicate_category", category: name });
+ }
+ seen.add(name);
+ }
}
}
return issues;
diff --git a/lib/deck/mutation/plan.ts b/lib/deck/mutation/plan.ts
index 3d55a25..1f6ca88 100644
--- a/lib/deck/mutation/plan.ts
+++ b/lib/deck/mutation/plan.ts
@@ -1,14 +1,17 @@
import type { RevisionDelta } from "@/lib/deck/revision";
import type { DbOp } from "./diff-snapshots";
-import { diffSnapshots } from "./diff-snapshots";
+import { diffSnapshots, sameCategories } from "./diff-snapshots";
import { checkStructural, projectChanges } from "./invariants";
import type { DeckSnapshot, LegalityIssue, PlannedChange } from "./types";
/**
- * Revision deltas are the net per-(card, zone, category) quantity change between
- * the before snapshot and the projected after snapshot — the *same* projection
+ * Revision deltas are the net per-(card, zone) quantity change between the
+ * before snapshot and the projected after snapshot — the *same* projection
* the DB writes come from, so the audit trail can never disagree with what was
- * actually written.
+ * actually written. Each delta carries the after-side memberships (falling
+ * back to the before-side for pure removals) so a revert can restore them.
+ * When an edit changes memberships without changing quantity, a zero-delta
+ * entry with `previousCategories` records the recategorization.
*/
function computeDeltas(
before: DeckSnapshot,
@@ -20,26 +23,47 @@ function computeDeltas(
cardId: number,
cardName: string,
zone: DeckSnapshot["cards"][number]["zone"],
- category: string | null,
+ categories: readonly string[],
delta: number,
+ fromAfter: boolean,
) => {
- const key = `${cardId}|${zone}|${category ?? ""}`;
+ const key = `${cardId}|${zone}`;
const prior = acc.get(key);
if (prior) {
prior.delta += delta;
+ if (fromAfter) prior.categories = [...categories];
} else {
- acc.set(key, { cardId, cardName, zone, category, delta });
+ acc.set(key, {
+ cardId,
+ cardName,
+ zone,
+ categories: [...categories],
+ // Only before-side rows have a before-state; a pure add has none.
+ ...(fromAfter ? {} : { previousCategories: [...categories] }),
+ delta,
+ });
}
};
for (const c of before.cards) {
- bump(c.cardId, c.cardName, c.zone, c.category, -c.quantity);
+ bump(c.cardId, c.cardName, c.zone, c.categories, -c.quantity, false);
}
for (const c of after.cards) {
- bump(c.cardId, c.cardName, c.zone, c.category, c.quantity);
+ bump(c.cardId, c.cardName, c.zone, c.categories, c.quantity, true);
}
- return [...acc.values()].filter((d) => d.delta !== 0);
+ return [...acc.values()]
+ .map((d): RevisionDelta => {
+ if (
+ d.previousCategories !== undefined &&
+ sameCategories(d.categories, d.previousCategories)
+ ) {
+ const { previousCategories: _omit, ...rest } = d;
+ return rest;
+ }
+ return d;
+ })
+ .filter((d) => d.delta !== 0 || d.previousCategories !== undefined);
}
/**
@@ -62,7 +86,7 @@ export function planMutation(
opts?: { skipRevision?: boolean },
): MutationPlan {
const projected = projectChanges(before, changes);
- const structural = checkStructural(changes);
+ const structural = checkStructural(changes, before.categoryNames);
const beforeIds = new Set(before.cards.map((c) => c.id));
let missingDeckCardId: string | null = null;
diff --git a/lib/deck/mutation/snapshot-pure.ts b/lib/deck/mutation/snapshot-pure.ts
index a917d40..4cec51b 100644
--- a/lib/deck/mutation/snapshot-pure.ts
+++ b/lib/deck/mutation/snapshot-pure.ts
@@ -48,7 +48,7 @@ export function snapshotFromDeck(deck: Deck): DeckSnapshot {
cardId: dc.cardId,
cardName: dc.card.name,
zone: dc.zone,
- category: dc.category,
+ categories: [...dc.categories],
quantity: dc.quantity,
typeLine: dc.card.typeLine ?? null,
colorIdentity: dc.card.colorIdentity ?? [],
diff --git a/lib/deck/mutation/snapshot.ts b/lib/deck/mutation/snapshot.ts
index dadaf31..9c5e201 100644
--- a/lib/deck/mutation/snapshot.ts
+++ b/lib/deck/mutation/snapshot.ts
@@ -32,14 +32,20 @@ export async function loadSnapshotForDeck(
id: true,
format: true,
cards: {
+ // Deterministic row order so merge-target selection and revision
+ // deltas don't depend on Postgres heap order.
+ orderBy: { id: "asc" },
select: {
id: true,
cardId: true,
quantity: true,
zone: true,
- category: true,
printingId: true,
isFoil: true,
+ categoryLinks: {
+ select: { deckCategory: { select: { name: true } } },
+ orderBy: { position: "asc" },
+ },
card: {
select: {
name: true,
@@ -100,7 +106,7 @@ export async function loadSnapshotForDeck(
cardId: dc.cardId,
cardName: dc.card.name,
zone: dc.zone,
- category: dc.category,
+ categories: dc.categoryLinks.map((l) => l.deckCategory.name),
quantity: dc.quantity,
typeLine: dc.card.typeLine,
colorIdentity: dc.card.colorIdentity,
diff --git a/lib/deck/mutation/types.ts b/lib/deck/mutation/types.ts
index 723bb2f..6ec15a4 100644
--- a/lib/deck/mutation/types.ts
+++ b/lib/deck/mutation/types.ts
@@ -11,7 +11,9 @@ export type LegalityIssue =
| { kind: "singleton_violation"; cardName: string; quantity: number }
| { kind: "color_identity_violation"; cardName: string; offending: string[] }
| { kind: "companion_violation"; cardName: string; reason: string }
- | { kind: "category_zone_mismatch" };
+ | { kind: "category_zone_mismatch" }
+ | { kind: "unknown_category"; category: string }
+ | { kind: "duplicate_category"; category: string };
export type PlannedChange =
| {
@@ -19,7 +21,8 @@ export type PlannedChange =
cardId: number;
quantity: number;
zone: Zone;
- category: string | null;
+ /** Ordered category memberships; `[0]` is the primary. */
+ categories: string[];
printingId?: number | null;
isFoil?: boolean;
}
@@ -29,7 +32,20 @@ export type PlannedChange =
op: "move";
deckCardId: string;
zone: Zone;
- category: string | null;
+ /** Ordered category memberships; `[0]` is the primary. */
+ categories: string[];
+ }
+ | {
+ /**
+ * Replace a row's memberships wholesale, addressed by `(cardId, zone)`
+ * instead of `deckCardId` — used by revision revert, which only knows
+ * the delta key. No-op when no row matches.
+ */
+ op: "setCategories";
+ cardId: number;
+ zone: Zone;
+ /** Ordered category memberships; `[0]` is the primary. */
+ categories: string[];
};
export type SnapshotCard = {
@@ -37,7 +53,8 @@ export type SnapshotCard = {
cardId: number;
cardName: string;
zone: Zone;
- category: string | null;
+ /** Ordered category memberships; `[0]` is the primary. */
+ categories: string[];
quantity: number;
typeLine: string | null;
colorIdentity: string[];
diff --git a/lib/deck/queries.ts b/lib/deck/queries.ts
index 5517417..4681795 100644
--- a/lib/deck/queries.ts
+++ b/lib/deck/queries.ts
@@ -559,9 +559,12 @@ export async function getDeckById(id: string) {
cardId: true,
quantity: true,
zone: true,
- category: true,
printingId: true,
isFoil: true,
+ categoryLinks: {
+ select: { deckCategory: { select: { name: true } } },
+ orderBy: { position: "asc" },
+ },
card: {
select: {
id: true,
@@ -631,8 +634,10 @@ export async function getDeckById(id: string) {
return {
...rest,
likeCount: _count?.likes ?? 0,
- cards: deck.cards.map((dc) => ({
+ cards: deck.cards.map(({ categoryLinks, ...dc }) => ({
...dc,
+ // Ordered memberships; `[0]` is the primary category.
+ categories: categoryLinks.map((l) => l.deckCategory.name),
...(dc.card
? {
card: {
diff --git a/lib/deck/revision.ts b/lib/deck/revision.ts
index b9c970e..f958a59 100644
--- a/lib/deck/revision.ts
+++ b/lib/deck/revision.ts
@@ -4,15 +4,46 @@ import type { BulkChange } from "@/lib/deck/editor-actions";
import type { ExistingDeckCard } from "@/lib/deck/mutation/diff";
import { logWarn } from "@/lib/telemetry";
-export const revisionDeltaSchema = z.object({
+const modernRevisionDeltaSchema = z.object({
cardId: z.number().int(),
cardName: z.string(),
zone: z.enum(Zone),
- category: z.string().nullable(),
+ /** Ordered after-state category memberships; `[0]` is the primary. */
+ categories: z.array(z.string()),
+ /**
+ * Before-state memberships, present only when the edit changed them — a
+ * zero-delta entry with this set records a pure recategorization.
+ * `invertDeltas` swaps this with `categories` so an inverted delta is still
+ * "apply `categories`".
+ */
+ previousCategories: z.array(z.string()).optional(),
delta: z.number().int(),
});
-export type RevisionDelta = z.infer;
+/**
+ * Pre-multi-category payloads (stored in `DeckRevision.changes` and
+ * `DeckProposal.changes`) carried a single nullable `category` string.
+ * Normalize them to the modern shape on read.
+ */
+const legacyRevisionDeltaSchema = z
+ .object({
+ cardId: z.number().int(),
+ cardName: z.string(),
+ zone: z.enum(Zone),
+ category: z.string().nullable(),
+ delta: z.number().int(),
+ })
+ .transform(({ category, ...rest }) => ({
+ ...rest,
+ categories: category === null ? [] : [category],
+ }));
+
+export const revisionDeltaSchema = z.union([
+ modernRevisionDeltaSchema,
+ legacyRevisionDeltaSchema,
+]);
+
+export type RevisionDelta = z.infer;
export function parseRevisionDeltas(input: unknown): RevisionDelta[] {
const result = z.array(revisionDeltaSchema).safeParse(input);
@@ -28,10 +59,24 @@ export function parseRevisionDeltas(input: unknown): RevisionDelta[] {
export const REVISION_WINDOW_MS = 5 * 60 * 1000;
-export function deltaKey(
- d: Pick,
-): string {
- return `${d.cardId}|${d.zone}|${d.category ?? ""}`;
+export function deltaKey(d: Pick): string {
+ return `${d.cardId}|${d.zone}`;
+}
+
+function sameCategories(a: readonly string[], b: readonly string[]): boolean {
+ return a.length === b.length && a.every((name, i) => name === b[i]);
+}
+
+/** Drop `previousCategories` when it no longer records a real change. */
+function normalizeDelta(d: RevisionDelta): RevisionDelta {
+ if (
+ d.previousCategories !== undefined &&
+ sameCategories(d.categories, d.previousCategories)
+ ) {
+ const { previousCategories: _omit, ...rest } = d;
+ return rest;
+ }
+ return d;
}
export function mergeDeltas(
@@ -48,11 +93,29 @@ export function mergeDeltas(
if (prior) {
prior.delta += d.delta;
prior.cardName = d.cardName;
+ // Keep the earliest before-state so a merged revision still describes
+ // original → final.
+ if (prior.previousCategories === undefined) {
+ prior.previousCategories = d.previousCategories;
+ }
+ prior.categories = d.categories;
} else {
byKey.set(key, { ...d });
}
}
- return [...byKey.values()].filter((d) => d.delta !== 0);
+ return [...byKey.values()]
+ .map(normalizeDelta)
+ .filter((d) => d.delta !== 0 || d.previousCategories !== undefined);
+}
+
+/**
+ * Net deltas by `(cardId, zone)` key for rendering. Legacy per-category
+ * payloads can repeat a key, which breaks React list keys and double-counts.
+ */
+export function squashDeltas(
+ deltas: readonly RevisionDelta[],
+): RevisionDelta[] {
+ return mergeDeltas([], deltas);
}
export interface DeltaSummary {
@@ -74,17 +137,31 @@ export function summarizeDeltas(
}
export function invertDeltas(deltas: readonly RevisionDelta[]): RevisionDelta[] {
- return deltas.map((d) => ({ ...d, delta: -d.delta }));
+ return deltas.map((d) => {
+ const delta = d.delta === 0 ? 0 : -d.delta;
+ return d.previousCategories === undefined
+ ? { ...d, delta }
+ : {
+ ...d,
+ delta,
+ categories: d.previousCategories,
+ previousCategories: d.categories,
+ };
+ });
}
/**
* Translate revert deltas into BulkChange operations against current deck rows.
* Negative deltas cap at the current quantity so a double-revert doesn't throw
- * after the user has manually removed cards.
+ * after the user has manually removed cards. Deltas are merged first so legacy
+ * per-category payloads (which can repeat a `${cardId}|${zone}` key) net out
+ * before conversion. `knownCategories` filters restored memberships to
+ * categories that still exist in the deck.
*/
export function deltasToBulkChanges(
deltas: readonly RevisionDelta[],
existing: readonly ExistingDeckCard[],
+ knownCategories: ReadonlySet,
): BulkChange[] {
const existingByKey = new Map();
for (const e of existing) {
@@ -92,10 +169,21 @@ export function deltasToBulkChanges(
}
const changes: BulkChange[] = [];
- for (const d of deltas) {
- if (d.delta === 0) continue;
+ for (const d of mergeDeltas([], deltas)) {
const key = deltaKey(d);
const row = existingByKey.get(key);
+ const categories = d.categories.filter((name) => knownCategories.has(name));
+ // A delta that changed memberships applies them to the surviving row;
+ // the add path below carries them on the new row instead.
+ const setCategories = (): void => {
+ if (d.previousCategories === undefined) return;
+ changes.push({
+ op: "setCategories",
+ cardId: d.cardId,
+ zone: d.zone,
+ categories,
+ });
+ };
if (d.delta > 0) {
if (row) {
@@ -104,16 +192,17 @@ export function deltasToBulkChanges(
deckCardId: row.deckCardId,
quantity: row.quantity + d.delta,
});
+ setCategories();
} else {
changes.push({
op: "add",
cardId: d.cardId,
quantity: d.delta,
zone: d.zone,
- category: d.category,
+ categories,
});
}
- } else {
+ } else if (d.delta < 0) {
if (!row) continue;
const next = row.quantity + d.delta;
if (next <= 0) {
@@ -124,7 +213,11 @@ export function deltasToBulkChanges(
deckCardId: row.deckCardId,
quantity: next,
});
+ setCategories();
}
+ } else {
+ if (!row) continue;
+ setCategories();
}
}
return changes;
diff --git a/lib/deck/zone-view.ts b/lib/deck/zone-view.ts
index aead511..b5adf26 100644
--- a/lib/deck/zone-view.ts
+++ b/lib/deck/zone-view.ts
@@ -7,7 +7,12 @@ import { assertNever } from "@/lib/utils";
import type { getDeckById } from "./queries";
export type Deck = NonNullable>>;
-export type DeckCard = Deck["cards"][number];
+/**
+ * `isSecondary` is set on the fan-out copies `groupByCategory` emits for a
+ * card's non-primary memberships: they render ghosted, don't count toward
+ * section totals, and are not draggable.
+ */
+export type DeckCard = Deck["cards"][number] & { isSecondary?: boolean };
export function resolveCardImage(dc: DeckCard): string | null {
return resolveCardImageRule({ printing: dc.printing, card: dc.card });
@@ -28,7 +33,8 @@ export type ZoneAction =
type: "move";
deckCardId: string;
zone: Zone;
- category: string | null;
+ /** Ordered category memberships; `[0]` is the primary. */
+ categories: string[];
};
export function applyZoneOptimistic(
@@ -41,7 +47,7 @@ export function applyZoneOptimistic(
case "move":
return cards.map((c) =>
c.id === action.deckCardId
- ? { ...c, zone: action.zone, category: action.category }
+ ? { ...c, zone: action.zone, categories: action.categories }
: c,
);
case "update":
diff --git a/lib/edhrec/recommendations.ts b/lib/edhrec/recommendations.ts
index 651cdc7..da158fb 100644
--- a/lib/edhrec/recommendations.ts
+++ b/lib/edhrec/recommendations.ts
@@ -153,9 +153,9 @@ export async function getEdhrecSuggestions(
}
return cards.map((card) => {
- const front = card.name.split(" // ")[0]?.toLowerCase();
- const m =
- meta.get(card.name.toLowerCase()) ?? (front ? meta.get(front) : undefined);
+ /* v8 ignore next -- String.split always returns a non-empty array */
+ const front = (card.name.split(" // ")[0] ?? card.name).toLowerCase();
+ const m = meta.get(card.name.toLowerCase()) ?? meta.get(front);
return { ...card, synergy: m?.synergy ?? 0, inclusion: m?.inclusion ?? 0 };
});
}
diff --git a/lib/search/__tests__/card-search.test.ts b/lib/search/__tests__/card-search.test.ts
index 21c8d4f..8e3e65f 100644
--- a/lib/search/__tests__/card-search.test.ts
+++ b/lib/search/__tests__/card-search.test.ts
@@ -440,6 +440,32 @@ describe("findCardsByNames", () => {
expect(result[0]?.id).toBe(7);
});
+ it("keeps only the first row when two DFC rows share a front face", async () => {
+ // Two distinct stored rows both match the front-face LIKE pattern (e.g. the
+ // requested name's pattern happens to match more than one card sharing that
+ // front face). Only the first row encountered should populate the
+ // front-face map; the second must be skipped rather than overwriting it.
+ const first = {
+ ...RAW_ROW,
+ id: 7,
+ name: "Fable // Side A",
+ };
+ const second = {
+ ...RAW_ROW,
+ id: 8,
+ name: "Fable // Side B",
+ };
+ mockQueryRaw
+ .mockResolvedValueOnce([] as never)
+ .mockResolvedValueOnce([first, second] as never);
+
+ const result = await findCardsByNames(["Fable"]);
+
+ expect(result).toHaveLength(1);
+ expect(result[0]?.id).toBe(7);
+ expect(result[0]?.name).toBe("Fable // Side A");
+ });
+
it("escapes LIKE specials in the front-face prefix pattern", async () => {
// An unmatched name with wildcard chars must be escaped so it matches the
// literal front face, not an injected pattern. The left-anchored ` // %`
diff --git a/lib/user/__tests__/feed.test.ts b/lib/user/__tests__/feed.test.ts
index 2f9e086..5fac620 100644
--- a/lib/user/__tests__/feed.test.ts
+++ b/lib/user/__tests__/feed.test.ts
@@ -121,7 +121,7 @@ describe("getFollowingUpdates", () => {
cardId: 1,
cardName: "Sol Ring",
zone: "MAINBOARD",
- category: null,
+ categories: [],
delta: 1,
},
],
diff --git a/prisma/migrations/20260711000000_deck_card_multi_category/migration.sql b/prisma/migrations/20260711000000_deck_card_multi_category/migration.sql
new file mode 100644
index 0000000..05513f9
--- /dev/null
+++ b/prisma/migrations/20260711000000_deck_card_multi_category/migration.sql
@@ -0,0 +1,111 @@
+-- Multi-category cards (issue #30).
+--
+-- Replaces the single `deck_card.category` string with an ordered join table
+-- `deck_card_category` (lowest position = primary). Because category is no
+-- longer part of a DeckCard's identity, rows that only differed by category
+-- are merged into one row per (deck_id, card_id, zone, printing_id, is_foil),
+-- with total quantity preserved exactly and each old category becoming a
+-- membership ordered by how many copies it held.
+
+-- 1. Join table.
+CREATE TABLE "deck_card_category" (
+ "deck_card_id" TEXT NOT NULL,
+ "deck_category_id" TEXT NOT NULL,
+ "position" INTEGER NOT NULL DEFAULT 0,
+
+ CONSTRAINT "deck_card_category_pkey" PRIMARY KEY ("deck_card_id","deck_category_id")
+);
+
+CREATE INDEX "deck_card_category_deck_category_id_idx" ON "deck_card_category"("deck_category_id");
+
+ALTER TABLE "deck_card_category" ADD CONSTRAINT "deck_card_category_deck_card_id_fkey" FOREIGN KEY ("deck_card_id") REFERENCES "deck_card"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+ALTER TABLE "deck_card_category" ADD CONSTRAINT "deck_card_category_deck_category_id_fkey" FOREIGN KEY ("deck_category_id") REFERENCES "deck_category"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- 2. Backfill deck_category rows for orphan MAINBOARD category strings
+-- (the wishlist flow wrote strings without registry rows).
+INSERT INTO "deck_category" ("id", "deck_id", "name", "sort_order")
+SELECT
+ gen_random_uuid()::text,
+ o."deck_id",
+ o."name",
+ COALESCE(m."max_order", -1)
+ + ROW_NUMBER() OVER (PARTITION BY o."deck_id" ORDER BY o."name")
+FROM (
+ SELECT DISTINCT dc."deck_id", dc."category" AS "name"
+ FROM "deck_card" dc
+ WHERE dc."zone" = 'MAINBOARD' AND dc."category" IS NOT NULL
+ AND btrim(dc."category") <> ''
+) o
+LEFT JOIN (
+ SELECT "deck_id", MAX("sort_order") AS "max_order"
+ FROM "deck_category"
+ GROUP BY "deck_id"
+) m ON m."deck_id" = o."deck_id"
+WHERE NOT EXISTS (
+ SELECT 1 FROM "deck_category" c
+ WHERE c."deck_id" = o."deck_id" AND c."name" = o."name"
+);
+
+-- 3. Merge rows that only differ by category. NULL printing_id groups as one
+-- bucket (window PARTITION BY treats NULLs as equal). Keeper = highest
+-- quantity, then oldest, then id for determinism.
+CREATE TEMPORARY TABLE "_dc_merge" AS
+SELECT
+ "id",
+ "deck_id",
+ "zone",
+ "category",
+ "quantity",
+ ROW_NUMBER() OVER (
+ PARTITION BY "deck_id", "card_id", "zone", "printing_id", "is_foil"
+ ORDER BY "quantity" DESC, "created_at" ASC, "id" ASC
+ ) AS "rn",
+ FIRST_VALUE("id") OVER (
+ PARTITION BY "deck_id", "card_id", "zone", "printing_id", "is_foil"
+ ORDER BY "quantity" DESC, "created_at" ASC, "id" ASC
+ ) AS "keeper_id",
+ SUM("quantity") OVER (
+ PARTITION BY "deck_id", "card_id", "zone", "printing_id", "is_foil"
+ ) AS "total_quantity"
+FROM "deck_card";
+
+-- 3a. Memberships: every category held by any row in the bucket, positions
+-- ordered by copies held (desc) so the primary is the category that held
+-- the most copies. Non-MAINBOARD category strings are dropped (they are
+-- already invisible in the app). Positions are 0-based.
+INSERT INTO "deck_card_category" ("deck_card_id", "deck_category_id", "position")
+SELECT
+ k."keeper_id",
+ cat."id",
+ ROW_NUMBER() OVER (
+ PARTITION BY k."keeper_id"
+ ORDER BY k."qty" DESC, cat."name" ASC
+ ) - 1
+FROM (
+ SELECT r."keeper_id", r."deck_id", r."category", SUM(r."quantity") AS "qty"
+ FROM "_dc_merge" r
+ WHERE r."zone" = 'MAINBOARD' AND r."category" IS NOT NULL
+ AND btrim(r."category") <> ''
+ GROUP BY r."keeper_id", r."deck_id", r."category"
+) k
+JOIN "deck_category" cat
+ ON cat."deck_id" = k."deck_id" AND cat."name" = k."category";
+
+-- 3b. Sum merged quantities onto the keeper (totals exactly preserved).
+UPDATE "deck_card" dc
+SET "quantity" = r."total_quantity"
+FROM "_dc_merge" r
+WHERE dc."id" = r."keeper_id"
+ AND r."rn" = 1
+ AND dc."quantity" <> r."total_quantity";
+
+-- 3c. Delete the merged non-keeper rows.
+DELETE FROM "deck_card"
+WHERE "id" IN (SELECT "id" FROM "_dc_merge" WHERE "rn" <> 1);
+
+DROP TABLE "_dc_merge";
+
+-- 4. Drop the old column and its index; replace with a category-free index.
+DROP INDEX IF EXISTS "deck_card_deck_id_card_id_zone_category_idx";
+ALTER TABLE "deck_card" DROP COLUMN "category";
+CREATE INDEX "deck_card_deck_id_card_id_zone_idx" ON "deck_card"("deck_id", "card_id", "zone");
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 00a3158..cd93956 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -295,39 +295,52 @@ model Deck {
}
model DeckCard {
- id String @id @default(cuid())
- deckId String @map("deck_id")
- cardId Int @map("card_id")
- quantity Int @default(1)
- zone Zone @default(MAINBOARD)
- category String?
- printingId Int? @map("printing_id")
- isFoil Boolean @default(false) @map("is_foil")
- createdAt DateTime @default(now()) @map("created_at")
- updatedAt DateTime @updatedAt @map("updated_at")
- deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade)
- card Card @relation(fields: [cardId], references: [id], onDelete: Cascade)
- printing Printing? @relation(fields: [printingId], references: [id], onDelete: SetNull)
-
- @@index([deckId, cardId, zone, category])
+ id String @id @default(cuid())
+ deckId String @map("deck_id")
+ cardId Int @map("card_id")
+ quantity Int @default(1)
+ zone Zone @default(MAINBOARD)
+ printingId Int? @map("printing_id")
+ isFoil Boolean @default(false) @map("is_foil")
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @updatedAt @map("updated_at")
+ deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade)
+ card Card @relation(fields: [cardId], references: [id], onDelete: Cascade)
+ printing Printing? @relation(fields: [printingId], references: [id], onDelete: SetNull)
+ categoryLinks DeckCardCategory[]
+
+ @@index([deckId, cardId, zone])
@@index([deckId, zone])
@@index([cardId])
@@map("deck_card")
}
model DeckCategory {
- id String @id @default(cuid())
- deckId String @map("deck_id")
+ id String @id @default(cuid())
+ deckId String @map("deck_id")
name String
- sortOrder Int @default(0) @map("sort_order")
- createdAt DateTime @default(now()) @map("created_at")
- deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade)
+ sortOrder Int @default(0) @map("sort_order")
+ createdAt DateTime @default(now()) @map("created_at")
+ deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade)
+ cardLinks DeckCardCategory[]
@@unique([deckId, name])
@@index([deckId])
@@map("deck_category")
}
+model DeckCardCategory {
+ deckCardId String @map("deck_card_id")
+ deckCategoryId String @map("deck_category_id")
+ position Int @default(0)
+ deckCard DeckCard @relation(fields: [deckCardId], references: [id], onDelete: Cascade)
+ deckCategory DeckCategory @relation(fields: [deckCategoryId], references: [id], onDelete: Cascade)
+
+ @@id([deckCardId, deckCategoryId])
+ @@index([deckCategoryId])
+ @@map("deck_card_category")
+}
+
model DeckRevision {
id String @id @default(cuid())
deckId String @map("deck_id")
diff --git a/workflows/scryfall/__tests__/steps.test.ts b/workflows/scryfall/__tests__/steps.test.ts
index 0edcff9..b2c7345 100644
--- a/workflows/scryfall/__tests__/steps.test.ts
+++ b/workflows/scryfall/__tests__/steps.test.ts
@@ -1164,6 +1164,61 @@ describe("ingestCollectorPrintings", () => {
expect(mockedPrisma.card.findMany).not.toHaveBeenCalled();
fetchSpy.mockRestore();
});
+
+ it("throws when the search API returns a non-404 error status", async () => {
+ // 400 isn't in fetchWithRetry's default 5xx retry range, so it comes back
+ // immediately as a non-ok Response and must route through throwForStatus
+ // rather than the 404 "empty result" branch.
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response(
+ JSON.stringify({ object: "error", code: "bad_request" }),
+ { status: 400 },
+ ),
+ );
+
+ await expect(ingestCollectorPrintings()).rejects.toThrow(/400/);
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
+ fetchSpy.mockRestore();
+ });
+
+ it("excludes results that parse but fail the paper-playable guard", async () => {
+ const playable = makeCard({
+ id: "jp-1",
+ name: "Counterspell",
+ lang: "ja",
+ printed_name: "対抗呪文",
+ set: "sta",
+ collector_number: "100",
+ });
+ // Parses fine (valid shape), but isPaperPlayable rejects it: games has no
+ // "paper" entry, mirroring a digital-only printing that snuck into a
+ // curated JP query.
+ const digitalOnly = makeCard({
+ id: "jp-2",
+ name: "Lightning Bolt",
+ lang: "ja",
+ games: ["arena"],
+ });
+ const fetchSpy = vi
+ .spyOn(globalThis, "fetch")
+ .mockResolvedValueOnce(searchResponse([playable, digitalOnly]))
+ .mockResolvedValueOnce(searchResponse([]));
+ mockedPrisma.card.findMany.mockResolvedValue([
+ { id: 1, name: "Counterspell" },
+ ] as never);
+ mockedPrisma.printing.findMany.mockResolvedValue([] as never);
+ mockedPrisma.printing.createMany.mockResolvedValue({ count: 1 } as never);
+
+ const stats = await ingestCollectorPrintings();
+
+ expect(stats.printingsInserted).toBe(1);
+ // Only the paper-playable card's name is ever looked up — the
+ // digital-only card was dropped before leaving fetchScryfallSearch.
+ expect(mockedPrisma.card.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({ where: { name: { in: ["Counterspell"] } } }),
+ );
+ fetchSpy.mockRestore();
+ });
});
describe("invalidateSearchCache", () => {