mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
230 lines
9.7 KiB
TypeScript
230 lines
9.7 KiB
TypeScript
/**
|
|
* The undo guard: an applied change is taken back only while what it created
|
|
* is as it left it, and, for a place, still only where the change put it.
|
|
*/
|
|
import {
|
|
unchangedSinceApplied,
|
|
undoSnapshotOf,
|
|
undoTargetsOf,
|
|
type TripSnapshot,
|
|
} from '../../../../src/nest/assistant/assistant-undo.helpers';
|
|
import { readKey } from '../../../../src/nest/assistant/cards/card-values';
|
|
import { ASSISTANT_UNDO_MAP } from '../../../../src/nest/assistant/cards/registry';
|
|
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
const PLACE = { id: 9, name: 'Kinkaku-ji', updated_at: '2026-10-21 10:00:00', lat: 35, lng: 135 };
|
|
|
|
function snapshot(
|
|
overrides: { places?: unknown[]; days?: unknown[]; summary?: Record<string, unknown> } = {},
|
|
): TripSnapshot {
|
|
return {
|
|
places: { places: overrides.places ?? [{ ...PLACE, category: { id: 1 } }] },
|
|
summary: {
|
|
days: overrides.days ?? [
|
|
{ id: 31, assignments: [{ id: 301, place_id: 9, day_id: 31 }], notes: [{ id: 41, text: 'Pass' }] },
|
|
],
|
|
reservations: [],
|
|
accommodations: [],
|
|
budget: { items: [{ id: 71, name: 'Taxi', total_price: 30 }] },
|
|
packing: { items: [{ id: 51, name: 'Socks', updated_at: 'a' }] },
|
|
todos: [{ id: 61, name: 'Book', checked: 0 }],
|
|
...overrides.summary,
|
|
},
|
|
};
|
|
}
|
|
|
|
describe('unchangedSinceApplied', () => {
|
|
it('ASTUND-001: a place as it was, planned only where the change put it, can be taken back', () => {
|
|
expect(
|
|
unchangedSinceApplied('create_and_assign_place', { place: PLACE, assignment: { id: 301 } }, snapshot()),
|
|
).toBe(true);
|
|
expect(unchangedSinceApplied('create_place', { place: PLACE }, snapshot({ days: [] }))).toBe(true);
|
|
});
|
|
|
|
it('ASTUND-002: an edited, re-planned, booked or deleted place cannot', () => {
|
|
const result = { place: PLACE, assignment: { id: 301 } };
|
|
expect(
|
|
unchangedSinceApplied(
|
|
'create_and_assign_place',
|
|
result,
|
|
snapshot({ places: [{ ...PLACE, updated_at: 'later' }] }),
|
|
),
|
|
).toBe(false);
|
|
expect(
|
|
unchangedSinceApplied(
|
|
'create_and_assign_place',
|
|
result,
|
|
snapshot({
|
|
days: [
|
|
{
|
|
id: 32,
|
|
assignments: [
|
|
{ id: 301, place_id: 9 },
|
|
{ id: 302, place_id: 9 },
|
|
],
|
|
},
|
|
],
|
|
}),
|
|
),
|
|
).toBe(false);
|
|
expect(
|
|
unchangedSinceApplied(
|
|
'create_place',
|
|
{ place: PLACE },
|
|
snapshot({ days: [], summary: { reservations: [{ place_id: 9 }] } }),
|
|
),
|
|
).toBe(false);
|
|
expect(unchangedSinceApplied('create_place', { place: PLACE }, snapshot({ places: [] }))).toBe(false);
|
|
});
|
|
|
|
it('ASTUND-003: assignments, notes, packing items, to-dos and expenses are compared field by field', () => {
|
|
expect(
|
|
unchangedSinceApplied('assign_place_to_day', { assignment: { id: 301, day_id: 31, place_id: 9 } }, snapshot()),
|
|
).toBe(true);
|
|
expect(unchangedSinceApplied('assign_place_to_day', { assignment: { id: 301, day_id: 30 } }, snapshot())).toBe(
|
|
false,
|
|
);
|
|
expect(unchangedSinceApplied('create_day_note', { note: { id: 41, text: 'Pass' } }, snapshot())).toBe(true);
|
|
expect(unchangedSinceApplied('create_day_note', { note: { id: 41, text: 'Passport' } }, snapshot())).toBe(false);
|
|
expect(
|
|
unchangedSinceApplied('create_packing_item', { item: { id: 51, name: 'Socks', updated_at: 'a' } }, snapshot()),
|
|
).toBe(true);
|
|
expect(unchangedSinceApplied('create_todo', { item: { id: 61, name: 'Book', checked: 1 } }, snapshot())).toBe(
|
|
false,
|
|
);
|
|
expect(
|
|
unchangedSinceApplied('create_budget_item', { item: { id: 71, name: 'Taxi', total_price: 30 } }, snapshot()),
|
|
).toBe(true);
|
|
expect(unchangedSinceApplied('create_budget_item', { item: { id: 72 } }, snapshot())).toBe(false);
|
|
});
|
|
|
|
it('ASTUND-004: every undoable tool has a guard, a tool without one never passes', () => {
|
|
for (const name of Object.keys(ASSISTANT_UNDO_MAP)) {
|
|
expect(unchangedSinceApplied(name, {}, snapshot()), name).toBe(false);
|
|
}
|
|
expect(unchangedSinceApplied('update_place', { place: PLACE }, snapshot())).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('ASSISTANT_UNDO_MAP', () => {
|
|
it('ASTUND-010: builds the counter-call from the change and its answer, or none', () => {
|
|
expect(ASSISTANT_UNDO_MAP.create_place.args({}, { place: { id: 9 } }, null)).toEqual({ placeId: 9 });
|
|
expect(ASSISTANT_UNDO_MAP.create_and_assign_place.args({}, { place: { id: 9 } }, null)).toEqual({ placeId: 9 });
|
|
expect(ASSISTANT_UNDO_MAP.assign_place_to_day.args({ dayId: 31 }, { assignment: { id: 301 } }, null)).toEqual({
|
|
dayId: 31,
|
|
assignmentId: 301,
|
|
});
|
|
expect(ASSISTANT_UNDO_MAP.create_day_note.args({ dayId: 31 }, { note: { id: 41 } }, null)).toEqual({
|
|
dayId: 31,
|
|
noteId: 41,
|
|
});
|
|
expect(ASSISTANT_UNDO_MAP.create_packing_item.args({}, { item: { id: 51 } }, null)).toEqual({ itemId: 51 });
|
|
expect(ASSISTANT_UNDO_MAP.create_todo.args({}, { item: { id: 61 } }, null)).toEqual({ itemId: 61 });
|
|
expect(ASSISTANT_UNDO_MAP.create_budget_item.args({}, { item: { id: 71 } }, null)).toEqual({ itemId: 71 });
|
|
expect(ASSISTANT_UNDO_MAP.create_place.args({}, 'Place not found.', null)).toBeNull();
|
|
expect(ASSISTANT_UNDO_MAP.assign_place_to_day.args({}, { assignment: { id: 301 } }, null)).toBeNull();
|
|
expect(ASSISTANT_UNDO_MAP.create_day_note.args({ dayId: 31 }, { note: { id: 0 } }, null)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('a created tour', () => {
|
|
/** A hike as list_places has it: a place with its name and description, stamped once when it was made. */
|
|
const MADE = '2026-10-21 10:00:00';
|
|
const HIKE = {
|
|
id: 64,
|
|
name: 'Herzogstand',
|
|
description: null,
|
|
lat: 47.6,
|
|
lng: 11.3,
|
|
created_at: MADE,
|
|
updated_at: MADE,
|
|
};
|
|
const created = { tour: { place_id: 64, name: 'Herzogstand', description: null }, waypoints: [] };
|
|
|
|
it('ASTUND-005: is taken back while it has its name and words and nothing plans, books or pays for it', () => {
|
|
const tours = snapshot({ places: [PLACE, HIKE] });
|
|
for (const tool of ['create_hike', 'create_city_walk']) {
|
|
expect(unchangedSinceApplied(tool, created, tours), tool).toBe(true);
|
|
}
|
|
// A description the tour was created without reads as none on both sides.
|
|
expect(unchangedSinceApplied('create_hike', { tour: { place_id: 64, name: 'Herzogstand' } }, tours)).toBe(true);
|
|
});
|
|
|
|
it('ASTUND-006: stays once it was renamed, changed, put on a day, booked or deleted', () => {
|
|
expect(
|
|
unchangedSinceApplied('create_hike', created, snapshot({ places: [PLACE, { ...HIKE, name: 'Heimgarten' }] })),
|
|
).toBe(false);
|
|
// A new way, limit or planned time (update_tour, the planner's save) leaves name and words as they were.
|
|
const rerouted = { ...HIKE, updated_at: '2026-10-21 10:04:00' };
|
|
for (const tool of ['create_hike', 'create_city_walk']) {
|
|
expect(unchangedSinceApplied(tool, created, snapshot({ places: [PLACE, rerouted] })), tool).toBe(false);
|
|
}
|
|
// A row that does not say when it was made cannot say it is unchanged.
|
|
const { created_at: _made, ...unstamped } = HIKE;
|
|
expect(unchangedSinceApplied('create_hike', created, snapshot({ places: [PLACE, unstamped] }))).toBe(false);
|
|
expect(
|
|
unchangedSinceApplied(
|
|
'create_hike',
|
|
created,
|
|
snapshot({ places: [PLACE, HIKE], days: [{ id: 31, assignments: [{ id: 305, place_id: 64 }] }] }),
|
|
),
|
|
).toBe(false);
|
|
expect(
|
|
unchangedSinceApplied(
|
|
'create_city_walk',
|
|
created,
|
|
snapshot({ places: [PLACE, HIKE], summary: { budget: { items: [{ id: 72, place_id: 64 }] } } }),
|
|
),
|
|
).toBe(false);
|
|
expect(unchangedSinceApplied('create_hike', created, snapshot())).toBe(false);
|
|
});
|
|
|
|
it('ASTUND-011: is taken back by deleting its place, named in the answer', () => {
|
|
for (const tool of ['create_hike', 'create_city_walk']) {
|
|
expect(ASSISTANT_UNDO_MAP[tool].tool).toBe('delete_place');
|
|
expect(ASSISTANT_UNDO_MAP[tool].args({}, created, null)).toEqual({ placeId: 64 });
|
|
expect(ASSISTANT_UNDO_MAP[tool].args({}, { tour: { place_id: 0 } }, null)).toBeNull();
|
|
expect(ASSISTANT_UNDO_MAP[tool].args({}, { tour: 'odd' }, null)).toBeNull();
|
|
expect(ASSISTANT_UNDO_MAP[tool].args({}, 'Tour not found.', null)).toBeNull();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('the undo session', () => {
|
|
const session = (failing: string[] = []) => ({
|
|
read: vi.fn(async (name: string, args: Record<string, unknown> = {}) =>
|
|
failing.includes(name) ? { ok: false, value: 'Not allowed.' } : { ok: true, value: { name, args } },
|
|
),
|
|
});
|
|
|
|
it('ASTUND-020: holds the removing tool and the trip reads, and reads the trip before the check', async () => {
|
|
expect([...undoTargetsOf('delete_place', 'create_hike', {}, {})]).toEqual([
|
|
'delete_place',
|
|
'get_trip_summary',
|
|
'list_places',
|
|
]);
|
|
const snap = await undoSnapshotOf(session(), 'create_place', {}, {});
|
|
expect(snap).toEqual({
|
|
summary: { name: 'get_trip_summary', args: {} },
|
|
places: { name: 'list_places', args: {} },
|
|
reads: new Map(),
|
|
});
|
|
expect(await undoSnapshotOf(session(['list_places']), 'create_place', {}, {})).toBeNull();
|
|
});
|
|
|
|
it('ASTUND-021: keeps what a package reads by tool and arguments, a failed read as null', async () => {
|
|
const reads = [
|
|
{ tool: 'get_vacay_plan', args: { year: 2026 } },
|
|
{ tool: 'list_vacay_entries', args: {} },
|
|
];
|
|
const snap = await undoSnapshotOf(session(['list_vacay_entries']), 'toggle_vacay', {}, {}, reads);
|
|
expect(snap?.reads.get(readKey('get_vacay_plan', { year: 2026 }))).toEqual({
|
|
name: 'get_vacay_plan',
|
|
args: { year: 2026 },
|
|
});
|
|
expect(snap?.reads.get(readKey('list_vacay_entries'))).toBeNull();
|
|
expect(snap?.reads.size).toBe(2);
|
|
});
|
|
});
|