Something went wrong. Try again.
An atproto AppView for tracking a Magic: The Gathering collection github.com/eth0net/manaweb
appview atproto mtg rust typescript
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428import { 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<string> { 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);});