👁️
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

at dev 554 lines 15 kB view raw
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});