test(assistant): shared card tests, wiki rows and ratchets after the card packages

This commit is contained in:
Maurice
2026-10-22 15:50:57 +02:00
parent 5632916a69
commit db023f4c4d
16 changed files with 329 additions and 85 deletions
@@ -5,7 +5,7 @@
* ask for a change only for someone who may make it, and the answer's own
* when none is left for a reader.
*/
import type { AssistantMessage } from '@trek/shared';
import type { AssistantMessage, TrekCard } from '@trek/shared';
import { afterEach, describe, expect, it } from 'vitest';
import { followUpsFor } from './assistantFollowUpRules';
@@ -19,7 +19,7 @@ const answer: AssistantMessage = {
parts: [
{ type: 'text', text: 'Here is your year.' },
{ type: 'tool-call', toolCallId: 'r1', toolName: 'get_vacay_plan', args: {}, result: {} },
{ type: 'data-trek-card', data: { toolCallId: 'r1', card: { kind: 'vacay' } } },
{ type: 'data-trek-card', data: { toolCallId: 'r1', card: { kind: 'vacay' } as TrekCard } },
],
};
@@ -304,8 +304,8 @@ describe('messageBlocks: found places and weather', () => {
});
describe('messageBlocks: cards', () => {
const ROUTE: TrekCard = { kind: 'tourRoute' };
const LIST: TrekCard = { kind: 'tourList' };
const ROUTE = { kind: 'tourRoute' } as TrekCard;
const LIST = { kind: 'tourList' } as TrekCard;
const card = (toolCallId: string, value: TrekCard | null): AssistantMessagePart => ({
type: 'data-trek-card',
data: { toolCallId, card: value },
@@ -30,11 +30,15 @@ describe('assistantShowOnMap', () => {
it('FE-ASSIST-SHOW-003: no target where nothing shows on the map; a place without a spot opens the planner', () => {
expect(showOnMapTarget(applied('packing', [2], null))).toBeNull();
expect(showOnMapTarget(applied('tour', [2], 3))).toBeNull();
expect(showOnMapTarget(applied('tour', [], 3))).toBeNull();
expect(showOnMapTarget(applied('memory', [], null))).toBeNull();
expect(searchOf(applied('place', [], null))).toBe('');
});
it('FE-ASSIST-SHOW-005: an applied tour goes to its line on the plan, by the place it is', () => {
expect(searchOf(applied('tour', [2], 3))).toBe('?place=2');
});
it('FE-ASSIST-SHOW-004: a tab of the trip goes as ?tab= with what the tab reads beside it', () => {
const expense = plannerSearchOf({ kind: 'tab', tab: 'finanzplan', params: { expense: '7' } });
expect(expense).toBe('?tab=finanzplan&expense=7');
@@ -17,6 +17,8 @@ vi.mock('./cardViews', async (importOriginal) => {
const { createElement } = await import('react');
const views = {
...actual.CARD_VIEWS,
// A kind left without a view, whatever its package draws.
vacay: {},
tourList: {
Block: function Block({ cards }: { cards: readonly { toolCallId: string }[] }) {
return createElement('p', null, `${cards.length} tour lists`);
@@ -50,12 +52,12 @@ afterEach(() => vi.restoreAllMocks());
describe('AssistantCardBlock', () => {
it('FE-AI-CARDBLOCK-001: the kind draws the whole block', () => {
render(<AssistantCardBlock block={block({ kind: 'tourList' }, 2)} message={MESSAGE} />);
render(<AssistantCardBlock block={block({ kind: 'tourList' } as TrekCard, 2)} message={MESSAGE} />);
expect(screen.getByText('2 tour lists')).toBeInTheDocument();
});
it('FE-AI-CARDBLOCK-002: a kind not drawn yet shows nothing', () => {
const { container } = render(<AssistantCardBlock block={block({ kind: 'vacay' })} message={MESSAGE} />);
const { container } = render(<AssistantCardBlock block={block({ kind: 'vacay' } as TrekCard)} message={MESSAGE} />);
expect(container).toBeEmptyDOMElement();
});
@@ -63,7 +65,7 @@ describe('AssistantCardBlock', () => {
const logged = vi.spyOn(console, 'error').mockImplementation(() => {});
const { container } = render(
<div>
<AssistantCardBlock block={block({ kind: 'tourRoute' })} message={MESSAGE} />
<AssistantCardBlock block={block({ kind: 'tourRoute' } as TrekCard)} message={MESSAGE} />
<span>the answer</span>
</div>
);
@@ -64,9 +64,9 @@ vi.mock('./cardViews', async (importOriginal) => {
const TRIP = 4;
const INITIAL = useAssistantStore.getState();
const ROUTE: TrekCard = { kind: 'tourRoute' };
const LIST: TrekCard = { kind: 'tourList' };
const BROKEN: TrekCard = { kind: 'dayRoute' };
const ROUTE = { kind: 'tourRoute' } as TrekCard;
const LIST = { kind: 'tourList' } as TrekCard;
const BROKEN = { kind: 'dayRoute' } as TrekCard;
function preview(toolCallId: string, subject: string): AssistantChangePreview {
return {
@@ -11,7 +11,9 @@ import {
ASSISTANT_CHANGE_VERB_KEYS,
type AssistantApplied,
type AssistantChangeVerbKey,
type TrekCard,
type TrekCardKind,
type TrekCardOf,
trekCardSchema,
} from '@trek/shared';
import { describe, expect, it } from 'vitest';
@@ -32,6 +34,10 @@ import type { CardLook } from './cardTypes';
import { CARD_VIEWS } from './cardViews';
const KINDS = trekCardSchema.options.map((option) => option.shape.kind.value) as TrekCardKind[];
/** A vacation card cut down to its kind: the layout and map rules read nothing else of it. */
const VACAY = { kind: 'vacay' } as TrekCard;
/** A day route cut down to its kind, for the same reason. */
const DAY_ROUTE = { kind: 'dayRoute' } as TrekCardOf<'dayRoute'>;
describe('cardLooks', () => {
it('FE-AI-LOOK-001: every verb is named by its own key', () => {
@@ -46,17 +52,22 @@ describe('cardLooks', () => {
expect(STEP_LOOKS.get_trip_summary?.stepKey).toBe('assistant.step.reading');
});
it('FE-AI-LOOK-003: only a place, a stop, a note and a booking show on the map, as before the cards', () => {
it('FE-AI-LOOK-003: a place, a stop, a note, a booking and a stay show on the map; a kept note never', () => {
const onMap = Object.entries(ENTITY_LOOK).flatMap(([entity, look]) => (look.onMap ? [entity] : []));
expect(onMap.sort()).toEqual(['assignment', 'day_note', 'place', 'reservation']);
expect(Object.values(ENTITY_LOOK).every((look) => !look.open)).toBe(true);
// The core's own; a part of TREK may add its entities (a stay shows its place).
expect(onMap).toEqual(expect.arrayContaining(['assignment', 'day_note', 'place', 'reservation', 'accommodation']));
expect(onMap).not.toContain('memory');
expect(onMap).not.toContain('other');
for (const entity of ['place', 'assignment', 'day_note', 'memory', 'other'] as const) {
expect(ENTITY_LOOK[entity].open).toBeUndefined();
}
});
it('FE-AI-LOOK-004: every kind has a look and a view; a pending view groups by kind and gives way to nothing', () => {
expect(Object.keys(CARD_LOOKS).sort()).toEqual([...KINDS].sort());
expect(Object.keys(CARD_VIEWS).sort()).toEqual([...KINDS].sort());
expect(CARD_RULES.group({ kind: 'vacay' })).toBe('vacay');
expect(CARD_RULES.yieldsToChange({ kind: 'expenses' }, { kind: 'payments' })).toBe(false);
expect(CARD_RULES.group(VACAY)).toBe('vacay');
expect(CARD_RULES.yieldsToChange({ kind: 'costPerPerson' } as TrekCard, VACAY)).toBe(false);
});
it('FE-AI-LOOK-005: a kind draws its lines and points on the map through its look', () => {
@@ -66,10 +77,10 @@ describe('cardLooks', () => {
],
ghosts: (): GhostPoint[] => [{ id: 'via', lat: 45.1, lng: 7.2, name: 'Col', variant: 'point' }],
};
expect(look.lines?.({ kind: 'dayRoute' }, 'before')).toEqual([
expect(look.lines?.(DAY_ROUTE, 'before')).toEqual([
{ id: 'day:before', lines: [[[45.1, 7.2]]], color: 'var(--accent)', dashed: true },
]);
expect(look.ghosts?.({ kind: 'dayRoute' })[0]?.variant).toBe('point');
expect(look.ghosts?.(DAY_ROUTE)[0]?.variant).toBe('point');
});
it("FE-AI-LOOK-006: the layout rules and the map read a kind's look, nothing for one that says nothing", () => {
@@ -78,19 +89,17 @@ describe('cardLooks', () => {
route.yieldsToChange = (_answer, change) => change.kind === 'dayRoute';
route.lines = (_card, use) => [{ id: `day:${use}`, lines: [], color: 'var(--accent)' }];
try {
expect(CARD_RULES.group({ kind: 'dayRoute' })).toBe('routes');
expect(CARD_RULES.yieldsToChange({ kind: 'dayRoute' }, { kind: 'dayRoute' })).toBe(true);
expect(CARD_MAP_LOOK.lines({ kind: 'dayRoute' }, 'look')).toEqual([
{ id: 'day:look', lines: [], color: 'var(--accent)' },
]);
expect(CARD_RULES.group(DAY_ROUTE)).toBe('routes');
expect(CARD_RULES.yieldsToChange(DAY_ROUTE, DAY_ROUTE)).toBe(true);
expect(CARD_MAP_LOOK.lines(DAY_ROUTE, 'look')).toEqual([{ id: 'day:look', lines: [], color: 'var(--accent)' }]);
} finally {
delete route.group;
delete route.yieldsToChange;
delete route.lines;
}
expect(CARD_MAP_LOOK.lines({ kind: 'dayRoute' }, 'look')).toEqual([]);
expect(CARD_MAP_LOOK.ghosts({ kind: 'vacay' })).toEqual([]);
expect(CARD_MAP_LOOK.hidesEffectGhost({ kind: 'vacay' })).toBe(false);
expect(CARD_MAP_LOOK.lines(DAY_ROUTE, 'look')).toEqual([]);
expect(CARD_MAP_LOOK.ghosts(VACAY)).toEqual([]);
expect(CARD_MAP_LOOK.hidesEffectGhost(VACAY)).toBe(false);
});
it('FE-AI-LOOK-007: a verb or entity only a newer server knows looks like a plain update or an other', () => {
@@ -3,7 +3,8 @@
*
* The links the chat opens the planner with while it is on screen: another
* tab of the trip switches there and leaves the URL, and the parts' own links
* (tours, bookings, road trip) take nothing yet.
* (tours, bookings, road trip) are each handed the planner, the workspace and
* the query (each part's own test covers what it does with them).
*/
import { renderHook } from '@testing-library/react';
import type { ReactNode } from 'react';
@@ -12,6 +13,11 @@ import { describe, expect, it, vi } from 'vitest';
import { useAssistantEntityLinks } from './useAssistantEntityLinks';
const parts = vi.hoisted(() => ({ tour: vi.fn(), booking: vi.fn(), roadtrip: vi.fn() }));
vi.mock('./deepLinks/useTourDeepLink', () => ({ useTourDeepLink: parts.tour }));
vi.mock('./deepLinks/useBookingDeepLink', () => ({ useBookingDeepLink: parts.booking }));
vi.mock('./deepLinks/useRoadtripDeepLink', () => ({ useRoadtripDeepLink: parts.roadtrip }));
type Options = Parameters<typeof useAssistantEntityLinks>[0];
function setup(path: string) {
@@ -37,9 +43,14 @@ describe('useAssistantEntityLinks', () => {
expect(hook.result.current.search).toBe('?expense=7');
});
it("FE-PLANNER-ENTITYLINK-002: the parts' own links take nothing yet, and nothing switches without a tab", () => {
it("FE-PLANNER-ENTITYLINK-002: each part's own link gets the planner, the workspace and the query; nothing switches without a tab", () => {
const { planner, hook } = setup('/trips/5?tour=12');
expect(planner.handleTabChange).not.toHaveBeenCalled();
expect(hook.result.current.search).toBe('?tour=12');
for (const part of [parts.tour, parts.booking, parts.roadtrip]) {
const links = part.mock.lastCall?.[0] as { planner: unknown; searchParams: URLSearchParams };
expect(links.planner).toBe(planner);
expect(links.searchParams.get('tour')).toBe('12');
}
});
});
@@ -71,8 +71,8 @@ describe('assistantMessages: cards in an answer', () => {
expect(messages[0]?.parts.map((p) => p.type)).toEqual(['data-trek-weather']);
});
const ROUTE: TrekCard = { kind: 'tourRoute' };
const LIST: TrekCard = { kind: 'tourList' };
const ROUTE = { kind: 'tourRoute' } as TrekCard;
const LIST = { kind: 'tourList' } as TrekCard;
const cardsOf = (message: AssistantMessage | undefined) =>
message?.parts.flatMap((p) => (p.type === 'data-trek-card' ? [p.data] : [])) ?? [];