import { describe, expect, test } from "bun:test"; import { draftIncoming, draftSettled, draftStartSave, newDraft, type Draft, formatDateRange, formatPartialDate, formatRating, friendlyError, parsePartialDate, rollupLabel, shortDate } from "../src/format.ts"; describe("formatRating", () => { test("5 stars with halves", () => { expect(formatRating(90, "5half")).toBe("★★★★½"); expect(formatRating(100, "5half")).toBe("★★★★★"); expect(formatRating(80, "5half")).toBe("★★★★☆"); expect(formatRating(0, "5half")).toBe("☆☆☆☆☆"); }); test("null is empty on every scale", () => { expect(formatRating(null, "5half")).toBe(""); expect(formatRating(null, "10")).toBe(""); expect(formatRating(undefined, "100")).toBe(""); }); test("whole stars, 10 and 100 point scales", () => { expect(formatRating(90, "5")).toBe("★★★★★"); expect(formatRating(70, "5")).toBe("★★★★☆"); expect(formatRating(90, "10")).toBe("9/10"); expect(formatRating(85, "10")).toBe("8.5/10"); expect(formatRating(73, "100")).toBe("73/100"); }); }); describe("formatPartialDate", () => { test("renders by precision", () => { expect(formatPartialDate({ value: "2025-03", precision: "month" })).toBe("Mar 2025"); expect(formatPartialDate({ value: "2019", precision: "year" })).toBe("2019"); expect(formatPartialDate({ value: "2026-09-14", precision: "day" })).toBe("14 Sep 2026"); expect(formatPartialDate({ value: "2026-09-04T23:30:00-05:00", precision: "day" })).toBe("4 Sep 2026"); expect(formatPartialDate({ value: "", precision: "unknown" })).toBe(""); expect(formatPartialDate(null)).toBe(""); }); }); describe("formatDateRange", () => { test("flags dates that are not exact", () => { expect(formatDateRange(null, { value: "2025-03", precision: "month" })).toBe("Mar 2025 (no exact date)"); expect(formatDateRange({ value: "2026-09-02", precision: "day" }, { value: "2026-09-14", precision: "day" })).toBe( "2 Sep – 14 Sep 2026", ); expect(formatDateRange({ value: "2025-12-30", precision: "day" }, { value: "2026-01-02", precision: "day" })).toBe( "30 Dec 2025 – 2 Jan 2026", ); expect(formatDateRange({ value: "2026-09-02", precision: "day" }, null)).toBe("Started 2 Sep 2026"); expect(formatDateRange(null, null)).toBe("No dates"); }); }); describe("parsePartialDate", () => { test("accepts year, month and day, rejects the rest", () => { expect(parsePartialDate("2019")).toEqual({ value: "2019", precision: "year" }); expect(parsePartialDate(" 2025-03 ")).toEqual({ value: "2025-03", precision: "month" }); expect(parsePartialDate("2024-02-29")).toEqual({ value: "2024-02-29", precision: "day" }); expect(parsePartialDate("2023-02-29")).toBeNull(); expect(parsePartialDate("03/04/2019")).toBeNull(); expect(parsePartialDate("")).toBeNull(); }); }); describe("rollupLabel", () => { test("children roll up as 'season 3 of 5'", () => { expect(rollupLabel("show", { total: 5, completed: 3 })).toBe("season 3 of 5"); expect(rollupLabel("show", { total: 5, completed: 0 })).toBe("5 seasons"); expect(rollupLabel("podcast", { total: 200, completed: 200 })).toBe("all 200 episodes"); expect(rollupLabel("book", null)).toBeNull(); }); }); describe("friendlyError", () => { const v = (message: string, status = 400) => ({ status, code: "validation", message }); test("maps validation reasons to plain copy", () => { expect(friendlyError(v("customFields.Website: not_url"))).toBe("Website needs a full web address, starting with https://."); expect(friendlyError(v("finishedAt: not_date"))).toBe("Finished needs a date like 2025, 2025-03 or 2025-03-14."); expect(friendlyError(v("rating: out_of_range"))).toBe("Pick a rating between 0 and 5 stars."); expect(friendlyError(v("rating: out_of_range"), { ratingScale: "10" })).toBe("Pick a rating between 0 and 10."); expect(friendlyError(v("customFields.Mood: out_of_range"), { ratingScale: "5" })).toBe("Pick a rating for Mood between 0 and 5 stars."); expect(friendlyError(v("title: required"))).toBe("Title can't be empty."); expect(friendlyError(v("review: too_long"))).toBe("Review is too long. Shorten it and try again."); expect(friendlyError(v("password: Too small: expected string to have >=8 characters"))).toBe("Passwords need at least 8 characters."); expect(friendlyError(v("title: Too big: expected string to have <=1000 characters"))).toBe("Title is too long. Shorten it and try again."); expect(friendlyError(v("mime: unsupported file type"))).toBe("That file type isn't supported. Use an image, a PDF or a text file."); expect(friendlyError(v("customFields.Mood: unknown_option"))).toBe("Mood has an option that no longer exists. Pick one from the list."); expect(friendlyError(v("displayName: weird_reason"))).toBe("Display name wasn't accepted. Check it and try again."); }); test("413 means the file is too large", () => { expect(friendlyError({ status: 413, code: "validation", message: "Request body is too large (max 50 MB)." })).toBe("That file is too large. The limit is 25 MB."); expect(friendlyError(v("file: larger than 25 MB"))).toBe("That file is too large. The limit is 25 MB."); }); test("other API errors keep the server's message; unknown errors get a generic line", () => { expect(friendlyError({ status: 401, code: "unauthenticated", message: "Wrong username or password." })).toBe("Wrong username or password."); expect(friendlyError(new Error("boom"))).toBe("Something went wrong. Try again."); }); }); test("shortDate", () => { expect(shortDate("2026-10-12T10:00:00")).toBe("12 Oct"); }); describe("review draft sync", () => { const draft = (text: string, synced: string): Draft => ({ ...newDraft(synced), text }); const pick = (d: Draft) => [d.text, d.synced, d.pending]; test("a rollback after a failed save keeps the local text", () => { const save = { was: "First thoughts.", stored: "Second thoughts." }; let d = draftStartSave(draft("Second thoughts.", save.was), save); d = draftIncoming(d, save.stored); // optimistic update d = draftIncoming(d, save.was); // rollback d = draftSettled(d, save, false); expect(pick(d)).toEqual(["Second thoughts.", "First thoughts.", 0]); }); // Two overlapping saves: "a" (was "old"), then "b" (was "a"). Incoming values mirror what the cache does. const A = { was: "old", stored: "a" }; const B = { was: "a", stored: "b" }; const twoSaves = () => draftIncoming(draftIncoming(draftStartSave({ ...draftStartSave(draft("a", "old"), A), text: "b" }, B), "a"), "b"); test("two overlapping saves both settle and the flag clears (success, success)", () => { let d = twoSaves(); expect(d.pending).toBe(2); d = draftSettled(draftSettled(d, A, true), B, true); expect(pick(d)).toEqual(["b", "b", 0]); expect(d.echoes).toEqual([]); }); test("two overlapping saves: fail then success ends synced; success then fail keeps text unsaved", () => { let d = draftIncoming(twoSaves(), "old"); // A's rollback d = draftSettled(draftSettled(d, A, false), B, true); expect(pick(d)).toEqual(["b", "b", 0]); let e = draftIncoming(twoSaves(), "a"); // B's rollback e = draftSettled(draftSettled(e, A, true), B, false); expect(pick(e)).toEqual(["b", "a", 0]); }); test("an incoming value that arrives while saving is applied once saving ends", () => { const save = { was: "x", stored: "y" }; let d = draftStartSave(draft("y", "x"), save); d = draftIncoming(d, "Restored."); expect(d.text).toBe("y"); d = draftSettled(d, save, true); expect(pick(d)).toEqual(["Restored.", "Restored.", 0]); }); test("a change from elsewhere replaces untouched text but not edited text", () => { expect(draftIncoming(draft("Second.", "Second."), "First.").text).toBe("First."); expect(draftIncoming(draft("Typed.", "Second."), "Other.").text).toBe("Typed."); }); });