Add unit tests for the lib modules

This commit is contained in:
Rūdolfs Ošiņš
2026-10-01 18:20:25 +02:00
parent 92e866f7b8
commit 731c552661
22 changed files with 2131 additions and 77 deletions
+210
View File
@@ -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<string, CommentOwner> = {
d: { kind: "draft", draftId: "draft-1" },
r: { kind: "review", reviewId: "review-1" },
v: { kind: "revision", revisionId: "revision-1" },
};
let reload: ReturnType<typeof vi.fn<() => Promise<void>>>;
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<typeof actions>) => a.editComment("x", "", []),
],
["deleteComment", (a: ReturnType<typeof actions>) => a.deleteComment("x")],
[
"reactOnComment",
(a: ReturnType<typeof actions>) => 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);
});
+58
View File
@@ -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",
});
});
+152
View File
@@ -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: "",
});
});
});
+51
View File
@@ -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);
});
+12 -30
View File
@@ -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();
});
+31
View File
@@ -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);
});
+106
View File
@@ -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"]);
});
+48
View File
@@ -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);
});
+39
View File
@@ -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();
});
+111
View File
@@ -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);
});
-11
View File
@@ -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);
});
-22
View File
@@ -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" })),
+329
View File
@@ -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<string[]> {
return { cursor: 0, more, content };
}
function deferred() {
let resolve!: (value: PaginatedQuery<string[]>) => void;
const promise = new Promise<PaginatedQuery<string[]>>(res => {
resolve = res;
});
return { promise, resolve };
}
const fetchPage =
vi.fn<
(
skip: number,
take: number | undefined,
) => Promise<PaginatedQuery<string[]>>
>();
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<typeof createPaginatedList<string>>;
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<string[]>) {
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();
});
+60
View File
@@ -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);
});
+56
View File
@@ -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 });
});
+102
View File
@@ -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"),
);
});
+54
View File
@@ -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");
});
+339
View File
@@ -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<typeof import("@app/lib/utils")>()),
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);
});
});
+166
View File
@@ -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);
});
+144
View File
@@ -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<string>;
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");
});
+62
View File
@@ -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"]');
});
});
+1 -14
View File
@@ -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],