| 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. |
| 40 | import { readFileSync } from "node:fs"; |
| 41 | import parser from "../../js/datetime.js"; |
| 42 | |
| 43 | const fixturePath = process.argv[2]; |
| 44 | if (!fixturePath) { |
| 45 | process.stderr.write("usage: datetime_node.mjs <fixture.json | --round-trip | --calendar>\n"); |
| 46 | process.exit(2); |
| 47 | } |
| 48 | |
| 49 | const chunks = []; |
| 50 | for await (const chunk of process.stdin) chunks.push(chunk); |
| 51 | const { 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. |
| 67 | const 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 | |
| 133 | if (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. |
| 153 | const 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 | |
| 281 | if (fixturePath === "--calendar") await calendar(); |
| 282 | |
| 283 | |
| 284 | const cases = JSON.parse(readFileSync(fixturePath, "utf8")); |
| 285 | |
| 286 | // One set of locale tables per language in the file, built once. |
| 287 | const tablesFor = new Map(); |
| 288 | const langOf = (c) => c.lang || locale; |
| 289 | const 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. |
| 300 | const 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 | }; |
| 305 | const pad = (n) => String(n).padStart(2, "0"); |
| 306 | const wire = (d) => |
| 307 | `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; |
| 308 | |
| 309 | const failures = []; |
| 310 | |
| 311 | for (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 | |
| 359 | if (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 | } |
| 365 | process.stdout.write(`${cases.length} cases passed in ${fixturePath}\n`); |
| 366 | |