import { test } from 'node:test' import assert from 'node:assert/strict' import { DECK_SIZE } from '../src/lib/commander.js' import { decodeDeck, encodeDeck, formatDecklist, parseDecklist, sortedEntries, } from '../src/lib/deckStore.js' /** * Decklists arrive as text pasted from other tools and leave as links, so the * parser has to survive the formats players actually paste, and the share * encoding has to round-trip a full deck. */ const SAMPLE = `// Atraxa Beatdown Commander: Atraxa, Grand Unifier 1 Sol Ring 1 Command Tower *Lands* 37 Plains 37 Island 5 Swamp Sideboard 2 Negate ` test('a decklist is split into commander, cards and quantity', () => { const deck = parseDecklist(SAMPLE) assert.equal(deck.name, 'Atraxa Beatdown') assert.deepEqual(deck.commanders, [{ id: '', name: 'Atraxa, Grand Unifier' }]) const wanted = Object.fromEntries(deck.cards.map((c) => [c.name, c.qty])) assert.equal(wanted['Sol Ring'], 1) assert.equal(wanted.Plains, 37) assert.equal(wanted.Swamp, 5) assert.equal(wanted.Negate, undefined, 'sideboard cards are not part of the deck') }) test('quantities for the same card are summed', () => { const deck = parseDecklist('3 Forest\n2 Forest\n1 Forest') assert.equal(deck.cards.length, 1) assert.equal(deck.cards[0].qty, 6) }) test('a set code in brackets is not part of the card name', () => { const deck = parseDecklist('1 Lightning Bolt (M10)') assert.equal(deck.cards[0].name, 'Lightning Bolt') }) test('a list with no commander line still parses as a main deck', () => { const deck = parseDecklist('1 Sol Ring\n2 Llanowar Elves') assert.equal(deck.commanders.length, 0) assert.equal(deck.cards.length, 2) }) test('blank lines, category headers and stray text are ignored', () => { const deck = parseDecklist('// Name\n\n*Creatures*\n1 Elvish Mystic\nnot a card line\n1 Sol Ring') assert.equal(deck.cards.length, 2) }) test('a decklist formats back to the standard layout', () => { const deck = parseDecklist(SAMPLE) const text = formatDecklist(deck) assert.match(text, /^\/\/ Atraxa Beatdown/) assert.match(text, /^Commander: Atraxa, Grand Unifier$/m) assert.match(text, /^37 Plains$/m) const round = parseDecklist(text) assert.equal(round.name, deck.name) assert.equal(round.cards.length, deck.cards.length) }) test('entries are ordered by type, then mana value, then name', () => { const deck = { id: 'd', name: 'Order', commanders: [], createdAt: 0, updatedAt: 0, cards: [ { id: '1', name: 'Forest', qty: 10 }, { id: '2', name: 'Giant Growth', qty: 1 }, { id: '3', name: 'Llanowar Elves', qty: 1 }, { id: '4', name: 'Sol Ring', qty: 1 }, { id: '5', name: 'Teferi, Time Raveler', qty: 1 }, ], } const typeLine = { Forest: 'Basic Land — Forest', 'Giant Growth': 'Instant', 'Llanowar Elves': 'Creature — Elf Druid', 'Sol Ring': 'Artifact', 'Teferi, Time Raveler': 'Legendary Planeswalker', } const cost = { 'Giant Growth': 1, 'Llanowar Elves': 1, 'Sol Ring': 1, Forest: 0, 'Teferi, Time Raveler': 2 } const order = sortedEntries(deck, { cmcOf: (e) => cost[e.name] ?? 0, typeOf: (e) => typeLine[e.name] ?? '', }).map((e) => e.name) // Creature, Artifact, Instant, Planeswalker, then Land — the order a // player reads a decklist in, regardless of mana value. The Planeswalker is // here because this list used to be a second copy of TYPE_ORDER that had // drifted from it, and the exported list then disagreed with the deck on // screen. A test without a planeswalker in it cannot catch that. assert.deepEqual(order, [ 'Llanowar Elves', 'Sol Ring', 'Giant Growth', 'Teferi, Time Raveler', 'Forest', ]) }) test('a full deck survives a share link round trip', async () => { const deck = { id: 'local-id', name: "Kaer'shir, the Golden Wake", commanders: [{ id: '', name: "Ka'erishir, the Golden Wake" }], cards: Array.from({ length: DECK_SIZE - 1 }, (_, i) => ({ id: '', name: i === 0 ? 'Sol Ring' : `Card ${i}`, qty: i === 0 ? 1 : 1, })), createdAt: 1, updatedAt: 2, } const url = await encodeDeck(deck) // A share link has to survive being pasted into a chat app, so it must be // URL-safe and must not carry a base64 padding tail. assert.match(url, /^z\.[A-Za-z0-9_-]+$/) const back = await decodeDeck(url) assert.equal(back.name, deck.name) assert.deepEqual(back.commanders, deck.commanders) assert.equal(back.cards.length, deck.cards.length) assert.equal(back.cards[0].name, 'Sol Ring') assert.notEqual(back.id, deck.id, 'a shared deck is copied, not shared by reference') }) test('a share link is short enough to paste into a chat message', async () => { // Discord rejects a message `content` field over 2,000 characters, so that // is the budget a share link has to fit inside. A real 100-card deck did // not: uncompressed it was 3,519 characters, which no chat app would carry. const deck = { id: 'x', name: "Ka'erishir, the Golden Wake", commanders: [{ id: '', name: "Ka'erishir, the Golden Wake" }], cards: [ ...Array.from({ length: 36 }, () => ({ id: '', name: 'Command Tower', qty: 1 })), ...Array.from({ length: 36 }, () => ({ id: '', name: 'Fabled Passage', qty: 1 })), ...Array.from({ length: 27 }, () => ({ id: '', name: 'Bonto, the Giant Pump', qty: 1 })), ], createdAt: 0, updatedAt: 0, } const slug = await encodeDeck(deck) assert.ok(slug.length < 2000, `share slug is ${slug.length} characters, over the chat budget`) }) test('a share link written before compression still opens', async () => { // Links are already in the wild. The prefix is what tells the two formats // apart, so an unprefixed slug must keep decoding as plain base64 JSON. const payload = { n: 'Legacy', c: ['Atraxa, Grand Unifier'], d: ['1xSol Ring', '2xForest'], } const bytes = new TextEncoder().encode(JSON.stringify(payload)) let binary = '' for (const byte of bytes) binary += String.fromCharCode(byte) const legacy = btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '') assert.equal(legacy.startsWith('z.'), false) const deck = await decodeDeck(legacy) assert.equal(deck.name, 'Legacy') assert.deepEqual(deck.commanders, [{ id: '', name: 'Atraxa, Grand Unifier' }]) assert.equal(deck.cards.length, 2) assert.equal(deck.cards[0].name, 'Sol Ring') }) test('a deck full of basic lands survives a share link round trip', async () => { for (const count of [1, 2, 3, 30, 98, 99]) { const deck = { id: 'x', name: 'Lands'.padEnd((count % 5) + 1, 'a'), commanders: [], cards: Array.from({ length: count }, (_, i) => ({ id: '', name: `Land ${i}`, qty: 1 })), createdAt: 0, updatedAt: 0, } const back = await decodeDeck(await encodeDeck(deck)) assert.equal(back.cards.length, count, `lost cards at count ${count}`) assert.equal(back.name, deck.name) } }) test('a card name containing "x" does not confuse the quantity encoding', async () => { const deck = { id: 'x', name: 'Awkward', commanders: [], cards: [{ id: '', name: 'Xenodoch Green', qty: 4 }], createdAt: 0, updatedAt: 0, } const back = await decodeDeck(await encodeDeck(deck)) assert.equal(back.cards[0].name, 'Xenodoch Green') assert.equal(back.cards[0].qty, 4) })