rastrillo / fiesta Public

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

Plain git — no account needed to clone.

Download

Download this file

1package check
2
3import (
4 "bytes"
5 "encoding/json"
6 "maps"
7 "os"
8 "path/filepath"
9 "slices"
10 "strings"
11 "testing"
12 "time"
13
14 "amadan.net/rastrillo/fiesta"
15 "amadan.net/rastrillo/fiesta/internal/nodetest"
16)
17
18// Ported from rastrillo's ui/datetime_test.go at the seed commit. The
19// parser and the calendar's arithmetic run in Node
20// (node/datetime_node.mjs); the contracts read the scripts as text.
21// The vocabulary the browser reads off data-rst-date-words comes from
22// i18n/<lang>.json's date-words now, through fiesta.Strings, rather
23// than from rastrillo's catalogs.
24
25// shippedScript returns one script by its file name, failing the test
26// when the tree does not ship it.
27func shippedScript(t *testing.T, name string) []byte {
28 t.Helper()
29 js, ok := fiesta.Script(name)
30 if !ok {
31 t.Fatalf("%s is not shipped", name)
32 }
33 return js
34}
35
36// retagged reads testdata/datetime/retagged.json: the tags a page may
37// still carry (plain pt) and the shipped language each one's table is.
38// No pt.json ships; the alias lives here, in test data, only.
39func retagged(t *testing.T) map[string]string {
40 t.Helper()
41 b, err := os.ReadFile(filepath.Join("testdata", "datetime", "retagged.json"))
42 if err != nil {
43 t.Fatal(err)
44 }
45 var out map[string]string
46 if err := json.Unmarshal(b, &out); err != nil {
47 t.Fatalf("retagged.json: %v", err)
48 }
49 if len(out) == 0 {
50 t.Fatal("retagged.json maps nothing; pt is the alias this suite expects")
51 }
52 return out
53}
54
55// dateWordsOf reads one shipped language's date-words, the seventeen
56// vocabulary names and nothing else, out of its table.
57func dateWordsOf(t *testing.T, lang string) map[string]string {
58 t.Helper()
59 b, ok := fiesta.Strings(lang)
60 if !ok {
61 t.Fatalf("fiesta.Strings(%q): not shipped", lang)
62 }
63 var table struct {
64 Words map[string]string `json:"date-words"`
65 }
66 if err := json.Unmarshal(b, &table); err != nil {
67 t.Fatalf("i18n/%s.json: %v", lang, err)
68 }
69 words := make(map[string]string, len(dateWords))
70 for _, name := range dateWords {
71 value := table.Words[name]
72 if value == "" {
73 t.Fatalf("i18n/%s.json: date-words has no %s", lang, name)
74 }
75 words[name] = value
76 }
77 return words
78}
79
80// vocabStdin is what the browser reads off data-rst-date-words, built
81// the same way a page's table carries it: each shipped language's
82// date-words, the seventeen vocabulary keys, and nothing else. The
83// fixtures deliberately do not carry their own copy: words under test
84// have to be the words that ship, or a translator's improvement would
85// go green against a stale duplicate.
86//
87// Every shipped language goes over, not just the fixture's own: a case
88// may name its own language with a "lang" key, which is what
89// testdata/datetime/regressions.json is made of. The retagged aliases
90// go over too: a page whose lang is still pt gets pt-PT's words, on a
91// tag Intl writes as Brazil, and that pairing is one the parser has to
92// read as well.
93func vocabStdin(t *testing.T, locale string) []byte {
94 t.Helper()
95 catalogs := make(map[string]map[string]string)
96 for _, lang := range fiesta.Languages() {
97 catalogs[lang] = dateWordsOf(t, lang)
98 }
99 for from, to := range retagged(t) {
100 words, ok := catalogs[to]
101 if !ok {
102 t.Fatalf("retagged.json maps %s to %s, which is not a shipped language", from, to)
103 }
104 catalogs[from] = words
105 }
106 if _, ok := catalogs[locale]; !ok {
107 t.Fatalf("no table for %q", locale)
108 }
109 encoded, err := json.Marshal(map[string]any{"locale": locale, "catalogs": catalogs})
110 if err != nil {
111 t.Fatal(err)
112 }
113 return encoded
114}
115
116// localeOf reads a fixture's default language off its filename. A file
117// not named after a shipped language is a cross-locale one, every case
118// in it names its own language, and English is its default.
119func localeOf(fixture string) string {
120 name := strings.TrimSuffix(filepath.Base(fixture), ".json")
121 if slices.Contains(fiesta.Languages(), name) {
122 return name
123 }
124 return "en"
125}
126
127// datetimeFixtures lists the parser fixtures: every JSON file under
128// testdata/datetime except retagged.json, which is the alias table.
129func datetimeFixtures(t *testing.T) []string {
130 t.Helper()
131 all, err := filepath.Glob(filepath.Join("testdata", "datetime", "*.json"))
132 if err != nil {
133 t.Fatal(err)
134 }
135 var fixtures []string
136 for _, f := range all {
137 if filepath.Base(f) != "retagged.json" {
138 fixtures = append(fixtures, f)
139 }
140 }
141 if len(fixtures) == 0 {
142 t.Fatal("no fixtures in testdata/datetime")
143 }
144 return fixtures
145}
146
147// The parser is the risk in datetime.js, and it is the half no Go test
148// can reach on its own: it reads month and weekday names out of Intl,
149// folds the digits a locale writes, and answers in wall-clock dates.
150// datetime_node.mjs runs it under plain Node against one fixture per
151// language.
152//
153// Node is not a build dependency, so its absence skips, naming the
154// test, and fails instead under FIESTA_TEST_REQUIRE_NODE (nodetest),
155// because a silent skip on a CI image without node would retire this
156// suite without anybody deciding to.
157func TestDatetimeParserFixtures(t *testing.T) {
158 nodetest.Node(t)
159
160 fixtures := datetimeFixtures(t)
161 if _, err := os.Stat(filepath.Join("testdata", "datetime", "en.json")); err != nil {
162 t.Fatalf("en.json is the reference fixture and must exist: %v", err)
163 }
164
165 for _, fixture := range fixtures {
166 name := strings.TrimSuffix(filepath.Base(fixture), ".json")
167 t.Run(name, func(t *testing.T) {
168 out := nodetest.Run(t, nodetest.Cmd{
169 Args: []string{"node/datetime_node.mjs", fixture},
170 Stdin: vocabStdin(t, localeOf(fixture)),
171 // The fixtures are wall-clock: a runner in a zone with a
172 // summer-time jump inside a fixture's span would move an
173 // expectation for reasons that have nothing to do with the
174 // parser. UTC has no such jump.
175 Env: []string{"TZ=UTC"},
176 })
177 t.Log(strings.TrimSpace(string(out)))
178 })
179 }
180}
181
182// A field has to be able to read what it just wrote. Every fixture is a
183// phrase somebody chose to type, so no fixture could catch the opposite
184// failure: the combobox formatting a value with Intl and then refusing
185// its own formatting back. It was refusing it in five of the twelve
186// shipped languages (ja and zh-Hans write 年 and a bracketed weekday,
187// pt writes "de" linkers, ru writes a "г." suffix, yue writes a
188// dayPeriod (下晝) no table spells), which made an in-place edit
189// impossible in each of them and looked like nothing at all from here.
190//
191// So this drives datetime_node.mjs's --round-trip mode: format three
192// instants per kind with the display's own options, parse each back,
193// and compare. All twelve have to survive the trip. The first cut of
194// this gate allowed eleven, which meant a single-locale regression
195// exited 0 and left its only trace in a t.Log, a passing test nobody
196// reads. The harness now exits non-zero the moment one locale fails and
197// names the languages on the FAIL line, which is what lands in the
198// t.Fatalf below.
199func TestDatetimeReadsItsOwnDisplayBack(t *testing.T) {
200 out := nodetest.Run(t, nodetest.Cmd{
201 Args: []string{"node/datetime_node.mjs", "--round-trip"},
202 Stdin: vocabStdin(t, "en"),
203 Env: []string{"TZ=UTC"},
204 })
205 t.Log(strings.TrimSpace(string(out)))
206}
207
208// The calendar overlay draws six weeks of days, and the arithmetic
209// behind that is the half of it that can be quietly wrong: a grid that
210// drops a day in a zone that moved its clocks that morning, a week that
211// starts on the wrong day, column headings that do not describe their
212// own columns. None of that shows up in a screenshot (a calendar
213// missing 1 March looks exactly like a calendar) and none of it needs
214// a browser to catch, because js/calendar.js keeps that arithmetic in
215// front of its own document guard for precisely this reason.
216//
217// --calendar walks every shipped language over twenty-five months (both
218// of 2026 and 2027, plus a leap February) and holds each grid to its
219// invariants: forty-two cells, every one at local midnight, each one
220// day after the last, starting on that locale's own first day of the
221// week, with every day of the month present exactly once and the 1st in
222// the first row. Four undisputed week-start facts are pinned there too:
223// the derivation keeps no table, so the test carries the anchor.
224//
225// The DST case is not hypothetical: building the grid by adding
226// 86,400,000 milliseconds instead of counting whole days fails here in
227// every locale, on the two months a clock changes, and passes
228// everywhere else. TZ is deliberately NOT pinned to UTC, unlike the
229// fixture runs: a zone with no clock changes is the one zone that
230// cannot catch it, and the invariants hold in every zone by
231// construction.
232func TestCalendarGridHoldsInEveryLocale(t *testing.T) {
233 out := nodetest.Run(t, nodetest.Cmd{
234 Args: []string{"node/datetime_node.mjs", "--calendar"},
235 Stdin: vocabStdin(t, "en"),
236 })
237 t.Log(strings.TrimSpace(string(out)))
238}
239
240// calendar.js holds to the same contract as the other scripts: its own
241// vocabulary, a native-input-free widget built in the browser, a
242// document guard so Node can load its arithmetic, and no English month
243// or weekday name anywhere in it.
244func TestCalendarContract(t *testing.T) {
245 js := string(shippedScript(t, "calendar.js"))
246 for _, want := range []string{
247 // The seam datetime.js looks up at enhance time. If this name
248 // moves, the button silently falls back to the browser's own
249 // picker, which is the failure this whole overlay removed.
250 "fiestaCalendar",
251 // The roles a calendar has to carry to be usable without sight:
252 // a named dialog, a grid, real column headers, the committed
253 // day marked as selected and today marked as today.
254 `"dialog"`, `"grid"`, `"columnheader"`, `"gridcell"`,
255 "aria-selected", "aria-current", "aria-disabled",
256 // The keys it has to answer to.
257 "ArrowLeft", "ArrowRight", "PageUp", "PageDown", "Home", "End", "Escape",
258 // The three strings it puts on screen all arrive translated.
259 "data-rst-day", "tabIndex",
260 } {
261 if !strings.Contains(js, want) {
262 t.Errorf("calendar.js does not mention %q", want)
263 }
264 }
265 if !strings.Contains(js, `typeof document !== "undefined"`) {
266 t.Error("calendar.js must keep its panel behind a document guard, or the Node harness cannot load it")
267 }
268 if !strings.Contains(js, "module.exports") {
269 t.Error("calendar.js must export its arithmetic for node/datetime_node.mjs --calendar")
270 }
271 if strings.Contains(js, "eval(") || strings.Contains(js, "new Function") {
272 t.Error("calendar.js must stay CSP-clean")
273 }
274 if strings.Contains(js, "\t") {
275 t.Error("calendar.js uses two-space indentation, not tabs")
276 }
277 // Its own budget, set where datetime.js's was first set and for the
278 // same reason: an app owner has to be able to read the whole file in
279 // one sitting, and the next few hundred bytes should be a decision
280 // rather than a drift. It lands at about 15KB, roughly half of it
281 // the prose explaining the other half.
282 if n := len(js); n > 24*1024 {
283 t.Fatalf("calendar.js is %d bytes; split something out before it stops being readable", n)
284 }
285}
286
287// The same rule datetime.js lives under, for the same reason: a month
288// or weekday name written down here would work in English and fail
289// silently in every other language shipped. calendar.js asks Intl, in
290// the page's own lang, for all of it: the names, the digits, and the
291// day a week starts on.
292func TestCalendarDerivesItsNames(t *testing.T) {
293 body := string(shippedScript(t, "calendar.js"))
294 body = body[strings.Index(body, "(function ("):]
295 for _, name := range []string{
296 "january", "february", "december", "\"jan\"", "\"dec\"",
297 "monday", "friday", "sunday", "\"mon\"", "\"fri\"",
298 } {
299 if strings.Contains(body, name) {
300 t.Errorf("calendar.js spells %s itself; month and weekday names come from Intl", name)
301 }
302 }
303}
304
305// Every shipped language gets its own fixture, because the vocabulary
306// is the part of this parser that cannot be derived: a missing fixture
307// is a language nobody has checked can type a date. Over
308// fiesta.Languages(), so a language added to the tree with no fixture
309// beside it fails here.
310func TestEveryLanguageHasADatetimeFixture(t *testing.T) {
311 langs := fiesta.Languages()
312 if len(langs) == 0 {
313 t.Fatal("fiesta.Languages() is empty")
314 }
315 for _, lang := range langs {
316 if _, err := os.Stat(filepath.Join("testdata", "datetime", lang+".json")); err != nil {
317 t.Errorf("no parser fixture for shipped language %q: %v", lang, err)
318 }
319 }
320}
321
322// datetime.js holds to the same contract as the shim and select.js: the
323// vocabulary the docs promise, the ARIA convention, an idempotent scan,
324// and a native input that survives enhancement because it is what the
325// form submits.
326func TestDatetimeContract(t *testing.T) {
327 raw := shippedScript(t, "datetime.js")
328 js := string(raw)
329 for _, want := range []string{
330 "data-rst-date", "data-rst-time", "data-rst-date-words",
331 "data-rst-date-set", "data-rst-date-hint", "data-rst-date-pick",
332 "data-rst-date-results", "data-rst-date-result-one",
333 "data-rst-date-quick-today", "data-rst-date-quick-tomorrow",
334 "data-rst-date-quick-next-week", "data-rst-date-quick-plus-1h",
335 "data-rst-date-quick-plus-2h", "data-rst-date-quick-end-of-day",
336 "data-rst-date-quick-next-day", "data-rst-range",
337 // The calendar overlay's own strings, and a time field's own
338 // button label: the overlay builds its markup in the browser,
339 // so every word it shows has to arrive on an attribute or in
340 // the page's table.
341 "data-rst-date-pick-time", "data-rst-date-calendar",
342 "data-rst-date-prev-month", "data-rst-date-next-month",
343 // The seam to js/calendar.js. If this name moves and the other
344 // file is not moved with it, nothing breaks loudly: the button
345 // quietly falls back to the browser's own picker, which is the
346 // unusable control this whole overlay replaced.
347 "fiestaCalendar",
348 // The completion layer, which is what lets "5j" read as a date
349 // at all, and parse, which stays strict underneath it.
350 "parseAll", "function parse(",
351 // The convention, pinned: an ARIA combobox that mirrors rather
352 // than replaces, announced to assistive tech.
353 "combobox", "aria-activedescendant", "aria-expanded", "aria-live",
354 "rst-sr-only",
355 // The month and weekday names are Intl's, never a table.
356 "formatToParts",
357 // showPicker survives as the fallback for a page that links
358 // this file without calendar.js, and nowhere else.
359 "showPicker",
360 // {example} is substituted here, in the locale's own format;
361 // the markup emits the hint template verbatim.
362 "{example}", "{n}",
363 } {
364 if !strings.Contains(js, want) {
365 t.Errorf("datetime.js does not mention %q", want)
366 }
367 }
368 for _, bad := range []string{".remove()", "removeChild", "outerHTML ="} {
369 if strings.Contains(js, bad) {
370 t.Errorf("datetime.js destroys DOM (%q); the native input must survive", bad)
371 }
372 }
373 if !strings.Contains(js, "rstEnhanced") {
374 t.Error("datetime.js is not idempotent; re-scanning would double-enhance")
375 }
376 if !strings.Contains(js, `typeof document !== "undefined"`) {
377 t.Error("datetime.js must keep its page half behind a document guard, or the Node harness cannot load it")
378 }
379 if !strings.Contains(js, "module.exports") {
380 t.Error("datetime.js must export its parser for node/datetime_node.mjs")
381 }
382 if strings.Contains(js, "eval(") || strings.Contains(js, "new Function") {
383 t.Error("datetime.js must stay CSP-clean")
384 }
385 if bytes.Contains(raw, []byte("\t")) {
386 t.Error("datetime.js uses two-space indentation, not tabs")
387 }
388 // Bigger than the shim's and select.js's 8KB by design: this one
389 // carries a parser as well as a combobox, and it lands within a few
390 // lines of the implementation it was ported from. The cap is here
391 // so the next few hundred bytes are a decision rather than a drift:
392 // a file an app owner has to page through is a file they stop
393 // owning.
394 //
395 // Raised twice, and the arithmetic is written down both times so
396 // the next raise has to argue with real numbers.
397 //
398 // 48KB → 54KB, by the twelve-locale fixtures: writing a real date
399 // in each of the eleven other languages bought four capabilities
400 // this parser did not have. It folds the digits an Arabic or Hindi
401 // keyboard makes (plain "ar" resolves to latn in ICU, so the
402 // locale's default numbering system was not enough); it holds the
403 // month name Intl only gives up when a day is in the format, which
404 // is the form a Russian actually types; it reads a date written
405 // with counter words rather than month names (12月25日); and it
406 // tells a counted 月 from Monday. The honest split of that 6.6KB:
407 // about 2.7KB of code and about 3.9KB of the prose explaining it.
408 // An earlier wording here said "six kilobytes for eleven
409 // languages", which read as six kilobytes of parser and was not.
410 //
411 // 54KB → 60KB, by the read-back wave: the parser now derives, from
412 // the same Intl options the field displays with, the literals that
413 // display threads between the numbers and the dayPeriod names it
414 // picks, so it can read its own writing in all twelve languages
415 // instead of five, and "25 de marzo" parses because "de" is a
416 // literal Intl already told us about. Split again: about 2.7KB of
417 // code, about 3.2KB of comment. More than half of both raises is
418 // prose, which is the trade this file makes on purpose: the cap is
419 // on what an owner has to page through, not on what runs.
420 //
421 // 60KB → 76KB, by the calendar overlay, and this raise came with a
422 // SPLIT in front of it. The button used to call native.showPicker(),
423 // which is one line and close to useless in a form: the panel
424 // belongs to the browser, cannot be styled to match the page, does
425 // not exist at all for a time input on some engines, and (measured,
426 // not guessed) opens WITHOUT moving focus, so the guard on the
427 // change listener here swallowed the pick and the field looked like
428 // it had ignored the selection. Replacing it took a real widget, so
429 // the widget went to calendar.js: 22KB that would otherwise have
430 // landed on this number, in a file that needs none of the parser.
431 //
432 // What stayed is +15.1KB, an honest 7.3KB of code and 7.8KB of
433 // prose, and none of it draws a calendar. The popup became three
434 // states rather than one (the suggestions, the grid, and a time
435 // field's half hours) so there is still only one thing to close and
436 // one thing the arrow keys drive; the words drive the grid live
437 // while it is open; min/max became bounds a range's end can tighten;
438 // the panel flips above the field where there is no room below it;
439 // and parseAll arrived, which offers a reading where the exact
440 // grammar had none: "5j" is not a date, and 5 January, 5 June and
441 // 5 July are three.
442 //
443 // The cap is on what an owner has to page through. Two files at 74KB
444 // and 22KB is that; one file at 96KB is not, which is the whole
445 // reason the split came first and the raise second.
446 //
447 // 76KB → 78KB, by the string tables. The file sat 45 bytes under
448 // the cap, so anything at all raised it. What arrived is +1.7KB
449 // net (+4.4KB written, 2.75KB removed): fiestaPickLang and
450 // fiestaWord, the lookup that reads a label off the attribute,
451 // then the page's table, then the fallback, about 1.0KB of code
452 // once the old attr helper and its seventeen calls are netted off,
453 // with the exports and the date-words branch that takes the
454 // table's object or the attribute's JSON; and about 0.7KB of prose,
455 // the header's strings paragraph rewritten for the new order, the
456 // seven quick-pick attributes written out where "quick-*" stood,
457 // and the section comment. 78KB is the next whole step that holds
458 // it, with 0.4KB to spare. The copy in select.js is the same bytes;
459 // see TestTheTwoLookupsAreTheSame.
460 //
461 // 78KB → 82KB, in two steps the release review asked for. First to
462 // 82,376 bytes (+2,910 on the 79,466 the string tables left): the
463 // English vocabulary, ENGLISH_WORDS and vocabulary(), so the three
464 // phrases the README advertises parse with no table loaded (about
465 // 0.9KB, half of it the words themselves); the native state, locked()
466 // and follow(), so a readonly field refuses every way of changing it
467 // and a disabled one is disabled throughout (about 1.0KB with its
468 // guards); and the form-reset repaint (about 0.6KB, most of it the
469 // comment saying why it waits a task). Then to 83,347 (+971) when the
470 // re-review found the vocabulary assigned after the first scan, so a
471 // deferred script captured undefined: the block moved ahead of the
472 // page half with a comment saying why, a malformed attribute now
473 // falls to the table before English (NO_ATTRIBUTE and the catch),
474 // and a reset that lands in the tick before a blur's commit wins
475 // (lastReset). 81KB (82,944) did not hold it; 82KB (83,968) did,
476 // with 621 to spare.
477 //
478 // 82KB → 83KB (measured 84,708 bytes, +1,361) when the third review
479 // found two orderings lastReset still lost (an accepted reset
480 // followed by a cancelled one, and the reverse around the blur).
481 // The accounting is now per form at the top of the page half: a
482 // pending count every reset raises and its timer lowers, an
483 // accepted count only an uncancelled reset's timer raises and
484 // nothing lowers, and a blur commit that waits out pending resets
485 // and runs only if the accepted count is what it was when queued.
486 // About 0.9KB is the comment saying why; lastReset's four lines
487 // went. 82KB (83,968) does not hold it; 83KB (84,992) does, with
488 // 284 to spare.
489 if n := len(raw); n > 83*1024 {
490 t.Fatalf("datetime.js is %d bytes; split something out before it stops being readable", n)
491 }
492}
493
494// The half a machine CAN derive is derived. Month and weekday names
495// never appear in this file: they come out of Intl in the page's own
496// language, so a field enhanced in Hindi reads Hindi month names with
497// nothing added here. A spelling written down would work in English and
498// silently fail everywhere else, the exact half-failure the split
499// exists to prevent. (The vocabulary keys themselves, "tomorrow",
500// "next", "ago", do appear: they are the attribute's field names, and
501// their VALUES are what arrives translated.)
502func TestDatetimeDerivesItsCalendarNames(t *testing.T) {
503 body := string(shippedScript(t, "datetime.js"))
504 body = body[strings.Index(body, "(function () {"):]
505 for _, name := range []string{
506 "january", "february", "december", "\"jan\"", "\"dec\"",
507 "monday", "friday", "sunday", "\"mon\"", "\"fri\"",
508 } {
509 if strings.Contains(body, name) {
510 t.Errorf("datetime.js spells %s itself; month and weekday names come from Intl", name)
511 }
512 }
513}
514
515// The README promises an English page three phrases with no table
516// loaded and no attribute: "tomorrow", "next fri 9am" and "in 2 weeks".
517// The labels always had an English fallback written into the script;
518// the vocabulary fell back to nothing, so all three read as nothing.
519// node/vocab_node.mjs resolves a bare field's vocabulary on a lang="en"
520// page with no self.fiestaStrings and parses each phrase with it. The
521// words must be i18n/en.json's date-words exactly, so a word improved
522// in the table is improved in the script, and every phrase must read:
523// tomorrow is the 29th, two weeks on is 11 November, and next Friday
524// at nine is a Friday at 09:00. The driver's second scenario holds the
525// order when the attribute is broken: a Spanish page reads its table.
526func TestTheEnglishVocabularyIsTheFallback(t *testing.T) {
527 nodetest.Node(t)
528 phrases := []string{"tomorrow", "next fri 9am", "in 2 weeks"}
529 in, err := json.Marshal(map[string]any{"phrases": phrases})
530 if err != nil {
531 t.Fatal(err)
532 }
533 out := nodetest.Run(t, nodetest.Cmd{Args: []string{"node/vocab_node.mjs"}, Stdin: in, Env: []string{"TZ=UTC"}})
534 var got struct {
535 Words map[string]string `json:"words"`
536 Parsed map[string]*string `json:"parsed"`
537 Malformed map[string]string `json:"malformed"`
538 }
539 if err := json.Unmarshal(out, &got); err != nil {
540 t.Fatalf("decode %q: %v", out, err)
541 }
542 en, ok := fiesta.Strings("en")
543 if !ok {
544 t.Fatal("i18n/en.json is not shipped")
545 }
546 var table struct {
547 Words map[string]string `json:"date-words"`
548 }
549 if err := json.Unmarshal(en, &table); err != nil {
550 t.Fatal(err)
551 }
552 if len(table.Words) == 0 {
553 t.Fatal("i18n/en.json has no date-words to hold the fallback to")
554 }
555 if !maps.Equal(got.Words, table.Words) {
556 t.Errorf("the English fallback vocabulary is not i18n/en.json's date-words:\n script: %v\n table: %v", got.Words, table.Words)
557 }
558 want := map[string]string{"tomorrow": "2026-10-29", "in 2 weeks": "2026-11-11"}
559 for _, phrase := range phrases {
560 read := got.Parsed[phrase]
561 if read == nil {
562 t.Errorf("%q read as nothing with no table and no attribute; the README promises it parses", phrase)
563 continue
564 }
565 at, err := time.Parse(time.RFC3339Nano, *read)
566 if err != nil {
567 t.Errorf("%q read as %q, not an instant: %v", phrase, *read, err)
568 continue
569 }
570 if day, ok := want[phrase]; ok && at.Format(time.DateOnly) != day {
571 t.Errorf("%q read as %s, want %s", phrase, at.Format(time.DateOnly), day)
572 }
573 if phrase == "next fri 9am" && (at.Weekday() != time.Friday || at.Hour() != 9) {
574 t.Errorf("%q read as %s, want a Friday at 09:00", phrase, at.Format(time.RFC3339))
575 }
576 }
577 // An attribute that is not JSON is as good as absent: on a Spanish
578 // page with the Spanish table loaded, the field reads the table, not
579 // English, so the order is attribute, then table, then English even
580 // when the attribute is broken.
581 es, ok := fiesta.Strings("es")
582 if !ok {
583 t.Fatal("i18n/es.json is not shipped")
584 }
585 var spanish struct {
586 Words map[string]string `json:"date-words"`
587 }
588 if err := json.Unmarshal(es, &spanish); err != nil {
589 t.Fatal(err)
590 }
591 if !maps.Equal(got.Malformed, spanish.Words) {
592 t.Errorf("a Spanish page with a malformed date-words attribute resolved to:\n %v\nwant the Spanish table:\n %v", got.Malformed, spanish.Words)
593 }
594}
595
596// A form reset and the commit a blur queues can land in one task in
597// any order, and an accepted reset must win every time: the reset has
598// blanked the box, and the commit would empty the field the reset
599// restored. node/reset_node.mjs lays out four orderings on a stub page
600// with queued timers and runs the timers as a browser would. The two
601// overlapping ones are the review's: blur, an accepted reset, then a
602// cancelled one, where the cancelled event once replaced the accepted
603// one's guard; and a cancelled reset, blur, then an accepted one, where
604// the first reset's cleanup once erased the second's. Both ended with
605// the field and the box empty. The single accepted reset is the
606// original case, and a single cancelled reset must still commit the
607// text the blur was committing.
608func TestAResetOutranksAPendingBlurCommitInEveryOrder(t *testing.T) {
609 nodetest.Node(t)
610 out := nodetest.Run(t, nodetest.Cmd{Args: []string{"node/reset_node.mjs"}, Env: []string{"TZ=UTC"}})
611 type state struct {
612 Native string `json:"native"`
613 Shown string `json:"shown"`
614 }
615 var got struct {
616 ShownLoad string `json:"shownLoad"`
617 Changed string `json:"changed"`
618 Single state `json:"single"`
619 Cancelled state `json:"cancelled"`
620 BlurAcceptedCancelled state `json:"blurAcceptedCancelled"`
621 CancelledBlurAccepted state `json:"cancelledBlurAccepted"`
622 }
623 if err := json.Unmarshal(out, &got); err != nil {
624 t.Fatalf("decode %q: %v", out, err)
625 }
626 if got.ShownLoad == "" || got.Changed != "2026-12-25" {
627 t.Fatalf("setup: the box showed %q on load and the field is %q after 25 Dec 2026 was committed; the harness did not drive the field", got.ShownLoad, got.Changed)
628 }
629 for name, s := range map[string]state{
630 "blur then an accepted reset": got.Single,
631 "blur, an accepted reset, then a cancelled reset": got.BlurAcceptedCancelled,
632 "a cancelled reset, blur, then an accepted reset": got.CancelledBlurAccepted,
633 } {
634 if s.Native != "2026-10-20" || s.Shown != got.ShownLoad {
635 t.Errorf("%s: the field is %q and the box shows %q; want the default 2026-10-20 and %q", name, s.Native, s.Shown, got.ShownLoad)
636 }
637 }
638 if got.Cancelled.Native != "2026-12-31" {
639 t.Errorf("text typed, blur, then a cancelled reset: the field is %q, want 2026-12-31, the text the blur was committing", got.Cancelled.Native)
640 }
641}
642