diff --git a/src/service/impl/trips/__tests__/utils.test.ts b/src/service/impl/trips/__tests__/utils.test.ts index 8855d9ed..4d6e834a 100644 --- a/src/service/impl/trips/__tests__/utils.test.ts +++ b/src/service/impl/trips/__tests__/utils.test.ts @@ -115,6 +115,131 @@ describe('hasTemporalOverlap', () => { }); }); +describe('hasTemporalOverlap with interchanges', () => { + it('ignores an overlap at a guaranteed interchange', () => { + const legs = [ + makeTransitLeg({ + expectedEndTime: '2024-01-01T10:10:00.000Z', + interchangeTo: {guaranteed: true}, + }), + makeTransitLeg({expectedStartTime: '2024-01-01T10:09:00.000Z'}), + ]; + expect(hasTemporalOverlap(legs)).toBe(false); + }); + + it('returns true when the interchange is explicitly not guaranteed', () => { + const legs = [ + makeTransitLeg({ + expectedEndTime: '2024-01-01T10:10:00.000Z', + interchangeTo: {guaranteed: false}, + }), + makeTransitLeg({expectedStartTime: '2024-01-01T10:09:00.000Z'}), + ]; + expect(hasTemporalOverlap(legs)).toBe(true); + }); + + it('returns true when there is no interchange at all', () => { + const legs = [ + makeTransitLeg({expectedEndTime: '2024-01-01T10:10:00.000Z'}), + makeTransitLeg({expectedStartTime: '2024-01-01T10:09:00.000Z'}), + ]; + expect(hasTemporalOverlap(legs)).toBe(true); + }); + + it('ignores a guaranteed interchange reached through a walk', () => { + const legs = [ + makeTransitLeg({ + expectedEndTime: '2024-01-01T10:10:00.000Z', + interchangeTo: {guaranteed: true}, + }), + makeFootLeg({ + expectedStartTime: '2024-01-01T10:10:00.000Z', + expectedEndTime: '2024-01-01T10:15:00.000Z', + }), + makeTransitLeg({expectedStartTime: '2024-01-01T10:13:00.000Z'}), + ]; + expect(hasTemporalOverlap(legs)).toBe(false); + }); + + it('ignores an overlap within the interchange maximum wait time', () => { + const legs = [ + makeTransitLeg({ + expectedEndTime: '2024-01-01T10:10:00.000Z', + interchangeTo: {guaranteed: true, maximumWaitTime: 300}, + }), + makeTransitLeg({ + aimedStartTime: '2024-01-01T10:08:00.000Z', + expectedStartTime: '2024-01-01T10:08:00.000Z', + }), + ]; + // Held until 10:08 + 5 min = 10:13, and we arrive at 10:10. + expect(hasTemporalOverlap(legs)).toBe(false); + }); + + it('treats arrival exactly at the maximum wait deadline as caught', () => { + const legs = [ + makeTransitLeg({ + expectedEndTime: '2024-01-01T10:13:00.000Z', + interchangeTo: {guaranteed: true, maximumWaitTime: 300}, + }), + makeTransitLeg({ + aimedStartTime: '2024-01-01T10:08:00.000Z', + expectedStartTime: '2024-01-01T10:08:00.000Z', + }), + ]; + expect(hasTemporalOverlap(legs)).toBe(false); + }); + + it('returns true once arrival is past the maximum wait time', () => { + const legs = [ + makeTransitLeg({ + expectedEndTime: '2024-01-01T10:20:00.000Z', + interchangeTo: {guaranteed: true, maximumWaitTime: 300}, + }), + makeTransitLeg({ + aimedStartTime: '2024-01-01T10:08:00.000Z', + expectedStartTime: '2024-01-01T10:08:00.000Z', + }), + ]; + // Held until 10:13, but we do not arrive until 10:20. + expect(hasTemporalOverlap(legs)).toBe(true); + }); + + it('counts an intervening walk against the maximum wait time', () => { + const legs = [ + makeTransitLeg({ + expectedEndTime: '2024-01-01T10:10:00.000Z', + interchangeTo: {guaranteed: true, maximumWaitTime: 120}, + }), + makeFootLeg({ + expectedStartTime: '2024-01-01T10:10:00.000Z', + expectedEndTime: '2024-01-01T10:15:00.000Z', + }), + makeTransitLeg({ + aimedStartTime: '2024-01-01T10:11:00.000Z', + expectedStartTime: '2024-01-01T10:11:00.000Z', + }), + ]; + // Held until 10:13, but the walk does not end until 10:15. + expect(hasTemporalOverlap(legs)).toBe(true); + }); + + it('still catches a later overlap at an unguaranteed interchange', () => { + const legs = [ + makeTransitLeg({ + expectedEndTime: '2024-01-01T10:10:00.000Z', + interchangeTo: {guaranteed: true}, + }), + makeTransitLeg({ + expectedStartTime: '2024-01-01T10:09:00.000Z', + expectedEndTime: '2024-01-01T10:20:00.000Z', + }), + makeTransitLeg({expectedStartTime: '2024-01-01T10:19:00.000Z'}), + ]; + expect(hasTemporalOverlap(legs)).toBe(true); + }); +}); + describe('computeTripAimedStartEnd', () => { it('returns aimed times from first and last leg when all are transit', () => { const legs: Leg[] = [ @@ -261,6 +386,22 @@ describe('adjustNonTransitExpectedTimes', () => { }); describe('determineTripStatus', () => { + it('returns valid when the only overlap is at a guaranteed interchange', () => { + const now = new Date().toISOString(); + const legs = [ + makeTransitLeg({ + expectedEndTime: '2024-01-01T10:10:00.000Z', + interchangeTo: {guaranteed: true}, + refreshedAt: now, + }), + makeTransitLeg({ + expectedStartTime: '2024-01-01T10:09:00.000Z', + refreshedAt: now, + }), + ]; + expect(determineTripStatus(legs)).toBe('valid'); + }); + it('returns valid when legs are sequential', () => { const now = new Date().toISOString(); const legs: Leg[] = [ diff --git a/src/service/impl/trips/utils.ts b/src/service/impl/trips/utils.ts index fa9e35fa..77b40ece 100644 --- a/src/service/impl/trips/utils.ts +++ b/src/service/impl/trips/utils.ts @@ -109,18 +109,63 @@ export function isTransitLeg(leg: Leg): boolean { /** * Checks if any leg N+1's expectedStartTime is before leg N's expectedEndTime, * indicating a missed connection (impossible trip). + * + * Overlaps at a guaranteed interchange are ignored: the connecting service has + * committed to waiting for the delayed one, so a negative gap there is not a + * missed connection. */ export function hasTemporalOverlap(legs: Leg[]): boolean { for (let i = 1; i < legs.length; i++) { const prev = legs[i - 1]; const curr = legs[i]; if (parseISO(curr.expectedStartTime) < parseISO(prev.expectedEndTime)) { + if (interchangeHolds(legs, i)) continue; return true; } } return false; } +/** + * Whether the interchange into the leg at `index` still guarantees the + * connection, given when you actually arrive. + * + * A guarantee is bounded by `maximumWaitTime`: the connecting service holds + * for that many seconds past its own scheduled departure, and arriving later + * means missing it despite the guarantee. An absent `maximumWaitTime` means it + * waits however long it takes. Arrival is the end of the leg immediately + * before, so an intervening walk counts against the deadline. + */ +function interchangeHolds(legs: Leg[], index: number): boolean { + const interchange = previousTransitLeg(legs, index)?.interchangeTo; + if (interchange?.guaranteed !== true) return false; + if (interchange.maximumWaitTime == null) return true; + + const deadline = addSeconds( + parseISO(legs[index].aimedStartTime), + interchange.maximumWaitTime, + ); + const arrival = parseISO(legs[index - 1].expectedEndTime); + // Unparseable times keep the guarantee, so bad data suppresses a warning + // rather than inventing one. + if (isNaN(deadline.getTime()) || isNaN(arrival.getTime())) return true; + + return arrival <= deadline; +} + +/** + * The transit leg you alight from, which is the one carrying the interchange + * relationship. Walks back past non-transit legs: a bus -> walk -> bus + * transfer overlaps on the (walk, bus) pair, but it is the first bus that + * holds `interchangeTo`. + */ +function previousTransitLeg(legs: Leg[], index: number): Leg | undefined { + for (let i = index - 1; i >= 0; i--) { + if (isTransitLeg(legs[i])) return legs[i]; + } + return undefined; +} + /** * Computes the trip-level aimedStartTime and aimedEndTime. *