/** * FE-AI-LAUNCH-001 to FE-AI-LAUNCH-007 * * The way in: hidden until the assistant can run, a button that opens and * closes the window, a dot for warnings in the plan not seen yet, a floating * button that breathes while a trip's answer is written in a closed window, * and ⌘K / Ctrl+K, which leaves a dialog alone. */ import { renderHook } from '@testing-library/react'; import type { AiStatusResponse } from '@trek/shared'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { buildAssignment, buildDay, buildPlace, buildTrip } from '../../../tests/helpers/factories'; import { act, fireEvent, render, screen } from '../../../tests/helpers/render'; import { resetAllStores } from '../../../tests/helpers/store'; import { assistantChatKey } from '../../store/assistantChatKey'; import { chatOf, useAssistantStore } from '../../store/assistantStore'; import { useTripStore } from '../../store/tripStore'; import AssistantLauncher from './AssistantLauncher'; import { isAssistantHotkey, useAssistantHotkey } from './useAssistantHotkey'; const INITIAL = useAssistantStore.getState(); const STATUS = { available: true } as AiStatusResponse; function press(init: KeyboardEventInit): KeyboardEvent { const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init }); window.dispatchEvent(event); return event; } afterEach(() => useAssistantStore.setState(INITIAL, true)); describe('AssistantLauncher', () => { it('FE-AI-LAUNCH-001: shows nothing until the assistant has a connection to use', () => { useAssistantStore.setState({ status: { ...STATUS, available: false } }); const { container } = render(); expect(container).toBeEmptyDOMElement(); }); it('FE-AI-LAUNCH-002: the navbar button and the floating one open and close the window', () => { useAssistantStore.setState({ status: STATUS }); const { unmount } = render(); const button = screen.getByRole('button', { name: 'Assistant' }); fireEvent.click(button); expect(useAssistantStore.getState().window).toEqual({ open: true, minimized: false }); expect(button).toHaveAttribute('aria-pressed', 'true'); fireEvent.click(button); expect(useAssistantStore.getState().window.open).toBe(false); unmount(); render(); fireEvent.click(screen.getByRole('button', { name: 'Assistant' })); expect(useAssistantStore.getState().window.open).toBe(true); }); it('FE-AI-LAUNCH-006: the floating button carries a dot for a warning not seen yet, until the chat is opened', () => { window.localStorage.clear(); resetAllStores(); const places = [ buildPlace({ id: 50, place_time: '10:00', end_time: '12:00' }), buildPlace({ id: 51, place_time: '11:00', end_time: '12:30' }), ]; const stops = places.map((place, i) => buildAssignment({ id: 60 + i, day_id: 1, place_id: place.id, order_index: i, place }) ); useTripStore.setState({ trip: buildTrip({ id: 3, start_date: '2099-06-01', end_date: '2099-06-01' }), days: [buildDay({ id: 1, trip_id: 3, date: '2099-06-01' })], places, assignments: { '1': stops }, reservations: [], dayNotes: {}, }); useAssistantStore.setState({ status: STATUS }); const { unmount } = render(); expect(screen.getByRole('button', { name: 'Assistant' })).toBeInTheDocument(); unmount(); render(); const button = screen.getByRole('button', { name: 'Assistant, 1 hint' }); // The dot stands out from the accent button it sits on. expect(button.querySelector('.bg-warning')).not.toBeNull(); fireEvent.click(button); expect(useAssistantStore.getState().window.open).toBe(true); expect(screen.getByRole('button', { name: 'Assistant' }).querySelector('.bg-warning')).toBeNull(); }); it('FE-AI-LAUNCH-007: the floating button breathes for a trip answer being written, not for one still running in the Studio', () => { const studio = assistantChatKey({ kind: 'journey', journeyId: 3 }); const chat = (key: number, isRunning: boolean) => ({ ...chatOf(useAssistantStore.getState(), key), isRunning }); useAssistantStore.setState({ status: STATUS, byTrip: { [studio]: chat(studio, true), [5]: chat(5, false) } }); render(); const button = screen.getByRole('button', { name: 'Assistant' }); expect(button).not.toHaveAttribute('aria-busy'); act(() => useAssistantStore.setState((s) => ({ byTrip: { ...s.byTrip, [4]: chat(4, true) } }))); expect(button).toHaveAttribute('aria-busy', 'true'); // An open window shows the answer itself. act(() => useAssistantStore.getState().open()); expect(button).not.toHaveAttribute('aria-busy'); }); }); describe('the shortcut', () => { it('FE-AI-LAUNCH-003: is ⌘K or Ctrl+K, without Alt or Shift', () => { const key = (init: KeyboardEventInit) => isAssistantHotkey(new KeyboardEvent('keydown', init)); expect(key({ key: 'k', metaKey: true })).toBe(true); expect(key({ key: 'K', ctrlKey: true })).toBe(true); expect(key({ key: 'k' })).toBe(false); expect(key({ key: 'k', ctrlKey: true, altKey: true })).toBe(false); expect(key({ key: 'k', metaKey: true, shiftKey: true })).toBe(false); expect(key({ key: 'j', metaKey: true })).toBe(false); }); it('FE-AI-LAUNCH-004: toggles and takes the key, only while enabled', () => { const onToggle = vi.fn(); const { rerender } = renderHook(({ enabled }) => useAssistantHotkey(enabled, onToggle), { initialProps: { enabled: false }, }); expect(press({ key: 'k', ctrlKey: true }).defaultPrevented).toBe(false); expect(onToggle).not.toHaveBeenCalled(); rerender({ enabled: true }); expect(press({ key: 'k', ctrlKey: true }).defaultPrevented).toBe(true); expect(onToggle).toHaveBeenCalledTimes(1); }); it('FE-AI-LAUNCH-005: leaves the key alone while a dialog is open or something else took it', () => { const onToggle = vi.fn(); renderHook(() => useAssistantHotkey(true, onToggle)); const backdrop = document.createElement('div'); backdrop.className = 'trek-modal-backdrop'; document.body.appendChild(backdrop); press({ key: 'k', metaKey: true }); backdrop.remove(); const taken = new KeyboardEvent('keydown', { key: 'k', metaKey: true, cancelable: true }); taken.preventDefault(); window.dispatchEvent(taken); expect(onToggle).not.toHaveBeenCalled(); }); });