web/src/lib had grown to 85 flat modules -- 42% of the web source, about
12,800 lines -- with one subdirectory (smime/) to its name. The tell was
that a naming prefix had taken over a directory's job: eight adminX.ts
files sat adjacent because alphabetical order put them there, not because
anything said they belonged together.
lib/admin/ adminAccess, adminDashboard, adminDirectory, adminDomains,
adminGroups, adminLists, adminRoles, adminTenants
lib/calendar/ appointment, availabilityWindow, eventDrag, ics, recurrence
Tests move with their modules into lib/admin/__tests__ and
lib/calendar/__tests__, which is what views/ already does. describeRules
stays in lib/__tests__: it checks that sieve's describeRule and
recurrence's agree, so it belongs to neither.
recurrence.ts joins the calendar group and archiveDate.ts does not, which
is the opposite of the first guess from the filenames. archiveDate picks
the Archive/2026/09 mailbox for a message -- mail, not calendar --
while recurrence reads JSCalendarRecurrenceRule. schedule.ts is scheduled
*send*, so it stays put too. birthdays.ts is left alone deliberately: it
is read off the contact cards and only rendered by the calendar, so it
belongs to whichever of the two you ask.
docs/ held no documentation. It held ten JPEGs and the two scripts that
capture them, while the actual documentation is a separate site in the
ihasmail.org repository -- so anyone opening docs/ expecting prose found
a headless-Chrome driver. The images are now screenshots/, and the two
capture scripts join the other .mjs tooling in scripts/, which is where a
generator belongs. Renaming docs/ to screenshots/ wholesale would have
produced screenshots/screenshots/inbox-dark.jpg.
No behavior changes: every import was already on the @/ alias, so this is
path rewrites and nothing else.
94 lines
4.1 KiB
TypeScript
94 lines
4.1 KiB
TypeScript
/**
|
|
* The two sentence builders, which had no tests while they were building
|
|
* English by concatenation -- and no test would have caught the thing wrong
|
|
* with them, since the English output was correct. These pin the two
|
|
* properties that matter now: every fragment goes through the catalog, and
|
|
* the joining is Intl's rather than a hardcoded " and ".
|
|
*/
|
|
import { describe, expect, it } from "vitest";
|
|
import { describeRule as describeSieve } from "../sieve";
|
|
import { describeRule as describeRecurrence, weekdayOptions } from "../calendar/recurrence";
|
|
import { setUiLanguageForFormatting } from "../datetime";
|
|
import { setCatalog } from "../i18n";
|
|
|
|
describe("sieve describeRule", () => {
|
|
it("names the header and operator through the catalog", () => {
|
|
const s = describeSieve({
|
|
id: "1", name: "r", join: "allof", enabled: true,
|
|
tests: [{ type: "header", header: "subject", op: "contains", value: "invoice" }],
|
|
actions: [{ type: "fileinto", mailbox: "Work" }],
|
|
} as never);
|
|
expect(s).toContain("Subject");
|
|
expect(s).toContain("contains");
|
|
expect(s).toContain("invoice");
|
|
expect(s).toContain("Work");
|
|
});
|
|
|
|
it("joins an allof rule as a conjunction and anyof as a disjunction", () => {
|
|
const base = {
|
|
id: "1", name: "r", enabled: true,
|
|
tests: [
|
|
{ type: "header", header: "from", op: "is", value: "a@b" },
|
|
{ type: "header", header: "to", op: "is", value: "c@d" },
|
|
],
|
|
actions: [{ type: "keep" }],
|
|
};
|
|
expect(describeSieve({ ...base, join: "allof" } as never)).toContain(" and ");
|
|
expect(describeSieve({ ...base, join: "anyof" } as never)).toContain(" or ");
|
|
});
|
|
|
|
it("says 'always' when a rule has no tests", () => {
|
|
const s = describeSieve({ id: "1", name: "r", join: "allof", enabled: true, tests: [], actions: [{ type: "stop" }] } as never);
|
|
expect(s).toContain("always");
|
|
});
|
|
});
|
|
|
|
describe("recurrence describeRule", () => {
|
|
it("describes the simple frequencies", () => {
|
|
expect(describeRecurrence(undefined)).toBe("Does not repeat");
|
|
expect(describeRecurrence({ "@type": "RecurrenceRule", frequency: "daily" } as never)).toBe("Daily");
|
|
expect(describeRecurrence({ "@type": "RecurrenceRule", frequency: "daily", interval: 3 } as never)).toBe("Every 3 days");
|
|
});
|
|
|
|
it("recognizes Monday to Friday as every weekday", () => {
|
|
const rule = {
|
|
"@type": "RecurrenceRule", frequency: "weekly",
|
|
byDay: ["mo", "tu", "we", "th", "fr"].map((day) => ({ "@type": "NDay", day })),
|
|
};
|
|
expect(describeRecurrence(rule as never)).toBe("Every weekday");
|
|
});
|
|
|
|
it("uses a word, not a suffix, for the nth weekday of a month", () => {
|
|
const s = describeRecurrence({
|
|
"@type": "RecurrenceRule", frequency: "monthly",
|
|
byDay: [{ "@type": "NDay", day: "tu", nthOfPeriod: 2 }],
|
|
} as never);
|
|
expect(s).toContain("second");
|
|
expect(s).not.toContain("2nd");
|
|
});
|
|
|
|
it("wraps the sentence for count and until rather than appending to it", () => {
|
|
const s = describeRecurrence({ "@type": "RecurrenceRule", frequency: "daily", count: 5 } as never);
|
|
expect(s).toBe("Daily, 5 times");
|
|
const u = describeRecurrence({ "@type": "RecurrenceRule", frequency: "daily", until: "2026-05-03T00:00:00" } as never);
|
|
expect(u).toBe("Daily, until 2026-05-03");
|
|
});
|
|
|
|
it("takes its weekday names from the locale, not a table of English", () => {
|
|
setUiLanguageForFormatting("de-DE");
|
|
const names = weekdayOptions().map((w) => w.label);
|
|
expect(names[0]).toBe("Montag");
|
|
expect(names).toHaveLength(7);
|
|
// The narrow forms collide in English ("T" for both Tuesday and Thursday),
|
|
// which is why they cannot be catalog keys and come from Intl instead.
|
|
expect(weekdayOptions().map((w) => w.short)).toHaveLength(7);
|
|
setUiLanguageForFormatting(null);
|
|
});
|
|
|
|
it("renders a translated rule through the catalog", () => {
|
|
setCatalog("de", { strings: { Daily: "Täglich" }, plurals: {} });
|
|
expect(describeRecurrence({ "@type": "RecurrenceRule", frequency: "daily" } as never)).toBe("Täglich");
|
|
setCatalog("en", { strings: {}, plurals: {} });
|
|
});
|
|
});
|