| new file mode 100644 |
| index 0000000..dd05d34 |
| --- /dev/null |
| +++ b/internal/slidestest/browser_editor_strip_test.go |
| @@ -0,0 +1,175 @@ |
| +//go:build browser |
| + |
| +// The editor's round-2 polish, held in a real Chromium: the strip does |
| +// not draw over its own words, a reload is not somebody else arriving, |
| +// and a modal dialog owns the keyboard. Each of these was a defect a |
| +// person could see (polish audit, slides.md), and none of them is |
| +// visible to a markup test — they are geometry, a sessionStorage and a |
| +// key event's route through the page. |
| +package slidestest |
| + |
| +import ( |
| + "fmt" |
| + "testing" |
| + "time" |
| + |
| + "amadan.net/rastrillo/rastrillo/harness" |
| + "github.com/chromedp/cdproto/input" |
| + "github.com/chromedp/chromedp" |
| +) |
| + |
| +const editorBound = `document.querySelector('[slides-stage] [data-block="heading"]')?.getAttribute('contenteditable') === 'true'` |
| + |
| +// openEditor signs in and opens the sample deck's editor, bound. |
| +func openEditor(t *testing.T) (*harness.Rig, *fakeMailer, string) { |
| + t.Helper() |
| + rig, mailer := newRig(t) |
| + browserSignIn(t, rig, mailer) |
| + var deck string |
| + rig.Run(chromedp.Evaluate(`(document.querySelector('a[href^="/d/"]')?.getAttribute('href') ?? '')`, &deck)) |
| + if !deckPath.MatchString(deck) { |
| + t.Fatalf("the deck list links no deck: %q", deck) |
| + } |
| + rig.Run( |
| + chromedp.Navigate(rig.Origin+deck+"/edit"), |
| + chromedp.WaitVisible(`[slides-editor]`, chromedp.ByQuery), |
| + waitFor(editorBound), |
| + ) |
| + return rig, mailer, deck |
| +} |
| + |
| +// stripGeometry measures every strip item: its label, its avatars and |
| +// its menu trigger, and reports each overlap, each trigger under the |
| +// 24px target minimum, and each avatar box outside its thumbnail. An |
| +// empty answer is the pass. Items outside the strip's scrollport are |
| +// measured too — getBoundingClientRect does not care. |
| +const stripGeometry = `(() => { |
| + const r = el => el && el.getBoundingClientRect() |
| + const hit = (a, b) => a && b && a.width > 0 && b.width > 0 && |
| + a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom |
| + 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 |
| + const out = [] |
| + let avatars = 0 |
| + for (const li of document.querySelectorAll('[slides-strip-item]')) { |
| + const id = li.dataset.slideId |
| + const label = r(li.querySelector('[slides-strip-label]')) |
| + const thumb = r(li.querySelector('[slides-thumb]')) |
| + const av = r(li.querySelector('[slides-presence-avatars]')) |
| + const menu = r(li.querySelector('[slides-strip-menu] > summary')) |
| + if (av) avatars++ |
| + if (hit(label, av)) out.push(id + ': the label is under the avatars') |
| + if (hit(label, menu)) out.push(id + ': the label is under the menu trigger') |
| + if (av && !inside(av, thumb)) out.push(id + ': the avatars are outside the thumbnail') |
| + if (menu && (menu.width < 24 || menu.height < 24)) out.push(id + ': the menu trigger is ' + menu.width + 'x' + menu.height + ', under 24x24') |
| + 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') |
| + if (menu && !inside(menu, thumb)) out.push(id + ': the menu trigger is outside the thumbnail') |
| + } |
| + if (avatars === 0) out.push('no strip item carries an avatar — nothing was measured') |
| + return out.join('\n') |
| +})()` |
| + |
| +// TestBrowserStripDoesNotOverlapItself (polish audit, "PA RRENT |
| +// SLIDE"): with another tab's avatar on the current slide, the item's |
| +// label is clear of the avatars and of the menu trigger, the trigger is |
| +// a compact 24px-or-more target, and the words "Current slide" are for |
| +// a screen reader only — at 1440, where the strip is a column, and at |
| +// 800, where it is a row. |
| +func TestBrowserStripDoesNotOverlapItself(t *testing.T) { |
| + rig, _, deck := openEditor(t) |
| + var slide string |
| + rig.Run(chromedp.Evaluate(`document.querySelector('[slides-editor]').dataset.slide`, &slide)) |
| + |
| + // A real second tab, so the avatar is presence-view.mjs's own and |
| + // sits wherever the bundle puts it. |
| + tab2, cancel := chromedp.NewContext(rig.Context()) |
| + defer cancel() |
| + runOn(t, tab2, |
| + chromedp.Navigate(rig.Origin+deck+"/edit"), |
| + chromedp.WaitVisible(`[slides-editor]`, chromedp.ByQuery), |
| + waitFor(editorBound), |
| + ) |
| + rig.Run(waitFor(`document.querySelector('[slides-strip-item][data-slide-id="` + slide + `"] [slides-presence-avatar]') !== null`)) |
| + |
| + for _, width := range []int64{1440, 800} { |
| + rig.Run( |
| + chromedp.EmulateViewport(width, 900), |
| + // Park the pointer away from the strip: hover is one of the |
| + // states that shows a menu trigger, and this is the resting |
| + // look being measured. |
| + chromedp.MouseEvent(input.MouseMoved, float64(width)-5, 895), |
| + chromedp.Sleep(300*time.Millisecond), |
| + ) |
| + var problems string |
| + rig.Run(chromedp.Evaluate(stripGeometry, &problems)) |
| + if problems != "" { |
| + t.Errorf("at %dpx the strip overlaps itself:\n%s", width, problems) |
| + } |
| + |
| + // The resting menu trigger of an item that is neither current |
| + // nor hovered nor focused is not drawn over its thumbnail. |
| + var restingOpacity string |
| + rig.Run(chromedp.Evaluate(`getComputedStyle(document.querySelector('[slides-strip-item]:not([data-current="true"]) [slides-strip-menu] > summary')).opacity`, &restingOpacity)) |
| + if restingOpacity != "0" { |
| + t.Errorf("at %dpx a resting item's menu trigger has opacity %s, want 0 (shown on hover, focus or current only)", width, restingOpacity) |
| + } |
| + var currentOpacity string |
| + rig.Run(chromedp.Evaluate(`getComputedStyle(document.querySelector('[slides-strip-item][data-current="true"] [slides-strip-menu] > summary')).opacity`, ¤tOpacity)) |
| + if currentOpacity != "1" { |
| + t.Errorf("at %dpx the current item's menu trigger has opacity %s, want 1", width, currentOpacity) |
| + } |
| + |
| + // "Current slide" is in the accessible name and not on screen. |
| + var current struct { |
| + Text string `json:"text"` |
| + Width float64 `json:"width"` |
| + Height float64 `json:"height"` |
| + Upper string `json:"upper"` |
| + } |
| + rig.Run(chromedp.Evaluate(`(() => { |
| + const el = document.querySelector('[slides-strip-item][data-current="true"] [slides-strip-current]') |
| + const b = el.getBoundingClientRect() |
| + return { text: el.textContent, width: b.width, height: b.height, upper: getComputedStyle(el).textTransform } |
| + })()`, ¤t)) |
| + if current.Text == "" { |
| + t.Errorf("at %dpx the current item lost its 'Current slide' text — a screen reader still needs it", width) |
| + } |
| + if current.Width > 1 || current.Height > 1 { |
| + t.Errorf("at %dpx 'Current slide' is visible (%gx%g), want it for screen readers only", width, current.Width, current.Height) |
| + } |
| + if current.Upper == "uppercase" { |
| + t.Errorf("at %dpx 'Current slide' is still uppercase", width) |
| + } |
| + |
| + // Hover and keyboard focus each show a resting item's trigger. |
| + var at struct { |
| + X float64 `json:"x"` |
| + Y float64 `json:"y"` |
| + } |
| + rig.Run(chromedp.Evaluate(`(() => { |
| + const li = document.querySelector('[slides-strip-item]:not([data-current="true"])') |
| + li.scrollIntoView({ block: 'nearest', inline: 'nearest' }) |
| + const b = li.getBoundingClientRect() |
| + return { x: b.left + b.width / 2, y: b.top + b.height / 2 } |
| + })()`, &at)) |
| + var hoverOpacity string |
| + rig.Run( |
| + chromedp.MouseEvent(input.MouseMoved, at.X, at.Y), |
| + chromedp.Sleep(300*time.Millisecond), |
| + 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), |
| + ) |
| + if hoverOpacity != "1" { |
| + t.Errorf("at %dpx a hovered item's menu trigger has opacity %q, want 1", width, hoverOpacity) |
| + } |
| + var focusOpacity string |
| + rig.Run( |
| + chromedp.MouseEvent(input.MouseMoved, float64(width)-5, 895), |
| + chromedp.Evaluate(`document.querySelectorAll('[slides-strip-item]:not([data-current="true"]) [slides-strip-link]')[1].focus()`, nil), |
| + chromedp.Sleep(300*time.Millisecond), |
| + chromedp.Evaluate(`getComputedStyle(document.activeElement.closest('[slides-strip-item]').querySelector('[slides-strip-menu] > summary')).opacity`, &focusOpacity), |
| + ) |
| + if focusOpacity != "1" { |
| + t.Errorf("at %dpx a focused item's menu trigger has opacity %q, want 1", width, focusOpacity) |
| + } |
| + rig.Screen(`[slides-strip-nav]`, fmt.Sprintf("the strip at %dpx", width)) |
| + } |
| +} |