From 8860e16149d6d9a1a42c9a29e9d44cedddb62fbe Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 17 Sep 2026 15:32:19 +0200 Subject: [PATCH] test(journey): cover the Dawarich fold, and mend what the switches and the wiring assumed Three things the CI would have caught. The settings dialog's switches are the shared ToggleSwitch now, which is a button with aria-pressed rather than a role="switch" with aria-checked, and three tests still looked for the old shape. The page's wiring test mocks the whole hook, so the day map the timeline reads was undefined there and every case in the file died on it. The mock carries it now, empty, because the fold has a suite of its own. That suite is new: what it pins is the fold rather than the rows inside it, which the panel's own tests already cover end to end. That it says nothing on a quiet day, that the line reads shut, that the count takes the singular at one, that it starts closed, that both actions reach the journal with the stay they belong to, and that the span follows the traveller's own clock. The grouping the hook does moved into the model beside the other pure shaping, where it is four cheap tests instead of a round trip through MSW: by day, in the order they were lived, only what is still waiting, and no bucket at all for a day whose stays are all handled. --- .../Dawarich/dawarichSuggestionModel.test.ts | 47 ++++++ .../Dawarich/dawarichSuggestionModel.ts | 24 +++ .../Journey/JourneyDayDawarich.test.tsx | 147 ++++++++++++++++++ .../JourneyDetailPageSettingsDialog.test.tsx | 13 +- .../pages/JourneyDetailPage.wiring.test.tsx | 4 + .../pages/journeyDetail/useJourneyDetail.ts | 13 +- 6 files changed, 232 insertions(+), 16 deletions(-) create mode 100644 client/src/components/Journey/JourneyDayDawarich.test.tsx diff --git a/client/src/components/Dawarich/dawarichSuggestionModel.test.ts b/client/src/components/Dawarich/dawarichSuggestionModel.test.ts index 4e49da102..b522483c1 100644 --- a/client/src/components/Dawarich/dawarichSuggestionModel.test.ts +++ b/client/src/components/Dawarich/dawarichSuggestionModel.test.ts @@ -7,6 +7,7 @@ import { formatDayOption, formatDuration, groupByDay, + openStaysByDate, timeRange, } from './dawarichSuggestionModel' @@ -166,3 +167,49 @@ describe('formatDayOption', () => { expect(formatDayOption(1, null, 'en-GB', t)).toEqual({ label: 'planner.dayN:{"n":1}', badge: undefined }) }) }) + +describe('openStaysByDate', () => { + it('FE-DAWARICH-SUGMODEL-015: buckets the open stays by their own local day', () => { + // What a journal timeline asks for: it draws one day and folds that day's stays into + // it, so it looks them up by date rather than walking a flat list per day. + const byDate = openStaysByDate([ + stay({ id: 1, localDate: '2026-09-10' }), + stay({ id: 2, localDate: '2026-09-11' }), + stay({ id: 3, localDate: '2026-09-10' }), + ]) + + expect([...byDate.keys()].sort()).toEqual(['2026-09-10', '2026-09-11']) + expect(byDate.get('2026-09-10')!.map(s => s.id)).toEqual([1, 3]) + }) + + it('FE-DAWARICH-SUGMODEL-016: within a day they are in the order they were lived', () => { + // A run of stays reads as an afternoon only in that order; whatever order the server + // sent them in is not it. + const byDate = openStaysByDate([ + stay({ id: 1, localDate: '2026-09-10', startedAt: '2026-09-10T16:00:00+02:00' }), + stay({ id: 2, localDate: '2026-09-10', startedAt: '2026-09-10T08:30:00+02:00' }), + stay({ id: 3, localDate: '2026-09-10', startedAt: '2026-09-10T12:15:00+02:00' }), + ]) + + expect(byDate.get('2026-09-10')!.map(s => s.id)).toEqual([2, 3, 1]) + }) + + it('FE-DAWARICH-SUGMODEL-017: only what is still waiting', () => { + // An accepted stay is an entry on that timeline already, and a dismissed one was waved + // away on purpose; offering either again is offering to do it twice. + const byDate = openStaysByDate([ + stay({ id: 1, localDate: '2026-09-10', state: 'accepted' }), + stay({ id: 2, localDate: '2026-09-10', state: 'dismissed' }), + stay({ id: 3, localDate: '2026-09-10' }), + ]) + + expect(byDate.get('2026-09-10')!.map(s => s.id)).toEqual([3]) + }) + + it('FE-DAWARICH-SUGMODEL-018: a day whose every stay is handled is not a day at all', () => { + // An empty bucket would draw a fold saying "0 stays" on a day that has none. + const byDate = openStaysByDate([stay({ id: 1, localDate: '2026-09-10', state: 'accepted' })]) + + expect(byDate.size).toBe(0) + }) +}) diff --git a/client/src/components/Dawarich/dawarichSuggestionModel.ts b/client/src/components/Dawarich/dawarichSuggestionModel.ts index dfe530969..7dbfe2179 100644 --- a/client/src/components/Dawarich/dawarichSuggestionModel.ts +++ b/client/src/components/Dawarich/dawarichSuggestionModel.ts @@ -45,6 +45,30 @@ export function groupByDay(suggestions: DawarichSuggestion[]): SuggestionDay[] { .reverse() } +/** + * The stays still waiting, by the local day they happened on. + * + * What a journal timeline needs: it draws one day at a time and folds that day's stays + * into it, so it asks by date rather than walking a flat list per day. Only `new` ones — + * an accepted stay is already an entry on that timeline and a dismissed one was waved + * away on purpose — and within a day the order is the order they were lived in, which is + * what makes a run of them read as an afternoon. + * + * A Map rather than the day list `groupByDay` builds: that one is ordered newest-first for + * a panel to print, and this one is looked up by the day the timeline is already drawing. + */ +export function openStaysByDate(suggestions: DawarichSuggestion[]): Map { + const byDate = new Map(); + for (const stay of suggestions) { + if (stay.state !== 'new') continue; + const bucket = byDate.get(stay.localDate); + if (bucket) bucket.push(stay); + else byDate.set(stay.localDate, [stay]); + } + for (const stays of byDate.values()) stays.sort((a, b) => (a.startedAt < b.startedAt ? -1 : 1)); + return byDate; +} + /** `HH:MM` out of an ISO timestamp, in the offset the timestamp carries. */ export function clockOf(iso: string): string { return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(iso) ? iso.slice(11, 16) : '' diff --git a/client/src/components/Journey/JourneyDayDawarich.test.tsx b/client/src/components/Journey/JourneyDayDawarich.test.tsx new file mode 100644 index 000000000..2b20b4c75 --- /dev/null +++ b/client/src/components/Journey/JourneyDayDawarich.test.tsx @@ -0,0 +1,147 @@ +// FE-JRN-DAYDAW-001 to FE-JRN-DAYDAW-008 +/** + * The fold a journal day carries for the stays Dawarich recorded on it. + * + * The rows inside it are `SuggestionRow`, which the panel's own suite pins end to end, so + * what is worth holding here is the fold: that it says nothing on a quiet day, that the + * line reads before it is opened, and that the two actions reach the journal with the + * stay they belong to. The whole point of this component is that forty stays no longer + * stand between a reader and their first entry, and that only works while it starts shut. + */ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, within } from '../../../tests/helpers/render' +import { fireEvent } from '@testing-library/react' +import type { DawarichSuggestion } from '@trek/shared' +import { useSettingsStore } from '../../store/settingsStore' +import JourneyDayDawarich from './JourneyDayDawarich' + +function stay(over: Partial & { id: number }): DawarichSuggestion { + const date = over.localDate ?? '2026-09-10' + return { + id: over.id, + sourceVisitId: String(over.id), + tripId: null, + tripTitle: null, + name: over.name ?? 'Cafe Reichard', + lat: 50.94, + lng: 6.96, + startedAt: over.startedAt ?? `${date}T10:15:00+02:00`, + endedAt: over.endedAt ?? `${date}T12:40:00+02:00`, + durationMinutes: over.durationMinutes ?? 145, + localDate: date, + sourceStatus: 'suggested', + confidence: null, + confidenceBand: null, + state: 'new', + target: null, + acceptedPlaceId: null, + acceptedJournalEntryId: null, + acceptedBucketListItemId: null, + sourceChanged: false, + sourceMissing: false, + matchedBucketListItemId: null, + matchedBucketListName: null, + countryCode: null, + firstSeenAt: `${date}T06:00:00Z`, + lastSeenAt: `${date}T06:00:00Z`, + ...over, + } as DawarichSuggestion +} + +function fold(suggestions: DawarichSuggestion[], over: Partial<{ busyId: number | null }> = {}) { + const onAccept = vi.fn() + const onDismiss = vi.fn() + const view = render( + , + ) + return { onAccept, onDismiss, ...view } +} + +const head = () => screen.getByRole('button', { expanded: false }) ?? screen.getByRole('button') + +describe('JourneyDayDawarich', () => { + it('FE-JRN-DAYDAW-001: a day with nothing pending draws nothing at all', () => { + // Not an empty row saying so: a connected, quiet integration is invisible, and this + // one would otherwise appear on every single day of the journey. + const { container } = fold([]) + + expect(container).toBeEmptyDOMElement() + }) + + it('FE-JRN-DAYDAW-002: shut, it says how many and which part of the day they cover', () => { + fold([ + stay({ id: 1, startedAt: '2026-09-10T08:00:00+02:00', endedAt: '2026-09-10T09:30:00+02:00' }), + stay({ id: 2, startedAt: '2026-09-10T14:00:00+02:00', endedAt: '2026-09-10T17:08:00+02:00' }), + ]) + + expect(screen.getByText('2 stays from Dawarich')).toBeInTheDocument() + // First arrival to last departure, not each stay's own range. + expect(screen.getByText('08:00 – 17:08')).toBeInTheDocument() + }) + + it('FE-JRN-DAYDAW-003: one stay takes the singular', () => { + // A single key with a placeholder would read "1 stays". + fold([stay({ id: 1 })]) + + expect(screen.getByText('1 stay from Dawarich')).toBeInTheDocument() + }) + + it('FE-JRN-DAYDAW-004: it starts shut, and the stays are not in the page until it opens', () => { + fold([stay({ id: 1, name: 'Cafe Reichard' })]) + + expect(screen.queryByText('Cafe Reichard')).toBeNull() + expect(head()).toHaveAttribute('aria-expanded', 'false') + + fireEvent.click(head()) + + expect(screen.getByText('Cafe Reichard')).toBeInTheDocument() + }) + + it('FE-JRN-DAYDAW-005: open, each stay is a row with its own two actions', () => { + const { onDismiss } = fold([stay({ id: 7, name: 'Cafe Reichard' })]) + fireEvent.click(head()) + + const row = screen.getByText('Cafe Reichard').closest('div')!.parentElement! + fireEvent.click(within(row).getByRole('button', { name: 'Not a place I visited' })) + + expect(onDismiss).toHaveBeenCalledTimes(1) + expect(onDismiss.mock.calls[0][0]).toMatchObject({ id: 7 }) + }) + + it('FE-JRN-DAYDAW-006: accepting hands back the stay it was asked about and where it goes', () => { + const { onAccept } = fold([stay({ id: 7, name: 'Cafe Reichard' })]) + fireEvent.click(head()) + + fireEvent.click(screen.getByRole('button', { name: /journal|journey/i })) + + expect(onAccept).toHaveBeenCalledTimes(1) + expect(onAccept.mock.calls[0][0]).toMatchObject({ id: 7 }) + expect(onAccept.mock.calls[0][1]).toBe('journal') + }) + + it('FE-JRN-DAYDAW-007: the stay a write is in flight for is the one that goes busy', () => { + fold([stay({ id: 7, name: 'Cafe Reichard' }), stay({ id: 8, name: 'Zum Treppchen' })], { busyId: 7 }) + fireEvent.click(head()) + + const busyRow = screen.getByText('Cafe Reichard').closest('div')!.parentElement! + const idleRow = screen.getByText('Zum Treppchen').closest('div')!.parentElement! + expect(within(busyRow).getAllByRole('button')[0]).toBeDisabled() + expect(within(idleRow).getAllByRole('button')[0]).not.toBeDisabled() + }) + + it('FE-JRN-DAYDAW-008: the span follows the traveller own clock', () => { + useSettingsStore.setState(s => ({ settings: { ...s.settings, time_format: '12h' } })) + fold([ + stay({ id: 1, startedAt: '2026-09-10T08:00:00+02:00', endedAt: '2026-09-10T09:30:00+02:00' }), + stay({ id: 2, startedAt: '2026-09-10T14:00:00+02:00', endedAt: '2026-09-10T17:08:00+02:00' }), + ]) + + expect(screen.getByText('8:00 AM – 5:08 PM')).toBeInTheDocument() + useSettingsStore.setState(s => ({ settings: { ...s.settings, time_format: '24h' } })) + }) +}) diff --git a/client/src/components/Journey/JourneyDetailPageSettingsDialog.test.tsx b/client/src/components/Journey/JourneyDetailPageSettingsDialog.test.tsx index 677f4240c..e16c22d4d 100644 --- a/client/src/components/Journey/JourneyDetailPageSettingsDialog.test.tsx +++ b/client/src/components/Journey/JourneyDetailPageSettingsDialog.test.tsx @@ -377,8 +377,11 @@ describe('JourneySettingsDialog', () => { it('FE-JRN-SETTINGS-023: the trip-GPX switch reads off by default and turns on in one click (#2194)', async () => { const { onRefresh, onSaved } = mountDialog() - const sw = screen.getByRole('switch', { name: /GPX/i }) - expect(sw).toHaveAttribute('aria-checked', 'false') + // The shared ToggleSwitch is a button with aria-pressed, not a role="switch" with + // aria-checked: the dialog drew its own switches by hand until they turned out to be + // white on near-white in the dark. + const sw = screen.getByRole('button', { name: /GPX/i }) + expect(sw).toHaveAttribute('aria-pressed', 'false') await userEvent.click(sw) @@ -393,8 +396,8 @@ describe('JourneySettingsDialog', () => { // The column is INTEGER, so what arrives over the wire is 1, not true. mountDialog(buildJourney({ show_trip_tracks: 1 })) - const sw = screen.getByRole('switch', { name: /GPX/i }) - expect(sw).toHaveAttribute('aria-checked', 'true') + const sw = screen.getByRole('button', { name: /GPX/i }) + expect(sw).toHaveAttribute('aria-pressed', 'true') await userEvent.click(sw) await waitFor(() => expect(updateJourney).toHaveBeenCalledWith(3, { show_trip_tracks: false })) @@ -404,7 +407,7 @@ describe('JourneySettingsDialog', () => { updateJourney.mockRejectedValueOnce(new Error('nope')) const { onClose } = mountDialog() - await userEvent.click(screen.getByRole('switch', { name: /GPX/i })) + await userEvent.click(screen.getByRole('button', { name: /GPX/i })) await waitFor(() => expect(toastSpy).toHaveBeenCalled()) expect(onClose).not.toHaveBeenCalled() diff --git a/client/src/pages/JourneyDetailPage.wiring.test.tsx b/client/src/pages/JourneyDetailPage.wiring.test.tsx index 593385ed8..52abdb574 100644 --- a/client/src/pages/JourneyDetailPage.wiring.test.tsx +++ b/client/src/pages/JourneyDetailPage.wiring.test.tsx @@ -100,6 +100,10 @@ function buildHook(over: Record = {}): Record hideSkeletons: false, setHideSkeletons: vi.fn(), query: '', setQuery: vi.fn(), dismissSuggestion: vi.fn(async () => {}), restoreSuggestions: vi.fn(async () => {}), openAtEntryId: null, + // The stays Dawarich recorded, by the day they fall on. Empty here: the fold each day + // carries has its own suite (FE-JRN-DAYDAW), and what this file pins is the wiring. + dawarichByDate: new Map(), dawarichBusyId: null, + acceptDawarich: vi.fn(async () => {}), dismissDawarich: vi.fn(), mapRef: { current: null }, fullMapRef: { current: null }, galleryUploadRef: { current: null }, galleryProviders: [], setGalleryProviders: vi.fn(), galleryBrowseRef: { current: null }, activeLocationId: null, handleMarkerClick: vi.fn(), handleLocationClick: vi.fn(), diff --git a/client/src/pages/journeyDetail/useJourneyDetail.ts b/client/src/pages/journeyDetail/useJourneyDetail.ts index dbb5efdba..69627f81c 100644 --- a/client/src/pages/journeyDetail/useJourneyDetail.ts +++ b/client/src/pages/journeyDetail/useJourneyDetail.ts @@ -13,6 +13,7 @@ import { lockBodyScroll } from '../../utils/bodyScrollLock' import type { JourneyEntry } from '../../store/journeyStore' import { createDraftJourneyEntry } from './JourneyDetailPage.helpers' import { useDawarichSuggestions } from '../../hooks/useDawarichSuggestions' +import { openStaysByDate } from '../../components/Dawarich/dawarichSuggestionModel' import type { DawarichSuggestion, DawarichSuggestionTarget } from '@trek/shared' import { useDawarichJournalTrail } from '../../hooks/useDawarichJournalTrail' @@ -312,17 +313,7 @@ export function useJourneyDetail() { * lived in, which is what makes a run of them read as an afternoon. */ const dawarich = useDawarichSuggestions() - const dawarichByDate = useMemo(() => { - const byDate = new Map() - for (const stay of dawarich.suggestions) { - if (stay.state !== 'new') continue - const bucket = byDate.get(stay.localDate) - if (bucket) bucket.push(stay) - else byDate.set(stay.localDate, [stay]) - } - for (const stays of byDate.values()) stays.sort((a, b) => (a.startedAt < b.startedAt ? -1 : 1)) - return byDate - }, [dawarich.suggestions]) + const dawarichByDate = useMemo(() => openStaysByDate(dawarich.suggestions), [dawarich.suggestions]) /** * Accepting writes the stay into THIS journal and reloads it, which is how the new entry