Leonie FINDING-02/03 + Elicit NFR concern + Sara #4: role="region" with no aria-label is an axe-core warning, and the pulsing-bars skeleton carries no semantics for SR clients. - Add aria-label to the region root: person displayName when loaded, localised "Lade Person…" while loading. Region always has a name. - Add aria-busy="true" while loading; cleared on loaded/error so the state change is announced via aria-live="polite". - Add role="status" + aria-label on the skeleton so SR clients hear "Lade Person" rather than three silent <div>s. - New Paraglide key person_mention_loading in de/en/es. Five new tests pin: aria-busy true while loading, aria-busy unset/false when loaded, aria-label is displayName when loaded, aria-label is the loading label while loading. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
321 lines
10 KiB
TypeScript
321 lines
10 KiB
TypeScript
import { describe, it, expect, afterEach } from 'vitest';
|
||
import { cleanup, render } from 'vitest-browser-svelte';
|
||
import { page } from 'vitest/browser';
|
||
import PersonHoverCard from './PersonHoverCard.svelte';
|
||
import type { components } from '$lib/generated/api';
|
||
|
||
type Person = components['schemas']['Person'];
|
||
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||
|
||
const AUGUSTE: Person = {
|
||
id: 'p-aug',
|
||
firstName: 'Auguste',
|
||
lastName: 'Raddatz',
|
||
displayName: 'Auguste Raddatz',
|
||
personType: 'PERSON',
|
||
familyMember: true,
|
||
birthYear: 1882,
|
||
deathYear: 1944
|
||
} as unknown as Person;
|
||
|
||
const POSITION = { top: 100, left: 200 };
|
||
|
||
afterEach(() => cleanup());
|
||
|
||
describe('PersonHoverCard — loading state', () => {
|
||
it('shows the skeleton when state.status is loading', async () => {
|
||
render(PersonHoverCard, {
|
||
personId: 'p-aug',
|
||
cardId: 'card-1',
|
||
position: POSITION,
|
||
state: { status: 'loading' }
|
||
});
|
||
await expect.element(page.getByTestId('person-hover-card-skeleton')).toBeInTheDocument();
|
||
});
|
||
|
||
it('renders three skeleton bars', async () => {
|
||
render(PersonHoverCard, {
|
||
personId: 'p-aug',
|
||
cardId: 'card-1',
|
||
position: POSITION,
|
||
state: { status: 'loading' }
|
||
});
|
||
const bars = document.querySelectorAll('[data-testid="person-hover-card-skeleton"] .bar');
|
||
expect(bars.length).toBe(3);
|
||
});
|
||
});
|
||
|
||
describe('PersonHoverCard — error state', () => {
|
||
it('shows a generic error message when state.status is error', async () => {
|
||
render(PersonHoverCard, {
|
||
personId: 'p-aug',
|
||
cardId: 'card-1',
|
||
position: POSITION,
|
||
state: { status: 'error' }
|
||
});
|
||
await expect.element(page.getByTestId('person-hover-card-error')).toBeInTheDocument();
|
||
});
|
||
|
||
it('still allows the link footer to navigate (link present in error state)', async () => {
|
||
render(PersonHoverCard, {
|
||
personId: 'p-aug',
|
||
cardId: 'card-1',
|
||
position: POSITION,
|
||
state: { status: 'error' }
|
||
});
|
||
// The card root must show the footer link even when the body errored —
|
||
// click navigation works regardless of fetch outcome.
|
||
const link = document.querySelector('a[href="/persons/p-aug"]');
|
||
expect(link).not.toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('PersonHoverCard — loaded state', () => {
|
||
it('renders the person displayName as the header name', async () => {
|
||
render(PersonHoverCard, {
|
||
personId: 'p-aug',
|
||
cardId: 'card-1',
|
||
position: POSITION,
|
||
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
|
||
});
|
||
await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
|
||
});
|
||
|
||
it('renders the life-date range when birthYear and deathYear are present', async () => {
|
||
render(PersonHoverCard, {
|
||
personId: 'p-aug',
|
||
cardId: 'card-1',
|
||
position: POSITION,
|
||
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
|
||
});
|
||
await expect.element(page.getByText('* 1882 – † 1944')).toBeInTheDocument();
|
||
});
|
||
|
||
it('omits the life-date line when both years are missing', async () => {
|
||
const noDates = { ...AUGUSTE, birthYear: undefined, deathYear: undefined } as Person;
|
||
render(PersonHoverCard, {
|
||
personId: 'p-aug',
|
||
cardId: 'card-1',
|
||
position: POSITION,
|
||
state: { status: 'loaded', person: noDates, relationships: [] }
|
||
});
|
||
const dates = document.querySelector('[data-testid="person-hover-card-dates"]');
|
||
expect(dates).toBeNull();
|
||
});
|
||
|
||
it('renders "geb. <alias>" when alias is set', async () => {
|
||
const withAlias = { ...AUGUSTE, alias: 'Müller' } as Person;
|
||
render(PersonHoverCard, {
|
||
personId: 'p-aug',
|
||
cardId: 'card-1',
|
||
position: POSITION,
|
||
state: { status: 'loaded', person: withAlias, relationships: [] }
|
||
});
|
||
await expect.element(page.getByText('geb. Müller')).toBeInTheDocument();
|
||
});
|
||
|
||
it('omits the maiden name line when alias is null', async () => {
|
||
render(PersonHoverCard, {
|
||
personId: 'p-aug',
|
||
cardId: 'card-1',
|
||
position: POSITION,
|
||
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
|
||
});
|
||
const maiden = document.querySelector('[data-testid="person-hover-card-maiden"]');
|
||
expect(maiden).toBeNull();
|
||
});
|
||
|
||
it('renders family relationship chips for PARENT_OF, SPOUSE_OF, SIBLING_OF only', async () => {
|
||
const relationships: RelationshipDTO[] = [
|
||
{
|
||
id: 'r1',
|
||
personId: 'p-aug',
|
||
relatedPersonId: 'p-spouse',
|
||
personDisplayName: 'Auguste',
|
||
relatedPersonDisplayName: 'Otto Raddatz',
|
||
relationType: 'SPOUSE_OF'
|
||
},
|
||
{
|
||
id: 'r2',
|
||
personId: 'p-aug',
|
||
relatedPersonId: 'p-friend',
|
||
personDisplayName: 'Auguste',
|
||
relatedPersonDisplayName: 'Karl Friend',
|
||
relationType: 'FRIEND'
|
||
},
|
||
{
|
||
id: 'r3',
|
||
personId: 'p-aug',
|
||
relatedPersonId: 'p-sibling',
|
||
personDisplayName: 'Auguste',
|
||
relatedPersonDisplayName: 'Marie Sister',
|
||
relationType: 'SIBLING_OF'
|
||
}
|
||
];
|
||
render(PersonHoverCard, {
|
||
personId: 'p-aug',
|
||
cardId: 'card-1',
|
||
position: POSITION,
|
||
state: { status: 'loaded', person: AUGUSTE, relationships }
|
||
});
|
||
await expect.element(page.getByText('Otto Raddatz')).toBeInTheDocument();
|
||
await expect.element(page.getByText('Marie Sister')).toBeInTheDocument();
|
||
// Non-family relationship type must be filtered out
|
||
const friendChip = page.getByText('Karl Friend');
|
||
await expect.element(friendChip).not.toBeInTheDocument();
|
||
});
|
||
|
||
it('omits the chips section entirely when no family relationships', async () => {
|
||
const onlyFriend: RelationshipDTO[] = [
|
||
{
|
||
id: 'r1',
|
||
personId: 'p-aug',
|
||
relatedPersonId: 'p-friend',
|
||
personDisplayName: 'Auguste',
|
||
relatedPersonDisplayName: 'Karl Friend',
|
||
relationType: 'FRIEND'
|
||
}
|
||
];
|
||
render(PersonHoverCard, {
|
||
personId: 'p-aug',
|
||
cardId: 'card-1',
|
||
position: POSITION,
|
||
state: { status: 'loaded', person: AUGUSTE, relationships: onlyFriend }
|
||
});
|
||
const chips = document.querySelector('[data-testid="person-hover-card-chips"]');
|
||
expect(chips).toBeNull();
|
||
});
|
||
|
||
it('renders notes excerpt unchanged when notes ≤ 120 characters', async () => {
|
||
const withNotes = { ...AUGUSTE, notes: 'Born in Berlin.' } as Person;
|
||
render(PersonHoverCard, {
|
||
personId: 'p-aug',
|
||
cardId: 'card-1',
|
||
position: POSITION,
|
||
state: { status: 'loaded', person: withNotes, relationships: [] }
|
||
});
|
||
await expect.element(page.getByText('Born in Berlin.')).toBeInTheDocument();
|
||
});
|
||
|
||
it('truncates notes longer than 120 characters with an ellipsis', async () => {
|
||
const long = 'x'.repeat(150);
|
||
const withLongNotes = { ...AUGUSTE, notes: long } as Person;
|
||
render(PersonHoverCard, {
|
||
personId: 'p-aug',
|
||
cardId: 'card-1',
|
||
position: POSITION,
|
||
state: { status: 'loaded', person: withLongNotes, relationships: [] }
|
||
});
|
||
const notes = document.querySelector('[data-testid="person-hover-card-notes"]')!;
|
||
expect(notes.textContent!.length).toBeLessThanOrEqual(122);
|
||
expect(notes.textContent).toContain('…');
|
||
});
|
||
|
||
it('omits notes section when notes is null', async () => {
|
||
render(PersonHoverCard, {
|
||
personId: 'p-aug',
|
||
cardId: 'card-1',
|
||
position: POSITION,
|
||
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
|
||
});
|
||
const notes = document.querySelector('[data-testid="person-hover-card-notes"]');
|
||
expect(notes).toBeNull();
|
||
});
|
||
|
||
it('footer renders an anchor link to /persons/{personId}', async () => {
|
||
render(PersonHoverCard, {
|
||
personId: 'p-aug',
|
||
cardId: 'card-1',
|
||
position: POSITION,
|
||
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
|
||
});
|
||
const link = document.querySelector('a[href="/persons/p-aug"]')!;
|
||
expect(link).not.toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('PersonHoverCard — accessibility', () => {
|
||
it('uses aria-live="polite" so screen readers announce loaded content', async () => {
|
||
render(PersonHoverCard, {
|
||
personId: 'p-aug',
|
||
cardId: 'card-1',
|
||
position: POSITION,
|
||
state: { status: 'loading' }
|
||
});
|
||
const root = document.querySelector('[data-testid="person-hover-card"]')!;
|
||
expect(root.getAttribute('aria-live')).toBe('polite');
|
||
});
|
||
|
||
it('sets aria-busy="true" while loading so SR announces the state change on load', async () => {
|
||
render(PersonHoverCard, {
|
||
personId: 'p-aug',
|
||
cardId: 'card-1',
|
||
position: POSITION,
|
||
state: { status: 'loading' }
|
||
});
|
||
const root = document.querySelector('[data-testid="person-hover-card"]')!;
|
||
expect(root.getAttribute('aria-busy')).toBe('true');
|
||
});
|
||
|
||
it('does not set aria-busy when loaded (so the loaded content is announced)', async () => {
|
||
render(PersonHoverCard, {
|
||
personId: 'p-aug',
|
||
cardId: 'card-1',
|
||
position: POSITION,
|
||
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
|
||
});
|
||
const root = document.querySelector('[data-testid="person-hover-card"]')!;
|
||
// aria-busy is either absent or "false"
|
||
const busy = root.getAttribute('aria-busy');
|
||
expect(busy === null || busy === 'false').toBe(true);
|
||
});
|
||
|
||
it('names the region with the person displayName when loaded (WCAG 1.3.1)', async () => {
|
||
render(PersonHoverCard, {
|
||
personId: 'p-aug',
|
||
cardId: 'card-1',
|
||
position: POSITION,
|
||
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
|
||
});
|
||
const root = document.querySelector('[data-testid="person-hover-card"]')!;
|
||
expect(root.getAttribute('aria-label')).toBe('Auguste Raddatz');
|
||
});
|
||
|
||
it('names the region with a generic loading label while loading', async () => {
|
||
render(PersonHoverCard, {
|
||
personId: 'p-aug',
|
||
cardId: 'card-1',
|
||
position: POSITION,
|
||
state: { status: 'loading' }
|
||
});
|
||
const root = document.querySelector('[data-testid="person-hover-card"]')!;
|
||
// Region must have an accessible name in every state — axe-core flags
|
||
// role="region" without aria-label / aria-labelledby.
|
||
expect(root.getAttribute('aria-label')).toBeTruthy();
|
||
});
|
||
|
||
it('exposes the cardId as the host element id (so anchor aria-describedby works)', async () => {
|
||
render(PersonHoverCard, {
|
||
personId: 'p-aug',
|
||
cardId: 'card-xyz',
|
||
position: POSITION,
|
||
state: { status: 'loading' }
|
||
});
|
||
const root = document.querySelector('[data-testid="person-hover-card"]')!;
|
||
expect(root.id).toBe('card-xyz');
|
||
});
|
||
|
||
it('positions itself absolutely at the given top/left', async () => {
|
||
render(PersonHoverCard, {
|
||
personId: 'p-aug',
|
||
cardId: 'card-1',
|
||
position: { top: 333, left: 444 },
|
||
state: { status: 'loading' }
|
||
});
|
||
const root = document.querySelector('[data-testid="person-hover-card"]') as HTMLElement;
|
||
expect(root.style.top).toBe('333px');
|
||
expect(root.style.left).toBe('444px');
|
||
expect(root.style.position).toBe('absolute');
|
||
});
|
||
});
|