Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions src/service/impl/trips/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
Expand Down Expand Up @@ -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[] = [
Expand Down
45 changes: 45 additions & 0 deletions src/service/impl/trips/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines +139 to +142

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.
*
Expand Down
Loading