Files
TREK/client/tests/unit/mobile/trip/MAssistantScreen.test.tsx
T

254 lines
11 KiB
TypeScript

import type { AiStatusResponse } from '@trek/shared';
import { useLocation } from 'react-router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { EntityTarget } from '../../../../src/components/Assistant/cards/cardTypes';
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-014
//
// 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(() => ({
onOpen: null as ((target: EntityTarget) => 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: {
onOpen?: (target: EntityTarget) => void;
onFocusDay?: (dayId: number) => void;
onRevealMap?: () => void;
}) => {
surface.onOpen = props.onOpen ?? 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 Location() {
const { pathname, search } = useLocation();
return <output data-testid="location">{`${pathname}${search}`}</output>;
}
function renderScreen(plannerOverrides = {}, shellOverrides = {}, { withLocation = false } = {}) {
const planner = buildPlanner(plannerOverrides);
const shell = buildShell(shellOverrides);
const view = render(
<>
<MAssistantScreen planner={planner} shell={shell} />
{withLocation && <Location />}
</>,
{ initialEntries: ['/trips/1'] }
);
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.onOpen?.({ kind: 'map', placeId: 42, dayId: 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-012: a day on the map is selected there after the chat stepped aside', () => {
const { planner, shell } = renderScreen({}, { trTab: 'plan', view: 'plan' });
act(() => surface.onOpen?.({ kind: 'map', placeId: null, dayId: 5 }));
expect(useAssistantStore.getState().window.minimized).toBe(true);
expect(shell.toggleView).toHaveBeenCalled();
expect(planner.handleSelectDay).toHaveBeenCalledWith(5, false);
expect(planner.setSelectedPlaceId).not.toHaveBeenCalled();
});
it('FE-MOB-ASSIST-013: a tab opens on its own list or chat part, with what it reads in the URL', () => {
const { shell } = renderScreen({}, { trTab: 'plan', view: 'map' }, { withLocation: true });
act(() => surface.onOpen?.({ kind: 'tab', tab: 'listen', sub: 'todo', params: { todo: '4' } }));
expect(useAssistantStore.getState().window.minimized).toBe(true);
expect(shell.setTrTab).toHaveBeenCalledWith('listen');
expect(shell.setListsTab).toHaveBeenCalledWith('todo');
expect(shell.toggleView).not.toHaveBeenCalled();
expect(screen.getByTestId('location')).toHaveTextContent('/trips/1?todo=4');
act(() => surface.onOpen?.({ kind: 'tab', tab: 'collab', sub: 'polls' }));
expect(shell.setTrTab).toHaveBeenLastCalledWith('collab');
expect(shell.setCollabTab).toHaveBeenCalledWith('polls');
// A sub-tab a tab does not have is left alone.
act(() => surface.onOpen?.({ kind: 'tab', tab: 'collab', sub: 'packing' }));
expect(shell.setCollabTab).toHaveBeenCalledTimes(1);
});
it('FE-MOB-ASSIST-014: another page leaves the trip, and the chat closes with it', () => {
renderScreen({}, {}, { withLocation: true });
act(() => surface.onOpen?.({ kind: 'page', path: '/atlas' }));
expect(useAssistantStore.getState().window.open).toBe(false);
expect(screen.getByTestId('location')).toHaveTextContent('/atlas');
});
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);
});
});