Files
TREK/client/tests/unit/mobile/trip/MAssistantScreen.test.tsx
T
Maurice b64da26651 feat(assistant): trip assistant chat with your own AI connection
Adds an in-app assistant that reads and plans the open trip. Users connect
their own provider (Sign in with ChatGPT, or an Anthropic, OpenAI, Gemini or
OpenRouter key) or use the one the admin set up, including a custom
OpenAI-compatible server. It is an addon and only shows while a connection
is usable.

The chat runs on the existing MCP tools, pinned to the trip. Every change is
held as a change card the user applies or discards, and applied changes can
be undone. On the desktop it is a floating window that can be moved and
resized; on the phone it is fullscreen. Search results show as place cards
that can go onto a day, weather lookups as a strip, proposed places as
ghost markers on the map with a before/after switch, and an answer can be
shared into the trip chat. Booking files can be dropped in and are read
into change cards.
2026-10-22 21:30:12 +00:00

208 lines
8.5 KiB
TypeScript

import type { AiStatusResponse, AssistantApplied } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import MAssistantScreen from '../../../../src/mobile/screens/trip/assistant/MAssistantScreen';
import { chatOf, useAssistantStore } from '../../../../src/store/assistantStore';
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip';
import { act, cleanup, fireEvent, render, screen } from '../../../helpers/render';
// FE-MOB-ASSIST-001 to FE-MOB-ASSIST-011
//
// The phone's full-screen chat around the shared chat surface, which is
// replaced here: what is tested is the frame, the header and the way to the
// map and back.
const surface = vi.hoisted(() => ({
onShowOnMap: null as ((applied: AssistantApplied) => void) | null,
onFocusDay: null as ((dayId: number) => void) | null,
onRevealMap: null as (() => void) | null,
}));
const thread = vi.hoisted(() => ({
threadId: 3 as number | null,
threads: [{ id: 3, title: 'Temples on day 2', lastMessageAt: '2026-10-20T09:00:00.000Z' }],
title: 'Temples on day 2' as string | null,
isRunning: false,
retentionNote: 'Chats are deleted after 90 days.',
refreshThreads: vi.fn(),
openThread: vi.fn(),
newThread: vi.fn(),
removeThread: vi.fn(async () => undefined),
stop: vi.fn(),
}));
vi.mock('../../../../src/components/Assistant/AssistantSurfaceLazy', () => ({
default: (props: {
onShowOnMap?: (applied: AssistantApplied) => void;
onFocusDay?: (dayId: number) => void;
onRevealMap?: () => void;
}) => {
surface.onShowOnMap = props.onShowOnMap ?? null;
surface.onFocusDay = props.onFocusDay ?? null;
surface.onRevealMap = props.onRevealMap ?? null;
return <div data-testid="chat-surface" />;
},
}));
vi.mock('../../../../src/components/Assistant/useAssistantThread', () => ({
useAssistantThread: () => thread,
}));
const INITIAL = useAssistantStore.getState();
const STATUS = { available: true, retention: { mode: 'days', days: 90 } } as AiStatusResponse;
function renderScreen(plannerOverrides = {}, shellOverrides = {}) {
const planner = buildPlanner(plannerOverrides);
const shell = buildShell(shellOverrides);
const view = render(<MAssistantScreen planner={planner} shell={shell} />);
return { planner, shell, ...view };
}
beforeEach(() => {
// Closing the screen takes its history entry back a tick later; that step stays queued here.
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
window.history.replaceState(null, '');
// The screen asks for the status itself; here it is already known.
useAssistantStore.setState({
status: STATUS,
window: { open: true, minimized: false },
loadStatus: vi.fn(async () => undefined),
});
});
afterEach(() => {
cleanup();
vi.clearAllTimers();
vi.useRealTimers();
useAssistantStore.setState(INITIAL, true);
vi.clearAllMocks();
});
describe('MAssistantScreen', () => {
it('FE-MOB-ASSIST-001: shows nothing until the assistant has a connection to use', () => {
useAssistantStore.setState({ status: { ...STATUS, available: false } });
const { container } = renderScreen();
expect(container).toBeEmptyDOMElement();
});
it('FE-MOB-ASSIST-002: asks for the status once when the trip opens', () => {
renderScreen();
expect(useAssistantStore.getState().loadStatus).toHaveBeenCalledTimes(1);
});
it('FE-MOB-ASSIST-003: opens full screen with the chat, named after the conversation and the trip', () => {
renderScreen();
expect(screen.getByRole('dialog', { name: 'Assistant' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Temples on day 2' })).toBeInTheDocument();
expect(screen.getByText('Japan 2026')).toBeInTheDocument();
expect(screen.getByTestId('chat-surface')).toBeInTheDocument();
});
it('FE-MOB-ASSIST-004: the back button closes the chat', () => {
renderScreen();
fireEvent.click(screen.getByRole('button', { name: 'Back' }));
expect(useAssistantStore.getState().window.open).toBe(false);
});
it('FE-MOB-ASSIST-005: the recent chats list switches the conversation and names how long chats are kept', () => {
renderScreen();
fireEvent.click(screen.getByRole('button', { name: 'Recent chats' }));
expect(thread.refreshThreads).toHaveBeenCalled();
expect(screen.getByText('Chats are deleted after 90 days.')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Temples on day 2/ }));
expect(thread.openThread).toHaveBeenCalledWith(3);
});
it('FE-MOB-ASSIST-006: deleting the chat asks first', () => {
renderScreen();
fireEvent.click(screen.getByRole('button', { name: 'More options' }));
fireEvent.click(screen.getByRole('button', { name: 'Delete chat' }));
expect(thread.removeThread).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
expect(thread.removeThread).toHaveBeenCalledTimes(1);
});
it('FE-MOB-ASSIST-007: "Show on map" steps aside for the map, and the pill brings the chat back as it was', () => {
const { planner, shell } = renderScreen({}, { trTab: 'listen', view: 'plan' });
act(() => surface.onShowOnMap?.({ toolCallId: 'c1', entity: 'place', ids: [42], dayId: 2, undoUntil: null }));
expect(useAssistantStore.getState().window).toEqual({ open: true, minimized: true });
expect(shell.setTrTab).toHaveBeenCalledWith('plan');
expect(shell.toggleView).toHaveBeenCalled();
expect(planner.setSelectedPlaceId).toHaveBeenCalledWith(42);
// Hidden, not gone: the answer being written keeps coming in.
expect(screen.getByTestId('chat-surface')).toBeInTheDocument();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Back to chat' }));
expect(useAssistantStore.getState().window.minimized).toBe(false);
expect(screen.getByRole('dialog', { name: 'Assistant' })).toBeInTheDocument();
});
it('FE-MOB-ASSIST-009: a day picked in the chat is selected on the map, and the pill says where a step looks', () => {
const { planner, shell } = renderScreen({}, { trTab: 'listen', view: 'plan' });
act(() => surface.onFocusDay?.(7));
expect(useAssistantStore.getState().window.minimized).toBe(true);
expect(shell.setTrTab).toHaveBeenCalledWith('plan');
expect(planner.handleSelectDay).toHaveBeenCalledWith(7, false);
act(() =>
useAssistantStore.setState((s) => ({
byTrip: {
...s.byTrip,
[planner.tripId]: {
...chatOf(s, planner.tripId),
messages: [
{
id: 'msg-1',
role: 'assistant',
status: 'complete',
createdAt: '2026-10-21T08:00:00.000Z',
parts: [{ type: 'tool-call', toolCallId: 'c1', toolName: 'search_nearby_places', args: {} }],
},
],
live: { activity: { toolCallId: 'c1', label: 'Gion', area: null } },
},
},
}))
);
expect(screen.getByRole('button', { name: 'Back to chat' })).toHaveTextContent('Searching near Gion');
});
it('FE-MOB-ASSIST-010: before the map moves to a found place, the chat steps aside', () => {
const { shell } = renderScreen({}, { trTab: 'plan', view: 'plan' });
act(() => surface.onRevealMap?.());
expect(useAssistantStore.getState().window.minimized).toBe(true);
expect(shell.toggleView).toHaveBeenCalled();
});
it('FE-MOB-ASSIST-011: while a block can be compared, its switch sits beside the pill over the map', () => {
const { planner } = renderScreen({}, { trTab: 'plan', view: 'plan' });
act(() => surface.onRevealMap?.());
expect(screen.queryByRole('group', { name: 'Compare on the map' })).not.toBeInTheDocument();
act(() =>
useAssistantStore.setState((s) => ({
byTrip: {
...s.byTrip,
[planner.tripId]: {
...chatOf(s, planner.tripId),
compare: { blockKey: 'c1', mode: 'after', snapshot: {}, newPlaceCalls: [], addedPlaceIds: [] },
},
},
}))
);
expect(screen.getByRole('group', { name: 'Compare on the map' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'After' })).toHaveAttribute('aria-pressed', 'true');
});
it('FE-MOB-ASSIST-008: the back gesture closes the chat instead of leaving the trip', () => {
renderScreen();
expect(window.history.state).toMatchObject({ trekAssistant: true });
act(() => {
window.history.replaceState({ idx: 0 }, '');
window.dispatchEvent(new PopStateEvent('popstate'));
});
expect(useAssistantStore.getState().window.open).toBe(false);
});
});