mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 06:56:35 +00:00
cd407cf80c
HIK-01. The details of the hiking planner gain an optional Winter activity switch, off by default, so the normal hiking flow asks for nothing more. Switched on, the hike is a winter hike or a snowshoe hike; the choice stays dimmed and out of reach while the switch is off. The label is saved with the tour, comes back when it is opened, survives a draft reload, a copy and a take-over, and switching it off takes only the label away: the route, its grade and the days stay as they were. The label is a label only. Routing, the walking time and the 3D terrain view work as for any hike, and the info dot and the head pill's tooltip say that nothing was checked for snow, avalanches or whether a path can be walked in winter. The saved label shows as a frosted snowflake on the corner of the tour's tile in the tour list and the day plan, and as a pill beside the type in the tour's detail. The server keeps it in tours.winter_activity (CHECK list, NULL for every tour saved before and for every other type), refuses it on a bike tour, a city walk and a paddle tour, and keeps the stored label when an older client saves a hike without the field. A snowshoe hike exports to GPX as snowshoeing. list_tours names the field, with the same words on what it does not say.
241 lines
10 KiB
TypeScript
241 lines
10 KiB
TypeScript
import { act, renderHook } from '@testing-library/react';
|
|
import { describe, expect, it } from 'vitest';
|
|
import { tourPlannerFacts, type TourPlannerFactsInput } from './tourPlannerFacts';
|
|
import { usePlannerHistory } from './usePlannerHistory';
|
|
import { useTourDraftStorage } from './useTourDraftStorage';
|
|
|
|
const hike = (overrides: Partial<TourPlannerFactsInput> = {}): TourPlannerFactsInput => ({
|
|
tourType: 'hike',
|
|
maxHikingDifficulty: 3,
|
|
hikingPreferences: null,
|
|
bikeProfile: 'touring',
|
|
winterActivity: null,
|
|
website: '',
|
|
plannedDurationMinutes: null,
|
|
breakAdditionalMinutes: null,
|
|
paddle: { boatType: null, waterContext: null, speedKmh: null, portageSpeedKmh: null, lakeStyle: null },
|
|
paddleSpeedInvalid: false,
|
|
paddleEstimate: null,
|
|
routeAnalysis: null,
|
|
durationSeconds: null,
|
|
...overrides,
|
|
});
|
|
|
|
describe('tourPlannerFacts', () => {
|
|
it('times a bike tour by the router even with its heights in, and saves its profile and nothing of a hike', () => {
|
|
const analysis = { distanceKm: 30, gain: 400, loss: 400 } as TourPlannerFactsInput['routeAnalysis'];
|
|
const facts = tourPlannerFacts(
|
|
hike({
|
|
tourType: 'bike',
|
|
bikeProfile: 'gravel',
|
|
routeAnalysis: analysis,
|
|
durationSeconds: 5_400,
|
|
hikingPreferences: { avoid_steps: true },
|
|
})
|
|
);
|
|
expect(facts.walkingDurationMinutes).toBe(90);
|
|
expect(facts.typeFields).toMatchObject({
|
|
tour_type: 'bike',
|
|
bike_profile: 'gravel',
|
|
max_hiking_difficulty: null,
|
|
hiking_preferences: null,
|
|
boat_type: null,
|
|
});
|
|
expect(tourPlannerFacts(hike()).typeFields).toMatchObject({ bike_profile: null, max_hiking_difficulty: 3 });
|
|
});
|
|
|
|
it('times a hike by the router until its heights are in, then by the signposts, breaks on top', () => {
|
|
expect(tourPlannerFacts(hike({ durationSeconds: 3_570 })).walkingDurationMinutes).toBe(60);
|
|
const analysis = { distanceKm: 8, gain: 600, loss: 600 } as TourPlannerFactsInput['routeAnalysis'];
|
|
const facts = tourPlannerFacts(
|
|
hike({ routeAnalysis: analysis, durationSeconds: 3_600, breakAdditionalMinutes: 30 })
|
|
);
|
|
const walking = facts.walkingDurationMinutes ?? 0;
|
|
expect(walking).toBeGreaterThan(60);
|
|
expect(facts.plannedTotalMinutes).toBe(walking + 30);
|
|
expect(facts.savedDurationSeconds).toBe(walking * 60);
|
|
expect(facts.typeFields).toMatchObject({ tour_type: 'hike', max_hiking_difficulty: 3, boat_type: null });
|
|
});
|
|
|
|
it('HIK-01: saves a hike with its winter label and the same times as without, and no other type with one', () => {
|
|
const analysis = { distanceKm: 8, gain: 600, loss: 600 } as TourPlannerFactsInput['routeAnalysis'];
|
|
const summer = tourPlannerFacts(hike({ routeAnalysis: analysis, breakAdditionalMinutes: 30 }));
|
|
const winter = tourPlannerFacts(
|
|
hike({ routeAnalysis: analysis, breakAdditionalMinutes: 30, winterActivity: 'snowshoe' })
|
|
);
|
|
expect(winter.typeFields).toMatchObject({
|
|
tour_type: 'hike',
|
|
winter_activity: 'snowshoe',
|
|
max_hiking_difficulty: 3,
|
|
});
|
|
expect(summer.typeFields).toMatchObject({ winter_activity: null });
|
|
// A label only: the time is the signposts' rule, as for any hike.
|
|
expect(winter.walkingDurationMinutes).toBe(summer.walkingDurationMinutes);
|
|
expect(winter.plannedTotalMinutes).toBe(summer.plannedTotalMinutes);
|
|
for (const tourType of ['bike', 'walk', 'kayak'] as const) {
|
|
expect(tourPlannerFacts(hike({ tourType, winterActivity: 'winter_hike' })).typeFields.winter_activity).toBeNull();
|
|
}
|
|
});
|
|
|
|
it('holds the fields to their bounds, and a total that runs past a day without an override', () => {
|
|
const facts = tourPlannerFacts(
|
|
hike({ website: 'http://plain.example', plannedDurationMinutes: 1.5, breakAdditionalMinutes: -1 })
|
|
);
|
|
expect(facts).toMatchObject({ websiteInvalid: true, plannedDurationInvalid: true, breakAdditionalInvalid: true });
|
|
expect(tourPlannerFacts(hike({ durationSeconds: 1_500 * 60 })).plannedTotalInvalid).toBe(true);
|
|
expect(
|
|
tourPlannerFacts(hike({ durationSeconds: 1_500 * 60, plannedDurationMinutes: 600 })).plannedTotalInvalid
|
|
).toBe(false);
|
|
expect(tourPlannerFacts(hike({ website: 'https://ok.example' })).websiteInvalid).toBe(false);
|
|
});
|
|
|
|
it('times a paddle tour on the water and asks for its boat and water', () => {
|
|
const paddle = hike({
|
|
tourType: 'kayak',
|
|
paddle: { boatType: 'canoe', waterContext: null, speedKmh: 5, portageSpeedKmh: 3, lakeStyle: 'shore' },
|
|
paddleEstimate: { waterMinutes: 80, estimateMinutes: 95 },
|
|
durationSeconds: 999,
|
|
});
|
|
const facts = tourPlannerFacts(paddle);
|
|
expect(facts.walkingDurationMinutes).toBe(95);
|
|
expect(facts.paddleIncomplete).toBe(true);
|
|
expect(facts.typeFields).toMatchObject({
|
|
tour_type: 'kayak',
|
|
max_hiking_difficulty: null,
|
|
boat_type: 'canoe',
|
|
paddle_lake_style: 'shore',
|
|
});
|
|
const unknown = tourPlannerFacts({ ...paddle, paddleEstimate: { waterMinutes: null, estimateMinutes: null } });
|
|
expect(unknown.walkingDurationMinutes).toBeNull();
|
|
expect(unknown.savedDurationSeconds).toBeNull();
|
|
});
|
|
|
|
it('CW-02: times a city walk by the router on foot, its stays on top in place of breaks, and saves no hiking limit', () => {
|
|
const analysis = { distanceKm: 4, gain: 300, loss: 300 } as TourPlannerFactsInput['routeAnalysis'];
|
|
const walk = hike({
|
|
tourType: 'walk',
|
|
routeAnalysis: analysis,
|
|
durationSeconds: 3_000,
|
|
breakAdditionalMinutes: 40,
|
|
stays: { stayMinutes: 75, unknownStays: 0, stations: 2 },
|
|
hikingPreferences: { avoid_steps: true },
|
|
});
|
|
const facts = tourPlannerFacts(walk);
|
|
expect(facts.walkingDurationMinutes).toBe(50);
|
|
expect(facts.automaticPlannedTotalMinutes).toBe(125);
|
|
expect(facts.plannedTotalMinutes).toBe(125);
|
|
expect(facts.savedDurationSeconds).toBe(3_000);
|
|
expect(facts.typeFields).toMatchObject({
|
|
tour_type: 'walk',
|
|
max_hiking_difficulty: null,
|
|
hiking_preferences: null,
|
|
bike_profile: null,
|
|
boat_type: null,
|
|
});
|
|
expect(facts.stationStays).toMatchObject({
|
|
walkingMinutes: 50,
|
|
stayMinutes: 75,
|
|
totalMinutes: 125,
|
|
partial: false,
|
|
});
|
|
// Stays nobody entered count for nothing; a total typed by hand still wins.
|
|
expect(tourPlannerFacts({ ...walk, stays: null }).automaticPlannedTotalMinutes).toBe(50);
|
|
expect(tourPlannerFacts({ ...walk, plannedDurationMinutes: 180 }).plannedTotalMinutes).toBe(180);
|
|
expect(tourPlannerFacts({ ...walk, stays: null, durationSeconds: null }).automaticPlannedTotalMinutes).toBeNull();
|
|
});
|
|
|
|
it('CW-02: a walk whose walking time is not known plans at least the stays entered, as its overview says', () => {
|
|
const walk = hike({
|
|
tourType: 'walk',
|
|
durationSeconds: null,
|
|
stays: { stayMinutes: 120, unknownStays: 1, stations: 3 },
|
|
});
|
|
const facts = tourPlannerFacts(walk);
|
|
expect(facts.walkingDurationMinutes).toBeNull();
|
|
expect(facts.plannedTotalMinutes).toBe(120);
|
|
expect(facts.stationStays).toMatchObject({ walkingMinutes: null, totalMinutes: 120, partial: true });
|
|
expect(facts.plannedTotalInvalid).toBe(false);
|
|
// Stays past the longest plan a tour may have hold the save up as the walking time would.
|
|
expect(
|
|
tourPlannerFacts({ ...walk, stays: { stayMinutes: 1_500, unknownStays: 0, stations: 2 } }).plannedTotalInvalid
|
|
).toBe(true);
|
|
expect(tourPlannerFacts(hike({ durationSeconds: 600 })).stationStays).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('usePlannerHistory', () => {
|
|
it('goes back and forth, forgets the undone on a new step, and keeps at most thirty', () => {
|
|
const { result } = renderHook(() => usePlannerHistory<number>());
|
|
act(() => result.current.record(1));
|
|
act(() => result.current.record(2));
|
|
let step: number | null = null;
|
|
act(() => {
|
|
step = result.current.undo(3);
|
|
});
|
|
expect(step).toBe(2);
|
|
expect(result.current.canRedo).toBe(true);
|
|
act(() => {
|
|
step = result.current.redo(2);
|
|
});
|
|
expect(step).toBe(3);
|
|
act(() => {
|
|
result.current.undo(3);
|
|
});
|
|
act(() => result.current.record(9));
|
|
expect(result.current.canRedo).toBe(false);
|
|
act(() => {
|
|
step = result.current.redo(9);
|
|
});
|
|
expect(step).toBeNull();
|
|
act(() => result.current.clear());
|
|
expect(result.current.canUndo).toBe(false);
|
|
act(() => {
|
|
step = result.current.undo(0);
|
|
});
|
|
expect(step).toBeNull();
|
|
for (let i = 0; i < 40; i++) act(() => result.current.record(i));
|
|
let undone = 0;
|
|
for (let i = 0; i < 40; i++)
|
|
act(() => {
|
|
if (result.current.undo(-1) !== null) undone++;
|
|
});
|
|
expect(undone).toBe(30);
|
|
});
|
|
});
|
|
|
|
describe('useTourDraftStorage', () => {
|
|
it('keeps a draft per trip, gives back only one that passes the check, and forgets it', () => {
|
|
localStorage.clear();
|
|
const draft = {
|
|
version: 9 as const,
|
|
name: 'Ridge',
|
|
waypoints: [{ id: 'a', lat: 47, lng: 11, role: 'start' as const }],
|
|
baseRevision: 4,
|
|
};
|
|
const first = renderHook(() => useTourDraftStorage(1));
|
|
act(() => first.result.current.keep(draft));
|
|
expect(renderHook(() => useTourDraftStorage(1)).result.current.restored).toMatchObject({
|
|
name: 'Ridge',
|
|
baseRevision: 4,
|
|
});
|
|
expect(renderHook(() => useTourDraftStorage(2)).result.current.restored).toBeNull();
|
|
localStorage.setItem('tour-draft-3', JSON.stringify({ ...draft, baseRevision: 0 }));
|
|
expect(renderHook(() => useTourDraftStorage(3)).result.current.restored).toBeNull();
|
|
localStorage.setItem('tour-draft-4', '{not json');
|
|
expect(renderHook(() => useTourDraftStorage(4)).result.current.restored).toBeNull();
|
|
act(() => first.result.current.forget());
|
|
expect(localStorage.getItem('tour-draft-1')).toBeNull();
|
|
});
|
|
|
|
it('HIK-01: gives back a draft with its winter label, and drops one whose label it does not know', () => {
|
|
localStorage.clear();
|
|
const draft = { version: 10, name: 'Snowy ridge', waypoints: [{ id: 'a', lat: 47, lng: 11, role: 'start' }] };
|
|
localStorage.setItem('tour-draft-5', JSON.stringify({ ...draft, winterActivity: 'snowshoe' }));
|
|
expect(renderHook(() => useTourDraftStorage(5)).result.current.restored).toMatchObject({
|
|
winterActivity: 'snowshoe',
|
|
});
|
|
localStorage.setItem('tour-draft-6', JSON.stringify({ ...draft, winterActivity: 'ski_tour' }));
|
|
expect(renderHook(() => useTourDraftStorage(6)).result.current.restored).toBeNull();
|
|
});
|
|
});
|