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. " 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 (single long word)', async () => { // Single 150-char word with no spaces: word-boundary cut would yield nothing, // so fall back to a hard cut at 120 + ellipsis (Sara #7: pin the exact length). 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).toBe('x'.repeat(120) + '…'); }); it('truncates at the last word boundary inside the 120-char window (Leonie FINDING-04)', async () => { // 150-char string with spaces — must cut at the last space, not mid-word. const sentence = 'Sie war eine bekannte Schriftstellerin und engagierte sich '.repeat(3); // length is 180, last space at idx ≤120 const withLongNotes = { ...AUGUSTE, notes: sentence } 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"]')!; const text = notes.textContent ?? ''; // Ends with ellipsis expect(text.endsWith('…')).toBe(true); // Last char before the ellipsis is NOT a half-word — verify by checking that // the position right before … is the end of a word (i.e., there's no letter // further along in the original text immediately after our cut point). const cut = text.slice(0, -1); // strip the … // Find this cut substring in the original sentence const idx = sentence.indexOf(cut); expect(idx).toBe(0); const charAfterCut = sentence[cut.length]; // The next char should be a space — confirming we cut on a boundary expect(charAfterCut).toBe(' '); }); 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'); }); });