Files
OpenFrame/tests/unit/video-player-utils.test.ts
T
yusufipk 1d099c68f2 test: add unit, API, component and end-to-end test suites
The repo had no automated tests. Every change was verified by hand.

Adds four layers, 2023 tests in total, runnable with one command:

- 1191 unit tests over the pure logic in lib/, including the full
  computeProjectAccess permission matrix and the billing gate
- 167 component and hook tests in jsdom, covering the hooks that hold
  real logic rather than presentational wrappers
- 647 API integration tests against a real Postgres, with only auth()
  mocked, including a data-driven sweep asserting that none of the 60
  route modules answers 2xx to an unauthenticated caller
- 18 Playwright specs driving a real browser against a real build

Infrastructure: vitest.config.ts with three projects, a disposable
Postgres and MinIO in docker-compose.test.yml, factories and helpers
under tests/, scripts/test.sh as the single entry point, a pre-push
hook running bun run verify, and CI split into check, test and e2e jobs.

The test database is built with prisma db push plus a replay of the
hand-written SQL, because prisma migrate deploy cannot build this schema
from empty: the migration history has no captured baseline. This mirrors
what scripts/docker-db-bootstrap.ts already does in production, and
tests/setup/db-global.ts carries a drift guard so a new migration fails
the run until someone reviews it.

Production code is unchanged apart from one pure-function extraction out
of use-video-player.ts, which was too large to test in jsdom.

Several tests pin behaviour that looks wrong, each marked KNOWN BUG in
place. TESTING.md section 12 records where the plan turned out to be
wrong, and AGENTS.md now states which layer a change needs a test in.
2026-07-26 11:17:26 +07:00

274 lines
11 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import {
clampSeekTime,
getAdjacentPlaybackSpeed,
getFrameIndexAtTime,
getFrameStepLabel,
getFrameStepSeconds,
getPlayheadPercent,
isTypingTarget,
normalizeFrameRate,
resolvePlayerShortcut,
resolveSkipAmount,
timeFromClientX,
} from '@/components/video-page/hooks/video-player-utils';
const SPEEDS = [0.25, 0.5, 1, 1.5, 2];
describe('normalizeFrameRate', () => {
it('snaps a drifting measurement to a broadcast standard', () => {
expect(normalizeFrameRate(29.94)).toBe(29.97);
expect(normalizeFrameRate(23.9)).toBe(23.976);
expect(normalizeFrameRate(59.8)).toBe(59.94);
expect(normalizeFrameRate(24.9)).toBe(25);
});
it('returns an exact standard rate unchanged, except the NTSC-shadowed ones', () => {
for (const rate of [23.976, 25, 29.97, 48, 50, 59.94, 120]) {
expect(normalizeFrameRate(rate)).toBe(rate);
}
});
// KNOWN PRODUCTION BUG, pinned rather than fixed. The tolerance is +/-1.5%
// but 23.976/24, 29.97/30 and 59.94/60 are only 0.1% apart, and the lookup
// takes the FIRST entry within tolerance rather than the closest. The NTSC
// rate always comes first in STANDARD_FRAME_RATES, so 24, 30 and 60 can
// never be returned: an exactly-30fps source is reported as 29.97fps, which
// is the very frame-count drift the snapping is meant to prevent (~18 frames
// off after 10 minutes).
it('mislabels exact 24, 30 and 60 fps as their NTSC neighbours', () => {
expect(normalizeFrameRate(24)).toBe(23.976);
expect(normalizeFrameRate(30)).toBe(29.97);
expect(normalizeFrameRate(60)).toBe(59.94);
// 30.07 is closer to 30 than to 29.97 and still loses.
expect(normalizeFrameRate(30.07)).toBe(29.97);
});
it('keeps a plausible non-standard rate rather than forcing a snap', () => {
// 45fps is more than 1.5% away from every standard, so it must survive.
expect(normalizeFrameRate(45)).toBe(45);
expect(normalizeFrameRate(12)).toBe(12);
});
it('rejects rates outside the 12-120 window', () => {
expect(normalizeFrameRate(11.9)).toBeNull();
expect(normalizeFrameRate(120.1)).toBeNull();
expect(normalizeFrameRate(0)).toBeNull();
expect(normalizeFrameRate(-30)).toBeNull();
});
it('rejects non-finite and missing input', () => {
expect(normalizeFrameRate(undefined)).toBeNull();
expect(normalizeFrameRate(Number.NaN)).toBeNull();
expect(normalizeFrameRate(Number.POSITIVE_INFINITY)).toBeNull();
});
});
describe('getFrameStepSeconds', () => {
it('is the reciprocal of a measured frame rate', () => {
expect(getFrameStepSeconds(25)).toBe(0.04);
expect(getFrameStepSeconds(50)).toBe(0.02);
});
it('falls back to one second when no rate has been measured', () => {
expect(getFrameStepSeconds(null)).toBe(1);
expect(getFrameStepSeconds(0)).toBe(1);
expect(getFrameStepSeconds(-25)).toBe(1);
expect(getFrameStepSeconds(Number.NaN)).toBe(1);
expect(getFrameStepSeconds(Number.POSITIVE_INFINITY)).toBe(1);
});
});
describe('getFrameStepLabel', () => {
it('reads "1f" only when a usable frame rate is known', () => {
expect(getFrameStepLabel(29.97)).toBe('1f');
expect(getFrameStepLabel(null)).toBe('1s');
expect(getFrameStepLabel(0)).toBe('1s');
expect(getFrameStepLabel(Number.NaN)).toBe('1s');
});
it('agrees with getFrameStepSeconds about which unit is in play', () => {
for (const rate of [null, 0, Number.NaN, 24, 25, 60]) {
const usesFrames = getFrameStepLabel(rate) === '1f';
expect(getFrameStepSeconds(rate) !== 1).toBe(usesFrames);
}
});
});
describe('resolveSkipAmount', () => {
it('passes the requested seconds straight through outside frame mode', () => {
expect(resolveSkipAmount(-5, { isFrameMode: false, frameStepSeconds: 0.04 })).toBe(-5);
expect(resolveSkipAmount(5, { isFrameMode: false, frameStepSeconds: 0.04 })).toBe(5);
expect(resolveSkipAmount(0, { isFrameMode: false, frameStepSeconds: 0.04 })).toBe(0);
});
it('collapses any jump to a single frame in frame mode, keeping direction', () => {
expect(resolveSkipAmount(5, { isFrameMode: true, frameStepSeconds: 0.04 })).toBe(0.04);
expect(resolveSkipAmount(30, { isFrameMode: true, frameStepSeconds: 0.04 })).toBe(0.04);
expect(resolveSkipAmount(-5, { isFrameMode: true, frameStepSeconds: 0.04 })).toBe(-0.04);
expect(resolveSkipAmount(-30, { isFrameMode: true, frameStepSeconds: 0.04 })).toBe(-0.04);
});
it('treats a zero-second request as one frame forward', () => {
expect(resolveSkipAmount(0, { isFrameMode: true, frameStepSeconds: 0.04 })).toBe(0.04);
});
});
describe('clampSeekTime', () => {
it('keeps a time inside [0, duration]', () => {
expect(clampSeekTime(5, 10)).toBe(5);
expect(clampSeekTime(-3, 10)).toBe(0);
expect(clampSeekTime(25, 10)).toBe(10);
expect(clampSeekTime(10, 10)).toBe(10);
});
it('pins to zero while the duration is still unknown', () => {
expect(clampSeekTime(42, 0)).toBe(0);
});
});
describe('getPlayheadPercent', () => {
it('maps a time onto a 0-100 timeline position', () => {
expect(getPlayheadPercent(0, 10)).toBe(0);
expect(getPlayheadPercent(2.5, 10)).toBe(25);
expect(getPlayheadPercent(10, 10)).toBe(100);
});
it('clamps positions that fall outside the media', () => {
expect(getPlayheadPercent(-4, 10)).toBe(0);
expect(getPlayheadPercent(14, 10)).toBe(100);
});
it('returns 0 rather than NaN when the duration is unknown', () => {
expect(getPlayheadPercent(4, 0)).toBe(0);
expect(getPlayheadPercent(4, -1)).toBe(0);
});
});
describe('getFrameIndexAtTime', () => {
it('numbers frames from zero within their half-open interval', () => {
expect(getFrameIndexAtTime(0, 25, 60)).toBe(0);
expect(getFrameIndexAtTime(0.039, 25, 60)).toBe(0);
expect(getFrameIndexAtTime(0.04, 25, 60)).toBe(1);
expect(getFrameIndexAtTime(1, 25, 60)).toBe(25);
});
it('does not slip a frame backwards on an inexact boundary', () => {
// 1.16 * 25 evaluates to 28.999999999999996 in IEEE-754, so a bare
// Math.floor would report frame 28 for the exact start of frame 29.
expect(1.16 * 25).toBeLessThan(29);
expect(getFrameIndexAtTime(1.16, 25, 60)).toBe(29);
// Same trap at 4.1s on a 30fps timeline.
expect(4.1 * 30).toBeLessThan(123);
expect(getFrameIndexAtTime(4.1, 30, 60)).toBe(123);
});
it('never reports a frame past the end of the media', () => {
// 2s at 25fps holds frames 0..49.
expect(getFrameIndexAtTime(1.99, 25, 2)).toBe(49);
expect(getFrameIndexAtTime(2, 25, 2)).toBe(49);
expect(getFrameIndexAtTime(600, 25, 2)).toBe(49);
});
it('reports frame 0 while the duration is unknown', () => {
expect(getFrameIndexAtTime(12, 25, 0)).toBe(0);
});
});
describe('timeFromClientX', () => {
const rect = { left: 100, width: 200 };
it('maps a pointer position across the timeline onto a time', () => {
expect(timeFromClientX(100, rect, 60)).toBe(0);
expect(timeFromClientX(200, rect, 60)).toBe(30);
expect(timeFromClientX(300, rect, 60)).toBe(60);
});
it('clamps a pointer that has been dragged off either end', () => {
expect(timeFromClientX(-500, rect, 60)).toBe(0);
expect(timeFromClientX(5000, rect, 60)).toBe(60);
});
it('returns 0 when the timeline rect is missing or has no width', () => {
expect(timeFromClientX(200, null, 60)).toBe(0);
expect(timeFromClientX(200, { left: 100, width: 0 }, 60)).toBe(0);
});
});
describe('getAdjacentPlaybackSpeed', () => {
it('steps up and down the ladder', () => {
expect(getAdjacentPlaybackSpeed(SPEEDS, 1, 1)).toBe(1.5);
expect(getAdjacentPlaybackSpeed(SPEEDS, 1, -1)).toBe(0.5);
expect(getAdjacentPlaybackSpeed(SPEEDS, 0.5, -1)).toBe(0.25);
});
it('returns null at either end instead of wrapping around', () => {
expect(getAdjacentPlaybackSpeed(SPEEDS, 2, 1)).toBeNull();
expect(getAdjacentPlaybackSpeed(SPEEDS, 0.25, -1)).toBeNull();
});
it('recovers from an unknown current speed by starting at the slowest', () => {
expect(getAdjacentPlaybackSpeed(SPEEDS, 3, 1)).toBe(0.25);
expect(getAdjacentPlaybackSpeed(SPEEDS, 3, -1)).toBeNull();
});
it('returns null for an empty ladder', () => {
expect(getAdjacentPlaybackSpeed([], 1, 1)).toBeNull();
expect(getAdjacentPlaybackSpeed([], 1, -1)).toBeNull();
});
});
describe('resolvePlayerShortcut', () => {
it('maps the play/pause, seek, mute and fullscreen keys', () => {
expect(resolvePlayerShortcut({ code: 'Space' })).toBe('toggle-play');
expect(resolvePlayerShortcut({ code: 'KeyK' })).toBe('toggle-play');
expect(resolvePlayerShortcut({ code: 'ArrowLeft' })).toBe('skip-back');
expect(resolvePlayerShortcut({ code: 'ArrowRight' })).toBe('skip-forward');
expect(resolvePlayerShortcut({ code: 'KeyJ' })).toBe('jump-back');
expect(resolvePlayerShortcut({ code: 'KeyL' })).toBe('jump-forward');
expect(resolvePlayerShortcut({ code: 'KeyM' })).toBe('toggle-mute');
expect(resolvePlayerShortcut({ code: 'KeyF' })).toBe('toggle-fullscreen');
});
it('maps the arrow keys to speed changes regardless of shift', () => {
expect(resolvePlayerShortcut({ code: 'ArrowUp' })).toBe('speed-up');
expect(resolvePlayerShortcut({ code: 'ArrowDown' })).toBe('speed-down');
expect(resolvePlayerShortcut({ code: 'ArrowUp', shiftKey: true })).toBe('speed-up');
expect(resolvePlayerShortcut({ code: 'ArrowDown', shiftKey: true })).toBe('speed-down');
});
it('requires shift for the comma and period speed shortcuts', () => {
expect(resolvePlayerShortcut({ code: 'Comma', shiftKey: true })).toBe('speed-down');
expect(resolvePlayerShortcut({ code: 'Period', shiftKey: true })).toBe('speed-up');
// Unshifted, these must stay available for typing.
expect(resolvePlayerShortcut({ code: 'Comma', shiftKey: false })).toBeNull();
expect(resolvePlayerShortcut({ code: 'Period' })).toBeNull();
});
it('claims no other key', () => {
for (const code of ['KeyA', 'Enter', 'Escape', 'Tab', 'Digit1', 'Slash', 'ShiftLeft']) {
expect(resolvePlayerShortcut({ code, shiftKey: true })).toBeNull();
}
});
});
describe('isTypingTarget', () => {
const asTarget = (target: { tagName?: string; isContentEditable?: boolean }) =>
isTypingTarget(target as HTMLElement);
it('recognises the elements a keystroke should be left alone in', () => {
expect(asTarget({ tagName: 'INPUT' })).toBe(true);
expect(asTarget({ tagName: 'TEXTAREA' })).toBe(true);
expect(asTarget({ tagName: 'DIV', isContentEditable: true })).toBe(true);
});
it('lets the player claim keystrokes anywhere else', () => {
expect(asTarget({ tagName: 'BODY', isContentEditable: false })).toBe(false);
expect(asTarget({ tagName: 'DIV', isContentEditable: false })).toBe(false);
expect(asTarget({ tagName: 'BUTTON' })).toBe(false);
// Lowercase never happens for HTML elements, but the check is case
// sensitive and that is worth pinning.
expect(asTarget({ tagName: 'input' })).toBe(false);
});
});