mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +00:00
fix(tours): keep a lost stage end visible on paddle tours
A stage end set on a lock or portage point the water router placed left the point automatic, so the router could drop it with its leg later, silently and without an undo step. Ending a stage there now makes the point the paddler's own, like any other edit of it. The notice for stage ends a change could not keep also vanished as soon as the router laid its points in again after the change. It now stays until the next change the user makes, an undo or redo, a save, or another draft.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
// FE-PADDLE-ROUTING-001 to FE-PADDLE-ROUTING-010: a paddle tour's route put together from the water router's legs (PAD-11).
|
||||
// FE-PADDLE-ROUTING-001 to FE-PADDLE-ROUTING-011: a paddle tour's route put together from the water router's legs (PAD-11).
|
||||
import type { PaddleRouteLeg, PaddleRouteSegment } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { paddleRouteRepo } from '../../../repo/paddleRouteRepo';
|
||||
@@ -191,6 +191,18 @@ describe('automatic points', () => {
|
||||
expect(adopted.find((point) => point.id === 'auto-3')?.auto).toBeUndefined();
|
||||
expect(adopted.find((point) => point.id === 'auto-1')?.auto).toBeDefined();
|
||||
});
|
||||
|
||||
it('FE-PADDLE-ROUTING-011: an automatic point made a stage end belongs to the paddler and stays when its leg changes', () => {
|
||||
const points = materializePaddleLegs([A, B, C], { legs: [WEIR_AND_LOCK, straight(B, C)] }, newId);
|
||||
const marked = adoptChangedPoints(
|
||||
points,
|
||||
points.map((point) => (point.id === 'auto-3' ? { ...point, stageEnd: true } : point))
|
||||
);
|
||||
expect(marked.find((point) => point.id === 'auto-3')).toMatchObject({ stageEnd: true });
|
||||
expect(marked.find((point) => point.id === 'auto-3')?.auto).toBeUndefined();
|
||||
const moved = marked.map((point) => (point.id === 'b' ? { ...point, lat: point.lat + 0.01 } : point));
|
||||
expect(pruneAutoPoints(moved).map((point) => point.id)).toEqual(['a', 'auto-3', 'b', 'c']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('planPaddleStep', () => {
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface PaddlePoint {
|
||||
fixedLeg?: TourFixedLeg | null;
|
||||
lock?: PaddleLock | null;
|
||||
shaping?: boolean;
|
||||
stageEnd?: boolean;
|
||||
/** Set by the planner where the router found a portage or a lock; gone once the paddler touches the point. */
|
||||
auto?: AutoBase;
|
||||
}
|
||||
@@ -224,7 +225,11 @@ export function materializePaddleLegs<T extends PaddlePoint>(
|
||||
return result;
|
||||
}
|
||||
|
||||
/** A point the paddler changed is theirs from then on: it loses its automatic mark. */
|
||||
/**
|
||||
* A point the paddler changed is theirs from then on: it loses its automatic
|
||||
* mark. Ending a stage there counts as a change (MDT-01), so the router never
|
||||
* drops a stage end with the leg around it.
|
||||
*/
|
||||
export function adoptChangedPoints<T extends PaddlePoint>(before: readonly T[], after: readonly T[]): T[] {
|
||||
const previous = new Map(before.map((point) => [point.id, point]));
|
||||
return after.map((point) => {
|
||||
@@ -233,6 +238,7 @@ export function adoptChangedPoints<T extends PaddlePoint>(before: readonly T[],
|
||||
const same =
|
||||
!!was &&
|
||||
samePlace(was, point) &&
|
||||
!!was.stageEnd === !!point.stageEnd &&
|
||||
JSON.stringify(was.lock ?? null) === JSON.stringify(point.lock ?? null) &&
|
||||
JSON.stringify(was.fixedLeg ?? null) === JSON.stringify(point.fixedLeg ?? null);
|
||||
if (same) return point;
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { routeWalkingTour, enrichTourElevations, createTour, detailTour, updateTour } = vi.hoisted(() => ({
|
||||
const { routeWalkingTour, enrichTourElevations, createTour, detailTour, updateTour, paddlePlan } = vi.hoisted(() => ({
|
||||
routeWalkingTour: vi.fn(),
|
||||
enrichTourElevations: vi.fn(),
|
||||
createTour: vi.fn(),
|
||||
detailTour: vi.fn(),
|
||||
updateTour: vi.fn(),
|
||||
paddlePlan: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./tourRouting', () => ({ routeWalkingTour, enrichTourElevations }));
|
||||
vi.mock('../../../repo/tourRepo', () => ({ tourRepo: { create: createTour, detail: detailTour, update: updateTour } }));
|
||||
vi.mock('../../../repo/paddleRouteRepo', () => ({ paddleRouteRepo: { plan: paddlePlan, forget: vi.fn() } }));
|
||||
vi.mock('../useTourPermissions', () => ({
|
||||
useTourPermissions: ({ canEdit = true, canAssign = true }: { canEdit?: boolean; canAssign?: boolean }) => ({
|
||||
canEdit,
|
||||
@@ -21,7 +23,7 @@ vi.mock('../useTourPermissions', () => ({
|
||||
import { validDraft } from './tourPlannerDraft';
|
||||
import { useTourPlanner } from './useTourPlanner';
|
||||
|
||||
// FE-TOUR-STAGES-001 to FE-TOUR-STAGES-008 (MDT-01)
|
||||
// FE-TOUR-STAGES-001 to FE-TOUR-STAGES-009 (MDT-01)
|
||||
|
||||
const POINTS: Array<[number, number]> = [
|
||||
[48, 11],
|
||||
@@ -54,6 +56,23 @@ const savedHike = {
|
||||
stage_count: 2,
|
||||
};
|
||||
|
||||
type LegAsked = { from: { lat: number; lng: number }; to: { lat: number; lng: number } };
|
||||
/** The water router's answer for every leg asked: water, a portage round a weir, water. */
|
||||
const withPortage = (legs: LegAsked[]) =>
|
||||
legs.map(({ from, to }) => {
|
||||
const at = (t: number): [number, number] => [
|
||||
from.lat + (to.lat - from.lat) * t,
|
||||
from.lng + (to.lng - from.lng) * t,
|
||||
];
|
||||
const segments = [
|
||||
{ kind: 'water', from: 0, to: 1 },
|
||||
{ kind: 'portage', from: 1, to: 2, source: 'tagged' },
|
||||
{ kind: 'water', from: 2, to: 3 },
|
||||
].map((segment) => ({ name: null, source: null, osm_ref: null, grade: null, ...segment }));
|
||||
const geometry = [at(0), at(0.3), at(0.4), at(1)];
|
||||
return { status: 'ok', geometry, segments, distance_m: 3000, snapped_m: { from: 0, to: 0 } };
|
||||
});
|
||||
|
||||
async function settle() {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(450);
|
||||
@@ -79,6 +98,7 @@ describe('useTourPlanner, multi-day tours (MDT-01)', () => {
|
||||
createTour.mockReset();
|
||||
detailTour.mockReset();
|
||||
updateTour.mockReset();
|
||||
paddlePlan.mockReset().mockImplementation(async (_trip: number, legs: LegAsked[]) => withPortage(legs));
|
||||
});
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
@@ -229,4 +249,26 @@ describe('useTourPlanner, multi-day tours (MDT-01)', () => {
|
||||
draft.waypoints[1].stageEnd = 'yes';
|
||||
expect(validDraft(draft)).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-TOUR-STAGES-009: on a paddle tour the loss stays said while the router lays its points in again', async () => {
|
||||
const { result } = await plannedHike(70);
|
||||
act(() => result.current.setTourType('kayak'));
|
||||
await settle();
|
||||
await settle();
|
||||
expect(result.current.waypoints.some((point) => point.auto)).toBe(true);
|
||||
const stop = result.current.waypoints.filter((point) => !point.auto)[1].id;
|
||||
act(() => result.current.stages.toggleStageEnd(stop));
|
||||
act(() => result.current.removeWaypoint(stop));
|
||||
const removed = result.current.waypoints;
|
||||
expect(result.current.stages.lost).toBe(1);
|
||||
await settle();
|
||||
await settle();
|
||||
await settle();
|
||||
// The router dropped and set its points around the gap without a step of the history.
|
||||
expect(result.current.waypoints).not.toBe(removed);
|
||||
expect(result.current.stages.lost).toBe(1);
|
||||
act(() => result.current.undo());
|
||||
expect(result.current.stages.lost).toBe(0);
|
||||
expect(result.current.waypoints.find((point) => point.id === stop)?.stageEnd).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -162,8 +162,9 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
const [routeProfileFocus, setRouteProfileFocus] = useState<RouteProfileFocus | null>(null)
|
||||
const [newDraftGeneration, setNewDraftGeneration] = useState(0)
|
||||
const [mapFocusKey, setMapFocusKey] = useState(0)
|
||||
// MDT-01: stage ends a change of the points could not keep, said while those points are the planner's.
|
||||
const [stageLoss, setStageLoss] = useState<{ count: number; after: TourPlannerWaypoint[] } | null>(null)
|
||||
// MDT-01: stage ends the last change of the points could not keep. Said until the user's next change,
|
||||
// undo or redo, or another draft, so a point the router lays in afterwards does not take the notice away.
|
||||
const [stageLoss, setStageLoss] = useState(0)
|
||||
const draftRevisionRef = useRef(0)
|
||||
const saveRequestIdRef = useRef(0)
|
||||
const saveInFlightRef = useRef(false)
|
||||
@@ -268,7 +269,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
waypointsRef.current = next
|
||||
setWaypoints(next)
|
||||
const lost = lostStageEnds(current, changed, next)
|
||||
setStageLoss(lost > 0 ? { count: lost, after: next } : null)
|
||||
setStageLoss(lost)
|
||||
// CW-02: a station's own fields (its stay, its name) plan no route: the routing goes on, and Save stays as it was.
|
||||
if (routeInputsKey(current) === routeInputsKey(next)) {
|
||||
markEdited()
|
||||
@@ -451,6 +452,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
runProfileRef.current = entry.runProfile
|
||||
tourTypeRef.current = entry.tourType
|
||||
setWaypoints(entry.waypoints)
|
||||
setStageLoss(0)
|
||||
setMaxHikingDifficultyState(entry.difficulty)
|
||||
setHikingPreferencesState(entry.preferences)
|
||||
setBikeProfileState(entry.bikeProfile)
|
||||
@@ -519,6 +521,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
recordStep(snapshot())
|
||||
tourTypeRef.current = value
|
||||
setTourTypeState(value)
|
||||
setStageLoss(0)
|
||||
const noFerrata = value === 'bike' || value === 'run'
|
||||
const next = normalizePlannerWaypoints(noFerrata ? ridablePoints(waypointsRef.current) : waypointsRef.current, value)
|
||||
waypointsRef.current = next
|
||||
@@ -551,7 +554,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
waypoints, line: routeCurrent ? (enrichedGeometry ?? route) : null, tourType, routerSeconds: durationSeconds,
|
||||
paddle: stagePaddle, commit: commitWaypoints, mayEdit: mayEditPoints,
|
||||
draftKey: `${editingPlaceId ?? 'new'}:${newDraftGeneration}`,
|
||||
lost: stageLoss?.after === waypoints ? stageLoss.count : 0,
|
||||
lost: stageLoss,
|
||||
})
|
||||
const stageCount = Math.max(1, stages.stages.length)
|
||||
const facts = useMemo(() => tourPlannerFacts({
|
||||
@@ -598,6 +601,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
tourTypeRef.current = 'hike'
|
||||
setSelectedWaypointId(null)
|
||||
clearHistory()
|
||||
setStageLoss(0)
|
||||
setStatus('empty')
|
||||
clearRoute()
|
||||
setError(null)
|
||||
@@ -778,6 +782,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
tourTypeRef.current = opened.tourType
|
||||
setSelectedWaypointId(nextWaypoints[0]?.id ?? null)
|
||||
clearHistory()
|
||||
setStageLoss(0)
|
||||
setStatus('dirty')
|
||||
clearRoute()
|
||||
// CW-11: a walk without its way yet is planned on opening and can be saved with it at once.
|
||||
@@ -921,6 +926,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
setRoutePending(false)
|
||||
setStatus('saved')
|
||||
clearHistory()
|
||||
setStageLoss(0)
|
||||
setEditingPlaceId(result.tour.place_id)
|
||||
editingPlaceIdRef.current = result.tour.place_id
|
||||
setDraftSource(null)
|
||||
|
||||
Reference in New Issue
Block a user