package check import ( "encoding/json" "os" "path/filepath" "slices" "sort" "strings" "testing" "amadan.net/rastrillo/fiesta" "amadan.net/rastrillo/fiesta/internal/nodetest" ) // The interface strings datetime.js, select.js and copy.js put on // screen, one flat JSON object per shipped language under i18n/, seeded from // rastrillo's catalogs at the seed commit and read through fiesta.Strings. The // scripts look a string up in three places, in this order: the // element's own data-rst- attribute, then the page's table // (self.fiestaStrings, filled by i18n/.js, picked by the // document's lang), then the English fallback written in the script. // These tests hold the tables' shape and the lookup, in Node // (node/strings_node.mjs) and in a browser (strings_browser_test.go). // languageKeys are the twenty-two string keys, each an attribute's // name with data-rst- removed. var languageKeys = []string{ "date-set", "date-hint", "date-pick", "date-pick-time", "date-calendar", "date-prev-month", "date-next-month", "date-results", "date-result-one", "date-quick-today", "date-quick-tomorrow", "date-quick-next-week", "date-quick-plus-1h", "date-quick-plus-2h", "date-quick-end-of-day", "date-quick-next-day", "select-filter", "select-results", "select-result-one", "select-no-matches", "copy-done", "copy-failed", } // dateWords are the seventeen names under date-words. var dateWords = []string{ "today", "tomorrow", "yesterday", "next", "last", "in", "ago", "at", "day", "week", "month", "hour", "minute", "noon", "midnight", "am", "pm", } // languageFileProblems reads one language file and lists what is // wrong with its shape: a missing or extra key, a missing or extra // word, an empty value, or a hint with no {example}. func languageFileProblems(data []byte) []string { var obj map[string]json.RawMessage if err := json.Unmarshal(data, &obj); err != nil { return []string{"not a JSON object: " + err.Error()} } var problems []string want := append([]string{"date-words"}, languageKeys...) var got []string for k := range obj { got = append(got, k) } sort.Strings(got) for _, k := range want { if _, ok := obj[k]; !ok { problems = append(problems, "missing key "+k) } } for _, k := range got { if !slices.Contains(want, k) { problems = append(problems, "unexpected key "+k) } } for _, k := range languageKeys { raw, ok := obj[k] if !ok { continue } var s string if err := json.Unmarshal(raw, &s); err != nil { problems = append(problems, k+" is not a string") } else if s == "" { problems = append(problems, k+" is empty") } } if raw, ok := obj["date-hint"]; ok { var s string if json.Unmarshal(raw, &s) == nil && s != "" && !strings.Contains(s, "{example}") { problems = append(problems, "date-hint has no {example}") } } if raw, ok := obj["date-words"]; ok { var words map[string]string if err := json.Unmarshal(raw, &words); err != nil { return append(problems, "date-words is not an object of strings: "+err.Error()) } for _, w := range dateWords { v, ok := words[w] if !ok { problems = append(problems, "date-words is missing "+w) } else if v == "" { problems = append(problems, "date-words."+w+" is empty") } } for w := range words { if !slices.Contains(dateWords, w) { problems = append(problems, "date-words has an unexpected "+w) } } } sort.Strings(problems) return problems } // TestEveryLanguageHasEveryKey: twelve languages, each read through // fiesta.Strings, with exactly twenty-three keys, exactly seventeen // date-words names, no empty value, and {example} in date-hint. The // precondition is not empty: fiesta.Languages() is the twelve tags // with en first, i18n/ on disk holds exactly those files, and the // alias pt is not one of them. The first leg is the check's own // control: a file with one key dropped, one word empty, a hint with // no placeholder and an extra key must be caught, or the pass below // means nothing. func TestEveryLanguageHasEveryKey(t *testing.T) { t.Run("control", func(t *testing.T) { bad := map[string]any{"date-words": map[string]string{}} for _, w := range dateWords { bad["date-words"].(map[string]string)[w] = w } bad["date-words"].(map[string]string)["noon"] = "" for _, k := range languageKeys[1:] { bad[k] = k } bad["date-hint"] = "Try:" bad["extra"] = "x" data, _ := json.Marshal(bad) got := languageFileProblems(data) want := []string{"date-hint has no {example}", "date-words.noon is empty", "missing key date-set", "unexpected key extra"} if !slices.Equal(got, want) { t.Fatalf("the control file's problems are %v, want %v", got, want) } }) languages := fiesta.Languages() if want := []string{"en", "ar", "bn", "es", "ga", "hi", "ja", "pt-PT", "ru", "vi", "yue", "zh-Hans"}; !slices.Equal(languages, want) { t.Fatalf("fiesta.Languages() = %q, want %q", languages, want) } entries, err := os.ReadDir(filepath.Join("..", "i18n")) if err != nil { t.Fatalf("reading i18n/: %v", err) } var files, want []string for _, e := range entries { if filepath.Ext(e.Name()) == ".json" { files = append(files, e.Name()) } } for _, l := range languages { want = append(want, l+".json") } sort.Strings(files) sort.Strings(want) if !slices.Equal(files, want) { t.Errorf("i18n/ holds %v, want %v", files, want) } if _, ok := fiesta.Strings("pt"); ok { t.Error("fiesta.Strings(\"pt\") answers; pt is a test alias for pt-PT, not a shipped file") } for _, l := range languages { data, ok := fiesta.Strings(l) if !ok { t.Errorf("fiesta.Strings(%q): not shipped", l) continue } if len(data) == 0 { t.Errorf("fiesta.Strings(%q) is empty", l) continue } for _, p := range languageFileProblems(data) { t.Errorf("i18n/%s.json: %s", l, p) } } } // lookupScripts are the two scripts that carry the string lookup, each // its own copy: every enhancement is one script tag, and a shared // file would be a third tag every page had to remember. var lookupScripts = []string{"datetime.js", "select.js"} // stringsReport is what node/strings_node.mjs reports for one script: // fiestaPickLang's answer for each tag asked (null for none), and // fiestaWord's answer in each scenario. type stringsReport struct { Pick map[string]*string Word map[string]string } // stringsNode runs node/strings_node.mjs against one script, with the // registry's tags and the tags to resolve on stdin. func stringsNode(t *testing.T, script string, registry []string, tags []string) stringsReport { t.Helper() in, err := json.Marshal(map[string]any{"registry": registry, "tags": tags}) if err != nil { t.Fatal(err) } var out stringsReport if err := json.Unmarshal(nodetest.Run(t, nodetest.Cmd{Args: []string{"node/strings_node.mjs", script}, Stdin: in}), &out); err != nil { t.Fatal(err) } return out } // TestPickLangResolvesNearMisses: with tables for en, pt-PT and // zh-Hans, a page's lang finds its table by exact match whatever its // case, then by primary language, and a language with no table is // null rather than English. Both copies of the lookup are asked. func TestPickLangResolvesNearMisses(t *testing.T) { registry := []string{"en", "pt-PT", "zh-Hans"} want := map[string]*string{ "pt-PT": ptr("pt-PT"), "PT-pt": ptr("pt-PT"), "pt-BR": ptr("pt-PT"), "zh-Hant": ptr("zh-Hans"), "EN": ptr("en"), "de": nil, "": nil, "": nil, } var tags []string for tag := range want { if tag != "" { tags = append(tags, tag) } } sort.Strings(tags) for _, script := range lookupScripts { t.Run(script, func(t *testing.T) { got := stringsNode(t, script, registry, tags).Pick if len(got) != len(want) { t.Fatalf("the driver resolved %d tags, want %d: %v", len(got), len(want), got) } for tag, w := range want { g, ok := got[tag] if !ok { t.Errorf("fiestaPickLang(%q) was not asked", tag) continue } if str(g) != str(w) { t.Errorf("fiestaPickLang(%q) = %s, want %s", tag, str(g), str(w)) } } }) } } func ptr(s string) *string { return &s } func str(p *string) string { if p == nil { return "null" } return `"` + *p + `"` } // TestTheTableFillsAMissingAttribute: fiestaWord reads the attribute // first, the page's table second, and the fallback last. On a stub // element in a lang="es" document whose table says "Tabla": with the // attribute "Atributo" and the table both present, the attribute; // with the attribute empty, the table; with only the table, the // table; with neither, the English fallback; and on a lang="de" // document with only that es table, no attribute, the fallback too, // since a language with no table is not read in another one. The // driver builds its own tables for these, so no registry goes over. func TestTheTableFillsAMissingAttribute(t *testing.T) { want := map[string]string{"both": "Atributo", "empty": "Tabla", "table": "Tabla", "neither": "English", "notable": "English"} for _, script := range lookupScripts { t.Run(script, func(t *testing.T) { got := stringsNode(t, script, nil, nil).Word if len(got) != len(want) { t.Fatalf("the driver ran %d scenarios, want %d: %v", len(got), len(want), got) } for scenario, w := range want { g, ok := got[scenario] if !ok { t.Errorf("the %s scenario was not run", scenario) continue } if g != w { t.Errorf("%s: fiestaWord gave %q, want %q", scenario, g, w) } } }) } } // functionBody returns the text of `function name(` through its // closing brace, counting braces, or "" when the script has no such // function. The two lookups hold no brace in a string, so counting is // enough. func functionBody(js, name string) string { start := strings.Index(js, "function "+name+"(") if start < 0 { return "" } depth := 0 for i := start; i < len(js); i++ { switch js[i] { case '{': depth++ case '}': depth-- if depth == 0 { return js[start : i+1] } } } return "" } // TestTheTwoLookupsAreTheSame: datetime.js and select.js each carry // fiestaPickLang and fiestaWord, so each enhancement stays one script // tag. The price of two copies is drift, so the two bodies are held // identical, character for character. func TestTheTwoLookupsAreTheSame(t *testing.T) { a, b := string(shippedScript(t, "datetime.js")), string(shippedScript(t, "select.js")) for _, name := range []string{"fiestaPickLang", "fiestaWord"} { fa, fb := functionBody(a, name), functionBody(b, name) if fa == "" || fb == "" { t.Errorf("%s: datetime.js has it %v, select.js has it %v; both must carry it", name, fa != "", fb != "") continue } if fa != fb { t.Errorf("%s differs between datetime.js and select.js:\n--- datetime.js\n%s\n--- select.js\n%s", name, fa, fb) } } }