Files
electric-cherry/tests/unit/ui-status-accessibility.test.ts
T

95 lines
4.5 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import { render } from 'svelte/server';
import { get, writable } from 'svelte/store';
import ServerCard from '../../src/lib/ui/components/ServerCard.svelte';
import EmptyState from '../../src/lib/ui/components/primitives/EmptyState.svelte';
import StatusBadge from '../../src/lib/ui/components/primitives/StatusBadge.svelte';
import StatusPip from '../../src/lib/ui/components/primitives/StatusPip.svelte';
import { statusPresentation } from '../../src/lib/ui/status.js';
import { uiNow } from '../../src/lib/ui/stores/servers.js';
import { gameEvent } from '../helpers/telemetry.js';
vi.mock('$app/stores', () => ({
page: writable({ url: new URL('http://localhost/?filter=occupied') })
}));
describe('status accessibility', () => {
it.each([
['online', 'Online', 'online', 'circle'],
['starting', 'Starting', 'info', 'ring'],
['provisioning', 'Provisioning', 'info', 'ring'],
['stale', 'Not responding', 'warning', 'triangle'],
['offline', 'Offline', 'danger', 'square'],
['queued', 'Queued', 'neutral', 'hollow']
])(
'pairs %s with a consistent word and non-color cue without creating a live region',
(state, label, tone, shape) => {
expect(statusPresentation(state)).toEqual({ label, tone, shape });
const body = render(StatusBadge, { props: { state } }).body;
expect(body).toContain(label);
expect(body).toContain(`shape-${shape}`);
expect(body).toContain(`v-${tone}`);
expect(body).not.toMatch(/role="status"|aria-live=/);
}
);
it('provides a static accessible name only when an isolated pip needs one', () => {
const decorative = render(StatusPip, { props: { health: 'online' } }).body;
expect(decorative).toContain('aria-hidden="true"');
expect(decorative).not.toContain('role=');
const labelled = render(StatusPip, { props: { health: 'online', label: 'Online' } }).body;
expect(labelled).toContain('role="img"');
expect(labelled).toContain('aria-label="Online"');
expect(labelled).not.toContain('aria-hidden=');
});
it('names the card link from its title and keeps its clock and roster out of live regions', () => {
const receivedAt = new Date(get(uiNow) - 10_000).toISOString();
const body = render(ServerCard, {
props: {
name: 't5_zombie_theater_1',
health: 'online',
players: 4,
telemetry: { ...gameEvent('heartbeat', 30, 4), receivedAt },
activeSince: receivedAt,
gameStartedAt: receivedAt,
roster: [{ num: 0, name: 'Player One', score: 30000, ping: 40, guid: '1' }]
}
}).body;
const card = body.match(/<a\b[^>]*>/)?.[0] ?? '';
const titleId = card.match(/aria-labelledby="([^"]+)"/)?.[1]?.split(' ')[0];
expect(titleId).toBeTruthy();
expect(body).toContain(`id="${titleId}"`);
expect(card).not.toContain('aria-label=');
expect(body).toContain('Online');
expect(body).toMatch(/<dt[^>]*>Round<\/dt>\s*<dd[^>]*>30<\/dd>/);
expect(body).toContain('Player One');
expect(body).not.toMatch(/aria-live=|role="status"/);
});
it('decodes persisted errors and keeps unknown technical errors in diagnostics', () => {
const props = { name: 't5_zombie_theater_1', health: 'offline' as const };
const known = render(ServerCard, {
props: { ...props, lastError: 'timeout:Agent received no reply.' }
}).body;
expect(known).toContain('Agent received no reply.');
expect(known).not.toContain('timeout:');
const unknown = render(ServerCard, {
props: { ...props, lastError: 'unknown-error:raw diagnostic data' }
}).body;
expect(unknown).toContain('Open diagnostics for details.');
expect(unknown).not.toContain('raw diagnostic data');
});
it('uses a page-level heading by default and allows nested empty states without live announcements', () => {
const props = {
title: 'No campaigns',
description: 'Campaigns apply file changes to your servers.'
};
const body = render(EmptyState, { props }).body;
expect(body.replaceAll(/<!--.*?-->/g, '')).toMatch(/<h2\b[^>]*>No campaigns<\/h2>/);
expect(body).not.toMatch(/role="status"|aria-live=/);
expect(render(EmptyState, { props: { ...props, headingLevel: 3 } }).body).toMatch(/<h3\b/);
});
});