import { describe, it, expect, vi, afterEach } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; import TranscriptionReadView from './TranscriptionReadView.svelte'; import type { TranscriptionBlockData } from '$lib/shared/types'; const PERSON_ID = '11111111-0000-0000-0000-000000000001'; const block: TranscriptionBlockData = { id: 'b1', annotationId: 'a1', documentId: 'd1', text: '@Auguste', label: null, sortOrder: 0, version: 1, source: 'MANUAL', reviewed: false, mentionedPersons: [{ personId: PERSON_ID, displayName: 'Auguste' }] }; function mockPersonFetch() { vi.stubGlobal( 'fetch', vi.fn().mockImplementation((url: string) => { if (url.includes('/relationships')) { return Promise.resolve({ ok: true, json: () => Promise.resolve([]) }); } return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ id: PERSON_ID, firstName: 'Auguste', lastName: 'Raddatz', displayName: 'Auguste Raddatz' }) }); }) ); } function getMentionLink(): HTMLAnchorElement { return document.querySelector( `a.person-mention[data-person-id="${PERSON_ID}"]` ) as HTMLAnchorElement; } function getHoverCard(): HTMLElement | null { return document.querySelector('[data-testid="person-hover-card"]'); } /** Hover a mention and wait until the loaded card content is in the DOM. */ async function showCard(): Promise { getMentionLink().dispatchEvent(new MouseEvent('mouseenter', { bubbles: false })); await vi.waitFor(() => { expect(document.querySelector('[data-testid="person-hover-card-content"]')).not.toBeNull(); }); } afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); // ─── Mouse timer behavior ────────────────────────────────────────────────────── describe('TranscriptionReadView — hover card mouse timer', () => { it('keeps the card open when mouse moves from mention to card within 150ms', async () => { mockPersonFetch(); render(TranscriptionReadView, { blocks: [block], onParagraphClick: () => {} }); await showCard(); // Leave mention — starts 150ms close timer getMentionLink().dispatchEvent(new MouseEvent('mouseleave', { bubbles: false })); // Enter card before 150ms — cancels timer getHoverCard()!.dispatchEvent(new MouseEvent('mouseenter')); // Wait past the original 150ms window await new Promise((r) => setTimeout(r, 200)); expect(getHoverCard()).not.toBeNull(); }); it('closes the card immediately when mouse leaves the card (no timer)', async () => { mockPersonFetch(); render(TranscriptionReadView, { blocks: [block], onParagraphClick: () => {} }); await showCard(); // Leave card — activeCard = null immediately, no timer getHoverCard()!.dispatchEvent(new MouseEvent('mouseleave')); await vi.waitFor(() => { expect(getHoverCard()).toBeNull(); }); }); it('cancels a pending close when mouse re-enters a mention', async () => { mockPersonFetch(); render(TranscriptionReadView, { blocks: [block], onParagraphClick: () => {} }); await showCard(); // Leave mention — starts 150ms close timer getMentionLink().dispatchEvent(new MouseEvent('mouseleave', { bubbles: false })); // Re-enter same mention before 150ms — cancels timer getMentionLink().dispatchEvent(new MouseEvent('mouseenter', { bubbles: false })); // Wait past the original 150ms window await new Promise((r) => setTimeout(r, 200)); expect(getHoverCard()).not.toBeNull(); }); }); // ─── Keyboard focus behavior ─────────────────────────────────────────────────── describe('TranscriptionReadView — hover card keyboard focus', () => { it('keeps the card open when keyboard focus moves from mention into card', async () => { mockPersonFetch(); render(TranscriptionReadView, { blocks: [block], onParagraphClick: () => {} }); // Show card via keyboard focusin on mention getMentionLink().dispatchEvent(new FocusEvent('focusin', { bubbles: true })); await vi.waitFor(() => { expect(document.querySelector('[data-testid="person-hover-card-content"]')).not.toBeNull(); }); // Focus leaves mention — starts 150ms close timer getMentionLink().dispatchEvent(new FocusEvent('focusout', { bubbles: true })); // Focus enters card — should cancel the close timer getHoverCard()!.dispatchEvent(new FocusEvent('focusin', { bubbles: true })); // Wait past the 150ms window await new Promise((r) => setTimeout(r, 200)); expect(getHoverCard()).not.toBeNull(); }); it('closes the card when keyboard focus leaves the card entirely', async () => { mockPersonFetch(); render(TranscriptionReadView, { blocks: [block], onParagraphClick: () => {} }); // Show card via keyboard focusin getMentionLink().dispatchEvent(new FocusEvent('focusin', { bubbles: true })); await vi.waitFor(() => { expect(document.querySelector('[data-testid="person-hover-card-content"]')).not.toBeNull(); }); // Focus leaves mention — 150ms timer starts getMentionLink().dispatchEvent(new FocusEvent('focusout', { bubbles: true })); // Focus enters card — cancels timer getHoverCard()!.dispatchEvent(new FocusEvent('focusin', { bubbles: true })); // Focus leaves card entirely (relatedTarget = null means focus left the page) getHoverCard()!.dispatchEvent( new FocusEvent('focusout', { bubbles: true, relatedTarget: null }) ); await vi.waitFor(() => { expect(getHoverCard()).toBeNull(); }); }); });