mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-19 14:14:17 +00:00
00b3ba8ed3
Three simplifications from a review of where the complexity actually sat. The animation frame loop now runs for the lifetime of the view instead of being woken around each action. Waking a sleeping loop meant that every path which could move the script had to remember to do it, across eight call sites, and forgetting simply lost the action with no error. An idle frame costs one pass of arithmetic and the browser suspends the loop entirely while the tab is hidden. This removes ensureLoop, the frame handle and the work test. Running always did break something, which is worth recording: any scroll the loop had not made itself was reverted on the next frame, so a scrollbar drag or find in page would snap back. The loop now compares the scroller against where it left it and adopts the DOM position when they differ. That covers every cause rather than only the gestures which had a handler, so the adopt flag and its wiring are gone too, and there is an end to end test for it. Dropped hidePast in favour of dimPast. They read as alternatives but only one is safe during a read: removing past events shortens the document underneath the reader, while dimming is only paint. Also dropped the shade reading line variant, which drew the same gradient over the same box as dimPast, so turning both on stacked two of them. The reading line position was carried as two CSS variables, a percentage and a unitless number. That split caused the padding bug earlier. One unitless variable now serves both, scaled by 1% for the overlays and 1dvh for the padding. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cb8RVPNQ2ETPJxdy4b8CHf
136 lines
5.1 KiB
TypeScript
136 lines
5.1 KiB
TypeScript
import { type Page, expect, test } from '@playwright/test';
|
|
|
|
/**
|
|
* The note field is used as the script source throughout: every event in the
|
|
* test fixture has one, which keeps these tests independent of how custom field
|
|
* keys happen to be spelled in the fixture.
|
|
*/
|
|
const teleprompterUrl = '/teleprompter?script=note';
|
|
|
|
function scroller(page: Page) {
|
|
return page.getByTestId('teleprompter-scroller');
|
|
}
|
|
|
|
function scrollTop(page: Page) {
|
|
return scroller(page).evaluate((element) => element.scrollTop);
|
|
}
|
|
|
|
test.describe('teleprompter', () => {
|
|
test('shows the script from the selected source', async ({ page }) => {
|
|
await page.goto(teleprompterUrl);
|
|
|
|
await expect(page.getByTestId('teleprompter-view')).toBeVisible();
|
|
await expect(scroller(page)).toBeVisible();
|
|
// the fixture uses cue style notes, the first event is Albania
|
|
await expect(page.getByText('SF1.01', { exact: true })).toBeVisible();
|
|
});
|
|
|
|
test('asks for a script source when none is selected', async ({ page }) => {
|
|
await page.goto('/teleprompter');
|
|
|
|
await expect(page.getByTestId('teleprompter-view')).toBeVisible();
|
|
await expect(page.getByText('Select which field holds the script in the view options')).toBeVisible();
|
|
});
|
|
|
|
test('space starts and stops the scroll', async ({ page }) => {
|
|
await page.goto(teleprompterUrl);
|
|
await expect(scroller(page)).toBeVisible();
|
|
|
|
expect(await scrollTop(page)).toBe(0);
|
|
|
|
await page.keyboard.press('Space');
|
|
await expect.poll(() => scrollTop(page)).toBeGreaterThan(0);
|
|
|
|
await page.keyboard.press('Space');
|
|
await page.waitForTimeout(200);
|
|
const afterPause = await scrollTop(page);
|
|
await page.waitForTimeout(500);
|
|
|
|
expect(await scrollTop(page)).toBe(afterPause);
|
|
});
|
|
|
|
test('space drives playback instead of opening the navigation menu', async ({ page }) => {
|
|
// the navigation menu binds Space globally, the teleprompter claims it back
|
|
await page.goto('/timer');
|
|
await expect(page.locator('data-testid=timer-view')).toBeVisible();
|
|
await page.keyboard.press('Space');
|
|
await expect(page.getByRole('dialog')).toBeVisible();
|
|
|
|
await page.goto(teleprompterUrl);
|
|
await expect(scroller(page)).toBeVisible();
|
|
await page.keyboard.press('Space');
|
|
await expect(page.getByRole('dialog')).toHaveCount(0);
|
|
|
|
// and the claim is released once the view goes away
|
|
await page.goto('/timer');
|
|
await expect(page.locator('data-testid=timer-view')).toBeVisible();
|
|
await page.keyboard.press('Space');
|
|
await expect(page.getByRole('dialog')).toBeVisible();
|
|
});
|
|
|
|
test('arrow keys nudge and home rewinds', async ({ page }) => {
|
|
await page.goto(teleprompterUrl);
|
|
await expect(scroller(page)).toBeVisible();
|
|
|
|
await page.keyboard.press('ArrowDown');
|
|
await page.keyboard.press('ArrowDown');
|
|
await expect.poll(() => scrollTop(page)).toBeGreaterThan(0);
|
|
|
|
// the rewind eases rather than snapping, so poll rather than guess a duration
|
|
await page.keyboard.press('Home');
|
|
await expect.poll(() => scrollTop(page)).toBe(0);
|
|
});
|
|
|
|
test('keeps a scroll it did not make itself', async ({ page }) => {
|
|
// the animation frame loop runs for the lifetime of the view, so it has to
|
|
// notice when something else moves the scroller, otherwise a scrollbar drag
|
|
// or find in page would be snapped back on the next frame
|
|
await page.goto(teleprompterUrl);
|
|
await expect(scroller(page)).toBeVisible();
|
|
|
|
await scroller(page).evaluate((element) => {
|
|
element.scrollTop = 400;
|
|
});
|
|
|
|
await page.waitForTimeout(500);
|
|
expect(await scrollTop(page)).toBe(400);
|
|
});
|
|
|
|
test('arrow keys change the speed', async ({ page }) => {
|
|
await page.goto(teleprompterUrl);
|
|
await expect(scroller(page)).toBeVisible();
|
|
|
|
const readout = page.getByTestId('teleprompter-speed');
|
|
const before = Number(await readout.innerText().then((text) => text.replace(/\D/g, '')));
|
|
|
|
await page.keyboard.press('ArrowRight');
|
|
const faster = Number(await readout.innerText().then((text) => text.replace(/\D/g, '')));
|
|
expect(faster).toBeGreaterThan(before);
|
|
|
|
await page.keyboard.press('ArrowLeft');
|
|
const slower = Number(await readout.innerText().then((text) => text.replace(/\D/g, '')));
|
|
expect(slower).toBe(before);
|
|
});
|
|
|
|
test('f mirrors the view for a beam splitter rig', async ({ page }) => {
|
|
await page.goto(teleprompterUrl);
|
|
await expect(scroller(page)).toBeVisible();
|
|
|
|
const view = page.getByTestId('teleprompter-view');
|
|
await page.keyboard.press('f');
|
|
|
|
// a horizontal flip is a negative x scale in the computed matrix
|
|
const transform = await view.evaluate((element) => getComputedStyle(element).transform);
|
|
expect(transform.startsWith('matrix(-1')).toBe(true);
|
|
});
|
|
|
|
test('honours the flip and reading line params', async ({ page }) => {
|
|
await page.goto('/teleprompter?script=note&flipV=true&readingLine=arrows');
|
|
|
|
const view = page.getByTestId('teleprompter-view');
|
|
const transform = await view.evaluate((element) => getComputedStyle(element).transform);
|
|
// a vertical flip leaves x positive and makes the y scale negative
|
|
expect(transform).toMatch(/^matrix\(1, 0, 0, -1/);
|
|
});
|
|
});
|