mirror of
https://seed.radicle.dev/rad:z4D5UCArafTzTQpDZNQRuqswh3ury.git
synced 2026-10-18 19:10:45 +08:00
Add unit tests for the lib modules
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
@@ -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: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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" })),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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"),
|
||||
);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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"]');
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user