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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352import { expect, test } from "bun:test";import type { Owned, Stack } from "../collection/cards";import { parse } from "../import/csv";import { columns, type Format, MANABOX } from "../import/formats";import { read } from "../import/read";import { write } from "./csv";import { type Describe, type Printing, rows } from "./rows";
const NOW = "2026-10-04T00:00:00.000Z";
const FIXTURES = ["manabox-binder", "manabox-collection", "manabox-list"];
// By name rather than by position: ManaBox has added a column before now.const COLUMN = Object.fromEntries( columns(MANABOX).map((one, at) => [one, at]),) as Record<string, number>;const QUANTITY = COLUMN.Quantity as number;const PRICE = COLUMN["Purchase price"] as number;const CURRENCY = COLUMN["Purchase price currency"] as number;const THEIR_ID = COLUMN["ManaBox ID"] as number;const NAME = COLUMN.Name as number;const FINISH = COLUMN.Foil as number;const SCRYFALL = COLUMN["Scryfall ID"] as number;
async function fixture(name: string): Promise<string> { const at = new URL(`../import/fixtures/${name}.csv`, import.meta.url); return await Bun.file(at).text();}
// Stacks as a set, for a comparison a deliberate reordering must not fail.function ordered(owned: Owned[]): string[] { return owned.map((one) => JSON.stringify(one)).sort();}
// A repo holds records rather than rows, and only `value` reaches a file.function stacks(owned: Owned[]): Stack[] { return owned.map((value, at) => ({ uri: `at://x/${at}`, cid: "", value }));}
// The catalog as the file itself describes it, so a round trip needs nothing// fetched. The columns this fills are ones no import reads back.function describing(text: string): Describe { const [head = [], ...body] = parse(text); const at = (name: string) => head.indexOf(name); const held = new Map<string, Printing>( body.map((row) => [ (row[at("Scryfall ID")] ?? "").toLowerCase(), { card: { name: row[at("Name")] ?? "" }, print: { set: (row[at("Set code")] ?? "").toLowerCase(), setName: row[at("Set name")] ?? "", collectorNumber: row[at("Collector number")] ?? "", rarity: row[at("Rarity")] ?? "", lang: row[at("Language")] ?? "", }, }, ]), ); return (ids) => new Map( [...ids].flatMap((id) => { const one = held.get(id); return one ? [[id, one] as const] : []; }), );}
test.each(FIXTURES)("%s survives a round trip", async (name) => { const text = await fixture(name); const first = read(text, MANABOX, NOW); expect(first.skipped).toEqual([]);
const out = write( rows(stacks(first.stacks), MANABOX, describing(text)).rows, ); const again = read(out, MANABOX, NOW);
expect(again.skipped).toEqual([]); // The file is written in a settled order, so what comes back is the same // stacks rather than the same list. expect(ordered(again.stacks)).toEqual(ordered(first.stacks));});
test("an export names itself as the format it was written in", async () => { const text = await fixture("manabox-binder"); const first = read(text, MANABOX, NOW); const out = write( rows(stacks(first.stacks), MANABOX, describing(text)).rows, );
expect(parse(out)[0]).toEqual(columns(MANABOX));});
test("copies bought at two figures are two rows", () => { const one: Owned = { scryfallId: "d40c73de-7a5f-46f2-a70b-449bc8ecfe24", finish: "foil", quantity: 3, createdAt: NOW, acquisitions: [ { quantity: 1, marketValue: "0.18", marketCurrency: "GBP" }, { quantity: 2, marketValue: "0.20", marketCurrency: "GBP" }, ], }; const { rows: out } = rows(stacks([one]), MANABOX, () => new Map());
expect(out.slice(1).map((row) => [row[QUANTITY], row[PRICE]])).toEqual([ ["1", "0.18"], ["2", "0.20"], ]);});
test("copies no lot covers go out with no figure against them", () => { const one: Owned = { scryfallId: "d40c73de-7a5f-46f2-a70b-449bc8ecfe24", finish: "nonfoil", quantity: 5, createdAt: NOW, acquisitions: [ { quantity: 2, marketValue: "0.18", marketCurrency: "GBP" }, ], }; const { rows: out } = rows(stacks([one]), MANABOX, () => new Map());
expect(out.slice(1).map((row) => [row[QUANTITY], row[PRICE]])).toEqual([ ["2", "0.18"], ["3", ""], ]);});
test("a history outrunning what is held exports what is held", () => { const one: Owned = { scryfallId: "d40c73de-7a5f-46f2-a70b-449bc8ecfe24", finish: "nonfoil", quantity: 1, createdAt: NOW, acquisitions: [ { quantity: 4, marketValue: "0.18", marketCurrency: "GBP" }, ], }; const { rows: out } = rows(stacks([one]), MANABOX, () => new Map());
expect(out.slice(1).map((row) => row[QUANTITY])).toEqual(["1"]);});
test("a printing the catalog cannot name is counted, not dropped", () => { const one: Owned = { scryfallId: "d40c73de-7a5f-46f2-a70b-449bc8ecfe24", finish: "nonfoil", quantity: 2, createdAt: NOW, }; const written = rows(stacks([one]), MANABOX, () => new Map());
expect(written.unnamed).toBe(2); expect(written.rows).toHaveLength(2); expect(written.rows[1]?.[SCRYFALL]).toBe(one.scryfallId); expect(written.rows[1]?.[NAME]).toBe("");});
test("what the format has no column for is named before the file is", () => { const one: Owned = { scryfallId: "d40c73de-7a5f-46f2-a70b-449bc8ecfe24", finish: "nonfoil", quantity: 1, createdAt: NOW, container: "at://x/app.manaweb.container/one", note: "signed at a prerelease", proxy: true, tags: ["altered", "signed"], acquisitions: [{ quantity: 1, price: "2.00", currency: "GBP" }], }; const { dropped } = rows(stacks([one]), MANABOX, () => new Map());
expect(dropped).toEqual(["container", "note", "price", "currency"]);});
// Both shipped formats bind these, so a binding of our own is what reaches// the rest of the table.const BARE: Format = { name: "Bare", binding: { scryfallId: "Scryfall ID", finish: "Finish", quantity: "Quantity", },};
test("what a copy was worth is a field too, empty or not", () => { const one: Owned = { scryfallId: "d40c73de-7a5f-46f2-a70b-449bc8ecfe24", finish: "nonfoil", quantity: 1, createdAt: NOW, acquisitions: [{ quantity: 1, marketValue: "", marketCurrency: "USD" }], }; const { dropped } = rows(stacks([one]), BARE, () => new Map());
expect(dropped).toContain("marketValue"); expect(dropped).toContain("marketCurrency");});
// `stack` reads an absent proxy and a false one as the same card, so a format// with no column for it loses nothing by saying nothing.test("a card that is not a proxy loses nothing to a format without one", () => { const one: Owned = { scryfallId: "d40c73de-7a5f-46f2-a70b-449bc8ecfe24", finish: "nonfoil", quantity: 1, createdAt: NOW, proxy: false, }; expect(rows(stacks([one]), BARE, () => new Map()).dropped).not.toContain( "proxy", );});
// The lexicon sets no minimum on a price, so an empty one is a record a PDS// takes, and it is the one a truthy test would read as no price at all.test("a price is a field a lot has, not a figure that reads as true", () => { const one: Owned = { scryfallId: "d40c73de-7a5f-46f2-a70b-449bc8ecfe24", finish: "nonfoil", quantity: 1, createdAt: NOW, acquisitions: [{ quantity: 1, price: "", currency: "GBP" }], }; expect(rows(stacks([one]), MANABOX, () => new Map()).dropped).toEqual([ "price", "currency", ]);});
// ManaBox has a column for what a copy was worth and none for the day it was// bought, so the lot goes out dated by nothing at all.test("a date no column carries is a loss like any other", () => { const one: Owned = { scryfallId: "d40c73de-7a5f-46f2-a70b-449bc8ecfe24", finish: "nonfoil", quantity: 1, createdAt: NOW, acquisitions: [ { quantity: 1, at: "2025-01-01T00:00:00.000Z", marketValue: "3.00", marketCurrency: "USD", }, ], updatedAt: "2026-02-02T00:00:00.000Z", }; const { dropped } = rows(stacks([one]), MANABOX, () => new Map());
expect(dropped).toEqual(["acquiredAt", "updatedAt"]);});
test("a collection carrying only what fits loses nothing", () => { const one: Owned = { scryfallId: "d40c73de-7a5f-46f2-a70b-449bc8ecfe24", finish: "nonfoil", quantity: 1, createdAt: NOW, tags: ["misprint"], }; expect(rows(stacks([one]), MANABOX, () => new Map()).dropped).toEqual([]);});
test("one collection writes one file however the repo ordered it", async () => { const text = await fixture("manabox-binder"); const { stacks: owned } = read(text, MANABOX, NOW); const describe = describing(text);
const forward = write(rows(stacks(owned), MANABOX, describe).rows); const backward = write( rows(stacks([...owned].reverse()), MANABOX, describe).rows, );
expect(backward).toBe(forward);});
// Their own file is the only thing that can say whether ManaBox will read// ours, so every row is held to the one it came from. Two columns cannot// match: `ManaBox ID` is theirs to issue, and they write a currency beside a// blank price, which is not a figure and so is not a lot to write back.test.each(FIXTURES)( "%s goes back out as the file it came from", async (name) => { const text = await fixture(name); const { stacks: owned } = read(text, MANABOX, NOW); const { rows: out } = rows(stacks(owned), MANABOX, describing(text));
const same = (row: string[]) => row .map((cell, at) => (at === CURRENCY && !row[PRICE] ? "" : cell)) .filter((_, at) => at !== THEIR_ID) .join("|");
const theirs = parse(text).slice(1).map(same).sort(); expect(out.slice(1).map(same).sort()).toEqual(theirs); },);
test("a lot counting none of the copies puts none in the file", () => { const none: Owned = { scryfallId: "d40c73de-7a5f-46f2-a70b-449bc8ecfe24", finish: "nonfoil", quantity: 2, createdAt: NOW, acquisitions: [{ quantity: 0 }, { quantity: -3 }], }; const { rows: out } = rows(stacks([none]), MANABOX, () => new Map());
expect(out.slice(1).map((row) => row[QUANTITY])).toEqual(["2"]);});
test("a fractional lot is the copies it covers whole", () => { const part: Owned = { scryfallId: "d40c73de-7a5f-46f2-a70b-449bc8ecfe24", finish: "nonfoil", quantity: 3, createdAt: NOW, acquisitions: [{ quantity: 1.9, marketValue: "2", marketCurrency: "GBP" }], }; const { rows: out } = rows(stacks([part]), MANABOX, () => new Map());
expect(out.slice(1).map((row) => row[QUANTITY])).toEqual(["1", "2"]);});
test("a finish the format cannot spell is counted, not assumed", () => { const odd: Owned = { scryfallId: "d40c73de-7a5f-46f2-a70b-449bc8ecfe24", finish: "glossy", quantity: 2, createdAt: NOW, }; const written = rows(stacks([odd]), MANABOX, () => new Map());
expect(written.unspelled).toBe(2); expect(written.rows[1]?.[FINISH]).toBe("glossy");});
test("a finish the format spells is not counted against it", () => { const plain: Owned = { scryfallId: "d40c73de-7a5f-46f2-a70b-449bc8ecfe24", finish: "etched", quantity: 1, createdAt: NOW, }; expect(rows(stacks([plain]), MANABOX, () => new Map()).unspelled).toBe(0);});