import { describe, expect, test } from "bun:test"; import type { ReviewCard } from "../src/core/session"; import { GRANULARITY_LABELS, cardFacts, defaultHost, hostChoices, seenBar, seenSpan, stageText, timelineGroups } from "../src/web/view-model"; import { inspected } from "./helpers/covers"; test("hosts: server first when it has room; hidden without storage; disabled when full", () => { const ids = (c: ReturnType) => c.map((h) => `${h.id}${h.disabled ? "(disabled)" : ""}`); expect(ids(hostChoices({ storage: true, storageFull: false }, {}, { catboxNeedsServer: true }))).toEqual(["server", "litterbox", "catbox", "direct"]); expect(ids(hostChoices({ storage: false, storageFull: false }, {}, { catboxNeedsServer: true }))).toEqual(["litterbox", "catbox", "direct"]); const full = hostChoices({ storage: true, storageFull: true }, {}, { catboxNeedsServer: true }); expect(ids(full)).toEqual(["server(disabled)", "litterbox", "catbox", "direct"]); expect(defaultHost(full)).toBe("litterbox"); expect(ids(hostChoices(null, {}, { catboxNeedsServer: true }))).toEqual(["litterbox", "catbox(disabled)", "direct"]); expect(ids(hostChoices(null, {}, { catboxNeedsServer: false }))).toEqual(["litterbox", "catbox", "direct"]); }); test("litterbox carries the review-time warning and its chosen lifetime", () => { const litterbox = hostChoices(null, { litterboxTime: "24h" }, { catboxNeedsServer: false })[0]!; expect(litterbox.lifetime).toBe("24 hours"); expect(litterbox.warning).toMatch(/review can take longer/); }); test("stage text with and without progress", () => { expect(stageText("history")).toBe("Looking through the Wayback Machine…"); expect(stageText("history", 3, 24)).toBe("Looking through the Wayback Machine… 3/24"); }); test("granularity labels", () => { expect(GRANULARITY_LABELS).toEqual({ 1: "every month", 3: "every 3 months", 6: "every 6 months", 12: "every year" }); }); test("card facts: size, shape, when seen, archive-only", () => { const card = (cover: ReturnType): ReviewCard => ({ cover, selected: true, type: cover.type, note: "", contentRating: "safe", hostedUrl: null, error: null }); expect(cardFacts(card(inspected({ bytes: new Uint8Array(352813) })))).toEqual(["480×623", "345 KB", "on the page now"]); expect(cardFacts(card(inspected({ width: 720, height: 1230, shape: "tall", live: false, bytes: new Uint8Array(2048), candidate: { current: false, seenAt: "20230401000000", lastSeenAt: "20260301000000" }, })))).toEqual(["720×1230", "2 KB", "landing-page art", "seen 2023-04 – 2026-03", "only on archive.org"]); }); describe("seen ranges and the timeline", () => { const now = new Date(Date.UTC(2026, 9, 15)); // 2026-10 const card = (candidate: NonNullable[0]>["candidate"]): ReviewCard => { const cover = inspected({ candidate }); return { cover, selected: true, type: cover.type, note: "", contentRating: "safe", hostedUrl: null, error: null }; }; const old = card({ current: false, seenAt: "20150312000000", lastSeenAt: "20150901000000" }); const mid = card({ current: false, seenAt: "20190301000000", lastSeenAt: "20220901000000" }); const live = card({ current: true }); const liveArchived = card({ current: true, seenAt: "20230401000000" }); test("the span runs from the earliest sighting to now", () => { expect(seenSpan([live.cover, old.cover, mid.cover], now)).toEqual({ from: 2015 * 12 + 2, to: 2026 * 12 + 9 }); expect(seenSpan([live.cover], now)).toEqual({ from: 2026 * 12 + 9, to: 2026 * 12 + 9 }); }); test("bars are drawn to scale; current covers reach the right edge", () => { const span = seenSpan([old.cover, live.cover], now); const total = span.to - span.from + 1; expect(seenBar(old.cover, span, now)).toEqual({ left: 0, width: (7 / total) * 100 }); const b = seenBar(liveArchived.cover, span, now); expect(b.left + b.width).toBeCloseTo(100, 5); expect(seenBar(live.cover, span, now).width).toBeGreaterThanOrEqual(1.5); }); test("the timeline groups covers by the year they were last seen, newest first", () => { const groups = timelineGroups([old, live, mid, liveArchived], now); expect(groups.map((g) => g.year)).toEqual([2026, 2022, 2015]); expect(groups[0]!.cards).toEqual([live, liveArchived]); expect(groups[1]!.cards).toEqual([mid]); }); }); test("relative time for the history list", async () => { const { relativeTime } = await import("../src/web/view-model"); const now = new Date("2026-10-15T12:00:00Z"); const ago = (ms: number) => new Date(now.getTime() - ms).toISOString(); expect(relativeTime(ago(20_000), now)).toBe("just now"); expect(relativeTime(ago(5 * 60_000), now)).toBe("5 min ago"); expect(relativeTime(ago(3 * 3_600_000), now)).toBe("3 h ago"); expect(relativeTime(ago(30 * 3_600_000), now)).toBe("yesterday"); expect(relativeTime(ago(4 * 86_400_000), now)).toBe("4 days ago"); expect(relativeTime(ago(40 * 86_400_000), now)).toBe("2026-09-05"); }); test("history marks and summary line", async () => { const { markText, summaryLine } = await import("../src/web/view-model"); expect(markText({})).toEqual([]); expect(markText({ downloaded: "x", rehosted: { at: "x", host: "litterbox" }, filed: { at: "x", submissionId: 77 } })) .toEqual(["downloaded", "rehosted on litterbox", "filed #77"]); expect(summaryLine({ fresh: 14, present: 3 })).toBe("14 new · 3 on MangaBaka"); expect(summaryLine({ fresh: 1, present: 0 })).toBe("1 new"); });