package check import ( "bytes" "encoding/json" "maps" "os" "path/filepath" "slices" "strings" "testing" "time" "amadan.net/rastrillo/fiesta" "amadan.net/rastrillo/fiesta/internal/nodetest" ) // Ported from rastrillo's ui/datetime_test.go at the seed commit. The // parser and the calendar's arithmetic run in Node // (node/datetime_node.mjs); the contracts read the scripts as text. // The vocabulary the browser reads off data-rst-date-words comes from // i18n/.json's date-words now, through fiesta.Strings, rather // than from rastrillo's catalogs. // shippedScript returns one script by its file name, failing the test // when the tree does not ship it. func shippedScript(t *testing.T, name string) []byte { t.Helper() js, ok := fiesta.Script(name) if !ok { t.Fatalf("%s is not shipped", name) } return js } // retagged reads testdata/datetime/retagged.json: the tags a page may // still carry (plain pt) and the shipped language each one's table is. // No pt.json ships; the alias lives here, in test data, only. func retagged(t *testing.T) map[string]string { t.Helper() b, err := os.ReadFile(filepath.Join("testdata", "datetime", "retagged.json")) if err != nil { t.Fatal(err) } var out map[string]string if err := json.Unmarshal(b, &out); err != nil { t.Fatalf("retagged.json: %v", err) } if len(out) == 0 { t.Fatal("retagged.json maps nothing; pt is the alias this suite expects") } return out } // dateWordsOf reads one shipped language's date-words, the seventeen // vocabulary names and nothing else, out of its table. func dateWordsOf(t *testing.T, lang string) map[string]string { t.Helper() b, ok := fiesta.Strings(lang) if !ok { t.Fatalf("fiesta.Strings(%q): not shipped", lang) } var table struct { Words map[string]string `json:"date-words"` } if err := json.Unmarshal(b, &table); err != nil { t.Fatalf("i18n/%s.json: %v", lang, err) } words := make(map[string]string, len(dateWords)) for _, name := range dateWords { value := table.Words[name] if value == "" { t.Fatalf("i18n/%s.json: date-words has no %s", lang, name) } words[name] = value } return words } // vocabStdin is what the browser reads off data-rst-date-words, built // the same way a page's table carries it: each shipped language's // date-words, the seventeen vocabulary keys, and nothing else. The // fixtures deliberately do not carry their own copy: words under test // have to be the words that ship, or a translator's improvement would // go green against a stale duplicate. // // Every shipped language goes over, not just the fixture's own: a case // may name its own language with a "lang" key, which is what // testdata/datetime/regressions.json is made of. The retagged aliases // go over too: a page whose lang is still pt gets pt-PT's words, on a // tag Intl writes as Brazil, and that pairing is one the parser has to // read as well. func vocabStdin(t *testing.T, locale string) []byte { t.Helper() catalogs := make(map[string]map[string]string) for _, lang := range fiesta.Languages() { catalogs[lang] = dateWordsOf(t, lang) } for from, to := range retagged(t) { words, ok := catalogs[to] if !ok { t.Fatalf("retagged.json maps %s to %s, which is not a shipped language", from, to) } catalogs[from] = words } if _, ok := catalogs[locale]; !ok { t.Fatalf("no table for %q", locale) } encoded, err := json.Marshal(map[string]any{"locale": locale, "catalogs": catalogs}) if err != nil { t.Fatal(err) } return encoded } // localeOf reads a fixture's default language off its filename. A file // not named after a shipped language is a cross-locale one, every case // in it names its own language, and English is its default. func localeOf(fixture string) string { name := strings.TrimSuffix(filepath.Base(fixture), ".json") if slices.Contains(fiesta.Languages(), name) { return name } return "en" } // datetimeFixtures lists the parser fixtures: every JSON file under // testdata/datetime except retagged.json, which is the alias table. func datetimeFixtures(t *testing.T) []string { t.Helper() all, err := filepath.Glob(filepath.Join("testdata", "datetime", "*.json")) if err != nil { t.Fatal(err) } var fixtures []string for _, f := range all { if filepath.Base(f) != "retagged.json" { fixtures = append(fixtures, f) } } if len(fixtures) == 0 { t.Fatal("no fixtures in testdata/datetime") } return fixtures } // The parser is the risk in datetime.js, and it is the half no Go test // can reach on its own: it reads month and weekday names out of Intl, // folds the digits a locale writes, and answers in wall-clock dates. // datetime_node.mjs runs it under plain Node against one fixture per // language. // // Node is not a build dependency, so its absence skips, naming the // test, and fails instead under FIESTA_TEST_REQUIRE_NODE (nodetest), // because a silent skip on a CI image without node would retire this // suite without anybody deciding to. func TestDatetimeParserFixtures(t *testing.T) { nodetest.Node(t) fixtures := datetimeFixtures(t) if _, err := os.Stat(filepath.Join("testdata", "datetime", "en.json")); err != nil { t.Fatalf("en.json is the reference fixture and must exist: %v", err) } for _, fixture := range fixtures { name := strings.TrimSuffix(filepath.Base(fixture), ".json") t.Run(name, func(t *testing.T) { out := nodetest.Run(t, nodetest.Cmd{ Args: []string{"node/datetime_node.mjs", fixture}, Stdin: vocabStdin(t, localeOf(fixture)), // The fixtures are wall-clock: a runner in a zone with a // summer-time jump inside a fixture's span would move an // expectation for reasons that have nothing to do with the // parser. UTC has no such jump. Env: []string{"TZ=UTC"}, }) t.Log(strings.TrimSpace(string(out))) }) } } // A field has to be able to read what it just wrote. Every fixture is a // phrase somebody chose to type, so no fixture could catch the opposite // failure: the combobox formatting a value with Intl and then refusing // its own formatting back. It was refusing it in five of the twelve // shipped languages (ja and zh-Hans write 年 and a bracketed weekday, // pt writes "de" linkers, ru writes a "г." suffix, yue writes a // dayPeriod (下晝) no table spells), which made an in-place edit // impossible in each of them and looked like nothing at all from here. // // So this drives datetime_node.mjs's --round-trip mode: format three // instants per kind with the display's own options, parse each back, // and compare. All twelve have to survive the trip. The first cut of // this gate allowed eleven, which meant a single-locale regression // exited 0 and left its only trace in a t.Log, a passing test nobody // reads. The harness now exits non-zero the moment one locale fails and // names the languages on the FAIL line, which is what lands in the // t.Fatalf below. func TestDatetimeReadsItsOwnDisplayBack(t *testing.T) { out := nodetest.Run(t, nodetest.Cmd{ Args: []string{"node/datetime_node.mjs", "--round-trip"}, Stdin: vocabStdin(t, "en"), Env: []string{"TZ=UTC"}, }) t.Log(strings.TrimSpace(string(out))) } // The calendar overlay draws six weeks of days, and the arithmetic // behind that is the half of it that can be quietly wrong: a grid that // drops a day in a zone that moved its clocks that morning, a week that // starts on the wrong day, column headings that do not describe their // own columns. None of that shows up in a screenshot (a calendar // missing 1 March looks exactly like a calendar) and none of it needs // a browser to catch, because js/calendar.js keeps that arithmetic in // front of its own document guard for precisely this reason. // // --calendar walks every shipped language over twenty-five months (both // of 2026 and 2027, plus a leap February) and holds each grid to its // invariants: forty-two cells, every one at local midnight, each one // day after the last, starting on that locale's own first day of the // week, with every day of the month present exactly once and the 1st in // the first row. Four undisputed week-start facts are pinned there too: // the derivation keeps no table, so the test carries the anchor. // // The DST case is not hypothetical: building the grid by adding // 86,400,000 milliseconds instead of counting whole days fails here in // every locale, on the two months a clock changes, and passes // everywhere else. TZ is deliberately NOT pinned to UTC, unlike the // fixture runs: a zone with no clock changes is the one zone that // cannot catch it, and the invariants hold in every zone by // construction. func TestCalendarGridHoldsInEveryLocale(t *testing.T) { out := nodetest.Run(t, nodetest.Cmd{ Args: []string{"node/datetime_node.mjs", "--calendar"}, Stdin: vocabStdin(t, "en"), }) t.Log(strings.TrimSpace(string(out))) } // calendar.js holds to the same contract as the other scripts: its own // vocabulary, a native-input-free widget built in the browser, a // document guard so Node can load its arithmetic, and no English month // or weekday name anywhere in it. func TestCalendarContract(t *testing.T) { js := string(shippedScript(t, "calendar.js")) for _, want := range []string{ // The seam datetime.js looks up at enhance time. If this name // moves, the button silently falls back to the browser's own // picker, which is the failure this whole overlay removed. "fiestaCalendar", // The roles a calendar has to carry to be usable without sight: // a named dialog, a grid, real column headers, the committed // day marked as selected and today marked as today. `"dialog"`, `"grid"`, `"columnheader"`, `"gridcell"`, "aria-selected", "aria-current", "aria-disabled", // The keys it has to answer to. "ArrowLeft", "ArrowRight", "PageUp", "PageDown", "Home", "End", "Escape", // The three strings it puts on screen all arrive translated. "data-rst-day", "tabIndex", } { if !strings.Contains(js, want) { t.Errorf("calendar.js does not mention %q", want) } } if !strings.Contains(js, `typeof document !== "undefined"`) { t.Error("calendar.js must keep its panel behind a document guard, or the Node harness cannot load it") } if !strings.Contains(js, "module.exports") { t.Error("calendar.js must export its arithmetic for node/datetime_node.mjs --calendar") } if strings.Contains(js, "eval(") || strings.Contains(js, "new Function") { t.Error("calendar.js must stay CSP-clean") } if strings.Contains(js, "\t") { t.Error("calendar.js uses two-space indentation, not tabs") } // Its own budget, set where datetime.js's was first set and for the // same reason: an app owner has to be able to read the whole file in // one sitting, and the next few hundred bytes should be a decision // rather than a drift. It lands at about 15KB, roughly half of it // the prose explaining the other half. if n := len(js); n > 24*1024 { t.Fatalf("calendar.js is %d bytes; split something out before it stops being readable", n) } } // The same rule datetime.js lives under, for the same reason: a month // or weekday name written down here would work in English and fail // silently in every other language shipped. calendar.js asks Intl, in // the page's own lang, for all of it: the names, the digits, and the // day a week starts on. func TestCalendarDerivesItsNames(t *testing.T) { body := string(shippedScript(t, "calendar.js")) body = body[strings.Index(body, "(function ("):] for _, name := range []string{ "january", "february", "december", "\"jan\"", "\"dec\"", "monday", "friday", "sunday", "\"mon\"", "\"fri\"", } { if strings.Contains(body, name) { t.Errorf("calendar.js spells %s itself; month and weekday names come from Intl", name) } } } // Every shipped language gets its own fixture, because the vocabulary // is the part of this parser that cannot be derived: a missing fixture // is a language nobody has checked can type a date. Over // fiesta.Languages(), so a language added to the tree with no fixture // beside it fails here. func TestEveryLanguageHasADatetimeFixture(t *testing.T) { langs := fiesta.Languages() if len(langs) == 0 { t.Fatal("fiesta.Languages() is empty") } for _, lang := range langs { if _, err := os.Stat(filepath.Join("testdata", "datetime", lang+".json")); err != nil { t.Errorf("no parser fixture for shipped language %q: %v", lang, err) } } } // datetime.js holds to the same contract as the shim and select.js: the // vocabulary the docs promise, the ARIA convention, an idempotent scan, // and a native input that survives enhancement because it is what the // form submits. func TestDatetimeContract(t *testing.T) { raw := shippedScript(t, "datetime.js") js := string(raw) for _, want := range []string{ "data-rst-date", "data-rst-time", "data-rst-date-words", "data-rst-date-set", "data-rst-date-hint", "data-rst-date-pick", "data-rst-date-results", "data-rst-date-result-one", "data-rst-date-quick-today", "data-rst-date-quick-tomorrow", "data-rst-date-quick-next-week", "data-rst-date-quick-plus-1h", "data-rst-date-quick-plus-2h", "data-rst-date-quick-end-of-day", "data-rst-date-quick-next-day", "data-rst-range", // The calendar overlay's own strings, and a time field's own // button label: the overlay builds its markup in the browser, // so every word it shows has to arrive on an attribute or in // the page's table. "data-rst-date-pick-time", "data-rst-date-calendar", "data-rst-date-prev-month", "data-rst-date-next-month", // The seam to js/calendar.js. If this name moves and the other // file is not moved with it, nothing breaks loudly: the button // quietly falls back to the browser's own picker, which is the // unusable control this whole overlay replaced. "fiestaCalendar", // The completion layer, which is what lets "5j" read as a date // at all, and parse, which stays strict underneath it. "parseAll", "function parse(", // The convention, pinned: an ARIA combobox that mirrors rather // than replaces, announced to assistive tech. "combobox", "aria-activedescendant", "aria-expanded", "aria-live", "rst-sr-only", // The month and weekday names are Intl's, never a table. "formatToParts", // showPicker survives as the fallback for a page that links // this file without calendar.js, and nowhere else. "showPicker", // {example} is substituted here, in the locale's own format; // the markup emits the hint template verbatim. "{example}", "{n}", } { if !strings.Contains(js, want) { t.Errorf("datetime.js does not mention %q", want) } } for _, bad := range []string{".remove()", "removeChild", "outerHTML ="} { if strings.Contains(js, bad) { t.Errorf("datetime.js destroys DOM (%q); the native input must survive", bad) } } if !strings.Contains(js, "rstEnhanced") { t.Error("datetime.js is not idempotent; re-scanning would double-enhance") } if !strings.Contains(js, `typeof document !== "undefined"`) { t.Error("datetime.js must keep its page half behind a document guard, or the Node harness cannot load it") } if !strings.Contains(js, "module.exports") { t.Error("datetime.js must export its parser for node/datetime_node.mjs") } if strings.Contains(js, "eval(") || strings.Contains(js, "new Function") { t.Error("datetime.js must stay CSP-clean") } if bytes.Contains(raw, []byte("\t")) { t.Error("datetime.js uses two-space indentation, not tabs") } // Bigger than the shim's and select.js's 8KB by design: this one // carries a parser as well as a combobox, and it lands within a few // lines of the implementation it was ported from. The cap is here // so the next few hundred bytes are a decision rather than a drift: // a file an app owner has to page through is a file they stop // owning. // // Raised twice, and the arithmetic is written down both times so // the next raise has to argue with real numbers. // // 48KB → 54KB, by the twelve-locale fixtures: writing a real date // in each of the eleven other languages bought four capabilities // this parser did not have. It folds the digits an Arabic or Hindi // keyboard makes (plain "ar" resolves to latn in ICU, so the // locale's default numbering system was not enough); it holds the // month name Intl only gives up when a day is in the format, which // is the form a Russian actually types; it reads a date written // with counter words rather than month names (12月25日); and it // tells a counted 月 from Monday. The honest split of that 6.6KB: // about 2.7KB of code and about 3.9KB of the prose explaining it. // An earlier wording here said "six kilobytes for eleven // languages", which read as six kilobytes of parser and was not. // // 54KB → 60KB, by the read-back wave: the parser now derives, from // the same Intl options the field displays with, the literals that // display threads between the numbers and the dayPeriod names it // picks, so it can read its own writing in all twelve languages // instead of five, and "25 de marzo" parses because "de" is a // literal Intl already told us about. Split again: about 2.7KB of // code, about 3.2KB of comment. More than half of both raises is // prose, which is the trade this file makes on purpose: the cap is // on what an owner has to page through, not on what runs. // // 60KB → 76KB, by the calendar overlay, and this raise came with a // SPLIT in front of it. The button used to call native.showPicker(), // which is one line and close to useless in a form: the panel // belongs to the browser, cannot be styled to match the page, does // not exist at all for a time input on some engines, and (measured, // not guessed) opens WITHOUT moving focus, so the guard on the // change listener here swallowed the pick and the field looked like // it had ignored the selection. Replacing it took a real widget, so // the widget went to calendar.js: 22KB that would otherwise have // landed on this number, in a file that needs none of the parser. // // What stayed is +15.1KB, an honest 7.3KB of code and 7.8KB of // prose, and none of it draws a calendar. The popup became three // states rather than one (the suggestions, the grid, and a time // field's half hours) so there is still only one thing to close and // one thing the arrow keys drive; the words drive the grid live // while it is open; min/max became bounds a range's end can tighten; // the panel flips above the field where there is no room below it; // and parseAll arrived, which offers a reading where the exact // grammar had none: "5j" is not a date, and 5 January, 5 June and // 5 July are three. // // The cap is on what an owner has to page through. Two files at 74KB // and 22KB is that; one file at 96KB is not, which is the whole // reason the split came first and the raise second. // // 76KB → 78KB, by the string tables. The file sat 45 bytes under // the cap, so anything at all raised it. What arrived is +1.7KB // net (+4.4KB written, 2.75KB removed): fiestaPickLang and // fiestaWord, the lookup that reads a label off the attribute, // then the page's table, then the fallback, about 1.0KB of code // once the old attr helper and its seventeen calls are netted off, // with the exports and the date-words branch that takes the // table's object or the attribute's JSON; and about 0.7KB of prose, // the header's strings paragraph rewritten for the new order, the // seven quick-pick attributes written out where "quick-*" stood, // and the section comment. 78KB is the next whole step that holds // it, with 0.4KB to spare. The copy in select.js is the same bytes; // see TestTheTwoLookupsAreTheSame. // // 78KB → 82KB, in two steps the release review asked for. First to // 82,376 bytes (+2,910 on the 79,466 the string tables left): the // English vocabulary, ENGLISH_WORDS and vocabulary(), so the three // phrases the README advertises parse with no table loaded (about // 0.9KB, half of it the words themselves); the native state, locked() // and follow(), so a readonly field refuses every way of changing it // and a disabled one is disabled throughout (about 1.0KB with its // guards); and the form-reset repaint (about 0.6KB, most of it the // comment saying why it waits a task). Then to 83,347 (+971) when the // re-review found the vocabulary assigned after the first scan, so a // deferred script captured undefined: the block moved ahead of the // page half with a comment saying why, a malformed attribute now // falls to the table before English (NO_ATTRIBUTE and the catch), // and a reset that lands in the tick before a blur's commit wins // (lastReset). 81KB (82,944) did not hold it; 82KB (83,968) did, // with 621 to spare. // // 82KB → 83KB (measured 84,708 bytes, +1,361) when the third review // found two orderings lastReset still lost (an accepted reset // followed by a cancelled one, and the reverse around the blur). // The accounting is now per form at the top of the page half: a // pending count every reset raises and its timer lowers, an // accepted count only an uncancelled reset's timer raises and // nothing lowers, and a blur commit that waits out pending resets // and runs only if the accepted count is what it was when queued. // About 0.9KB is the comment saying why; lastReset's four lines // went. 82KB (83,968) does not hold it; 83KB (84,992) does, with // 284 to spare. if n := len(raw); n > 83*1024 { t.Fatalf("datetime.js is %d bytes; split something out before it stops being readable", n) } } // The half a machine CAN derive is derived. Month and weekday names // never appear in this file: they come out of Intl in the page's own // language, so a field enhanced in Hindi reads Hindi month names with // nothing added here. A spelling written down would work in English and // silently fail everywhere else, the exact half-failure the split // exists to prevent. (The vocabulary keys themselves, "tomorrow", // "next", "ago", do appear: they are the attribute's field names, and // their VALUES are what arrives translated.) func TestDatetimeDerivesItsCalendarNames(t *testing.T) { body := string(shippedScript(t, "datetime.js")) body = body[strings.Index(body, "(function () {"):] for _, name := range []string{ "january", "february", "december", "\"jan\"", "\"dec\"", "monday", "friday", "sunday", "\"mon\"", "\"fri\"", } { if strings.Contains(body, name) { t.Errorf("datetime.js spells %s itself; month and weekday names come from Intl", name) } } } // The README promises an English page three phrases with no table // loaded and no attribute: "tomorrow", "next fri 9am" and "in 2 weeks". // The labels always had an English fallback written into the script; // the vocabulary fell back to nothing, so all three read as nothing. // node/vocab_node.mjs resolves a bare field's vocabulary on a lang="en" // page with no self.fiestaStrings and parses each phrase with it. The // words must be i18n/en.json's date-words exactly, so a word improved // in the table is improved in the script, and every phrase must read: // tomorrow is the 29th, two weeks on is 11 November, and next Friday // at nine is a Friday at 09:00. The driver's second scenario holds the // order when the attribute is broken: a Spanish page reads its table. func TestTheEnglishVocabularyIsTheFallback(t *testing.T) { nodetest.Node(t) phrases := []string{"tomorrow", "next fri 9am", "in 2 weeks"} in, err := json.Marshal(map[string]any{"phrases": phrases}) if err != nil { t.Fatal(err) } out := nodetest.Run(t, nodetest.Cmd{Args: []string{"node/vocab_node.mjs"}, Stdin: in, Env: []string{"TZ=UTC"}}) var got struct { Words map[string]string `json:"words"` Parsed map[string]*string `json:"parsed"` Malformed map[string]string `json:"malformed"` } if err := json.Unmarshal(out, &got); err != nil { t.Fatalf("decode %q: %v", out, err) } en, ok := fiesta.Strings("en") if !ok { t.Fatal("i18n/en.json is not shipped") } var table struct { Words map[string]string `json:"date-words"` } if err := json.Unmarshal(en, &table); err != nil { t.Fatal(err) } if len(table.Words) == 0 { t.Fatal("i18n/en.json has no date-words to hold the fallback to") } if !maps.Equal(got.Words, table.Words) { t.Errorf("the English fallback vocabulary is not i18n/en.json's date-words:\n script: %v\n table: %v", got.Words, table.Words) } want := map[string]string{"tomorrow": "2026-10-29", "in 2 weeks": "2026-11-11"} for _, phrase := range phrases { read := got.Parsed[phrase] if read == nil { t.Errorf("%q read as nothing with no table and no attribute; the README promises it parses", phrase) continue } at, err := time.Parse(time.RFC3339Nano, *read) if err != nil { t.Errorf("%q read as %q, not an instant: %v", phrase, *read, err) continue } if day, ok := want[phrase]; ok && at.Format(time.DateOnly) != day { t.Errorf("%q read as %s, want %s", phrase, at.Format(time.DateOnly), day) } if phrase == "next fri 9am" && (at.Weekday() != time.Friday || at.Hour() != 9) { t.Errorf("%q read as %s, want a Friday at 09:00", phrase, at.Format(time.RFC3339)) } } // An attribute that is not JSON is as good as absent: on a Spanish // page with the Spanish table loaded, the field reads the table, not // English, so the order is attribute, then table, then English even // when the attribute is broken. es, ok := fiesta.Strings("es") if !ok { t.Fatal("i18n/es.json is not shipped") } var spanish struct { Words map[string]string `json:"date-words"` } if err := json.Unmarshal(es, &spanish); err != nil { t.Fatal(err) } if !maps.Equal(got.Malformed, spanish.Words) { 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) } } // A form reset and the commit a blur queues can land in one task in // any order, and an accepted reset must win every time: the reset has // blanked the box, and the commit would empty the field the reset // restored. node/reset_node.mjs lays out four orderings on a stub page // with queued timers and runs the timers as a browser would. The two // overlapping ones are the review's: blur, an accepted reset, then a // cancelled one, where the cancelled event once replaced the accepted // one's guard; and a cancelled reset, blur, then an accepted one, where // the first reset's cleanup once erased the second's. Both ended with // the field and the box empty. The single accepted reset is the // original case, and a single cancelled reset must still commit the // text the blur was committing. func TestAResetOutranksAPendingBlurCommitInEveryOrder(t *testing.T) { nodetest.Node(t) out := nodetest.Run(t, nodetest.Cmd{Args: []string{"node/reset_node.mjs"}, Env: []string{"TZ=UTC"}}) type state struct { Native string `json:"native"` Shown string `json:"shown"` } var got struct { ShownLoad string `json:"shownLoad"` Changed string `json:"changed"` Single state `json:"single"` Cancelled state `json:"cancelled"` BlurAcceptedCancelled state `json:"blurAcceptedCancelled"` CancelledBlurAccepted state `json:"cancelledBlurAccepted"` } if err := json.Unmarshal(out, &got); err != nil { t.Fatalf("decode %q: %v", out, err) } if got.ShownLoad == "" || got.Changed != "2026-12-25" { 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) } for name, s := range map[string]state{ "blur then an accepted reset": got.Single, "blur, an accepted reset, then a cancelled reset": got.BlurAcceptedCancelled, "a cancelled reset, blur, then an accepted reset": got.CancelledBlurAccepted, } { if s.Native != "2026-10-20" || s.Shown != got.ShownLoad { 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) } } if got.Cancelled.Native != "2026-12-31" { 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) } }