feat(tours): plan ski and winter tours, snowshoeing and winter hiking among them

Ski becomes "Ski & winter", a type the planner plans with six disciplines
(alpine, snowboard, cross-country, ski touring, snowshoeing, winter hiking),
nothing preselected; saving needs a discipline, and a technique for
cross-country. Every leg is drawn straight between the points through
planner/skiRouting.ts, the one place the piste router will plug in, with its
heights from the elevation source and no ski time worked out.

The hike's winter label goes entirely: its migration, column, field, marks,
GPX rule, MCP field and strings. A hike is summer only; a ski tour with the
snowshoe discipline exports as snowshoeing. The unreleased ski migration takes
the six disciplines and the collection migration loses the winter column.

The pistes are offered for a ski tour, shown until switched off, and with no
tour open, never for a hike. Export, copy, collections, stages (untimed),
actual tracks, the atlas, list and day marks (one glyph per discipline), 3D,
planning over a ski import and list_tours/get_tour_plan know the planned ski
tour.
This commit is contained in:
Maurice
2026-10-20 18:27:53 +02:00
parent 204ee2ef86
commit 164a3633ac
116 changed files with 1675 additions and 1576 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
* with a pace out of bounds, copied, exported and imported, through the real
* JwtAuthGuard, AddonGuard and TripAccessGuard against a migrated and seeded
* temp SQLite db. The permission check is a container spy and the broadcast
* goes to a FakeRealtimeService, as in tours-winter.e2e.test.ts.
* goes to a FakeRealtimeService, as in tours-ski.e2e.test.ts.
*
* Fixtures: user 1 owns trip 5, user 2 is a member of trip 5 whom the
* permission spy denies 'place_edit'.
+61 -7
View File
@@ -1,8 +1,9 @@
/**
* Tours e2e for ski tracks (SKI-02): a GPX file imported as a ski track with
* Tours e2e for ski tours (SKI-02): a GPX file imported as a ski track with
* its discipline (and a cross-country technique), kept as recorded with no
* time worked out, listed, opened, exported, copied and classified again
* without touching the track, its days or its provenance, through the real
* without touching the track, its days or its provenance; a ski tour planned
* with its discipline, drawn and untimed; through the real
* JwtAuthGuard, AddonGuard and TripAccessGuard against a migrated and seeded
* temp SQLite db. The permission check is a container spy and the broadcast
* goes to a FakeRealtimeService, as in tours-run.e2e.test.ts.
@@ -75,7 +76,7 @@ const GPX = `<?xml version="1.0"?>
<trkpt lat="47.22" lon="11.32"><ele>1980</ele></trkpt>
</trkseg></trk></gpx>`;
// A planned hike, the type a ski track is not.
// A planned hike; a planned ski tour is the same body as type ski with its discipline and no time.
const hikeBody = {
name: 'Ridge walk',
tour_type: 'hike',
@@ -223,13 +224,66 @@ describe('Ski tracks e2e (real guards + temp SQLite)', () => {
expect(await countRows(orm, Places)).toBe(0);
});
it('TOURS-SKI-E2E-004: a ski track is never planned: a planned save of one is refused', async () => {
it('TOURS-SKI-E2E-004: a planned ski tour saves with its discipline, drawn and untimed, and never without one', async () => {
const planned = { ...hikeBody, name: 'Snowshoes to the hut', tour_type: 'ski', max_hiking_difficulty: null };
const refused = await oneByOne(
[
{ ...planned, duration_seconds: null },
{ ...planned, duration_seconds: null, ski_discipline: 'cross_country' },
{ ...planned, duration_seconds: null, ski_discipline: 'alpine', ski_technique: 'classic' },
{ ...planned, ski_discipline: 'snowshoe' },
{ ...planned, duration_seconds: null, ski_discipline: 'snowshoe', max_hiking_difficulty: 3 },
],
(body) => request(server).post('/api/trips/5/tours').set('Cookie', sessionCookie(OWNER)).send(body),
);
expect(refused.map((res) => res.status)).toEqual([400, 400, 400, 400, 400]);
expect(await countRows(orm, Tours)).toBe(0);
const res = await request(server)
.post('/api/trips/5/tours')
.set('Cookie', sessionCookie(OWNER))
.send({ ...hikeBody, tour_type: 'ski', max_hiking_difficulty: null });
expect(res.status).toBe(400);
expect(await countRows(orm, Tours)).toBe(0);
.send({ ...planned, duration_seconds: null, ski_discipline: 'snowshoe' });
expect(res.status).toBe(201);
expect(res.body.tour).toMatchObject({
tour_type: 'ski',
ski_discipline: 'snowshoe',
ski_technique: null,
max_hiking_difficulty: null,
duration: null,
origin: 'trek',
});
expect(res.body.tour.elevation_gain).toBeGreaterThan(0);
});
it('TOURS-SKI-E2E-010: a planned cross-country tour keeps its technique, exports as its discipline, and a hike takes both off', async () => {
const planned = { ...hikeBody, tour_type: 'ski', max_hiking_difficulty: null, duration_seconds: null };
const created = await request(server)
.post('/api/trips/5/tours')
.set('Cookie', sessionCookie(OWNER))
.send({ ...planned, ski_discipline: 'cross_country', ski_technique: 'skating' });
expect(created.status).toBe(201);
const placeId = created.body.tour.place_id as number;
expect(created.body.tour).toMatchObject({ ski_discipline: 'cross_country', ski_technique: 'skating' });
const exported = await request(server)
.get(`/api/trips/5/tours/${placeId}/export.gpx`)
.set('Cookie', sessionCookie(OWNER))
.buffer(true)
.parse((response, done) => {
let text = '';
response.setEncoding('utf8');
response.on('data', (chunk: string) => (text += chunk));
response.on('end', () => done(null, text));
});
expect(/<type>([^<]+)<\/type>/.exec(String(exported.body))?.[1]).toBe('cross_country_skiing');
const asHike = await request(server)
.put(`/api/trips/5/tours/${placeId}`)
.set('Cookie', sessionCookie(OWNER))
.send({ ...hikeBody, expected_revision: created.body.tour.revision });
expect(asHike.status).toBe(200);
expect(asHike.body.tour).toMatchObject({ tour_type: 'hike', ski_discipline: null, ski_technique: null });
expect(await findRow(orm, Tours, { place: placeId })).toMatchObject({ ski_discipline: null, ski_technique: null });
});
it('TOURS-SKI-E2E-005: classified again, only the classification changes: track, measures, days and provenance stay', async () => {
-248
View File
@@ -1,248 +0,0 @@
/**
* Tours e2e for the winter label of a hike (HIK-01): saved, listed, kept by an
* edit that leaves it out, taken off without touching the route or the days,
* refused on any other type, copied and exported, through the real
* JwtAuthGuard, AddonGuard and TripAccessGuard against a migrated and seeded
* temp SQLite db. The permission check is a container spy and the broadcast
* goes to a FakeRealtimeService, as in tours-bike.e2e.test.ts.
*
* Fixtures: user 1 owns trip 5, user 2 is a member of trip 5 whom the
* permission spy denies 'place_edit'.
*/
import { DayAssignments } from '../../src/db/entities/DayAssignments.entity';
import { Days } from '../../src/db/entities/Days.entity';
import { Places } from '../../src/db/entities/Places.entity';
import { Tours } from '../../src/db/entities/Tours.entity';
import { Trips } from '../../src/db/entities/Trips.entity';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
import { JourneyDomainService } from '../../src/nest/journey/journey-domain.service';
import { MapsService } from '../../src/nest/maps/maps.service';
import { OverpassElementsClient } from '../../src/nest/overpass/overpass-elements.service';
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
import { RealtimeService } from '../../src/nest/realtime/realtime.service';
import { ToursModule } from '../../src/nest/tours/tours.module';
import { createSnapshotTestDb } from '../helpers/db-mock';
import { countRows, deleteRows, findRow, insertRows } from '../helpers/factories/rows';
import { setAddonEnabled } from '../helpers/factories/settings';
import { addTripMember } from '../helpers/factories/trips';
import { makeUser } from '../helpers/factories/users';
import { FakeRealtimeService } from '../helpers/fake-realtime';
import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { sessionCookie } from './harness';
import { Test } from '@nestjs/testing';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import request from 'supertest';
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockInstance } from 'vitest';
vi.mock('../../src/nest/audit/audit-log.logger', () => ({
LOG_LEVEL: 'error',
logInfo: vi.fn(),
logDebug: vi.fn(),
logError: vi.fn(),
logWarn: vi.fn(),
}));
const { journeyHooks } = vi.hoisted(() => ({
journeyHooks: {
reconcileTripSkeletons: vi.fn().mockResolvedValue(undefined),
onPlaceCreated: vi.fn().mockResolvedValue(undefined),
onPlaceUpdated: vi.fn().mockResolvedValue(undefined),
onPlaceDeleted: vi.fn().mockResolvedValue(undefined),
},
}));
const db = createSnapshotTestDb();
const realtime = new FakeRealtimeService();
let orm: TestOrm;
let checkPermission: MockInstance;
const OWNER = 1;
const VIEWER = 2;
const GEOMETRY = [
[47, 11, 1200],
[47.01, 11.01, 1350],
[47.02, 11.02, 1300],
];
const hikeBody = (overrides: Record<string, unknown> = {}) => ({
name: 'Snowy ridge',
tour_type: 'hike',
max_hiking_difficulty: 2,
winter_activity: 'snowshoe',
route_geometry: GEOMETRY,
waypoints: [
{ lat: 47, lng: 11, role: 'start', sequence: 0 },
{ lat: 47.02, lng: 11.02, role: 'end', sequence: 1 },
],
duration_seconds: 5400,
...overrides,
});
describe('Winter hikes e2e (real guards + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({
imports: [
await TestUnitOfWorkModule.forRoot(db),
await createTestMikroOrmModule(db),
RealtimeModule,
ToursModule,
],
})
.overrideProvider(RealtimeService)
.useValue(realtime)
.overrideProvider(JourneyDomainService)
.useValue(journeyHooks)
.overrideProvider(MapsService)
.useValue({ pois: vi.fn() })
// No network in a test: the trail grades read OpenStreetMap through Overpass.
.overrideProvider(OverpassElementsClient)
.useValue({ query: vi.fn() })
.compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalPipes(new ZodValidationPipe());
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
const send = (method: 'post' | 'put', path: string, body: Record<string, unknown>, userId = OWNER) =>
request(server)[method](`/api/trips/5/tours${path}`).set('Cookie', sessionCookie(userId)).send(body);
const createViaApi = async (body: Record<string, unknown> = hikeBody()) => {
const res = await send('post', '', body);
expect(res.status).toBe(201);
return res.body.tour.place_id as number;
};
const gpxTypeOf = async (placeId: number) => {
const res = await request(server)
.get(`/api/trips/5/tours/${placeId}/export.gpx`)
.set('Cookie', sessionCookie(OWNER))
.buffer(true)
.parse((res, done) => {
let text = '';
res.setEncoding('utf8');
res.on('data', (chunk: string) => (text += chunk));
res.on('end', () => done(null, text));
});
return /<type>([^<]+)<\/type>/.exec(String(res.body))?.[1] ?? '';
};
beforeAll(async () => {
orm = await createTestOrm(db);
// Pinned ids: the session cookies sign for exactly these users.
await makeUser(orm, { id: 1, username: 'owner', email: 'owner@example.test', role: 'user', password_version: 0 });
await makeUser(orm, { id: 2, username: 'viewer', email: 'viewer@example.test', role: 'user', password_version: 0 });
await insertRows(orm, Trips, [{ id: 5, user: 1, title: 'Winter week' }]);
await addTripMember(orm, 5, 2);
await insertRows(orm, Days, [{ id: 3, trip: 5, day_number: 1, date: '2027-01-10' }]);
app = await build();
checkPermission = vi.spyOn(app.get(PermissionsService), 'checkPermission');
server = app.getHttpServer();
});
beforeEach(async () => {
checkPermission.mockImplementation(
(action: string, _role: string, _owner: number, userId: number) =>
!(action === 'place_edit' && userId === VIEWER),
);
await setAddonEnabled(orm, 'tours', true);
realtime.broadcastMock.mockClear();
await deleteRows(orm, DayAssignments);
await deleteRows(orm, Places);
});
afterAll(async () => {
await app.close();
await orm.close();
});
it('TOURS-WINTER-E2E-001: saves a hike with its winter label, and lists and opens it so', async () => {
const placeId = await createViaApi();
expect(await findRow(orm, Tours, { place: placeId })).toMatchObject({
tour_type: 'hike',
winter_activity: 'snowshoe',
max_hiking_difficulty: 2,
});
const list = await request(server).get('/api/trips/5/tours').set('Cookie', sessionCookie(VIEWER));
expect(list.status).toBe(200);
expect(list.body.tours[0]).toMatchObject({ place_id: placeId, winter_activity: 'snowshoe' });
const detail = await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', sessionCookie(OWNER));
expect(detail.body.tour).toMatchObject({ tour_type: 'hike', winter_activity: 'snowshoe' });
});
it('TOURS-WINTER-E2E-002: a hike saved without one carries none, as every hike did before', async () => {
const placeId = await createViaApi(hikeBody({ winter_activity: undefined }));
expect((await findRow(orm, Tours, { place: placeId }))?.winter_activity).toBeNull();
});
it('TOURS-WINTER-E2E-003: an edit leaving it out keeps it; taking it off keeps the route and the days', async () => {
const placeId = await createViaApi();
await insertRows(orm, DayAssignments, [{ day: 3, place: placeId, order_index: 0 }]);
const kept = await send('put', `/${placeId}`, hikeBody({ winter_activity: undefined, expected_revision: 1 }));
expect(kept.status).toBe(200);
expect(kept.body.tour).toMatchObject({ winter_activity: 'snowshoe', revision: 2 });
const off = await send('put', `/${placeId}`, hikeBody({ winter_activity: null, expected_revision: 2 }));
expect(off.status).toBe(200);
expect(off.body.tour).toMatchObject({ winter_activity: null, tour_type: 'hike', planned: true });
expect(off.body.waypoints).toHaveLength(2);
expect(await countRows(orm, DayAssignments, { place: placeId })).toBe(1);
expect((await findRow(orm, Places, { id: placeId }))?.route_geometry).toBe(JSON.stringify(GEOMETRY));
});
it('TOURS-WINTER-E2E-004: refuses a label it does not know, and one on another type', async () => {
const refused = await Promise.all(
[
hikeBody({ winter_activity: 'ski_tour' }),
hikeBody({ tour_type: 'bike', bike_profile: 'mtb', max_hiking_difficulty: undefined }),
hikeBody({ tour_type: 'walk', max_hiking_difficulty: undefined, winter_activity: 'winter_hike' }),
].map((body) => send('post', '', body)),
);
expect(refused.map((res) => res.status)).toEqual([400, 400, 400]);
expect(await countRows(orm, Tours)).toBe(0);
});
it('TOURS-WINTER-E2E-005: a hike made into a bike tour loses its label', async () => {
const placeId = await createViaApi();
const bike = await send(
'put',
`/${placeId}`,
hikeBody({
tour_type: 'bike',
bike_profile: 'touring',
max_hiking_difficulty: undefined,
winter_activity: undefined,
}),
);
expect(bike.status).toBe(200);
expect(bike.body.tour).toMatchObject({ tour_type: 'bike', winter_activity: null });
});
it('TOURS-WINTER-E2E-006: a member who may only read cannot save one', async () => {
expect((await send('post', '', hikeBody(), VIEWER)).status).toBe(403);
});
it('TOURS-WINTER-E2E-007: a copy keeps the label', async () => {
const placeId = await createViaApi(hikeBody({ winter_activity: 'winter_hike' }));
const copy = await send('post', `/${placeId}/copy`, { name: 'Again' });
expect(copy.status).toBe(201);
expect(copy.body.tour).toMatchObject({ tour_type: 'hike', winter_activity: 'winter_hike' });
});
it('TOURS-WINTER-E2E-008: exports a snowshoe hike as snowshoeing, a winter hike as hiking', async () => {
expect(await gpxTypeOf(await createViaApi())).toBe('snowshoeing');
expect(await gpxTypeOf(await createViaApi(hikeBody({ winter_activity: 'winter_hike' })))).toBe('hiking');
});
});
@@ -66,7 +66,6 @@ const UNNUMBERED = [
'Migration20200101045200_a_tour_can_be_ridden',
'Migration20200101045300_a_tour_can_be_a_city_walk',
'Migration20200101045400_a_tour_waypoint_remembers_the_kind_of_place_it_was_found_as',
'Migration20200101045500_a_hike_can_be_marked_as_a_winter_activity',
'Migration20200101045510_a_tour_can_be_a_run',
'Migration20200101045520_a_tour_can_be_a_ski_track',
'Migration20200101045600_a_tour_can_be_ridden_on_horseback',
@@ -110,7 +110,6 @@ describe('ToursRepository reads', () => {
revision: 1,
hiking_preferences: null,
bike_profile: null,
winter_activity: null,
run_profile: null,
run_pace_seconds_per_km: null,
ski_discipline: null,
@@ -150,7 +149,6 @@ describe('ToursRepository reads', () => {
revision: 1,
hiking_preferences: null,
bike_profile: null,
winter_activity: null,
run_profile: null,
run_pace_seconds_per_km: null,
ski_discipline: null,
@@ -379,7 +377,6 @@ describe('ToursRepository writes', () => {
revision: 1,
hiking_preferences: null,
bike_profile: null,
winter_activity: null,
run_profile: null,
run_pace_seconds_per_km: null,
ski_discipline: null,
@@ -428,20 +425,19 @@ describe('ToursRepository writes', () => {
});
});
it('TOURREPO-022: a hike keeps its winter label through an edit that leaves it out, and loses it only when told (HIK-01)', async () => {
it('TOURREPO-022: a planned ski tour keeps snowshoeing or winter hiking as its discipline, and a hike takes it off (SKI-02)', async () => {
const { trip } = fixture();
const place = createPlace(testDb, trip.id, { name: 'Snowy ridge' });
await repo.insertTour({ place_id: place.id, ...METRICS, winter_activity: 'snowshoe' });
expect((await repo.findInTrip(trip.id, place.id))?.winter_activity).toBe('snowshoe');
const ski = { ...METRICS, tour_type: 'ski', max_hiking_difficulty: null, duration: null };
await repo.insertTour({ place_id: place.id, ...ski, ski_discipline: 'snowshoe' });
expect(await repo.findInTrip(trip.id, place.id)).toMatchObject({ tour_type: 'ski', ski_discipline: 'snowshoe' });
const winterHike = { ...HIKE_UPDATE, ...ski, ski_discipline: 'winter_hike' as const };
expect(await repo.updateInTrip(trip.id, place.id, winterHike)).toBe(true);
expect((await repo.listForTrip(trip.id))[0]).toMatchObject({ tour_type: 'ski', ski_discipline: 'winter_hike' });
expect(await repo.updateInTrip(trip.id, place.id, HIKE_UPDATE)).toBe(true);
expect((await repo.findInTrip(trip.id, place.id))?.winter_activity).toBe('snowshoe');
expect(await repo.updateInTrip(trip.id, place.id, { ...HIKE_UPDATE, winter_activity: 'winter_hike' })).toBe(true);
expect((await repo.listForTrip(trip.id))[0]?.winter_activity).toBe('winter_hike');
expect(await repo.updateInTrip(trip.id, place.id, { ...HIKE_UPDATE, winter_activity: null })).toBe(true);
expect((await repo.findInTrip(trip.id, place.id))?.winter_activity).toBeNull();
expect(await repo.findInTrip(trip.id, place.id)).toMatchObject({ ski_discipline: null, ski_technique: null });
});
it('TOURREPO-023: a run keeps its profile and pace, an edit replaces them, and another type takes them off (RUN-01)', async () => {
@@ -537,7 +533,6 @@ describe('ToursRepository writes', () => {
revision: 1,
hiking_preferences: null,
bike_profile: null,
winter_activity: null,
run_profile: null,
run_pace_seconds_per_km: null,
ski_discipline: null,
@@ -1,7 +1,8 @@
/**
* SKI-02: `Migration20200101045520_a_tour_can_be_a_ski_track` switches the ski
* type on, without a routing profile, and gives a tour its ski discipline and
* cross-country technique. Both columns hold a CHECK list the entity carries
* type on, without a routing profile, and gives a tour its ski discipline (six
* of them, snowshoeing and winter hiking among them) and cross-country
* technique. Both columns hold a CHECK list the entity carries
* as its enum and the contract holds too: a value one side takes and the
* other refuses would be a ski track the import accepts and the database
* cannot store.
@@ -56,6 +57,9 @@ describe('ski columns match the contract', () => {
orm,
"INSERT INTO tours (place_id, tour_type, ski_discipline, ski_technique) VALUES (101, 'ski', 'cross_country', 'unknown')",
);
// Snowshoeing and winter hiking are disciplines of a ski tour, never labels of a hike.
await rawExec(orm, "UPDATE tours SET ski_discipline = 'snowshoe', ski_technique = NULL WHERE place_id = 101");
await rawExec(orm, "UPDATE tours SET ski_discipline = 'winter_hike' WHERE place_id = 101");
await expect(rawExec(orm, "UPDATE tours SET ski_discipline = 'freeride' WHERE place_id = 101")).rejects.toThrow(
/CHECK/,
);
@@ -1,16 +0,0 @@
import { ToursWinterActivity } from '../../../src/db/entities/Tours.entity';
import { WINTER_ACTIVITIES } from '@trek/shared';
import { describe, expect, it } from 'vitest';
/**
* HIK-01: the winter label column holds a CHECK list (migration 045500) that
* the entity carries as its enum, and the contract holds its own list. A label
* one side knows and the other refuses would be a hike the planner can save
* and the database cannot store.
*/
describe('winter activity column matches the contract', () => {
it('knows the same winter activities as the tours contract', () => {
expect(Object.values(ToursWinterActivity).sort()).toEqual([...WINTER_ACTIVITIES].sort());
});
});
@@ -41,7 +41,6 @@ const row: CollectionTourRow = {
match_confidence: 1,
max_hiking_difficulty: null,
bike_profile: 'gravel',
winter_activity: null,
run_profile: null,
run_pace_seconds_per_km: null,
ski_discipline: null,
@@ -92,8 +91,7 @@ describe('collection tour helpers (LIB-03)', () => {
it('LIB3-HLP-007: a kept tour carries what the trip list shows of its type, and its stages', () => {
expect(keptTourItem(row)).toMatchObject({
bike_profile: 'gravel',
winter_activity: null,
ski_discipline: null,
ski_discipline: null,
run_pace_seconds_per_km: null,
stage_count: null,
});
@@ -107,8 +105,9 @@ describe('collection tour helpers (LIB-03)', () => {
expect(keptTourItem(ski)).toMatchObject({ ski_discipline: 'cross_country', ski_technique: 'skating' });
const run = { ...row, tour_type: 'run', bike_profile: null, run_profile: 'trail', run_pace_seconds_per_km: 360 };
expect(keptTourItem(run)).toMatchObject({ run_profile: 'trail', run_pace_seconds_per_km: 360 });
const snowshoe = { ...row, tour_type: 'hike', bike_profile: null, winter_activity: 'snowshoe' };
expect(keptTourItem(snowshoe).winter_activity).toBe('snowshoe');
const snowshoe = { ...row, tour_type: 'ski', bike_profile: null, ski_discipline: 'snowshoe' };
expect(keptTourItem(snowshoe)).toMatchObject({ tour_type: 'ski', ski_discipline: 'snowshoe', ski_technique: null });
expect(keptTourItem(snowshoe)).not.toHaveProperty('winter_activity');
const hutToHut = {
...row,
waypoints: [
@@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest';
// TOUR-GPX-ACTIVITY-001 to TOUR-GPX-ACTIVITY-007
describe('gpxActivityOf (PR1-06A, BIK-01, HIK-01, RUN-01, SKI-02, HOR-01)', () => {
describe('gpxActivityOf (PR1-06A, BIK-01, RUN-01, SKI-02, HOR-01)', () => {
it('TOUR-GPX-ACTIVITY-001: a bike tour names its profile, and the plainest riding without a known one', () => {
expect(gpxActivityOf({ tour_type: 'bike', bike_profile: 'mtb' })).toBe('mountain_biking');
expect(gpxActivityOf({ tour_type: 'bike', bike_profile: null })).toBe('cycling');
@@ -22,11 +22,11 @@ describe('gpxActivityOf (PR1-06A, BIK-01, HIK-01, RUN-01, SKI-02, HOR-01)', () =
expect(gpxActivityOf({ tour_type: 'walk' })).toBe('walk');
});
it('TOUR-GPX-ACTIVITY-004: a hike on snowshoes is snowshoeing, a winter hike stays hiking (HIK-01)', () => {
expect(gpxActivityOf({ tour_type: 'hike', winter_activity: 'snowshoe' })).toBe('snowshoeing');
expect(gpxActivityOf({ tour_type: 'hike', winter_activity: 'winter_hike' })).toBe('hiking');
expect(gpxActivityOf({ tour_type: 'hike', winter_activity: null })).toBe('hiking');
expect(gpxActivityOf({ tour_type: 'walk', winter_activity: 'snowshoe' })).toBe('walk');
it('TOUR-GPX-ACTIVITY-004: snowshoes make a ski tour snowshoeing, a winter hike stays hiking, a hike is hiking (SKI-02)', () => {
expect(gpxActivityOf({ tour_type: 'ski', ski_discipline: 'snowshoe' })).toBe('snowshoeing');
expect(gpxActivityOf({ tour_type: 'ski', ski_discipline: 'winter_hike' })).toBe('hiking');
expect(gpxActivityOf({ tour_type: 'hike', ski_discipline: null })).toBe('hiking');
expect(gpxActivityOf({ tour_type: 'walk' })).toBe('walk');
});
it('TOUR-GPX-ACTIVITY-005: a run is running, and stays running without a profile this build knows (RUN-01)', () => {
@@ -122,4 +122,24 @@ describe('tour plan read (MCP-T01)', () => {
} as unknown as TourDetailResponse;
expect(derivedFields(staged, none)).toEqual(expect.arrayContaining(['tour.stage_count', 'tour.stage_stops']));
});
it('TOUR-PLAN-READ-008: every leg of a planned ski tour is drawn freely with its straight length, a fixed one kept (SKI-02)', () => {
const free = point(1, {
fixed_leg: {
kind: 'free',
geometry: [
[48.01, 11],
[48.02, 11],
],
},
});
expect(planLegs([point(0), free, point(2)], { recorded: false, drawn: true })).toEqual([
expect.objectContaining({ kind: 'free', name: null, length_km: 1.11 }),
expect.objectContaining({ kind: 'free', name: null, length_km: 1.11 }),
]);
// A ski track kept as recorded stays a track.
expect(planLegs([point(0), point(1)], { recorded: true, drawn: true })).toEqual([
expect.objectContaining({ kind: 'track', length_km: null }),
]);
});
});
+38 -9
View File
@@ -561,19 +561,25 @@ describe('ToursMcp', () => {
}
});
it('TOURS-MCP-020: list_tours names the winter label of a hike, and says it is only a label (HIK-01)', async () => {
it('TOURS-MCP-020: list_tours lists snowshoeing as a ski tour, and a hike with no winter label at all (SKI-02)', async () => {
const { user, trip, ridge, gorge } = fixture();
const orm = await createTestOrm(db);
await updateRows(orm, Tours, { place: ridge.id }, { winter_activity: 'snowshoe' });
await updateRows(
orm,
Tours,
{ place: ridge.id },
{ tourTypeRef: 'ski', max_hiking_difficulty: null, ski_discipline: 'snowshoe' },
);
await orm.close();
const { tours } = read(await mcp.listTours({ tripId: trip.id }, ctx(user.id)));
const byId = new Map(tours.map((tour: { place_id: number }) => [tour.place_id, tour]));
expect(byId.get(ridge.id)).toMatchObject({ tour_type: 'hike', winter_activity: 'snowshoe' });
expect(byId.get(gorge.id)).toMatchObject({ winter_activity: null });
expect(byId.get(ridge.id)).toMatchObject({ tour_type: 'ski', ski_discipline: 'snowshoe', ski_technique: null });
expect(byId.get(gorge.id)).toMatchObject({ tour_type: 'hike' });
expect(byId.get(gorge.id)).not.toHaveProperty('winter_activity');
const entry = getEntry(ToursMcp as unknown as ClassRef, 'listTours');
const description = entry?.kind === 'tool' ? (entry.options.description ?? '') : '';
expect(description).toMatch(/winter_activity: winter_hike or snowshoe/);
expect(description).toContain('never a statement that a way can be walked in winter');
expect(description).not.toContain('winter_activity');
expect(description).toContain('a hike is summer only');
});
it('TOURS-MCP-021: list_tours names a run with its profile and pace, and says its time is a plan at that pace only (RUN-01)', async () => {
@@ -620,7 +626,7 @@ describe('ToursMcp', () => {
expect(description).toContain('never a statement that a way can be run');
});
it('TOURS-MCP-023: list_tours names a ski track with its discipline and technique, and says it is kept as recorded (SKI-02)', async () => {
it('TOURS-MCP-023: list_tours names a ski tour with its discipline and technique, and says how its line came to be (SKI-02)', async () => {
const { user, trip, ridge, gorge } = fixture();
const orm = await createTestOrm(db);
await updateRows(
@@ -640,9 +646,32 @@ describe('ToursMcp', () => {
expect(byId.get(gorge.id)).toMatchObject({ ski_discipline: null, ski_technique: null });
const entry = getEntry(ToursMcp as unknown as ClassRef, 'listTours');
const description = entry?.kind === 'tool' ? (entry.options.description ?? '') : '';
expect(description).toMatch(/ski_discipline: alpine, snowboard, cross_country or touring/);
expect(description).toContain('never routed');
expect(description).toContain('ski_discipline: alpine, snowboard, cross_country, touring for ski touring');
expect(description).toContain('snowshoe or winter_hike');
expect(description).toContain('drawn straight between its points, never routed');
expect(description).toContain('an imported one is kept as recorded');
expect(description).toContain('it has no ski time worked out');
expect(description).toContain('nothing in it says anything about snow, avalanches');
});
it('TOURS-MCP-024: get_tour_plan names every leg of a planned ski tour as drawn freely, with its straight length (SKI-02)', async () => {
const { member, trip } = fixture();
const orm = await createTestOrm(db);
const ski = await makePlace(orm, trip.id, { name: 'Over the pass' });
await makeTour(orm, ski.id, { tourTypeRef: 'ski', max_hiking_difficulty: null, ski_discipline: 'touring' });
await addTourWaypoints(orm, ski.id, [
{ lat: 47, lng: 11, station_key: 'sk-start' },
{ lat: 47.01, lng: 11, station_key: 'sk-col' },
{ lat: 47.01, lng: 11.01, station_key: 'sk-end' },
]);
await orm.close();
const plan = read(await mcp.getTourPlan({ tripId: trip.id, placeId: ski.id }, ctx(member.id)));
expect(plan.tour).toMatchObject({ tour_type: 'ski', ski_discipline: 'touring' });
expect(plan.legs).toEqual([
expect.objectContaining({ from_key: 'sk-start', to_key: 'sk-col', kind: 'free', name: null }),
expect.objectContaining({ from_key: 'sk-col', to_key: 'sk-end', kind: 'free', name: null }),
]);
expect(plan.legs[0].length_km).toBeCloseTo(1.11, 1);
expect(plan.legs[1].length_km).toBeCloseTo(0.76, 1);
});
});