From 4c29756abe997c0ce78473978e0a58dd820d2209 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 11:38:27 +0000 Subject: [PATCH] test: cover runtime update gating and colour parsing Adds unit tests for two pure modules which had no coverage: - `runtime.utils.ts` gates every websocket broadcast and the `onUpdate` / `onClock` automation triggers. The tests pin the second-boundary rounding, the documented cases where a field is deliberately *not* broadcast (`elapsed`, `expectedFinish`), and the wrap-around behaviour of the load-next / load-previous / go-to-cue lookups. - `colour.utils.ts` parses user supplied colour strings for both the Google Sheets export and the cuesheet rows. The tests cover the hex/CSS-name parsing, the null returns for invalid input, and the hexToColour <-> colourToHex round trip. Both files are pure, so neither test uses a mock. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SAH21unBqSzEH9HMwqfa5Q --- .../__tests__/runtime.utils.test.ts | 226 ++++++++++++++++++ .../utils/src/colour/colour.utils.test.ts | 89 +++++++ 2 files changed, 315 insertions(+) create mode 100644 apps/server/src/services/runtime-service/__tests__/runtime.utils.test.ts create mode 100644 packages/utils/src/colour/colour.utils.test.ts diff --git a/apps/server/src/services/runtime-service/__tests__/runtime.utils.test.ts b/apps/server/src/services/runtime-service/__tests__/runtime.utils.test.ts new file mode 100644 index 000000000..4c5079a2a --- /dev/null +++ b/apps/server/src/services/runtime-service/__tests__/runtime.utils.test.ts @@ -0,0 +1,226 @@ +import { Offset, OffsetMode, Playback, TimerPhase, TimerState, TimerType } from 'ontime-types'; + +import { makeOntimeEvent, makeRundown } from '../../../api-data/rundown/__mocks__/rundown.mocks.js'; +import { + findNextPlayableId, + findNextPlayableWithCue, + findPreviousPlayableId, + getEventAtIndex, + getShouldClockUpdate, + getShouldOffsetUpdate, + getShouldTimerUpdate, + isNewSecond, +} from '../runtime.utils.js'; + +describe('isNewSecond()', () => { + it('is false while the value moves within the same second', () => { + // count down rounds up, so both resolve to second 2 + expect(isNewSecond(1500, 1200)).toBe(false); + }); + + it('is true once the value crosses a second boundary', () => { + expect(isNewSecond(1001, 1000)).toBe(true); + }); + + it('rounds according to the given direction', () => { + // 1200 -> ceil 2 / floor 1, 1800 -> ceil 2 / floor 1 + expect(isNewSecond(1200, 1800, TimerType.CountDown)).toBe(false); + expect(isNewSecond(1200, 1800, TimerType.CountUp)).toBe(false); + // 1200 -> ceil 2 / floor 1, 2200 -> ceil 3 / floor 2 + expect(isNewSecond(1200, 2200, TimerType.CountDown)).toBe(true); + expect(isNewSecond(1200, 2200, TimerType.CountUp)).toBe(true); + }); + + it('treats null and undefined as second zero', () => { + expect(isNewSecond(undefined, null)).toBe(false); + expect(isNewSecond(null, 0)).toBe(false); + expect(isNewSecond(undefined, 500)).toBe(true); + }); +}); + +describe('getShouldClockUpdate()', () => { + it('is false within the same second and true across the boundary', () => { + expect(getShouldClockUpdate(1000, 1999)).toBe(false); + expect(getShouldClockUpdate(1000, 2000)).toBe(true); + }); +}); + +describe('getShouldTimerUpdate()', () => { + const baseTimer: TimerState = { + addedTime: 0, + current: 10000, + duration: 10000, + elapsed: 0, + expectedFinish: 10000, + phase: TimerPhase.Default, + playback: Playback.Play, + secondaryTimer: null, + startedAt: 0, + }; + + it('always updates when there is no previous state', () => { + expect(getShouldTimerUpdate(undefined, baseTimer)).toBe(true); + }); + + it('does not update while the timer ticks within the same second', () => { + expect(getShouldTimerUpdate(baseTimer, { ...baseTimer, current: 9500 })).toBe(false); + }); + + it('updates when the timer crosses a second', () => { + expect(getShouldTimerUpdate(baseTimer, { ...baseTimer, current: 8999 })).toBe(true); + }); + + it('updates when the secondary timer crosses a second', () => { + const previous = { ...baseTimer, secondaryTimer: 2000 }; + // counting down rounds up, so 1999 is still second 2 + expect(getShouldTimerUpdate(previous, { ...previous, secondaryTimer: 1999 })).toBe(false); + expect(getShouldTimerUpdate(previous, { ...previous, secondaryTimer: 1000 })).toBe(true); + }); + + it.each([ + ['addedTime', { addedTime: 1 }], + ['duration', { duration: 1 }], + ['phase', { phase: TimerPhase.Warning }], + ['playback', { playback: Playback.Pause }], + ['startedAt', { startedAt: 1 }], + ])('updates immediately when %s changes', (_label, patch) => { + expect(getShouldTimerUpdate(baseTimer, { ...baseTimer, ...patch })).toBe(true); + }); + + it.each([ + ['elapsed', { elapsed: 1 }], + ['expectedFinish', { expectedFinish: 1 }], + ])('does not update on %s alone, since it is derived', (_label, patch) => { + expect(getShouldTimerUpdate(baseTimer, { ...baseTimer, ...patch })).toBe(false); + }); +}); + +describe('getShouldOffsetUpdate()', () => { + const baseOffset: Offset = { + absolute: 0, + relative: 0, + mode: OffsetMode.Absolute, + expectedGroupEnd: null, + expectedRundownEnd: null, + expectedFlagStart: null, + }; + + it('always updates when there is no previous state', () => { + expect(getShouldOffsetUpdate(undefined, baseOffset, false)).toBe(true); + }); + + it('updates on a mode change even when no dependency ticked', () => { + expect(getShouldOffsetUpdate(baseOffset, { ...baseOffset, mode: OffsetMode.Relative }, false)).toBe(true); + }); + + it('holds back value changes until a dependency ticks', () => { + const next = { ...baseOffset, absolute: 1000 }; + expect(getShouldOffsetUpdate(baseOffset, next, false)).toBe(false); + expect(getShouldOffsetUpdate(baseOffset, next, true)).toBe(true); + }); + + it('does not update when a dependency ticked but nothing changed', () => { + expect(getShouldOffsetUpdate(baseOffset, { ...baseOffset }, true)).toBe(false); + }); +}); + +describe('findPreviousPlayableId()', () => { + const order = ['1', '2', '3']; + + it('returns undefined when there is nothing to play', () => { + expect(findPreviousPlayableId([])).toBeUndefined(); + }); + + it('returns the first event when nothing is loaded', () => { + expect(findPreviousPlayableId(order)).toBe('1'); + }); + + it('returns the preceding event', () => { + expect(findPreviousPlayableId(order, '3')).toBe('2'); + }); + + it('stays on the first event when already at the top', () => { + expect(findPreviousPlayableId(order, '1')).toBe('1'); + }); + + it('falls back to the first event when the loaded id is unknown', () => { + expect(findPreviousPlayableId(order, 'not-in-rundown')).toBe('1'); + }); +}); + +describe('findNextPlayableId()', () => { + const order = ['1', '2', '3']; + + it('returns undefined when there is nothing to play', () => { + expect(findNextPlayableId([])).toBeUndefined(); + }); + + it('returns the first event when nothing is loaded', () => { + expect(findNextPlayableId(order)).toBe('1'); + }); + + it('returns the following event', () => { + expect(findNextPlayableId(order, '1')).toBe('2'); + }); + + it('wraps to the first event from the last', () => { + expect(findNextPlayableId(order, '3')).toBe('1'); + }); + + it('falls back to the first event when the loaded id is unknown', () => { + expect(findNextPlayableId(order, 'not-in-rundown')).toBe('1'); + }); +}); + +describe('findNextPlayableWithCue()', () => { + const rundown = makeRundown({ + order: ['1', '2', '3', '4'], + entries: { + '1': makeOntimeEvent({ id: '1', cue: 'a' }), + '2': makeOntimeEvent({ id: '2', cue: 'b' }), + '3': makeOntimeEvent({ id: '3', cue: 'b', skip: true }), + '4': makeOntimeEvent({ id: '4', cue: 'b' }), + }, + }); + const order = ['1', '2', '3', '4']; + + it('finds the next event with the given cue', () => { + expect(findNextPlayableWithCue(rundown, order, 'b')?.id).toBe('2'); + }); + + it('skips events which are not playable', () => { + expect(findNextPlayableWithCue(rundown, order, 'b', 2)?.id).toBe('4'); + }); + + it('wraps around to the start of the rundown', () => { + expect(findNextPlayableWithCue(rundown, order, 'a', 2)?.id).toBe('1'); + }); + + it('excludes the current event unless allowCurrent is set', () => { + expect(findNextPlayableWithCue(rundown, order, 'b', 1)?.id).toBe('4'); + expect(findNextPlayableWithCue(rundown, order, 'b', 1, true)?.id).toBe('2'); + }); + + it('returns undefined when no event carries the cue', () => { + expect(findNextPlayableWithCue(rundown, order, 'missing')).toBeUndefined(); + }); +}); + +describe('getEventAtIndex()', () => { + const rundown = makeRundown({ + order: ['1', '2'], + entries: { + '1': makeOntimeEvent({ id: '1' }), + '2': makeOntimeEvent({ id: '2' }), + }, + }); + + it('returns the event at the given index', () => { + expect(getEventAtIndex(rundown, ['1', '2'], 1)?.id).toBe('2'); + }); + + it('returns undefined when the index is out of range', () => { + expect(getEventAtIndex(rundown, ['1', '2'], 5)).toBeUndefined(); + expect(getEventAtIndex(rundown, [], 0)).toBeUndefined(); + }); +}); diff --git a/packages/utils/src/colour/colour.utils.test.ts b/packages/utils/src/colour/colour.utils.test.ts new file mode 100644 index 000000000..7ec8a6536 --- /dev/null +++ b/packages/utils/src/colour/colour.utils.test.ts @@ -0,0 +1,89 @@ +import { colourToHex, cssOrHexToColour, hexToColour, isLightColour, mixColours } from './colour.utils'; + +describe('hexToColour()', () => { + it('parses a full length hex', () => { + expect(hexToColour('#ff8800')).toStrictEqual({ red: 255, green: 136, blue: 0, alpha: 1 }); + }); + + it('parses a compressed hex by duplicating each digit', () => { + expect(hexToColour('#f80')).toStrictEqual(hexToColour('#ff8800')); + }); + + it('parses the alpha channel of a full length hex', () => { + expect(hexToColour('#ff880000')).toStrictEqual({ red: 255, green: 136, blue: 0, alpha: 0 }); + expect(hexToColour('#ff8800ff')).toStrictEqual({ red: 255, green: 136, blue: 0, alpha: 1 }); + }); + + it('parses the alpha channel of a compressed hex', () => { + expect(hexToColour('#f800')).toStrictEqual(hexToColour('#ff880000')); + }); + + it('is case insensitive', () => { + expect(hexToColour('#FF8800')).toStrictEqual(hexToColour('#ff8800')); + }); + + it('returns null for values which are not a hex colour', () => { + // these are the values which reach us from user input + for (const invalid of ['', 'red', '#', '#ff', '#fffff', '#ffg', 'ff8800']) { + expect(hexToColour(invalid)).toBeNull(); + } + }); +}); + +describe('colourToHex()', () => { + it('pads single digit channels', () => { + expect(colourToHex({ red: 0, green: 1, blue: 2, alpha: 1 })).toBe('#000102ff'); + }); + + it('round trips with hexToColour', () => { + for (const hex of ['#000000ff', '#ff8800ff', '#ffffffff', '#12345600']) { + expect(colourToHex(hexToColour(hex)!)).toBe(hex); + } + }); +}); + +describe('cssOrHexToColour()', () => { + it('resolves named css colours', () => { + expect(cssOrHexToColour('red')).toStrictEqual({ red: 255, green: 0, blue: 0, alpha: 1 }); + }); + + it('resolves named css colours regardless of casing', () => { + expect(cssOrHexToColour('CornflowerBlue')).toStrictEqual(cssOrHexToColour('cornflowerblue')); + }); + + it('delegates hex values to the hex parser', () => { + expect(cssOrHexToColour('#f80')).toStrictEqual(hexToColour('#f80')); + }); + + it('returns null for an unknown colour name', () => { + expect(cssOrHexToColour('not-a-colour')).toBeNull(); + expect(cssOrHexToColour('')).toBeNull(); + }); +}); + +describe('mixColours()', () => { + const black = { red: 0, green: 0, blue: 0, alpha: 1 }; + const white = { red: 255, green: 255, blue: 255, alpha: 1 }; + + it('defaults to an even mix', () => { + expect(mixColours(black, white)).toStrictEqual({ red: 128, green: 128, blue: 128, alpha: 1 }); + }); + + it('weights the first colour by the given proportion', () => { + expect(mixColours(black, white, 1)).toStrictEqual({ ...black, alpha: 1 }); + expect(mixColours(black, white, 0)).toStrictEqual({ ...white, alpha: 1 }); + }); +}); + +describe('isLightColour()', () => { + it('detects light and dark colours', () => { + expect(isLightColour({ red: 255, green: 255, blue: 255, alpha: 1 })).toBe(true); + expect(isLightColour({ red: 0, green: 0, blue: 0, alpha: 1 })).toBe(false); + }); + + it('weights green most heavily, as per the YIQ calculation', () => { + // pure green is considered light, pure blue is not + expect(isLightColour({ red: 0, green: 255, blue: 0, alpha: 1 })).toBe(true); + expect(isLightColour({ red: 0, green: 0, blue: 255, alpha: 1 })).toBe(false); + }); +});