Files
TREK/client/tests/unit/studio/travelRefresh.test.ts
T
Maurice 4570ace78c feat(journey): the shared book engine lays out, edits and designs whole books
Every element kind and Studio tool gets an op, the auto layout and its
templates move from the client to shared with a parity fixture, and a pure
design engine turns directives into a finished book with design findings.
2026-10-22 21:34:15 +00:00

213 lines
8.0 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import type { BookElement, JourneyStats } from '@trek/shared'
import { isStale, liveMetrics, refreshPatch } from '@trek/shared'
/**
* Keeping a travel element in step with its journey (#1973).
*
* The behaviour being pinned is restraint: an element holds its own values so
* the print renderer never needs a network, and the editor offers to update
* them only when they have genuinely stopped matching. Marking a page stale
* because *something* changed would make the prompt meaningless.
*/
const stats: JourneyStats = {
journeyId: 6,
distance: 1_189_000,
days: 14,
steps: 14,
photos: 57,
places: 21,
furthest: 408_000,
countries: [
{ code: 'IS', name: 'Iceland', places: 14, firstVisit: '2026-06-02' },
],
points: [
{ lat: 64.14, lng: -21.94, label: 'Reykjavík', date: '2026-06-02', country: 'IS', tripId: null, photoId: null, entryId: null },
{ lat: 65.68, lng: -18.12, label: 'Akureyri', date: '2026-06-06', country: 'IS', tripId: null, photoId: null, entryId: null },
],
trips: [],
excluded: [],
start: '2026-06-02',
end: '2026-06-15',
}
const frame = { x: 0, y: 0, w: 100, h: 60 }
const common = { frame, rotation: 0, opacity: 1, locked: false }
const typeset = { font: 'sans' as const, color: '#1a1a1a', accent: '#111111', textScale: 1, weight: 700 as const, stale: false }
function statsEl(over: Partial<Record<string, unknown>> = {}): BookElement {
return {
...common, ...typeset, id: 'st-1', kind: 'stats',
metrics: ['distance', 'days'],
layout: 'grid', showIcons: true, units: 'metric',
values: { distance: 1_189_000, days: 14 },
...over,
} as BookElement
}
function countriesEl(over: Partial<Record<string, unknown>> = {}): BookElement {
return {
...common, ...typeset, id: 'co-1', kind: 'countries',
codes: ['IS'], names: ['Island'],
layout: 'list', showOutline: true, showFlag: false, showName: true, align: 'center',
...over,
} as BookElement
}
function mapEl(over: Partial<Record<string, unknown>> = {}): BookElement {
return {
...common, ...typeset, id: 'mp-1', kind: 'map',
style: 'minimal', showLand: true, showRoute: true, showPins: true, showLabels: false,
countries: ['IS'],
points: stats.points.map(p => ({ lat: p.lat, lng: p.lng, label: p.label })),
...over,
} as BookElement
}
describe('liveMetrics', () => {
it('maps every metric name onto a figure', () => {
expect(liveMetrics(stats)).toEqual({
distance: 1_189_000, days: 14, steps: 14, photos: 57, countries: 1, places: 21, furthest: 408_000,
})
})
it('reports the count of countries, not the list', () => {
expect(liveMetrics(stats).countries).toBe(1)
})
})
describe('isStale', () => {
it('is false with no figures to compare against', () => {
expect(isStale(statsEl(), null)).toBe(false)
})
it('is false for an element that carries no journey figures at all', () => {
const text = { ...common, id: 't-1', kind: 'text', text: 'hi' } as unknown as BookElement
expect(isStale(text, stats)).toBe(false)
})
it('is false while the stored figures still match', () => {
expect(isStale(statsEl(), stats)).toBe(false)
})
it('is true once a shown figure has moved', () => {
expect(isStale(statsEl({ values: { distance: 900_000, days: 14 } }), stats)).toBe(true)
})
/*
* The restraint that makes the prompt worth showing: a summary of distance
* and days does not go stale because somebody uploaded a photograph.
*/
it('ignores a figure the element does not show', () => {
expect(isStale(statsEl({ values: { distance: 1_189_000, days: 14, photos: 3 } }), stats)).toBe(false)
})
it('treats a missing stored value as zero and therefore stale', () => {
expect(isStale(statsEl({ values: { distance: 1_189_000 } }), stats)).toBe(true)
})
it('spots a country added to the journey', () => {
expect(isStale(countriesEl({ codes: [] }), stats)).toBe(true)
expect(isStale(countriesEl(), stats)).toBe(false)
})
it('spots countries reordered, since the list retells the route', () => {
const twoCountries: JourneyStats = {
...stats,
countries: [
{ code: 'IS', name: 'Iceland', places: 14, firstVisit: '2026-06-02' },
{ code: 'FO', name: 'Faroe Islands', places: 2, firstVisit: '2026-06-16' },
],
}
expect(isStale(countriesEl({ codes: ['FO', 'IS'], names: ['a', 'b'] }), twoCountries)).toBe(true)
})
it('spots a route that gained or lost a stop', () => {
expect(isStale(mapEl({ points: [stats.points[0]] }), stats)).toBe(true)
expect(isStale(mapEl(), stats)).toBe(false)
})
it('spots a route of the same length whose ends have moved', () => {
const moved = mapEl({
points: [{ lat: 0, lng: 0, label: '' }, { lat: 65.68, lng: -18.12, label: '' }],
})
expect(isStale(moved, stats)).toBe(true)
})
it('is false for an empty route against an empty journey', () => {
expect(isStale(mapEl({ points: [] }), { ...stats, points: [] })).toBe(false)
})
})
describe('refreshPatch', () => {
it('is null for an element with nothing to refresh', () => {
const text = { ...common, id: 't-1', kind: 'text', text: 'hi' } as unknown as BookElement
expect(refreshPatch(text, stats)).toBeNull()
})
it('takes the current figures and clears the stale flag', () => {
const patch = refreshPatch(statsEl({ values: { distance: 1, days: 1 } }), stats)!
expect(patch).toMatchObject({ stale: false })
expect((patch as { values: Record<string, number> }).values.distance).toBe(1_189_000)
})
it('brings a country list up to date', () => {
const patch = refreshPatch(countriesEl({ codes: [], names: [] }), stats)!
expect((patch as { codes: string[] }).codes).toEqual(['IS'])
})
/*
* Names are the book's language, resolved on the client when the element was
* placed. A refresh must not quietly replace "Island" with the API's English.
*/
it('keeps the name a country already had, in whatever language it was set', () => {
const twoCountries: JourneyStats = {
...stats,
countries: [
{ code: 'IS', name: 'Iceland', places: 14, firstVisit: '2026-06-02' },
{ code: 'FO', name: 'Faroe Islands', places: 2, firstVisit: '2026-06-16' },
],
}
const patch = refreshPatch(countriesEl(), twoCountries)!
expect((patch as { names: string[] }).names[0]).toBe('Island')
expect((patch as { names: string[] }).names[1]).toBe('Faroe Islands')
})
it('keeps a name matched to its country when the order changes', () => {
const reordered: JourneyStats = {
...stats,
countries: [
{ code: 'FO', name: 'Faroe Islands', places: 2, firstVisit: '2026-05-01' },
{ code: 'IS', name: 'Iceland', places: 14, firstVisit: '2026-06-02' },
],
}
const patch = refreshPatch(countriesEl(), reordered)!
const { codes, names } = patch as { codes: string[]; names: string[] }
expect(codes).toEqual(['FO', 'IS'])
// Iceland keeps the German name it was placed with, now in second position.
expect(names[codes.indexOf('IS')]).toBe('Island')
})
it('replaces the route and the countries a map draws', () => {
const patch = refreshPatch(mapEl({ points: [], countries: [] }), stats)!
expect((patch as { points: unknown[] }).points).toHaveLength(2)
expect((patch as { countries: string[] }).countries).toEqual(['IS'])
})
it('carries only what a map draws with, not the whole stats point', () => {
const patch = refreshPatch(mapEl({ points: [] }), stats)!
// The date, the country and the trip stay behind; the photograph comes
// along because a marker can be a picture of the stop.
expect((patch as { points: object[] }).points[0]).toEqual({
lat: 64.14, lng: -21.94, label: 'Reykjavík', photoId: null,
})
})
it('leaves the element stale-free after refreshing, so the prompt goes away', () => {
const el = statsEl({ values: { distance: 1, days: 1 } })
const patched = { ...el, ...refreshPatch(el, stats) } as BookElement
expect(isStale(patched, stats)).toBe(false)
})
})