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(``) // 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 the  in
// 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, `
")
	shut := strings.Index(rest, "")
	if open < 0 || shut < open {
		t.Fatalf("%s/%s: no  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
//