test(tours): cover a bike tour's GPX activity and the trail grade cache's limit

This commit is contained in:
Maurice
2026-10-19 22:59:59 +02:00
parent e877d35e0b
commit da0f79e285
5 changed files with 67 additions and 26 deletions
@@ -0,0 +1,25 @@
import type { BikeProfile, PaddleBoatType } from '@trek/shared';
/** What GPX calls the activity in a track's <type>; a paddle tour says what it is paddled in. */
const PADDLE_ACTIVITY: Record<PaddleBoatType, string> = {
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<BikeProfile, string> = {
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;
}
@@ -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 <type>; a paddle tour says what it is paddled in. */
const PADDLE_ACTIVITY: Record<PaddleBoatType, string> = {
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<BikeProfile, string> = {
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
@@ -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<string, string> = {}): TourTrailGrade | null {
@@ -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');
});
});
@@ -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<unknown[]>>();
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();
});
});