Files
magik/tests/deckStore.test.js
masi c0fe10e664 Batch import, a usable phone layout, and table mode
Three things were missing: a way to get many decklists in at once, a UI
that works on a phone, and somewhere to keep the state of a game in
progress.

Batch import — ImportDialog now takes a folder of .txt files or a pasted
block and splits it on `-----` runs or on each list's own `// name`
header, and reads a JSON backup directly (auto-detected by first
character). Settings gains a matching Restore. Names resolve against
the card index in 150-name chunks; unresolved names keep their entry
with an empty id so the rules panel names them instead of the deck
silently arriving short. importDecks merges by id, so restoring the
same backup twice updates rather than duplicates.

Mobile — the root cause of the layout problems was .app/.main, which
had no min-width:0 and let every page overflow its viewport by 22px
at 390. Fixed that, then stacked the three-column layouts at 1180,
made Browse's card detail a modal below that breakpoint instead of a
display:none third column, and added 44px targets under
`pointer: coarse`, dvh units, safe-area insets and a 16px input floor
to stop iOS zoom.

Table mode — new /table route, one board for the game rather than one
per device. src/lib/table.js is plain JS on purpose so node --test
exercises the same reducer the UI imports: 40 life, poison on every
seat, per-commander damage, arbitrary counters, and a turn/phase
tracker that rotates the active player. Wake lock and fullscreen keep
the screen up, and damage changes are announced through one
role="status" region (WCAG 4.1.3).

58 tests, up from 49.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-02 00:57:55 -03:00

291 lines
10 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,
parseBackup,
parseDecklist,
parseDecklists,
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)
})
/* ── Batch import ─────────────────────────────────────────────────────── */
test('a run of dashes splits a folder of decklists pasted as one text', () => {
const decks = parseDecklists(`// Atraxa Beatdown
Commander: Atraxa, Grand Unifier
1 Sol Ring
-----
//
// Marchesa
Commander: Marchesa, the Black Rose
2 Swamp
`)
assert.equal(decks.length, 2)
assert.equal(decks[0].name, 'Atraxa Beatdown')
assert.equal(decks[0].commanders[0].name, 'Atraxa, Grand Unifier')
assert.equal(decks[1].name, 'Marchesa')
assert.deepEqual(decks[1].cards, [{ id: '', name: 'Swamp', qty: 2 }])
})
test('a second name header also starts a new deck, so a lost separator is survivable', () => {
const decks = parseDecklists(`// First
1 Sol Ring
// Second
1 Counterspell
`)
assert.equal(decks.length, 2)
assert.equal(decks[0].cards.length, 1)
assert.equal(decks[1].cards[0].name, 'Counterspell')
})
test('a leading header does not produce an empty deck before the first list', () => {
const decks = parseDecklists(`// Only one
1 Sol Ring
`)
assert.equal(decks.length, 1)
assert.equal(decks[0].name, 'Only one')
})
test('a chunk with no cards is not offered as a deck', () => {
assert.equal(parseDecklists('-----\n-----\n1 Sol Ring\n').length, 1)
assert.deepEqual(parseDecklists(' \n\n'), [])
})
test('quantities accumulate across a list that repeats a card', () => {
const [deck] = parseDecklists('40 Mountain\n3 Mountain\n')
assert.deepEqual(deck.cards, [{ id: '', name: 'Mountain', qty: 43 }])
})
test('a MAGIK backup restores from both shapes it has been written in', () => {
const deck = {
id: 'keep-me',
name: 'Atraxa Beatdown',
commanders: [{ id: 'a', name: 'Atraxa, Grand Unifier' }],
cards: [{ id: 'b', name: 'Sol Ring', qty: 1 }],
createdAt: 1,
updatedAt: 2,
}
const fromArray = parseBackup(JSON.stringify([deck]))
const fromObject = parseBackup(JSON.stringify({ decks: [deck] }))
assert.equal(fromArray[0].id, 'keep-me', 'ids survive, so a restore is idempotent')
assert.equal(fromObject[0].cards.length, 1)
assert.equal(fromArray[0].name, deck.name)
})
test('a backup entry with no id is given one, so the gallery can open it', () => {
const [deck] = parseBackup(JSON.stringify([{ name: 'No id', cards: [], commanders: [] }]))
assert.ok(deck.id, 'a deck without an id cannot be opened, linked or deleted')
})
test('a file that is not a backup fails with a reason, not a stack trace', () => {
assert.throws(() => parseBackup('not json at all'), /JSON/)
assert.throws(() => parseBackup('{"decks": 3}'), /list of decks/)
assert.throws(() => parseBackup('[]'), /no decks/i)
})