Merge branch 'main' into feat/spam-score-panel
# Conflicts: # web/src/store/mail.ts # web/src/styles/app.css
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { archiveSegments, archivePath, groupByArchivePath } from "@/lib/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,68 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { navSwipeThreshold, swipeNavDirection, swipeThreshold, lockAxis } from "@/lib/touch";
|
||||
|
||||
describe("navSwipeThreshold", () => {
|
||||
it("asks for more travel than a row swipe does, at every width", () => {
|
||||
// Not because the consequence is bigger -- stepping back undoes it -- but
|
||||
// because this gesture reveals nothing on the way and offers no Undo
|
||||
// after, so the distance is the only chance to not mean it.
|
||||
for (const width of [320, 360, 414, 768, 1024]) {
|
||||
expect(navSwipeThreshold(width)).toBeGreaterThan(swipeThreshold(width));
|
||||
}
|
||||
});
|
||||
|
||||
it("is a share of the width, bounded at both ends", () => {
|
||||
expect(navSwipeThreshold(360)).toBe(108);
|
||||
expect(navSwipeThreshold(200)).toBe(80); // floor
|
||||
expect(navSwipeThreshold(1000)).toBe(180); // ceiling
|
||||
});
|
||||
});
|
||||
|
||||
describe("swipeNavDirection", () => {
|
||||
const W = 400; // threshold is 120 at this width
|
||||
|
||||
it("goes forward when the finger drags left, the way pages turn", () => {
|
||||
expect(swipeNavDirection(-200, W)).toBe(1);
|
||||
});
|
||||
|
||||
it("goes back when the finger drags right", () => {
|
||||
expect(swipeNavDirection(200, W)).toBe(-1);
|
||||
});
|
||||
|
||||
it("does nothing short of the threshold, in either direction", () => {
|
||||
expect(swipeNavDirection(-60, W)).toBe(0);
|
||||
expect(swipeNavDirection(60, W)).toBe(0);
|
||||
expect(swipeNavDirection(0, W)).toBe(0);
|
||||
});
|
||||
|
||||
it("fires exactly at the threshold and not a pixel before", () => {
|
||||
const at = navSwipeThreshold(W);
|
||||
expect(swipeNavDirection(-at, W)).toBe(1);
|
||||
expect(swipeNavDirection(-(at - 1), W)).toBe(0);
|
||||
expect(swipeNavDirection(at, W)).toBe(-1);
|
||||
expect(swipeNavDirection(at - 1, W)).toBe(0);
|
||||
});
|
||||
|
||||
it("scales with the width, so a tablet asks for more than a phone", () => {
|
||||
// The same 120px drag commits on a narrow screen and does not on a wide one.
|
||||
expect(swipeNavDirection(-120, 360)).toBe(1);
|
||||
expect(swipeNavDirection(-120, 1024)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the axis lock this shares with the row swipe", () => {
|
||||
it("keeps a mostly-vertical drag as a scroll, which is what the day grid needs", () => {
|
||||
// The day view scrolls through the hours; a scroll misread as a swipe
|
||||
// throws the reader into another day.
|
||||
expect(lockAxis(20, 30)).toBe("y");
|
||||
expect(lockAxis(30, 25)).toBe("y");
|
||||
});
|
||||
|
||||
it("commits to sideways only when it is clearly sideways", () => {
|
||||
expect(lockAxis(40, 10)).toBe("x");
|
||||
});
|
||||
|
||||
it("is undecided until the drag has moved at all", () => {
|
||||
expect(lockAxis(2, 2)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fillPlaceholders, PLACEHOLDER_NAMES, type PlaceholderContext } from "@/lib/templatePlaceholders";
|
||||
|
||||
const AT = new Date("2026-03-04T15:07:00Z");
|
||||
|
||||
function ctx(over: Partial<PlaceholderContext> = {}): PlaceholderContext {
|
||||
return {
|
||||
to: [{ name: "Ada Lovelace", email: "[email protected]" }],
|
||||
from: { name: "Grace Hopper", email: "[email protected]" },
|
||||
subject: "Quarterly report",
|
||||
now: AT,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("fillPlaceholders", () => {
|
||||
it("fills the names it knows", () => {
|
||||
expect(fillPlaceholders("Hi {{recipientFirstName}},", ctx(), { html: true })).toBe("Hi Ada,");
|
||||
expect(fillPlaceholders("{{recipientName}} <{{recipientEmail}}>", ctx(), { html: false })).toBe("Ada Lovelace <[email protected]>");
|
||||
expect(fillPlaceholders("-- {{myName}}", ctx(), { html: true })).toBe("-- Grace Hopper");
|
||||
expect(fillPlaceholders("Re: {{subject}}", ctx(), { html: false })).toBe("Re: Quarterly report");
|
||||
});
|
||||
|
||||
it("tolerates spaces inside the braces but not a different case", () => {
|
||||
expect(fillPlaceholders("{{ myEmail }}", ctx(), { html: false })).toBe("[email protected]");
|
||||
expect(fillPlaceholders("{{MyEmail}}", ctx(), { html: false })).toBe("{{MyEmail}}");
|
||||
});
|
||||
|
||||
it("leaves a placeholder it cannot answer exactly as written", () => {
|
||||
// The case the design is about: a template inserted before the message is
|
||||
// addressed. "Hi ," would be wrong; "Hi {{recipientFirstName}}," is unfinished.
|
||||
const unaddressed = ctx({ to: [] });
|
||||
expect(fillPlaceholders("Hi {{recipientFirstName}},", unaddressed, { html: true })).toBe("Hi {{recipientFirstName}},");
|
||||
expect(fillPlaceholders("{{recipientEmail}}", unaddressed, { html: false })).toBe("{{recipientEmail}}");
|
||||
expect(fillPlaceholders("{{myName}}", ctx({ from: null }), { html: false })).toBe("{{myName}}");
|
||||
});
|
||||
|
||||
it("leaves a name it does not know alone rather than eating it", () => {
|
||||
expect(fillPlaceholders("{{nonsense}} {{}} {{ }}", ctx(), { html: true })).toBe("{{nonsense}} {{}} {{ }}");
|
||||
});
|
||||
|
||||
it("falls back to the local part when a recipient has no name", () => {
|
||||
const c = ctx({ to: [{ name: null, email: "[email protected]" }] });
|
||||
expect(fillPlaceholders("{{recipientName}}", c, { html: false })).toBe("ada.lovelace");
|
||||
expect(fillPlaceholders("{{recipientFirstName}}", c, { html: false })).toBe("ada.lovelace");
|
||||
});
|
||||
|
||||
it("escapes a substituted value on the way into HTML, and not into a subject", () => {
|
||||
const c = ctx({ to: [{ name: 'Ada <script>alert("x")</script>', email: "[email protected]" }] });
|
||||
expect(fillPlaceholders("{{recipientName}}", c, { html: true })).not.toContain("<script>");
|
||||
expect(fillPlaceholders("{{recipientName}}", c, { html: true })).toContain("<script>");
|
||||
expect(fillPlaceholders("{{recipientName}}", c, { html: false })).toContain("<script>");
|
||||
});
|
||||
|
||||
it("repeats a placeholder as many times as it appears", () => {
|
||||
expect(fillPlaceholders("{{recipientFirstName}} {{recipientFirstName}}", ctx(), { html: true })).toBe("Ada Ada");
|
||||
});
|
||||
|
||||
it("answers date and time from the injected clock", () => {
|
||||
const date = fillPlaceholders("{{date}}", ctx(), { html: false });
|
||||
const time = fillPlaceholders("{{time}}", ctx(), { html: false });
|
||||
expect(date).not.toBe("{{date}}");
|
||||
expect(date).toMatch(/2026/);
|
||||
expect(time).not.toBe("{{time}}");
|
||||
expect(time).toMatch(/\d/);
|
||||
});
|
||||
|
||||
it("names every resolver in the list Settings shows", () => {
|
||||
expect(PLACEHOLDER_NAMES).toEqual([
|
||||
"recipientName",
|
||||
"recipientFirstName",
|
||||
"recipientEmail",
|
||||
"myName",
|
||||
"myEmail",
|
||||
"subject",
|
||||
"date",
|
||||
"time",
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user