Files
TREK/client/src/components/Assistant/AssistantWindow.test.tsx
T

244 lines
9.9 KiB
TypeScript

/**
* FE-AI-WIN-001 to FE-AI-WIN-011
*
* The desktop window around the chat: when it shows at all, the button it
* opens from, the trip picker outside a trip, closing that keeps the chat, and
* Escape, which stops an answer before it closes anything (and leaves the
* header's menu to itself), and where the focus goes on the way. The geometry is
* useDragResize's and has its own tests; here it is a fixed rect.
*/
import type { AiStatusResponse } from '@trek/shared';
import { useLocation } from 'react-router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, cleanup, fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
import { tripRepo } from '../../repo/tripRepo';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import type { Trip } from '../../types';
import AssistantWindow from './AssistantWindow';
import type { EntityTarget } from './cards/cardTypes';
vi.mock('../../hooks/useDragResize', () => ({
useDragResize: () => ({
rect: { x: 600, y: 120, width: 400, height: 600 },
active: false,
handleProps: {},
edgeProps: () => ({}),
onKeyDown: () => {},
}),
}));
vi.mock('./AssistantSurfaceLazy', () => ({
default: ({
tripId,
tripTitle,
density,
onFocusDay,
onOpen,
}: {
tripId: number;
tripTitle: string;
density?: string;
onFocusDay?: (dayId: number) => void;
onOpen?: (target: EntityTarget) => void;
}) => (
<div>
<p>
chat {tripId} {tripTitle}
</p>
<output data-testid="density">{density}</output>
{onFocusDay && (
<button type="button" onClick={() => onFocusDay(12)}>
pick day
</button>
)}
{onOpen && (
<>
<button type="button" onClick={() => onOpen({ kind: 'map', placeId: 7, dayId: null })}>
open place
</button>
<button type="button" onClick={() => onOpen({ kind: 'tab', tab: 'finanzplan', params: { expense: '3' } })}>
open tab
</button>
<button type="button" onClick={() => onOpen({ kind: 'page', path: '/atlas' })}>
open page
</button>
</>
)}
<textarea aria-label="composer" />
</div>
),
}));
const TRIP = { id: 4, title: 'Kyoto' };
function Location() {
const { pathname, search } = useLocation();
return <output data-testid="location">{`${pathname}${search}`}</output>;
}
const INITIAL = useAssistantStore.getState();
const STATUS = { available: true, retention: { mode: 'days', days: 90 } } as AiStatusResponse;
const loadStatus = vi.fn(async () => {});
const resume = vi.fn(async () => {});
const cancel = vi.fn();
function seed(window: { open: boolean; minimized: boolean }, isRunning = false) {
// A chat the test before unmounted stops itself on the way out; that call is not this test's.
cancel.mockClear();
useAssistantStore.setState({
status: STATUS,
window,
loadStatus,
resume,
cancel,
byTrip: { [TRIP.id]: { ...chatOf(useAssistantStore.getState(), TRIP.id), isRunning } },
});
}
beforeEach(() => {
vi.spyOn(tripRepo, 'list').mockResolvedValue({
trips: [{ id: 9, title: 'Lisbon', start_date: '2026-10-01', end_date: '2026-12-01' } as Trip],
archivedTrips: [],
});
});
afterEach(() => {
// Unmounted before the store gets its real actions back: a window still on screen would ask the real
// loadStatus, whose failed request lands in a later case and hides the assistant there (status null).
cleanup();
useAssistantStore.setState(INITIAL, true);
vi.restoreAllMocks();
vi.clearAllMocks();
});
describe('AssistantWindow', () => {
it('FE-AI-WIN-001: shows nothing at all without a connection, but asks for the status', () => {
seed({ open: true, minimized: false });
useAssistantStore.setState({ status: { ...STATUS, available: false } });
const { container } = render(<AssistantWindow trip={TRIP} />);
expect(container).toBeEmptyDOMElement();
expect(loadStatus).toHaveBeenCalled();
});
it('FE-AI-WIN-002: closed, the planner shows the round button, and it opens the chat on this trip', () => {
seed({ open: false, minimized: false });
render(<AssistantWindow trip={TRIP} />);
fireEvent.click(screen.getByRole('button', { name: 'Assistant' }));
expect(screen.getByRole('dialog', { name: 'Assistant' })).toHaveTextContent('chat 4 Kyoto');
expect(resume).toHaveBeenCalledWith(TRIP.id);
});
it('FE-AI-WIN-003: closed outside a trip, nothing floats', () => {
seed({ open: false, minimized: false });
const { container } = render(<AssistantWindow trip={null} />);
expect(container).toBeEmptyDOMElement();
});
it('FE-AI-WIN-004: outside a trip it asks which trip first, then chats there', async () => {
seed({ open: true, minimized: false });
render(<AssistantWindow trip={null} />);
expect(screen.getByRole('dialog', { name: 'Which trip?' })).toBeInTheDocument();
fireEvent.click(await screen.findByRole('button', { name: /Lisbon/ }));
expect(screen.getByText('chat 9 Lisbon')).toBeInTheDocument();
});
it('FE-AI-WIN-005: closed while writing, the chat stays mounted and the round button breathes until it is opened', () => {
seed({ open: true, minimized: true }, true);
render(<AssistantWindow trip={TRIP} />);
expect(screen.getByText('chat 4 Kyoto')).not.toBeVisible();
const button = screen.getByRole('button', { name: 'Assistant' });
expect(button).toHaveAttribute('aria-busy', 'true');
fireEvent.click(button);
expect(useAssistantStore.getState().window.minimized).toBe(false);
expect(cancel).not.toHaveBeenCalled();
});
it('FE-AI-WIN-006: Escape stops a running answer first and only then closes', () => {
seed({ open: true, minimized: false }, true);
render(<AssistantWindow trip={TRIP} />);
const dialog = screen.getByRole('dialog', { name: 'Assistant' });
fireEvent.keyDown(dialog, { key: 'Escape' });
expect(cancel).toHaveBeenCalledWith(TRIP.id);
expect(useAssistantStore.getState().window.open).toBe(true);
act(() =>
useAssistantStore.setState({
byTrip: { [TRIP.id]: { ...chatOf(useAssistantStore.getState(), TRIP.id), isRunning: false } },
})
);
fireEvent.keyDown(dialog, { key: 'Escape' });
expect(useAssistantStore.getState().window.open).toBe(false);
});
it('FE-AI-WIN-007: the head has one close button, which hides the window and keeps the chat', () => {
seed({ open: true, minimized: false });
render(<AssistantWindow trip={TRIP} />);
expect(screen.queryByRole('button', { name: 'Minimize' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(useAssistantStore.getState().window.minimized).toBe(true);
expect(screen.getByText('chat 4 Kyoto')).not.toBeVisible();
});
it('FE-AI-WIN-008: closing hands the focus to the round button, opening hands it back to the composer', async () => {
seed({ open: true, minimized: false });
render(<AssistantWindow trip={TRIP} />);
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
const button = await screen.findByRole('button', { name: 'Assistant' });
expect(button).toHaveFocus();
fireEvent.click(button);
await waitFor(() => expect(screen.getByLabelText('composer')).toHaveFocus());
});
it("FE-AI-WIN-009: Escape in the header's menu closes the menu, not the window", async () => {
seed({ open: true, minimized: false });
render(<AssistantWindow trip={TRIP} />);
fireEvent.click(screen.getByRole('button', { name: 'More options' }));
fireEvent.keyDown(await screen.findByRole('menu'), { key: 'Escape' });
await waitFor(() => expect(screen.queryByRole('menu')).not.toBeInTheDocument());
expect(useAssistantStore.getState().window.open).toBe(true);
});
it('FE-AI-WIN-010: tells the map where it sits, is compact at 400 px wide, and picks days on screen', () => {
seed({ open: true, minimized: false });
const onFocusDay = vi.fn();
const { unmount } = render(<AssistantWindow trip={TRIP} onFocusDay={onFocusDay} />);
expect(useAssistantStore.getState().windowRect).toEqual({ x: 600, y: 120, width: 400, height: 600 });
expect(screen.getByTestId('density')).toHaveTextContent('compact');
fireEvent.click(screen.getByRole('button', { name: 'pick day' }));
expect(onFocusDay).toHaveBeenCalledWith(12);
unmount();
expect(useAssistantStore.getState().windowRect).toBeNull();
});
it('FE-AI-WIN-011: opens through the planner on screen, keeps a trip it leaves, and opens a picked trip itself', async () => {
seed({ open: true, minimized: false });
const onOpen = vi.fn();
const onScreen = render(<AssistantWindow trip={TRIP} onOpen={onOpen} />);
fireEvent.click(screen.getByRole('button', { name: 'open place' }));
expect(onOpen).toHaveBeenCalledWith({ kind: 'map', placeId: 7, dayId: null });
// Another page leaves the trip; the chat stays on it as if it had been picked.
fireEvent.click(screen.getByRole('button', { name: 'open page' }));
expect(onOpen).toHaveBeenLastCalledWith({ kind: 'page', path: '/atlas' });
onScreen.rerender(<AssistantWindow trip={null} onOpen={onOpen} />);
expect(screen.getByText('chat 4 Kyoto')).toBeInTheDocument();
onScreen.unmount();
seed({ open: true, minimized: false });
render(
<>
<AssistantWindow trip={null} onOpen={onOpen} />
<Location />
</>
);
fireEvent.click(await screen.findByRole('button', { name: /Lisbon/ }));
fireEvent.click(screen.getByRole('button', { name: 'open tab' }));
expect(screen.getByTestId('location')).toHaveTextContent('/trips/9?tab=finanzplan&expense=3');
fireEvent.click(screen.getByRole('button', { name: 'open place' }));
expect(screen.getByTestId('location')).toHaveTextContent(/^\/trips\/9$/);
fireEvent.click(screen.getByRole('button', { name: 'open page' }));
expect(screen.getByTestId('location')).toHaveTextContent('/atlas');
expect(onOpen).toHaveBeenCalledTimes(2);
});
});