package gallery import ( "encoding/json" "html" "io/fs" "regexp" "slices" "sort" "strings" "sync" "testing" "amadan.net/rastrillo/fiesta" "amadan.net/rastrillo/fiesta/example" ) // The samples in the page's language: what the walker translates and // what it keeps, the sample catalogs against what the frames ask for, // the enhanced controls' attributes, the money table and a word's // sense. // sources is every sample the renderer walks, failed loudly. func sources(t *testing.T) [][2]string { t.Helper() srcs, err := sampleSources(fiesta.FS(), manifest(t)) if err != nil { t.Fatal(err) } if len(srcs) < 150 { t.Fatalf("%d samples to walk; the sample list has stopped reading the examples", len(srcs)) } return srcs } // loadedSamples is the eleven sample catalogs, failed loudly. func loadedSamples(t *testing.T) map[string]catalog { t.Helper() cs, err := sampleCatalogs() if err != nil { t.Fatal(err) } return cs } // English is the source, so translating a sample into English must // change nothing: not a byte of markup, not an attribute, the enhanced // controls' included. A walker that dropped an attribute or re-spaced a // line would pass every other gate in English and show up only as a // difference between languages nobody compares. func TestLocalisingIntoEnglishChangesNothing(t *testing.T) { s := newTestSite(t, fiesta.FS(), "") checked := 0 for _, src := range sources(t) { if got := localMarkup("en", src[0], src[1]); got != src[1] { t.Errorf("%s: localMarkup changed English markup\n got %q\nwant %q", src[0], got, src[1]) } checked++ } if got := string(s.schemeIn("en")); got != string(s.scheme) { t.Errorf("the scheme menu in English is not the lifted markup\n got %q\nwant %q", got, s.scheme) } t.Logf("%d samples unchanged in English", checked) } // tracedSamples runs fn with the tracer on and returns the sample keys // it looked up, in order. func tracedSamples(fn func()) []string { var mu sync.Mutex var asked []string stop := setTrace(func(id string) { if k, ok := strings.CutPrefix(id, "samples."); ok { mu.Lock() asked = append(asked, k) mu.Unlock() } }) fn() stop() return asked } // What the walker translates and what it leaves, on the cases that // decide it: a name, an address, a code, a route and a field's name // stay; a sentence around a name is translated with the name kept; a // run of words beside inline elements is one sentence; the shell's // brand and person, a drawing and a language's own name stay; a word // in a sense is looked up under the sense; and the enhanced controls' // attributes take the framework's own words for the page. func TestTheWalkersTranslateWordsAndKeepStandIns(t *testing.T) { for s, keep := range map[string]bool{ "Grace Hopper": true, "grace@example.com": true, "AB3PX": true, "/orders/1": true, "notify_on": true, "€48,210": true, "D02 XY45": true, "4.1k": true, "#nav": true, "Party planner": true, "Fiesta": true, "Order AB3PX": false, "Actions for Grace Hopper": false, "Save": false, "1h 12m": false, } { if got := keepsAsWritten(s); got != keep { t.Errorf("keepsAsWritten(%q) = %v, want %v", s, got, keep) } } var out string asked := tracedSamples(func() { out = localMarkup("ja", "row-menu", `

Displaying 1–20 of 412

`+ ``+ `NotesAda Lovelace`+ `AB3PX · grace@example.com`+ ``+ ``+ `Gaeilge`+ `A harbour at dusk`+ ``) }) want := []string{ "Displaying 1–20 of 412", "Publish", "Help: orders", "About orders", "Europe", "Dublin", "A harbour at dusk", "Archive" + verbSense, } sort.Strings(asked) sort.Strings(want) if !slices.Equal(asked, want) { t.Errorf("the walker looked up\n%q\nwant\n%q", asked, want) } for _, kept := range []string{`>Notes`, `>Gaeilge`, ``, `data-rst-whatever="Kept"`, ``} { if !strings.Contains(out, kept) { t.Errorf("the walker did not keep %s as written: %s", kept, out) } } for _, said := range []string{`data-rst-date-pick="カレンダーを開く"`, `data-rst-date-quick-plus-1h="1時間後"`} { if !strings.Contains(out, said) { t.Errorf("the walker did not write %s from the framework's ja catalog: %s", said, out) } } } // standInRun is what a translation must carry over from its key: the // names and brands, every email address, and every code that mixes // letters and digits. var standInRun = regexp.MustCompile(`\S+@\S+\.\w+|#\d+|\b[A-Z]+[0-9][A-Z0-9]*\b`) func TestSampleTranslationsKeepTheirStandIns(t *testing.T) { cs := loadedSamples(t) keys := sampleCatalogKeys(cs) if len(keys) == 0 { t.Fatal("the sample catalogs are empty; there is nothing to hold") } for _, key := range keys { var kept []string for _, s := range sampleStandIns { if strings.Contains(key, s) { kept = append(kept, s) } } // A key is markup, so its text is read with its entities // decoded: the ' of "couldn't" is no code. kept = append(kept, standInRun.FindAllString(html.UnescapeString(stripTags(key)), -1)...) for _, locale := range nonEnglish() { for _, s := range kept { if !strings.Contains(cs[locale][key], s) { t.Errorf("the %s translation of %q lost %q: %q", locale, key, s, cs[locale][key]) } } } } } // sampleCatalogKeys is every key any sample catalog carries, sorted. func sampleCatalogKeys(cs map[string]catalog) []string { seen := map[string]bool{} for _, c := range cs { for k := range c { seen[k] = true } } return keysOf(seen) } // tagSkeleton is a run of markup's elements in a comparable form: each // tag with its spoken attribute values blanked, sorted, because a // language may put the two numbers of a count line the other way round // but may not drop one, add one or change an href. func tagSkeleton(s string) []string { var out []string for _, t := range tokenise(s) { if t.tag == "" { continue } raw := spokenAttr.ReplaceAllStringFunc(t.raw, func(m string) string { sub := spokenAttr.FindStringSubmatch(m) if spokenAttrs[sub[1]] { return sub[1] + `=""` } return m }) out = append(out, raw) } sort.Strings(out) return out } // A key with markup in it is a sentence with elements inside, and its // translation is inserted into the sample as markup. So every // translation carries exactly the elements its key does, attributes and // all bar the spoken ones; and a key with no markup gets a translation // with none, and with nothing that could close the attribute it may be // written into. func TestSampleMarkupSurvivesTranslation(t *testing.T) { cs := loadedSamples(t) keys := sampleCatalogKeys(cs) if len(keys) == 0 { t.Fatal("the sample catalogs are empty; there is nothing to hold") } for _, key := range keys { want := tagSkeleton(key) for _, locale := range nonEnglish() { tr := cs[locale][key] if got := tagSkeleton(tr); !slices.Equal(got, want) { t.Errorf("the %s translation of %q has elements %q, want %q", locale, key, got, want) } if len(want) == 0 && strings.ContainsAny(tr, `<>"`) { t.Errorf("the %s translation of %q carries markup characters: %q", locale, key, tr) } } } } // englishSampleLabels are sample words that must not be in a document // in another language: a button, a menu item, a field's help, a row // title, a shell's foot, the scheme menu's heading. Short ones are // matched as an element's whole text, or a whole attribute value, // which is how a label is written; a bare "Save" would also match // "Saved" in prose. var englishSampleLabels = []string{ "Save", "Cancel", "Publish", "Settings", "Sign out", "Profile", "Language", "Colour scheme", "Skip to content", "Write a post", "Start typing to filter.", "Release notes, August", "Invoice #4471 never arrived", "Back to the design system", "Export takes twenty minutes", "Everything the team has waiting this morning.", } // TestNoEnglishSampleLabelReachesATranslatedPage: the samples speak the // page's language. Every document a translated directory holds, the // example pages with their HTML tabs, the frames, the demo and the // modal route, is swept for a fixed list of the samples' English. The // parity gate catches a missing translation; this catches a sample // that never asked for one, a string the walker decided was a stand-in // when it was a word. func TestNoEnglishSampleLabelReachesATranslatedPage(t *testing.T) { files := render(t, "") swept, frames := 0, 0 var en strings.Builder for name, body := range files { parts := strings.SplitN(name, "/", 3) if !strings.HasSuffix(name, ".html") || len(parts) != 3 { continue } if parts[1] == "en" { en.Write(body) continue } swept++ if isFrame(name) { frames++ } page := string(body) for _, label := range englishSampleLabels { var hit bool if len(label) < 12 { hit = strings.Contains(page, ">"+label+"<") || strings.Contains(page, `"`+label+`"`) } else { hit = strings.Contains(page, label) } if hit { t.Errorf("%s carries the English sample label %q", name, label) } } } s := newTestSite(t, fiesta.FS(), "") perDir := 0 for _, e := range s.m.Examples { perDir += len(s.frameFiles(e)) } if want := len(fiesta.ThemeNames()) * len(nonEnglish()) * perDir; frames != want { t.Errorf("swept %d translated frames, want %d", frames, want) } // The labels have to be on the English pages for their absence // elsewhere to mean anything. for _, label := range englishSampleLabels { if !strings.Contains(en.String(), label) { t.Errorf("%q is on no English page; the sweep for it checks nothing", label) } } t.Logf("swept %d translated documents, %d of them frames", swept, frames) } // TestEverySampleStringIsTranslated: every sample key a full render // looks up is one EnglishStrings lists, and every one EnglishStrings // lists is looked up, so the exporter and the renderer walk the same // samples; each has a translation in all eleven sample catalogs, with // every {placeholder} kept; no catalog carries a key no render asks // for; and the drafts drafts.json names for samples are in the // catalogs, drafted from the English the key has now. func TestEverySampleStringIsTranslated(t *testing.T) { ids := tracedIDs(t) english := englishStrings(t) cs := loadedSamples(t) asked := map[string]bool{} for id := range ids { if k, ok := strings.CutPrefix(id, "samples."); ok { asked[k] = true if _, ok := english[id]; !ok { t.Errorf("a render looked up the sample %q, which EnglishStrings does not list, so the exporter never sees it", k) } } } listed := 0 for id := range english { if k, ok := strings.CutPrefix(id, "samples."); ok { listed++ if !asked[k] { t.Errorf("EnglishStrings lists the sample %q, which no render looks up", k) } } } if len(asked) < 200 || listed < 200 { t.Fatalf("a render looked up %d sample strings and EnglishStrings lists %d; the walker has stopped reading the samples", len(asked), listed) } for _, k := range keysOf(asked) { want := placeholderName.FindAllString(k, -1) sort.Strings(want) for _, l := range nonEnglish() { tr := cs[l][k] if strings.TrimSpace(tr) == "" { t.Errorf("i18n/samples.%s.json has no translation of %q", l, k) continue } got := placeholderName.FindAllString(tr, -1) sort.Strings(got) if !slices.Equal(got, want) { t.Errorf("i18n/samples.%s.json: %q carries the placeholders %v, its key %v", l, k, got, want) } } } for _, l := range nonEnglish() { for k := range cs[l] { if !asked[k] { t.Errorf("i18n/samples.%s.json carries %q, which no render looks up", l, k) } } } b, err := fs.ReadFile(catalogFiles, "i18n/drafts.json") if err != nil { t.Fatal(err) } var drafts map[string]struct { English string `json:"english"` Locales []string `json:"locales"` } if err := json.Unmarshal(b, &drafts); err != nil { t.Fatalf("i18n/drafts.json: %v", err) } for id, d := range drafts { k, ok := strings.CutPrefix(id, "samples.") if !ok { continue } if d.English != english[id] { t.Errorf("i18n/drafts.json: the sample %q was drafted from %q, and its English is now %q", k, d.English, english[id]) } for _, l := range d.Locales { if !known(l) || l == "en" || cs[l][k] == "" { t.Errorf("i18n/drafts.json names a %s draft of the sample %q that no sample catalog carries", l, k) } } } } // controlAttrsOf is every data-rst-select-*, data-rst-date-* and // data-rst-copy-* value in a document, by its key. func controlAttrsOf(t *testing.T, doc []byte) map[string]string { t.Helper() out := map[string]string{} attrsOf(t, doc, func(_ string, attrs map[string]string) { for k, v := range attrs { if key, ok := strings.CutPrefix(k, "data-rst-"); ok && (strings.HasPrefix(key, "select-") || strings.HasPrefix(key, "date-") || strings.HasPrefix(key, "copy-")) { out[key] = v } } }) return out } // TestTheEnhancedControlsCarryThePagesStrings: in day/ja's field-select, // field-date and snippet frames, every data-rst-select-*, // data-rst-date-* and data-rst-copy-* value is the framework's own ja // catalog's, the date words as their JSON; English's are the seeded // values. The snippet's button must carry both of its strings: the // attribute wins over the page's table, so an English one left on it // would say "Copied" on a Japanese page. func TestTheEnhancedControlsCarryThePagesStrings(t *testing.T) { files := render(t, "") b, ok := fiesta.Strings("ja") if !ok { t.Fatal("the framework has no ja catalog") } var raw map[string]json.RawMessage if err := json.Unmarshal(b, &raw); err != nil { t.Fatal(err) } read := 0 for _, name := range []string{"field-select", "field-date", "snippet"} { e := mustEntry(t, name) for _, st := range e.States { doc := files["day/ja/frames/"+name+"/"+st.ID+".html"] if doc == nil { t.Fatalf("no day/ja/frames/%s/%s.html", name, st.ID) } attrs := controlAttrsOf(t, doc) if name == "snippet" { for _, key := range []string{"copy-done", "copy-failed"} { if _, ok := attrs[key]; !ok { t.Errorf("snippet/%s carries no data-rst-%s on its Copy button", st.ID, key) } } } for key, got := range attrs { read++ want, ok := raw[key] if !ok { t.Errorf("%s/%s carries data-rst-%s, which the framework's catalog has no key for", name, st.ID, key) continue } var s string if json.Unmarshal(want, &s) != nil { var words, gotWords map[string]string if err := json.Unmarshal(want, &words); err != nil { t.Fatal(err) } if err := json.Unmarshal([]byte(got), &gotWords); err != nil { t.Errorf("%s/%s: data-rst-%s is not JSON: %q", name, st.ID, key, got) continue } if !sameStrings(words, gotWords) { t.Errorf("%s/%s: data-rst-%s is %q, want the ja catalog's %v", name, st.ID, key, got, words) } continue } if got != s { t.Errorf("%s/%s: data-rst-%s is %q, want the ja catalog's %q", name, st.ID, key, got, s) } } } } if read < 20 { t.Fatalf("read %d enhanced-control attributes in day/ja; the frames carry fewer than they did", read) } } // sameStrings says whether two string tables are equal. func sameStrings(a, b map[string]string) bool { if len(a) != len(b) { return false } for k, v := range a { if b[k] != v { return false } } return true } // moneyFrame is the money sample's frame in one theme and locale, // its body's text: the codes and the amounts. func moneyFrame(t *testing.T, files map[string][]byte, locale string) string { t.Helper() e := mustEntry(t, moneyExample) doc := files["day/"+locale+"/frames/"+moneyExample+"/"+e.Default+".html"] if doc == nil { t.Fatalf("no day/%s money frame", locale) } return string(doc) } // TestMoneySampleIsFormattedPerLocale: the Money sample writes three // currencies the way each page's language does, Rastrillo's formatting // carried; the currency comes from the data, so the euro stays a euro on // the Japanese page, and the three codes are on every page as written. // The frame and the HTML tab show the same markup. func TestMoneySampleIsFormattedPerLocale(t *testing.T) { files := render(t, "") for locale, wants := range map[string][]string{ "en": {"€1,284.50", "¥1,285", "KWD\u00a01,284.500"}, // English writes the yen with U+00A5 "ja": {"€1,284.50", "¥1,285", "KWD\u00a01,284.500"}, // Japanese with the fullwidth U+FFE5 "es": {"1284,50\u00a0€"}, "pt-PT": {"1284,50\u00a0€"}, "ar": {"\u200f1,284.50\u00a0€"}, } { frame := moneyFrame(t, files, locale) page := examplePage(t, files, "day", locale, moneyExample) code := strings.Join(sourceTexts(page), "\n") for _, w := range wants { if !strings.Contains(frame, w) { t.Errorf("%s: the money frame lacks %q", locale, w) } if !strings.Contains(code, w) { t.Errorf("%s: the money sample's HTML tab lacks %q", locale, w) } } for _, c := range []string{"
EUR
", "
JPY
", "
KWD
"} { if !strings.Contains(frame, c) { t.Errorf("%s: the money frame lacks %s as written", locale, c) } } } } // TestTheMoneyTableStartsFromTheExample: the table's English entry is // the seeded example's body byte for byte, so the English frame is the // example's document as written; and the table has a sample for each // of the twelve locales and nothing else. func TestTheMoneyTableStartsFromTheExample(t *testing.T) { e := mustEntry(t, moneyExample) seeded, err := example.Markup(fiesta.FS(), e.Name, e.Default) if err != nil { t.Fatal(err) } table, err := moneyTable() if err != nil { t.Fatal(err) } if table["en"] != seeded { t.Errorf("i18n/money.json's English is\n%s\nthe seeded body is\n%s", table["en"], seeded) } for _, l := range Locales { if strings.TrimSpace(table[l]) == "" { t.Errorf("i18n/money.json has no %s sample", l) } } for l := range table { if !known(l) { t.Errorf("i18n/money.json carries %s, which is no locale the gallery renders", l) } } } // TestAVerbAndItsNounAreTranslatedApart: the row menu's Archive is an // action and the topbar's a destination; Spanish says Archivar and // Archivo, and the catalog has the two apart because the walker looks // the action up under its sense, which EnglishStrings lists as a key // of its own beside the noun. func TestAVerbAndItsNounAreTranslatedApart(t *testing.T) { files := render(t, "") menu := string(files["day/es/frames/row-menu/links-a-form-and-a-destructive-item.html"]) if !strings.Contains(menu, ``) { t.Errorf("day/es's row-menu frame does not show the button Archivar") } topbar := string(files["day/es/frames/shell-topbar/"+mustEntry(t, "shell-topbar").Default+".html"]) if !regexp.MustCompile(`]*>Archivo`).MatchString(topbar) { t.Errorf("day/es's shell-topbar frame does not show the nav link Archivo") } english := englishStrings(t) if english["samples.Archive"+verbSense] != "Archive" || english["samples.Archive"] != "Archive" { t.Errorf("EnglishStrings lists Archive (verb) as %q and Archive as %q; want both, each saying Archive", english["samples.Archive"+verbSense], english["samples.Archive"]) } } // TestANounAndItsVerbAreTranslatedApart is the same split the other // way round: the seg-tabs group's View names the view a reader picks, // and the list's row menu View opens a row. Russian says Вид and // Открыть; read under the bare word, the group would be labelled // "Open", a command, for every Russian screen reader. func TestANounAndItsVerbAreTranslatedApart(t *testing.T) { files := render(t, "") group := string(files["day/ru/frames/seg-tabs/buttons-that-switch-in-place.html"]) if !strings.Contains(group, `role="group" aria-label="Вид"`) { t.Errorf("day/ru's seg-tabs buttons frame does not label its group Вид") } rows := string(files["day/ru/frames/list-grid/"+mustEntry(t, "list-grid").Default+".html"]) if !strings.Contains(rows, `>Открыть`) { t.Errorf("day/ru's list-grid frame does not show the row menu link Открыть") } english := englishStrings(t) if english["samples.View"+nounSense] != "View" || english["samples.View"] != "View" { t.Errorf("EnglishStrings lists View (noun) as %q and View as %q; want both, each saying View", english["samples.View"+nounSense], english["samples.View"]) } }