| 1 | //go:build browser |
| 2 | |
| 3 | // Browser drives for the console shell — the fourth page frame, and the |
| 4 | // only one with two pieces of chrome to fold rather than one. |
| 5 | // |
| 6 | // Run with the rest of the family: |
| 7 | // |
| 8 | // go test -tags browser ./check/ |
| 9 | // |
| 10 | // Every drive in this file carries a CONTROL: a second page, served by |
| 11 | // the same mux, measured by the same JavaScript, in the same browser, |
| 12 | // in the same run — differing from the page under test by the few |
| 13 | // declarations that ARE the thing being gated. The control has to come |
| 14 | // out the other way. Design spec §7-v2: a drive that finds nothing must |
| 15 | // be demonstrated finding something, or its zero cannot be told apart |
| 16 | // from a broken instrument. This project has shipped twelve gates that |
| 17 | // gated nothing, and the most recent survived independent reproduction |
| 18 | // by the simple expedient of nobody asking it a question whose answer |
| 19 | // was already known. |
| 20 | // |
| 21 | // There is no script under test here. The pages are markup, tokens.css |
| 22 | // and a theme; the shell's own <script defer> tags resolve to addresses |
| 23 | // this mux does not serve, exactly as the other shell drives leave |
| 24 | // them. Every state change below is a click on a <summary>. The pages |
| 25 | // are the ones rastrillo's drives rendered at the seed commit, read through |
| 26 | // fixture (testdata/fixtures/). |
| 27 | package check |
| 28 | |
| 29 | import ( |
| 30 | "context" |
| 31 | "encoding/json" |
| 32 | "fmt" |
| 33 | "net/http" |
| 34 | "slices" |
| 35 | "strings" |
| 36 | "testing" |
| 37 | "time" |
| 38 | |
| 39 | "github.com/chromedp/chromedp" |
| 40 | |
| 41 | "amadan.net/rastrillo/fiesta" |
| 42 | "amadan.net/rastrillo/fiesta/internal/harness" |
| 43 | ) |
| 44 | |
| 45 | // consoleReading is one measurement of a console page. Everything here |
| 46 | // is read off getBoundingClientRect and window.innerHeight — geometry |
| 47 | // the engine computed, never a claim about the text of a rule. |
| 48 | type consoleReading struct { |
| 49 | // The chrome, and how much of it a reader can see. |
| 50 | MenuShown bool |
| 51 | MenuOpen bool |
| 52 | TailShown bool |
| 53 | RailShown bool |
| 54 | VisibleSummaries int |
| 55 | AccountMenu bool |
| 56 | BackShown bool |
| 57 | |
| 58 | // The wide frame. |
| 59 | TailEndPx int |
| 60 | BarAboveRail bool |
| 61 | RailBeforeMain bool |
| 62 | |
| 63 | // The viewport question. NavHeight is the border box, because |
| 64 | // max-block-size is a promise about the window and the window |
| 65 | // measures border boxes — that is the whole of the regression this |
| 66 | // gate exists for. |
| 67 | Viewport int |
| 68 | NavTop int |
| 69 | NavBottom int |
| 70 | NavHeight int |
| 71 | NavOverhang int |
| 72 | NavScrolls int |
| 73 | |
| 74 | Overflow int |
| 75 | Dir string |
| 76 | |
| 77 | // The legacy-engine readings. RailBottom against MainBottom is the |
| 78 | // discriminator between "the rail is in its declared grid area" |
| 79 | // (which spans the page row AND the footer row) and "the rail was |
| 80 | // auto-placed into row 2" (which stops where main stops). They look |
| 81 | // identical in a screenshot. |
| 82 | RailBottom int |
| 83 | MainBottom int |
| 84 | RailBorderEnd string |
| 85 | NavLinksShown int |
| 86 | } |
| 87 | |
| 88 | // consoleMeasure is the one instrument. Both drives use it, and so does |
| 89 | // each drive's control, which is what makes the control a control: a |
| 90 | // different reading of the same measurement rather than a different |
| 91 | // measurement. |
| 92 | const consoleMeasure = `(() => { |
| 93 | const shown = el => { if (!el) return false; const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; }; |
| 94 | const de = document.documentElement; |
| 95 | const bar = document.querySelector("[rst-shell-bar]"); |
| 96 | const menu = document.querySelector("[rst-shell-menu]"); |
| 97 | const rail = document.querySelector("[rst-shell-rail]"); |
| 98 | const nav = document.querySelector("[rst-shell-nav]"); |
| 99 | const account = document.querySelector("[rst-shell-account]"); |
| 100 | const locale = document.querySelector("#bar-locale"); |
| 101 | const main = document.querySelector("[rst-shell-main]"); |
| 102 | const panel = account.querySelector("[rst-dropdown-menu]"); |
| 103 | const br = bar.getBoundingClientRect(); |
| 104 | const rr = rail.getBoundingClientRect(); |
| 105 | const nr = nav.getBoundingClientRect(); |
| 106 | const mr = main.getBoundingClientRect(); |
| 107 | // The inline-end gap of the LAST thing on the bar, whichever of the |
| 108 | // two menus that is: the current layout puts the language first and |
| 109 | // the account last, a layout from before the profile the other way |
| 110 | // round. margin-inline-start: auto pushes the pair to the end, and |
| 111 | // measuring the one that is not last would read the other's width |
| 112 | // and call it a layout. |
| 113 | const lr = locale.getBoundingClientRect(), ar = account.getBoundingClientRect(); |
| 114 | const ltr = getComputedStyle(de).direction !== "rtl"; |
| 115 | const end = ltr ? Math.max(lr.right, ar.right) : Math.min(lr.left, ar.left); |
| 116 | // Every <summary> a reader can see anywhere in the document. The |
| 117 | // claim "one control" is a count, not an assertion about one element: |
| 118 | // a second disclosure added beside this one would satisfy every |
| 119 | // other reading here and fail only this. |
| 120 | let summaries = 0; |
| 121 | document.querySelectorAll("summary").forEach(s => { if (shown(s)) summaries++; }); |
| 122 | return JSON.stringify({ |
| 123 | MenuShown: shown(menu.querySelector("summary")), |
| 124 | MenuOpen: menu.open, |
| 125 | TailShown: shown(account) || shown(locale), |
| 126 | RailShown: shown(rail), |
| 127 | VisibleSummaries: summaries, |
| 128 | AccountMenu: shown(panel), |
| 129 | BackShown: shown(document.querySelector("[rst-shell-back] a")), |
| 130 | TailEndPx: Math.round(ltr ? br.right - end : end - br.left), |
| 131 | BarAboveRail: Math.round(br.bottom) <= Math.round(rr.top) + 1, |
| 132 | RailBeforeMain: ltr ? Math.round(rr.right) <= Math.round(mr.left) + 1 |
| 133 | : Math.round(rr.left) >= Math.round(mr.right) - 1, |
| 134 | Viewport: window.innerHeight, |
| 135 | NavTop: Math.round(nr.top), |
| 136 | NavBottom: Math.round(nr.bottom), |
| 137 | NavHeight: Math.round(nr.height), |
| 138 | NavOverhang: Math.round(nr.bottom - window.innerHeight), |
| 139 | NavScrolls: nav.scrollHeight - nav.clientHeight, |
| 140 | Overflow: de.scrollWidth - de.clientWidth, |
| 141 | Dir: getComputedStyle(de).direction, |
| 142 | RailBottom: Math.round(rr.bottom), |
| 143 | MainBottom: Math.round(mr.bottom), |
| 144 | RailBorderEnd: getComputedStyle(rail).borderInlineEndWidth, |
| 145 | NavLinksShown: [...nav.querySelectorAll("a")].filter(shown).length |
| 146 | }); |
| 147 | })()` |
| 148 | |
| 149 | // consoleControlCSS is the control's whole difference from the page |
| 150 | // under test, and each half re-creates a bug this project has actually |
| 151 | // shipped rather than one imagined for the occasion. |
| 152 | // |
| 153 | // The first half defeats the rule that hides the rail on a narrow |
| 154 | // content page — the failure "the tail folded and the rail did not", |
| 155 | // which is a phone page with its whole navigation stacked above the |
| 156 | // content it was opened for. |
| 157 | // |
| 158 | // The second half is the rail regression itself, verbatim: a box sized |
| 159 | // against the viewport that also carries padding, as a CONTENT box, so |
| 160 | // its border box is 32px taller than the window it promised to fit. |
| 161 | // The gate that missed it asked where the person sat inside the rail |
| 162 | // and got a true answer about a rail hanging off the screen. |
| 163 | const consoleControlCSS = ` |
| 164 | @media (max-width: 799px) { |
| 165 | [rst-shell-console] > [rst-shell-rail] { display: flex !important; } |
| 166 | } |
| 167 | @media (min-width: 800px) { |
| 168 | [rst-shell-console] > [rst-shell-rail] > [rst-shell-nav] { |
| 169 | block-size: 100dvh !important; |
| 170 | box-sizing: content-box !important; |
| 171 | max-block-size: none !important; |
| 172 | padding: 1rem !important; |
| 173 | } |
| 174 | }` |
| 175 | |
| 176 | // consolePage is the console shell as rastrillo's drive rendered it, by |
| 177 | // case, and its control twin. Every case has two account links, the |
| 178 | // language menu as #bar-locale (English and Gaeilge) and a foot; the |
| 179 | // cases differ in their nav, direction and view: "ltr" is Posts (the |
| 180 | // current page), Drafts and Settings; "ltr-index" the same with the |
| 181 | // view written as index; "rtl" is Posts and Drafts, mirrored; "tall" |
| 182 | // is the twenty-link nav that cannot fit a 420px window. |
| 183 | func consolePage(t *testing.T, kase string) (page, control string) { |
| 184 | t.Helper() |
| 185 | return consolePageFrom(t, fixture(t, "console_shell_browser_test.go", "consolePage", kase)) |
| 186 | } |
| 187 | |
| 188 | // legacyConsolePage is consolePage's "ltr" case through the console |
| 189 | // layout as it was before the phone index, whose rail is gated on the |
| 190 | // Menu's [open]. |
| 191 | func legacyConsolePage(t *testing.T) (page, control string) { |
| 192 | t.Helper() |
| 193 | return consolePageFrom(t, fixture(t, "console_shell_browser_test.go", "legacyConsolePage", "ltr")) |
| 194 | } |
| 195 | |
| 196 | // consolePageFrom is a page and its control twin: the page with |
| 197 | // consoleControlCSS in a <style> before </head>. |
| 198 | func consolePageFrom(t *testing.T, page string) (_, control string) { |
| 199 | t.Helper() |
| 200 | const head = "</head>" |
| 201 | i := strings.Index(page, head) |
| 202 | if i < 0 { |
| 203 | t.Fatal("the console shell rendered no </head> to inject the control's stylesheet into") |
| 204 | } |
| 205 | control = page[:i] + "<style>" + consoleControlCSS + "</style>" + page[i:] |
| 206 | return page, control |
| 207 | } |
| 208 | |
| 209 | // consoleServe puts the page at / and its control twin at /control. |
| 210 | func consoleServe(t *testing.T, page, control string) *harness.Rig { |
| 211 | t.Helper() |
| 212 | mux := http.NewServeMux() |
| 213 | stylesheets(t, mux) |
| 214 | serve := func(body string) http.HandlerFunc { |
| 215 | return func(w http.ResponseWriter, r *http.Request) { |
| 216 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 217 | fmt.Fprint(w, body) |
| 218 | } |
| 219 | } |
| 220 | mux.HandleFunc("GET /control", serve(control)) |
| 221 | mux.HandleFunc("GET /", serve(page)) |
| 222 | return harness.New(t, func(string) http.Handler { return mux }) |
| 223 | } |
| 224 | |
| 225 | func readConsole(t *testing.T, raw string) consoleReading { |
| 226 | t.Helper() |
| 227 | var got consoleReading |
| 228 | if err := json.Unmarshal([]byte(raw), &got); err != nil { |
| 229 | t.Fatalf("reading the measurement (%q): %v", raw, err) |
| 230 | } |
| 231 | return got |
| 232 | } |
| 233 | |
| 234 | // TestTheConsoleFoldsItsBarAndIndexesItsRail is the design work of the |
| 235 | // fourth shell, measured. |
| 236 | // |
| 237 | // Every other shell has ONE thing to put away below 800px. This one has |
| 238 | // two — the bar's tail and the navigation rail — and each has its own |
| 239 | // pattern: the tail folds behind one <details rst-shell-menu> and opens |
| 240 | // as a card, and the rail is an index page, shown on the page whose |
| 241 | // view block says index and replaced by a back control everywhere else. |
| 242 | // |
| 243 | // The claims, in the order they matter: |
| 244 | // |
| 245 | // 1. Wide, both chromes are drawn and neither is behind anything: bar |
| 246 | // across the top, rail beside the page, account at the bar's inline |
| 247 | // end. This is the layout the shell exists for. |
| 248 | // 2. Narrow and closed, ONE control for the bar. Not "a control" — |
| 249 | // exactly one visible summary in the whole document (the back |
| 250 | // control is a link), which is the assertion a second disclosure |
| 251 | // would fail and nothing else here would. |
| 252 | // 3. One click reveals the tail card and NOT the rail: the rail |
| 253 | // follows the page's view, never the Menu's [open]. On the index |
| 254 | // view the rail is shown with the Menu closed. |
| 255 | // 4. THE TRAP. The account menu is <details name="rst-menus">. |
| 256 | // <details name> exclusivity is document-wide rather than |
| 257 | // sibling-scoped, so a disclosure in that group would be closed by |
| 258 | // the menu it just revealed, and the card with it. |
| 259 | // 5. Nothing is reordered at any width, so the reader's eye and the |
| 260 | // tab key agree: bar, then rail, at 320px and at 1280px, in both |
| 261 | // directions of the language. |
| 262 | // |
| 263 | // THE CONTROL, and what it is for: /control is this page with the |
| 264 | // rail's hide rule defeated. Leg 2's reading of RailShown must be false |
| 265 | // on the page and TRUE on the control. A drive whose RailShown is |
| 266 | // hard-wired false — a stale selector, a rail that never rendered, |
| 267 | // getBoundingClientRect on a detached node — passes leg 2 and fails |
| 268 | // here, which is the whole point. |
| 269 | func TestTheConsoleFoldsItsBarAndIndexesItsRail(t *testing.T) { |
| 270 | page, control := consolePage(t, "ltr") |
| 271 | rig := consoleServe(t, page, control) |
| 272 | ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second) |
| 273 | defer cancel() |
| 274 | |
| 275 | at := func(t *testing.T, w, h int, path string, before ...chromedp.Action) consoleReading { |
| 276 | t.Helper() |
| 277 | acts := []chromedp.Action{ |
| 278 | chromedp.EmulateViewport(int64(w), int64(h)), |
| 279 | chromedp.Navigate(rig.Origin + path), |
| 280 | chromedp.WaitVisible(`[rst-shell-bar]`, chromedp.ByQuery), |
| 281 | } |
| 282 | acts = append(acts, before...) |
| 283 | var raw string |
| 284 | acts = append(acts, chromedp.Evaluate(consoleMeasure, &raw)) |
| 285 | if err := chromedp.Run(ctx, acts...); err != nil { |
| 286 | t.Fatalf("driving %s at %dx%d: %v", path, w, h, err) |
| 287 | } |
| 288 | return readConsole(t, raw) |
| 289 | } |
| 290 | |
| 291 | // 1. Wide: both chromes, no control, and the frame in the right |
| 292 | // shape. |
| 293 | wide := at(t, 1280, 900, "/") |
| 294 | t.Logf("wide 1280x900: menu=%v tail=%v rail=%v summaries=%d barAboveRail=%v railBeforeMain=%v tailEnd=%dpx", |
| 295 | wide.MenuShown, wide.TailShown, wide.RailShown, wide.VisibleSummaries, wide.BarAboveRail, wide.RailBeforeMain, wide.TailEndPx) |
| 296 | if wide.MenuShown { |
| 297 | t.Error("the collapse control is drawn at 1280px; above 800px both chromes are laid out and it has nothing to do") |
| 298 | } |
| 299 | if !wide.TailShown || !wide.RailShown { |
| 300 | t.Fatalf("at 1280px tail=%v rail=%v: the console is not showing both chromes, which is the only reason this shell exists", wide.TailShown, wide.RailShown) |
| 301 | } |
| 302 | if !wide.BarAboveRail { |
| 303 | t.Error("the bar is not above the rail at 1280px; a console is a bar ACROSS the top with the rail beneath it") |
| 304 | } |
| 305 | if !wide.RailBeforeMain { |
| 306 | t.Error("the rail is not on the inline-start side of the page column at 1280px") |
| 307 | } |
| 308 | if wide.TailEndPx > 40 { |
| 309 | t.Errorf("the bar's tail ends %dpx short of the bar's inline end; margin-inline-start: auto is no longer reaching it (the bar's own inline padding is 16px)", wide.TailEndPx) |
| 310 | } |
| 311 | if wide.Overflow > 1 { |
| 312 | t.Errorf("the wide console spills %dpx sideways at 1280px", wide.Overflow) |
| 313 | } |
| 314 | |
| 315 | // 1b. And at exactly 800px, the breakpoint itself: a min-width query |
| 316 | // matches AT its own width, so 800 is wide and 799 is narrow. An |
| 317 | // off-by-one here is a window width with neither layout. |
| 318 | edge := at(t, 800, 780, "/") |
| 319 | if edge.MenuShown || !edge.TailShown || !edge.RailShown { |
| 320 | t.Errorf("at exactly 800px control=%v tail=%v rail=%v; a min-width query matches at its own width, so 800px is the wide layout", |
| 321 | edge.MenuShown, edge.TailShown, edge.RailShown) |
| 322 | } |
| 323 | |
| 324 | // 2. Narrow and closed: one control, and both chromes behind it. |
| 325 | closed := at(t, 390, 780, "/") |
| 326 | t.Logf("narrow 390x780 closed: menu=%v tail=%v rail=%v visible summaries=%d", |
| 327 | closed.MenuShown, closed.TailShown, closed.RailShown, closed.VisibleSummaries) |
| 328 | if !closed.MenuShown { |
| 329 | t.Fatal("there is no collapse control at 390px: the console has no narrow layout") |
| 330 | } |
| 331 | if closed.TailShown { |
| 332 | t.Error("the bar's account and language menus are still drawn at 390px with the disclosure closed") |
| 333 | } |
| 334 | if closed.RailShown { |
| 335 | t.Error("the navigation rail is still drawn at 390px with the disclosure closed; the tail folded and the rail did not") |
| 336 | } |
| 337 | if closed.VisibleSummaries != 1 { |
| 338 | t.Errorf("a reader at 390px meets %d disclosures, want exactly 1. Two chromes fold behind ONE control here; a second summary is a second thing to learn and is the answer this shell was designed against", |
| 339 | closed.VisibleSummaries) |
| 340 | } |
| 341 | if closed.Overflow > 1 { |
| 342 | t.Errorf("the collapsed console spills %dpx sideways at 390px", closed.Overflow) |
| 343 | } |
| 344 | |
| 345 | // 2b. THE CONTROL for leg 2. Same instrument, same viewport, same |
| 346 | // closed disclosure — one stylesheet apart. RailShown must come |
| 347 | // out the other way, or the false above proves nothing. |
| 348 | ctrl := at(t, 390, 780, "/control") |
| 349 | if !ctrl.RailShown { |
| 350 | t.Errorf("CONTROL FAILED: the control page defeats the rule that hides the rail while the disclosure is closed, and this drive still reports the rail hidden (rail=%v, summaries=%d). "+ |
| 351 | "The measurement is not reading the rail, so leg 2's pass says nothing about the shell", ctrl.RailShown, ctrl.VisibleSummaries) |
| 352 | } |
| 353 | if !ctrl.MenuShown || ctrl.MenuOpen { |
| 354 | t.Errorf("CONTROL FAILED: the control is meant to differ from the page only in the rail's hide rule, and its disclosure reads shown=%v open=%v", ctrl.MenuShown, ctrl.MenuOpen) |
| 355 | } |
| 356 | |
| 357 | // 3. One click reveals the tail card, and not the rail: on a |
| 358 | // content page the rail is the index's, behind the back control. |
| 359 | open := at(t, 390, 780, "/", |
| 360 | chromedp.Click(`[rst-shell-menu] > summary`, chromedp.ByQuery), |
| 361 | chromedp.WaitVisible(`[rst-shell-tail] [rst-shell-account] > summary`, chromedp.ByQuery), |
| 362 | ) |
| 363 | if !open.TailShown { |
| 364 | t.Error("opening the Menu did not reveal the bar's tail") |
| 365 | } |
| 366 | if open.RailShown { |
| 367 | t.Error("opening the Menu revealed the rail on a content page; the rail follows the page's view, and the legacy :has() gate is matching new markup") |
| 368 | } |
| 369 | if open.Overflow > 1 { |
| 370 | t.Errorf("the opened console spills %dpx sideways at 390px", open.Overflow) |
| 371 | } |
| 372 | |
| 373 | // 4. The trap: the account menu must not close the disclosure it |
| 374 | // sits behind, and opening it must not bring the rail in. |
| 375 | var trapRaw string |
| 376 | if err := chromedp.Run(ctx, |
| 377 | // Settle rather than WaitVisible: the failure this step exists |
| 378 | // for is the account menu never appearing, because it closed the |
| 379 | // disclosure it lives behind. Waiting would report that as a |
| 380 | // timeout, and a timeout says nothing about <details name>. |
| 381 | chromedp.Click(`[rst-shell-account] > summary`, chromedp.ByQuery), |
| 382 | chromedp.Sleep(250*time.Millisecond), |
| 383 | chromedp.Evaluate(consoleMeasure, &trapRaw), |
| 384 | ); err != nil { |
| 385 | t.Fatalf("opening the account menu inside the collapsed console: %v", err) |
| 386 | } |
| 387 | trap := readConsole(t, trapRaw) |
| 388 | if !trap.MenuOpen { |
| 389 | t.Error("opening the account menu CLOSED the disclosure it sits behind: the two <details> are in one exclusivity group, and <details name> exclusivity is document-wide rather than sibling-scoped") |
| 390 | } |
| 391 | if !trap.TailShown { |
| 392 | t.Error("the bar's tail vanished when the account menu opened") |
| 393 | } |
| 394 | if trap.RailShown { |
| 395 | t.Error("the rail appeared when the account menu opened; on a content page it is the index's, never the Menu's") |
| 396 | } |
| 397 | if !trap.AccountMenu { |
| 398 | t.Error("the account menu's panel is not drawn inside the collapsed console") |
| 399 | } |
| 400 | |
| 401 | // 3b. The index view at 390: the rail is the page, with the Menu |
| 402 | // closed, and still one visible summary. 5b below is the same |
| 403 | // page at 320, which keeps the rail's reflow covered now that a |
| 404 | // content page no longer shows it. |
| 405 | indexHTML, indexControl := consolePage(t, "ltr-index") |
| 406 | indexRig := consoleServe(t, indexHTML, indexControl) |
| 407 | indexCtx, indexCancel := context.WithTimeout(indexRig.Context(), 60*time.Second) |
| 408 | defer indexCancel() |
| 409 | atIndex := func(w, h int) consoleReading { |
| 410 | t.Helper() |
| 411 | var raw string |
| 412 | if err := chromedp.Run(indexCtx, |
| 413 | chromedp.EmulateViewport(int64(w), int64(h)), |
| 414 | chromedp.Navigate(indexRig.Origin+"/"), |
| 415 | chromedp.WaitVisible(`[rst-shell-bar]`, chromedp.ByQuery), |
| 416 | chromedp.Evaluate(consoleMeasure, &raw), |
| 417 | ); err != nil { |
| 418 | t.Fatalf("driving the console's index at %dx%d: %v", w, h, err) |
| 419 | } |
| 420 | return readConsole(t, raw) |
| 421 | } |
| 422 | idx := atIndex(390, 780) |
| 423 | if !idx.RailShown || idx.MenuOpen || idx.VisibleSummaries != 1 { |
| 424 | t.Errorf("the console's index at 390: rail=%v menu open=%v summaries=%d; want the rail shown with the Menu closed and one visible summary", idx.RailShown, idx.MenuOpen, idx.VisibleSummaries) |
| 425 | } |
| 426 | |
| 427 | // 5. The smallest viewport the shells promise, with the card open on |
| 428 | // a content page: the tail, the back control, no rail. |
| 429 | tiny := at(t, 320, 640, "/", |
| 430 | chromedp.Click(`[rst-shell-menu] > summary`, chromedp.ByQuery), |
| 431 | chromedp.WaitVisible(`[rst-shell-tail] [rst-shell-account] > summary`, chromedp.ByQuery), |
| 432 | ) |
| 433 | if tiny.Overflow > 1 { |
| 434 | t.Errorf("the opened console spills %dpx sideways at 320px", tiny.Overflow) |
| 435 | } |
| 436 | if !tiny.TailShown || !tiny.BackShown || tiny.RailShown { |
| 437 | t.Errorf("at 320px, card open: tail=%v back=%v rail=%v; want the tail and the back control, no rail", tiny.TailShown, tiny.BackShown, tiny.RailShown) |
| 438 | } |
| 439 | |
| 440 | // 5b. And the index at 320: the rail, reflowed. |
| 441 | tinyIndex := atIndex(320, 640) |
| 442 | if !tinyIndex.RailShown || tinyIndex.Overflow > 1 { |
| 443 | t.Errorf("the console's index at 320: rail=%v, spills %dpx sideways", tinyIndex.RailShown, tinyIndex.Overflow) |
| 444 | } |
| 445 | |
| 446 | // 6. And the mirror. Every rule in this shell is a logical property |
| 447 | // and the wide frame is a named grid area, so RTL should need no |
| 448 | // second rule — which is a claim, and claims get measured. |
| 449 | rtlPage, rtlControl := consolePage(t, "rtl") |
| 450 | rtlRig := consoleServe(t, rtlPage, rtlControl) |
| 451 | rtlCtx, rtlCancel := context.WithTimeout(rtlRig.Context(), 60*time.Second) |
| 452 | defer rtlCancel() |
| 453 | var rtlRaw string |
| 454 | if err := chromedp.Run(rtlCtx, |
| 455 | chromedp.EmulateViewport(1280, 900), |
| 456 | chromedp.Navigate(rtlRig.Origin+"/"), |
| 457 | chromedp.WaitVisible(`[rst-shell-bar]`, chromedp.ByQuery), |
| 458 | chromedp.Evaluate(consoleMeasure, &rtlRaw), |
| 459 | ); err != nil { |
| 460 | t.Fatalf("driving the mirrored console: %v", err) |
| 461 | } |
| 462 | rtl := readConsole(t, rtlRaw) |
| 463 | if rtl.Dir != "rtl" { |
| 464 | t.Fatalf("the mirrored page reports direction %q; the fixture is not mirrored and legs below prove nothing", rtl.Dir) |
| 465 | } |
| 466 | if !rtl.RailBeforeMain { |
| 467 | t.Error("in RTL the rail is not on the inline-start (right) side of the page column: the grid areas are not following the writing mode") |
| 468 | } |
| 469 | if rtl.TailEndPx > 40 { |
| 470 | t.Errorf("in RTL the bar's tail ends %dpx short of the bar's inline end; the auto margin is not following the writing mode", rtl.TailEndPx) |
| 471 | } |
| 472 | if rtl.Overflow > 1 { |
| 473 | t.Errorf("the mirrored console spills %dpx sideways at 1280px", rtl.Overflow) |
| 474 | } |
| 475 | } |
| 476 | |
| 477 | // TestTheConsoleRailFitsTheViewport is the gate this project earned the |
| 478 | // hard way, asked of the fourth shell before it ships rather than after. |
| 479 | // |
| 480 | // The regression: a rail with `padding` and `block-size: 100dvh` under |
| 481 | // content-box has a BORDER box 32px taller than the window it was sized |
| 482 | // against. Sticky at the top, its last 32px hung under the foot of the |
| 483 | // screen at every scroll position, with the person block in them. |
| 484 | // overflow-y: auto was no defence — the content fitted the content box, |
| 485 | // so no scrollbar was ever owed — and the gate that missed it asserted |
| 486 | // that the person sat at the foot OF THE RAIL, which stayed perfectly |
| 487 | // true while the rail hung off the screen. |
| 488 | // |
| 489 | // So this drive asks the viewport, not the container: |
| 490 | // |
| 491 | // 1. The nav's BORDER box is never taller than the window. |
| 492 | // 2. Scrolled to the foot of the document, the whole of it — top edge |
| 493 | // and bottom edge — is inside the window. Nothing in it is |
| 494 | // unreachable at every scroll position, which is what the |
| 495 | // regression actually cost. |
| 496 | // 3. And the fixture is doing its job: the nav is longer than the cap, |
| 497 | // so the cap is engaged. A "it fits" from a nav with three links in |
| 498 | // it is a measurement of nothing. |
| 499 | // |
| 500 | // THE CONTROL: /control is this page with the regression put back, |
| 501 | // declaration for declaration. The same three readings must come out |
| 502 | // the other way there. A drive that reports "fits" because it is |
| 503 | // measuring a detached node, or the wrong element, or a box that never |
| 504 | // laid out, passes 1–3 and fails the control. |
| 505 | func TestTheConsoleRailFitsTheViewport(t *testing.T) { |
| 506 | page, control := consolePage(t, "tall") |
| 507 | rig := consoleServe(t, page, control) |
| 508 | ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second) |
| 509 | defer cancel() |
| 510 | |
| 511 | // A deliberately SHORT window, which is where the regression lived: |
| 512 | // a viewport-sized box overhangs by its padding whatever the window |
| 513 | // height is, but a short window is where a reader meets it and |
| 514 | // where twenty links certainly exceed the cap. |
| 515 | const w, h = 1280, 420 |
| 516 | |
| 517 | measureAt := func(t *testing.T, path string, scroll bool) consoleReading { |
| 518 | t.Helper() |
| 519 | acts := []chromedp.Action{ |
| 520 | chromedp.EmulateViewport(w, h), |
| 521 | chromedp.Navigate(rig.Origin + path), |
| 522 | chromedp.WaitVisible(`[rst-shell-rail] [rst-shell-nav] a`, chromedp.ByQuery), |
| 523 | } |
| 524 | if scroll { |
| 525 | acts = append(acts, |
| 526 | chromedp.Evaluate(`window.scrollTo(0, document.documentElement.scrollHeight); "ok"`, new(string)), |
| 527 | chromedp.Sleep(250*time.Millisecond)) |
| 528 | } |
| 529 | var raw string |
| 530 | acts = append(acts, chromedp.Evaluate(consoleMeasure, &raw)) |
| 531 | if err := chromedp.Run(ctx, acts...); err != nil { |
| 532 | t.Fatalf("driving %s at %dx%d (scrolled=%v): %v", path, w, h, scroll, err) |
| 533 | } |
| 534 | return readConsole(t, raw) |
| 535 | } |
| 536 | |
| 537 | // Logged before anything is asserted, and named against what it is |
| 538 | // supposed to be compared to: the number that gave the regression |
| 539 | // away was already in a passing test's output — "a 932px rail" at a |
| 540 | // 900px viewport — and nobody read it, because nothing said what it |
| 541 | // should have been. |
| 542 | top := measureAt(t, "/", false) |
| 543 | bottom := measureAt(t, "/", true) |
| 544 | t.Logf("console rail, %dx%d viewport: nav border box %dpx (viewport %dpx), %dpx of it scrolls inside itself; at scroll top %d..%d, at document foot %d..%d", |
| 545 | w, h, top.NavHeight, top.Viewport, top.NavScrolls, top.NavTop, top.NavBottom, bottom.NavTop, bottom.NavBottom) |
| 546 | |
| 547 | // 3 first, because 1 and 2 mean nothing without it. |
| 548 | if top.NavScrolls <= 0 { |
| 549 | t.Fatalf("the nav is %dpx and scrolls %dpx inside itself: it is SHORTER than the cap, so this drive is measuring a box that was never constrained. The fixture has to overflow or the fit proves nothing", |
| 550 | top.NavHeight, top.NavScrolls) |
| 551 | } |
| 552 | // 1. The border box against the window. This is the regression's own |
| 553 | // shape, and it is a claim about the box the window measures. |
| 554 | // |
| 555 | // Its caveat, recorded where it lives rather than in a report |
| 556 | // nobody greps: in THIS shell the padding is on the rail, not on |
| 557 | // the nav, so a content-box mutation alone does not inflate the |
| 558 | // nav's border box and this leg does not fire on it — leg 2 is |
| 559 | // what catches that one, by 16px. The leg is kept for three |
| 560 | // reasons: it fires the day somebody gives the nav padding of its |
| 561 | // own, which is the exact edit that shipped the regression the |
| 562 | // first time; it is the reading the control below pins to the |
| 563 | // pixel (452 = 420 + 32), which is what proves the height |
| 564 | // measurement is live rather than inert; and its message names |
| 565 | // the fix, which leg 2's cannot. |
| 566 | if top.NavHeight > top.Viewport { |
| 567 | t.Errorf("the rail's nav is %dpx in a %dpx window: %dpx taller than the viewport it is sized against, so whatever is at its foot is under the fold at every scroll position. box-sizing: border-box on the capped box is the fix", |
| 568 | top.NavHeight, top.Viewport, top.NavHeight-top.Viewport) |
| 569 | } |
| 570 | // 2. And reachability, which is what the reader actually lost. |
| 571 | if bottom.NavTop < 0 || bottom.NavBottom > bottom.Viewport { |
| 572 | t.Errorf("scrolled to the foot of the document the rail's nav occupies %d..%dpx of a %dpx window: part of it is off-screen with nothing left to scroll to reach it", |
| 573 | bottom.NavTop, bottom.NavBottom, bottom.Viewport) |
| 574 | } |
| 575 | |
| 576 | // THE CONTROL. Same drive, same instrument, same window — the |
| 577 | // regression put back in a <style> block. All three readings must |
| 578 | // invert. If they do not, this test's zero is a broken harness and |
| 579 | // not a shell that fits. |
| 580 | ctop := measureAt(t, "/control", false) |
| 581 | cbottom := measureAt(t, "/control", true) |
| 582 | t.Logf("control (the regression restored): nav border box %dpx (viewport %dpx), scrolls %dpx; at scroll top %d..%d, at document foot %d..%d", |
| 583 | ctop.NavHeight, ctop.Viewport, ctop.NavScrolls, ctop.NavTop, ctop.NavBottom, cbottom.NavTop, cbottom.NavBottom) |
| 584 | |
| 585 | if ctop.NavHeight <= ctop.Viewport { |
| 586 | t.Errorf("CONTROL FAILED: the control page sizes the nav at 100dvh as a content box with 1rem of padding, so its border box must be %dpx taller than the %dpx window — and this drive measured %dpx. The height reading is not measuring a border box against the viewport, so the pass above says nothing", |
| 587 | 32, ctop.Viewport, ctop.NavHeight) |
| 588 | } |
| 589 | if cbottom.NavTop >= 0 && cbottom.NavBottom <= cbottom.Viewport { |
| 590 | t.Errorf("CONTROL FAILED: the control's oversized nav cannot fit a %dpx window at any scroll position, and this drive reports it fitting at %d..%dpx. The reachability reading is inert", |
| 591 | cbottom.Viewport, cbottom.NavTop, cbottom.NavBottom) |
| 592 | } |
| 593 | // And the control differs from the page in exactly the way it says |
| 594 | // it does, arithmetically: block-size: 100dvh on a content box with |
| 595 | // 1rem of padding is a border box of viewport + 32, to the pixel. |
| 596 | // This is the calibration proper — not "the control failed" but |
| 597 | // "the control failed BY THE PREDICTED AMOUNT". An instrument that |
| 598 | // reports some other number is measuring some other box, and its |
| 599 | // agreement with the page above would be luck. |
| 600 | if want := ctop.Viewport + 32; ctop.NavHeight != want { |
| 601 | t.Errorf("CONTROL FAILED: the control's nav should measure exactly %dpx (a %dpx content box plus 2×1rem of padding) and this drive read %dpx. The height reading is not the border box of the element the rule names", |
| 602 | want, ctop.Viewport, ctop.NavHeight) |
| 603 | } |
| 604 | } |
| 605 | |
| 606 | // stripHas returns tokens.css as an engine that does not implement |
| 607 | // :has() would see it. |
| 608 | // |
| 609 | // The important part is what it drops, which is more than the selector. |
| 610 | // A selector list is invalid AS A WHOLE if any selector in it is |
| 611 | // invalid, so such an engine drops the entire rule — every declaration |
| 612 | // in it, including the ones that have nothing to do with :has(), and |
| 613 | // including the ones the OTHER selectors in the list were carrying. |
| 614 | // That is the finding this drive was written for: the console's wide |
| 615 | // rail rule used to co-list two plain selectors with two :has() ones, |
| 616 | // so a legacy engine lost `grid-area: rail` along with the fight it was |
| 617 | // having about `display`, and the rail landed in the right cell only |
| 618 | // because grid auto-placement happened to agree with the declared area. |
| 619 | // It looked correct. It was luck. |
| 620 | // |
| 621 | // It reads the stylesheet with cssItems, the declaration reader's own |
| 622 | // statement splitter, into the grouping rules (@media, @supports, |
| 623 | // @container) and out again. A pattern over braces ended a rule at a |
| 624 | // brace inside a string, and so dropped the wrong text or kept a |
| 625 | // :has() rule. |
| 626 | func stripHas(t testing.TB, css string) (string, int) { |
| 627 | t.Helper() |
| 628 | dropped := 0 |
| 629 | // Comments come out FIRST, with the comment reader the gates use. A |
| 630 | // browser tokenises them away before it ever looks at a selector, |
| 631 | // and the first version of this function did not: the wide |
| 632 | // layout's comment explains why the :has() spelling is repeated |
| 633 | // there, so the characters ":has(" sat in the text immediately |
| 634 | // before a rule that does not use it and the stripper took the |
| 635 | // rule. The wide legs of the drive below caught it, which is the |
| 636 | // useful thing to say about them — they are not only a gate on the |
| 637 | // stylesheet, they are the control on this function. |
| 638 | css = stripCSSComments(t, css) |
| 639 | var strip func(src string) string |
| 640 | strip = func(src string) string { |
| 641 | items, err := cssItems(src) |
| 642 | if err != nil { |
| 643 | t.Fatal(err) |
| 644 | } |
| 645 | var b strings.Builder |
| 646 | for _, it := range items { |
| 647 | prelude := collapseSpace(it.prelude) |
| 648 | switch { |
| 649 | case !it.hasBlock: |
| 650 | b.WriteString(it.prelude + ";") |
| 651 | case strings.HasPrefix(prelude, "@") && groupingRules[atRuleName(prelude)]: |
| 652 | b.WriteString(it.prelude + "{" + strip(it.block) + "}") |
| 653 | case !strings.HasPrefix(prelude, "@") && selectorHas(prelude): |
| 654 | dropped++ |
| 655 | b.WriteString("\n") |
| 656 | default: |
| 657 | b.WriteString(it.prelude + "{" + it.block + "}") |
| 658 | } |
| 659 | } |
| 660 | return b.String() |
| 661 | } |
| 662 | return strip(css), dropped |
| 663 | } |
| 664 | |
| 665 | // selectorHas reports whether a selector list uses :has(), read with |
| 666 | // cssSpan, so :has( inside a quoted attribute value is text. |
| 667 | func selectorHas(prelude string) bool { |
| 668 | for i := 0; i < len(prelude); { |
| 669 | end, _, err := cssSpan(prelude, i) |
| 670 | if err != nil { |
| 671 | return false |
| 672 | } |
| 673 | if prelude[i] == ':' && strings.HasPrefix(prelude[end:], "has(") { |
| 674 | return true |
| 675 | } |
| 676 | i = end |
| 677 | } |
| 678 | return false |
| 679 | } |
| 680 | |
| 681 | // TestTheConsoleDegradesTheWayItSaysItDoesWithoutHas is the gate on the |
| 682 | // sentence the shell, tokens.css and docs/site/templates.md all make: |
| 683 | // that this shell's dependence on :has() has a CHOSEN failure |
| 684 | // direction. |
| 685 | // |
| 686 | // The claim has two halves, and only one of them was ever true by |
| 687 | // accident: |
| 688 | // |
| 689 | // 1. Narrow, the rail stays VISIBLE. The rules are written as |
| 690 | // hide-when-closed, so the rule an old engine drops is the one that |
| 691 | // would have hidden the navigation. The page gets longer; nothing |
| 692 | // becomes unreachable. Spelled the other way round, the same |
| 693 | // missing selector would be a phone that cannot navigate. |
| 694 | // 2. Wide, the frame is UNTOUCHED. Nothing about the console's grid |
| 695 | // depends on :has() — and that has to be true of the rules as |
| 696 | // written, not merely of the intent, which is what a co-listed |
| 697 | // selector quietly took away. |
| 698 | // |
| 699 | // Half 2 is measured on the discriminator rather than on appearance: |
| 700 | // the rail's declared area spans the page row AND the footer row, so |
| 701 | // its bottom edge is below main's. An auto-placed rail stops where main |
| 702 | // stops. The two are indistinguishable in a screenshot and this is the |
| 703 | // only reading that tells them apart. |
| 704 | // |
| 705 | // THE CONTROL, twice over. The strip itself is checked to have dropped |
| 706 | // rules that matter (a strip that silently did nothing would make every |
| 707 | // reading below a reading of the ordinary page), and the same narrow |
| 708 | // reading is taken on the REAL stylesheet, where the rail must be |
| 709 | // hidden. If the rail reads visible on both, the visibility is the |
| 710 | // fixture's and not the degradation's. |
| 711 | func TestTheConsoleDegradesTheWayItSaysItDoesWithoutHas(t *testing.T) { |
| 712 | real := string(fiesta.TokensCSS()) |
| 713 | legacy, dropped := stripHas(t, real) |
| 714 | |
| 715 | // Control on the instrument before anything is measured with it. |
| 716 | if dropped == 0 { |
| 717 | t.Fatal("the :has()-less simulation dropped no rules at all: it is serving the ordinary stylesheet under another name, and every reading below is a reading of the page as it already is") |
| 718 | } |
| 719 | if strings.Contains(legacy, ":has(") { |
| 720 | t.Fatal("the :has()-less simulation left a :has() selector in the stylesheet; it is not the engine it claims to simulate") |
| 721 | } |
| 722 | // And it reads a brace inside a string as text: a pattern over |
| 723 | // braces ended [rst-a]'s rule at the quoted } and kept the :has() |
| 724 | // rule. |
| 725 | probe, n := stripHas(t, `[rst-a] { content: "}"; } [rst-b]:has(i) { color: red; content: "{"; } [rst-c] { color: blue; }`) |
| 726 | if kept := ruleSelectors(t, probe); n != 1 || !slices.Equal(kept, []string{"[rst-a]", "[rst-c]"}) { |
| 727 | t.Fatalf("the :has()-less simulation of a stylesheet with quoted braces dropped %d rules and kept %q; want 1 dropped and [rst-a], [rst-c] kept", n, kept) |
| 728 | } |
| 729 | consoleRules := 0 |
| 730 | for _, sel := range ruleSelectors(t, legacy) { |
| 731 | consoleRules += strings.Count(sel, "rst-shell-console") |
| 732 | } |
| 733 | if consoleRules == 0 { |
| 734 | t.Fatal("the simulation dropped every console rule in the file; the shell has no stylesheet left and the legs below would be measuring an unstyled document") |
| 735 | } |
| 736 | t.Logf(":has()-less simulation: %d rules dropped, %d console selectors survive", dropped, consoleRules) |
| 737 | |
| 738 | // The legacy layout: the :has() gate now lives only for markup with |
| 739 | // no view, and the legs below are about that gate. |
| 740 | page, _ := legacyConsolePage(t) |
| 741 | newPage, _ := consolePage(t, "ltr") |
| 742 | newIndex, _ := consolePage(t, "ltr-index") |
| 743 | stripped := func(p string) string { return strings.Replace(p, `href="/tokens.css"`, `href="/legacy-tokens.css"`, 1) } |
| 744 | |
| 745 | mux := http.NewServeMux() |
| 746 | mux.HandleFunc("GET /tokens.css", func(w http.ResponseWriter, r *http.Request) { |
| 747 | w.Header().Set("Content-Type", "text/css") |
| 748 | fmt.Fprint(w, real) |
| 749 | }) |
| 750 | mux.HandleFunc("GET /legacy-tokens.css", func(w http.ResponseWriter, r *http.Request) { |
| 751 | w.Header().Set("Content-Type", "text/css") |
| 752 | fmt.Fprint(w, legacy) |
| 753 | }) |
| 754 | mux.HandleFunc("GET /theme.css", func(w http.ResponseWriter, r *http.Request) { |
| 755 | css, ok := fiesta.ThemeCSS(fiesta.ThemeNames()[0]) |
| 756 | if !ok { |
| 757 | http.Error(w, "no theme", http.StatusInternalServerError) |
| 758 | return |
| 759 | } |
| 760 | w.Header().Set("Content-Type", "text/css") |
| 761 | w.Write(css) |
| 762 | }) |
| 763 | mux.HandleFunc("GET /legacy", func(w http.ResponseWriter, r *http.Request) { |
| 764 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 765 | fmt.Fprint(w, stripped(page)) |
| 766 | }) |
| 767 | mux.HandleFunc("GET /new-page", func(w http.ResponseWriter, r *http.Request) { |
| 768 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 769 | fmt.Fprint(w, stripped(newPage)) |
| 770 | }) |
| 771 | mux.HandleFunc("GET /new-index", func(w http.ResponseWriter, r *http.Request) { |
| 772 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 773 | fmt.Fprint(w, stripped(newIndex)) |
| 774 | }) |
| 775 | mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { |
| 776 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 777 | fmt.Fprint(w, page) |
| 778 | }) |
| 779 | |
| 780 | rig := harness.New(t, func(string) http.Handler { return mux }) |
| 781 | ctx, cancel := context.WithTimeout(rig.Context(), 90*time.Second) |
| 782 | defer cancel() |
| 783 | |
| 784 | at := func(t *testing.T, w, h int, path string) consoleReading { |
| 785 | t.Helper() |
| 786 | var raw string |
| 787 | if err := chromedp.Run(ctx, |
| 788 | chromedp.EmulateViewport(int64(w), int64(h)), |
| 789 | chromedp.Navigate(rig.Origin+path), |
| 790 | chromedp.WaitVisible(`[rst-shell-bar]`, chromedp.ByQuery), |
| 791 | chromedp.Evaluate(consoleMeasure, &raw), |
| 792 | ); err != nil { |
| 793 | t.Fatalf("driving %s at %dx%d: %v", path, w, h, err) |
| 794 | } |
| 795 | return readConsole(t, raw) |
| 796 | } |
| 797 | |
| 798 | // 2. Wide, on the legacy stylesheet: the frame is the declared one. |
| 799 | lw := at(t, 1280, 900, "/legacy") |
| 800 | t.Logf("legacy engine, 1280x900: rail shown=%v barAbove=%v beforeMain=%v borderInlineEnd=%s rail bottom %dpx vs main bottom %dpx", |
| 801 | lw.RailShown, lw.BarAboveRail, lw.RailBeforeMain, lw.RailBorderEnd, lw.RailBottom, lw.MainBottom) |
| 802 | if !lw.RailShown || !lw.TailShown { |
| 803 | t.Fatalf("without :has() the wide console loses its chrome: rail=%v tail=%v", lw.RailShown, lw.TailShown) |
| 804 | } |
| 805 | if !lw.BarAboveRail || !lw.RailBeforeMain { |
| 806 | t.Errorf("without :has() the wide frame is not bar-over-rail-beside-page: barAbove=%v beforeMain=%v", lw.BarAboveRail, lw.RailBeforeMain) |
| 807 | } |
| 808 | if lw.RailBorderEnd == "0px" { |
| 809 | t.Error("without :has() the rail has no inline-end border: the wide rail rule is being dropped whole, which means it is co-listing plain selectors with :has() ones again") |
| 810 | } |
| 811 | // THE DISCRIMINATOR. |
| 812 | if lw.RailBottom <= lw.MainBottom { |
| 813 | t.Errorf("without :has() the rail ends at %dpx and main ends at %dpx: the rail is sitting in the page's row rather than in the area declared for it, which spans the footer row too. "+ |
| 814 | "That is grid auto-placement agreeing with the declared area by coincidence — the wide rail rule is being dropped whole. Keep the :has() selectors in a rule of their own", |
| 815 | lw.RailBottom, lw.MainBottom) |
| 816 | } |
| 817 | |
| 818 | // 1. Narrow, on the legacy stylesheet: the rail is visible, whole, |
| 819 | // and the page still fits. |
| 820 | ln := at(t, 390, 780, "/legacy") |
| 821 | t.Logf("legacy engine, 390x780 closed: rail shown=%v with %d/3 links visible, control shown=%v, overflow=%dpx", |
| 822 | ln.RailShown, ln.NavLinksShown, ln.MenuShown, ln.Overflow) |
| 823 | if !ln.RailShown { |
| 824 | t.Error("without :has() the rail is HIDDEN at 390px: the hide rule is the one that survived, so the chosen failure direction is inverted and a reader on an old phone has no navigation at all") |
| 825 | } |
| 826 | if ln.NavLinksShown != 3 { |
| 827 | t.Errorf("without :has() the disclosed-by-default rail shows %d of 3 navigation links at 390px", ln.NavLinksShown) |
| 828 | } |
| 829 | if ln.Overflow > 1 { |
| 830 | t.Errorf("without :has() the console spills %dpx sideways at 390px", ln.Overflow) |
| 831 | } |
| 832 | |
| 833 | // THE CONTROL for leg 1: the same reading on the REAL stylesheet, |
| 834 | // where the answer is known and is the opposite one. |
| 835 | rn := at(t, 390, 780, "/") |
| 836 | if rn.RailShown { |
| 837 | t.Errorf("CONTROL FAILED: on the real stylesheet the rail must be hidden at 390px with the disclosure closed, and this drive reports it shown. " + |
| 838 | "The visible rail on the legacy page is then the fixture's doing and not the degradation's, and leg 1 measures nothing") |
| 839 | } |
| 840 | |
| 841 | // 3. New markup needs no :has() at all: on the stripped stylesheet a |
| 842 | // content page hides its rail and the index shows it, because the |
| 843 | // view decides, not the Menu. |
| 844 | np, ni := at(t, 390, 780, "/new-page"), at(t, 390, 780, "/new-index") |
| 845 | if np.RailShown || !ni.RailShown { |
| 846 | t.Errorf("without :has(), new markup at 390: rail on a content page %v, on the index %v; want hidden then shown", np.RailShown, ni.RailShown) |
| 847 | } |
| 848 | } |
| 849 | |