fix(ci): card points re-typed for the non-strict builds, formatting, strict ratchet and two assistant e2e checks

This commit is contained in:
Maurice
2026-10-22 16:44:46 +02:00
parent 162f2996d2
commit 88cd4fba6e
23 changed files with 143 additions and 67 deletions
-1
View File
@@ -101,6 +101,5 @@
"src/nest/roadtrip/roadtrip-router.service.ts": 12,
"src/nest/settings/settings.service.ts": 1,
"src/nest/storage/drivers/s3.driver.ts": 1,
"src/nest/trips/trips.controller.ts": 1,
"src/nest/trips/trips.service.ts": 1
}
@@ -16,7 +16,11 @@ const SHOWN = 8;
function listCard(input: AnswerInput, rows: Row[], toRow: (raw: Row) => BookingRow): TrekCard | null {
const scope = scopeOfCard(input.card);
return bookingCard('none', rows.slice(0, SHOWN).map((raw) => bookingItemOf(toRow(raw), scope)), rows.length);
return bookingCard(
'none',
rows.slice(0, SHOWN).map((raw) => bookingItemOf(toRow(raw), scope)),
rows.length,
);
}
/** A stay listed once: the list joins each booking that links it, so one stay can come twice. */
@@ -1,5 +1,5 @@
import { IMPORT_BOOKING_TOOL } from '../../assistant-tools.constants';
import type { TripCard } from '../../assistant-context';
import { IMPORT_BOOKING_TOOL } from '../../assistant-tools.constants';
import type { DomainCards, PreviewContext, ToolArgs } from '../card-types';
import { argId, clipped, idIn } from '../card-values';
import { BOOKING_ANSWERS } from './booking-answer';
@@ -28,7 +28,10 @@ function expenseName(ctx: PreviewContext, args: ToolArgs): string {
/** Who shares a stored expense, by name. */
function sharedBy(ctx: PreviewContext, row: Row | undefined): string {
return memberNames(ctx, rowsOf(row?.members).map((member) => member.user_id));
return memberNames(
ctx,
rowsOf(row?.members).map((member) => member.user_id),
);
}
/** "Ana → Ben", from the two people of a payment. */
@@ -117,7 +117,11 @@ export function splitOf(
max: number,
): Pick<CardExpense, 'split' | 'shares' | 'moreShares'> {
if (members.length === 0) return { split: 'none', shares: [], moreShares: 0 };
const equal = splitEqualShares(toMinor(total), members.map((member) => ({ user_id: member.id })), itemId);
const equal = splitEqualShares(
toMinor(total),
members.map((member) => ({ user_id: member.id })),
itemId,
);
const shares = members.map((member) => ({
person: personFrom(card, member.id, member.name, member.avatarUrl),
amount: member.amount ?? (equal[member.id] ?? 0) / 100,
@@ -73,12 +73,15 @@ export const COLLECTION_CARDS: DomainCards = {
list_collections: { answer: listsAnswer },
find_place_in_collections: { answer: membershipAnswer },
...WRITE_CARDS,
create_collection: { ...WRITE_CARDS.create_collection, undo: DELETE_CREATED_LIST },
create_collection: { ...writeCards('create_collection', WRITES.create_collection), undo: DELETE_CREATED_LIST },
save_place_to_collection: {
...WRITE_CARDS.save_place_to_collection,
...writeCards('save_place_to_collection', WRITES.save_place_to_collection),
undo: DELETE_SAVED_PLACE,
argPolicy: NO_IMAGE,
},
update_collection_place: { ...WRITE_CARDS.update_collection_place, argPolicy: NO_IMAGE },
update_collection_place: {
...writeCards('update_collection_place', WRITES.update_collection_place),
argPolicy: NO_IMAGE,
},
},
};
@@ -24,12 +24,7 @@ import {
type ListItem,
type PackingTier,
} from './packing-rows';
import {
resolvePackedState,
type PackingCardBag,
type PackingCardItem,
type PackingListCard,
} from '@trek/shared';
import { resolvePackedState, type PackingCardBag, type PackingCardItem, type PackingListCard } from '@trek/shared';
/**
* The card each packing change shows inside its change card, from the call's
@@ -330,37 +325,56 @@ export function packingAssigneesCard(ctx: PreviewContext, args: ToolArgs, loaded
const before = rowsOf(recordOf(recordOf(loaded).assignees)[raw]).flatMap((row) => positiveId(row.user_id) ?? []);
const inside = tripPacking(ctx.card).filter((item) => !item.placeholder && item.category === raw);
const personal = inside.length > 0 && inside.every((item) => item.isPrivate);
return packingCard({ type: 'trip' }, 'category', [], [
{
key: { by: 'category', name, slot: slotOf(ctx.card, raw, personal) },
assignees: peopleOf(ctx.card, chosen, 6),
assigneesRemoved: peopleOf(ctx.card, before.filter((id) => !chosen.includes(id)), 6),
items: [],
count: inside.length,
packed: inside.filter((item) => item.checked).length,
},
]);
return packingCard(
{ type: 'trip' },
'category',
[],
[
{
key: { by: 'category', name, slot: slotOf(ctx.card, raw, personal) },
assignees: peopleOf(ctx.card, chosen, 6),
assigneesRemoved: peopleOf(
ctx.card,
before.filter((id) => !chosen.includes(id)),
6,
),
items: [],
count: inside.length,
packed: inside.filter((item) => item.checked).length,
},
],
);
}
/** A bag head on a card of its own: what a change to one bag shows. */
function bagCard(card: TripCard, bag: PackingCardBag): PackingListCard {
const inside = tripPacking(card).filter((item) => !item.placeholder && bag.id !== null && item.bagId === bag.id);
return packingCard({ type: 'trip' }, 'bag', [bag], [
{
key: { by: 'bag', bagKey: bag.key },
assignees: [],
assigneesRemoved: [],
items: [],
count: inside.length,
packed: inside.filter((item) => item.checked).length,
},
]);
return packingCard(
{ type: 'trip' },
'bag',
[bag],
[
{
key: { by: 'bag', bagKey: bag.key },
assignees: [],
assigneesRemoved: [],
items: [],
count: inside.length,
packed: inside.filter((item) => item.checked).length,
},
],
);
}
/** The bag a call names (`bagId`), as the lookup read it. */
function namedBag(card: TripCard, args: ToolArgs, loaded: unknown): PackingCardBag | null {
const bagId = argId(args, 'bagId');
return bagId === null ? null : bagOf(card, rawBags(loaded).find((bag) => bag.id === bagId));
return bagId === null
? null
: bagOf(
card,
rawBags(loaded).find((bag) => bag.id === bagId),
);
}
/** `create_packing_bag`: the new bag's head, in its colour (indigo without one) and with its limit. */
@@ -204,7 +204,11 @@ export function slotOf(card: TripCard | null, category: string | null, personal:
/** The people of an assignee list (`[{ user_id }]`), as trip members. */
export function assigneesOf(card: TripCard, raw: unknown): CardPerson[] {
return peopleOf(card, rowsOf(raw).flatMap((row) => positiveId(row.user_id) ?? []), 6);
return peopleOf(
card,
rowsOf(raw).flatMap((row) => positiveId(row.user_id) ?? []),
6,
);
}
/** One bag as `list_packing_bags` answers it. */
@@ -302,7 +306,11 @@ export function packingGroups(
items: readonly ListItem[],
options: GroupOptions,
): { groups: PackingCardGroup[]; moreGroups: number } {
const buckets = bucketsOf(card, items.filter((item) => !item.placeholder), options);
const buckets = bucketsOf(
card,
items.filter((item) => !item.placeholder),
options,
);
let left = options.maxRows ?? MAX_PACKING_ROWS;
const perGroup = options.fillSingleGroup && buckets.length === 1 ? left : ROWS_PER_GROUP;
const groups = buckets.slice(0, MAX_CARD_GROUPS).map((bucket): PackingCardGroup => {
@@ -170,7 +170,11 @@ function todoAssigneesCard(ctx: PreviewContext, args: ToolArgs, loaded: TodoAssi
category,
slot: todoSlot(ctx.card, args.categoryName),
assignees: peopleOf(ctx.card, chosen, 6),
assigneesRemoved: peopleOf(ctx.card, before.filter((id) => !chosen.includes(id)), 6),
assigneesRemoved: peopleOf(
ctx.card,
before.filter((id) => !chosen.includes(id)),
6,
),
items: [],
count: inList.length,
open: inList.filter((todo) => !todo.checked).length,
@@ -113,7 +113,7 @@ export function describeSettings(ctx: PreviewContext, args: Args, loaded: SavedS
after: settingText(change.after),
});
}
return { subject: clip(ctx.card.title), rows, dayId: null, placeIds: [] };
return { subject: clip(ctx.card.title), rows, dayId: null, placeIds: [], tool: 'update_roadtrip_settings' };
}
export function describeDayEnd(ctx: PreviewContext, args: Args): Described {
@@ -129,7 +129,9 @@ export function describeImportRoute(ctx: PreviewContext, args: Args): Described
const names = Array.isArray(args.stops)
? args.stops.map((stop: unknown) => text(recordOf(stop).name)).filter((name) => name !== '')
: [];
const rows = dayRows(ctx, dayId).add('quantity', String(names.length)).add('route', clip(routeText(names)));
const rows = dayRows(ctx, dayId)
.add('quantity', String(names.length))
.add('route', clip(routeText(names)));
const ends = names.length > 0 ? routeText([names[0] ?? '', names[names.length - 1] ?? '']) : '';
return { subject: ends, rows, dayId, placeIds: [] };
}
@@ -1,6 +1,5 @@
import type { CardReader, PreviewContext, ToolArgs } from '../card-types';
import { canonicalJson, clipped, finite, recordOf, type Row } from '../card-values';
import { hikeCard } from './tour-route.cards';
import {
argText,
climbOf,
@@ -19,6 +18,7 @@ import {
straightMetres,
whole,
} from './tour-card-values';
import { hikeCard } from './tour-route.cards';
import {
hikingPreferencesOf,
TOUR_ROUTE_LIMITS,
@@ -104,7 +104,11 @@ export const VACAY_ANSWER_CARDS = {
const stats = statsOf(input.value);
if (!stats || stats.length === 0) return null;
const viewerId = input.card.viewerId;
const people = peopleOf(stats.map((row) => row.personId), knownPeople(null, [], stats), viewerId);
const people = peopleOf(
stats.map((row) => row.personId),
knownPeople(null, [], stats),
viewerId,
);
const order = new Map(people.map((person, index) => [person.id, index]));
const balances = keepKnown(stats, people)
.sort((a, b) => (order.get(a.personId) ?? 0) - (order.get(b.personId) ?? 0))
@@ -96,7 +96,10 @@ function toggleProposal(viewerId: number, args: ToolArgs, loaded: VacayLoaded, d
const was = existing ? counted(existing.kind, existing.fraction) : 0;
const will = outcome === 'remove' ? 0 : outcome === 'blocked' ? was : counted(kind, fraction);
const year = Number(date.slice(0, 4));
const periodYear = periodOf(date, loaded.stats.find((row) => row.personId === personId && row.year === year));
const periodYear = periodOf(
date,
loaded.stats.find((row) => row.personId === personId && row.year === year),
);
const counts = kind === 'vacation' && (outcome === 'add' || outcome === 'convert');
return {
date,
@@ -51,7 +51,9 @@ function tripDatesOf(card: TripCard): string[] {
/** The dated days of the trip inside `months`, as many as a card holds. */
export function tripDatesIn(card: TripCard, months: readonly CardMonth[]): string[] {
return tripDatesOf(card).filter((date) => inMonths(date, months)).slice(0, VACAY_CARD_MAX_DATES);
return tripDatesOf(card)
.filter((date) => inMonths(date, months))
.slice(0, VACAY_CARD_MAX_DATES);
}
/** The company holidays inside `months`, each date once. */
@@ -130,7 +130,10 @@ export class RoadtripPlanningMcp {
});
const betweenStops = (alongKm: number): [string, string] | null => {
if (anchors.length < 2) return null;
const index = insertIndexForAlong(anchors.map((anchor) => anchor.alongKm), alongKm);
const index = insertIndexForAlong(
anchors.map((anchor) => anchor.alongKm),
alongKm,
);
return [anchors[index - 1].name, anchors[index].name];
};
const hits = new Map<
+4 -3
View File
@@ -1131,9 +1131,10 @@ describe('Trip assistant e2e', () => {
const args = { question, options: ['Trattoria', 'Night market'] };
provider.script = [callTools({ id: 'call_poll', name: 'create_collab_poll', args })];
const turn = await post(`${t.base}/chat`, t.userId, { ...CTX, threadId: null, text: 'Ask the group' });
const toolCallId = ofType(turn.chunks, 'tool-approval-request')[0]?.toolCallId ?? '';
const [change] = ofType(turn.chunks, 'data-trek-change');
expect(change.data).toMatchObject({
toolCallId: 'call_poll',
toolCallId,
verbKey: 'genericCreate',
tool: 'create_collab_poll',
domain: 'collab',
@@ -1148,7 +1149,7 @@ describe('Trip assistant e2e', () => {
provider.script = [say('The poll is up.')];
const decided = await post(`${t.base}/threads/${threadId}/decisions`, t.userId, {
...CTX,
decisions: [{ toolCallId: 'call_poll', approved: true }],
decisions: [{ toolCallId, approved: true }],
});
expect(decided.status).toBe(200);
const poll = await findRow(orm, CollabPolls, { trip: t.tripId });
@@ -1156,7 +1157,7 @@ describe('Trip assistant e2e', () => {
const [applied] = ofType(decided.chunks, 'data-trek-applied');
expect(applied.data).toMatchObject({ entity: 'collab_poll', ids: [poll?.id] });
expect(ofType(decided.chunks, 'data-trek-card')[0].data).toMatchObject({
toolCallId: 'call_poll',
toolCallId,
card: { kind: 'collabPolls', polls: [{ pollId: poll?.id, change: 'create', question }] },
});
expect(broadcasts.broadcast).toHaveBeenCalledWith(t.tripId, 'collab:poll:created', expect.anything());
@@ -222,7 +222,14 @@ describe('answer cards', () => {
it('ASTCARD-BUD-004: an empty list, a broken answer and rows without a name or an amount show no card', () => {
expect(answer('list_budget_items', { currency: 'EUR', total: 0, items: [] })).toBeNull();
expect(answer('list_budget_items', 'not a list')).toBeNull();
expect(answer('list_budget_items', { items: [{ id: 1, total_price: 5 }, { id: 2, name: 'Tip' }] })).toBeNull();
expect(
answer('list_budget_items', {
items: [
{ id: 1, total_price: 5 },
{ id: 2, name: 'Tip' },
],
}),
).toBeNull();
expect(answer('get_settlement_summary', { error: 'nope' })).toBeNull();
expect(answer('get_budget_per_person', { summary: [] })).toBeNull();
expect(answer('list_settlements', { settlements: [] })).toBeNull();
@@ -143,7 +143,14 @@ describe('the chat card of a read', () => {
const row = message(4, {
origin: 'assistant',
reactions: [
{ emoji: '❤️', count: 2, users: [{ user_id: 7, username: 'anna' }, { user_id: 8, username: 'jon' }] },
{
emoji: '❤️',
count: 2,
users: [
{ user_id: 7, username: 'anna' },
{ user_id: 8, username: 'jon' },
],
},
{ emoji: '👍', count: 1, users: [{ user_id: 8, username: 'jon' }] },
],
attachments: [
@@ -195,7 +195,12 @@ describe('collection change cards', () => {
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 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([
@@ -342,7 +342,17 @@ describe('the words and the way back of the other tour writes', () => {
expect(unchangedSinceApplied('create_hike', created, onItsDay)).toBe(true);
const twice = {
places: { places: [hike] },
summary: { days: [{ id: 31, assignments: [{ id: 306, place_id: 64 }, { id: 305, place_id: 64 }] }] },
summary: {
days: [
{
id: 31,
assignments: [
{ id: 306, place_id: 64 },
{ id: 305, place_id: 64 },
],
},
],
},
};
expect(unchangedSinceApplied('create_hike', created, twice)).toBe(false);
// A new way saved since (any route write stamps updated_at) keeps the tour, name and day unchanged or not.
@@ -13,13 +13,7 @@ import type { AnswerInput } from '../../../../../../src/nest/assistant/cards/car
import { answerCardOf } from '../../../../../../src/nest/assistant/cards/registry';
import { fitted, hardestStretches } from '../../../../../../src/nest/assistant/cards/tour/tour-card-values';
import { sacStretches } from '../../../../../../src/nest/assistant/cards/tour/tour-route.cards';
import {
MAX_CARD_CHARS,
trekCardSchema,
type TourRouteCard,
type TourRouteStretch,
type TrekCard,
} from '@trek/shared';
import { MAX_CARD_CHARS, trekCardSchema, type TourRouteCard, type TourRouteStretch, type TrekCard } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
+3 -2
View File
@@ -17,8 +17,9 @@ export const cardLngSchema = z.number().min(-180).max(180);
/** A point of a line to draw, `[lat, lng]` at five decimals; single points are `{ lat, lng }` objects. */
export type CardPoint = [number, number];
// Typed by hand: server and client compile without strictNullChecks, where an inferred tuple makes every item optional.
export const cardPointSchema: z.ZodType<CardPoint> = z.tuple([cardLatSchema, cardLngSchema]);
// Re-typed on the way out: server and client compile without strictNullChecks, where an inferred tuple
// makes every item optional and no longer fits a `[number, number]`.
export const cardPointSchema = z.tuple([cardLatSchema, cardLngSchema]).transform(([lat, lng]): CardPoint => [lat, lng]);
export const cardDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
export const cardClockSchema = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/);
@@ -38,12 +38,10 @@ const sacStep = z.number().int().min(1).max(6);
/** A point of the line, `[lat, lng, height]` at five decimals; every height is null when one is missing. */
export type TourRoutePoint = [number, number, number | null];
// Typed by hand like cardPointSchema: an inferred tuple reads every item as optional where strictNullChecks is off.
export const tourRoutePointSchema: z.ZodType<TourRoutePoint> = z.tuple([
cardLatSchema,
cardLngSchema,
height.nullable(),
]);
// Re-typed on the way out like cardPointSchema, for the builds without strictNullChecks.
export const tourRoutePointSchema = z
.tuple([cardLatSchema, cardLngSchema, height.nullable()])
.transform(([lat, lng, metres]): TourRoutePoint => [lat, lng, metres]);
/** What a stop is beyond its role: a kind of place along a hike, its highest point, a city walk's station. */
export const TOUR_ROUTE_STOP_KINDS = ['highest', 'station'] as const;