From 731c552661ea28a3046b7413689013a9a685ff04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C5=ABdolfs=20O=C5=A1i=C5=86=C5=A1?= Date: Wed, 30 Sep 2026 16:28:21 +0200 Subject: [PATCH] Add unit tests for the lib modules --- tests/unit/codeCommentActions.test.ts | 210 +++++++++++++++ tests/unit/diffOptions.svelte.test.ts | 58 ++++ tests/unit/diffText.test.ts | 152 +++++++++++ tests/unit/hints.test.ts | 51 ++++ tests/unit/identityState.test.ts | 42 +-- tests/unit/ignoredFiles.test.ts | 31 +++ tests/unit/inboxFilter.test.ts | 106 ++++++++ tests/unit/issueCounts.test.ts | 48 ++++ tests/unit/listState.test.ts | 39 +++ tests/unit/modal.test.ts | 111 ++++++++ tests/unit/notificationCount.test.ts | 11 - tests/unit/notifications.test.ts | 22 -- tests/unit/paginatedList.svelte.test.ts | 329 +++++++++++++++++++++++ tests/unit/patchCounts.test.ts | 60 +++++ tests/unit/revisionListSettings.test.ts | 56 ++++ tests/unit/router.test.ts | 102 +++++++ tests/unit/shareAction.test.ts | 54 ++++ tests/unit/shortcuts.svelte.test.ts | 339 ++++++++++++++++++++++++ tests/unit/sidebar.test.ts | 166 ++++++++++++ tests/unit/updateChecker.test.ts | 144 ++++++++++ tests/unit/useLocalStorage.test.ts | 62 +++++ tests/unit/utils.test.ts | 15 +- 22 files changed, 2131 insertions(+), 77 deletions(-) create mode 100644 tests/unit/codeCommentActions.test.ts create mode 100644 tests/unit/diffOptions.svelte.test.ts create mode 100644 tests/unit/diffText.test.ts create mode 100644 tests/unit/hints.test.ts create mode 100644 tests/unit/ignoredFiles.test.ts create mode 100644 tests/unit/inboxFilter.test.ts create mode 100644 tests/unit/issueCounts.test.ts create mode 100644 tests/unit/listState.test.ts create mode 100644 tests/unit/modal.test.ts delete mode 100644 tests/unit/notificationCount.test.ts create mode 100644 tests/unit/paginatedList.svelte.test.ts create mode 100644 tests/unit/patchCounts.test.ts create mode 100644 tests/unit/revisionListSettings.test.ts create mode 100644 tests/unit/router.test.ts create mode 100644 tests/unit/shareAction.test.ts create mode 100644 tests/unit/shortcuts.svelte.test.ts create mode 100644 tests/unit/sidebar.test.ts create mode 100644 tests/unit/updateChecker.test.ts create mode 100644 tests/unit/useLocalStorage.test.ts diff --git a/tests/unit/codeCommentActions.test.ts b/tests/unit/codeCommentActions.test.ts new file mode 100644 index 000000000..6fca814f2 --- /dev/null +++ b/tests/unit/codeCommentActions.test.ts @@ -0,0 +1,210 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import type { CommentOwner } from "@app/lib/codeCommentActions"; +import { commentActions } from "@app/lib/codeCommentActions"; + +import { author } from "./support/cobs"; + +const { invoke, draftReviewStorage } = vi.hoisted(() => ({ + invoke: vi.fn(), + draftReviewStorage: { updateComment: vi.fn(), deleteComment: vi.fn() }, +})); +vi.mock("@app/lib/invoke", () => ({ invoke })); +vi.mock("@app/lib/draftReviewStorage", () => ({ draftReviewStorage })); + +const me = author("me"); +const other = author("other"); +const embeds = [{ name: "a.png", content: "data:image/png;base64," }]; + +const owners: Record = { + d: { kind: "draft", draftId: "draft-1" }, + r: { kind: "review", reviewId: "review-1" }, + v: { kind: "revision", revisionId: "revision-1" }, +}; + +let reload: ReturnType Promise>>; + +function actions(announce = false) { + return commentActions({ + rid: "rad:z1", + patchId: "patch-1", + publicKey: me.did.replace("did:key:", ""), + announce, + ownerOf: id => owners[id], + reload, + }); +} + +function sentAction() { + expect(invoke).toHaveBeenCalledTimes(1); + const [command, args] = invoke.mock.calls[0]; + expect(command).toBe("edit_patch"); + expect(args).toMatchObject({ rid: "rad:z1", cobId: "patch-1" }); + return args.action; +} + +beforeEach(() => { + invoke.mockReset().mockResolvedValue(undefined); + draftReviewStorage.updateComment.mockReset(); + draftReviewStorage.deleteComment.mockReset(); + reload = vi.fn(() => Promise.resolve()); +}); + +test("passes the announce option through", async () => { + await actions(true).deleteComment("r"); + + expect(invoke.mock.calls[0][1].opts).toEqual({ announce: true }); +}); + +describe("editComment", () => { + test("updates a draft comment locally", async () => { + await actions().editComment("d", "new", embeds); + + expect(draftReviewStorage.updateComment).toHaveBeenCalledWith( + "draft-1", + "d", + { body: "new" }, + ); + expect(invoke).not.toHaveBeenCalled(); + expect(reload).toHaveBeenCalledTimes(1); + }); + + test.each([ + [ + "r", + { + type: "review.comment.edit", + review: "review-1", + comment: "r", + body: "new", + embeds, + }, + ], + [ + "v", + { + type: "revision.comment.edit", + revision: "revision-1", + comment: "v", + body: "new", + embeds, + }, + ], + ])("edits comment %s on its owner", async (id, expected) => { + await actions().editComment(id, "new", embeds); + + expect(sentAction()).toEqual(expected); + expect(reload).toHaveBeenCalledTimes(1); + }); +}); + +describe("deleteComment", () => { + test("deletes a draft comment locally", async () => { + await actions().deleteComment("d"); + + expect(draftReviewStorage.deleteComment).toHaveBeenCalledWith( + "draft-1", + "d", + ); + expect(invoke).not.toHaveBeenCalled(); + expect(reload).toHaveBeenCalledTimes(1); + }); + + test.each([ + ["r", { type: "review.comment.redact", review: "review-1", comment: "r" }], + [ + "v", + { type: "revision.comment.redact", revision: "revision-1", comment: "v" }, + ], + ])("redacts comment %s on its owner", async (id, expected) => { + await actions().deleteComment(id); + + expect(sentAction()).toEqual(expected); + }); +}); + +describe("changeCommentStatus", () => { + test.each([ + [true, "review.comment.resolve"], + [false, "review.comment.unresolve"], + ])("resolved=%s sends %s", async (resolved, type) => { + await actions().changeCommentStatus("r", resolved); + + expect(sentAction()).toEqual({ type, review: "review-1", comment: "r" }); + }); + + test.each(["d", "v"])("ignores comment %s outside a review", async id => { + await actions().changeCommentStatus(id, true); + + expect(invoke).not.toHaveBeenCalled(); + expect(reload).not.toHaveBeenCalled(); + }); +}); + +describe("reactOnComment", () => { + test.each([ + ["adds", [other], true], + ["removes", [other, me], false], + ])("%s the viewer's reaction", async (_, authors, active) => { + await actions().reactOnComment("r", authors, "🚀"); + + expect(sentAction()).toEqual({ + type: "review.comment.react", + review: "review-1", + comment: "r", + reaction: "🚀", + active, + }); + }); + + test("reacts on a revision comment", async () => { + await actions().reactOnComment("v", [], "🚀"); + + expect(sentAction()).toEqual({ + type: "revision.comment.react", + revision: "revision-1", + comment: "v", + reaction: "🚀", + active: true, + }); + }); + + test("ignores draft comments", async () => { + await actions().reactOnComment("d", [], "🚀"); + + expect(invoke).not.toHaveBeenCalled(); + expect(reload).not.toHaveBeenCalled(); + }); +}); + +test.each([ + [ + "editComment", + (a: ReturnType) => a.editComment("x", "", []), + ], + ["deleteComment", (a: ReturnType) => a.deleteComment("x")], + [ + "reactOnComment", + (a: ReturnType) => a.reactOnComment("x", [], "🚀"), + ], +])("%s skips comments without a known owner", async (_, act) => { + await act(actions()); + + expect(invoke).not.toHaveBeenCalled(); + expect(draftReviewStorage.updateComment).not.toHaveBeenCalled(); + expect(draftReviewStorage.deleteComment).not.toHaveBeenCalled(); + expect(reload).not.toHaveBeenCalled(); +}); + +test("reloads and logs when the action fails", async () => { + const error = vi.spyOn(console, "error").mockReturnValue(undefined); + invoke.mockRejectedValue(new Error("boom")); + + await actions().deleteComment("r"); + + expect(error).toHaveBeenCalledWith( + "Deleting comment failed", + expect.any(Error), + ); + expect(reload).toHaveBeenCalledTimes(1); +}); diff --git a/tests/unit/diffOptions.svelte.test.ts b/tests/unit/diffOptions.svelte.test.ts new file mode 100644 index 000000000..a09000427 --- /dev/null +++ b/tests/unit/diffOptions.svelte.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, expect, test, vi } from "vitest"; + +const defaults = { + showTree: true, + diffStyle: "unified", + wordWrap: false, + indicators: "bars", + lineDiffType: "word-alt", +}; + +async function load() { + const { diffOptions } = await import("@app/lib/diffOptions.svelte"); + const { flushSync } = await import("svelte"); + return { diffOptions, flushSync }; +} + +beforeEach(() => { + localStorage.clear(); + vi.resetModules(); +}); + +test("starts with the defaults when nothing is stored", async () => { + expect((await load()).diffOptions).toEqual(defaults); +}); + +test("merges stored options over the defaults", async () => { + localStorage.setItem( + "diffOptions", + JSON.stringify({ diffStyle: "split", wordWrap: true }), + ); + + expect((await load()).diffOptions).toEqual({ + ...defaults, + diffStyle: "split", + wordWrap: true, + }); +}); + +test.each([ + "{not json", + "null", + JSON.stringify({ diffStyle: "sideways", wordWrap: true }), +])("falls back to the defaults for %s", async stored => { + localStorage.setItem("diffOptions", stored); + + expect((await load()).diffOptions).toEqual(defaults); +}); + +test("persists changes", async () => { + const { diffOptions, flushSync } = await load(); + diffOptions.indicators = "none"; + flushSync(); + + expect(JSON.parse(localStorage.getItem("diffOptions") ?? "")).toEqual({ + ...defaults, + indicators: "none", + }); +}); diff --git a/tests/unit/diffText.test.ts b/tests/unit/diffText.test.ts new file mode 100644 index 000000000..d79cff67b --- /dev/null +++ b/tests/unit/diffText.test.ts @@ -0,0 +1,152 @@ +import type { DiffContent } from "@bindings/diff/DiffContent"; +import type { FileDiff } from "@bindings/diff/FileDiff"; + +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { + checkedFileProgress, + checkedPaths, + fileDiffPath, + fileMetaOf, + fullFileLoader, + gitStatusEntries, +} from "@app/lib/diffText"; + +const { invoke } = vi.hoisted(() => ({ invoke: vi.fn() })); +vi.mock("@app/lib/invoke", () => ({ invoke })); + +const plain: DiffContent = { + type: "plain", + stats: { additions: 1, deletions: 0 }, +}; + +const added: FileDiff = { status: "added", path: "new.ts", diff: plain }; +const deleted: FileDiff = { status: "deleted", path: "old.ts", diff: plain }; +const modified: FileDiff = { status: "modified", path: "a.ts", diff: plain }; +const moved: FileDiff = { + status: "moved", + oldPath: "from.ts", + newPath: "to.ts", + diff: plain, +}; +const copied: FileDiff = { + status: "copied", + oldPath: "orig.ts", + newPath: "copy.ts", + diff: plain, +}; + +test("fileDiffPath names renames and copies by their new path", () => { + expect([added, deleted, modified, moved, copied].map(fileDiffPath)).toEqual([ + "new.ts", + "old.ts", + "a.ts", + "to.ts", + "copy.ts", + ]); +}); + +test("gitStatusEntries marks a rename as renamed and a copy as added", () => { + expect(gitStatusEntries([added, deleted, modified, moved, copied])).toEqual([ + { path: "new.ts", status: "added" }, + { path: "old.ts", status: "deleted" }, + { path: "a.ts", status: "modified" }, + { path: "to.ts", status: "renamed" }, + { path: "copy.ts", status: "added" }, + ]); +}); + +test("fileMetaOf notes unrenderable files and lockfiles", () => { + const meta = fileMetaOf([ + moved, + { status: "added", path: "logo.png", diff: { type: "binary" } }, + { status: "added", path: "empty.txt", diff: { type: "empty" } }, + { status: "modified", path: "web/package-lock.json", diff: plain }, + ]); + + expect(Object.fromEntries(meta.statuses)).toEqual({ + "to.ts": "moved", + "logo.png": "added", + "empty.txt": "added", + "web/package-lock.json": "modified", + }); + expect(Object.fromEntries(meta.notes)).toEqual({ + "logo.png": "binary", + "empty.txt": "empty", + }); + expect([...meta.ignored]).toEqual(["web/package-lock.json"]); +}); + +test("checkedFileProgress ignores checks on files the diff no longer has", () => { + expect( + checkedFileProgress([modified, moved], ["a.ts", "from.ts", "gone.ts"]), + ).toEqual({ filesChecked: 1, filesTotal: 2 }); +}); + +test("checkedPaths names checked files by their diff path", () => { + expect([...checkedPaths([modified, moved], path => path !== "a.ts")]).toEqual( + ["to.ts"], + ); +}); + +describe("fullFileLoader", () => { + beforeEach(() => { + invoke.mockReset(); + invoke.mockImplementation( + (_cmd, { path, sha }: { path: string; sha: string }) => + Promise.resolve( + path.endsWith(".png") + ? { binary: true, content: "\u0089PNG" } + : { binary: false, content: `${path}@${sha}` }, + ), + ); + }); + + const load = (files: FileDiff[]) => + fullFileLoader("rad:z", "base", "head", () => files); + + test("reads a rename's old side from its old path", async () => { + expect(await load([moved])("to.ts")).toEqual({ + oldContents: "from.ts@base", + newContents: "to.ts@head", + }); + }); + + test("reads only the side a file has", async () => { + expect(await load([added])("new.ts")).toEqual({ + oldContents: "", + newContents: "new.ts@head", + }); + expect(await load([deleted])("old.ts")).toEqual({ + oldContents: "old.ts@base", + newContents: "", + }); + expect(invoke).toHaveBeenCalledTimes(2); + }); + + test("has no old side without a base", async () => { + const rootCommit = fullFileLoader("rad:z", undefined, "head", () => [ + modified, + ]); + expect(await rootCommit("a.ts")).toEqual({ + oldContents: "", + newContents: "a.ts@head", + }); + }); + + test("gives binary blobs and unknown paths no contents", async () => { + const binary: FileDiff = { + status: "modified", + path: "logo.png", + diff: { type: "binary" }, + }; + expect(await load([binary])("logo.png")).toEqual({ + oldContents: "", + newContents: "", + }); + expect(await load([modified])("missing.ts")).toEqual({ + oldContents: "", + newContents: "", + }); + }); +}); diff --git a/tests/unit/hints.test.ts b/tests/unit/hints.test.ts new file mode 100644 index 000000000..3cf37c98e --- /dev/null +++ b/tests/unit/hints.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, expect, test, vi } from "vitest"; + +async function load() { + vi.resetModules(); + return (await import("@app/lib/hints")).hints; +} + +beforeEach(() => { + localStorage.clear(); + vi.spyOn(console, "error").mockReturnValue(undefined); +}); + +test("starts with nothing dismissed", async () => { + const hints = await load(); + + expect(hints.isDismissed("markdown")).toBe(false); + expect(hints.dismissedCount).toBe(0); +}); + +test("remembers dismissed hints across reloads", async () => { + const hints = await load(); + hints.dismiss("markdown"); + hints.dismiss("markdown"); + + const reloaded = await load(); + expect(reloaded.isDismissed("markdown")).toBe(true); + expect(reloaded.isDismissed("guide")).toBe(false); + expect(reloaded.dismissedCount).toBe(1); +}); + +test("resetAll brings every hint back", async () => { + const hints = await load(); + hints.dismiss("markdown"); + hints.dismiss("guide"); + expect(hints.dismissedCount).toBe(2); + + hints.resetAll(); + + expect(hints.isDismissed("markdown")).toBe(false); + expect(hints.dismissedCount).toBe(0); + expect((await load()).dismissedCount).toBe(0); +}); + +test("ignores an invalid stored value", async () => { + localStorage.setItem("dismissedHints", '"markdown"'); + + const hints = await load(); + + expect(hints.isDismissed("markdown")).toBe(false); + expect(hints.dismissedCount).toBe(0); +}); diff --git a/tests/unit/identityState.test.ts b/tests/unit/identityState.test.ts index 8b0813dea..302b91621 100644 --- a/tests/unit/identityState.test.ts +++ b/tests/unit/identityState.test.ts @@ -1,34 +1,16 @@ -import type { State } from "@bindings/identity/State"; - import { expect, test } from "vitest"; -import { stateCaption, stateIcon, stateLabel } from "@app/lib/identityState"; +import { stateCaption } from "@app/lib/identityState"; -const sibling = "4b2d0a1e9f5c3b7a6d8e0f1a2b3c4d5e6f7a8b9c"; - -test.each<[State, string, string, string | undefined]>([ - [{ status: "active" }, "hourglass", "Active", undefined], - [{ status: "accepted" }, "checkmark", "Accepted", undefined], - [ - { status: "rejected", reason: { type: "vote" } }, - "close", - "Rejected", - "Rejected by delegate votes", - ], - [ - { status: "rejected", reason: { type: "parent" } }, - "close", - "Rejected", - "Rejected because its parent revision was rejected", - ], - [ - { status: "rejected", reason: { type: "sibling", revision: sibling } }, - "close", - "Rejected", - "Rejected because sibling 4b2d0a1 was accepted", - ], -])("%j", (state, icon, label, caption) => { - expect(stateIcon(state)).toBe(icon); - expect(stateLabel(state)).toBe(label); - expect(stateCaption(state)).toBe(caption); +test("stateCaption names the sibling that was accepted", () => { + expect( + stateCaption({ + status: "rejected", + reason: { + type: "sibling", + revision: "4b2d0a1e9f5c3b7a6d8e0f1a2b3c4d5e6f7a8b9c", + }, + }), + ).toBe("Rejected because sibling 4b2d0a1 was accepted"); + expect(stateCaption({ status: "accepted" })).toBeUndefined(); }); diff --git a/tests/unit/ignoredFiles.test.ts b/tests/unit/ignoredFiles.test.ts new file mode 100644 index 000000000..c5f3c311d --- /dev/null +++ b/tests/unit/ignoredFiles.test.ts @@ -0,0 +1,31 @@ +import type { FileDiff } from "@bindings/diff/FileDiff"; + +import { expect, test } from "vitest"; + +import { isIgnoredFile, isIgnoredPath } from "@app/lib/ignoredFiles"; + +const diff = { type: "empty" } as const; + +test.each([ + ["Cargo.lock", true], + ["web/package-lock.json", true], + ["a/b/go.sum", true], + ["Cargo.lock.bak", false], + ["cargo.lock", false], + ["Cargo.lock/main.rs", false], + ["src/main.rs", false], +])("isIgnoredPath(%j) is %s", (path, expected) => { + expect(isIgnoredPath(path)).toBe(expected); +}); + +test("isIgnoredFile judges a renamed file by its new path", () => { + const moved = (oldPath: string, newPath: string): FileDiff => ({ + status: "moved", + oldPath, + newPath, + diff, + }); + + expect(isIgnoredFile(moved("yarn.lock", "notes.txt"))).toBe(false); + expect(isIgnoredFile(moved("notes.txt", "yarn.lock"))).toBe(true); +}); diff --git a/tests/unit/inboxFilter.test.ts b/tests/unit/inboxFilter.test.ts new file mode 100644 index 000000000..691b0cc6f --- /dev/null +++ b/tests/unit/inboxFilter.test.ts @@ -0,0 +1,106 @@ +import type { Author } from "@bindings/cob/Author"; +import type { NotificationItem } from "@bindings/cob/inbox/NotificationItem"; +import type { NotificationsByRepo } from "@bindings/cob/inbox/NotificationsByRepo"; + +import { describe, expect, test } from "vitest"; + +import { filterNotifications, rowIdsOf } from "@app/lib/inboxFilter"; + +import { author } from "./support/cobs"; + +function issue( + id: string, + rowId: string, + props: { title?: string; authors?: Author[]; closed?: boolean } = {}, +): NotificationItem { + const { title = "Untitled", authors = [author("alice")], closed } = props; + return { + type: "issue", + rowId, + id, + update: { type: "created", name: "refs/cobs", oid: "o" }, + title, + timestamp: 0, + status: closed + ? { status: "closed", reason: "solved" } + : { status: "open" }, + actions: authors.map(by => ({ + type: "edit", + title, + oid: "o", + timestamp: 0, + author: by, + })), + repoId: "rad:z1", + }; +} + +function repo( + rid: string, + notifications: NotificationItem[][], +): NotificationsByRepo { + return { rid, name: rid, notifications, count: 99 }; +} + +const crash = [issue("aaa111", "1", { title: "Crash on startup" })]; +const renamed = [ + issue("bbb222", "2", { title: "Old wording" }), + issue("bbb222", "3", { + title: "Sidebar overflow", + authors: [author("bob"), author("carol", null)], + closed: true, + }), +]; +const repos = [repo("rad:z1", [crash, renamed]), repo("rad:z2", [])]; + +function ids(query: string, excluded?: string[]) { + return filterNotifications(repos, query, excluded).map(r => ({ + rid: r.repo.rid, + ids: r.groups.map(g => g[0].id), + })); +} + +describe("filterNotifications", () => { + test.each(["", " "])("returns every repo unchanged for %j", query => { + expect(filterNotifications(repos, query)).toEqual([ + { repo: repos[0], groups: [crash, renamed], count: 99 }, + { repo: repos[1], groups: [], count: 99 }, + ]); + }); + + test.each([ + ["the latest title", "sidebar"], + ["the latest state", "closed"], + ["an alias from any item", "bob"], + ["a DID", "z6Mkcarol"], + ["the id", "bbb222"], + ])("matches %s", (_, query) => { + expect(ids(query)).toEqual([{ rid: "rad:z1", ids: ["bbb222"] }]); + }); + + test("matches the type", () => { + expect(ids("issue")[0].ids).toEqual( + expect.arrayContaining(["aaa111", "bbb222"]), + ); + }); + + test.each([ + ["an earlier title", "wording"], + ["a missing alias", "undefined"], + ["a weak fuzzy match", "cso"], + ])("doesn't match %s", (_, query) => { + expect(ids(query)).toEqual([]); + }); + + test("counts the items of the matching groups", () => { + expect(filterNotifications(repos, "sidebar")[0].count).toBe(2); + }); + + test("drops excluded groups and repos left empty", () => { + expect(ids("crash", ["aaa111"])).toEqual([]); + }); +}); + +test("rowIdsOf lists the rows of every group", () => { + expect(rowIdsOf([crash, renamed])).toEqual(["1", "2", "3"]); +}); diff --git a/tests/unit/issueCounts.test.ts b/tests/unit/issueCounts.test.ts new file mode 100644 index 000000000..0fc908b86 --- /dev/null +++ b/tests/unit/issueCounts.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, expect, test } from "vitest"; + +import { + issueCountMismatch, + issueCounts, + resetIssueCounts, + updateIssueCounts, +} from "@app/lib/issueCounts.svelte"; + +const sidebar = { open: 3, closed: 2, all: 5 }; + +beforeEach(() => { + resetIssueCounts(); +}); + +test.each([ + ["all", 5], + ["open", 3], + ["closed", 2], +] as const)( + "matches when the %s list has the sidebar's count", + (status, count) => { + updateIssueCounts(count, sidebar, status); + + expect(issueCounts[status]).toEqual({ sidebar: count, total: count }); + expect(issueCountMismatch(status)).toBe(false); + }, +); + +test("flags a list whose length differs from the sidebar", () => { + updateIssueCounts(4, sidebar, "open"); + + expect(issueCountMismatch("open")).toBe(true); + expect(issueCountMismatch("closed")).toBe(false); +}); + +test("reset clears every status", () => { + updateIssueCounts(4, sidebar, "open"); + updateIssueCounts(1, sidebar, "closed"); + resetIssueCounts(); + + expect(issueCounts).toEqual({ + all: { sidebar: null, total: null }, + open: { sidebar: null, total: null }, + closed: { sidebar: null, total: null }, + }); + expect(issueCountMismatch("open")).toBe(false); +}); diff --git a/tests/unit/listState.test.ts b/tests/unit/listState.test.ts new file mode 100644 index 000000000..af63f27f5 --- /dev/null +++ b/tests/unit/listState.test.ts @@ -0,0 +1,39 @@ +import { expect, test } from "vitest"; + +import { readListState, saveListState } from "@app/lib/listState"; + +function state(scrollOffset: number) { + return { items: [scrollOffset], more: false, scrollOffset, cache: undefined }; +} + +function saveMany(prefix: string, count: number) { + for (let i = 0; i < count; i++) { + saveListState(`${prefix}${i}`, state(i)); + } +} + +test("reads back the latest saved state", () => { + expect(readListState("list")).toBeUndefined(); + + saveListState("list", state(1)); + saveListState("list", state(2)); + + expect(readListState("list")).toEqual(state(2)); +}); + +test("keeps only the 20 most recent lists", () => { + saveMany("a", 21); + + expect(readListState("a0")).toBeUndefined(); + expect(readListState("a1")).toEqual(state(1)); + expect(readListState("a20")).toEqual(state(20)); +}); + +test("saving a list again makes it the most recent", () => { + saveMany("b", 20); + saveListState("b0", state(0)); + saveListState("b20", state(20)); + + expect(readListState("b0")).toEqual(state(0)); + expect(readListState("b1")).toBeUndefined(); +}); diff --git a/tests/unit/modal.test.ts b/tests/unit/modal.test.ts new file mode 100644 index 000000000..c89f5fcf6 --- /dev/null +++ b/tests/unit/modal.test.ts @@ -0,0 +1,111 @@ +import type { Component } from "svelte"; + +import { get } from "svelte/store"; +import { afterEach, expect, test, vi } from "vitest"; + +import * as modal from "@app/lib/modal"; + +const First = vi.fn() as unknown as Component; +const Second = vi.fn() as unknown as Component; + +afterEach(() => { + modal.forceHide(); + document.body.replaceChildren(); +}); + +test("show opens the modal with the scrim close enabled by default", () => { + modal.show({ component: First, props: { a: 1 } }); + + expect(get(modal.modalStore)).toEqual({ + component: First, + props: { a: 1 }, + disableScrimClose: false, + }); + + modal.show({ component: Second, props: {}, disableScrimClose: true }); + + expect(get(modal.modalStore)).toMatchObject({ + component: Second, + disableScrimClose: true, + }); +}); + +test("show blurs the focused element", () => { + const input = document.createElement("input"); + document.body.append(input); + input.focus(); + expect(document.activeElement).toBe(input); + + modal.show({ component: First, props: {} }); + + expect(document.activeElement).not.toBe(input); +}); + +test("hide closes the modal and runs its hide callback once", () => { + const hideCallback = vi.fn(); + modal.show({ component: First, props: {}, hideCallback }); + + modal.hide(); + modal.hide(); + + expect(get(modal.modalStore)).toBeUndefined(); + expect(hideCallback).toHaveBeenCalledTimes(1); +}); + +test("disableHide keeps the modal open until enableHide", () => { + const hideCallback = vi.fn(); + modal.show({ component: First, props: {}, hideCallback }); + modal.disableHide(); + + modal.hide(); + expect(get(modal.modalStore)).toBeDefined(); + expect(hideCallback).not.toHaveBeenCalled(); + + modal.enableHide(); + modal.hide(); + expect(get(modal.modalStore)).toBeUndefined(); + expect(hideCallback).toHaveBeenCalledTimes(1); +}); + +test("forceHide closes the modal even when hiding is disabled", () => { + const hideCallback = vi.fn(); + modal.show({ component: First, props: {}, hideCallback }); + modal.disableHide(); + + modal.forceHide(); + modal.forceHide(); + + expect(get(modal.modalStore)).toBeUndefined(); + expect(hideCallback).toHaveBeenCalledTimes(1); +}); + +test.each([ + ["enableHide", modal.enableHide], + ["disableHide", modal.disableHide], +])("%s without a modal leaves it closed", (_, fn) => { + fn(); + + expect(get(modal.modalStore)).toBeUndefined(); +}); + +test("toggle opens, switches and closes the modal", () => { + modal.toggle({ component: First, props: {} }); + expect(get(modal.modalStore)?.component).toBe(First); + + modal.toggle({ component: Second, props: {} }); + expect(get(modal.modalStore)?.component).toBe(Second); + + modal.toggle({ component: Second, props: {} }); + expect(get(modal.modalStore)).toBeUndefined(); +}); + +test("toggle does nothing while hiding is disabled", () => { + modal.show({ component: First, props: {} }); + modal.disableHide(); + + modal.toggle({ component: Second, props: {} }); + expect(get(modal.modalStore)?.component).toBe(First); + + modal.toggle({ component: First, props: {} }); + expect(get(modal.modalStore)?.component).toBe(First); +}); diff --git a/tests/unit/notificationCount.test.ts b/tests/unit/notificationCount.test.ts deleted file mode 100644 index d6ddd30a5..000000000 --- a/tests/unit/notificationCount.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { expect, test } from "vitest"; - -import { badgeCount } from "@app/lib/notificationCount.svelte"; - -test.each([ - [3, true, 3], - [0, true, undefined], - [3, false, undefined], -])("%j notifications with the badge on %j show %j", (count, on, expected) => { - expect(badgeCount(count, on)).toBe(expected); -}); diff --git a/tests/unit/notifications.test.ts b/tests/unit/notifications.test.ts index 9ebb28e3e..03e08ab91 100644 --- a/tests/unit/notifications.test.ts +++ b/tests/unit/notifications.test.ts @@ -11,7 +11,6 @@ import { notificationActions, notificationIcon, notificationRoute, - notificationStatusColor, } from "@app/lib/notification"; import { formatOid } from "@app/lib/utils"; @@ -283,27 +282,6 @@ describe("notification teaser", () => { expect(notificationIcon(notification)).toBe(expected); }); - test("colors follow the status", () => { - expect(notificationStatusColor(item("patch", { status: "draft" }))).toEqual( - { - color: "var(--color-text-draft)", - background: "var(--color-surface-draft)", - }, - ); - expect( - notificationStatusColor( - item("issue", { status: "closed", reason: "solved" }), - ), - ).toEqual({ - color: "var(--color-text-closed)", - background: "var(--color-surface-closed)", - }); - expect(notificationStatusColor(undefined)).toEqual({ - color: "var(--color-text-secondary)", - background: "var(--color-surface-subtle)", - }); - }); - test("routes to the patch or to the issue in every status", () => { expect( notificationRoute("rad:z", item("patch", { status: "open" })), diff --git a/tests/unit/paginatedList.svelte.test.ts b/tests/unit/paginatedList.svelte.test.ts new file mode 100644 index 000000000..27d3360fc --- /dev/null +++ b/tests/unit/paginatedList.svelte.test.ts @@ -0,0 +1,329 @@ +import type { PaginatedQuery } from "@bindings/cob/PaginatedQuery"; + +import { flushSync } from "svelte"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; + +import { readListState, saveListState } from "@app/lib/listState"; +import { createPaginatedList } from "@app/lib/paginatedList.svelte"; + +const { isHistoryNavigation } = vi.hoisted(() => ({ + isHistoryNavigation: vi.fn(() => false), +})); +vi.mock("@app/lib/router", () => ({ isHistoryNavigation })); + +function page(content: string[], more = true): PaginatedQuery { + return { cursor: 0, more, content }; +} + +function deferred() { + let resolve!: (value: PaginatedQuery) => void; + const promise = new Promise>(res => { + resolve = res; + }); + return { promise, resolve }; +} + +const fetchPage = + vi.fn< + ( + skip: number, + take: number | undefined, + ) => Promise> + >(); +let keyCount = 0; +let cleanup: (() => void) | undefined; + +function nextKey() { + return `list-${keyCount + 1}`; +} + +function setup(first = page(["a", "b"]), skipPersist?: () => boolean) { + let key = $state(`list-${++keyCount}`); + let current = $state(first); + let list!: ReturnType>; + cleanup = $effect.root(() => { + list = createPaginatedList({ + key: () => key, + page: () => current, + fetchPage, + pageSize: 2, + id: item => item, + skipPersist, + }); + }); + flushSync(); + return { + list, + key: () => key, + change(newKey: string, next: PaginatedQuery) { + key = newKey; + current = next; + flushSync(); + }, + }; +} + +beforeEach(() => { + fetchPage.mockReset(); + vi.spyOn(console, "error").mockReturnValue(undefined); +}); + +afterEach(() => { + cleanup?.(); + isHistoryNavigation.mockReturnValue(false); +}); + +test("starts from the loader's page", () => { + const { list } = setup(page(["a", "b"], false)); + + expect(list.items).toEqual(["a", "b"]); + expect(list.more).toBe(false); + expect(list.loadingMore).toBe(false); + expect(list.initialScrollOffset).toBeUndefined(); + expect(fetchPage).not.toHaveBeenCalled(); +}); + +test("loadMore appends the next page, skipping rows already shown", async () => { + const { list } = setup(); + fetchPage.mockResolvedValueOnce(page(["b", "c"])); + fetchPage.mockResolvedValueOnce(page(["d"], false)); + + await list.loadMore(); + expect(fetchPage).toHaveBeenLastCalledWith(2, 2); + expect(list.items).toEqual(["a", "b", "c"]); + expect(list.more).toBe(true); + + await list.loadMore(); + expect(fetchPage).toHaveBeenLastCalledWith(4, 2); + expect(list.items).toEqual(["a", "b", "c", "d"]); + expect(list.more).toBe(false); + + await list.loadMore(); + expect(fetchPage).toHaveBeenCalledTimes(2); +}); + +test("an empty page ends the list", async () => { + const { list } = setup(); + fetchPage.mockResolvedValueOnce(page([], true)); + + await list.loadMore(); + + expect(list.more).toBe(false); +}); + +test("loadMore(true) replaces the list with every row", async () => { + const { list } = setup(); + fetchPage.mockResolvedValueOnce(page(["x", "y", "z"], false)); + fetchPage.mockResolvedValueOnce(page(["w"], false)); + + await list.loadMore(true); + + expect(fetchPage).toHaveBeenCalledWith(0, undefined); + expect(list.items).toEqual(["x", "y", "z"]); + expect(list.more).toBe(false); +}); + +test("loadMore(true) moves the cursor to the rows it fetched", async () => { + const { list } = setup(); + fetchPage.mockResolvedValueOnce(page(["x", "y", "z"])); + fetchPage.mockResolvedValueOnce(page(["w"])); + + await list.loadMore(true); + await list.loadMore(); + + expect(fetchPage).toHaveBeenLastCalledWith(3, 2); +}); + +test("loadingMore is set while a page loads", async () => { + const { list } = setup(); + const pending = deferred(); + fetchPage.mockReturnValueOnce(pending.promise); + + const loading = list.loadMore(); + expect(list.loadingMore).toBe(true); + + pending.resolve(page(["c"])); + await loading; + expect(list.loadingMore).toBe(false); +}); + +test("a failed load keeps the list and allows another attempt", async () => { + const { list } = setup(); + fetchPage.mockRejectedValueOnce(new Error("offline")); + + await list.loadMore(); + + expect(list.items).toEqual(["a", "b"]); + expect(list.more).toBe(true); + expect(list.loadingMore).toBe(false); + expect(console.error).toHaveBeenCalled(); +}); + +test("a superseded load leaves the list to the newer one", async () => { + const { list } = setup(); + const older = deferred(); + const newer = deferred(); + fetchPage.mockReturnValueOnce(older.promise); + fetchPage.mockReturnValueOnce(newer.promise); + + const first = list.loadMore(); + const second = list.loadMore(true); + older.resolve(page(["c"])); + await first; + expect(list.items).toEqual(["a", "b"]); + expect(list.loadingMore).toBe(true); + + newer.resolve(page(["x"], false)); + await second; + expect(list.items).toEqual(["x"]); + expect(list.loadingMore).toBe(false); +}); + +test("a key change resets the list and drops an in-flight load", async () => { + const { list, change } = setup(); + const pending = deferred(); + fetchPage.mockReturnValueOnce(pending.promise); + fetchPage.mockResolvedValueOnce(page(["r"])); + + const loading = list.loadMore(); + change("other", page(["p", "q", "r"], true)); + expect(list.items).toEqual(["p", "q", "r"]); + expect(list.loadingMore).toBe(false); + + pending.resolve(page(["c"])); + await loading; + expect(list.items).toEqual(["p", "q", "r"]); + + await list.loadMore(); + expect(fetchPage).toHaveBeenLastCalledWith(3, 2); +}); + +test("a new loader page under the same key is ignored", () => { + const { list, change, key } = setup(); + + change(key(), page(["z"], false)); + expect(list.items).toEqual(["a", "b"]); + expect(list.more).toBe(true); + + change("other", page(["p"], false)); + change("other", page(["z"])); + expect(list.items).toEqual(["p"]); + expect(list.more).toBe(false); +}); + +test("persistScroll saves the list for the current key", () => { + const { list, key } = setup(page(["a", "b"], false)); + + list.persistScroll({ scrollOffset: 40, cache: undefined as never }); + + expect(readListState(key())).toEqual({ + items: ["a", "b"], + more: false, + scrollOffset: 40, + cache: undefined, + }); +}); + +test("persistScroll saves nothing while skipped", () => { + const { list, key } = setup(undefined, () => true); + + list.persistScroll({ scrollOffset: 40, cache: undefined as never }); + + expect(readListState(key())).toBeUndefined(); +}); + +test("revalidate refetches a page-sized window by default", async () => { + const { list } = setup(); + fetchPage.mockResolvedValueOnce(page(["n", "a"], false)); + + await list.revalidate(); + + expect(fetchPage).toHaveBeenCalledWith(0, 2); + expect(list.items).toEqual(["n", "a"]); + expect(list.more).toBe(false); +}); + +test("a failed revalidate keeps the list", async () => { + const { list } = setup(); + fetchPage.mockRejectedValueOnce(new Error("offline")); + + await list.revalidate(); + + expect(list.items).toEqual(["a", "b"]); + expect(list.loadingMore).toBe(false); + expect(console.error).toHaveBeenCalled(); +}); + +test("a superseded revalidate leaves loadingMore to the newer load", async () => { + const { list } = setup(); + const older = deferred(); + const newer = deferred(); + fetchPage.mockReturnValueOnce(older.promise); + fetchPage.mockReturnValueOnce(newer.promise); + + const first = list.revalidate(); + void list.loadMore(true); + older.resolve(page(["c"])); + await first; + + expect(list.items).toEqual(["a", "b"]); + expect(list.loadingMore).toBe(true); +}); + +function snapshot(key: string, items: string[]) { + saveListState(key, { + items, + more: true, + scrollOffset: 120, + cache: "cache" as never, + }); +} + +test("history navigation restores a deep list and refetches it", async () => { + isHistoryNavigation.mockReturnValue(true); + snapshot(nextKey(), ["s1", "s2", "s3"]); + const pending = deferred(); + fetchPage.mockReturnValueOnce(pending.promise); + + const { list } = setup(page(["a", "b"], false)); + expect(fetchPage).toHaveBeenCalledWith(0, 3); + expect(list.items).toEqual(["s1", "s2", "s3"]); + expect(list.more).toBe(true); + expect(list.loadingMore).toBe(true); + expect(list.initialScrollOffset).toBe(120); + expect(list.initialCache).toBe("cache"); + + pending.resolve(page(["t1", "t2", "t3"])); + await vi.waitFor(() => expect(list.loadingMore).toBe(false)); + expect(list.items).toEqual(["t1", "t2", "t3"]); + + fetchPage.mockResolvedValueOnce(page(["t4"])); + await list.loadMore(); + expect(fetchPage).toHaveBeenLastCalledWith(3, 2); + + list.consumeRestoredScroll(); + expect(list.initialScrollOffset).toBeUndefined(); + expect(list.initialCache).toBeUndefined(); +}); + +test("history navigation replaces a shallow list with the loader's page", () => { + isHistoryNavigation.mockReturnValue(true); + snapshot(nextKey(), ["s1", "s2"]); + + const { list } = setup(page(["a", "b"], false)); + + expect(list.items).toEqual(["a", "b"]); + expect(list.more).toBe(false); + expect(list.initialScrollOffset).toBe(120); + expect(fetchPage).not.toHaveBeenCalled(); +}); + +test("a fresh navigation ignores the snapshot", () => { + snapshot(nextKey(), ["s1", "s2", "s3"]); + + const { list } = setup(); + + expect(list.items).toEqual(["a", "b"]); + expect(list.initialScrollOffset).toBeUndefined(); + expect(fetchPage).not.toHaveBeenCalled(); +}); diff --git a/tests/unit/patchCounts.test.ts b/tests/unit/patchCounts.test.ts new file mode 100644 index 000000000..77c961308 --- /dev/null +++ b/tests/unit/patchCounts.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, expect, test } from "vitest"; + +import { + patchCountMismatch, + patchCounts, + resetPatchCounts, + updatePatchCounts, +} from "@app/lib/patchCounts.svelte"; + +const sidebar = { open: 3, draft: 2, archived: 1, merged: 4 }; + +beforeEach(() => { + resetPatchCounts(); +}); + +test.each([ + ["open", 3], + ["draft", 2], + ["archived", 1], + ["merged", 4], +] as const)( + "matches when the %s list has the sidebar's count", + (status, count) => { + updatePatchCounts(count, sidebar, status); + + expect(patchCounts[status]).toEqual({ sidebar: count, total: count }); + expect(patchCountMismatch(status)).toBe(false); + }, +); + +test("compares the unfiltered list with the sum of all statuses", () => { + updatePatchCounts(10, sidebar); + + expect(patchCounts.all).toEqual({ sidebar: 10, total: 10 }); + expect(patchCountMismatch()).toBe(false); + + updatePatchCounts(9, sidebar); + + expect(patchCountMismatch()).toBe(true); +}); + +test("flags a list whose length differs from the sidebar", () => { + updatePatchCounts(4, sidebar, "open"); + + expect(patchCountMismatch("open")).toBe(true); + expect(patchCountMismatch("draft")).toBe(false); + expect(patchCountMismatch()).toBe(false); +}); + +test("reset clears every status", () => { + updatePatchCounts(4, sidebar, "open"); + updatePatchCounts(9, sidebar); + resetPatchCounts(); + + expect(Object.values(patchCounts)).toEqual( + Array(5).fill({ sidebar: null, total: null }), + ); + expect(patchCountMismatch("open")).toBe(false); + expect(patchCountMismatch()).toBe(false); +}); diff --git a/tests/unit/revisionListSettings.test.ts b/tests/unit/revisionListSettings.test.ts new file mode 100644 index 000000000..1896a0933 --- /dev/null +++ b/tests/unit/revisionListSettings.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, expect, test, vi } from "vitest"; + +const key = "revisionListSettings"; + +const defaults = { + sortDesc: false, + groupByAuthor: false, + showNumber: false, + showStats: false, + showReviewers: true, +}; + +async function load() { + const { revisionListSettings } = + await import("@app/lib/revisionListSettings"); + return revisionListSettings; +} + +beforeEach(() => { + localStorage.clear(); + vi.resetModules(); + vi.spyOn(console, "error").mockReturnValue(undefined); +}); + +test("starts with the defaults when nothing is stored", async () => { + expect((await load()).value).toEqual(defaults); +}); + +test.each(Object.entries(defaults))( + "fills in the defaults around a stored %s", + async (setting, value) => { + localStorage.setItem(key, JSON.stringify({ [setting]: !value })); + + expect((await load()).value).toEqual({ ...defaults, [setting]: !value }); + }, +); + +test("falls back to the defaults for an invalid stored value", async () => { + localStorage.setItem(key, JSON.stringify({ sortDesc: "yes" })); + + expect((await load()).value).toEqual(defaults); +}); + +test("toggle flips one setting and persists it", async () => { + const settings = await load(); + settings.toggle("showReviewers"); + settings.toggle("groupByAuthor"); + + const expected = { ...defaults, showReviewers: false, groupByAuthor: true }; + expect(settings.value).toEqual(expected); + expect(JSON.parse(localStorage.getItem(key) ?? "")).toEqual(expected); + + settings.toggle("groupByAuthor"); + + expect(settings.value).toEqual({ ...defaults, showReviewers: false }); +}); diff --git a/tests/unit/router.test.ts b/tests/unit/router.test.ts new file mode 100644 index 000000000..7c37aa804 --- /dev/null +++ b/tests/unit/router.test.ts @@ -0,0 +1,102 @@ +import { get } from "svelte/store"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import type { Route } from "@app/lib/router"; +import { + activeUnloadedRouteStore, + loadFromLocation, + routeToPath, +} from "@app/lib/router"; + +const { invoke } = vi.hoisted(() => ({ invoke: vi.fn() })); +vi.mock("@app/lib/invoke", () => ({ invoke })); +vi.mock("@app/lib/router/definitions", () => ({ + loadRoute: async (route: Route) => route, +})); + +const rid = "rad:z3fpY7nttPPa6MBnAv2DccHzQJnqe"; +const sha = "a".repeat(40); + +async function resolve(path: string) { + window.history.pushState(null, "", path); + await loadFromLocation(); + return get(activeUnloadedRouteStore); +} + +beforeEach(() => { + vi.stubGlobal("origin", window.location.origin); + vi.spyOn(console, "error").mockReturnValue(undefined); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("routeToPath and loadFromLocation round trip", () => { + test.each<[string, Route]>([ + ["/inbox", { resource: "inbox" }], + ["/guide", { resource: "guide" }], + [`/repos/${rid}/home`, { resource: "repo.home", rid }], + [ + `/repos/${rid}/commits/main`, + { resource: "repo.commits", rid, revision: "main" }, + ], + [ + `/repos/${rid}/commits/${sha}`, + { resource: "repo.commit", rid, commit: sha }, + ], + [`/repos/${rid}/identity`, { resource: "repo.identity", rid }], + [ + `/repos/${rid}/issues?status=closed`, + { resource: "repo.issues", rid, status: "closed" }, + ], + [ + `/repos/${rid}/issues/abc?status=open`, + { resource: "repo.issue", rid, issue: "abc", status: "open" }, + ], + [ + `/repos/${rid}/patches?status=merged`, + { resource: "repo.patches", rid, status: "merged" }, + ], + [ + `/repos/${rid}/patches/abc?status=draft`, + { + resource: "repo.patch", + rid, + patch: "abc", + status: "draft", + reviewId: undefined, + }, + ], + ])("%s", async (path, route) => { + expect(routeToPath(route)).toBe(path); + expect(await resolve(path)).toEqual(route); + expect(console.error).not.toHaveBeenCalled(); + }); +}); + +test("booting has no path", () => { + expect(routeToPath({ resource: "booting" })).toBe(""); +}); + +test("resolves the root to the inbox", async () => { + await resolve("/guide"); + + expect(await resolve("/")).toEqual({ resource: "inbox" }); + expect(console.error).not.toHaveBeenCalled(); +}); + +test.each([ + "/bogus", + "/inbox/extra", + "/guide/extra", + "/repos/", + `/repos/${rid}/bogus`, +])("falls back to the inbox for %s", async path => { + await resolve("/guide"); + + expect(await resolve(path)).toEqual({ resource: "inbox" }); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("Could not resolve route"), + ); +}); diff --git a/tests/unit/shareAction.test.ts b/tests/unit/shareAction.test.ts new file mode 100644 index 000000000..b89940e56 --- /dev/null +++ b/tests/unit/shareAction.test.ts @@ -0,0 +1,54 @@ +import { afterEach, beforeEach, expect, test, vi } from "vitest"; + +let flushSync: () => void; + +async function load() { + vi.resetModules(); + const { shareAction } = await import("@app/lib/shareAction.svelte"); + ({ flushSync } = await import("svelte")); + flushSync(); + return shareAction; +} + +beforeEach(() => { + localStorage.clear(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +test("defaults to copying the link", async () => { + expect((await load()).value).toBe("copyLink"); +}); + +test.each([ + ["copyId", "copyId"], + ["open", "open"], + ["share", "copyLink"], + ['"open"', "copyLink"], +])("reads a stored %j as %j", async (stored, expected) => { + localStorage.setItem("shareAction", stored); + + expect((await load()).value).toBe(expected); +}); + +test("persists a change", async () => { + const shareAction = await load(); + + shareAction.value = "open"; + flushSync(); + + expect(localStorage.getItem("shareAction")).toBe("open"); + expect((await load()).value).toBe("open"); +}); + +test("works without localStorage", async () => { + vi.stubGlobal("localStorage", undefined); + const shareAction = await load(); + + shareAction.value = "open"; + flushSync(); + + expect(shareAction.value).toBe("open"); +}); diff --git a/tests/unit/shortcuts.svelte.test.ts b/tests/unit/shortcuts.svelte.test.ts new file mode 100644 index 000000000..240df2ccc --- /dev/null +++ b/tests/unit/shortcuts.svelte.test.ts @@ -0,0 +1,339 @@ +import { flushSync } from "svelte"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import * as modal from "@app/lib/modal"; +import type { Binding, ShortcutId } from "@app/lib/shortcuts.svelte"; +import { + ariaKeyShortcuts, + comboKeys, + listenForShortcuts, + matchesCombo, + matchesShortcut, + modifierHeld, + useShortcuts, +} from "@app/lib/shortcuts.svelte"; + +const platform = vi.hoisted(() => ({ mac: false })); +vi.mock("@app/lib/utils", async importOriginal => ({ + ...(await importOriginal()), + isMac: () => platform.mac, + modifierKey: () => (platform.mac ? "⌘" : "ctrl"), +})); + +function key(init: KeyboardEventInit): KeyboardEvent { + return new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + ...init, + }); +} + +afterEach(() => { + platform.mac = false; +}); + +describe("matchesCombo", () => { + test.each<[boolean, string, KeyboardEventInit, boolean]>([ + [false, "Mod+F", { key: "f", ctrlKey: true }, true], + [false, "Mod+F", { key: "F", ctrlKey: true }, true], + [false, "Mod+F", { key: "f", metaKey: true }, false], + [false, "Mod+F", { key: "f", ctrlKey: true, metaKey: true }, false], + [false, "Mod+F", { key: "f", ctrlKey: true, altKey: true }, false], + [false, "Mod+F", { key: "F", ctrlKey: true, shiftKey: true }, false], + [false, "Mod+F", { key: "f" }, false], + [false, "Mod+F", { key: "g", ctrlKey: true }, false], + [true, "Mod+F", { key: "f", metaKey: true }, true], + [true, "Mod+F", { key: "f", ctrlKey: true }, false], + [true, "Mod+F", { key: "f", metaKey: true, ctrlKey: true }, false], + [false, "F", { key: "f", ctrlKey: true }, false], + [false, "Alt+ArrowLeft", { key: "ArrowLeft", altKey: true }, true], + [false, "Alt+ArrowLeft", { key: "ArrowLeft" }, false], + [false, "Alt+ArrowLeft", { key: "ArrowRight", altKey: true }, false], + [false, "ArrowUp", { key: "ArrowUp", shiftKey: true }, false], + [false, "Mod+Shift+P", { key: "P", ctrlKey: true, shiftKey: true }, true], + [false, "Mod+Shift+P", { key: "p", ctrlKey: true }, false], + [false, "?", { key: "?", shiftKey: true }, true], + [false, "?", { key: "/" }, false], + [false, "Mod++", { key: "+", ctrlKey: true, shiftKey: true }, true], + [false, "Mod++", { key: "=", ctrlKey: true }, false], + [false, "Mod+=", { key: "=", ctrlKey: true }, true], + [false, "Mod+1", { key: "1", ctrlKey: true }, true], + [false, "Mod+F", { key: "а", code: "KeyF", ctrlKey: true }, true], + [false, "Mod+F", { key: "а", code: "KeyG", ctrlKey: true }, false], + [false, "Mod+1", { key: "ё", code: "Digit1", ctrlKey: true }, true], + [false, "Mod+1", { key: "ё", code: "Key1", ctrlKey: true }, false], + [false, "Mod+F", { key: "u", code: "KeyF", ctrlKey: true }, false], + [false, "Mod+F", { key: "Dead", code: "KeyF", ctrlKey: true }, false], + ])("mac %s: %s with %j is %s", (mac, combo, init, expected) => { + platform.mac = mac; + expect(matchesCombo(key(init), combo)).toBe(expected); + }); +}); + +test.each<[ShortcutId, KeyboardEventInit]>([ + ["goToRepo", { key: "9", ctrlKey: true }], + ["increaseFontSize", { key: "=", ctrlKey: true }], +])("matchesShortcut tries every combo of %s", (id, init) => { + expect(matchesShortcut(key(init), id)).toBe(true); +}); + +test.each<[boolean, string, string[]]>([ + [false, "Mod+F", ["ctrl", "f"]], + [true, "Mod+F", ["⌘", "f"]], + [false, "Alt+ArrowLeft", ["alt", "←"]], + [true, "Alt+ArrowRight", ["⌥", "→"]], + [false, "ArrowUp", ["↑"]], + [false, "ArrowDown", ["↓"]], + [false, "Escape", ["esc"]], + [false, "Mod++", ["ctrl", "+"]], + [false, "Shift+Enter", ["shift", "enter"]], +])("comboKeys on mac %s labels %s as %j", (mac, combo, expected) => { + platform.mac = mac; + expect(comboKeys(combo)).toEqual(expected); +}); + +test.each<[boolean, ShortcutId, string]>([ + [false, "filter", "Control+F"], + [true, "filter", "Meta+F"], + [false, "help", "?"], + [false, "increaseFontSize", "Control++"], + [false, "allCommits", "Escape"], + [false, "back", "Alt+ArrowLeft"], +])("ariaKeyShortcuts on mac %s gives %s as %s", (mac, id, expected) => { + platform.mac = mac; + expect(ariaKeyShortcuts(id)).toBe(expected); +}); + +test("history shortcuts use brackets on macOS", async () => { + platform.mac = true; + vi.resetModules(); + const { shortcuts } = await import("@app/lib/shortcuts.svelte"); + + expect(shortcuts.back.combos).toEqual(["Mod+["]); + expect(shortcuts.forward.combos).toEqual(["Mod+]"]); +}); + +describe("dispatch", () => { + let stopListening: () => void; + let cleanups: (() => void)[] = []; + + function bind(...bindings: Binding[]) { + cleanups.push($effect.root(() => useShortcuts(...bindings))); + flushSync(); + } + + function press(init: KeyboardEventInit, target: EventTarget = document) { + const event = key(init); + target.dispatchEvent(event); + return event; + } + + beforeEach(() => { + stopListening = listenForShortcuts(); + }); + + afterEach(() => { + cleanups.forEach(fn => fn()); + cleanups = []; + window.dispatchEvent(new Event("blur")); + stopListening(); + modal.forceHide(); + document.body.replaceChildren(); + }); + + test("runs a matching binding with the combo index and prevents default", () => { + const run = vi.fn(); + bind({ shortcut: "goToRepo", run }); + + expect(press({ key: "x", ctrlKey: true }).defaultPrevented).toBe(false); + expect(run).not.toHaveBeenCalled(); + + const event = press({ key: "3", ctrlKey: true }); + expect(run).toHaveBeenCalledWith(event, 2); + expect(event.defaultPrevented).toBe(true); + }); + + test.each<[string, () => KeyboardEvent]>([ + [ + "already handled", + () => { + const event = key({ key: "?" }); + event.preventDefault(); + return event; + }, + ], + ["being composed", () => key({ key: "?", isComposing: true })], + ])("ignores keys %s", (_, event) => { + const run = vi.fn(); + bind({ shortcut: "help", run }); + + document.dispatchEvent(event()); + + expect(run).not.toHaveBeenCalled(); + }); + + test.each<[string, string, boolean, KeyboardEventInit, boolean]>([ + ["input", "", false, { key: "?" }, false], + ["textarea", "", false, { key: "?" }, false], + ["div", "", true, { key: "?" }, false], + ["div", "", false, { key: "?" }, true], + ["input", "", false, { key: "f", ctrlKey: true }, false], + ["input", "data-mod-shortcuts", false, { key: "f", ctrlKey: true }, true], + ["input", "data-mod-shortcuts", false, { key: "?" }, false], + ])( + "from a %s %s (editable %s), %j runs: %s", + (tag, attribute, editable, init, runs) => { + const run = vi.fn(); + bind({ shortcut: "help", run }, { shortcut: "filter", run }); + const element = document.createElement(tag); + if (attribute) element.setAttribute(attribute, ""); + if (editable) element.contentEditable = "true"; + document.body.append(element); + + press(init, element); + + expect(run).toHaveBeenCalledTimes(runs ? 1 : 0); + }, + ); + + test("skips bindings while a modal is open unless they allow it", () => { + const outside = vi.fn(); + const inside = vi.fn(); + bind({ shortcut: "help", run: outside }); + modal.show({ component: vi.fn(), props: {} }); + + press({ key: "?" }); + expect(outside).not.toHaveBeenCalled(); + + bind({ shortcut: "closeModal", run: inside, allowInModal: true }); + press({ key: "Escape" }); + expect(inside).toHaveBeenCalledTimes(1); + }); + + test("skips disabled bindings", () => { + const disabled = vi.fn(); + const enabled = vi.fn(); + bind( + { shortcut: "help", run: enabled, enabled: () => true }, + { shortcut: "help", run: disabled, enabled: () => false }, + ); + + press({ key: "?" }); + + expect(disabled).not.toHaveBeenCalled(); + expect(enabled).toHaveBeenCalledTimes(1); + }); + + test("tries higher priority first, then the most recently registered", () => { + const calls: string[] = []; + const binding = (name: string, priority?: number): Binding => ({ + shortcut: "allCommits", + priority, + run: () => { + calls.push(name); + return false; + }, + }); + bind(binding("low", -1), binding("first"), binding("high", 1)); + bind(binding("second")); + + const event = press({ key: "Escape" }); + + expect(calls).toEqual(["high", "second", "first", "low"]); + expect(event.defaultPrevented).toBe(false); + }); + + test("returning false passes the key on to the next binding", () => { + const next = vi.fn(); + bind( + { shortcut: "help", run: next }, + { shortcut: "help", run: () => false }, + ); + + expect(press({ key: "?" }).defaultPrevented).toBe(true); + expect(next).toHaveBeenCalledTimes(1); + }); + + test("repeated presses cycle past the active binding", () => { + let focused = ""; + const binding = (name: string): Binding => ({ + shortcut: "filter", + run: () => (focused = name), + active: () => focused === name, + }); + bind(binding("c"), binding("b"), binding("a")); + + const order = [1, 2, 3, 4].map(() => { + press({ key: "f", ctrlKey: true }); + return focused; + }); + + expect(order).toEqual(["a", "b", "c", "a"]); + }); + + test("unbinds when the owner is destroyed", () => { + const run = vi.fn(); + bind({ shortcut: "help", run }); + cleanups.pop()?.(); + + press({ key: "?" }); + + expect(run).not.toHaveBeenCalled(); + }); + + test("stops listening once the listener is removed", () => { + const run = vi.fn(); + bind({ shortcut: "help", run }); + press({ key: "Control" }); + stopListening(); + + press({ key: "?" }); + document.dispatchEvent(new KeyboardEvent("keyup", { key: "Control" })); + window.dispatchEvent(new Event("blur")); + + expect(run).not.toHaveBeenCalled(); + expect(modifierHeld.value).toBe(true); + stopListening = listenForShortcuts(); + }); + + test.each<[boolean, string]>([ + [false, "Control"], + [true, "Meta"], + ])("modifierHeld follows mac %s %s", (mac, modifier) => { + platform.mac = mac; + press({ key: mac ? "Control" : "Meta" }); + expect(modifierHeld.value).toBe(false); + + press({ key: modifier }); + expect(modifierHeld.value).toBe(true); + document.dispatchEvent(new KeyboardEvent("keyup", { key: "Shift" })); + expect(modifierHeld.value).toBe(true); + document.dispatchEvent(new KeyboardEvent("keyup", { key: modifier })); + expect(modifierHeld.value).toBe(false); + + press({ key: modifier }); + window.dispatchEvent(new Event("blur")); + expect(modifierHeld.value).toBe(false); + }); + + test.each<[string, string, boolean]>([ + ["a field", "", false], + ["a Mod-shortcut field", "data-mod-shortcuts", true], + ])("modifierHeld from %s is %s", (_, attribute, held) => { + const input = document.createElement("input"); + if (attribute) input.setAttribute(attribute, ""); + document.body.append(input); + + press({ key: "Control" }, input); + + expect(modifierHeld.value).toBe(held); + }); + + test("modifierHeld stays false while a modal is open", () => { + modal.show({ component: vi.fn(), props: {} }); + + press({ key: "Control" }); + + expect(modifierHeld.value).toBe(false); + }); +}); diff --git a/tests/unit/sidebar.test.ts b/tests/unit/sidebar.test.ts new file mode 100644 index 000000000..90b4e06aa --- /dev/null +++ b/tests/unit/sidebar.test.ts @@ -0,0 +1,166 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +function setWindow(innerWidth: number, fontSize = "") { + Object.defineProperty(window, "innerWidth", { + value: innerWidth, + configurable: true, + }); + document.documentElement.style.fontSize = fontSize; +} + +async function load() { + return await import("@app/lib/sidebar.svelte"); +} + +beforeEach(() => { + localStorage.clear(); + vi.resetModules(); + setWindow(2000); +}); + +test("rootFontSize falls back to 16px when it can't be read", async () => { + const { rootFontSize } = await load(); + vi.spyOn(window, "getComputedStyle").mockReturnValueOnce({ + fontSize: "", + } as CSSStyleDeclaration); + + expect(rootFontSize()).toBe(16); +}); + +describe("clampSidebarWidth", () => { + test.each([ + [20, 20], + [12, 12], + [11.9, 12], + [-5, 12], + [30, 30], + [30.1, 30], + [NaN, 16.5], + [Infinity, 16.5], + ])("clamps %d rem to %d in a wide window", async (rem, expected) => { + const { clampSidebarWidth } = await load(); + + expect(clampSidebarWidth(rem)).toBe(expected); + }); + + test.each([ + [1000, "", 25, 25], + [1000, "", 26, 25], + [1000, "20px", 25, 20], + [100, "", 25, 12], + ])( + "caps the width at 40%% of a %dpx window with font size %j", + async (innerWidth, fontSize, rem, expected) => { + setWindow(innerWidth, fontSize); + const { clampSidebarWidth } = await load(); + + expect(clampSidebarWidth(rem)).toBe(expected); + }, + ); +}); + +describe("sidebarWidth", () => { + test("starts with the default width", async () => { + const { renderedSidebarWidth } = await load(); + + expect(renderedSidebarWidth.value).toBe(16.5); + expect(localStorage.getItem("sidebarWidth")).toBeNull(); + }); + + test.each([ + ["20", 20, "20"], + ["50", 30, "30"], + ["5", 12, "12"], + ])("restores a stored %s rem as %d", async (stored, expected, afterLoad) => { + localStorage.setItem("sidebarWidth", stored); + const { renderedSidebarWidth } = await load(); + + expect(renderedSidebarWidth.value).toBe(expected); + expect(localStorage.getItem("sidebarWidth")).toBe(afterLoad); + }); + + test("stores a clamped width set directly", async () => { + const { renderedSidebarWidth, setSidebarWidth } = await load(); + setSidebarWidth(40); + + expect(renderedSidebarWidth.value).toBe(30); + expect(localStorage.getItem("sidebarWidth")).toBe("30"); + }); + + test("stores a drag's width only once it is committed", async () => { + const { + commitSidebarWidth, + previewSidebarWidth, + renderedSidebarWidth, + sidebarWidth, + } = await load(); + previewSidebarWidth(20); + previewSidebarWidth(40); + + expect(renderedSidebarWidth.value).toBe(30); + expect(localStorage.getItem("sidebarWidth")).toBeNull(); + + commitSidebarWidth(); + + expect(renderedSidebarWidth.value).toBe(30); + expect(localStorage.getItem("sidebarWidth")).toBe("30"); + + sidebarWidth.value = 20; + + expect(renderedSidebarWidth.value).toBe(20); + }); + + test("a discarded drag keeps the stored width", async () => { + const { + discardSidebarWidthPreview, + previewSidebarWidth, + renderedSidebarWidth, + setSidebarWidth, + } = await load(); + setSidebarWidth(20); + previewSidebarWidth(25); + discardSidebarWidthPreview(); + + expect(renderedSidebarWidth.value).toBe(20); + expect(localStorage.getItem("sidebarWidth")).toBe("20"); + }); + + test("committing without a drag stores nothing", async () => { + const { commitSidebarWidth, renderedSidebarWidth } = await load(); + commitSidebarWidth(); + + expect(renderedSidebarWidth.value).toBe(16.5); + expect(localStorage.getItem("sidebarWidth")).toBeNull(); + }); + + test("a direct set drops an ongoing drag", async () => { + const { + commitSidebarWidth, + previewSidebarWidth, + renderedSidebarWidth, + setSidebarWidth, + } = await load(); + previewSidebarWidth(25); + setSidebarWidth(20); + + expect(renderedSidebarWidth.value).toBe(20); + + commitSidebarWidth(); + + expect(localStorage.getItem("sidebarWidth")).toBe("20"); + }); +}); + +test("toggleSidebar flips and stores the collapsed state", async () => { + const { sidebarCollapsed, toggleSidebar } = await load(); + expect(sidebarCollapsed.value).toBe(false); + + toggleSidebar(); + + expect(sidebarCollapsed.value).toBe(true); + expect(localStorage.getItem("sidebarCollapsed")).toBe("true"); + + toggleSidebar(); + + expect(sidebarCollapsed.value).toBe(false); +}); diff --git a/tests/unit/updateChecker.test.ts b/tests/unit/updateChecker.test.ts new file mode 100644 index 000000000..df9b1830a --- /dev/null +++ b/tests/unit/updateChecker.test.ts @@ -0,0 +1,144 @@ +import { afterEach, beforeEach, expect, test, vi } from "vitest"; + +const { invoke } = vi.hoisted(() => ({ invoke: vi.fn() })); +vi.mock("@app/lib/invoke", () => ({ invoke })); + +const storageKey = "updateChecker.isEnabled"; +const latestUrl = + "https://files.radicle.dev/releases/radicle-desktop/latest/latest.json"; +const hour = 3600 * 1000; + +const fetch = vi.fn(); +let latest: Promise; + +async function start(current: string, stored?: boolean) { + if (stored !== undefined) { + localStorage.setItem(storageKey, JSON.stringify(stored)); + } + invoke.mockResolvedValue(current); + vi.resetModules(); + const { updateChecker } = await import("@app/lib/updateChecker.svelte"); + await vi.advanceTimersByTimeAsync(0); + return updateChecker; +} + +beforeEach(() => { + vi.useFakeTimers(); + localStorage.clear(); + latest = Promise.resolve("1.0.0"); + fetch.mockReset(); + fetch.mockImplementation(async () => ({ + json: async () => ({ version: await latest }), + })); + vi.stubGlobal("fetch", fetch); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +test("is disabled by default and doesn't check", async () => { + const checker = await start("0.9.0"); + + expect(invoke).toHaveBeenCalledWith("version"); + expect(checker.currentVersion).toBe("0.9.0"); + expect(checker.isEnabled).toBe(false); + expect(checker.newVersion).toBeUndefined(); + expect(checker.upToDate).toBe(false); + + await vi.advanceTimersByTimeAsync(hour); + expect(fetch).not.toHaveBeenCalled(); +}); + +test("stays disabled when disabling was stored", async () => { + const checker = await start("0.9.0", false); + + expect(checker.isEnabled).toBe(false); + expect(fetch).not.toHaveBeenCalled(); +}); + +test.each<[string, string, string | undefined, boolean]>([ + ["0.9.0", "1.0.0", "1.0.0", false], + ["0.9.0", "0.9.0", undefined, true], + ["1.1.0", "1.0.0", undefined, true], + ["v0.9.0-12-gabcdef", "0.9.1", "0.9.1", false], + ["not a version", "1.0.0", undefined, false], +])( + "on %s with %s released, offers %s and is up to date: %s", + async (current, released, newVersion, upToDate) => { + latest = Promise.resolve(released); + const checker = await start(current, true); + + expect(fetch).toHaveBeenCalledWith(latestUrl, { cache: "no-store" }); + expect(checker.currentVersion).toBe(current); + expect(checker.isEnabled).toBe(true); + expect(checker.newVersion).toBe(newVersion); + expect(checker.upToDate).toBe(upToDate); + }, +); + +test("a failed check claims neither an update nor being up to date", async () => { + latest = Promise.reject(new Error("offline")); + const checker = await start("0.9.0", true); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(checker.newVersion).toBeUndefined(); + expect(checker.upToDate).toBe(false); +}); + +test("enable stores the choice and checks now and every hour", async () => { + const checker = await start("0.9.0"); + + checker.enable(); + checker.enable(); + await vi.advanceTimersByTimeAsync(0); + expect(localStorage.getItem(storageKey)).toBe("true"); + expect(checker.isEnabled).toBe(true); + expect(checker.newVersion).toBe("1.0.0"); + expect(fetch).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(hour - 1); + expect(fetch).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(1); + expect(fetch).toHaveBeenCalledTimes(3); +}); + +test("an hourly check picks up a new release", async () => { + latest = Promise.resolve("0.9.0"); + const checker = await start("0.9.0", true); + expect(checker.upToDate).toBe(true); + + latest = Promise.resolve("0.10.0"); + await vi.advanceTimersByTimeAsync(hour); + + expect(checker.newVersion).toBe("0.10.0"); + expect(checker.upToDate).toBe(false); +}); + +test("disable stores the choice and stops checking", async () => { + latest = Promise.resolve("0.9.0"); + const checker = await start("0.9.0", true); + expect(fetch).toHaveBeenCalledTimes(1); + expect(checker.upToDate).toBe(true); + + checker.disable(); + await vi.advanceTimersByTimeAsync(hour); + + expect(localStorage.getItem(storageKey)).toBe("false"); + expect(checker.isEnabled).toBe(false); + expect(checker.upToDate).toBe(false); + expect(fetch).toHaveBeenCalledTimes(1); + + checker.enable(); + await vi.advanceTimersByTimeAsync(hour); + expect(fetch).toHaveBeenCalledTimes(3); +}); + +test("disabling keeps offering an update that was already found", async () => { + const checker = await start("0.9.0", true); + + checker.disable(); + + expect(checker.newVersion).toBe("1.0.0"); +}); diff --git a/tests/unit/useLocalStorage.test.ts b/tests/unit/useLocalStorage.test.ts new file mode 100644 index 000000000..bbc4a5ed6 --- /dev/null +++ b/tests/unit/useLocalStorage.test.ts @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { array, string } from "zod"; + +import useLocalStorage from "@app/lib/useLocalStorage.svelte"; + +const key = "test.list"; + +beforeEach(() => { + localStorage.clear(); + vi.spyOn(console, "error").mockReturnValue(undefined); +}); + +describe("useLocalStorage", () => { + test("starts with the initial value when nothing is stored", () => { + expect(useLocalStorage(key, array(string()), ["x"]).value).toEqual(["x"]); + }); + + test("reads a valid stored value", () => { + localStorage.setItem(key, JSON.stringify(["a", "b"])); + + expect(useLocalStorage(key, array(string()), []).value).toEqual(["a", "b"]); + }); + + test.each(["{not json", JSON.stringify([1, 2]), JSON.stringify("text")])( + "falls back to the initial value for %j", + stored => { + localStorage.setItem(key, stored); + + expect(useLocalStorage(key, array(string()), ["x"]).value).toEqual(["x"]); + }, + ); + + test("writes through on set and update", () => { + const store = useLocalStorage(key, array(string()), []); + store.value = ["a"]; + expect(localStorage.getItem(key)).toBe('["a"]'); + + store.update(list => [...list, "b"]); + expect(store.value).toEqual(["a", "b"]); + expect(localStorage.getItem(key)).toBe('["a","b"]'); + }); + + test("clear resets the value and removes it from storage", () => { + const store = useLocalStorage(key, array(string()), ["x"]); + store.value = ["a"]; + store.clear(); + + expect(store.value).toEqual(["x"]); + expect(localStorage.getItem(key)).toBeNull(); + }); + + test("with storage disabled, neither reads nor writes it", () => { + localStorage.setItem(key, '["stored"]'); + const store = useLocalStorage(key, array(string()), ["x"], true); + expect(store.value).toEqual(["x"]); + + store.value = ["a"]; + + expect(store.value).toEqual(["a"]); + expect(localStorage.getItem(key)).toBe('["stored"]'); + }); +}); diff --git a/tests/unit/utils.test.ts b/tests/unit/utils.test.ts index 4331eaad6..bbc1525fb 100644 --- a/tests/unit/utils.test.ts +++ b/tests/unit/utils.test.ts @@ -20,9 +20,6 @@ import { safeHttpUrl, truncateDid, unqualifyBranch, - verdictAction, - verdictBadge, - verdictIcon, } from "@app/lib/utils"; const rid = "rad:z3fpY7nttPPa6MBnAv2DccHzQJnqe"; @@ -280,17 +277,7 @@ describe("explorer links", () => { }); }); -describe("verdicts", () => { - test.each([ - ["accept", "Accept", "Accepted", "accept", "thumbs-up"], - ["reject", "Reject", "Rejected", "reject", "stop"], - [undefined, "Comment", "Reviewed", "comment", "comment"], - ] as const)("%j", (verdict, action, label, variant, icon) => { - expect(verdictAction(verdict)).toBe(action); - expect(verdictBadge(verdict)).toEqual({ label, variant }); - expect(verdictIcon(verdict)).toBe(icon); - }); - +describe("isPublishableReview", () => { test.each([ ["accept", "", true], ["reject", " ", true],