mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
Compare commits
5 Commits
1d090e0ac9
...
309bef4db7
| Author | SHA1 | Date | |
|---|---|---|---|
| 309bef4db7 | |||
| 6e59240328 | |||
| bdb082d490 | |||
| 8957503dbe | |||
| 0a50119c52 |
@@ -55,7 +55,7 @@
|
|||||||
"components/Layout/DemoBanner.tsx": 25,
|
"components/Layout/DemoBanner.tsx": 25,
|
||||||
"components/Layout/InAppNotificationBell.tsx": 10,
|
"components/Layout/InAppNotificationBell.tsx": 10,
|
||||||
"components/Layout/MobileTopBar.tsx": 51,
|
"components/Layout/MobileTopBar.tsx": 51,
|
||||||
"components/Layout/Navbar.tsx": 23,
|
"components/Layout/Navbar.tsx": 22,
|
||||||
"components/Layout/NewVersionNotice.tsx": 3,
|
"components/Layout/NewVersionNotice.tsx": 3,
|
||||||
"components/Layout/OfflineBanner.tsx": 2,
|
"components/Layout/OfflineBanner.tsx": 2,
|
||||||
"components/Layout/PageSidebar.tsx": 3,
|
"components/Layout/PageSidebar.tsx": 3,
|
||||||
|
|||||||
@@ -91,17 +91,18 @@ const ferrata = (
|
|||||||
id: string,
|
id: string,
|
||||||
name: string | null,
|
name: string | null,
|
||||||
level: number | null,
|
level: number | null,
|
||||||
options: { routeName?: string | null; lat?: number; plus?: '+' | null } = {}
|
options: { routeName?: string | null; lat?: number; lng?: number; plus?: '+' | null } = {}
|
||||||
): Ferrata => {
|
): Ferrata => {
|
||||||
const lat = options.lat ?? 46.5;
|
const lat = options.lat ?? 46.5;
|
||||||
|
const lng = options.lng ?? 11.7;
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
grade: { raw: level === null ? null : String(level), level, plus: options.plus ?? null },
|
grade: { raw: level === null ? null : String(level), level, plus: options.plus ?? null },
|
||||||
routeName: options.routeName ?? null,
|
routeName: options.routeName ?? null,
|
||||||
geometry: [
|
geometry: [
|
||||||
[lat, 11.7],
|
[lat, lng],
|
||||||
[lat + 0.01, 11.71],
|
[lat + 0.01, Math.round((lng + 0.01) * 1e6) / 1e6],
|
||||||
],
|
],
|
||||||
osmUrl: `https://www.openstreetmap.org/${id}`,
|
osmUrl: `https://www.openstreetmap.org/${id}`,
|
||||||
};
|
};
|
||||||
@@ -141,7 +142,8 @@ describe('FerrataLayerLeaflet', () => {
|
|||||||
it('FE-FERRATA-LEAFLET-003: the tooltip names the ferrata and its grade range, or falls back to the route and unnamed', () => {
|
it('FE-FERRATA-LEAFLET-003: the tooltip names the ferrata and its grade range, or falls back to the route and unnamed', () => {
|
||||||
const groups = groupFerratas([
|
const groups = groupFerratas([
|
||||||
ferrata('way/1', 'Pisciadù', 2),
|
ferrata('way/1', 'Pisciadù', 2),
|
||||||
ferrata('way/2', 'Pisciadù', 4, { lat: 46.51, plus: '+' }),
|
// Picks up where way/1 ends: one ferrata in two segments.
|
||||||
|
ferrata('way/2', 'Pisciadù', 4, { lat: 46.51, lng: 11.71, plus: '+' }),
|
||||||
ferrata('way/3', null, 3, { routeName: 'Sentiero attrezzato', lat: 47 }),
|
ferrata('way/3', null, 3, { routeName: 'Sentiero attrezzato', lat: 47 }),
|
||||||
ferrata('way/4', null, null, { lat: 48 }),
|
ferrata('way/4', null, null, { lat: 48 }),
|
||||||
]);
|
]);
|
||||||
@@ -183,7 +185,7 @@ describe('FerrataLayerLeaflet', () => {
|
|||||||
root.style.setProperty('--map-ferrata-lit', 'navy');
|
root.style.setProperty('--map-ferrata-lit', 'navy');
|
||||||
const groups = groupFerratas([
|
const groups = groupFerratas([
|
||||||
ferrata('way/1', 'Via A', 2),
|
ferrata('way/1', 'Via A', 2),
|
||||||
ferrata('way/2', 'Via A', 3, { lat: 46.51 }),
|
ferrata('way/2', 'Via A', 3, { lat: 46.51, lng: 11.71 }),
|
||||||
ferrata('way/3', 'Via B', 3, { lat: 47 }),
|
ferrata('way/3', 'Via B', 3, { lat: 47 }),
|
||||||
]);
|
]);
|
||||||
const { rerender } = render(<FerrataLayerLeaflet groups={groups} onSelect={vi.fn()} />);
|
const { rerender } = render(<FerrataLayerLeaflet groups={groups} onSelect={vi.fn()} />);
|
||||||
|
|||||||
@@ -49,11 +49,11 @@ describe('routeFerrataCrossings', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('FE-FERRATA-CROSS-004: a short gap in the data does not split the stretch', () => {
|
it('FE-FERRATA-CROSS-004: a short gap in the data does not split the stretch', () => {
|
||||||
const route = northFrom([47.4, 11.0], 20);
|
// Two pieces of one ferrata with 44 m missing between them: the route point
|
||||||
// The route steps 25 m aside for two points, still within the join gap.
|
// in the middle of the hole is out of reach, the ones beside it are not.
|
||||||
route[10] = [route[10][0], 11.0004];
|
const pieces = groupFerratas([ferrata(2, [47.4, 11.0], 10), ferrata(3, [47.4 + 14 * STEP, 11.0], 11)]);
|
||||||
route[11] = [route[11][0], 11.0004];
|
expect(pieces).toHaveLength(1);
|
||||||
expect(routeFerrataCrossings(route, groups)).toHaveLength(1);
|
expect(routeFerrataCrossings(northFrom([47.4, 11.0], 25), pieces)).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* FE-FERRATA-GL-001 to FE-FERRATA-GL-012: the via ferrata layer on a
|
* FE-FERRATA-GL-001 to FE-FERRATA-GL-013: the via ferrata layer on a
|
||||||
* MapLibre or Mapbox GL map.
|
* MapLibre or Mapbox GL map.
|
||||||
*
|
*
|
||||||
* Pinned: the layers go in once per map (and wait for `idle` when tiles are
|
* Pinned: the layers go in once per map (and wait for `idle` when tiles are
|
||||||
@@ -37,14 +37,14 @@ const HIT = 'trek-ferratas-hit';
|
|||||||
const LABEL = 'trek-ferratas-label';
|
const LABEL = 'trek-ferratas-label';
|
||||||
const LAYERS = [CASING, LINE, HIGHLIGHT_CASING, HIGHLIGHT, HIT];
|
const LAYERS = [CASING, LINE, HIGHLIGHT_CASING, HIGHLIGHT, HIT];
|
||||||
|
|
||||||
const ferrata = (id: string, lat: number, name: string): Ferrata => ({
|
const ferrata = (id: string, lat: number, name: string, lng = 11.7): Ferrata => ({
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
grade: { raw: '2', level: 2, plus: null },
|
grade: { raw: '2', level: 2, plus: null },
|
||||||
routeName: null,
|
routeName: null,
|
||||||
geometry: [
|
geometry: [
|
||||||
[lat, 11.7],
|
[lat, lng],
|
||||||
[lat + 0.01, 11.71],
|
[lat + 0.01, lng + 0.01],
|
||||||
],
|
],
|
||||||
osmUrl: `https://www.openstreetmap.org/${id}`,
|
osmUrl: `https://www.openstreetmap.org/${id}`,
|
||||||
});
|
});
|
||||||
@@ -52,7 +52,7 @@ const ferrata = (id: string, lat: number, name: string): Ferrata => ({
|
|||||||
/** Two ferratas: way/1 and way/2 are one (same name, touching), way/3 another. */
|
/** Two ferratas: way/1 and way/2 are one (same name, touching), way/3 another. */
|
||||||
const GROUPS = groupFerratas([
|
const GROUPS = groupFerratas([
|
||||||
ferrata('way/1', 46.5, 'Via A'),
|
ferrata('way/1', 46.5, 'Via A'),
|
||||||
ferrata('way/2', 46.51, 'Via A'),
|
ferrata('way/2', 46.51, 'Via A', 11.71),
|
||||||
ferrata('way/3', 47, 'Via B'),
|
ferrata('way/3', 47, 'Via B'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ function fakeGlMap(styleLoaded = true) {
|
|||||||
const images = new Set<string>();
|
const images = new Set<string>();
|
||||||
const handlers = new Map<string, Set<Handler>>();
|
const handlers = new Map<string, Set<Handler>>();
|
||||||
const once = new Map<string, Set<Handler>>();
|
const once = new Map<string, Set<Handler>>();
|
||||||
const state = { styleLoaded, hit: null as null | { properties: { id: string; group: string } } };
|
const state = { styleLoaded, removed: false, hit: null as null | { properties: { id: string; group: string } } };
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
const container = document.createElement('div');
|
const container = document.createElement('div');
|
||||||
const keyOf = (type: string, layer?: unknown) => (typeof layer === 'string' ? `${type}:${layer}` : type);
|
const keyOf = (type: string, layer?: unknown) => (typeof layer === 'string' ? `${type}:${layer}` : type);
|
||||||
@@ -77,7 +77,12 @@ function fakeGlMap(styleLoaded = true) {
|
|||||||
removeSource: vi.fn((id: string) => {
|
removeSource: vi.fn((id: string) => {
|
||||||
sources.delete(id);
|
sources.delete(id);
|
||||||
}),
|
}),
|
||||||
getLayer: vi.fn((id: string) => (layers.has(id) ? {} : undefined)),
|
// Like MapLibre: a removed map has no style, and asking it for a layer throws.
|
||||||
|
getStyle: vi.fn(() => (state.removed ? undefined : { version: 8 })),
|
||||||
|
getLayer: vi.fn((id: string) => {
|
||||||
|
if (state.removed) throw new TypeError('can\'t access property "getLayer", this.style is undefined');
|
||||||
|
return layers.has(id) ? {} : undefined;
|
||||||
|
}),
|
||||||
addLayer: vi.fn((spec: { id: string }) => {
|
addLayer: vi.fn((spec: { id: string }) => {
|
||||||
layers.add(spec.id);
|
layers.add(spec.id);
|
||||||
}),
|
}),
|
||||||
@@ -267,6 +272,15 @@ describe('useFerrataLayerGL', () => {
|
|||||||
expect(() => renderHook(() => useFerrataLayerGL(null, GROUPS, vi.fn()))).not.toThrow();
|
expect(() => renderHook(() => useFerrataLayerGL(null, GROUPS, vi.fn()))).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FE-FERRATA-GL-013: a map already removed with its view (leaving 3D) is left alone on unmount', async () => {
|
||||||
|
const gl = fakeGlMap();
|
||||||
|
const { unmount } = render(<FerrataLayerGL map={gl.map} groups={GROUPS} onSelect={vi.fn()} />);
|
||||||
|
await waitFor(() => expect(gl.layers.has(LABEL)).toBe(true));
|
||||||
|
gl.state.removed = true;
|
||||||
|
expect(() => unmount()).not.toThrow();
|
||||||
|
expect(gl.listeners()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('FE-FERRATA-GL-010: a theme change repaints the installed layers without rebuilding them', async () => {
|
it('FE-FERRATA-GL-010: a theme change repaints the installed layers without rebuilding them', async () => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
root.style.setProperty('--map-ferrata', 'black');
|
root.style.setProperty('--map-ferrata', 'black');
|
||||||
|
|||||||
@@ -136,6 +136,9 @@ function addFerrataIcon(map: Map, paint: FerrataPaint, gone: () => boolean): voi
|
|||||||
}
|
}
|
||||||
|
|
||||||
function removeFerrataLayers(map: Map): void {
|
function removeFerrataLayers(map: Map): void {
|
||||||
|
// A map already removed with its view (leaving 3D) took its style and the layers with it;
|
||||||
|
// asking it for a layer then throws.
|
||||||
|
if (!map.getStyle?.()) return;
|
||||||
for (const layer of [...LAYERS].reverse()) if (map.getLayer(layer)) map.removeLayer(layer);
|
for (const layer of [...LAYERS].reverse()) if (map.getLayer(layer)) map.removeLayer(layer);
|
||||||
for (const source of [LABEL_SOURCE, SOURCE]) if (map.getSource(source)) map.removeSource(source);
|
for (const source of [LABEL_SOURCE, SOURCE]) if (map.getSource(source)) map.removeSource(source);
|
||||||
if (map.hasImage(FERRATA_ICON_IMAGE)) map.removeImage(FERRATA_ICON_IMAGE);
|
if (map.hasImage(FERRATA_ICON_IMAGE)) map.removeImage(FERRATA_ICON_IMAGE);
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ describe('useFerrataMapLayer', () => {
|
|||||||
expect(result.current.layer.active).toBe(false);
|
expect(result.current.layer.active).toBe(false);
|
||||||
expect(result.current.layer.count).toBe(0);
|
expect(result.current.layer.count).toBe(0);
|
||||||
expect(result.current.selected).toBeNull();
|
expect(result.current.selected).toBeNull();
|
||||||
expect(lastCall()).toEqual([7, null, false, null]);
|
expect(lastCall()).toEqual([7, null, false, null, []]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-FERRATA-MAPLAYER-002: an area reported while off is remembered without a re-render, and used once it is on', () => {
|
it('FE-FERRATA-MAPLAYER-002: an area reported while off is remembered without a re-render, and used once it is on', () => {
|
||||||
|
|||||||
@@ -61,9 +61,10 @@ describe('ToursSidebar', () => {
|
|||||||
render(<ToursSidebar tripId={1} days={[]} tours={[listed]} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />)
|
render(<ToursSidebar tripId={1} days={[]} tours={[listed]} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />)
|
||||||
const row = screen.getByRole('option', { name: /Selected ridge walk/i })
|
const row = screen.getByRole('option', { name: /Selected ridge walk/i })
|
||||||
expect(row).toHaveTextContent('A quiet path above the lake')
|
expect(row).toHaveTextContent('A quiet path above the lake')
|
||||||
expect(row).toHaveTextContent('Komoot · komoot.com')
|
expect(row).toHaveTextContent('Komoot')
|
||||||
|
expect(row).not.toHaveTextContent('komoot.com')
|
||||||
expect(row).toHaveTextContent('1 h 35 min')
|
expect(row).toHaveTextContent('1 h 35 min')
|
||||||
expect(row.querySelector('[data-testid="tour-description-preview"]')).toHaveStyle({ WebkitLineClamp: '2' })
|
expect(row.querySelector('[data-testid="tour-description-preview"] > span')).toHaveStyle({ WebkitLineClamp: '2' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('selects the tour from keyboard activation', () => {
|
it('selects the tour from keyboard activation', () => {
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ describe('TourPlannerToursRail', () => {
|
|||||||
walkingDurationMinutes: 80, breakAdditionalMinutes: 50, plannedTotalMinutes: 130,
|
walkingDurationMinutes: 80, breakAdditionalMinutes: 50, plannedTotalMinutes: 130,
|
||||||
})} tours={[tour]} {...railProps} />)
|
})} tours={[tour]} {...railProps} />)
|
||||||
|
|
||||||
expect(screen.getByText('Walking time')).toBeInTheDocument()
|
expect(screen.getByText(/^(Estimated walking time|Est. walking time)$/)).toBeInTheDocument()
|
||||||
expect(screen.getByText('1 h 20 min')).toBeInTheDocument()
|
expect(screen.getByText('1 h 20 min')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -219,7 +219,7 @@ describe('TourPlannerToursRail', () => {
|
|||||||
const onViewGpxTour = vi.fn()
|
const onViewGpxTour = vi.fn()
|
||||||
render(<TourPlannerToursRail planner={controller} tours={[gpxTour]} {...railProps} onViewGpxTour={onViewGpxTour} />)
|
render(<TourPlannerToursRail planner={controller} tours={[gpxTour]} {...railProps} onViewGpxTour={onViewGpxTour} />)
|
||||||
|
|
||||||
expect(screen.getByRole('option', { name: /Saved ridge walk/i })).toHaveTextContent('GPX import')
|
expect(screen.getByRole('option', { name: /Saved ridge walk/i })).toHaveTextContent('GPX')
|
||||||
fireEvent.click(screen.getByRole('option', { name: /Saved ridge walk/i }))
|
fireEvent.click(screen.getByRole('option', { name: /Saved ridge walk/i }))
|
||||||
expect(onViewGpxTour).not.toHaveBeenCalled()
|
expect(onViewGpxTour).not.toHaveBeenCalled()
|
||||||
expect(controller.openTour).not.toHaveBeenCalled()
|
expect(controller.openTour).not.toHaveBeenCalled()
|
||||||
@@ -366,20 +366,19 @@ describe('TourPlannerRail', () => {
|
|||||||
|
|
||||||
expect(screen.queryByTestId('tour-planner-walking-time')).not.toBeInTheDocument()
|
expect(screen.queryByTestId('tour-planner-walking-time')).not.toBeInTheDocument()
|
||||||
expect(screen.getByTestId('tour-planner-time-grid')).toHaveClass('grid')
|
expect(screen.getByTestId('tour-planner-time-grid')).toHaveClass('grid')
|
||||||
expect(screen.getByTestId('tour-planner-time-grid')).toHaveClass('grid-cols-[repeat(auto-fit,minmax(min(100%,12rem),1fr))]')
|
expect(screen.getByTestId('tour-planner-time-grid')).toHaveClass('grid-cols-2')
|
||||||
const breaks = screen.getByLabelText('Breaks / additional time')
|
const breaks = screen.getByLabelText('Breaks / additional time')
|
||||||
expect(breaks).toHaveValue(35)
|
// A duration field shows "1 h 35 min" and reads minutes, a clock or units back.
|
||||||
|
expect(breaks).toHaveValue('35 min')
|
||||||
fireEvent.change(breaks, { target: { value: '50' } })
|
fireEvent.change(breaks, { target: { value: '50' } })
|
||||||
expect(setBreakAdditionalMinutes).toHaveBeenCalledWith(50)
|
expect(setBreakAdditionalMinutes).toHaveBeenCalledWith(50)
|
||||||
fireEvent.change(breaks, { target: { value: '' } })
|
fireEvent.change(breaks, { target: { value: '' } })
|
||||||
expect(setBreakAdditionalMinutes).toHaveBeenLastCalledWith(null)
|
expect(setBreakAdditionalMinutes).toHaveBeenLastCalledWith(null)
|
||||||
fireEvent.change(breaks, { target: { value: '50' } })
|
fireEvent.change(breaks, { target: { value: '50' } })
|
||||||
const input = screen.getByLabelText('Planned total duration')
|
const input = screen.getByLabelText('Planned total duration')
|
||||||
expect(input).toHaveValue(95)
|
expect(input).toHaveValue('1 h 35 min')
|
||||||
expect(input).toHaveStyle({ width: '6rem' })
|
|
||||||
expect(screen.getByText('Calculated total')).toBeInTheDocument()
|
|
||||||
expect(controller.durationSeconds).toBe(3600)
|
expect(controller.durationSeconds).toBe(3600)
|
||||||
fireEvent.change(input, { target: { value: '125' } })
|
fireEvent.change(input, { target: { value: '2:05' } })
|
||||||
expect(setPlannedDurationMinutes).toHaveBeenCalledWith(125)
|
expect(setPlannedDurationMinutes).toHaveBeenCalledWith(125)
|
||||||
|
|
||||||
rerender(<TourPlannerRail planner={planner({
|
rerender(<TourPlannerRail planner={planner({
|
||||||
@@ -388,8 +387,8 @@ describe('TourPlannerRail', () => {
|
|||||||
plannedTotalMinutes: 125, walkingDurationMinutes: 60, breakAdditionalMinutes: 35,
|
plannedTotalMinutes: 125, walkingDurationMinutes: 60, breakAdditionalMinutes: 35,
|
||||||
})} />)
|
})} />)
|
||||||
const overriddenInput = screen.getByLabelText(/Planned total duration/)
|
const overriddenInput = screen.getByLabelText(/Planned total duration/)
|
||||||
expect(overriddenInput).toHaveValue(125)
|
expect(overriddenInput).toHaveValue('2 h 5 min')
|
||||||
expect(document.querySelector('label[for="tour-planner-planned-duration"]')).toHaveTextContent('Manual override')
|
expect(screen.getByTestId('tour-planner-planned-manual')).toHaveTextContent('Manual override')
|
||||||
fireEvent.change(overriddenInput, { target: { value: '' } })
|
fireEvent.change(overriddenInput, { target: { value: '' } })
|
||||||
expect(setPlannedDurationMinutes).toHaveBeenCalledWith(null)
|
expect(setPlannedDurationMinutes).toHaveBeenCalledWith(null)
|
||||||
|
|
||||||
@@ -602,7 +601,8 @@ describe('TourPlannerRail', () => {
|
|||||||
render(<TourPlannerRail planner={controller} />)
|
render(<TourPlannerRail planner={controller} />)
|
||||||
|
|
||||||
const save = screen.getByRole('button', { name: 'Save' })
|
const save = screen.getByRole('button', { name: 'Save' })
|
||||||
expect(save.parentElement?.parentElement).toHaveTextContent('Routing is planning assistance, not a safety guarantee.')
|
// The safety note lives in the info dot beside the hiking difficulty, as its description.
|
||||||
|
expect(screen.getByRole('button', { name: 'About safety' })).toHaveAccessibleDescription('Routing is planning assistance, not a safety guarantee.')
|
||||||
expect(save).toHaveClass('bg-accent', 'text-accent-text')
|
expect(save).toHaveClass('bg-accent', 'text-accent-text')
|
||||||
expect(save).toHaveClass('hover:opacity-90')
|
expect(save).toHaveClass('hover:opacity-90')
|
||||||
const firstNumber = screen.getByLabelText('Waypoint 1')
|
const firstNumber = screen.getByLabelText('Waypoint 1')
|
||||||
|
|||||||
@@ -1644,7 +1644,7 @@ describe('useTourPlanner', () => {
|
|||||||
expect(result.current.canSave).toBe(false)
|
expect(result.current.canSave).toBe(false)
|
||||||
expect(localStorage.getItem('tour-draft-29')).toBeNull()
|
expect(localStorage.getItem('tour-draft-29')).toBeNull()
|
||||||
expect(screen.getByText('Plan a tour')).toBeInTheDocument()
|
expect(screen.getByText('Plan a tour')).toBeInTheDocument()
|
||||||
expect(screen.queryByText('Tour could not be saved. Your draft is still here.')).not.toBeInTheDocument()
|
expect(screen.queryByLabelText('Tour could not be saved. Your draft is still here.')).not.toBeInTheDocument()
|
||||||
await act(async () => { expect(await result.current.save()).toBeNull() })
|
await act(async () => { expect(await result.current.save()).toBeNull() })
|
||||||
expect(updateTour).not.toHaveBeenCalled()
|
expect(updateTour).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
@@ -1669,6 +1669,6 @@ describe('useTourPlanner', () => {
|
|||||||
|
|
||||||
expect(result.current.editingPlaceId).toBe(savedTour.place_id)
|
expect(result.current.editingPlaceId).toBe(savedTour.place_id)
|
||||||
expect(result.current.name).toBe('Keep this draft')
|
expect(result.current.name).toBe('Keep this draft')
|
||||||
expect(screen.getByText('Tour could not be saved. Your draft is still here.')).toBeInTheDocument()
|
expect(screen.getByLabelText('Tour could not be saved. Your draft is still here.')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { usePlaceLanguage } from '../../../hooks/usePlaceLanguage';
|
import { usePlaceLanguage } from '../../../hooks/usePlaceLanguage';
|
||||||
import { reverseGeocodeRepo } from '../../../repo/reverseGeocodeRepo';
|
import { reverseGeocodeRepo } from '../../../repo/reverseGeocodeRepo';
|
||||||
|
|
||||||
@@ -55,10 +55,13 @@ export function useWaypointPlaceNames(waypoints: WaypointCoordinate[]): Record<s
|
|||||||
};
|
};
|
||||||
}, [lang, signature]);
|
}, [lang, signature]);
|
||||||
|
|
||||||
const result: Record<string, string> = {};
|
// The same object while the points and their names stay, so a list or a map built on it does not redraw.
|
||||||
for (const point of waypoints) {
|
return useMemo(() => {
|
||||||
const entry = names[point.id];
|
const result: Record<string, string> = {};
|
||||||
if (entry && entry.key === coordinateKey(point)) result[point.id] = entry.name;
|
for (const point of waypoints) {
|
||||||
}
|
const entry = names[point.id];
|
||||||
return result;
|
if (entry && entry.key === coordinateKey(point)) result[point.id] = entry.name;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [names, waypoints]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const gl = vi.hoisted(() => {
|
|||||||
const listeners = new Map<string, Handler[]>();
|
const listeners = new Map<string, Handler[]>();
|
||||||
const sources = new Map<string, Record<string, unknown>>();
|
const sources = new Map<string, Record<string, unknown>>();
|
||||||
const markers: MarkerStub[] = [];
|
const markers: MarkerStub[] = [];
|
||||||
|
const container = document.createElement('div');
|
||||||
const listen = (type: string, fn: Handler) => listeners.set(type, [...(listeners.get(type) ?? []), fn]);
|
const listen = (type: string, fn: Handler) => listeners.set(type, [...(listeners.get(type) ?? []), fn]);
|
||||||
const map = {
|
const map = {
|
||||||
on: vi.fn((type: string, fn: Handler) => {
|
on: vi.fn((type: string, fn: Handler) => {
|
||||||
@@ -39,6 +40,14 @@ const gl = vi.hoisted(() => {
|
|||||||
listen(type, wrapped);
|
listen(type, wrapped);
|
||||||
return map;
|
return map;
|
||||||
}),
|
}),
|
||||||
|
off: vi.fn((type: string, fn: Handler) => {
|
||||||
|
listeners.set(
|
||||||
|
type,
|
||||||
|
(listeners.get(type) ?? []).filter((other) => other !== fn)
|
||||||
|
);
|
||||||
|
return map;
|
||||||
|
}),
|
||||||
|
getContainer: vi.fn(() => container),
|
||||||
remove: vi.fn(),
|
remove: vi.fn(),
|
||||||
fitBounds: vi.fn(),
|
fitBounds: vi.fn(),
|
||||||
jumpTo: vi.fn(),
|
jumpTo: vi.fn(),
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ describe('Tours English and German copy', () => {
|
|||||||
['saving', 'tours.planner.status.saving'],
|
['saving', 'tours.planner.status.saving'],
|
||||||
] as const) {
|
] as const) {
|
||||||
rerender(<TourPlannerRail planner={planner({ mode: { type: 'new-draft' }, status, error: null })} />)
|
rerender(<TourPlannerRail planner={planner({ mode: { type: 'new-draft' }, status, error: null })} />)
|
||||||
expect(screen.getByText(toursText(de, key))).toBeInTheDocument()
|
expect(screen.getByLabelText(toursText(de, key))).toBeInTheDocument()
|
||||||
assertNoRawToursKeys()
|
assertNoRawToursKeys()
|
||||||
}
|
}
|
||||||
rerender(<TourPlannerRail planner={planner({ mode: { type: 'new-draft' }, status: 'ready', error: 'save' })} />)
|
rerender(<TourPlannerRail planner={planner({ mode: { type: 'new-draft' }, status: 'ready', error: 'save' })} />)
|
||||||
|
|||||||
@@ -73,9 +73,9 @@ describe('Tours permissions without database setup', () => {
|
|||||||
|
|
||||||
it.each([[false, false], [true, false], [false, true], [true, true]])('update edit=%s assign=%s writes only with place rights and never assigns', async (canEdit, canAssign) => {
|
it.each([[false, false], [true, false], [false, true], [true, true]])('update edit=%s assign=%s writes only with place rights and never assigns', async (canEdit, canAssign) => {
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
state.detail.mockResolvedValue({ tour, waypoints: [{ lat: 48, lng: 11, role: 'start' }, { lat: 49, lng: 12, role: 'end' }] })
|
state.detail.mockResolvedValue({ tour, waypoints: [{ lat: 48, lng: 11, role: 'start' }, { lat: 48.008, lng: 11.008, role: 'end' }] })
|
||||||
state.route.mockResolvedValue({ coordinates: [[48, 11], [49, 12]], distanceMeters: 1000, durationSeconds: 600 })
|
state.route.mockResolvedValue({ coordinates: [[48, 11], [48.008, 11.008]], distanceMeters: 1000, durationSeconds: 600 })
|
||||||
state.elevation.mockResolvedValue([[48, 11, 500], [49, 12, 550]])
|
state.elevation.mockResolvedValue([[48, 11, 500], [48.008, 11.008, 550]])
|
||||||
state.update.mockResolvedValue({ tour, waypoints: [] })
|
state.update.mockResolvedValue({ tour, waypoints: [] })
|
||||||
const saved = vi.fn()
|
const saved = vi.fn()
|
||||||
const { result } = renderHook(() => useTourPlanner({ tripId: 7, canEdit, canAssign, onSaved: saved }))
|
const { result } = renderHook(() => useTourPlanner({ tripId: 7, canEdit, canAssign, onSaved: saved }))
|
||||||
@@ -159,11 +159,11 @@ describe('Tours permissions without database setup', () => {
|
|||||||
})
|
})
|
||||||
it.each([[false, false], [true, false], [false, true], [true, true]])('planner edit=%s assign=%s gates direct create and retained save after revocation', async (canEdit, canAssign) => {
|
it.each([[false, false], [true, false], [false, true], [true, true]])('planner edit=%s assign=%s gates direct create and retained save after revocation', async (canEdit, canAssign) => {
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
state.route.mockResolvedValue({ coordinates: [[48, 11], [49, 12]], distanceMeters: 1000, durationSeconds: 600 })
|
state.route.mockResolvedValue({ coordinates: [[48, 11], [48.008, 11.008]], distanceMeters: 1000, durationSeconds: 600 })
|
||||||
state.elevation.mockResolvedValue([[48, 11, 500], [49, 12, 550]])
|
state.elevation.mockResolvedValue([[48, 11, 500], [48.008, 11.008, 550]])
|
||||||
state.create.mockResolvedValue({ tour, waypoints: [] })
|
state.create.mockResolvedValue({ tour, waypoints: [] })
|
||||||
const { result, rerender } = renderHook(({ edit }) => useTourPlanner({ tripId: 7, canEdit: edit, canAssign }), { initialProps: { edit: canEdit } })
|
const { result, rerender } = renderHook(({ edit }) => useTourPlanner({ tripId: 7, canEdit: edit, canAssign }), { initialProps: { edit: canEdit } })
|
||||||
act(() => { result.current.setName('Ridge'); result.current.addWaypoint(48, 11); result.current.addWaypoint(49, 12) })
|
act(() => { result.current.setName('Ridge'); result.current.addWaypoint(48, 11); result.current.addWaypoint(48.008, 11.008) })
|
||||||
await act(async () => { await vi.advanceTimersByTimeAsync(450) })
|
await act(async () => { await vi.advanceTimersByTimeAsync(450) })
|
||||||
expect(result.current.canSave).toBe(canEdit)
|
expect(result.current.canSave).toBe(canEdit)
|
||||||
expect(result.current.waypoints.length).toBe(canEdit ? 2 : 0)
|
expect(result.current.waypoints.length).toBe(canEdit ? 2 : 0)
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ describe('TripPlannerPage — shell', () => {
|
|||||||
toursEnabled: true,
|
toursEnabled: true,
|
||||||
places: [],
|
places: [],
|
||||||
});
|
});
|
||||||
const input = screen.getByRole('combobox', { name: 'reservations.searchLocation' });
|
const input = screen.getByRole('combobox', { name: 'tours.planner.mapSearch' });
|
||||||
const mapElement = screen.getByTestId('map-view');
|
const mapElement = screen.getByTestId('map-view');
|
||||||
|
|
||||||
await user.type(input, 'Kyoto');
|
await user.type(input, 'Kyoto');
|
||||||
@@ -245,6 +245,6 @@ describe('TripPlannerPage — shell', () => {
|
|||||||
|
|
||||||
view.unmount();
|
view.unmount();
|
||||||
renderPage({ activeTab: 'plan', showPlaceForm: true });
|
renderPage({ activeTab: 'plan', showPlaceForm: true });
|
||||||
expect(screen.queryByRole('combobox', { name: 'reservations.searchLocation' })).not.toBeInTheDocument();
|
expect(screen.queryByRole('combobox', { name: 'tours.planner.mapSearch' })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { act, fireEvent, render, screen, waitFor } from '../../../tests/helpers/
|
|||||||
import { useTripFerratas } from '../../components/Ferratas/useTripFerratas';
|
import { useTripFerratas } from '../../components/Ferratas/useTripFerratas';
|
||||||
import type { MapViewProps } from '../../components/Map/mapViewContract';
|
import type { MapViewProps } from '../../components/Map/mapViewContract';
|
||||||
import { useSettingsStore } from '../../store/settingsStore';
|
import { useSettingsStore } from '../../store/settingsStore';
|
||||||
|
import { initialTourPlaceToolState, useTourPlaceToolStore } from '../../store/tourPlaceToolStore';
|
||||||
import { TourPlannerMapStage, type TourPlannerMapStageProps } from './TourPlannerMapStage';
|
import { TourPlannerMapStage, type TourPlannerMapStageProps } from './TourPlannerMapStage';
|
||||||
|
|
||||||
type Handler = (event?: unknown) => void;
|
type Handler = (event?: unknown) => void;
|
||||||
@@ -19,6 +20,21 @@ const stage = vi.hoisted(() => {
|
|||||||
once: vi.fn(() => map),
|
once: vi.fn(() => map),
|
||||||
off: vi.fn(() => map),
|
off: vi.fn(() => map),
|
||||||
loaded: vi.fn(() => false),
|
loaded: vi.fn(() => false),
|
||||||
|
// The style never finishes in these tests: the ferrata layer waits for it and draws nothing.
|
||||||
|
isStyleLoaded: vi.fn(() => false),
|
||||||
|
getContainer: vi.fn(() => document.body),
|
||||||
|
getLayer: vi.fn(() => undefined),
|
||||||
|
hasImage: vi.fn(() => false),
|
||||||
|
addImage: vi.fn(),
|
||||||
|
removeImage: vi.fn(),
|
||||||
|
addSource: vi.fn(),
|
||||||
|
addLayer: vi.fn(),
|
||||||
|
removeLayer: vi.fn(),
|
||||||
|
removeSource: vi.fn(),
|
||||||
|
setFilter: vi.fn(),
|
||||||
|
setPaintProperty: vi.fn(),
|
||||||
|
queryRenderedFeatures: vi.fn(() => []),
|
||||||
|
getCanvas: vi.fn(() => ({ style: {} })),
|
||||||
getBounds: vi.fn(() => ({
|
getBounds: vi.fn(() => ({
|
||||||
getSouth: () => 46.5,
|
getSouth: () => 46.5,
|
||||||
getWest: () => 11.7,
|
getWest: () => 11.7,
|
||||||
@@ -153,6 +169,7 @@ async function openTerrain() {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
window.sessionStorage.clear();
|
window.sessionStorage.clear();
|
||||||
|
useTourPlaceToolStore.setState(initialTourPlaceToolState());
|
||||||
stage.listeners.clear();
|
stage.listeners.clear();
|
||||||
stage.flatMap.props = null;
|
stage.flatMap.props = null;
|
||||||
stage.search.props = null;
|
stage.search.props = null;
|
||||||
@@ -251,6 +268,7 @@ describe('TourPlannerMapStage', () => {
|
|||||||
null,
|
null,
|
||||||
false,
|
false,
|
||||||
{ south: 46, west: 7, north: 46, east: 7 },
|
{ south: 46, west: 7, north: 46, east: 7 },
|
||||||
|
[],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -277,8 +295,8 @@ describe('TourPlannerMapStage', () => {
|
|||||||
const overlay = screen.getByTestId('ferrata-detail-overlay');
|
const overlay = screen.getByTestId('ferrata-detail-overlay');
|
||||||
// The right panel's 380px plus the 20px the layer pill keeps to the left one.
|
// The right panel's 380px plus the 20px the layer pill keeps to the left one.
|
||||||
expect(overlay.style.insetInlineEnd).toBe('400px');
|
expect(overlay.style.insetInlineEnd).toBe('400px');
|
||||||
// Above the bottom row: its 12px gap, the 42px pill, the same gap again.
|
// On the flat map nothing is under its corner: it rests at the edge gap.
|
||||||
expect(overlay.style.bottom).toBe('66px');
|
expect(overlay.style.bottom).toBe('12px');
|
||||||
expect(overlay).toHaveTextContent('Via delle Trincee');
|
expect(overlay).toHaveTextContent('Via delle Trincee');
|
||||||
expect(stage.flatMap.props?.ferrataLayer?.selectedId).toBe(group.id);
|
expect(stage.flatMap.props?.ferrataLayer?.selectedId).toBe(group.id);
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ const hookText = readFileSync(resolve(process.cwd(), 'src/pages/tripPlanner/useT
|
|||||||
const pageControllerText = readFileSync(resolve(process.cwd(), 'src/pages/tripPlanner/useTripPlannerPage.ts'), 'utf8')
|
const pageControllerText = readFileSync(resolve(process.cwd(), 'src/pages/tripPlanner/useTripPlannerPage.ts'), 'utf8')
|
||||||
// The Tours map and its edit gates live in the map stage the page renders.
|
// The Tours map and its edit gates live in the map stage the page renders.
|
||||||
const mapStageText = readFileSync(resolve(process.cwd(), 'src/pages/tripPlanner/TourPlannerMapStage.tsx'), 'utf8')
|
const mapStageText = readFileSync(resolve(process.cwd(), 'src/pages/tripPlanner/TourPlannerMapStage.tsx'), 'utf8')
|
||||||
|
// Which clicks place a point is decided by the map stage's points hook.
|
||||||
|
const mapPointsText = readFileSync(resolve(process.cwd(), 'src/pages/tripPlanner/useTourMapPoints.ts'), 'utf8')
|
||||||
const parse = (file: string, text: string) => ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true)
|
const parse = (file: string, text: string) => ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true)
|
||||||
const page = parse(pagePath, pageText)
|
const page = parse(pagePath, pageText)
|
||||||
const hook = parse(hookPath, hookText)
|
const hook = parse(hookPath, hookText)
|
||||||
@@ -187,17 +189,18 @@ test('B4A desktop page delegates Tours state and permissions to its page hook',
|
|||||||
const mapClickGate = collect(mapStage, node => ts.isJsxAttribute(node)
|
const mapClickGate = collect(mapStage, node => ts.isJsxAttribute(node)
|
||||||
&& node.name.getText(mapStage) === 'onMapClick'
|
&& node.name.getText(mapStage) === 'onMapClick'
|
||||||
&& node.initializer && ts.isJsxExpression(node.initializer)
|
&& node.initializer && ts.isJsxExpression(node.initializer)
|
||||||
&& !!node.initializer.expression && /canEditRoute|canPlaceEdit/.test(node.initializer.expression.getText(mapStage)))
|
&& !!node.initializer.expression && /onAdd/.test(node.initializer.expression.getText(mapStage)))
|
||||||
assert.ok(mapClickGate.length > 0)
|
assert.ok(mapClickGate.length > 0)
|
||||||
// The gate the 2D and 3D maps share is derived from the place permission.
|
// The gate the 2D and 3D maps share is derived from the place permission.
|
||||||
assert.match(mapStageText, /const canEditRoute = canPlaceEdit && /)
|
assert.match(mapStageText, /useTourMapPoints\(\{[\s\S]*?canPlaceEdit,/)
|
||||||
|
assert.match(mapPointsText, /const canEditRoute = canPlaceEdit && /)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('B4A dedicated desktop controller retains GPX, draft, focus, and saved rail wiring', () => {
|
test('B4A dedicated desktop controller retains GPX, draft, focus, and saved rail wiring', () => {
|
||||||
assert.ok(callNamed(pageController, 'useTourPlanner'))
|
assert.ok(callNamed(pageController, 'useTourPlanner'))
|
||||||
assert.ok(pageText.includes('const pageState = useTripPlannerPage()'))
|
assert.ok(pageText.includes('const pageState = useTripPlannerPage()'))
|
||||||
assert.ok(pageControllerText.includes("tourPlanner.mode.type === 'view-gpx'"))
|
assert.ok(pageControllerText.includes("tourPlanner.mode.type === 'view-gpx'"))
|
||||||
for (const mode of ['new-draft', 'edit-saved']) assert.ok(mapStageText.includes(`tourPlanner.mode.type === '${mode}'`))
|
for (const mode of ['new-draft', 'edit-saved']) assert.ok(mapPointsText.includes(`tourPlanner.mode.type === '${mode}'`))
|
||||||
assert.match(pageControllerText, /readOnlyGpxAnalysis\?\.routeCoordinates/)
|
assert.match(pageControllerText, /readOnlyGpxAnalysis\?\.routeCoordinates/)
|
||||||
assert.match(mapStageText, /routeProfileFocus=\{tourPlanner\.routeProfileFocus\}/)
|
assert.match(mapStageText, /routeProfileFocus=\{tourPlanner\.routeProfileFocus\}/)
|
||||||
assert.match(pageText, /tourPlanner\.viewGpxTour\(tour, place\?\.route_geometry \?\? null\)/)
|
assert.match(pageText, /tourPlanner\.viewGpxTour\(tour, place\?\.route_geometry \?\? null\)/)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
import type { useOutsidePressGuard } from '../../components/Ferratas/useOutsidePressGuard';
|
import type { useOutsidePressGuard } from '../../components/Ferratas/useOutsidePressGuard';
|
||||||
import type { TourPlannerController } from '../../components/Tours/planner/useTourPlanner';
|
import type { TourPlannerController } from '../../components/Tours/planner/useTourPlanner';
|
||||||
import { useWaypointPlaceNames } from '../../components/Tours/planner/useWaypointPlaceNames';
|
import { useWaypointPlaceNames } from '../../components/Tours/planner/useWaypointPlaceNames';
|
||||||
@@ -5,6 +6,7 @@ import { useTranslation } from '../../i18n';
|
|||||||
import { usePlacedPointHint } from './usePlacedPointHint';
|
import { usePlacedPointHint } from './usePlacedPointHint';
|
||||||
import { useTourMapPlacing, type TourMapMode } from './useTourMapPlacing';
|
import { useTourMapPlacing, type TourMapMode } from './useTourMapPlacing';
|
||||||
|
|
||||||
|
const NO_POINTS: never[] = [];
|
||||||
const ROLE_KEY = {
|
const ROLE_KEY = {
|
||||||
start: 'tours.planner.start',
|
start: 'tours.planner.start',
|
||||||
via: 'tours.planner.via',
|
via: 'tours.planner.via',
|
||||||
@@ -44,7 +46,7 @@ export function useTourMapPoints({ tourPlanner, canPlaceEdit, mode, ferrataOpen,
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const editing = tourPlanner.mode.type === 'new-draft' || tourPlanner.mode.type === 'edit-saved';
|
const editing = tourPlanner.mode.type === 'new-draft' || tourPlanner.mode.type === 'edit-saved';
|
||||||
// The same lookup as the waypoint list, so a pin is named on hover as its row is.
|
// The same lookup as the waypoint list, so a pin is named on hover as its row is.
|
||||||
const placeNames = useWaypointPlaceNames(editing ? tourPlanner.waypoints : []);
|
const placeNames = useWaypointPlaceNames(editing ? tourPlanner.waypoints : NO_POINTS);
|
||||||
const canEditRoute = canPlaceEdit && !tourPlanner.isSaving && editing;
|
const canEditRoute = canPlaceEdit && !tourPlanner.isSaving && editing;
|
||||||
const { active, toggle, gate, attach } = useTourMapPlacing(mode);
|
const { active, toggle, gate, attach } = useTourMapPlacing(mode);
|
||||||
const placeOn = canEditRoute && active;
|
const placeOn = canEditRoute && active;
|
||||||
@@ -61,16 +63,23 @@ export function useTourMapPoints({ tourPlanner, canPlaceEdit, mode, ferrataOpen,
|
|||||||
addWaypoint(lat, lng);
|
addWaypoint(lat, lng);
|
||||||
};
|
};
|
||||||
const onAdd = guard(gate(placeOn ? addPoint : undefined));
|
const onAdd = guard(gate(placeOn ? addPoint : undefined));
|
||||||
|
// Kept by identity while nothing about the points changed, so the map does not redraw its pins on every render.
|
||||||
return {
|
const { waypoints } = tourPlanner;
|
||||||
waypoints: {
|
const points = useMemo(
|
||||||
points: editing
|
() =>
|
||||||
? tourPlanner.waypoints.map((point, index) => ({
|
editing
|
||||||
|
? waypoints.map((point, index) => ({
|
||||||
...point,
|
...point,
|
||||||
name: placeNames[point.id] ?? t('tours.planner.waypointLabel', { n: index + 1 }),
|
name: placeNames[point.id] ?? t('tours.planner.waypointLabel', { n: index + 1 }),
|
||||||
detail: t(ROLE_KEY[point.role]),
|
detail: t(ROLE_KEY[point.role]),
|
||||||
}))
|
}))
|
||||||
: [],
|
: NO_POINTS,
|
||||||
|
[editing, waypoints, placeNames, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
waypoints: {
|
||||||
|
points,
|
||||||
selectedId: editing ? tourPlanner.selectedWaypointId : null,
|
selectedId: editing ? tourPlanner.selectedWaypointId : null,
|
||||||
onSelect: tourPlanner.setSelectedWaypointId,
|
onSelect: tourPlanner.setSelectedWaypointId,
|
||||||
onMove: canEditRoute ? tourPlanner.setWaypointPosition : undefined,
|
onMove: canEditRoute ? tourPlanner.setWaypointPosition : undefined,
|
||||||
|
|||||||
@@ -51,4 +51,6 @@ export const UNRESET_STORES: Record<string, string> = {
|
|||||||
useServerVersionStore: NOT_YET,
|
useServerVersionStore: NOT_YET,
|
||||||
useStudioStore: NOT_YET,
|
useStudioStore: NOT_YET,
|
||||||
useSystemNoticeStore: NOT_YET,
|
useSystemNoticeStore: NOT_YET,
|
||||||
|
useTourFerrataCrossingStore: 'set by the tour map stage; the tests that read it reset it in their own beforeEach',
|
||||||
|
useTourPlaceToolStore: 'reset in the tour tests themselves to initialTourPlaceToolState()',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
|||||||
// + Tours: tour_types label_key, icon, color; tours.tour_type; tour_waypoints id, place_id, lat, lng, role, sequence.
|
// + Tours: tour_types label_key, icon, color; tours.tour_type; tour_waypoints id, place_id, lat, lng, role, sequence.
|
||||||
// + scheduler_leases: name, owner, expires_at (the scheduler always writes all three).
|
// + scheduler_leases: name, owner, expires_at (the scheduler always writes all three).
|
||||||
// + user_sessions: id, user_id, expires_at.
|
// + user_sessions: id, user_id, expires_at.
|
||||||
const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 446;
|
// + ferrata_tiles: tile_key, payload, fetched_at (a fetched tile always carries all three).
|
||||||
|
const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 449;
|
||||||
|
|
||||||
describe('entity class fields never carry a NaN initialiser (task-4-review-shape.md, Important 1)', () => {
|
describe('entity class fields never carry a NaN initialiser (task-4-review-shape.md, Important 1)', () => {
|
||||||
it('CLASSFIELD-001: new X() has no own property whose value is NaN, across all 133 entities', () => {
|
it('CLASSFIELD-001: new X() has no own property whose value is NaN, across all 133 entities', () => {
|
||||||
|
|||||||
@@ -271,7 +271,7 @@ describe('toFerratas', () => {
|
|||||||
{ type: 'node', id: 3, lat: 1, lon: 2 },
|
{ type: 'node', id: 3, lat: 1, lon: 2 },
|
||||||
{ type: 'way', id: -1, geometry: line },
|
{ type: 'way', id: -1, geometry: line },
|
||||||
way(5, {}, [{ lat: 47.1, lon: 11.2 }, null]),
|
way(5, {}, [{ lat: 47.1, lon: 11.2 }, null]),
|
||||||
way(6, {}, undefined),
|
{ type: 'way', id: 6, tags: { highway: 'via_ferrata' } },
|
||||||
way(7, {}, [{ lat: 470, lon: 11.2 }, line[1]]),
|
way(7, {}, [{ lat: 470, lon: 11.2 }, line[1]]),
|
||||||
way(8),
|
way(8),
|
||||||
way(8),
|
way(8),
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const tokens = (value: string) => [...value.matchAll(/\{[^{}]+\}/g)].map((match)
|
|||||||
// placeholder spec; here only the general keys are.
|
// placeholder spec; here only the general keys are.
|
||||||
const groups = pluralGroups(Object.keys(english));
|
const groups = pluralGroups(Object.keys(english));
|
||||||
const generalKeys = (keys: string[]) => keys.filter((key) => !pluralFormOf(key, groups));
|
const generalKeys = (keys: string[]) => keys.filter((key) => !pluralFormOf(key, groups));
|
||||||
const GENERAL_KEY_COUNT = 140;
|
const GENERAL_KEY_COUNT = 154;
|
||||||
|
|
||||||
function literalKeys(source: string): string[] {
|
function literalKeys(source: string): string[] {
|
||||||
const tree = ts.createSourceFile('locale.ts', source, ts.ScriptTarget.Latest, true);
|
const tree = ts.createSourceFile('locale.ts', source, ts.ScriptTarget.Latest, true);
|
||||||
@@ -34,7 +34,7 @@ function literalKeys(source: string): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('Tours locale contracts', () => {
|
describe('Tours locale contracts', () => {
|
||||||
it('covers all 27 supported locales with 140 general source keys', () => {
|
it('covers all 27 supported locales with 154 general source keys', () => {
|
||||||
expect(Object.keys(domains)).toHaveLength(27);
|
expect(Object.keys(domains)).toHaveLength(27);
|
||||||
expect(Object.keys(domains).sort()).toEqual(SUPPORTED_LANGUAGE_CODES.map((code) => `./${code}/tours.ts`).sort());
|
expect(Object.keys(domains).sort()).toEqual(SUPPORTED_LANGUAGE_CODES.map((code) => `./${code}/tours.ts`).sort());
|
||||||
expect(generalKeys(Object.keys(english))).toHaveLength(GENERAL_KEY_COUNT);
|
expect(generalKeys(Object.keys(english))).toHaveLength(GENERAL_KEY_COUNT);
|
||||||
|
|||||||
Reference in New Issue
Block a user