mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-31 20:09:11 +00:00
refactor(report): summary
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
import { TimerLifeCycle } from 'ontime-types';
|
||||
import type { PlayableEvent } from 'ontime-types';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { makeRuntimeStateData } from '../../../stores/__mocks__/runtimeState.mocks.js';
|
||||
import { makeOntimeEvent } from '../../rundown/__mocks__/rundown.mocks.js';
|
||||
import { clear, generate, generateShowReport, triggerReportEntry } from '../report.service.js';
|
||||
|
||||
vi.mock('../../../adapters/WebsocketAdapter.js', () => ({
|
||||
sendRefetch: vi.fn(),
|
||||
}));
|
||||
|
||||
const eventA = makeOntimeEvent({ id: 'event-a', timeStart: 0, timeEnd: 10000, duration: 10000 }) as PlayableEvent;
|
||||
const eventB = makeOntimeEvent({ id: 'event-b', timeStart: 10000, timeEnd: 20000, duration: 10000 }) as PlayableEvent;
|
||||
|
||||
beforeEach(() => {
|
||||
clear();
|
||||
});
|
||||
|
||||
describe('triggerReportEntry()', () => {
|
||||
it('snapshots the schedule when an event starts', () => {
|
||||
const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 500 }, clock: 500 });
|
||||
triggerReportEntry(TimerLifeCycle.onStart, state);
|
||||
|
||||
expect(generate()[eventA.id]).toEqual({
|
||||
startedAt: 500,
|
||||
endedAt: null,
|
||||
scheduledStart: eventA.timeStart,
|
||||
scheduledDuration: eventA.duration,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the snapshot taken at start when the event stops', () => {
|
||||
const start = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 });
|
||||
triggerReportEntry(TimerLifeCycle.onStart, start);
|
||||
|
||||
const stop = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 12000 });
|
||||
triggerReportEntry(TimerLifeCycle.onStop, stop);
|
||||
|
||||
expect(generate()[eventA.id]).toMatchObject({
|
||||
startedAt: 0,
|
||||
endedAt: 12000,
|
||||
scheduledStart: eventA.timeStart,
|
||||
scheduledDuration: eventA.duration,
|
||||
});
|
||||
});
|
||||
|
||||
it('records the schedule as it was, not as it later becomes', () => {
|
||||
const start = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 });
|
||||
triggerReportEntry(TimerLifeCycle.onStart, start);
|
||||
|
||||
// the event is edited to a different duration, then stopped
|
||||
const edited = { ...eventA, duration: 99999, timeEnd: 99999 } as PlayableEvent;
|
||||
const stop = makeRuntimeStateData({ eventNow: edited, timer: { startedAt: 0 }, clock: 10000 });
|
||||
triggerReportEntry(TimerLifeCycle.onStop, stop);
|
||||
|
||||
expect(generate()[eventA.id].scheduledDuration).toBe(10000);
|
||||
});
|
||||
|
||||
it('falls back to the current event when a stop arrives with no start', () => {
|
||||
const stop = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 10000 });
|
||||
triggerReportEntry(TimerLifeCycle.onStop, stop);
|
||||
|
||||
expect(generate()[eventA.id]).toMatchObject({
|
||||
startedAt: null,
|
||||
endedAt: 10000,
|
||||
scheduledDuration: eventA.duration,
|
||||
});
|
||||
});
|
||||
|
||||
it('starts a fresh report when a new show begins', () => {
|
||||
// a rehearsal ran earlier and left its numbers behind
|
||||
const rehearsal = makeRuntimeStateData({
|
||||
eventNow: eventA,
|
||||
timer: { startedAt: 0 },
|
||||
clock: 0,
|
||||
_startEpoch: 1000,
|
||||
});
|
||||
triggerReportEntry(TimerLifeCycle.onStart, rehearsal);
|
||||
triggerReportEntry(TimerLifeCycle.onStop, { ...rehearsal, clock: 20000 } as typeof rehearsal);
|
||||
expect(generate()[eventA.id].endedAt).toBe(20000);
|
||||
|
||||
// the performance is a different show and must not inherit them
|
||||
const show = makeRuntimeStateData({
|
||||
eventNow: eventB,
|
||||
timer: { startedAt: 0 },
|
||||
clock: 0,
|
||||
_startEpoch: 9999,
|
||||
});
|
||||
triggerReportEntry(TimerLifeCycle.onStart, show);
|
||||
|
||||
expect(generate()[eventA.id]).toBeUndefined();
|
||||
expect(generate()[eventB.id]).toBeDefined();
|
||||
});
|
||||
|
||||
it('keeps accumulating within the same show', () => {
|
||||
const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0, _startEpoch: 1000 });
|
||||
triggerReportEntry(TimerLifeCycle.onStart, state);
|
||||
triggerReportEntry(TimerLifeCycle.onStop, { ...state, clock: 10000 } as typeof state);
|
||||
|
||||
const next = makeRuntimeStateData({
|
||||
eventNow: eventB,
|
||||
timer: { startedAt: 10000 },
|
||||
clock: 10000,
|
||||
_startEpoch: 1000,
|
||||
});
|
||||
triggerReportEntry(TimerLifeCycle.onStart, next);
|
||||
|
||||
// same show, so the earlier event is still part of the report
|
||||
expect(Object.keys(generate())).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('ignores events without an id', () => {
|
||||
const state = makeRuntimeStateData({ eventNow: null });
|
||||
triggerReportEntry(TimerLifeCycle.onStart, state);
|
||||
expect(generate()).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateShowReport()', () => {
|
||||
it('captures the plan the show was measured against', () => {
|
||||
const state = makeRuntimeStateData({
|
||||
eventNow: eventA,
|
||||
timer: { startedAt: 0 },
|
||||
clock: 0,
|
||||
_startEpoch: 1000,
|
||||
rundown: { plannedStart: 68400000, plannedEnd: 75600000 },
|
||||
});
|
||||
triggerReportEntry(TimerLifeCycle.onStart, state);
|
||||
|
||||
expect(generateShowReport()).toMatchObject({ plannedStart: 68400000, plannedEnd: 75600000 });
|
||||
});
|
||||
|
||||
it('keeps the plan captured at start when the rundown is edited later', () => {
|
||||
const start = makeRuntimeStateData({
|
||||
eventNow: eventA,
|
||||
timer: { startedAt: 0 },
|
||||
clock: 0,
|
||||
_startEpoch: 1000,
|
||||
rundown: { plannedStart: 68400000, plannedEnd: 75600000 },
|
||||
});
|
||||
triggerReportEntry(TimerLifeCycle.onStart, start);
|
||||
|
||||
// the rundown is reworked mid show, the plan it started against stands
|
||||
const edited = { ...start, rundown: { ...start.rundown, plannedEnd: 99999999 } } as typeof start;
|
||||
triggerReportEntry(TimerLifeCycle.onStop, { ...edited, clock: 10000 } as typeof start);
|
||||
|
||||
expect(generateShowReport().plannedEnd).toBe(75600000);
|
||||
});
|
||||
|
||||
it('derives actual times from the events that ran', () => {
|
||||
const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 500 }, clock: 500, _startEpoch: 1 });
|
||||
triggerReportEntry(TimerLifeCycle.onStart, state);
|
||||
triggerReportEntry(TimerLifeCycle.onStop, { ...state, clock: 12000 } as typeof state);
|
||||
|
||||
expect(generateShowReport()).toMatchObject({ actualStart: 500, actualEnd: 12000 });
|
||||
});
|
||||
|
||||
it('has no times before anything runs', () => {
|
||||
expect(generateShowReport()).toEqual({
|
||||
plannedStart: null,
|
||||
plannedEnd: null,
|
||||
actualStart: null,
|
||||
actualEnd: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear()', () => {
|
||||
it('clears a single event', () => {
|
||||
const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 });
|
||||
triggerReportEntry(TimerLifeCycle.onStart, state);
|
||||
clear(eventA.id);
|
||||
expect(generate()).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,14 @@ router.get('/', (_req: Request, res: Response) => {
|
||||
res.status(200).json(report.generate());
|
||||
});
|
||||
|
||||
/**
|
||||
* Show level times, kept separate so the report payload stays as it was
|
||||
* for the integrations that already read it.
|
||||
*/
|
||||
router.get('/show', (_req: Request, res: Response) => {
|
||||
res.status(200).json(report.generateShowReport());
|
||||
});
|
||||
|
||||
router.delete('/all', (_req: Request, res: Response) => {
|
||||
report.clear();
|
||||
res.status(204).send();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { OntimeEventReport, OntimeReport, RefetchKey, TimerLifeCycle } from 'ontime-types';
|
||||
import { OntimeEventReport, OntimeReport, RefetchKey, ShowReport, TimerLifeCycle } from 'ontime-types';
|
||||
import { getActualShowTimes } from 'ontime-utils';
|
||||
import { DeepReadonly } from 'ts-essentials';
|
||||
|
||||
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
|
||||
@@ -8,6 +9,20 @@ const report = new Map<string, OntimeEventReport>();
|
||||
|
||||
let formattedReport: OntimeReport | null = null;
|
||||
|
||||
/**
|
||||
* Identifies the show the current report belongs to.
|
||||
* The report describes one run, so starting a new show begins a fresh one
|
||||
* rather than mixing a rehearsal into the numbers for the performance.
|
||||
*/
|
||||
let currentShowStart: number | null = null;
|
||||
|
||||
/**
|
||||
* The plan the show was measured against, taken when it starts.
|
||||
* Snapshotted for the same reason the per event schedule is: editing the
|
||||
* rundown afterwards must not move the target a past show was judged by.
|
||||
*/
|
||||
let plannedTimes: Pick<ShowReport, 'plannedStart' | 'plannedEnd'> = { plannedStart: null, plannedEnd: null };
|
||||
|
||||
/**
|
||||
* generates a full report
|
||||
* @returns full report
|
||||
@@ -27,9 +42,14 @@ export function clear(id?: string) {
|
||||
formattedReport = null;
|
||||
if (id) {
|
||||
report.delete(id);
|
||||
} else {
|
||||
report.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
// clearing everything also forgets which show the report described, so the
|
||||
// next event starts a report rather than resuming the one just discarded
|
||||
report.clear();
|
||||
currentShowStart = null;
|
||||
plannedTimes = { plannedStart: null, plannedEnd: null };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,15 +69,62 @@ export function triggerReportEntry(
|
||||
const eventId = state.eventNow.id;
|
||||
|
||||
if (cycle === TimerLifeCycle.onStart) {
|
||||
report.set(eventId, { startedAt: state.timer.startedAt, endedAt: null });
|
||||
startShowIfNew(state);
|
||||
|
||||
report.set(eventId, {
|
||||
startedAt: state.timer.startedAt,
|
||||
endedAt: null,
|
||||
// snapshot the schedule so later rundown edits cannot change how a show
|
||||
// that already happened is reported
|
||||
scheduledStart: state.eventNow.timeStart,
|
||||
scheduledDuration: state.eventNow.duration,
|
||||
});
|
||||
formattedReport = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (cycle === TimerLifeCycle.onStop) {
|
||||
const startedAt = report.get(eventId)?.startedAt ?? null;
|
||||
report.set(eventId, { startedAt, endedAt: state.clock });
|
||||
const previous = report.get(eventId);
|
||||
report.set(eventId, {
|
||||
startedAt: previous?.startedAt ?? null,
|
||||
endedAt: state.clock,
|
||||
scheduledStart: previous?.scheduledStart ?? state.eventNow.timeStart,
|
||||
scheduledDuration: previous?.scheduledDuration ?? state.eventNow.duration,
|
||||
});
|
||||
formattedReport = null;
|
||||
sendRefetch(RefetchKey.Report);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the report when a new show begins.
|
||||
*
|
||||
* The runtime stamps a show when its first event starts, so a change of stamp
|
||||
* means the previous report described a different run. Without this the report
|
||||
* would accumulate across rehearsals and performances with no way to tell
|
||||
* which numbers belonged to which.
|
||||
* @private
|
||||
*/
|
||||
function startShowIfNew(state: DeepReadonly<RuntimeState>) {
|
||||
const showStart = state._startEpoch ?? state.rundown.actualStart;
|
||||
if (showStart === null || showStart === currentShowStart) {
|
||||
return;
|
||||
}
|
||||
|
||||
report.clear();
|
||||
formattedReport = null;
|
||||
currentShowStart = showStart;
|
||||
plannedTimes = {
|
||||
plannedStart: state.rundown.plannedStart,
|
||||
plannedEnd: state.rundown.plannedEnd,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Show level times for the report.
|
||||
* Planned times are the ones captured when the show started, actual times are
|
||||
* derived from the events that ran.
|
||||
*/
|
||||
export function generateShowReport(): ShowReport {
|
||||
return { ...plannedTimes, ...getActualShowTimes(generate()) };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user