import React from 'react'; import { describe, it, expect, vi } from 'vitest'; import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; import { PixelModal } from '../../overlays/PixelModal'; describe('PixelModal — hardening', () => { it('renders into document.body via portal by default', () => { const { container } = render(
{}}>
, ); const child = screen.getByTestId('modal-child'); expect(document.body.contains(child)).toBe(true); // Critical: child must NOT live inside the inline parent (it must be portaled out). const inline = container.querySelector('[data-testid="inline-parent"]'); expect(inline?.contains(child)).toBe(false); }); it('traps Tab focus inside the modal — Tab from last cycles to first', async () => { render( {}}> , ); // useFocusTrap focuses the first focusable element (the close button in title bar) // We grab the LAST focusable element (the "last" button) and ensure that pressing // Tab from there wraps back to the first focusable (the close button). const last = screen.getByTestId('last'); last.focus(); expect(document.activeElement).toBe(last); // Tab from last → should cycle to first focusable (close button). fireEvent.keyDown(document, { key: 'Tab' }); const closeBtn = screen.getByRole('button', { name: /close/i }); expect(document.activeElement).toBe(closeBtn); // Shift+Tab from first focusable → should cycle to last. fireEvent.keyDown(document, { key: 'Tab', shiftKey: true }); expect(document.activeElement).toBe(last); }); it('escape key closes the modal', () => { const onClose = vi.fn(); render( body , ); fireEvent.keyDown(window, { key: 'Escape' }); expect(onClose).toHaveBeenCalledTimes(1); }); it('renders footer when provided', () => { render( {}} footer={}> body , ); expect(screen.getByTestId('footer-btn')).toBeInTheDocument(); }); it('renders description and wires aria-describedby', () => { render( {}} description="Helper text for AT users."> body , ); const dialog = screen.getByRole('dialog'); const describedBy = dialog.getAttribute('aria-describedby'); expect(describedBy).toBeTruthy(); const descNode = document.getElementById(describedBy!); expect(descNode).not.toBeNull(); expect(descNode!.textContent).toContain('Helper text'); }); it('size="full" applies full-viewport classes', () => { render( {}} size="full"> body , ); const dialog = screen.getByRole('dialog'); // The inner dialog box (child after the overlay div) has the size classes. const box = dialog.querySelector('.bg-retro-bg') as HTMLElement; expect(box).not.toBeNull(); expect(box.className).toMatch(/max-w-\[95vw\]/); expect(box.className).toMatch(/max-h-\[95vh\]/); expect(box.className).toMatch(/overflow-y-auto/); }); it('size="xl" applies max-w-4xl', () => { render( {}} size="xl"> body , ); const dialog = screen.getByRole('dialog'); const box = dialog.querySelector('.bg-retro-bg') as HTMLElement; expect(box.className).toMatch(/max-w-4xl/); }); it('scroll lock applied while open and released on close', () => { const { rerender } = render( {}}> body , ); expect(document.body.style.overflow).toBe('hidden'); rerender( {}}> body , ); // After unmounting (open=false), scroll lock should release. expect(document.body.style.overflow).toBe(''); }); it('asyncClose awaits before invoking onClose and shows loading state', async () => { let resolveClose: (() => void) | null = null; const asyncClose = vi.fn(() => new Promise((res) => { resolveClose = res; })); const onClose = vi.fn(); render( body , ); const closeBtn = screen.getByRole('button', { name: /close/i }); fireEvent.click(closeBtn); // Loading state: button busy + disabled until promise resolves. await waitFor(() => { expect(closeBtn.getAttribute('aria-busy')).toBe('true'); }); expect((closeBtn as HTMLButtonElement).disabled).toBe(true); expect(asyncClose).toHaveBeenCalledTimes(1); expect(onClose).not.toHaveBeenCalled(); // Resolve the promise — onClose fires after. await act(async () => { resolveClose?.(); }); expect(onClose).toHaveBeenCalledTimes(1); }); });