test(desktop): automate the QA items left to manual verification

Independent QA marked three checks `not run` because they needed a human
at the keyboard. Each covers behavior this refactor changed, so they are
now assertions instead of intentions.

`A11Y-01` — a full keyboard tab-order audit. These components back
roughly 500 controls, so a decoration that picks up a tab stop or a
control that loses one would spread everywhere before anyone noticed.
`keyboard.test.tsx` fixes the property they must all share: interactive
components expose an exact number of tab stops, decorative ones expose
none, `disabled` and `loading` leave the sequence, a segmented control
stays one stop at any size, and every reachable control shows a focus
indicator.

That last check has two valid shapes, which the first version got wrong:
most controls style themselves, but `Switch` hides its native checkbox
under `peer sr-only` and paints the ring on the track beside it. Checking
only the focused node reported a missing indicator on a control that
draws one.

`ATT-02` — the image lightbox. Its arrows were icon-only with hardcoded
English before this branch; the existing suite only counted overlay
registrations. Now covers naming, wrap-around in both directions, arrow
keys, that the arrows disappear for a single image, and that keys stop
firing once closed.

`SEARCH-02` — stepping through find-in-page matches. `FindInPageModal`
had no `useTranslation` at all; its three controls are icon-only, so a
missing name leaves them unreachable by screen reader. Now covers naming,
the disabled state before a query matches, forward and backward wrap, and
close.

`CHAT-05` (queued message edit/delete) needed nothing — `ChatInput.test.tsx`
already covers it; QA had only skipped it by hand.

Each guard was verified by breaking the thing it protects: a `tabIndex`
on `Badge` fails the decorative check, and reverting the focus fix fails
the indicator check. Changed-lines coverage 91.32%.
This commit is contained in:
程序员阿江(Relakkes) 2026-07-26 18:11:38 +08:00
parent 598b968eec
commit 47d47d1881
3 changed files with 315 additions and 2 deletions

View File

@ -1,14 +1,22 @@
import '@testing-library/jest-dom'
import { render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ImageGalleryModal } from './ImageGalleryModal'
import { useOverlayStore } from '../../stores/overlayStore'
import { useSettingsStore } from '../../stores/settingsStore'
const images = [{ src: 'data:image/png;base64,AAAA', name: 'a.png' }]
const gallery = [
{ src: 'data:image/png;base64,AAAA', name: 'a.png' },
{ src: 'data:image/png;base64,BBBB', name: 'b.png' },
{ src: 'data:image/png;base64,CCCC', name: 'c.png' },
]
const reset = () => {
useOverlayStore.setState(useOverlayStore.getInitialState(), true)
useSettingsStore.setState({ locale: 'en' })
}
beforeEach(reset)
@ -86,3 +94,73 @@ describe('ImageGalleryModal · overlay suppression', () => {
expect(useOverlayStore.getState().count).toBe(0)
})
})
describe('ImageGalleryModal · navigation', () => {
it('names both arrows, which an icon-only control otherwise lacks', () => {
render(<ImageGalleryModal open images={gallery} activeIndex={0} onClose={() => {}} onSelect={() => {}} />)
expect(screen.getByRole('button', { name: 'Previous image' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Next image' })).toBeInTheDocument()
})
it('hides the arrows for a single image', () => {
render(<ImageGalleryModal open images={images} activeIndex={0} onClose={() => {}} onSelect={() => {}} />)
expect(screen.queryByRole('button', { name: 'Previous image' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Next image' })).not.toBeInTheDocument()
})
it('advances and wraps past the last image', () => {
const onSelect = vi.fn()
const { rerender } = render(
<ImageGalleryModal open images={gallery} activeIndex={0} onClose={() => {}} onSelect={onSelect} />,
)
fireEvent.click(screen.getByRole('button', { name: 'Next image' }))
expect(onSelect).toHaveBeenLastCalledWith(1)
rerender(<ImageGalleryModal open images={gallery} activeIndex={2} onClose={() => {}} onSelect={onSelect} />)
fireEvent.click(screen.getByRole('button', { name: 'Next image' }))
expect(onSelect).toHaveBeenLastCalledWith(0)
})
it('steps back and wraps before the first image', () => {
const onSelect = vi.fn()
render(<ImageGalleryModal open images={gallery} activeIndex={0} onClose={() => {}} onSelect={onSelect} />)
fireEvent.click(screen.getByRole('button', { name: 'Previous image' }))
expect(onSelect).toHaveBeenLastCalledWith(2)
})
it('navigates with the arrow keys, not only the buttons', () => {
const onSelect = vi.fn()
render(<ImageGalleryModal open images={gallery} activeIndex={1} onClose={() => {}} onSelect={onSelect} />)
fireEvent.keyDown(document, { key: 'ArrowRight' })
expect(onSelect).toHaveBeenLastCalledWith(2)
fireEvent.keyDown(document, { key: 'ArrowLeft' })
expect(onSelect).toHaveBeenLastCalledWith(0)
})
it('ignores arrow keys once closed', () => {
const onSelect = vi.fn()
const { rerender } = render(
<ImageGalleryModal open images={gallery} activeIndex={0} onClose={() => {}} onSelect={onSelect} />,
)
rerender(<ImageGalleryModal open={false} images={gallery} activeIndex={0} onClose={() => {}} onSelect={onSelect} />)
fireEvent.keyDown(document, { key: 'ArrowRight' })
expect(onSelect).not.toHaveBeenCalled()
})
it('shows the position and closes on Escape', () => {
const onClose = vi.fn()
render(<ImageGalleryModal open images={gallery} activeIndex={1} onClose={onClose} onSelect={() => {}} />)
expect(screen.getByText('2 / 3')).toBeInTheDocument()
fireEvent.keyDown(document, { key: 'Escape' })
expect(onClose).toHaveBeenCalled()
})
})

View File

@ -1,3 +1,4 @@
import '@testing-library/jest-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { FindInPageModal } from './FindInPageModal'
@ -209,3 +210,78 @@ describe('FindInPageModal', () => {
}
})
})
describe('FindInPageModal · match stepping', () => {
function setupHighlights() {
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
value: vi.fn(),
})
const highlights = new Map<string, { ranges: Range[]; priority?: number }>()
class TestHighlight {
ranges: Range[] = []
priority?: number
add(range: Range) { this.ranges.push(range) }
}
vi.stubGlobal('CSS', { highlights })
vi.stubGlobal('Highlight', TestHighlight)
}
beforeEach(() => {
vi.unstubAllGlobals()
useSettingsStore.setState({ locale: 'en' })
})
it('names every control, none of which shows visible text', async () => {
setupHighlights()
render(<><main>alpha</main><FindInPageModal open onClose={() => {}} /></>)
// These three were icon-only with hardcoded English before; the file had no
// `useTranslation` at all.
expect(screen.getByRole('button', { name: 'Previous match' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Next match' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Close find bar' })).toBeInTheDocument()
})
it('disables stepping until there is something to step through', async () => {
setupHighlights()
render(<><main>alpha beta alpha</main><FindInPageModal open onClose={() => {}} /></>)
expect(screen.getByRole('button', { name: 'Next match' })).toBeDisabled()
fireEvent.change(screen.getByPlaceholderText('Find'), { target: { value: 'alpha' } })
await waitFor(() => expect(screen.getByRole('button', { name: 'Next match' })).toBeEnabled())
})
it('steps forward and wraps at the last match', async () => {
setupHighlights()
render(<><main>alpha beta alpha</main><FindInPageModal open onClose={() => {}} /></>)
fireEvent.change(screen.getByPlaceholderText('Find'), { target: { value: 'alpha' } })
await waitFor(() => expect(screen.getByText('1 / 2')).toBeTruthy())
fireEvent.click(screen.getByRole('button', { name: 'Next match' }))
await waitFor(() => expect(screen.getByText('2 / 2')).toBeTruthy())
fireEvent.click(screen.getByRole('button', { name: 'Next match' }))
await waitFor(() => expect(screen.getByText('1 / 2')).toBeTruthy())
})
it('steps backward and wraps at the first match', async () => {
setupHighlights()
render(<><main>alpha beta alpha</main><FindInPageModal open onClose={() => {}} /></>)
fireEvent.change(screen.getByPlaceholderText('Find'), { target: { value: 'alpha' } })
await waitFor(() => expect(screen.getByText('1 / 2')).toBeTruthy())
fireEvent.click(screen.getByRole('button', { name: 'Previous match' }))
await waitFor(() => expect(screen.getByText('2 / 2')).toBeTruthy())
})
it('closes from the close button', async () => {
setupHighlights()
const onClose = vi.fn()
render(<><main>alpha</main><FindInPageModal open onClose={onClose} /></>)
fireEvent.click(screen.getByRole('button', { name: 'Close find bar' }))
expect(onClose).toHaveBeenCalled()
})
})

View File

@ -0,0 +1,159 @@
import type { ReactElement } from 'react'
import { render } from '@testing-library/react'
import '@testing-library/jest-dom'
import { describe, expect, it } from 'vitest'
import { Badge, StatusDot } from './Badge'
import { Button } from './Button'
import { Card } from './Card'
import { Checkbox } from './Checkbox'
import { EmptyState } from './EmptyState'
import { ErrorState } from './ErrorState'
import { IconButton } from './IconButton'
import { Input } from './Input'
import { LoadingState } from './LoadingState'
import { Progress } from './Progress'
import { SearchField } from './SearchField'
import { SegmentedControl } from './SegmentedControl'
import { SelectField } from './SelectField'
import { Skeleton } from './Skeleton'
import { Spinner } from './Spinner'
import { Switch } from './Switch'
import { TextArea } from './TextArea'
/**
* Keyboard reachability across the library.
*
* Independent QA left `A11Y-01` (a full keyboard tab-order audit) unrun, and
* these components now back roughly 500 controls a decoration that picks up a
* tab stop, or a control that loses one, would spread everywhere before anyone
* noticed. Per-component suites assert their own semantics; this asserts the
* one property they must all share.
*/
const NATIVELY_FOCUSABLE = 'a[href], button, input, select, textarea, [tabindex]'
/** Elements a keyboard user can reach with Tab, in DOM order. */
function tabStops(container: HTMLElement): HTMLElement[] {
return [...container.querySelectorAll<HTMLElement>(NATIVELY_FOCUSABLE)].filter((el) => {
if (el.hasAttribute('disabled') || el.getAttribute('aria-disabled') === 'true') return false
const tabIndex = el.getAttribute('tabindex')
if (tabIndex !== null && Number(tabIndex) < 0) return false
// `sr-only` inputs (the Switch's hidden checkbox) are still reachable —
// they are visually hidden, not removed from the sequence.
return true
})
}
describe('keyboard reachability', () => {
const interactive: Array<[string, ReactElement, number]> = [
['Button', <Button>Save</Button>, 1],
['IconButton', <IconButton icon={<span />} label="Close" />, 1],
['Checkbox', <Checkbox label="Include archived" />, 1],
['Switch', <Switch label="Auto update" checked onChange={() => {}} />, 1],
['Input', <Input label="Port" />, 1],
['TextArea', <TextArea label="Prompt" />, 1],
[
'SelectField',
<SelectField label="Transport" options={[{ value: 'a', label: 'A' }]} value="a" onChange={() => {}} />,
1,
],
// The field plus its clear button, which must be reachable rather than
// mouse-only.
['SearchField', <SearchField label="Search" clearLabel="Clear" value="abc" onChange={() => {}} />, 2],
[
'EmptyState with an action',
<EmptyState title="Nothing here" action={{ label: 'Create', onClick: () => {} }} />,
1,
],
['ErrorState with a retry', <ErrorState title="Failed" onRetry={() => {}} retryLabel="Retry" />, 1],
]
it.each(interactive)('%s exposes exactly %i tab stop(s)', (_name, element, expected) => {
const { container } = render(element)
expect(tabStops(container)).toHaveLength(expected)
})
const decorative: Array<[string, ReactElement]> = [
['Badge', <Badge>Ready</Badge>],
['StatusDot', <StatusDot tone="success" />],
['Spinner', <Spinner />],
['Skeleton', <Skeleton />],
['Progress', <Progress label="Uploading" value={40} />],
['LoadingState', <LoadingState label="Loading" />],
['EmptyState without an action', <EmptyState title="Nothing here" />],
['ErrorState without a retry', <ErrorState title="Failed" />],
['Card', <Card>Body</Card>],
]
it.each(decorative)('%s takes no tab stop', (_name, element) => {
const { container } = render(element)
expect(tabStops(container)).toHaveLength(0)
})
it('drops disabled controls out of the tab sequence', () => {
// `disabled` also has to reach the DOM node; styling it alone leaves the
// control reachable and operable by keyboard.
const cases: ReactElement[] = [
<Button disabled>Save</Button>,
<IconButton icon={<span />} label="Close" disabled />,
<Checkbox label="Include archived" disabled />,
<Switch label="Auto update" checked onChange={() => {}} disabled />,
<Input label="Port" disabled />,
<TextArea label="Prompt" disabled />,
]
for (const element of cases) {
const { container, unmount } = render(element)
expect(tabStops(container)).toHaveLength(0)
unmount()
}
})
it('leaves a loading button out of the tab sequence too', () => {
const { container } = render(<Button loading>Saving</Button>)
expect(tabStops(container)).toHaveLength(0)
})
it('keeps a segmented control to one tab stop regardless of size', () => {
// Roving tabindex: the group is one stop and arrows move within it. A naive
// implementation puts every segment in the sequence, so a 6-item filter
// costs six tabs to pass.
for (const count of [2, 4, 6]) {
const items = Array.from({ length: count }, (_, i) => ({ value: `v${i}`, label: `Item ${i}` }))
const { container, unmount } = render(
<SegmentedControl items={items} value="v0" onChange={() => {}} label="Filter" />,
)
expect(tabStops(container)).toHaveLength(1)
unmount()
}
})
it('gives every reachable control a visible focus treatment', () => {
// A control that is reachable but shows nothing on focus is worse than one
// that is unreachable: the user cannot tell where they are.
//
// Two valid shapes. Most controls style themselves. `Switch` hides its
// native checkbox with `peer sr-only` and paints the focus ring on the
// track beside it, so the indicator lives on a sibling under
// `peer-focus-visible:` — checking only the focused node would call that a
// failure when the ring is in fact drawn.
const hasOwnFocusStyle = (el: HTMLElement) => /focus-visible:|focus:/.test(el.className)
const hasPeerFocusStyle = (el: HTMLElement) =>
el.classList.contains('peer')
&& [...(el.parentElement?.children ?? [])].some(
(sibling) => sibling !== el && /peer-focus-visible:|peer-focus:/.test(sibling.className),
)
for (const [name, element] of interactive) {
const { container, unmount } = render(element)
for (const stop of tabStops(container)) {
expect(
hasOwnFocusStyle(stop) || hasPeerFocusStyle(stop),
`${name}: ${stop.tagName.toLowerCase()} is focusable with no focus indicator, on itself or a peer`,
).toBe(true)
}
unmount()
}
})
})