rastrillo / fiesta Public

Clone
git clone https://amadan.net/rastrillo/fiesta

Plain git — no account needed to clone.

Download

Download this file

1// datetime_node — drives datetime.js's parser over one fixture file, in
2// plain Node, with no browser anywhere.
3//
4// The parser is the risk in datetime.js and the half a Go test cannot
5// reach: it is a pure function of (text, vocabulary, locale tables,
6// now), and this harness is what lets it be developed and regressed at
7// the speed of `go test` instead of a browser drive. Everything that
8// touches a page stays behind datetime.js's `document` guard, which
9// Node never takes — so loading the file here runs the parser and
10// nothing else.
11//
12// Usage, which check/datetime_test.go is the only caller of:
13//
14// echo '{"locale":"en","catalogs":{"en":{…},…}}' |
15// node node/datetime_node.mjs testdata/datetime/en.json
16//
17// Two modes take the place of a fixture path. --round-trip runs the
18// read-back loop: every catalog on stdin, formatted with the combobox's
19// own display options and parsed straight back. --calendar holds
20// js/calendar.js's grid arithmetic to its invariants in every shipped
21// locale. See roundTrip and calendar below.
22//
23// The vocabulary comes in on stdin rather than out of the fixture,
24// because it is not test data: it is the shipped table's date-words
25// for that language, and a fixture carrying its own copy would go
26// stale the first time a translator improved a word. The Go test reads
27// it from fiesta.Strings and pipes it in — every shipped language, not
28// just the fixture's own, plus the retagged aliases — so the words
29// under test are the words that ship.
30//
31// A fixture named after a locale (en.json) is read in that locale. A
32// case may also name its own with a "lang" key, which is what
33// regressions.json is: one file of cross-locale regressions, each case
34// carrying the language it is about, so a bug found in Japanese has
35// somewhere to live before the Japanese fixture exists.
36//
37// This file lives under check/node/ beside the other Node drivers, so
38// `go test` in check/ finds it by name, and it is never embedded:
39// fiesta.go reaches for js/, never for *.mjs.
40import { readFileSync } from "node:fs";
41import parser from "../../js/datetime.js";
42
43const fixturePath = process.argv[2];
44if (!fixturePath) {
45 process.stderr.write("usage: datetime_node.mjs <fixture.json | --round-trip | --calendar>\n");
46 process.exit(2);
47}
48
49const chunks = [];
50for await (const chunk of process.stdin) chunks.push(chunk);
51const { locale, catalogs } = JSON.parse(Buffer.concat(chunks).toString("utf8"));
52
53// ── the read-back loop ───────────────────────────────────────────────
54//
55// A field that cannot read its own writing is a field nobody can edit
56// in place: focus it, change the year, and the reading it wrote a
57// second ago comes back a refusal. That failed silently in five of the
58// twelve shipped languages — ja and zh wrote 年 and a bracketed
59// weekday, pt wrote "de" linkers, ru wrote a "г." suffix, and yue wrote
60// a dayPeriod no catalog spells — and no fixture could have caught it,
61// because every fixture is a phrase somebody chose to type.
62//
63// So this loop types nothing. It formats an instant with the SAME
64// options the combobox displays with, hands the result straight back to
65// the parser, and asks for the instant again. Run as
66// `datetime_node.mjs --round-trip`, over every catalog on stdin.
67const roundTrip = () => {
68 const samples = [
69 new Date(2026, 11, 25, 15, 30),
70 new Date(2026, 2, 3, 9, 5),
71 new Date(2027, 7, 28, 0, 0),
72 ];
73 const now = new Date(2026, 7, 28, 10, 30);
74 const day = (d) => [d.getFullYear(), d.getMonth(), d.getDate()].join("-");
75 const clock = (d) => [d.getHours(), d.getMinutes()].join(":");
76 const lines = [], broken = [];
77 for (const lang of Object.keys(catalogs)) {
78 const tbl = parser.tables(lang);
79 const misses = [];
80 let ok = 0, total = 0;
81 for (const kind of ["date", "datetime", "time"]) {
82 let fmt;
83 try {
84 fmt = new Intl.DateTimeFormat(lang, parser.displayOptions(kind));
85 } catch {
86 continue;
87 }
88 for (const want of samples) {
89 total++;
90 const shown = fmt.format(want);
91 let read = null;
92 try {
93 read = parser.parse(shown, catalogs[lang], tbl, now, {});
94 } catch (e) {
95 read = null;
96 }
97 // A date display carries no clock and a time display carries no
98 // day, so each kind is asked back only for what it wrote.
99 const same = read &&
100 (kind === "time" || day(read.date) === day(want)) &&
101 (kind === "date" || clock(read.date) === clock(want));
102 if (same) ok++;
103 else misses.push(` ${kind} ${JSON.stringify(shown)} -> ${read ? read.date : "null"}`);
104 }
105 }
106 lines.push(` ${lang}: ${ok}/${total}`);
107 if (misses.length) {
108 lines.push(...misses);
109 broken.push(lang);
110 }
111 }
112 // Every locale, not all-but-one. An earlier version of this gate
113 // allowed eleven of twelve so a CLDR bump could not block the build,
114 // and that is exactly the hole a reviewer walked through: one locale
115 // could stop reading its own writing, the harness would still exit 0,
116 // and the only trace was a name in a t.Log nobody reads on a passing
117 // test. A language that cannot re-read its own date is broken for the
118 // people who speak it, whoever caused it, so it fails — and the
119 // failure line names the languages rather than making anyone go and
120 // count the columns.
121 const langs = Object.keys(catalogs).length;
122 const good = langs - broken.length;
123 process.stdout.write(
124 `own-display read-back: ${good} of ${langs} locales round-trip fully\n` +
125 `${lines.join("\n")}\n` +
126 (broken.length
127 ? `FAIL: ${broken.length} locale(s) cannot read their own display back: ${broken.join(", ")}\n`
128 : ""),
129 );
130 process.exit(broken.length ? 1 : 0);
131};
132
133if (fixturePath === "--round-trip") roundTrip();
134
135// ── the calendar's arithmetic ────────────────────────────────────────
136//
137// js/calendar.js draws the month grid a date field opens. Everything in
138// it that touches a page is behind a `document` guard Node never takes,
139// and the three functions in front of that guard are the ones that can
140// be quietly WRONG: a grid that drops a day in a zone that moved its
141// clocks that morning, a week that starts on the wrong day, column
142// headings that do not describe their own columns.
143//
144// None of that needs a browser to catch, and none of it shows up in a
145// screenshot either — a calendar missing 1 March looks exactly like a
146// calendar. So this mode asserts the invariants directly, over every
147// locale the framework ships and over a span of months wide enough to
148// include the awkward ones: a leap February, both hemispheres' clock
149// changes, and a month beginning on each of the seven weekdays.
150//
151// Run as `datetime_node.mjs --calendar`, with the same catalogs on
152// stdin as every other mode — used here only for the list of locales.
153const calendar = async () => {
154 const cal = (await import("../../js/calendar.js")).default;
155 const two = (n) => String(n).padStart(2, "0");
156 const day = (d) => `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())}`;
157 const langs = Object.keys(catalogs);
158 const broken = new Set();
159 const failures = [];
160 const anchors = [];
161
162 // Every month of 2026 and 2027, plus February 2028 for the leap day.
163 // Wide enough that a month beginning on each of the seven weekdays
164 // appears several times over, and that both hemispheres' clock
165 // changes are covered whatever zone the runner sits in.
166 const months = [];
167 for (let y = 2026; y <= 2027; y++) for (let m = 0; m < 12; m++) months.push([y, m]);
168 months.push([2028, 1]);
169
170 // Four facts about the world, pinned in the TEST rather than in the
171 // file under it. calendar.js derives the first day of the week from
172 // Intl and keeps no table, which is right — but a derivation that
173 // came back consistently wrong would still satisfy every invariant
174 // below, because they all check the grid against the same number.
175 // These four are the anchor: they are not in dispute, they are
176 // stable in CLDR, and they belong here, where a table is evidence
177 // rather than a thing to maintain.
178 for (const [tag, want, who] of [
179 ["en-US", 0, "the United States starts its weeks on Sunday"],
180 ["en-GB", 1, "the United Kingdom starts its weeks on Monday"],
181 ["ar-EG", 6, "Egypt starts its weeks on Saturday"],
182 ["ja-JP", 0, "Japan starts its weeks on Sunday"],
183 ]) {
184 const got = cal.firstDayOfWeek(tag);
185 if (got !== want) anchors.push(` ${tag}: firstDayOfWeek = ${got}, want ${want} — ${who}`);
186 }
187
188 for (const lang of langs) {
189 const say = (msg) => {
190 broken.add(lang);
191 if (failures.length < 40) failures.push(` ${lang}: ${msg}`);
192 };
193 const first = cal.firstDayOfWeek(lang);
194 const heads = cal.weekdayNames(lang, first);
195
196 if (!Number.isInteger(first) || first < 0 || first > 6) {
197 say(`firstDayOfWeek = ${first}, which is not a getDay number`);
198 }
199 if (heads.length !== 7) say(`weekdayNames gave ${heads.length} columns, want 7`);
200 else {
201 // The headings must describe the columns they sit over. This is
202 // the failure that would look right and read wrong: a grid
203 // starting on Monday under a heading row starting on Sunday is
204 // off by one the whole way across, and every date in it is
205 // announced on the wrong weekday.
206 for (let i = 0; i < 7; i++) {
207 if (heads[i].day !== (first + i) % 7) {
208 say(`column ${i} heads day ${heads[i].day}, want ${(first + i) % 7}`);
209 }
210 if (!heads[i].short || !heads[i].long) say(`column ${i} has an empty name`);
211 }
212 if (new Set(heads.map((h) => h.long)).size !== 7) {
213 say(`the seven spoken weekday names are not seven distinct names`);
214 }
215 }
216
217 for (const [y, m] of months) {
218 const grid = cal.monthGrid(y, m, first);
219 const where = `${y}-${two(m + 1)}`;
220
221 // Always 42 cells, so the panel keeps one height all year.
222 if (grid.length !== 42) {
223 say(`${where}: ${grid.length} cells, want 42`);
224 continue;
225 }
226
227 // Every cell at local midnight, and each exactly one day after
228 // the one before it. Building the grid by adding 86,400,000
229 // milliseconds instead of counting whole days breaks precisely
230 // here, in the two months a clock changes, and nowhere else.
231 let stop = false;
232 for (let i = 0; i < 42 && !stop; i++) {
233 const d = grid[i];
234 if (d.getHours() || d.getMinutes() || d.getSeconds()) {
235 say(`${where}: cell ${i} is ${d}, not local midnight`);
236 stop = true;
237 } else if (i > 0) {
238 const p = grid[i - 1];
239 const want = new Date(p.getFullYear(), p.getMonth(), p.getDate() + 1);
240 if (day(d) !== day(want)) {
241 say(`${where}: cell ${i} is ${day(d)}, want ${day(want)} — a day was dropped or repeated`);
242 stop = true;
243 }
244 }
245 }
246 if (stop) continue;
247
248 if (grid[0].getDay() !== first) {
249 say(`${where}: the grid starts on day ${grid[0].getDay()}, want ${first}`);
250 }
251
252 // Every day of the month present, exactly once, with the 1st in
253 // the first week. Six rows is enough for every month there is —
254 // 31 days beginning on the last day of the week needs 37 cells —
255 // but only when the leading run is the right length.
256 const inMonth = grid.filter((d) => d.getMonth() === m && d.getFullYear() === y);
257 const last = new Date(y, m + 1, 0).getDate();
258 if (inMonth.length !== last) {
259 say(`${where}: ${inMonth.length} of the month's ${last} days are on the grid`);
260 }
261 const at = grid.findIndex((d) => d.getDate() === 1 && d.getMonth() === m);
262 if (at < 0 || at > 6) say(`${where}: the 1st sits at cell ${at}, not in the first week`);
263 }
264 }
265
266 process.stdout.write(
267 `calendar grid: ${langs.length - broken.size} of ${langs.length} locales draw ` +
268 `${months.length} months each with the right days in the right columns\n` +
269 (anchors.length
270 ? `FAIL: the first day of the week is derived wrongly in ${anchors.length} of 4 anchor locales:\n` +
271 `${anchors.join("\n")}\n`
272 : "") +
273 (broken.size
274 ? `FAIL: ${broken.size} locale(s) draw a month wrong: ${[...broken].join(", ")}\n` +
275 `${failures.join("\n")}\n`
276 : ""),
277 );
278 process.exit(broken.size || anchors.length ? 1 : 0);
279};
280
281if (fixturePath === "--calendar") await calendar();
282
283
284const cases = JSON.parse(readFileSync(fixturePath, "utf8"));
285
286// One set of locale tables per language in the file, built once.
287const tablesFor = new Map();
288const langOf = (c) => c.lang || locale;
289const setupFor = (lang) => {
290 if (!tablesFor.has(lang)) {
291 if (!catalogs[lang]) throw new Error(`no catalog for lang ${JSON.stringify(lang)}`);
292 tablesFor.set(lang, parser.tables(lang));
293 }
294 return { tables: tablesFor.get(lang), vocab: catalogs[lang] };
295};
296
297// Wall-clock strings, both ways, in the local zone the Go test pins —
298// deliberately not Date.parse, which reads a bare "2026-08-28T10:30" as
299// UTC and would move every expectation by the runner's offset.
300const at = (s) => {
301 const m = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2}))?$/.exec(String(s));
302 if (!m) throw new Error(`fixture: ${JSON.stringify(s)} is not a wall-clock date`);
303 return new Date(+m[1], +m[2] - 1, +m[3], m[4] ? +m[4] : 0, m[5] ? +m[5] : 0);
304};
305const pad = (n) => String(n).padStart(2, "0");
306const wire = (d) =>
307 `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
308
309const failures = [];
310
311for (const [i, c] of cases.entries()) {
312 const id = c.id || `#${i}`;
313 // A missing "expected" is a typo, not an expectation of null. Read as
314 // null it would pass against any refusal, which is exactly the answer
315 // a broken parser gives — a whole case quietly asserting nothing.
316 if (!Object.prototype.hasOwnProperty.call(c, "expected")) {
317 throw new Error(`case ${id} in ${fixturePath} has no "expected" key`);
318 }
319 const lang = langOf(c);
320 let got, err = null, override = null;
321 try {
322 const setup = setupFor(lang);
323 // parseAll, not parse, because parseAll is what the FIELD reads
324 // with: the exact grammar first, and where that refuses, the
325 // readings a half-typed word could still be finished into. Its
326 // first answer is the one Enter takes, so that is what "expected"
327 // means here, and a case may name the rest with "alternatives".
328 //
329 // Every reading in the list is the strict grammar's own — a
330 // completion only ever hands it a longer sentence to read — so a
331 // fixture asserting null still asserts exactly what it used to.
332 const reads = parser.parseAll(c.input, setup.vocab, setup.tables, at(c.now), {
333 prev: c.prev ? at(c.prev) : null,
334 base: c.base ? at(c.base) : null,
335 });
336 got = reads.length ? wire(reads[0].date) : null;
337 if (Object.prototype.hasOwnProperty.call(c, "alternatives")) {
338 const rest = reads.slice(1).map((r) => wire(r.date));
339 if (JSON.stringify(rest) !== JSON.stringify(c.alternatives)) {
340 got = `${got} + alternatives ${JSON.stringify(rest)}`;
341 override = `${c.expected} + alternatives ${JSON.stringify(c.alternatives)}`;
342 }
343 }
344 } catch (e) {
345 err = e;
346 got = `threw: ${e && e.message}`;
347 }
348 const want = override === null ? c.expected : override;
349 if (got !== want) {
350 failures.push(
351 ` ${id} [${lang}] input=${JSON.stringify(c.input)} now=${c.now}` +
352 `${c.prev ? ` prev=${c.prev}` : ""}${c.base ? ` base=${c.base}` : ""}\n` +
353 ` got ${JSON.stringify(got)}\n want ${JSON.stringify(want)}` +
354 (err ? `\n ${err.stack}` : ""),
355 );
356 }
357}
358
359if (failures.length) {
360 process.stdout.write(
361 `${failures.length} of ${cases.length} cases failed in ${fixturePath}:\n${failures.join("\n")}\n`,
362 );
363 process.exit(1);
364}
365process.stdout.write(`${cases.length} cases passed in ${fixturePath}\n`);
366