- Review draft sync is a pure helper (format.ts): incoming values are ignored while a save is in flight, and a failed save restores the previous synced value, so the typed text is kept whichever of the rollback and the error callback lands first. Tests cover both orders. - A failed background refetch keeps showing the cached entry instead of the error screen (it unmounted the review box mid-outage). - History restore joins the entry-patch mutation scope. - updatePreferences: read-modify-write in a transaction, row locked on Postgres. - Invalidate auth-status after register; out-of-range rating errors speak in the user's scale. Co-Authored-By: Claude Opus 5.5 <[email protected]>
127 lines
6.9 KiB
TypeScript
127 lines
6.9 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
||
import { draftFailedSave, draftIncoming, draftStartSave, 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 typed = { text: "Second thoughts.", synced: "First thoughts.", saving: false };
|
||
|
||
test("a rollback after a failed save keeps the local text (rollback lands first)", () => {
|
||
let d = draftStartSave(typed); // optimistic cache now says "Second thoughts."
|
||
d = draftIncoming(d, "Second thoughts.");
|
||
const was = typed.synced;
|
||
d = draftIncoming(d, "First thoughts."); // onError rolled the cache back
|
||
d = draftFailedSave(d, was);
|
||
expect(d.text).toBe("Second thoughts.");
|
||
expect(d.synced).toBe("First thoughts."); // so the text still counts as unsaved
|
||
expect(d.saving).toBe(false);
|
||
});
|
||
|
||
test("a rollback after a failed save keeps the local text (error callback first)", () => {
|
||
let d = draftStartSave(typed);
|
||
d = draftFailedSave(d, typed.synced);
|
||
d = draftIncoming(d, "First thoughts.");
|
||
expect(d.text).toBe("Second thoughts.");
|
||
});
|
||
|
||
test("a change from elsewhere (Restore) replaces untouched text but not edited text", () => {
|
||
const clean = { text: "Second thoughts.", synced: "Second thoughts.", saving: false };
|
||
expect(draftIncoming(clean, "First thoughts.").text).toBe("First thoughts.");
|
||
expect(draftIncoming(typed, "Something else").text).toBe("Second thoughts.");
|
||
});
|
||
});
|