mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
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:
@@ -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'.
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user