mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-08-01 16:43:37 +08:00
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:
parent
598b968eec
commit
47d47d1881
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
159
desktop/src/components/ui/keyboard.test.tsx
Normal file
159
desktop/src/components/ui/keyboard.test.tsx
Normal 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user