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`+
``+
``)
})
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"])
}
}