diff --git a/server/src/db/entities/Tours.entity.ts b/server/src/db/entities/Tours.entity.ts index dd4ee4041..7f42572a5 100644 --- a/server/src/db/entities/Tours.entity.ts +++ b/server/src/db/entities/Tours.entity.ts @@ -50,6 +50,7 @@ export class Tours { track_started_at?: string | null; track_ended_at?: string | null; ski_discipline?: TToursSkiDiscipline | null; + ski_options?: string | null; tourTypeRef!: Ref; tour_activities_collection = new Collection(this); tour_activities_collection1 = new Collection(this); @@ -181,6 +182,7 @@ export const ToursSchema = defineEntity({ track_started_at: p.type(DbTimestampType).nullable(), track_ended_at: p.type(DbTimestampType).nullable(), ski_discipline: p.enum(() => ToursSkiDiscipline).nullable(), + ski_options: p.text().nullable(), tourTypeRef: () => p.manyToOne(TourTypes).ref().joinColumn('tour_type').hidden().index('idx_tours_tour_type'), tour_activities_collection: () => p.oneToMany(TourActivities).mappedBy('place').hidden(), tour_activities_collection1: () => p.oneToMany(TourActivities).mappedBy('trackPlace').hidden(), diff --git a/server/src/db/json-columns.ts b/server/src/db/json-columns.ts index 7c00d3df5..f5a5e9fdc 100644 --- a/server/src/db/json-columns.ts +++ b/server/src/db/json-columns.ts @@ -8,6 +8,7 @@ import { paddleLockSchema, paddleRegionBoundarySchema, paddleRegionGraphStatsSchema, + skiRouteOptionsSchema, tourChangeSchema, tourFixedLegSchema, tourSourceHintChangeSchema, @@ -21,6 +22,7 @@ import { type PaddleLock, type PaddleRegionBoundary, type PaddleRegionGraphStats, + type SkiRouteOptions, type TourChange, type TourFixedLeg, type TourSourceHintChange, @@ -191,6 +193,19 @@ export const TOUR_HIKING_PREFERENCES: JsonColumn = { fallback: () => null, }; +/** What a ski tour's legs were planned with: the piste router's options and the piste data build they ran on. */ +export interface TourSkiRoute { + options: SkiRouteOptions; + build: string | null; +} + +/** A ski tour's piste router options. A broken row reads as none, and the planner offers the defaults. */ +export const TOUR_SKI_ROUTE: JsonColumn = { + column: 'tours.ski_options', + schema: z.object({ options: skiRouteOptionsSchema, build: z.string().nullable() }).nullable(), + fallback: () => null, +}; + /** A paddle object's own geometry (PAD-05A). A broken row has none, and the object is left out rather than drawn anywhere. */ export const PADDLE_FEATURE_GEOMETRY: JsonColumn = { column: 'paddle_features.geometry', diff --git a/server/src/db/kysely/Tours.table.ts b/server/src/db/kysely/Tours.table.ts index b3cb34b77..5f46c12fe 100644 --- a/server/src/db/kysely/Tours.table.ts +++ b/server/src/db/kysely/Tours.table.ts @@ -37,4 +37,5 @@ export interface ToursTable { track_started_at: InsertOptional; track_ended_at: InsertOptional; ski_discipline: InsertOptional; + ski_options: InsertOptional; } diff --git a/server/src/db/migrations/Migration20200101046140_a_ski_tour_keeps_its_piste_router_options.ts b/server/src/db/migrations/Migration20200101046140_a_ski_tour_keeps_its_piste_router_options.ts new file mode 100644 index 000000000..b9ade4710 --- /dev/null +++ b/server/src/db/migrations/Migration20200101046140_a_ski_tour_keeps_its_piste_router_options.ts @@ -0,0 +1,18 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * A ski tour keeps what its legs were planned for: the piste router's options + * (difficulty limit, lifts to avoid, pace and the rest) and the build of the + * piste data they were planned on, as one JSON text written by the server + * (`TOUR_SKI_ROUTE` in json-columns.ts). NULL for every other type, for an + * imported track and for a ski tour saved before the router. Reopened, the + * tour is planned with the same options, and a newer build of the piste data + * can be offered instead of applied. + */ +export class Migration20200101046140_a_ski_tour_keeps_its_piste_router_options extends Migration { + override name = 'Migration20200101046140_a_ski_tour_keeps_its_piste_router_options'; + + override up(): void { + this.addSql('ALTER TABLE tours ADD COLUMN ski_options TEXT'); + } +} diff --git a/server/src/db/repositories/Tours.repository.ts b/server/src/db/repositories/Tours.repository.ts index 442c7ac95..a0fcd8e12 100644 --- a/server/src/db/repositories/Tours.repository.ts +++ b/server/src/db/repositories/Tours.repository.ts @@ -45,6 +45,8 @@ export interface TourListRow { /** SKI-02: a ski tour's discipline and, for cross-country, its technique; NULL on every other type. */ ski_discipline: string | null; ski_technique: string | null; + /** A ski tour's piste router options and build, still JSON text; absent where a read does not take it. */ + ski_options?: string | null; boat_type: string | null; water_context: string | null; paddle_speed_kmh: number | null; @@ -123,6 +125,7 @@ export interface TourRow { ski_technique: string | null; track_started_at: string | null; track_ended_at: string | null; + ski_options: string | null; } const _tourRowKeys: AssertRowKeys = true; @@ -146,6 +149,8 @@ export interface TourInsert { /** SKI-02: a ski tour's discipline and technique; left out, NULL. */ ski_discipline?: SkiDiscipline | null; ski_technique?: SkiTechnique | null; + /** A ski tour's piste router options and build as JSON text; left out, NULL. */ + ski_options?: string | null; /** A paddle tour's boat, water and chosen speed; left out, a hike's NULLs. */ boat_type?: PaddleBoatType | null; water_context?: PaddleWaterContext | null; @@ -305,6 +310,7 @@ export class ToursRepository extends TrekRepository { 't.run_pace_seconds_per_km as run_pace_seconds_per_km', 't.ski_discipline as ski_discipline', 't.ski_technique as ski_technique', + 't.ski_options as ski_options', 't.boat_type as boat_type', 't.water_context as water_context', 't.paddle_speed_kmh as paddle_speed_kmh', @@ -771,6 +777,7 @@ export class ToursRepository extends TrekRepository { run_pace_seconds_per_km: input.run_pace_seconds_per_km ?? null, ski_discipline: input.ski_discipline ?? null, ski_technique: input.ski_technique ?? null, + ski_options: input.ski_options ?? null, boat_type: input.boat_type ?? null, water_context: input.water_context ?? null, paddle_speed_kmh: input.paddle_speed_kmh ?? null, @@ -862,6 +869,7 @@ export class ToursRepository extends TrekRepository { run_pace_seconds_per_km: input.run_pace_seconds_per_km, ski_discipline: input.ski_discipline, ski_technique: input.ski_technique, + ...(input.ski_options !== undefined ? { ski_options: input.ski_options } : {}), boat_type: input.boat_type, water_context: input.water_context, paddle_speed_kmh: input.paddle_speed_kmh, diff --git a/server/src/nest/tours/tour-item.helpers.ts b/server/src/nest/tours/tour-item.helpers.ts index 52d214bd4..7f5d5e392 100644 --- a/server/src/nest/tours/tour-item.helpers.ts +++ b/server/src/nest/tours/tour-item.helpers.ts @@ -2,7 +2,7 @@ * The read half of ToursService that needs no database: a tour as it is * listed, and where it came from as its provenance names it. */ -import { TOUR_HIKING_PREFERENCES, TOUR_TRACK_MARKS } from '../../db/json-columns'; +import { TOUR_HIKING_PREFERENCES, TOUR_SKI_ROUTE, TOUR_TRACK_MARKS } from '../../db/json-columns'; import type { TourActivityRefRow } from '../../db/repositories/TourActivities.repository'; import type { TourWaypointRow } from '../../db/repositories/TourWaypoints.repository'; import type { TourListRow, TourProvenanceRow } from '../../db/repositories/Tours.repository'; @@ -32,6 +32,13 @@ function stageStopsOrNull(points: Parameters[0]): TourL return stops.length > 0 ? stops : null; } +/** A ski tour's piste router options and the build its legs ran on, where it keeps them; nothing for any other tour. */ +function skiRouteFacts(r: TourListRow): Pick { + if (r.tour_type !== 'ski' || !r.ski_options) return {}; + const route = decodeJson(TOUR_SKI_ROUTE, r.ski_options, 'tour'); + return route ? { ski_options: route.options, ski_route_build: route.build } : {}; +} + /** * A tour as it is listed; a city walk with the stations its points make and their stays (CW-02, CW-03), * every tour with the recordings assigned to it and the tour it documents itself (ACT-01). @@ -64,6 +71,7 @@ export function tourItemOf( run_pace_seconds_per_km: r.run_pace_seconds_per_km, ski_discipline: r.ski_discipline as SkiDiscipline | null, ski_technique: r.ski_technique as SkiTechnique | null, + ...skiRouteFacts(r), boat_type: r.boat_type as PaddleBoatType | null, water_context: r.water_context as PaddleWaterContext | null, paddle_speed_kmh: r.paddle_speed_kmh, diff --git a/server/src/nest/tours/tour-write.helpers.ts b/server/src/nest/tours/tour-write.helpers.ts index 007aa5ab9..1182d3704 100644 --- a/server/src/nest/tours/tour-write.helpers.ts +++ b/server/src/nest/tours/tour-write.helpers.ts @@ -3,7 +3,7 @@ * or an update stores, the checks a write passes before its transaction, and * the keys its points keep. */ -import { TOUR_HIKING_PREFERENCES } from '../../db/json-columns'; +import { TOUR_HIKING_PREFERENCES, TOUR_SKI_ROUTE } from '../../db/json-columns'; import type { TourUpdate } from '../../db/repositories/Tours.repository'; import { encodeJson } from '../../utils/json-column'; import { badRequest } from '../common/domain-error'; @@ -14,6 +14,7 @@ import { maxTourMinutesFor, plannedTimeLimitMessage, plannedTourDurationMinutesSchema, + skiOptionsForDiscipline, tourStageCountOf, tourStageIssues, tourWebsiteSchema, @@ -41,6 +42,17 @@ function skiFields(input: TourCreateCommand): Pick { { ...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(refused.map((res) => res.status)).toEqual([400, 400, 400, 400]); expect(await countRows(orm, Tours)).toBe(0); const res = await request(server) @@ -286,6 +285,42 @@ describe('Ski tracks e2e (real guards + temp SQLite)', () => { expect(await findRow(orm, Tours, { place: placeId })).toMatchObject({ ski_discipline: null, ski_technique: null }); }); + it('TOURS-SKI-E2E-011: a ski tour the piste router planned keeps its time, its options and the build', async () => { + const planned = { ...hikeBody, tour_type: 'ski', max_hiking_difficulty: null, ski_discipline: 'alpine' }; + const created = await request(server) + .post('/api/trips/5/tours') + .set('Cookie', sessionCookie(OWNER)) + .send({ + ...planned, + duration_seconds: 3600, + ski_options: { max_difficulty: 'advanced', lit_only: true }, + ski_route_build: '2026-11-02', + }); + expect(created.status).toBe(201); + expect(created.body.tour).toMatchObject({ + duration: 60, + ski_route_build: '2026-11-02', + ski_options: { max_difficulty: 'advanced', lit_only: false, priority: 'fastest' }, + }); + const placeId = created.body.tour.place_id as number; + const stored = await findRow(orm, Tours, { place: placeId }); + expect(JSON.parse(String(stored?.ski_options))).toMatchObject({ build: '2026-11-02' }); + + const refused = await request(server) + .post('/api/trips/5/tours') + .set('Cookie', sessionCookie(OWNER)) + .send({ ...hikeBody, ski_options: {} }); + expect(refused.status).toBe(400); + + 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).not.toHaveProperty('ski_options'); + expect(await findRow(orm, Tours, { place: placeId })).toMatchObject({ ski_options: null }); + }); + it('TOURS-SKI-E2E-005: classified again, only the classification changes: track, measures, days and provenance stay', async () => { const placeId = await importSki({ ski_discipline: 'cross_country', ski_technique: 'unknown' }); await insertRows(orm, DayAssignments, [{ day: 3, place: placeId, order_index: 0 }]); diff --git a/server/tests/unit/db/legacy-baseline.test.ts b/server/tests/unit/db/legacy-baseline.test.ts index b6fec4c75..97c6b4046 100644 --- a/server/tests/unit/db/legacy-baseline.test.ts +++ b/server/tests/unit/db/legacy-baseline.test.ts @@ -81,6 +81,7 @@ const UNNUMBERED = [ 'Migration20200101046120_a_wanderer_connection_can_watch_its_sources', 'Migration20200101046130_snowshoeing_and_winter_hiking_are_ski_disciplines', 'Migration20200101046140_tours_are_on_for_every_install', + 'Migration20200101046140_a_ski_tour_keeps_its_piste_router_options', ]; // The last step the positional runner ever had; planLegacyBaseline refuses a // schema_version past it. diff --git a/server/tests/unit/db/tour-ski-options-column.test.ts b/server/tests/unit/db/tour-ski-options-column.test.ts new file mode 100644 index 000000000..9024c68a5 --- /dev/null +++ b/server/tests/unit/db/tour-ski-options-column.test.ts @@ -0,0 +1,38 @@ +/** + * `Migration20200101046140_a_ski_tour_keeps_its_piste_router_options` gives a + * tour one more column, `ski_options`: the JSON text of the piste router's + * options and the build its legs ran on. Every tour that was there keeps its + * columns and reads NULL in the new one; the column takes the server's JSON. + */ +import { createMigrationOrm, migrateTo, pendingNames, rawExec, rawQuery } from '../../helpers/migration-step'; + +import { describe, expect, it } from 'vitest'; + +const TARGET = 'Migration20200101046140_a_ski_tour_keeps_its_piste_router_options'; + +describe('the ski options column', () => { + it('TOURSKIOPT-001: adds a nullable column, leaves every tour as it was and takes the JSON the server writes', async () => { + const orm = await createMigrationOrm(); + try { + const names = await pendingNames(orm); + const idx = names.indexOf(TARGET); + expect(idx).toBeGreaterThan(0); + await migrateTo(orm, names[idx - 1] as string); + 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, 'Valley run')"); + await rawExec(orm, "INSERT INTO tours (place_id, tour_type, ski_discipline) VALUES (100, 'ski', 'alpine')"); + + await migrateTo(orm, TARGET); + + expect(await rawQuery(orm, 'SELECT tour_type, ski_discipline, ski_options FROM tours')).toEqual([ + { tour_type: 'ski', ski_discipline: 'alpine', ski_options: null }, + ]); + const stored = JSON.stringify({ options: { max_difficulty: 'advanced' }, build: '2026-11-02' }); + await rawExec(orm, 'UPDATE tours SET ski_options = ? WHERE place_id = 100', [stored]); + expect(await rawQuery(orm, 'SELECT ski_options FROM tours')).toEqual([{ ski_options: stored }]); + } finally { + await orm.close(true); + } + }, 30000); +}); diff --git a/server/tests/unit/nest/tours.mcp.test.ts b/server/tests/unit/nest/tours.mcp.test.ts index de0a900bc..4a40734da 100644 --- a/server/tests/unit/nest/tours.mcp.test.ts +++ b/server/tests/unit/nest/tours.mcp.test.ts @@ -34,6 +34,7 @@ import { resetTestDb } from '../../helpers/test-db'; import { createTestModule, type TestModule } from '../../helpers/test-module'; import { createTestOrm } from '../../helpers/test-orm'; import { createTour } from '../../helpers/tours-repos'; +import { DEFAULT_SKI_ROUTE_OPTIONS } from '@trek/shared'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -650,9 +651,10 @@ describe('ToursMcp', () => { const description = entry?.kind === 'tool' ? (entry.options.description ?? '') : ''; 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('planned by the piste router along the runs, lifts and trails'); + expect(description).toContain('and drawn straight elsewhere'); expect(description).toContain('an imported one is kept as recorded'); - expect(description).toContain('it has no ski time worked out'); + expect(description).toContain("the router's estimate only when every leg was planned by it"); expect(description).toContain('nothing in it says anything about snow, avalanches'); }); @@ -702,4 +704,25 @@ describe('ToursMcp', () => { 'never present them as saved facts. tour.time_estimate is one of them where present', ); }); + + it('TOURS-MCP-027: a ski tour the piste router planned names its options and build, and its legs as routed', async () => { + const { member, trip } = fixture(); + const orm = await createTestOrm(db); + const ski = await makePlace(orm, trip.id, { name: 'Down to the valley' }); + const stored = { options: { ...DEFAULT_SKI_ROUTE_OPTIONS, max_difficulty: 'advanced' }, build: '2026-11-02' }; + await makeTour(orm, ski.id, { + tourTypeRef: 'ski', + max_hiking_difficulty: null, + ski_discipline: 'alpine', + ski_options: JSON.stringify(stored), + }); + await addTourWaypoints(orm, ski.id, [ + { lat: 46, lng: 7, station_key: 'pk-top' }, + { lat: 46.01, lng: 7, station_key: 'pk-valley' }, + ]); + await orm.close(); + const plan = read(await mcp.getTourPlan({ tripId: trip.id, placeId: ski.id }, ctx(member.id))); + expect(plan.tour).toMatchObject({ ski_options: stored.options, ski_route_build: '2026-11-02' }); + expect(plan.legs).toEqual([expect.objectContaining({ from_key: 'pk-top', to_key: 'pk-valley', kind: 'routed' })]); + }); }); diff --git a/shared/src/ski/ski-route.schema.spec.ts b/shared/src/ski/ski-route.schema.spec.ts new file mode 100644 index 000000000..6fdfffabd --- /dev/null +++ b/shared/src/ski/ski-route.schema.spec.ts @@ -0,0 +1,269 @@ +import { + DEFAULT_SKI_ROUTE_OPTIONS, + SKI_LIFT_CLASSES, + skiOptionsForDiscipline, + skiRouteOptionsSchema, + skiUsesLifts, +} from '../tours/ski.schema'; +import { tourCreateRequestSchema, tourListItemSchema } from '../tours/tours.schema'; +import { + MAX_SKI_ROUTE_LEGS, + skiAreaIdSchema, + skiAreaNearQuerySchema, + skiNetworkResponseSchema, + skiRouteRequestSchema, + skiRouteResponseSchema, +} from './ski-route.schema'; +import { winterIndexSchema, winterMetaSchema } from './winter-index.schema'; + +import { describe, expect, it } from 'vitest'; + +const A = { lat: 45.98, lng: 7.75 }; +const B = { lat: 45.93, lng: 7.71 }; +const SHA = 'a'.repeat(64); + +describe('piste router options (SKI-ROUTE)', () => { + it('SKI-ROUTE-SCHEMA-001: an empty object is a complete answer, use_lifts left to the discipline', () => { + expect(DEFAULT_SKI_ROUTE_OPTIONS).toEqual({ + max_difficulty: 'intermediate', + allow_freeride: false, + avoid_lifts: [], + allow_lift_down: true, + priority: 'fastest', + pace: 'normal', + groomed_only: false, + lit_only: false, + touring_uphill_on_pistes: false, + avoid_inferred_links: false, + }); + expect(skiUsesLifts('alpine', {})).toBe(true); + expect(skiUsesLifts('snowboard', {})).toBe(true); + expect(skiUsesLifts('touring', {})).toBe(false); + expect(skiUsesLifts('cross_country', { use_lifts: true })).toBe(true); + expect(skiUsesLifts('alpine', { use_lifts: false })).toBe(false); + }); + + it('SKI-ROUTE-SCHEMA-002: refuses unknown keys, unknown lift classes and difficulties off the scale', () => { + expect(skiRouteOptionsSchema.safeParse({ colour: 'red' }).success).toBe(false); + expect(skiRouteOptionsSchema.safeParse({ avoid_lifts: ['zipline'] }).success).toBe(false); + expect(skiRouteOptionsSchema.safeParse({ max_difficulty: 'freeride' }).success).toBe(false); + expect(skiRouteOptionsSchema.safeParse({ avoid_lifts: [...SKI_LIFT_CLASSES, 'drag'] }).success).toBe(false); + }); + + it('SKI-ROUTE-SCHEMA-003: keeps the options a discipline reads and puts the others back to their defaults', () => { + const asked = { + max_difficulty: 'expert' as const, + avoid_lifts: ['drag' as const], + groomed_only: true, + lit_only: true, + touring_uphill_on_pistes: true, + use_lifts: true, + }; + expect(skiOptionsForDiscipline('alpine', asked)).toEqual({ + ...DEFAULT_SKI_ROUTE_OPTIONS, + max_difficulty: 'expert', + avoid_lifts: ['drag'], + use_lifts: true, + }); + expect(skiOptionsForDiscipline('cross_country', asked)).toEqual({ + ...DEFAULT_SKI_ROUTE_OPTIONS, + groomed_only: true, + lit_only: true, + use_lifts: true, + }); + expect(skiOptionsForDiscipline('touring', asked)).toEqual({ + ...DEFAULT_SKI_ROUTE_OPTIONS, + max_difficulty: 'expert', + avoid_lifts: ['drag'], + touring_uphill_on_pistes: true, + use_lifts: true, + }); + expect(skiOptionsForDiscipline('snowshoe', { priority: 'easiest' })).toEqual(DEFAULT_SKI_ROUTE_OPTIONS); + }); + + it('SKI-ROUTE-SCHEMA-004: a saved ski tour keeps its options, another type may not carry them', () => { + const planned = { + name: 'Valley run', + tour_type: 'ski' as const, + ski_discipline: 'alpine' as const, + route_geometry: [ + [A.lat, A.lng, 2500], + [B.lat, B.lng, 1600], + ], + waypoints: [ + { lat: A.lat, lng: A.lng, role: 'start', sequence: 0 }, + { lat: B.lat, lng: B.lng, role: 'end', sequence: 1 }, + ], + }; + const saved = tourCreateRequestSchema.parse({ + ...planned, + ski_options: { max_difficulty: 'advanced', lit_only: true }, + ski_route_build: '2026-11-02', + }); + expect(saved.ski_options).toEqual({ ...DEFAULT_SKI_ROUTE_OPTIONS, max_difficulty: 'advanced', lit_only: true }); + expect(saved.ski_route_build).toBe('2026-11-02'); + expect(tourCreateRequestSchema.parse(planned).ski_options).toBeUndefined(); + expect(tourCreateRequestSchema.safeParse({ ...planned, ski_route_build: 'x y' }).success).toBe(false); + const hike = { ...planned, tour_type: 'hike' as const, ski_discipline: null }; + expect(() => tourCreateRequestSchema.parse({ ...hike, ski_options: {} })).toThrow(/ski_options belongs/); + expect(() => tourCreateRequestSchema.parse({ ...hike, ski_route_build: '2026-11-02' })).toThrow( + /ski_route_build belongs/, + ); + }); + + it('SKI-ROUTE-SCHEMA-005: a listed tour may carry its options and build, or neither', () => { + const listed = { + place_id: 4, + tour_type: 'ski', + distance: 8, + elevation_gain: 100, + elevation_loss: 900, + duration: 3600, + difficulty: null, + wanderer_ref: null, + match_confidence: 1, + max_hiking_difficulty: null, + name: 'Valley run', + planned: true, + caution: false, + }; + expect(tourListItemSchema.parse({ ...listed, ski_options: {}, ski_route_build: '2026-11-02' })).toMatchObject({ + ski_options: DEFAULT_SKI_ROUTE_OPTIONS, + ski_route_build: '2026-11-02', + }); + expect(tourListItemSchema.parse(listed).ski_options).toBeUndefined(); + }); +}); + +describe('piste router request and answer (SKI-ROUTE)', () => { + it('SKI-ROUTE-SCHEMA-006: fills a request with no technique, default options and no alternatives', () => { + expect(skiRouteRequestSchema.parse({ legs: [{ from: A, to: B }], discipline: 'alpine' })).toEqual({ + legs: [{ from: A, to: B, shaping_to: false }], + discipline: 'alpine', + technique: null, + options: DEFAULT_SKI_ROUTE_OPTIONS, + alternatives: 0, + }); + }); + + it('SKI-ROUTE-SCHEMA-007: refuses no legs, too many, a point off the globe, three alternatives', () => { + const many = Array.from({ length: MAX_SKI_ROUTE_LEGS + 1 }, () => ({ from: A, to: B })); + const base = { discipline: 'alpine' }; + expect(skiRouteRequestSchema.safeParse({ ...base, legs: [] }).success).toBe(false); + expect(skiRouteRequestSchema.safeParse({ ...base, legs: many }).success).toBe(false); + const offGlobe = [{ from: { lat: 91, lng: 0 }, to: B }]; + expect(skiRouteRequestSchema.safeParse({ ...base, legs: offGlobe }).success).toBe(false); + const three = { ...base, legs: [{ from: A, to: B }], alternatives: 3 }; + expect(skiRouteRequestSchema.safeParse(three).success).toBe(false); + expect(skiRouteRequestSchema.safeParse({ legs: [{ from: A, to: B }], discipline: 'sledge' }).success).toBe(false); + }); + + it('SKI-ROUTE-SCHEMA-008: answers a planned leg, heights possibly unknown, and a failed one with its fix', () => { + const answer = { + build: '2026-11-02', + stale: false, + area_names: ['Zermatt'], + colour_scheme: 'europe', + attribution: '© OpenStreetMap contributors, ODbL', + legs: [ + { + status: 'ok', + geometry: [ + [A.lat, A.lng, 2500], + [B.lat, B.lng, null], + ], + segments: [ + { + kind: 'run', + from: 0, + to: 1, + name: 'Rote Eins', + ref: '1', + difficulty: 'intermediate', + lift_type: null, + grooming: [], + seconds: 300, + wait_seconds: 0, + length_m: 2500, + flags: ['dir_from_dem'], + osm_way: 12, + }, + ], + distance_m: 2500, + climb_m: 0, + descent_m: 900, + seconds: { ski: 300, lift: 0, wait: 0, walk: 0 }, + snapped_m: { from: 3, to: 0 }, + area_ids: ['w:100'], + why: [{ kind: 'flag', flag: 'dir_from_dem', count: 1 }], + alternatives: [], + }, + { + status: 'failed', + code: 'blocked_by_difficulty', + at: null, + hint: { raise_difficulty: 'advanced' }, + detail: { name: 'Schwarze Wand' }, + }, + ], + }; + expect(skiRouteResponseSchema.parse(answer)).toEqual(answer); + const badFlag = structuredClone(answer); + (badFlag.legs[0] as { segments: Array<{ flags: string[] }> }).segments[0]!.flags = ['avalanche']; + expect(skiRouteResponseSchema.safeParse(badFlag).success).toBe(false); + }); + + it('SKI-ROUTE-SCHEMA-009: area ids, the area-near query and the network answer', () => { + expect(skiAreaIdSchema.safeParse('w:100').success).toBe(true); + expect(skiAreaIdSchema.safeParse('r:42-1a2b').success).toBe(true); + expect(skiAreaIdSchema.safeParse('../etc').success).toBe(false); + expect(skiAreaNearQuerySchema.parse({ lat: '46.1', lng: '7.2' })).toEqual({ lat: 46.1, lng: 7.2 }); + expect(skiAreaNearQuerySchema.safeParse({ lat: '95', lng: '7' }).success).toBe(false); + const network = { + area_id: 'w:100', + build: '2026-11-02', + colour_scheme: 'europe', + simplified_m: 5, + features: [ + { + kind: 'lift', + difficulty: null, + name: 'Sonnenbahn', + lift_type: 'chair_detachable', + oneway: true, + line: '_p~iF~ps|U', + }, + ], + }; + expect(skiNetworkResponseSchema.parse(network)).toEqual(network); + }); +}); + +describe('the winter layer of the Places host (SKI-ROUTE)', () => { + it('SKI-ROUTE-SCHEMA-010: reads the meta and an index row as the host sends them', () => { + const meta = { + build: '2026-11-02', + format: 1, + areas: 1, + indexSha: SHA, + sourceTimestamp: null, + attribution: { data: '© OpenStreetMap contributors, ODbL', terrain: 'Terrain: Mapzen Terrarium', url: 'x' }, + }; + expect(winterMetaSchema.parse(meta)).toMatchObject({ build: '2026-11-02', indexSha: SHA }); + expect(winterMetaSchema.safeParse({ ...meta, indexSha: 'abc' }).success).toBe(false); + const row = { + id: 'w:100', + name: 'Sonnenbahn', + kind: 'resort', + country: null, + colourScheme: 'europe', + bbox: [7.0, 45.999, 7.005, 46.0052], + counts: { runsKm: 1.19, nordicKm: 0.53, lifts: 1, skitourKm: 0, hikeKm: 0 }, + bytes: 900, + sha256: SHA, + }; + const index = { build: '2026-11-02', format: 1, areas: [row] }; + expect(winterIndexSchema.parse(index).areas[0]).toEqual(row); + expect(winterIndexSchema.safeParse({ ...index, areas: [{ ...row, id: 'x' }] }).success).toBe(false); + }); +}); diff --git a/shared/src/tours/ski.schema.spec.ts b/shared/src/tours/ski.schema.spec.ts index 5c6e2bba0..b0bbbd40b 100644 --- a/shared/src/tours/ski.schema.spec.ts +++ b/shared/src/tours/ski.schema.spec.ts @@ -150,10 +150,10 @@ describe('ski and winter tours (SKI-02)', () => { ); }); - it('SKI-SCHEMA-008: works out no ski time and takes nothing of another type, a free leg allowed', () => { - expect(() => tourCreateRequestSchema.parse({ ...planned, duration_seconds: 3600 })).toThrow( - /a ski tour has no ski time worked out/, - ); + it('SKI-SCHEMA-008: takes the time the piste router worked out and nothing of another type, a free leg allowed', () => { + expect(tourCreateRequestSchema.parse({ ...planned, duration_seconds: 3600 })).toMatchObject({ + duration_seconds: 3600, + }); expect(tourCreateRequestSchema.safeParse({ ...planned, duration_seconds: null }).success).toBe(true); expect(() => tourCreateRequestSchema.parse({ ...planned, max_hiking_difficulty: 3 })).toThrow( /a ski tour has no hiking difficulty/, diff --git a/shared/src/tours/tours.schema.ts b/shared/src/tours/tours.schema.ts index 6653e2471..0b3c32c39 100644 --- a/shared/src/tours/tours.schema.ts +++ b/shared/src/tours/tours.schema.ts @@ -10,6 +10,8 @@ import { runPaceSecondsPerKmSchema } from './run-pace'; import { skiDisciplineSchema, skiIssues, + skiRouteBuildSchema, + skiRouteOptionsSchema, skiTechniqueSchema, type SkiDiscipline, type SkiTechnique, @@ -48,8 +50,8 @@ export type TourTypeKey = z.infer; * run (RUN-01), planned on foot with the runner's own pace. `horse` is a * ride (HOR-01): sections of signposted horse routes and stretches the rider * draws by hand, never routed. `ski` is a ski and winter tour (SKI-02): a - * discipline from alpine skiing to winter hiking, every leg drawn between - * its points until the piste router plans them, no ski time worked out. + * discipline from alpine skiing to winter hiking, its legs planned by the + * piste router where the piste data reaches and drawn straight elsewhere. */ export const plannableTourTypeSchema = z.enum(['hike', 'walk', 'run', 'bike', 'kayak', 'horse', 'ski']); export type PlannableTourType = z.infer; @@ -221,6 +223,10 @@ export const tourSchema = z.object({ ski_discipline: skiDisciplineSchema.nullable().optional(), /** SKI-02: how a cross-country tour is skied; null for every other discipline and type. */ ski_technique: skiTechniqueSchema.nullable().optional(), + /** The piste router's options a ski tour was planned with; null for every other type and an imported track. */ + ski_options: skiRouteOptionsSchema.nullable().optional(), + /** The piste data build a ski tour's legs were planned on; null where the router planned none. */ + ski_route_build: z.string().nullable().optional(), /** A paddle tour's boat and water; null for every other type. Absent in a list cached before they existed. */ boat_type: paddleBoatTypeSchema.nullable().optional(), water_context: paddleWaterContextSchema.nullable().optional(), @@ -521,6 +527,10 @@ export const tourCreateRequestSchema = z /** SKI-02, a ski tour's only, and its one requirement: the discipline, for cross-country the technique too. */ ski_discipline: skiDisciplineSchema.nullable().optional(), ski_technique: skiTechniqueSchema.nullable().optional(), + /** A ski tour's only: what the piste router was asked for (the server keeps what its discipline reads). */ + ski_options: skiRouteOptionsSchema.nullable().optional(), + /** A ski tour's only: the piste data build its legs were planned on, so newer data can be offered later. */ + ski_route_build: skiRouteBuildSchema.nullable().optional(), boat_type: paddleBoatTypeSchema.nullable().optional(), water_context: paddleWaterContextSchema.nullable().optional(), paddle_speed_kmh: paddleSpeedKmhSchema.nullable().optional(), @@ -632,8 +642,8 @@ const PADDLE_FIELDS = [ * paddler's, a run also its pace; a paddle tour exactly one boat and its * water, and no hiking limit; a horse tour (HOR-01) only legs taken from a * horse route or drawn by hand, and no riding time worked out for it; a ski - * tour (SKI-02) its discipline (and a cross-country technique), no via - * ferrata, and no ski time worked out for it. + * tour (SKI-02) its discipline (and a cross-country technique), its piste + * router options, and no via ferrata. */ function tourTypeIssues(value: { tour_type: PlannableTourType; @@ -645,6 +655,8 @@ function tourTypeIssues(value: { run_pace_seconds_per_km?: number | null; ski_discipline?: SkiDiscipline | null; ski_technique?: SkiTechnique | null; + ski_options?: object | null; + ski_route_build?: string | null; boat_type?: PaddleBoatType | null; water_context?: PaddleWaterContext | null; paddle_speed_kmh?: number | null; @@ -685,12 +697,15 @@ function tourTypeIssues(value: { issues.push({ path: ['bike_profile'], message: 'bike_profile belongs to a bike tour' }); issues.push(...runIssues(value)); issues.push(...skiIssues(type === 'ski', value)); + if (type !== 'ski') { + for (const field of ['ski_options', 'ski_route_build'] as const) { + if (value[field] != null) issues.push({ path: [field], message: `${field} belongs to a ski tour` }); + } + } // No router plans a ride, so no time is worked out for it; a planned total the rider enters is theirs. + // A ski tour's time is the piste router's, sent only when it planned every leg. if (type === 'horse' && value.duration_seconds != null) issues.push({ path: ['duration_seconds'], message: 'a horse tour has no riding time worked out' }); - // SKI-02: no ski time is worked out until the piste router plans the legs; a planned total stays the traveller's. - if (type === 'ski' && value.duration_seconds != null) - issues.push({ path: ['duration_seconds'], message: 'a ski tour has no ski time worked out' }); const points = value.waypoints ?? []; // A portage and a lock are a paddler's; a via ferrata is climbed, never ridden. points.forEach((point, index) => {