/** * Motion system: preferences, the progressive reveal's pacing, the primitives' * accessible output, and the Back gesture closing layers in order. */ import React from 'react' import { act, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { AudioWaveform, Collapsible, DEFAULT_MOTION_PREFS, LoadingDots, MOTION_EVENT, ProgressBar, StatusText, StreamReveal, VoiceOrb, partialMarkdown, readMotionPrefs, revealDuration, revealStops, waveformWeights, writeMotionPrefs, MotionGallery, } from '../ui/motion' import { useBackToClose } from '../ui/lib/useBackToClose' afterEach(() => { localStorage.clear() delete document.documentElement.dataset.hpMotion delete document.documentElement.dataset.hpReveal vi.useRealTimers() }) describe('motion preferences', () => { it('default to the enterprise level with streamed answers, and ignore unknown values', () => { expect(readMotionPrefs()).toEqual(DEFAULT_MOTION_PREFS) localStorage.setItem('homepilot_motion', JSON.stringify({ level: 'wild', reveal: 'fade' })) expect(readMotionPrefs()).toEqual({ level: 'enterprise', reveal: 'fade' }) localStorage.setItem('homepilot_motion', '{not json') expect(readMotionPrefs()).toEqual(DEFAULT_MOTION_PREFS) }) it('are saved on this device, mirrored onto , and announced', () => { const heard = vi.fn() window.addEventListener(MOTION_EVENT, heard) writeMotionPrefs({ level: 'minimal' }) window.removeEventListener(MOTION_EVENT, heard) expect(readMotionPrefs()).toEqual({ level: 'minimal', reveal: 'stream' }) expect(document.documentElement.dataset.hpMotion).toBe('minimal') expect(document.documentElement.dataset.hpReveal).toBe('stream') expect(heard).toHaveBeenCalledTimes(1) }) }) describe('progressive reveal', () => { it('stops only after whole words and always ends at the full text', () => { const text = 'Hello there, world' const stops = revealStops(text) expect(stops).toEqual([6, 14, 19]) expect(stops.map((i) => text.slice(0, i))).toEqual(['Hello ', 'Hello there, ', 'Hello there, world']) expect(revealStops('')).toEqual([0]) }) it('is paced by length but never slow', () => { expect(revealDuration(3)).toBe(450) expect(revealDuration(60)).toBe(840) expect(revealDuration(5000)).toBe(1600) }) it('closes an unfinished code fence so a partial answer still renders as code', () => { const text = 'Run this:\n\n```bash\nls -la\n```\n\nDone.' expect(partialMarkdown(text, 'Run this:\n\n```bash\nls'.length)).toBe('Run this:\n\n```bash\nls\n```') expect(partialMarkdown(text, text.length)).toBe(text) }) it('renders the final answer at once where motion cannot run (tests, reduced motion)', () => { render(

{t}

} />) expect(screen.getByText('All of it')).toBeInTheDocument() }) }) describe('motion primitives', () => { it('give every animation a readable, accessible equivalent', () => { render( <> , ) expect(screen.getByRole('img', { name: 'Waiting for a reply' })).toBeInTheDocument() expect(screen.getByRole('progressbar', { name: 'Upload' })).toHaveAttribute('aria-valuenow', '42') expect(screen.getByRole('progressbar', { name: 'Refreshing' })).not.toHaveAttribute('aria-valuenow') expect(screen.getByRole('status')).toHaveTextContent('Searching the web') const orb = document.querySelector('.hp-orb') as HTMLElement expect(orb.style.getPropertyValue('--hp-level')).toBe('1.000') // clamped expect(document.querySelectorAll('.hp-wave > span')).toHaveLength(9) }) it('announce a changed status once, with the new label', () => { const { rerender } = render() rerender() expect(screen.getByRole('status')).toHaveTextContent('Reading 3 sources') expect(screen.getByRole('status')).not.toHaveTextContent('Searching') }) it('hide collapsed content from everyone', () => { const { rerender } = render(Details) expect(document.querySelector('.hp-collapsible')).toHaveAttribute('aria-hidden', 'true') rerender(Details) expect(document.querySelector('.hp-collapsible')).not.toHaveAttribute('aria-hidden') }) it('shape the waveform like a voice: tallest in the middle', () => { const w = waveformWeights(7) expect(w[3]).toBeCloseTo(1) expect(w[0]).toBeCloseTo(w[6]) expect(w[0]).toBeLessThan(w[3]) }) }) describe('Settings → Motion', () => { it('lists all 22 animations and applies a choice immediately', () => { render() const list = screen.getByRole('list', { name: 'Animations in HomePilot' }) expect(list.textContent).toContain('Text shimmer') expect(list.textContent).toContain('Progress indicator') expect(list.querySelectorAll('[data-motion-demo]')).toHaveLength(22) fireEvent.click(screen.getByRole('radio', { name: /Off/ })) expect(readMotionPrefs().level).toBe('off') expect(screen.getByRole('radio', { name: /Off/ })).toHaveAttribute('aria-checked', 'true') fireEvent.click(screen.getByRole('radio', { name: /Instant/ })) expect(readMotionPrefs().reveal).toBe('instant') }) }) function Layer({ name, onClose }: { name: string; onClose: () => void }) { useBackToClose(true, onClose) return
{name}
} describe('Back gesture', () => { it('closes only the top layer, and gives its history entry back when closed another way', async () => { vi.useFakeTimers() const start = window.history.length const closeDrawer = vi.fn() const closeSettings = vi.fn() const { rerender } = render() await act(async () => { vi.advanceTimersByTime(1) }) rerender(<>) await act(async () => { vi.advanceTimersByTime(1) }) expect(window.history.length).toBe(start + 2) expect(window.history.state.hpLayer).toBe(2) // Back from settings: lands on the drawer's entry. await act(async () => { window.dispatchEvent(new PopStateEvent('popstate', { state: { hpLayer: 1 } })) }) expect(closeSettings).toHaveBeenCalledTimes(1) expect(closeDrawer).not.toHaveBeenCalled() }) }) describe('StatusText under rapid changes', () => { it('never shows two labels when the status flips faster than it can animate', () => { const { rerender } = render() for (let i = 0; i < 6; i++) { rerender() } expect(document.querySelectorAll('.hp-status-swap__out')).toHaveLength(0) expect(screen.getByRole('status').textContent).toBe('Listening for voice...') }) })