Group six more clusters out of web/src/lib
Takes the flat module count from 66 to 42, continuing what admin/ and
calendar/ started.
lib/mailbox/ archiveDate, emptyFolder, folderMove, labelTree,
mailboxName, mailboxRoute
lib/sieve/ sieve, sieveApply, sieveFolders
lib/input/ keyboard, swipe, touch, listSelection, dropUpload
lib/notify/ notify, webpush, webpushEnable
lib/sw/ swCache, swFacts, staleBuild
lib/text/ html, markdown, text, emlName
FOUR THINGS THE FILENAMES GET WRONG, each checked by reading the file
rather than trusting what it is called:
- appFolder is not a mailbox. It is the `ihasmail` folder in JMAP
*Files*, where the client keeps signature images and synced settings.
It stays flat.
- format holds no formatting of text. It re-exports the date and clock
formatters, so it belongs with dates/datetime, not with text/.
- preview is the file viewer deciding what it can show without
downloading, and source is where to point someone asking for this
instance's AGPL source. Neither is about text.
- notify is not Web Push. It is the tab title, the favicon badge and
the new-mail sound -- in-app notification, which is why it sits with
webpush rather than under sw/ with the service worker's own concerns.
threadScroll stays flat too: it decides where a conversation opens, which
is view state rather than a gesture, and input/ is honest only if
everything in it interprets something the reader did.
No behavior change. Almost every reference was on the @/ alias; eight
relative imports in files that did not move, or that moved away from a
sibling, needed rewriting by hand.
This commit is contained in:
1 parent
5cc31037c1
commit
bd6a605d61
95 files changed
+104
-104
No files matched your search
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { archiveSegments, archivePath, groupByArchivePath } from "@/lib/mailbox/archiveDate";
|
||||
|
||||
/**
|
||||
* The dates below are written as local-time strings on purpose. The segments
|
||||
* follow the reader's timezone, so a test pinned to UTC instants would pass or
|
||||
* fail depending on where it ran.
|
||||
*/
|
||||
describe("archiveSegments", () => {
|
||||
it("gives the year, and the zero-padded month", () => {
|
||||
expect(archiveSegments("2026-09-04T10:00:00", "year")).toEqual(["2026"]);
|
||||
expect(archiveSegments("2026-09-04T10:00:00", "month")).toEqual(["2026", "09"]);
|
||||
});
|
||||
|
||||
it("zero-pads every month below October, so the folders sort", () => {
|
||||
expect(archiveSegments("2026-01-15T10:00:00", "month")).toEqual(["2026", "01"]);
|
||||
expect(archiveSegments("2026-10-15T10:00:00", "month")).toEqual(["2026", "10"]);
|
||||
expect(archiveSegments("2026-12-15T10:00:00", "month")).toEqual(["2026", "12"]);
|
||||
});
|
||||
|
||||
it("returns nothing to append when the date cannot be read", () => {
|
||||
// Archive itself, rather than a folder named after a guess.
|
||||
expect(archiveSegments(null, "month")).toEqual([]);
|
||||
expect(archiveSegments(undefined, "month")).toEqual([]);
|
||||
expect(archiveSegments("", "month")).toEqual([]);
|
||||
expect(archiveSegments("not a date", "month")).toEqual([]);
|
||||
});
|
||||
|
||||
it("joins to a path", () => {
|
||||
expect(archivePath(["2026", "09"])).toBe("2026/09");
|
||||
expect(archivePath([])).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupByArchivePath", () => {
|
||||
it("keeps one destination for a selection from one month", () => {
|
||||
const groups = groupByArchivePath(
|
||||
[
|
||||
{ id: "a", receivedAt: "2026-09-04T10:00:00" },
|
||||
{ id: "b", receivedAt: "2026-09-28T10:00:00" },
|
||||
],
|
||||
"month",
|
||||
);
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0]!.segments).toEqual(["2026", "09"]);
|
||||
expect(groups[0]!.ids).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("splits a selection that spans months, which is the case that matters", () => {
|
||||
const groups = groupByArchivePath(
|
||||
[
|
||||
{ id: "a", receivedAt: "2026-09-04T10:00:00" },
|
||||
{ id: "b", receivedAt: "2026-08-30T10:00:00" },
|
||||
{ id: "c", receivedAt: "2026-09-01T10:00:00" },
|
||||
],
|
||||
"month",
|
||||
);
|
||||
expect(groups.map((g) => g.segments)).toEqual([
|
||||
["2026", "09"],
|
||||
["2026", "08"],
|
||||
]);
|
||||
expect(groups[0]!.ids).toEqual(["a", "c"]);
|
||||
expect(groups[1]!.ids).toEqual(["b"]);
|
||||
});
|
||||
|
||||
it("collapses the same span back to one group at year granularity", () => {
|
||||
const entries = [
|
||||
{ id: "a", receivedAt: "2026-09-04T10:00:00" },
|
||||
{ id: "b", receivedAt: "2026-02-28T10:00:00" },
|
||||
];
|
||||
expect(groupByArchivePath(entries, "month")).toHaveLength(2);
|
||||
expect(groupByArchivePath(entries, "year")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("orders groups by where their first message appeared", () => {
|
||||
const groups = groupByArchivePath(
|
||||
[
|
||||
{ id: "a", receivedAt: "2024-01-04T10:00:00" },
|
||||
{ id: "b", receivedAt: "2026-01-04T10:00:00" },
|
||||
],
|
||||
"year",
|
||||
);
|
||||
expect(groups.map((g) => archivePath(g.segments))).toEqual(["2024", "2026"]);
|
||||
});
|
||||
|
||||
it("gathers the undatable ones into their own group, bound for Archive itself", () => {
|
||||
const groups = groupByArchivePath(
|
||||
[
|
||||
{ id: "a", receivedAt: "2026-09-04T10:00:00" },
|
||||
{ id: "b", receivedAt: null },
|
||||
{ id: "c", receivedAt: "bad" },
|
||||
],
|
||||
"month",
|
||||
);
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(groups[1]!.segments).toEqual([]);
|
||||
expect(groups[1]!.ids).toEqual(["b", "c"]);
|
||||
});
|
||||
|
||||
it("has nothing to do with an empty selection", () => {
|
||||
expect(groupByArchivePath([], "month")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { canEmpty, emptyLabel } from "@/lib/mailbox/emptyFolder";
|
||||
import type { MailboxRole } from "@/jmap/types";
|
||||
|
||||
/**
|
||||
* Emptying destroys everything in a folder in one action, with no undo and no
|
||||
* trip through Deleted Items. Which folders may be emptied is therefore a
|
||||
* safety property, not a presentation one — the store enforces it too, and
|
||||
* these pin the half the menus decide.
|
||||
*/
|
||||
|
||||
describe("which folders may be emptied", () => {
|
||||
it("allows exactly Deleted Items and Junk Mail", () => {
|
||||
expect(canEmpty("trash")).toBe(true);
|
||||
expect(canEmpty("junk")).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses folders holding mail someone meant to keep", () => {
|
||||
const keep: MailboxRole[] = ["inbox", "archive", "sent", "drafts", "all", "flagged", "important", "subscribed"];
|
||||
for (const role of keep) expect(canEmpty(role), String(role)).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a plain folder, which has no role at all", () => {
|
||||
expect(canEmpty(null)).toBe(false);
|
||||
expect(canEmpty(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the action is called", () => {
|
||||
it("says what it does to spam, rather than naming the folder", () => {
|
||||
// "Delete all spam" is what this is called everywhere else; "Empty Junk
|
||||
// Mail" would be accurate and still leave people hunting for it.
|
||||
expect(emptyLabel({ name: "Junk Mail", role: "junk" })).toBe("Delete all spam");
|
||||
expect(emptyLabel({ name: "Spam", role: "junk" })).toBe("Delete all spam");
|
||||
});
|
||||
|
||||
it("names the folder for Deleted Items, whatever the server calls it", () => {
|
||||
expect(emptyLabel({ name: "Deleted Items", role: "trash" })).toBe("Empty Deleted Items");
|
||||
expect(emptyLabel({ name: "Trash", role: "trash" })).toBe("Empty Trash");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { canDropFolder, canMoveFolderTo, descendantIds, folderColor, movable } from "../folderMove";
|
||||
import type { Id, Mailbox } from "@/jmap/types";
|
||||
|
||||
const mb = (id: string, name: string, parentId: string | null, role: Mailbox["role"] = null): Mailbox =>
|
||||
({ id, name, parentId, role, sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, isSubscribed: true, myRights: {} as Mailbox["myRights"] });
|
||||
|
||||
/** root ── Work ── Clients ── EU
|
||||
* └─ Archive (role)
|
||||
* └─ Inbox (role) */
|
||||
const tree: Record<Id, Mailbox> = Object.fromEntries([
|
||||
mb("inbox", "Inbox", null, "inbox"),
|
||||
mb("arch", "Archive", null, "archive"),
|
||||
mb("work", "Work", null),
|
||||
mb("clients", "Clients", "work"),
|
||||
mb("eu", "EU", "clients"),
|
||||
mb("news", "Newsletters", null),
|
||||
].map((m) => [m.id, m]));
|
||||
|
||||
describe("movable", () => {
|
||||
it("refuses folders the server gave a role", () => {
|
||||
expect(movable(tree.inbox!)).toBe(false);
|
||||
expect(movable(tree.arch!)).toBe(false);
|
||||
expect(movable(tree.work!)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("descendantIds", () => {
|
||||
it("finds the whole subtree, not just the children", () => {
|
||||
expect([...descendantIds(tree, "work")].sort()).toEqual(["clients", "eu"]);
|
||||
expect([...descendantIds(tree, "eu")]).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canDropFolder", () => {
|
||||
it("allows a plain move into another folder", () => {
|
||||
expect(canDropFolder(tree, "news", "work")).toBe(true);
|
||||
expect(canDropFolder(tree, "eu", "news")).toBe(true);
|
||||
});
|
||||
|
||||
it("allows a move into a role folder, which may hold subfolders", () => {
|
||||
expect(canDropFolder(tree, "news", "arch")).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses to move a folder into itself or its own subtree", () => {
|
||||
expect(canDropFolder(tree, "work", "work")).toBe(false);
|
||||
expect(canDropFolder(tree, "work", "clients")).toBe(false);
|
||||
expect(canDropFolder(tree, "work", "eu")).toBe(false); // grandchild, not just child
|
||||
});
|
||||
|
||||
it("refuses a move to the parent it already has", () => {
|
||||
expect(canDropFolder(tree, "clients", "work")).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses to move a role folder anywhere", () => {
|
||||
expect(canDropFolder(tree, "inbox", "work")).toBe(false);
|
||||
expect(canDropFolder(tree, "arch", null)).toBe(false);
|
||||
});
|
||||
|
||||
it("handles the root: allowed from a parent, refused when already there", () => {
|
||||
expect(canDropFolder(tree, "eu", null)).toBe(true);
|
||||
expect(canDropFolder(tree, "news", null)).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a target that does not exist", () => {
|
||||
expect(canDropFolder(tree, "news", "gone")).toBe(false);
|
||||
expect(canDropFolder(tree, "gone", "work")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canMoveFolderTo", () => {
|
||||
const rights = (r: Partial<Mailbox["myRights"]>) => ({ mayRename: true, mayCreateChild: true, ...r }) as Mailbox["myRights"];
|
||||
const owned: Record<Id, Mailbox> = Object.fromEntries(Object.values(tree).map((m) => [m.id, { ...m, myRights: rights({}) }]));
|
||||
|
||||
it("agrees with a drop when every right is granted", () => {
|
||||
expect(canMoveFolderTo(owned, "news", "work")).toBe(true);
|
||||
expect(canMoveFolderTo(owned, "eu", null)).toBe(true);
|
||||
expect(canMoveFolderTo(owned, "work", "eu")).toBe(false);
|
||||
expect(canMoveFolderTo(owned, "news", null)).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a folder the user may not rename, top level included", () => {
|
||||
const locked = { ...owned, eu: { ...owned.eu!, myRights: rights({ mayRename: false }) } };
|
||||
expect(canMoveFolderTo(locked, "eu", "news")).toBe(false);
|
||||
expect(canMoveFolderTo(locked, "eu", null)).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a destination that may not hold new subfolders", () => {
|
||||
const closed = { ...owned, work: { ...owned.work!, myRights: rights({ mayCreateChild: false }) } };
|
||||
expect(canMoveFolderTo(closed, "news", "work")).toBe(false);
|
||||
expect(canMoveFolderTo(closed, "eu", null)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("folderColor", () => {
|
||||
it("returns the color chosen for that folder, and null for the rest", () => {
|
||||
const colors = { work: "#7c3aed" };
|
||||
expect(folderColor(colors, "work")).toBe("#7c3aed");
|
||||
expect(folderColor(colors, "news")).toBeNull();
|
||||
expect(folderColor({}, "work")).toBeNull();
|
||||
});
|
||||
|
||||
it("is keyed by id, so a renamed folder keeps its color", () => {
|
||||
// The id is stable across a rename; the name and path are not.
|
||||
expect(folderColor({ mb1: "#0f766e" }, "mb1")).toBe("#0f766e");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { labelTree, visibleLabels, descendantKeywords } from "@/lib/mailbox/labelTree";
|
||||
import type { Label } from "@/store/settings";
|
||||
|
||||
const L = (keyword: string, over: Partial<Label> = {}): Label => ({ keyword, name: keyword, color: "#000", ...over });
|
||||
|
||||
const flat = (labels: Label[], counts: Record<string, number> = {}) =>
|
||||
visibleLabels(labelTree(labels, counts)).map((n) => `${" ".repeat(n.depth)}${n.label.keyword}`);
|
||||
|
||||
describe("labelTree", () => {
|
||||
it("nests a label under its parent and indents it", () => {
|
||||
const roots = labelTree([L("work"), L("work_urgent", { parent: "work" })]);
|
||||
expect(roots).toHaveLength(1);
|
||||
expect(roots[0]!.label.keyword).toBe("work");
|
||||
expect(roots[0]!.children[0]!.label.keyword).toBe("work_urgent");
|
||||
expect(roots[0]!.children[0]!.depth).toBe(1);
|
||||
});
|
||||
|
||||
it("nests three deep", () => {
|
||||
expect(flat([L("a"), L("b", { parent: "a" }), L("c", { parent: "b" })])).toEqual(["a", " b", " c"]);
|
||||
});
|
||||
|
||||
it("puts a label back at the top when its parent no longer exists", () => {
|
||||
// Settings sync between devices; a parent can be deleted on one while
|
||||
// another still points at it. Dropping the child would lose it for good.
|
||||
expect(flat([L("orphan", { parent: "gone" })])).toEqual(["orphan"]);
|
||||
});
|
||||
|
||||
it("survives a cycle rather than hanging", () => {
|
||||
const out = flat([L("a", { parent: "b" }), L("b", { parent: "a" })]);
|
||||
expect(out).toHaveLength(2);
|
||||
expect(out.map((s) => s.trim()).sort()).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("survives a label parented to itself", () => {
|
||||
expect(flat([L("a", { parent: "a" })])).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("carries each label's own unread count, not its children's", () => {
|
||||
const roots = labelTree([L("a"), L("b", { parent: "a" })], { a: 2, b: 5 });
|
||||
expect(roots[0]!.unread).toBe(2);
|
||||
expect(roots[0]!.children[0]!.unread).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("visibleLabels", () => {
|
||||
it("draws everything set to always", () => {
|
||||
expect(flat([L("a"), L("b")])).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("never draws a hidden label", () => {
|
||||
expect(flat([L("a"), L("b", { visibility: "hidden" })], { b: 9 })).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("draws an unread-only label just while it has unread mail", () => {
|
||||
const labels = [L("a", { visibility: "unread" })];
|
||||
expect(flat(labels, { a: 0 })).toEqual([]);
|
||||
expect(flat(labels, { a: 1 })).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("keeps a parent that would otherwise be dropped, when a child survives", () => {
|
||||
// A child cannot be drawn under a parent that is not there, and promoting
|
||||
// it would silently rearrange the tree. The parent comes back as a
|
||||
// container instead.
|
||||
const labels = [L("work", { visibility: "unread" }), L("work_urgent", { parent: "work" })];
|
||||
expect(flat(labels, { work: 0 })).toEqual(["work", " work_urgent"]);
|
||||
});
|
||||
|
||||
it("keeps a hidden parent too, when a child survives", () => {
|
||||
const labels = [L("work", { visibility: "hidden" }), L("work_urgent", { parent: "work" })];
|
||||
expect(flat(labels, {})).toEqual(["work", " work_urgent"]);
|
||||
});
|
||||
|
||||
it("drops a whole branch when nothing in it survives", () => {
|
||||
const labels = [
|
||||
L("work", { visibility: "unread" }),
|
||||
L("work_urgent", { parent: "work", visibility: "unread" }),
|
||||
L("other"),
|
||||
];
|
||||
expect(flat(labels, { work: 0, work_urgent: 0 })).toEqual(["other"]);
|
||||
});
|
||||
|
||||
it("keeps a grandparent when only a grandchild survives", () => {
|
||||
const labels = [
|
||||
L("a", { visibility: "hidden" }),
|
||||
L("b", { parent: "a", visibility: "hidden" }),
|
||||
L("c", { parent: "b" }),
|
||||
];
|
||||
expect(flat(labels, {})).toEqual(["a", " b", " c"]);
|
||||
});
|
||||
|
||||
it("treats a label with no visibility set as always, so old settings parse unchanged", () => {
|
||||
const l = L("a");
|
||||
expect(l.visibility).toBeUndefined();
|
||||
expect(flat([l], {})).toEqual(["a"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("descendantKeywords", () => {
|
||||
it("names everything below a label, so the parent picker cannot offer a cycle", () => {
|
||||
const roots = labelTree([L("a"), L("b", { parent: "a" }), L("c", { parent: "b" }), L("d")]);
|
||||
expect([...descendantKeywords(roots, "a")].sort()).toEqual(["b", "c"]);
|
||||
expect([...descendantKeywords(roots, "d")]).toEqual([]);
|
||||
});
|
||||
|
||||
it("says nothing about a label that is not there", () => {
|
||||
expect([...descendantKeywords(labelTree([L("a")]), "missing")]).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { isLocalizedName, mailboxDisplayName, mailboxDisplayPath } from "@/lib/mailbox/mailboxName";
|
||||
import { setCatalog, type Catalog } from "@/lib/i18n";
|
||||
import type { Mailbox } from "@/jmap/types";
|
||||
|
||||
/**
|
||||
* Stalwart names the standard folders once, at account creation, and never
|
||||
* renames them — so a German reader on an English-provisioned account would
|
||||
* otherwise see "Deleted Items" in an otherwise German app. The role is what
|
||||
* lets ihasmail say "Papierkorb" without writing anything to the server.
|
||||
*/
|
||||
const de: Catalog = {
|
||||
strings: { Inbox: "Posteingang", "Deleted Items": "Papierkorb", Drafts: "Entwürfe" },
|
||||
plurals: {},
|
||||
};
|
||||
const mb = (id: string, name: string, role: string | null = null, parentId: string | null = null) =>
|
||||
({ id, name, role, parentId } as unknown as Mailbox);
|
||||
|
||||
afterEach(() => setCatalog("en", { strings: {}, plurals: {} }));
|
||||
|
||||
describe("mailboxDisplayName", () => {
|
||||
it("is the server's name until a catalog says otherwise", () => {
|
||||
expect(mailboxDisplayName(mb("1", "Deleted Items", "trash"))).toBe("Deleted Items");
|
||||
});
|
||||
|
||||
it("follows the interface language for a folder carrying a role", () => {
|
||||
setCatalog("de", de);
|
||||
expect(mailboxDisplayName(mb("1", "Deleted Items", "trash"))).toBe("Papierkorb");
|
||||
expect(mailboxDisplayName(mb("2", "Inbox", "inbox"))).toBe("Posteingang");
|
||||
});
|
||||
|
||||
it("leaves a folder somebody made alone", () => {
|
||||
// "Newsletters" is their word. Translating it would name a folder they
|
||||
// never created, and it would not match what any other client shows.
|
||||
setCatalog("de", de);
|
||||
expect(mailboxDisplayName(mb("3", "Newsletters"))).toBe("Newsletters");
|
||||
expect(mailboxDisplayName(mb("4", "Work", "subscribed"))).toBe("Work");
|
||||
});
|
||||
|
||||
it("survives a missing mailbox rather than printing undefined", () => {
|
||||
expect(mailboxDisplayName(null)).toBe("");
|
||||
expect(mailboxDisplayName(undefined)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLocalizedName", () => {
|
||||
it("tells an editor when the name on screen is not the server's", () => {
|
||||
// A rename box prefilled with "Papierkorb" would rename the folder to that
|
||||
// the moment somebody pressed Save — a real change made by accident.
|
||||
expect(isLocalizedName(mb("1", "Deleted Items", "trash"))).toBe(true);
|
||||
expect(isLocalizedName(mb("2", "Newsletters"))).toBe(false);
|
||||
expect(isLocalizedName(mb("3", "Work", "subscribed"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mailboxDisplayPath", () => {
|
||||
it("localizes each part that has a role and leaves the rest", () => {
|
||||
setCatalog("de", de);
|
||||
const all = { a: mb("a", "Inbox", "inbox"), b: mb("b", "Projects", null, "a") };
|
||||
expect(mailboxDisplayPath(all.b!, all)).toBe("Posteingang / Projects");
|
||||
});
|
||||
|
||||
it("stops rather than looping on a parent cycle", () => {
|
||||
// A malformed tree from the server must not hang the folder picker.
|
||||
const all: Record<string, Mailbox> = { a: mb("a", "A", null, "b"), b: mb("b", "B", null, "a") };
|
||||
expect(mailboxDisplayPath(all.a!, all)).toBe("B / A");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isUnknownMailbox } from "@/lib/mailbox/mailboxRoute";
|
||||
import type { Mailbox } from "@/jmap/types";
|
||||
|
||||
/**
|
||||
* Issue #111: a folder id the account does not have rendered the ordinary
|
||||
* empty state — "Nothing here. This folder is empty" — which is a claim about
|
||||
* a folder that is not there. A stale link read as a folder that had emptied
|
||||
* itself rather than one that was gone.
|
||||
*
|
||||
* The interesting case is not the unknown id. It is `loaded`: the folder list
|
||||
* arrives after the first paint, so for a moment *every* id is unknown,
|
||||
* including the right one. A version without that gate sends the reader to
|
||||
* their inbox from the folder they asked for, on every cold load, and looks
|
||||
* exactly like a flaky link.
|
||||
*/
|
||||
|
||||
const boxes = (...ids: string[]): Record<string, Mailbox> =>
|
||||
Object.fromEntries(ids.map((id) => [id, { id, name: id } as Mailbox]));
|
||||
|
||||
describe("spotting a folder the account does not have", () => {
|
||||
it("is unknown when the list is loaded and does not contain it", () => {
|
||||
expect(isUnknownMailbox({ mailboxId: "ghost", mailboxes: boxes("a", "b"), loaded: true })).toBe(true);
|
||||
});
|
||||
|
||||
it("is not unknown when the list contains it", () => {
|
||||
expect(isUnknownMailbox({ mailboxId: "a", mailboxes: boxes("a", "b"), loaded: true })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what it refuses to call unknown", () => {
|
||||
it("says nothing before the folder list has arrived", () => {
|
||||
// The whole point. Every id is unknown at this moment, the real one too.
|
||||
expect(isUnknownMailbox({ mailboxId: "a", mailboxes: {}, loaded: false })).toBe(false);
|
||||
expect(isUnknownMailbox({ mailboxId: "ghost", mailboxes: {}, loaded: false })).toBe(false);
|
||||
});
|
||||
|
||||
it("says nothing when there is no folder in the address", () => {
|
||||
// /mail has its own redirect to the inbox; this must not race it.
|
||||
expect(isUnknownMailbox({ mailboxId: undefined, mailboxes: boxes("a"), loaded: true })).toBe(false);
|
||||
});
|
||||
|
||||
it("says nothing on a search, which has no folder to be wrong about", () => {
|
||||
expect(isUnknownMailbox({ mailboxId: "ghost", mailboxes: boxes("a"), loaded: true, search: true })).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user