Files
TREK/client/tests/unit/studio/travelRefresh.test.ts
T
Maurice 6cbefd23e8 feat(studio): filter photos by entry, upload from Studio, and switch stops off
The content browser gets a filter over the journey's pictures: all of them,
the ones no entry holds, or one entry at a time, with the day and the place
under each name. An entry card in the Entries tab reaches its own pictures
the same way. Pictures can now come in without leaving Studio, into the
entry the filter names or into the gallery, from the panel or dropped
straight onto the page, and a photo element can fill its page or the whole
spread in one press.

Stops can be taken out of the arithmetic. A journal entry carries a
stats_excluded flag; an entry that has it is off the route, out of the
distance, out of the countries and not a step, while staying in the
journal. Studio lists every stop in the travel panel with a switch, the
entry editor carries the same switch on desktop and phone, and the entry
card says when a day is off the route.
2026-09-03 10:47:08 +02: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 '../../../src/components/Studio/travelRefresh'
/**
* 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)
})
})