mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
265 lines
11 KiB
TypeScript
265 lines
11 KiB
TypeScript
/**
|
|
* The trip card the assistant plans with: built from the raw answers of the
|
|
* trip tools (tolerating broken ones), rendered with the ids the model needs,
|
|
* and the turn context around it (today in the user's zone, their units).
|
|
*/
|
|
import {
|
|
buildTripCard,
|
|
categoriesFrom,
|
|
languageName,
|
|
renderTripCard,
|
|
todayIn,
|
|
tripMemoryOf,
|
|
turnContext,
|
|
} from '../../../../src/nest/assistant/assistant-context';
|
|
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
const SUMMARY = {
|
|
trip: { id: 1, title: 'Kyoto in June', start_date: '2026-06-11', end_date: '2026-06-12', currency: 'JPY' },
|
|
members: {
|
|
owner: { id: 7, username: 'mia', email: 'mia@example.test' },
|
|
collaborators: [{ id: 8, username: 'jon' }],
|
|
},
|
|
days: [
|
|
{
|
|
id: 31,
|
|
day_number: 1,
|
|
date: '2026-06-11',
|
|
title: 'Arrival',
|
|
assignments: [{ id: 301, place: { id: 12, name: 'Nishiki Market', lat: 35.005, lng: 135.764 } }],
|
|
notes: [{ id: 41, text: 'Pick up rail pass', time: '09:00', icon: '🎫', color: null }],
|
|
},
|
|
{ id: 32, day_number: 2, date: '2026-06-12', title: null, assignments: [], notes: [] },
|
|
],
|
|
packing: { items: [{ id: 51, name: 'Umbrella', category: 'Gear', quantity: 1, weight_grams: 300 }] },
|
|
};
|
|
const PLACES = {
|
|
places: [
|
|
{ id: 12, name: 'Nishiki Market', address: 'Nakagyo Ward' },
|
|
{ id: 14, name: 'Fushimi Inari', category_id: 3, price: 0 },
|
|
],
|
|
};
|
|
|
|
describe('buildTripCard', () => {
|
|
it('ASTCTX-030: knows the source ids of places and the bookings with their day', () => {
|
|
const card = buildTripCard(
|
|
1,
|
|
7,
|
|
{
|
|
...SUMMARY,
|
|
reservations: [{ id: 71, title: 'Gion Corner', day_id: 31 }, { id: 72, day_id: null }, { title: 'no id' }],
|
|
},
|
|
{ places: [{ id: 14, name: 'Fushimi Inari', google_place_id: 'ChIJf', osm_id: 'way:9' }] },
|
|
null,
|
|
);
|
|
expect(card.places.get(14)).toMatchObject({ googlePlaceId: 'ChIJf', osmId: 'way:9' });
|
|
expect(card.places.get(12)).toMatchObject({ googlePlaceId: null, osmId: null });
|
|
expect([...card.reservations.entries()]).toEqual([
|
|
[71, { title: 'Gion Corner', dayId: 31 }],
|
|
[72, { title: '#72', dayId: null }],
|
|
]);
|
|
expect(card.mentions).toEqual([]);
|
|
});
|
|
|
|
it('ASTCTX-031: names what the user mentioned that is in the trip, and nothing that is not or is in focus', () => {
|
|
const summary = {
|
|
...SUMMARY,
|
|
reservations: [
|
|
{ id: 71, title: 'Gion Corner', day_id: 31 },
|
|
{ id: 73, title: 'Bus' },
|
|
],
|
|
};
|
|
const card = buildTripCard(1, 7, summary, PLACES, { dayId: 32, placeIds: [12] }, [
|
|
{ kind: 'place', id: 14 },
|
|
{ kind: 'place', id: 12 },
|
|
{ kind: 'place', id: 999 },
|
|
{ kind: 'day', id: 31 },
|
|
{ kind: 'day', id: 32 },
|
|
{ kind: 'day', id: 99 },
|
|
{ kind: 'reservation', id: 71 },
|
|
{ kind: 'reservation', id: 73 },
|
|
{ kind: 'reservation', id: 98 },
|
|
]);
|
|
const text = renderTripCard(card);
|
|
expect(text).toContain(
|
|
[
|
|
'The user names these in the message (an "@" before a name points at one):',
|
|
'- place 14: Fushimi Inari',
|
|
'- day 31 (2026-06-11)',
|
|
'- booking 71: Gion Corner (day 31)',
|
|
'- booking 73: Bus',
|
|
'',
|
|
'Trip: Kyoto in June',
|
|
].join('\n'),
|
|
);
|
|
expect(text).not.toMatch(/place 999|day 99\b|booking 98/);
|
|
const unknownOnly = buildTripCard(1, 7, SUMMARY, PLACES, null, [{ kind: 'place', id: 999 }]);
|
|
expect(renderTripCard(unknownOnly)).not.toContain('The user names');
|
|
});
|
|
|
|
it('ASTCTX-032: reads the categories a list answers, and leaves the malformed ones out', () => {
|
|
expect(
|
|
categoriesFrom({
|
|
categories: [
|
|
{ id: 1, name: 'Hotel', icon: '🏨' },
|
|
{ id: 2, name: 'Food' },
|
|
{ id: 'x', name: 'Bad' },
|
|
{ id: 3, name: '' },
|
|
],
|
|
}),
|
|
).toEqual([
|
|
{ id: 1, name: 'Hotel', icon: '🏨' },
|
|
{ id: 2, name: 'Food', icon: null },
|
|
]);
|
|
expect(categoriesFrom('Not allowed.')).toEqual([]);
|
|
});
|
|
|
|
it('ASTCTX-001: reads days, places, assignments, notes, packing and members', () => {
|
|
const card = buildTripCard(1, 7, SUMMARY, PLACES, null);
|
|
expect(card).toMatchObject({ tripId: 1, title: 'Kyoto in June', currency: 'JPY', startDate: '2026-06-11' });
|
|
expect(card.days).toEqual([
|
|
{ id: 31, number: 1, date: '2026-06-11', title: 'Arrival', placeIds: [12] },
|
|
{ id: 32, number: 2, date: '2026-06-12', title: null, placeIds: [] },
|
|
]);
|
|
expect(card.places.get(12)).toMatchObject({ name: 'Nishiki Market', address: 'Nakagyo Ward' });
|
|
expect(card.places.get(14)).toMatchObject({ name: 'Fushimi Inari', categoryId: 3, price: 0 });
|
|
expect(card.assignments.get(301)).toEqual({ dayId: 31, placeId: 12 });
|
|
expect(card.notes.get(41)).toEqual({
|
|
dayId: 31,
|
|
text: 'Pick up rail pass',
|
|
time: '09:00',
|
|
icon: '🎫',
|
|
color: null,
|
|
});
|
|
expect(card.packing.get(51)).toMatchObject({ name: 'Umbrella', weight: 300 });
|
|
expect([...card.members.entries()]).toEqual([
|
|
[7, { name: 'mia', avatarKey: null }],
|
|
[8, { name: 'jon', avatarKey: null }],
|
|
]);
|
|
});
|
|
|
|
it('ASTCTX-003: knows who asks, the members by their stored pictures, and the answers it was built from', () => {
|
|
const summary = {
|
|
...SUMMARY,
|
|
members: {
|
|
owner: { id: 7, username: 'mia', avatar_url: '/uploads/avatars/mia-1.png' },
|
|
collaborators: [
|
|
{ id: 8, username: 'jon', avatar_url: 'https://lh3.example.test/jon.png' },
|
|
{ id: 9, username: 'ana', avatar_url: '/uploads/avatars/../../secret' },
|
|
],
|
|
},
|
|
};
|
|
const card = buildTripCard(1, 8, summary, PLACES, null);
|
|
expect(card.viewerId).toBe(8);
|
|
expect(card.members.get(7)).toEqual({ name: 'mia', avatarKey: 'mia-1.png' });
|
|
expect(card.members.get(8)).toEqual({ name: 'jon', avatarKey: null });
|
|
expect(card.members.get(9)).toEqual({ name: 'ana', avatarKey: null });
|
|
expect(card.sources).toEqual({ summary, places: PLACES });
|
|
});
|
|
|
|
it('ASTCTX-002: a tool that answered with an error leaves an emptier card, not a broken one', () => {
|
|
const card = buildTripCard(5, 7, 'Trip not found or access denied.', null, null);
|
|
expect(card).toMatchObject({ tripId: 5, title: '', days: [] });
|
|
expect(card.places.size).toBe(0);
|
|
expect(
|
|
buildTripCard(5, 7, { days: [{ id: 'x' }, null, { id: 3, assignments: [{ id: 1 }] }] }, { places: 'no' }, null)
|
|
.days,
|
|
).toEqual([{ id: 3, number: 1, date: null, title: null, placeIds: [] }]);
|
|
});
|
|
});
|
|
|
|
describe('renderTripCard', () => {
|
|
it('ASTCTX-010: lists the days with their places and ids, the unscheduled places and the focus', () => {
|
|
const text = renderTripCard(buildTripCard(1, 7, SUMMARY, PLACES, { dayId: 32, placeIds: [14, 999] }));
|
|
expect(text).toContain('Trip: Kyoto in June');
|
|
expect(text).toContain('Dates: 2026-06-11 to 2026-06-12');
|
|
expect(text).toContain('31 | 2026-06-11 | Arrival | Nishiki Market (12)');
|
|
expect(text).toContain('32 | 2026-06-12 | | nothing planned');
|
|
expect(text).toContain('Places not on a day yet: Fushimi Inari (14)');
|
|
expect(text).toContain('- day 32 (2026-06-12)');
|
|
expect(text).toContain('- place 14: Fushimi Inari');
|
|
expect(text).not.toContain('999');
|
|
expect(text).not.toContain('mia@example.test');
|
|
});
|
|
|
|
it('ASTCTX-011: a trip without days says so, and long lists are counted, not listed', () => {
|
|
const many = { places: Array.from({ length: 205 }, (_, i) => ({ id: i + 1, name: `P${i + 1}` })) };
|
|
const text = renderTripCard(buildTripCard(1, 7, { trip: { title: 'T' } }, many, null));
|
|
expect(text).toContain('(no days yet)');
|
|
expect(text).toContain('and 5 more');
|
|
});
|
|
|
|
it('ASTCTX-012: names where the trip is and the google ids it knows, so a lookup needs no search first', () => {
|
|
const places = {
|
|
places: [
|
|
{ id: 12, name: 'Nishiki Market', lat: 35.005, lng: 135.764, google_place_id: 'ChIJn' },
|
|
{ id: 14, name: 'Fushimi Inari', lat: 34.967, lng: 135.773 },
|
|
{ id: 15, name: 'Kansai Airport', lat: 34.434, lng: 135.244 },
|
|
],
|
|
};
|
|
const text = renderTripCard(buildTripCard(1, 7, SUMMARY, places, { placeIds: [12] }));
|
|
expect(text).toContain('Around: 34.9670, 135.7640');
|
|
expect(text).toContain('Nishiki Market (12, google ChIJn)');
|
|
expect(text).toContain('- place 12: Nishiki Market (at 35.0050, 135.7640; google place id ChIJn)');
|
|
expect(renderTripCard(buildTripCard(1, 7, SUMMARY, PLACES, null))).not.toContain('Around:');
|
|
});
|
|
|
|
it('ASTCTX-013: the part of the map on screen tells "here", a view of a whole country does not', () => {
|
|
const view = { south: 35.6, west: 139.7, north: 35.72, east: 139.82 };
|
|
const text = renderTripCard(buildTripCard(1, 7, SUMMARY, PLACES, { view }));
|
|
expect(text).toContain(
|
|
"The map on the user's screen shows south 35.6000, west 139.7000, north 35.7200, east 139.8200.",
|
|
);
|
|
expect(text).toContain('"here", "nearby" or "around" means this area');
|
|
const country = { south: 30, west: 129, north: 45, east: 146 };
|
|
expect(renderTripCard(buildTripCard(1, 7, SUMMARY, PLACES, { view: country }))).not.toContain('on the user');
|
|
});
|
|
});
|
|
|
|
describe('the turn context', () => {
|
|
it('ASTCTX-020: today is read in the user zone, and an unknown zone falls back to UTC', () => {
|
|
const now = new Date('2026-06-11T23:30:00Z');
|
|
expect(todayIn('Asia/Tokyo', now)).toBe('2026-06-12');
|
|
expect(todayIn('America/Los_Angeles', now)).toBe('2026-06-11');
|
|
expect(todayIn('Mars/Olympus', now)).toBe('2026-06-11');
|
|
});
|
|
|
|
it('ASTCTX-021: names the language to answer in', () => {
|
|
expect(languageName('de')).toBe('German');
|
|
expect(languageName('zh-TW')).toContain('Chinese');
|
|
expect(languageName('!!')).toBe('!!');
|
|
});
|
|
|
|
it('ASTCTX-022: carries the date, the units the user prefers and the card', () => {
|
|
const card = buildTripCard(1, 7, SUMMARY, PLACES, null);
|
|
const context = turnContext(card, 'Asia/Tokyo', new Date('2026-06-11T23:30:00Z'), {
|
|
temperature_unit: 'fahrenheit',
|
|
distance_unit: 'metric',
|
|
time_format: 'nonsense',
|
|
});
|
|
expect(context).toContain('Today is 2026-06-12 (time zone Asia/Tokyo).');
|
|
expect(context).toContain('The user prefers temperatures in °F, distances in km.');
|
|
expect(context).toContain('<trip_card>\nTrip: Kyoto in June');
|
|
expect(turnContext(card, 'UTC', new Date(), {})).not.toContain('prefers');
|
|
});
|
|
|
|
it("ASTCTX-023: the trip notes of this trip come along as preferences, never another trip's", () => {
|
|
const card = buildTripCard(1, 7, SUMMARY, PLACES, null);
|
|
const prefs = { assistant_memory: { '1': ' Vegetarian, no museums. ', '2': 'Other trip' } };
|
|
const context = turnContext(card, 'UTC', new Date(), prefs);
|
|
expect(context).toContain('<trip_notes>\nVegetarian, no museums.\n</trip_notes>');
|
|
expect(context).not.toContain('Other trip');
|
|
expect(tripMemoryOf({ assistant_memory: { '1': 'x'.repeat(3000) } }, 1)).toHaveLength(2000);
|
|
expect(tripMemoryOf({ assistant_memory: 'broken' }, 1)).toBeNull();
|
|
});
|
|
|
|
it('ASTCTX-024: cost estimates are asked for unless the user turned them off', () => {
|
|
const card = buildTripCard(1, 7, SUMMARY, PLACES, null);
|
|
expect(turnContext(card, 'UTC', new Date(), {})).toContain('create_budget_item');
|
|
expect(turnContext(card, 'UTC', new Date(), { assistant_cost_estimates: false })).not.toContain(
|
|
'create_budget_item',
|
|
);
|
|
});
|
|
});
|