rastrillo / fiesta Public

Clone
git clone https://amadan.net/rastrillo/fiesta

Plain git — no account needed to clone.

Download

Download this file

1package gallery
2
3import (
4 "encoding/json"
5 "html"
6 "io/fs"
7 "regexp"
8 "slices"
9 "sort"
10 "strings"
11 "sync"
12 "testing"
13
14 "amadan.net/rastrillo/fiesta"
15 "amadan.net/rastrillo/fiesta/example"
16)
17
18// The samples in the page's language: what the walker translates and
19// what it keeps, the sample catalogs against what the frames ask for,
20// the enhanced controls' attributes, the money table and a word's
21// sense.
22
23// sources is every sample the renderer walks, failed loudly.
24func sources(t *testing.T) [][2]string {
25 t.Helper()
26 srcs, err := sampleSources(fiesta.FS(), manifest(t))
27 if err != nil {
28 t.Fatal(err)
29 }
30 if len(srcs) < 150 {
31 t.Fatalf("%d samples to walk; the sample list has stopped reading the examples", len(srcs))
32 }
33 return srcs
34}
35
36// loadedSamples is the eleven sample catalogs, failed loudly.
37func loadedSamples(t *testing.T) map[string]catalog {
38 t.Helper()
39 cs, err := sampleCatalogs()
40 if err != nil {
41 t.Fatal(err)
42 }
43 return cs
44}
45
46// English is the source, so translating a sample into English must
47// change nothing: not a byte of markup, not an attribute, the enhanced
48// controls' included. A walker that dropped an attribute or re-spaced a
49// line would pass every other gate in English and show up only as a
50// difference between languages nobody compares.
51func TestLocalisingIntoEnglishChangesNothing(t *testing.T) {
52 s := newTestSite(t, fiesta.FS(), "")
53 checked := 0
54 for _, src := range sources(t) {
55 if got := localMarkup("en", src[0], src[1]); got != src[1] {
56 t.Errorf("%s: localMarkup changed English markup\n got %q\nwant %q", src[0], got, src[1])
57 }
58 checked++
59 }
60 if got := string(s.schemeIn("en")); got != string(s.scheme) {
61 t.Errorf("the scheme menu in English is not the lifted markup\n got %q\nwant %q", got, s.scheme)
62 }
63 t.Logf("%d samples unchanged in English", checked)
64}
65
66// tracedSamples runs fn with the tracer on and returns the sample keys
67// it looked up, in order.
68func tracedSamples(fn func()) []string {
69 var mu sync.Mutex
70 var asked []string
71 stop := setTrace(func(id string) {
72 if k, ok := strings.CutPrefix(id, "samples."); ok {
73 mu.Lock()
74 asked = append(asked, k)
75 mu.Unlock()
76 }
77 })
78 fn()
79 stop()
80 return asked
81}
82
83// What the walker translates and what it leaves, on the cases that
84// decide it: a name, an address, a code, a route and a field's name
85// stay; a sentence around a name is translated with the name kept; a
86// run of words beside inline elements is one sentence; the shell's
87// brand and person, a drawing and a language's own name stay; a word
88// in a sense is looked up under the sense; and the enhanced controls'
89// attributes take the framework's own words for the page.
90func TestTheWalkersTranslateWordsAndKeepStandIns(t *testing.T) {
91 for s, keep := range map[string]bool{
92 "Grace Hopper": true, "grace@example.com": true, "AB3PX": true, "/orders/1": true,
93 "notify_on": true, "€48,210": true, "D02 XY45": true, "4.1k": true, "#nav": true,
94 "Party planner": true, "Fiesta": true,
95 "Order AB3PX": false, "Actions for Grace Hopper": false, "Save": false, "1h 12m": false,
96 } {
97 if got := keepsAsWritten(s); got != keep {
98 t.Errorf("keepsAsWritten(%q) = %v, want %v", s, got, keep)
99 }
100 }
101 var out string
102 asked := tracedSamples(func() {
103 out = localMarkup("ja", "row-menu", `<p rst-count-line>Displaying <strong>1–20</strong> of <strong>412</strong></p>`+
104 `<button><span rst-spin aria-hidden="true"></span>Publish</button>`+
105 `<a rst-shell-brand href="/">Notes</a><span rst-person-name>Ada Lovelace</span>`+
106 `<small>AB3PX · <bdi>grace@example.com</bdi></small>`+
107 `<a href="/x" aria-label="Help: orders" data-tip="About orders"><svg><path d="M0 0"/></svg></a>`+
108 `<select><optgroup label="Europe"><option value="dublin">Dublin</option></optgroup></select>`+
109 `<a href="/ga/" lang="ga" dir="ltr">Gaeilge</a><!-- fiesta:slot -->`+
110 `<img src="a.jpg" alt="A harbour at dusk"><button type="submit">Archive</button>`+
111 `<input data-rst-date data-rst-date-pick="Open the calendar" data-rst-date-quick-plus-1h="An hour later" data-rst-whatever="Kept">`)
112 })
113 want := []string{
114 "Displaying <strong>1–20</strong> of <strong>412</strong>", "Publish",
115 "Help: orders", "About orders", "Europe", "Dublin", "A harbour at dusk", "Archive" + verbSense,
116 }
117 sort.Strings(asked)
118 sort.Strings(want)
119 if !slices.Equal(asked, want) {
120 t.Errorf("the walker looked up\n%q\nwant\n%q", asked, want)
121 }
122 for _, kept := range []string{`>Notes</a>`, `>Gaeilge</a>`, `<!-- fiesta:slot -->`, `data-rst-whatever="Kept"`, `<path d="M0 0"/>`} {
123 if !strings.Contains(out, kept) {
124 t.Errorf("the walker did not keep %s as written: %s", kept, out)
125 }
126 }
127 for _, said := range []string{`data-rst-date-pick="カレンダーを開く"`, `data-rst-date-quick-plus-1h="1時間後"`} {
128 if !strings.Contains(out, said) {
129 t.Errorf("the walker did not write %s from the framework's ja catalog: %s", said, out)
130 }
131 }
132}
133
134// standInRun is what a translation must carry over from its key: the
135// names and brands, every email address, and every code that mixes
136// letters and digits.
137var standInRun = regexp.MustCompile(`\S+@\S+\.\w+|#\d+|\b[A-Z]+[0-9][A-Z0-9]*\b`)
138
139func TestSampleTranslationsKeepTheirStandIns(t *testing.T) {
140 cs := loadedSamples(t)
141 keys := sampleCatalogKeys(cs)
142 if len(keys) == 0 {
143 t.Fatal("the sample catalogs are empty; there is nothing to hold")
144 }
145 for _, key := range keys {
146 var kept []string
147 for _, s := range sampleStandIns {
148 if strings.Contains(key, s) {
149 kept = append(kept, s)
150 }
151 }
152 // A key is markup, so its text is read with its entities
153 // decoded: the &#39; of "couldn&#39;t" is no code.
154 kept = append(kept, standInRun.FindAllString(html.UnescapeString(stripTags(key)), -1)...)
155 for _, locale := range nonEnglish() {
156 for _, s := range kept {
157 if !strings.Contains(cs[locale][key], s) {
158 t.Errorf("the %s translation of %q lost %q: %q", locale, key, s, cs[locale][key])
159 }
160 }
161 }
162 }
163}
164
165// sampleCatalogKeys is every key any sample catalog carries, sorted.
166func sampleCatalogKeys(cs map[string]catalog) []string {
167 seen := map[string]bool{}
168 for _, c := range cs {
169 for k := range c {
170 seen[k] = true
171 }
172 }
173 return keysOf(seen)
174}
175
176// tagSkeleton is a run of markup's elements in a comparable form: each
177// tag with its spoken attribute values blanked, sorted, because a
178// language may put the two numbers of a count line the other way round
179// but may not drop one, add one or change an href.
180func tagSkeleton(s string) []string {
181 var out []string
182 for _, t := range tokenise(s) {
183 if t.tag == "" {
184 continue
185 }
186 raw := spokenAttr.ReplaceAllStringFunc(t.raw, func(m string) string {
187 sub := spokenAttr.FindStringSubmatch(m)
188 if spokenAttrs[sub[1]] {
189 return sub[1] + `=""`
190 }
191 return m
192 })
193 out = append(out, raw)
194 }
195 sort.Strings(out)
196 return out
197}
198
199// A key with markup in it is a sentence with elements inside, and its
200// translation is inserted into the sample as markup. So every
201// translation carries exactly the elements its key does, attributes and
202// all bar the spoken ones; and a key with no markup gets a translation
203// with none, and with nothing that could close the attribute it may be
204// written into.
205func TestSampleMarkupSurvivesTranslation(t *testing.T) {
206 cs := loadedSamples(t)
207 keys := sampleCatalogKeys(cs)
208 if len(keys) == 0 {
209 t.Fatal("the sample catalogs are empty; there is nothing to hold")
210 }
211 for _, key := range keys {
212 want := tagSkeleton(key)
213 for _, locale := range nonEnglish() {
214 tr := cs[locale][key]
215 if got := tagSkeleton(tr); !slices.Equal(got, want) {
216 t.Errorf("the %s translation of %q has elements %q, want %q", locale, key, got, want)
217 }
218 if len(want) == 0 && strings.ContainsAny(tr, `<>"`) {
219 t.Errorf("the %s translation of %q carries markup characters: %q", locale, key, tr)
220 }
221 }
222 }
223}
224
225// englishSampleLabels are sample words that must not be in a document
226// in another language: a button, a menu item, a field's help, a row
227// title, a shell's foot, the scheme menu's heading. Short ones are
228// matched as an element's whole text, or a whole attribute value,
229// which is how a label is written; a bare "Save" would also match
230// "Saved" in prose.
231var englishSampleLabels = []string{
232 "Save", "Cancel", "Publish", "Settings", "Sign out", "Profile", "Language",
233 "Colour scheme", "Skip to content", "Write a post", "Start typing to filter.",
234 "Release notes, August", "Invoice #4471 never arrived", "Back to the design system",
235 "Export takes twenty minutes", "Everything the team has waiting this morning.",
236}
237
238// TestNoEnglishSampleLabelReachesATranslatedPage: the samples speak the
239// page's language. Every document a translated directory holds, the
240// example pages with their HTML tabs, the frames, the demo and the
241// modal route, is swept for a fixed list of the samples' English. The
242// parity gate catches a missing translation; this catches a sample
243// that never asked for one, a string the walker decided was a stand-in
244// when it was a word.
245func TestNoEnglishSampleLabelReachesATranslatedPage(t *testing.T) {
246 files := render(t, "")
247 swept, frames := 0, 0
248 var en strings.Builder
249 for name, body := range files {
250 parts := strings.SplitN(name, "/", 3)
251 if !strings.HasSuffix(name, ".html") || len(parts) != 3 {
252 continue
253 }
254 if parts[1] == "en" {
255 en.Write(body)
256 continue
257 }
258 swept++
259 if isFrame(name) {
260 frames++
261 }
262 page := string(body)
263 for _, label := range englishSampleLabels {
264 var hit bool
265 if len(label) < 12 {
266 hit = strings.Contains(page, ">"+label+"<") || strings.Contains(page, `"`+label+`"`)
267 } else {
268 hit = strings.Contains(page, label)
269 }
270 if hit {
271 t.Errorf("%s carries the English sample label %q", name, label)
272 }
273 }
274 }
275 s := newTestSite(t, fiesta.FS(), "")
276 perDir := 0
277 for _, e := range s.m.Examples {
278 perDir += len(s.frameFiles(e))
279 }
280 if want := len(fiesta.ThemeNames()) * len(nonEnglish()) * perDir; frames != want {
281 t.Errorf("swept %d translated frames, want %d", frames, want)
282 }
283 // The labels have to be on the English pages for their absence
284 // elsewhere to mean anything.
285 for _, label := range englishSampleLabels {
286 if !strings.Contains(en.String(), label) {
287 t.Errorf("%q is on no English page; the sweep for it checks nothing", label)
288 }
289 }
290 t.Logf("swept %d translated documents, %d of them frames", swept, frames)
291}
292
293// TestEverySampleStringIsTranslated: every sample key a full render
294// looks up is one EnglishStrings lists, and every one EnglishStrings
295// lists is looked up, so the exporter and the renderer walk the same
296// samples; each has a translation in all eleven sample catalogs, with
297// every {placeholder} kept; no catalog carries a key no render asks
298// for; and the drafts drafts.json names for samples are in the
299// catalogs, drafted from the English the key has now.
300func TestEverySampleStringIsTranslated(t *testing.T) {
301 ids := tracedIDs(t)
302 english := englishStrings(t)
303 cs := loadedSamples(t)
304 asked := map[string]bool{}
305 for id := range ids {
306 if k, ok := strings.CutPrefix(id, "samples."); ok {
307 asked[k] = true
308 if _, ok := english[id]; !ok {
309 t.Errorf("a render looked up the sample %q, which EnglishStrings does not list, so the exporter never sees it", k)
310 }
311 }
312 }
313 listed := 0
314 for id := range english {
315 if k, ok := strings.CutPrefix(id, "samples."); ok {
316 listed++
317 if !asked[k] {
318 t.Errorf("EnglishStrings lists the sample %q, which no render looks up", k)
319 }
320 }
321 }
322 if len(asked) < 200 || listed < 200 {
323 t.Fatalf("a render looked up %d sample strings and EnglishStrings lists %d; the walker has stopped reading the samples", len(asked), listed)
324 }
325 for _, k := range keysOf(asked) {
326 want := placeholderName.FindAllString(k, -1)
327 sort.Strings(want)
328 for _, l := range nonEnglish() {
329 tr := cs[l][k]
330 if strings.TrimSpace(tr) == "" {
331 t.Errorf("i18n/samples.%s.json has no translation of %q", l, k)
332 continue
333 }
334 got := placeholderName.FindAllString(tr, -1)
335 sort.Strings(got)
336 if !slices.Equal(got, want) {
337 t.Errorf("i18n/samples.%s.json: %q carries the placeholders %v, its key %v", l, k, got, want)
338 }
339 }
340 }
341 for _, l := range nonEnglish() {
342 for k := range cs[l] {
343 if !asked[k] {
344 t.Errorf("i18n/samples.%s.json carries %q, which no render looks up", l, k)
345 }
346 }
347 }
348 b, err := fs.ReadFile(catalogFiles, "i18n/drafts.json")
349 if err != nil {
350 t.Fatal(err)
351 }
352 var drafts map[string]struct {
353 English string `json:"english"`
354 Locales []string `json:"locales"`
355 }
356 if err := json.Unmarshal(b, &drafts); err != nil {
357 t.Fatalf("i18n/drafts.json: %v", err)
358 }
359 for id, d := range drafts {
360 k, ok := strings.CutPrefix(id, "samples.")
361 if !ok {
362 continue
363 }
364 if d.English != english[id] {
365 t.Errorf("i18n/drafts.json: the sample %q was drafted from %q, and its English is now %q", k, d.English, english[id])
366 }
367 for _, l := range d.Locales {
368 if !known(l) || l == "en" || cs[l][k] == "" {
369 t.Errorf("i18n/drafts.json names a %s draft of the sample %q that no sample catalog carries", l, k)
370 }
371 }
372 }
373}
374
375// controlAttrsOf is every data-rst-select-*, data-rst-date-* and
376// data-rst-copy-* value in a document, by its key.
377func controlAttrsOf(t *testing.T, doc []byte) map[string]string {
378 t.Helper()
379 out := map[string]string{}
380 attrsOf(t, doc, func(_ string, attrs map[string]string) {
381 for k, v := range attrs {
382 if key, ok := strings.CutPrefix(k, "data-rst-"); ok && (strings.HasPrefix(key, "select-") || strings.HasPrefix(key, "date-") || strings.HasPrefix(key, "copy-")) {
383 out[key] = v
384 }
385 }
386 })
387 return out
388}
389
390// TestTheEnhancedControlsCarryThePagesStrings: in day/ja's field-select,
391// field-date and snippet frames, every data-rst-select-*,
392// data-rst-date-* and data-rst-copy-* value is the framework's own ja
393// catalog's, the date words as their JSON; English's are the seeded
394// values. The snippet's button must carry both of its strings: the
395// attribute wins over the page's table, so an English one left on it
396// would say "Copied" on a Japanese page.
397func TestTheEnhancedControlsCarryThePagesStrings(t *testing.T) {
398 files := render(t, "")
399 b, ok := fiesta.Strings("ja")
400 if !ok {
401 t.Fatal("the framework has no ja catalog")
402 }
403 var raw map[string]json.RawMessage
404 if err := json.Unmarshal(b, &raw); err != nil {
405 t.Fatal(err)
406 }
407 read := 0
408 for _, name := range []string{"field-select", "field-date", "snippet"} {
409 e := mustEntry(t, name)
410 for _, st := range e.States {
411 doc := files["day/ja/frames/"+name+"/"+st.ID+".html"]
412 if doc == nil {
413 t.Fatalf("no day/ja/frames/%s/%s.html", name, st.ID)
414 }
415 attrs := controlAttrsOf(t, doc)
416 if name == "snippet" {
417 for _, key := range []string{"copy-done", "copy-failed"} {
418 if _, ok := attrs[key]; !ok {
419 t.Errorf("snippet/%s carries no data-rst-%s on its Copy button", st.ID, key)
420 }
421 }
422 }
423 for key, got := range attrs {
424 read++
425 want, ok := raw[key]
426 if !ok {
427 t.Errorf("%s/%s carries data-rst-%s, which the framework's catalog has no key for", name, st.ID, key)
428 continue
429 }
430 var s string
431 if json.Unmarshal(want, &s) != nil {
432 var words, gotWords map[string]string
433 if err := json.Unmarshal(want, &words); err != nil {
434 t.Fatal(err)
435 }
436 if err := json.Unmarshal([]byte(got), &gotWords); err != nil {
437 t.Errorf("%s/%s: data-rst-%s is not JSON: %q", name, st.ID, key, got)
438 continue
439 }
440 if !sameStrings(words, gotWords) {
441 t.Errorf("%s/%s: data-rst-%s is %q, want the ja catalog's %v", name, st.ID, key, got, words)
442 }
443 continue
444 }
445 if got != s {
446 t.Errorf("%s/%s: data-rst-%s is %q, want the ja catalog's %q", name, st.ID, key, got, s)
447 }
448 }
449 }
450 }
451 if read < 20 {
452 t.Fatalf("read %d enhanced-control attributes in day/ja; the frames carry fewer than they did", read)
453 }
454}
455
456// sameStrings says whether two string tables are equal.
457func sameStrings(a, b map[string]string) bool {
458 if len(a) != len(b) {
459 return false
460 }
461 for k, v := range a {
462 if b[k] != v {
463 return false
464 }
465 }
466 return true
467}
468
469// moneyFrame is the money sample's frame in one theme and locale,
470// its body's text: the codes and the amounts.
471func moneyFrame(t *testing.T, files map[string][]byte, locale string) string {
472 t.Helper()
473 e := mustEntry(t, moneyExample)
474 doc := files["day/"+locale+"/frames/"+moneyExample+"/"+e.Default+".html"]
475 if doc == nil {
476 t.Fatalf("no day/%s money frame", locale)
477 }
478 return string(doc)
479}
480
481// TestMoneySampleIsFormattedPerLocale: the Money sample writes three
482// currencies the way each page's language does, Rastrillo's formatting
483// carried; the currency comes from the data, so the euro stays a euro on
484// the Japanese page, and the three codes are on every page as written.
485// The frame and the HTML tab show the same markup.
486func TestMoneySampleIsFormattedPerLocale(t *testing.T) {
487 files := render(t, "")
488 for locale, wants := range map[string][]string{
489 "en": {"€1,284.50", "¥1,285", "KWD\u00a01,284.500"}, // English writes the yen with U+00A5
490 "ja": {"€1,284.50", "¥1,285", "KWD\u00a01,284.500"}, // Japanese with the fullwidth U+FFE5
491 "es": {"1284,50\u00a0€"},
492 "pt-PT": {"1284,50\u00a0€"},
493 "ar": {"\u200f1,284.50\u00a0€"},
494 } {
495 frame := moneyFrame(t, files, locale)
496 page := examplePage(t, files, "day", locale, moneyExample)
497 code := strings.Join(sourceTexts(page), "\n")
498 for _, w := range wants {
499 if !strings.Contains(frame, w) {
500 t.Errorf("%s: the money frame lacks %q", locale, w)
501 }
502 if !strings.Contains(code, w) {
503 t.Errorf("%s: the money sample's HTML tab lacks %q", locale, w)
504 }
505 }
506 for _, c := range []string{"<dt>EUR</dt>", "<dt>JPY</dt>", "<dt>KWD</dt>"} {
507 if !strings.Contains(frame, c) {
508 t.Errorf("%s: the money frame lacks %s as written", locale, c)
509 }
510 }
511 }
512}
513
514// TestTheMoneyTableStartsFromTheExample: the table's English entry is
515// the seeded example's body byte for byte, so the English frame is the
516// example's document as written; and the table has a sample for each
517// of the twelve locales and nothing else.
518func TestTheMoneyTableStartsFromTheExample(t *testing.T) {
519 e := mustEntry(t, moneyExample)
520 seeded, err := example.Markup(fiesta.FS(), e.Name, e.Default)
521 if err != nil {
522 t.Fatal(err)
523 }
524 table, err := moneyTable()
525 if err != nil {
526 t.Fatal(err)
527 }
528 if table["en"] != seeded {
529 t.Errorf("i18n/money.json's English is\n%s\nthe seeded body is\n%s", table["en"], seeded)
530 }
531 for _, l := range Locales {
532 if strings.TrimSpace(table[l]) == "" {
533 t.Errorf("i18n/money.json has no %s sample", l)
534 }
535 }
536 for l := range table {
537 if !known(l) {
538 t.Errorf("i18n/money.json carries %s, which is no locale the gallery renders", l)
539 }
540 }
541}
542
543// TestAVerbAndItsNounAreTranslatedApart: the row menu's Archive is an
544// action and the topbar's a destination; Spanish says Archivar and
545// Archivo, and the catalog has the two apart because the walker looks
546// the action up under its sense, which EnglishStrings lists as a key
547// of its own beside the noun.
548func TestAVerbAndItsNounAreTranslatedApart(t *testing.T) {
549 files := render(t, "")
550 menu := string(files["day/es/frames/row-menu/links-a-form-and-a-destructive-item.html"])
551 if !strings.Contains(menu, `<button type="submit">Archivar</button>`) {
552 t.Errorf("day/es's row-menu frame does not show the button Archivar")
553 }
554 topbar := string(files["day/es/frames/shell-topbar/"+mustEntry(t, "shell-topbar").Default+".html"])
555 if !regexp.MustCompile(`<a [^>]*>Archivo</a>`).MatchString(topbar) {
556 t.Errorf("day/es's shell-topbar frame does not show the nav link Archivo")
557 }
558 english := englishStrings(t)
559 if english["samples.Archive"+verbSense] != "Archive" || english["samples.Archive"] != "Archive" {
560 t.Errorf("EnglishStrings lists Archive (verb) as %q and Archive as %q; want both, each saying Archive",
561 english["samples.Archive"+verbSense], english["samples.Archive"])
562 }
563}
564
565// TestANounAndItsVerbAreTranslatedApart is the same split the other
566// way round: the seg-tabs group's View names the view a reader picks,
567// and the list's row menu View opens a row. Russian says Вид and
568// Открыть; read under the bare word, the group would be labelled
569// "Open", a command, for every Russian screen reader.
570func TestANounAndItsVerbAreTranslatedApart(t *testing.T) {
571 files := render(t, "")
572 group := string(files["day/ru/frames/seg-tabs/buttons-that-switch-in-place.html"])
573 if !strings.Contains(group, `role="group" aria-label="Вид"`) {
574 t.Errorf("day/ru's seg-tabs buttons frame does not label its group Вид")
575 }
576 rows := string(files["day/ru/frames/list-grid/"+mustEntry(t, "list-grid").Default+".html"])
577 if !strings.Contains(rows, `>Открыть</a>`) {
578 t.Errorf("day/ru's list-grid frame does not show the row menu link Открыть")
579 }
580 english := englishStrings(t)
581 if english["samples.View"+nounSense] != "View" || english["samples.View"] != "View" {
582 t.Errorf("EnglishStrings lists View (noun) as %q and View as %q; want both, each saying View",
583 english["samples.View"+nounSense], english["samples.View"])
584 }
585}
586