Compare commits

..

1 Commits

Author SHA1 Message Date
Claude 4c29756abe 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAH21unBqSzEH9HMwqfa5Q
2026-08-17 11:38:27 +00:00
12 changed files with 2447 additions and 1146 deletions
+6 -7
View File
@@ -4,7 +4,7 @@
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@base-ui/react": "1.7.0", "@base-ui/react": "1.6.0",
"@codemirror/commands": "^6.0.0", "@codemirror/commands": "^6.0.0",
"@codemirror/lang-css": "^6.0.0", "@codemirror/lang-css": "^6.0.0",
"@codemirror/state": "^6.0.0", "@codemirror/state": "^6.0.0",
@@ -13,7 +13,7 @@
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@fontsource/open-sans": "^5.2.7", "@fontsource/open-sans": "^5.2.7",
"@mantine/hooks": "^9.5.1", "@mantine/hooks": "^8.3.7",
"@sentry/react": "^10.59.0", "@sentry/react": "^10.59.0",
"@table-nav/react": "^0.0.7", "@table-nav/react": "^0.0.7",
"@tanstack/react-query": "^5.101.0", "@tanstack/react-query": "^5.101.0",
@@ -29,7 +29,7 @@
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-fast-compare": "^3.2.2", "react-fast-compare": "^3.2.2",
"react-hook-form": "^7.80.0", "react-hook-form": "^7.80.0",
"react-icons": "5.7.0", "react-icons": "5.6.0",
"react-router": "^8.0.1", "react-router": "^8.0.1",
"react-virtuoso": "^4.18.7", "react-virtuoso": "^4.18.7",
"zustand": "^5.0.14" "zustand": "^5.0.14"
@@ -60,8 +60,7 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"@sentry/vite-plugin": "5.4.0", "@sentry/vite-plugin": "5.1.1",
"@types/node": "catalog:",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/react": "^19.1.12", "@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9", "@types/react-dom": "^19.1.9",
@@ -73,8 +72,8 @@
"ontime-utils": "workspace:*", "ontime-utils": "workspace:*",
"sass": "^1.57.1", "sass": "^1.57.1",
"typescript": "catalog:", "typescript": "catalog:",
"vite": "8.2.1", "vite": "8.0.1",
"vite-plugin-compression2": "2.5.3", "vite-plugin-compression2": "2.5.1",
"vite-plugin-svgr": "4.5.0", "vite-plugin-svgr": "4.5.0",
"vitest": "catalog:" "vitest": "catalog:"
} }
@@ -1,5 +1,5 @@
import { Dialog } from '@base-ui/react/dialog'; import { Dialog } from '@base-ui/react/dialog';
import { useDisclosure, useFullscreenDocument } from '@mantine/hooks'; import { useDisclosure, useFullscreen } from '@mantine/hooks';
import { memo } from 'react'; import { memo } from 'react';
import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5'; import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5';
import { LuCoffee } from 'react-icons/lu'; import { LuCoffee } from 'react-icons/lu';
@@ -33,7 +33,7 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
const isSmallScreen = useIsSmallScreen(); const isSmallScreen = useIsSmallScreen();
const [isRenameOpen, handlers] = useDisclosure(false); const [isRenameOpen, handlers] = useDisclosure(false);
const { fullscreen, toggle } = useFullscreenDocument(); const { fullscreen, toggle } = useFullscreen();
const { mirror, toggleMirror } = useViewOptionsStore(); const { mirror, toggleMirror } = useViewOptionsStore();
const { keepAwake, toggleKeepAwake } = useKeepAwakeOptions(); const { keepAwake, toggleKeepAwake } = useKeepAwakeOptions();
const location = useLocation(); const location = useLocation();
+2 -2
View File
@@ -14,8 +14,8 @@
"main": "src/main.js", "main": "src/main.js",
"devDependencies": { "devDependencies": {
"electron": "38.2.1", "electron": "38.2.1",
"electron-builder": "26.15.3", "electron-builder": "26.9.1",
"wait-on": "^9.0.0" "wait-on": "^7.2.0"
}, },
"scripts": { "scripts": {
"dev:electron": "wait-on http://localhost:3000 && cross-env NODE_ENV=development electron .", "dev:electron": "wait-on http://localhost:3000 && cross-env NODE_ENV=development electron .",
+3 -3
View File
@@ -10,7 +10,7 @@
"cookie": "1.0.2", "cookie": "1.0.2",
"cookie-parser": "1.4.7", "cookie-parser": "1.4.7",
"cors": "2.8.6", "cors": "2.8.6",
"dotenv": "^17.0.0", "dotenv": "^16.0.1",
"express": "5.2.1", "express": "5.2.1",
"express-static-gzip": "3.0.1", "express-static-gzip": "3.0.1",
"express-validator": "7.3.2", "express-validator": "7.3.2",
@@ -31,11 +31,11 @@
"@types/multer": "2.1.0", "@types/multer": "2.1.0",
"@types/node": "catalog:", "@types/node": "catalog:",
"@types/ws": "^8.5.10", "@types/ws": "^8.5.10",
"esbuild": "^0.28.0", "esbuild": "^0.24.0",
"ontime-types": "workspace:*", "ontime-types": "workspace:*",
"server-timing": "^3.3.3", "server-timing": "^3.3.3",
"ts-essentials": "catalog:", "ts-essentials": "catalog:",
"tsx": "^4.23.12", "tsx": "^4.19.2",
"typescript": "catalog:", "typescript": "catalog:",
"vitest": "catalog:" "vitest": "catalog:"
}, },
@@ -353,19 +353,19 @@ export function isLoadedPlayable(loadedEventId: EntryId, rundown: Readonly<Rundo
/** List of event properties which do not need the rundown to be regenerated */ /** List of event properties which do not need the rundown to be regenerated */
enum RegenerateWhitelist { enum RegenerateWhitelist {
id, // adding it for completeness, users cannot change ID 'id', // adding it for completeness, users cannot change ID
type, // adding it for completeness, users cannot change ID 'type', // adding it for completeness, users cannot change ID
cue, 'cue',
title, 'title',
note, 'note',
endAction, 'endAction',
timerType, 'timerType',
countToEnd, 'countToEnd',
colour, 'colour',
timeWarning, 'timeWarning',
timeDanger, 'timeDanger',
custom, 'custom',
triggers, 'triggers',
} }
/** /**
+6 -3
View File
@@ -20,9 +20,12 @@ export function createMcpServer(): Server {
{ capabilities: { tools: {}, prompts: {}, resources: {} } }, { capabilities: { tools: {}, prompts: {}, resources: {} } },
); );
server.setRequestHandler(ListToolsRequestSchema, async (): Promise<ListToolsResult> => ({ server.setRequestHandler(
tools: TOOL_DEFINITIONS as unknown as ListToolsResult['tools'], ListToolsRequestSchema,
})); async (): Promise<ListToolsResult> => ({
tools: TOOL_DEFINITIONS as unknown as ListToolsResult['tools'],
}),
);
server.setRequestHandler(CallToolRequestSchema, async (request): Promise<CallToolResult> => { server.setRequestHandler(CallToolRequestSchema, async (request): Promise<CallToolResult> => {
const { name, arguments: args = {} } = request.params; const { name, arguments: args = {} } = request.params;
@@ -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();
});
});
+5 -5
View File
@@ -41,14 +41,14 @@
"format:check": "oxfmt --check" "format:check": "oxfmt --check"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "1.62.1", "@playwright/test": "1.60.0",
"@types/node": "catalog:", "@types/node": "catalog:",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"oxfmt": "^0.63.0", "oxfmt": "^0.42.0",
"oxlint": "^1.78.0", "oxlint": "^1.57.0",
"oxlint-tsgolint": "^7.0.2001", "oxlint-tsgolint": "^0.17.4",
"rimraf": "catalog:", "rimraf": "catalog:",
"turbo": "2.10.10", "turbo": "2.8.20",
"typescript": "catalog:" "typescript": "catalog:"
}, },
"packageManager": "pnpm@11.1.2+sha512.415a1cc25974731e75455c1468371be74c5aa5fb7621b50d4056d222451609f11412f23fd602e6169f1e060466641f798597e1be961a10688836a67b16569499", "packageManager": "pnpm@11.1.2+sha512.415a1cc25974731e75455c1468371be74c5aa5fb7621b50d4056d222451609f11412f23fd602e6169f1e060466641f798597e1be961a10688836a67b16569499",
-1
View File
@@ -14,7 +14,6 @@
"nanoid": "^6.0.0" "nanoid": "^6.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "catalog:",
"ontime-types": "workspace:*", "ontime-types": "workspace:*",
"typescript": "catalog:", "typescript": "catalog:",
"vitest": "catalog:" "vitest": "catalog:"
@@ -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);
});
});
+2092 -1107
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -4,10 +4,10 @@ packages:
catalog: catalog:
'@types/node': 22.19.11 '@types/node': 22.19.11
rimraf: 6.1.3 rimraf: 6.0.1
ts-essentials: 10.2.1 ts-essentials: 10.1.1
typescript: 7.0.2 typescript: 7.0.2
vitest: 4.1.10 vitest: 4.0.17
allowBuilds: allowBuilds:
'@parcel/watcher': true '@parcel/watcher': true