Files
ontime/e2e/tests/features/215-teleprompter.spec.ts
T
Claude 00b3ba8ed3 refactor(teleprompter): simplify the scroll engine and drop settings that overlap
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
2026-08-14 19:17:16 +00:00

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/);
});
});