mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 08:11:03 +00:00
0b7cee0b0d
On an iPad or an unfolded foldable the list screens kept one phone-wide column in the middle of the page. They now lay out for the room they have: - Transports, bookings, polls and tasks put their cards in a grid of as many phone-wide columns as the page holds (CardGrid), three on a 13" iPad on its side, two upright, one in a narrow Split View. - Costs: with room for two panes (split size class) the summary (total, owe and owed, settle up, final budget, categories) stands in a pane of its own beside the expenses, each scrolling on its own (TabPanes). The search field sits in the expenses' column again instead of running past it. - Files: a grid of tiles with each photo's picture; with room for two panes the picked file is previewed beside the grid with what it belongs to and its actions, the way Files on an iPad does. The signed preview address comes through fileRepo.previewUrl. - Packing: the categories stand in columns, each card whole in its column; the action menu drops from its button at a popover's width. - To-dos: the four smart filters as tiles and the trip's lists in a pane beside the tasks, which take the filter's name as their head. - Collab: the chat stays in view at the trailing edge beside the notes, links or polls, and the header switches only the board. The chat keeps its place in the tree, so a fold or a rotation into two panes keeps the draft; a chat at its newest message stays there when its box changes size. Notes stand as a board in columns. - Administration and settings share a section list beside the page (MSectionNav); the stats grid takes four columns. - Collections: the lists stand in a column beside the places, which take two columns, and the head names the list on show. - Journal: the entries become a feed beside the map, under their days, with photo, place and the start of the story, in step with the map like the strip of cards it replaces; it fades out under the header. - The assistant opens as a tall panel at the trailing edge over a dimmed page, as Slide Over would, instead of covering the whole screen. - The rail fits every name: a word too wide for it shrinks the label before it is hyphenated, and the admin entry uses its short name. - The journeys and notifications headers keep to their column. - The address screen of the native shell sets the photo as a panel beside the choices on a large screen on its side. On a foldable that reports its fold, a leading pane ends at the fold and a trailing one starts there. Phones keep their layout.
339 lines
15 KiB
TypeScript
339 lines
15 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';
|
|
import { setSizeClass } from '../../../helpers/sizeClass';
|
|
|
|
// FE-MOB-ASSIST-001 to FE-MOB-ASSIST-018
|
|
//
|
|
// 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, and where the focus goes as the chat and its lists open.
|
|
|
|
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/map/RoutePeekLazy', () => ({
|
|
default: (props: { tripId: number }) => <div data-testid="route-peek-mount" data-trip={props.tripId} />,
|
|
}));
|
|
|
|
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-016: the chat takes the focus when it opens, keeps Tab inside and hands the focus back', () => {
|
|
const outside = document.createElement('button');
|
|
document.body.appendChild(outside);
|
|
outside.focus();
|
|
try {
|
|
renderScreen();
|
|
const dialog = screen.getByRole('dialog', { name: 'Assistant' });
|
|
expect(dialog).toHaveFocus();
|
|
fireEvent.keyDown(dialog, { key: 'Tab', shiftKey: true });
|
|
const more = screen.getByRole('button', { name: 'More options' });
|
|
expect(more).toHaveFocus();
|
|
fireEvent.keyDown(more, { key: 'Tab' });
|
|
expect(screen.getByRole('button', { name: 'Back' })).toHaveFocus();
|
|
|
|
// Set aside for the map, the chat gives the focus back to where it was.
|
|
act(() => useAssistantStore.getState().setMinimized(true));
|
|
expect(outside).toHaveFocus();
|
|
} finally {
|
|
outside.remove();
|
|
}
|
|
});
|
|
|
|
it('FE-MOB-ASSIST-017: the recent chats and the menu say whether they are open, and Escape closes them onto their button', () => {
|
|
renderScreen();
|
|
const history = screen.getByRole('button', { name: 'Recent chats' });
|
|
expect(history).toHaveAttribute('aria-expanded', 'false');
|
|
history.focus();
|
|
fireEvent.click(history);
|
|
expect(history).toHaveAttribute('aria-expanded', 'true');
|
|
// The list takes the focus onto its first entry and says which chat is open.
|
|
expect(screen.getAllByRole('button', { name: 'New chat' })[1]).toHaveFocus();
|
|
expect(screen.getByRole('button', { name: /Temples on day 2/ })).toHaveAttribute('aria-current', 'true');
|
|
fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' });
|
|
expect(screen.queryByText('Chats are deleted after 90 days.')).not.toBeInTheDocument();
|
|
expect(history).toHaveFocus();
|
|
expect(history).toHaveAttribute('aria-expanded', 'false');
|
|
|
|
const more = screen.getByRole('button', { name: 'More options' });
|
|
expect(more).toHaveAttribute('aria-haspopup', 'menu');
|
|
more.focus();
|
|
fireEvent.click(more);
|
|
expect(more).toHaveAttribute('aria-expanded', 'true');
|
|
fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' });
|
|
expect(screen.queryByRole('button', { name: 'Delete chat' })).not.toBeInTheDocument();
|
|
expect(more).toHaveFocus();
|
|
});
|
|
|
|
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-015: the route's peek is mounted only while a route is shown, and the pill back closes it", () => {
|
|
renderScreen({}, { trTab: 'plan', view: 'map' });
|
|
expect(screen.queryByTestId('route-peek-mount')).not.toBeInTheDocument();
|
|
// Over the map with no route shown (a place, a booking), the peek's code is not loaded.
|
|
act(() => surface.onRevealMap?.());
|
|
expect(screen.queryByTestId('route-peek-mount')).not.toBeInTheDocument();
|
|
act(() => useAssistantStore.getState().setRoutePeek({ tripId: 2, toolCallId: 'c1' }));
|
|
expect(screen.queryByTestId('route-peek-mount')).not.toBeInTheDocument();
|
|
act(() => {
|
|
useAssistantStore.getState().setRoutePeek({ tripId: 1, toolCallId: 'c1' });
|
|
useAssistantStore.getState().setMapHover(1, 'c1', 'map');
|
|
});
|
|
expect(screen.getByTestId('route-peek-mount').dataset.trip).toBe('1');
|
|
|
|
// Back in the chat the line the tap lit goes out with the peek.
|
|
fireEvent.click(screen.getByRole('button', { name: 'Back to chat' }));
|
|
expect(useAssistantStore.getState().routePeek).toBeNull();
|
|
expect(useAssistantStore.getState().mapLink).toEqual({ tripId: 1, hoveredId: null, source: 'map' });
|
|
expect(screen.queryByTestId('route-peek-mount')).not.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);
|
|
});
|
|
|
|
it('FE-MOB-ASSIST-018: on a wide window the chat is a panel at the trailing edge over a dimmed page, which closes it', () => {
|
|
setSizeClass('wide');
|
|
renderScreen();
|
|
const panel = screen.getByRole('dialog', { name: 'Assistant' });
|
|
expect(panel.className).toContain('wide:end-3');
|
|
expect(panel.className).toContain('m-panel-in');
|
|
const scrim = panel.previousElementSibling as HTMLElement;
|
|
expect(scrim).toHaveAttribute('aria-hidden', 'true');
|
|
fireEvent.click(scrim);
|
|
expect(useAssistantStore.getState().window.open).toBe(false);
|
|
});
|
|
});
|