package gallery import ( "html" "regexp" "sort" "strings" "sync" "testing" "testing/fstest" xhtml "golang.org/x/net/html" "amadan.net/rastrillo/fiesta" "amadan.net/rastrillo/fiesta/example" ) // render is one tree at one mount, failed loudly. Render is // deterministic and a tree is thirty-six directories, so one render // per mount serves every test in the package; each gets its own copy // of the map, so a test that adds files beside the tree changes // nothing another reads. func render(t *testing.T, mount string) map[string][]byte { t.Helper() renderMu.Lock() defer renderMu.Unlock() files, ok := rendered[mount] if !ok { var err error if files, err = Render(fiesta.FS(), Options{Mount: mount}); err != nil { t.Fatalf("Render: %v", err) } rendered[mount] = files } out := make(map[string][]byte, len(files)) for name, body := range files { out[name] = body } return out } var ( renderMu sync.Mutex rendered = map[string]map[string][]byte{} ) func manifest(t *testing.T) *example.Manifest { t.Helper() m, err := example.Load(fiesta.FS()) if err != nil { t.Fatal(err) } return m } // attrsOf walks doc and calls fn for every element with its lower-case // tag name and attributes, so a test reads URLs the way a browser // would rather than with a regexp over the bytes. func attrsOf(t *testing.T, doc []byte, fn func(tag string, attrs map[string]string)) { t.Helper() z := xhtml.NewTokenizer(strings.NewReader(string(doc))) for { switch z.Next() { case xhtml.ErrorToken: return case xhtml.StartTagToken, xhtml.SelfClosingTagToken: name, more := z.TagName() attrs := map[string]string{} for more { var k, v []byte k, v, more = z.TagAttr() attrs[string(k)] = string(v) } fn(string(name), attrs) } } } // galleryURLs are the URLs the gallery owns on one page: every // stylesheet, script, image and frame, and every link marked // data-gallery-nav. A sample's own links are not among them. func galleryURLs(t *testing.T, doc []byte) []string { t.Helper() var out []string attrsOf(t, doc, func(tag string, attrs map[string]string) { switch tag { case "link": out = append(out, attrs["href"]) case "script", "img", "iframe": if v, ok := attrs["src"]; ok { out = append(out, v) } case "a": if _, ok := attrs["data-gallery-nav"]; ok { out = append(out, attrs["href"]) } } }) return out } // TestRenderWritesEveryStateInEveryTheme: the tree holds every frame // document frameFiles names for every example, per theme and locale, // and no other frame; and the gallery page, the index, the tokens and // the icons pages beside them. Preservation: part 3's test, walking // Locales in place of the two directions and counting frames through // the frameFiles seam. func TestRenderWritesEveryStateInEveryTheme(t *testing.T) { files := render(t, "") s := newTestSite(t, fiesta.FS(), "") m := s.m perDir := 0 for _, theme := range fiesta.ThemeNames() { for _, locale := range Locales { dir := theme + "/" + locale + "/" for _, page := range []string{"index.html", "tokens.html", "icons.html"} { if _, ok := files[dir+page]; !ok { t.Errorf("no %s%s", dir, page) } } for _, e := range m.Examples { if _, ok := files[dir+e.Name+".html"]; !ok { t.Errorf("no gallery page %s%s.html", dir, e.Name) } for _, f := range s.frameFiles(e) { if theme == fiesta.ThemeNames()[0] && locale == "en" { perDir++ } if _, ok := files[dir+f]; !ok { t.Errorf("no frame %s%s", dir, f) } } } } } if perDir == 0 { t.Fatal("frameFiles names no document; the loop above checked nothing") } frames := 0 for name := range files { if strings.Contains(name, "/frames/") { frames++ } } if want := perDir * len(fiesta.ThemeNames()) * len(Locales); frames != want { t.Errorf("the tree holds %d frame documents, want %d (%d per directory x %d themes x %d locales)", frames, want, perDir, len(fiesta.ThemeNames()), len(Locales)) } if _, ok := files["index.html"]; !ok { t.Error("no index.html at the tree root") } } // TestEveryGalleryURLResolves: at a mount other than the root, every // URL the gallery owns is an absolute path under that mount naming a // file the render wrote. A relative URL, or one written for the root, // would be a page that works on a developer's machine and nowhere else. func TestEveryGalleryURLResolves(t *testing.T) { const mount = "/x" files := render(t, mount) checked, frames := 0, 0 for name, body := range files { if !strings.HasSuffix(name, ".html") { continue } for _, u := range galleryURLs(t, body) { checked++ if !strings.HasPrefix(u, mount+"/") { t.Errorf("%s links %q, which is not under the mount %s/", name, u, mount) continue } path, _, _ := strings.Cut(strings.TrimPrefix(u, mount+"/"), "#") if _, ok := files[path]; !ok { t.Errorf("%s links %q, and the render wrote no %s", name, u, path) } if strings.Contains(path, "/frames/") { frames++ } } } if checked == 0 || frames == 0 { t.Fatalf("checked %d URLs and %d of them frames; the walk above read no gallery page", checked, frames) } } // highlighted is a highlight element, which a reader does not see and // the copied text does not hold. var highlighted = regexp.MustCompile(`?ds-[tavxs]>`) // htmlTab returns the text a reader sees in one state's HTML tab on its // gallery page: the highlighted source with its elements removed and // its entities decoded. The tab is the first source block after the // state's marker, read from its
, so thein // the wrapper line above the block is never taken for it. func htmlTab(t *testing.T, page []byte, name, state string) string { t.Helper() marker := `data-gallery-state="` + name + "/" + state + `"` i := strings.Index(string(page), marker) if i < 0 { t.Fatalf("%s/%s: the gallery page has no %s", name, state, marker) } rest := string(page)[i:] pre := strings.Index(rest, `") if open < 0 || shut < open { t.Fatalf("%s/%s: no") shut := strings.Index(rest, "block after its marker", name, state) } return html.UnescapeString(highlighted.ReplaceAllString(rest[open+len(""):shut], "")) } // TestSampleURLsStayInTheMarkup: the routes a sample stands for are // shown as written in its HTML tab, where a reader copies them from, // and none of them is the href or action of anything in a frame // document, where a click or a submit would otherwise request it. In a // frame's body an href or action is either a fragment or a link the // gallery owns, marked data-gallery-nav and under the mount, which // TestEveryGalleryURLResolves then resolves; never a sample route. // Rendered at a mount, so an absolute route could not pass as the // gallery's. Preservation: part 3's test on day/en, with the one rule // the live shell demos need learnt; its control stays the sample-route // case. func TestSampleURLsStayInTheMarkup(t *testing.T) { const mount = "/x" files := render(t, mount) m := manifest(t) theme := fiesta.ThemeNames()[0] routes := map[string]bool{} tabs := 0 for _, e := range m.Examples { page, ok := files[theme+"/en/"+e.Name+".html"] if !ok { t.Fatalf("no gallery page for %s", e.Name) } for _, s := range e.States { if len(s.SampleURLs) == 0 { continue } tabs++ tab := htmlTab(t, page, e.Name, s.ID) for _, u := range s.SampleURLs { routes[u] = true if !strings.Contains(tab, u) { t.Errorf("%s/%s: the HTML tab does not show %q", e.Name, s.ID, u) } } } } if tabs == 0 { t.Fatal("no state declares a sample URL; the check above read nothing") } frames, seen := 0, 0 for name, body := range files { if !strings.Contains(name, "/frames/") { continue } frames++ inBody := false attrsOf(t, body, func(tag string, attrs map[string]string) { if tag == "body" { inBody = true } if !inBody { return } for _, attr := range []string{"href", "action"} { v, ok := attrs[attr] if !ok { continue } seen++ if routes[v] { t.Errorf("%s: <%s %s=%q> would request a sample route", name, tag, attr, v) } if _, nav := attrs["data-gallery-nav"]; nav { if !strings.HasPrefix(v, mount+"/") { t.Errorf("%s: <%s %s=%q> is the gallery's and not under the mount", name, tag, attr, v) } } else if !strings.HasPrefix(v, "#") { t.Errorf("%s: <%s %s=%q> is not a fragment", name, tag, attr, v) } } }) } if frames == 0 || seen == 0 { t.Fatalf("%d frames with %d hrefs and actions between them; the check above read nothing", frames, seen) } } // placeholder is how a label that has not been through copy review is // written while the code is built around it. None may reach a page. const placeholder = "TODO-COPY:" // TestTheGalleryCarriesNoPlaceholderCopy fails while any label is still // a placeholder: the table itself, label by label, since a label no // page renders today is still a label; and the render, so the // stylesheet and scripts it embeds are read too. func TestTheGalleryCarriesNoPlaceholderCopy(t *testing.T) { for id, v := range text { if strings.HasPrefix(v, placeholder) { t.Errorf("%s is still a placeholder, waiting for copy review", id) } } files := render(t, "") if len(files) == 0 { t.Fatal("the render is empty; the scan below would read nothing") } var names []string for name := range files { names = append(names, name) } sort.Strings(names) left := map[string]bool{} for _, name := range names { for _, m := range regexp.MustCompile(placeholder+`[a-z0-9.-]+`).FindAll(files[name], -1) { left[string(m)] = true } } if len(left) > 0 { var ids []string for id := range left { ids = append(ids, id) } sort.Strings(ids) t.Errorf("%d placeholders are still in the render, waiting for copy review:\n %s", len(ids), strings.Join(ids, "\n ")) } } // TestRTLDocumentsReadRightToLeft: every document under a theme's ar/ // says lang="ar" dir="rtl" on its , gallery pages and frames // alike, and every document under en/ says lang="en" dir="ltr". The // axe leg over the Arabic tree would otherwise scan left-to-right // copies. Preservation: part 3's test reading day/ar/ in place of the // rtl/ twin. func TestRTLDocumentsReadRightToLeft(t *testing.T) { files := render(t, "") rtl, ltr := 0, 0 for name, body := range files { if !strings.HasSuffix(name, ".html") { continue } tag := htmlTag.Find(body) if tag == nil { t.Errorf("%s has no tag", name) continue } var want string switch { case strings.Contains(name, "/ar/"): want = `lang="ar" dir="rtl"` rtl++ case strings.Contains(name, "/en/"): want = `lang="en" dir="ltr"` ltr++ default: continue } if !strings.Contains(string(tag), want) { t.Errorf("%s: %s does not say %s", name, tag, want) } } if rtl == 0 || ltr == 0 { t.Fatalf("%d right-to-left and %d left-to-right documents; the loop above read one side only", rtl, ltr) } } // TestTheHeaderLiftsOnlyTheSchemeMenu: when the scheme-menu example // shows its menu inside a row, as the footer row's states will, the // header lifts the menu and not the row around it. A lifted //