From b028b0fbbe921233d6e15f398f1d43e219a4b991 Mon Sep 17 00:00:00 2001 From: Jarrod Servilla Date: Sun, 12 Jul 2026 09:49:29 -0400 Subject: [PATCH 01/16] feat(deck): add DeckCardCategory join table for multi-category cards Replace DeckCard.category with an ordered join table deck_card_category(deck_card_id, deck_category_id, position); lowest position is the primary membership. The migration backfills registry rows for orphan MAINBOARD category strings, merges rows that only differed by category into one row per (deck, card, zone, printing, foil) with total quantity preserved, and converts old strings into memberships ordered by copies held. Non-MAINBOARD category strings are dropped (already invisible in the app). Part of #30. --- .../migration.sql | 109 ++++++++++++++++++ prisma/schema.prisma | 53 +++++---- 2 files changed, 142 insertions(+), 20 deletions(-) create mode 100644 prisma/migrations/20260711000000_deck_card_multi_category/migration.sql 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..12a75a3 --- /dev/null +++ b/prisma/migrations/20260711000000_deck_card_multi_category/migration.sql @@ -0,0 +1,109 @@ +-- 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 +) 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 + 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") From d18e9fb2559824c7801edd1dc6f5c8d176c7bd20 Mon Sep 17 00:00:00 2001 From: Jarrod Servilla Date: Sun, 12 Jul 2026 09:56:38 -0400 Subject: [PATCH 02/16] feat(deck): thread ordered category memberships through mutation core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PlannedChange add/move, SnapshotCard, DbOp, and RevisionDelta now carry categories: string[] ([0] = primary) instead of a single nullable string. Merge-target identity drops category — rows merge on (cardId, zone, printingId, isFoil) — and category-set changes surface as plain update ops with replace-all join-row writes (delete + createMany renumbering positions 0..n-1). Revision delta identity drops category too; stored legacy payloads in DeckRevision.changes and DeckProposal.changes parse through a zod union that normalizes category → categories. checkStructural gains an unknown_category issue validated against the deck's registry, and revert filters restored memberships to categories that still exist. Part of #30. --- lib/deck/__tests__/group-deltas.test.ts | 4 +- lib/deck/__tests__/revision.test.ts | 185 +++++++++++-- lib/deck/legality.test.ts | 4 +- lib/deck/legality/__tests__/brawl.test.ts | 2 +- lib/deck/legality/__tests__/commander.test.ts | 2 +- .../legality/__tests__/companions.test.ts | 2 +- .../legality/__tests__/oathbreaker.test.ts | 2 +- .../legality/__tests__/sixty-card.test.ts | 2 +- lib/deck/legality/shared.ts | 2 + lib/deck/mutation/__tests__/apply.test.ts | 260 +++++++++++++++--- .../mutation/__tests__/diff-snapshots.test.ts | 100 +++++-- lib/deck/mutation/__tests__/diff.test.ts | 63 ++--- .../mutation/__tests__/invariants.test.ts | 158 +++++++++-- lib/deck/mutation/__tests__/plan.test.ts | 68 ++++- lib/deck/mutation/__tests__/revision.test.ts | 49 +++- .../mutation/__tests__/snapshot-pure.test.ts | 10 +- lib/deck/mutation/__tests__/snapshot.test.ts | 44 ++- lib/deck/mutation/apply.ts | 81 +++++- lib/deck/mutation/diff-snapshots.ts | 18 +- lib/deck/mutation/diff.ts | 12 +- lib/deck/mutation/invariants.ts | 56 ++-- lib/deck/mutation/plan.ts | 27 +- lib/deck/mutation/snapshot-pure.ts | 2 +- lib/deck/mutation/snapshot.ts | 7 +- lib/deck/mutation/types.ts | 12 +- lib/deck/revision.ts | 49 +++- 26 files changed, 978 insertions(+), 243 deletions(-) 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__/revision.test.ts b/lib/deck/__tests__/revision.test.ts index 2c6ded6..5659338 100644 --- a/lib/deck/__tests__/revision.test.ts +++ b/lib/deck/__tests__/revision.test.ts @@ -5,6 +5,7 @@ import { deltasToBulkChanges, invertDeltas, mergeDeltas, + parseRevisionDeltas, summarizeDeltas, type RevisionDelta, } from "@/lib/deck/revision"; @@ -14,31 +15,84 @@ 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, }; } 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"); }); +}); - it("distinguishes zone and category", () => { - expect( - deltaKey({ cardId: 1, zone: Zone.MAINBOARD, category: null }), - ).not.toBe(deltaKey({ cardId: 1, zone: Zone.SIDEBOARD, category: null })); +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("returns [] for malformed payloads", () => { + expect(parseRevisionDeltas([{ bad: "data" }])).toEqual([]); + expect(parseRevisionDeltas("not an array")).toEqual([]); }); }); @@ -86,12 +140,22 @@ 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).toHaveLength(2); + 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"] }), + ]); }); }); @@ -134,38 +198,64 @@ describe("invertDeltas", () => { }); 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 +263,75 @@ 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); + const changes = deltasToBulkChanges( + [delta(1, "Forest", 0)], + existing, + NO_CATEGORIES, + ); expect(changes).toEqual([]); }); 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/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__/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..719fe5a 100644 --- a/lib/deck/legality/shared.ts +++ b/lib/deck/legality/shared.ts @@ -51,6 +51,8 @@ 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`; } } diff --git a/lib/deck/mutation/__tests__/apply.test.ts b/lib/deck/mutation/__tests__/apply.test.ts index 723ac14..db192c1 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,128 @@ 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"] }, + ]); + + expect(mockDeckCardUpdate).not.toHaveBeenCalled(); + expect(mockLinkDeleteMany).toHaveBeenCalledWith({ + where: { deckCardId: "dc-1" }, + }); + expect(mockLinkCreateMany).toHaveBeenCalledWith({ + data: [{ deckCardId: "dc-1", deckCategoryId: "cat-ramp", position: 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 +543,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 +566,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 +580,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 +607,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 +631,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 +657,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 +681,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 +709,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..1b261a6 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,74 +79,47 @@ 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" }); - }); - - 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. - const changes = diffDeck( - [resolved(1, "Forest", 5)], - [ - existing("dc-a", 1, 1, null), - existing("dc-b", 1, 2, null), - ], - ); - // 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).toContainEqual({ op: "remove", deckCardId: "dc-z" }); expect(changes.filter((c) => c.op === "update")).toHaveLength(1); expect(changes.filter((c) => c.op === "remove")).toHaveLength(1); }); @@ -156,14 +127,14 @@ describe("diffDeck", () => { 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..2fd23e0 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,11 +204,50 @@ 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("merging move takes the move's categories on the target", () => { + 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"]); + }); + + 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 }); + }); }); // `fullLegality` is the rule engine; these assert singleton rule behavior on a @@ -163,7 +260,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 +274,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 +287,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 +296,59 @@ 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("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..4684702 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,42 @@ 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 but zero net delta", () => { + 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([]); + }); }); describe("planMutation — opts matrix", () => { @@ -177,17 +205,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..546794b 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,43 @@ 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("loads extra metadata for cards introduced by add changes", async () => { mockFindUnique.mockResolvedValueOnce({ id: "deck-1", @@ -86,7 +118,7 @@ describe("loadSnapshotForDeck", () => { cardId: 42, quantity: 1, zone: Zone.MAINBOARD, - category: null, + categories: [], }, ]); @@ -111,7 +143,7 @@ describe("loadSnapshotForDeck", () => { cardId: 7, quantity: 1, zone: Zone.MAINBOARD, - category: null, + categoryLinks: [], printingId: null, isFoil: false, card: { @@ -131,7 +163,7 @@ describe("loadSnapshotForDeck", () => { cardId: 7, quantity: 1, zone: Zone.MAINBOARD, - category: null, + categories: [], }, ]); @@ -148,7 +180,7 @@ describe("loadSnapshotForDeck", () => { cardId: 1, quantity: 1, zone: Zone.MAINBOARD, - category: null, + categoryLinks: [], printingId: null, isFoil: false, card: { @@ -190,7 +222,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..6fb4e27 100644 --- a/lib/deck/mutation/apply.ts +++ b/lib/deck/mutation/apply.ts @@ -12,32 +12,105 @@ 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 }); + if (op.quantity !== undefined || op.zone !== 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..972456b 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,20 @@ export type DbOp = deckCardId: string; quantity?: number; zone?: Zone; - category?: string | null; + /** When present, replaces the row's memberships wholesale. */ + categories?: string[]; }; +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 +57,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 +79,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..faea9c9 100644 --- a/lib/deck/mutation/diff.ts +++ b/lib/deck/mutation/diff.ts @@ -6,7 +6,6 @@ export type ExistingDeckCard = { deckCardId: string; cardId: number; zone: Zone; - category: string | null; quantity: number; }; @@ -50,12 +49,9 @@ 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 ?? ""); - }); + const sorted = [...list].sort((a, b) => + a.deckCardId.localeCompare(b.deckCardId), + ); const [primary, ...extras] = sorted; map.set(key, { primary: primary!, extras }); } @@ -78,7 +74,7 @@ export function diffDeck( cardId: want.cardId, quantity: want.quantity, zone: want.zone, - category: null, + categories: [], }); continue; } diff --git a/lib/deck/mutation/invariants.ts b/lib/deck/mutation/invariants.ts index f1e6d06..e5a9320 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 ?? [], @@ -106,13 +101,20 @@ function applyMove( ): 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; + target.categories = [...change.categories]; cards.splice(cards.indexOf(row), 1); } else { row.zone = change.zone; - row.category = change.category; + row.categories = [...change.categories]; } } @@ -120,7 +122,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": @@ -142,13 +147,20 @@ 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.categories.length > 0 && change.zone !== Zone.MAINBOARD) { issues.push({ kind: "category_zone_mismatch" }); } + for (const name of change.categories) { + if (!known.has(name)) { + issues.push({ kind: "unknown_category", category: name }); + } + } } } return issues; diff --git a/lib/deck/mutation/plan.ts b/lib/deck/mutation/plan.ts index 3d55a25..889b20c 100644 --- a/lib/deck/mutation/plan.ts +++ b/lib/deck/mutation/plan.ts @@ -5,10 +5,11 @@ 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. */ function computeDeltas( before: DeckSnapshot, @@ -20,23 +21,31 @@ 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], + 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); @@ -62,7 +71,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..e6fba54 100644 --- a/lib/deck/mutation/snapshot.ts +++ b/lib/deck/mutation/snapshot.ts @@ -37,9 +37,12 @@ export async function loadSnapshotForDeck( 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 +103,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..d849476 100644 --- a/lib/deck/mutation/types.ts +++ b/lib/deck/mutation/types.ts @@ -11,7 +11,8 @@ 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 }; export type PlannedChange = | { @@ -19,7 +20,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 +31,8 @@ export type PlannedChange = op: "move"; deckCardId: string; zone: Zone; - category: string | null; + /** Ordered category memberships; `[0]` is the primary. */ + categories: string[]; }; export type SnapshotCard = { @@ -37,7 +40,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/revision.ts b/lib/deck/revision.ts index b9c970e..0894969 100644 --- a/lib/deck/revision.ts +++ b/lib/deck/revision.ts @@ -4,15 +4,39 @@ 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 category memberships at time of change; `[0]` is the primary. */ + categories: z.array(z.string()), 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 +52,8 @@ 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}`; } export function mergeDeltas( @@ -48,6 +70,7 @@ export function mergeDeltas( if (prior) { prior.delta += d.delta; prior.cardName = d.cardName; + prior.categories = d.categories; } else { byKey.set(key, { ...d }); } @@ -80,11 +103,15 @@ export function invertDeltas(deltas: readonly RevisionDelta[]): RevisionDelta[] /** * 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,7 +119,7 @@ export function deltasToBulkChanges( } const changes: BulkChange[] = []; - for (const d of deltas) { + for (const d of mergeDeltas([], deltas)) { if (d.delta === 0) continue; const key = deltaKey(d); const row = existingByKey.get(key); @@ -110,7 +137,9 @@ export function deltasToBulkChanges( cardId: d.cardId, quantity: d.delta, zone: d.zone, - category: d.category, + categories: d.categories.filter((name) => + knownCategories.has(name), + ), }); } } else { From ce501f2d08b7a306552496832114d44434d74e16 Mon Sep 17 00:00:00 2001 From: Jarrod Servilla Date: Sun, 12 Jul 2026 09:57:14 -0400 Subject: [PATCH 03/16] feat(deck): rewrite category server actions for multi-membership MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit renameCategory only touches the registry row (memberships follow the DeckCategory id). deleteCategory's uncategorize mode is a plain registry delete — the FK cascade removes memberships and the next one auto-promotes on read — while deleteCards removes only cards whose primary is the category, through applyChanges so revisions record it. moveCardSubcategory is replaced by setCardCategories (whole-list replace, validated and deduped); moveCardTo promotes the drop target to primary while preserving other memberships; moveCategoryCards operates on primary members and swaps the primary in place; autogenerateCategories routes through applyChanges so runs are recorded as revisions. Wishlist adds now register the source deck name as a real DeckCategory and link it. duplicate copies join rows onto the copy's own registry; export's category filter matches on any membership. Part of #30. --- app/_actions/__tests__/export.test.ts | 18 +- app/_actions/__tests__/inventory.test.ts | 54 +- app/_actions/deck/__tests__/bulk-edit.test.ts | 41 +- .../deck/__tests__/categories.test.ts | 609 +++++++++--------- .../deck/__tests__/collaboration.test.ts | 21 +- app/_actions/deck/__tests__/duplicate.test.ts | 92 ++- app/_actions/deck/__tests__/import.test.ts | 91 ++- app/_actions/deck/__tests__/manabase.test.ts | 10 +- app/_actions/deck/__tests__/revisions.test.ts | 35 +- app/_actions/deck/categories.ts | 311 +++++---- app/_actions/deck/collaboration.ts | 32 +- app/_actions/deck/duplicate.ts | 48 +- app/_actions/deck/export.ts | 6 +- app/_actions/deck/manabase.ts | 4 +- app/_actions/deck/revisions.ts | 32 +- app/_actions/inventory.ts | 35 +- lib/deck/__tests__/editor-actions.test.ts | 40 +- lib/deck/editor-actions.ts | 18 +- 18 files changed, 897 insertions(+), 600 deletions(-) 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..6ba29a8 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,41 @@ 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("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.objectContaining({ category: null }), + data: expect.not.objectContaining({ categoryLinks: expect.anything() }), }), ); }); @@ -243,9 +283,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 +298,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..950fa8f 100644 --- a/app/_actions/deck/__tests__/bulk-edit.test.ts +++ b/app/_actions/deck/__tests__/bulk-edit.test.ts @@ -61,7 +61,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 +75,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 +108,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 +123,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 +131,7 @@ describe("diffDeck", () => { cardId: 2, quantity: 1, zone: Zone.MAINBOARD, - category: null, + categories: [], }, ]); }); @@ -140,7 +139,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 +150,7 @@ describe("diffDeck", () => { cardId: 1, quantity: 1, zone: Zone.SIDEBOARD, - category: null, + categories: [], }); expect(changes).toHaveLength(2); }); @@ -167,7 +166,7 @@ describe("diffDeck", () => { cardId: 1, quantity: 5, zone: Zone.MAINBOARD, - category: null, + categories: [], }, ]); }); @@ -178,7 +177,7 @@ describe("diffDeck", () => { name: "Not A Real Card", quantity: 1, zone: Zone.MAINBOARD, - category: null, + categories: [], isFoil: false, }, cardId: null, @@ -191,18 +190,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 +224,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 }, ] as never); const result = await bulkReplaceDeck(DECK_ID, "1 Forest"); @@ -250,7 +249,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 }, ] as never); const result = await bulkReplaceDeck(DECK_ID, "4 Forest\n1 Sol Ring"); @@ -267,7 +266,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 +296,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..18e183e 100644 --- a/app/_actions/deck/__tests__/categories.test.ts +++ b/app/_actions/deck/__tests__/categories.test.ts @@ -34,10 +34,6 @@ vi.mock("@/lib/db", () => ({ deckCard: { findMany: vi.fn(), findUnique: vi.fn(), - update: vi.fn(), - updateMany: vi.fn(), - delete: vi.fn(), - deleteMany: vi.fn(), }, $transaction: vi.fn(), }, @@ -52,12 +48,12 @@ import { autogenerateCategories, createCategory, deleteCategory, - moveCardSubcategory, moveCardTo, moveCardZone, moveCategoryCards, renameCategory, reorderCategories, + setCardCategories, } from "../categories"; const mockSession = vi.mocked(requireSession); @@ -70,8 +66,6 @@ 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 +73,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]!; @@ -167,155 +184,123 @@ 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("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 +369,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 +399,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 +407,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 +420,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 +430,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"], + }); + }); - await moveCardSubcategory(DECK_ID, "dc-1", null); + it("uncategorizes with an empty array (skips the registry lookup)", async () => { + mockCardFindUnique.mockResolvedValue( + cardRow("dc-1", Zone.MAINBOARD, ["ramp"]) as never, + ); + + 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 +576,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 +621,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 +632,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 +645,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 +655,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 +726,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 +771,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,7 +809,15 @@ 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" }), @@ -813,7 +827,6 @@ describe("autogenerateCategories", () => { mockCategoryFindUnique.mockResolvedValue(null); mockCategoryFindFirst.mockResolvedValue(null); mockCategoryCreate.mockResolvedValue({ id: "any" } as never); - mockCardUpdateMany.mockResolvedValue({ count: 0 } as never); await autogenerateCategories(DECK_ID, "byType"); @@ -827,29 +840,37 @@ describe("autogenerateCategories", () => { ); 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"]); + 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); 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 () => { @@ -860,7 +881,7 @@ describe("autogenerateCategories", () => { await autogenerateCategories(DECK_ID, "byType"); expect(mockCategoryCreate).not.toHaveBeenCalled(); - expect(mockCardUpdateMany).not.toHaveBeenCalled(); + expect(mockApply).not.toHaveBeenCalled(); }); it("byType: reuses an existing DeckCategory row instead of creating a duplicate", async () => { @@ -868,7 +889,6 @@ describe("autogenerateCategories", () => { mainboardRow("dc-1", { mainType: "Creature" }), ] as never); mockCategoryFindUnique.mockResolvedValue({ id: "cat-existing" } as never); - mockCardUpdateMany.mockResolvedValue({ count: 1 } as never); await autogenerateCategories(DECK_ID, "byType"); @@ -877,7 +897,7 @@ describe("autogenerateCategories", () => { select: { id: true }, }); expect(mockCategoryCreate).not.toHaveBeenCalled(); - expect(mockCardUpdateMany).toHaveBeenCalledTimes(1); + expect(mockApply).toHaveBeenCalledTimes(1); }); it("byType: assigns sortOrder = max+1 when creating a new category alongside existing ones", async () => { @@ -887,7 +907,6 @@ describe("autogenerateCategories", () => { mockCategoryFindUnique.mockResolvedValue(null); mockCategoryFindFirst.mockResolvedValue({ sortOrder: 4 } as never); mockCategoryCreate.mockResolvedValue({ id: "cat-new" } as never); - mockCardUpdateMany.mockResolvedValue({ count: 1 } as never); await autogenerateCategories(DECK_ID, "byType"); @@ -929,22 +948,18 @@ describe("autogenerateCategories", () => { mockCategoryFindUnique.mockResolvedValue(null); mockCategoryFindFirst.mockResolvedValue(null); mockCategoryCreate.mockResolvedValue({ id: "any" } as never); - mockCardUpdateMany.mockResolvedValue({ count: 1 } 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 () => { @@ -954,7 +969,7 @@ describe("autogenerateCategories", () => { expect(mockCategoryFindUnique).not.toHaveBeenCalled(); expect(mockCategoryCreate).not.toHaveBeenCalled(); - expect(mockCardUpdateMany).not.toHaveBeenCalled(); + expect(mockApply).not.toHaveBeenCalled(); }); it("returns early without category writes when every card classifies to null", async () => { @@ -967,7 +982,7 @@ describe("autogenerateCategories", () => { expect(mockCategoryFindUnique).not.toHaveBeenCalled(); expect(mockCategoryCreate).not.toHaveBeenCalled(); - expect(mockCardUpdateMany).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..760d97c 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, @@ -190,16 +195,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 +230,6 @@ describe("submitDeckProposal", () => { id: "dc-stale", cardId: 1, zone: Zone.MAINBOARD, - category: null, quantity: 1, }, ] as never); @@ -230,7 +239,7 @@ describe("submitDeckProposal", () => { cardId: 1, cardName: "Sol Ring", zone: Zone.MAINBOARD, - category: null, + categories: [] as string[], delta: -1, }, ]; @@ -381,7 +390,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..c08baae 100644 --- a/app/_actions/deck/__tests__/duplicate.test.ts +++ b/app/_actions/deck/__tests__/duplicate.test.ts @@ -11,7 +11,10 @@ vi.mock("@/lib/db", () => ({ create: vi.fn(), }, deckCard: { - createMany: vi.fn(), + create: vi.fn(), + }, + deckCategory: { + findMany: vi.fn(), }, $transaction: vi.fn(), $queryRaw: vi.fn(), @@ -27,7 +30,8 @@ import { duplicateDeck } from "../duplicate"; 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 mockCardCreate = vi.mocked(prisma.deckCard.create); +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 +53,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 +89,19 @@ function setupTransaction() { if (typeof fn === "function") { const tx = { deck: { create: mockDeckCreate }, - deckCard: { createMany: mockCardCreateMany }, + deckCard: { create: mockCardCreate }, + deckCategory: { findMany: mockCategoryFindMany }, }; return fn(tx); } }); mockDeckCreate.mockResolvedValue({ id: NEW_DECK_ID } as never); - mockCardCreateMany.mockResolvedValue({ count: 3 } 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); + mockCardCreate.mockResolvedValue({} as never); } beforeEach(() => { @@ -127,27 +140,38 @@ describe("duplicateDeck", () => { }), ); - expect(mockCardCreateMany).toHaveBeenCalledWith({ - data: expect.arrayContaining([ - expect.objectContaining({ - 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, - }), - ]), + // One create per DeckCard; memberships are remapped onto the copy's own + // DeckCategory rows with positions preserved ([0] = primary). + expect(mockCardCreate).toHaveBeenCalledTimes(3); + expect(mockCardCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ + deckId: NEW_DECK_ID, + cardId: 1, + quantity: 2, + zone: "MAINBOARD", + categoryLinks: { + create: [ + { deckCategoryId: "new-cat-ramp", position: 0 }, + { deckCategoryId: "new-cat-removal", position: 1 }, + ], + }, + }), + }); + expect(mockCardCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ + cardId: 2, + quantity: 1, + zone: "SIDEBOARD", + categoryLinks: { create: [] }, + }), + }); + expect(mockCardCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ + cardId: 3, + quantity: 1, + zone: "COMMANDER", + categoryLinks: { create: [] }, + }), }); expect(mockUpdateTag).toHaveBeenCalledWith("deck-list"); @@ -242,14 +266,12 @@ describe("duplicateDeck", () => { await duplicateDeck(DECK_ID); - expect(mockCardCreateMany).toHaveBeenCalledWith({ - data: expect.arrayContaining([ - expect.objectContaining({ isFoil: true, printingId: 5 }), - ]), + expect(mockCardCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ isFoil: true, printingId: 5 }), }); }); - it("skips deckCard.createMany when the source deck has no cards", async () => { + 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); @@ -259,6 +281,8 @@ describe("duplicateDeck", () => { expect(result).toEqual({ id: NEW_DECK_ID }); expect(mockDeckCreate).toHaveBeenCalled(); - expect(mockCardCreateMany).not.toHaveBeenCalled(); + expect(mockCardCreate).not.toHaveBeenCalled(); + // The registry remap load is skipped too — nothing to remap. + expect(mockCategoryFindMany).not.toHaveBeenCalled(); }); }); diff --git a/app/_actions/deck/__tests__/import.test.ts b/app/_actions/deck/__tests__/import.test.ts index dacf932..c031117 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); @@ -94,6 +111,14 @@ function txPassthrough() { update: mockDeckRevisionUpdate, delete: mockDeckRevisionDelete, }, + deckCategory: { + findMany: mockDeckCategoryFindMany, + createMany: mockDeckCategoryCreateMany, + }, + deckCardCategory: { + deleteMany: mockDeckCardCategoryDeleteMany, + createMany: mockDeckCardCategoryCreateMany, + }, }; return fn(tx); } @@ -105,13 +130,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 +216,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 +233,7 @@ describe("importDeck", () => { cardId: 1, quantity: 1, zone: Zone.MAINBOARD, - category: null, + categoryLinks: [], printingId: null, isFoil: false, card: { @@ -260,13 +291,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 +385,7 @@ describe("importDeck", () => { cardId: 1, quantity: 1, zone: Zone.MAINBOARD, - category: null, + categoryLinks: [], printingId: 50, isFoil: false, card: { @@ -445,14 +478,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 +554,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 +577,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..a310207 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, }, ], @@ -201,7 +222,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 +243,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 +252,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 +296,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..3eb4a1e 100644 --- a/app/_actions/deck/categories.ts +++ b/app/_actions/deck/categories.ts @@ -14,8 +14,51 @@ import { type AutogenPreset, } from "@/lib/deck/category-autogen"; -const normalizeCategory = (name: string | null) => - name === null ? null : name.trim().toLowerCase(); +const normalizeCategory = (name: string) => 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, +): Promise { + const rows = await prisma.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 +91,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 +118,31 @@ 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: { - 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 }, - }); + if (parsedMode === "deleteCards") { + const members = await loadCategoryMembers(deckId, categoryName); + const primaryMembers = members.filter( + (m) => m.categories[0] === categoryName, + ); + if (primaryMembers.length > 0) { + await applyChanges( + deckId, + userId, + primaryMembers.map((m) => ({ + op: "remove" as const, + deckCardId: m.id, + })), + ); } - await tx.deckCategory.delete({ where: { id: category.id } }); - }); + } + + 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 +170,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 +208,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 +221,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 +230,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 +251,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 +260,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 +276,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 +342,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 +386,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 +433,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 +445,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 +468,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); @@ -435,11 +498,17 @@ export const autogenerateCategories = runOwnerDeckMutation( } } - for (const [name, ids] of assignments) { - await prisma.deckCard.updateMany({ - where: { id: { in: ids }, deckId }, - data: { category: name }, - }); - } + await applyChanges( + deckId, + userId, + [...assignments].flatMap(([name, ids]) => + ids.map((deckCardId) => ({ + op: "move" as const, + deckCardId, + zone: Zone.MAINBOARD, + categories: [name], + })), + ), + ); }, ); 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..70458fd 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,38 @@ export const duplicateDeck = withActionLogging( }); if (original.cards.length > 0) { - 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, - })), + // 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]), + ); + + for (const c of original.cards) { + await tx.deckCard.create({ + data: { + deckId: deck.id, + cardId: c.cardId, + quantity: c.quantity, + zone: c.zone, + isFoil: c.isFoil, + printingId: c.printingId, + categoryLinks: { + create: c.categoryLinks.flatMap((link) => { + const deckCategoryId = categoryIdByName.get( + link.deckCategory.name, + ); + return deckCategoryId === undefined + ? [] + : [{ deckCategoryId, position: link.position }]; + }), + }, + }, + }); + } } 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..9653ab8 100644 --- a/app/_actions/inventory.ts +++ b/app/_actions/inventory.ts @@ -136,12 +136,39 @@ 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) { + const normalized = categoryName.trim().toLowerCase(); + 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 +186,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/lib/deck/__tests__/editor-actions.test.ts b/lib/deck/__tests__/editor-actions.test.ts index 38dd536..36f267c 100644 --- a/lib/deck/__tests__/editor-actions.test.ts +++ b/lib/deck/__tests__/editor-actions.test.ts @@ -76,10 +76,10 @@ 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"] }); expect(changesPassedToApply()).toEqual([ { @@ -87,12 +87,12 @@ describe("addCardToDeck", () => { cardId: 42, quantity: 3, zone: Zone.MAINBOARD, - category: "Ramp", + categories: ["Ramp"], }, ]); }); - it("defaults to MAINBOARD/null category and quantity 1", async () => { + it("defaults to MAINBOARD/no categories and quantity 1", async () => { asOwner(); await addCardToDeck(DECK_ID, 42); @@ -103,7 +103,7 @@ describe("addCardToDeck", () => { cardId: 42, quantity: 1, zone: Zone.MAINBOARD, - category: null, + categories: [], }, ]); }); @@ -130,7 +130,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 +157,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 +197,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 +303,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 }, ]; @@ -325,7 +327,7 @@ describe("bulkUpdateDeck", () => { // ); // await expect( // bulkUpdateDeck(DECK_ID, [ - // { op: "add", cardId: 7, quantity: 2, zone: Zone.MAINBOARD, category: null }, + // { op: "add", cardId: 7, quantity: 2, zone: Zone.MAINBOARD, categories: [] }, // ]), // ).rejects.toBeInstanceOf(InvariantViolation); // }); @@ -335,7 +337,7 @@ describe("bulkUpdateDeck", () => { 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/editor-actions.ts b/lib/deck/editor-actions.ts index e6be8d4..f8eb2e4 100644 --- a/lib/deck/editor-actions.ts +++ b/lib/deck/editor-actions.ts @@ -16,19 +16,19 @@ export const addCardToDeck = runOwnerDeckMutation( 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 = 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 +42,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 = 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 { From d1718bffbe8f640b20c0b0d8d9a84ea12c349b68 Mon Sep 17 00:00:00 2001 From: Jarrod Servilla Date: Sun, 12 Jul 2026 09:57:47 -0400 Subject: [PATCH 04/16] feat(deck): support category memberships in decklist IO ParsedCard and DeckCardWithDetails carry categories: string[]. The JSON adapter serializes the full ordered list and parses both the modern shape and legacy single-category exports via a per-card union; names are normalized (trim/lowercase) and deduped on parse. Text and Arena exports group by the primary membership only so re-import can't double quantities. Import registers any missing category names in the deck's registry before applying changes, keeping JSON round-trips lossless. Part of #30. --- lib/deck/__tests__/external-fetch.test.ts | 2 +- lib/deck/io/__tests__/card-resolver.test.ts | 2 +- lib/deck/io/__tests__/parse.test.ts | 8 +- lib/deck/io/__tests__/resolve.test.ts | 2 +- lib/deck/io/__tests__/serialize.test.ts | 163 +++++++++----------- lib/deck/io/adapters/__tests__/json.test.ts | 123 ++++++++++++++- lib/deck/io/adapters/_shared.ts | 9 +- lib/deck/io/adapters/dek.ts | 2 +- lib/deck/io/adapters/json.ts | 49 ++++-- lib/deck/io/adapters/types.ts | 3 +- lib/deck/io/intake.ts | 38 ++++- lib/deck/io/parse.ts | 3 +- lib/deck/io/serialize.ts | 2 +- 13 files changed, 284 insertions(+), 122 deletions(-) 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/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__/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..6969ffa 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); @@ -330,7 +321,7 @@ describe("stripCommentHeaders", () => { card: boltCard, quantity: 4, zone: "MAINBOARD", - category: "Burn", + categories: ["Burn"], }), makeDeckCard({ id: "dc2", @@ -339,7 +330,7 @@ describe("stripCommentHeaders", () => { card: solRingCard, quantity: 1, zone: "MAINBOARD", - category: "Ramp", + categories: ["Ramp"], }), ], [makeCategory("Ramp", 0), makeCategory("Burn", 1)], @@ -645,7 +636,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 +645,7 @@ describe("toMaindeckJson", () => { card: boltCard, quantity: 4, zone: "MAINBOARD", - category: "Burn", + categories: ["Burn", "Removal"], }), makeDeckCard({ id: "dc2", @@ -663,7 +654,7 @@ describe("toMaindeckJson", () => { card: duressCard, quantity: 2, zone: "SIDEBOARD", - category: null, + categories: [], }), ]); const parsed = JSON.parse(toMaindeckJson(deck)); @@ -674,8 +665,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..8062809 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,113 @@ 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 — 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 +243,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..c98b5e6 100644 --- a/lib/deck/io/adapters/json.ts +++ b/lib/deck/io/adapters/json.ts @@ -4,17 +4,32 @@ import type { ParsedCard, ParsedDecklist } from "../parse"; import { MAX_CARD_QTY } from "../consts"; import type { DecklistParser } from "./types"; -const JsonCardSchema = z.object({ +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()), }); +/** Pre-multi-category exports carried a single nullable `category`. */ +const legacyJsonCardSchema = z + .object({ ...jsonCardBase, category: z.string().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(), @@ -50,15 +65,27 @@ 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. + const categories: string[] = []; + for (const raw of c.categories) { + const name = raw.trim().toLowerCase(); + if (name.length > 0 && !categories.includes(name)) { + categories.push(name); + } + } + 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, + }; + }); return { format: "json", cards, unmatchedLines: [], warnings }; } 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..230708d 100644 --- a/lib/deck/io/intake.ts +++ b/lib/deck/io/intake.ts @@ -42,12 +42,45 @@ 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). + */ +async function ensureCategories( + deckId: string, + changes: readonly PlannedChange[], +): Promise { + const names = new Set(); + for (const c of changes) { + if (c.op === "add" || c.op === "move") { + for (const name of c.categories) names.add(name); + } + } + if (names.size === 0) return; + + const existing = await prisma.deckCategory.findMany({ + where: { deckId }, + select: { name: true, sortOrder: true }, + }); + const known = new Set(existing.map((c) => c.name)); + const missing = [...names].filter((name) => !known.has(name)).sort(); + if (missing.length === 0) return; + + let nextOrder = + existing.reduce((max, c) => Math.max(max, c.sortOrder), -1) + 1; + await prisma.deckCategory.createMany({ + data: missing.map((name) => ({ deckId, name, sortOrder: nextOrder++ })), + skipDuplicates: true, + }); +} + async function buildReplaceChanges( deckId: string, resolved: ResolvedDecklist, @@ -58,7 +91,6 @@ async function buildReplaceChanges( id: true, cardId: true, zone: true, - category: true, quantity: true, }, }); @@ -66,7 +98,6 @@ async function buildReplaceChanges( deckCardId: e.id, cardId: e.cardId, zone: e.zone, - category: e.category, quantity: e.quantity, })); return diffDeck(resolved.cards, existing); @@ -125,6 +156,7 @@ export async function intakeDecklist(input: IntakeInput): Promise } try { + await ensureCategories(deckId, changes); await applyChanges(deckId, userId, changes, applyOptions); } catch (err) { // InvariantViolation = legality/structural issues; surface as warnings, drop the batch. diff --git a/lib/deck/io/parse.ts b/lib/deck/io/parse.ts index 9cfbaf6..b5881bc 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 = { 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, From 095f46a304fd276d8a9cdaaa0dbfc10cf26b2481 Mon Sep 17 00:00:00 2001 From: Jarrod Servilla Date: Sun, 12 Jul 2026 09:58:15 -0400 Subject: [PATCH 05/16] feat(deck): render multi-category cards with primary/ghost fan-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deck reads select categoryLinks ordered by position and flatten to categories: string[] on the client DeckCard type. groupByCategory fans a card into every member section — full appearance under its primary, a ghosted (opacity, non-draggable, uncounted) copy under each secondary — with composite dnd-kit ids so ghosts never collide with the real entry. Section counts, commander template targets, and the role bar tally primary membership only. The card menu's category tab becomes multi-select: taps toggle membership without closing, the primary shows a filled star, a hollow star promotes, number keys toggle 1-9 and 0 clears. Dragging onto a section promotes it to primary while preserving other memberships; dragging onto Uncategorized or leaving MAINBOARD clears them. Part of #30. --- app/(ui)/deck/[id]/play/page.tsx | 1 - app/(ui)/deck/[id]/play/playtest-client.tsx | 1 - app/(ui)/deck/[id]/propose/page.tsx | 29 ++- .../card-browser/deck-browser-context.tsx | 4 +- app/_components/builder/card-row-sortable.tsx | 39 ++-- app/_components/builder/card-row.tsx | 1 + .../builder/card-stack-sortable.tsx | 14 +- app/_components/builder/card-stack.tsx | 2 +- .../builder/deck-builder-owner.tsx | 16 +- app/_components/builder/decklist-dnd.tsx | 15 +- app/_components/builder/decklist-section.tsx | 7 +- app/_components/builder/decklist.test.tsx | 6 +- app/_components/builder/decklist.tsx | 4 +- app/_components/builder/move-card-menu.tsx | 174 ++++++++++++------ .../deck/deck-detail-sheet.test.tsx | 2 +- .../deck/deck-proposal-review-list.tsx | 6 +- app/_components/deck/deck-propose-draft.tsx | 11 +- app/_components/deck/revision-diff.tsx | 4 +- .../header-search/deck-mode-bar.test.tsx | 2 +- .../header-search/deck-mode-bar.tsx | 16 +- app/_components/hotkeys/registry.ts | 2 +- app/_components/stats/deck-stats.test.tsx | 6 +- app/_components/stats/role-bar.tsx | 7 +- lib/deck/__tests__/group-sort.test.ts | 48 ++++- lib/deck/__tests__/queries.test.ts | 11 ++ lib/deck/__tests__/shuffle.test.ts | 16 +- lib/deck/__tests__/zone-view.test.ts | 14 +- lib/deck/group-sort.ts | 22 ++- lib/deck/queries.ts | 9 +- lib/deck/zone-view.ts | 12 +- lib/user/__tests__/feed.test.ts | 2 +- 31 files changed, 351 insertions(+), 152 deletions(-) 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/_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..307a8b7 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 ( { for (const id of cardIds) { - dispatch({ type: "move", deckCardId: id, zone, category }); + dispatch({ type: "move", deckCardId: 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..eb6832f 100644 --- a/app/_components/builder/decklist-section.tsx +++ b/app/_components/builder/decklist-section.tsx @@ -246,7 +246,12 @@ export function CategorySectionView({ renderCards, }: CategorySectionViewProps) { const [editing, setEditing] = useState(false); - const total = cards.reduce((sum, dc) => sum + dc.quantity, 0); + // Ghost (secondary-membership) entries are excluded so multi-category + // cards count only toward their primary section. + const total = cards.reduce( + (sum, dc) => (dc.isSecondary ? sum : sum + dc.quantity), + 0, + ); const canManage = isOwner && kind === "category" && !!dbName && !!onRename; const bodyId = `section-body-${droppableId}`; const target = diff --git a/app/_components/builder/decklist.test.tsx b/app/_components/builder/decklist.test.tsx index 3a81edf..68f3b82 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(), @@ -146,7 +146,7 @@ describe("Decklist - category controls", () => { cardId: 1, quantity: 1, zone: "MAINBOARD", - category: null, + categories: [], printingId: null, isFoil: false, createdAt: new Date(), @@ -241,7 +241,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..1aa9bea 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 { 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, @@ -121,29 +122,58 @@ 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 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 setCardCategories(deckId, deckCardId, next); + } else { + await 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 +192,7 @@ export function MoveCardMenu({ ); const isMainboardUncategorized = - currentZone === "MAINBOARD" && currentSubcategory === null; + currentZone === "MAINBOARD" && currentCategories.length === 0; const onMenuKeyDown = useMenuShortcuts([ { @@ -207,17 +237,15 @@ export function MoveCardMenu({ key: "0", disabled: isMainboardUncategorized, action: () => { - handleSubcategoryMove(null); - setDesktopOpen(false); + clearCategories(); }, }, + // 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), - disabled: - currentZone === "MAINBOARD" && currentSubcategory === name, action: () => { - handleSubcategoryMove(name); - setDesktopOpen(false); + toggleCategory(name); }, })), ]); @@ -348,7 +376,8 @@ export function MoveCardMenu({ handleSubcategoryMove(null)} + closeOnClick={false} + onClick={() => clearCategories()} className="gap-2" > {isMainboardUncategorized && ( @@ -365,23 +394,44 @@ export function MoveCardMenu({ 0 {subcategories.map((name, idx) => { - const isCurrent = + const isMember = currentZone === "MAINBOARD" && - currentSubcategory === name; + currentCategories.includes(name); + const isPrimary = + currentZone === "MAINBOARD" && + currentCategories[0] === name; const shortcut = idx < 9 ? String(idx + 1) : null; return ( handleSubcategoryMove(name)} + closeOnClick={false} + onClick={() => toggleCategory(name)} className="gap-2" > - {isCurrent && ( + {isMember && ( )} - + {toTitleCase(name)} + {isPrimary ? ( + + ) : isMember ? ( + + ) : null} {shortcut && ( {shortcut} )} @@ -554,10 +604,7 @@ export function MoveCardMenu({ {subcategories.map((name) => { - const isCurrent = + const isMember = + currentZone === "MAINBOARD" && + currentCategories.includes(name); + const isPrimary = currentZone === "MAINBOARD" && - currentSubcategory === name; + 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..fd144d2 100644 --- a/app/_components/deck/deck-proposal-review-list.tsx +++ b/app/_components/deck/deck-proposal-review-list.tsx @@ -139,7 +139,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..32929c4 100644 --- a/app/_components/deck/revision-diff.tsx +++ b/app/_components/deck/revision-diff.tsx @@ -45,9 +45,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/registry.ts b/app/_components/hotkeys/registry.ts index bf5c910..0bcd5a3 100644 --- a/app/_components/hotkeys/registry.ts +++ b/app/_components/hotkeys/registry.ts @@ -45,7 +45,7 @@ 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: "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/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.tsx b/app/_components/stats/role-bar.tsx index 6229beb..877d811 100644 --- a/app/_components/stats/role-bar.tsx +++ b/app/_components/stats/role-bar.tsx @@ -81,10 +81,15 @@ export function RoleBar({ cards, group, categoryOrder }: RoleBarProps) { (s) => s.cards.length > 0, ); + // Secondary-membership fan-out copies are display-only; counting them + // would double-count multi-category cards. const counts = sections.map((s) => ({ key: s.key, label: toTitleCase(s.label), - count: s.cards.reduce((sum, dc) => sum + dc.quantity, 0), + count: s.cards.reduce( + (sum, dc) => (dc.isSecondary ? sum : sum + dc.quantity), + 0, + ), })); const total = counts.reduce((sum, s) => sum + s.count, 0); 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..f58c3dc 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,9 @@ describe("getDeckById", () => { expect(printing.priceEur).toBe(1.1); expect(printing.priceEurFoil).toBeNull(); + // Category links flatten to ordered membership names; [0] is the primary. + expect(result!.cards[0]!.categories).toEqual(["Ramp", "Artifacts"]); + expect(result!.cards[1]!.categories).toEqual([]); // Null printings pass through untouched. expect(result!.cards[1]!.printing).toBeNull(); }); @@ -851,6 +859,7 @@ describe("getDeckById", () => { cards: [ { id: "dc-1", + categoryLinks: [], card: { id: 7, name: "Lightning Bolt", @@ -899,6 +908,7 @@ describe("getDeckById", () => { cards: [ { id: "dc-1", + categoryLinks: [], printing: { priceUsd: decimal(1.5), priceUsdFoil: decimal(3.25), @@ -908,6 +918,7 @@ describe("getDeckById", () => { }, { id: "dc-2", + categoryLinks: [], printing: { priceUsd: null, priceUsdFoil: null, 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/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/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/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/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, }, ], From 2ffef16a7b96746d6c9a971503e5a0fc499f5b8b Mon Sep 17 00:00:00 2001 From: Jarrod Servilla Date: Sun, 12 Jul 2026 09:58:57 -0400 Subject: [PATCH 06/16] docs(domain): define multi-category memberships in CONTEXT.md Category and DeckCard entries now describe ordered memberships with a primary, primary-only counting, MAINBOARD-only scope, and clearing on zone exit; the example dialogue reflects the join-table model. Part of #30. --- CONTEXT.md | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 955e585..d830056 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 counts and commander-template targets tally the primary only, so nothing is double-counted. +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`. @@ -103,7 +109,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 +121,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." From 29c15152c8ed7ba63505e11a52540e0ec1bb8527 Mon Sep 17 00:00:00 2001 From: Jarrod Servilla Date: Sun, 12 Jul 2026 18:10:57 -0400 Subject: [PATCH 07/16] fix(deck): repair mutation-core category semantics and record recategorizations - applyMove merge now mirrors applyAdd: plain moves keep the target's memberships, categorized moves promote-merge instead of wiping (C1) - reject duplicate membership names structurally and normalize/dedupe at the editor-action boundary via shared normalizeCategory (I2) - record recategorizations as zero-delta revision entries carrying previousCategories; invertDeltas swaps before/after so revert restores prior memberships through a new setCategories op (I3) - replace-import keeper prefers the categorized duplicate row (I5) - deterministic snapshot row order (M3); category-only updates touch the row so @updatedAt reflects membership changes (M4) --- app/_actions/deck/__tests__/bulk-edit.test.ts | 4 +- app/_actions/deck/categories.ts | 3 +- app/_components/deck/revision-diff.tsx | 18 ++ lib/deck/__tests__/editor-actions.test.ts | 25 ++- lib/deck/__tests__/revision.test.ts | 165 +++++++++++++++++- lib/deck/constants.ts | 8 + lib/deck/editor-actions.ts | 16 +- lib/deck/io/__tests__/intake.test.ts | 8 +- lib/deck/io/intake.ts | 2 + lib/deck/legality/shared.ts | 2 + lib/deck/mutation/__tests__/apply.test.ts | 23 ++- lib/deck/mutation/__tests__/diff.test.ts | 14 ++ .../mutation/__tests__/invariants.test.ts | 111 +++++++++++- lib/deck/mutation/__tests__/plan.test.ts | 54 +++++- lib/deck/mutation/apply.ts | 8 +- lib/deck/mutation/diff-snapshots.ts | 5 +- lib/deck/mutation/diff.ts | 14 +- lib/deck/mutation/invariants.ts | 35 +++- lib/deck/mutation/plan.ts | 19 +- lib/deck/mutation/snapshot.ts | 3 + lib/deck/mutation/types.ts | 15 +- lib/deck/revision.ts | 70 +++++++- 22 files changed, 585 insertions(+), 37 deletions(-) diff --git a/app/_actions/deck/__tests__/bulk-edit.test.ts b/app/_actions/deck/__tests__/bulk-edit.test.ts index 950fa8f..2826ba0 100644 --- a/app/_actions/deck/__tests__/bulk-edit.test.ts +++ b/app/_actions/deck/__tests__/bulk-edit.test.ts @@ -224,7 +224,7 @@ describe("bulkReplaceDeck", () => { { id: 1, name: "Forest" }, ] as never); mockDeckCardFindMany.mockResolvedValue([ - { id: "dc-1", cardId: 1, zone: Zone.MAINBOARD, quantity: 1 }, + { id: "dc-1", cardId: 1, zone: Zone.MAINBOARD, quantity: 1, categoryLinks: [] }, ] as never); const result = await bulkReplaceDeck(DECK_ID, "1 Forest"); @@ -249,7 +249,7 @@ describe("bulkReplaceDeck", () => { { id: 2, name: "Sol Ring" }, ] as never); mockDeckCardFindMany.mockResolvedValue([ - { id: "dc-1", cardId: 1, zone: Zone.MAINBOARD, 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"); diff --git a/app/_actions/deck/categories.ts b/app/_actions/deck/categories.ts index 3eb4a1e..612e917 100644 --- a/app/_actions/deck/categories.ts +++ b/app/_actions/deck/categories.ts @@ -2,6 +2,7 @@ import { prisma } from "@/lib/db"; import { Zone } from "@/lib/generated/prisma/client"; +import { normalizeCategory } from "@/lib/deck/constants"; import { applyChanges, runOwnerDeckMutation } from "@/lib/deck/mutation"; import { categoryDeleteModeSchema, @@ -14,8 +15,6 @@ import { type AutogenPreset, } from "@/lib/deck/category-autogen"; -const normalizeCategory = (name: string) => 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 diff --git a/app/_components/deck/revision-diff.tsx b/app/_components/deck/revision-diff.tsx index 32929c4..ceedd24 100644 --- a/app/_components/deck/revision-diff.tsx +++ b/app/_components/deck/revision-diff.tsx @@ -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, @@ -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)} diff --git a/lib/deck/__tests__/editor-actions.test.ts b/lib/deck/__tests__/editor-actions.test.ts index 36f267c..16db0c2 100644 --- a/lib/deck/__tests__/editor-actions.test.ts +++ b/lib/deck/__tests__/editor-actions.test.ts @@ -81,13 +81,32 @@ describe("addCardToDeck", () => { 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, - categories: ["Ramp"], + categories: ["ramp"], + }, + ]); + }); + + 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"], }, ]); }); @@ -176,7 +195,7 @@ describe("addCardsToDeck", () => { expect(changesPassedToApply()).toEqual([ { op: "add", cardId: 1, quantity: 1, zone: Zone.SIDEBOARD, categories: [] }, - { op: "add", cardId: 2, quantity: 1, zone: Zone.MAINBOARD, categories: ["Ramp"] }, + { op: "add", cardId: 2, quantity: 1, zone: Zone.MAINBOARD, categories: ["ramp"] }, ]); }); @@ -189,7 +208,7 @@ describe("addCardsToDeck", () => { }); expect(changesPassedToApply()).toEqual([ - { op: "add", cardId: 7, quantity: 1, zone: Zone.MAINBOARD, categories: ["Lands"] }, + { op: "add", cardId: 7, quantity: 1, zone: Zone.MAINBOARD, categories: ["lands"] }, ]); }); diff --git a/lib/deck/__tests__/revision.test.ts b/lib/deck/__tests__/revision.test.ts index 5659338..5d08fcf 100644 --- a/lib/deck/__tests__/revision.test.ts +++ b/lib/deck/__tests__/revision.test.ts @@ -15,13 +15,20 @@ function delta( cardId: number, cardName: string, d: number, - opts: { zone?: Zone; categories?: string[] } = {}, + opts: { + zone?: Zone; + categories?: string[]; + previousCategories?: string[]; + } = {}, ): RevisionDelta { return { cardId, cardName, zone: opts.zone ?? Zone.MAINBOARD, categories: opts.categories ?? [], + ...(opts.previousCategories !== undefined + ? { previousCategories: opts.previousCategories } + : {}), delta: d, }; } @@ -90,6 +97,26 @@ describe("parseRevisionDeltas", () => { ]); }); + 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([]); @@ -157,6 +184,67 @@ describe("mergeDeltas", () => { 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("summarizeDeltas", () => { @@ -195,6 +283,22 @@ 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", () => { @@ -273,7 +377,7 @@ describe("deltasToBulkChanges", () => { ]); }); - it("skips zero-delta entries", () => { + it("skips zero-delta entries with no membership change", () => { const changes = deltasToBulkChanges( [delta(1, "Forest", 0)], existing, @@ -282,6 +386,63 @@ describe("deltasToBulkChanges", () => { 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)], 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 f8eb2e4..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,6 +11,17 @@ 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", @@ -20,7 +32,7 @@ export const addCardToDeck = runOwnerDeckMutation( ): Promise => { const quantity = opts?.quantity ?? 1; const zone = opts?.zone ?? Zone.MAINBOARD; - const categories = opts?.categories ?? []; + const categories = normalizeCategories(opts?.categories ?? []); if (categories.length > 0 && zone !== Zone.MAINBOARD) { throw new Error("Subcategories only apply to MAINBOARD cards"); @@ -47,7 +59,7 @@ export const addCardsToDeck = runOwnerDeckMutation( ): Promise => { const changes = cards.map((c) => { const zone = c.zone ?? opts?.zone ?? Zone.MAINBOARD; - const categories = c.categories ?? opts?.categories ?? []; + const categories = normalizeCategories(c.categories ?? opts?.categories ?? []); if (categories.length > 0 && zone !== Zone.MAINBOARD) { throw new Error("Subcategories only apply to MAINBOARD cards"); } diff --git a/lib/deck/io/__tests__/intake.test.ts b/lib/deck/io/__tests__/intake.test.ts index 94a9d49..d52d284 100644 --- a/lib/deck/io/__tests__/intake.test.ts +++ b/lib/deck/io/__tests__/intake.test.ts @@ -231,7 +231,7 @@ describe("intakeDecklist — replace mode", () => { id: "dc-1", cardId: 1, zone: Zone.MAINBOARD, - category: null, + categoryLinks: [], quantity: 4, }, ] as never); @@ -263,14 +263,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 +294,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/intake.ts b/lib/deck/io/intake.ts index 230708d..5febbd3 100644 --- a/lib/deck/io/intake.ts +++ b/lib/deck/io/intake.ts @@ -92,6 +92,7 @@ async function buildReplaceChanges( cardId: true, zone: true, quantity: true, + categoryLinks: { take: 1, select: { deckCardId: true } }, }, }); const existing: ExistingDeckCard[] = rows.map((e) => ({ @@ -99,6 +100,7 @@ async function buildReplaceChanges( cardId: e.cardId, zone: e.zone, quantity: e.quantity, + hasCategories: e.categoryLinks.length > 0, })); return diffDeck(resolved.cards, existing); } diff --git a/lib/deck/legality/shared.ts b/lib/deck/legality/shared.ts index 719fe5a..a53cb63 100644 --- a/lib/deck/legality/shared.ts +++ b/lib/deck/legality/shared.ts @@ -53,6 +53,8 @@ export function formatLegalityIssue(issue: LegalityIssue): string { 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 db192c1..9af24d7 100644 --- a/lib/deck/mutation/__tests__/apply.test.ts +++ b/lib/deck/mutation/__tests__/apply.test.ts @@ -422,13 +422,34 @@ describe("applyChanges — basic ops", () => { { op: "move", deckCardId: "dc-1", zone: Zone.MAINBOARD, categories: ["Ramp"] }, ]); - expect(mockDeckCardUpdate).not.toHaveBeenCalled(); + // 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 () => { diff --git a/lib/deck/mutation/__tests__/diff.test.ts b/lib/deck/mutation/__tests__/diff.test.ts index 1b261a6..8f9d7b5 100644 --- a/lib/deck/mutation/__tests__/diff.test.ts +++ b/lib/deck/mutation/__tests__/diff.test.ts @@ -124,6 +124,20 @@ describe("diffDeck", () => { expect(changes.filter((c) => c.op === "remove")).toHaveLength(1); }); + it("keeps the categorized duplicate even when its deckCardId sorts later", () => { + const changes = diffDeck( + [resolved(1, "Forest", 2)], + [ + existing("dc-a", 1, 1), + { ...existing("dc-z", 1, 2), hasCategories: true }, + ], + ); + 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)], diff --git a/lib/deck/mutation/__tests__/invariants.test.ts b/lib/deck/mutation/__tests__/invariants.test.ts index 2fd23e0..9f5f7cb 100644 --- a/lib/deck/mutation/__tests__/invariants.test.ts +++ b/lib/deck/mutation/__tests__/invariants.test.ts @@ -210,7 +210,7 @@ describe("projectChanges", () => { expect(after.cards[0]!.quantity).toBe(2); }); - it("merging move takes the move's categories on the target", () => { + it("categorized move merging into a target promote-merges memberships", () => { const before = snapshotFromCards({ format: Format.COMMANDER, cards: [ @@ -228,7 +228,82 @@ describe("projectChanges", () => { ]); expect(after.cards).toHaveLength(1); expect(after.cards[0]!.id).toBe("dc-2"); - expect(after.cards[0]!.categories).toEqual(["Ramp"]); + 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)", () => { @@ -340,6 +415,38 @@ describe("checkStructural — structural", () => { ]); }); + 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[] = [ { diff --git a/lib/deck/mutation/__tests__/plan.test.ts b/lib/deck/mutation/__tests__/plan.test.ts index 4684702..8b0dcf7 100644 --- a/lib/deck/mutation/__tests__/plan.test.ts +++ b/lib/deck/mutation/__tests__/plan.test.ts @@ -146,7 +146,7 @@ describe("planMutation — op kinds", () => { expect(plan.deltas).toEqual([]); }); - it("category-only change → categories update op but zero net delta", () => { + it("category-only change → categories update op + zero-delta recategorization entry", () => { const before = snapshotFromCards({ format: Format.COMMANDER, cards: [ @@ -167,7 +167,57 @@ describe("planMutation — op kinds", () => { expect(plan.ops).toEqual([ { kind: "update", deckCardId: "dc-1", categories: ["Rocks"] }, ]); - expect(plan.deltas).toEqual([]); + 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("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"); }); }); diff --git a/lib/deck/mutation/apply.ts b/lib/deck/mutation/apply.ts index 6fb4e27..3066311 100644 --- a/lib/deck/mutation/apply.ts +++ b/lib/deck/mutation/apply.ts @@ -100,7 +100,13 @@ async function applyOps( const data: Prisma.DeckCardUpdateInput = {}; if (op.quantity !== undefined) data.quantity = op.quantity; if (op.zone !== undefined) data.zone = op.zone; - if (op.quantity !== undefined || op.zone !== undefined) { + // A category-only update still touches the row so `@updatedAt` reflects + // the membership change (the links live on a separate table). + if ( + op.quantity !== undefined || + op.zone !== undefined || + op.categories !== undefined + ) { await tx.deckCard.update({ where: { id: op.deckCardId }, data }); } if (op.categories !== undefined) { diff --git a/lib/deck/mutation/diff-snapshots.ts b/lib/deck/mutation/diff-snapshots.ts index 972456b..f76e833 100644 --- a/lib/deck/mutation/diff-snapshots.ts +++ b/lib/deck/mutation/diff-snapshots.ts @@ -27,7 +27,10 @@ export type DbOp = categories?: string[]; }; -function sameCategories(a: readonly string[], b: readonly string[]): boolean { +export function sameCategories( + a: readonly string[], + b: readonly string[], +): boolean { return a.length === b.length && a.every((name, i) => name === b[i]); } diff --git a/lib/deck/mutation/diff.ts b/lib/deck/mutation/diff.ts index faea9c9..fcac44d 100644 --- a/lib/deck/mutation/diff.ts +++ b/lib/deck/mutation/diff.ts @@ -7,6 +7,12 @@ export type ExistingDeckCard = { cardId: number; zone: Zone; 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 }; @@ -49,8 +55,12 @@ function buildExisting( { primary: ExistingDeckCard; extras: ExistingDeckCard[] } >(); for (const [key, list] of buckets) { - const sorted = [...list].sort((a, b) => - a.deckCardId.localeCompare(b.deckCardId), + // 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 }); diff --git a/lib/deck/mutation/invariants.ts b/lib/deck/mutation/invariants.ts index e5a9320..efa848e 100644 --- a/lib/deck/mutation/invariants.ts +++ b/lib/deck/mutation/invariants.ts @@ -95,6 +95,17 @@ 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, @@ -110,7 +121,15 @@ function applyMove( ); if (target) { target.quantity += row.quantity; - target.categories = [...change.categories]; + // 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; @@ -140,6 +159,9 @@ export function projectChanges( case "move": applyMove(cards, change); break; + case "setCategories": + applySetCategories(cards, change); + break; } } return { ...before, cards }; @@ -152,14 +174,23 @@ export function checkStructural( const known = new Set(categoryNames); const issues: LegalityIssue[] = []; for (const change of changes) { - if (change.op === "add" || change.op === "move") { + 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); } } } diff --git a/lib/deck/mutation/plan.ts b/lib/deck/mutation/plan.ts index 889b20c..1f6ca88 100644 --- a/lib/deck/mutation/plan.ts +++ b/lib/deck/mutation/plan.ts @@ -1,6 +1,6 @@ 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"; @@ -10,6 +10,8 @@ import type { DeckSnapshot, LegalityIssue, PlannedChange } from "./types"; * the DB writes come from, so the audit trail can never disagree with what was * 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, @@ -36,6 +38,8 @@ function computeDeltas( cardName, zone, categories: [...categories], + // Only before-side rows have a before-state; a pure add has none. + ...(fromAfter ? {} : { previousCategories: [...categories] }), delta, }); } @@ -48,7 +52,18 @@ function computeDeltas( 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); } /** diff --git a/lib/deck/mutation/snapshot.ts b/lib/deck/mutation/snapshot.ts index e6fba54..9c5e201 100644 --- a/lib/deck/mutation/snapshot.ts +++ b/lib/deck/mutation/snapshot.ts @@ -32,6 +32,9 @@ 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, diff --git a/lib/deck/mutation/types.ts b/lib/deck/mutation/types.ts index d849476..6ec15a4 100644 --- a/lib/deck/mutation/types.ts +++ b/lib/deck/mutation/types.ts @@ -12,7 +12,8 @@ export type LegalityIssue = | { kind: "color_identity_violation"; cardName: string; offending: string[] } | { kind: "companion_violation"; cardName: string; reason: string } | { kind: "category_zone_mismatch" } - | { kind: "unknown_category"; category: string }; + | { kind: "unknown_category"; category: string } + | { kind: "duplicate_category"; category: string }; export type PlannedChange = | { @@ -33,6 +34,18 @@ export type PlannedChange = zone: Zone; /** 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 = { diff --git a/lib/deck/revision.ts b/lib/deck/revision.ts index 0894969..b0428ac 100644 --- a/lib/deck/revision.ts +++ b/lib/deck/revision.ts @@ -8,8 +8,15 @@ const modernRevisionDeltaSchema = z.object({ cardId: z.number().int(), cardName: z.string(), zone: z.enum(Zone), - /** Ordered category memberships at time of change; `[0]` is the primary. */ + /** 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(), }); @@ -56,6 +63,22 @@ 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( existing: readonly RevisionDelta[], incoming: readonly RevisionDelta[], @@ -70,12 +93,19 @@ 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); } export interface DeltaSummary { @@ -97,7 +127,17 @@ 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, + }; + }); } /** @@ -120,9 +160,20 @@ export function deltasToBulkChanges( const changes: BulkChange[] = []; for (const d of mergeDeltas([], deltas)) { - if (d.delta === 0) continue; 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) { @@ -131,18 +182,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, - categories: d.categories.filter((name) => - knownCategories.has(name), - ), + categories, }); } - } else { + } else if (d.delta < 0) { if (!row) continue; const next = row.quantity + d.delta; if (next <= 0) { @@ -153,7 +203,11 @@ export function deltasToBulkChanges( deckCardId: row.deckCardId, quantity: next, }); + setCategories(); } + } else { + if (!row) continue; + setCategories(); } } return changes; From 3fdf51dff0d03f52a828269b9d1e77a0abae725d Mon Sep 17 00:00:00 2001 From: Jarrod Servilla Date: Sun, 12 Jul 2026 18:17:52 -0400 Subject: [PATCH 08/16] fix(deck): make category server actions atomic and duplicateDeck bulk - deleteCategory deleteCards mode runs member removal and registry delete in one transaction (I1) - duplicateDeck copies rows with one createMany + identity-tuple remap instead of a per-card create loop in the 5s tx (I4) - wishlist source-deck category names truncate to CATEGORY_NAME_MAX (M1) - autogenerateCategories creates missing registry rows atomically via shared ensureDeckCategories and wraps assignment in the same tx (M2) --- .github/pr/pr_review_30.md | 362 ++++++++++++++++++ app/_actions/__tests__/inventory.test.ts | 21 + .../deck/__tests__/categories.test.ts | 110 ++++-- app/_actions/deck/__tests__/duplicate.test.ts | 82 ++-- app/_actions/deck/categories.ts | 89 ++--- app/_actions/deck/duplicate.ts | 83 +++- app/_actions/inventory.ts | 8 +- lib/deck/category-registry.ts | 30 ++ 8 files changed, 647 insertions(+), 138 deletions(-) create mode 100644 .github/pr/pr_review_30.md create mode 100644 lib/deck/category-registry.ts diff --git a/.github/pr/pr_review_30.md b/.github/pr/pr_review_30.md new file mode 100644 index 0000000..85903ce --- /dev/null +++ b/.github/pr/pr_review_30.md @@ -0,0 +1,362 @@ + + +# Review Notes: Branch `jarrod/30-multi-category-cards` — Multi-category cards (issue #30) + +> No PR exists yet for this branch; these notes review `main...HEAD` (6 commits, 91 files, +2663/−1142). File under PR number once opened. +> +> **Verification pass (2026-07-12)**: C1, I1–I10, M1–M7, M9, M11, L6, and the 🔴 test gap were independently re-verified against the working tree and `main` (code read directly; `main` versions diffed for every regression claim). None refuted. Caveats: L6 is conditional on legacy data actually containing `category = ''`; I8's keyboard-unreachability relies on Base UI's standard roving-focus menu behavior (structurally confirmed: nested ` + ) : null} {shortcut && ( {shortcut} diff --git a/app/_components/deck/deck-proposal-review-list.tsx b/app/_components/deck/deck-proposal-review-list.tsx index fd144d2..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 (
    • diff --git a/app/_components/deck/revision-diff.tsx b/app/_components/deck/revision-diff.tsx index ceedd24..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"; @@ -22,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 (
      diff --git a/app/_components/hotkeys/registry.ts b/app/_components/hotkeys/registry.ts index 0bcd5a3..213aa22 100644 --- a/app/_components/hotkeys/registry.ts +++ b/app/_components/hotkeys/registry.ts @@ -46,6 +46,7 @@ const SHORTCUTS: ShortcutEntry[] = [ { 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: "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/role-bar.tsx b/app/_components/stats/role-bar.tsx index 877d811..29bdaec 100644 --- a/app/_components/stats/role-bar.tsx +++ b/app/_components/stats/role-bar.tsx @@ -77,20 +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, - ); - // Secondary-membership fan-out copies are display-only; counting them - // would double-count multi-category cards. - const counts = sections.map((s) => ({ - key: s.key, - label: toTitleCase(s.label), - count: s.cards.reduce( - (sum, dc) => (dc.isSecondary ? sum : sum + dc.quantity), - 0, - ), - })); + // 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/deck/revision.ts b/lib/deck/revision.ts index b0428ac..f958a59 100644 --- a/lib/deck/revision.ts +++ b/lib/deck/revision.ts @@ -108,6 +108,16 @@ export function mergeDeltas( .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 { added: number; removed: number; From 54a0ed03818a675485326d1187f0e0b648b5a4f1 Mon Sep 17 00:00:00 2001 From: Jarrod Servilla Date: Sun, 12 Jul 2026 20:36:04 -0400 Subject: [PATCH 11/16] fix(deck): skip blank legacy category strings in multi-category migration Guard both backfill predicates with btrim(category) <> '' so legacy empty/whitespace category values produce no phantom registry rows or memberships. Verified against a scratch database seeded with '', ' ', and real category values: only the real name lands in deck_category and row merging is unaffected. --- .github/pr/pr_review_30.md | 2 +- .../20260711000000_deck_card_multi_category/migration.sql | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/pr/pr_review_30.md b/.github/pr/pr_review_30.md index 7ad559a..0bc4b06 100644 --- a/.github/pr/pr_review_30.md +++ b/.github/pr/pr_review_30.md @@ -339,7 +339,7 @@ All changed suites pass locally (776 tests). Quality rating: **MINOR slop** — - [ ] Resolve dead test `editor-actions.test.ts:315-332` **Nice to have:** -- [ ] M3 snapshot `orderBy` ✅; M4 `updatedAt` touch ✅; M5 import caps ✅; M11 role-bar zero rows ✅; L5 `@@unique([deckCardId, position])`; L6 migration `btrim` guard +- [ ] M3 snapshot `orderBy` ✅; M4 `updatedAt` touch ✅; M5 import caps ✅; M11 role-bar zero rows ✅; L5 `@@unique([deckCardId, position])`; L6 migration `btrim` guard ✅ --- ## QUESTIONS FOR AUTHOR diff --git a/prisma/migrations/20260711000000_deck_card_multi_category/migration.sql b/prisma/migrations/20260711000000_deck_card_multi_category/migration.sql index 12a75a3..05513f9 100644 --- a/prisma/migrations/20260711000000_deck_card_multi_category/migration.sql +++ b/prisma/migrations/20260711000000_deck_card_multi_category/migration.sql @@ -34,6 +34,7 @@ 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" @@ -84,6 +85,7 @@ 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 From ddce28905a42a56deec8b6ff2bac3009ca8bf820 Mon Sep 17 00:00:00 2001 From: Jarrod Servilla Date: Sun, 12 Jul 2026 20:42:22 -0400 Subject: [PATCH 12/16] test(deck): close multi-category coverage gaps - render tests pin primary-only tallies: section headers count a multi-category card once; RoleBar drops ghost-only legend rows - gapped-position promotion covered through snapshot and getDeckById flattening (position asc ordering asserted) - composed text/arena round-trip: multi-category card serializes once and re-imports as a single row with its original quantity - re-enable the bulkUpdateDeck InvariantViolation propagation test - CONTEXT.md: Revision entry documents zero-delta recategorizations --- .github/pr/pr_review_30.md | 8 +-- CONTEXT.md | 1 + app/_components/builder/decklist.test.tsx | 24 +++++++ app/_components/stats/role-bar.test.tsx | 67 ++++++++++++++++++++ lib/deck/__tests__/editor-actions.test.ts | 31 ++++----- lib/deck/__tests__/queries.test.ts | 8 +++ lib/deck/io/__tests__/serialize.test.ts | 59 +++++++++++++++++ lib/deck/mutation/__tests__/snapshot.test.ts | 34 ++++++++++ 8 files changed, 210 insertions(+), 22 deletions(-) create mode 100644 app/_components/stats/role-bar.test.tsx diff --git a/.github/pr/pr_review_30.md b/.github/pr/pr_review_30.md index 0bc4b06..66e4c8f 100644 --- a/.github/pr/pr_review_30.md +++ b/.github/pr/pr_review_30.md @@ -333,10 +333,10 @@ All changed suites pass locally (776 tests). Quality rating: **MINOR slop** — - [x] I8 — keyboard path for promote-primary **Test quality:** -- [ ] 🔴 Render test: multi-category card counted once in section header + role bar -- [ ] 🟡 Gapped-position promotion test through `queries.ts` flattening (or one integration test on the cascade) -- [ ] 🟡 Composed export→re-import round-trip with a multi-category card (text + arena) -- [ ] Resolve dead test `editor-actions.test.ts:315-332` +- [x] 🔴 Render test: multi-category card counted once in section header + role bar +- [x] 🟡 Gapped-position promotion test through `queries.ts` flattening (or one integration test on the cascade) +- [x] 🟡 Composed export→re-import round-trip with a multi-category card (text + arena) +- [x] Resolve dead test `editor-actions.test.ts:315-332` **Nice to have:** - [ ] M3 snapshot `orderBy` ✅; M4 `updatedAt` touch ✅; M5 import caps ✅; M11 role-bar zero rows ✅; L5 `@@unique([deckCardId, position])`; L6 migration `btrim` guard ✅ diff --git a/CONTEXT.md b/CONTEXT.md index d830056..a3fa21b 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -63,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. diff --git a/app/_components/builder/decklist.test.tsx b/app/_components/builder/decklist.test.tsx index 68f3b82..a299669 100644 --- a/app/_components/builder/decklist.test.tsx +++ b/app/_components/builder/decklist.test.tsx @@ -129,6 +129,30 @@ describe("Decklist - Command Zone template targets", () => { }); }); +describe("Decklist - multi-category tallies", () => { + it("counts a multi-category card once, under its primary section only", () => { + 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(); + // ...the secondary section shows the ghost but doesn't count it. + const removal = screen.getByRole("region", { name: /^removal \(0\)$/i }); + expect(within(removal).getByText("Llanowar Elves")).toBeInTheDocument(); + }); +}); + describe("Decklist - category controls", () => { it("has no a11y violations", async () => { const deck = makeDeck(["Ramp", "Removal"]); 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/lib/deck/__tests__/editor-actions.test.ts b/lib/deck/__tests__/editor-actions.test.ts index 16db0c2..b8612c5 100644 --- a/lib/deck/__tests__/editor-actions.test.ts +++ b/lib/deck/__tests__/editor-actions.test.ts @@ -332,24 +332,19 @@ 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, categories: [] }, - // ]), - // ).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(); diff --git a/lib/deck/__tests__/queries.test.ts b/lib/deck/__tests__/queries.test.ts index f58c3dc..2eaa053 100644 --- a/lib/deck/__tests__/queries.test.ts +++ b/lib/deck/__tests__/queries.test.ts @@ -842,7 +842,15 @@ describe("getDeckById", () => { 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(); diff --git a/lib/deck/io/__tests__/serialize.test.ts b/lib/deck/io/__tests__/serialize.test.ts index 6969ffa..dd80914 100644 --- a/lib/deck/io/__tests__/serialize.test.ts +++ b/lib/deck/io/__tests__/serialize.test.ts @@ -256,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 = [ diff --git a/lib/deck/mutation/__tests__/snapshot.test.ts b/lib/deck/mutation/__tests__/snapshot.test.ts index 546794b..d7bb7ba 100644 --- a/lib/deck/mutation/__tests__/snapshot.test.ts +++ b/lib/deck/mutation/__tests__/snapshot.test.ts @@ -95,6 +95,40 @@ describe("loadSnapshotForDeck", () => { 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", From bad804661e59153c698cc4ebe54b86b0dd13be68 Mon Sep 17 00:00:00 2001 From: Jarrod Servilla Date: Sun, 12 Jul 2026 21:01:10 -0400 Subject: [PATCH 13/16] docs(agents): add project verify skill with local runtime recipe --- .claude/skills/verify/SKILL.md | 65 ++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 .claude/skills/verify/SKILL.md 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. From 45136f4081ecbf8667cef46397ac4b3ebeba0f00 Mon Sep 17 00:00:00 2001 From: Jarrod Servilla Date: Sun, 12 Jul 2026 22:12:34 -0400 Subject: [PATCH 14/16] tidy: remove review md --- .github/pr/pr_review_30.md | 362 ------------------------------------- 1 file changed, 362 deletions(-) delete mode 100644 .github/pr/pr_review_30.md diff --git a/.github/pr/pr_review_30.md b/.github/pr/pr_review_30.md deleted file mode 100644 index 66e4c8f..0000000 --- a/.github/pr/pr_review_30.md +++ /dev/null @@ -1,362 +0,0 @@ - - -# Review Notes: Branch `jarrod/30-multi-category-cards` — Multi-category cards (issue #30) - -> No PR exists yet for this branch; these notes review `main...HEAD` (6 commits, 91 files, +2663/−1142). File under PR number once opened. -> -> **Verification pass (2026-07-12)**: C1, I1–I10, M1–M7, M9, M11, L6, and the 🔴 test gap were independently re-verified against the working tree and `main` (code read directly; `main` versions diffed for every regression claim). None refuted. Caveats: L6 is conditional on legacy data actually containing `category = ''`; I8's keyboard-unreachability relies on Base UI's standard roving-focus menu behavior (structurally confirmed: nested `