Files
TREK/client/tests/unit/studio/travelPanelEngine.test.tsx
T
Maurice a7b53988ff feat(studio): rethink the travel tab with many new looks, a saved tile and a newer-book overlay
The travel tab is four tabs now, figures, maps, countries and marks, and
offers only the newer looks, each dressed in the book's theme and linked to
it. The looks books already hold still draw on their pages exactly as
before; they are just no longer offered.

- Figures: a summary as a poster, a ticket, tiles, a road sign, an
  odometer, a handwritten note, a pencil sketch or a band, and each figure
  on its own as a medal, on odometer drums or ringed by hand.
- Maps: the route with its photographs, its stops numbered, its two ends
  marked or on a dark ground, over satellite or relief imagery, a map per
  trip, and a map of each country with its stops alone, cut to its shape.
- Countries: each country as a passport stamp, a visa, an airmail
  postcard, its flag in cloth, a map pin, a luggage label or a sketch.
- Marks: the date, the day, the distance, the coordinates and the flag as
  stamps, tags, pills, ribbons, die-cut stickers, embroidered patches and
  sketches.

The schema gains a summary's and a country's look, seven mark styles and
the numbered and ends stops of a map, all additive, as revision 11.

Also in Studio: the save state is a tile beside who is here, a book a newer
Studio saved is said by an overlay that holds the whole Studio until it
reloads, and the page rail draws the first and the last page beside the
blank inside of their cover. The notifications page and the bell's panel
take the dialogs' look: a head with a tile, cards with an unread dot, and
tools that come forward on hover.
2026-10-24 14:45:04 +02:00

308 lines
11 KiB
TypeScript

// STTRV-001 to STTRV-009: the Travel panel places what the book engine's factories build, set in one of the newer
// looks, so a figure, a map, a country or a mark added by hand is the element the assistant's `add` adds in that look.
// Only the frame and the look are the panel's own.
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { BookDocument, BookElement, BookFrame, BookTravelContext, JourneyStats } from '@trek/shared';
import {
BOOK_SATELLITE_TILES,
bookBadgeValue,
bookPageSetupSchema,
bookThemeOf,
bookTravelBadge,
bookTravelCountries,
bookTravelMap,
bookTravelSize,
bookTravelStats,
} from '@trek/shared';
import { StudioTravelPanel } from '../../../src/components/Studio/StudioTravelPanel';
import { useStudioStore } from '../../../src/store/studioStore';
import { fireEvent, render, screen } from '../../helpers/render';
// A placed map asks for its roads. The answer never comes, so every map stays as it was placed.
vi.mock('../../../src/components/Map/RouteCalculator', () => ({
calculateRouteWithLegs: vi.fn(() => new Promise(() => {})),
}));
const ICELAND = 11;
const PORTUGAL = 22;
const page = bookPageSetupSchema.parse({ preset: 'square-210' });
/** A stop on the route, read from a trip's place rather than from an entry. */
const stop = (
label: string,
[lat, lng]: [number, number],
country: string,
tripId: number,
date: string
): JourneyStats['points'][number] => ({
lat,
lng,
label,
date,
country,
tripId,
photoId: null,
entryId: null,
});
const stats: JourneyStats = {
journeyId: 5,
distance: 4_000_000,
days: 21,
steps: 6,
photos: 40,
places: 12,
furthest: 3_000_000,
countries: [
{ code: 'IS', name: 'Iceland', places: 2, firstVisit: '2026-06-02' },
{ code: 'PT', name: 'Portugal', places: 2, firstVisit: '2026-09-04' },
],
points: [
{ ...stop('Reykjavík', [64.14, -21.94], 'IS', ICELAND, '2026-06-02'), photoId: 3 },
stop('Akureyri', [65.68, -18.12], 'IS', ICELAND, '2026-06-06'),
stop('Lisboa', [38.72, -9.14], 'PT', PORTUGAL, '2026-09-04'),
stop('Porto', [41.15, -8.61], 'PT', PORTUGAL, '2026-09-08'),
],
// Named apart from the countries, so a trip card and a country card never share a name.
trips: [
{ id: ICELAND, title: 'June', start: '2026-06-02', end: '2026-06-14', points: 2 },
{ id: PORTUGAL, title: 'September', start: '2026-09-04', end: '2026-09-12', points: 2 },
],
excluded: [],
start: '2026-06-02',
end: '2026-09-12',
};
/** The recorded way, as the hook hands it to the panel. */
const PATH: [number, number][][] = [
[
[64.14, -21.94],
[65.68, -18.12],
],
[
[38.72, -9.14],
[41.15, -8.61],
],
];
/** What the panel hands the factories: `t` answers with the key in this suite. */
const travel: BookTravelContext = {
locale: 'en',
stats,
path: PATH,
entries: [],
labels: {
day: 'journey.studio.day',
stations: 'journey.studio.stations',
summary: 'journey.studio.summary',
countries: 'journey.studio.countries',
distance: 'journey.studio.metric.distance',
pros: 'journey.editor.pros',
cons: 'journey.editor.cons',
},
};
/** A size centred across the open spread, the panel's own placement. */
const spreadCentre = (size: { w: number; h: number }): BookFrame => ({
x: (page.pageWidth * 2 - size.w) / 2,
y: (page.pageHeight - size.h) / 2,
w: size.w,
h: size.h,
});
const ANY_FRAME: BookFrame = { x: 0, y: 0, w: 1, h: 1 };
/** An element apart from what the panel decides itself: the id it mints and where the frame goes. */
const content = (el: BookElement) => ({ ...el, id: '', frame: ANY_FRAME });
/** The panel on a journey, its words answered by `t`: the key itself unless a case says otherwise. */
function open(journey: JourneyStats = stats, t: (k: string) => string = (k) => k) {
return render(
<StudioTravelPanel
page={page}
stats={journey}
path={PATH}
t={t}
locale="en"
canEdit={false}
onToggleStop={async () => true}
/>
);
}
const card = (name: string) => screen.getByRole('button', { name });
/** The element the last click put on the page. */
function placed(): BookElement {
const elements = useStudioStore.getState().doc?.spreads[0]?.elements ?? [];
const last = elements[elements.length - 1];
if (!last) throw new Error('nothing was placed');
return last;
}
beforeEach(() => {
useStudioStore.getState().load({
version: 1,
title: 'T',
page,
spreads: [{ id: 's1', role: 'inner', background: null, elements: [], parked: [], entryId: null }],
} as unknown as BookDocument);
});
/** Open one of the panel's tabs by its name. */
const tab = (key: string) => fireEvent.click(screen.getByRole('tab', { name: key }));
describe('the Travel panel builds through the book engine', () => {
it('STTRV-001: places a summary in the look of its card, with the figures the engine resolves', () => {
open();
fireEvent.click(card('journey.studio.statsLook.poster'));
const engine = bookTravelStats(stats, {
id: 'st',
frame: ANY_FRAME,
metrics: ['distance', 'days', 'countries', 'photos'],
});
expect(content(placed())).toEqual(content({ ...engine, look: 'poster' as const }));
});
it('STTRV-002: offers each figure on its own as a medal, an odometer and a sketch', () => {
open();
const days = screen.getAllByRole('button', { name: 'journey.studio.metric.days' });
expect(days).toHaveLength(3);
const looks = days.map((button) => {
fireEvent.click(button);
return placed() as { look?: string; metrics?: string[] };
});
expect(looks.map((el) => el.look)).toEqual(['medal', 'odometer', 'sketch']);
expect(looks.every((el) => el.metrics?.join() === 'days')).toBe(true);
});
it('STTRV-003: places the journey map with its stops numbered, its ends marked or on the dark ground', () => {
open();
tab('journey.studio.travelMaps');
fireEvent.click(card('journey.studio.pinNumbered'));
const engine = bookTravelMap(travel, stats, { id: 'mp', frame: ANY_FRAME, scope: 'journey' });
expect(content(placed())).toEqual(content({ ...engine, style: 'minimal' as const, pinStyle: 'numbered' as const }));
expect(placed()).toMatchObject({ kind: 'map', source: 'vector', tripId: null, path: PATH });
expect(placed().frame).toEqual(spreadCentre(bookTravelSize('map', page)));
fireEvent.click(card('journey.studio.pinEnds'));
expect(placed()).toMatchObject({ pinStyle: 'ends' });
fireEvent.click(card('journey.studio.mapStyle.dark'));
expect(placed()).toMatchObject({ style: 'dark', pinStyle: 'numbered' });
});
it('STTRV-004: places a map per trip of that trip alone, numbered, with no journey trail', () => {
open();
tab('journey.studio.travelMaps');
fireEvent.click(card('June'));
expect(placed()).toMatchObject({
tripId: ICELAND,
countries: ['IS'],
path: [],
pinStyle: 'numbered',
points: [{ label: 'Reykjavík', photoId: 3 }, { label: 'Akureyri' }],
});
});
it('STTRV-005: places the satellite map the engine places, with its tiles, its credit and a white line', () => {
open();
tab('journey.studio.travelMaps');
fireEvent.click(screen.getAllByRole('button', { name: 'journey.studio.mapSourceSatellite' })[0] as HTMLElement);
const engine = bookTravelMap(travel, stats, { id: 'mp', frame: ANY_FRAME, scope: 'journey', source: 'satellite' });
expect(content(placed())).toEqual(content({ ...engine, style: 'minimal' as const }));
expect(placed()).toMatchObject({ source: 'tiles', tileUrl: BOOK_SATELLITE_TILES, accent: '#ffffff' });
});
it('STTRV-006: places a map of one country with its stops alone, cut to its shape on a dark ground', () => {
open();
tab('journey.studio.travelMaps');
fireEvent.click(screen.getAllByRole('button', { name: 'journey.studio.mapClipCountry' })[0] as HTMLElement);
expect(placed()).toMatchObject({
clip: 'country',
style: 'dark',
pinStyle: 'numbered',
countries: ['IS'],
path: [],
points: [{ label: 'Reykjavík' }, { label: 'Akureyri' }],
});
});
it('STTRV-007: offers every country in every look, each placing that country alone', () => {
open();
tab('journey.studio.countries');
const looks = ['stamp', 'visa', 'postcard', 'flag', 'pin', 'label', 'sketch'] as const;
for (const look of looks) {
const [iceland] = screen.getAllByRole('button', { name: `journey.studio.countryLook.${look}` });
fireEvent.click(iceland as HTMLElement);
const engine = bookTravelCountries(travel, { id: 'co', frame: ANY_FRAME, codes: ['IS'] });
expect(placed()).toMatchObject({ kind: 'countries', codes: engine.codes, names: engine.names, look });
}
});
it('STTRV-008: places each mark in its style with the words the engine gives it', () => {
open();
tab('journey.studio.marks');
const [stamp] = screen.getAllByRole('button', { name: 'journey.studio.dateMark' });
fireEvent.click(stamp as HTMLElement);
const value = bookBadgeValue(travel, 'date', null);
if (!value) throw new Error('the engine has no date mark for this journey');
expect(content(placed())).toEqual(
content({ ...bookTravelBadge({ id: 'bd', frame: ANY_FRAME, variant: 'date', value }), style: 'stamp' as const })
);
const styles = screen.getAllByRole('button', { name: 'journey.studio.distanceMark' }).map((button) => {
fireEvent.click(button);
return (placed() as { style?: string }).style;
});
expect(styles).toEqual(['tag', 'pill', 'ribbon', 'sticker', 'patch', 'sketch']);
expect(placed()).toMatchObject({ variant: 'distance', text: '4,000 km' });
});
it('STTRV-009: offers no date mark without a start, and the day where its word puts it', () => {
open({ ...stats, start: null }, (k) => (k === 'journey.studio.day' ? '{day}日目' : k));
tab('journey.studio.marks');
expect(screen.queryAllByRole('button', { name: 'journey.studio.dateMark' })).toHaveLength(0);
fireEvent.click(screen.getAllByRole('button', { name: 'journey.studio.dayMark' })[0] as HTMLElement);
expect(placed()).toMatchObject({ variant: 'day', text: '1日目', style: 'stamp' });
});
it("STTRV-010: dresses what it places in the book's theme, linked so it follows a change of theme", () => {
const theme = bookThemeOf('nordic');
useStudioStore.getState().load({
version: 1,
title: 'T',
page,
theme,
spreads: [{ id: 's1', role: 'inner', background: null, elements: [], parked: [], entryId: null }],
} as unknown as BookDocument);
open();
fireEvent.click(card('journey.studio.statsLook.tiles'));
expect(placed()).toMatchObject({
accent: theme.palette.accent,
accentToken: 'accent',
token: 'ink',
fontRole: 'heading',
});
fireEvent.click(card('journey.studio.statsLook.note'));
expect(placed()).toMatchObject({ fontRole: 'script' });
});
});