import { expect, test } from "bun:test"; import type { Locate } from "../catalog"; import { NOTE, NOTE_BYTES } from "../collection/cards"; import { BATCH, BYTES } from "../oauth/repo"; import { asPaid, type Format, MANABOX } from "./formats"; import { read } from "./read"; const NOW = "2026-09-13T00:00:00.000Z"; const HEAD = "Name,Set code,Set name,Collector number,Foil,Rarity,Quantity,ManaBox ID," + "Scryfall ID,Purchase price,Misprint,Altered,Condition,Language," + "Purchase price currency,Added"; const ONE = "d40c73de-7a5f-46f2-a70b-449bc8ecfe24"; // One row, with only the columns a test varies spelled out. function row({ name = "Infestation Sage", id = ONE, foil = "normal", quantity = "1", price = "0.18", misprint = "false", altered = "false", condition = "near_mint", currency = "GBP", added = "2025-11-22T13:20:10.577Z", } = {}): string { return [ `${name},FDN,Foundations,64`, foil, "common", quantity, "101601", id, price, misprint, altered, condition, "en", currency, added, ].join(","); } function one(...rows: string[]) { const { stacks, skipped } = read([HEAD, ...rows].join("\n"), MANABOX, NOW); return { stack: stacks[0], stacks, skipped }; } async function fixture(name: string): Promise { const at = new URL(`./fixtures/${name}.csv`, import.meta.url); return await Bun.file(at).text(); } test("a binder export reads every row", async () => { const { stacks, skipped } = read( await fixture("manabox-binder"), MANABOX, NOW, ); expect(skipped).toEqual([]); expect(stacks).toHaveLength(25); expect(stacks.reduce((sum, held) => sum + held.quantity, 0)).toBe(43); }); test("a name carrying a comma survives the row it sits in", async () => { const { stacks } = read(await fixture("manabox-binder"), MANABOX, NOW); const etched = stacks.filter((held) => held.finish === "etched"); expect(etched).toHaveLength(2); }); test("every line ending reads the same collection", async () => { const text = await fixture("manabox-binder"); expect(read(text.replaceAll("\n", "\r\n"), MANABOX, NOW)).toEqual( read(text, MANABOX, NOW), ); }); test("a finish is what the record calls it", () => { expect(one(row({ foil: "normal" })).stack?.finish).toBe("nonfoil"); expect(one(row({ foil: "foil" })).stack?.finish).toBe("foil"); expect(one(row({ foil: "etched" })).stack?.finish).toBe("etched"); }); test("a grade is the lexicon's spelling of it", () => { expect(one(row({ condition: "near_mint" })).stack?.condition).toBe( "nearMint", ); expect(one(row({ condition: "light_played" })).stack?.condition).toBe( "lightPlayed", ); }); test("a grade nothing recognizes leaves the stack ungraded", () => { expect(one(row({ condition: "pristine" })).stack?.condition).toBeUndefined(); }); test("the price column is what a copy was worth, not what was paid", () => { expect(one(row()).stack?.acquisitions).toEqual([ { quantity: 1, marketValue: "0.18", marketCurrency: "GBP" }, ]); }); test("the same column read as paid is a figure, not a snapshot", () => { const { stacks } = read( [HEAD, row()].join("\n"), asPaid(MANABOX) as Format, NOW, ); expect(stacks[0]?.acquisitions).toEqual([ { quantity: 1, price: "0.18", currency: "GBP" }, ]); }); test("a sum with no currency beside it is no lot at all", () => { expect(one(row({ currency: "" })).stack?.acquisitions).toBeUndefined(); expect(one(row({ price: "" })).stack?.acquisitions).toBeUndefined(); }); test("the added date seeds the record, and a missing one is now", () => { expect(one(row()).stack?.createdAt).toBe("2025-11-22T13:20:10.577Z"); expect(one(row({ added: "" })).stack?.createdAt).toBe(NOW); }); test("misprint and altered land as tags", () => { expect(one(row({ misprint: "true", altered: "true" })).stack?.tags).toEqual([ "altered", "misprint", ]); expect(one(row()).stack?.tags).toBeUndefined(); }); test("identical rows are one stack", () => { const { stacks } = one(row({ quantity: "2" }), row({ quantity: "3" })); expect(stacks).toHaveLength(1); expect(stacks[0]?.quantity).toBe(5); }); test("a row differing only in price joins, keeping both figures", () => { const { stacks } = one(row({ price: "0.18" }), row({ price: "0.31" })); expect(stacks).toHaveLength(1); expect(stacks[0]?.acquisitions).toHaveLength(2); }); test("a merge keeps the earlier of the two dates", () => { const { stacks } = one( row({ added: "2026-04-07T22:14:49.647Z" }), row({ added: "2025-11-22T13:20:10.577Z" }), ); expect(stacks[0]?.createdAt).toBe("2025-11-22T13:20:10.577Z"); }); test("a differing grade stays a stack of its own", () => { const { stacks } = one(row(), row({ condition: "played" })); expect(stacks).toHaveLength(2); }); test("a row naming no printing is skipped by line", () => { const { stacks, skipped } = one(row(), row({ id: "" }), row({ id: "x" })); expect(stacks).toHaveLength(1); expect(skipped).toEqual([ { line: 3, reason: "no print" }, { line: 4, reason: "no such print x" }, ]); }); // A name can hold the line ending a row is split on, and what the reader // is told has to be what the file shows them. test("a row after a field spanning two lines is named by the later one", () => { const spanning = row().replace("Infestation Sage", '"Infestation\nSage"'); expect(one(spanning, row({ quantity: "0" })).skipped).toEqual([ { line: 4, reason: "0 is not a count" }, ]); }); test("a count nothing can own is skipped", () => { expect(one(row({ quantity: "0" })).skipped).toHaveLength(1); expect(one(row({ quantity: "1.5" })).skipped).toHaveLength(1); expect(one(row({ quantity: "" })).skipped).toHaveLength(1); }); test("a finish nothing recognizes is skipped rather than guessed", () => { expect(one(row({ foil: "shiny" })).skipped).toEqual([ { line: 2, reason: "unknown finish shiny" }, ]); }); test("a file missing a column it is read by yields nothing", () => { const { stacks, skipped } = read( "Name,Set code\nInfestation Sage,FDN", MANABOX, NOW, ); expect(stacks).toEqual([]); expect(skipped).toEqual([ { line: 1, reason: "no Scryfall ID, Foil, Quantity column" }, ]); }); test("an empty file is not an error", () => { expect(read("", MANABOX, NOW)).toEqual({ stacks: [], skipped: [] }); }); test("a blank line is not a row", () => { expect(one(row(), "").stacks).toHaveLength(1); }); test("a blank finish is how an export writes nonfoil", () => { expect(one(row({ foil: "" })).stack?.finish).toBe("nonfoil"); }); // The same file read against a binding that says the figures were typed. const PAID = { name: "Custom", binding: { ...MANABOX.binding, price: "Purchase price", currency: "Purchase price currency", marketValue: undefined, marketCurrency: undefined, }, }; test("a binding decides whether a sum was paid or was worth", () => { const { stacks } = read([HEAD, row()].join("\n"), PAID, NOW); expect(stacks[0]?.acquisitions).toEqual([ { quantity: 1, price: "0.18", currency: "GBP" }, ]); }); test("a column the binding does not name is not read", () => { const bare = { name: "Bare", binding: { scryfallId: "Scryfall ID", finish: "Foil", quantity: "Quantity", }, }; const { stacks } = read([HEAD, row()].join("\n"), bare, NOW); expect(stacks[0]?.condition).toBeUndefined(); expect(stacks[0]?.acquisitions).toBeUndefined(); expect(stacks[0]?.createdAt).toBe(NOW); }); test("a tags column joins the flags that have their own", () => { const tagged = { name: "Tagged", binding: { ...MANABOX.binding, tags: "Name" }, }; const { stacks } = read( [HEAD, row({ altered: "true" })].join("\n"), tagged, NOW, ); expect(stacks[0]?.tags).toEqual(["altered", "Infestation Sage"]); }); // A note carried in a column every row already has, so the cell under test is // the whole of it. const NOTED: Format = { name: "Noted", binding: { ...MANABOX.binding, note: "Name" }, }; const CLUSTERS = new Intl.Segmenter("en", { granularity: "grapheme" }); function noted(text: string): string { const { stacks } = read([HEAD, row({ name: text })].join("\n"), NOTED, NOW); return stacks[0]?.note ?? ""; } function graphemes(text: string): number { return [...CLUSTERS.segment(text)].length; } // Two code points to the cluster. const FLAG = "๐Ÿ‡ฏ๐Ÿ‡ต"; // Seven, joined, and 25 bytes: 120 of them are the byte ceiling exactly. const FAMILY = "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ"; test("a note past the cluster ceiling is cut to it", () => { expect(noted("x".repeat(400))).toBe("x".repeat(NOTE)); }); test("a note is measured in clusters, not the code points under them", () => { expect(graphemes(noted(FLAG.repeat(300)))).toBe(300); }); test("a note that fits is kept whole, cluster and byte ceiling both", () => { const flags = noted(`a${FLAG.repeat(299)}`); expect(graphemes(flags)).toBe(300); expect(flags.endsWith(FLAG)).toBe(true); const families = noted(FAMILY.repeat(120)); expect(graphemes(families)).toBe(120); expect(families.endsWith(FAMILY)).toBe(true); }); // Well under the cluster ceiling, over the byte one, and the byte ceiling // falls inside the last cluster rather than between two. test("a note over the byte ceiling is cut there, on a cluster", () => { const note = noted(`${FAMILY.repeat(119)}ab${FAMILY}`); expect(new TextEncoder().encode(note).length).toBeLessThanOrEqual( NOTE_BYTES, ); expect(graphemes(note)).toBe(121); expect(note.endsWith("ab")).toBe(true); }); // A binding with no id column, which is what leaves the set and number the // only thing naming a printing. const NUMBERED: Format = { name: "Numbered", binding: { setCode: "Set code", collectorNumber: "Collector number", finish: "Foil", quantity: "Quantity", condition: "Condition", }, }; // A catalog holding one printing, which is enough to tell a hit from a miss. const CATALOG: Locate = (keys) => new Map( [...keys].filter((key) => key === "fdn/64").map((key) => [key, ONE]), ); // The row `row()` writes, moved to a printing the catalog above has not got. function elsewhere(): string { return row().replace("FDN,Foundations,64", "MH3,Modern Horizons 3,12"); } test("a file with no id column names its printings by set and number", () => { const { stacks, skipped } = read( [HEAD, row()].join("\n"), NUMBERED, NOW, CATALOG, ); expect(skipped).toEqual([]); expect(stacks[0]?.scryfallId).toBe(ONE); }); test("a printing the catalog hasn't got is skipped as the file spelled it", () => { const { stacks, skipped } = read( [HEAD, elsewhere(), row()].join("\n"), NUMBERED, NOW, CATALOG, ); expect(stacks).toHaveLength(1); expect(skipped).toEqual([{ line: 2, reason: "no such print MH3 12" }]); }); // Signed out, and before the catalog has loaded. test("no catalog leaves a file named that way with nothing to import", () => { const { stacks, skipped } = read([HEAD, row()].join("\n"), NUMBERED, NOW); expect(stacks).toEqual([]); expect(skipped).toEqual([{ line: 2, reason: "no such print FDN 64" }]); }); // The id has to land before stacks are folded together, or a file naming one // printing twice would write two records. test("rows resolving to one printing collapse as a named id would", () => { const { stacks } = read( [HEAD, row(), row()].join("\n"), NUMBERED, NOW, CATALOG, ); expect(stacks).toHaveLength(1); expect(stacks[0]?.quantity).toBe(2); }); test("a file missing the columns that name a printing yields nothing", () => { const { stacks, skipped } = read( "Name,Foil\nInfestation Sage,normal", NUMBERED, NOW, CATALOG, ); expect(stacks).toEqual([]); expect(skipped).toEqual([ { line: 1, reason: "no Set code, Collector number, Quantity column" }, ]); }); // Derived from a real 8,325-row export: its finishes, grades, quantities and // quoted names in their measured proportions, plus the rows a real export never // has โ€” a repeat, one differing only in price, an alter, a misprint, a grade. test("a collection-sized export reads into stacks", async () => { const { stacks, skipped } = read( await fixture("manabox-collection"), MANABOX, ); expect(skipped).toEqual([]); expect(stacks.length).toBeGreaterThan(BATCH); expect(stacks.reduce((sum, held) => sum + held.quantity, 0)).toBe(505); // Of the five planted rows two join what they copy โ€” the identical one and // the one differing only in price โ€” and three are stacks of their own. expect(stacks).toHaveLength(365 - 2); expect(stacks.filter((held) => held.tags?.includes("altered"))).toHaveLength( 1, ); expect(stacks.filter((held) => held.condition === "played")).toHaveLength(1); expect(stacks.some((held) => (held.acquisitions?.length ?? 0) > 1)).toBe( true, ); }); // The body limit is what bounds a batch once records carry history, so the // measured size is worth holding onto. test("a batch of real records sits well inside one body", async () => { const { stacks } = read(await fixture("manabox-collection"), MANABOX); const bytes = stacks .slice(0, BATCH) .reduce((sum, held) => sum + JSON.stringify(held).length, 0); expect(bytes).toBeLessThan(BYTES / 10); });