Tours: client tests for the new hooks and views, row chips fade at their end, admin user table aligned; strict and lint ratchets green

This commit is contained in:
Maurice
2026-10-15 23:08:46 +02:00
parent a0884685ef
commit 845979902c
25 changed files with 810 additions and 113 deletions
-1
View File
@@ -42,7 +42,6 @@ import { ratingBadgeHtml } from './ratingBadge'
import type { Poi } from './poiCategories'
import { poiPinParts } from './poiMarker'
import { addGpxTrackLayers, GPX_HIT_LAYER_ID, hasGpxTrackLayers, selectGpxTrack, setGpxTracks } from './gpxTrackLayers'
import { parseRenderableRouteGeometry } from '../../utils/routeGeometry'
import { buildPoiPopupHtml } from './placePopup'
import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api/client'
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM } from '../../constants/mapDefaults'
@@ -0,0 +1,66 @@
import type mapboxgl from 'mapbox-gl';
import { describe, expect, it, vi } from 'vitest';
import type { Place } from '../../types';
import { addGpxTrackLayers, GPX_HIT_LAYER_ID, hasGpxTrackLayers, selectGpxTrack, setGpxTracks } from './gpxTrackLayers';
function fakeMap() {
const layers = new Map<string, { filter?: unknown }>();
const sources = new Map<string, { setData: ReturnType<typeof vi.fn> }>();
const map = {
addSource: vi.fn((id: string) => sources.set(id, { setData: vi.fn() })),
getSource: (id: string) => sources.get(id),
addLayer: vi.fn((layer: { id: string; filter?: unknown }) => layers.set(layer.id, { filter: layer.filter })),
getLayer: (id: string) => layers.get(id),
setFilter: vi.fn((id: string, filter: unknown) => layers.set(id, { filter })),
};
return { map: map as unknown as mapboxgl.Map, layers, sources, raw: map };
}
describe('gpxTrackLayers (PR1-08)', () => {
it('adds the rim, the line, the picked track and the hit line once, picking nothing yet', () => {
const { map, layers } = fakeMap();
expect(hasGpxTrackLayers(map)).toBe(false);
addGpxTrackLayers(map);
expect(hasGpxTrackLayers(map)).toBe(true);
expect([...layers.keys()]).toEqual([
'trip-gpx-casing',
'trip-gpx-line',
'trip-gpx-selected-casing',
'trip-gpx-selected',
GPX_HIT_LAYER_ID,
]);
expect(layers.get('trip-gpx-selected')?.filter).toEqual(['==', ['get', 'place_id'], -1]);
});
it('puts only places with a drawable track into the source, with their colour and rim', () => {
const { map, sources } = fakeMap();
addGpxTrackLayers(map);
const track = { id: 5, route_geometry: '[[47,11],[47.1,11.1]]', route_color: '#ff0000' } as Place;
const plain = { id: 6, route_geometry: null } as Place;
setGpxTracks(map, [track, plain]);
const data = sources.get('trip-gpx')?.setData.mock.calls[0]?.[0];
expect(data.features).toHaveLength(1);
expect(data.features[0].properties).toEqual({ color: '#ff0000', cased: true, place_id: 5 });
expect(data.features[0].geometry.coordinates).toEqual([
[11, 47],
[11.1, 47.1],
]);
});
it('points the picked layers at the selected place, and at none again when nothing is picked', () => {
const { map, layers } = fakeMap();
addGpxTrackLayers(map);
selectGpxTrack(map, 5);
expect(layers.get('trip-gpx-selected')?.filter).toEqual(['==', ['get', 'place_id'], 5]);
expect(layers.get('trip-gpx-selected-casing')?.filter).toEqual(['==', ['get', 'place_id'], 5]);
selectGpxTrack(map, null);
expect(layers.get('trip-gpx-selected')?.filter).toEqual(['==', ['get', 'place_id'], -1]);
});
it('does nothing before the style has its layers', () => {
const { map, raw } = fakeMap();
selectGpxTrack(map, 5);
setGpxTracks(map, []);
expect(raw.setFilter).not.toHaveBeenCalled();
});
});
@@ -2,6 +2,7 @@ import type { TourListItem } from '@trek/shared';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useTranslation } from '../../i18n';
import '../../styles/fadeEnd.css';
import type { Category, Place } from '../../types';
import { formatTime } from '../../utils/formatters';
import { fs } from '../shared/DialogShell';
@@ -95,7 +96,7 @@ export function DayPlanPlaceSubline({ place, cat, tourTimes, isTourPlace, timeFo
return (
// One line like the row's name: when the hover controls come out, the description
// gives way and the pills keep their place instead of wrapping onto a second line.
<div className="mt-0.5 flex min-w-0 flex-nowrap items-center gap-1.5 overflow-hidden">
<div className="trek-fade-end mt-0.5 flex min-w-0 flex-nowrap items-center gap-1.5 overflow-hidden">
{place.place_time && (
<TimePill quiet>
{formatTime(place.place_time, locale, timeFormat)}
@@ -3,24 +3,9 @@ import { useEffect, useRef, useState, type ReactNode } from 'react';
import { useTranslation } from '../../i18n';
import { fs } from '../shared/DialogShell';
import { InfoDot } from './tourParts';
import type { TourFilter } from './tourPresentation';
import { ORIGIN_LABEL_KEY, statusLabel } from './tourPresentation';
import type { TourListFilters } from './useTourListFilters';
type Translate = (key: string, params?: Record<string, unknown>) => string;
/** How each origin is named in a list, the words the source badge uses. */
export const ORIGIN_LABEL_KEY = {
trek: 'tours.badge.planned',
gpx: 'tours.badge.gpx',
wanderer: 'tours.badge.wanderer',
} as const;
export function statusLabel(status: TourFilter, t: Translate): string {
if (status === 'unplanned') return t('places.unplanned');
if (status === 'planned') return t('places.planned');
return t('places.all');
}
function MenuRow({
active,
label,
@@ -132,7 +117,11 @@ export default function TourListFilterMenu({ filters }: { filters: TourListFilte
/>
</button>
{open && (
<div className="trek-popover-enter absolute start-0 top-full z-50 mt-1.5 flex max-h-[min(420px,60vh)] w-full min-w-[200px] flex-col gap-1.5 overflow-y-auto rounded-[12px] border border-edge-secondary bg-surface-card p-1.5 shadow-popover">
<div
role="group"
aria-label={t('places.filterShow')}
className="trek-popover-enter absolute start-0 top-full z-50 mt-1.5 flex max-h-[min(420px,60vh)] w-full min-w-[200px] flex-col gap-1.5 overflow-y-auto rounded-[12px] border border-edge-secondary bg-surface-card p-1.5 shadow-popover"
>
<MenuSection label={t('tours.filter.status')}>
{(['all', 'unplanned', 'planned'] as const).map((status) => (
<MenuRow
+63 -38
View File
@@ -1,3 +1,4 @@
import '../../styles/fadeEnd.css'
import { useId, type ReactNode } from 'react'
import { Eye, EyeOff } from 'lucide-react'
import type { TourListItem } from '@trek/shared'
@@ -38,9 +39,7 @@ function MapToggleButton({ toggle }: { toggle: NonNullable<TourListRowProps['map
aria-label={label}
aria-pressed={toggle.hidden}
onClick={event => { event.stopPropagation(); toggle.onToggle() }}
className={`grid h-[26px] w-[26px] flex-none place-items-center rounded-full shadow-sm ring-1 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${toggle.hidden
? 'bg-surface-tertiary text-content-muted ring-edge-faint hover:text-content'
: 'bg-surface-card text-content-muted ring-edge-faint hover:text-content'}`}
className={`grid h-[26px] w-[26px] flex-none place-items-center rounded-full text-content-muted shadow-sm ring-1 ring-edge-faint transition-colors hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${toggle.hidden ? 'bg-surface-tertiary' : 'bg-surface-card'}`}
>
{toggle.hidden ? <EyeOff size={13} strokeWidth={2.2} /> : <Eye size={13} strokeWidth={2.2} />}
</button>
@@ -48,6 +47,65 @@ function MapToggleButton({ toggle }: { toggle: NonNullable<TourListRowProps['map
)
}
/** The tile, the name and one line of badges with the description trailing after them. */
function TourRowBody({ tour, nameId, tourDays, dimmed }: {
tour: TourListItem
nameId: string
tourDays: ReturnType<typeof useTourDays>
dimmed: boolean
}) {
return (
<div className={`flex min-w-0 flex-1 items-center gap-2.5 transition-opacity ${dimmed ? 'opacity-55' : ''}`}>
<TourGradeTile tour={tour} />
<div className="min-w-0 flex-1">
<span id={nameId} className="block min-w-0 truncate font-semibold leading-tight text-content"
style={fs(13, 'body')}>{tour.name}</span>
{/* One line like a plan row's: the badges keep their place, the description gives way,
and what does not fit fades out instead of ending at a hard edge. */}
<div
data-testid="tour-metrics"
className="trek-fade-end mt-0.5 flex min-w-0 flex-nowrap items-center gap-1.5 overflow-hidden"
>
<TourCautionBadge tour={tour} />
<TourDayRowPills tour={tour} />
<TourDaysBadge tourDays={tourDays} />
{tour.description && (
<span
data-testid="tour-description-preview"
className="min-w-0 flex-1 truncate text-content-faint"
style={fs(10.5)}
>
{tour.description}
</span>
)}
</div>
</div>
</div>
)
}
/**
* The eye and the row's actions on the right. A hidden tour keeps its eye in
* sight; the rest come out on hover, focus or selection, as a plan row's
* controls do, so the name and badges have the room otherwise.
*/
function TourRowActions({ action, mapToggle, selected }: Pick<TourListRowProps, 'action' | 'mapToggle'> & { selected: boolean }) {
const hoverToggle = mapToggle && !mapToggle.hidden ? mapToggle : null
return (
<>
{mapToggle?.hidden && <MapToggleButton toggle={mapToggle} />}
{(action || hoverToggle) && (
<div data-testid="tour-action-row" role="presentation"
onClick={event => event.stopPropagation()}
className={`flex-none items-center gap-1.5 group-focus-within:flex group-hover:flex ${selected ? 'flex' : 'hidden'}`}>
{hoverToggle && <MapToggleButton toggle={hoverToggle} />}
{action}
</div>
)}
</>
)
}
/**
* A tour in a list, laid out like a stop in the day plan: its tile with the
* grade, the name, and one line of the plan's white badges (time, distance,
@@ -89,41 +147,8 @@ export default function TourListRow({ tour,
// The line's colour on the map, as a plan row shows its category.
<span aria-hidden="true" className="-me-1 h-8 w-[3px] flex-none rounded-full" style={{ background: trackColor }} />
)}
<div className={`flex min-w-0 flex-1 items-center gap-2.5 transition-opacity ${mapToggle?.hidden ? 'opacity-55' : ''}`}>
<TourGradeTile tour={tour} />
<div className="min-w-0 flex-1">
<span id={nameId} className="block min-w-0 truncate font-semibold leading-tight text-content"
style={fs(13, 'body')}>{tour.name}</span>
{/* One line like a plan row's: the badges keep their place, the description gives way. */}
<div
data-testid="tour-metrics"
className="mt-0.5 flex min-w-0 flex-nowrap items-center gap-1.5 overflow-hidden"
>
<TourCautionBadge tour={tour} />
<TourDayRowPills tour={tour} />
<TourDaysBadge tourDays={tourDays} />
{tour.description && (
<span
data-testid="tour-description-preview"
className="min-w-0 flex-1 truncate text-content-faint"
style={fs(10.5)}
>
{tour.description}
</span>
)}
</div>
</div>
</div>
{mapToggle?.hidden && <MapToggleButton toggle={mapToggle} />}
{(action || (mapToggle && !mapToggle.hidden)) && (
// Out on hover, focus or selection, as a plan row's controls are: the name and badges have the room otherwise.
<div data-testid="tour-action-row" role="presentation"
onClick={event => event.stopPropagation()}
className={`flex-none items-center gap-1.5 group-focus-within:flex group-hover:flex ${selected ? 'flex' : 'hidden'}`}>
{mapToggle && !mapToggle.hidden && <MapToggleButton toggle={mapToggle} />}
{action}
</div>
)}
<TourRowBody tour={tour} nameId={nameId} tourDays={tourDays} dimmed={!!mapToggle?.hidden} />
<TourRowActions action={action} mapToggle={mapToggle} selected={selected} />
</div>
{children}
</li>
@@ -3,6 +3,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { TourListItem } from '@trek/shared'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { useSettingsStore } from '../../store/settingsStore'
import { useAuthStore } from '../../store/authStore'
import { useTourMapVisibilityStore } from '../../store/tourMapVisibilityStore'
import { useTripStore } from '../../store/tripStore'
import { toursApi } from '../../api/client'
import ToursSidebar from './ToursSidebar'
@@ -304,4 +306,39 @@ describe('ToursSidebar', () => {
expect(badge).toHaveTextContent('Days 1, 3')
expect(badge).toHaveAttribute('aria-label', 'Day 1, Day 3: Summit')
})
it('PR1-13: offers the source and routing limit only where the list varies, and narrows by them', () => {
render(<ToursSidebar tripId={1} days={[]} tours={[
{ ...tour, place_id: 1, name: 'Drawn', origin: 'trek', max_hiking_difficulty: 2 },
{ ...tour, place_id: 2, name: 'Imported', origin: 'gpx', max_hiking_difficulty: null },
{ ...tour, place_id: 3, name: 'Snapshot', origin: 'wanderer', max_hiking_difficulty: 3 },
]} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Show' }))
const source = screen.getByRole('group', { name: 'Source' })
fireEvent.click(within(source).getByRole('button', { name: /GPX import\s*1/ }))
expect(screen.getAllByRole('option').map(row => row.textContent)).toEqual([expect.stringContaining('Imported')])
expect(screen.getByRole('button', { name: 'Show' })).toHaveTextContent('+1')
// Only one tour type in the list: no type filter.
expect(screen.queryByRole('group', { name: 'Type' })).toBeNull()
fireEvent.click(within(screen.getByRole('group', { name: 'Routing limit' })).getByRole('button', { name: /T3/ }))
expect(screen.getByRole('status')).toHaveTextContent('No tour matches these filters.')
fireEvent.click(screen.getByRole('button', { name: 'Reset filters' }))
expect(screen.getAllByRole('option')).toHaveLength(3)
})
it('PR1-07/08: a row shows its line colour, and its eye hides the tour from the map until shown again', () => {
seedStore(useAuthStore, { user: { id: 3 } as never })
seedStore(useTripStore, { places: [{ id: 42, route_color: '#e11d48' }] as never })
render(<ToursSidebar tripId={1} days={[]} tours={[tour]} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />)
const row = screen.getByRole('option', { name: /Selected ridge walk/ })
expect(row.querySelector('[aria-hidden="true"][style]')).toHaveStyle({ background: '#e11d48' })
fireEvent.click(within(row).getByRole('button', { name: 'Hide on the map' }))
expect(useTourMapVisibilityStore.getState().hidden).toEqual([42])
expect(screen.getByRole('status')).toHaveTextContent('Hidden on the map')
// A hidden tour keeps its eye in sight, not only on hover.
expect(within(row).getByRole('button', { name: 'Show on the map' })).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(screen.getByRole('button', { name: 'Show all' }))
expect(useTourMapVisibilityStore.getState().hidden).toEqual([])
})
})
@@ -0,0 +1,137 @@
import type { TourProvenance, TourSourceCheckResponse } from '@trek/shared';
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../../tests/helpers/render';
import TourProvenanceSection from './TourProvenanceSection';
import type { SourceCheck } from './useSourceCheck';
const base: TourProvenance = {
origin: 'trek',
createdAt: '2026-07-01 10:00:00',
source: { kind: 'trek' },
derivedFrom: null,
plannedOver: false,
editedLocally: null,
};
function check(over: Partial<SourceCheck> = {}): SourceCheck {
return {
checking: false,
result: null,
check: vi.fn(),
canReimport: false,
confirming: false,
askReimport: vi.fn(),
cancelReimport: vi.fn(),
reimporting: false,
reimport: vi.fn(),
...over,
} as SourceCheck;
}
describe('TourProvenanceSection (P2C-02, MF-03)', () => {
it('names a planned tour as planned here, with no local state and no check', () => {
render(<TourProvenanceSection provenance={base} />);
expect(screen.getByText('tours.provenance.label')).toBeInTheDocument();
expect(screen.getByText('tours.badge.planned')).toBeInTheDocument();
expect(screen.getByText('tours.provenance.createdAt')).toBeInTheDocument();
expect(screen.queryByText('tours.provenance.localState')).toBeNull();
expect(screen.queryByRole('button', { name: /tours.provenance.check/ })).toBeNull();
});
it('shows a GPX import with its file, and that it was planned over keeping its track', () => {
render(
<TourProvenanceSection
provenance={{ ...base, origin: 'gpx', source: { kind: 'gpx', fileName: 'ridge.gpx' }, plannedOver: true }}
/>
);
expect(screen.getByText('tours.badge.gpx')).toBeInTheDocument();
expect(screen.getByText('ridge.gpx')).toBeInTheDocument();
expect(screen.getByText('tours.provenance.importedAt')).toBeInTheDocument();
expect(screen.getByText('tours.provenance.plannedOver')).toBeInTheDocument();
});
it('links a wanderer snapshot to its trail, tells local edits apart and checks the source on purpose', () => {
const onCheck = vi.fn();
render(
<TourProvenanceSection
provenance={{
...base,
origin: 'wanderer',
source: {
kind: 'wanderer',
instance: 'https://trails.example',
externalId: 't1',
externalUrl: 'https://trails.example/trail/view/@me/t1',
externalUpdated: '2026-10-01 08:00:00Z',
importedAt: '2026-10-02 09:00:00',
},
editedLocally: true,
}}
check={check({ check: onCheck })}
/>
);
expect(screen.getByText('trails.example')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'tours.provenance.openSource' })).toHaveAttribute(
'href',
'https://trails.example/trail/view/@me/t1'
);
expect(screen.getByText('tours.provenance.externalUpdated')).toBeInTheDocument();
expect(screen.getByText('tours.provenance.editedLocally')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /tours.provenance.check$/ }));
expect(onCheck).toHaveBeenCalledTimes(1);
});
it('says what the check found, the parts that changed, and asks before the reimport', () => {
const result: TourSourceCheckResponse = {
state: 'changed',
changes: ['name', 'route'],
externalUpdated: '2026-10-14 12:00:00Z',
importedUpdated: null,
editedLocally: false,
reimport: { trailId: 't1', contentHash: 'h' },
};
const askReimport = vi.fn();
const { rerender } = render(
<TourProvenanceSection
provenance={{
...base,
origin: 'wanderer',
source: {
kind: 'wanderer',
instance: 'https://t.example',
externalId: 't1',
externalUrl: null,
externalUpdated: null,
importedAt: null,
},
}}
check={check({ result, canReimport: true, askReimport })}
/>
);
expect(screen.getByRole('status')).toHaveTextContent('tours.provenance.state.changed');
expect(screen.getByText('tours.provenance.change.name')).toBeInTheDocument();
expect(screen.getByText('tours.provenance.change.route')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /tours.provenance.reimport/ }));
expect(askReimport).toHaveBeenCalledTimes(1);
rerender(
<TourProvenanceSection
provenance={{
...base,
origin: 'wanderer',
source: {
kind: 'wanderer',
instance: 'https://t.example',
externalId: 't1',
externalUrl: null,
externalUpdated: null,
importedAt: null,
},
}}
check={check({ result: { ...result, state: 'unavailable', changes: [], reimport: null } })}
/>
);
expect(screen.getByText('tours.provenance.body.unavailable')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /tours.provenance.reimport/ })).toBeNull();
});
});
@@ -0,0 +1,103 @@
import { act, renderHook } from '@testing-library/react';
import type { TourListItem, TourSourceCheckResponse } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useSourceCheck } from './useSourceCheck';
const repo = vi.hoisted(() => ({ checkSource: vi.fn(), importTrail: vi.fn() }));
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }));
vi.mock('../../../repo/wandererRepo', () => ({
wandererRepo: repo,
wandererErrorCode: (error: unknown) => (error as { code?: string })?.code ?? 'unknown',
}));
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
const changed: TourSourceCheckResponse = {
state: 'changed',
changes: ['route'],
externalUpdated: '2026-10-14',
importedUpdated: '2026-10-01',
editedLocally: false,
reimport: { trailId: 't1', contentHash: 'h' },
};
const newTour = { place_id: 99, name: 'Ridge' } as TourListItem;
beforeEach(() => {
vi.clearAllMocks();
repo.checkSource.mockResolvedValue(changed);
repo.importTrail.mockResolvedValue({ tour: newTour });
});
describe('useSourceCheck (MF-03)', () => {
it('asks only on purpose, and offers the reimport for a change when someone takes it in', async () => {
const onReimported = vi.fn();
const { result } = renderHook(() => useSourceCheck({ tripId: 1, placeId: 7, onReimported }));
expect(repo.checkSource).not.toHaveBeenCalled();
expect(result.current.result).toBeNull();
await act(async () => {
await result.current.check();
});
expect(repo.checkSource).toHaveBeenCalledWith(1, 7);
expect(result.current.result).toEqual(changed);
expect(result.current.canReimport).toBe(true);
act(() => result.current.askReimport());
expect(result.current.confirming).toBe(true);
await act(async () => {
await result.current.reimport();
});
expect(repo.importTrail).toHaveBeenCalledWith(1, { trailId: 't1', contentHash: 'h' });
expect(onReimported).toHaveBeenCalledWith(newTour);
expect(toast.success).toHaveBeenCalled();
expect(result.current.result).toBeNull();
expect(result.current.confirming).toBe(false);
});
it('without anyone to take the new snapshot in, or for an unchanged trail, offers no reimport', async () => {
repo.checkSource.mockResolvedValue({ ...changed, state: 'unchanged', changes: [], reimport: null });
const unchanged = renderHook(() => useSourceCheck({ tripId: 1, placeId: 7, onReimported: vi.fn() }));
await act(async () => {
await unchanged.result.current.check();
});
expect(unchanged.result.current.canReimport).toBe(false);
repo.checkSource.mockResolvedValue(changed);
const nobody = renderHook(() => useSourceCheck({ tripId: 1, placeId: 7 }));
await act(async () => {
await nobody.result.current.check();
});
expect(nobody.result.current.canReimport).toBe(false);
});
it('a failed check or reimport says so and changes nothing', async () => {
repo.checkSource.mockRejectedValueOnce({ code: 'unreachable' });
const onReimported = vi.fn();
const { result } = renderHook(() => useSourceCheck({ tripId: 1, placeId: 7, onReimported }));
await act(async () => {
await result.current.check();
});
expect(result.current.result).toBeNull();
expect(toast.error).toHaveBeenCalledTimes(1);
await act(async () => {
await result.current.check();
});
repo.importTrail.mockRejectedValueOnce({ code: 'preview_stale' });
await act(async () => {
await result.current.reimport();
});
expect(onReimported).not.toHaveBeenCalled();
expect(toast.error).toHaveBeenCalledTimes(2);
});
it('another tour in the same dialog starts unchecked', async () => {
const { result, rerender } = renderHook(({ placeId }) => useSourceCheck({ tripId: 1, placeId }), {
initialProps: { placeId: 7 },
});
await act(async () => {
await result.current.check();
});
rerender({ placeId: 8 });
expect(result.current.result).toBeNull();
});
});
@@ -1,5 +1,5 @@
import type { TourListItem, TourSourceCheckResponse } from '@trek/shared';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useState } from 'react';
import { useTranslation } from '../../../i18n';
import { wandererErrorCode, wandererRepo } from '../../../repo/wandererRepo';
import { useToast } from '../../shared/Toast';
@@ -22,15 +22,24 @@ export function useSourceCheck({ tripId, placeId, onReimported }: SourceCheckOpt
const { t } = useTranslation();
const toast = useToast();
const [checking, setChecking] = useState(false);
const [result, setResult] = useState<TourSourceCheckResponse | null>(null);
const [confirming, setConfirming] = useState(false);
const [reimporting, setReimporting] = useState(false);
// Another tour shown in the same dialog starts unchecked.
useEffect(() => {
setResult(null);
setConfirming(false);
}, [placeId]);
// The answer and the question belong to the tour they were asked for:
// another tour shown in the same dialog starts unchecked.
const [state, setState] = useState<{ placeId: number; result: TourSourceCheckResponse | null; confirming: boolean }>({
placeId,
result: null,
confirming: false,
});
const result = state.placeId === placeId ? state.result : null;
const confirming = state.placeId === placeId && state.confirming;
const setResult = useCallback(
(next: TourSourceCheckResponse | null) => setState({ placeId, result: next, confirming: false }),
[placeId]
);
const setConfirming = useCallback(
(next: boolean) => setState((current) => ({ ...current, placeId, confirming: next })),
[placeId]
);
const check = useCallback(async () => {
if (checking) return;
@@ -43,7 +52,7 @@ export function useSourceCheck({ tripId, placeId, onReimported }: SourceCheckOpt
} finally {
setChecking(false);
}
}, [checking, placeId, t, toast, tripId]);
}, [checking, placeId, setResult, t, toast, tripId]);
const reimport = useCallback(async () => {
const payload = result?.reimport;
@@ -61,7 +70,7 @@ export function useSourceCheck({ tripId, placeId, onReimported }: SourceCheckOpt
} finally {
setReimporting(false);
}
}, [onReimported, reimporting, result, t, toast, tripId]);
}, [onReimported, reimporting, result, setConfirming, setResult, t, toast, tripId]);
return {
checking,
@@ -70,8 +79,8 @@ export function useSourceCheck({ tripId, placeId, onReimported }: SourceCheckOpt
/** A reimport can be offered: the trail was read and differs, and someone takes the new snapshot in. */
canReimport: !!result?.reimport && !!onReimported,
confirming,
askReimport: useCallback(() => setConfirming(true), []),
cancelReimport: useCallback(() => setConfirming(false), []),
askReimport: useCallback(() => setConfirming(true), [setConfirming]),
cancelReimport: useCallback(() => setConfirming(false), [setConfirming]),
reimporting,
reimport,
};
@@ -0,0 +1,48 @@
import { renderHook, waitFor } from '@testing-library/react';
import type { TourListItem, TourProvenance } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useTourProvenance } from './useTourProvenance';
const repo = vi.hoisted(() => ({ detail: vi.fn() }));
vi.mock('../../../repo/tourRepo', () => ({ tourRepo: repo }));
const provenance: TourProvenance = {
origin: 'trek',
createdAt: null,
source: { kind: 'trek' },
derivedFrom: null,
plannedOver: false,
editedLocally: null,
};
const tour = { place_id: 7, name: 'Ridge', distance: 4 } as TourListItem;
beforeEach(() => {
vi.clearAllMocks();
repo.detail.mockResolvedValue({ tour, waypoints: [], provenance });
});
describe('useTourProvenance (P2C-02)', () => {
it('reads the provenance with the detail, and asks again when the tour changes', async () => {
const { result, rerender } = renderHook(({ current }) => useTourProvenance(1, current), {
initialProps: { current: tour },
});
expect(result.current).toBeNull();
await waitFor(() => expect(result.current).toEqual(provenance));
expect(repo.detail).toHaveBeenCalledWith(1, 7, expect.any(AbortSignal));
rerender({ current: { ...tour, name: 'Renamed' } });
await waitFor(() => expect(repo.detail).toHaveBeenCalledTimes(2));
});
it('shows none when the detail cannot be had, or carries none', async () => {
repo.detail.mockRejectedValueOnce(new Error('offline'));
const failed = renderHook(() => useTourProvenance(1, tour));
await waitFor(() => expect(repo.detail).toHaveBeenCalled());
expect(failed.result.current).toBeNull();
repo.detail.mockResolvedValueOnce({ tour, waypoints: [] });
const none = renderHook(() => useTourProvenance(1, { ...tour, place_id: 8 }));
await waitFor(() => expect(repo.detail).toHaveBeenCalledTimes(2));
expect(none.result.current).toBeNull();
});
});
@@ -29,6 +29,13 @@ describe('tourPresentation', () => {
expect(hikeSourceBadgeLabel({ wanderer_ref: 'wanderer:123' }, t)).toBe('tours.badge.wanderer')
})
it('P2C-02: takes the origin the server recorded over the shape of the tour', () => {
// An import planned over in place has waypoints, and is still an import.
expect(tourSource({ wanderer_ref: null, has_waypoints: true, origin: 'gpx' })).toBe('gpx')
expect(tourSource({ wanderer_ref: null, has_waypoints: false, origin: 'trek' })).toBe('trek')
expect(tourSource({ wanderer_ref: 'w#1', origin: 'wanderer' })).toBe('wanderer')
})
it('filters tours by All, Unplanned and Planned, keeping their order', () => {
const tours = [
{ id: 1, planned: true },
@@ -86,3 +86,13 @@ export function filterTours<T extends Pick<TourListItem, 'planned'>>(tours: T[],
return true
})
}
/** How each origin is named in a list, the words the source badge uses. */
export const ORIGIN_LABEL_KEY = { trek: 'tours.badge.planned', gpx: 'tours.badge.gpx', wanderer: 'tours.badge.wanderer' } as const
/** The day status a tour list is filtered by, in words. */
export function statusLabel(status: TourFilter, t: Translate): string {
if (status === 'unplanned') return t('places.unplanned')
if (status === 'planned') return t('places.planned')
return t('places.all')
}
@@ -0,0 +1,68 @@
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useTripStore } from '../../store/tripStore';
import type { AssignmentsMap } from '../../types';
import { isTourOnDay, useTourDayAssignment } from './useTourDayAssignment';
const assignments = { '3': [{ id: 31, place_id: 42 }], '4': [] } as unknown as AssignmentsMap;
beforeEach(() => {
resetAllStores();
seedStore(useTripStore, { assignments });
});
describe('isTourOnDay (PR1-18)', () => {
it('reads the day lists: a tour is on a day once it has an assignment there', () => {
expect(isTourOnDay(assignments, 42, 3)).toBe(true);
expect(isTourOnDay(assignments, 42, 4)).toBe(false);
expect(isTourOnDay(assignments, 42, 99)).toBe(false);
});
});
describe('useTourDayAssignment (PR1-18)', () => {
it('offers only a day that does not have the tour yet, and nothing without the right', () => {
const allowed = renderHook(() => useTourDayAssignment({ canAssign: true, onAssignToDay: vi.fn() }));
expect(allowed.result.current.isOnDay(42, 3)).toBe(true);
expect(allowed.result.current.isAvailable(42, 3)).toBe(false);
expect(allowed.result.current.isAvailable(42, 4)).toBe(true);
const denied = renderHook(() => useTourDayAssignment({ canAssign: false, onAssignToDay: vi.fn() }));
expect(denied.result.current.isAvailable(42, 4)).toBe(false);
});
it('sends one write per tour and day while it is on its way, and says whether it happened', async () => {
let finish: (value: boolean) => void = () => undefined;
const onAssignToDay = vi.fn(() => new Promise<boolean>((resolve) => (finish = resolve)));
const { result } = renderHook(() => useTourDayAssignment({ canAssign: true, onAssignToDay }));
let first: Promise<boolean> = Promise.resolve(false);
let second: Promise<boolean> = Promise.resolve(false);
act(() => {
first = result.current.assign(42, 4);
second = result.current.assign(42, 4);
});
expect(onAssignToDay).toHaveBeenCalledTimes(1);
expect(onAssignToDay).toHaveBeenCalledWith(42, 4, undefined);
expect(await second).toBe(false);
expect(result.current.isPending(42, 4)).toBe(true);
expect(result.current.isAvailable(42, 4)).toBe(false);
await act(async () => {
finish(true);
expect(await first).toBe(true);
});
expect(result.current.isPending(42, 4)).toBe(false);
});
it('reports a refused write as not done, and a day that has the tour is not even asked', async () => {
const onAssignToDay = vi.fn(async () => false);
const { result } = renderHook(() => useTourDayAssignment({ canAssign: true, onAssignToDay }));
await act(async () => {
expect(await result.current.assign(42, 4, 2)).toBe(false);
expect(await result.current.assign(42, 3)).toBe(false);
});
expect(onAssignToDay).toHaveBeenCalledTimes(1);
expect(onAssignToDay).toHaveBeenCalledWith(42, 4, 2);
});
});
@@ -0,0 +1,76 @@
import { act, renderHook } from '@testing-library/react';
import type { TourListItem } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { useTourListFilters } from './useTourListFilters';
const tour = (over: Partial<TourListItem>): TourListItem =>
({
place_id: 1,
name: 'Ridge',
tour_type: 'hike',
distance: 5,
elevation_gain: null,
elevation_loss: null,
duration: null,
difficulty: null,
wanderer_ref: null,
match_confidence: 1,
max_hiking_difficulty: 2,
planned: false,
caution: false,
origin: 'trek',
...over,
}) as TourListItem;
const tours = [
tour({ place_id: 1, origin: 'trek', max_hiking_difficulty: 2, planned: true }),
tour({ place_id: 2, origin: 'gpx', max_hiking_difficulty: null }),
tour({ place_id: 3, origin: 'wanderer', max_hiking_difficulty: 1 }),
tour({ place_id: 4, origin: 'gpx', max_hiking_difficulty: 3, planned: true }),
];
const ids = (list: readonly TourListItem[]) => list.map((item) => item.place_id);
describe('useTourListFilters (PR1-13)', () => {
it('offers only the dimensions the list varies in, each value with its count', () => {
const { result } = renderHook(() => useTourListFilters(tours));
expect(result.current.facets.origin).toEqual([
{ value: 'trek', count: 1 },
{ value: 'gpx', count: 2 },
{ value: 'wanderer', count: 1 },
]);
expect(result.current.facets.grade.map((option) => option.value)).toEqual([1, 2, 3]);
// One tour type only: no type filter.
expect(result.current.facets.type).toEqual([]);
expect(result.current.hasFacets).toBe(true);
});
it('a small list that does not vary keeps its single status filter', () => {
const { result } = renderHook(() => useTourListFilters([tour({ place_id: 1 }), tour({ place_id: 2 })]));
expect(result.current.hasFacets).toBe(false);
});
it('narrows by status and dimensions together, counts follow, a second choice of a value clears it', () => {
const { result } = renderHook(() => useTourListFilters(tours));
act(() => result.current.select('origin', 'gpx'));
expect(ids(result.current.filtered)).toEqual([2, 4]);
expect(result.current.activeFacets).toBe(1);
// A GPX import without a grade drops out under a grade, it is not counted as one.
act(() => result.current.select('grade', 3));
expect(ids(result.current.filtered)).toEqual([4]);
act(() => result.current.setStatus('unplanned'));
expect(result.current.filtered).toEqual([]);
expect(result.current.emptyByFilter).toBe(true);
expect(result.current.statusCounts).toEqual({ all: 4, unplanned: 2, planned: 2 });
act(() => result.current.select('grade', 3));
expect(ids(result.current.filtered)).toEqual([2]);
act(() => result.current.reset());
expect(ids(result.current.filtered)).toEqual([1, 2, 3, 4]);
expect(result.current.activeFacets).toBe(0);
});
it('an empty list is empty, not empty by a filter', () => {
const { result } = renderHook(() => useTourListFilters([]));
expect(result.current.emptyByFilter).toBe(false);
});
});
@@ -0,0 +1,62 @@
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it } from 'vitest';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useAuthStore } from '../../store/authStore';
import { tourVisibilityScope, useTourMapVisibilityStore } from '../../store/tourMapVisibilityStore';
import { useTourMapVisibility } from './useTourMapVisibility';
const KEY = (userId: number, tripId: number) => `trek:hidden-tours:${tourVisibilityScope(userId, tripId)}`;
beforeEach(() => {
resetAllStores();
window.localStorage.clear();
useTourMapVisibilityStore.setState({ scope: null, hidden: [] });
seedStore(useAuthStore, { user: { id: 3 } as never });
});
describe('useTourMapVisibility (PR1-07)', () => {
it('hides and shows a tour for this person in this trip, and keeps it over a reload', () => {
const tours = new Set([10, 11]);
const first = renderHook(() => useTourMapVisibility(7, tours));
act(() => first.result.current.toggle(10));
expect(first.result.current.isHidden(10)).toBe(true);
expect(first.result.current.hiddenCount).toBe(1);
expect(JSON.parse(window.localStorage.getItem(KEY(3, 7)) ?? '[]')).toEqual([10]);
// A reload reads it back.
useTourMapVisibilityStore.setState({ scope: null, hidden: [] });
const reloaded = renderHook(() => useTourMapVisibility(7, tours));
expect(reloaded.result.current.isHidden(10)).toBe(true);
act(() => reloaded.result.current.toggle(10));
expect(reloaded.result.current.isHidden(10)).toBe(false);
expect(window.localStorage.getItem(KEY(3, 7))).toBeNull();
});
it('belongs to one user and one trip only', () => {
window.localStorage.setItem(KEY(3, 7), JSON.stringify([10]));
expect(renderHook(() => useTourMapVisibility(8, new Set([10]))).result.current.hiddenCount).toBe(0);
useTourMapVisibilityStore.setState({ scope: null, hidden: [] });
seedStore(useAuthStore, { user: { id: 4 } as never });
expect(renderHook(() => useTourMapVisibility(7, new Set([10]))).result.current.hiddenCount).toBe(0);
});
it('a tour that is gone hides nothing, and show all clears the choice', () => {
window.localStorage.setItem(KEY(3, 7), JSON.stringify([10, 99, 'x', -1]));
const { result } = renderHook(() => useTourMapVisibility(7, new Set([10, 11])));
expect([...result.current.hiddenIds]).toEqual([10]);
act(() => result.current.showAll());
expect(result.current.hiddenCount).toBe(0);
expect(window.localStorage.getItem(KEY(3, 7))).toBeNull();
});
it('a broken stored entry or no signed-in user shows every tour', () => {
window.localStorage.setItem(KEY(3, 7), '{not json');
expect(renderHook(() => useTourMapVisibility(7, new Set([10]))).result.current.hiddenCount).toBe(0);
useTourMapVisibilityStore.setState({ scope: null, hidden: [] });
seedStore(useAuthStore, { user: null });
const { result } = renderHook(() => useTourMapVisibility(7, new Set([10])));
act(() => result.current.toggle(10));
expect(result.current.hiddenCount).toBe(0);
});
});