diff --git a/server/src/nest/tours/tour-gpx-export.helpers.ts b/server/src/nest/tours/tour-gpx-export.helpers.ts new file mode 100644 index 000000000..4fa9e4ba1 --- /dev/null +++ b/server/src/nest/tours/tour-gpx-export.helpers.ts @@ -0,0 +1,25 @@ +import type { BikeProfile, PaddleBoatType } from '@trek/shared'; + +/** What GPX calls the activity in a track's ; a paddle tour says what it is paddled in. */ +const PADDLE_ACTIVITY: Record = { + kayak: 'kayaking', + canoe: 'canoeing', + sup: 'stand_up_paddling', + other: 'paddling', +}; + +/** A bike tour says what it is ridden on, in the words Garmin and Strava read. */ +const BIKE_ACTIVITY: Record = { + touring: 'cycling', + road: 'road_biking', + gravel: 'gravel_cycling', + mtb: 'mountain_biking', +}; + +/** The activity a tour's track names in GPX: a paddle tour its boat, a bike tour its profile, a hike hiking; whatever is missing falls back to the plainest. */ +export function gpxActivityOf(tour: { tour_type: string; boat_type?: string | null; bike_profile?: string | null }): string { + const { tour_type: type, boat_type: boat, bike_profile: bike } = tour; + if (type === 'kayak') return PADDLE_ACTIVITY[(boat ?? 'other') as PaddleBoatType] ?? PADDLE_ACTIVITY.other; + if (type === 'bike') return BIKE_ACTIVITY[(bike ?? 'touring') as BikeProfile] ?? BIKE_ACTIVITY.touring; + return type === 'hike' ? 'hiking' : type; +} diff --git a/server/src/nest/tours/tour-gpx-export.service.ts b/server/src/nest/tours/tour-gpx-export.service.ts index a25551447..0b4902ca2 100644 --- a/server/src/nest/tours/tour-gpx-export.service.ts +++ b/server/src/nest/tours/tour-gpx-export.service.ts @@ -7,32 +7,9 @@ import type { ToursRepository } from '../../db/repositories/Tours.repository'; import { DomainError } from '../common/domain-error'; import { toRowId } from '../common/row-id'; import { PlaceImportService } from '../place-import/place-import.service'; +import { gpxActivityOf } from './tour-gpx-export.helpers'; import { InjectRepository } from '@mikro-orm/nestjs'; import { Injectable, NotFoundException } from '@nestjs/common'; -import type { BikeProfile, PaddleBoatType } from '@trek/shared'; - -/** What GPX calls the activity in a track's ; a paddle tour says what it is paddled in. */ -const PADDLE_ACTIVITY: Record = { - kayak: 'kayaking', - canoe: 'canoeing', - sup: 'stand_up_paddling', - other: 'paddling', -}; - -/** A bike tour says what it is ridden on, in the words Garmin and Strava read. */ -const BIKE_ACTIVITY: Record = { - touring: 'cycling', - road: 'road_biking', - gravel: 'gravel_cycling', - mtb: 'mountain_biking', -}; - -function gpxActivityOf(tour: { tour_type: string; boat_type?: string | null; bike_profile?: string | null }): string { - const { tour_type: type, boat_type: boat, bike_profile: bike } = tour; - if (type === 'kayak') return PADDLE_ACTIVITY[(boat ?? 'other') as PaddleBoatType] ?? PADDLE_ACTIVITY.other; - if (type === 'bike') return BIKE_ACTIVITY[(bike ?? 'touring') as BikeProfile] ?? BIKE_ACTIVITY.touring; - return type === 'hike' ? 'hiking' : type; -} /** * PR1-06A: one saved tour as a GPX file, its whole saved line with the heights diff --git a/server/src/nest/tours/tour-trail-grade.service.ts b/server/src/nest/tours/tour-trail-grade.service.ts index 3591373a9..575f9e9b8 100644 --- a/server/src/nest/tours/tour-trail-grade.service.ts +++ b/server/src/nest/tours/tour-trail-grade.service.ts @@ -21,7 +21,7 @@ const wayElementSchema = z.object({ function step(value: string | undefined, top: number, signed: boolean): string | null { const match = /^\s*S?([0-9])([+-])?\s*$/i.exec(value ?? ''); if (!match || Number(match[1]) > top) return null; - return signed ? `${match[1]}${match[2] ?? ''}` : (match[1] ?? null); + return signed ? `${match[1]}${match[2] ?? ''}` : match[1]; } function gradeOf(id: number, tags: Record = {}): TourTrailGrade | null { diff --git a/server/tests/unit/nest/tour-gpx-export.helpers.test.ts b/server/tests/unit/nest/tour-gpx-export.helpers.test.ts new file mode 100644 index 000000000..d49286c01 --- /dev/null +++ b/server/tests/unit/nest/tour-gpx-export.helpers.test.ts @@ -0,0 +1,24 @@ +import { gpxActivityOf } from '../../../src/nest/tours/tour-gpx-export.helpers'; + +import { describe, expect, it } from 'vitest'; + +// TOUR-GPX-ACTIVITY-001 to TOUR-GPX-ACTIVITY-003 + +describe('gpxActivityOf (PR1-06A, BIK-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'); + expect(gpxActivityOf({ tour_type: 'bike', bike_profile: 'unicycle' })).toBe('cycling'); + }); + + it('TOUR-GPX-ACTIVITY-002: a paddle tour names its boat, and paddling without a known one', () => { + expect(gpxActivityOf({ tour_type: 'kayak', boat_type: 'canoe' })).toBe('canoeing'); + expect(gpxActivityOf({ tour_type: 'kayak', boat_type: null })).toBe('paddling'); + expect(gpxActivityOf({ tour_type: 'kayak', boat_type: 'raft' })).toBe('paddling'); + }); + + it('TOUR-GPX-ACTIVITY-003: a hike is hiking, any other type its own key', () => { + expect(gpxActivityOf({ tour_type: 'hike' })).toBe('hiking'); + expect(gpxActivityOf({ tour_type: 'walk' })).toBe('walk'); + }); +}); diff --git a/server/tests/unit/nest/tour-trail-grade.service.test.ts b/server/tests/unit/nest/tour-trail-grade.service.test.ts index 28eac0982..ac787db3e 100644 --- a/server/tests/unit/nest/tour-trail-grade.service.test.ts +++ b/server/tests/unit/nest/tour-trail-grade.service.test.ts @@ -4,7 +4,7 @@ import { Test } from '@nestjs/testing'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -// TOUR-GRADE-001 to TOUR-GRADE-005 +// TOUR-GRADE-001 to TOUR-GRADE-006 const query = vi.fn<(q: string, caller: string) => Promise>(); let service: TourTrailGradeService; @@ -75,4 +75,19 @@ describe('TourTrailGradeService (BIK-05)', () => { query.mockResolvedValue([{ type: 'node', id: 1, tags: { 'mtb:scale': '1' } }, { nonsense: true }]); expect(await service.grades({ way_ids: [1] })).toEqual({ ways: [], unanswered: [] }); }); + + it('TOUR-GRADE-006: keeps at most fifty thousand ways, forgetting the longest kept first', async () => { + query.mockResolvedValue([]); + for (let part = 0; part < 26; part += 1) { + const ids = Array.from({ length: 2000 }, (_, index) => part * 2000 + index + 1); + await service.grades({ way_ids: ids }); + } + query.mockClear(); + // The first way was forgotten, so it is asked about again; the last one is still kept. + await service.grades({ way_ids: [1] }); + expect(query).toHaveBeenCalledTimes(1); + query.mockClear(); + await service.grades({ way_ids: [52_000] }); + expect(query).not.toHaveBeenCalled(); + }); });