mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
351 lines
18 KiB
TypeScript
351 lines
18 KiB
TypeScript
/**
|
|
* ASTCOL-020 to ASTCOL-035: the change cards of the collections' writes, as
|
|
* describeToolCall writes them (the card through the guarded registry, so it
|
|
* passed the union), and the lookups they read first.
|
|
*/
|
|
import { loadFor, memoisedReader } from '../../../../../../src/nest/assistant/cards/card-context';
|
|
import { COLLECTION_CARDS } from '../../../../../../src/nest/assistant/cards/collection';
|
|
import type { CollectionLookup } from '../../../../../../src/nest/assistant/cards/collection/collection-load';
|
|
import { ASSISTANT_WRITE_TOOLS, TOOL_CARDS } from '../../../../../../src/nest/assistant/cards/registry';
|
|
import { describeToolCall } from '../../../../../../src/nest/assistant/tool-call-preview.helpers';
|
|
import { collectionValue, ctx, itemsValue, LABELS, savedPlace } from './collection-fixtures';
|
|
import {
|
|
assistantChangePreviewSchema,
|
|
type CollectionListsCard,
|
|
type CollectionPlacesCard,
|
|
type TrekCard,
|
|
} from '@trek/shared';
|
|
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
vi.mock('../../../../../../src/nest/audit/audit-log.logger', () => ({
|
|
logInfo: vi.fn(),
|
|
logError: vi.fn(),
|
|
logWarn: vi.fn(),
|
|
logDebug: vi.fn(),
|
|
}));
|
|
|
|
const NONE: CollectionLookup = { collection: null, target: null, items: null, membership: null, lists: null };
|
|
|
|
function preview(tool: string, args: Record<string, unknown>, loaded: Partial<CollectionLookup> | null = null) {
|
|
return describeToolCall(tool, 'call_9', args, ctx, loaded ? { ...NONE, ...loaded } : null);
|
|
}
|
|
|
|
const placesOf = (card: TrekCard | null | undefined) => card as CollectionPlacesCard;
|
|
const listsOf = (card: TrekCard | null | undefined) => card as CollectionListsCard;
|
|
|
|
const COLLECTION_WRITES = Object.keys(COLLECTION_CARDS.tools).filter((tool) => COLLECTION_CARDS.tools[tool]?.write);
|
|
|
|
describe('collection change cards', () => {
|
|
it('ASTCOL-020: every write of the collections describes empty arguments and a failed lookup in its own words', () => {
|
|
const some = ['save_place_to_collection', 'copy_collection_places_to_trip', 'cancel_collection_invite'];
|
|
expect(COLLECTION_WRITES).toEqual(expect.arrayContaining(some));
|
|
expect(COLLECTION_WRITES.length).toBeGreaterThanOrEqual(19);
|
|
for (const tool of COLLECTION_WRITES) {
|
|
for (const loaded of [null, NONE]) {
|
|
const shown = preview(tool, {}, loaded);
|
|
expect(assistantChangePreviewSchema.safeParse(shown).success, tool).toBe(true);
|
|
expect(shown).toMatchObject({ tool, domain: 'collections', placeIds: [], rows: [], dayId: null });
|
|
expect(shown.verbKey).toBe(ASSISTANT_WRITE_TOOLS[tool]?.verbKey);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('ASTCOL-021: a place to save shows its list, the status it gets, its category and the lists that hold it already', () => {
|
|
const args = {
|
|
collection_id: 12,
|
|
name: 'Musée Rodin',
|
|
google_place_id: 'ChIJlouvre',
|
|
lat: 48.86,
|
|
lng: 2.33,
|
|
category_id: 3,
|
|
status: 'want',
|
|
};
|
|
const membership = { saved: true, lists: [{ collection_id: 13, name: 'Later', place_id: 9, status: 'idea' }] };
|
|
const shown = preview('save_place_to_collection', args, { collection: collectionValue(), membership });
|
|
expect(shown.subject).toBe('Musée Rodin');
|
|
const card = placesOf(shown.card);
|
|
expect(card.list).toMatchObject({ id: 12, name: 'Paris food', counts: { idea: 4, want: 1, visited: 5 } });
|
|
expect(card.places[0]).toEqual({
|
|
id: null,
|
|
listId: 12,
|
|
name: 'Musée Rodin',
|
|
address: null,
|
|
lat: 48.86,
|
|
lng: 2.33,
|
|
category: { name: 'Sights', icon: null, color: null },
|
|
status: 'want',
|
|
labelIds: [],
|
|
rating: null,
|
|
ratingCount: 0,
|
|
googlePlaceId: 'ChIJlouvre',
|
|
osmId: null,
|
|
imageKey: null,
|
|
inTripPlaceId: 5,
|
|
savedIn: [{ listId: 13, name: 'Later', status: 'idea' }],
|
|
delta: null,
|
|
});
|
|
// Without the lookup the arguments still describe the place; it is an idea by default.
|
|
const bare = placesOf(preview('save_place_to_collection', { collection_id: 12, name: 'Musée Rodin' }).card);
|
|
expect(bare).toMatchObject({ list: null, places: [{ listId: 12, status: 'idea', savedIn: [] }] });
|
|
});
|
|
|
|
it('ASTCOL-022: a status change reads from the old status to the new one; a vote names the stars or that it goes', () => {
|
|
const items = itemsValue([savedPlace()]);
|
|
const status = placesOf(preview('set_collection_place_status', { placeId: 34, status: 'visited' }, { items }).card);
|
|
expect(status.places[0]?.delta?.status).toEqual({ from: 'want', to: 'visited' });
|
|
expect(status.list).toMatchObject({ id: 12, counts: null });
|
|
const rated = placesOf(preview('rate_collection_place', { placeId: 34, rating: 4 }, { items }).card);
|
|
expect(rated.places[0]?.delta?.rating).toEqual({ to: 4 });
|
|
const cleared = placesOf(preview('rate_collection_place', { placeId: 34, rating: null }, { items }).card);
|
|
expect(cleared.places[0]?.delta?.rating).toEqual({ to: null });
|
|
const removed = preview('delete_collection_place', { placeId: 34 }, { items });
|
|
expect(removed).toMatchObject({ subject: 'Septime', verbKey: 'genericRemove' });
|
|
expect(placesOf(removed.card).places[0]?.delta?.removed).toBe(true);
|
|
});
|
|
|
|
it('ASTCOL-023: a change of a saved place names its fields, its labels as its own list allows them and the list it moves to', () => {
|
|
const items = itemsValue([savedPlace()]);
|
|
// In its own list: the labels asked for replace the ones it carries.
|
|
const relabel = { placeId: 34, label_ids: [51, 53, 99] };
|
|
const kept = placesOf(preview('update_collection_place', relabel, { items }).card);
|
|
expect(kept.places[0]?.delta).toMatchObject({ labelsAdded: [53], labelsRemoved: [52], movedTo: null });
|
|
// A move takes every label of the old list off and applies the new list's.
|
|
const args = {
|
|
placeId: 34,
|
|
name: 'Septime Cave',
|
|
notes: 'Wine bar next door',
|
|
lat: 48.85,
|
|
lng: 2.38,
|
|
status: 'visited',
|
|
label_ids: [51, 61, 99],
|
|
collection_id: 13,
|
|
};
|
|
const brunch = { id: 61, collection_id: 13, name: 'Brunch', color: '#f59e0b' };
|
|
const target = { collection: { id: 13, name: 'Later', members: [], labels: [brunch] }, places: [] };
|
|
const shown = preview('update_collection_place', args, { items, target });
|
|
expect(shown.subject).toBe('Septime Cave');
|
|
const card = placesOf(shown.card);
|
|
expect(card.places[0]?.delta).toEqual({
|
|
status: { from: 'want', to: 'visited' },
|
|
rating: null,
|
|
labelsAdded: [61],
|
|
labelsRemoved: [51, 52],
|
|
fields: ['name', 'coordinates', 'notes'],
|
|
movedTo: {
|
|
id: 13,
|
|
name: 'Later',
|
|
color: null,
|
|
placeCount: null,
|
|
memberCount: 0,
|
|
counts: { idea: 0, want: 0, visited: 0 },
|
|
},
|
|
removed: false,
|
|
});
|
|
expect(card.labels.map((label) => label.name)).toEqual(['Dinner', 'Late', 'Brunch']);
|
|
// A move without labels still takes the old ones off.
|
|
const bare = { placeId: 34, collection_id: 13 };
|
|
const moved = placesOf(preview('update_collection_place', bare, { items, target }).card);
|
|
expect(moved.places[0]?.delta).toMatchObject({ labelsAdded: [], labelsRemoved: [51, 52] });
|
|
});
|
|
|
|
it('ASTCOL-024: labels added or taken away are exactly the ones the service applies: its own list, a real change', () => {
|
|
const items = itemsValue([savedPlace(), savedPlace({ id: 35, name: 'Le Comptoir', label_ids: [] })]);
|
|
const add = { label_ids: [51, 53, 99], place_ids: [34, 35] };
|
|
const added = placesOf(preview('assign_collection_labels', add, { items }).card);
|
|
expect(added.places.map((place) => place.delta?.labelsAdded)).toEqual([[53], [51, 53]]);
|
|
const args = { label_ids: [51, 53, 99], place_ids: [34, 35], remove: true };
|
|
const taken = placesOf(preview('assign_collection_labels', args, { items }).card);
|
|
expect(taken.places.map((place) => place.delta?.labelsRemoved)).toEqual([[51], []]);
|
|
expect(taken.list).toMatchObject({ id: 12 });
|
|
// A place with more labels than its card names: the change is worked out on all of them.
|
|
const crowded = { items: itemsValue([savedPlace({ label_ids: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 51] })]) };
|
|
const removeArgs = { label_ids: [51], place_ids: [34], remove: true };
|
|
const off = placesOf(preview('assign_collection_labels', removeArgs, crowded).card);
|
|
expect(off.places[0]?.delta?.labelsRemoved).toEqual([51]);
|
|
const on = placesOf(preview('assign_collection_labels', { label_ids: [51], place_ids: [34] }, crowded).card);
|
|
expect(on.places[0]?.delta?.labelsAdded).toEqual([]);
|
|
});
|
|
|
|
it('ASTCOL-025: a copy into the trip marks the places the trip has already and flags another trip', () => {
|
|
const items = itemsValue([savedPlace(), savedPlace({ id: 35, name: 'Le Comptoir', source_place_id: null })]);
|
|
const shown = preview('copy_collection_places_to_trip', { trip_id: 7, place_ids: [34, 35, 36] }, { items });
|
|
expect(shown).toMatchObject({ subject: 'Septime, Le Comptoir', placeIds: [] });
|
|
const card = placesOf(shown.card);
|
|
expect(card.places.map((place) => place.inTripPlaceId)).toEqual([6, null]);
|
|
expect(card).toMatchObject({ more: 1, otherTrip: false });
|
|
// Into another trip: what the chat's trip holds says nothing about that one.
|
|
const other = { trip_id: 8, place_ids: [34] };
|
|
const elsewhere = placesOf(preview('copy_collection_places_to_trip', other, { items }).card);
|
|
expect(elsewhere.otherTrip).toBe(true);
|
|
expect(elsewhere.places.map((place) => place.inTripPlaceId)).toEqual([null]);
|
|
});
|
|
|
|
it('ASTCOL-026: a list change shows the list: new, renamed with what it was, deleted with what goes, reordered', () => {
|
|
const collection = collectionValue();
|
|
const created = listsOf(preview('create_collection', { name: 'Rome', color: '#abc' }).card);
|
|
expect(created.lists[0]).toMatchObject({ id: null, name: 'Rome', color: '#aabbcc', placeCount: 0, change: 'add' });
|
|
const rename = { collectionId: 12, name: 'Paris eats' };
|
|
const renamed = listsOf(preview('update_collection', rename, { collection }).card);
|
|
expect(renamed.lists[0]).toMatchObject({
|
|
name: 'Paris eats',
|
|
change: 'update',
|
|
before: { name: 'Paris food', color: '#e11d48' },
|
|
});
|
|
const deleted = preview('delete_collection', { collectionId: 12 }, { collection });
|
|
expect(deleted).toMatchObject({ subject: 'Paris food', verbKey: 'genericRemove' });
|
|
expect(listsOf(deleted.card).lists[0]).toMatchObject({ placeCount: 10, memberCount: 1, change: 'remove' });
|
|
const lists = {
|
|
collections: [
|
|
{ id: 12, name: 'A' },
|
|
{ id: 13, name: 'B' },
|
|
],
|
|
};
|
|
const order = preview('reorder_collections', { orderedIds: [13, 12] }, { lists });
|
|
expect(order.subject).toBe('B, A');
|
|
expect(listsOf(order.card).lists.map((row) => [row.name, row.change])).toEqual([
|
|
['B', 'order'],
|
|
['A', 'order'],
|
|
]);
|
|
});
|
|
|
|
it('ASTCOL-027: an invite joined, declined or withdrawn names the list and the person', () => {
|
|
const invite = { collection_id: 40, name: 'Rome', from: { id: 4, username: 'maya' } };
|
|
const lists = { collections: [], incomingInvites: [invite] };
|
|
const joined = listsOf(preview('accept_collection_invite', { collectionId: 40 }, { lists }).card);
|
|
expect(joined.lists[0]).toMatchObject({ id: 40, invite: true, change: 'join', person: { id: 4, name: 'maya' } });
|
|
const declined = listsOf(preview('decline_collection_invite', { collectionId: 40 }, { lists }).card);
|
|
expect(declined.lists[0]?.change).toBe('decline');
|
|
const withdraw = { collectionId: 12, userId: 3 };
|
|
const withdrawn = preview('cancel_collection_invite', withdraw, { collection: collectionValue() });
|
|
const person = { id: 3, name: 'tom', avatarKey: null };
|
|
expect(listsOf(withdrawn.card).lists[0]).toMatchObject({ change: 'withdraw', person });
|
|
});
|
|
|
|
it('ASTCOL-028: trip places saved to a list or marked across lists light their pins and show as the trip has them', () => {
|
|
const args = { collectionId: 12, tripId: 7, placeIds: [5, 6, 99] };
|
|
const saved = preview('save_trip_places_to_collection', args, { collection: collectionValue() });
|
|
expect(saved).toMatchObject({ subject: 'Louvre, Septime (trip)', placeIds: [5, 6] });
|
|
const shown = placesOf(saved.card).places.map((place) => [
|
|
place.inTripPlaceId,
|
|
place.imageKey,
|
|
place.category?.name,
|
|
]);
|
|
expect(shown).toEqual([
|
|
[5, null, 'Sights'],
|
|
[6, 'septime.jpg', undefined],
|
|
]);
|
|
const marked = preview('set_collection_place_status_from_trip', { trip_id: 7, place_ids: [5], status: 'visited' });
|
|
expect(marked.placeIds).toEqual([5]);
|
|
const card = placesOf(marked.card);
|
|
expect(card).toMatchObject({ list: null, everyList: true });
|
|
expect(card.places[0]).toMatchObject({ status: 'visited', delta: { status: { from: null, to: 'visited' } } });
|
|
});
|
|
|
|
it('ASTCOL-029: a label created, renamed or deleted shows with its list and how many places carry it', () => {
|
|
const collection = collectionValue();
|
|
const items = { lists: [], places: [], labels: [LABELS[0]] };
|
|
const brunch = { collection_id: 12, name: 'Brunch', color: '#ff0000' };
|
|
const created = placesOf(preview('create_collection_label', brunch, { collection }).card);
|
|
expect(created).toMatchObject({ list: { id: 12 }, places: [] });
|
|
expect(created.labels).toEqual([
|
|
{ id: null, name: 'Brunch', color: '#ff0000', places: 0, change: 'add', before: null },
|
|
]);
|
|
const supper = { labelId: 51, name: 'Supper' };
|
|
const renamed = placesOf(preview('update_collection_label', supper, { collection, items }).card);
|
|
expect(renamed.labels[0]).toEqual({
|
|
id: 51,
|
|
name: 'Supper',
|
|
color: '#2563eb',
|
|
places: 2,
|
|
change: 'rename',
|
|
before: { name: 'Dinner', color: '#2563eb' },
|
|
});
|
|
const removed = preview('delete_collection_label', { labelId: 51 }, { collection, items });
|
|
expect(removed.subject).toBe('Dinner');
|
|
expect(placesOf(removed.card).labels[0]).toMatchObject({ change: 'remove', places: 2 });
|
|
});
|
|
|
|
it('ASTCOL-035: a short colour the forms accept reads as that colour, never as one taken away', () => {
|
|
const collection = collectionValue();
|
|
const recolour = listsOf(preview('update_collection', { collectionId: 12, color: '#e1d' }, { collection }).card);
|
|
expect(recolour.lists[0]).toMatchObject({ color: '#ee11dd', before: { color: '#e11d48' } });
|
|
const same = listsOf(preview('update_collection', { collectionId: 12, name: 'Paris food' }, { collection }).card);
|
|
expect(same.lists[0]).toMatchObject({ color: '#e11d48', before: null });
|
|
const brunch = { collection_id: 12, name: 'Brunch', color: '#0f0' };
|
|
const label = placesOf(preview('create_collection_label', brunch, { collection }).card);
|
|
expect(label.labels[0]?.color).toBe('#00ff00');
|
|
});
|
|
|
|
it('ASTCOL-030: a saved place 5 is never the trip place 5: the subject is the saved place, no pin lights up', () => {
|
|
const items = itemsValue([savedPlace({ id: 5, name: 'Septime' })]);
|
|
const shown = preview('update_collection_place', { placeId: 5, status: 'visited' }, { items });
|
|
expect(shown).toMatchObject({ subject: 'Septime', placeIds: [] });
|
|
expect(preview('set_collection_place_status', { placeId: 5, status: 'visited' })).toMatchObject({
|
|
subject: '',
|
|
placeIds: [],
|
|
});
|
|
expect(ctx.card.places.get(5)?.name).toBe('Louvre');
|
|
});
|
|
});
|
|
|
|
describe('collection lookups', () => {
|
|
function reader() {
|
|
const read = vi.fn(async (name: string, args: Record<string, unknown> = {}) => ({
|
|
ok: true,
|
|
value: { name, args },
|
|
}));
|
|
return { read, memo: memoisedReader({ read }) };
|
|
}
|
|
|
|
it('ASTCOL-031: a place to save reads its list and where it is saved; without an id or a position only its list', async () => {
|
|
const { read, memo } = reader();
|
|
const args = { collection_id: 12, name: 'Rodin', google_place_id: 'ChIJx', lat: 1, lng: 2 };
|
|
const loaded = (await loadFor('save_place_to_collection', args, memo)) as CollectionLookup;
|
|
expect(loaded.collection).toEqual({ name: 'get_collection', args: { collectionId: 12 } });
|
|
expect(loaded.membership).toEqual({
|
|
name: 'find_place_in_collections',
|
|
args: { google_place_id: 'ChIJx', lat: 1, lng: 2 },
|
|
});
|
|
await loadFor('save_place_to_collection', { collection_id: 12, name: 'Rodin' }, memo);
|
|
// The list is read once per turn, however many changes ask for it.
|
|
expect(read.mock.calls.map(([name]) => name)).toEqual(['get_collection', 'find_place_in_collections']);
|
|
});
|
|
|
|
it('ASTCOL-032: a change of saved places reads them by id, a move its target, a label its list after the label', async () => {
|
|
const { read, memo } = reader();
|
|
await loadFor('update_collection_place', { placeId: 34, collection_id: 13 }, memo);
|
|
const sixty = Array.from({ length: 60 }, (_, i) => i + 1);
|
|
await loadFor('copy_collection_places_to_trip', { trip_id: 7, place_ids: sixty }, memo);
|
|
await loadFor('reorder_collections', { orderedIds: [1] }, memo);
|
|
expect(read.mock.calls).toEqual([
|
|
['get_collection_items', { placeIds: [34] }],
|
|
['get_collection', { collectionId: 13 }],
|
|
['get_collection_items', { placeIds: sixty.slice(0, 50) }],
|
|
['list_collections', {}],
|
|
]);
|
|
const labelRead = vi.fn(async (name: string) =>
|
|
name === 'get_collection_items' ? { labels: [{ id: 51, collection_id: 12 }] } : { collection: { id: 12 } },
|
|
);
|
|
const label = (await loadFor('delete_collection_label', { labelId: 51 }, labelRead)) as CollectionLookup;
|
|
expect(labelRead.mock.calls).toEqual([
|
|
['get_collection_items', { labelIds: [51] }],
|
|
['get_collection', { collectionId: 12 }],
|
|
]);
|
|
expect(label.collection).toEqual({ collection: { id: 12 } });
|
|
});
|
|
|
|
it('ASTCOL-033: a write with nothing to look up reads nothing', async () => {
|
|
const { read, memo } = reader();
|
|
expect(await loadFor('create_collection', { name: 'Rome' }, memo)).toBeNull();
|
|
expect(await loadFor('set_collection_place_status_from_trip', { trip_id: 7, place_ids: [5] }, memo)).toBeNull();
|
|
expect(read).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('ASTCOL-034: a place photo the model chose is never sent: the image argument is taken away on save and change', () => {
|
|
for (const tool of ['save_place_to_collection', 'update_collection_place']) {
|
|
expect(TOOL_CARDS[tool]?.argPolicy?.stripped).toEqual(['image_url']);
|
|
}
|
|
});
|
|
});
|