Files
magik/tests/deckStore.test.js
masi a07522a189 MAGIK — Commander deck builder
React 19 SPA + node:http/node:sqlite API in one container. Card data is
baked into the image from a Scryfall bulk build, so the runtime never calls
Scryfall. No server-side user state: decks live in the browser and travel in
the share URL, which is why there is no PVC and replicas: 1 is correct.

Adds a README covering the architecture and the reasoning behind it, and
publishes the project skills under .claude/skills/.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 21:12:19 -03:00

213 lines
7.3 KiB
JavaScript

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)
})