mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
261 lines
10 KiB
TypeScript
261 lines
10 KiB
TypeScript
/**
|
|
* FE-AI-SURF-001 to FE-AI-SURF-009
|
|
*
|
|
* 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, the composer holding back while a change
|
|
* waits for a decision, and a journey's chat under its own key, without the
|
|
* trip's questions and booking upload, read only for a viewer of the
|
|
* journey and in the book's words.
|
|
*/
|
|
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 { assistantChatKey } from '../../store/assistantChatKey';
|
|
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
|
import { type JourneyDetail, useJourneyStore } from '../../store/journeyStore';
|
|
import AssistantSurface from './AssistantSurface';
|
|
|
|
const TRIP = 4;
|
|
const INITIAL = useAssistantStore.getState();
|
|
const JOURNEYS = useJourneyStore.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);
|
|
useJourneyStore.setState(JOURNEYS, 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 Nishiki 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();
|
|
});
|
|
|
|
it('FE-AI-SURF-007: a journey chat reads and sends under its own key, not the trip with the same id', async () => {
|
|
const key = assistantChatKey({ kind: 'journey', journeyId: TRIP });
|
|
useAssistantStore.setState({
|
|
status: STATUS,
|
|
send,
|
|
byTrip: {
|
|
[TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), draft: 'trip draft' },
|
|
[key]: {
|
|
...chatOf(useAssistantStore.getState(), key),
|
|
messages: [
|
|
{
|
|
id: 'q1',
|
|
role: 'user',
|
|
status: 'complete',
|
|
createdAt: AT,
|
|
parts: [{ type: 'text', text: 'Tidy page 3' }],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
});
|
|
render(<AssistantSurface tripId={key} tripTitle="Lisbon" variant="window" density="compact" />);
|
|
|
|
expect(await screen.findByText('Tidy page 3')).toBeInTheDocument();
|
|
const input = screen.getByRole('textbox', { name: 'Ask anything about Lisbon' });
|
|
fireEvent.change(input, { target: { value: 'Calmer cover' } });
|
|
fireEvent.keyDown(input, { key: 'Enter' });
|
|
await waitFor(() => expect(send).toHaveBeenCalledWith(key, 'Calmer cover'));
|
|
expect(chatOf(useAssistantStore.getState(), TRIP).draft).toBe('trip draft');
|
|
});
|
|
|
|
it('FE-AI-SURF-008: an empty journey chat offers no trip questions, no booking upload and no drop zone', async () => {
|
|
const key = assistantChatKey({ kind: 'journey', journeyId: TRIP });
|
|
useAssistantStore.setState({
|
|
status: STATUS,
|
|
send,
|
|
byTrip: { [key]: { ...chatOf(useAssistantStore.getState(), key), messages: [] } },
|
|
});
|
|
render(<AssistantSurface tripId={key} tripTitle="Lisbon" variant="window" />);
|
|
|
|
const input = await screen.findByRole('textbox', { name: 'Ask anything about Lisbon' });
|
|
// It asks about the book, not the plan.
|
|
expect(screen.getByText('What should the book look like?')).toBeInTheDocument();
|
|
expect(screen.queryByText('What are we planning?')).toBeNull();
|
|
// An owner of the journey, as the Studio reads one before the journey says otherwise.
|
|
expect(screen.queryByText(/You can only view/)).toBeNull();
|
|
expect(screen.queryByRole('button', { name: 'Which days are still empty?' })).toBeNull();
|
|
expect(screen.queryByRole('button', { name: 'Attach a booking' })).toBeNull();
|
|
|
|
// A file dragged over the chat is left to the browser: nothing lights up to take it.
|
|
fireEvent.dragEnter(input, { dataTransfer: { types: ['Files'], files: [] } });
|
|
expect(screen.queryByText('Drop it here and I will read the booking')).toBeNull();
|
|
});
|
|
|
|
it('FE-AI-SURF-009: a viewer of the journey hears in the book words that the chat changes nothing', async () => {
|
|
const key = assistantChatKey({ kind: 'journey', journeyId: TRIP });
|
|
useAssistantStore.setState({
|
|
status: STATUS,
|
|
send,
|
|
byTrip: { [key]: { ...chatOf(useAssistantStore.getState(), key), messages: [] } },
|
|
});
|
|
useJourneyStore.setState({ current: { id: TRIP, my_role: 'viewer' } as JourneyDetail });
|
|
render(<AssistantSurface tripId={key} tripTitle="Lisbon" variant="window" />);
|
|
|
|
expect(
|
|
await screen.findByText('You can only view this book, so I can answer questions but not change anything.')
|
|
).toBeInTheDocument();
|
|
expect(screen.queryByText(/this trip/)).toBeNull();
|
|
});
|
|
});
|