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 "os"
6 "path/filepath"
7 "slices"
8 "sort"
9 "strings"
10 "testing"
11
12 "amadan.net/rastrillo/fiesta"
13 "amadan.net/rastrillo/fiesta/internal/nodetest"
14)
15
16// The interface strings datetime.js, select.js and copy.js put on
17// screen, one flat JSON object per shipped language under i18n/, seeded from
18// rastrillo's catalogs at the seed commit and read through fiesta.Strings. The
19// scripts look a string up in three places, in this order: the
20// element's own data-rst- attribute, then the page's table
21// (self.fiestaStrings, filled by i18n/<lang>.js, picked by the
22// document's lang), then the English fallback written in the script.
23// These tests hold the tables' shape and the lookup, in Node
24// (node/strings_node.mjs) and in a browser (strings_browser_test.go).
25
26// languageKeys are the twenty-two string keys, each an attribute's
27// name with data-rst- removed.
28var languageKeys = []string{
29 "date-set", "date-hint", "date-pick", "date-pick-time", "date-calendar",
30 "date-prev-month", "date-next-month", "date-results", "date-result-one",
31 "date-quick-today", "date-quick-tomorrow", "date-quick-next-week",
32 "date-quick-plus-1h", "date-quick-plus-2h", "date-quick-end-of-day", "date-quick-next-day",
33 "select-filter", "select-results", "select-result-one", "select-no-matches",
34 "copy-done", "copy-failed",
35}
36
37// dateWords are the seventeen names under date-words.
38var dateWords = []string{
39 "today", "tomorrow", "yesterday", "next", "last", "in", "ago", "at",
40 "day", "week", "month", "hour", "minute", "noon", "midnight", "am", "pm",
41}
42
43// languageFileProblems reads one language file and lists what is
44// wrong with its shape: a missing or extra key, a missing or extra
45// word, an empty value, or a hint with no {example}.
46func languageFileProblems(data []byte) []string {
47 var obj map[string]json.RawMessage
48 if err := json.Unmarshal(data, &obj); err != nil {
49 return []string{"not a JSON object: " + err.Error()}
50 }
51 var problems []string
52 want := append([]string{"date-words"}, languageKeys...)
53 var got []string
54 for k := range obj {
55 got = append(got, k)
56 }
57 sort.Strings(got)
58 for _, k := range want {
59 if _, ok := obj[k]; !ok {
60 problems = append(problems, "missing key "+k)
61 }
62 }
63 for _, k := range got {
64 if !slices.Contains(want, k) {
65 problems = append(problems, "unexpected key "+k)
66 }
67 }
68 for _, k := range languageKeys {
69 raw, ok := obj[k]
70 if !ok {
71 continue
72 }
73 var s string
74 if err := json.Unmarshal(raw, &s); err != nil {
75 problems = append(problems, k+" is not a string")
76 } else if s == "" {
77 problems = append(problems, k+" is empty")
78 }
79 }
80 if raw, ok := obj["date-hint"]; ok {
81 var s string
82 if json.Unmarshal(raw, &s) == nil && s != "" && !strings.Contains(s, "{example}") {
83 problems = append(problems, "date-hint has no {example}")
84 }
85 }
86 if raw, ok := obj["date-words"]; ok {
87 var words map[string]string
88 if err := json.Unmarshal(raw, &words); err != nil {
89 return append(problems, "date-words is not an object of strings: "+err.Error())
90 }
91 for _, w := range dateWords {
92 v, ok := words[w]
93 if !ok {
94 problems = append(problems, "date-words is missing "+w)
95 } else if v == "" {
96 problems = append(problems, "date-words."+w+" is empty")
97 }
98 }
99 for w := range words {
100 if !slices.Contains(dateWords, w) {
101 problems = append(problems, "date-words has an unexpected "+w)
102 }
103 }
104 }
105 sort.Strings(problems)
106 return problems
107}
108
109// TestEveryLanguageHasEveryKey: twelve languages, each read through
110// fiesta.Strings, with exactly twenty-three keys, exactly seventeen
111// date-words names, no empty value, and {example} in date-hint. The
112// precondition is not empty: fiesta.Languages() is the twelve tags
113// with en first, i18n/ on disk holds exactly those files, and the
114// alias pt is not one of them. The first leg is the check's own
115// control: a file with one key dropped, one word empty, a hint with
116// no placeholder and an extra key must be caught, or the pass below
117// means nothing.
118func TestEveryLanguageHasEveryKey(t *testing.T) {
119 t.Run("control", func(t *testing.T) {
120 bad := map[string]any{"date-words": map[string]string{}}
121 for _, w := range dateWords {
122 bad["date-words"].(map[string]string)[w] = w
123 }
124 bad["date-words"].(map[string]string)["noon"] = ""
125 for _, k := range languageKeys[1:] {
126 bad[k] = k
127 }
128 bad["date-hint"] = "Try:"
129 bad["extra"] = "x"
130 data, _ := json.Marshal(bad)
131 got := languageFileProblems(data)
132 want := []string{"date-hint has no {example}", "date-words.noon is empty", "missing key date-set", "unexpected key extra"}
133 if !slices.Equal(got, want) {
134 t.Fatalf("the control file's problems are %v, want %v", got, want)
135 }
136 })
137
138 languages := fiesta.Languages()
139 if want := []string{"en", "ar", "bn", "es", "ga", "hi", "ja", "pt-PT", "ru", "vi", "yue", "zh-Hans"}; !slices.Equal(languages, want) {
140 t.Fatalf("fiesta.Languages() = %q, want %q", languages, want)
141 }
142 entries, err := os.ReadDir(filepath.Join("..", "i18n"))
143 if err != nil {
144 t.Fatalf("reading i18n/: %v", err)
145 }
146 var files, want []string
147 for _, e := range entries {
148 if filepath.Ext(e.Name()) == ".json" {
149 files = append(files, e.Name())
150 }
151 }
152 for _, l := range languages {
153 want = append(want, l+".json")
154 }
155 sort.Strings(files)
156 sort.Strings(want)
157 if !slices.Equal(files, want) {
158 t.Errorf("i18n/ holds %v, want %v", files, want)
159 }
160 if _, ok := fiesta.Strings("pt"); ok {
161 t.Error("fiesta.Strings(\"pt\") answers; pt is a test alias for pt-PT, not a shipped file")
162 }
163 for _, l := range languages {
164 data, ok := fiesta.Strings(l)
165 if !ok {
166 t.Errorf("fiesta.Strings(%q): not shipped", l)
167 continue
168 }
169 if len(data) == 0 {
170 t.Errorf("fiesta.Strings(%q) is empty", l)
171 continue
172 }
173 for _, p := range languageFileProblems(data) {
174 t.Errorf("i18n/%s.json: %s", l, p)
175 }
176 }
177}
178
179// lookupScripts are the two scripts that carry the string lookup, each
180// its own copy: every enhancement is one script tag, and a shared
181// file would be a third tag every page had to remember.
182var lookupScripts = []string{"datetime.js", "select.js"}
183
184// stringsReport is what node/strings_node.mjs reports for one script:
185// fiestaPickLang's answer for each tag asked (null for none), and
186// fiestaWord's answer in each scenario.
187type stringsReport struct {
188 Pick map[string]*string
189 Word map[string]string
190}
191
192// stringsNode runs node/strings_node.mjs against one script, with the
193// registry's tags and the tags to resolve on stdin.
194func stringsNode(t *testing.T, script string, registry []string, tags []string) stringsReport {
195 t.Helper()
196 in, err := json.Marshal(map[string]any{"registry": registry, "tags": tags})
197 if err != nil {
198 t.Fatal(err)
199 }
200 var out stringsReport
201 if err := json.Unmarshal(nodetest.Run(t, nodetest.Cmd{Args: []string{"node/strings_node.mjs", script}, Stdin: in}), &out); err != nil {
202 t.Fatal(err)
203 }
204 return out
205}
206
207// TestPickLangResolvesNearMisses: with tables for en, pt-PT and
208// zh-Hans, a page's lang finds its table by exact match whatever its
209// case, then by primary language, and a language with no table is
210// null rather than English. Both copies of the lookup are asked.
211func TestPickLangResolvesNearMisses(t *testing.T) {
212 registry := []string{"en", "pt-PT", "zh-Hans"}
213 want := map[string]*string{
214 "pt-PT": ptr("pt-PT"),
215 "PT-pt": ptr("pt-PT"),
216 "pt-BR": ptr("pt-PT"),
217 "zh-Hant": ptr("zh-Hans"),
218 "EN": ptr("en"),
219 "de": nil,
220 "": nil,
221 "<undefined>": nil,
222 }
223 var tags []string
224 for tag := range want {
225 if tag != "<undefined>" {
226 tags = append(tags, tag)
227 }
228 }
229 sort.Strings(tags)
230 for _, script := range lookupScripts {
231 t.Run(script, func(t *testing.T) {
232 got := stringsNode(t, script, registry, tags).Pick
233 if len(got) != len(want) {
234 t.Fatalf("the driver resolved %d tags, want %d: %v", len(got), len(want), got)
235 }
236 for tag, w := range want {
237 g, ok := got[tag]
238 if !ok {
239 t.Errorf("fiestaPickLang(%q) was not asked", tag)
240 continue
241 }
242 if str(g) != str(w) {
243 t.Errorf("fiestaPickLang(%q) = %s, want %s", tag, str(g), str(w))
244 }
245 }
246 })
247 }
248}
249
250func ptr(s string) *string { return &s }
251
252func str(p *string) string {
253 if p == nil {
254 return "null"
255 }
256 return `"` + *p + `"`
257}
258
259// TestTheTableFillsAMissingAttribute: fiestaWord reads the attribute
260// first, the page's table second, and the fallback last. On a stub
261// element in a lang="es" document whose table says "Tabla": with the
262// attribute "Atributo" and the table both present, the attribute;
263// with the attribute empty, the table; with only the table, the
264// table; with neither, the English fallback; and on a lang="de"
265// document with only that es table, no attribute, the fallback too,
266// since a language with no table is not read in another one. The
267// driver builds its own tables for these, so no registry goes over.
268func TestTheTableFillsAMissingAttribute(t *testing.T) {
269 want := map[string]string{"both": "Atributo", "empty": "Tabla", "table": "Tabla", "neither": "English", "notable": "English"}
270 for _, script := range lookupScripts {
271 t.Run(script, func(t *testing.T) {
272 got := stringsNode(t, script, nil, nil).Word
273 if len(got) != len(want) {
274 t.Fatalf("the driver ran %d scenarios, want %d: %v", len(got), len(want), got)
275 }
276 for scenario, w := range want {
277 g, ok := got[scenario]
278 if !ok {
279 t.Errorf("the %s scenario was not run", scenario)
280 continue
281 }
282 if g != w {
283 t.Errorf("%s: fiestaWord gave %q, want %q", scenario, g, w)
284 }
285 }
286 })
287 }
288}
289
290// functionBody returns the text of `function name(` through its
291// closing brace, counting braces, or "" when the script has no such
292// function. The two lookups hold no brace in a string, so counting is
293// enough.
294func functionBody(js, name string) string {
295 start := strings.Index(js, "function "+name+"(")
296 if start < 0 {
297 return ""
298 }
299 depth := 0
300 for i := start; i < len(js); i++ {
301 switch js[i] {
302 case '{':
303 depth++
304 case '}':
305 depth--
306 if depth == 0 {
307 return js[start : i+1]
308 }
309 }
310 }
311 return ""
312}
313
314// TestTheTwoLookupsAreTheSame: datetime.js and select.js each carry
315// fiestaPickLang and fiestaWord, so each enhancement stays one script
316// tag. The price of two copies is drift, so the two bodies are held
317// identical, character for character.
318func TestTheTwoLookupsAreTheSame(t *testing.T) {
319 a, b := string(shippedScript(t, "datetime.js")), string(shippedScript(t, "select.js"))
320 for _, name := range []string{"fiestaPickLang", "fiestaWord"} {
321 fa, fb := functionBody(a, name), functionBody(b, name)
322 if fa == "" || fb == "" {
323 t.Errorf("%s: datetime.js has it %v, select.js has it %v; both must carry it", name, fa != "", fb != "")
324 continue
325 }
326 if fa != fb {
327 t.Errorf("%s differs between datetime.js and select.js:\n--- datetime.js\n%s\n--- select.js\n%s", name, fa, fb)
328 }
329 }
330}
331