From 1175b3c641d35142bf6899fd10235677778ca4f1 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Sat, 5 Sep 2026 20:44:54 +0200 Subject: [PATCH] fix(report): harden empty report check --- .../report/__tests__/report.service.test.ts | 51 +++++++++++++++++++ .../src/api-data/report/report.service.ts | 12 +++-- 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/apps/server/src/api-data/report/__tests__/report.service.test.ts b/apps/server/src/api-data/report/__tests__/report.service.test.ts index b6471fd42..6ea33a01e 100644 --- a/apps/server/src/api-data/report/__tests__/report.service.test.ts +++ b/apps/server/src/api-data/report/__tests__/report.service.test.ts @@ -62,6 +62,27 @@ it('falls back to the current event when a stop arrives without a start', () => }); }); +it('captures the rundown plan when a stop is the first report entry', () => { + const rundown = makeRundown({ + id: 'run-1', + title: 'Stopped without start', + order: [eventA.id], + entries: { [eventA.id]: eventA }, + }); + const stop = makeRuntimeStateData({ + eventNow: eventA, + clock: MILLIS_PER_MINUTE, + rundown: { plannedStart: 0, plannedEnd: MILLIS_PER_MINUTE }, + }); + + triggerReportEntry(TimerLifeCycle.onStop, stop, rundown); + + expect(generateReport()).toMatchObject({ + eventReports: { [eventA.id]: { endedAt: MILLIS_PER_MINUTE } }, + rundown: { id: rundown.id, title: rundown.title }, + }); +}); + it('accumulates entries until the report is explicitly cleared', () => { const firstRun = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, _startEpoch: 1 }); triggerReportEntry(TimerLifeCycle.onStart, firstRun); @@ -111,3 +132,33 @@ it('clears the retained report and rundown snapshot together', () => { expect(sendRefetch).toHaveBeenCalledOnce(); expect(sendRefetch).toHaveBeenCalledWith(RefetchKey.Report); }); + +it('captures a new plan after removing the final event by id', () => { + const firstRundown = makeRundown({ + id: 'run-1', + title: 'First run', + order: [eventA.id], + entries: { [eventA.id]: eventA }, + }); + const secondRundown = makeRundown({ + id: 'run-2', + title: 'Second run', + order: [eventB.id], + entries: { [eventB.id]: eventB }, + }); + const firstStart = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, _startEpoch: 1 }); + const secondStart = makeRuntimeStateData({ + eventNow: eventB, + timer: { startedAt: MILLIS_PER_MINUTE }, + _startEpoch: 2, + }); + + triggerReportEntry(TimerLifeCycle.onStart, firstStart, firstRundown); + clear(eventA.id); + triggerReportEntry(TimerLifeCycle.onStart, secondStart, secondRundown); + + expect(generateReport()).toMatchObject({ + eventReports: { [eventB.id]: { startedAt: MILLIS_PER_MINUTE } }, + rundown: { id: secondRundown.id, title: secondRundown.title }, + }); +}); diff --git a/apps/server/src/api-data/report/report.service.ts b/apps/server/src/api-data/report/report.service.ts index b8cb4fef3..e03888acf 100644 --- a/apps/server/src/api-data/report/report.service.ts +++ b/apps/server/src/api-data/report/report.service.ts @@ -43,11 +43,11 @@ export function clear(id?: string) { formattedReport = null; if (id) { report.delete(id); + if (report.size === 0) resetReportPlan(); } else { // A full clear makes the next event start a new report instead of resuming this run. report.clear(); - plannedTimes = emptyPlannedTimes; - rundownSnapshot = null; + resetReportPlan(); } sendRefetch(RefetchKey.Report); @@ -86,6 +86,7 @@ export function triggerReportEntry( } if (cycle === TimerLifeCycle.onStop) { + captureReportPlan(state, rundown); const previous = report.get(eventId); const schedule = previous ?? getScheduleSnapshot(state.eventNow); report.set(eventId, { @@ -113,7 +114,7 @@ function getScheduleSnapshot( } /** - * Captures the plan once, when the first event in the report starts. + * Captures the plan once, when the first event in the report is recorded. */ function captureReportPlan(state: DeepReadonly, rundown: Readonly) { if (rundownSnapshot !== null) return; @@ -126,6 +127,11 @@ function captureReportPlan(state: DeepReadonly, rundown: Readonly< }; } +function resetReportPlan() { + plannedTimes = emptyPlannedTimes; + rundownSnapshot = null; +} + /** * Show level times for the report. * Planned times are the ones captured when the show started, actual times are