import React from 'react'; import { describe, it, expect, vi, afterEach } from 'vitest'; import { render, screen, fireEvent, act } from '@testing-library/react'; import MSheet from '../../../src/mobile/components/MSheet'; // FE-MOB-SHEET-001 onwards describe('MSheet', () => { afterEach(() => { vi.useRealTimers(); document.body.style.overflow = ''; }); it('FE-MOB-SHEET-001: renders its children in a dialog when open', () => { render( {}} ariaLabel="Test sheet"> Sheet content , ); expect(screen.getByRole('dialog', { name: 'Test sheet' })).toBeInTheDocument(); expect(screen.getByText('Sheet content')).toBeInTheDocument(); }); it('FE-MOB-SHEET-002: renders nothing while closed', () => { render( {}}> Sheet content , ); expect(screen.queryByText('Sheet content')).not.toBeInTheDocument(); }); it('FE-MOB-SHEET-003: backdrop click closes, clicks inside the panel do not', () => { const onClose = vi.fn(); render( Sheet content , ); fireEvent.click(screen.getByText('Sheet content')); expect(onClose).not.toHaveBeenCalled(); const dialog = screen.getByRole('dialog'); const backdrop = dialog.parentElement!.parentElement!; fireEvent.click(backdrop); expect(onClose).toHaveBeenCalledTimes(1); }); it('FE-MOB-SHEET-004: Escape closes the sheet', () => { const onClose = vi.fn(); render( Sheet content , ); fireEvent.keyDown(document, { key: 'Escape' }); expect(onClose).toHaveBeenCalledTimes(1); }); it('FE-MOB-SHEET-005: stays mounted for the exit animation, then unmounts', () => { vi.useFakeTimers(); const { rerender } = render( {}}> Sheet content , ); rerender( {}}> Sheet content , ); // Still visible right after closing (exit animation running). expect(screen.getByText('Sheet content')).toBeInTheDocument(); act(() => { vi.advanceTimersByTime(300); }); expect(screen.queryByText('Sheet content')).not.toBeInTheDocument(); }); it('FE-MOB-SHEET-007: Tab wraps around inside the dialog', () => { render( {}} ariaLabel="Trap"> , ); const dialog = screen.getByRole('dialog'); const first = screen.getByText('first'); const last = screen.getByText('last'); last.focus(); fireEvent.keyDown(dialog, { key: 'Tab' }); expect(document.activeElement).toBe(first); fireEvent.keyDown(dialog, { key: 'Tab', shiftKey: true }); expect(document.activeElement).toBe(last); }); it('FE-MOB-SHEET-008: shift+Tab from the panel itself jumps to the last control', () => { render( {}} ariaLabel="Trap"> , ); const dialog = screen.getByRole('dialog'); dialog.focus(); fireEvent.keyDown(dialog, { key: 'Tab', shiftKey: true }); expect(document.activeElement).toBe(screen.getByText('last')); }); it('FE-MOB-SHEET-009: keys other than Tab pass through', () => { render( {}} ariaLabel="Trap"> , ); const dialog = screen.getByRole('dialog'); const first = screen.getByText('first'); first.focus(); fireEvent.keyDown(dialog, { key: 'a' }); expect(document.activeElement).toBe(first); }); it('FE-MOB-SHEET-010: a sheet without focusable content swallows Tab', () => { render( {}} ariaLabel="Empty"> nothing to focus , ); const dialog = screen.getByRole('dialog'); const handled = fireEvent.keyDown(dialog, { key: 'Tab' }); // fireEvent returns false once a listener called preventDefault. expect(handled).toBe(false); }); it('FE-MOB-SHEET-011: the entrance animation class is dropped once it has played', () => { render( {}} ariaLabel="Anim"> Sheet content , ); const dialog = screen.getByRole('dialog'); expect(dialog.className).toContain('m-sheet-in'); // jsdom has no AnimationEvent, so React binds the vendor-prefixed name. act(() => { dialog.dispatchEvent(new Event('animationend', { bubbles: true })); dialog.dispatchEvent(new Event('webkitAnimationEnd', { bubbles: true })); }); expect(dialog.className).not.toContain('m-sheet-in'); }); it('FE-MOB-SHEET-012: dragging the bottom sheet far enough dismisses it', () => { HTMLElement.prototype.setPointerCapture = vi.fn(); const onClose = vi.fn(); render( Sheet content , ); const dialog = screen.getByRole('dialog'); Object.defineProperty(dialog, 'offsetHeight', { configurable: true, value: 400 }); const strip = dialog.querySelector('.cursor-grab') as HTMLElement; fireEvent.pointerDown(strip, { pointerId: 1, clientY: 100 }); fireEvent.pointerMove(strip, { pointerId: 1, clientY: 260 }); expect(dialog.getAttribute('style')).toContain('translateY(160px)'); // A slow last move keeps the velocity low, so only the 30% travel rule can close it. fireEvent.pointerMove(strip, { pointerId: 1, clientY: 260.3 }); fireEvent.pointerUp(strip, { pointerId: 1, clientY: 260.3 }); expect(onClose).toHaveBeenCalledTimes(1); }); it('FE-MOB-SHEET-015: a quick flick dismisses even without much travel', () => { HTMLElement.prototype.setPointerCapture = vi.fn(); const onClose = vi.fn(); render( Sheet content , ); const strip = screen.getByRole('dialog').querySelector('.cursor-grab') as HTMLElement; fireEvent.pointerDown(strip, { pointerId: 1, clientY: 100 }); fireEvent.pointerMove(strip, { pointerId: 1, clientY: 130 }); fireEvent.pointerUp(strip, { pointerId: 1, clientY: 130 }); expect(onClose).toHaveBeenCalledTimes(1); }); it('FE-MOB-SHEET-013: a short drag snaps back instead of closing', () => { HTMLElement.prototype.setPointerCapture = vi.fn(); const onClose = vi.fn(); render( Sheet content , ); const dialog = screen.getByRole('dialog'); Object.defineProperty(dialog, 'offsetHeight', { configurable: true, value: 400 }); const strip = dialog.querySelector('.cursor-grab') as HTMLElement; fireEvent.pointerDown(strip, { pointerId: 1, clientY: 100 }); fireEvent.pointerMove(strip, { pointerId: 1, clientY: 100.4 }); fireEvent.pointerCancel(strip, { pointerId: 1, clientY: 100.4 }); expect(onClose).not.toHaveBeenCalled(); expect(dialog.getAttribute('style')).toContain('transform 280ms'); }); it('FE-MOB-SHEET-014: pointer moves without a drag in progress are ignored', () => { const onClose = vi.fn(); render( Sheet content , ); const strip = screen.getByRole('dialog').querySelector('.cursor-grab') as HTMLElement; fireEvent.pointerMove(strip, { pointerId: 1, clientY: 400 }); fireEvent.pointerUp(strip, { pointerId: 1, clientY: 400 }); expect(onClose).not.toHaveBeenCalled(); }); it('FE-MOB-SHEET-006: locks body scroll while open and releases it on close', () => { const { rerender } = render( {}}> Sheet content , ); expect(document.body.style.overflow).toBe('hidden'); rerender( {}}> Sheet content , ); expect(document.body.style.overflow).toBe(''); }); });