fix: count-to-end events break link chain

This commit is contained in:
Carlos Valente
2026-06-20 10:16:25 +02:00
parent daa032e92c
commit 0ff226f0ec
12 changed files with 479 additions and 89 deletions
@@ -1,7 +1,7 @@
import { Day, OffsetMode } from 'ontime-types';
import { MILLIS_PER_HOUR, dayInMs } from './conversionUtils';
import { getExpectedStart } from './getExpectedStart';
import { getExpectedEnd, getExpectedStart } from './getExpected';
describe('getExpectedStart()', () => {
describe('Absolute offset mode', () => {
@@ -315,3 +315,124 @@ describe('getExpectedStart()', () => {
expect(getExpectedStart(testEvent, { ...testState, currentDay: 0 })).toBe(23 * MILLIS_PER_HOUR + 5);
});
});
describe('getExpectedEnd()', () => {
const baseState = {
currentDay: 0,
totalGap: 0,
mode: OffsetMode.Absolute,
actualStart: null,
plannedStart: null,
isLinkedToLoaded: true,
};
test('a regular event ends at its expected start plus duration', () => {
const testEvent = {
timeStart: 100,
duration: 50,
delay: 0,
dayOffset: 0 as Day,
countToEnd: false,
};
// on schedule
expect(getExpectedEnd(testEvent, { ...baseState, offset: 0 })).toBe(150);
// running 20 behind pushes the end out
expect(getExpectedEnd(testEvent, { ...baseState, offset: 20 })).toBe(170);
});
test('a countToEnd event pins to the planned end while in overtime', () => {
const testEvent = {
timeStart: 100,
duration: 50,
delay: 0,
dayOffset: 0 as Day,
countToEnd: true,
};
// overtime would otherwise push the end to 170, but countToEnd absorbs it and pins to 150
expect(getExpectedEnd(testEvent, { ...baseState, offset: 20 })).toBe(150);
});
test('an overnight countToEnd event returns a normalised end', () => {
// event starts at 23:00 and counts to 01:00 the next day -> duration spans midnight
const timeStart = 23 * MILLIS_PER_HOUR;
const duration = 2 * MILLIS_PER_HOUR;
const testEvent = {
timeStart,
duration,
delay: 0,
dayOffset: 0 as Day,
countToEnd: true,
};
expect(getExpectedEnd(testEvent, { ...baseState, offset: 0 })).toBe(timeStart + duration);
});
test('a countToEnd event ignores upstream delays and stays pinned to its fixed end', () => {
const testEvent = {
timeStart: 100,
duration: 50,
delay: 20,
dayOffset: 0 as Day,
};
// events shift their schedule based on delay...
expect(getExpectedEnd({ ...testEvent, countToEnd: false }, { ...baseState, offset: 0 })).toBe(170);
// ... but count to end events stay pinned to the scheduled end
expect(getExpectedEnd({ ...testEvent, countToEnd: true }, { ...baseState, offset: 0 })).toBe(150);
});
test('a countToEnd event drifts when a delay pushes its start past the fixed end', () => {
const testEvent = {
timeStart: 100,
duration: 50,
delay: 60,
dayOffset: 0 as Day,
countToEnd: true,
};
// the delayed start (160) is past the fixed end (150), so the event can no longer
// finish on time and the end follows the compromised start
expect(getExpectedEnd(testEvent, { ...baseState, offset: 0 })).toBe(160);
});
test('a countToEnd event on a later day keeps the day offset on the end', () => {
const testEvent = {
timeStart: 100,
duration: 50,
delay: 0,
dayOffset: 1 as Day,
countToEnd: true,
};
// the scheduled end must include the day offset (timeStart + dayInMs + duration),
// not collapse to the day-shifted start
expect(getExpectedEnd(testEvent, { ...baseState, currentDay: 0, offset: 0 })).toBe(150 + dayInMs);
// when the running event is already on the same day, no extra day is added
expect(getExpectedEnd({ ...testEvent, dayOffset: 0 as Day }, { ...baseState, currentDay: 0, offset: 0 })).toBe(150);
});
test('a countToEnd event is anchored to its wall-clock end in relative mode', () => {
const testEvent = {
timeStart: 100,
duration: 50,
delay: 0,
dayOffset: 0 as Day,
countToEnd: true,
};
const relativeState = {
...baseState,
mode: OffsetMode.Relative,
actualStart: 30,
plannedStart: 0,
offset: 0,
};
// a regular event in the same state is shifted by the relative-start offset to 180
expect(getExpectedEnd({ ...testEvent, countToEnd: false }, relativeState)).toBe(180);
// the countToEnd event stays pinned to its wall-clock end (150), not shifted
expect(getExpectedEnd(testEvent, relativeState)).toBe(150);
});
});
@@ -4,25 +4,21 @@ import { OffsetMode } from 'ontime-types';
import { dayInMs } from './conversionUtils.js';
/**
* @param event the event that we are counting to
* @param currentDay the day offset of the currently running event
* @param totalGap accumulated gap from the current event
* @param isLinkedToLoaded is this event part of a chain linking back to the current loaded event
* @param clock
* @param offset
* @returns
* Runtime context shared by the expected start/end calculations
*/
type ExpectedTimesState = {
currentDay: number; // the current day from the rundown
totalGap: number; // accumulated gap from the current event
isLinkedToLoaded: boolean; // is this event part of a chain linking back to the loaded event
offset: number;
mode: OffsetMode;
actualStart: MaybeNumber;
plannedStart: MaybeNumber;
};
export function getExpectedStart(
event: Pick<OntimeEvent, 'timeStart' | 'dayOffset' | 'delay'>,
state: {
currentDay: number; // the current day from the rundown
totalGap: number;
isLinkedToLoaded: boolean;
offset: number;
mode: OffsetMode;
actualStart: MaybeNumber;
plannedStart: MaybeNumber;
},
state: ExpectedTimesState,
): number {
const { timeStart, dayOffset, delay } = event;
const { currentDay, totalGap, isLinkedToLoaded, offset, mode, actualStart, plannedStart } = state;
@@ -60,3 +56,27 @@ export function getExpectedStart(
const offsetStartTimeBufferedByGaps = offsetStartTime - totalGap;
return offsetStartTimeBufferedByGaps;
}
export function getExpectedEnd(
event: Pick<OntimeEvent, 'timeStart' | 'dayOffset' | 'delay' | 'duration' | 'countToEnd'>,
state: ExpectedTimesState,
): number {
// expected start includes the delay and any offset compensation
const expectedStart = getExpectedStart(event, state);
/**
* Count to end events are a special case
* - the end time is always the wall clock
*/
if (event.countToEnd) {
// account for day offset
const relativeDayOffset = event.dayOffset - state.currentDay;
const plannedEnd = event.timeStart + event.duration + relativeDayOffset * dayInMs;
// count to end should finish on the planned time or on start
return Math.max(expectedStart, plannedEnd);
}
// for normal events, the expected end is when we would start + its duration
return expectedStart + event.duration;
}