| 1 | //go:build browser |
| 2 | |
| 3 | // The editor's round-2 polish, held in a real Chromium: the strip does |
| 4 | // not draw over its own words, a reload is not somebody else arriving, |
| 5 | // and a modal dialog owns the keyboard. Each of these was a defect a |
| 6 | // person could see (polish audit, slides.md), and none of them is |
| 7 | // visible to a markup test — they are geometry, a sessionStorage and a |
| 8 | // key event's route through the page. |
| 9 | package slidestest |
| 10 | |
| 11 | import ( |
| 12 | "fmt" |
| 13 | "testing" |
| 14 | "time" |
| 15 | |
| 16 | "amadan.net/rastrillo/rastrillo/harness" |
| 17 | "github.com/chromedp/cdproto/input" |
| 18 | "github.com/chromedp/chromedp" |
| 19 | ) |
| 20 | |
| 21 | const editorBound = `document.querySelector('[slides-stage] [data-block="heading"]')?.getAttribute('contenteditable') === 'true'` |
| 22 | |
| 23 | // openEditor signs in and opens the sample deck's editor, bound. |
| 24 | func openEditor(t *testing.T) (*harness.Rig, *fakeMailer, string) { |
| 25 | t.Helper() |
| 26 | rig, mailer := newRig(t) |
| 27 | browserSignIn(t, rig, mailer) |
| 28 | var deck string |
| 29 | rig.Run(chromedp.Evaluate(`(document.querySelector('a[href^="/d/"]')?.getAttribute('href') ?? '')`, &deck)) |
| 30 | if !deckPath.MatchString(deck) { |
| 31 | t.Fatalf("the deck list links no deck: %q", deck) |
| 32 | } |
| 33 | rig.Run( |
| 34 | chromedp.Navigate(rig.Origin+deck+"/edit"), |
| 35 | chromedp.WaitVisible(`[slides-editor]`, chromedp.ByQuery), |
| 36 | waitFor(editorBound), |
| 37 | ) |
| 38 | return rig, mailer, deck |
| 39 | } |
| 40 | |
| 41 | // stripGeometry measures every strip item: its label, its avatars and |
| 42 | // its menu trigger, and reports each overlap, each trigger under the |
| 43 | // 24px target minimum, and each avatar box outside its thumbnail. An |
| 44 | // empty answer is the pass. Items outside the strip's scrollport are |
| 45 | // measured too — getBoundingClientRect does not care. |
| 46 | const stripGeometry = `(() => { |
| 47 | const r = el => el && el.getBoundingClientRect() |
| 48 | const hit = (a, b) => a && b && a.width > 0 && b.width > 0 && |
| 49 | a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom |
| 50 | const inside = (a, b) => a.left >= b.left - 0.5 && a.right <= b.right + 0.5 && a.top >= b.top - 0.5 && a.bottom <= b.bottom + 0.5 |
| 51 | const out = [] |
| 52 | let avatars = 0 |
| 53 | for (const li of document.querySelectorAll('[slides-strip-item]')) { |
| 54 | const id = li.dataset.slideId |
| 55 | const label = r(li.querySelector('[slides-strip-label]')) |
| 56 | const thumb = r(li.querySelector('[slides-thumb]')) |
| 57 | const av = r(li.querySelector('[slides-presence-avatars]')) |
| 58 | const menu = r(li.querySelector('[slides-strip-menu] > summary')) |
| 59 | if (av) avatars++ |
| 60 | if (hit(label, av)) out.push(id + ': the label is under the avatars') |
| 61 | if (hit(label, menu)) out.push(id + ': the label is under the menu trigger') |
| 62 | if (av && !inside(av, thumb)) out.push(id + ': the avatars are outside the thumbnail') |
| 63 | if (menu && (menu.width < 24 || menu.height < 24)) out.push(id + ': the menu trigger is ' + menu.width + 'x' + menu.height + ', under 24x24') |
| 64 | if (menu && (menu.width > 32 || menu.height > 32)) out.push(id + ': the menu trigger is ' + menu.width + 'x' + menu.height + ', not a compact icon button') |
| 65 | if (menu && !inside(menu, thumb)) out.push(id + ': the menu trigger is outside the thumbnail') |
| 66 | } |
| 67 | if (avatars === 0) out.push('no strip item carries an avatar — nothing was measured') |
| 68 | return out.join('\n') |
| 69 | })()` |
| 70 | |
| 71 | // TestBrowserStripDoesNotOverlapItself (polish audit, "PA RRENT |
| 72 | // SLIDE"): with another tab's avatar on the current slide, the item's |
| 73 | // label is clear of the avatars and of the menu trigger, the trigger is |
| 74 | // a compact 24px-or-more target, and the words "Current slide" are for |
| 75 | // a screen reader only — at 1440, where the strip is a column, and at |
| 76 | // 800, where it is a row. |
| 77 | func TestBrowserStripDoesNotOverlapItself(t *testing.T) { |
| 78 | rig, _, deck := openEditor(t) |
| 79 | var slide string |
| 80 | rig.Run(chromedp.Evaluate(`document.querySelector('[slides-editor]').dataset.slide`, &slide)) |
| 81 | |
| 82 | // A real second tab, so the avatar is presence-view.mjs's own and |
| 83 | // sits wherever the bundle puts it. |
| 84 | tab2, cancel := chromedp.NewContext(rig.Context()) |
| 85 | defer cancel() |
| 86 | runOn(t, tab2, |
| 87 | chromedp.Navigate(rig.Origin+deck+"/edit"), |
| 88 | chromedp.WaitVisible(`[slides-editor]`, chromedp.ByQuery), |
| 89 | waitFor(editorBound), |
| 90 | ) |
| 91 | rig.Run(waitFor(`document.querySelector('[slides-strip-item][data-slide-id="` + slide + `"] [slides-presence-avatar]') !== null`)) |
| 92 | |
| 93 | for _, width := range []int64{1440, 800} { |
| 94 | rig.Run( |
| 95 | chromedp.EmulateViewport(width, 900), |
| 96 | // Park the pointer away from the strip: hover is one of the |
| 97 | // states that shows a menu trigger, and this is the resting |
| 98 | // look being measured. |
| 99 | chromedp.MouseEvent(input.MouseMoved, float64(width)-5, 895), |
| 100 | chromedp.Sleep(300*time.Millisecond), |
| 101 | ) |
| 102 | var problems string |
| 103 | rig.Run(chromedp.Evaluate(stripGeometry, &problems)) |
| 104 | if problems != "" { |
| 105 | t.Errorf("at %dpx the strip overlaps itself:\n%s", width, problems) |
| 106 | } |
| 107 | |
| 108 | // The resting menu trigger of an item that is neither current |
| 109 | // nor hovered nor focused is not drawn over its thumbnail. |
| 110 | var restingOpacity string |
| 111 | rig.Run(chromedp.Evaluate(`getComputedStyle(document.querySelector('[slides-strip-item]:not([data-current="true"]) [slides-strip-menu] > summary')).opacity`, &restingOpacity)) |
| 112 | if restingOpacity != "0" { |
| 113 | t.Errorf("at %dpx a resting item's menu trigger has opacity %s, want 0 (shown on hover, focus or current only)", width, restingOpacity) |
| 114 | } |
| 115 | var currentOpacity string |
| 116 | rig.Run(chromedp.Evaluate(`getComputedStyle(document.querySelector('[slides-strip-item][data-current="true"] [slides-strip-menu] > summary')).opacity`, ¤tOpacity)) |
| 117 | if currentOpacity != "1" { |
| 118 | t.Errorf("at %dpx the current item's menu trigger has opacity %s, want 1", width, currentOpacity) |
| 119 | } |
| 120 | |
| 121 | // "Current slide" is in the accessible name and not on screen. |
| 122 | var current struct { |
| 123 | Text string `json:"text"` |
| 124 | Width float64 `json:"width"` |
| 125 | Height float64 `json:"height"` |
| 126 | Upper string `json:"upper"` |
| 127 | } |
| 128 | rig.Run(chromedp.Evaluate(`(() => { |
| 129 | const el = document.querySelector('[slides-strip-item][data-current="true"] [slides-strip-current]') |
| 130 | const b = el.getBoundingClientRect() |
| 131 | return { text: el.textContent, width: b.width, height: b.height, upper: getComputedStyle(el).textTransform } |
| 132 | })()`, ¤t)) |
| 133 | if current.Text == "" { |
| 134 | t.Errorf("at %dpx the current item lost its 'Current slide' text — a screen reader still needs it", width) |
| 135 | } |
| 136 | if current.Width > 1 || current.Height > 1 { |
| 137 | t.Errorf("at %dpx 'Current slide' is visible (%gx%g), want it for screen readers only", width, current.Width, current.Height) |
| 138 | } |
| 139 | if current.Upper == "uppercase" { |
| 140 | t.Errorf("at %dpx 'Current slide' is still uppercase", width) |
| 141 | } |
| 142 | |
| 143 | // Hover and keyboard focus each show a resting item's trigger. |
| 144 | var at struct { |
| 145 | X float64 `json:"x"` |
| 146 | Y float64 `json:"y"` |
| 147 | } |
| 148 | rig.Run(chromedp.Evaluate(`(() => { |
| 149 | const li = document.querySelector('[slides-strip-item]:not([data-current="true"])') |
| 150 | li.scrollIntoView({ block: 'nearest', inline: 'nearest' }) |
| 151 | const b = li.getBoundingClientRect() |
| 152 | return { x: b.left + b.width / 2, y: b.top + b.height / 2 } |
| 153 | })()`, &at)) |
| 154 | var hoverOpacity string |
| 155 | rig.Run( |
| 156 | chromedp.MouseEvent(input.MouseMoved, at.X, at.Y), |
| 157 | chromedp.Sleep(300*time.Millisecond), |
| 158 | chromedp.Evaluate(`[...document.querySelectorAll('[slides-strip-item]:not([data-current="true"])')].filter(li => li.matches(':hover')).map(li => getComputedStyle(li.querySelector('[slides-strip-menu] > summary')).opacity).join(',')`, &hoverOpacity), |
| 159 | ) |
| 160 | if hoverOpacity != "1" { |
| 161 | t.Errorf("at %dpx a hovered item's menu trigger has opacity %q, want 1", width, hoverOpacity) |
| 162 | } |
| 163 | var focusOpacity string |
| 164 | rig.Run( |
| 165 | chromedp.MouseEvent(input.MouseMoved, float64(width)-5, 895), |
| 166 | chromedp.Evaluate(`document.querySelectorAll('[slides-strip-item]:not([data-current="true"]) [slides-strip-link]')[1].focus()`, nil), |
| 167 | chromedp.Sleep(300*time.Millisecond), |
| 168 | chromedp.Evaluate(`getComputedStyle(document.activeElement.closest('[slides-strip-item]').querySelector('[slides-strip-menu] > summary')).opacity`, &focusOpacity), |
| 169 | ) |
| 170 | if focusOpacity != "1" { |
| 171 | t.Errorf("at %dpx a focused item's menu trigger has opacity %q, want 1", width, focusOpacity) |
| 172 | } |
| 173 | rig.Screen(`[slides-strip-nav]`, fmt.Sprintf("the strip at %dpx", width)) |
| 174 | } |
| 175 | } |
| 176 | |