mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 23:16:46 +00:00
b6f7488591
- 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
183 lines
6.7 KiB
TypeScript
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();
|
|
});
|
|
});
|