Import initial : code en production sur Hetzner
This commit is contained in:
commit
cf6ee64e31
43 changed files with 14404 additions and 0 deletions
65
test/unit/booking.spec.ts
Normal file
65
test/unit/booking.spec.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { buildBookingLink, buildChargemapLink, buildCheckMyBusLink, buildKomboLink } from '../../shared/booking'
|
||||
import type { Place } from '../../shared/types'
|
||||
|
||||
const lille: Place = { label: 'Lille, France', lat: 50.6292, lon: 3.0573 }
|
||||
const paris: Place = { label: 'Paris, France', lat: 48.8566, lon: 2.3522 }
|
||||
const stEtienne: Place = { label: 'Saint-Étienne, France', lat: 45.4397, lon: 4.3872 }
|
||||
const date = new Date('2026-08-15T00:00:00Z')
|
||||
|
||||
describe('buildKomboLink', () => {
|
||||
it('genere une URL Kombo avec noms de ville en clair', () => {
|
||||
const { url, provider } = buildKomboLink(lille, paris, date)
|
||||
expect(url).toContain('/2026-08-15/Lille/Paris/')
|
||||
expect(url).toContain('/outward/1/1/18/')
|
||||
expect(url).toContain('/results')
|
||||
expect(provider).toBe('Kombo')
|
||||
})
|
||||
|
||||
it('gere les accents dans les noms de ville', () => {
|
||||
const { url } = buildKomboLink(stEtienne, paris, date)
|
||||
// Saint-Etienne conserve son tiret et son E encode
|
||||
expect(url).toContain('Saint-')
|
||||
expect(url).toContain('Paris')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildCheckMyBusLink', () => {
|
||||
it('genere une URL CheckMyBus avec slug + coordonnees + UUID zeros', () => {
|
||||
const { url, provider } = buildCheckMyBusLink(lille, paris, date)
|
||||
expect(url).toContain('/lille/paris?')
|
||||
expect(url).toContain('departureDate=2026-08-15')
|
||||
expect(url).toContain('50.6292%2C3.0573')
|
||||
expect(url).toContain('48.8566%2C2.3522')
|
||||
expect(url).toContain('00000000-0000-0000-0000-000000000000')
|
||||
expect(provider).toBe('CheckMyBus')
|
||||
})
|
||||
|
||||
it('slugifie les villes avec accents', () => {
|
||||
const { url } = buildCheckMyBusLink(stEtienne, paris, date)
|
||||
expect(url).toContain('/saint-etienne/paris?')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildChargemapLink', () => {
|
||||
it('renvoie la page d accueil du planificateur', () => {
|
||||
const { url, provider } = buildChargemapLink()
|
||||
expect(url).toBe('https://chargemap.com/map')
|
||||
expect(provider).toBe('Chargemap')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildBookingLink', () => {
|
||||
it('renvoie Kombo pour train', () => {
|
||||
expect(buildBookingLink('train', lille, paris, date)?.provider).toBe('Kombo')
|
||||
})
|
||||
it('renvoie CheckMyBus pour bus', () => {
|
||||
expect(buildBookingLink('bus', lille, paris, date)?.provider).toBe('CheckMyBus')
|
||||
})
|
||||
it('renvoie Chargemap pour voiture electrique', () => {
|
||||
expect(buildBookingLink('electric_car', lille, paris, date)?.provider).toBe('Chargemap')
|
||||
})
|
||||
it('renvoie null pour velo', () => {
|
||||
expect(buildBookingLink('bike', lille, paris, date)).toBeNull()
|
||||
})
|
||||
})
|
||||
59
test/unit/distances.spec.ts
Normal file
59
test/unit/distances.spec.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { haversineKm, legKm, summarize } from '../../server/utils/transitous'
|
||||
|
||||
const PARIS = { lat: 48.8566, lon: 2.3522 }
|
||||
const LYON = { lat: 45.764, lon: 4.8357 }
|
||||
|
||||
describe('haversineKm', () => {
|
||||
it('mesure Paris-Lyon a environ 392 km a vol d oiseau', () => {
|
||||
const km = haversineKm(PARIS, LYON)
|
||||
expect(km).toBeGreaterThan(388)
|
||||
expect(km).toBeLessThan(396)
|
||||
})
|
||||
|
||||
it('renvoie zero entre un point et lui-meme', () => {
|
||||
expect(haversineKm(PARIS, PARIS)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('legKm', () => {
|
||||
const base = { mode: 'RAIL', startTime: '', endTime: '', from: PARIS, to: LYON }
|
||||
|
||||
it('utilise la distance fournie quand elle existe', () => {
|
||||
expect(legKm({ ...base, distance: 465000 })).toBe(465)
|
||||
})
|
||||
|
||||
it('retombe sur le calcul geometrique quand la distance manque', () => {
|
||||
expect(legKm(base)).toBeCloseTo(haversineKm(PARIS, LYON), 5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('summarize', () => {
|
||||
const leg = (mode: string, distance: number) => ({
|
||||
mode,
|
||||
distance,
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
from: PARIS,
|
||||
to: PARIS,
|
||||
})
|
||||
|
||||
it('applique a chaque troncon son propre facteur d emission', () => {
|
||||
// 10 km de metro a 4,1 g + 400 km de TGV a 2,4 g
|
||||
const r = summarize({ duration: 7200, transfers: 1, legs: [leg('SUBWAY', 10000), leg('HIGHSPEED_RAIL', 400000)] }, 'train')
|
||||
expect(r.co2Grams).toBe(1001)
|
||||
expect(r.distanceKm).toBe(410)
|
||||
expect(r.durationMin).toBe(120)
|
||||
})
|
||||
|
||||
it('ne compte pas la marche dans les emissions', () => {
|
||||
const r = summarize({ duration: 600, transfers: 0, legs: [leg('WALK', 2000)] }, 'bus')
|
||||
expect(r.co2Grams).toBe(0)
|
||||
})
|
||||
|
||||
it('prend le facteur le plus penalisant pour un mode inconnu', () => {
|
||||
// Mieux vaut surestimer que de laisser croire a un trajet propre.
|
||||
const r = summarize({ duration: 600, transfers: 0, legs: [leg('TELEPORTATION', 10000)] }, 'bus')
|
||||
expect(r.co2Grams).toBe(1130)
|
||||
})
|
||||
})
|
||||
42
test/unit/estimate.spec.ts
Normal file
42
test/unit/estimate.spec.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { estimateTrainTrip } from '../../server/utils/estimate'
|
||||
|
||||
describe('estimateTrainTrip', () => {
|
||||
const grenoble = { lat: 45.1885, lon: 5.7245 }
|
||||
const marseille = { lat: 43.2965, lon: 5.3698 }
|
||||
const lille = { lat: 50.6292, lon: 3.0573 }
|
||||
const paris = { lat: 48.8566, lon: 2.3522 }
|
||||
|
||||
it('renvoie un objet marque comme estime', () => {
|
||||
const trip = estimateTrainTrip(grenoble, marseille)
|
||||
expect(trip.estimated).toBe(true)
|
||||
expect(trip.mode).toBe('train')
|
||||
})
|
||||
|
||||
it('estime Grenoble-Marseille (vol oiseau 216 km, ferroviaire ~260 km)', () => {
|
||||
const trip = estimateTrainTrip(grenoble, marseille)
|
||||
expect(trip.distanceKm).toBeGreaterThan(230)
|
||||
expect(trip.distanceKm).toBeLessThan(290)
|
||||
// A 90 km/h : entre 2h30 et 3h20 environ
|
||||
expect(trip.durationMin).toBeGreaterThan(150)
|
||||
expect(trip.durationMin).toBeLessThan(200)
|
||||
})
|
||||
|
||||
it('estime Lille-Paris (vol oiseau 203 km, ferroviaire ~245 km)', () => {
|
||||
const trip = estimateTrainTrip(lille, paris)
|
||||
expect(trip.distanceKm).toBeGreaterThan(220)
|
||||
expect(trip.distanceKm).toBeLessThan(280)
|
||||
})
|
||||
|
||||
it('renvoie une geometrie a deux points', () => {
|
||||
const trip = estimateTrainTrip(grenoble, marseille)
|
||||
expect(trip.geometry).toHaveLength(2)
|
||||
expect(trip.geometry[0]).toEqual([grenoble.lon, grenoble.lat])
|
||||
expect(trip.geometry[1]).toEqual([marseille.lon, marseille.lat])
|
||||
})
|
||||
|
||||
it('calcule le CO2 avec le facteur ADEME train (2.4 g/km)', () => {
|
||||
const trip = estimateTrainTrip(grenoble, marseille)
|
||||
expect(trip.co2Grams).toBeCloseTo(trip.distanceKm * 2.4, 0)
|
||||
})
|
||||
})
|
||||
28
test/unit/geocoding.spec.ts
Normal file
28
test/unit/geocoding.spec.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { dedupe } from '../../server/utils/geocoding'
|
||||
|
||||
const place = (label: string, lat: number, lon: number) => ({ label, lat, lon })
|
||||
|
||||
describe('dedupe', () => {
|
||||
it('elimine deux entrees au meme libelle', () => {
|
||||
expect(dedupe([place('Lille', 50.63, 3.05), place('lille', 47.1, 2.2)])).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('elimine deux entrees a la meme position malgre des libelles differents', () => {
|
||||
const r = dedupe([
|
||||
place('Bruxelles, Belgique', 50.846, 4.352),
|
||||
place('Bruxelles-Capitale, Belgique', 50.848, 4.351),
|
||||
])
|
||||
expect(r).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('conserve deux villes homonymes reellement distinctes', () => {
|
||||
const r = dedupe([place('Lille, France', 50.63, 3.05), place('Lille, Belgique', 50.72, 3.20)])
|
||||
expect(r).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('preserve l ordre d entree', () => {
|
||||
const r = dedupe([place('A', 1, 1), place('B', 2, 2), place('C', 3, 3)])
|
||||
expect(r.map(p => p.label)).toEqual(['A', 'B', 'C'])
|
||||
})
|
||||
})
|
||||
41
test/unit/midpoint.spec.ts
Normal file
41
test/unit/midpoint.spec.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { midpointAt } from '../../shared/polyline'
|
||||
import type { Coord } from '../../shared/types'
|
||||
|
||||
describe('midpointAt', () => {
|
||||
it('renvoie le point unique quand un seul est fourni', () => {
|
||||
expect(midpointAt([[1, 2]], 0.5)).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('renvoie le milieu geometrique sur une ligne droite reguliere', () => {
|
||||
const coords: Coord[] = [[0, 0], [10, 0]]
|
||||
expect(midpointAt(coords, 0.5)).toEqual([5, 0])
|
||||
})
|
||||
|
||||
it('renvoie le vrai milieu de distance sur un trace inegal', () => {
|
||||
// Trajet : 0 -> 10 (long), puis 10 -> 11 (court)
|
||||
// Distance totale = 11, milieu a 5.5, donc sur le premier segment
|
||||
const coords: Coord[] = [[0, 0], [10, 0], [11, 0]]
|
||||
const mid = midpointAt(coords, 0.5)
|
||||
expect(mid[0]).toBeCloseTo(5.5, 5)
|
||||
expect(mid[1]).toBeCloseTo(0, 5)
|
||||
})
|
||||
|
||||
it('renvoie le point de debut avec ratio 0', () => {
|
||||
const coords: Coord[] = [[0, 0], [10, 0], [20, 5]]
|
||||
expect(midpointAt(coords, 0)).toEqual([0, 0])
|
||||
})
|
||||
|
||||
it('renvoie le point de fin avec ratio 1', () => {
|
||||
const coords: Coord[] = [[0, 0], [10, 0], [20, 5]]
|
||||
expect(midpointAt(coords, 1)).toEqual([20, 5])
|
||||
})
|
||||
|
||||
it('gere un trace en forme de coude', () => {
|
||||
// 0,0 -> 10,0 -> 10,10 : total = 20, milieu = 10 = fin du premier segment
|
||||
const coords: Coord[] = [[0, 0], [10, 0], [10, 10]]
|
||||
const mid = midpointAt(coords, 0.5)
|
||||
expect(mid[0]).toBeCloseTo(10, 5)
|
||||
expect(mid[1]).toBeCloseTo(0, 5)
|
||||
})
|
||||
})
|
||||
43
test/unit/polyline.spec.ts
Normal file
43
test/unit/polyline.spec.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { decodePolyline, thin } from '#shared/polyline'
|
||||
|
||||
describe('decodePolyline', () => {
|
||||
it('decode l exemple de reference de Google a la precision 5', () => {
|
||||
// Exemple officiel : trois points en Californie.
|
||||
const r = decodePolyline('_p~iF~ps|U_ulLnnqC_mqNvxq`@', 5)
|
||||
expect(r).toHaveLength(3)
|
||||
// Ordre GeoJSON : longitude d'abord.
|
||||
expect(r[0]![1]).toBeCloseTo(38.5, 4)
|
||||
expect(r[0]![0]).toBeCloseTo(-120.2, 4)
|
||||
})
|
||||
|
||||
it('place le trace ailleurs si la precision est fausse', () => {
|
||||
// C'est tout l'enjeu : MOTIS encode a 1e7, Google a 1e5.
|
||||
const juste = decodePolyline('_p~iF~ps|U', 5)
|
||||
const faux = decodePolyline('_p~iF~ps|U', 7)
|
||||
expect(faux[0]![1]).not.toBeCloseTo(juste[0]![1], 2)
|
||||
expect(faux[0]![1]).toBeCloseTo(juste[0]![1] / 100, 4)
|
||||
})
|
||||
|
||||
it('renvoie un tableau vide sur une chaine vide', () => {
|
||||
expect(decodePolyline('', 7)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('thin', () => {
|
||||
const trace = Array.from({ length: 1000 }, (_, i) => [i, i] as [number, number])
|
||||
|
||||
it('ne touche pas a un trace deja assez court', () => {
|
||||
const court = trace.slice(0, 50)
|
||||
expect(thin(court, 800)).toBe(court)
|
||||
})
|
||||
|
||||
it('ramene un trace long sous la limite demandee', () => {
|
||||
expect(thin(trace, 100).length).toBeLessThanOrEqual(101)
|
||||
})
|
||||
|
||||
it('conserve toujours le terminus', () => {
|
||||
const r = thin(trace, 100)
|
||||
expect(r[r.length - 1]).toEqual(trace[trace.length - 1])
|
||||
})
|
||||
})
|
||||
106
test/unit/publicTransport.spec.ts
Normal file
106
test/unit/publicTransport.spec.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { publicTransport } from '../../server/utils/transitous'
|
||||
|
||||
const LILLE = { lat: 50.6292, lon: 3.0573 }
|
||||
const BRUXELLES = { lat: 50.8503, lon: 4.3517 }
|
||||
|
||||
const leg = (mode: string, distance: number, minutes: number) => ({
|
||||
mode,
|
||||
distance,
|
||||
duration: minutes * 60,
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
from: LILLE,
|
||||
to: BRUXELLES,
|
||||
})
|
||||
|
||||
const journey = (legs: ReturnType<typeof leg>[], minutes: number, transfers = 0) => ({
|
||||
duration: minutes * 60,
|
||||
transfers,
|
||||
legs,
|
||||
})
|
||||
|
||||
/**
|
||||
* Le vrai code passe deux requetes distinctes, une par famille de mode.
|
||||
* Le faux doit faire la meme distinction, sinon il renvoie un trajet en car
|
||||
* a la requete ferroviaire et le test valide un comportement impossible.
|
||||
*/
|
||||
function mockApi(byFamily: { rail?: unknown[], urban?: unknown[] }) {
|
||||
const spy = vi.fn().mockImplementation((url: string) => {
|
||||
const rail = url.includes('HIGHSPEED_RAIL')
|
||||
return Promise.resolve({ itineraries: (rail ? byFamily.rail : byFamily.urban) ?? [] })
|
||||
})
|
||||
vi.stubGlobal('$fetch', spy)
|
||||
return spy
|
||||
}
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('publicTransport', () => {
|
||||
it('interroge chaque famille de mode separement', async () => {
|
||||
const spy = mockApi({})
|
||||
await publicTransport(LILLE, BRUXELLES, new Date())
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(2)
|
||||
const urls = spy.mock.calls.map(c => c[0] as string)
|
||||
expect(urls.some(u => u.includes('HIGHSPEED_RAIL'))).toBe(true)
|
||||
expect(urls.some(u => u.includes('COACH') && !u.includes('HIGHSPEED_RAIL'))).toBe(true)
|
||||
})
|
||||
|
||||
it('separe le meilleur trajet ferroviaire du meilleur trajet urbain', async () => {
|
||||
mockApi({
|
||||
rail: [journey([leg('HIGHSPEED_RAIL', 100000, 90)], 90, 1)],
|
||||
urban: [journey([leg('BUS', 95000, 180)], 180, 3)],
|
||||
})
|
||||
|
||||
const r = await publicTransport(LILLE, BRUXELLES, new Date())
|
||||
|
||||
// 100 km de TGV a 2,4 g contre 95 km de bus a 113 g.
|
||||
expect(r.train?.co2Grams).toBe(240)
|
||||
expect(r.bus?.co2Grams).toBe(10735)
|
||||
expect(r.train?.transfers).toBe(1)
|
||||
})
|
||||
|
||||
it('retient le plus rapide quand plusieurs trajets ferroviaires existent', async () => {
|
||||
mockApi({
|
||||
rail: [
|
||||
journey([leg('REGIONAL_RAIL', 100000, 180)], 180, 2),
|
||||
journey([leg('HIGHSPEED_RAIL', 100000, 90)], 90, 0),
|
||||
],
|
||||
})
|
||||
|
||||
const r = await publicTransport(LILLE, BRUXELLES, new Date())
|
||||
|
||||
expect(r.train?.durationMin).toBe(90)
|
||||
expect(r.train?.transfers).toBe(0)
|
||||
})
|
||||
|
||||
it('renvoie null pour un mode absent plutot que d inventer un trajet', async () => {
|
||||
mockApi({ urban: [journey([leg('COACH', 50000, 60)], 60)] })
|
||||
|
||||
const r = await publicTransport(LILLE, BRUXELLES, new Date())
|
||||
|
||||
expect(r.train).toBeNull()
|
||||
expect(r.bus).not.toBeNull()
|
||||
})
|
||||
|
||||
it('ecarte un itineraire entierement pietonnier', async () => {
|
||||
// MOTIS propose parfois de marcher faute de mieux : ce n'est pas
|
||||
// un trajet en transport en commun et il ne doit pas s'afficher.
|
||||
mockApi({ rail: [journey([leg('WALK', 3000, 40)], 40)] })
|
||||
|
||||
const r = await publicTransport(LILLE, BRUXELLES, new Date())
|
||||
|
||||
expect(r.train).toBeNull()
|
||||
})
|
||||
|
||||
it('s identifie aupres de Transitous comme leur politique d usage l exige', async () => {
|
||||
const spy = mockApi({})
|
||||
|
||||
await publicTransport(LILLE, BRUXELLES, new Date())
|
||||
|
||||
const [url, options] = spy.mock.calls[0]
|
||||
expect(url).toContain('fromPlace=50.6292,3.0573')
|
||||
expect(options.headers['User-Agent']).toContain('lecheminvert.tech')
|
||||
})
|
||||
})
|
||||
69
test/unit/transitous.spec.ts
Normal file
69
test/unit/transitous.spec.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { dominant, family, legMin } from '../../server/utils/transitous'
|
||||
|
||||
const leg = (mode: string, minutes: number) => ({
|
||||
mode,
|
||||
duration: minutes * 60,
|
||||
startTime: '2026-07-29T08:00:00Z',
|
||||
endTime: '2026-07-29T08:00:00Z',
|
||||
from: { lat: 0, lon: 0 },
|
||||
to: { lat: 0, lon: 0 },
|
||||
})
|
||||
|
||||
const journey = (legs: ReturnType<typeof leg>[]) => ({ duration: 0, transfers: 0, legs })
|
||||
|
||||
describe('family', () => {
|
||||
it('classe le TGV et le TER dans la meme famille ferroviaire', () => {
|
||||
expect(family('HIGHSPEED_RAIL')).toBe('rail')
|
||||
expect(family('REGIONAL_RAIL')).toBe('rail')
|
||||
})
|
||||
|
||||
it('classe metro, tram et bus comme transport urbain', () => {
|
||||
expect(family('SUBWAY')).toBe('urban')
|
||||
expect(family('TRAM')).toBe('urban')
|
||||
expect(family('BUS')).toBe('urban')
|
||||
})
|
||||
|
||||
it('ne compte pas la marche comme un transport', () => {
|
||||
expect(family('WALK')).toBe('walk')
|
||||
})
|
||||
|
||||
it('traite un mode inconnu comme de la marche plutot que de planter', () => {
|
||||
expect(family('TELEPORTATION')).toBe('walk')
|
||||
})
|
||||
})
|
||||
|
||||
describe('legMin', () => {
|
||||
it('convertit la duree en minutes', () => {
|
||||
expect(legMin(leg('BUS', 12))).toBe(12)
|
||||
})
|
||||
|
||||
it('retombe sur les horodatages quand la duree manque', () => {
|
||||
const l = {
|
||||
mode: 'BUS',
|
||||
startTime: '2026-07-29T08:00:00Z',
|
||||
endTime: '2026-07-29T08:45:00Z',
|
||||
from: { lat: 0, lon: 0 },
|
||||
to: { lat: 0, lon: 0 },
|
||||
}
|
||||
expect(legMin(l)).toBe(45)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dominant', () => {
|
||||
it('classe en ferroviaire un trajet TGV precede d un bus de rabattement', () => {
|
||||
expect(dominant(journey([leg('BUS', 10), leg('HIGHSPEED_RAIL', 100)]))).toBe('rail')
|
||||
})
|
||||
|
||||
it('classe en urbain un trajet ou le bus domine largement', () => {
|
||||
expect(dominant(journey([leg('REGIONAL_RAIL', 5), leg('BUS', 90)]))).toBe('urban')
|
||||
})
|
||||
|
||||
it('ignore la marche dans la ponderation', () => {
|
||||
expect(dominant(journey([leg('WALK', 200), leg('TRAM', 10)]))).toBe('urban')
|
||||
})
|
||||
|
||||
it('renvoie walk quand il n y a aucun transport en commun', () => {
|
||||
expect(dominant(journey([leg('WALK', 30)]))).toBe('walk')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue