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
119 changes: 100 additions & 19 deletions src/transfer-risk/__tests__/transfer-risk.test.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,25 @@
import {
getTransferRisk,
getLegTransferRisk,
getTripTransferRisk,
withTransferRisk,
isTransitLeg,
UNLIKELY_TRANSFER_LIMIT_IN_SECONDS,
TransferRisk,
type TransferLeg,
} from '..';

const transitLeg = (overrides: Partial<TransferLeg> = {}): TransferLeg => ({
/** A leg that can carry a stamped risk, as every real consumer's leg can. */
type TestLeg = TransferLeg & {transferRisk?: TransferRisk};

const transitLeg = (overrides: Partial<TestLeg> = {}): TestLeg => ({
aimedStartTime: '2024-01-01T10:00:00.000Z',
expectedStartTime: '2024-01-01T10:00:00.000Z',
expectedEndTime: '2024-01-01T10:10:00.000Z',
serviceJourney: {id: 'ATB:ServiceJourney:1'},
...overrides,
});

const footLeg = (overrides: Partial<TransferLeg> = {}): TransferLeg => ({
const footLeg = (overrides: Partial<TestLeg> = {}): TestLeg => ({
aimedStartTime: '2024-01-01T10:10:00.000Z',
expectedStartTime: '2024-01-01T10:10:00.000Z',
expectedEndTime: '2024-01-01T10:15:00.000Z',
Expand All @@ -32,18 +37,10 @@ describe('getTransferRisk', () => {
expect(getTransferRisk(0)).toBe('uncertain');
});

it('is uncertain down to the unlikely limit', () => {
it('is uncertain at any negative gap, however large', () => {
expect(getTransferRisk(-1)).toBe('uncertain');
expect(getTransferRisk(-60)).toBe('uncertain');
expect(getTransferRisk(UNLIKELY_TRANSFER_LIMIT_IN_SECONDS)).toBe(
'uncertain',
);
});

it('is unlikely past the limit', () => {
expect(getTransferRisk(UNLIKELY_TRANSFER_LIMIT_IN_SECONDS - 1)).toBe(
'unlikely',
);
expect(getTransferRisk(-600)).toBe('unlikely');
expect(getTransferRisk(-600)).toBe('uncertain');
});

it('passes when the gap is not a finite number', () => {
Expand All @@ -68,12 +65,12 @@ describe('getLegTransferRisk', () => {
expect(getLegTransferRisk(legs, 1)).toBe('uncertain');
});

it('reports unlikely once the gap is past the limit', () => {
it('stays uncertain on a badly missed transfer', () => {
const legs = [
transitLeg({expectedEndTime: '2024-01-01T10:10:00.000Z'}),
transitLeg({expectedStartTime: '2024-01-01T10:05:00.000Z'}),
];
expect(getLegTransferRisk(legs, 1)).toBe('unlikely');
expect(getLegTransferRisk(legs, 1)).toBe('uncertain');
});

it('passes when there is time to spare', () => {
Expand Down Expand Up @@ -117,7 +114,7 @@ describe('getLegTransferRisk', () => {
}),
transitLeg({expectedStartTime: '2024-01-01T10:11:00.000Z'}),
];
expect(getLegTransferRisk(legs, 2)).toBe('unlikely');
expect(getLegTransferRisk(legs, 2)).toBe('uncertain');
});

it('passes when the gap is unparseable rather than inventing a risk', () => {
Expand Down Expand Up @@ -207,7 +204,7 @@ describe('getLegTransferRisk', () => {
}),
];
// Held until 10:13, but we do not arrive until 10:20.
expect(getLegTransferRisk(legs, 1)).toBe('unlikely');
expect(getLegTransferRisk(legs, 1)).toBe('uncertain');
});

it('counts an intervening walk against the maximum wait time', () => {
Expand All @@ -226,7 +223,7 @@ describe('getLegTransferRisk', () => {
}),
];
// Held until 10:13, but the walk does not end until 10:15.
expect(getLegTransferRisk(legs, 2)).toBe('unlikely');
expect(getLegTransferRisk(legs, 2)).toBe('uncertain');
});

it('keeps the guarantee when the deadline is unparseable', () => {
Expand All @@ -244,3 +241,87 @@ describe('getLegTransferRisk', () => {
});
});
});

describe('withTransferRisk', () => {
it('stamps the leg you might miss, not the one before', () => {
const legs = withTransferRisk([
transitLeg({expectedEndTime: '2024-01-01T10:10:00.000Z'}),
transitLeg({expectedStartTime: '2024-01-01T10:09:00.000Z'}),
]);
expect(legs[0].transferRisk).toBeUndefined();
expect(legs[1].transferRisk).toBe('uncertain');
});

it('leaves a comfortable transfer unstamped', () => {
const legs = withTransferRisk([
transitLeg({expectedEndTime: '2024-01-01T10:10:00.000Z'}),
transitLeg({expectedStartTime: '2024-01-01T10:15:00.000Z'}),
]);
expect(legs.every((leg) => leg.transferRisk === undefined)).toBe(true);
});

it('clears a risk the caller passed back in, once the gap is fine', () => {
const legs = withTransferRisk([
transitLeg({expectedEndTime: '2024-01-01T10:10:00.000Z'}),
transitLeg({
expectedStartTime: '2024-01-01T10:15:00.000Z',
transferRisk: TransferRisk.Uncertain,
}),
]);
expect(legs[1].transferRisk).toBeUndefined();
});

it('does not stamp a guaranteed transfer', () => {
const legs = withTransferRisk([
transitLeg({
expectedEndTime: '2024-01-01T10:10:00.000Z',
interchangeTo: {guaranteed: true},
}),
transitLeg({expectedStartTime: '2024-01-01T10:00:00.000Z'}),
]);
expect(legs[1].transferRisk).toBeUndefined();
});
});

describe('getTripTransferRisk', () => {
it('passes when every transfer has time to spare', () => {
const legs = [
transitLeg({expectedEndTime: '2024-01-01T10:10:00.000Z'}),
transitLeg({expectedStartTime: '2024-01-01T10:15:00.000Z'}),
];
expect(getTripTransferRisk(legs)).toBeUndefined();
});

it('reports a risk from anywhere in the trip', () => {
const legs = [
transitLeg({expectedEndTime: '2024-01-01T10:10:00.000Z'}),
transitLeg({
expectedStartTime: '2024-01-01T10:15:00.000Z',
expectedEndTime: '2024-01-01T10:25:00.000Z',
}),
transitLeg({expectedStartTime: '2024-01-01T10:24:00.000Z'}),
];
expect(getTripTransferRisk(legs)).toBe('uncertain');
});

it('ignores a guaranteed transfer when looking across the trip', () => {
const legs = [
transitLeg({
expectedEndTime: '2024-01-01T10:10:00.000Z',
interchangeTo: {guaranteed: true},
}),
transitLeg({expectedStartTime: '2024-01-01T10:00:00.000Z'}),
];
expect(getTripTransferRisk(legs)).toBeUndefined();
});

it('does not need the legs to be stamped first', () => {
const legs = [
transitLeg({expectedEndTime: '2024-01-01T10:10:00.000Z'}),
transitLeg({expectedStartTime: '2024-01-01T10:09:00.000Z'}),
];
expect(getTripTransferRisk(legs)).toBe(
getTripTransferRisk(withTransferRisk(legs)),
);
});
});
5 changes: 3 additions & 2 deletions src/transfer-risk/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
export {
getTransferRisk,
getLegTransferRisk,
getTripTransferRisk,
withTransferRisk,
isTransitLeg,
UNLIKELY_TRANSFER_LIMIT_IN_SECONDS,
} from './transfer-risk';
// Exports both the value (TransferRisk.Unlikely) and the type.
// Exports both the value (TransferRisk.Uncertain) and the type.
export {TransferRisk} from './types';
export type {TransferLeg} from './types';
53 changes: 45 additions & 8 deletions src/transfer-risk/transfer-risk.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,17 @@
import type {TransferLeg} from './types';
import {TransferRisk} from './types';

/** Below this, the transfer is not one to count on. */
export const UNLIKELY_TRANSFER_LIMIT_IN_SECONDS = -120;

/**
* Classifies the gap between arriving and the next departure. Zero counts as
* uncertain; a non-finite gap yields undefined.
* Classifies the gap between arriving and the next departure: any gap that is
* not positive is uncertain, however large. Zero counts, because arriving
* exactly as the service leaves is not a transfer you can rely on. A
* non-finite gap yields undefined.
*/
export const getTransferRisk = (seconds: number): TransferRisk | undefined => {
if (!Number.isFinite(seconds) || seconds > 0) {
return undefined;
}
return seconds < UNLIKELY_TRANSFER_LIMIT_IN_SECONDS
? TransferRisk.Unlikely
: TransferRisk.Uncertain;
return TransferRisk.Uncertain;
};

/** Whether a leg is scheduled transit rather than walking, cycling and such. */
Expand Down Expand Up @@ -48,6 +45,46 @@ export const getLegTransferRisk = (
);
};

/**
* Stamps `transferRisk` on each transit leg the trip is at risk of missing.
*
* The risk sits on the boarding leg rather than the leg before the gap:
* clients filter insignificant foot legs out of the display but never transit
* legs, so a warning here cannot be filtered away.
*
* Always overwrites, including with `undefined`. Clients round-trip the whole
* trip pattern back to the server, so a leg that fails to refresh arrives
* carrying the risk from an earlier response; leaving it in place would keep a
* warning on screen after the delay behind it had cleared.
*/
export const withTransferRisk = <
T extends TransferLeg & {transferRisk?: TransferRisk},
>(
legs: T[],
): T[] =>
legs.map((leg, index) => ({
...leg,
transferRisk: getLegTransferRisk(legs, index),
}));

/**
* The worst transfer risk across a trip, for a trip-level field. Computed from
* the legs rather than read off `transferRisk`, so it does not depend on
* `withTransferRisk` having run first.
*
* There is one level today, so the first risky transfer is the worst — add a
* severity comparison here if a second level is introduced.
*/
export const getTripTransferRisk = (
legs: TransferLeg[],
): TransferRisk | undefined => {
for (let index = 0; index < legs.length; index++) {
const risk = getLegTransferRisk(legs, index);
if (risk) return risk;
}
return undefined;
};

/**
* The transit leg you alight from, which carries the interchange. Walks back
* past non-transit legs: bus -> walk -> bus is measured on the (walk, bus)
Expand Down
7 changes: 5 additions & 2 deletions src/transfer-risk/types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
/** How risky a transfer is when there is no time to spare. */
/**
* Transfer risk classification for transfers with no slack (0s) or already
* missed (negative gaps). Kept as a string (vs boolean) because additional
* levels (e.g. `shortWait`) may be added later.
*/
Comment thread
Copilot marked this conversation as resolved.
export const TransferRisk = {
Uncertain: 'uncertain',
Unlikely: 'unlikely',
} as const;

export type TransferRisk = (typeof TransferRisk)[keyof typeof TransferRisk];
Expand Down