128 lines
6.8 KiB
TypeScript
128 lines
6.8 KiB
TypeScript
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([]);
|
|
});
|
|
});
|