import { describe, expect, test } from "bun:test"; import type { SourceSeries } from "../src/core/model"; import type { RunInput, RunResult } from "../src/core/pipeline"; import { HISTORY_LIMITS, SavedSearchError, carryEdits, fromSaved, memoryHistory, pruneIds, saveAndPrune, summarize, toSaved, type SavedSearch, } from "../src/core/saved"; import type { ReviewCard } from "../src/core/session"; import { SOURCES } from "../src/core/sources/registry"; import { webtoons } from "../src/core/sources/webtoons"; import { inspected } from "./helpers/covers"; const series: SourceSeries = { source: "webtoons", externalId: "95", title: "Tower of God", language: "en", canonicalUrl: "https://www.webtoons.com/en/fantasy/tower-of-god/list?title_no=95", archiveUrls: [], mature: false, flags: {}, }; const input: RunInput = { mangabakaLink: "https://mangabaka.org/manhwa/9999/Tower-of-God", sourceLink: series.canonicalUrl, history: 6 }; const NOW = Date.UTC(2026, 9, 15, 12); function result(seriesId = 9999): RunResult { const poster = inspected({ bytes: new Uint8Array(1000) }); const square = inspected({ candidate: { role: "square" }, shape: "square", bytes: new Uint8Array(500) }); const already = inspected({ bytes: new Uint8Array(250) }); return { seriesId, mbSeries: { id: seriesId, title: "Tower of God", type: "manhwa", content_rating: "safe" }, source: webtoons, series, linkMatches: true, fresh: [poster, square], present: [{ cover: already, existing: { id: 8000, sha256: already.sha256, language: "en", type: "volume" } }], suggestedDefault: poster, warnings: ["a warning"], }; } const cardsOf = (r: RunResult): ReviewCard[] => r.fresh.map((cover) => ({ cover, selected: true, type: cover.type, note: "n", contentRating: "safe", hostedUrl: null, error: null })); function saved(over: { result?: RunResult; cards?: ReviewCard[]; savedAt?: number } = {}): SavedSearch { const r = over.result ?? result(); return toSaved({ result: r, cards: over.cards ?? cardsOf(r), makeDefault: true, input, rating: "suggestive", marks: {}, foundAt: new Date(NOW - 60_000).toISOString(), now: new Date(over.savedAt ?? NOW), }); } describe("save and restore", () => { test("round trip keeps the result, every edit, the inputs and the bytes", () => { const r = result(); const cards = cardsOf(r); cards[1] = { ...cards[1]!, selected: false, note: "my note", type: "other", contentRating: "erotica" }; const s = toSaved({ result: r, cards, makeDefault: false, input, rating: "suggestive", marks: { downloaded: "2026-10-15T11:00:00.000Z" }, foundAt: "2026-10-15T10:00:00.000Z", now: new Date(NOW) }); expect(s.bytes).toBe(1750); expect(s.result.sourceId).toBe("webtoons"); expect("source" in s.result).toBe(false); expect(s.cards[0]).not.toHaveProperty("cover"); const back = fromSaved(structuredClone(s), SOURCES, NOW); expect(back.result.source).toBe(webtoons); expect(back.result.fresh[0]!.bytes).toEqual(r.fresh[0]!.bytes); expect(back.cards[0]!.cover).toBe(back.result.fresh[0]!); expect(back.cards[1]).toMatchObject({ selected: false, note: "my note", type: "other", contentRating: "erotica", error: null }); expect(back).toMatchObject({ makeDefault: false, input, rating: "suggestive", marks: { downloaded: "2026-10-15T11:00:00.000Z" }, foundAt: "2026-10-15T10:00:00.000Z" }); expect(back.result.warnings).toEqual(["a warning"]); }); test("an entry whose source no longer exists can't be opened", () => { const s = saved(); expect(() => fromSaved({ ...s, result: { ...s.result, sourceId: "gone" } }, SOURCES, NOW)).toThrow(SavedSearchError); }); test("drops expired links, keeps the rest of the card", () => { const r = result(); const cards = cardsOf(r); cards[0] = { ...cards[0]!, hostedUrl: "https://litter.catbox.moe/a.jpg", hosting: { by: "litterbox", key: "litterbox:1h:x", until: NOW - 1 } }; cards[1] = { ...cards[1]!, hostedUrl: "https://files.catbox.moe/b.jpg", hosting: { by: "catbox", key: "catbox:y", until: null } }; const back = fromSaved(saved({ result: r, cards }), SOURCES, NOW); expect(back.cards[0]).toMatchObject({ hostedUrl: null, hosting: undefined, note: "n", selected: true }); expect(back.cards[1]!.hostedUrl).toBe("https://files.catbox.moe/b.jpg"); }); test("summary: title, counts, marks, a thumbnail of the suggested main cover", () => { const s = saved(); const sum = summarize(s); expect(sum).toMatchObject({ seriesId: 9999, title: "Tower of God", sourceName: "WEBTOON", language: "en", fresh: 2, present: 1, bytes: 1750 }); expect(sum.thumb?.bytes).toBe(s.result.fresh[0]!.bytes); }); }); describe("searching a series again", () => { test("carries edits by hash: unticked stays unticked, notes kept, new covers keep defaults", () => { const r = result(); const old = cardsOf(r); old[0] = { ...old[0]!, selected: false, note: "kept note", hostedUrl: "https://files.catbox.moe/a.jpg", hosting: { by: "catbox", key: "catbox:a", until: null } }; old[1] = { ...old[1]!, hostedUrl: "https://litter.catbox.moe/b.jpg", hosting: { by: "litterbox", key: "l", until: NOW - 1 } }; const prev = saved({ result: r, cards: old }); const fresh = [...cardsOf(r), ...cardsOf({ ...r, fresh: [inspected()] })]; carryEdits(prev.cards, fresh, NOW); expect(fresh[0]).toMatchObject({ selected: false, note: "kept note", hostedUrl: "https://files.catbox.moe/a.jpg" }); expect(fresh[1]).toMatchObject({ selected: true, hostedUrl: null }); expect(fresh[2]).toMatchObject({ selected: true, note: "n", hostedUrl: null }); }); }); describe("pruning", () => { const entry = (seriesId: number, savedAt: number, bytes = 1) => ({ seriesId, savedAt: new Date(savedAt).toISOString(), bytes }); test("keeps the 30 most recently saved", () => { const list = Array.from({ length: 31 }, (_, i) => entry(i, NOW + i * 1000)); expect(pruneIds(list)).toEqual([0]); }); test("drops the oldest past 300 MB, but always keeps the newest", () => { const big = HISTORY_LIMITS.bytes / 2; expect(pruneIds([entry(1, NOW, big), entry(2, NOW + 1, big), entry(3, NOW + 2, big)]).sort()).toEqual([1]); expect(pruneIds([entry(1, NOW, HISTORY_LIMITS.bytes * 2)])).toEqual([]); }); test("memory history: newest first; saveAndPrune removes what falls out", async () => { const store = memoryHistory(); await store.put(saved({ result: result(1), savedAt: NOW })); await store.put(saved({ result: result(2), savedAt: NOW + 1 })); expect((await store.list()).map((s) => s.seriesId)).toEqual([2, 1]); const removed = await saveAndPrune(store, saved({ result: result(3), savedAt: NOW + 2 }), { count: 2, bytes: HISTORY_LIMITS.bytes }); expect(removed).toEqual([1]); expect((await store.list()).map((s) => s.seriesId)).toEqual([3, 2]); expect(await store.get(1)).toBeNull(); await store.clear(); expect(await store.list()).toEqual([]); }); });