| 1 | //go:build browser |
| 2 | |
| 3 | // The column's footer row as a reader meets it: under the page, quiet, |
| 4 | // its menus flat, and each menu's panel on the screen however the |
| 5 | // browser places it. |
| 6 | package check |
| 7 | |
| 8 | import ( |
| 9 | "context" |
| 10 | "encoding/json" |
| 11 | "fmt" |
| 12 | "math" |
| 13 | "net/http" |
| 14 | "os" |
| 15 | "strings" |
| 16 | "testing" |
| 17 | "time" |
| 18 | |
| 19 | "github.com/chromedp/cdproto/emulation" |
| 20 | cdppage "github.com/chromedp/cdproto/page" |
| 21 | "github.com/chromedp/chromedp" |
| 22 | |
| 23 | "amadan.net/rastrillo/fiesta/internal/harness" |
| 24 | ) |
| 25 | |
| 26 | // columnDoc is the column example as the rig serves it, its assets |
| 27 | // flattened beside the page. |
| 28 | func columnDoc(t *testing.T) string { |
| 29 | t.Helper() |
| 30 | return parityLinks(defaultMarkup(t, "layout-column")) |
| 31 | } |
| 32 | |
| 33 | // inArabic is a document re-rooted right to left, as an Arabic page |
| 34 | // of the same app would be. |
| 35 | func inArabic(t *testing.T, doc string) string { |
| 36 | t.Helper() |
| 37 | const root = `<html lang="en" dir="ltr">` |
| 38 | if !strings.Contains(doc, root) { |
| 39 | t.Fatalf("the document has no %s to turn right to left", root) |
| 40 | } |
| 41 | return strings.Replace(doc, root, `<html lang="ar" dir="rtl">`, 1) |
| 42 | } |
| 43 | |
| 44 | // TestTheFooterRowSitsUnderThePage: the page's title is the first |
| 45 | // thing on screen and the menus come after the content, at the inline |
| 46 | // end of the column (the right in English, the left in Arabic), side by |
| 47 | // side where they fit and wrapping without a sideways scrollbar where |
| 48 | // they do not; the row is muted, under a hairline in the line colour. |
| 49 | func TestTheFooterRowSitsUnderThePage(t *testing.T) { |
| 50 | doc := columnDoc(t) |
| 51 | rig := sizingRig(t, false, map[string]string{"/": doc, "/rtl": inArabic(t, doc)}) |
| 52 | ctx, cancel := context.WithTimeout(rig.Context(), 90*time.Second) |
| 53 | defer cancel() |
| 54 | for _, size := range [][2]int64{{1280, 800}, {390, 844}, {320, 640}} { |
| 55 | for _, path := range []string{"/", "/rtl"} { |
| 56 | where := fmt.Sprintf("%s at %d", path, size[0]) |
| 57 | mustRun(t, ctx, chromedp.EmulateViewport(size[0], size[1]), chromedp.Navigate(rig.Origin+path), chromedp.WaitVisible("[rst-page-header] h1", chromedp.ByQuery)) |
| 58 | var foot bool |
| 59 | at(t, ctx, `JSON.stringify(!!document.querySelector("main[rst-page] > [rst-page-foot]"))`, &foot) |
| 60 | if !foot { |
| 61 | t.Fatalf("%s: the column page has no footer row in its <main rst-page>", where) |
| 62 | } |
| 63 | var g struct { |
| 64 | Last, RTL bool |
| 65 | TitleTop, CountBottom, FootTop float64 |
| 66 | SummaryTops []float64 |
| 67 | SchemeEnd, ContentEnd float64 |
| 68 | ScrollW, ClientW float64 |
| 69 | Colour, Muted string |
| 70 | BorderWidth, BorderStyle, Border, Line string |
| 71 | } |
| 72 | at(t, ctx, `(() => { |
| 73 | const main = document.querySelector("main[rst-page]"), foot = document.querySelector("[rst-page-foot]"); |
| 74 | const last = main.lastElementChild === foot, f = foot.getBoundingClientRect(), cs = getComputedStyle(foot), rtl = cs.direction === "rtl"; |
| 75 | const end = rtl ? f.left + parseFloat(cs.borderLeftWidth) + parseFloat(cs.paddingLeft) : f.right - parseFloat(cs.borderRightWidth) - parseFloat(cs.paddingRight); |
| 76 | const scheme = foot.querySelector("[rst-scheme] > summary").getBoundingClientRect(); |
| 77 | const probe = document.createElement("span"); probe.style.color = "var(--rst-text-muted)"; probe.style.borderTop = "1px solid var(--rst-line)"; main.append(probe); |
| 78 | const p = getComputedStyle(probe), muted = p.color, line = p.borderTopColor; probe.remove(); |
| 79 | return JSON.stringify({Last: last, RTL: rtl, |
| 80 | TitleTop: document.querySelector("[rst-page-header] h1").getBoundingClientRect().top, |
| 81 | CountBottom: document.querySelector("[rst-count-line]").getBoundingClientRect().bottom, FootTop: f.top, |
| 82 | SummaryTops: [...document.querySelectorAll("[rst-dropdown] > summary")].map(s => s.getBoundingClientRect().top), |
| 83 | SchemeEnd: rtl ? scheme.left : scheme.right, ContentEnd: end, |
| 84 | ScrollW: document.documentElement.scrollWidth, ClientW: document.documentElement.clientWidth, |
| 85 | Colour: cs.color, Muted: muted, BorderWidth: cs.borderTopWidth, BorderStyle: cs.borderTopStyle, Border: cs.borderTopColor, Line: line}); })()`, &g) |
| 86 | if g.RTL != (path == "/rtl") { |
| 87 | t.Fatalf("%s: the footer reads direction rtl=%v; this leg is not the direction it claims", where, g.RTL) |
| 88 | } |
| 89 | if len(g.SummaryTops) != 2 { |
| 90 | t.Fatalf("%s: %d dropdown summaries on the page, want the footer's two", where, len(g.SummaryTops)) |
| 91 | } |
| 92 | for _, top := range g.SummaryTops { |
| 93 | if top <= g.TitleTop { |
| 94 | t.Errorf("%s: a menu's summary starts at %.0f and the title at %.0f; the title must be the first thing on screen", where, top, g.TitleTop) |
| 95 | } |
| 96 | } |
| 97 | if !g.Last { |
| 98 | t.Errorf("%s: the footer row is not the page's last element", where) |
| 99 | } |
| 100 | if g.FootTop < g.CountBottom-0.5 { |
| 101 | t.Errorf("%s: the footer starts at %.1f, above the count line's foot at %.1f", where, g.FootTop, g.CountBottom) |
| 102 | } |
| 103 | if math.Abs(g.SchemeEnd-g.ContentEnd) > 1 { |
| 104 | t.Errorf("%s: the scheme menu ends at %.1f, the footer's content box at %.1f; the menus belong at the row's inline end", where, g.SchemeEnd, g.ContentEnd) |
| 105 | } |
| 106 | if size[0] != 320 && math.Abs(g.SummaryTops[0]-g.SummaryTops[1]) > 0.5 { |
| 107 | t.Errorf("%s: the two summaries start at %.1f and %.1f; at this width they fit on one line", where, g.SummaryTops[0], g.SummaryTops[1]) |
| 108 | } |
| 109 | if size[0] == 320 && g.ScrollW > g.ClientW { |
| 110 | t.Errorf("%s: the page is %.0f wide in a %.0f viewport; the row must wrap, not scroll sideways", where, g.ScrollW, g.ClientW) |
| 111 | } |
| 112 | if g.Colour != g.Muted { |
| 113 | t.Errorf("%s: the footer's text is %s, want the muted text colour %s", where, g.Colour, g.Muted) |
| 114 | } |
| 115 | if g.BorderWidth != "1px" || g.BorderStyle != "solid" || g.Border != g.Line { |
| 116 | t.Errorf("%s: the footer's top border is %s %s %s, want 1px solid %s, the line colour", where, g.BorderWidth, g.BorderStyle, g.Border, g.Line) |
| 117 | } |
| 118 | } |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | // TestTheFootersMenusWearNoFace: in day, light, the footer row's |
| 123 | // summaries stay flat in every state, as the menus in a shell's tail |
| 124 | // do: a quiet row of muted words, not two buttons at the foot of the |
| 125 | // page. The same dropdown in a page of its own wears the summary face, |
| 126 | // so the check can see a face when there is one. Both spellings. |
| 127 | func TestTheFootersMenusWearNoFace(t *testing.T) { |
| 128 | doc := columnDoc(t) |
| 129 | i, j := strings.Index(doc, "<footer rst-page-foot>"), strings.Index(doc, "</footer>") |
| 130 | if i < 0 || j < i { |
| 131 | t.Fatal("the column example has no footer row to spell with classes") |
| 132 | } |
| 133 | classFoot := classSpelling(t, `<main rst-page>`+doc[i:j+len("</footer>")]+`</main>`) |
| 134 | if !strings.Contains(classFoot, `class="rst-page__foot"`) { |
| 135 | t.Fatalf("the class-spelled footer is %.120q; it does not carry .rst-page__foot", classFoot) |
| 136 | } |
| 137 | pages := map[string]string{ |
| 138 | "/": doc, |
| 139 | "/class": sizingDoc("footer, classes", strings.Replace(classFoot, `class="rst-page__foot"`, `class="rst-page__foot" id="class-foot"`, 1)), |
| 140 | "/alone": sizingDoc("a dropdown alone", `<div id="alone">`+dropdownSample(t)+`</div>`), |
| 141 | } |
| 142 | for _, width := range []int64{1280, 390} { |
| 143 | rig := sizingRig(t, false, pages, harness.WithHover()) |
| 144 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 145 | mustRun(t, ctx, chromedp.EmulateViewport(width, 844), emulation.SetEmulatedMedia().WithFeatures([]*emulation.MediaFeature{{Name: "prefers-color-scheme", Value: "light"}})) |
| 146 | mustRun(t, ctx, chromedp.Navigate(rig.Origin+"/alone"), chromedp.WaitVisible("#alone", chromedp.ByQuery)) |
| 147 | still(t, ctx) |
| 148 | if bg, sh := computed(t, ctx, "#alone > [rst-dropdown] > summary", "background-color"), computed(t, ctx, "#alone > [rst-dropdown] > summary", "box-shadow"); bg == "rgba(0, 0, 0, 0)" && sh == "none" { |
| 149 | t.Errorf("%d: CONTROL: the dropdown alone is %s with box-shadow %q, no face; the check below cannot see one", width, bg, sh) |
| 150 | } |
| 151 | for _, page := range []struct{ path, sel string }{ |
| 152 | {"/", "[rst-page-foot] [rst-locale] > summary"}, |
| 153 | {"/", "[rst-page-foot] [rst-scheme] > summary"}, |
| 154 | {"/class", "#class-foot .rst-scheme > summary"}, |
| 155 | } { |
| 156 | mustRun(t, ctx, chromedp.Navigate(rig.Origin+page.path), chromedp.WaitVisible(page.sel, chromedp.ByQuery)) |
| 157 | still(t, ctx) |
| 158 | var hov bool |
| 159 | at(t, ctx, `JSON.stringify(matchMedia("(hover: hover)").matches)`, &hov) |
| 160 | if !hov { |
| 161 | t.Fatalf("%d: (hover: hover) is false; the face's hover rules cannot be read here", width) |
| 162 | } |
| 163 | for _, states := range [][]string{{}, {"hover"}, {"hover", "active"}, {"active"}} { |
| 164 | forceState(t, ctx, page.sel, states...) |
| 165 | if bg, sh := computed(t, ctx, page.sel, "background-color"), computed(t, ctx, page.sel, "box-shadow"); bg != "rgba(0, 0, 0, 0)" || sh != "none" { |
| 166 | t.Errorf("%d: footer summary %s %v is %s with box-shadow %q; want no face (rgba(0, 0, 0, 0), none)", width, page.sel, states, bg, sh) |
| 167 | } |
| 168 | forceState(t, ctx, page.sel) |
| 169 | } |
| 170 | } |
| 171 | cancel() |
| 172 | } |
| 173 | } |
| 174 | |
| 175 | // longestLabels is the pair of footer labels, Language and Colour |
| 176 | // scheme, of the left-to-right locale whose two summaries are widest |
| 177 | // on screen, read from the gallery's sample catalogs (the words the |
| 178 | // column demo shows in each language), measured in ctx's browser. |
| 179 | func longestLabels(t *testing.T, ctx context.Context, origin string) (lang, language, scheme string) { |
| 180 | t.Helper() |
| 181 | var pairs []struct{ Lang, Language, Scheme string } |
| 182 | for _, locale := range []string{"bn", "es", "ga", "hi", "ja", "pt-PT", "ru", "vi", "yue", "zh-Hans"} { |
| 183 | language, scheme := sampleWord(t, locale, "Language"), sampleWord(t, locale, "Colour scheme") |
| 184 | pairs = append(pairs, struct{ Lang, Language, Scheme string }{locale, language, scheme}) |
| 185 | } |
| 186 | raw, _ := json.Marshal(pairs) |
| 187 | var widest int |
| 188 | mustRun(t, ctx, chromedp.EmulateViewport(1280, 800), chromedp.Navigate(origin+"/measure"), chromedp.WaitReady("[rst-page]", chromedp.ByQuery)) |
| 189 | at(t, ctx, `(() => { const pairs = `+string(raw)+`, page = document.querySelector("[rst-page]"); let best = -1, at = 0; |
| 190 | pairs.forEach((p, i) => { const f = document.createElement("footer"); f.setAttribute("rst-page-foot", ""); f.lang = p.Lang; |
| 191 | for (const w of [p.Language, p.Scheme]) { const d = document.createElement("details"); d.setAttribute("rst-dropdown", ""); const s = document.createElement("summary"); s.textContent = w; d.append(s); f.append(d); } |
| 192 | page.append(f); const w = [...f.querySelectorAll("summary")].reduce((n, s) => n + s.getBoundingClientRect().width, 0); f.remove(); |
| 193 | if (w > best) { best = w; at = i; } }); |
| 194 | return JSON.stringify(at); })()`, &widest) |
| 195 | return pairs[widest].Lang, pairs[widest].Language, pairs[widest].Scheme |
| 196 | } |
| 197 | |
| 198 | // sampleWord is word in locale's sample catalog, the translation the |
| 199 | // gallery puts on its pages. |
| 200 | func sampleWord(t *testing.T, locale, word string) string { |
| 201 | t.Helper() |
| 202 | raw, err := os.ReadFile("../internal/gallery/i18n/samples." + locale + ".json") |
| 203 | if err != nil { |
| 204 | t.Fatal(err) |
| 205 | } |
| 206 | var catalog map[string]string |
| 207 | if err := json.Unmarshal(raw, &catalog); err != nil { |
| 208 | t.Fatalf("samples.%s.json: %v", locale, err) |
| 209 | } |
| 210 | got, ok := catalog[word] |
| 211 | if !ok || got == "" { |
| 212 | t.Fatalf("samples.%s.json has no %q", locale, word) |
| 213 | } |
| 214 | return got |
| 215 | } |
| 216 | |
| 217 | // labelled is the column document with its two footer summaries |
| 218 | // reading language and scheme, in lang, in dir. |
| 219 | func labelled(t *testing.T, doc, lang, dir, language, scheme string) string { |
| 220 | t.Helper() |
| 221 | for _, swap := range [][2]string{ |
| 222 | {`<html lang="en" dir="ltr">`, `<html lang="` + lang + `" dir="` + dir + `">`}, |
| 223 | {`<summary>Language<`, `<summary>` + language + `<`}, |
| 224 | {`<summary>Colour scheme<`, `<summary>` + scheme + `<`}, |
| 225 | } { |
| 226 | if !strings.Contains(doc, swap[0]) { |
| 227 | t.Fatalf("the column document has no %s to relabel", swap[0]) |
| 228 | } |
| 229 | doc = strings.Replace(doc, swap[0], swap[1], 1) |
| 230 | } |
| 231 | return doc |
| 232 | } |
| 233 | |
| 234 | // TestTheFootersMenusOpenOnScreen: on a phone, scrolled to the foot of |
| 235 | // the page, each footer menu opens on the screen, with the longest |
| 236 | // labels a left-to-right translation gives the row and with Arabic's, |
| 237 | // right to left. Where the browser anchors a panel to its summary it |
| 238 | // flips it up and over as it must. Where it cannot, a panel hangs from |
| 239 | // the box that holds it, and in the row Language is not at the end: |
| 240 | // hung from its own <details>, its panel, at least 176px wide, runs |
| 241 | // past the opposite edge at 320. So the row holds every panel, under |
| 242 | // it and flush with its inline end, whichever menu it belongs to, with |
| 243 | // anchor.js running and with no script at all, in both spellings, and |
| 244 | // the page grows to hold the panel so it can be scrolled to. A panel |
| 245 | // at the row's inline start would be inside the row and on the screen |
| 246 | // too, and under nothing it belongs to. |
| 247 | func TestTheFootersMenusOpenOnScreen(t *testing.T) { |
| 248 | doc := columnDoc(t) |
| 249 | noAnchor := tokensWithoutAnchorPositioning(t) |
| 250 | measure := sizingRig(t, false, map[string]string{"/measure": sizingDoc("measure", `<main rst-page></main>`)}) |
| 251 | mctx, mcancel := context.WithTimeout(measure.Context(), 30*time.Second) |
| 252 | lang, language, scheme := longestLabels(t, mctx, measure.Origin) |
| 253 | mcancel() |
| 254 | t.Logf("the widest left-to-right labels are %s's: %q and %q", lang, language, scheme) |
| 255 | ltr := labelled(t, doc, lang, "ltr", language, scheme) |
| 256 | rtl := labelled(t, doc, "ar", "rtl", sampleWord(t, "ar", "Language"), sampleWord(t, "ar", "Colour scheme")) |
| 257 | for _, leg := range []struct { |
| 258 | name string |
| 259 | anchor, scripts bool |
| 260 | }{ |
| 261 | {"anchor positioning", true, true}, |
| 262 | {"no anchor positioning, anchor.js", false, true}, |
| 263 | {"no anchor positioning, no scripts", false, false}, |
| 264 | } { |
| 265 | t.Run(leg.name, func(t *testing.T) { |
| 266 | pages := map[string]string{"/ltr": ltr, "/rtl": rtl, "/ltr-class": classSpelling(t, ltr), "/rtl-class": classSpelling(t, rtl)} |
| 267 | if !leg.anchor { |
| 268 | for path, page := range pages { |
| 269 | pages[path] = strings.Replace(page, `href="tokens.css"`, `href="tokens-noanchor.css"`, 1) |
| 270 | } |
| 271 | } |
| 272 | rig := harness.New(t, func(string) http.Handler { |
| 273 | mux := sizingMux(t, pages) |
| 274 | mux.HandleFunc("GET /tokens-noanchor.css", func(w http.ResponseWriter, _ *http.Request) { |
| 275 | w.Header().Set("Content-Type", "text/css") |
| 276 | fmt.Fprint(w, noAnchor) |
| 277 | }) |
| 278 | return mux |
| 279 | }, harness.WithCoarsePointer()) |
| 280 | ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second) |
| 281 | defer cancel() |
| 282 | if !leg.anchor && leg.scripts { |
| 283 | mustRun(t, ctx, chromedp.ActionFunc(func(ctx context.Context) error { |
| 284 | _, err := cdppage.AddScriptToEvaluateOnNewDocument(withoutAnchorSupport).Do(ctx) |
| 285 | return err |
| 286 | })) |
| 287 | } |
| 288 | if !leg.scripts { |
| 289 | mustRun(t, ctx, emulation.SetScriptExecutionDisabled(true)) |
| 290 | } |
| 291 | for _, size := range [][2]int64{{390, 640}, {320, 568}} { |
| 292 | for _, path := range []string{"/ltr", "/rtl", "/ltr-class", "/rtl-class"} { |
| 293 | for _, menu := range []string{"rst-locale", "rst-scheme"} { |
| 294 | where := fmt.Sprintf("%s at %dx%d, %s", path, size[0], size[1], menu) |
| 295 | details, panel := "[rst-page-foot] > ["+menu+"]", "[rst-dropdown-menu]" |
| 296 | if strings.HasSuffix(path, "-class") { |
| 297 | details, panel = ".rst-page__foot > ."+menu, ".rst-dropdown__menu" |
| 298 | } |
| 299 | mustRun(t, ctx, chromedp.EmulateViewport(size[0], size[1]), chromedp.Navigate(rig.Origin+path), chromedp.WaitVisible(details, chromedp.ByQuery)) |
| 300 | var flips bool |
| 301 | at(t, ctx, `JSON.stringify(document.documentElement.hasAttribute("data-rst-flip-menus"))`, &flips) |
| 302 | if want := !leg.anchor && leg.scripts; flips != want { |
| 303 | t.Fatalf("%s: <html> data-rst-flip-menus is %v, want %v; anchor.js is not doing what this leg needs", where, flips, want) |
| 304 | } |
| 305 | RequireAnchorPositioning(t, ctx, where, details+" > "+panel, leg.anchor) |
| 306 | mustRun(t, ctx, chromedp.Evaluate(`window.scrollTo(0, document.documentElement.scrollHeight)`, nil), |
| 307 | chromedp.Click(details+" > summary", chromedp.ByQuery)) |
| 308 | if leg.scripts { |
| 309 | // anchor.js places a panel on toggle, which |
| 310 | // fires after the click. |
| 311 | mustRun(t, ctx, chromedp.Evaluate(`new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))`, nil, awaitPromise)) |
| 312 | } |
| 313 | var g struct { |
| 314 | Open bool |
| 315 | L, R, T, B, FL, FR, FB, VW float64 |
| 316 | VH, SY, SH float64 |
| 317 | PanelEnd, RowEnd float64 |
| 318 | } |
| 319 | // The row's inline end is its padding box's, the |
| 320 | // edge an absolute panel's inset-inline-end |
| 321 | // measures from: the right in LTR, the left in RTL. |
| 322 | at(t, ctx, `(() => { const d = document.querySelector(`+jsStr(details)+`), p = d.querySelector(":scope > `+panel+`").getBoundingClientRect(), row = d.parentElement, f = row.getBoundingClientRect(), cs = getComputedStyle(row), rtl = cs.direction === "rtl"; |
| 323 | return JSON.stringify({Open: d.open, L: p.left, R: p.right, T: p.top, B: p.bottom, FL: f.left, FR: f.right, FB: f.bottom, |
| 324 | VW: document.documentElement.clientWidth, VH: innerHeight, SY: scrollY, SH: document.documentElement.scrollHeight, |
| 325 | PanelEnd: rtl ? p.left : p.right, RowEnd: rtl ? f.left + parseFloat(cs.borderLeftWidth) : f.right - parseFloat(cs.borderRightWidth)}); })()`, &g) |
| 326 | if !g.Open { |
| 327 | t.Fatalf("%s: the menu did not open; this leg proves nothing", where) |
| 328 | } |
| 329 | if g.L < -0.5 || g.R > g.VW+0.5 { |
| 330 | t.Errorf("%s: the panel spans %.1f to %.1f of a %.0fpx viewport; it must open on the screen", where, g.L, g.R, g.VW) |
| 331 | } |
| 332 | if leg.anchor { |
| 333 | if g.T < -0.5 || g.B > g.VH+0.5 { |
| 334 | t.Errorf("%s: the panel runs from %.1f to %.1f of a %.0fpx viewport; anchored, it must flip to stay on the screen", where, g.T, g.B, g.VH) |
| 335 | } |
| 336 | continue |
| 337 | } |
| 338 | if g.L < g.FL-0.5 || g.R > g.FR+0.5 { |
| 339 | t.Errorf("%s: the panel spans %.1f to %.1f and the footer %.1f to %.1f; the row must hold its panel", where, g.L, g.R, g.FL, g.FR) |
| 340 | } |
| 341 | if math.Abs(g.PanelEnd-g.RowEnd) > 0.5 { |
| 342 | t.Errorf("%s: the panel's inline end is at %.1f and the row's at %.1f; without anchoring every panel opens flush with the row's inline end", where, g.PanelEnd, g.RowEnd) |
| 343 | } |
| 344 | if g.T < g.FB-0.5 { |
| 345 | t.Errorf("%s: the panel starts at %.1f, above the footer's foot at %.1f; it must open under the row", where, g.T, g.FB) |
| 346 | } |
| 347 | if g.B+g.SY > g.SH+0.5 { |
| 348 | t.Errorf("%s: the panel ends at %.1f of the document, which is %.0f tall; the page must grow to hold it", where, g.B+g.SY, g.SH) |
| 349 | } |
| 350 | } |
| 351 | } |
| 352 | } |
| 353 | }) |
| 354 | } |
| 355 | } |
| 356 | |
| 357 | // TestAnEmptyFooterRowDrawsNothing: an app with one language and no |
| 358 | // scheme menu renders the row with nothing in it but the template's |
| 359 | // whitespace. That row is not drawn: no hairline and no gap under the |
| 360 | // page. :empty would miss it, since whitespace is a child. The control |
| 361 | // is the same row with one menu in it, which is drawn. Both spellings. |
| 362 | func TestAnEmptyFooterRowDrawsNothing(t *testing.T) { |
| 363 | menu := `<details rst-dropdown><summary>Language</summary><div rst-dropdown-menu><a href="#">English</a></div></details>` |
| 364 | body := `<main rst-page><p>Content</p>` + |
| 365 | `<footer rst-page-foot id="empty">` + "\n \n" + `</footer>` + |
| 366 | `<footer rst-page-foot id="held">` + menu + `</footer></main>` + |
| 367 | `<main class="rst-page"><p>Content</p>` + |
| 368 | `<footer class="rst-page__foot" id="empty-c">` + "\n \n" + `</footer>` + |
| 369 | `<footer class="rst-page__foot" id="held-c">` + strings.NewReplacer("rst-dropdown-menu", `class="rst-dropdown__menu"`, "rst-dropdown", `class="rst-dropdown"`).Replace(menu) + `</footer></main>` |
| 370 | rig := sizingRig(t, false, map[string]string{"/": sizingDoc("empty footer", body)}) |
| 371 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 372 | defer cancel() |
| 373 | mustRun(t, ctx, chromedp.EmulateViewport(1280, 800), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible("#held", chromedp.ByQuery)) |
| 374 | for sel, want := range map[string]string{"#empty": "none", "#held": "flex", "#empty-c": "none", "#held-c": "flex"} { |
| 375 | if got := computed(t, ctx, sel, "display"); got != want { |
| 376 | t.Errorf("footer %s is display %s, want %s", sel, got, want) |
| 377 | } |
| 378 | } |
| 379 | } |
| 380 | |