Files
TREK/client/src/components/Assistant/AssistantSurface.test.tsx
T
Maurice b6f7488591 fix(assistant): green CI round, trip notes the assistant can keep, calmer change cards
- the assistant keeps facts for a trip (remember_for_trip) as a change card,
  applied into the user's trip notes; existing expenses can be changed
- change card fields as a few icon chips with the rest folded under Details,
  the day band marks only what changes, the week grid as compact tiles
- no map button on receipts of things that have no place on the map
- tests follow the composer's accessible name, the receipt's status icon and
  the GL map's container; the hanging stream helper honours an early abort
- Postgres probe: qualified columns in the upsert updates; entities restored
  to the generator's output; strict ratchet reads the SDK client as untyped
2026-10-21 22:29:57 +02:00

183 lines
6.7 KiB
TypeScript

/**
* FE-AI-SURF-001 to FE-AI-SURF-006
*
* The chat on the real @assistant-ui runtime, fed from the assistant store:
* the empty chat with its suggestions, stored messages as the user sees
* them, sending from the composer, and the composer holding back while a
* change waits for a decision.
*/
import type { AiStatusResponse, AssistantMessage } from '@trek/shared';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import AssistantSurface from './AssistantSurface';
const TRIP = 4;
const INITIAL = useAssistantStore.getState();
const AT = '2026-10-21T08:00:00.000Z';
const STATUS: AiStatusResponse = {
available: true,
policy: 'instance',
canConnectOwn: false,
effective: { source: 'instance', kind: 'openai', model: 'gpt', host: 'api.openai.com', paidBy: 'instance' },
own: null,
instance: { kind: 'openai', model: 'gpt', host: 'api.openai.com' },
quota: null,
privacyAcked: true,
retention: { mode: 'days', days: 90 },
openrouterOAuth: false,
};
const send = vi.fn(async () => {});
function seed(messages: AssistantMessage[], status: AiStatusResponse = STATUS) {
useAssistantStore.setState({
status,
send,
byTrip: { [TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), messages } },
});
}
function renderChat() {
return render(<AssistantSurface tripId={TRIP} tripTitle="Kyoto" variant="window" />);
}
beforeAll(() => {
// jsdom lays nothing out and has no element scrolling; the viewport only needs the call to exist.
Element.prototype.scrollTo = vi.fn() as unknown as typeof Element.prototype.scrollTo;
});
beforeEach(() => send.mockClear());
afterEach(() => useAssistantStore.setState(INITIAL, true));
describe('AssistantSurface', () => {
it('FE-AI-SURF-001: the empty chat asks what to plan and offers questions that change nothing for an unknown trip', async () => {
seed([]);
renderChat();
expect(await screen.findByText('What are we planning?')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Make a packing list for this trip' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Which days are still empty?' }));
await waitFor(() => expect(send).toHaveBeenCalledWith(TRIP, 'Which days are still empty?'));
});
it('FE-AI-SURF-002: shows the question and the answer, with the steps folded away', async () => {
seed([
{ id: 'q1', role: 'user', status: 'complete', createdAt: AT, parts: [{ type: 'text', text: 'Plan Friday' }] },
{
id: 'a1',
role: 'assistant',
status: 'complete',
createdAt: AT,
parts: [
{ type: 'tool-call', toolCallId: 'r1', toolName: 'get_trip_summary', args: {}, result: { ok: true } },
{ type: 'text', text: 'Here is a **plan**.' },
],
},
]);
renderChat();
expect(await screen.findByText('Plan Friday')).toBeInTheDocument();
expect(screen.getByText('plan')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '1 step' })).toHaveAttribute('aria-expanded', 'false');
expect(screen.queryByText('What are we planning?')).toBeNull();
});
it('FE-AI-SURF-003: Enter sends what was typed, with the trip in the placeholder', async () => {
seed([]);
renderChat();
const input = await screen.findByRole('textbox', { name: 'Ask anything about Kyoto' });
fireEvent.change(input, { target: { value: 'Where should we eat?' } });
expect(chatOf(useAssistantStore.getState(), TRIP).draft).toBe('Where should we eat?');
fireEvent.keyDown(input, { key: 'Enter' });
await waitFor(() => expect(send).toHaveBeenCalledWith(TRIP, 'Where should we eat?'));
});
it('FE-AI-SURF-004: a change waiting for a decision holds the send button back', async () => {
seed([
{
id: 'a1',
role: 'assistant',
status: 'awaiting_approval',
createdAt: AT,
parts: [{ type: 'tool-call', toolCallId: 'w1', toolName: 'create_todo', args: {}, approval: { id: 'w1' } }],
},
]);
renderChat();
const input = await screen.findByRole('textbox', { name: 'Ask anything about Kyoto' });
fireEvent.change(input, { target: { value: 'Something else' } });
expect(screen.getByRole('button', { name: 'Send' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument();
});
it('FE-AI-SURF-005: on the admin connection the chat says once where messages go', async () => {
seed([], { ...STATUS, privacyAcked: false });
renderChat();
expect(
await screen.findByText('Your messages and the trip data the assistant reads go to OpenAI.')
).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'What gets shared?' }));
expect(screen.getByText('Not your password, your files or your other trips.')).toBeInTheDocument();
});
it('FE-AI-SURF-006: an answer shows what its search found and the weather it looked up', async () => {
seed([
{
id: 'a1',
role: 'assistant',
status: 'complete',
createdAt: AT,
parts: [
{ type: 'tool-call', toolCallId: 's1', toolName: 'search_nearby_places', args: {}, result: { ok: true } },
{
type: 'data-trek-places',
data: {
toolCallId: 's1',
more: 0,
places: [
{
ref: 's1:0',
name: 'Nishiki Market',
address: null,
lat: 35.005,
lng: 135.765,
kind: 'food',
rating: null,
openNow: null,
googlePlaceId: null,
osmId: null,
photoKey: null,
inTripPlaceId: null,
},
],
},
},
{
type: 'data-trek-weather',
data: {
days: [
{
dayId: null,
date: '2026-11-02',
main: 'Rain',
tempMax: 14,
tempMin: 9,
rainPct: 70,
kind: 'forecast',
},
],
},
},
{ type: 'text', text: 'Rain on Monday, so the market is a good pick.' },
],
},
]);
renderChat();
expect(await screen.findByRole('region', { name: 'Places found' })).toBeInTheDocument();
expect(screen.getByText('Nishiki Market')).toBeInTheDocument();
expect(screen.getByRole('list', { name: 'Weather for 1 day' })).toBeInTheDocument();
expect(screen.getByText('14°')).toBeInTheDocument();
});
});