diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d4ad933..abae2c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,3 +39,45 @@ jobs: - name: Check the build produced an entry point run: test -f dist/index.html + + # The same app, driven in a real browser. + # + # Once rather than per Node version: what this checks is what the browser + # does with the built site, and that does not vary with the Node that built + # it. The build above already covers both. + browser: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + + - name: Install + run: npm ci + + # Only Chromium, and only what it needs to start. The suite has one + # browser project, so fetching the other two is a minute of download + # for nothing. + - name: Get the browser + run: npx playwright install --with-deps chromium + + # This builds the site and serves it before running; see + # playwright.config.ts, which tests what ships rather than the dev + # server. + - name: Browser tests + run: npm run test:browser + + # Traces and screenshots for whatever failed, which is the only way to + # see what a headless run on somebody else's machine actually did. + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-report + path: | + playwright-report/ + test-results/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index 4070665..65949f4 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,10 @@ tools/.wk/ # The assembled design canvas, which bundles the whole editor into one 2.5 MB # file. Generated from the .dc.html sources beside it, so it is not kept. design/**/beat-studio-*.html + +# What a browser test run leaves behind: the traces and screenshots kept for +# a failure, and the report built from them. Both are about one run on one +# machine and neither is worth keeping. +test-results/ +playwright-report/ +blob-report/ diff --git a/README.md b/README.md index 845abcd..a17ac4a 100644 --- a/README.md +++ b/README.md @@ -28,19 +28,33 @@ npm run build # type check, then build into dist/ npm run preview # serve the built files npm test # run the tests once npm run test:watch # run them again whenever a file changes +npm run test:browser # drive the built site in a real browser ``` +The browser tests need a browser to drive. `npx playwright install chromium` +fetches the one they are pinned to. On a machine that already has a Chromium +and cannot fetch another, point `CHROMIUM_PATH` at it instead. + After a build, the `dist/` folder contains plain static files. You can host that folder on any web server. Paths in the build are relative, so it also works from a subfolder. Every push and pull request runs the type check, the tests and the build on -both versions of Node. The workflow is at `.github/workflows/ci.yml`. +both versions of Node, and the browser tests once. The workflow is at +`.github/workflows/ci.yml`. ## Tests -The tests run under Vitest and live beside what they test, as `*.test.ts`. Run -them with `npm test`. +There are two suites, and the split is not about speed. The Vitest one answers +questions that have an answer on paper: given this curve, what kind of moment +is it. The browser one answers questions that only a browser can be asked: +given this stylesheet and this layout, what does the pointer look like over a +sound. + +### What is a plain function of its inputs + +These run under Vitest and live beside what they test, as `*.test.ts`. Run them +with `npm test`. What is tested is the part of the app that is a plain function of its inputs: what kind of moment a curve in the picture describes, what sound belongs on it, @@ -70,8 +84,37 @@ when they always go up, since they are rebuilt by adding deltas and a delta is never written negative however wrong it is. One invented a curve to stand in for real measurements and tuned it until it passed. -What is not tested yet: anything that draws, and the parts that reach the audio -graph. +### What only a browser can answer + +These run under Playwright, live in `test/browser/` as `*.spec.ts`, and are run +with `npm run test:browser`. They drive the built site rather than the +development server, because one of the faults below was found by comparing what +shipped to the design and would not have shown up any other way. + +They exist because a particular kind of fault kept getting through. A tool +cursor set on the timeline viewport, which every surface inside it overrides, +so picking up the blade changed nothing you could see. A cursor drawn from a +data URI that the browser accepted, reported back intact, and silently failed +to decode. The blade itself looking for `.tl__cue`, a class that has never +existed. A panel dropped into the empty column landing nowhere, because tearing +down the drag collapsed that column before the drop was worked out. Six panel +tabs overflowing their column into a scrollbar that had been switched off, so +two panels could not be opened at all. None of those is reachable from Node, +none is a type error, and every one of them shipped. + +Each test names the fault it is for. `test/browser/app.ts` holds what they all +need: opening the app past the walkthrough, and a clip. The clip is a few +seconds of canvas recorded through `MediaRecorder` in the page — about ninety +kilobytes, made in under a second, and no binary in the repository that +somebody has to take on trust. + +Every test here was checked the same way as the ones above, by putting the +fault back and watching it fail, and writing them found two more: the transport +was hiding its last three controls behind a scrollbar it had disabled, and the +frame rate measurement that runs just after a clip loads was undoing a play +started while it ran. + +What is not tested yet: the parts that reach the audio graph. ## Deploying @@ -936,6 +979,7 @@ src/ work-panel.ts the three tabs the right panel shows one at a time styles/ design tokens and stylesheets test/fixtures/ measurements taken off real clips, for the tests to read +test/browser/ the suite that drives the built site in a real browser public/ files copied to the site root, which is where the icons live ``` diff --git a/package-lock.json b/package-lock.json index 2245f90..7f2176a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "smplr": "^1.0.0" }, "devDependencies": { + "@playwright/test": "^1.62.1", "@types/node": "^26.4.0", "typescript": "^5.9.3", "vite": "^7.3.6", @@ -503,6 +504,22 @@ "node": "^22.20 || ^24.12 || >=25" } }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.62.4", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", @@ -1207,6 +1224,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.26", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", diff --git a/package.json b/package.json index d0b4d0b..be2a490 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "beat-studio", "version": "1.0.0", "private": true, - "description": "Toolcraft ST-88 Beat Studio \u2014 sound design for video in the browser: it reads a clip, says what belongs on each moment, and exports WAV, MP3, MIDI and a marker list that line up.", + "description": "Toolcraft ST-88 Beat Studio — sound design for video in the browser: it reads a clip, says what belongs on each moment, and exports WAV, MP3, MIDI and a marker list that line up.", "type": "module", "engines": { "node": "^20.19.0 || >=22.12.0" @@ -12,6 +12,8 @@ "build": "tsc --noEmit && vite build", "test": "vitest run", "test:watch": "vitest", + "test:browser": "playwright test", + "test:browser:ui": "playwright test --ui", "preview": "vite preview", "typecheck": "tsc --noEmit" }, @@ -21,6 +23,7 @@ "smplr": "^1.0.0" }, "devDependencies": { + "@playwright/test": "^1.62.1", "@types/node": "^26.4.0", "typescript": "^5.9.3", "vite": "^7.3.6", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..9950136 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,107 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * A Chromium that is already on the machine, when there is one. + * + * Playwright pins an exact browser build and fetches it, which is what should + * happen on CI: the version in the lockfile is the version the tests ran + * against. Some machines already carry a Chromium of their own and cannot + * reach the download — a locked-down container, an offline checkout — and + * there the pin is the only thing standing between them and a suite that + * could otherwise run fine. So the path can be given, and nothing changes for + * anybody who does not give it. + */ +const ownChromium = process.env.CHROMIUM_PATH; + +/** + * The browser tests. + * + * Everything under `src/**\/*.test.ts` is a pure function measured in Node, + * which is the right shape for what a curve means or what a WAV header says. + * It is the wrong shape for the interface, and the interface is where the + * faults have actually been: a tool cursor that was set on the wrong element + * and changed nothing, a blade that matched a class name that did not exist, + * a drop zone that collapsed before the drop was worked out, two panel tabs + * sitting past the edge of a column with nothing saying so. Every one of + * those was found by driving a browser by hand and then forgotten, because + * the script that found it lived in a temporary folder. + * + * So these run against the built site rather than the dev server. The dock + * tab fault was found by comparing the built artifact to the design and would + * not have shown up any other way; testing what actually ships costs one + * build and removes a whole class of "worked locally". + */ +export default defineConfig({ + testDir: './test/browser', + // A fault that only appears sometimes is still a fault, and retrying until + // it passes is how a flake becomes permanent. Failures are failures. + retries: 0, + fullyParallel: true, + // One at a time on CI, where the runner has two cores and a parallel run + // measuring layout gets its measurements from a machine under load. + workers: process.env.CI ? 1 : undefined, + /* + * On CI: annotations on the failing lines, a line per test in the log, and + * an HTML report kept as an artifact -- which is the only way to see what a + * headless run on somebody else's machine actually did. + */ + reporter: process.env.CI + ? [['github'], ['list'], ['html', { open: 'never' }]] + : [['list']], + timeout: 30_000, + expect: { timeout: 10_000 }, + + use: { + baseURL: 'http://127.0.0.1:4173', + // Kept only for the run that failed, which is the only one anybody wants + // to look at. + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, + + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + /* + * A fixed window, because half of what these check is measured in + * pixels: what is clipped at 1280, whether a tab sits past the edge + * of a column. A default that varies by machine would make those + * assertions mean different things in different places. + */ + viewport: { width: 1440, height: 900 }, + ...(ownChromium ? { launchOptions: { executablePath: ownChromium } } : {}), + }, + }, + ], + + /* + * Built once, then served. + * + * `vite preview` refuses to start without a build, so the command does + * both. Reused when it is already up, so running these repeatedly while + * working on one does not rebuild the site every time. + * + * `--host 127.0.0.1` names the interface rather than leaving it to be + * worked out. Vite asks Node not to reorder what a name resolves to, so + * `localhost` binds to whichever address the machine's hosts file happens + * to list first: on a machine that has only `127.0.0.1 localhost` that is + * IPv4 and everything works, and on one that also has `::1 localhost` -- + * which is the Ubuntu default, and so what CI runs on -- it binds to IPv6 + * alone and nothing ever answers here. That cost a CI run that timed out + * after two minutes without starting a single test. + * + * Its output is passed through for the same reason: when the server does + * not come up, the timeout says only that it did not, and whatever the + * build or the server said about why is thrown away. + */ + webServer: { + command: 'npm run build && npx vite preview --port 4173 --strictPort --host 127.0.0.1', + url: 'http://127.0.0.1:4173', + reuseExistingServer: !process.env.CI, + timeout: 120_000, + stdout: 'pipe', + stderr: 'pipe', + }, +}); diff --git a/src/sound-design-session.ts b/src/sound-design-session.ts index e25e92b..e57ed9c 100644 --- a/src/sound-design-session.ts +++ b/src/sound-design-session.ts @@ -1160,9 +1160,24 @@ export class SoundDesignSession { await video.play(); const fps = await estimateFps(video); video.pause(); - video.currentTime = 0; // Measuring the rate is not an edit, so it is not something to undo. this.#setProject({ ...this.project, fps }, '', false); + + /* + * Unless somebody started playing while this was going on. + * + * The clip is announced as ready before the rate is measured, and the + * transport comes alive with it, so pressing play inside that half + * second is a fair thing to do. It was answered by this method finishing + * a moment later, stopping the clip and putting it back to the start -- + * a play that turned into a jump to zero with nothing said about why. + * Whoever is driving wins. + */ + if (this.playing) { + void video.play().catch(() => {}); + return; + } + video.currentTime = 0; this.effects.onTime(0); } catch { // Leave the default rate in place; it can be set by hand. diff --git a/src/styles/sound-design.css b/src/styles/sound-design.css index 14da0dc..97836c5 100644 --- a/src/styles/sound-design.css +++ b/src/styles/sound-design.css @@ -1912,23 +1912,30 @@ * are the controls a hand rests on, and putting them in the same line as the * scan settings and the zoom would make one long row of unrelated things, * which is what the top of the window used to be. + * + * It wraps rather than hiding its right hand end. It used to scroll sideways + * with the scrollbar switched off, which is the same trap the panel tabs were + * in: at 1440 the help dot and part of the reference switch sat past the edge, + * and at 1280 the last three controls -- the video window toggle, the + * reference switch and help -- were off it entirely, with nothing on screen + * saying they existed or that the strip could be dragged. Three controls you + * cannot see and cannot reach is worse than a transport an inch taller. + * + * `min-height` rather than `height`, so a row that fits looks exactly as it + * did and one that does not grows instead of swallowing whatever is on the + * end. */ .transport-row { flex: 0 0 auto; - height: 46px; + min-height: 46px; display: flex; + flex-wrap: wrap; align-items: center; + align-content: center; gap: 6px; padding: 0 16px; border-bottom: 1px solid var(--line); background: var(--panel); - overflow-x: auto; - overscroll-behavior-x: contain; - scrollbar-width: none; -} - -.transport-row::-webkit-scrollbar { - display: none; } .transport__time { diff --git a/test/browser/app.ts b/test/browser/app.ts new file mode 100644 index 0000000..b3f72b0 --- /dev/null +++ b/test/browser/app.ts @@ -0,0 +1,184 @@ +import { expect, type Locator, type Page } from '@playwright/test'; + +/** + * Opening the app, and the two things every test needs first. + * + * The walkthrough opens over the top on a first visit and would swallow the + * first click of every test, and most of what is worth checking needs a clip + * loaded. Both are here rather than in each file so that a test reads as the + * thing it is testing. + */ +export async function open(page: Page): Promise { + await page.goto('/'); + // The rail is the last thing built, so its tools mean the app is up. + await expect(page.locator('.rail__tool').first()).toBeVisible(); + + /* + * The walkthrough is dismissed rather than disabled. + * + * It only appears on a first visit, and each test gets a fresh profile, so + * it is always there. Skipping it through its own button is also the only + * way that keeps working if how it is stored ever changes. + */ + const skip = page.getByRole('button', { name: 'Skip', exact: true }); + if (await skip.isVisible().catch(() => false)) await skip.click(); + await expect(skip).toBeHidden(); +} + +/** + * A clip, made in the page rather than committed to the repository. + * + * A few seconds of canvas recorded through MediaRecorder: about ninety + * kilobytes, made in under a second, and no binary in git that somebody has + * to trust. `cuts` puts a white frame at two seconds and a red one at four + * and a half, which is what the scanner reads as moments — so the same clip + * serves both the tests that only need a duration and the ones that need + * something to find. + */ +export async function loadClip( + page: Page, + { seconds = 5, cuts = true, settle = true }: { seconds?: number; cuts?: boolean; settle?: boolean } = {}, +): Promise { + const bytes = await page.evaluate( + async ({ seconds, cuts }) => { + const canvas = document.createElement('canvas'); + canvas.width = 320; + canvas.height = 180; + const ctx = canvas.getContext('2d')!; + const rec = new MediaRecorder(canvas.captureStream(30), { mimeType: 'video/webm' }); + const parts: Blob[] = []; + rec.ondataavailable = (event) => parts.push(event.data); + rec.start(); + + const began = performance.now(); + await new Promise((done) => { + const draw = (): void => { + const at = (performance.now() - began) / 1000; + if (at > seconds) return done(); + let ground = '#101018'; + if (cuts && at > 2 && at < 2.25) ground = '#ffffff'; + if (cuts && at > 4.5 && at < 4.7) ground = '#ff2020'; + ctx.fillStyle = ground; + ctx.fillRect(0, 0, 320, 180); + ctx.fillStyle = '#88ccff'; + ctx.fillRect(40 + Math.sin(at * 2) * 30, 60, 60, 60); + requestAnimationFrame(draw); + }; + draw(); + }); + + rec.stop(); + await new Promise((settled) => (rec.onstop = settled)); + const blob = new Blob(parts, { type: 'video/webm' }); + return Array.from(new Uint8Array(await blob.arrayBuffer())); + }, + { seconds, cuts }, + ); + + /* + * Watched before the file goes in, because what is being waited for happens + * on its own the moment it does. + * + * The frame rate is measured by letting the clip run muted for a moment and + * then putting it back to the start, and until that is over the app is + * driving its own transport. A test that starts in that window is aiming at + * something that is still moving. There is no flag for it, so the thing + * itself is watched: the one play-then-pause that is not anybody's doing. + */ + await page.evaluate(() => { + const video = document.querySelector('video')!; + (window as unknown as { rateMeasured?: boolean }).rateMeasured = false; + video.addEventListener( + 'play', + () => { + video.addEventListener( + 'pause', + () => { + (window as unknown as { rateMeasured?: boolean }).rateMeasured = true; + }, + { once: true }, + ); + }, + { once: true }, + ); + }); + + await page.setInputFiles('input[type=file][accept*=video]', { + name: 'clip.webm', + mimeType: 'video/webm', + buffer: Buffer.from(bytes), + }); + + // The length readout is written from the file, so it is the app itself + // saying the clip is in rather than a guess at how long that takes. + await expect(page.locator('.transport__total')).not.toHaveText('00:00:00:00'); + if (!settle) return; + await page.waitForFunction( + () => (window as unknown as { rateMeasured?: boolean }).rateMeasured === true, + undefined, + { timeout: 15_000 }, + ); +} + +/** + * Wait until the frame rate measurement has taken hold of the clip. + * + * The other half of `settle: false`, for the one test that is about what + * happens if somebody reaches for the transport while this is going on. + * Waiting for it to *start* rather than sleeping for a moment and hoping is + * what makes that test land inside the window every time rather than most + * times. + */ +export async function whileMeasuringRate(page: Page): Promise { + await page.waitForFunction(() => { + const video = document.querySelector('video'); + return !!video && !video.paused && video.currentTime >= 0; + }, undefined, { timeout: 15_000 }); +} + +/** Put one sound on the first layer, at roughly the given fraction along. */ +export async function placeSound(page: Page, along = 0.3): Promise { + const lane = page.locator('.tl__lane').first(); + const box = (await lane.boundingBox())!; + const before = await page.locator('.cue').count(); + await page.mouse.click(box.x + box.width * along, box.y + box.height / 2); + await expect(page.locator('.cue')).toHaveCount(before + 1); +} + +/** + * A point on an element that is actually on screen, as a fraction along it. + * + * The lanes scroll under a fixed frame, and a sound zoomed in far enough runs + * off the right of it and under the panel column, which sits on top. Clicking + * the middle of such a sound by its own box lands on the panel and does + * nothing at all -- which is what the first blade test did, silently, and + * looked for all the world like the blade being broken. + * + * So the point comes from the part of the element inside the frame, and the + * test says how much of it it needs; a run where the sound is barely in view + * fails saying so rather than passing for the wrong reason. + */ +export async function pointOn( + page: Page, + target: Locator, + along = 0.5, + { need = 40 }: { need?: number } = {}, +): Promise<{ x: number; y: number; width: number }> { + const box = (await target.boundingBox())!; + const frame = (await page.locator('.tl__viewport').boundingBox())!; + const from = Math.max(box.x, frame.x); + const to = Math.min(box.x + box.width, frame.x + frame.width); + const width = to - from; + expect(width, 'enough of it is on screen to aim at').toBeGreaterThan(need); + return { x: from + width * along, y: box.y + box.height / 2, width }; +} + +/** Which tool is currently held, by the name on its button. */ +export async function currentTool(page: Page): Promise { + return page.locator('.rail__tool.is-on').getAttribute('aria-label'); +} + +/** The cursor a surface actually shows, as the browser resolves it. */ +export async function cursorOver(page: Page, selector: string): Promise { + return page.locator(selector).first().evaluate((node) => getComputedStyle(node).cursor); +} diff --git a/test/browser/docking.spec.ts b/test/browser/docking.spec.ts new file mode 100644 index 0000000..8a404ab --- /dev/null +++ b/test/browser/docking.spec.ts @@ -0,0 +1,125 @@ +import { expect, test } from '@playwright/test'; +import { open } from './app.ts'; + +/** + * Moving the panels about, and whether it stays moved. + * + * The interesting fault here was invisible in the stylesheet and in the + * source: dropping a tab into the empty left column did nothing at all, + * because tearing down the drag collapsed that column back to no width + * *before* the code worked out which column the pointer was over. The answer + * was therefore always "neither", the panel snapped back, and the one column + * that had to be reachable by dragging was the one column you could not drag + * into. + * + * None of that is reachable without a real pointer over a real layout, which + * is the entire reason this file exists. + */ + +/** The tab strip of a column, by the panel name on the tab. */ +const tab = (page: import('@playwright/test').Page, name: string) => + page.locator('.dock__tab', { hasText: name }).first(); + +/** + * Carry a tab into a column and let go. + * + * The first small move is not incidental. A press only becomes a drag after + * five pixels, and until it is a drag the left column is still closed and + * still has no width to aim at -- so the move that opens it has to happen + * before the move that aims at it. + */ +async function dragTabTo( + page: import('@playwright/test').Page, + name: string, + side: 'left' | 'right', +): Promise { + const from = (await tab(page, name).boundingBox())!; + await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2); + await page.mouse.down(); + await page.mouse.move(from.x + from.width / 2 + 20, from.y + from.height / 2, { steps: 4 }); + + const target = (await page.locator(`.dock--${side}`).boundingBox())!; + expect(target.width, `the ${side} column opened to something to aim at`).toBeGreaterThan(1); + await page.mouse.move(target.x + target.width / 2, target.y + target.height / 2, { steps: 8 }); + await page.mouse.up(); +} + +test.describe('the panel columns', () => { + test.beforeEach(async ({ page }) => { + await open(page); + }); + + test('everything starts in one column, on the right', async ({ page }) => { + await expect(page.locator('.dock--right .dock__tab')).toHaveCount(6); + await expect(page.locator('.dock--left .dock__tab')).toHaveCount(0); + await expect(page.locator('.dock--left')).toHaveClass(/is-empty/); + }); + + /* The one that shipped doing nothing. */ + test('a tab can be dragged into the empty column', async ({ page }) => { + await dragTabTo(page, 'Moments', 'left'); + + await expect(page.locator('.dock--left .dock__tab', { hasText: 'Moments' })).toHaveCount(1); + await expect(page.locator('.dock--right .dock__tab', { hasText: 'Moments' })).toHaveCount(0); + await expect(page.locator('.dock--left')).not.toHaveClass(/is-empty/); + + // And it is the panel that is there, not just its name: a tab that moves + // without its body is the same as a tab that did not move. + const left = (await page.locator('.dock--left').boundingBox())!; + const body = (await page.locator('.dock--left .dock__body').boundingBox())!; + expect(body.width, 'the panel came with it').toBeGreaterThan(0); + expect(left.width, 'the column took room of its own').toBeGreaterThan(100); + }); + + test('and dragged back again', async ({ page }) => { + await dragTabTo(page, 'Moments', 'left'); + await dragTabTo(page, 'Moments', 'right'); + await expect(page.locator('.dock--right .dock__tab')).toHaveCount(6); + await expect(page.locator('.dock--left')).toHaveClass(/is-empty/); + }); + + /* + * An arrangement nobody can get back to is not an arrangement. + * + * Checked through a reload rather than by reading what was stored, because + * what matters is that it comes back, and a stored layout is read against + * the panels that exist now -- which is the part with the edge cases. + */ + test('where the panels were put is where they are next time', async ({ page }) => { + await dragTabTo(page, 'Sounds', 'left'); + await expect(page.locator('.dock--left .dock__tab', { hasText: 'Sounds' })).toHaveCount(1); + + await page.reload(); + await expect(page.locator('.rail__tool').first()).toBeVisible(); + + await expect(page.locator('.dock--left .dock__tab', { hasText: 'Sounds' })).toHaveCount(1); + await expect(page.locator('.dock--right .dock__tab')).toHaveCount(5); + }); + + /* + * A closed panel is by definition not somewhere you can click, so the + * window menu is the only way back to it. If that is wrong the panel is + * gone for good, which is the worst thing docking can do. + */ + test('a panel can be put away from the window menu, and got back', async ({ page }) => { + await page.getByRole('button', { name: 'Window' }).first().click(); + await page.getByRole('menuitem', { name: 'Palette' }).click(); + await expect(page.locator('.dock__tab', { hasText: 'Palette' })).toHaveCount(0); + + await page.getByRole('button', { name: 'Window' }).first().click(); + await page.getByRole('menuitem', { name: 'Palette' }).click(); + await expect(page.locator('.dock__tab', { hasText: 'Palette' })).toHaveCount(1); + }); + + test('and everything can be put back where it started', async ({ page }) => { + await dragTabTo(page, 'Moments', 'left'); + await dragTabTo(page, 'Selected', 'left'); + await expect(page.locator('.dock--left .dock__tab')).toHaveCount(2); + + await page.getByRole('button', { name: 'Window' }).first().click(); + await page.getByRole('menuitem', { name: 'Put the panels back' }).click(); + + await expect(page.locator('.dock--right .dock__tab')).toHaveCount(6); + await expect(page.locator('.dock--left')).toHaveClass(/is-empty/); + }); +}); diff --git a/test/browser/layout.spec.ts b/test/browser/layout.spec.ts new file mode 100644 index 0000000..2cdf675 --- /dev/null +++ b/test/browser/layout.spec.ts @@ -0,0 +1,154 @@ +import { expect, test } from '@playwright/test'; +import { loadClip, open } from './app.ts'; + +/** + * Whether everything on screen is actually reachable. + * + * The faults this is for are the quiet kind. Six panel tabs overflowed their + * column by seventy-five pixels into a hidden scrollbar, so two panels could + * not be opened by their tab at all and nothing anywhere said so -- no error, + * no warning, and a screenshot of the top of the column that looks correct. + * The same shape of fault put controls past the right edge of the app bar. + * + * All of it is arithmetic on boxes, which is exactly what a browser is for + * and exactly what neither a type checker nor a test of a pure function can + * do. + */ + +/** Every window width the layout claims to work at, and why that one. */ +const WIDTHS = [ + { width: 1440, height: 900, why: 'a laptop with the browser filling it' }, + { width: 1280, height: 800, why: 'the narrowest the stylesheet has a case for' }, +]; + +for (const { width, height, why } of WIDTHS) { + test.describe(`at ${width}×${height} — ${why}`, () => { + test.beforeEach(async ({ page }) => { + await page.setViewportSize({ width, height }); + await open(page); + }); + + /* + * The one that shipped. + * + * Measured against the column each tab is in rather than against the + * window, because that is the box that clips them: the column is a fixed + * width and the strip inside it was a single row. + */ + test('every panel tab is inside the column it belongs to', async ({ page }) => { + const columns = await page.locator('.dock:not(.is-empty)').all(); + expect(columns.length, 'there is a column of panels').toBeGreaterThan(0); + + for (const column of columns) { + const box = (await column.boundingBox())!; + const tabs = await column.locator('.dock__tab').all(); + expect(tabs.length, 'the column has tabs').toBeGreaterThan(0); + + for (const tab of tabs) { + const name = (await tab.textContent())?.trim() ?? '?'; + const at = (await tab.boundingBox())!; + // A pixel of slack, because a border can land on a half pixel and + // that is not a tab anybody cannot click. + expect(at.x + at.width, `“${name}” runs past the right of its column`) + .toBeLessThanOrEqual(box.x + box.width + 1); + expect(at.x, `“${name}” starts left of its column`) + .toBeGreaterThanOrEqual(box.x - 1); + expect(at.y + at.height, `“${name}” runs past the bottom of its column`) + .toBeLessThanOrEqual(box.y + box.height + 1); + } + } + }); + + /* + * And each of them opens the panel it names. + * + * Being inside the column is necessary and not sufficient: a tab can be + * drawn in the right place and covered by something, which a click finds + * out and a measurement does not. + */ + test('every panel can be opened by its tab', async ({ page }) => { + const tabs = await page.locator('.dock__tab').all(); + const names = await Promise.all(tabs.map(async (tab) => (await tab.textContent())!.trim())); + + for (const name of names) { + const tab = page.locator('.dock__tab', { hasText: name }).first(); + await tab.click({ timeout: 5_000 }); + await expect(tab, `“${name}” did not come to the front`).toHaveClass(/is-on/); + } + expect(names.length, 'there were panels to check').toBeGreaterThan(0); + }); + + /** Nothing in the app bar runs off the end of it. */ + test('the app bar keeps its controls inside itself', async ({ page }) => { + const bar = (await page.locator('.appbar').boundingBox())!; + for (const selector of ['.appbar__export', '.appbar__title']) { + const at = (await page.locator(selector).boundingBox())!; + expect(at.x + at.width, `${selector} runs past the end of the bar`) + .toBeLessThanOrEqual(bar.x + bar.width + 1); + } + }); + + /* + * The page itself does not scroll sideways. + * + * The timeline scrolls, on purpose, inside its own frame. The document + * doing it means something is wider than the window, which is the state + * every one of these faults is a symptom of. + */ + test('the window does not scroll sideways', async ({ page }) => { + await loadClip(page); + const over = await page.evaluate(() => ({ + scroll: document.documentElement.scrollWidth, + client: document.documentElement.clientWidth, + })); + expect(over.scroll, 'the page is wider than the window').toBeLessThanOrEqual(over.client); + }); + + /* + * Nothing is parked behind a scrollbar that has been switched off. + * + * This is the general form of the fault above, and it is worth stating + * generally because it has now happened twice in the same codebase: a + * strip of controls given `overflow: auto` and a hidden scrollbar looks + * finished at the width it was built at and quietly swallows its right + * hand end at every other one. A strip that scrolls on purpose says so + * with a scrollbar; these do not, so they must fit. + */ + test('no strip of controls hides its end', async ({ page }) => { + const strips = ['.transport-row', '.appbar', '.dock__tabs', '.rail']; + for (const selector of strips) { + const over = await page.locator(selector).first().evaluate((node) => ({ + need: node.scrollWidth, + have: node.clientWidth, + tall: node.scrollHeight, + room: node.clientHeight, + })); + expect(over.need, `${selector} is wider than itself`).toBeLessThanOrEqual(over.have); + expect(over.tall, `${selector} is taller than itself`).toBeLessThanOrEqual(over.room); + } + }); + + /* + * The transport went down onto the timeline, and all of it has to fit. + * + * That move was the whole point of the rearrangement, and the thing that + * makes it a bad move is a transport whose right hand end -- the two + * switches and help -- is off the edge. It was: the strip scrolled + * sideways with its scrollbar switched off, so at 1280 those three were + * simply not on screen and nothing said so. + */ + test('the transport fits on the timeline', async ({ page }) => { + const row = (await page.locator('.transport-row').boundingBox())!; + const controls = await page.locator('.transport-row button, .transport-row select').all(); + expect(controls.length, 'the transport has controls').toBeGreaterThan(4); + + for (const control of controls) { + const at = await control.boundingBox(); + if (!at || at.width === 0) continue; // Deliberately hidden, like the length box. + const label = (await control.getAttribute('title')) ?? (await control.textContent()) ?? '?'; + expect(at.x + at.width, `“${label.trim()}” runs past the end of the transport`) + .toBeLessThanOrEqual(row.x + row.width + 1); + } + }); + }); +} diff --git a/test/browser/menus.spec.ts b/test/browser/menus.spec.ts new file mode 100644 index 0000000..8f3e2a2 --- /dev/null +++ b/test/browser/menus.spec.ts @@ -0,0 +1,136 @@ +import { expect, test } from '@playwright/test'; +import { loadClip, open, placeSound, pointOn } from './app.ts'; + +/** + * Right-clicking, and whether the answer is about what was clicked. + * + * There is one listener for the whole timeline rather than one per drawn + * thing, and it works out what you meant from where the press landed. That is + * the right way round -- the lanes are rebuilt constantly and per-element + * menus would be rebuilt with them -- but it means the menu being correct + * depends entirely on a chain of `closest` calls against class names, which + * is the exact shape of thing that has already been wrong once here: the + * blade looked for `.tl__cue`, which has never existed. + * + * A menu is also the only place in the app that can be positioned off screen, + * so that is checked too. + */ +test.describe('the context menus', () => { + test.beforeEach(async ({ page }) => { + await open(page); + await loadClip(page); + }); + + test('a sound offers what you can do with a sound', async ({ page }) => { + await placeSound(page); + await page.locator('.cue__head').first().click({ button: 'right' }); + + const menu = page.getByRole('menu'); + await expect(menu).toBeVisible(); + for (const label of ['Hear it', 'Cut it short here', 'Delete sound']) { + await expect(menu.getByRole('menuitem', { name: label })).toBeVisible(); + } + // And not the ones that belong to something else, which is what makes it + // a menu about this sound rather than a menu about the timeline. + await expect(menu.getByRole('menuitem', { name: 'Place a sound here' })).toHaveCount(0); + }); + + test('an empty lane offers what can arrive on it', async ({ page }) => { + const lane = page.locator('.tl__lane').first(); + const at = await pointOn(page, lane, 0.4); + await page.mouse.click(at.x, at.y, { button: 'right' }); + + const menu = page.getByRole('menu'); + await expect(menu.getByRole('menuitem', { name: 'Place a sound here' })).toBeVisible(); + await expect(menu.getByRole('menuitem', { name: 'Play from here' })).toBeVisible(); + await expect(menu.getByRole('menuitem', { name: 'Hear it' })).toHaveCount(0); + }); + + test('a layer offers what you can do to a layer', async ({ page }) => { + await page.locator('[data-gutter-layer]').first().click({ button: 'right' }); + + const menu = page.getByRole('menu'); + for (const label of ['Rename…', 'Solo', 'Add a layer below']) { + await expect(menu.getByRole('menuitem', { name: label })).toBeVisible(); + } + }); + + /* + * And it does the thing it says. + * + * A menu that opens correctly and then acts on the wrong sound, or on + * nothing, looks identical in a screenshot. + */ + test('choosing from the menu acts on what was clicked', async ({ page }) => { + await placeSound(page, 0.2); + await placeSound(page, 0.6); + await expect(page.locator('.cue')).toHaveCount(2); + + await page.locator('.cue__head').first().click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Delete sound' }).click(); + + await expect(page.locator('.cue')).toHaveCount(1); + await expect(page.getByRole('menu')).toHaveCount(0); + }); + + test('Escape closes it, and so does a press elsewhere', async ({ page }) => { + await placeSound(page); + + await page.locator('.cue__head').first().click({ button: 'right' }); + await expect(page.getByRole('menu')).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(page.getByRole('menu')).toHaveCount(0); + + await page.locator('.cue__head').first().click({ button: 'right' }); + await expect(page.getByRole('menu')).toBeVisible(); + await page.locator('.appbar').click({ position: { x: 4, y: 4 } }); + await expect(page.getByRole('menu')).toHaveCount(0); + }); + + /* + * The browser's own menu does not also appear. + * + * Two menus at once is the state this replaced, and the only thing stopping + * it is one `preventDefault` that runs after the items have been worked + * out -- so a menu that comes up empty for any reason silently goes back to + * offering to reload the page. + */ + test('the browser keeps its own menu to itself', async ({ page }) => { + await placeSound(page); + const stopped = page.evaluate( + () => + new Promise((done) => { + window.addEventListener('contextmenu', (event) => done(event.defaultPrevented), { + once: true, + }); + }), + ); + await page.locator('.cue__head').first().click({ button: 'right' }); + expect(await stopped, 'the default menu was not stopped').toBe(true); + }); + + /* + * A menu near the bottom right of the window opens back towards the middle. + * + * Placed after it is in the document, because until then it has no size, + * and flipped rather than clamped -- a menu shoved up against the bottom + * edge covers the thing it is about. + */ + test('a menu at the edge stays on screen', async ({ page }) => { + const window = page.viewportSize()!; + const lane = page.locator('.tl__lane').last(); + const box = (await lane.boundingBox())!; + const frame = (await page.locator('.tl__viewport').boundingBox())!; + // As near the bottom right of the timeline as there is a lane to click. + const x = Math.min(box.x + box.width, frame.x + frame.width) - 6; + await page.mouse.click(x, box.y + box.height - 4, { button: 'right' }); + + const menu = page.getByRole('menu'); + await expect(menu).toBeVisible(); + const at = (await menu.boundingBox())!; + expect(at.x, 'off the left').toBeGreaterThanOrEqual(0); + expect(at.y, 'off the top').toBeGreaterThanOrEqual(0); + expect(at.x + at.width, 'off the right').toBeLessThanOrEqual(window.width); + expect(at.y + at.height, 'off the bottom').toBeLessThanOrEqual(window.height); + }); +}); diff --git a/test/browser/project.spec.ts b/test/browser/project.spec.ts new file mode 100644 index 0000000..2edfd8a --- /dev/null +++ b/test/browser/project.spec.ts @@ -0,0 +1,203 @@ +import { expect, test } from '@playwright/test'; +import { loadClip, open, whileMeasuringRate } from './app.ts'; + +/** The length readout, which is also the control that sets it. */ +const total = (page: import('@playwright/test').Page) => page.locator('.transport__total'); +const clock = (page: import('@playwright/test').Page) => page.locator('.transport__clock'); + +/** Seconds, from a timecode as the transport prints it. */ +function seconds(text: string, fps = 25): number { + const [h, m, s, f] = text.split(':').map(Number); + return h * 3600 + m * 60 + s + f / fps; +} + +/** Type a new length into the readout and commit it. */ +async function setLength(page: import('@playwright/test').Page, typed: string): Promise { + await total(page).click(); + const box = page.getByLabel('Length of the piece'); + await expect(box).toBeVisible(); + await box.fill(typed); + await box.press('Enter'); +} + +/** + * Move the playhead to a fraction of the way through the piece. + * + * By fraction of the whole rather than of what happens to be on screen. Those + * are not the same thing and assuming they were is how the first version of + * this quietly tested four seconds while claiming to test eleven: the lanes + * are drawn at a fixed scale, so a twelve second piece is two and a half + * frames wide and the far end of it is off to the right. So it is scrolled + * into view first, and the moment it actually landed on is read back from the + * clock rather than assumed. + */ +async function scrubTo(page: import('@playwright/test').Page, along: number): Promise { + await page.locator('.tl__viewport').evaluate((frame, at) => { + const content = frame.querySelector('.tl__content') as HTMLElement; + frame.scrollLeft = Math.max(0, content.offsetWidth * (at as number) - frame.clientWidth / 2); + }, along); + + const content = (await page.locator('.tl__content').boundingBox())!; + const frame = (await page.locator('.tl__viewport').boundingBox())!; + const ruler = (await page.locator('.tl__ruler').boundingBox())!; + const x = content.x + content.width * along; + expect(x, 'the moment is on screen to be clicked').toBeGreaterThan(frame.x); + expect(x, 'the moment is on screen to be clicked').toBeLessThan(frame.x + frame.width); + + await page.mouse.click(x, ruler.y + ruler.height / 2); +} + +/** + * How long the piece is, and the playhead's right to leave the picture. + * + * These are two halves of the same idea. The length of the piece used to be + * whatever the video happened to be, and the playhead used to stop dead on + * its last frame -- so a tail that rings out past the final cut had nowhere + * to be and no way to be heard. Both are driven by a clock that hands over + * from the video element to the audio context at the end of the clip, which + * is a piece of timing no pure function test can stand in for. + */ +test.describe('the length of the piece', () => { + test.beforeEach(async ({ page }) => { + await open(page); + await loadClip(page); + }); + + test('starts as the length of the clip that was loaded', async ({ page }) => { + const shown = seconds(await total(page).innerText()); + expect(shown, 'about the five seconds that were recorded').toBeGreaterThan(3); + expect(shown).toBeLessThan(8); + }); + + test('can be typed as full timecode', async ({ page }) => { + await setLength(page, '00:00:20:00'); + await expect(total(page)).toHaveText('00:00:20:00'); + }); + + /* Nobody types four fields to say thirty seconds. */ + test('can be typed as a bare number of seconds', async ({ page }) => { + await setLength(page, '30'); + await expect(total(page)).toHaveText('00:00:30:00'); + }); + + /* + * A field that quietly keeps the old value looks exactly like one that took + * a value and lost it, so this says so. + */ + test('says so when what was typed is not a length', async ({ page }) => { + await setLength(page, 'later'); + await expect(page.locator('.status, [class*="status"]').first()).toContainText('not a length'); + await expect(total(page)).not.toHaveText('00:00:00:00'); + }); + + /* + * Making the piece longer makes the ruler longer. + * + * The point of setting a length is the room it gives you; a number that + * changes on screen and leaves the lanes the width they were has done + * nothing. + */ + test('a longer piece is a longer timeline', async ({ page }) => { + const before = await page.locator('.tl__content').evaluate((n) => n.getBoundingClientRect().width); + await setLength(page, '60'); + await expect + .poll(async () => page.locator('.tl__content').evaluate((n) => n.getBoundingClientRect().width)) + .toBeGreaterThan(before); + }); +}); + +test.describe('the playhead past the end of the video', () => { + test.beforeEach(async ({ page }) => { + await open(page); + await loadClip(page); + // Twice the clip, so there is a stretch with no picture under it. + await setLength(page, '00:00:12:00'); + await expect(total(page)).toHaveText('00:00:12:00'); + }); + + /* + * The ruler can be dragged past the last frame. + * + * It could not: the playhead was the video's own currentTime and nothing + * else, so it stopped dead wherever the clip did however long the piece + * was, and the stretch you had just made was unreachable. + */ + test('the ruler scrubs into the empty stretch', async ({ page }) => { + await scrubTo(page, 0.85); + + await expect + .poll(async () => seconds(await clock(page).innerText()), { + message: 'the clock went past the end of the clip', + }) + .toBeGreaterThan(6); + }); + + /* + * And it keeps running there. + * + * This is the handover: the video element ends, and the clock carries on + * off the audio context instead. Started from inside the clip rather than + * after it, so what is measured is the crossing itself. + */ + test('playing runs on through the end of the clip', async ({ page }) => { + // A third of the way into twelve seconds, which is inside the five + // second clip with a second or so to spare. + await scrubTo(page, 1 / 3); + await expect + .poll(async () => seconds(await clock(page).innerText()), { message: 'it started inside the clip' }) + .toBeGreaterThan(2); + + await page.getByTitle('Play or pause (Space)').click(); + await expect + .poll(async () => seconds(await clock(page).innerText()), { + message: 'the clock crossed the end of the clip while playing', + timeout: 20_000, + }) + .toBeGreaterThan(6.5); + + // And the playhead went with it rather than being left on the last frame. + const head = (await page.locator('.tl__playhead').boundingBox())!; + const content = (await page.locator('.tl__content').boundingBox())!; + const along = (head.x - content.x) / content.width; + expect(along, 'the playhead is past halfway through a twelve second piece') + .toBeGreaterThan(0.45); + }); +}); + +/* + * Reaching for the transport before the app has finished with the clip. + * + * Loading a clip is not one step. The length appears, the transport comes + * alive, and then the frame rate is measured by letting the picture run muted + * for half a second and putting it back to the start -- and that last part + * used to stop whatever you had started and drop you at zero, because it + * reset the position unconditionally. Nothing said why; it looked like the + * play button had simply not worked. + * + * Every other test in this file waits that window out, which is what makes + * this one worth writing: it is the only place the window is aimed at + * deliberately. + */ +test.describe('while the clip is still being measured', () => { + test('pressing play is not undone by the measurement', async ({ page }) => { + await open(page); + await loadClip(page, { settle: false }); + await whileMeasuringRate(page); + + await page.getByTitle('Play or pause (Space)').click(); + + // Still playing once the measurement is over and done with, rather than + // stopped at the top of the clip. + await page.waitForFunction( + () => (window as unknown as { rateMeasured?: boolean }).rateMeasured === true, + undefined, + { timeout: 15_000 }, + ); + await expect + .poll(async () => seconds(await clock(page).innerText()), { + message: 'the playhead kept running', + }) + .toBeGreaterThan(1); + await expect(page.locator('.play-btn')).toHaveClass(/is-playing/); + }); +}); diff --git a/test/browser/tools.spec.ts b/test/browser/tools.spec.ts new file mode 100644 index 0000000..1ee3736 --- /dev/null +++ b/test/browser/tools.spec.ts @@ -0,0 +1,192 @@ +import { expect, test } from '@playwright/test'; +import { cursorOver, currentTool, loadClip, open, placeSound, pointOn } from './app.ts'; + +/** + * The tools: what they say they are, and what they do. + * + * Both halves have been wrong in ways nothing caught. The cursor was set on + * the timeline viewport, which every surface inside it overrides, so for one + * release picking up a tool changed nothing you could see. The blade looked + * for `.tl__cue`, a class that has never existed, so it could not find a + * sound to cut. Neither is the sort of thing a type checker or a pure + * function test can reach. + */ +test.describe('the tools', () => { + test.beforeEach(async ({ page }) => { + await open(page); + }); + + test('the letter keys change tool', async ({ page }) => { + for (const [key, name] of [ + ['t', 'Range tool'], + ['c', 'Cut tool'], + ['h', 'Hand tool'], + ['z', 'Zoom tool'], + ['p', 'Pen tool'], + ['v', 'Move tool'], + ] as const) { + await page.keyboard.press(key); + expect(await currentTool(page), `pressing ${key}`).toBe(name); + } + }); + + /* + * The one that shipped broken. + * + * Checked over a sound rather than over empty lane, because a sound is what + * exposed it: `.cue__head` sets `grab` of its own, and a rule on the + * viewport loses to it. Every tool looked identical there while looking + * correct in the stylesheet. + * + * The expected shapes are named rather than merely compared to each other, + * because the hand's own cursor is `grab` too. "Not what the surface said" + * would call the hand a failure for agreeing with it by coincidence, and + * "all different" alone would not say which one had gone wrong. + */ + test('the cursor over a sound says which tool is held', async ({ page }) => { + await loadClip(page); + await placeSound(page); + + const seen = new Map(); + for (const [key, name, want] of [ + ['t', 'Range', 'text'], + ['c', 'Cut', 'url('], + ['h', 'Hand', 'grab'], + ['z', 'Zoom', 'zoom-in'], + ['p', 'Pen', 'url('], + ] as const) { + await page.keyboard.press(key); + const cursor = await cursorOver(page, '.cue__head'); + expect(cursor, `${name} over a sound`).toContain(want); + seen.set(name, cursor); + } + + // And each is its own, which is the whole point: two tools sharing a + // cursor is the same as neither having one. This is what the fault + // actually looked like -- five rows all reading `grab`. + expect(new Set(seen.values()).size, `distinct cursors, got ${JSON.stringify([...seen])}`) + .toBe(seen.size); + }); + + /* + * Move is the deliberate exception, and worth pinning as one. + * + * It sets no cursor of its own so that every surface keeps saying what it + * can do -- grab over a sound, an I-beam over a name. That is a decision + * rather than an omission, and without a test it reads like the fault above + * to the next person who goes looking. + */ + test('the move tool leaves each surface saying what it does', async ({ page }) => { + await loadClip(page); + await placeSound(page); + await page.keyboard.press('v'); + expect(await currentTool(page)).toBe('Move tool'); + expect(await cursorOver(page, '.cue__head'), 'a sound offers to be moved').toBe('grab'); + expect(await cursorOver(page, '.tl__ruler'), 'the ruler offers to be scrubbed').toBe('ew-resize'); + }); + + /* + * A cursor drawn from a data URI can be accepted, reported back intact, and + * still fail to decode, at which point it silently falls through to the + * plain keyword after the comma. Both of these shipped in that state. The + * only way to know is to load it as an image. + */ + test('the drawn cursors are real images', async ({ page }) => { + for (const [key, name] of [['c', 'Cut'], ['p', 'Pen']] as const) { + await page.keyboard.press(key); + const cursor = await cursorOver(page, '.tl__viewport'); + expect(cursor, `${name} should be drawn`).toContain('url('); + + const loaded = await page.evaluate(async (value: string) => { + const from = value.indexOf('url("'); + const to = value.lastIndexOf('")'); + if (from < 0 || to < 0) return { ok: false, why: 'no url in the value' }; + const src = value.slice(from + 5, to); + return new Promise<{ ok: boolean; why?: string; w?: number }>((done) => { + const img = new Image(); + img.onload = () => done({ ok: true, w: img.naturalWidth }); + img.onerror = () => done({ ok: false, why: 'it did not decode' }); + img.src = src; + }); + }, cursor); + + expect(loaded.ok, `${name} cursor: ${loaded.why ?? ''}`).toBe(true); + expect(loaded.w).toBeGreaterThan(0); + } + }); + + test('alt over the zoom tool offers to go the other way', async ({ page }) => { + await page.keyboard.press('z'); + expect(await cursorOver(page, '.tl__viewport')).toBe('zoom-in'); + await page.keyboard.down('Alt'); + expect(await cursorOver(page, '.tl__viewport')).toBe('zoom-out'); + await page.keyboard.up('Alt'); + expect(await cursorOver(page, '.tl__viewport')).toBe('zoom-in'); + }); + + test('the hand drags the view without moving anything on it', async ({ page }) => { + await loadClip(page); + await placeSound(page); + // Zoomed in, so there is somewhere to pan to. + for (let i = 0; i < 3; i += 1) await page.getByRole('button', { name: '+', exact: true }).click(); + + const cues = await page.locator('.cue').count(); + const lane = (await page.locator('.tl__lane').first().boundingBox())!; + await page.keyboard.press('h'); + await page.mouse.move(lane.x + 400, lane.y + 10); + await page.mouse.down(); + await page.mouse.move(lane.x + 150, lane.y + 10, { steps: 8 }); + await page.mouse.up(); + + const scrolled = await page.locator('.tl__viewport').evaluate((n) => n.scrollLeft); + expect(scrolled, 'the view moved').toBeGreaterThan(0); + expect(await page.locator('.cue').count(), 'nothing was added or removed').toBe(cues); + }); + + /* + * The blade, which spent a release unable to find a sound at all. + * + * Zoomed in first because a sound is drawn at a ten pixel minimum, and a + * cut inside the first frame of one is refused rather than making something + * of no length: at the default scale the whole sound is inside that. Placed + * near the start of the piece for the same reason the click is taken from + * `pointOn` -- zoomed in this far, a sound anywhere else has run off under + * the panel column, and a click there hits the panel. + */ + test('the blade cuts a sound short where it is clicked', async ({ page }) => { + await loadClip(page); + await placeSound(page, 0.05); + for (let i = 0; i < 4; i += 1) await page.getByRole('button', { name: '+', exact: true }).click(); + await page.locator('.tl__viewport').evaluate((n) => { n.scrollLeft = 0; }); + + const cue = page.locator('.cue').first(); + const before = (await cue.boundingBox())!; + const at = await pointOn(page, cue, 0.5); + + await page.keyboard.press('c'); + await page.mouse.click(at.x, at.y); + + await expect + .poll(async () => (await cue.boundingBox())!.width, { message: 'the sound got shorter' }) + .toBeLessThan(before.width); + }); + + test('the range tool draws a stretch, and Delete clears what is inside it', async ({ page }) => { + await loadClip(page); + await placeSound(page, 0.2); + await placeSound(page, 0.5); + const placed = await page.locator('.cue').count(); + + await page.keyboard.press('t'); + const lane = (await page.locator('.tl__lane').first().boundingBox())!; + await page.mouse.move(lane.x + 5, lane.y + 10); + await page.mouse.down(); + await page.mouse.move(lane.x + lane.width * 0.8, lane.y + 10, { steps: 10 }); + await page.mouse.up(); + + await expect(page.locator('.tl__range')).toBeVisible(); + await page.keyboard.press('Delete'); + await expect(page.locator('.cue')).toHaveCount(0); + expect(placed, 'there was something to clear').toBeGreaterThan(0); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 48ab769..8cabc33 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -40,5 +40,5 @@ * build was green, the deployment was not. Anything that gets deployed is * checked. */ - "include": ["src", "api", "test", "vite.config.ts", "vitest.config.ts"] + "include": ["src", "api", "test", "vite.config.ts", "vitest.config.ts", "playwright.config.ts"] }