From 317c1ee5adaa397db777810bfa743d43289fc7a8 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 23 Oct 2026 04:21:01 +0000 Subject: [PATCH] fix(journey): the day mark reads as its language writes it, and a country mark names its country by one rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The day word of every language now says where the number goes ("DAY {day}", "{day}日目", "第{day}天", "{day}. GÜN"), and one helper builds the mark for the engine, the auto layout, a template and the Travel panel, so Japanese, Korean and Chinese no longer read "日目 3". A word stored without the placeholder is followed by the number as before. The panel's own day word is gone with its only reader. A country mark carries the name as its words beside the outline and a flag mark sets it small beside the flag, the same for the engine's value, the panel's tile, the assistant's add and a template's country mark; a flag in a template keeps the place its name was drawn in. A flag or country mark asked for by its own code is named after that country without the journey having to name one. --- .../components/Studio/StudioTravelPanel.tsx | 16 ++-- .../pages/journeyStudio/useJourneyStudio.ts | 2 + .../unit/studio/travelPanelEngine.test.tsx | 31 ++++++-- .../nest/journey-book-edit.book-level.test.ts | 2 +- .../nest/journey/book-travel-labels.test.ts | 9 ++- shared/src/book/auto/auto-elements.ts | 10 ++- shared/src/book/auto/auto-spreads.ts | 4 +- shared/src/book/auto/auto-template-apply.ts | 31 +++++--- shared/src/book/auto/auto-types.ts | 6 +- shared/src/book/book-element-ops.spec.ts | 15 +++- shared/src/book/book-ops-elements.ts | 31 ++++---- shared/src/book/book-travel.spec.ts | 75 ++++++++++++++++++- shared/src/book/book-travel.ts | 33 +++++++- shared/src/i18n/ar/journey.ts | 3 +- shared/src/i18n/az/journey.ts | 3 +- shared/src/i18n/br/journey.ts | 3 +- shared/src/i18n/ca/journey.ts | 3 +- shared/src/i18n/cs/journey.ts | 3 +- shared/src/i18n/de/journey.ts | 3 +- shared/src/i18n/en/journey.ts | 3 +- shared/src/i18n/es/journey.ts | 3 +- shared/src/i18n/et/journey.ts | 3 +- shared/src/i18n/fr/journey.ts | 3 +- shared/src/i18n/gr/journey.ts | 3 +- shared/src/i18n/hu/journey.ts | 3 +- shared/src/i18n/id/journey.ts | 3 +- shared/src/i18n/it/journey.ts | 3 +- shared/src/i18n/ja/journey.ts | 3 +- shared/src/i18n/ko/journey.ts | 3 +- shared/src/i18n/nl/journey.ts | 3 +- shared/src/i18n/pl/journey.ts | 3 +- shared/src/i18n/ru/journey.ts | 3 +- shared/src/i18n/sk/journey.ts | 3 +- shared/src/i18n/sv/journey.ts | 3 +- shared/src/i18n/th/journey.ts | 3 +- shared/src/i18n/tr/journey.ts | 3 +- shared/src/i18n/uk/journey.ts | 3 +- shared/src/i18n/vi/journey.ts | 3 +- shared/src/i18n/zh-TW/journey.ts | 3 +- shared/src/i18n/zh/journey.ts | 3 +- 40 files changed, 231 insertions(+), 115 deletions(-) diff --git a/client/src/components/Studio/StudioTravelPanel.tsx b/client/src/components/Studio/StudioTravelPanel.tsx index f17f745c3..30455cd42 100644 --- a/client/src/components/Studio/StudioTravelPanel.tsx +++ b/client/src/components/Studio/StudioTravelPanel.tsx @@ -4,8 +4,8 @@ import type { BookBadgeVariant, BookElement, BookMapScope, BookMetric, BookPageSetup, BookTravelContext, JourneyStats, } from '@trek/shared' import { - BOOK_METRICS, bookBadgeValue, bookCountryName, bookElementId as uid, bookTravelBadge, bookTravelCountries, - bookTravelMap, bookTravelSize, bookTravelStats, + BOOK_METRICS, bookBadgeValue, bookCountryMark, bookCountryName, bookElementId as uid, bookTravelBadge, + bookTravelCountries, bookTravelMap, bookTravelSize, bookTravelStats, } from '@trek/shared' import { useStudioStore } from '../../store/studioStore' import { PanelHead } from './StudioPanelHead' @@ -170,16 +170,14 @@ export function StudioTravelPanel({ * What the book engine's factories read: the journey, its recorded way and * the words in the reader's language. Every tile below is built by the * factory the engine's `add` calls. Where it lands is the panel's own: - * centred across the spread, where `add` takes the right page. Two words are - * still the panel's too, so a mark placed here and one the assistant adds - * can read differently: the country mark carries the name in `text`, where - * the engine's value has it in `sub`, and the day mark says `dayWord`, where - * the engine says `day` (the two differ in ja, ko, zh and zh-TW). + * centred across the spread, where `add` takes the right page. The day word + * is the engine's too, handed over as its template: `t` leaves the `{day}` + * in it untouched, and `bookDayText` puts the number there. */ const travel: BookTravelContext = { locale, stats, path, entries: [], labels: { - day: t('journey.studio.dayWord'), stations: t('journey.studio.stations'), summary: t('journey.studio.summary'), + day: t('journey.studio.day'), stations: t('journey.studio.stations'), summary: t('journey.studio.summary'), countries: t('journey.studio.countries'), distance: t('journey.studio.metric.distance'), pros: t('journey.editor.pros'), cons: t('journey.editor.cons'), }, @@ -564,7 +562,7 @@ export function StudioTravelPanel({ */}
{stats.countries.slice(0, 12).map(c => { - const el = badgeEl('country', { text: countryName(c.code), sub: '', code: c.code }) + const el = badgeEl('country', bookCountryMark('country', c.code, countryName(c.code))) return ( place(el)}> diff --git a/client/src/pages/journeyStudio/useJourneyStudio.ts b/client/src/pages/journeyStudio/useJourneyStudio.ts index db561e6f6..f720b10c0 100644 --- a/client/src/pages/journeyStudio/useJourneyStudio.ts +++ b/client/src/pages/journeyStudio/useJourneyStudio.ts @@ -296,6 +296,8 @@ export function useJourneyStudio() { // line over a route somebody had actually walked. path, stationsLabel: t('journey.studio.stations'), + // The template as it stands ("DAY {day}", "{day}日目"): without params `t` + // leaves the `{day}` alone, and the layout puts each chip's number there. dayLabel: t('journey.studio.day'), summaryLabel: t('journey.studio.summary'), countriesLabel: t('journey.studio.countries'), diff --git a/client/tests/unit/studio/travelPanelEngine.test.tsx b/client/tests/unit/studio/travelPanelEngine.test.tsx index aabd293ed..c27414e55 100644 --- a/client/tests/unit/studio/travelPanelEngine.test.tsx +++ b/client/tests/unit/studio/travelPanelEngine.test.tsx @@ -1,6 +1,6 @@ -// STTRV-001 to STTRV-006: the Travel panel places what the book engine's factories build, so a figure, a map, a -// country or a mark added by hand is the element the assistant's `add` adds. Only the frame is the panel's own: -// centred across the spread rather than on the right-hand page. +// STTRV-001 to STTRV-006 and STTRV-009: the Travel panel places what the book engine's factories build, so a figure, +// a map, a country or a mark added by hand is the element the assistant's `add` adds. Only the frame is the panel's +// own: centred across the spread rather than on the right-hand page. import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { BookDocument, BookElement, BookFrame, BookTravelContext, JourneyStats } from '@trek/shared'; @@ -94,7 +94,7 @@ const travel: BookTravelContext = { path: PATH, entries: [], labels: { - day: 'journey.studio.dayWord', + day: 'journey.studio.day', stations: 'journey.studio.stations', summary: 'journey.studio.summary', countries: 'journey.studio.countries', @@ -117,13 +117,14 @@ 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 }); -function open(journey: JourneyStats = stats) { +/** 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( k} + t={t} locale="en" canEdit={false} onToggleStop={async () => true} @@ -225,7 +226,7 @@ describe('the Travel panel builds through the book engine', () => { expect(screen.queryByRole('button', { name: 'journey.studio.dateMark' })).toBeNull(); fireEvent.click(card('journey.studio.dayMark')); - expect(placed()).toMatchObject({ variant: 'day', text: 'journey.studio.dayWord 1', style: 'chip' }); + expect(placed()).toMatchObject({ variant: 'day', text: 'journey.studio.day 1', style: 'chip' }); const [outline, mark] = screen.getAllByRole('button', { name: 'Iceland' }); fireEvent.click(outline); @@ -237,4 +238,20 @@ describe('the Travel panel builds through the book engine', () => { fireEvent.click(mark); expect(placed()).toMatchObject({ kind: 'badge', variant: 'country', text: 'Iceland', sub: '', code: 'IS' }); }); + + it('STTRV-009: places a country mark as the engine makes one, and the day where its word puts it', () => { + open(stats, (k) => (k === 'journey.studio.day' ? '{day}日目' : k)); + + const [, mark] = screen.getAllByRole('button', { name: 'Iceland' }); + fireEvent.click(mark); + const value = bookBadgeValue(travel, 'country', null); + expect(value).toEqual({ text: 'Iceland', sub: '', code: 'IS' }); + if (!value) throw new Error('the engine has no country mark for this journey'); + const engine = bookTravelBadge({ id: 'bd', frame: ANY_FRAME, variant: 'country', value }); + expect(content(placed())).toEqual(content(engine)); + expect(placed().frame).toEqual(spreadCentre(bookTravelSize('badge', page, { variant: 'country' }))); + + fireEvent.click(card('journey.studio.dayMark')); + expect(placed()).toMatchObject({ variant: 'day', text: '1日目' }); + }); }); diff --git a/server/tests/unit/nest/journey-book-edit.book-level.test.ts b/server/tests/unit/nest/journey-book-edit.book-level.test.ts index b770c334f..9726ec333 100644 --- a/server/tests/unit/nest/journey-book-edit.book-level.test.ts +++ b/server/tests/unit/nest/journey-book-edit.book-level.test.ts @@ -214,7 +214,7 @@ describe('what the engine reads', () => { expect(travel.entries.map((e) => [e.id, e.title, e.mood])).toEqual([[entry.id, 'Alfama', 'amazing']]); expect(travel.locale).toBe('de-DE'); - expect(travel.labels).toMatchObject({ day: 'TAG', stations: 'Stationen', pros: 'Pro', cons: 'Contra' }); + expect(travel.labels).toMatchObject({ day: 'TAG {day}', stations: 'Stationen', pros: 'Pro', cons: 'Contra' }); expect(travel.path).toEqual([]); expect((await edit.wordsFor(owner.id)).locale).toBe('de-DE'); }); diff --git a/server/tests/unit/nest/journey/book-travel-labels.test.ts b/server/tests/unit/nest/journey/book-travel-labels.test.ts index b91a935d4..046f5b847 100644 --- a/server/tests/unit/nest/journey/book-travel-labels.test.ts +++ b/server/tests/unit/nest/journey/book-travel-labels.test.ts @@ -19,12 +19,17 @@ describe('the book words', () => { it('JBLBL-002: the words come from the locale table, in the locale the book formats in', async () => { const french = await loadBookWords('fr'); expect(french.locale).toBe('fr-FR'); - expect(french.labels).toMatchObject({ day: 'JOUR', stations: 'Étapes', summary: 'Résumé', countries: 'Pays' }); + expect(french.labels).toMatchObject({ + day: 'JOUR {day}', + stations: 'Étapes', + summary: 'Résumé', + countries: 'Pays', + }); const english = await loadBookWords('xx'); expect(english).toEqual({ locale: 'en-US', labels: { - day: 'DAY', + day: 'DAY {day}', stations: 'Stations', summary: 'Summary', countries: 'Countries', diff --git a/shared/src/book/auto/auto-elements.ts b/shared/src/book/auto/auto-elements.ts index 45c1438e5..253ef23ec 100644 --- a/shared/src/book/auto/auto-elements.ts +++ b/shared/src/book/auto/auto-elements.ts @@ -256,10 +256,12 @@ export function coordsMark( return badgeEl(newId, frame, 'coords', { text: formatBookCoords(lat, lng) }, { style: 'plain', textScale: 0.7 }); } -/** The "DAY 5" chip. Small, solid, and the only filled shape on most pages. */ -export function dayChip(newId: NewId, frame: BookFrame, day: number | null | undefined, label: string) { - if (!day) return null; - return badgeEl(newId, frame, 'day', { text: `${label} ${day}` }, { style: 'chip', textScale: 0.75 }); +/** + * The "DAY 5" chip, its words made by `bookDayText`. Small, solid, and the + * only filled shape on most pages. + */ +export function dayChip(newId: NewId, frame: BookFrame, text: string) { + return badgeEl(newId, frame, 'day', { text }, { style: 'chip', textScale: 0.75 }); } /** A rule along the foot with a dot showing how far into the journey this is. */ diff --git a/shared/src/book/auto/auto-spreads.ts b/shared/src/book/auto/auto-spreads.ts index afdad51ac..b36f42b53 100644 --- a/shared/src/book/auto/auto-spreads.ts +++ b/shared/src/book/auto/auto-spreads.ts @@ -1,4 +1,5 @@ import { formatBookDate } from '../book-text'; +import { bookDayText } from '../book-travel'; import type { BookElement, BookSpread } from '../book.schema'; import { ACCENT, @@ -138,8 +139,7 @@ function footMarks(ctx: EntryContext, els: BookElement[], at: { x: number; y: nu const t = total && total > 1 ? Math.min(1, Math.max(0, (day - 1) / (total - 1))) : 0; const cw = 24; const cx = Math.min(x + w - cw, Math.max(x, x + w * t - cw / 2)); - const chip = dayChip(newId, { x: cx, y: y - 11, w: cw, h: 7 }, day, input.dayLabel); - if (chip) els.push(chip); + els.push(dayChip(newId, { x: cx, y: y - 11, w: cw, h: 7 }, bookDayText(input.dayLabel, day))); } /** The story, bound to the entry, set in a column. */ diff --git a/shared/src/book/auto/auto-template-apply.ts b/shared/src/book/auto/auto-template-apply.ts index ff007008f..fd9f7cc6a 100644 --- a/shared/src/book/auto/auto-template-apply.ts +++ b/shared/src/book/auto/auto-template-apply.ts @@ -1,4 +1,5 @@ import { bookCoordValue, formatBookCoords, formatBookDate } from '../book-text'; +import { bookCountryMark, bookCountryName, bookDayText } from '../book-travel'; import { MAX_TEXT_LENGTH, type BookBadgeElement, @@ -32,7 +33,7 @@ export interface AutoTemplateContext { page: BookPageSetup; locale: string; stats: JourneyStats | null; - /** Already translated, because this module has no translation context. */ + /** The day mark's label ("DAY {day}"), already translated, because this module has no translation context. */ dayLabel: string; newId: NewId; } @@ -143,14 +144,11 @@ function fillText( * answer keeps the one it was drawn with. */ function fillBadge(el: BookBadgeElement, entry: AutoEntry, ctx: AutoTemplateContext): void { - if (el.variant === 'day' && entry.dayNumber) el.text = `${ctx.dayLabel} ${entry.dayNumber}`; + if (el.variant === 'day' && entry.dayNumber) el.text = bookDayText(ctx.dayLabel, entry.dayNumber); if (el.variant === 'coords' && entry.lat != null && entry.lng != null) { el.text = formatBookCoords(entry.lat, entry.lng); } - if ((el.variant === 'flag' || el.variant === 'country') && entry.country) { - el.code = entry.country; - el.text = countryName(entry.country, ctx).toUpperCase(); - } + if ((el.variant === 'flag' || el.variant === 'country') && entry.country) fillCountry(el, entry.country, ctx); if (el.variant === 'date' && entry.date) { const d = new Date(`${entry.date}T00:00:00`); if (!Number.isNaN(d.getTime())) { @@ -163,11 +161,20 @@ function fillBadge(el: BookBadgeElement, entry: AutoEntry, ctx: AutoTemplateCont } } -function countryName(code: string, ctx: AutoTemplateContext): string { - const known = ctx.stats?.countries.find((c) => c.code === code); - try { - return new Intl.DisplayNames([ctx.locale], { type: 'region' }).of(code) ?? known?.name ?? code; - } catch { - return known?.name ?? code; +/** + * A country mark takes the rule the engine and the Travel panel follow + * (`bookCountryMark`): the name as its words, and the drawn words beside them + * go. A flag keeps the place its template set the name in: in capitals as its + * words, the way the auto layout sets a flag, or beside the flag (`sub`) where + * it was placed from the panel, so no drawn name is left next to the new one. + */ +function fillCountry(el: BookBadgeElement, code: string, ctx: AutoTemplateContext): void { + const name = bookCountryName(code, ctx.locale, ctx.stats); + if (el.variant === 'country') { + Object.assign(el, bookCountryMark('country', code, name)); + return; } + el.code = code; + if (el.text || !el.sub) el.text = name.toUpperCase(); + else el.sub = name; } diff --git a/shared/src/book/auto/auto-types.ts b/shared/src/book/auto/auto-types.ts index ddf7fbc90..c05857393 100644 --- a/shared/src/book/auto/auto-types.ts +++ b/shared/src/book/auto/auto-types.ts @@ -67,7 +67,11 @@ export interface AutoInput { stats: JourneyStats | null; /** The heading over a list of bare stops, already translated. */ stationsLabel: string; - /** The word before a day number on a chip ("DAY 5"). Already translated. */ + /** + * The words of a day chip, already translated: `{day}` stands where the + * number goes ("DAY {day}", "{day}日目"), and a word without it is followed + * by the number ("DAY 5"). See `bookDayText`. + */ dayLabel: string; /** * The real geometry of the route, when the journey has any (GPX and KML diff --git a/shared/src/book/book-element-ops.spec.ts b/shared/src/book/book-element-ops.spec.ts index 56098ed61..efff436e6 100644 --- a/shared/src/book/book-element-ops.spec.ts +++ b/shared/src/book/book-element-ops.spec.ts @@ -183,11 +183,20 @@ describe('add', () => { }); it('BOOKEL-017: a flag or country mark asked for by its own code is labelled with that country', () => { - expect(added({ kind: 'badge', variant: 'flag' })).toMatchObject({ code: 'IS', sub: 'Iceland' }); - expect(added({ kind: 'badge', variant: 'flag', code: 'PT' })).toMatchObject({ code: 'PT', sub: 'Portugal' }); + // A flag sets the name beside the flag (sub); a country mark has it as its words, the outline beside them. + expect(added({ kind: 'badge', variant: 'flag' })).toMatchObject({ code: 'IS', text: '', sub: 'Iceland' }); + const portugal = added({ kind: 'badge', variant: 'flag', code: 'PT' }); + expect(portugal).toMatchObject({ code: 'PT', text: '', sub: 'Portugal' }); + expect(added({ kind: 'badge', variant: 'country' })).toMatchObject({ code: 'IS', text: 'Iceland', sub: '' }); const norway = added({ kind: 'badge', variant: 'country', entryId: 7, code: 'NO' }); - expect(norway).toMatchObject({ code: 'NO', sub: 'Norway' }); + expect(norway).toMatchObject({ code: 'NO', text: 'Norway', sub: '' }); expect(added({ kind: 'badge', variant: 'flag', code: 'PT', sub: 'Lisboa' })).toMatchObject({ sub: 'Lisboa' }); + const lisboa = added({ kind: 'badge', variant: 'country', code: 'PT', text: 'Lisboa' }); + expect(lisboa).toMatchObject({ code: 'PT', text: 'Lisboa', sub: '' }); + // Its own code names the country on a journey whose figures name none; without one there is nothing to show. + const noCountries: BookOpsContext = { ...ctx, travel: { ...travel, stats: { ...stats, countries: [] } } }; + expect(added({ kind: 'badge', variant: 'flag', code: 'PT' }, noCountries)).toMatchObject({ sub: 'Portugal' }); + expect(addRefusal({ kind: 'badge', variant: 'flag' }, noCountries)).toBe('text_required'); // Another mark's code is its own (a mood, the weather) and names no country. expect(added({ kind: 'badge', variant: 'mood', code: 'good', text: 'Good' })).toMatchObject({ sub: '' }); }); diff --git a/shared/src/book/book-ops-elements.ts b/shared/src/book/book-ops-elements.ts index a4a89d21b..39447300f 100644 --- a/shared/src/book/book-ops-elements.ts +++ b/shared/src/book/book-ops-elements.ts @@ -1,5 +1,5 @@ import { relayoutSpread } from './auto/auto-book'; -import type { AutoInput } from './auto/auto-types'; +import type { AutoEntry, AutoInput } from './auto/auto-types'; import { bookElementPropsSchema, type BOOK_ELEMENT_OP_SCHEMAS, type BookNewElement } from './book-element-ops.schema'; import { bookGridElements, defaultBookGridBox, findBookGrid } from './book-grids'; import { @@ -15,6 +15,7 @@ import { } from './book-ops-core'; import { bookBadgeValue, + bookCountryMark, bookCountryName, bookEntryListItems, bookTravelBadge, @@ -334,7 +335,7 @@ function newBadge(spec: Extract, id: string, at: if (spec.entryId !== undefined && !entry) { throw failOp('entry_not_found', `entry ${spec.entryId} is not in this journey`, env.opIndex); } - const resolved = travel ? bookBadgeValue(travel, spec.variant, entry) : null; + const resolved = travel ? badgeValue(spec, travel, entry) : null; const ownCode = spec.variant === 'mood' ? entry?.mood : spec.variant === 'weather' ? entry?.weather : undefined; const text = spec.text ?? resolved?.text; if (text === undefined || (!text && !resolved)) { @@ -343,29 +344,25 @@ function newBadge(spec: Extract, id: string, at: } const value = { text, - sub: spec.sub ?? badgeSub(spec, resolved?.sub, resolved?.code, travel) ?? '', + sub: spec.sub ?? resolved?.sub ?? '', code: spec.code ?? ownCode ?? resolved?.code ?? null, }; const frame = at(bookTravelSize('badge', env.page, { variant: spec.variant })); return bookTravelBadge({ id, frame, variant: spec.variant, value }); } -/** The marks whose code names a country. */ -const COUNTRY_MARKS: ReadonlySet = new Set(['flag', 'country']); - /** - * A flag or country mark asked for by its own code is labelled with that - * country; the journey's value names its first country, the one a mark - * without a code shows. + * The words the journey gives a mark. A flag or country mark asked for by its + * own code is labelled with that country, its name where the mark's rule puts + * it (`bookCountryMark`); without a code it shows the entry's country or the + * journey's first. */ -function badgeSub( - spec: Extract, - sub: string | undefined, - code: string | null | undefined, - travel: BookTravelContext | undefined, -): string | undefined { - if (!travel || !spec.code || !COUNTRY_MARKS.has(spec.variant)) return sub; - return spec.code === code ? sub : bookCountryName(spec.code, travel.locale, travel.stats); +function badgeValue(spec: Extract, travel: BookTravelContext, entry: AutoEntry | null) { + const { variant, code } = spec; + if (code && (variant === 'flag' || variant === 'country')) { + return bookCountryMark(variant, code, bookCountryName(code, travel.locale, travel.stats)); + } + return bookBadgeValue(travel, variant, entry); } function newList(spec: Extract, id: string, at: At, env: ElementOpEnv): BookElement { diff --git a/shared/src/book/book-travel.spec.ts b/shared/src/book/book-travel.spec.ts index ca59aca41..6bff1b6dc 100644 --- a/shared/src/book/book-travel.spec.ts +++ b/shared/src/book/book-travel.spec.ts @@ -1,7 +1,11 @@ +import { buildBook } from './auto/auto-book'; +import { applyAutoTemplate } from './auto/auto-template-apply'; import type { AutoEntry } from './auto/auto-types'; import { bookBadgeValue, + bookCountryMark, bookCountryName, + bookDayText, bookEntryListItems, bookTravelCountries, bookTravelMap, @@ -13,7 +17,7 @@ import { routeFor, type BookTravelContext, } from './book-travel'; -import { bookElementSchema, bookPageSetupSchema, type BookElement } from './book.schema'; +import { bookBadgeElementSchema, bookElementSchema, bookPageSetupSchema, type BookElement } from './book.schema'; import type { JourneyStats } from './journey-stats.schema'; import { describe, expect, it } from 'vitest'; @@ -173,4 +177,73 @@ describe('book travel', () => { expect(refreshPatch(map, moved)).toMatchObject({ countries: ['IS'] }); expect(isStale(figures, null)).toBe(false); }); + + it('BOOKTRV-008: puts the day number where the label says, and after a label that does not say', () => { + expect(bookDayText('DAY {day}', 5)).toBe('DAY 5'); + expect(bookDayText('{day}日目', 3)).toBe('3日目'); + expect(bookDayText('第{day}天', 12)).toBe('第12天'); + expect(bookDayText('DAY', 5)).toBe('DAY 5'); + const japanese: BookTravelContext = { ...ctx, labels: { ...ctx.labels, day: '{day}日目' } }; + expect(bookBadgeValue(japanese, 'day', entry)).toEqual({ text: '5日目', sub: '', code: null }); + expect(bookBadgeValue(japanese, 'day', null)?.text).toBe('1日目'); + // The auto layout's chip at the foot of an entry says it the same way. + const doc = buildBook({ + locale: 'ja', + title: 'North', + subtitle: null, + coverPhotoId: null, + entries: [{ ...entry, story: 'Whales off the harbour.' }], + page: bookPageSetupSchema.parse({}), + stats, + stationsLabel: 'Stations', + dayLabel: '{day}日目', + summaryLabel: 'Summary', + countriesLabel: 'Countries', + templates: [], + }); + const elements = doc.spreads.flatMap((s) => s.elements); + const days = elements.flatMap((e) => (e.kind === 'badge' && e.variant === 'day' ? [e.text] : [])); + expect(days).toEqual(['5日目']); + }); + + it('BOOKTRV-009: names the country as the words of a country mark and beside the flag of a flag mark', () => { + expect(bookBadgeValue(ctx, 'country', entry)).toEqual({ text: 'Iceland', sub: '', code: 'IS' }); + expect(bookBadgeValue({ ...ctx, locale: 'de' }, 'country', null)).toEqual({ text: 'Island', sub: '', code: 'IS' }); + expect(bookBadgeValue(ctx, 'flag', null)).toEqual({ text: '', sub: 'Iceland', code: 'IS' }); + expect(bookBadgeValue({ ...ctx, stats: null }, 'country', null)).toBeNull(); + expect(bookCountryMark('country', 'PT', 'Portugal')).toEqual({ text: 'Portugal', sub: '', code: 'PT' }); + expect(bookCountryMark('flag', 'PT', 'Portugal')).toEqual({ text: '', sub: 'Portugal', code: 'PT' }); + }); + + it('BOOKTRV-010: fills the day and country marks of a template by the same rules, a flag where it was drawn', () => { + const frame = { x: 0.1, y: 0.1, w: 0.2, h: 0.05 }; + const mark = (variant: 'day' | 'country' | 'flag', text: string, sub: string) => + bookBadgeElementSchema.parse({ id: variant, kind: 'badge', variant, text, sub, frame }); + // Flags set the auto layout's way (the name as its words, or no words yet) and one placed from the panel. + const template = { + id: 'marks', + background: null, + elements: [ + mark('day', 'DAY 1', ''), + mark('country', 'FRANCE', 'Paris'), + mark('flag', 'FRANCE', ''), + mark('flag', '', ''), + mark('flag', '', 'France'), + ], + }; + const fill = { + page: bookPageSetupSchema.parse({}), + locale: 'en', + stats, + dayLabel: '{day}日目', + newId: (p: string) => p, + }; + expect(applyAutoTemplate(template, entry, fill).elements).toMatchObject([ + { variant: 'day', text: '5日目' }, + { variant: 'country', text: 'Iceland', sub: '', code: 'IS' }, + { variant: 'flag', text: 'ICELAND', sub: '', code: 'IS' }, + { variant: 'flag', text: 'ICELAND', sub: '', code: 'IS' }, + { variant: 'flag', text: '', sub: 'Iceland', code: 'IS' }, + ]); + }); }); diff --git a/shared/src/book/book-travel.ts b/shared/src/book/book-travel.ts index 760903b32..ae63752af 100644 --- a/shared/src/book/book-travel.ts +++ b/shared/src/book/book-travel.ts @@ -121,6 +121,7 @@ export function refreshPatch(el: BookElement, stats: JourneyStats): Partial