refactor: move person domain components and utils to lib/person/
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
373
frontend/src/lib/person/PersonHoverCard.svelte.spec.ts
Normal file
373
frontend/src/lib/person/PersonHoverCard.svelte.spec.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
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('shows the other person name when hovered person is the object (relatedPersonId) in a PARENT_OF row', async () => {
|
||||
// Storage: Heinrich PARENT_OF Auguste. When viewing Auguste's card,
|
||||
// the chip must show "Heinrich" (the parent), not "Auguste" (herself).
|
||||
const relationships: RelationshipDTO[] = [
|
||||
{
|
||||
id: 'r-parent',
|
||||
personId: 'p-heinrich',
|
||||
relatedPersonId: 'p-aug',
|
||||
personDisplayName: 'Heinrich Raddatz',
|
||||
relatedPersonDisplayName: 'Auguste Raddatz',
|
||||
relationType: 'PARENT_OF'
|
||||
}
|
||||
];
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: AUGUSTE, relationships }
|
||||
});
|
||||
await expect.element(page.getByText('Heinrich Raddatz')).toBeInTheDocument();
|
||||
// Auguste must NOT appear as her own parent chip name
|
||||
const chips = document.querySelector('[data-testid="person-hover-card-chips"]');
|
||||
expect(chips?.textContent).not.toContain('Auguste Raddatz');
|
||||
});
|
||||
|
||||
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 fixed 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('fixed');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user