Files
ihasmail-inbuxa/web/src/lib/__tests__/describeRules.test.ts
T
jcoffey-dev 1a842d8d14 Build the two rule descriptions as sentences, not fragments
Both describeRule functions assembled their output by concatenation, which no
catalogue could fix. A translator handed " and " or " on " in isolation cannot
move it: German puts the verb last, Japanese does not separate list items with
a word at all, and the fragments arrive in an order the English sentence chose.
Reported by a native speaker reviewing the German catalogue (#247), whose "the
summaries" item is the Sieve one.

Every branch is now one whole sentence with placeholders, so a translator
rewrites the sentence including its word order. Joining is Intl.ListFormat,
which gives "A, B und C" for an allof rule and the language's own disjunction
for anyof, rather than a hardcoded " and " that would be wrong twice over.

The recurrence tail no longer appends: ", 5 times" and ", until 2026-05-03"
wrap the sentence they qualify, so a language that puts the limit first can.

Ordinals become words. The old suffix table -- st, nd, rd, th, picked by
arithmetic -- is English spelling rules in code, and no catalogue can reach a
suffix chosen that way. German writes "1.", Japanese "第1". nthOfPeriod is 1-5
or -1 in practice, so five words and "last" cover it.

WEEKDAYS is gone. Its long names could have been catalogue entries but its
short ones never could: "T" is Tuesday and Thursday, "S" is Saturday and
Sunday, and a catalogue cannot hold two translations under one key. That was
bad data rather than missing translation, and Intl has every name in every
locale in three widths. lib/datetime.ts gains weekdayName, weekdayNames and
formatList; recurrence.ts keeps WEEKDAY_KEYS for the ordering, which is not a
language question.

Adds the first tests either function has had. Neither had any, and no test
would have caught what was wrong with them, since the English output was
correct -- so these pin the two properties that actually matter: fragments go
through the catalogue, and the joining is Intl's.

32 strings and 9 plural forms are new and land with each language.

Verified: typecheck clean, 1009 tests pass.
2026-09-03 14:20:50 -07:00

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 catalogue, 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 "../recurrence";
import { setUiLanguageForFormatting } from "../datetime";
import { setCatalog } from "../i18n";
describe("sieve describeRule", () => {
it("names the header and operator through the catalogue", () => {
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("recognises 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 catalogue keys and come from Intl instead.
expect(weekdayOptions().map((w) => w.short)).toHaveLength(7);
setUiLanguageForFormatting(null);
});
it("renders a translated rule through the catalogue", () => {
setCatalog("de", { strings: { Daily: "Täglich" }, plurals: {} });
expect(describeRecurrence({ "@type": "RecurrenceRule", frequency: "daily" } as never)).toBe("Täglich");
setCatalog("en", { strings: {}, plurals: {} });
});
});