nuthatch/web/test/components/accessibility.test.ts

152 lines
4.7 KiB
TypeScript
Raw Permalink Normal View History

import { cleanup, render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import axe from 'axe-core';
import { afterEach, describe, expect, test, vi } from 'vitest';
import Feedback from '../../src/components/Feedback.svelte';
import ObjectBrowser from '../../src/components/ObjectBrowser.svelte';
import UploadPanel from '../../src/components/UploadPanel.svelte';
import WorkspaceHeader from '../../src/components/WorkspaceHeader.svelte';
afterEach(cleanup);
describe('accessible component behavior', () => {
test('root state has names, pressed state, and keyboard activation', async () => {
const onselect = vi.fn(async () => {});
const user = userEvent.setup();
const { container } = render(WorkspaceHeader, {
root: 'personal',
busy: false,
onselect
});
const personal = screen.getByRole('button', { name: 'Personal' });
const shared = screen.getByRole('button', { name: 'Shared' });
expect(personal.getAttribute('aria-pressed')).toBe('true');
expect(shared.getAttribute('aria-pressed')).toBe('false');
shared.focus();
await user.keyboard('{Enter}');
expect(onselect).toHaveBeenCalledWith('shared');
await expectNoSeriousViolations(container);
});
test('feedback exposes live notices, alerts, and retry names', async () => {
const { container } = render(Feedback, {
notice: 'Uploaded file.txt',
error: 'Delete is pending',
uploadPending: true,
uploadFollowup: 'replay',
deletePending: true,
deleteFollowup: 'restart',
onuploadretry: async () => {},
ondeleteretry: async () => {}
});
expect(screen.getByRole('status').textContent).toContain('Uploaded file.txt');
expect(screen.getByRole('alert').textContent).toContain('Delete is pending');
screen.getByRole('button', { name: 'Retry same upload' });
screen.getByRole('button', { name: 'Start new delete operation' });
await expectNoSeriousViolations(container);
});
test('browser state names deletion controls and reports busy state', async () => {
const rendered = render(ObjectBrowser, browserProps());
expect(screen.getByRole('textbox', { name: 'Search this root' })).toBeTruthy();
expect(screen.getByRole('button', { name: 'Delete alpha.txt' })).toBeTruthy();
expect(rendered.container.querySelector('.list')?.getAttribute('aria-busy')).toBe(
'false'
);
await rendered.rerender({ ...browserProps(), busy: true });
expect(rendered.container.querySelector('.list')?.getAttribute('aria-busy')).toBe(
'true'
);
expect(
(screen.getByRole('textbox', {
name: 'Search this root'
}) as HTMLInputElement).disabled
).toBe(true);
expect(
(screen.getByRole('button', {
name: 'Delete alpha.txt'
}) as HTMLButtonElement).disabled
).toBe(true);
await expectNoSeriousViolations(rendered.container);
});
test('post-delete focus moves to the logical surviving entry', async () => {
const onfocused = vi.fn();
const rendered = render(ObjectBrowser, {
...browserProps(),
onfocused
});
await rendered.rerender({
...browserProps(),
entries: [
{
name: 'beta.txt',
path: 'beta.txt',
is_prefix: false,
size: 4
}
],
focusPath: 'beta.txt',
onfocused
});
await vi.waitFor(() => {
expect(document.activeElement?.textContent).toContain('beta.txt');
});
expect(onfocused).toHaveBeenCalledOnce();
});
test('upload input retains a native named keyboard alternative', async () => {
const { container } = render(UploadPanel, {
busy: false,
onfile: async () => {}
});
expect(screen.getByLabelText('Choose file').getAttribute('type')).toBe('file');
await expectNoSeriousViolations(container);
});
});
function browserProps() {
return {
root: 'personal' as const,
entries: [
{
name: 'alpha.txt',
path: 'alpha.txt',
is_prefix: false,
size: 5
},
{
name: 'beta.txt',
path: 'beta.txt',
is_prefix: false,
size: 4
}
],
continuation: null,
partial: false,
busy: false,
query: '',
focusPath: null,
onquery: () => {},
onbrowse: async () => {},
onopen: async () => {},
ondelete: async () => {},
onfocused: () => {}
};
}
async function expectNoSeriousViolations(container: HTMLElement) {
const results = await axe.run(container, {
rules: { 'color-contrast': { enabled: false } }
});
expect(
results.violations.filter(({ impact }) =>
impact === 'serious' || impact === 'critical'
)
).toEqual([]);
}