mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
feat(assistant): passport, country and bucket list cards
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
import type { AnswerInput } from '../card-types';
|
||||
import { clipped, finite, recordOf, type Row } from '../card-values';
|
||||
import { countOf, countryCodeOf, entryOf, isoDayOf, statusOf } from './atlas-values';
|
||||
import {
|
||||
ATLAS_CARD_CONTINENTS,
|
||||
type AtlasBucketCard,
|
||||
type AtlasBucketEntry,
|
||||
type AtlasCardContinent,
|
||||
type AtlasCountryCard,
|
||||
type AtlasPassportCard,
|
||||
type VisitStatus,
|
||||
} from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The answer cards of the atlas reads: the passport of `get_atlas_stats`, one
|
||||
* country of `get_country_atlas_places`, the wishes of `list_bucket_list`.
|
||||
* Built from the raw answer: every code checked, every list cut to its bound
|
||||
* with the rest counted, every text masked. Never a coordinate.
|
||||
*/
|
||||
|
||||
const MAX_FLAGS = 40;
|
||||
const MAX_TRIPS = 4;
|
||||
const MAX_PLACES = 8;
|
||||
const MAX_WISHES = 12;
|
||||
|
||||
function rowsOf(value: unknown): Row[] {
|
||||
return Array.isArray(value) ? value.map(recordOf) : [];
|
||||
}
|
||||
|
||||
/** Later dates first; rows without one last. */
|
||||
function byDateDesc(a: string | null, b: string | null): number {
|
||||
if (a === b) return 0;
|
||||
if (a === null) return 1;
|
||||
if (b === null) return -1;
|
||||
return a < b ? 1 : -1;
|
||||
}
|
||||
|
||||
/** Earlier dates first; rows without one last. */
|
||||
function byDateAsc(a: string | null, b: string | null): number {
|
||||
if (a === b) return 0;
|
||||
if (a === null) return 1;
|
||||
if (b === null) return -1;
|
||||
return a < b ? -1 : 1;
|
||||
}
|
||||
|
||||
/** The flags in the Atlas page's order: visited ones latest first, then planned ones soonest first. */
|
||||
function flagsOf(countries: Row[]) {
|
||||
const listed = countries.flatMap((row) => {
|
||||
const code = countryCodeOf(row.code);
|
||||
// A user without trips has only countries marked by hand, which are visited.
|
||||
const status: VisitStatus = statusOf(row.status) ?? 'visited';
|
||||
return code && status !== 'idea'
|
||||
? [{ code, status, first: isoDayOf(row.firstVisit), last: isoDayOf(row.lastVisit) }]
|
||||
: [];
|
||||
});
|
||||
const visited = listed.filter((f) => f.status === 'visited').sort((a, b) => byDateDesc(a.last, b.last));
|
||||
const planned = listed.filter((f) => f.status === 'planned').sort((a, b) => byDateAsc(a.first, b.first));
|
||||
return [...visited, ...planned].map(({ code, status }) => ({ code, status }));
|
||||
}
|
||||
|
||||
function isContinent(name: string): name is AtlasCardContinent {
|
||||
return (ATLAS_CARD_CONTINENTS as readonly string[]).includes(name);
|
||||
}
|
||||
|
||||
function continentsOf(visited: Row, planned: Row): AtlasPassportCard['continents'] {
|
||||
const names = [...new Set([...Object.keys(visited), ...Object.keys(planned)])].filter(isContinent);
|
||||
return names
|
||||
.map((name) => ({ name, visited: countOf(visited[name]), planned: countOf(planned[name]) }))
|
||||
.sort((a, b) => b.visited - a.visited || b.planned - a.planned)
|
||||
.slice(0, ATLAS_CARD_CONTINENTS.length);
|
||||
}
|
||||
|
||||
function nextTripOf(value: unknown): AtlasPassportCard['nextTrip'] {
|
||||
const trip = recordOf(value);
|
||||
const title = clipped(trip.title, 80);
|
||||
if (!title) return null;
|
||||
const days = finite(trip.daysUntil);
|
||||
const daysUntil = days !== null && Number.isInteger(days) && days >= 0 && days <= 36_500 ? days : null;
|
||||
return { title, startDate: isoDayOf(trip.start_date), daysUntil };
|
||||
}
|
||||
|
||||
/** `{ stats, travel }` of get_atlas_stats as the passport; null when it holds no atlas. */
|
||||
export function passportOf(raw: unknown): AtlasPassportCard | null {
|
||||
const stats = recordOf(recordOf(raw).stats);
|
||||
if (!Array.isArray(stats.countries)) return null;
|
||||
const travel = recordOf(recordOf(raw).travel);
|
||||
const totals = recordOf(stats.stats);
|
||||
const flags = flagsOf(rowsOf(stats.countries));
|
||||
const flown = finite(travel.totalDistanceKm);
|
||||
const streak = finite(stats.streak);
|
||||
return {
|
||||
kind: 'atlasPassport',
|
||||
countries: {
|
||||
visited: countOf(totals.totalCountries),
|
||||
planned: countOf(totals.totalCountriesPlanned),
|
||||
idea: countOf(totals.totalCountriesIdea),
|
||||
},
|
||||
totals: {
|
||||
trips: countOf(totals.totalTrips),
|
||||
days: countOf(totals.totalDays),
|
||||
places: countOf(totals.totalPlaces),
|
||||
// The Atlas page's own figure, the page "Open Atlas" leads to; the passport's list without it.
|
||||
cities: totals.totalCities === undefined ? rowsOf(travel.cities).length : countOf(totals.totalCities),
|
||||
flownKm: flown !== null && flown >= 1 ? countOf(flown) : null,
|
||||
},
|
||||
flags: flags.slice(0, MAX_FLAGS),
|
||||
moreFlags: Math.max(0, flags.length - MAX_FLAGS),
|
||||
continents: continentsOf(recordOf(stats.continents), recordOf(stats.continentsPlanned)),
|
||||
nextTrip: nextTripOf(stats.nextTrip),
|
||||
streakYears: streak !== null && Number.isInteger(streak) && streak >= 0 && streak <= 200 ? streak : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** One country of get_country_atlas_places: its trips latest first, its places in the order of those trips. */
|
||||
export function countryOf(args: Row, raw: unknown): AtlasCountryCard | null {
|
||||
const code = countryCodeOf(args.countryCode);
|
||||
if (!code) return null;
|
||||
const r = recordOf(raw);
|
||||
const places = rowsOf(r.places);
|
||||
const tripRows = rowsOf(r.trips).sort((a, b) => byDateDesc(isoDayOf(a.start_date), isoDayOf(b.start_date)));
|
||||
const trips = tripRows.flatMap((trip) => {
|
||||
const id = finite(trip.id);
|
||||
const title = clipped(trip.title, 80);
|
||||
return id !== null && Number.isInteger(id) && id > 0 && title
|
||||
? [{ id, title, startDate: isoDayOf(trip.start_date), endDate: isoDayOf(trip.end_date) }]
|
||||
: [];
|
||||
});
|
||||
const order = new Map(tripRows.map((trip, index): [unknown, number] => [trip.id, index]));
|
||||
const names = [
|
||||
...new Set(
|
||||
places
|
||||
.map((place, index) => ({ place, index, trip: order.get(place.trip_id) ?? tripRows.length }))
|
||||
.sort((a, b) => a.trip - b.trip || a.index - b.index)
|
||||
.map(({ place }) => clipped(place.name, 80))
|
||||
.filter((name): name is string => name !== null),
|
||||
),
|
||||
];
|
||||
const marked = r.manually_marked === true;
|
||||
const source = r.marked_source;
|
||||
// A country with no trip, no place and no mark of its own is not on the map, whatever the read calls it.
|
||||
const onMap = marked || places.length > 0 || trips.length > 0;
|
||||
return {
|
||||
kind: 'atlasCountry',
|
||||
code,
|
||||
status: onMap ? statusOf(r.status) : null,
|
||||
markedBy: source === 'manual' || source === 'dawarich' || source === 'region' ? source : null,
|
||||
placeCount: places.length,
|
||||
tripCount: trips.length,
|
||||
trips: trips.slice(0, MAX_TRIPS),
|
||||
moreTrips: Math.max(0, trips.length - MAX_TRIPS),
|
||||
places: names.slice(0, MAX_PLACES),
|
||||
morePlaces: Math.max(0, names.length - MAX_PLACES),
|
||||
change: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** The open wishes first, soonest first and undated last, then the ones that came true, latest first. */
|
||||
function wishOrder(a: AtlasBucketEntry, b: AtlasBucketEntry): number {
|
||||
if (!a.reachedOn !== !b.reachedOn) return a.reachedOn ? 1 : -1;
|
||||
return a.reachedOn ? byDateDesc(a.reachedOn, b.reachedOn) : byDateAsc(a.targetDate, b.targetDate);
|
||||
}
|
||||
|
||||
/** The wishes of list_bucket_list; null for an empty list. */
|
||||
export function bucketListOf(raw: unknown): AtlasBucketCard | null {
|
||||
const entries = rowsOf(recordOf(raw).items)
|
||||
.map((row) => entryOf(row, { fromResult: true }))
|
||||
.filter((entry): entry is AtlasBucketEntry => entry !== null)
|
||||
.sort(wishOrder);
|
||||
const [first, ...rest] = entries;
|
||||
if (!first) return null;
|
||||
return {
|
||||
kind: 'atlasBucket',
|
||||
items: [first, ...rest.slice(0, MAX_WISHES - 1)],
|
||||
more: Math.max(0, entries.length - MAX_WISHES),
|
||||
change: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** The answer builders by tool, for the registry. */
|
||||
export const ATLAS_ANSWERS = {
|
||||
get_atlas_stats: (input: AnswerInput) => passportOf(input.value),
|
||||
get_country_atlas_places: (input: AnswerInput) => countryOf(input.args, input.value),
|
||||
list_bucket_list: (input: AnswerInput) => bucketListOf(input.value),
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { ToolArgs, ToolCards, UndoSnapshot } from '../card-types';
|
||||
import { idIn, readKey, recordOf } from '../card-values';
|
||||
import { countOf, entryOf, statusOf } from './atlas-values';
|
||||
import type { AssistantChangePreview, AtlasBucketCard, AtlasCountryCard, AtlasStatusAfter } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The cards of applied atlas changes, which take the place of the change
|
||||
* cards beside their receipts: the country with where it really stands now
|
||||
* and the exact counters (the mark tools answer `after`), a wish with the id
|
||||
* it was stored under. And the way back from an added wish: deleting it,
|
||||
* as long as it still reads as it was added.
|
||||
*/
|
||||
|
||||
type AppliedInput = { args: ToolArgs; result: unknown; preview: AssistantChangePreview | null };
|
||||
|
||||
function countryCardOf(preview: AssistantChangePreview | null): AtlasCountryCard | null {
|
||||
const card = preview?.card;
|
||||
return card?.kind === 'atlasCountry' ? card : null;
|
||||
}
|
||||
|
||||
function bucketCardOf(preview: AssistantChangePreview | null): AtlasBucketCard | null {
|
||||
const card = preview?.card;
|
||||
return card?.kind === 'atlasBucket' ? card : null;
|
||||
}
|
||||
|
||||
/** A country change with the counters the write answered; the preview's prediction where it answered none. */
|
||||
function appliedCountry({ result, preview }: AppliedInput, region: 'mark' | 'unmark' | null): AtlasCountryCard | null {
|
||||
const card = countryCardOf(preview);
|
||||
const change = card?.change;
|
||||
if (!card || !change) return null;
|
||||
const after = recordOf(recordOf(result).after);
|
||||
if (Object.keys(after).length === 0) return card;
|
||||
const status: AtlasStatusAfter = statusOf(after.country_status) ?? 'none';
|
||||
const visited = countOf(after.countries_visited);
|
||||
const regionsLeft = after.regions_visited === undefined ? null : countOf(after.regions_visited);
|
||||
const regionAfter: AtlasStatusAfter = region === 'mark' ? 'visited' : 'none';
|
||||
return {
|
||||
...card,
|
||||
change: {
|
||||
...change,
|
||||
after: status,
|
||||
countries: change.countries ? { before: change.countries.before, after: visited } : null,
|
||||
region: change.region && region ? { ...change.region, after: regionAfter } : change.region,
|
||||
regions:
|
||||
change.regions && regionsLeft !== null ? { before: change.regions.before, after: regionsLeft } : change.regions,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** An added or changed wish as it was stored, with its id; the counter from the preview. */
|
||||
function appliedWish({ result, preview }: AppliedInput, op: 'add' | 'update'): AtlasBucketCard | null {
|
||||
const entry = entryOf(recordOf(result).item, { fromResult: true });
|
||||
if (!entry) return null;
|
||||
const change = bucketCardOf(preview)?.change ?? null;
|
||||
const wishes = change?.wishes ?? null;
|
||||
return {
|
||||
kind: 'atlasBucket',
|
||||
items: [entry],
|
||||
more: 0,
|
||||
change: {
|
||||
op,
|
||||
before: op === 'update' ? (change?.before ?? null) : null,
|
||||
duplicate: false,
|
||||
// The add landed, so the list grew by one whatever the preview foresaw.
|
||||
wishes: wishes && op === 'add' ? { before: wishes.before, after: wishes.before + 1 } : wishes,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const ATLAS_APPLIED = {
|
||||
mark_country_visited: (input: AppliedInput) => appliedCountry(input, null),
|
||||
unmark_country_visited: (input: AppliedInput) => appliedCountry(input, null),
|
||||
mark_region_visited: (input: AppliedInput) => appliedCountry(input, 'mark'),
|
||||
unmark_region_visited: (input: AppliedInput) => appliedCountry(input, 'unmark'),
|
||||
create_bucket_list_item: (input: AppliedInput) => appliedWish(input, 'add'),
|
||||
update_bucket_list_item: (input: AppliedInput) => appliedWish(input, 'update'),
|
||||
// A removed wish reads as the change card showed it.
|
||||
delete_bucket_list_item: ({ preview }: AppliedInput) => bucketCardOf(preview),
|
||||
};
|
||||
|
||||
const BUCKET_READ = { tool: 'list_bucket_list', args: {} };
|
||||
|
||||
/**
|
||||
* An added wish, still as it was added: on the list under its id with the
|
||||
* name, country, region, date and notes it was created with, and not reached
|
||||
* since. A wish edited or ticked off meanwhile is kept.
|
||||
*/
|
||||
function wishUnchanged(result: unknown, snapshot: UndoSnapshot): boolean {
|
||||
const added = recordOf(recordOf(result).item);
|
||||
const id = idIn(result, 'item');
|
||||
const items = recordOf(snapshot.reads.get(readKey(BUCKET_READ.tool, BUCKET_READ.args))).items;
|
||||
if (id === null || !Array.isArray(items)) return false;
|
||||
const current = items.map(recordOf).find((row) => row.id === id);
|
||||
if (!current || current.visited_at) return false;
|
||||
return (['name', 'country_code', 'region_code', 'target_date', 'notes', 'lat', 'lng'] as const).every(
|
||||
(key) => (current[key] ?? null) === (added[key] ?? null),
|
||||
);
|
||||
}
|
||||
|
||||
/** An added wish taken back by deleting it. */
|
||||
export const DELETE_ADDED_WISH: NonNullable<ToolCards['undo']> = {
|
||||
tool: 'delete_bucket_list_item',
|
||||
args: (_args, result) => {
|
||||
const itemId = idIn(result, 'item');
|
||||
return itemId ? { itemId } : null;
|
||||
},
|
||||
reads: () => [BUCKET_READ],
|
||||
unchanged: wishUnchanged,
|
||||
};
|
||||
@@ -0,0 +1,315 @@
|
||||
import type { CardReader, Described, PreviewContext, ToolArgs } from '../card-types';
|
||||
import { argId, finite } from '../card-values';
|
||||
import { localeOf, Rows, text } from '../preview-format';
|
||||
import {
|
||||
argText,
|
||||
atlasSnapshotOf,
|
||||
bucketSnapshotOf,
|
||||
countryCodeOf,
|
||||
entryOf,
|
||||
identityOf,
|
||||
regionCodeOf,
|
||||
regionsSnapshotOf,
|
||||
sameWish,
|
||||
targetDateOf,
|
||||
visitedRegionsIn,
|
||||
type AtlasSnapshot,
|
||||
type BucketSnapshot,
|
||||
type CountryStanding,
|
||||
type RegionEntry,
|
||||
} from './atlas-values';
|
||||
import type {
|
||||
AtlasBucketCard,
|
||||
AtlasBucketEntry,
|
||||
AtlasCountryCard,
|
||||
AtlasStatusAfter,
|
||||
AtlasTally,
|
||||
TrekCard,
|
||||
VisitStatus,
|
||||
} from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The change cards of the atlas writes, drawn before the user decides: the
|
||||
* country (or region) with its flag, where it stands now and after, and how
|
||||
* the visited counters move; a wish of the bucket list as it will be added,
|
||||
* changed or removed, and how long the list gets. A mark or a bucket change is
|
||||
* predicted exactly; an unmark of a country the user's trips put on the map,
|
||||
* and the unmark of a country's last region, stay `unknown` until applied,
|
||||
* since the trips decide (the applied card is exact). The subject names the
|
||||
* country in the user's language instead of its code.
|
||||
*/
|
||||
|
||||
/** What a change card is predicted from, read once per turn; null where a read failed. */
|
||||
export interface AtlasLoaded {
|
||||
atlas: AtlasSnapshot | null;
|
||||
regions: ReadonlyMap<string, RegionEntry> | null;
|
||||
bucket: BucketSnapshot | null;
|
||||
}
|
||||
|
||||
const NOTHING: AtlasLoaded = { atlas: null, regions: null, bucket: null };
|
||||
|
||||
export async function loadAtlas(_args: ToolArgs, read: CardReader): Promise<AtlasLoaded> {
|
||||
return { ...NOTHING, atlas: atlasSnapshotOf(await read('get_atlas_stats')) };
|
||||
}
|
||||
|
||||
export async function loadAtlasRegions(_args: ToolArgs, read: CardReader): Promise<AtlasLoaded> {
|
||||
const [atlas, regions] = await Promise.all([read('get_atlas_stats'), read('list_visited_regions')]);
|
||||
return { ...NOTHING, atlas: atlasSnapshotOf(atlas), regions: regionsSnapshotOf(regions) };
|
||||
}
|
||||
|
||||
export async function loadBucket(_args: ToolArgs, read: CardReader): Promise<AtlasLoaded> {
|
||||
return { ...NOTHING, bucket: bucketSnapshotOf(await read('list_bucket_list')) };
|
||||
}
|
||||
|
||||
/** A country's name in the user's language; its code where the runtime has none. */
|
||||
export function countryNameIn(ctx: Pick<PreviewContext, 'locale'>, code: string): string {
|
||||
try {
|
||||
return new Intl.DisplayNames([localeOf(ctx)], { type: 'region' }).of(code) ?? code;
|
||||
} catch {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
/** The empty country a change card starts from: no trips or places listed, the change carries the story. */
|
||||
function countryCard(code: string, entry: CountryStanding | undefined) {
|
||||
return {
|
||||
kind: 'atlasCountry' as const,
|
||||
code,
|
||||
status: entry?.status ?? null,
|
||||
markedBy: null,
|
||||
placeCount: entry?.placeCount ?? null,
|
||||
tripCount: entry?.tripCount ?? null,
|
||||
trips: [],
|
||||
moreTrips: 0,
|
||||
places: [],
|
||||
morePlaces: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function tally(before: number, after: number | null): AtlasTally {
|
||||
return { before, after: after === null ? null : Math.max(0, after) };
|
||||
}
|
||||
|
||||
function markCountry(args: ToolArgs, loaded: AtlasLoaded | null): AtlasCountryCard | null {
|
||||
const code = countryCodeOf(args.country_code);
|
||||
if (!code) return null;
|
||||
const atlas = loaded?.atlas ?? null;
|
||||
const entry = atlas?.countries.get(code);
|
||||
const countries = atlas ? tally(atlas.visited, atlas.visited + (entry?.status === 'visited' ? 0 : 1)) : null;
|
||||
return {
|
||||
...countryCard(code, entry),
|
||||
change: { op: 'mark', region: null, after: 'visited', countries, regions: null },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an unmarked country ends up. The tombstone hides what a mark or a
|
||||
* booking put on the map; a country with places of the user's trips stays,
|
||||
* at whatever status those trips give it.
|
||||
*/
|
||||
function unmarkCountry(args: ToolArgs, loaded: AtlasLoaded | null): AtlasCountryCard | null {
|
||||
const code = countryCodeOf(args.country_code);
|
||||
if (!code) return null;
|
||||
const atlas = loaded?.atlas ?? null;
|
||||
const entry = atlas?.countries.get(code);
|
||||
let after: AtlasStatusAfter = 'none';
|
||||
let visitedAfter: number | null = atlas?.visited ?? null;
|
||||
if (entry && entry.placeCount > 0) {
|
||||
after = entry.status === 'visited' ? 'unknown' : entry.status;
|
||||
if (entry.status === 'visited') visitedAfter = null;
|
||||
} else if (entry?.status === 'visited' && atlas) visitedAfter = atlas.visited - 1;
|
||||
return {
|
||||
...countryCard(code, entry),
|
||||
change: {
|
||||
op: 'unmark',
|
||||
region: null,
|
||||
after,
|
||||
countries: atlas ? tally(atlas.visited, visitedAfter) : null,
|
||||
regions: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function markRegion(args: ToolArgs, loaded: AtlasLoaded | null): AtlasCountryCard | null {
|
||||
const code = countryCodeOf(args.countryCode);
|
||||
const regionCode = regionCodeOf(args.regionCode);
|
||||
if (!code || !regionCode) return null;
|
||||
const country = markCountry({ country_code: code }, loaded);
|
||||
const regions = loaded?.regions ?? null;
|
||||
const before: VisitStatus | null = regions?.get(regionCode)?.status ?? null;
|
||||
const visited = regions ? visitedRegionsIn(regions, code) : null;
|
||||
if (!country?.change) return null;
|
||||
return {
|
||||
...country,
|
||||
change: {
|
||||
...country.change,
|
||||
region: { code: regionCode, name: argText(args.regionName, 80) ?? regionCode, before, after: 'visited' },
|
||||
regions: visited === null ? null : tally(visited, visited + (before === 'visited' ? 0 : 1)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The unmark of a region. Its country is read from the regions, else from the
|
||||
* code. Taking a country's last region away may take the country along
|
||||
* (unless it was marked itself), so the country's fate stays `unknown` then.
|
||||
*/
|
||||
function unmarkRegion(args: ToolArgs, loaded: AtlasLoaded | null): AtlasCountryCard | null {
|
||||
const regionCode = regionCodeOf(args.regionCode);
|
||||
if (!regionCode) return null;
|
||||
const regions = loaded?.regions ?? null;
|
||||
const known = regions?.get(regionCode);
|
||||
const code = known?.country ?? regionCode.slice(0, 2);
|
||||
const atlas = loaded?.atlas ?? null;
|
||||
const entry = atlas?.countries.get(code);
|
||||
const visited = regions ? visitedRegionsIn(regions, code) : null;
|
||||
const left = visited === null ? null : visited - (known?.status === 'visited' ? 1 : 0);
|
||||
const cascades = left === null || left <= 0;
|
||||
const after: AtlasStatusAfter = cascades ? 'unknown' : (entry?.status ?? 'none');
|
||||
const countries = atlas ? tally(atlas.visited, cascades ? null : atlas.visited) : null;
|
||||
return {
|
||||
...countryCard(code, entry),
|
||||
change: {
|
||||
op: 'unmark',
|
||||
region: { code: regionCode, name: known?.name ?? regionCode, before: known?.status ?? null, after: 'none' },
|
||||
after,
|
||||
countries,
|
||||
regions: visited === null ? null : tally(visited, left),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function bucketCard(entry: AtlasBucketEntry, change: NonNullable<AtlasBucketCard['change']>): AtlasBucketCard {
|
||||
return { kind: 'atlasBucket', items: [entry], more: 0, change };
|
||||
}
|
||||
|
||||
function createWish(args: ToolArgs, loaded: AtlasLoaded | null): AtlasBucketCard | null {
|
||||
const entry = entryOf({ ...args, id: null, visited_at: null }, { fromResult: false });
|
||||
if (!entry) return null;
|
||||
const bucket = loaded?.bucket ?? null;
|
||||
const identity = identityOf(args, { upperCountry: true });
|
||||
const duplicate = bucket ? [...bucket.wishes.values()].some((wish) => sameWish(wish.identity, identity)) : false;
|
||||
const wishes = bucket ? tally(bucket.size, bucket.size + (duplicate ? 0 : 1)) : null;
|
||||
return bucketCard(entry, { op: 'add', before: null, duplicate, wishes });
|
||||
}
|
||||
|
||||
/** The wish as it will read once changed: only the fields the call sets move. */
|
||||
function changedWish(args: ToolArgs, before: AtlasBucketEntry): AtlasBucketEntry {
|
||||
const countryCode = args.country_code !== undefined ? countryCodeOf(args.country_code) : before.countryCode;
|
||||
const region = before.regionCode;
|
||||
return {
|
||||
...before,
|
||||
name: argText(args.name, 120) ?? before.name,
|
||||
notes: args.notes !== undefined ? argText(args.notes, 200) : before.notes,
|
||||
countryCode,
|
||||
regionCode: region && countryCode && region.startsWith(`${countryCode}-`) ? region : null,
|
||||
targetDate: args.target_date !== undefined ? targetDateOf(args.target_date) : before.targetDate,
|
||||
};
|
||||
}
|
||||
|
||||
function updateWish(args: ToolArgs, loaded: AtlasLoaded | null): AtlasBucketCard | null {
|
||||
const id = argId(args, 'itemId');
|
||||
const bucket = loaded?.bucket ?? null;
|
||||
const current = id === null ? undefined : bucket?.wishes.get(id);
|
||||
if (!bucket || !current) return null;
|
||||
// A blank name leaves the stored one, as the service's `name?.trim() || item.name` does.
|
||||
const named = typeof args.name === 'string' && args.name.trim() !== '' ? args.name : null;
|
||||
const next = identityOf({
|
||||
name: named ?? current.identity.name,
|
||||
country_code: args.country_code !== undefined ? args.country_code : current.identity.country,
|
||||
target_date: args.target_date !== undefined ? args.target_date : current.identity.target,
|
||||
lat: args.lat !== undefined ? finite(args.lat) : current.identity.lat,
|
||||
lng: args.lng !== undefined ? finite(args.lng) : current.identity.lng,
|
||||
});
|
||||
const duplicate = [...bucket.wishes].some(([other, wish]) => other !== id && sameWish(wish.identity, next));
|
||||
return bucketCard(changedWish(args, current.entry), { op: 'update', before: current.entry, duplicate, wishes: null });
|
||||
}
|
||||
|
||||
function deleteWish(args: ToolArgs, loaded: AtlasLoaded | null): AtlasBucketCard | null {
|
||||
const id = argId(args, 'itemId');
|
||||
const bucket = loaded?.bucket ?? null;
|
||||
const current = id === null ? undefined : bucket?.wishes.get(id);
|
||||
if (!bucket || !current) return null;
|
||||
return bucketCard(current.entry, {
|
||||
op: 'remove',
|
||||
before: current.entry,
|
||||
duplicate: false,
|
||||
wishes: tally(bucket.size, bucket.size - 1),
|
||||
});
|
||||
}
|
||||
|
||||
type ChangeBuilder = (ctx: PreviewContext, args: ToolArgs, loaded: AtlasLoaded | null) => TrekCard | null;
|
||||
|
||||
/** The change cards by tool. */
|
||||
export const ATLAS_CHANGES = {
|
||||
mark_country_visited: (_ctx, args, loaded) => markCountry(args, loaded),
|
||||
unmark_country_visited: (_ctx, args, loaded) => unmarkCountry(args, loaded),
|
||||
mark_region_visited: (_ctx, args, loaded) => markRegion(args, loaded),
|
||||
unmark_region_visited: (_ctx, args, loaded) => unmarkRegion(args, loaded),
|
||||
create_bucket_list_item: (_ctx, args, loaded) => createWish(args, loaded),
|
||||
update_bucket_list_item: (_ctx, args, loaded) => updateWish(args, loaded),
|
||||
delete_bucket_list_item: (_ctx, args, loaded) => deleteWish(args, loaded),
|
||||
} satisfies Readonly<Record<string, ChangeBuilder>>;
|
||||
|
||||
/** The words of a country change: the country by its name, under the tool's own label. */
|
||||
function describeCountry(tool: string, ctx: PreviewContext, args: ToolArgs): Described {
|
||||
const code = countryCodeOf(args.country_code);
|
||||
return {
|
||||
subject: code ? countryNameIn(ctx, code) : text(args.country_code),
|
||||
rows: new Rows(),
|
||||
dayId: null,
|
||||
placeIds: [],
|
||||
tool,
|
||||
domain: 'atlas',
|
||||
};
|
||||
}
|
||||
|
||||
function describeRegion(tool: string, ctx: PreviewContext, args: ToolArgs, loaded: AtlasLoaded | null): Described {
|
||||
const regionCode = regionCodeOf(args.regionCode);
|
||||
const known = regionCode ? loaded?.regions?.get(regionCode) : undefined;
|
||||
const country = countryCodeOf(args.countryCode) ?? known?.country ?? (regionCode ? regionCode.slice(0, 2) : null);
|
||||
const name = argText(args.regionName, 80) ?? known?.name ?? regionCode ?? text(args.regionCode);
|
||||
return {
|
||||
subject: name,
|
||||
rows: new Rows().add('location', country ? countryNameIn(ctx, country) : ''),
|
||||
dayId: null,
|
||||
placeIds: [],
|
||||
tool,
|
||||
domain: 'atlas',
|
||||
};
|
||||
}
|
||||
|
||||
function describeWish(tool: string, ctx: PreviewContext, args: ToolArgs, loaded: AtlasLoaded | null): Described {
|
||||
const id = argId(args, 'itemId');
|
||||
const before = id === null ? undefined : loaded?.bucket?.wishes.get(id)?.entry;
|
||||
const country = countryCodeOf(args.country_code);
|
||||
const rows = new Rows();
|
||||
if (tool === 'update_bucket_list_item') {
|
||||
if (typeof args.name === 'string') rows.change('name', before?.name ?? null, text(args.name));
|
||||
if (args.notes !== undefined) rows.change('notes', before?.notes ?? null, text(args.notes));
|
||||
} else if (tool === 'create_bucket_list_item') {
|
||||
rows.add('location', country ? countryNameIn(ctx, country) : '').add('notes', text(args.notes));
|
||||
}
|
||||
const named = tool === 'delete_bucket_list_item' ? null : argText(args.name, 120);
|
||||
return {
|
||||
subject: named ?? before?.name ?? (id === null ? '' : `#${id}`),
|
||||
rows,
|
||||
dayId: null,
|
||||
placeIds: [],
|
||||
tool,
|
||||
domain: 'atlas',
|
||||
};
|
||||
}
|
||||
|
||||
type Describer = (ctx: PreviewContext, args: ToolArgs, loaded: AtlasLoaded | null) => Described;
|
||||
|
||||
/** The describers by tool; each one gives a card for empty arguments too. */
|
||||
export const ATLAS_DESCRIBERS = {
|
||||
mark_country_visited: (ctx, args) => describeCountry('mark_country_visited', ctx, args),
|
||||
unmark_country_visited: (ctx, args) => describeCountry('unmark_country_visited', ctx, args),
|
||||
mark_region_visited: (ctx, args, loaded) => describeRegion('mark_region_visited', ctx, args, loaded),
|
||||
unmark_region_visited: (ctx, args, loaded) => describeRegion('unmark_region_visited', ctx, args, loaded),
|
||||
create_bucket_list_item: (ctx, args, loaded) => describeWish('create_bucket_list_item', ctx, args, loaded),
|
||||
update_bucket_list_item: (ctx, args, loaded) => describeWish('update_bucket_list_item', ctx, args, loaded),
|
||||
delete_bucket_list_item: (ctx, args, loaded) => describeWish('delete_bucket_list_item', ctx, args, loaded),
|
||||
} satisfies Readonly<Record<string, Describer>>;
|
||||
@@ -0,0 +1,201 @@
|
||||
import { blockText, clipped, finite, recordOf } from '../card-values';
|
||||
import type { AtlasBucketEntry, VisitStatus } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The readers the atlas cards are built with: codes upper-cased and checked,
|
||||
* counts kept whole and in bounds, a bucket-list row as the card shows it,
|
||||
* and the snapshots a change card is predicted from. A wish's coordinates
|
||||
* stay in the snapshot (they decide whether an add is a duplicate) and never
|
||||
* reach a card.
|
||||
*/
|
||||
|
||||
const COUNTRY = /^[A-Z]{2}$/;
|
||||
const REGION = /^[A-Z]{2}-[A-Z0-9]{1,8}$/;
|
||||
const ISO_DAY = /^\d{4}-\d{2}-\d{2}/;
|
||||
const TARGET = /^\d{4}(-\d{2}(-\d{2})?)?$/;
|
||||
const MAX_COUNT = 1_000_000;
|
||||
|
||||
/** A country code as a card holds it: ISO alpha-2 in upper case, else null. */
|
||||
export function countryCodeOf(value: unknown): string | null {
|
||||
const code = typeof value === 'string' ? value.trim().toUpperCase() : '';
|
||||
return COUNTRY.test(code) ? code : null;
|
||||
}
|
||||
|
||||
/** A region code as a card holds it: ISO 3166-2 in upper case, else null. */
|
||||
export function regionCodeOf(value: unknown): string | null {
|
||||
const code = typeof value === 'string' ? value.trim().toUpperCase() : '';
|
||||
return REGION.test(code) ? code : null;
|
||||
}
|
||||
|
||||
/** The day of a date or a timestamp as `YYYY-MM-DD`, else null. */
|
||||
export function isoDayOf(value: unknown): string | null {
|
||||
return typeof value === 'string' && ISO_DAY.test(value) ? value.slice(0, 10) : null;
|
||||
}
|
||||
|
||||
/** A count as a card holds it: a whole number from 0 up to its bound; anything else counts 0. */
|
||||
export function countOf(value: unknown): number {
|
||||
const n = finite(value);
|
||||
return n === null || n < 0 ? 0 : Math.min(Math.round(n), MAX_COUNT);
|
||||
}
|
||||
|
||||
export function statusOf(value: unknown): VisitStatus | null {
|
||||
return value === 'visited' || value === 'planned' || value === 'idea' ? value : null;
|
||||
}
|
||||
|
||||
/** An argument's text as it will be stored: on one line and cut, not masked. */
|
||||
export function argText(value: unknown, max: number): string | null {
|
||||
return typeof value === 'string' ? blockText(value.replace(/\s+/g, ' '), max, { mask: false }) : null;
|
||||
}
|
||||
|
||||
/** When a wish is planned for, a year, a month or a day; anything else is none. */
|
||||
export function targetDateOf(value: unknown): string | null {
|
||||
return typeof value === 'string' && TARGET.test(value) ? value : null;
|
||||
}
|
||||
|
||||
function positiveId(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A wish as a card shows it, from a bucket-list row (the tool's answer, its
|
||||
* text masked) or from the arguments of an add (cut only). Null without a name.
|
||||
*/
|
||||
export function entryOf(row: unknown, { fromResult }: { fromResult: boolean }): AtlasBucketEntry | null {
|
||||
const r = recordOf(row);
|
||||
const text = (value: unknown, max: number) => (fromResult ? clipped(value, max) : argText(value, max));
|
||||
const name = text(r.name, 120);
|
||||
if (!name) return null;
|
||||
const countryCode = countryCodeOf(r.country_code);
|
||||
const region = regionCodeOf(r.region_code);
|
||||
const reachedOn = isoDayOf(r.visited_at);
|
||||
const source = r.visited_source;
|
||||
return {
|
||||
id: positiveId(r.id),
|
||||
name,
|
||||
countryCode,
|
||||
// A region belongs to its own country, the rule the service keeps too.
|
||||
regionCode: region && countryCode && region.startsWith(`${countryCode}-`) ? region : null,
|
||||
targetDate: targetDateOf(r.target_date),
|
||||
notes: text(r.notes, 200),
|
||||
reachedOn,
|
||||
reachedFrom: reachedOn && (source === 'manual' || source === 'dawarich') ? source : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Where one country stands on the atlas map. */
|
||||
export interface CountryStanding {
|
||||
status: VisitStatus;
|
||||
placeCount: number;
|
||||
tripCount: number;
|
||||
}
|
||||
|
||||
/** The atlas as a change is predicted from: visited countries and each country's standing. */
|
||||
export interface AtlasSnapshot {
|
||||
visited: number;
|
||||
countries: ReadonlyMap<string, CountryStanding>;
|
||||
}
|
||||
|
||||
/** `get_atlas_stats` read as a snapshot; null when it is not an atlas. */
|
||||
export function atlasSnapshotOf(raw: unknown): AtlasSnapshot | null {
|
||||
const stats = recordOf(recordOf(raw).stats);
|
||||
if (!Array.isArray(stats.countries)) return null;
|
||||
const countries = new Map<string, CountryStanding>();
|
||||
for (const row of stats.countries) {
|
||||
const r = recordOf(row);
|
||||
const code = countryCodeOf(r.code);
|
||||
// A user without trips has only countries marked by hand, which carry no status: they are visited.
|
||||
if (code) {
|
||||
countries.set(code, {
|
||||
status: statusOf(r.status) ?? 'visited',
|
||||
placeCount: countOf(r.placeCount),
|
||||
tripCount: countOf(r.tripCount),
|
||||
});
|
||||
}
|
||||
}
|
||||
return { visited: countOf(recordOf(stats.stats).totalCountries), countries };
|
||||
}
|
||||
|
||||
/** One region of the atlas map. */
|
||||
export interface RegionEntry {
|
||||
name: string;
|
||||
country: string;
|
||||
status: VisitStatus;
|
||||
}
|
||||
|
||||
/** `list_visited_regions` read as regions by code; null when it is not a region list. */
|
||||
export function regionsSnapshotOf(raw: unknown): ReadonlyMap<string, RegionEntry> | null {
|
||||
const grouped = recordOf(raw).regions;
|
||||
if (!grouped || typeof grouped !== 'object') return null;
|
||||
const regions = new Map<string, RegionEntry>();
|
||||
for (const [country, list] of Object.entries(recordOf(grouped))) {
|
||||
const countryCode = countryCodeOf(country);
|
||||
if (!countryCode || !Array.isArray(list)) continue;
|
||||
for (const row of list) {
|
||||
const r = recordOf(row);
|
||||
const code = regionCodeOf(r.code);
|
||||
const status = statusOf(r.status);
|
||||
if (code && status) regions.set(code, { name: clipped(r.name, 80) ?? code, country: countryCode, status });
|
||||
}
|
||||
}
|
||||
return regions;
|
||||
}
|
||||
|
||||
/** Visited regions of one country in a snapshot. */
|
||||
export function visitedRegionsIn(regions: ReadonlyMap<string, RegionEntry>, country: string): number {
|
||||
return [...regions.values()].filter((region) => region.country === country && region.status === 'visited').length;
|
||||
}
|
||||
|
||||
/** What makes two wishes the same wish (#1898): the name, the country, the target date and the spot. */
|
||||
interface WishIdentity {
|
||||
name: string;
|
||||
country: string | null;
|
||||
target: string | null;
|
||||
lat: number | null;
|
||||
lng: number | null;
|
||||
}
|
||||
|
||||
/** An empty value and none are the same "not set", as the service reads them (it does not trim). */
|
||||
function blankToNull(value: unknown): string | null {
|
||||
return typeof value === 'string' && value !== '' ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A wish's identity from raw values, the way the service compares them: the
|
||||
* name trimmed and folded to ASCII lower case (lower() in SQLite folds ASCII
|
||||
* only), the country exactly as it will be stored (`country_code IS ?`
|
||||
* compares it as is). Only create_bucket_list_item upper-cases its country
|
||||
* (its zod schema does), so only an add asks for `upperCountry`.
|
||||
*/
|
||||
export function identityOf(row: unknown, { upperCountry = false }: { upperCountry?: boolean } = {}): WishIdentity {
|
||||
const r = recordOf(row);
|
||||
const country = blankToNull(r.country_code);
|
||||
return {
|
||||
name: (typeof r.name === 'string' ? r.name.trim() : '').replace(/[A-Z]/g, (c) => c.toLowerCase()),
|
||||
country: country !== null && upperCountry ? country.toUpperCase() : country,
|
||||
target: blankToNull(r.target_date),
|
||||
lat: finite(r.lat),
|
||||
lng: finite(r.lng),
|
||||
};
|
||||
}
|
||||
|
||||
export function sameWish(a: WishIdentity, b: WishIdentity): boolean {
|
||||
return a.name === b.name && a.country === b.country && a.target === b.target && a.lat === b.lat && a.lng === b.lng;
|
||||
}
|
||||
|
||||
/** The bucket list as a change is predicted from: how many wishes, each with its identity. */
|
||||
export interface BucketSnapshot {
|
||||
size: number;
|
||||
wishes: ReadonlyMap<number, { entry: AtlasBucketEntry; identity: WishIdentity }>;
|
||||
}
|
||||
|
||||
/** `list_bucket_list` read as a snapshot; null when it is not a bucket list. */
|
||||
export function bucketSnapshotOf(raw: unknown): BucketSnapshot | null {
|
||||
const items = recordOf(raw).items;
|
||||
if (!Array.isArray(items)) return null;
|
||||
const wishes = new Map<number, { entry: AtlasBucketEntry; identity: WishIdentity }>();
|
||||
for (const row of items) {
|
||||
const entry = entryOf(row, { fromResult: true });
|
||||
if (entry?.id) wishes.set(entry.id, { entry, identity: identityOf(row) });
|
||||
}
|
||||
return { size: items.length, wishes };
|
||||
}
|
||||
@@ -1,4 +1,101 @@
|
||||
import type { DomainCards } from '../card-types';
|
||||
import type { DomainCards, ToolCards } from '../card-types';
|
||||
import { ATLAS_ANSWERS } from './atlas-answers';
|
||||
import { ATLAS_APPLIED, DELETE_ADDED_WISH } from './atlas-applied';
|
||||
import {
|
||||
ATLAS_CHANGES,
|
||||
ATLAS_DESCRIBERS,
|
||||
loadAtlas,
|
||||
loadAtlasRegions,
|
||||
loadBucket,
|
||||
type AtlasLoaded,
|
||||
} from './atlas-changes';
|
||||
|
||||
/** The cards of the atlas: countries, regions and the bucket list. None yet: this package fills them in. */
|
||||
export const ATLAS_CARDS: DomainCards = { id: 'atlas', tools: {} };
|
||||
/**
|
||||
* The atlas in the assistant chat: the passport of the user's travels, one
|
||||
* country with its trips and places, the bucket list as wish tiles, and the
|
||||
* changes to them drawn with their flag and the counters they move. Atlas
|
||||
* rows belong to the user, not to the trip, so nothing here lands on the
|
||||
* trip's map; a receipt opens the Atlas instead. Only an added wish can be
|
||||
* taken back: an unmark hides a country and may take regions along, which no
|
||||
* mark restores.
|
||||
*/
|
||||
|
||||
const ATLAS_WRITE = { entity: 'atlas' } as const;
|
||||
|
||||
const markCountry: ToolCards<AtlasLoaded> = {
|
||||
write: { ...ATLAS_WRITE, verbKey: 'genericCreate' },
|
||||
load: loadAtlas,
|
||||
describe: ATLAS_DESCRIBERS.mark_country_visited,
|
||||
change: ATLAS_CHANGES.mark_country_visited,
|
||||
applied: ATLAS_APPLIED.mark_country_visited,
|
||||
};
|
||||
|
||||
const unmarkCountry: ToolCards<AtlasLoaded> = {
|
||||
write: { ...ATLAS_WRITE, verbKey: 'genericRemove' },
|
||||
load: loadAtlas,
|
||||
describe: ATLAS_DESCRIBERS.unmark_country_visited,
|
||||
change: ATLAS_CHANGES.unmark_country_visited,
|
||||
applied: ATLAS_APPLIED.unmark_country_visited,
|
||||
};
|
||||
|
||||
const markRegion: ToolCards<AtlasLoaded> = {
|
||||
write: { ...ATLAS_WRITE, verbKey: 'genericCreate' },
|
||||
load: loadAtlasRegions,
|
||||
describe: ATLAS_DESCRIBERS.mark_region_visited,
|
||||
change: ATLAS_CHANGES.mark_region_visited,
|
||||
applied: ATLAS_APPLIED.mark_region_visited,
|
||||
};
|
||||
|
||||
const unmarkRegion: ToolCards<AtlasLoaded> = {
|
||||
write: { ...ATLAS_WRITE, verbKey: 'genericRemove' },
|
||||
load: loadAtlasRegions,
|
||||
describe: ATLAS_DESCRIBERS.unmark_region_visited,
|
||||
change: ATLAS_CHANGES.unmark_region_visited,
|
||||
applied: ATLAS_APPLIED.unmark_region_visited,
|
||||
};
|
||||
|
||||
const createWish: ToolCards<AtlasLoaded> = {
|
||||
write: { ...ATLAS_WRITE, verbKey: 'genericCreate' },
|
||||
load: loadBucket,
|
||||
describe: ATLAS_DESCRIBERS.create_bucket_list_item,
|
||||
change: ATLAS_CHANGES.create_bucket_list_item,
|
||||
applied: ATLAS_APPLIED.create_bucket_list_item,
|
||||
undo: DELETE_ADDED_WISH,
|
||||
};
|
||||
|
||||
const updateWish: ToolCards<AtlasLoaded> = {
|
||||
write: { ...ATLAS_WRITE, verbKey: 'genericUpdate' },
|
||||
load: loadBucket,
|
||||
describe: ATLAS_DESCRIBERS.update_bucket_list_item,
|
||||
change: ATLAS_CHANGES.update_bucket_list_item,
|
||||
applied: ATLAS_APPLIED.update_bucket_list_item,
|
||||
};
|
||||
|
||||
const deleteWish: ToolCards<AtlasLoaded> = {
|
||||
write: { ...ATLAS_WRITE, verbKey: 'genericRemove' },
|
||||
load: loadBucket,
|
||||
describe: ATLAS_DESCRIBERS.delete_bucket_list_item,
|
||||
change: ATLAS_CHANGES.delete_bucket_list_item,
|
||||
applied: ATLAS_APPLIED.delete_bucket_list_item,
|
||||
};
|
||||
|
||||
export const ATLAS_CARDS: DomainCards = {
|
||||
id: 'atlas',
|
||||
tools: {
|
||||
get_atlas_stats: { answer: ATLAS_ANSWERS.get_atlas_stats },
|
||||
get_country_atlas_places: { answer: ATLAS_ANSWERS.get_country_atlas_places },
|
||||
list_bucket_list: { answer: ATLAS_ANSWERS.list_bucket_list },
|
||||
mark_country_visited: markCountry,
|
||||
unmark_country_visited: unmarkCountry,
|
||||
mark_region_visited: markRegion,
|
||||
unmark_region_visited: unmarkRegion,
|
||||
create_bucket_list_item: createWish,
|
||||
update_bucket_list_item: updateWish,
|
||||
delete_bucket_list_item: deleteWish,
|
||||
},
|
||||
prompt: [
|
||||
'Atlas reads and changes are drawn as cards (flags, counts, the bucket list as tiles): answer in a sentence or two instead of listing them again.',
|
||||
'update_bucket_list_item and delete_bucket_list_item take the ids list_bucket_list gives; read it first when you have none.',
|
||||
],
|
||||
foreignIdGroups: ['atlas'],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { logWarn } from '../audit/audit-log.logger';
|
||||
import type { AtlasService } from './atlas.service';
|
||||
import type { VisitStatus } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* Where the atlas stands after a write, as the MCP mark tools answer it: the
|
||||
* country's own status the way the Atlas map paints it (null once it is off
|
||||
* the map) and how many countries count as visited, plus the visited regions
|
||||
* of that country for the region tools. Read through `stats()` and
|
||||
* `visitedRegions()`, the same reads the Atlas page makes, so the assistant's
|
||||
* "that makes 24 countries" and the map agree. REST answers stay as they were.
|
||||
*/
|
||||
interface AtlasStanding {
|
||||
country_status: VisitStatus | null;
|
||||
countries_visited: number;
|
||||
}
|
||||
|
||||
export async function standingOf(
|
||||
atlas: Pick<AtlasService, 'stats'>,
|
||||
userId: number,
|
||||
code: string,
|
||||
): Promise<AtlasStanding> {
|
||||
const stats = await atlas.stats(userId);
|
||||
// A user without trips only has the countries marked by hand, which carry no status: they are visited.
|
||||
const countries: ReadonlyArray<{ code: string; status?: VisitStatus }> = stats.countries;
|
||||
const entry = countries.find((country) => country.code === code);
|
||||
return { country_status: entry ? (entry.status ?? 'visited') : null, countries_visited: stats.stats.totalCountries };
|
||||
}
|
||||
|
||||
/** The visited regions of one country, as the map paints them. */
|
||||
export async function regionsVisitedOf(
|
||||
atlas: Pick<AtlasService, 'visitedRegions'>,
|
||||
userId: number,
|
||||
countryCode: string,
|
||||
): Promise<number> {
|
||||
const { regions } = await atlas.visitedRegions(userId);
|
||||
return (regions[countryCode] ?? []).filter((region) => region.status === 'visited').length;
|
||||
}
|
||||
|
||||
/**
|
||||
* A read made after a write that already landed and was broadcast: when it
|
||||
* fails, the answer leaves `after` out (the card falls back to what it held)
|
||||
* instead of turning a done write into a tool error the model would retry.
|
||||
*/
|
||||
export async function readAfterWrite<T>(read: () => Promise<T>): Promise<T | undefined> {
|
||||
try {
|
||||
return await read();
|
||||
} catch (err) {
|
||||
logWarn(`[atlas] reading the atlas back after a write failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '../../nest-mcp';
|
||||
import { addonGate } from '../addons/addon-gate';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { readAfterWrite, regionsVisitedOf, standingOf } from './atlas-standing.helpers';
|
||||
import { AtlasWritesService } from './atlas-writes.service';
|
||||
import { AtlasService, BucketItemExistsError } from './atlas.service';
|
||||
import { idSchema } from '@trek/shared';
|
||||
@@ -64,6 +65,12 @@ function jsonContent(uri: string, data: unknown) {
|
||||
* merged shape GET /regions has always returned instead of the raw manual
|
||||
* rows; and get_atlas_stats carries the dashboard passport figures from
|
||||
* GET /api/auth/travel-stats next to the atlas payload.
|
||||
*
|
||||
* For the assistant's cards the four mark tools also answer `after`: where
|
||||
* the country stands once the write ran and how many countries (and, for a
|
||||
* region, regions of that country) count as visited now (atlas-standing.helpers.ts;
|
||||
* a failed read leaves `after` out, the write stands), and list_bucket_list answers what GET /bucket-list does, the only way to
|
||||
* the ids update_bucket_list_item and delete_bucket_list_item take.
|
||||
*/
|
||||
@McpController()
|
||||
export class AtlasMcp {
|
||||
@@ -75,6 +82,21 @@ export class AtlasMcp {
|
||||
|
||||
// ── Bucket list ─────────────────────────────────────────────────────────
|
||||
|
||||
@Tool({
|
||||
name: 'list_bucket_list',
|
||||
description:
|
||||
'List your travel bucket list: each wish with its id, name, country and region, target date, notes and when it was reached. The ids are what update_bucket_list_item and delete_bucket_list_item take.',
|
||||
inputSchema: {},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: atlasAddonOn,
|
||||
access: { group: 'atlas', mode: 'read' },
|
||||
})
|
||||
async listBucketList(_args: Record<string, never>, ctx: McpContext) {
|
||||
// The same rows GET /bucket-list answers with, under the same `items` key.
|
||||
const items = await this.atlas.bucketList(ctx.userId);
|
||||
return ok({ items });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'create_bucket_list_item',
|
||||
description: 'Add a destination to your personal travel bucket list.',
|
||||
@@ -171,8 +193,10 @@ export class AtlasMcp {
|
||||
access: { group: 'atlas', mode: 'write' },
|
||||
})
|
||||
async markCountryVisited({ country_code }: { country_code: string }, ctx: McpContext) {
|
||||
await this.writes.markCountry(ctx.userId, country_code.toUpperCase());
|
||||
return ok({ success: true, country_code: country_code.toUpperCase() });
|
||||
const code = country_code.toUpperCase();
|
||||
await this.writes.markCountry(ctx.userId, code);
|
||||
const after = await readAfterWrite(() => standingOf(this.atlas, ctx.userId, code));
|
||||
return ok({ success: true, country_code: code, after });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
@@ -186,8 +210,10 @@ export class AtlasMcp {
|
||||
access: { group: 'atlas', mode: 'write' },
|
||||
})
|
||||
async unmarkCountryVisited({ country_code }: { country_code: string }, ctx: McpContext) {
|
||||
await this.writes.unmarkCountry(ctx.userId, country_code.toUpperCase());
|
||||
return ok({ success: true, country_code: country_code.toUpperCase() });
|
||||
const code = country_code.toUpperCase();
|
||||
await this.writes.unmarkCountry(ctx.userId, code);
|
||||
const after = await readAfterWrite(() => standingOf(this.atlas, ctx.userId, code));
|
||||
return ok({ success: true, country_code: code, after });
|
||||
}
|
||||
|
||||
// ── Atlas expanded ──────────────────────────────────────────────────────
|
||||
@@ -276,13 +302,14 @@ export class AtlasMcp {
|
||||
// (the legacy registrar passed them through verbatim, so a lowercase mark
|
||||
// created a row REST's uppercased unmark could never hit).
|
||||
const upperRegion = regionCode.toUpperCase();
|
||||
await this.writes.markRegion(ctx.userId, upperRegion, regionName, countryCode.toUpperCase());
|
||||
const upperCountry = countryCode.toUpperCase();
|
||||
await this.writes.markRegion(ctx.userId, upperRegion, regionName, upperCountry);
|
||||
const row = (await this.atlas.listManuallyVisitedRegions(ctx.userId)).find((r) => r.region_code === upperRegion);
|
||||
// Echo in the client-facing shape ({ code, name, ... }) rather than raw DB columns.
|
||||
const region = row
|
||||
? { code: row.region_code, name: row.region_name, country_code: row.country_code, manuallyMarked: true }
|
||||
: undefined;
|
||||
return ok({ region });
|
||||
return ok({ region, after: await readAfterWrite(() => this.regionStanding(ctx.userId, upperCountry)) });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
@@ -298,8 +325,19 @@ export class AtlasMcp {
|
||||
})
|
||||
async unmarkRegionVisited({ regionCode }: { regionCode: string }, ctx: McpContext) {
|
||||
// Post-fold quirk fix: uppercase, matching the REST controller.
|
||||
await this.writes.unmarkRegion(ctx.userId, regionCode.toUpperCase());
|
||||
return ok({ success: true });
|
||||
const code = regionCode.toUpperCase();
|
||||
// The country is read before the unmark, which may take its last manual row
|
||||
// along; a region derived from places carries its country in the code.
|
||||
const marked = (await this.atlas.listManuallyVisitedRegions(ctx.userId)).find((r) => r.region_code === code);
|
||||
const countryCode = marked?.country_code ?? code.split('-')[0] ?? '';
|
||||
await this.writes.unmarkRegion(ctx.userId, code);
|
||||
return ok({ success: true, after: await readAfterWrite(() => this.regionStanding(ctx.userId, countryCode)) });
|
||||
}
|
||||
|
||||
/** Where a region's country stands after a region write, with that country's visited regions. */
|
||||
private async regionStanding(userId: number, countryCode: string) {
|
||||
const standing = await standingOf(this.atlas, userId, countryCode);
|
||||
return { ...standing, regions_visited: await regionsVisitedOf(this.atlas, userId, countryCode) };
|
||||
}
|
||||
|
||||
@Tool({
|
||||
|
||||
@@ -268,7 +268,11 @@ describe('Tool: list_visited_regions', () => {
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const unmark = await h.client.callTool({ name: 'unmark_region_visited', arguments: { regionCode: 'IT-62' } });
|
||||
expect(parseToolResult(unmark)).toEqual({ success: true });
|
||||
// The places of the trip keep Italy on the map; only the region is gone.
|
||||
expect(parseToolResult(unmark)).toEqual({
|
||||
success: true,
|
||||
after: { country_status: 'visited', countries_visited: 1, regions_visited: 0 },
|
||||
});
|
||||
const tombstone = await findRow(orm, HiddenRegions, { user: user.id, region_code: 'IT-62' });
|
||||
expect(tombstone).toBeTruthy();
|
||||
|
||||
@@ -369,7 +373,7 @@ describe('Tool: mark_region_visited', () => {
|
||||
name: 'unmark_region_visited',
|
||||
arguments: { regionCode: 'jp-13' },
|
||||
});
|
||||
expect(parseToolResult(unmark)).toEqual({ success: true });
|
||||
expect(parseToolResult(unmark)).toMatchObject({ success: true });
|
||||
expect(await findRow(orm, VisitedRegions, { user: user.id })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -388,6 +392,8 @@ describe('Tool: mark_region_visited', () => {
|
||||
expect(data.region.name).toBe('California');
|
||||
expect(data.region.country_code).toBe('US');
|
||||
expect(data.region.manuallyMarked).toBe(true);
|
||||
// Where the atlas stands now, for the assistant's card: the region brought its country along.
|
||||
expect(data.after).toEqual({ country_status: 'visited', countries_visited: 1, regions_visited: 1 });
|
||||
const row = await findRow(orm, VisitedRegions, { user: user.id, region_code: 'US-CA' });
|
||||
expect(row).toBeTruthy();
|
||||
});
|
||||
@@ -419,8 +425,11 @@ describe('Tool: unmark_region_visited', () => {
|
||||
name: 'unmark_region_visited',
|
||||
arguments: { regionCode: 'IT-LO' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
// The last region of a country it was never marked for takes the country along.
|
||||
expect(parseToolResult(result)).toEqual({
|
||||
success: true,
|
||||
after: { country_status: null, countries_visited: 0, regions_visited: 0 },
|
||||
});
|
||||
expect(await findRow(orm, VisitedRegions, { user: user.id, region_code: 'IT-LO' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/**
|
||||
* Unit tests for MCP atlas and bucket list tools:
|
||||
* mark_country_visited, unmark_country_visited, create_bucket_list_item, delete_bucket_list_item.
|
||||
* mark_country_visited, unmark_country_visited, create_bucket_list_item, delete_bucket_list_item,
|
||||
* list_bucket_list.
|
||||
*/
|
||||
import { db as testDb } from '../../../src/db/database';
|
||||
import { BucketList } from '../../../src/db/entities/BucketList.entity';
|
||||
import { VisitedCountries } from '../../../src/db/entities/VisitedCountries.entity';
|
||||
import { AtlasService } from '../../../src/nest/atlas/atlas.service';
|
||||
import { createUser, createBucketListItem, createVisitedCountry } from '../../helpers/factories';
|
||||
import { countRows, findRow } from '../../helpers/factories/rows';
|
||||
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
@@ -55,11 +57,28 @@ describe('Tool: mark_country_visited', () => {
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.country_code).toBe('FR');
|
||||
// Where the atlas stands now, which the assistant's card and answer read.
|
||||
expect(data.after).toEqual({ country_status: 'visited', countries_visited: 1 });
|
||||
const row = await findRow(orm, VisitedCountries, { user: user.id, country_code: 'FR' });
|
||||
expect(row).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('MCP-ATLAS-AFTER-001: a failed read after the mark leaves `after` out, not the done write', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const stats = vi.spyOn(AtlasService.prototype, 'stats').mockRejectedValue(new Error('stats unavailable'));
|
||||
try {
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'mark_country_visited', arguments: { country_code: 'NO' } });
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(parseToolResult(result)).toEqual({ success: true, country_code: 'NO' });
|
||||
expect(await findRow(orm, VisitedCountries, { user: user.id, country_code: 'NO' })).toBeTruthy();
|
||||
});
|
||||
} finally {
|
||||
stats.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('is idempotent — marking twice does not error', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
createVisitedCountry(testDb, user.id, 'JP');
|
||||
@@ -92,8 +111,11 @@ describe('Tool: unmark_country_visited', () => {
|
||||
createVisitedCountry(testDb, user.id, 'ES');
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'unmark_country_visited', arguments: { country_code: 'ES' } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
expect(parseToolResult(result)).toEqual({
|
||||
success: true,
|
||||
country_code: 'ES',
|
||||
after: { country_status: null, countries_visited: 0 },
|
||||
});
|
||||
const row = await findRow(orm, VisitedCountries, { user: user.id, country_code: 'ES' });
|
||||
expect(row).toBeNull();
|
||||
});
|
||||
@@ -250,3 +272,32 @@ describe('Tool: delete_bucket_list_item', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// list_bucket_list
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: list_bucket_list', () => {
|
||||
it("MCP-ATLAS-BUCKET-001: answers the user's own wishes with their ids, shaped like GET /bucket-list", async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const kyoto = createBucketListItem(testDb, user.id, { name: 'Kyoto', country_code: 'JP', notes: 'In autumn' });
|
||||
createBucketListItem(testDb, other.id, { name: "Other's Wish" });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_bucket_list', arguments: {} });
|
||||
const data = parseToolResult(result) as { items: Array<Record<string, unknown>> };
|
||||
expect(data.items).toHaveLength(1);
|
||||
expect(data.items[0]).toMatchObject({ id: kyoto.id, name: 'Kyoto', country_code: 'JP', notes: 'In autumn' });
|
||||
});
|
||||
});
|
||||
|
||||
it('MCP-ATLAS-BUCKET-002: answers an empty list, and reads for a demo user too', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_bucket_list', arguments: {} });
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(parseToolResult(result)).toEqual({ items: [] });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,519 @@
|
||||
/**
|
||||
* The atlas cards: the passport of get_atlas_stats, one country of
|
||||
* get_country_atlas_places, the wishes of list_bucket_list, the change cards
|
||||
* of the seven atlas writes predicted from the turn's reads, the applied
|
||||
* cards built from what the writes answer, and the way back from an added
|
||||
* wish. Every card is checked against the card union and holds no address.
|
||||
*/
|
||||
import { buildTripCard } from '../../../../../../src/nest/assistant/assistant-context';
|
||||
import { bucketListOf, countryOf, passportOf } from '../../../../../../src/nest/assistant/cards/atlas/atlas-answers';
|
||||
import { DELETE_ADDED_WISH } from '../../../../../../src/nest/assistant/cards/atlas/atlas-applied';
|
||||
import {
|
||||
ATLAS_CHANGES,
|
||||
countryNameIn,
|
||||
loadAtlas,
|
||||
loadAtlasRegions,
|
||||
loadBucket,
|
||||
type AtlasLoaded,
|
||||
} from '../../../../../../src/nest/assistant/cards/atlas/atlas-changes';
|
||||
import {
|
||||
atlasSnapshotOf,
|
||||
bucketSnapshotOf,
|
||||
regionsSnapshotOf,
|
||||
} from '../../../../../../src/nest/assistant/cards/atlas/atlas-values';
|
||||
import type { CardReader, PreviewContext } from '../../../../../../src/nest/assistant/cards/card-types';
|
||||
import { readKey } from '../../../../../../src/nest/assistant/cards/card-values';
|
||||
import {
|
||||
answerCardOf,
|
||||
appliedCardOf,
|
||||
ASSISTANT_UNDO_MAP,
|
||||
ASSISTANT_WRITE_TOOLS,
|
||||
CARD_PROMPT_LINES,
|
||||
TOOL_CARDS,
|
||||
} from '../../../../../../src/nest/assistant/cards/registry';
|
||||
import { describeToolCall } from '../../../../../../src/nest/assistant/tool-call-preview.helpers';
|
||||
import { assistantChangePreviewSchema, trekCardSchema, type TrekCard } from '@trek/shared';
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const card = buildTripCard(1, 7, { trip: { title: 'Alps' } }, null, null);
|
||||
const ctx: PreviewContext = { card, categories: new Map(), locale: 'en', today: '2026-10-22', airport: () => null };
|
||||
|
||||
/** The card parses as a card of the union and carries no address. */
|
||||
function valid(built: TrekCard | null): TrekCard {
|
||||
expect(built).not.toBeNull();
|
||||
expect(trekCardSchema.safeParse(built).success).toBe(true);
|
||||
expect(JSON.stringify(built)).not.toMatch(/https?:/);
|
||||
return built as TrekCard;
|
||||
}
|
||||
|
||||
/** A country as `stats()` answers it. */
|
||||
function visit(code: string, places: number, first: string | null, last: string | null, status: string) {
|
||||
return { code, placeCount: places, tripCount: places > 0 ? 1 : 0, firstVisit: first, lastVisit: last, status };
|
||||
}
|
||||
|
||||
const STATS = {
|
||||
stats: {
|
||||
countries: [
|
||||
visit('FR', 4, '2019-05-01', '2019-05-09', 'visited'),
|
||||
visit('JP', 14, '2019-10-12', '2024-04-14', 'visited'),
|
||||
visit('DE', 0, null, null, 'visited'),
|
||||
visit('PT', 3, '2026-11-03', null, 'planned'),
|
||||
visit('IS', 1, null, null, 'idea'),
|
||||
],
|
||||
stats: {
|
||||
totalTrips: 3,
|
||||
totalPlaces: 22,
|
||||
totalCountries: 3,
|
||||
totalCountriesPlanned: 1,
|
||||
totalCountriesIdea: 1,
|
||||
totalDays: 30,
|
||||
totalCities: 9,
|
||||
},
|
||||
continents: { Europe: 2, Asia: 1, Atlantis: 4 },
|
||||
continentsPlanned: { Europe: 1 },
|
||||
nextTrip: { id: 3, title: 'Lisbon', start_date: '2026-11-03', daysUntil: 12 },
|
||||
streak: 3,
|
||||
},
|
||||
travel: { countries: ['FR', 'JP', 'DE'], cities: ['paris'], totalDistanceKm: 18_230.6 },
|
||||
};
|
||||
|
||||
const REGIONS = {
|
||||
regions: {
|
||||
JP: [
|
||||
{ code: 'JP-26', name: 'Kyoto', placeCount: 6, status: 'visited' },
|
||||
{ code: 'JP-13', name: 'Tokyo', placeCount: 8, status: 'visited' },
|
||||
],
|
||||
DE: [{ code: 'DE-BY', name: 'Bavaria', placeCount: 0, status: 'visited', manuallyMarked: true }],
|
||||
},
|
||||
};
|
||||
|
||||
const WISH = {
|
||||
id: 5,
|
||||
user_id: 1,
|
||||
name: 'Northern lights',
|
||||
lat: 64.1,
|
||||
lng: -21.9,
|
||||
country_code: 'IS',
|
||||
notes: 'Glacier hike',
|
||||
created_at: '2026-01-01 10:00:00',
|
||||
target_date: '2027-02',
|
||||
visited_at: null,
|
||||
visited_source: null,
|
||||
region_code: null,
|
||||
};
|
||||
const PETRA = { ...WISH, id: 8, name: 'Petra', country_code: 'JO' };
|
||||
const BUCKET = {
|
||||
items: [
|
||||
WISH,
|
||||
{ ...WISH, id: 6, name: 'Kyoto in autumn', country_code: 'JP', lat: null, lng: null, target_date: null },
|
||||
{ ...WISH, id: 7, name: 'Machu Picchu', country_code: 'PER', target_date: '2026-05', notes: null },
|
||||
{ ...PETRA, visited_at: '2025-04-02 08:00:00', visited_source: 'dawarich' },
|
||||
],
|
||||
};
|
||||
|
||||
const loaded: AtlasLoaded = {
|
||||
atlas: atlasSnapshotOf(STATS),
|
||||
regions: regionsSnapshotOf(REGIONS),
|
||||
bucket: bucketSnapshotOf(BUCKET),
|
||||
};
|
||||
|
||||
describe('atlas answer cards', () => {
|
||||
it('ASTATLAS-001: the passport takes its figures, flags in the Atlas order, known continents and the flown distance', () => {
|
||||
const passport = valid(passportOf(STATS));
|
||||
expect(passport).toMatchObject({
|
||||
kind: 'atlasPassport',
|
||||
countries: { visited: 3, planned: 1, idea: 1 },
|
||||
totals: { trips: 3, days: 30, places: 22, cities: 9, flownKm: 18_231 },
|
||||
moreFlags: 0,
|
||||
nextTrip: { title: 'Lisbon', startDate: '2026-11-03', daysUntil: 12 },
|
||||
streakYears: 3,
|
||||
});
|
||||
// Visited latest first, the one without a date last, then planned; an idea is left out.
|
||||
expect(passport.kind === 'atlasPassport' && passport.flags).toEqual([
|
||||
{ code: 'JP', status: 'visited' },
|
||||
{ code: 'FR', status: 'visited' },
|
||||
{ code: 'DE', status: 'visited' },
|
||||
{ code: 'PT', status: 'planned' },
|
||||
]);
|
||||
expect(passport.kind === 'atlasPassport' && passport.continents).toEqual([
|
||||
{ name: 'Europe', visited: 2, planned: 1 },
|
||||
{ name: 'Asia', visited: 1, planned: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('ASTATLAS-002: a user without trips gets the short form: no planned or idea counts, cities from the passport, nothing flown', () => {
|
||||
const passport = valid(
|
||||
passportOf({
|
||||
stats: {
|
||||
countries: [{ code: 'NO', placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }],
|
||||
trips: [],
|
||||
stats: { totalTrips: 0, totalPlaces: 0, totalCountries: 1, totalDays: 0 },
|
||||
},
|
||||
travel: { cities: ['oslo', 'bergen'], totalDistanceKm: 0 },
|
||||
}),
|
||||
);
|
||||
expect(passport).toMatchObject({
|
||||
countries: { visited: 1, planned: 0, idea: 0 },
|
||||
totals: { cities: 2, flownKm: null },
|
||||
flags: [{ code: 'NO', status: 'visited' }],
|
||||
continents: [],
|
||||
nextTrip: null,
|
||||
streakYears: null,
|
||||
});
|
||||
expect(passportOf({ stats: 'nope' })).toBeNull();
|
||||
});
|
||||
|
||||
it('ASTATLAS-003: forty flags at most, the rest counted', () => {
|
||||
const countries = Array.from({ length: 45 }, (_, i) => ({
|
||||
code: `${String.fromCharCode(65 + Math.floor(i / 26))}${String.fromCharCode(65 + (i % 26))}`,
|
||||
status: 'visited',
|
||||
}));
|
||||
const passport = valid(passportOf({ stats: { ...STATS.stats, countries }, travel: STATS.travel }));
|
||||
expect(passport.kind === 'atlasPassport' && passport.flags).toHaveLength(40);
|
||||
expect(passport).toMatchObject({ moreFlags: 5 });
|
||||
});
|
||||
|
||||
it('ASTATLAS-004: a country reads its trips latest first and its places in their order, masked and cut', () => {
|
||||
const trips = [
|
||||
{ id: 1, title: 'Tokyo food week', start_date: '2019-10-12', end_date: '2019-10-19' },
|
||||
{ id: 2, title: 'Kyoto with ana@example.com', start_date: '2024-04-03', end_date: '2024-04-14' },
|
||||
...[3, 4, 5].map((id) => ({ id, title: `Trip ${id}`, start_date: null, end_date: null })),
|
||||
];
|
||||
const places = [
|
||||
{ id: 10, name: 'Tsukiji', trip_id: 1 },
|
||||
{ id: 11, name: 'Fushimi Inari', trip_id: 2 },
|
||||
...Array.from({ length: 9 }, (_, i) => ({ id: 20 + i, name: `Spot ${i}`, trip_id: 2 })),
|
||||
];
|
||||
const raw = { places, trips, manually_marked: false, marked_source: null, status: 'visited' };
|
||||
const country = valid(countryOf({ countryCode: 'jp' }, raw));
|
||||
expect(country).toMatchObject({
|
||||
kind: 'atlasCountry',
|
||||
code: 'JP',
|
||||
status: 'visited',
|
||||
markedBy: null,
|
||||
placeCount: 11,
|
||||
tripCount: 5,
|
||||
moreTrips: 1,
|
||||
morePlaces: 3,
|
||||
change: null,
|
||||
});
|
||||
if (country.kind !== 'atlasCountry') throw new Error('not a country');
|
||||
expect(country.trips.map((trip) => trip.id)).toEqual([2, 1, 3, 4]);
|
||||
expect(country.trips[0]?.title).not.toContain('ana@example.com');
|
||||
expect(country.places.slice(0, 2)).toEqual(['Fushimi Inari', 'Spot 0']);
|
||||
expect(country.places).not.toContain('Tsukiji');
|
||||
});
|
||||
|
||||
it('ASTATLAS-005: a country nothing put on the map says so, one marked from recordings names it, a bad code gives none', () => {
|
||||
const empty = { places: [], trips: [], manually_marked: false, status: 'idea' };
|
||||
expect(countryOf({ countryCode: 'PE' }, empty)).toMatchObject({ status: null, placeCount: 0 });
|
||||
const recorded = { ...empty, manually_marked: true, marked_source: 'dawarich', status: 'visited' };
|
||||
const norway = valid(countryOf({ countryCode: 'NO' }, recorded));
|
||||
expect(norway).toMatchObject({ status: 'visited', markedBy: 'dawarich' });
|
||||
expect(countryOf({ countryCode: 'NOR' }, {})).toBeNull();
|
||||
expect(countryOf({}, {})).toBeNull();
|
||||
});
|
||||
|
||||
it('ASTATLAS-006: the bucket list shows open wishes soonest first, then the reached ones, without coordinates', () => {
|
||||
const bucket = valid(bucketListOf(BUCKET));
|
||||
if (bucket.kind !== 'atlasBucket') throw new Error('not a bucket');
|
||||
expect(bucket.items.map((item) => item.id)).toEqual([7, 5, 6, 8]);
|
||||
expect(bucket.items[0]).toMatchObject({ countryCode: null, targetDate: '2026-05', notes: null });
|
||||
expect(bucket.items[3]).toMatchObject({ reachedOn: '2025-04-02', reachedFrom: 'dawarich' });
|
||||
expect(JSON.stringify(bucket)).not.toMatch(/lat|lng|user_id|64\.1/);
|
||||
const many = { items: Array.from({ length: 15 }, (_, i) => ({ ...WISH, id: i + 1, name: `Wish ${i}` })) };
|
||||
expect(bucketListOf(many)).toMatchObject({ more: 3 });
|
||||
expect(bucketListOf({ items: [] })).toBeNull();
|
||||
expect(bucketListOf('That is not a list')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('atlas change cards', () => {
|
||||
it('ASTATLAS-007: marking a country adds one unless it is visited already; an unread atlas leaves the counter out', () => {
|
||||
const peru = valid(ATLAS_CHANGES.mark_country_visited(ctx, { country_code: 'pe' }, loaded));
|
||||
expect(peru).toMatchObject({
|
||||
code: 'PE',
|
||||
status: null,
|
||||
change: { op: 'mark', after: 'visited', countries: { before: 3, after: 4 } },
|
||||
});
|
||||
const portugal = ATLAS_CHANGES.mark_country_visited(ctx, { country_code: 'PT' }, loaded);
|
||||
expect(portugal).toMatchObject({ status: 'planned', placeCount: 3, change: { countries: { after: 4 } } });
|
||||
const france = ATLAS_CHANGES.mark_country_visited(ctx, { country_code: 'FR' }, loaded);
|
||||
expect(france).toMatchObject({ change: { countries: { before: 3, after: 3 } } });
|
||||
const blind = ATLAS_CHANGES.mark_country_visited(ctx, { country_code: 'FR' }, null);
|
||||
expect(valid(blind)).toMatchObject({ status: null, change: { countries: null } });
|
||||
expect(ATLAS_CHANGES.mark_country_visited(ctx, {}, loaded)).toBeNull();
|
||||
});
|
||||
|
||||
it('ASTATLAS-008: an unmark takes a country off the map unless its trips keep it, where the trips decide', () => {
|
||||
expect(ATLAS_CHANGES.unmark_country_visited(ctx, { country_code: 'DE' }, loaded)).toMatchObject({
|
||||
change: { op: 'unmark', after: 'none', countries: { before: 3, after: 2 } },
|
||||
});
|
||||
expect(ATLAS_CHANGES.unmark_country_visited(ctx, { country_code: 'JP' }, loaded)).toMatchObject({
|
||||
change: { after: 'unknown', countries: { before: 3, after: null } },
|
||||
});
|
||||
expect(ATLAS_CHANGES.unmark_country_visited(ctx, { country_code: 'PT' }, loaded)).toMatchObject({
|
||||
change: { after: 'planned', countries: { before: 3, after: 3 } },
|
||||
});
|
||||
expect(ATLAS_CHANGES.unmark_country_visited(ctx, { country_code: 'AU' }, loaded)).toMatchObject({
|
||||
status: null,
|
||||
change: { after: 'none', countries: { before: 3, after: 3 } },
|
||||
});
|
||||
});
|
||||
|
||||
it('ASTATLAS-009: a region mark counts the regions of its country and moves the country like a mark', () => {
|
||||
const osaka = valid(
|
||||
ATLAS_CHANGES.mark_region_visited(ctx, { regionCode: 'jp-27', regionName: 'Osaka', countryCode: 'jp' }, loaded),
|
||||
);
|
||||
expect(osaka).toMatchObject({
|
||||
code: 'JP',
|
||||
change: {
|
||||
op: 'mark',
|
||||
region: { code: 'JP-27', name: 'Osaka', before: null, after: 'visited' },
|
||||
countries: { before: 3, after: 3 },
|
||||
regions: { before: 2, after: 3 },
|
||||
},
|
||||
});
|
||||
const kyoto = { regionCode: 'JP-26', regionName: 'Kyoto', countryCode: 'JP' };
|
||||
const again = ATLAS_CHANGES.mark_region_visited(ctx, kyoto, loaded);
|
||||
expect(again).toMatchObject({ change: { region: { before: 'visited' }, regions: { before: 2, after: 2 } } });
|
||||
expect(ATLAS_CHANGES.mark_region_visited(ctx, { regionCode: 'Osaka', countryCode: 'JP' }, loaded)).toBeNull();
|
||||
});
|
||||
|
||||
it('ASTATLAS-010: a region unmark keeps the country while others are left; the last one leaves it to the trips', () => {
|
||||
expect(ATLAS_CHANGES.unmark_region_visited(ctx, { regionCode: 'JP-26' }, loaded)).toMatchObject({
|
||||
code: 'JP',
|
||||
change: {
|
||||
op: 'unmark',
|
||||
region: { code: 'JP-26', name: 'Kyoto', before: 'visited', after: 'none' },
|
||||
after: 'visited',
|
||||
countries: { before: 3, after: 3 },
|
||||
regions: { before: 2, after: 1 },
|
||||
},
|
||||
});
|
||||
const last = valid(ATLAS_CHANGES.unmark_region_visited(ctx, { regionCode: 'DE-BY' }, loaded));
|
||||
expect(last).toMatchObject({
|
||||
change: { after: 'unknown', countries: { before: 3, after: null }, regions: { before: 1, after: 0 } },
|
||||
});
|
||||
// A region the snapshot does not know is named by its code, its country by the code's prefix.
|
||||
expect(ATLAS_CHANGES.unmark_region_visited(ctx, { regionCode: 'FR-IDF' }, loaded)).toMatchObject({
|
||||
code: 'FR',
|
||||
change: { region: { name: 'FR-IDF', before: null } },
|
||||
});
|
||||
});
|
||||
|
||||
it('ASTATLAS-011: an add shows the wish from its arguments and knows a duplicate, which does not grow the list', () => {
|
||||
const patagonia = { name: 'Patagonia', country_code: 'ar', target_date: '2028', notes: 'Torres del Paine' };
|
||||
const add = valid(ATLAS_CHANGES.create_bucket_list_item(ctx, { ...patagonia, lat: -50.9, lng: -73.4 }, loaded));
|
||||
expect(add).toMatchObject({
|
||||
items: [{ id: null, name: 'Patagonia', countryCode: 'AR', targetDate: '2028', notes: 'Torres del Paine' }],
|
||||
change: { op: 'add', duplicate: false, wishes: { before: 4, after: 5 } },
|
||||
});
|
||||
expect(JSON.stringify(add)).not.toContain('-50.9');
|
||||
const dup = ATLAS_CHANGES.create_bucket_list_item(
|
||||
ctx,
|
||||
{ name: ' northern LIGHTS ', country_code: 'is', target_date: '2027-02', lat: 64.1, lng: -21.9 },
|
||||
loaded,
|
||||
);
|
||||
expect(dup).toMatchObject({ change: { duplicate: true, wishes: { before: 4, after: 4 } } });
|
||||
const otherDate = ATLAS_CHANGES.create_bucket_list_item(
|
||||
ctx,
|
||||
{ name: 'Northern lights', country_code: 'IS', target_date: '2028-02', lat: 64.1, lng: -21.9 },
|
||||
loaded,
|
||||
);
|
||||
expect(otherDate).toMatchObject({ change: { duplicate: false } });
|
||||
expect(ATLAS_CHANGES.create_bucket_list_item(ctx, {}, loaded)).toBeNull();
|
||||
});
|
||||
|
||||
it('ASTATLAS-012: a change shows the wish as it will read, a removal as it reads, and an unknown id draws no card', () => {
|
||||
const update = valid(
|
||||
ATLAS_CHANGES.update_bucket_list_item(ctx, { itemId: 5, name: 'Aurora', target_date: '2027-03' }, loaded),
|
||||
);
|
||||
expect(update).toMatchObject({
|
||||
items: [{ id: 5, name: 'Aurora', targetDate: '2027-03', notes: 'Glacier hike', countryCode: 'IS' }],
|
||||
change: { op: 'update', before: { name: 'Northern lights', targetDate: '2027-02' }, duplicate: false },
|
||||
});
|
||||
const clash = ATLAS_CHANGES.update_bucket_list_item(
|
||||
ctx,
|
||||
{ itemId: 6, name: 'Northern lights', country_code: 'IS', target_date: '2027-02', lat: 64.1, lng: -21.9 },
|
||||
loaded,
|
||||
);
|
||||
expect(clash).toMatchObject({ change: { duplicate: true } });
|
||||
expect(ATLAS_CHANGES.update_bucket_list_item(ctx, { itemId: 99, name: 'x' }, loaded)).toBeNull();
|
||||
const remove = valid(ATLAS_CHANGES.delete_bucket_list_item(ctx, { itemId: 6 }, loaded));
|
||||
expect(remove).toMatchObject({
|
||||
items: [{ id: 6, name: 'Kyoto in autumn' }],
|
||||
change: { op: 'remove', before: { id: 6 }, wishes: { before: 4, after: 3 } },
|
||||
});
|
||||
expect(ATLAS_CHANGES.delete_bucket_list_item(ctx, { itemId: 6 }, null)).toBeNull();
|
||||
});
|
||||
|
||||
it('ASTATLAS-013: the held card names the country in the user language, under the tool label in the atlas', () => {
|
||||
expect(countryNameIn({ locale: 'de' }, 'FR')).toBe('Frankreich');
|
||||
expect(countryNameIn({ locale: 'not a locale!' }, 'FR')).toBe('France');
|
||||
const german = { ...ctx, locale: 'de' };
|
||||
const preview = describeToolCall('mark_country_visited', 'tc-1', { country_code: 'fr' }, german, loaded);
|
||||
expect(assistantChangePreviewSchema.safeParse(preview).success).toBe(true);
|
||||
expect(preview).toMatchObject({
|
||||
verbKey: 'genericCreate',
|
||||
subject: 'Frankreich',
|
||||
tool: 'mark_country_visited',
|
||||
domain: 'atlas',
|
||||
undoable: false,
|
||||
card: { kind: 'atlasCountry', code: 'FR' },
|
||||
});
|
||||
const region = describeToolCall('unmark_region_visited', 'tc-2', { regionCode: 'JP-26' }, ctx, loaded);
|
||||
expect(region).toMatchObject({ verbKey: 'genericRemove', subject: 'Kyoto' });
|
||||
expect(region.rows).toEqual([{ op: 'add', labelKey: 'location', before: null, after: 'Japan' }]);
|
||||
const wish = describeToolCall('update_bucket_list_item', 'tc-3', { itemId: 5, notes: 'With Ana' }, ctx, loaded);
|
||||
expect(wish).toMatchObject({
|
||||
subject: 'Northern lights',
|
||||
rows: [{ op: 'change', labelKey: 'notes', before: 'Glacier hike', after: 'With Ana' }],
|
||||
});
|
||||
const add = describeToolCall('create_bucket_list_item', 'tc-4', { name: 'Petra' }, ctx, loaded);
|
||||
expect(add).toMatchObject({ undoable: true, subject: 'Petra' });
|
||||
expect(describeToolCall('delete_bucket_list_item', 'tc-5', { itemId: 8 }, ctx, loaded)).toMatchObject({
|
||||
verbKey: 'genericRemove',
|
||||
subject: 'Petra',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('atlas lookups, applied cards and undo', () => {
|
||||
it('ASTATLAS-014: each change reads only what it needs, and a failed read leaves its snapshot empty', async () => {
|
||||
const answers: Record<string, unknown> = { get_atlas_stats: STATS, list_visited_regions: REGIONS };
|
||||
const read = vi.fn<CardReader>((tool) => Promise.resolve(answers[tool] ?? null));
|
||||
expect((await loadAtlas({}, read)).atlas?.visited).toBe(3);
|
||||
expect(read).toHaveBeenCalledWith('get_atlas_stats');
|
||||
const both = await loadAtlasRegions({}, read);
|
||||
expect(both.regions?.get('JP-26')).toEqual({ name: 'Kyoto', country: 'JP', status: 'visited' });
|
||||
expect(both.bucket).toBeNull();
|
||||
expect((await loadBucket({}, read)).bucket).toBeNull();
|
||||
expect(read).toHaveBeenCalledTimes(4);
|
||||
expect(atlasSnapshotOf({ stats: {} })).toBeNull();
|
||||
expect(regionsSnapshotOf('nope')).toBeNull();
|
||||
});
|
||||
|
||||
it('ASTATLAS-015: the applied country takes the standing and the counters the write answered', () => {
|
||||
const held = describeToolCall('unmark_country_visited', 'tc-6', { country_code: 'JP' }, ctx, loaded);
|
||||
const applied = appliedCardOf('unmark_country_visited', {
|
||||
args: { country_code: 'JP' },
|
||||
result: { success: true, country_code: 'JP', after: { country_status: 'visited', countries_visited: 3 } },
|
||||
preview: held,
|
||||
});
|
||||
expect(valid(applied)).toMatchObject({ change: { after: 'visited', countries: { before: 3, after: 3 } } });
|
||||
const region = describeToolCall('unmark_region_visited', 'tc-7', { regionCode: 'DE-BY' }, ctx, loaded);
|
||||
const gone = appliedCardOf('unmark_region_visited', {
|
||||
args: { regionCode: 'DE-BY' },
|
||||
result: { success: true, after: { country_status: null, countries_visited: 2, regions_visited: 0 } },
|
||||
preview: region,
|
||||
});
|
||||
expect(gone).toMatchObject({
|
||||
change: { after: 'none', countries: { after: 2 }, region: { after: 'none' }, regions: { before: 1, after: 0 } },
|
||||
});
|
||||
// Without a held card there is nothing to build on.
|
||||
const blind = { args: { country_code: 'JP' }, result: {}, preview: null };
|
||||
expect(appliedCardOf('mark_country_visited', blind)).toBeNull();
|
||||
});
|
||||
|
||||
it('ASTATLAS-016: an applied wish carries its stored id, a removal keeps its held card', () => {
|
||||
const held = describeToolCall('create_bucket_list_item', 'tc-8', { name: 'Patagonia' }, ctx, loaded);
|
||||
const added = appliedCardOf('create_bucket_list_item', {
|
||||
args: { name: 'Patagonia' },
|
||||
result: { item: { ...WISH, id: 41, name: 'Patagonia', country_code: 'AR', notes: 'mail me at ana@example.com' } },
|
||||
preview: held,
|
||||
});
|
||||
expect(valid(added)).toMatchObject({
|
||||
items: [{ id: 41, name: 'Patagonia', countryCode: 'AR' }],
|
||||
change: { op: 'add', wishes: { before: 4, after: 5 } },
|
||||
});
|
||||
expect(JSON.stringify(added)).not.toContain('ana@example.com');
|
||||
const removal = describeToolCall('delete_bucket_list_item', 'tc-9', { itemId: 5 }, ctx, loaded);
|
||||
const input = { args: { itemId: 5 }, result: { success: true }, preview: removal };
|
||||
const removed = appliedCardOf('delete_bucket_list_item', input);
|
||||
expect(removed).toEqual(removal.card);
|
||||
});
|
||||
|
||||
it('ASTATLAS-017: an added wish is taken back while it reads as added, never once edited or reached', () => {
|
||||
const result = { item: { ...WISH, id: 41 } };
|
||||
expect(ASSISTANT_UNDO_MAP.create_bucket_list_item?.tool).toBe('delete_bucket_list_item');
|
||||
expect(DELETE_ADDED_WISH.args({}, result, null)).toEqual({ itemId: 41 });
|
||||
expect(DELETE_ADDED_WISH.args({}, { success: false }, null)).toBeNull();
|
||||
const reads = DELETE_ADDED_WISH.reads?.({}, result) ?? [];
|
||||
expect(reads).toEqual([{ tool: 'list_bucket_list', args: {} }]);
|
||||
const snapshot = (items: unknown[]) => ({
|
||||
summary: null,
|
||||
places: null,
|
||||
reads: new Map([[readKey('list_bucket_list', {}), { items }]]),
|
||||
});
|
||||
expect(DELETE_ADDED_WISH.unchanged(result, snapshot([{ ...WISH, id: 41 }]))).toBe(true);
|
||||
expect(DELETE_ADDED_WISH.unchanged(result, snapshot([{ ...WISH, id: 41, notes: 'Edited' }]))).toBe(false);
|
||||
expect(DELETE_ADDED_WISH.unchanged(result, snapshot([{ ...WISH, id: 41, visited_at: '2026-10-01' }]))).toBe(false);
|
||||
expect(DELETE_ADDED_WISH.unchanged(result, snapshot([]))).toBe(false);
|
||||
});
|
||||
|
||||
it('ASTATLAS-018: the registry holds the atlas tools, guards their cards and adds the two prompt lines', () => {
|
||||
const marks = ['mark_country_visited', 'unmark_country_visited', 'mark_region_visited', 'unmark_region_visited'];
|
||||
for (const tool of marks) {
|
||||
expect(ASSISTANT_WRITE_TOOLS[tool]?.entity).toBe('atlas');
|
||||
expect(TOOL_CARDS[tool]?.load).toBeTypeOf('function');
|
||||
}
|
||||
expect(ASSISTANT_WRITE_TOOLS.update_bucket_list_item).toEqual({ entity: 'atlas', verbKey: 'genericUpdate' });
|
||||
const input = { toolCallId: 'tc-10', card, today: '2026-10-22' };
|
||||
const passport = answerCardOf('get_atlas_stats', { ...input, args: {}, value: STATS });
|
||||
expect(valid(passport)).toMatchObject({ kind: 'atlasPassport' });
|
||||
const bucket = answerCardOf('list_bucket_list', { ...input, args: {}, value: BUCKET });
|
||||
expect(bucket).toMatchObject({ kind: 'atlasBucket' });
|
||||
expect(answerCardOf('get_country_atlas_places', { ...input, args: { countryCode: 'x' }, value: {} })).toBeNull();
|
||||
expect(CARD_PROMPT_LINES.some((line) => line.includes('list_bucket_list'))).toBe(true);
|
||||
});
|
||||
|
||||
it('ASTATLAS-019: the duplicate check compares a country as stored and a name only trimmed, as the service does', () => {
|
||||
const stored = bucketSnapshotOf({
|
||||
items: [
|
||||
{ ...WISH, id: 20, name: 'Kyoto', country_code: 'jp', lat: null, lng: null, target_date: null },
|
||||
{ ...WISH, id: 21, name: 'Osaka Castle', country_code: 'JP', lat: null, lng: null, target_date: null },
|
||||
],
|
||||
});
|
||||
const lower: AtlasLoaded = { atlas: null, regions: null, bucket: stored };
|
||||
// An update keeps its country as given: 'jp' meets the stored 'jp', 'JP' does not.
|
||||
const same = ATLAS_CHANGES.update_bucket_list_item(ctx, { itemId: 21, name: 'Kyoto', country_code: 'jp' }, lower);
|
||||
expect(same).toMatchObject({ change: { duplicate: true } });
|
||||
const upper = ATLAS_CHANGES.update_bucket_list_item(ctx, { itemId: 21, name: 'Kyoto', country_code: 'JP' }, lower);
|
||||
expect(upper).toMatchObject({ change: { duplicate: false } });
|
||||
// An add is stored upper-cased by its schema, so 'jp' lands as 'JP' and misses the stored 'jp'.
|
||||
const add = ATLAS_CHANGES.create_bucket_list_item(ctx, { name: 'Kyoto', country_code: 'jp' }, lower);
|
||||
expect(add).toMatchObject({ change: { duplicate: false } });
|
||||
// Inner spaces stay, as the service trims the ends only: the double space meets the stored one.
|
||||
const spaced = { itemId: 20, name: ' osaka castle ', country_code: 'JP' };
|
||||
expect(ATLAS_CHANGES.update_bucket_list_item(ctx, spaced, lower)).toMatchObject({ change: { duplicate: true } });
|
||||
// A blank name keeps the stored one, so the wish clashes as it is.
|
||||
const blank = ATLAS_CHANGES.update_bucket_list_item(ctx, { itemId: 20, name: ' ', country_code: 'JP' }, lower);
|
||||
expect(blank).toMatchObject({ items: [{ name: 'Kyoto' }], change: { duplicate: false } });
|
||||
});
|
||||
|
||||
it('ASTATLAS-020: a wish moved to another country loses its region, one kept in its country keeps it', () => {
|
||||
const regional = bucketSnapshotOf({ items: [{ ...WISH, id: 30, region_code: 'IS-1' }] });
|
||||
const withRegion: AtlasLoaded = { atlas: null, regions: null, bucket: regional };
|
||||
const moved = ATLAS_CHANGES.update_bucket_list_item(ctx, { itemId: 30, country_code: 'NO' }, withRegion);
|
||||
expect(moved).toMatchObject({
|
||||
items: [{ countryCode: 'NO', regionCode: null }],
|
||||
change: { before: { regionCode: 'IS-1' } },
|
||||
});
|
||||
const kept = ATLAS_CHANGES.update_bucket_list_item(ctx, { itemId: 30, notes: 'In March' }, withRegion);
|
||||
expect(kept).toMatchObject({ items: [{ countryCode: 'IS', regionCode: 'IS-1', notes: 'In March' }] });
|
||||
});
|
||||
|
||||
it('ASTATLAS-021: a wish change is named by its new name, else its stored one, else its id', () => {
|
||||
const subject = (tool: string, args: Record<string, unknown>) =>
|
||||
describeToolCall(tool, 'tc-21', args, ctx, loaded).subject;
|
||||
expect(subject('update_bucket_list_item', { itemId: 5, name: 'Aurora' })).toBe('Aurora');
|
||||
expect(subject('update_bucket_list_item', { itemId: 5, target_date: '2027-03' })).toBe('Northern lights');
|
||||
expect(subject('update_bucket_list_item', { itemId: 99, notes: 'x' })).toBe('#99');
|
||||
// A removal never takes a name from its arguments.
|
||||
expect(subject('delete_bucket_list_item', { itemId: 6, name: 'Ignored' })).toBe('Kyoto in autumn');
|
||||
expect(subject('delete_bucket_list_item', { itemId: 99 })).toBe('#99');
|
||||
expect(subject('delete_bucket_list_item', {})).toBe('');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user