mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-18 13:44:12 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c29756abe |
@@ -0,0 +1,21 @@
|
|||||||
|
import type { NextFunction, Request, RequestHandler, Response } from 'express';
|
||||||
|
|
||||||
|
import { hasPassword, hashedPassword } from '../api-data/session/session.service.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps the app authenticate middleware with support for the Authorization header.
|
||||||
|
* MCP clients conventionally authenticate with `Authorization: Bearer <token>`
|
||||||
|
* rather than cookies or query params; any other request falls through to the
|
||||||
|
* app middleware, keeping the behaviour of the shared middleware untouched.
|
||||||
|
*/
|
||||||
|
export function makeMcpAuthenticate(fallback: RequestHandler): RequestHandler {
|
||||||
|
return function mcpAuthenticate(req: Request, res: Response, next: NextFunction) {
|
||||||
|
if (hasPassword) {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (authHeader?.startsWith('Bearer ') && authHeader.slice(7) === hashedPassword) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback(req, res, next);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import { socket } from './adapters/WebsocketAdapter.js';
|
|||||||
// Import Routers
|
// Import Routers
|
||||||
import { appRouter } from './api-data/index.js';
|
import { appRouter } from './api-data/index.js';
|
||||||
import { integrationRouter } from './api-integration/integration.router.js';
|
import { integrationRouter } from './api-integration/integration.router.js';
|
||||||
|
import { makeMcpAuthenticate } from './api-mcp/mcp.auth.js';
|
||||||
import { mcpRouter } from './api-mcp/mcp.router.js';
|
import { mcpRouter } from './api-mcp/mcp.router.js';
|
||||||
import { flushPendingWrites, getDataProvider } from './classes/data-provider/DataProvider.js';
|
import { flushPendingWrites, getDataProvider } from './classes/data-provider/DataProvider.js';
|
||||||
// Services
|
// Services
|
||||||
@@ -101,7 +102,7 @@ app.get(`${prefix}/ready`, (_req, res) => {
|
|||||||
app.use(`${prefix}/login`, loginRouter); // router for login flow
|
app.use(`${prefix}/login`, loginRouter); // router for login flow
|
||||||
app.use(`${prefix}/data`, authenticate, appRouter); // router for application data
|
app.use(`${prefix}/data`, authenticate, appRouter); // router for application data
|
||||||
app.use(`${prefix}/api`, authenticate, integrationRouter); // router for integrations
|
app.use(`${prefix}/api`, authenticate, integrationRouter); // router for integrations
|
||||||
app.use(`${prefix}/mcp`, authenticate, mcpRouter); // router for MCP agent integration
|
app.use(`${prefix}/mcp`, makeMcpAuthenticate(authenticate), mcpRouter); // router for MCP agent integration
|
||||||
|
|
||||||
// serve static external files
|
// serve static external files
|
||||||
app.use(
|
app.use(
|
||||||
|
|||||||
@@ -1,35 +1,6 @@
|
|||||||
import type { IncomingMessage } from 'node:http';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import type { NextFunction, Request, Response } from 'express';
|
import { isPublicAssetRequest } from '../authenticate.js';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
vi.mock('../../api-data/session/session.service.js', () => ({
|
|
||||||
hasPassword: true,
|
|
||||||
hashedPassword: 'valid-token',
|
|
||||||
}));
|
|
||||||
|
|
||||||
import {
|
|
||||||
authenticateSocket,
|
|
||||||
isPublicAssetRequest,
|
|
||||||
makeAuthenticateMiddleware,
|
|
||||||
} from '../authenticate.js';
|
|
||||||
|
|
||||||
function makeResponse() {
|
|
||||||
return {
|
|
||||||
redirect: vi.fn(),
|
|
||||||
send: vi.fn(),
|
|
||||||
status: vi.fn().mockReturnThis(),
|
|
||||||
} as unknown as Response;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeHeadersWithFailingAuthorization(cookie?: string) {
|
|
||||||
return {
|
|
||||||
cookie,
|
|
||||||
get authorization(): never {
|
|
||||||
throw new Error('Authorization header should not be read');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('isPublicAssetRequest()', () => {
|
describe('isPublicAssetRequest()', () => {
|
||||||
it('allows root public assets without a prefix', () => {
|
it('allows root public assets without a prefix', () => {
|
||||||
@@ -47,102 +18,3 @@ describe('isPublicAssetRequest()', () => {
|
|||||||
expect(isPublicAssetRequest('/backstage', '')).toBe(false);
|
expect(isPublicAssetRequest('/backstage', '')).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('bearer authentication', () => {
|
|
||||||
const next = vi.fn() as NextFunction;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
next.mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('prioritises cookie authentication for API requests', () => {
|
|
||||||
const { authenticate } = makeAuthenticateMiddleware('');
|
|
||||||
const req = {
|
|
||||||
cookies: { token: JSON.stringify({ token: 'valid-token' }) },
|
|
||||||
headers: makeHeadersWithFailingAuthorization(),
|
|
||||||
query: {},
|
|
||||||
} as unknown as Request;
|
|
||||||
|
|
||||||
expect(() => authenticate(req, makeResponse(), next)).not.toThrow();
|
|
||||||
expect(next).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('prioritises cookie authentication for redirecting routes', () => {
|
|
||||||
const { authenticateAndRedirect } = makeAuthenticateMiddleware('');
|
|
||||||
const req = {
|
|
||||||
cookies: { token: JSON.stringify({ token: 'valid-token' }) },
|
|
||||||
headers: makeHeadersWithFailingAuthorization(),
|
|
||||||
originalUrl: '/external/image.png',
|
|
||||||
query: {},
|
|
||||||
} as unknown as Request;
|
|
||||||
|
|
||||||
expect(() => authenticateAndRedirect(req, makeResponse(), next)).not.toThrow();
|
|
||||||
expect(next).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('prioritises cookie authentication for WebSocket handshakes', () => {
|
|
||||||
const cookie = `token=${encodeURIComponent(JSON.stringify({ token: 'valid-token' }))}`;
|
|
||||||
const req = { headers: makeHeadersWithFailingAuthorization(cookie) } as IncomingMessage;
|
|
||||||
|
|
||||||
expect(() => authenticateSocket({} as never, req, next)).not.toThrow();
|
|
||||||
expect(next).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('authenticates API requests with a bearer token', () => {
|
|
||||||
const { authenticate } = makeAuthenticateMiddleware('');
|
|
||||||
const req = {
|
|
||||||
cookies: {},
|
|
||||||
headers: { authorization: 'Bearer valid-token' },
|
|
||||||
query: {},
|
|
||||||
} as unknown as Request;
|
|
||||||
const res = makeResponse();
|
|
||||||
|
|
||||||
authenticate(req, res, next);
|
|
||||||
|
|
||||||
expect(next).toHaveBeenCalledOnce();
|
|
||||||
expect(res.status).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('authenticates redirecting routes with a bearer token', () => {
|
|
||||||
const { authenticateAndRedirect } = makeAuthenticateMiddleware('/stage');
|
|
||||||
const req = {
|
|
||||||
cookies: {},
|
|
||||||
headers: { authorization: 'Bearer valid-token' },
|
|
||||||
originalUrl: '/stage/external/image.png',
|
|
||||||
query: {},
|
|
||||||
} as unknown as Request;
|
|
||||||
const res = makeResponse();
|
|
||||||
|
|
||||||
authenticateAndRedirect(req, res, next);
|
|
||||||
|
|
||||||
expect(next).toHaveBeenCalledOnce();
|
|
||||||
expect(res.redirect).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('authenticates WebSocket handshakes with a bearer token', () => {
|
|
||||||
const req = {
|
|
||||||
headers: { authorization: 'Bearer valid-token' },
|
|
||||||
} as IncomingMessage;
|
|
||||||
|
|
||||||
authenticateSocket({} as never, req, next);
|
|
||||||
|
|
||||||
expect(next).toHaveBeenCalledOnce();
|
|
||||||
expect(next).toHaveBeenCalledWith();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects an invalid bearer token', () => {
|
|
||||||
const { authenticate } = makeAuthenticateMiddleware('');
|
|
||||||
const req = {
|
|
||||||
cookies: {},
|
|
||||||
headers: { authorization: 'Bearer invalid-token' },
|
|
||||||
query: {},
|
|
||||||
} as unknown as Request;
|
|
||||||
const res = makeResponse();
|
|
||||||
|
|
||||||
authenticate(req, res, next);
|
|
||||||
|
|
||||||
expect(next).not.toHaveBeenCalled();
|
|
||||||
expect(res.status).toHaveBeenCalledWith(401);
|
|
||||||
expect(res.send).toHaveBeenCalledWith('Unauthorized');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -77,16 +77,17 @@ export function makeAuthenticateMiddleware(prefix: string) {
|
|||||||
const loginRedirectBase = `${prefix}/login?redirect=`;
|
const loginRedirectBase = `${prefix}/login?redirect=`;
|
||||||
|
|
||||||
function authenticate(req: Request, res: Response, next: NextFunction) {
|
function authenticate(req: Request, res: Response, next: NextFunction) {
|
||||||
if (getTokenFromCookies(req.cookies) === hashedPassword) {
|
if (req.query.token) {
|
||||||
return next();
|
if (req.query.token === hashedPassword) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (getTokenFromAuthHeader(req.headers.authorization) === hashedPassword) {
|
if (req.cookies?.token) {
|
||||||
return next();
|
const tokenFromCookie = getTokenFromCookie(req.cookies.token);
|
||||||
}
|
if (tokenFromCookie === hashedPassword) {
|
||||||
|
return next();
|
||||||
if (getTokenFromParams(req.query) === hashedPassword) {
|
}
|
||||||
return next();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(401).send('Unauthorized');
|
res.status(401).send('Unauthorized');
|
||||||
@@ -104,17 +105,17 @@ export function makeAuthenticateMiddleware(prefix: string) {
|
|||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (getTokenFromCookies(req.cookies) === hashedPassword) {
|
// we expect the token to be in the cookies
|
||||||
return next();
|
if (req.cookies?.token) {
|
||||||
}
|
const tokenFromCookie = getTokenFromCookie(req.cookies.token);
|
||||||
|
if (tokenFromCookie === hashedPassword) {
|
||||||
if (getTokenFromAuthHeader(req.headers.authorization) === hashedPassword) {
|
return next();
|
||||||
return next();
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// we use query params for generating authenticated URLs and for clients like the companion module
|
// we use query params for generating authenticated URLs and for clients like the companion module
|
||||||
// if the user gives is a token in the query params, we set the cookie to be used in further requests
|
// if the user gives is a token in the query params, we set the cookie to be used in further requests
|
||||||
if (getTokenFromParams(req.query) === hashedPassword) {
|
if (req.query.token === hashedPassword) {
|
||||||
if (hashedPassword !== undefined) {
|
if (hashedPassword !== undefined) {
|
||||||
setSessionCookie(res, hashedPassword, prefix);
|
setSessionCookie(res, hashedPassword, prefix);
|
||||||
}
|
}
|
||||||
@@ -135,16 +136,33 @@ export function authenticateSocket(_ws: WebSocket, req: IncomingMessage, next: (
|
|||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (getTokenFromCookies(req.headers.cookie) === hashedPassword) {
|
// check if the token is in the cookie
|
||||||
|
const cookieString = req.headers.cookie;
|
||||||
|
if (typeof cookieString === 'string') {
|
||||||
|
const cookies = parseCookie(cookieString);
|
||||||
|
if (cookies.token) {
|
||||||
|
const token = getTokenFromCookie(cookies.token);
|
||||||
|
if (token === hashedPassword) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if token is in the params - simple string check first
|
||||||
|
const urlString = req.url || '';
|
||||||
|
if (urlString.includes(`token=${hashedPassword}`)) {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (getTokenFromAuthHeader(req.headers.authorization) === hashedPassword) {
|
// fallback to full URL parsing for other formats
|
||||||
return next();
|
try {
|
||||||
}
|
const url = new URL(urlString, `http://${req.headers.host}`);
|
||||||
|
const token = url.searchParams.get('token');
|
||||||
if (getTokenFromParams(req.url, req.headers.host) === hashedPassword) {
|
if (token === hashedPassword) {
|
||||||
return next();
|
return next();
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// ignore URL parsing errors
|
||||||
}
|
}
|
||||||
|
|
||||||
return next(new Error('Unauthorized'));
|
return next(new Error('Unauthorized'));
|
||||||
@@ -163,18 +181,19 @@ function setSessionCookie(res: Response, token: string, prefix: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTokenFromCookies(cookies: string | Record<string, unknown> | undefined): string | undefined {
|
/**
|
||||||
const cookieContents = typeof cookies === 'string' ? parseCookie(cookies).token : cookies?.token;
|
* When calling this function we already know a cookie called 'token' exists
|
||||||
if (typeof cookieContents !== 'string') {
|
* And want to extract its value
|
||||||
return undefined;
|
*/
|
||||||
}
|
function getTokenFromCookie(cookieContents: string): string | undefined {
|
||||||
|
// Fast path: check if the hashed password is directly in the cookie string
|
||||||
// Fast path: avoid JSON parsing when the expected token can be found directly
|
// This avoids JSON parsing for the common case
|
||||||
const cookieTokenString = '"token":"' + hashedPassword + '}"';
|
const cookieTokenString = '"token":"' + hashedPassword + '}"';
|
||||||
if (cookieTokenString && cookieContents.includes(cookieTokenString)) {
|
if (cookieTokenString && cookieContents.includes(cookieTokenString)) {
|
||||||
return hashedPassword;
|
return hashedPassword;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback to JSON parsing for other cases or validation
|
||||||
try {
|
try {
|
||||||
const cookie = JSON.parse(cookieContents);
|
const cookie = JSON.parse(cookieContents);
|
||||||
if (cookie && typeof cookie.token === 'string') {
|
if (cookie && typeof cookie.token === 'string') {
|
||||||
@@ -184,29 +203,3 @@ function getTokenFromCookies(cookies: string | Record<string, unknown> | undefin
|
|||||||
// no error handling to do here
|
// no error handling to do here
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTokenFromAuthHeader(authorization: string | undefined): string | undefined {
|
|
||||||
if (authorization?.startsWith('Bearer ')) {
|
|
||||||
return authorization.slice(7);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTokenFromParams(
|
|
||||||
params: string | Record<string, unknown> | undefined,
|
|
||||||
host?: string,
|
|
||||||
): string | undefined {
|
|
||||||
if (typeof params !== 'string') {
|
|
||||||
return typeof params?.token === 'string' ? params.token : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fast path for WebSocket URLs
|
|
||||||
if (params.includes(`token=${hashedPassword}`)) {
|
|
||||||
return hashedPassword;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return new URL(params, `http://${host}`).searchParams.get('token') ?? undefined;
|
|
||||||
} catch (_) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user