diff --git a/.claude/skills/verify/SKILL.md b/.claude/skills/verify/SKILL.md new file mode 100644 index 0000000..67270bb --- /dev/null +++ b/.claude/skills/verify/SKILL.md @@ -0,0 +1,65 @@ +--- +name: verify +description: Build, launch, and drive maindeck locally to observe a change end-to-end (dev server + Postgres + browser). +--- + +# Verifying maindeck changes at runtime + +## Launch + +```bash +docker compose up -d # postgres :5432 (maindeck/maindeck/maindeck_dev), redis, srh +npm run dev # Next.js on http://localhost:3000 (~30s to first 200) +``` + +`DATABASE_URL` in `.env` points at the docker Postgres. The dev DB usually +already has ~33k cards ingested (Sol Ring=1386, Lightning Bolt=871, +Llanowar Elves=657, Forest=1 — ids stable in the local dataset; re-check with +`SELECT id FROM card WHERE name=...`). + +## Auth + +Sign-up form needs username, email, DOB, password. Two gotchas: + +- The DOB `` rejects accessibility-ref fills; set it via + `chrome-devtools-axi eval` with the native value setter + `input` event. +- Email verification blocks sign-in; flip it in SQL: + `UPDATE "user" SET email_verified = true WHERE email = '...';` + +## Seeding deck fixtures + +Fastest path is SQL against `maindeck_dev` (`PGPASSWORD=maindeck psql -h +localhost -U maindeck -d maindeck_dev`). Minimum viable deck: + +```sql +INSERT INTO deck (id, user_id, name, format, visibility, created_at, updated_at) VALUES (...); +INSERT INTO deck_category (id, deck_id, name, sort_order) VALUES (...); -- names lowercase +INSERT INTO deck_card (id, deck_id, card_id, quantity, zone, created_at, updated_at) VALUES (...); +INSERT INTO deck_card_category (deck_card_id, deck_category_id, position) VALUES (...); -- 0 = primary +``` + +`user.username`, `name`, `date_of_birth` are all NOT NULL. + +## Driving the builder + +- Card row menus: `button "Move card"` → tabs Actions / Category / Zone. + Number keys toggle memberships, Shift+digit promotes primary, menu stays + open (`closeOnClick=false`). +- Section menus: `button "Actions for "` → Move all cards to / + Delete. +- Bulk edit: deck page `button "Bulk edit decklist"` → textarea + Save. + **React textareas ignore plain accessibility fills for state purposes** — + set values with the native setter + `input` event via `eval`, or type + through the keyboard. `press Meta+a` inside the dialog does NOT reliably + select-all; verify the textarea value before submitting (a botched paste + that prepends to existing text parses as garbage and replace-mode will + happily wipe the deck). +- History/revert: `/deck//history` → "Revert all" → confirm dialog + `button "Revert"`. +- Server actions log one line each to the dev-server stdout + (`ƒ actionName(args…)`) — grep it to confirm what actually fired. + +## Evidence + +Assert final state in SQL (memberships, quantities, revisions) — the +`deck_revision.changes` JSON column shows delta payloads directly. diff --git a/CONTEXT.md b/CONTEXT.md index 955e585..a46ca3a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -36,7 +36,9 @@ A rule for bulk-reselecting the pinned **Printing** of every **DeckCard** in a * A user-owned collection of **DeckCards** under one **Format**, with a **Visibility** and an optional **Bracket** override. May be **forked** from another **Deck**. **DeckCard**: -A single line in a **Deck** — `(Card, Zone, Category?, Printing?, quantity, isFoil)`. The unit of mutation in the editor. +A single line in a **Deck** — `(Card, Zone, Printing?, quantity, isFoil)` plus an ordered list of **Category** memberships (join table `DeckCardCategory`). +The unit of mutation in the editor. +**Category** is not part of its identity: one row per `(Card, Zone, Printing, isFoil)` regardless of how many **Categories** it belongs to. _Avoid_: deck entry, slot, card-in-deck. When the surrounding code is unambiguous, "card" is acceptable shorthand — but at module interfaces, prefer **DeckCard**. **Zone**: @@ -47,7 +49,11 @@ _Avoid_: maybeboard (use `CONSIDERING`), board (ambiguous with mainboard). A **Card** placed in the `COMPANION` **Zone** whose deckbuilding restriction the rest of the deck (`MAINBOARD` + `COMMANDER`) must satisfy. There is a fixed set of ten companions; each restriction is a condition from the card's oracle text (e.g. Lurrus: every permanent has mana value ≤ 2). The restrictions are **not** present in Scryfall's structured data — only the `Companion` keyword is — so they are encoded as a name-keyed predicate registry in `lib/deck/legality/companions.ts` and validated as part of `fullLegality`. **Category**: -A user-defined free-text grouping within a **Zone** (e.g. "Ramp", "Removal"). Distinct from **CardType** (Creature/Instant/...) and from **Format**. +A user-defined free-text grouping within the `MAINBOARD` **Zone** (e.g. "Ramp", "Removal"). +A **DeckCard** may belong to several **Categories** at once: memberships are ordered, the first is the **primary**, and the rest are secondary. +The card renders in full under its primary and ghosted under each secondary; section header counts and commander-template targets count every membership (ghosts included), while the stats distribution bar tallies primaries only so it sums to deck size. +Zero memberships means uncategorized; leaving `MAINBOARD` clears all memberships. +Distinct from **CardType** (Creature/Instant/...) and from **Format**. **Visibility**: `PRIVATE` (owner-only, 404s for others), `UNLISTED` (link-accessible, not indexed), or `PUBLIC` (discoverable). Default is `PRIVATE`. Discovery is `kind=DECK` only: a wishlist (`kind=WISHLIST`) made `PUBLIC` is link-accessible via `/deck/[id]` but stays out of discovery and is always `noindex`. @@ -57,6 +63,7 @@ A copy of another **Deck**, retaining a `forkedFromId` pointer to its origin. **Revision**: A `DeckRevision` row capturing a JSON change payload for a **Deck** edit, used for the history view. +Pure recategorizations are recorded too: a zero-quantity delta carrying the memberships before (`previousCategories`) and after (`categories`), so the history shows the change and a revert restores the prior memberships. **Comparison**: A visibility-aware diff of two **Decks** — the **Cards** added, removed, and shared (with quantity deltas), plus a per-deck stat block (mana curve, color pips, type breakdown, average MV, land counts). Computed from each **Deck**'s `MAINBOARD` + `COMMANDER` **Zones** only; `SIDEBOARD` and `CONSIDERING` are excluded, mirroring deck stats. Both **Decks** must be viewable by the viewer (their own or non-`PRIVATE`), else the view 404s rather than 403s to avoid probing which **Deck** ids exist. @@ -103,7 +110,7 @@ A textual representation of a **Deck**. Three on-the-wire shapes: plain `text`, - A **Card** has many **Printings** and may produce many **Tokens**. - A **Deck** has many **DeckCards** and many **Categories**; each **DeckCard** points at one **Card** and optionally pins one **Printing**. -- A **DeckCard** belongs to exactly one **Zone**; **Category** is meaningful only within `MAINBOARD`. +- A **DeckCard** belongs to exactly one **Zone** and to zero or more **Categories** (ordered; first = primary), meaningful only within `MAINBOARD`. Moving out of `MAINBOARD` clears the memberships. - A **Deck** has one **Format**; **Bracket** applies only when the **Format** is `COMMANDER`. - **Legality** is computed from a **Deck** against its **Format** (and, for singleton formats, the commander-zone **Color identity**). - A **Deck** may be **forked** from another **Deck** (`forkedFromId`). @@ -115,8 +122,8 @@ A textual representation of a **Deck**. Three on-the-wire shapes: plain `text`, > **Dev:** "If a user pins a specific **Printing** on a **DeckCard** and that **Printing** later gets pulled from Scryfall, do we drop the **DeckCard**?" > **Domain expert:** "No — only the pin is nullable. The **DeckCard** still references the **Card**, so the deck stays whole; the **Printing** picker just falls back to the canonical first **Printing**." -> **Dev:** "A user moved a **DeckCard** from `MAINBOARD` to `SIDEBOARD`. Does its **Category** come along?" -> **Domain expert:** "No. **Category** is mainboard-only — moving out of `MAINBOARD` clears it. **Categories** are per-**Deck** strings, not first-class entities the **DeckCard** belongs to." +> **Dev:** "A user moved a **DeckCard** from `MAINBOARD` to `SIDEBOARD`. Do its **Category** memberships come along?" +> **Domain expert:** "No. **Category** is mainboard-only — moving out of `MAINBOARD` clears every membership. Deleting a **Category** cascades its memberships away; a card whose primary was deleted just promotes its next membership." > **Dev:** "Should we recompute the **Bracket** when the user changes the **Format** from `COMMANDER` to `MODERN`?" > **Domain expert:** "**Bracket** doesn't exist outside Commander. Hide it. The `manualBracket` value can stay on the row — it just isn't surfaced — so they don't lose the override if they switch back." diff --git a/app/(ui)/deck/[id]/play/page.tsx b/app/(ui)/deck/[id]/play/page.tsx index 7697be1..7fc3957 100644 --- a/app/(ui)/deck/[id]/play/page.tsx +++ b/app/(ui)/deck/[id]/play/page.tsx @@ -38,7 +38,6 @@ async function PlayPageContent({ params }: { params: Promise<{ id: string }> }) id: dc.id, quantity: dc.quantity, zone: dc.zone, - category: dc.category, card: { id: String(dc.card.id), name: dc.card.name, diff --git a/app/(ui)/deck/[id]/play/playtest-client.tsx b/app/(ui)/deck/[id]/play/playtest-client.tsx index 88e6287..2a407bc 100644 --- a/app/(ui)/deck/[id]/play/playtest-client.tsx +++ b/app/(ui)/deck/[id]/play/playtest-client.tsx @@ -17,7 +17,6 @@ interface DeckCardInput { id: string; quantity: number; zone: string; - category: string | null; card: { id: string; name: string; diff --git a/app/(ui)/deck/[id]/propose/page.tsx b/app/(ui)/deck/[id]/propose/page.tsx index da119ad..331184b 100644 --- a/app/(ui)/deck/[id]/propose/page.tsx +++ b/app/(ui)/deck/[id]/propose/page.tsx @@ -15,14 +15,27 @@ async function DeckProposeContent({ id }: { id: string }) { const deck = await getDeckById(id); if (!deck) notFound(); - const mainboard = deck.cards - .filter((c) => c.zone === "MAINBOARD") - .map((c) => ({ - cardId: c.cardId, - cardName: c.card.name, - category: c.category, - quantity: c.quantity, - })); + // Aggregate per card: proposal deltas are keyed by (cardId, zone), so rows + // that only differ by pinned printing collapse into one entry. + const byCard = new Map< + number, + { cardId: number; cardName: string; categories: string[]; quantity: number } + >(); + for (const c of deck.cards) { + if (c.zone !== "MAINBOARD") continue; + const prior = byCard.get(c.cardId); + if (prior) { + prior.quantity += c.quantity; + } else { + byCard.set(c.cardId, { + cardId: c.cardId, + cardName: c.card.name, + categories: c.categories, + quantity: c.quantity, + }); + } + } + const mainboard = [...byCard.values()]; return (
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 {subcategories.map((name) => { - const isCurrent = + const isMember = currentZone === "MAINBOARD" && - currentSubcategory === name; + currentCategories.includes(name); + const isPrimary = + currentZone === "MAINBOARD" && + currentCategories[0] === name; return (
  • - + + {isPrimary ? ( + + ) : isMember ? ( + + ) : 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", () => {