👁️
1import { beforeAll, describe, expect, it } from "vitest";
2import type { Card } from "@/lib/scryfall-types";
3import {
4 setupTestCards,
5 type TestCardLookup,
6} from "./__tests__/test-card-lookup";
7import {
8 type CardLookup,
9 computeManaCurve,
10 computeSpeedDistribution,
11 computeTypeDistribution,
12 countManaSymbols,
13 getSourceTempo,
14 getSpeedCategory,
15 isPermanent,
16} from "./deck-stats";
17import type { DeckCard } from "./deck-types";
18
19function makeCard(overrides: Partial<Card>): Card {
20 return {
21 id: "test-id" as Card["id"],
22 oracle_id: "test-oracle" as Card["oracle_id"],
23 name: "Test Card",
24 ...overrides,
25 };
26}
27
28function makeDeckCard(overrides: Partial<DeckCard> = {}): DeckCard {
29 return {
30 scryfallId: "test-id" as DeckCard["scryfallId"],
31 oracleId: "test-oracle" as DeckCard["oracleId"],
32 quantity: 1,
33 section: "mainboard",
34 ...overrides,
35 };
36}
37
38function createTestData(
39 items: Array<{ card: Partial<Card>; deckCard?: Partial<DeckCard> }>,
40): { cards: DeckCard[]; lookup: CardLookup } {
41 const cardMap = new Map<string, Card>();
42 const deckCards: DeckCard[] = [];
43
44 items.forEach((item, i) => {
45 const id = `test-id-${i}` as DeckCard["scryfallId"];
46 const card = makeCard({ ...item.card, id: id as Card["id"] });
47 const deckCard = makeDeckCard({ ...item.deckCard, scryfallId: id });
48
49 cardMap.set(id, card);
50 deckCards.push(deckCard);
51 });
52
53 const lookup: CardLookup = (dc) => cardMap.get(dc.scryfallId);
54
55 return { cards: deckCards, lookup };
56}
57
58describe("countManaSymbols", () => {
59 it("counts basic mana symbols", () => {
60 expect(countManaSymbols("{2}{U}{U}{B}")).toEqual({
61 W: 0,
62 U: 2,
63 B: 1,
64 R: 0,
65 G: 0,
66 C: 0,
67 });
68 });
69
70 it("handles hybrid mana", () => {
71 expect(countManaSymbols("{W/U}{W/U}")).toEqual({
72 W: 2,
73 U: 2,
74 B: 0,
75 R: 0,
76 G: 0,
77 C: 0,
78 });
79 });
80
81 it("handles phyrexian mana", () => {
82 expect(countManaSymbols("{W/P}{B/P}")).toEqual({
83 W: 1,
84 U: 0,
85 B: 1,
86 R: 0,
87 G: 0,
88 C: 0,
89 });
90 });
91
92 it("handles hybrid phyrexian (2/W)", () => {
93 expect(countManaSymbols("{2/W}{2/U}")).toEqual({
94 W: 1,
95 U: 1,
96 B: 0,
97 R: 0,
98 G: 0,
99 C: 0,
100 });
101 });
102
103 it("ignores generic and X costs", () => {
104 expect(countManaSymbols("{X}{X}{2}{R}")).toEqual({
105 W: 0,
106 U: 0,
107 B: 0,
108 R: 1,
109 G: 0,
110 C: 0,
111 });
112 });
113
114 it("counts colorless mana requirements", () => {
115 expect(countManaSymbols("{C}{C}{U}")).toEqual({
116 W: 0,
117 U: 1,
118 B: 0,
119 R: 0,
120 G: 0,
121 C: 2,
122 });
123 });
124
125 it("handles empty/undefined", () => {
126 expect(countManaSymbols("")).toEqual({
127 W: 0,
128 U: 0,
129 B: 0,
130 R: 0,
131 G: 0,
132 C: 0,
133 });
134 expect(countManaSymbols(undefined)).toEqual({
135 W: 0,
136 U: 0,
137 B: 0,
138 R: 0,
139 G: 0,
140 C: 0,
141 });
142 });
143
144 it("counts all five colors plus colorless", () => {
145 expect(countManaSymbols("{W}{U}{B}{R}{G}{C}")).toEqual({
146 W: 1,
147 U: 1,
148 B: 1,
149 R: 1,
150 G: 1,
151 C: 1,
152 });
153 });
154});
155
156describe("getSourceTempo", () => {
157 let cards: TestCardLookup;
158
159 beforeAll(async () => {
160 cards = await setupTestCards();
161 });
162
163 describe("immediate sources", () => {
164 it.each([
165 // === LANDS ===
166 // Basic lands
167 ["Forest", "basic land"],
168 ["Wastes", "colorless basic"],
169 // Dual lands (ABUR)
170 ["Tropical Island", "original dual"],
171 // Fetch lands
172 ["Misty Rainforest", "fetch land"],
173 // Shock lands (you can always pay 2 life = immediate)
174 ["Breeding Pool", "shockland"],
175 // Pain lands
176 ["Yavimaya Coast", "pain land"],
177 // Filter lands
178 ["Flooded Grove", "filter land"],
179 // Pathway lands (MDFCs)
180 ["Barkchannel Pathway", "pathway land"],
181 // Rainbow lands
182 ["Command Tower", "commander rainbow"],
183 ["Exotic Orchard", "conditional rainbow"],
184 ["Mana Confluence", "pain rainbow"],
185 ["City of Brass", "pain rainbow"],
186 // Utility lands
187 ["Ancient Tomb", "colorless + life loss"],
188 ["Gaea's Cradle", "creature-count mana"],
189 ["Phyrexian Tower", "sacrifice land"],
190 ["Cabal Coffers", "swamp-count mana"],
191 // === ARTIFACTS ===
192 // Fast mana
193 ["Mana Crypt", "free mana rock"],
194 ["Mana Vault", "burst mana rock"],
195 ["Grim Monolith", "burst mana rock"],
196 ["Sol Ring", "classic mana rock"],
197 // 0-cost rocks
198 ["Chrome Mox", "imprint rock"],
199 ["Mox Diamond", "discard rock"],
200 ["Lotus Petal", "sac rock"],
201 ["Lion's Eye Diamond", "discard-hand rock"],
202 // Larger rocks
203 ["Gilded Lotus", "5-mana rock"],
204 ["Thran Dynamo", "4-mana rock"],
205 ["Hedron Archive", "4-mana rock"],
206 ["Arcum's Astrolabe", "cantrip rock"],
207 ["Everflowing Chalice", "scaling rock"],
208 // === CREATURES ===
209 // Haste creatures
210 ["Beastcaller Savant", "hasty mana dork"],
211 ["Cormela, Glamour Thief", "hasty mana creature"],
212 // Sacrifice self (no tap)
213 ["Blood Pet", "sac self for mana"],
214 ["Skirk Prospector", "sac by creature type"],
215 // Exile from hand
216 ["Simian Spirit Guide", "exile from hand"],
217 ["Elvish Spirit Guide", "exile from hand"],
218 // ETB triggers
219 ["Akki Rockspeaker", "ETB add mana"],
220 ["Burning-Tree Emissary", "ETB add mana"],
221 ["Priest of Gix", "ETB add mana"],
222 ["Priest of Urabrask", "ETB add mana"],
223 // Creates sacrifice tokens
224 ["Basking Broodscale", "creates Eldrazi Spawn"],
225 // Pay life for mana
226 ["Treasonous Ogre", "pay life for mana (no tap)"],
227 // === ENCHANTMENTS ===
228 ["Cryptolith Rite", "grants tap abilities"],
229 // === SPELLS ===
230 ["Dark Ritual", "ritual"],
231 ])("%s → immediate (%s)", async (name) => {
232 const card = await cards.get(name);
233 expect(getSourceTempo(card)).toBe("immediate");
234 });
235 });
236
237 describe("conditional sources", () => {
238 it.each([
239 // Game-state dependent - you might not meet the condition
240 // Check lands (need a land of the right type)
241 ["Hinterland Harbor", "checkland"],
242 // Fast lands (need 2 or fewer lands - bad late game)
243 ["Botanical Sanctum", "fastland"],
244 // Battle/Tango lands (need 2+ basics)
245 ["Canopy Vista", "battle land"],
246 ])("%s → conditional (%s)", async (name) => {
247 const card = await cards.get(name);
248 expect(getSourceTempo(card)).toBe("conditional");
249 });
250 });
251
252 describe("delayed sources", () => {
253 it.each([
254 // === LANDS ===
255 // Tap lands (unconditional)
256 ["Temple of Mystery", "scry land"],
257 ["Ketria Triome", "triome"],
258 ["Undercity Sewers", "surveil land"],
259 // === ARTIFACTS ===
260 ["Worn Powerstone", "ETB tapped rock"],
261 // === CREATURES (summoning sickness) ===
262 ["Llanowar Elves", "classic mana dork"],
263 ["Elvish Mystic", "classic mana dork"],
264 ["Fyndhorn Elves", "classic mana dork"],
265 ["Birds of Paradise", "flying rainbow dork"],
266 ["Selvala, Heart of the Wilds", "big mana dork"],
267 // Land-creature
268 ["Dryad Arbor", "land creature (summoning sickness)"],
269 ])("%s → delayed (%s)", async (name) => {
270 const card = await cards.get(name);
271 expect(getSourceTempo(card)).toBe("delayed");
272 });
273 });
274
275 describe("bounce sources", () => {
276 it.each([
277 ["Simic Growth Chamber", "simic bounceland"],
278 ["Azorius Chancery", "azorius bounceland"],
279 ])("%s → bounce (%s)", async (name) => {
280 const card = await cards.get(name);
281 expect(getSourceTempo(card)).toBe("bounce");
282 });
283 });
284});
285
286describe("getSpeedCategory", () => {
287 it("returns instant for Instant type", () => {
288 const card = makeCard({ type_line: "Instant", keywords: [] });
289 expect(getSpeedCategory(card)).toBe("instant");
290 });
291
292 it("returns instant for Flash keyword", () => {
293 const card = makeCard({
294 type_line: "Creature — Human Wizard",
295 keywords: ["Flash"],
296 });
297 expect(getSpeedCategory(card)).toBe("instant");
298 });
299
300 it("returns sorcery for regular creatures", () => {
301 const card = makeCard({
302 type_line: "Creature — Human Wizard",
303 keywords: [],
304 });
305 expect(getSpeedCategory(card)).toBe("sorcery");
306 });
307
308 it("returns sorcery for Sorcery type", () => {
309 const card = makeCard({ type_line: "Sorcery", keywords: [] });
310 expect(getSpeedCategory(card)).toBe("sorcery");
311 });
312
313 it("returns sorcery for creatures without keywords", () => {
314 const card = makeCard({
315 type_line: "Creature — Dragon",
316 keywords: undefined,
317 });
318 expect(getSpeedCategory(card)).toBe("sorcery");
319 });
320
321 it("returns instant for Instant with Flash (edge case)", () => {
322 const card = makeCard({
323 type_line: "Instant",
324 keywords: ["Flash"],
325 });
326 expect(getSpeedCategory(card)).toBe("instant");
327 });
328});
329
330describe("isPermanent", () => {
331 it("returns true for creatures", () => {
332 expect(isPermanent("Legendary Creature — Human Wizard")).toBe(true);
333 });
334
335 it("returns true for artifacts", () => {
336 expect(isPermanent("Artifact — Equipment")).toBe(true);
337 });
338
339 it("returns true for enchantments", () => {
340 expect(isPermanent("Enchantment — Aura")).toBe(true);
341 });
342
343 it("returns true for planeswalkers", () => {
344 expect(isPermanent("Legendary Planeswalker — Jace")).toBe(true);
345 });
346
347 it("returns true for lands", () => {
348 expect(isPermanent("Basic Land — Island")).toBe(true);
349 });
350
351 it("returns true for battles", () => {
352 expect(isPermanent("Battle — Siege")).toBe(true);
353 });
354
355 it("returns false for instants", () => {
356 expect(isPermanent("Instant")).toBe(false);
357 });
358
359 it("returns false for sorceries", () => {
360 expect(isPermanent("Sorcery")).toBe(false);
361 });
362
363 it("returns false for undefined", () => {
364 expect(isPermanent(undefined)).toBe(false);
365 });
366
367 it("returns true for artifact creatures", () => {
368 expect(isPermanent("Artifact Creature — Golem")).toBe(true);
369 });
370
371 it("returns true for enchantment creatures", () => {
372 expect(isPermanent("Enchantment Creature — God")).toBe(true);
373 });
374});
375
376describe("computeManaCurve", () => {
377 it("groups cards by CMC bucket", () => {
378 const { cards, lookup } = createTestData([
379 { card: { cmc: 1, type_line: "Creature" } },
380 { card: { cmc: 2, type_line: "Creature" } },
381 { card: { cmc: 2, type_line: "Creature" } },
382 { card: { cmc: 3, type_line: "Creature" } },
383 ]);
384
385 const curve = computeManaCurve(cards, lookup);
386
387 expect(curve.find((b) => b.bucket === "1")?.permanents).toBe(1);
388 expect(curve.find((b) => b.bucket === "2")?.permanents).toBe(2);
389 expect(curve.find((b) => b.bucket === "3")?.permanents).toBe(1);
390 });
391
392 it("separates permanents from spells", () => {
393 const { cards, lookup } = createTestData([
394 { card: { cmc: 2, type_line: "Creature — Elf" } },
395 { card: { cmc: 2, type_line: "Instant" } },
396 ]);
397
398 const curve = computeManaCurve(cards, lookup);
399 const bucket2 = curve.find((b) => b.bucket === "2");
400
401 expect(bucket2?.permanents).toBe(1);
402 expect(bucket2?.spells).toBe(1);
403 });
404
405 it("handles high CMC buckets individually", () => {
406 const { cards, lookup } = createTestData([
407 { card: { cmc: 7, type_line: "Creature" } },
408 { card: { cmc: 8, type_line: "Creature" } },
409 { card: { cmc: 10, type_line: "Sorcery" } },
410 ]);
411
412 const curve = computeManaCurve(cards, lookup);
413
414 expect(curve.find((b) => b.bucket === "7")?.permanents).toBe(1);
415 expect(curve.find((b) => b.bucket === "8")?.permanents).toBe(1);
416 expect(curve.find((b) => b.bucket === "10")?.spells).toBe(1);
417 // Should have buckets 0-10 (with gaps filled)
418 expect(curve.find((b) => b.bucket === "9")?.permanents).toBe(0);
419 });
420
421 it("multiplies by quantity", () => {
422 const { cards, lookup } = createTestData([
423 { card: { cmc: 1, type_line: "Instant" }, deckCard: { quantity: 4 } },
424 ]);
425
426 const curve = computeManaCurve(cards, lookup);
427 expect(curve.find((b) => b.bucket === "1")?.spells).toBe(4);
428 });
429
430 it("returns empty buckets for missing CMCs", () => {
431 const { cards, lookup } = createTestData([
432 { card: { cmc: 5, type_line: "Creature" } },
433 ]);
434
435 const curve = computeManaCurve(cards, lookup);
436
437 expect(curve.find((b) => b.bucket === "0")?.permanents).toBe(0);
438 expect(curve.find((b) => b.bucket === "1")?.permanents).toBe(0);
439 expect(curve.find((b) => b.bucket === "5")?.permanents).toBe(1);
440 });
441
442 it("includes card references in buckets", () => {
443 const { cards, lookup } = createTestData([
444 { card: { name: "Llanowar Elves", cmc: 1, type_line: "Creature" } },
445 { card: { name: "Lightning Bolt", cmc: 1, type_line: "Instant" } },
446 ]);
447
448 const curve = computeManaCurve(cards, lookup);
449 const bucket1 = curve.find((b) => b.bucket === "1");
450
451 expect(bucket1?.permanentCards).toHaveLength(1);
452 expect(bucket1?.spellCards).toHaveLength(1);
453 });
454});
455
456describe("computeTypeDistribution", () => {
457 it("counts cards by primary type", () => {
458 const { cards, lookup } = createTestData([
459 { card: { type_line: "Creature — Elf" } },
460 { card: { type_line: "Creature — Human" } },
461 { card: { type_line: "Instant" } },
462 { card: { type_line: "Sorcery" } },
463 ]);
464
465 const types = computeTypeDistribution(cards, lookup);
466
467 expect(types.find((t) => t.type === "Creature")?.count).toBe(2);
468 expect(types.find((t) => t.type === "Instant")?.count).toBe(1);
469 expect(types.find((t) => t.type === "Sorcery")?.count).toBe(1);
470 });
471
472 it("multiplies by quantity", () => {
473 const { cards, lookup } = createTestData([
474 { card: { type_line: "Instant" }, deckCard: { quantity: 4 } },
475 ]);
476
477 const types = computeTypeDistribution(cards, lookup);
478 expect(types.find((t) => t.type === "Instant")?.count).toBe(4);
479 });
480
481 it("sorts by count descending", () => {
482 const { cards, lookup } = createTestData([
483 { card: { type_line: "Instant" } },
484 { card: { type_line: "Creature" } },
485 { card: { type_line: "Creature" } },
486 { card: { type_line: "Creature" } },
487 ]);
488
489 const types = computeTypeDistribution(cards, lookup);
490
491 expect(types[0].type).toBe("Creature");
492 expect(types[0].count).toBe(3);
493 expect(types[1].type).toBe("Instant");
494 expect(types[1].count).toBe(1);
495 });
496
497 it("includes card references", () => {
498 const { cards, lookup } = createTestData([
499 { card: { name: "Bolt", type_line: "Instant" } },
500 { card: { name: "Shock", type_line: "Instant" } },
501 ]);
502
503 const types = computeTypeDistribution(cards, lookup);
504 const instants = types.find((t) => t.type === "Instant");
505
506 expect(instants?.cards).toHaveLength(2);
507 });
508});
509
510describe("computeSpeedDistribution", () => {
511 it("separates instant and sorcery speed", () => {
512 const { cards, lookup } = createTestData([
513 { card: { type_line: "Instant", keywords: [] } },
514 { card: { type_line: "Creature", keywords: ["Flash"] } },
515 { card: { type_line: "Creature", keywords: [] } },
516 { card: { type_line: "Sorcery", keywords: [] } },
517 ]);
518
519 const speed = computeSpeedDistribution(cards, lookup);
520
521 expect(speed.find((s) => s.category === "instant")?.count).toBe(2);
522 expect(speed.find((s) => s.category === "sorcery")?.count).toBe(2);
523 });
524
525 it("multiplies by quantity", () => {
526 const { cards, lookup } = createTestData([
527 {
528 card: { type_line: "Instant", keywords: [] },
529 deckCard: { quantity: 4 },
530 },
531 ]);
532
533 const speed = computeSpeedDistribution(cards, lookup);
534 expect(speed.find((s) => s.category === "instant")?.count).toBe(4);
535 });
536
537 it("includes card references", () => {
538 const { cards, lookup } = createTestData([
539 { card: { name: "Bolt", type_line: "Instant", keywords: [] } },
540 {
541 card: {
542 name: "Snapcaster",
543 type_line: "Creature",
544 keywords: ["Flash"],
545 },
546 },
547 ]);
548
549 const speed = computeSpeedDistribution(cards, lookup);
550 const instant = speed.find((s) => s.category === "instant");
551
552 expect(instant?.cards).toHaveLength(2);
553 });
554});