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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355import { expect, test } from "bun:test";import type { Card, Print } from "../catalog";
// No hash differs by this much, which is what a lone match is measured by.const APART = 65;
import type { Read } from "./read";import { among, cards, choose, drop, type Entry, graded, joinable, merged, minus, offered, plus, referenced, refinish, regraded, reported, scanned, settled, sure,} from "./scratch";
// Enough of a printing for the list: an id and what it was made in.function print(id: string, finishes: string[]): { card: Card; print: Print } { return { card: { name: id } as Card, print: { id, finishes } as Print, };}
// Artworks two bits apart, so the read's own margin is the gap between the// nearest two whether or not the catalog can name either.function found(artworks: { card: Card; print: Print }[][]): Read { return { margin: artworks.length > 1 ? 2 : APART, detected: true, sure: false, found: artworks.map((prints, at) => ({ artwork: at, distance: at * 2, prints, })), };}
function one(over: Partial<Entry> = {}): Entry { return { id: "a", at: "2026-09-29T00:00:00.000Z", margin: 8, detected: true, matched: [{ distance: 0, prints: ["p1", "p2"] }], scryfallId: "p1", finish: "nonfoil", quantity: 1, ...over, };}
test("a scan names the nearest printing and counts one of it", () => { const made = scanned( found([[print("p1", ["nonfoil", "foil"])], [print("p2", ["foil"])]]), "a", "2026-09-29T00:00:00.000Z", );
expect(made?.scryfallId).toBe("p1"); expect(made?.quantity).toBe(1); expect(made?.margin).toBe(2); expect(made?.matched).toEqual([ { distance: 0, prints: ["p1"] }, { distance: 2, prints: ["p2"] }, ]);});
test("an artwork no printing carries is not what a scan answers with", () => { const made = scanned(found([[], [print("p2", ["foil"])]]), "a", "now"); expect(made?.scryfallId).toBe("p2"); expect(made?.matched).toHaveLength(1);});
test("a nearer artwork the catalog cannot name leaves nothing to be sure of", () => { // The read's own margin was measured against the artwork that was dropped, // so it says nothing about the printing the stack ends up holding. const made = scanned(found([[], [print("p2", ["foil"])]]), "a", "now"); expect(made?.margin).toBe(0); expect(sure(made as Entry)).toBe(false);});
test("the margin is the gap to the next artwork a printing carries", () => { const made = scanned( found([[print("p1", ["nonfoil"])], [], [print("p3", ["nonfoil"])]]), "a", "now", ); // Artworks 0 and 2, four bits apart, rather than the read's 2. expect(made?.margin).toBe(4);});
test("a scan matching no printing at all makes no stack", () => { expect(scanned(found([[], []]), "a", "now")).toBeNull();});
test("the margin is what says whether to believe it", () => { const alone = { distance: 0, prints: ["p1"] }; expect(sure(one({ margin: 4, matched: [alone] }))).toBe(true); expect(sure(one({ margin: 2, matched: [alone] }))).toBe(false);});
// The case a real photograph turned up: the artwork was right and the// margin wide, and the printing was still a coin flip between two.test("an artwork carrying two printings is not a settled printing", () => { const two = { distance: 0, prints: ["p1", "p2"] }; expect(sure(one({ margin: 8, matched: [two] }))).toBe(false); expect(among(one({ margin: 8, matched: [two] }))).toEqual(["p1", "p2"]);});
test("one finish settles itself and two start nonfoil", () => { expect(settled(["foil"])).toBe("foil"); expect(settled(["nonfoil", "foil"])).toBe("nonfoil"); expect(settled(["foil", "etched"])).toBe("foil"); expect(settled([])).toBe("nonfoil");});
test("a minus to zero takes the stack away", () => { const list = [one({ id: "a", quantity: 2 }), one({ id: "b" })]; expect(minus(list, "a").map((held) => held.quantity)).toEqual([1, 1]); expect(minus(minus(list, "a"), "a").map((held) => held.id)).toEqual(["b"]);});
test("a plus counts up and leaves the rest alone", () => { const list = [one({ id: "a" }), one({ id: "b" })]; expect(plus(list, "b").map((held) => held.quantity)).toEqual([1, 2]);});
test("a report is remembered on the stack, not in a view", () => { const list = [one({ id: "a" }), one({ id: "b" })]; const [first, second] = reported(list, "a", "2026-09-30T12:00:00.000Z");
expect(first?.reported).toBe("2026-09-30T12:00:00.000Z"); expect(second?.reported).toBeUndefined();});
test("drop takes one stack without touching another of the same card", () => { const list = [one({ id: "a" }), one({ id: "b" })]; expect(drop(list, "a").map((held) => held.id)).toEqual(["b"]);});
test("a printing is chosen, and a person saying so is recorded", () => { const made = (id: string) => (id === "p2" ? ["foil"] : ["nonfoil", "foil"]); const list = [one()];
expect(choose(list, "a", "p2", made)[0]).toMatchObject({ scryfallId: "p2", finish: "foil", picked: true, }); expect(list[0]?.picked).toBeUndefined();});
// The index having missed the card altogether is the reading most worth// correcting, so a printing it never returned is still a valid answer.// Confirming the reader's own guess is the commonest answer of all, and// treating it as a no-op sent reports up carrying nothing.test("agreeing with the printing already there is still an answer", () => { const made = () => ["nonfoil", "foil"]; const [held] = choose([one({ finish: "foil" })], "a", "p1", made);
expect(held?.picked).toBe(true); expect(held?.finish).toBe("foil");});
test("a printing the scan never matched can still be named", () => { const made = () => ["nonfoil"]; const [held] = choose([one()], "a", "elsewhere", made);
expect(held?.scryfallId).toBe("elsewhere"); expect(held?.picked).toBe(true); expect(offered(held as Entry, "elsewhere")).toBe(false);});
// The review resolves these against the catalog, so one left out is a stack// that shows no card and a report that carries no answer.test("what the list wants looked up includes the printing it was put right to", () => { const made = () => ["nonfoil"]; expect(referenced(choose([one()], "a", "elsewhere", made))).toEqual([ "elsewhere", "p1", "p2", ]);});
test("pressing the printing already chosen leaves its finish alone", () => { const made = () => ["nonfoil", "foil"]; const list = [one({ finish: "foil" })]; expect(choose(list, "a", "p1", made)[0]?.finish).toBe("foil");});
test("a finish the printing was never made in is refused", () => { const made = () => ["nonfoil", "foil"]; expect(refinish([one()], "a", "foil", made)[0]?.finish).toBe("foil"); expect(refinish([one()], "a", "etched", made)[0]?.finish).toBe("nonfoil");});
test("scanning the same card twice makes two stacks until a merge", () => { const list = [ one({ id: "a" }), one({ id: "b", scryfallId: "p2" }), one({ id: "c", quantity: 3 }), ];
expect(joinable(list, "")).toBe(1); expect(merged(list, "").map((held) => [held.id, held.quantity])).toEqual([ ["a", 4], ["b", 1], ]); expect(list[0]?.quantity).toBe(1);});
test("a merge joins one finish and leaves the other", () => { const list = [one({ id: "a" }), one({ id: "b", finish: "foil" })]; expect(joinable(list, "")).toBe(0);});
test("a stack the ceiling closed does not stop the next one joining", () => { const list = [ one({ id: "a", quantity: 5000 }), one({ id: "b", quantity: 6000 }), one({ id: "c", quantity: 5000 }), ];
expect(joinable(list, "")).toBe(1); expect(merged(list, "").map((held) => held.quantity)).toEqual([10000, 6000]);});
test("a plus stops at the ceiling", () => { const list = [one({ quantity: 10000 })]; expect(plus(list, "a")[0]?.quantity).toBe(10000);});
test("a merge keeps what the first of them was read as", () => { const list = [ one({ id: "a", at: "first", detected: false, quantity: 1 }), one({ id: "b", at: "second", detected: true, quantity: 1 }), ]; const [joined] = merged(list, "");
expect(joined?.at).toBe("first"); expect(joined?.detected).toBe(false); expect(joined?.quantity).toBe(2);});
test("the list comes to cards a file would have named", () => { const list = [one({ id: "a", quantity: 2 })];
expect(cards(list, null, "")).toEqual([ { scryfallId: "p1", finish: "nonfoil", quantity: 2, createdAt: "2026-09-29T00:00:00.000Z", }, ]); expect(cards(list, "binder", "")[0]?.container).toBe("binder");});
test("a stack saying nothing of its own is kept at what the list says", () => { expect(graded(one(), "played")).toBe("played"); expect(graded(one({ condition: "mint" }), "played")).toBe("mint");});
test("ungraded is a grade a stack can be put to, not the absence of one", () => { const list = regraded([one()], "a", "");
expect(list[0]?.condition).toBe(""); expect(graded(list[0] as Entry, "played")).toBe("");});
test("a stack is put back to following the list, which is not ungraded", () => { const list = regraded(regraded([one()], "a", "mint"), "a", null);
expect("condition" in (list[0] as Entry)).toBe(false); expect(graded(list[0] as Entry, "played")).toBe("played");});
// The pair that `??` cannot tell apart, and the one that writes a wrong grade.test("a stack put to ungraded does not join one following a graded list", () => { const list = [one({ id: "a" }), one({ id: "b", condition: "" })];
expect(joinable(list, "mint")).toBe(0); expect(cards(list, null, "mint").map((held) => held.condition)).toEqual([ "mint", undefined, ]);});
test("a stack is not joined to one the list grades otherwise", () => { const list = [one({ id: "a" }), one({ id: "b", condition: "played" })]; expect(joinable(list, "mint")).toBe(0);});
test("saying the grade the list says is the same card to write", () => { const list = [one({ id: "a" }), one({ id: "b", condition: "mint" })]; const [joined] = merged(list, "mint");
expect(joinable(list, "mint")).toBe(1); expect(joined?.quantity).toBe(2); // Pinned by the join: following the list afterwards would move copies the // person had said were mint. expect(joined?.condition).toBe("mint");});
// Either way round: the stack that said it can arrive first, and the pin has// to hold what it said rather than what the other one did not say.test("the grade is pinned whichever of them said it", () => { const said = merged( [one({ id: "a", condition: "mint" }), one({ id: "b" })], "mint", ); const followed = merged( [one({ id: "a" }), one({ id: "b", condition: "mint" })], "mint", );
expect(said[0]?.condition).toBe("mint"); expect(followed[0]?.condition).toBe("mint");});
test("two stacks following the list are joined still following it", () => { const [joined] = merged([one({ id: "a" }), one({ id: "b" })], "mint");
expect(joined?.quantity).toBe(2); expect("condition" in (joined as Entry)).toBe(false);});
test("the grade reaches the cards, and a stack's own beats the list's", () => { const list = [one({ id: "a" }), one({ id: "b", condition: "mint" })]; const owned = cards(list, null, "played");
expect(owned.map((held) => held.condition)).toEqual(["played", "mint"]); expect(cards(list, null, "")[0]?.condition).toBeUndefined();});
test("ungraded is written as no field at all, whatever asked for it", () => { expect(cards([one({ condition: "" })], null, "mint")[0]).not.toHaveProperty( "condition", );});