Files
TREK/shared/src/realtime/events.schema.spec.ts
T
Maurice 65bb2d95eb feat(journey): keep the route recorded on a journey day from Dawarich, previewed first, with its gaps
A day header in the journey timeline now carries the Dawarich mark for an
editor whose Dawarich answers (dimmed with the reason while offline). It opens
a dialog that names the journey and the day and reads that one local day from
the caller's own connection: a sketch of the route, distance, first and last
time, pieces, gaps without recording, modes and whether tracks or thinned
points answered. The day is cut in the zone Dawarich records in where its
answer shows one, else in this device's, and a picker cuts it in any other.
Nothing is kept before "Keep route". On a phone the same sheet opens from the
journey's More options for the day of the card shown.

What is kept is the preview that was seen: the save reads the day again and
refuses with recording_changed when its hash moved, and the dialog shows the
new version. The same day taken over twice keeps one version and announces
nothing; a different version is replaced only on "Replace", never touching an
entry or text. A failed read throws before any write, so the kept version
stays. Gaps stay gaps: points are split where silence ends elsewhere, and no
day is joined to the one before. The dialog says how many other people will
see the route; editors may keep their own recording.

Kept days show in the day header (distance, whose recording, since when,
still running) and on the desktop and phone journey maps in the colour of
their day's pins, winning over the live trail of their date, and stay drawn
without Dawarich. A trip's GPX track is now drawn dashed as the plan, recorded
routes solid. Shared links do not show recorded days. Remove route asks first
and works without a connection.

Server: migration 046160 adds journey_recorded_days (one row per journey,
local day and source, segments as JSON, zone, window, fetch time, hash,
importer kept past their account). The journey domain owns the rows through
JourneyRecordedDaysService; the new journey-recordings domain takes a day
over through DawarichTracksService.fetchDay, which reads past the cache, asks
a window wide enough for every zone and refuses with DomainErrors. REST under
/api/journeys/:id/recordings (list, preview, PUT and DELETE /:date) with
@trek/shared contracts, and MCP list_journey_recorded_days,
preview_journey_recorded_day, save_journey_recorded_day and
delete_journey_recorded_day on the same service and access. A new user event
journey:recording:updated keeps every open map current. JourneyTrack gains an
optional kind. All 27 locales carry the strings; the Journey and Dawarich wiki
pages and the MCP tool list say what it does.
2026-10-21 15:05:21 +02:00

211 lines
10 KiB
TypeScript

import {
TREK_WS_EVENTS,
TREK_WS_EVENT_NAMES,
TREK_WS_TRIP_EVENT_NAMES,
TREK_WS_USER_EVENT_NAMES,
type TrekWsEventName,
} from './events.schema';
import { describe, it, expect } from 'vitest';
/**
* One representative wire payload per event, transcribed from the emitting
* server call sites. The completeness guard below fails the moment a registry
* event has no fixture (or a fixture goes stale), i18n-parity style — so the
* fixture map cannot silently shrink relative to the registry.
*/
const FIXTURES: Record<TrekWsEventName, Record<string, unknown>> = {
'roadtripPreferences:changed': { preferences: { roadtrip_day_start: '08:00' } },
'place:created': { place: { id: 3, name: 'Louvre' } },
'place:updated': { place: { id: 3, name: 'Louvre' } },
'place:deleted': { placeId: 3 },
'tours:changed': { placeIds: [3] },
'tours:visits': { assignmentId: 9, visits: [{ assignment_id: 9, station_key: 'abcdef', status: 'visited' }] },
'assignment:created': { assignment: { id: 9, day_id: 2 } },
'assignment:updated': { assignment: { id: 9, day_id: 2 } },
'assignment:deleted': { assignmentId: 9, dayId: 2 },
'assignment:moved': { assignment: { id: 9, day_id: 4 }, oldDayId: 2, newDayId: 4 },
'assignment:reordered': { dayId: 2, orderedIds: [3, 1, 2] },
'assignment:participants': { assignmentId: 9, participants: [{ user_id: 1 }] },
'day:created': { day: { id: 2, date: '2026-06-11' } },
'day:updated': { day: { id: 2, date: '2026-06-11' } },
'day:deleted': { dayId: 2 },
'day:reordered': { orderedIds: [2, 1] },
'roadtripVia:changed': { dayId: 2, vias: [{ id: 9, lat: 53.5, lng: 9.9 }] },
'roadtripTrack:changed': { dayId: 2, track: null },
'roadtripBoundary:changed': {
boundaries: [{ day_number: 1, from_assignment_id: 9, to_assignment_id: null, fraction: 1 }],
},
'dayNote:created': { dayId: 2, note: { id: 5 } },
'dayNote:updated': { dayId: 2, note: { id: 5 } },
'dayNote:deleted': { noteId: 5, dayId: 2 },
'packing:created': { item: { id: 7 } },
'packing:updated': { item: { id: 7 } },
'packing:deleted': { itemId: 7 },
'packing:reordered': { orderedIds: [7, 8] },
'packing:bag-created': { bag: { id: 1 } },
'packing:bag-updated': { bag: { id: 1 } },
'packing:bag-deleted': { bagId: 1 },
'packing:bag-members-updated': { bagId: 1, members: [{ user_id: 1 }] },
'packing:assignees': { category: 'clothes', assignees: [1, 2] },
'packing:template-applied': { items: [{ id: 7 }] },
'packing:bag-totals': {},
'todo:created': { item: { id: 4 } },
'todo:updated': { item: { id: 4 } },
'todo:deleted': { itemId: 4 },
'todo:assignees': { category: 'before', assignees: [1] },
'budget:created': { item: { id: 11 } },
'budget:updated': { item: { id: 11 } },
'budget:deleted': { itemId: 11 },
'budget:reordered': { orderedIds: [11, 12] },
'budget:members-updated': { itemId: 11, members: [{ user_id: 1 }], persons: 2 },
'budget:member-paid-updated': { itemId: 11, userId: 1, paid: true },
'budget:settlement-created': { settlement: { id: 2 } },
'budget:settlement-updated': { settlement: { id: 2 } },
'budget:settlement-deleted': { settlementId: 2 },
'reservation:created': { reservation: { id: 6 } },
'reservation:updated': { reservation: { id: 6 } },
'reservation:deleted': { reservationId: 6 },
'reservation:positions': { positions: [{ id: 6, position: 0 }], day_id: 2 },
'reservation:travelers-updated': { reservationId: 6, travelers: [{ user_id: 1 }] },
'accommodation:created': { accommodation: { id: 8 } },
'accommodation:updated': { accommodation: { id: 8 } },
'accommodation:deleted': { accommodationId: 8 },
'trip:updated': { trip: { id: 1, name: 'Paris' } },
'trip:deleted': { id: 1 },
'member:added': { member: { user_id: 2 } },
'member:removed': { userId: 2 },
'trip:journal:changed': {},
'docsync:changed': { linkId: 4, pulled: 2, pushed: 1 },
'file:created': { file: { id: 13 } },
'file:updated': { file: { id: 13 } },
'file:deleted': { fileId: 13 },
'collab:note:created': { note: { id: 3 } },
'collab:note:updated': { note: { id: 3 } },
'collab:note:deleted': { noteId: 3 },
'collab:link:created': { link: { id: 4, title: 'Ferry', url: 'https://example.com' } },
'collab:link:updated': { link: { id: 4, title: 'Ferry', url: 'https://example.com' } },
'collab:link:deleted': { linkId: 4 },
'collab:poll:created': { poll: { id: 2 } },
'collab:poll:voted': { poll: { id: 2 } },
'collab:poll:closed': { poll: { id: 2 } },
'collab:poll:deleted': { pollId: 2 },
'collab:message:created': { message: { id: 14 } },
'collab:message:reacted': { messageId: 14, reactions: { '👍': [1] } },
'collab:message:deleted': { messageId: 14, username: 'ana' },
'memories:updated': { userId: 1 },
'notification:new': { notification: { id: 21, sender_username: 'ana' } },
'notification:updated': { notification: { id: 21 } },
'collections:updated': { collectionId: 4 },
'collections:accepted': { collectionId: 4 },
'collections:declined': { collectionId: 4 },
'collections:left': { collectionId: 4 },
'collections:deleted': { collectionId: 4 },
'collections:cancelled': { collectionId: 4 },
'collections:removed': { collectionId: 4 },
'collections:invite': { from: { id: 1, username: 'ana' }, collectionId: 4 },
'vacay:update': {},
'vacay:settings': {},
'vacay:accepted': {},
'vacay:declined': {},
'vacay:cancelled': {},
'vacay:dissolved': {},
'vacay:invite': { from: { id: 1, username: 'ana' }, planId: 2 },
'vacay:share': { from: { id: 1 } },
'vacay:share-removed': {},
'vacay:shared-update': {},
'journey:trip:synced': { journeyId: 3, tripId: 1 },
'journey:entry:created': { journeyId: 3, entry: { id: 5 } },
'journey:entry:updated': { journeyId: 3, entry: { id: 5 } },
'journey:entry:deleted': { journeyId: 3, entryId: 5 },
'journey:entries:reordered': { journeyId: 3, orderedIds: [5, 4] },
'journey:book:saved': { journeyId: 7, version: 4, savedBy: 1 },
'journey:book:peers': {
journeyId: 7,
peers: [{ socketId: 3, userId: 2, username: 'ada', avatar: null }],
},
'journey:book:cursor': { journeyId: 7, socketId: 3, userId: 2, spreadIndex: 0, x: 105.5, y: 60 },
'journey:contributor:changed': { journeyId: 3, targetUserId: 2, role: 'editor' },
'journey:photos:updated': { journeyId: 3 },
'journey:recording:updated': { journeyId: 3, date: '2026-05-14' },
'import:progress': { jobId: 'j1', tripId: 1, status: 'running', done: 1, total: 3, fileName: 'a.pdf' },
'import:done': { jobId: 'j1', tripId: 1, result: { items: [] } },
'import:error': { jobId: 'j1', tripId: 1, message: 'boom' },
};
/** Divergent shapes emitted for the same event today (see DRIFT notes in the registry). */
const DRIFT_VARIANTS: Partial<Record<TrekWsEventName, Record<string, unknown>[]>> = {
'day:reordered': [{ day: { id: 2 } }],
'budget:reordered': [{ orderedCategories: ['food', 'transport'] }],
'reservation:created': [{}],
'reservation:updated': [{}],
'reservation:positions': [{ positions: [], dayId: 2 }],
'accommodation:created': [{}],
'accommodation:updated': [{}],
'accommodation:deleted': [{}, { id: 8, linkedReservationId: 6 }],
'journey:entry:updated': [{ journeyId: 3, entryId: 5 }],
'journey:book:peers': [{ journeyId: 7, peers: [] }],
// Off the page: the arrow goes, the person stays.
'journey:book:cursor': [{ journeyId: 7, socketId: 3, userId: 2, spreadIndex: 4, x: null, y: null }],
};
describe('@trek/shared realtime event registry', () => {
it('WSEVT-REG-001: pins the authoritative inventory counts (77 trip + 34 user = 111)', () => {
// 67th to 69th trip event: the three collab:link:* a shared link emits.
// 70th and 71st: the road trip's vias and tracks, which used to be written silently.
// 74th: docsync:changed, so a sync run that moved documents refreshes the
// panel without every member polling for it.
// 76th: trip:journal:changed, so the trip's Journal tab and Settings follow
// a journal being linked, changed or unlinked.
// 77th: tours:visits, so the stops of a city walk checked off on one of its
// days show the same for every member of the trip.
// 33rd user event: journey:photos:updated, so the gallery re-sorts once the
// capture times of an import have landed (#1587).
// 34th: journey:recording:updated, so every map of a journey shows a day's
// recorded route once somebody took it over (JRN-01).
expect(TREK_WS_TRIP_EVENT_NAMES).toHaveLength(77);
expect(TREK_WS_USER_EVENT_NAMES).toHaveLength(34);
expect(TREK_WS_EVENT_NAMES).toHaveLength(111);
});
it('WSEVT-REG-002: every name is domain:action shaped and outside the reserved plugin: namespace', () => {
for (const name of TREK_WS_EVENT_NAMES) {
expect(name).toMatch(/^[a-zA-Z]+(:[a-zA-Z-]+)+$/);
expect(name.startsWith('plugin:')).toBe(false);
}
});
it('WSEVT-REG-003: trip/user scopes partition the registry', () => {
const union = new Set<string>([...TREK_WS_TRIP_EVENT_NAMES, ...TREK_WS_USER_EVENT_NAMES]);
expect(union.size).toBe(TREK_WS_EVENT_NAMES.length);
});
it('WSEVT-REG-004: the fixture map covers every registry event (completeness guard)', () => {
expect(Object.keys(FIXTURES).sort()).toEqual([...TREK_WS_EVENT_NAMES].sort());
});
it('WSEVT-REG-005: every event schema parses its representative server payload', () => {
for (const name of TREK_WS_EVENT_NAMES) {
const result = TREK_WS_EVENTS[name].payload.safeParse(FIXTURES[name]);
expect(result.success, `${name} rejected its fixture: ${JSON.stringify(FIXTURES[name])}`).toBe(true);
}
});
it('WSEVT-REG-006: drift unions accept every shape the server emits today', () => {
for (const [name, variants] of Object.entries(DRIFT_VARIANTS)) {
for (const variant of variants ?? []) {
const result = TREK_WS_EVENTS[name as TrekWsEventName].payload.safeParse(variant);
expect(result.success, `${name} rejected drift variant ${JSON.stringify(variant)}`).toBe(true);
}
}
});
it('WSEVT-REG-007: payload schemas tolerate the MCP _source marker (strip, not reject)', () => {
const result = TREK_WS_EVENTS['place:created'].payload.safeParse({
place: { id: 3 },
_source: 'mcp',
});
expect(result.success).toBe(true);
});
});