Files
familienarchiv/frontend/src/lib/components/PersonHoverCard.svelte.spec.ts
Marcel 6dd60571e3 fix(person-mention): name the hover-card region and announce its busy state
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>
2026-04-29 09:00:15 +02:00

321 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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');
});
});