mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-20 14:39:06 +00:00
fix: issue where a countToEnd would lead to incorrect expected times (#2149)
* fix: issue where a count-to-end would lead to incorrect expected times * fix: include add time in overtime when countToEnd * fix: ui and server use same calculation for expected end
This commit is contained in:
committed by
GitHub
parent
a8c611911d
commit
2a890cf2b3
@@ -5,6 +5,7 @@ import {
|
|||||||
MILLIS_PER_SECOND,
|
MILLIS_PER_SECOND,
|
||||||
dayInMs,
|
dayInMs,
|
||||||
formatFromMillis,
|
formatFromMillis,
|
||||||
|
getExpectedEnd,
|
||||||
getExpectedStart,
|
getExpectedStart,
|
||||||
} from 'ontime-utils';
|
} from 'ontime-utils';
|
||||||
|
|
||||||
@@ -186,15 +187,13 @@ export function getExpectedTimesFromExtendedEvent(
|
|||||||
...state,
|
...state,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
const expectedEnd = getExpectedEnd(event, expectedStart, state.currentDay);
|
||||||
const plannedEnd = event.timeStart + event.duration + event.delay;
|
const plannedEnd = event.timeStart + event.duration + event.delay;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
expectedStart,
|
expectedStart,
|
||||||
timeToStart: expectedStart - state.clock,
|
timeToStart: expectedStart - state.clock,
|
||||||
expectedEnd: event.countToEnd
|
expectedEnd,
|
||||||
? Math.max(expectedStart + event.duration, plannedEnd)
|
|
||||||
: expectedStart + event.duration,
|
|
||||||
plannedEnd,
|
plannedEnd,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ type ScheduleTimeProps = {
|
|||||||
//TODO: consider relative mode
|
//TODO: consider relative mode
|
||||||
export function ScheduleTime(props: ScheduleTimeProps) {
|
export function ScheduleTime(props: ScheduleTimeProps) {
|
||||||
const { event, showExpected } = props;
|
const { event, showExpected } = props;
|
||||||
const { timeStart, duration, delay, expectedStart, countToEnd } = event;
|
const { timeStart, duration, delay, expectedStart, expectedEnd } = event;
|
||||||
|
|
||||||
const plannedStart = timeStart + delay + event.dayOffset * dayInMs;
|
const plannedStart = timeStart + delay + event.dayOffset * dayInMs;
|
||||||
|
|
||||||
@@ -164,7 +164,6 @@ export function ScheduleTime(props: ScheduleTimeProps) {
|
|||||||
|
|
||||||
const expectedStateClass = `sub__schedule--${getOffsetState(expectedStart - plannedStart)}`;
|
const expectedStateClass = `sub__schedule--${getOffsetState(expectedStart - plannedStart)}`;
|
||||||
const plannedEnd = plannedStart + duration + delay;
|
const plannedEnd = plannedStart + duration + delay;
|
||||||
const expectedEnd = countToEnd ? Math.max(expectedStart + duration, plannedEnd) : expectedStart + duration;
|
|
||||||
const expectedEndClass = `sub__schedule--${getOffsetState(expectedEnd - plannedEnd)}`;
|
const expectedEndClass = `sub__schedule--${getOffsetState(expectedEnd - plannedEnd)}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
import { MaybeNumber, OntimeEvent } from 'ontime-types';
|
import { getExpectedEnd, getExpectedStart } from 'ontime-utils';
|
||||||
import { getExpectedStart } from 'ontime-utils';
|
|
||||||
import { IoPencil } from 'react-icons/io5';
|
import { IoPencil } from 'react-icons/io5';
|
||||||
|
|
||||||
import Button from '../../common/components/buttons/Button';
|
import Button from '../../common/components/buttons/Button';
|
||||||
import useReport from '../../common/hooks-query/useReport';
|
import useReport from '../../common/hooks-query/useReport';
|
||||||
import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity';
|
import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity';
|
||||||
import { useExpectedStartData } from '../../common/hooks/useSocket';
|
import { useExpectedStartData } from '../../common/hooks/useSocket';
|
||||||
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
|
|
||||||
import { cx } from '../../common/utils/styleUtils';
|
import { cx } from '../../common/utils/styleUtils';
|
||||||
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
|
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
|
||||||
import { getPropertyValue } from '../common/viewUtils';
|
import { getPropertyValue } from '../common/viewUtils';
|
||||||
import { useCountdownOptions } from './countdown.options';
|
import { useCountdownOptions } from './countdown.options';
|
||||||
import { CountdownTarget, useSubscriptionDisplayData } from './countdown.utils';
|
import { CountdownEvent, CountdownTarget, useSubscriptionDisplayData } from './countdown.utils';
|
||||||
import { ScheduleTime } from './CountdownSubscriptions';
|
import { ScheduleTime } from './CountdownSubscriptions';
|
||||||
|
|
||||||
import './SingleEventCountdown.scss';
|
import './SingleEventCountdown.scss';
|
||||||
@@ -38,8 +36,10 @@ export default function SingleEventCountdown({ subscribedEvent, goToEditMode }:
|
|||||||
mode,
|
mode,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const expectedEnd = getExpectedEnd(subscribedEvent, expectedStart, currentDay);
|
||||||
|
|
||||||
const { endedAt } = reportData[subscribedEvent.reportId ?? subscribedEvent.id] ?? { endedAt: null };
|
const { endedAt } = reportData[subscribedEvent.reportId ?? subscribedEvent.id] ?? { endedAt: null };
|
||||||
const countdownEvent = { ...subscribedEvent, expectedStart, endedAt };
|
const countdownEvent = { ...subscribedEvent, expectedStart, endedAt, expectedEnd };
|
||||||
const titleTmp = getPropertyValue(subscribedEvent, mainSource ?? 'title');
|
const titleTmp = getPropertyValue(subscribedEvent, mainSource ?? 'title');
|
||||||
const title = titleTmp?.length ? titleTmp : ' '; // insert utf-8 empty space to avoid the line collapsing;
|
const title = titleTmp?.length ? titleTmp : ' '; // insert utf-8 empty space to avoid the line collapsing;
|
||||||
// while a group is live, surface the running event's title as the secondary line
|
// while a group is live, surface the running event's title as the secondary line
|
||||||
@@ -64,7 +64,7 @@ export default function SingleEventCountdown({ subscribedEvent, goToEditMode }:
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface SubscriptionStatusProps {
|
interface SubscriptionStatusProps {
|
||||||
event: ExtendedEntry<OntimeEvent> & { endedAt: MaybeNumber; expectedStart: number };
|
event: CountdownEvent;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SubscriptionStatus({ event }: SubscriptionStatusProps) {
|
function SubscriptionStatus({ event }: SubscriptionStatusProps) {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
isOntimeGroup,
|
isOntimeGroup,
|
||||||
isPlayableEvent,
|
isPlayableEvent,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import { MILLIS_PER_MINUTE, getExpectedStart, millisToString, removeLeadingZero } from 'ontime-utils';
|
import { MILLIS_PER_MINUTE, getExpectedEnd, getExpectedStart, millisToString, removeLeadingZero } from 'ontime-utils';
|
||||||
|
|
||||||
import { useCountdownSocket } from '../../common/hooks/useSocket';
|
import { useCountdownSocket } from '../../common/hooks/useSocket';
|
||||||
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
|
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
|
||||||
@@ -197,7 +197,7 @@ export type CountdownTarget = ExtendedEntry<OntimeEvent> & {
|
|||||||
liveEntry?: ExtendedEntry<OntimeEvent> | null; // the running child while a group is live
|
liveEntry?: ExtendedEntry<OntimeEvent> | null; // the running child while a group is live
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CountdownEvent = CountdownTarget & { expectedStart: number; endedAt: MaybeNumber };
|
export type CountdownEvent = CountdownTarget & { expectedStart: number; endedAt: MaybeNumber; expectedEnd: number };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves a subscription (event or group) into an event-shaped countdown target.
|
* Resolves a subscription (event or group) into an event-shaped countdown target.
|
||||||
@@ -271,6 +271,7 @@ export function extendEventData(
|
|||||||
offset,
|
offset,
|
||||||
mode,
|
mode,
|
||||||
});
|
});
|
||||||
|
const expectedEnd = getExpectedEnd(event, expectedStart, currentDay);
|
||||||
const { endedAt } = reportData[event.reportId ?? event.id] ?? { endedAt: null };
|
const { endedAt } = reportData[event.reportId ?? event.id] ?? { endedAt: null };
|
||||||
return { ...event, expectedStart, endedAt };
|
return { ...event, expectedStart, endedAt, expectedEnd };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1126,7 +1126,7 @@ describe('getRuntimeOffset()', () => {
|
|||||||
} as RuntimeState;
|
} as RuntimeState;
|
||||||
|
|
||||||
const { absolute } = getRuntimeOffset(state);
|
const { absolute } = getRuntimeOffset(state);
|
||||||
expect(absolute).toBe(400000); // <--- offset is always the overtime
|
expect(absolute).toBe(400000 - 200000); // <--- offset is always the overtime + added time
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles time-to-end started after the end time', () => {
|
it('handles time-to-end started after the end time', () => {
|
||||||
|
|||||||
@@ -180,13 +180,13 @@ export function getRuntimeOffset(state: RuntimeState): { absolute: number; relat
|
|||||||
const pausedTime = state._timer.pausedAt === null ? 0 : clock - state._timer.pausedAt;
|
const pausedTime = state._timer.pausedAt === null ? 0 : clock - state._timer.pausedAt;
|
||||||
|
|
||||||
// absolute offset is difference between schedule and playback time
|
// absolute offset is difference between schedule and playback time
|
||||||
const absolute = eventStartOffset + overtime + pausedTime + addedTime;
|
// in case of count to end, the absolute offset is overtime and added time
|
||||||
|
const absolute = countToEnd ? overtime + addedTime : eventStartOffset + overtime + pausedTime + addedTime;
|
||||||
|
|
||||||
// the relative offset is the same as the absolute but adjusted relative to the actual start time
|
// the relative offset is the same as the absolute but adjusted relative to the actual start time
|
||||||
const relative = absolute + plannedStart - actualStart - _startDayOffset * dayInMs;
|
const relative = absolute + plannedStart - actualStart - _startDayOffset * dayInMs;
|
||||||
|
|
||||||
// in case of count to end, the absolute offset is just the overtime
|
return { absolute, relative };
|
||||||
return countToEnd ? { absolute: overtime, relative } : { absolute, relative };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1036,4 +1036,167 @@ describe('loadGroupFlagAndEnd()', () => {
|
|||||||
eventNow: rundown.entries[0],
|
eventNow: rundown.entries[0],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('countToEnd entries decouple the link chain for subsequent events', () => {
|
||||||
|
// Event 0 (loaded): no countToEnd, linkStart=true
|
||||||
|
// Event 1: countToEnd=true → breaks the link chain
|
||||||
|
// Event 2: linkStart=true → unlinked (chain broken by event 1)
|
||||||
|
// Event 3: flag event → also unlinked
|
||||||
|
const rundown = makeRundown({
|
||||||
|
entries: {
|
||||||
|
group: makeOntimeGroup({ id: 'group', entries: ['0', '1', '2'] }),
|
||||||
|
0: makeOntimeEvent({
|
||||||
|
id: '0',
|
||||||
|
parent: 'group',
|
||||||
|
timeStart: 0,
|
||||||
|
duration: 3600000,
|
||||||
|
countToEnd: false,
|
||||||
|
linkStart: true,
|
||||||
|
gap: 0,
|
||||||
|
} as any),
|
||||||
|
1: makeOntimeEvent({
|
||||||
|
id: '1',
|
||||||
|
parent: 'group',
|
||||||
|
timeStart: 3600000,
|
||||||
|
duration: 3600000,
|
||||||
|
countToEnd: true,
|
||||||
|
linkStart: true,
|
||||||
|
gap: 0,
|
||||||
|
} as any),
|
||||||
|
2: makeOntimeEvent({
|
||||||
|
id: '2',
|
||||||
|
parent: 'group',
|
||||||
|
timeStart: 7200000,
|
||||||
|
duration: 3600000,
|
||||||
|
linkStart: true,
|
||||||
|
gap: 0,
|
||||||
|
} as any),
|
||||||
|
3: makeOntimeEvent({
|
||||||
|
id: '3',
|
||||||
|
parent: null,
|
||||||
|
timeStart: 10800000,
|
||||||
|
duration: 3600000,
|
||||||
|
linkStart: true,
|
||||||
|
gap: 0,
|
||||||
|
} as any),
|
||||||
|
},
|
||||||
|
order: ['group', '0', '1', '2', '3'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
groupNow: null,
|
||||||
|
eventNow: rundown.entries[0],
|
||||||
|
rundown: { actualGroupStart: null },
|
||||||
|
} as RuntimeState;
|
||||||
|
|
||||||
|
const metadata = { playableEventOrder: ['0', '1', '2', '3'], flags: ['3'] } as RundownMetadata;
|
||||||
|
|
||||||
|
loadGroupFlagAndEnd(rundown, metadata, 0, state);
|
||||||
|
|
||||||
|
// _group is the last event in the group (event 2)
|
||||||
|
// isLinkedToLoaded is false because event 1 (between loaded and group end) has countToEnd=true
|
||||||
|
// accumulatedGap includes event 1's duration carried forward to event 2
|
||||||
|
// (the countToEnd duration is applied to the next event, not the countToEnd event itself)
|
||||||
|
expect(state._group).toMatchObject({
|
||||||
|
event: rundown.entries[2],
|
||||||
|
isLinkedToLoaded: false,
|
||||||
|
accumulatedGap: 3600000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// _flag (event 3): isLinkedToLoaded is false because chain was broken by event 1
|
||||||
|
// accumulatedGap is still 3600000 because event 2 is not countToEnd,
|
||||||
|
// so previousWasCountToEnd is null and no further duration is carried forward
|
||||||
|
expect(state._flag).toMatchObject({
|
||||||
|
event: rundown.entries[3],
|
||||||
|
isLinkedToLoaded: false,
|
||||||
|
accumulatedGap: 3600000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// _end (event 3): also unlinked because the chain was broken at event 1
|
||||||
|
expect(state._end).toMatchObject({
|
||||||
|
event: rundown.entries[3],
|
||||||
|
isLinkedToLoaded: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('countToEnd in the middle of the chain breaks links for downstream events', () => {
|
||||||
|
// Event 0 (loaded): no countToEnd, linkStart=true
|
||||||
|
// Event 1: linkStart=true → linked to loaded
|
||||||
|
// Event 2: countToEnd=true → breaks the chain (also last in group)
|
||||||
|
// Event 3: linkStart=true → unlinked (chain broken by event 2)
|
||||||
|
const rundown = makeRundown({
|
||||||
|
entries: {
|
||||||
|
group: makeOntimeGroup({ id: 'group', entries: ['0', '1', '2'] }),
|
||||||
|
0: makeOntimeEvent({
|
||||||
|
id: '0',
|
||||||
|
parent: 'group',
|
||||||
|
timeStart: 0,
|
||||||
|
duration: 3600000,
|
||||||
|
countToEnd: false,
|
||||||
|
linkStart: true,
|
||||||
|
gap: 0,
|
||||||
|
} as any),
|
||||||
|
1: makeOntimeEvent({
|
||||||
|
id: '1',
|
||||||
|
parent: 'group',
|
||||||
|
timeStart: 3600000,
|
||||||
|
duration: 3600000,
|
||||||
|
linkStart: true,
|
||||||
|
gap: 0,
|
||||||
|
} as any),
|
||||||
|
2: makeOntimeEvent({
|
||||||
|
id: '2',
|
||||||
|
parent: 'group',
|
||||||
|
timeStart: 7200000,
|
||||||
|
duration: 3600000,
|
||||||
|
countToEnd: true,
|
||||||
|
linkStart: true,
|
||||||
|
gap: 0,
|
||||||
|
} as any),
|
||||||
|
3: makeOntimeEvent({
|
||||||
|
id: '3',
|
||||||
|
parent: null,
|
||||||
|
timeStart: 10800000,
|
||||||
|
duration: 3600000,
|
||||||
|
linkStart: true,
|
||||||
|
gap: 0,
|
||||||
|
} as any),
|
||||||
|
},
|
||||||
|
order: ['group', '0', '1', '2', '3'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
groupNow: null,
|
||||||
|
eventNow: rundown.entries[0],
|
||||||
|
rundown: { actualGroupStart: null },
|
||||||
|
} as RuntimeState;
|
||||||
|
|
||||||
|
const metadata = { playableEventOrder: ['0', '1', '2', '3'], flags: ['3'] } as RundownMetadata;
|
||||||
|
|
||||||
|
loadGroupFlagAndEnd(rundown, metadata, 0, state);
|
||||||
|
|
||||||
|
// _group is the last event in the group (event 2)
|
||||||
|
// isLinkedToLoaded is true because no preceding event had countToEnd
|
||||||
|
// accumulatedGap is 0 because no preceding event was countToEnd
|
||||||
|
// (the countToEnd duration is carried forward to the next event, not added to the countToEnd event itself)
|
||||||
|
expect(state._group).toMatchObject({
|
||||||
|
event: rundown.entries[2],
|
||||||
|
isLinkedToLoaded: true,
|
||||||
|
accumulatedGap: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// _flag (event 3): isLinkedToLoaded is false because chain was broken by count-to-end event 2
|
||||||
|
// accumulatedGap includes event 2's duration carried forward
|
||||||
|
expect(state._flag).toMatchObject({
|
||||||
|
event: rundown.entries[3],
|
||||||
|
isLinkedToLoaded: false,
|
||||||
|
accumulatedGap: 3600000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// _end (event 3): unlinked because event 2 has countToEnd=true
|
||||||
|
expect(state._end).toMatchObject({
|
||||||
|
event: rundown.entries[3],
|
||||||
|
isLinkedToLoaded: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
calculateDuration,
|
calculateDuration,
|
||||||
checkIsNow,
|
checkIsNow,
|
||||||
dayInMs,
|
dayInMs,
|
||||||
|
getExpectedEnd,
|
||||||
getExpectedStart,
|
getExpectedStart,
|
||||||
getLastEventNormal,
|
getLastEventNormal,
|
||||||
isPlaybackActive,
|
isPlaybackActive,
|
||||||
@@ -831,7 +832,6 @@ function getExpectedTimes(state = runtimeState) {
|
|||||||
state.offset.expectedRundownEnd = null;
|
state.offset.expectedRundownEnd = null;
|
||||||
state.offset.expectedGroupEnd = null;
|
state.offset.expectedGroupEnd = null;
|
||||||
state.offset.expectedFlagStart = null;
|
state.offset.expectedFlagStart = null;
|
||||||
state.offset.expectedRundownEnd = null;
|
|
||||||
|
|
||||||
const { offset } = state;
|
const { offset } = state;
|
||||||
const { plannedStart, actualStart } = state.rundown;
|
const { plannedStart, actualStart } = state.rundown;
|
||||||
@@ -852,7 +852,7 @@ function getExpectedTimes(state = runtimeState) {
|
|||||||
plannedStart,
|
plannedStart,
|
||||||
actualStart,
|
actualStart,
|
||||||
});
|
});
|
||||||
state.offset.expectedGroupEnd = lastEventExpectedStart + lastEvent.duration;
|
state.offset.expectedGroupEnd = getExpectedEnd(lastEvent, lastEventExpectedStart, state.rundown.currentDay!);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -884,7 +884,7 @@ function getExpectedTimes(state = runtimeState) {
|
|||||||
plannedStart,
|
plannedStart,
|
||||||
actualStart,
|
actualStart,
|
||||||
});
|
});
|
||||||
state.offset.expectedRundownEnd = expectedStart + event.duration;
|
state.offset.expectedRundownEnd = getExpectedEnd(event, expectedStart, state.rundown.currentDay!);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -927,6 +927,7 @@ export function loadGroupFlagAndEnd(
|
|||||||
|
|
||||||
let accumulatedGap = 0;
|
let accumulatedGap = 0;
|
||||||
let isLinkedToLoaded = true;
|
let isLinkedToLoaded = true;
|
||||||
|
let previousWasCountToEnd: Maybe<number> = null;
|
||||||
|
|
||||||
for (let idx = currentIndex; idx < playableEventOrder.length; idx++) {
|
for (let idx = currentIndex; idx < playableEventOrder.length; idx++) {
|
||||||
const entry = entries[playableEventOrder[idx]];
|
const entry = entries[playableEventOrder[idx]];
|
||||||
@@ -934,8 +935,23 @@ export function loadGroupFlagAndEnd(
|
|||||||
if (isOntimeEvent(entry)) {
|
if (isOntimeEvent(entry)) {
|
||||||
if (idx !== currentIndex) {
|
if (idx !== currentIndex) {
|
||||||
// we only accumulate data after the loaded event
|
// we only accumulate data after the loaded event
|
||||||
accumulatedGap += entry.gap;
|
|
||||||
isLinkedToLoaded = isLinkedToLoaded && entry.linkStart;
|
if (previousWasCountToEnd !== null) {
|
||||||
|
/** previous event was countToEnd: add its duration as a positive gap (it "gives back" time downstream)
|
||||||
|
* and break the link to the loaded event since countToEnd events reset the schedule
|
||||||
|
*/
|
||||||
|
accumulatedGap += entry.gap + previousWasCountToEnd;
|
||||||
|
isLinkedToLoaded = false;
|
||||||
|
} else {
|
||||||
|
accumulatedGap += entry.gap;
|
||||||
|
isLinkedToLoaded = isLinkedToLoaded && entry.linkStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.countToEnd) {
|
||||||
|
previousWasCountToEnd = entry.duration;
|
||||||
|
} else {
|
||||||
|
previousWasCountToEnd = null;
|
||||||
|
}
|
||||||
|
|
||||||
// and the loaded event is not allowed to be the next flag
|
// and the loaded event is not allowed to be the next flag
|
||||||
if (!foundFlag && metadata.flags.includes(entry.id)) {
|
if (!foundFlag && metadata.flags.includes(entry.id)) {
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export { validateEndAction, validateTimerType } from './src/validate-events/vali
|
|||||||
|
|
||||||
// feature business logic
|
// feature business logic
|
||||||
|
|
||||||
export { getExpectedStart } from './src/date-utils/getExpectedStart.js';
|
export { getExpectedStart, getExpectedEnd } from './src/date-utils/getExpected.js';
|
||||||
|
|
||||||
// feature business logic - rundown
|
// feature business logic - rundown
|
||||||
export { checkIsNow } from './src/date-utils/checkIsNow.js';
|
export { checkIsNow } from './src/date-utils/checkIsNow.js';
|
||||||
|
|||||||
+142
-1
@@ -1,7 +1,7 @@
|
|||||||
import { Day, OffsetMode } from 'ontime-types';
|
import { Day, OffsetMode } from 'ontime-types';
|
||||||
|
|
||||||
import { MILLIS_PER_HOUR, dayInMs } from './conversionUtils';
|
import { MILLIS_PER_HOUR, dayInMs } from './conversionUtils';
|
||||||
import { getExpectedStart } from './getExpectedStart';
|
import { getExpectedEnd, getExpectedStart } from './getExpected';
|
||||||
|
|
||||||
describe('getExpectedStart()', () => {
|
describe('getExpectedStart()', () => {
|
||||||
describe('Absolute offset mode', () => {
|
describe('Absolute offset mode', () => {
|
||||||
@@ -315,3 +315,144 @@ describe('getExpectedStart()', () => {
|
|||||||
expect(getExpectedStart(testEvent, { ...testState, currentDay: 0 })).toBe(23 * MILLIS_PER_HOUR + 5);
|
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
|
||||||
|
const expectedStart0 = getExpectedStart(testEvent, { ...baseState, offset: 0 });
|
||||||
|
expect(getExpectedEnd(testEvent, expectedStart0, baseState.currentDay)).toBe(150);
|
||||||
|
|
||||||
|
// running 20 behind pushes the end out
|
||||||
|
const expectedStart20 = getExpectedStart(testEvent, { ...baseState, offset: 20 });
|
||||||
|
expect(getExpectedEnd(testEvent, expectedStart20, baseState.currentDay)).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
|
||||||
|
const expectedStart = getExpectedStart(testEvent, { ...baseState, offset: 20 });
|
||||||
|
expect(getExpectedEnd(testEvent, expectedStart, baseState.currentDay)).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,
|
||||||
|
};
|
||||||
|
|
||||||
|
const expectedStart = getExpectedStart(testEvent, { ...baseState, offset: 0 });
|
||||||
|
expect(getExpectedEnd(testEvent, expectedStart, baseState.currentDay)).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...
|
||||||
|
const expectedStart = getExpectedStart({ ...testEvent }, { ...baseState, offset: 0 });
|
||||||
|
expect(getExpectedEnd({ ...testEvent, countToEnd: false }, expectedStart, baseState.currentDay)).toBe(170);
|
||||||
|
|
||||||
|
// ... but count to end events stay pinned to the scheduled end
|
||||||
|
const expectedStartCountToEnd = getExpectedStart({ ...testEvent }, { ...baseState, offset: 0 });
|
||||||
|
expect(getExpectedEnd({ ...testEvent, countToEnd: true }, expectedStartCountToEnd, baseState.currentDay)).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
|
||||||
|
const expectedStart = getExpectedStart(testEvent, { ...baseState, offset: 0 });
|
||||||
|
expect(getExpectedEnd(testEvent, expectedStart, baseState.currentDay)).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
|
||||||
|
const expectedStart = getExpectedStart(testEvent, { ...baseState, currentDay: 0, offset: 0 });
|
||||||
|
expect(getExpectedEnd(testEvent, expectedStart, baseState.currentDay)).toBe(150 + dayInMs);
|
||||||
|
|
||||||
|
// when the running event is already on the same day, no extra day is added
|
||||||
|
const expectedStartSameDay = getExpectedStart(
|
||||||
|
{ ...testEvent, dayOffset: 0 as Day },
|
||||||
|
{ ...baseState, currentDay: 0, offset: 0 },
|
||||||
|
);
|
||||||
|
expect(getExpectedEnd({ ...testEvent, dayOffset: 0 as Day }, expectedStartSameDay, baseState.currentDay)).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
|
||||||
|
const expectedStartRegular = getExpectedStart({ ...testEvent }, relativeState);
|
||||||
|
expect(getExpectedEnd({ ...testEvent, countToEnd: false }, expectedStartRegular, relativeState.currentDay)).toBe(
|
||||||
|
180,
|
||||||
|
);
|
||||||
|
|
||||||
|
// the countToEnd event stays pinned to its wall-clock end (150), not shifted
|
||||||
|
const expectedStartCountToEnd = getExpectedStart(testEvent, relativeState);
|
||||||
|
expect(getExpectedEnd(testEvent, expectedStartCountToEnd, relativeState.currentDay)).toBe(150);
|
||||||
|
});
|
||||||
|
});
|
||||||
+22
-9
@@ -3,15 +3,6 @@ import { OffsetMode } from 'ontime-types';
|
|||||||
|
|
||||||
import { dayInMs } from './conversionUtils.js';
|
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
|
|
||||||
*/
|
|
||||||
export function getExpectedStart(
|
export function getExpectedStart(
|
||||||
event: Pick<OntimeEvent, 'timeStart' | 'dayOffset' | 'delay'>,
|
event: Pick<OntimeEvent, 'timeStart' | 'dayOffset' | 'delay'>,
|
||||||
state: {
|
state: {
|
||||||
@@ -60,3 +51,25 @@ export function getExpectedStart(
|
|||||||
const offsetStartTimeBufferedByGaps = offsetStartTime - totalGap;
|
const offsetStartTimeBufferedByGaps = offsetStartTime - totalGap;
|
||||||
return offsetStartTimeBufferedByGaps;
|
return offsetStartTimeBufferedByGaps;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getExpectedEnd(
|
||||||
|
event: Pick<OntimeEvent, 'timeStart' | 'dayOffset' | 'duration' | 'countToEnd'>,
|
||||||
|
expectedStart: number,
|
||||||
|
currentRuntimeDay: number,
|
||||||
|
): number {
|
||||||
|
/**
|
||||||
|
* 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 - currentRuntimeDay;
|
||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user