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 "encoding/json"
5 "strings"
6 "testing"
7
8 "amadan.net/rastrillo/fiesta/internal/nodetest"
9)
10
11// A country picker is where select.js's ranking was learned (Tito Go's
12// phone field), so it is what these tests search: each row written the
13// way a phone field writes it — "Name (+code)", the ISO code and the
14// calling code as data-rst-terms, data-rst-first on the country that owns
15// a shared code. The list is curated, not complete: every country an
16// assertion names is here, and so is every country that competes with it
17// for the query (Guinea-Bissau for "guin", Oman for "man", the four +44s).
18type countryRow struct{ iso, en, de, code string }
19
20// primary marks the country that owns a calling code others share.
21var primary = map[string]bool{"GB": true, "US": true, "RU": true, "RE": true}
22
23var countries = []countryRow{
24 {"DZ", "Algeria", "Algerien", "213"},
25 {"AS", "American Samoa", "Amerikanisch-Samoa", "1684"},
26 {"AT", "Austria", "Österreich", "43"},
27 {"KH", "Cambodia", "Kambodscha", "855"},
28 {"CM", "Cameroon", "Kamerun", "237"},
29 {"CA", "Canada", "Kanada", "1"},
30 {"CI", "Côte d’Ivoire", "Côte d’Ivoire", "225"},
31 {"DK", "Denmark", "Dänemark", "45"},
32 {"GQ", "Equatorial Guinea", "Äquatorialguinea", "240"},
33 {"GE", "Georgia", "Georgien", "995"},
34 {"DE", "Germany", "Deutschland", "49"},
35 {"GG", "Guernsey", "Guernsey", "44"},
36 {"GN", "Guinea", "Guinea", "224"},
37 {"GW", "Guinea-Bissau", "Guinea-Bissau", "245"},
38 {"IN", "India", "Indien", "91"},
39 {"ID", "Indonesia", "Indonesien", "62"},
40 {"IR", "Iran", "Iran", "98"},
41 {"IE", "Ireland", "Irland", "353"},
42 {"IM", "Isle of Man", "Isle of Man", "44"},
43 {"IL", "Israel", "Israel", "972"},
44 {"JM", "Jamaica", "Jamaika", "1876"},
45 {"JP", "Japan", "Japan", "81"},
46 {"JE", "Jersey", "Jersey", "44"},
47 {"JO", "Jordan", "Jordanien", "962"},
48 {"KZ", "Kazakhstan", "Kasachstan", "7"},
49 {"YT", "Mayotte", "Mayotte", "262"},
50 {"NE", "Niger", "Niger", "227"},
51 {"NG", "Nigeria", "Nigeria", "234"},
52 {"OM", "Oman", "Oman", "968"},
53 {"PG", "Papua New Guinea", "Papua-Neuguinea", "675"},
54 {"RE", "Réunion", "Réunion", "262"},
55 {"RU", "Russia", "Russland", "7"},
56 {"SS", "South Sudan", "Südsudan", "211"},
57 {"SD", "Sudan", "Sudan", "249"},
58 {"AE", "United Arab Emirates", "Vereinigte Arabische Emirate", "971"},
59 {"GB", "United Kingdom", "Vereinigtes Königreich", "44"},
60 {"US", "United States", "Vereinigte Staaten", "1"},
61}
62
63type rankOption struct {
64 Value string `json:"value"`
65 Name string `json:"name"`
66 Text string `json:"text"`
67 Terms []string `json:"terms"`
68 First bool `json:"first"`
69}
70
71func countryOptions(lang string) []rankOption {
72 var out []rankOption
73 for _, c := range countries {
74 name := c.en
75 if lang == "de" {
76 name = c.de
77 }
78 text := name + " (+" + c.code + ")"
79 out = append(out, rankOption{Value: c.iso, Name: text, Text: text, Terms: []string{c.iso, "+" + c.code}, First: primary[c.iso]})
80 }
81 return out
82}
83
84func runRank(t *testing.T, lang string, queries []string) (map[string][]string, map[string]string) {
85 t.Helper()
86 in, err := json.Marshal(map[string]any{"options": countryOptions(lang), "queries": queries})
87 if err != nil {
88 t.Fatal(err)
89 }
90 var got struct {
91 Order map[string][]string `json:"order"`
92 Settled map[string]string `json:"settled"`
93 }
94 if err := json.Unmarshal(nodetest.Run(t, nodetest.Cmd{Args: []string{"node/select_node.mjs"}, Stdin: in}), &got); err != nil {
95 t.Fatal(err)
96 }
97 return got.Order, got.Settled
98}
99
100// Leaving the box after typing (Tab, or a tap on the next field) commits
101// the highlighted row only when the search can mean one: a single match,
102// an exact hit on three characters, or a calling code its country owns.
103// Two letters never do: "ge" is Georgia's code and the start of Germany,
104// and a wrong calling code under a phone number is worse than an unpicked
105// box the form then asks about.
106func TestLeavingTheBoxCommitsOnlyWhatASearchCanMean(t *testing.T) {
107 t.Parallel()
108 _, settled := runRank(t, "en", []string{"irel", "germ", "india", "ger", "ire", "+44", "44", "+1", "1", "+7", "+971", "ge", "ir", "ind", "zzzz"})
109 for q, want := range map[string]string{
110 "irel": "IE", "germ": "DE", "india": "IN", "ger": "DE", "ire": "IE",
111 "+44": "GB", "44": "GB", "+1": "US", "1": "US", "+7": "RU", "+971": "AE",
112 "ge": "", "ir": "", "ind": "", "zzzz": "",
113 } {
114 if got := settled[q]; got != want {
115 t.Errorf("leaving after %q commits %q, want %q", q, got, want)
116 }
117 }
118}
119
120// Two rows that are both an exact hit settle nothing: the brackets are
121// glosses, so "Springfield" names "Springfield (IL)" and
122// "Springfield (MA)" equally, and leaving the box must not pick one.
123func TestLeavingTheBoxNeverPicksBetweenTwoExactHits(t *testing.T) {
124 t.Parallel()
125 opts := []rankOption{
126 {Value: "IL", Name: "Springfield (IL)", Text: "Springfield (IL)"},
127 {Value: "MA", Name: "Springfield (MA)", Text: "Springfield (MA)"},
128 {Value: "SH", Name: "Shelbyville", Text: "Shelbyville"},
129 {Value: "PA", Name: "Portland (OR)", Text: "Portland (OR)", First: true},
130 {Value: "PM", Name: "Portland (ME)", Text: "Portland (ME)"},
131 }
132 in, err := json.Marshal(map[string]any{"options": opts, "queries": []string{"springfield", "shelbyville", "portland"}})
133 if err != nil {
134 t.Fatal(err)
135 }
136 var got struct {
137 Settled map[string]string `json:"settled"`
138 }
139 if err := json.Unmarshal(nodetest.Run(t, nodetest.Cmd{Args: []string{"node/select_node.mjs"}, Stdin: in}), &got); err != nil {
140 t.Fatal(err)
141 }
142 if got.Settled["springfield"] != "" {
143 t.Errorf("leaving after \"springfield\" picked %q between two exact hits, want nothing", got.Settled["springfield"])
144 }
145 // Exactly one of the exact ties marked first is the single best row,
146 // the rule a shared calling code settles by.
147 if got.Settled["portland"] != "PA" {
148 t.Errorf("leaving after \"portland\" picked %q, want PA: one exact tie is marked first", got.Settled["portland"])
149 }
150 if got.Settled["shelbyville"] != "SH" {
151 t.Errorf("leaving after \"shelbyville\" picked %q, want SH: a lone exact hit still settles", got.Settled["shelbyville"])
152 }
153}
154
155// Someone types a country's name in their own language, its ISO code, or
156// the calling code with or without its "+", and the country they meant is
157// the first row — the one Enter takes. Substring matching alone got most
158// of these wrong: "ae" is inside "Israel", and "+1" is inside a dozen
159// codes, alphabetically led by American Samoa.
160func TestSearchRanksTheRightRowFirst(t *testing.T) {
161 t.Parallel()
162 en, _ := runRank(t, "en", []string{"c", "j", "44", "262", "+971", "971", "ae", "de", "gb", "united k", "man", "sudan", "suda", "guin", "+44", "+1", "cote", "cote d'ivoire", "ireland", "zzzz"})
163 for q, want := range map[string]string{
164 "c": "KH", // one letter reads alphabetically
165 "j": "JM", // owning a calling code only breaks a tie on the code
166 "44": "GB", // a code typed whole without its "+" is still exact
167 "262": "RE",
168 "+971": "AE", // the calling code, as read off a number
169 "971": "AE", // ...and without the plus
170 "ae": "AE", // an ISO code beats a name that merely contains the letters
171 "de": "DE", // ...and one that starts with them (Denmark)
172 "gb": "GB",
173 "united k": "GB", // the start of a name
174 "man": "IM", // the start of a WORD beats the middle of one (Oman, Germany)
175 "sudan": "SD", // the start of the name beats a later word (South Sudan)
176 "suda": "SD",
177 "guin": "GN", // Guinea before Equatorial Guinea and Papua New Guinea
178 "+44": "GB", // a shared code opens on the country that owns it
179 "+1": "US",
180 "cote": "CI", // accents fold away: Côte d’Ivoire
181 "cote d'ivoire": "CI", // ...and a typed apostrophe is its curly one
182 "ireland": "IE",
183 } {
184 if len(en[q]) == 0 || en[q][0] != want {
185 head := en[q]
186 if len(head) > 4 {
187 head = head[:4]
188 }
189 t.Errorf("en %q: first rows %v, want %s first", q, head, want)
190 }
191 }
192 if len(en["zzzz"]) != 0 {
193 t.Errorf("a query nothing matches found %v", en["zzzz"])
194 }
195 // The name someone reads is in their language, so that is what they type.
196 de, _ := runRank(t, "de", []string{"osterreich", "vereinigte"})
197 if len(de["osterreich"]) == 0 || de["osterreich"][0] != "AT" {
198 t.Errorf("de \"osterreich\": got %v, want AT first", de["osterreich"])
199 }
200 if len(de["vereinigte"]) != 3 {
201 t.Errorf("de \"vereinigte\": got %v, want the three Vereinigte", de["vereinigte"])
202 }
203}
204
205// The blank of a REQUIRED select ("Choose one") is a prompt, not an
206// answer: the box shows nothing for it, so its placeholder is what is
207// read, and the list never offers it — choosing it would only leave the
208// question unanswered. A marked prompt ("Country") drops out once
209// something is typed. An optional select's blank ("No answer") is a real
210// answer everywhere, and a disabled blank never is.
211func TestRequiredBlankIsAPrompt(t *testing.T) {
212 t.Parallel()
213 type blank struct {
214 Name string `json:"-"`
215 Value string `json:"value"`
216 Marked bool `json:"marked"`
217 Required bool `json:"required"`
218 Searching bool `json:"searching"`
219 Disabled bool `json:"disabled"`
220 Prompt bool `json:"-"`
221 Offered bool `json:"-"`
222 }
223 cases := []blank{
224 {Name: "required blank, idle", Required: true, Prompt: true, Offered: false},
225 {Name: "required blank, searching", Required: true, Searching: true, Prompt: true, Offered: false},
226 {Name: "marked prompt, idle", Marked: true, Prompt: true, Offered: true},
227 {Name: "marked prompt, searching", Marked: true, Searching: true, Prompt: true, Offered: false},
228 {Name: "marked required prompt, idle", Marked: true, Required: true, Prompt: true, Offered: true},
229 {Name: "marked required prompt, searching", Marked: true, Required: true, Searching: true, Prompt: true, Offered: false},
230 {Name: "optional blank, idle", Prompt: false, Offered: true},
231 {Name: "optional blank, searching", Searching: true, Prompt: false, Offered: true},
232 // A page that renders "Choose one" disabled means it, and a select
233 // that then stops being required must not bring it back as a pick.
234 {Name: "disabled blank on a select no longer required", Disabled: true, Prompt: true, Offered: false},
235 {Name: "a real answer on a required select", Value: "AE", Required: true, Searching: true, Prompt: false, Offered: true},
236 }
237 in, err := json.Marshal(map[string]any{"blanks": cases})
238 if err != nil {
239 t.Fatal(err)
240 }
241 var got []struct {
242 Prompt bool `json:"prompt"`
243 Offered bool `json:"offered"`
244 }
245 if err := json.Unmarshal(nodetest.Run(t, nodetest.Cmd{Args: []string{"node/select_node.mjs"}, Stdin: in}), &got); err != nil {
246 t.Fatal(err)
247 }
248 if len(got) != len(cases) {
249 t.Fatalf("got %d answers for %d cases", len(got), len(cases))
250 }
251 for i, c := range cases {
252 if got[i].Prompt != c.Prompt {
253 t.Errorf("%s: prompt = %v, want %v (the box would show it as the pick)", c.Name, got[i].Prompt, c.Prompt)
254 }
255 if got[i].Offered != c.Offered {
256 t.Errorf("%s: offered = %v, want %v", c.Name, got[i].Offered, c.Offered)
257 }
258 }
259}
260
261// The rules above are only true of a screen if the combobox calls them.
262// A source check, because the wiring is one line each; the browser drives
263// in select_browser_test.go hold the behaviour.
264func TestSelectUsesItsPromptRules(t *testing.T) {
265 t.Parallel()
266 src := string(shippedScript(t, "select.js"))
267 for _, want := range []string{
268 "prompt: isPrompt(o.value, d.rstPrompt !== undefined, native.required, offLimits(o)),",
269 "offered(o.el.value, o.marked, native.required, searching, o.disabled)",
270 // Up from nothing highlighted goes to the last row.
271 "setActive((active ? v[Math.max(v.indexOf(active) - 1, 0)] : v[v.length - 1]) || null);",
272 // An unanswered question opens with nothing highlighted, so a bare
273 // Enter cannot answer it with the first row...
274 "if (unanswered()) return null;",
275 // ...opening is where an idle list lands, from openList...
276 "markSelected();\n setActive(opening());",
277 // ...and from a cleared search.
278 "if (!searching && !steered && (requeried || unanswered())) setActive(opening());",
279 // A fallback highlight is never a prompt or a page's extra row.
280 "visible().find((o) => !o.run && !o.prompt)",
281 // A highlight that is no longer on the list is replaced.
282 "!shown.includes(active)",
283 // With the list open, Enter never submits the form.
284 "if (open) {\n e.preventDefault();\n tookEnter = true;\n if (active) choose(active);",
285 // A select that stops being required re-derives its prompts and
286 // re-marks the selection.
287 "if (!o.run) o.prompt = isPrompt(o.el.value, o.marked, native.required, offLimits(o.el));\n }\n markSelected();",
288 } {
289 if !strings.Contains(src, want) {
290 t.Errorf("select.js no longer calls %q", want)
291 }
292 }
293}
294