mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +00:00
feat(tours): import a ski track from GPX and keep it as recorded
SKI-02. Ski joins the types a GPX file can be imported as, offered in the import preview's type field only: TREK routes no ski track, so the planner never offers it. The preview asks for the discipline on purpose (alpine, snowboard, cross-country or ski touring, none chosen at first) and, for cross-country, the technique (classic, skating or unknown); Import waits until both are named, and the technique stays in sight but dimmed for the other disciplines. The track is stored exactly as recorded, with its file name as provenance, its distance and heights from the file and no time: no ski time is worked out and no hiking estimate stands in for one. Opened in the planner, a ski track gets a rail card with its discipline on a tinted tile, a cross-country technique as a badge beside it, the ski time said as not worked out, and an info dot on what TREK does not say (snow, avalanches, open runs, and that the 3D gradient colours are no difficulty or avalanche rating). Change opens a dialog to classify the track again; only the classification changes, through the offline core, with the revision it was made at. Planning over the import is not offered for ski. The existing 3D terrain view, the elevation profile and its focus work on the track as for any import, with the standard map, topo and satellite and no via ferratas. Lists, the day plan and the detail show the discipline as a frosted corner badge on the tile and as a pill with the technique inside; day assignment, copies and take-overs work as for any tour. The corner badge is now one component that the winter label shares. The server keeps tours.ski_discipline and tours.ski_technique (CHECK lists, NULL on every other type), switches the ski type on without a routing profile, refuses a ski import without its discipline, a technique outside cross-country and ski fields on any other type, and takes a planned save's classification off. PUT /api/trips/:tripId/tours/:placeId/ski classifies a ski track again (place_edit, 409 on a stale revision, 400 for a tour that is no ski track) and broadcasts tours:changed. GPX export names the discipline (alpine_skiing, snowboarding, cross_country_skiing, backcountry_skiing), and list_tours describes both fields with what a ski track does not say. Ski is labelled plainly as Ski in every language, so it no longer reads as ski touring.
This commit is contained in:
@@ -68,6 +68,7 @@ const UNNUMBERED = [
|
||||
'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',
|
||||
];
|
||||
// The last step the positional runner ever had; planLegacyBaseline refuses a
|
||||
// schema_version past it.
|
||||
|
||||
@@ -106,10 +106,10 @@ describe('ToursRepository reads', () => {
|
||||
hiking_preferences: null,
|
||||
bike_profile: null,
|
||||
winter_activity: null,
|
||||
run_profile: null,
|
||||
run_pace_seconds_per_km: null,
|
||||
run_profile: null,
|
||||
run_pace_seconds_per_km: null,
|
||||
ski_discipline: null,
|
||||
ski_technique: null,
|
||||
},
|
||||
{
|
||||
place_id: older.id,
|
||||
@@ -143,10 +143,10 @@ describe('ToursRepository reads', () => {
|
||||
hiking_preferences: null,
|
||||
bike_profile: null,
|
||||
winter_activity: null,
|
||||
run_profile: null,
|
||||
run_pace_seconds_per_km: null,
|
||||
run_profile: null,
|
||||
run_pace_seconds_per_km: null,
|
||||
ski_discipline: null,
|
||||
ski_technique: null,
|
||||
},
|
||||
]);
|
||||
expect((await repo.listForTrip(trip.id)).map((r) => r.place_id)).not.toContain(plain.id);
|
||||
@@ -263,6 +263,8 @@ describe('ToursRepository writes', () => {
|
||||
winter_activity: null,
|
||||
run_profile: null,
|
||||
run_pace_seconds_per_km: null,
|
||||
ski_discipline: null,
|
||||
ski_technique: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -272,6 +274,8 @@ describe('ToursRepository writes', () => {
|
||||
bike_profile: null,
|
||||
run_profile: null,
|
||||
run_pace_seconds_per_km: null,
|
||||
ski_discipline: null,
|
||||
ski_technique: null,
|
||||
boat_type: null,
|
||||
water_context: null,
|
||||
paddle_speed_kmh: null,
|
||||
@@ -333,6 +337,33 @@ describe('ToursRepository writes', () => {
|
||||
expect(await repo.findInTrip(trip.id, place.id)).toMatchObject({ run_profile: null, run_pace_seconds_per_km: null });
|
||||
});
|
||||
|
||||
it('TOURREPO-018: a ski track keeps its classification, is classified again only in its own trip, and a route edit takes it off (SKI-02)', async () => {
|
||||
const { trip, other } = fixture();
|
||||
const place = createPlace(testDb, trip.id, { name: 'Valley loop' });
|
||||
const ski = { ...METRICS, tour_type: 'ski', max_hiking_difficulty: null, duration: null };
|
||||
await repo.insertTour({ place_id: place.id, ...ski, ski_discipline: 'cross_country', ski_technique: 'classic' });
|
||||
expect(await repo.findInTrip(trip.id, place.id)).toMatchObject({
|
||||
tour_type: 'ski',
|
||||
ski_discipline: 'cross_country',
|
||||
ski_technique: 'classic',
|
||||
duration: null,
|
||||
});
|
||||
|
||||
const touring = { ski_discipline: 'touring' as const, ski_technique: null };
|
||||
expect(await repo.updateSkiInTrip(other.id, place.id, touring)).toBe(false);
|
||||
expect((await repo.findInTrip(trip.id, place.id))?.ski_discipline).toBe('cross_country');
|
||||
expect(await repo.updateSkiInTrip(trip.id, place.id, touring)).toBe(true);
|
||||
expect((await repo.listForTrip(trip.id))[0]).toMatchObject({
|
||||
ski_discipline: 'touring',
|
||||
ski_technique: null,
|
||||
distance: METRICS.distance,
|
||||
elevation_gain: METRICS.elevation_gain,
|
||||
});
|
||||
|
||||
expect(await repo.updateInTrip(trip.id, place.id, HIKE_UPDATE)).toBe(true);
|
||||
expect(await repo.findInTrip(trip.id, place.id)).toMatchObject({ ski_discipline: null, ski_technique: null });
|
||||
});
|
||||
|
||||
it('TOURREPO-008: listRowsForTrip and insertCopy carry every column, created_at included, onto the copied place', async () => {
|
||||
const { trip, other } = fixture();
|
||||
const source = createPlace(testDb, trip.id);
|
||||
@@ -379,10 +410,10 @@ describe('ToursRepository writes', () => {
|
||||
hiking_preferences: null,
|
||||
bike_profile: null,
|
||||
winter_activity: null,
|
||||
run_profile: null,
|
||||
run_pace_seconds_per_km: null,
|
||||
run_profile: null,
|
||||
run_pace_seconds_per_km: null,
|
||||
ski_discipline: null,
|
||||
ski_technique: null,
|
||||
taken_over_from_trip_title: null,
|
||||
taken_over_from_tour_name: null,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
import { ToursSkiDiscipline, ToursSkiTechnique } from '../../../src/db/entities/Tours.entity';
|
||||
import { createMigrationOrm, migrateTo, pendingNames, rawExec, rawQuery } from '../../helpers/migration-step';
|
||||
import type { MikroORM } from '@mikro-orm/sqlite';
|
||||
import { SKI_DISCIPLINES, SKI_TECHNIQUES } from '@trek/shared';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const TARGET = 'Migration20200101045520_a_tour_can_be_a_ski_track';
|
||||
|
||||
async function ormBeforeTarget(): Promise<MikroORM> {
|
||||
const orm = await createMigrationOrm();
|
||||
const names = await pendingNames(orm);
|
||||
const idx = names.indexOf(TARGET);
|
||||
expect(idx).toBeGreaterThan(0);
|
||||
await migrateTo(orm, names[idx - 1]);
|
||||
return orm;
|
||||
}
|
||||
|
||||
describe('ski columns match the contract', () => {
|
||||
it('TOURSKIMIG-001: stores exactly the disciplines and techniques the contract knows', () => {
|
||||
expect([...Object.values(ToursSkiDiscipline)].sort()).toEqual([...SKI_DISCIPLINES].sort());
|
||||
expect([...Object.values(ToursSkiTechnique)].sort()).toEqual([...SKI_TECHNIQUES].sort());
|
||||
});
|
||||
|
||||
it('TOURSKIMIG-002: switches the ski type on without a router, leaves every tour as it was and refuses an unknown value', async () => {
|
||||
const orm = await ormBeforeTarget();
|
||||
try {
|
||||
await rawExec(
|
||||
orm,
|
||||
"INSERT INTO users (id, username, email, password_hash) VALUES (1, 'owner', 'owner@test', 'x')",
|
||||
);
|
||||
await rawExec(orm, "INSERT INTO trips (id, user_id, title) VALUES (10, 1, 'Alps')");
|
||||
await rawExec(
|
||||
orm,
|
||||
"INSERT INTO places (id, trip_id, name) VALUES (100, 10, 'Ridge walk'), (101, 10, 'Up to the hut')",
|
||||
);
|
||||
await rawExec(orm, "INSERT INTO tours (place_id, tour_type, distance) VALUES (100, 'hike', 12.5)");
|
||||
|
||||
await migrateTo(orm, TARGET);
|
||||
|
||||
expect(await rawQuery(orm, "SELECT enabled, routing_profile FROM tour_types WHERE key = 'ski'")).toEqual([
|
||||
{ enabled: 1, routing_profile: null },
|
||||
]);
|
||||
expect(await rawQuery(orm, 'SELECT ski_discipline, ski_technique FROM tours')).toEqual([
|
||||
{ ski_discipline: null, ski_technique: null },
|
||||
]);
|
||||
await rawExec(
|
||||
orm,
|
||||
"INSERT INTO tours (place_id, tour_type, ski_discipline, ski_technique) VALUES (101, 'ski', 'cross_country', 'unknown')",
|
||||
);
|
||||
await expect(rawExec(orm, "UPDATE tours SET ski_discipline = 'freeride' WHERE place_id = 101")).rejects.toThrow(
|
||||
/CHECK/,
|
||||
);
|
||||
await expect(rawExec(orm, "UPDATE tours SET ski_technique = 'diagonal' WHERE place_id = 101")).rejects.toThrow(
|
||||
/CHECK/,
|
||||
);
|
||||
} finally {
|
||||
await orm.close(true);
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
// TOUR-GPX-ACTIVITY-001 to TOUR-GPX-ACTIVITY-005
|
||||
|
||||
describe('gpxActivityOf (PR1-06A, BIK-01, HIK-01, RUN-01)', () => {
|
||||
describe('gpxActivityOf (PR1-06A, BIK-01, HIK-01, RUN-01, SKI-02)', () => {
|
||||
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');
|
||||
@@ -35,4 +35,13 @@ describe('gpxActivityOf (PR1-06A, BIK-01, HIK-01, RUN-01)', () => {
|
||||
expect(gpxActivityOf({ tour_type: 'run', run_profile: 'sprint' })).toBe('running');
|
||||
expect(gpxActivityOf({ tour_type: 'run', run_profile: 'trail' })).toBe('trail_running');
|
||||
});
|
||||
|
||||
it('TOUR-GPX-ACTIVITY-006: a ski track names its discipline, and skiing without a known one (SKI-02)', () => {
|
||||
expect(gpxActivityOf({ tour_type: 'ski', ski_discipline: 'alpine' })).toBe('alpine_skiing');
|
||||
expect(gpxActivityOf({ tour_type: 'ski', ski_discipline: 'snowboard' })).toBe('snowboarding');
|
||||
expect(gpxActivityOf({ tour_type: 'ski', ski_discipline: 'cross_country' })).toBe('cross_country_skiing');
|
||||
expect(gpxActivityOf({ tour_type: 'ski', ski_discipline: 'touring' })).toBe('backcountry_skiing');
|
||||
expect(gpxActivityOf({ tour_type: 'ski', ski_discipline: null })).toBe('skiing');
|
||||
expect(gpxActivityOf({ tour_type: 'ski', ski_discipline: 'freeride' })).toBe('skiing');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* TourSkiController (SKI-02): the route metadata of classifying a ski track
|
||||
* again, and its delegation to TourSkiService with the validated body and the
|
||||
* socket id.
|
||||
*/
|
||||
import 'reflect-metadata';
|
||||
|
||||
import { AddonGuard } from '../../../src/nest/addons/addon.guard';
|
||||
import { REQUIRE_ADDON } from '../../../src/nest/addons/require-addon.decorator';
|
||||
import { JwtAuthGuard } from '../../../src/nest/auth-core/jwt-auth.guard';
|
||||
import { RESPONSE_CONTRACT_KEY } from '../../../src/nest/common/response-contract';
|
||||
import { TRIP_PERMISSION_KEY, TripAccessGuard } from '../../../src/nest/permissions/trip-access.guard';
|
||||
import { TourSkiController } from '../../../src/nest/tours/tour-ski.controller';
|
||||
import type { TourSkiService } from '../../../src/nest/tours/tour-ski.service';
|
||||
import { RequestMethod } from '@nestjs/common';
|
||||
import { GUARDS_METADATA, METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants';
|
||||
import { tourDetailResponseSchema, type TourDetailResponse } from '@trek/shared';
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
describe('TourSkiController', () => {
|
||||
it('TOUR-SKI-CTL-001: sits behind the addon guard before auth and trip access, and needs place_edit', () => {
|
||||
expect(Reflect.getMetadata(PATH_METADATA, TourSkiController)).toBe('api/trips/:tripId/tours/:placeId/ski');
|
||||
expect(Reflect.getMetadata(GUARDS_METADATA, TourSkiController)).toEqual([
|
||||
AddonGuard,
|
||||
JwtAuthGuard,
|
||||
TripAccessGuard,
|
||||
]);
|
||||
expect(Reflect.getMetadata(REQUIRE_ADDON, TourSkiController)).toEqual({ addonId: 'tours', label: 'Tours' });
|
||||
const handler = TourSkiController.prototype.classify;
|
||||
expect(Reflect.getMetadata(METHOD_METADATA, handler)).toBe(RequestMethod.PUT);
|
||||
expect(Reflect.getMetadata(PATH_METADATA, handler)).toBe('/');
|
||||
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, handler)).toBe('place_edit');
|
||||
expect(Reflect.getMetadata(RESPONSE_CONTRACT_KEY, handler)).toBe(tourDetailResponseSchema);
|
||||
});
|
||||
|
||||
it('TOUR-SKI-CTL-002: hands the trip, tour, classification and socket id to the service and answers what it answers', async () => {
|
||||
const answer = { tour: { place_id: 9 }, waypoints: [] } as unknown as TourDetailResponse;
|
||||
const service = { classify: vi.fn(async () => answer) };
|
||||
const controller = new TourSkiController(service as unknown as TourSkiService);
|
||||
const body = { ski_discipline: 'cross_country' as const, ski_technique: 'skating' as const, expected_revision: 2 };
|
||||
|
||||
expect(await controller.classify('7', '9', body, 'sock-1')).toBe(answer);
|
||||
expect(service.classify).toHaveBeenCalledExactlyOnceWith('7', '9', body, 'sock-1');
|
||||
});
|
||||
});
|
||||
@@ -411,4 +411,30 @@ describe('ToursMcp', () => {
|
||||
expect(description).toContain('trail for forest tracks and natural paths up to mountain paths (T2)');
|
||||
expect(description).toContain('never a statement that a way can be run');
|
||||
});
|
||||
|
||||
it('TOURS-MCP-018: list_tours names a ski track with its discipline and technique, and says it is kept as recorded (SKI-02)', async () => {
|
||||
const { user, trip, ridge, gorge } = fixture();
|
||||
const orm = await createTestOrm(db);
|
||||
await updateRows(
|
||||
orm,
|
||||
Tours,
|
||||
{ place: ridge.id },
|
||||
{ tourTypeRef: 'ski', max_hiking_difficulty: null, ski_discipline: 'cross_country', ski_technique: 'skating' },
|
||||
);
|
||||
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: 'ski',
|
||||
ski_discipline: 'cross_country',
|
||||
ski_technique: 'skating',
|
||||
});
|
||||
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('it has no ski time worked out');
|
||||
expect(description).toContain('nothing in it says anything about snow, avalanches');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -195,6 +195,8 @@ describe('ToursService planner contracts (mock-only; SQLite rollback semantics a
|
||||
bike_profile: null,
|
||||
run_profile: null,
|
||||
run_pace_seconds_per_km: null,
|
||||
ski_discipline: null,
|
||||
ski_technique: null,
|
||||
});
|
||||
expect((setup.toursRepo.insertTour.mock.calls[0][0] as { distance: number }).distance).toBeGreaterThan(0);
|
||||
expect(setup.waypointsRepo.insertForPlace).toHaveBeenCalledExactlyOnceWith(42, keyed(request.waypoints));
|
||||
@@ -315,6 +317,8 @@ describe('ToursService planner contracts (mock-only; SQLite rollback semantics a
|
||||
bike_profile: null,
|
||||
run_profile: null,
|
||||
run_pace_seconds_per_km: null,
|
||||
ski_discipline: null,
|
||||
ski_technique: null,
|
||||
});
|
||||
expect(setup.waypointsRepo.deleteForPlace).toHaveBeenCalledExactlyOnceWith(42);
|
||||
expect(setup.waypointsRepo.insertForPlace).toHaveBeenCalledExactlyOnceWith(42, keyed(update.waypoints));
|
||||
|
||||
Reference in New Issue
Block a user