From f1120be3c1581b352bd3d179651990d94b7a11e0 Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 7 Sep 2026 18:14:36 +0200 Subject: [PATCH] test(roadtrip): pin the via contracts The schemas had no spec of their own. The two things worth pinning are the ones the feature rests on: the anchor is a position and never an id, so a fraction or a negative is not a leg of anything; and the batch route's track has three states, because absent and null mean different things to a day that already follows a line. --- .../useTripPlanner.roadtrip.test.tsx | 60 +++++++ shared/src/roadtrip/roadtrip.schema.spec.ts | 154 ++++++++++++++++++ 2 files changed, 214 insertions(+) create mode 100644 shared/src/roadtrip/roadtrip.schema.spec.ts diff --git a/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx b/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx index 9efa9cb9b..e9df4a853 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx +++ b/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx @@ -516,6 +516,62 @@ describe('useTripPlanner road trip: saving a hit', () => { expect(result.current.stopDraftDuplicate).toBe('Aral A1') }) + it('FE-TP-ROAD-057: which side of the new stop a via falls on is measured on the road driven', async () => { + // Both the via and the new stop are projected onto the day's current routed line, + // so the comparison is "which one does the car reach first" rather than a + // straight-line guess. + seedTrip({ days: [buildDay({ id: 5, day_number: 1 })] }) + rt.corridor.day = { dayId: 5, dayNumber: 1 } + rt.corridor.insertIndexFor.mockReturnValue(1) + rt.routes.days = [{ + dayId: 5, + stops: [{ lat: 53.55, lng: 9.99 }, { lat: 52.52, lng: 13.4 }], + geometry: [[53.55, 9.99], [53.2, 10.9], [52.9, 12.0], [52.52, 13.4]], + }] + // One via before the hit on the road, one after it. + rt.vias.byDay = { 5: [via(1, 5, 0, 0, 53.2, 10.9), via(2, 5, 0, 1, 52.9, 12.0)] } + + const { result } = await renderRoadtrip() + // The hit sits between them, at the second shape point of the drive. + act(() => { result.current.handlePoiClick(poi({ lat: 53.05, lng: 11.45 }) as never) }) + await act(async () => { await result.current.saveStopDraft({ stopType: 'fuel', dwellMinutes: 10 }) }) + + const [, plan] = rt.vias.reanchor.mock.calls[0] as unknown as [number, { vias: Array<{ id: number; after_order_index: number }> }] + const moved = new Map(plan.vias.map(v => [v.id, v.after_order_index])) + // The one the car passes first keeps its leg; the one past the new stop moves on. + expect(moved.get(1)).not.toBe(moved.get(2)) + }) + + it('FE-TP-ROAD-058: saving through the full form still lands the stop where it will be driven past', async () => { + // The slice has taken a position all along and nothing ever passed it, so a place + // added by way of the full form went to the end of the day. + seedTrip({ days: [buildDay({ id: 5, day_number: 1 })] }) + rt.corridor.day = { dayId: 5, dayNumber: 1 } + rt.corridor.insertIndexFor.mockReturnValue(2) + const { result } = await renderRoadtrip() + + act(() => { result.current.handlePoiClick(poi() as never) }) + act(() => { result.current.stopDraftToForm({ stopType: 'fuel', dwellMinutes: 5 }) }) + await act(async () => { await result.current.handleSavePlace({ name: 'Rasthof' }) }) + + expect(actions.assignPlaceToDay).toHaveBeenCalledWith(42, 5, 900, 2) + expect(updateRouteForDay).toHaveBeenCalledWith(5) + }) + + it('FE-TP-ROAD-059: a day link that fails is said out loud, and the place itself stands', async () => { + seedTrip({ days: [buildDay({ id: 5, day_number: 1 })] }) + rt.corridor.day = { dayId: 5, dayNumber: 1 } + const { result } = await renderRoadtrip() + actions.assignPlaceToDay.mockRejectedValue(new Error('day gone')) + + act(() => { result.current.handlePoiClick(poi() as never) }) + act(() => { result.current.stopDraftToForm() }) + await act(async () => { await result.current.handleSavePlace({ name: 'Rasthof' }) }) + + expect(actions.addPlace).toHaveBeenCalled() + expect(toasts.some(t => t.type === 'error' && t.message === 'day gone')).toBe(true) + }) + it('FE-TP-ROAD-016: with nothing open there is nothing to warn about', async () => { seedTrip({ days: [buildDay({ id: 5, day_number: 1 })] }) rt.corridor.day = { dayId: 5, dayNumber: 1 } @@ -718,6 +774,10 @@ describe('useTripPlanner road trip: one stop at a time', () => { await act(async () => { await result.current.setRoadtripStay(1011, 0) }) expect(actions.updatePlace).toHaveBeenCalledWith(42, 1011, { duration_minutes: 0 }) + + actions.updatePlace.mockRejectedValue(new Error('read only')) + await act(async () => { await result.current.setRoadtripStay(1011, 45) }) + expect(toasts.some(t => t.type === 'error' && t.message === 'read only')).toBe(true) }) it('FE-TP-ROAD-032: a reader may not set a stay', async () => { diff --git a/shared/src/roadtrip/roadtrip.schema.spec.ts b/shared/src/roadtrip/roadtrip.schema.spec.ts new file mode 100644 index 000000000..4242edda1 --- /dev/null +++ b/shared/src/roadtrip/roadtrip.schema.spec.ts @@ -0,0 +1,154 @@ +import { + roadtripViaSchema, + roadtripViaCreateRequestSchema, + roadtripViaUpdateRequestSchema, + roadtripViaReanchorRequestSchema, + roadtripViaBatchRequestSchema, + roadtripDayTrackSchema, + roadtripViaListResponseSchema, +} from './roadtrip.schema'; + +import { describe, it, expect } from 'vitest'; + +/** + * The via contracts. + * + * Two things carry the feature and are worth pinning here rather than in a controller: + * the anchor is a POSITION and never an id, so it has to be a non-negative integer and + * nothing else; and the batch route's `track` has three states, not two, because absent + * and null mean different things to a day. + */ + +const via = { after_order_index: 0, lat: 53.55, lng: 9.99 }; + +describe('roadtripViaSchema', () => { + it('takes a stored via, with created_at optional', () => { + const row = { id: 1, day_id: 4, after_order_index: 2, sequence: 0, lat: 53.55, lng: 9.99 }; + expect(roadtripViaSchema.safeParse(row).success).toBe(true); + expect(roadtripViaSchema.safeParse({ ...row, created_at: '2026-01-01T00:00:00.000Z' }).success).toBe(true); + }); + + it('refuses coordinates no routing engine would accept', () => { + const row = { id: 1, day_id: 4, after_order_index: 0, sequence: 0, lat: 53.55, lng: 9.99 }; + expect(roadtripViaSchema.safeParse({ ...row, lat: 91 }).success).toBe(false); + expect(roadtripViaSchema.safeParse({ ...row, lat: -91 }).success).toBe(false); + expect(roadtripViaSchema.safeParse({ ...row, lng: 181 }).success).toBe(false); + expect(roadtripViaSchema.safeParse({ ...row, lng: -181 }).success).toBe(false); + }); + + it('refuses an anchor that is not a whole position', () => { + // It counts stops, so a fraction or a negative is not a leg of anything. + const row = { id: 1, day_id: 4, sequence: 0, lat: 53.55, lng: 9.99 }; + expect(roadtripViaSchema.safeParse({ ...row, after_order_index: -1 }).success).toBe(false); + expect(roadtripViaSchema.safeParse({ ...row, after_order_index: 1.5 }).success).toBe(false); + expect(roadtripViaSchema.safeParse({ ...row, after_order_index: 0 }).success).toBe(true); + }); +}); + +describe('roadtripViaCreateRequestSchema', () => { + it('needs an anchor and a point, and appends when no sequence is given', () => { + expect(roadtripViaCreateRequestSchema.safeParse(via).success).toBe(true); + expect(roadtripViaCreateRequestSchema.safeParse({ ...via, sequence: 3 }).success).toBe(true); + expect(roadtripViaCreateRequestSchema.safeParse({ lat: 53.55, lng: 9.99 }).success).toBe(false); + expect(roadtripViaCreateRequestSchema.safeParse({ ...via, sequence: -1 }).success).toBe(false); + }); +}); + +describe('roadtripViaUpdateRequestSchema', () => { + it('is a point and nothing else, because moving one is the whole edit', () => { + expect(roadtripViaUpdateRequestSchema.safeParse({ lat: 53.5, lng: 9.9 }).success).toBe(true); + expect(roadtripViaUpdateRequestSchema.safeParse({ lat: 53.5 }).success).toBe(false); + expect(roadtripViaUpdateRequestSchema.safeParse({ lat: 100, lng: 9.9 }).success).toBe(false); + }); +}); + +describe('roadtripViaReanchorRequestSchema', () => { + it('carries the corrected anchors, and the vias whose leg stopped existing', () => { + expect(roadtripViaReanchorRequestSchema.safeParse({ vias: [] }).success).toBe(true); + expect( + roadtripViaReanchorRequestSchema.safeParse({ + vias: [ + { id: 1, after_order_index: 0 }, + { id: 2, after_order_index: 3 }, + ], + remove: [9], + }).success, + ).toBe(true); + }); + + it('refuses an id that could not be a row', () => { + expect(roadtripViaReanchorRequestSchema.safeParse({ vias: [{ id: 0, after_order_index: 0 }] }).success).toBe(false); + expect(roadtripViaReanchorRequestSchema.safeParse({ vias: [], remove: [0] }).success).toBe(false); + }); + + it('caps both lists, so one request cannot be made to rewrite a whole trip', () => { + const many = Array.from({ length: 501 }, (_, i) => ({ id: i + 1, after_order_index: 0 })); + expect(roadtripViaReanchorRequestSchema.safeParse({ vias: many }).success).toBe(false); + expect(roadtripViaReanchorRequestSchema.safeParse({ vias: many.slice(0, 500) }).success).toBe(true); + }); +}); + +describe('roadtripViaBatchRequestSchema', () => { + it('lays a chain, optionally clearing the legs it fills', () => { + expect(roadtripViaBatchRequestSchema.safeParse({ vias: [via] }).success).toBe(true); + expect(roadtripViaBatchRequestSchema.safeParse({ vias: [via], replace_legs: [0, 2] }).success).toBe(true); + }); + + it('names legs rather than taking a boolean for the day', () => { + // A traveller who bent two legs by hand and then adopts a scenic road for a third + // has not asked to lose the two. + expect(roadtripViaBatchRequestSchema.safeParse({ vias: [], replace_legs: true }).success).toBe(false); + expect(roadtripViaBatchRequestSchema.safeParse({ vias: [], replace_legs: [-1] }).success).toBe(false); + }); + + it('keeps the track in three states, because absent and null differ', () => { + // Absent leaves whatever the day followed alone; null says it follows nothing any + // more; an object records the road it now takes. + expect(roadtripViaBatchRequestSchema.safeParse({ vias: [via] }).success).toBe(true); + expect(roadtripViaBatchRequestSchema.safeParse({ vias: [via], track: null }).success).toBe(true); + expect(roadtripViaBatchRequestSchema.safeParse({ vias: [via], track: { place_id: 7 } }).success).toBe(true); + expect( + roadtripViaBatchRequestSchema.safeParse({ + vias: [via], + track: { place_id: 7, stray_km: 1.4 }, + }).success, + ).toBe(true); + expect( + roadtripViaBatchRequestSchema.safeParse({ + vias: [via], + track: { place_id: 7, stray_km: null }, + }).success, + ).toBe(true); + expect(roadtripViaBatchRequestSchema.safeParse({ vias: [via], track: { place_id: 0 } }).success).toBe(false); + expect( + roadtripViaBatchRequestSchema.safeParse({ + vias: [via], + track: { place_id: 7, stray_km: -1 }, + }).success, + ).toBe(false); + }); + + it('caps the chain at a hundred points', () => { + const many = Array.from({ length: 101 }, () => via); + expect(roadtripViaBatchRequestSchema.safeParse({ vias: many }).success).toBe(false); + expect(roadtripViaBatchRequestSchema.safeParse({ vias: many.slice(0, 100) }).success).toBe(true); + }); +}); + +describe('roadtripDayTrackSchema and the list response', () => { + it('a day track names its line and how closely the drive followed it', () => { + expect(roadtripDayTrackSchema.safeParse({ day_id: 4, place_id: 7, stray_km: 1.2 }).success).toBe(true); + // Null is a real answer: the fit has not been measured. + expect(roadtripDayTrackSchema.safeParse({ day_id: 4, place_id: 7, stray_km: null }).success).toBe(true); + expect(roadtripDayTrackSchema.safeParse({ day_id: 4, place_id: 7 }).success).toBe(false); + }); + + it('the list answers with the vias and the tracks together, in one round trip', () => { + const parsed = roadtripViaListResponseSchema.safeParse({ + vias: [{ id: 1, day_id: 4, after_order_index: 0, sequence: 0, lat: 53.55, lng: 9.99 }], + tracks: [{ day_id: 4, place_id: 7, stray_km: 0.8 }], + }); + expect(parsed.success).toBe(true); + expect(roadtripViaListResponseSchema.safeParse({ vias: [] }).success).toBe(false); + }); +});