diff --git a/eslint.config.js b/eslint.config.js
index 65e95662..06caf820 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -1,7 +1,5 @@
import js from '@eslint/js'
import globals from 'globals'
-import reactHooks from 'eslint-plugin-react-hooks'
-import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
@@ -36,16 +34,6 @@ export default defineConfig([
},
},
- // React rules apply to the app only. Playwright specs and build scripts are
- // not React, and the hooks plugin cannot tell Playwright's `use` fixture
- // callback from React's `use` hook — it reported three rules-of-hooks
- // violations in an earlier fixture file for ordinary fixture
- // code.
- {
- files: ['src/**/*.{ts,tsx}'],
- extends: [reactHooks.configs.flat.recommended, reactRefresh.configs.vite],
- },
-
// #550 AC6/NFR: on `next/svelte-webgpu` the WebGPU surface lives in
// `src/lib/renderer/` (the hand-written engine). `@webgpu/types` is allowed
// there and nowhere else; `three` is forbidden there, which is what makes
diff --git a/index.html b/index.html
deleted file mode 100644
index 2314bd01..00000000
--- a/index.html
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
-
- virtualrow
-
-
-
-
-
-
diff --git a/playwright/playwright.config.next.ts b/playwright/playwright.config.next.ts
index 4a3fa7d4..1bbe1cea 100644
--- a/playwright/playwright.config.next.ts
+++ b/playwright/playwright.config.next.ts
@@ -43,9 +43,10 @@ const EXTRA_IGNORE = [
'**/oar-clearance.spec.ts',
'**/quality-tier-endurance.spec.ts',
'**/route-performance.spec.ts',
- '**/route-performance-budgets.spec.ts',
+ // #542 AC1: `route-performance-budgets.spec.ts` and `rower-stroke.spec.ts`
+ // read the deleted React render-budget / stroke-pose modules; they go with
+ // the React tree. A new WebGPU equivalent re-lands on the GPU runner.
'**/rower-slide.spec.ts',
- '**/rower-stroke.spec.ts',
'**/scene-context-endurance.spec.ts',
'**/scene-contrast.spec.ts',
'**/scene-telemetry.spec.ts',
diff --git a/playwright/tests/route-performance-budgets.spec.ts b/playwright/tests/route-performance-budgets.spec.ts
deleted file mode 100644
index 455a963c..00000000
--- a/playwright/tests/route-performance-budgets.spec.ts
+++ /dev/null
@@ -1,322 +0,0 @@
-import { test, expect, type Page } from '../fixtures/crash-watch';
-import * as fs from 'fs';
-import * as path from 'path';
-import { fileURLToPath } from 'url';
-import { expectSceneAlive } from '../utils/scene-health';
-import { RENDER_BUDGET, overBudget } from '../../src/components/rower3d/renderBudget';
-
-/**
- * Issue #224 — the runtime budgets: sustained frame rate, GPU memory, and no
- * visual regression on the demo route.
- *
- * These read the app's own telemetry rather than timing from the test process.
- * Where a budget cannot be honestly enforced in this environment the spec says
- * so at the assertion, and logs the measurement either way.
- */
-
-const __dirname = path.dirname(fileURLToPath(import.meta.url));
-const mockBluetoothPath = path.resolve(__dirname, '../mock-bluetooth.js');
-
-const METERS_PER_DEGREE_LAT = 111_195;
-
-function windingCourse(points: number, lengthMeters: number, bends: number, amplitude = 60) {
- const originLat = 51.45;
- const lngScale = METERS_PER_DEGREE_LAT * Math.cos((originLat * Math.PI) / 180);
- return Array.from({ length: points }, (_, i) => {
- const t = i / (points - 1);
- return [
- (Math.sin(t * bends * 2 * Math.PI) * amplitude) / lngScale,
- originLat + (t * lengthMeters) / METERS_PER_DEGREE_LAT,
- ];
- });
-}
-
-function geoJsonFile(name: string, coordinates: number[][]) {
- return {
- name: `${name}.geojson`,
- mimeType: 'application/geo+json',
- buffer: Buffer.from(
- JSON.stringify({
- type: 'Feature',
- properties: { name },
- geometry: { type: 'LineString', coordinates },
- }),
- ),
- };
-}
-
-async function waitForDeviceConnected(page: Page, deviceLabel: string) {
- await page.waitForFunction(
- (label) => {
- const containers = Array.from(document.querySelectorAll('.bluetooth-device-container'));
- const target = containers.find((c) =>
- c.querySelector('.device-name')?.textContent?.includes(label),
- );
- return target?.querySelector('.device-status')?.textContent?.includes('Connected') ?? false;
- },
- deviceLabel,
- { timeout: 10_000 },
- );
-}
-
-async function connectHardwareAndStart(page: Page) {
- await page.getByRole('button', { name: 'Connect PM5', exact: true }).click();
- await waitForDeviceConnected(page, 'Concept2 PM5');
- await page.evaluate(() => {
- const containers = Array.from(document.querySelectorAll('.bluetooth-device-container'));
- const hr = containers.find((c) =>
- c.querySelector('.device-name')?.textContent?.includes('Heart Rate Monitor'),
- );
- (hr?.querySelector('button.btn-connect') as HTMLButtonElement)?.click();
- });
- await waitForDeviceConnected(page, 'Heart Rate Monitor');
-
- await expect(page.locator('.btn-start-workout')).toBeEnabled({ timeout: 10_000 });
- await page.evaluate(() => {
- (document.querySelector('.btn-start-workout') as HTMLButtonElement)?.click();
- });
- await expect(page.locator('.rower3d-canvas-container')).toBeVisible({ timeout: 30_000 });
- await expectSceneAlive(page, 'the budgeted route scene');
-}
-
-async function rowGeneratedCourse(page: Page, routeName: string, coordinates: number[][]) {
- await page.addInitScript({ content: fs.readFileSync(mockBluetoothPath, 'utf8') });
- await page.goto('./');
-
- await page.getByRole('button', { name: 'Routes', exact: true }).click();
- await page.getByRole('button', { name: /import a file/i }).click();
- await page.getByLabel('Route name').fill(routeName);
- await page
- .locator('.route-import input[type="file"]')
- .setInputFiles(geoJsonFile(routeName, coordinates));
- await expect(page.locator('.route-info-overlay h2')).toContainText(routeName, {
- timeout: 30_000,
- });
-
- await connectHardwareAndStart(page);
-}
-
-/**
- * True when the page is drawing through a software rasteriser.
- *
- * SwiftShader is what CI has, and it is one to two orders of magnitude slower
- * than the integrated GPU the frame-rate criterion is written against. The
- * measurement is still worth taking and logging; it is not worth failing on.
- */
-async function isSoftwareRendered(page: Page): Promise {
- return page.evaluate(() => {
- try {
- const gl = document.createElement('canvas').getContext('webgl2') as WebGL2RenderingContext | null;
- if (!gl) return true;
- const info = gl.getExtension('WEBGL_debug_renderer_info');
- const name = String(info ? gl.getParameter(info.UNMASKED_RENDERER_WEBGL) : '');
- return /swiftshader|llvmpipe|software|angle \(google/i.test(name);
- } catch {
- return true;
- }
- });
-}
-
-test('reports frame telemetry while rowing a 5,000-point winding route', async ({ page }) => {
- const problems: string[] = [];
- page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
- await rowGeneratedCourse(page, 'Frame Budget Course', windingCourse(5000, 8000, 12));
-
- // Poll on the frame count, not the window duration: the trailing window is
- // trimmed a frame at a time, so its length oscillates by one frame.
- const stats = await (async () => {
- const read = () => page.evaluate(() => window.__ROWER3D_FRAME_STATS ?? null);
- const deadline = Date.now() + 45_000;
- let seen = await read();
- while (Date.now() < deadline && (seen?.frames ?? 0) < 40) {
- await page.waitForTimeout(1000);
- seen = await read();
- }
- return seen;
- })();
-
- const software = await isSoftwareRendered(page);
- console.log(
- `frames=${stats?.frames} over ${stats?.windowSeconds.toFixed(1)}s ` +
- `fps=${stats?.fps.toFixed(1)} p50=${stats?.p50Ms.toFixed(1)}ms ` +
- `p95=${stats?.p95Ms.toFixed(1)}ms max=${stats?.maxMs.toFixed(1)}ms software=${software}`,
- );
-
- // #224 asks for p5 frame time <= 18 ms sustained over ten seconds. That is
- // not measurable here: headless Chromium barely advances this scene — about
- // two frames a minute on a software rasteriser — which is why the oar-cadence
- // check in virtualrow.spec.ts is wrapped in a try/catch with the same
- // caveat. Any fps asserted here would describe SwiftShader, not the engine.
- // The per-frame CPU budget is enforced instead in `frameHotPath.test.ts`,
- // where nothing else is competing. What this spec holds is that the
- // telemetry is wired to a scene that is genuinely ticking.
- expect(stats, 'the scene reported no frame telemetry').toBeTruthy();
- expect(stats!.frames).toBeGreaterThanOrEqual(1);
- expect(stats!.fps).toBeGreaterThan(0);
- expect(Number.isFinite(stats!.p95Ms)).toBe(true);
-
- if (!software) {
- expect(stats!.p95Ms).toBeLessThanOrEqual(18);
-
- // #331 — the shape of the distribution, not just its height.
- //
- // The scenery used to re-render ten times a second, which does not move
- // the median at all: it adds a periodic 100-300 ms frame on top of an
- // otherwise healthy one. A budget on p95 alone can be met by a scene that
- // stutters twice a second, so what is asserted is the spread.
- //
- // Guarded like the budget above, and for the same reason: on SwiftShader
- // this ratio describes the rasteriser rather than the engine.
- expect(
- stats!.p95Ms,
- 'frame times are spiking periodically, which is what a re-rendering scene looks like',
- ).toBeLessThanOrEqual(stats!.p50Ms * 2);
- }
-
- expect(problems).toEqual([]);
-});
-
-/**
- * Issue #342 — the budget is a gate, per tier, on the scene a rower gets.
- *
- * There was a draw-call ceiling here before, and it had to be written with a
- * caveat: it read `window.__ROWER3D_RENDER_STATS`, which the scene published
- * only under `IS_TEST_MODE`, and test mode is also what drops the effect
- * stack, the wake, the spray and the environment probe. It recorded 186 draw
- * calls where the same route outside test mode recorded 1114 — a ceiling over
- * a scene nobody runs.
- *
- * `__VIRTUALROW_TELEMETRY` publishes the numbers without entering test mode,
- * the way `__VIRTUALROW_PERFORMANCE_MODE` gave #197 a tier without one. These
- * run at each tier against `RENDER_BUDGET`, which is the same table the debug
- * panel shows a rower.
- */
-for (const tier of ['basic', 'medium', 'extra-high'] as const) {
- test(`keeps the demo route inside its ${tier} render budget`, async ({ page }) => {
- await page.addInitScript((mode) => {
- (window as unknown as { __VIRTUALROW_PERFORMANCE_MODE?: string })
- .__VIRTUALROW_PERFORMANCE_MODE = mode as string;
- }, tier);
- await page.addInitScript(() => {
- (window as unknown as { __VIRTUALROW_TELEMETRY?: boolean }).__VIRTUALROW_TELEMETRY = true;
- });
- await page.setViewportSize({ width: 1280, height: 800 });
- await page.goto('./');
- await page.locator('.btn-try-demo').click();
- await expect(page.locator('.activity-view')).toBeVisible({ timeout: 30_000 });
-
- await expect
- .poll(async () => page.evaluate(() => window.__ROWER3D_RENDER_STATS?.drawCalls ?? 0), {
- timeout: 60_000,
- intervals: [1000],
- })
- .toBeGreaterThan(0);
-
- // Long enough for the progressive chunk build to have settled: a count
- // taken while the route is still tessellating describes a scene mid-load.
- await page.waitForTimeout(20_000);
-
- const stats = await page.evaluate(() => window.__ROWER3D_RENDER_STATS!);
- const frames = await page.evaluate(() => window.__ROWER3D_FRAME_STATS ?? null);
- const software = await isSoftwareRendered(page);
-
- console.log(
- `${tier}: draw calls ${stats.drawCalls}/${RENDER_BUDGET[tier].drawCalls}, ` +
- `triangles ${stats.triangles}/${RENDER_BUDGET[tier].triangles}, ` +
- `p50 ${frames?.p50Ms?.toFixed(1) ?? 'n/a'}ms, ` +
- `p95 ${frames?.p95Ms?.toFixed(1) ?? 'n/a'}/${RENDER_BUDGET[tier].p95Ms}ms, ` +
- `software=${software}`,
- );
-
- expect(stats.performanceMode, 'the tier override did not take').toBe(tier);
-
- // Draw calls and triangles are what the scene asks for and do not depend
- // on what is drawing it, so they are held on every machine. Frame time
- // does: this scene measures a p50 of 750 ms at low and 2,000 ms at high on
- // SwiftShader, so asserting it there would describe the rasteriser.
- const cost = software
- ? { drawCalls: stats.drawCalls, triangles: stats.triangles }
- : { drawCalls: stats.drawCalls, triangles: stats.triangles, p95Ms: frames?.p95Ms };
-
- expect(overBudget(tier, cost), `the ${tier} scene is over budget`).toEqual([]);
- });
-}
-
-test('keeps a 20 km route inside its geometry memory budget', async ({ page }) => {
- await rowGeneratedCourse(page, 'Twenty Kilometre Course', windingCourse(4000, 20_000, 16));
-
- // Every chunk builds on idle callbacks; wait for the count to settle.
- await page.waitForFunction(() => (window.__ROWER3D_MEMORY?.geometries ?? 0) > 0, undefined, {
- timeout: 60_000,
- });
- let previous = -1;
- await expect
- .poll(
- async () => {
- const current = await page.evaluate(() => window.__ROWER3D_MEMORY?.geometryBytes ?? 0);
- const settled = current === previous;
- previous = current;
- return settled;
- },
- { timeout: 60_000, intervals: [1500] },
- )
- .toBe(true);
-
- const memory = await page.evaluate(() => window.__ROWER3D_MEMORY!);
- console.log(
- `geometry ${memory.geometryMb.toFixed(2)} MB across ${memory.geometries} geometries, ` +
- `${memory.textures} textures`,
- );
-
- // #224 budgets 80 MB of GPU memory for a 20 km route. Chunk culling switches
- // geometry off to save draw calls but leaves the buffers resident, so this is
- // the whole route's geometry, not just the visible band.
- expect(memory.geometryMb).toBeLessThanOrEqual(80);
-});
-
-test('draws the demo route unchanged at start, middle and end', async ({ page }) => {
- await page.addInitScript({ content: fs.readFileSync(mockBluetoothPath, 'utf8') });
- const errors: string[] = [];
- page.on('pageerror', (error) => errors.push(error.message));
- await page.goto('./');
-
- // Willowbrook River is the pre-selected demo route (issue #187, TD-1).
- await expect(page.locator('.route-info-overlay h2')).toContainText('Willowbrook River');
- await connectHardwareAndStart(page);
- await page.waitForFunction(() => window.__ROWER3D_ROUTE?.hasCurve === true, undefined, {
- timeout: 30_000,
- });
-
- const route = await page.evaluate(() => window.__ROWER3D_ROUTE!);
-
- for (const progress of [0, 0.5, 0.95]) {
- // Geometric checks rather than a pixel diff: the boat must sit on the water
- // at every checkpoint, which is what "no visual regression" means here and
- // does not vary with the platform's font and rasteriser.
- const sample = await page.evaluate(async (target) => {
- const wait = () => new Promise((r) => requestAnimationFrame(r));
- for (let i = 0; i < 5; i++) await wait();
- return { pos: window.__ROWER3D_POS, camera: window.__ROWER3D_CAMERA, target };
- }, progress);
-
- expect(sample.pos, `no boat telemetry at ${progress}`).toBeTruthy();
- expect(Number.isFinite(sample.pos!.x)).toBe(true);
- expect(Number.isFinite(sample.pos!.z)).toBe(true);
- expect(Number.isFinite(sample.pos!.angle)).toBe(true);
- expect(sample.camera!.position.every((n) => Number.isFinite(n))).toBe(true);
- }
-
- expect(route.curveLength).toBeGreaterThan(0);
- expect(route.totalDistance).toBeGreaterThan(0);
-
- // The opt-in pixel baseline, matching the pattern already used in
- // virtualrow.spec.ts: comparison only where the runner owns the baseline.
- if (process.env.UPDATE_SNAPSHOTS === 'true') {
- const shot = await page.locator('.rower3d-canvas-container').screenshot();
- expect(shot).toMatchSnapshot('route-checkpoints.png', { maxDiffPixelRatio: 0.01 });
- }
-
- expect(
- errors.filter((text) => /typeerror|referenceerror|cannot read properties/i.test(text)),
- ).toEqual([]);
-});
diff --git a/playwright/tests/rower-stroke.spec.ts b/playwright/tests/rower-stroke.spec.ts
deleted file mode 100644
index 4375c424..00000000
--- a/playwright/tests/rower-stroke.spec.ts
+++ /dev/null
@@ -1,179 +0,0 @@
-import { test, expect, type Page } from '../fixtures/crash-watch';
-import { expectSceneAlive } from '../utils/scene-health';
-import {
- CATCH_SWEEP_RAD,
- FINISH_SWEEP_RAD,
-} from '../../src/components/rower3d/strokePose';
-
-/**
- * Issue #273 — the rower's arms move with the oars in the GLB scull.
- *
- * The GLB scull renders only when `IS_TEST_MODE` is false, so this spec
- * deliberately installs no BLE harness: with it, the app renders the
- * *procedural* scull instead and the path a real session uses goes untested.
- * That asymmetry is how the rower came to sit rigid while the oars swept, with
- * every spec passing.
- *
- * What this can and cannot assert. Both angles come from one `strokePose`, so
- * if the arm reports a value in the band that pose produces, the arms are being
- * driven by the stroke rather than left at rest. What it cannot show is the
- * arms *moving*: the render loop draws once and then idles (#261), so the
- * stroke phase does not advance and every sample reads the same pose. Movement
- * is covered by strokePose.test.ts until that is fixed.
- */
-
-/** The range strokePose can produce: -0.5 + armPull * 1.2, armPull in 0..1. */
-const ARM_MIN = -0.5;
-const ARM_MAX = 0.7;
-
-/**
- * How far the oar can sweep either way, from the angles the stroke is built
- * from rather than from a number written down beside them.
- *
- * This was a flat 0.5, with a comment saying `oarSweep` is
- * `sin(phase * 2pi) * 0.5`. It was, until #329 gave the stroke the catch and
- * finish angles a sculler actually rows - 55 degrees towards the bow and 35
- * the other way - and then the spec was asserting the shape of a stroke the
- * code had stopped having. Importing the constants means the next person to
- * retune them does not have to remember this line exists.
- */
-const OAR_LIMIT = Math.max(Math.abs(CATCH_SWEEP_RAD), Math.abs(FINISH_SWEEP_RAD));
-
-/**
- * Start the demo row with a DOM click rather than a synthesised one.
- *
- * `locator.click()` is bound by the config's 10s `actionTimeout`, and on a
- * Windows runner drawing this scene through SwiftShader the renderer's main
- * thread is busy for longer than that at a stretch: the log reads "element is
- * visible, enabled and stable" and then "performing click action" until the
- * timeout, because the input event is queued behind a frame. The button has
- * already been waited for, so what is left is delivering the click, and
- * `evaluate` delivers it against the test timeout instead of the action one.
- *
- * The same workaround, for the same reason, as the End Workout clicks in
- * `responsive.spec.ts` and `crew-model-loading.spec.ts`.
- */
-async function startDemoRow(page: Page) {
- const demo = page.locator('.btn-try-demo');
- await expect(demo).toBeVisible({ timeout: 20_000 });
- await page.evaluate(() =>
- (document.querySelector('.btn-try-demo') as HTMLButtonElement | null)?.click(),
- );
-}
-
-test('the GLB scull drives the rower from the same stroke as the oars', async ({ page }) => {
- test.slow();
- const errors: string[] = [];
- page.on('pageerror', (e) => errors.push(e.message));
-
- await page.setViewportSize({ width: 1280, height: 800 });
- await page.addInitScript(() => {
- (window as unknown as { __VIRTUALROW_PERFORMANCE_MODE?: string })
- .__VIRTUALROW_PERFORMANCE_MODE = 'low';
- });
- await page.goto('./');
- await startDemoRow(page);
- await page.locator('.rower3d-canvas-container').waitFor({ state: 'visible', timeout: 30_000 });
- await expectSceneAlive(page, 'the stroke scene');
-
- await expect
- .poll(() => page.evaluate(() => window.__ROWER3D_ARM_ANGLE ?? null), {
- timeout: 30_000,
- message: 'the GLB scull never reported an arm angle — the rower is not being animated',
- })
- .not.toBeNull();
-
- const { arm, oar } = await page.evaluate(() => ({
- arm: window.__ROWER3D_ARM_ANGLE ?? null,
- oar: window.__ROWER3D_OAR_ANGLE ?? null,
- }));
-
- expect(Number.isFinite(arm), 'the arm angle is not a number').toBe(true);
- expect(Number.isFinite(oar), 'the oar angle is not a number').toBe(true);
-
- // In the band the shared stroke produces — so the arms are posed by the
- // stroke, not parked at whatever the model was authored with.
- expect(arm!).toBeGreaterThanOrEqual(ARM_MIN - 0.001);
- expect(arm!).toBeLessThanOrEqual(ARM_MAX + 0.001);
- expect(Math.abs(oar!)).toBeLessThanOrEqual(OAR_LIMIT + 0.001);
-
- expect(errors.filter((e) => !/reading 'alpha'/.test(e))).toEqual([]);
-});
-
-/**
- * Issue #328 — the camera is a rig, and the rower can point it somewhere else.
- *
- * It was `camera.position.set(boat - tangent * 6)` every frame, with no lag,
- * no damping and one view. What is checked here is the part a unit test
- * cannot reach: that the rig is actually driving the camera in a running
- * scene, that it settles where it says it will, and that `V` reaches it.
- */
-test('the camera rig follows the boat and answers to V', async ({ page }) => {
- test.slow();
-
- await page.setViewportSize({ width: 1280, height: 800 });
- await page.addInitScript(() => {
- (window as unknown as { __VIRTUALROW_PERFORMANCE_MODE?: string })
- .__VIRTUALROW_PERFORMANCE_MODE = 'low';
- // `__ROWER3D_POS` is automation scaffolding and stays behind the test
- // flag; the camera is measured against it, so this spec sets it.
- window.__PLAYWRIGHT_TESTING = true;
- });
- await page.goto('./');
- await startDemoRow(page);
- await page.locator('.rower3d-canvas-container').waitFor({ state: 'visible', timeout: 30_000 });
- await expectSceneAlive(page, 'the camera scene');
-
- await expect
- .poll(() => page.evaluate(() => window.__ROWER3D_CAMERA?.view ?? null), {
- timeout: 30_000,
- message: 'the camera never reported which view it was using',
- })
- .toBe('chase');
-
- // Long enough for the damping to have settled: the position time constant is
- // 0.35 s, so three seconds is eight of them.
- await page.waitForTimeout(3_000);
-
- const settled = await page.evaluate(() => {
- const camera = window.__ROWER3D_CAMERA;
- const boat = window.__ROWER3D_POS;
- if (!camera || !boat) return null;
- return {
- view: camera.view,
- fov: camera.fov,
- distance: Math.hypot(camera.position[0] - boat.x, camera.position[2] - boat.z),
- height: camera.position[1] - boat.y,
- };
- });
-
- expect(settled, 'no camera or boat position was published').not.toBeNull();
- console.log(
- `[camera] view=${settled!.view} distance=${settled!.distance.toFixed(2)}m ` +
- `height=${settled!.height.toFixed(2)}m fov=${settled!.fov?.toFixed(1)}`,
- );
-
- // The chase view sits 7 m back. The window is wide enough for a boat that is
- // still accelerating, and narrow enough to fail if the rig stopped driving.
- expect(settled!.distance).toBeGreaterThan(6.5);
- expect(settled!.distance).toBeLessThan(7.5);
- expect(settled!.height).toBeGreaterThan(2.3);
- expect(settled!.height).toBeLessThan(2.9);
-
- // Pressing V reaches the rig, and the button says the same thing.
- // A DOM click, for the reason in `startDemoRow`, and on the one element in
- // the page that is definitely busy drawing.
- await page.evaluate(() =>
- (document.querySelector('.rower3d-canvas-container canvas') as HTMLElement | null)?.click(),
- );
- await page.keyboard.press('v');
-
- await expect
- .poll(() => page.evaluate(() => window.__ROWER3D_CAMERA?.view ?? null), {
- timeout: 15_000,
- message: 'V did not change the camera view',
- })
- .toBe('side');
-
- await expect(page.locator('.btn-camera-view')).toContainText('side');
-});
diff --git a/src/App.tsx b/src/App.tsx
deleted file mode 100644
index 782fee90..00000000
--- a/src/App.tsx
+++ /dev/null
@@ -1,2060 +0,0 @@
-import { useState, useEffect, useCallback, useMemo, useRef, lazy, Suspense } from 'react';
-import { RouteMap } from './components/RouteMap';
-import { BluetoothDevice } from './components/BluetoothDevice';
-import { PM5Simulator } from './components/PM5Simulator';
-import { HeartRateSimulator } from './components/HeartRateSimulator';
-import { RownativeRouteImport } from './components/RownativeRouteImport';
-import { FTMSDevice } from './components/FTMSDevice';
-import { routeService } from './lib/services/routeService';
-import { workoutService } from './lib/services/workoutService';
-import HeartRateMonitor from './components/HeartRateMonitor';
-import { heartRateBluetoothService } from './lib/services/heartRateBluetoothService';
-import { bluetoothService } from './lib/services/bluetoothService';
-import { ftmsBluetoothService } from './lib/services/ftmsBluetoothService';
-// Rower3D pulls in three, @react-three/{fiber,drei,postprocessing,rapier} (~hundreds of kB).
-// Code-split it so the routes view doesn't pay the cost — the chunk only
-// loads when the user actually starts a workout (currentView === 'workout').
-const Rower3D = lazy(() => import('./components/Rower3D'));
-import { RouteThumbnail } from './components/RouteThumbnail';
-import { RouteOverviewMap } from './components/RouteOverviewMap';
-import { RouteDetailOverlay } from './components/RouteDetailOverlay';
-import { GuestSessionSummary } from './components/GuestSessionSummary';
-import { SessionSummary } from './components/SessionSummary';
-import { AuthButton } from './components/AuthButton';
-import { SettingsPanel } from './components/SettingsPanel';
-import { CrashReportedBanner } from './components/CrashReportedBanner';
-import { heartRateSimulator } from './lib/services/heartRateSimulatorService';
-import { pm5Simulator } from './lib/services/pm5SimulatorService';
-import { debugPreferencesStore } from './lib/services/debugPreferencesStore';
-import { useAuth } from './context/useAuth';
-import { resolveCrew, effectiveCrewPreference, CREW_URL } from './components/rower3d/crewModel';
-import { RouteLoadingBar } from './components/RouteLoadingBar';
-import { useRouteLoadProgress } from './hooks/useRouteLoadProgress';
-import { isGlbSceneryEnabled } from './components/rower3d/sceneryAssets';
-import { readTelemetry, telemetryAsText, clearTelemetry } from './lib/utils/sceneTelemetryLog';
-import { useServices } from './context/useServices';
-import { useRownativeDeepLink } from './hooks/useRownativeDeepLink';
-import { useRowerServiceEvents } from './hooks/useRowerServiceEvents';
-import { OUTLINE_ONLY_TAG } from './lib/services/routeService';
-import { externalDistanceNote, formatRouteDistanceKm, geometryProvenanceBadge } from './lib/utils/geometryProvenance';
-import { TrackParseError, detectTrackFormat } from './lib/utils/trackParsers';
-import { useGraphicsQuality } from './hooks/useGraphicsQuality';
-import {
- useDevicePerformance,
- resolvePerformanceModeFromProbe,
-} from './hooks/useDevicePerformance';
-import { GraphicsQualityPicker } from './components/GraphicsQualityPicker';
-import { SoundPicker } from './components/SoundPicker';
-import { useRaceCues } from './hooks/useRaceCues';
-import { ConditionsPicker } from './components/ConditionsPicker';
-import { useConditions } from './hooks/useConditions';
-import { CrewPicker } from './components/CrewPicker';
-import { useCrewPreference } from './hooks/useCrewPreference';
-import { RowerAppearancePicker } from './components/RowerAppearancePicker';
-import { useRowerAppearance } from './hooks/useRowerAppearance';
-import { resolveAppearanceColors } from './components/rower3d/rowerAppearance';
-import { useRenderStats } from './hooks/useRenderStats';
-import { RENDER_BUDGET, overBudget } from './components/rower3d/renderBudget';
-import type { PerformanceMode } from './components/rower3d/constants';
-import { useCurrentWorkout } from './hooks/useCurrentWorkout';
-import { WorkoutBlockPanel } from './components/WorkoutBlockPanel';
-import { LoadTodaysWorkoutButton } from './components/LoadTodaysWorkoutButton';
-import { useWorkoutTargetController } from './hooks/useWorkoutTargetController';
-import { pickRowerTargets } from './context/useServices';
-import { RowHud } from './components/RowHud';
-import { useFullscreen } from './hooks/useFullscreen';
-import { isStrokeReading, useStartSequence } from './hooks/useStartSequence';
-import { FinishBanner, StartCallout } from './components/RaceCallouts';
-import { loadSessions, markSessionUploaded, saveCompletedSession } from './lib/services/localStorageWorkoutStore';
-import { bestPaceOnRoute } from './lib/utils/sessionSummary';
-import { formatSplit } from './lib/utils/formatters';
-import { GhostPicker, type GhostChoice } from './components/GhostPicker';
-import {
- bestRowOnRoute,
- gapMeters,
- ghostDistanceAt,
- ghostFinishSeconds,
- paceGhost,
- recordedGhost,
-} from './components/rower3d/ghost';
-import type { WaterRoute, PM5Data, WorkoutSession, HeartRateSample } from './lib/types/index';
-import type { RouteEnrichmentData } from './lib/services/routeEnrichmentService';
-import './App.css';
-
-// Session state type for workout controls
-type SessionState = 'idle' | 'active' | 'paused';
-
-/**
- * The three screens (issue #219, R3).
- *
- * `routes` is the Row screen — one route, the devices and the two ways to
- * start. `route-search` is where a different route is found and chosen.
- * `workout` is the running session. State, not a router: the app has no
- * addressable URLs beyond the rownative deep link, so adding one would buy
- * nothing.
- */
-type ViewMode = 'routes' | 'route-search' | 'workout';
-
-/** The bundled demo route, and the fallback when no default resolves. */
-const DEMO_ROUTE_ID = '1';
-
-/** How long the finish banner is up before the summary opens (#336). */
-const FINISH_BANNER_MS = 2000;
-
-/** Header nav. The workout screen is reached by starting a row, not by a tab. */
-const NAV_ITEMS: ReadonlyArray<{ view: ViewMode; label: string }> = [
- { view: 'routes', label: 'Row' },
- { view: 'route-search', label: 'Routes' },
-];
-
-/** Extract the rownative.icu status value from route tags (e.g. "status:provisional" → "provisional"). */
-function extractRouteStatus(tags: string[] | undefined): string | undefined {
- return tags?.find((t) => t.startsWith('status:'))?.replace('status:', '');
-}
-
-/**
- * Provenance of a route's geometry, for the badge.
- *
- * Routes imported before this field existed carry the `outline-only` tag and
- * nothing else, so fall back to reading that.
- */
-function routeGeometrySource(route: WaterRoute): WaterRoute['geometrySource'] {
- if (route.geometrySource) return route.geometrySource;
- return route.tags?.includes(OUTLINE_ONLY_TAG) ? 'gate-chain' : undefined;
-}
-
-function App() {
- const { isAuthenticated, isLoading, login, user } = useAuth();
- const {
- routeEnrichmentService,
- defaultRoutePreferenceStore,
- audioService,
- authService,
- intervalsIcuWorkoutService,
- crashReportsPreferenceStore,
- } = useServices();
- // In Playwright e2e tests, window.__PLAYWRIGHT_TESTING is set to true by mock-bluetooth.js.
- // Guard all unauthenticated-guest behaviours on this flag so tests can exercise the full UI.
- const isGuestSession = !isAuthenticated && !window.__PLAYWRIGHT_TESTING;
- const showAuthFeatures = isAuthenticated || !!window.__PLAYWRIGHT_TESTING;
- const [currentView, setCurrentView] = useState('routes');
- // The header nav collapses behind a hamburger below 768px (issue #219, R8).
- const [isNavOpen, setIsNavOpen] = useState(false);
- const [routes, setRoutes] = useState([]);
- const [selectedRoute, setSelectedRoute] = useState(null);
- const [isWorkoutActive, setIsWorkoutActive] = useState(false);
- const [currentSession, setCurrentSession] = useState(null);
- const [pm5Connected, setPM5Connected] = useState(false);
- const [pm5Data, setPM5Data] = useState(null);
- const [ftmsConnected, setFtmsConnected] = useState(false);
- const [hrConnected, setHrConnected] = useState(false);
- const [heartRateSamples, setHeartRateSamples] = useState([]);
- const [activeRowerType, setActiveRowerType] = useState<'pm5' | 'ftms'>('pm5');
- // Filter state for routes
- const [difficultyFilter, setDifficultyFilter] = useState<'all' | 'easy' | 'moderate' | 'hard'>('all');
- const distanceMin = 0;
- const distanceMax = 100;
- // Local activity timer (ms elapsed since workout started)
- const [activityElapsedMs, setActivityElapsedMs] = useState(0);
- // The start and the finish of a row (#336). `strokeAt` is when this session
- // saw its first stroke, not the last reading the erg left from the one before.
- const [strokeAt, setStrokeAt] = useState(null);
- const [finish, setFinish] = useState<{ distanceMeters: number; elapsedMs: number } | null>(null);
- const finishingRef = useRef(false);
- const strokeSeenRef = useRef(false);
- const activityTimerRef = useRef(null);
- /**
- * The stage, and whether it is filling the screen (#335).
- *
- * Declared here rather than in `RowHud` because the element that goes
- * fullscreen is the stage, and on iOS Safari - which has no Fullscreen API -
- * the fallback is a class on the view around it. Both are outside the HUD.
- */
- const rowStageRef = useRef(null);
- const rowFullscreen = useFullscreen(rowStageRef);
- // Re-entrancy guards — prevent recursive session start or HR update loops
- const isStartingSessionRef = useRef(false);
- const isProcessingHrUpdateRef = useRef(false);
- // RAF-based throttle for PM5/HR state updates — avoids stack overflow when
- // Playwright CDP adds extra frames to the WS→characteristic notification path.
- const pm5DataPendingRef = useRef(null);
- const pm5RafScheduledRef = useRef(false);
- // Debug mode state
- const [debugMode, setDebugMode] = useState(false);
- // Settings panel visibility (#473, D4=b).
- const [settingsOpen, setSettingsOpen] = useState(false);
- // Signed-in athletes don't see the demo-row CTA on the main flow (#453);
- // this toggle in the debug panel puts it back for testing and dogfooding,
- // persisted per browser so a reload doesn't fight a tester (FR3, FR4).
- const [showDemoCtaOverride, setShowDemoCtaOverride] = useState(() =>
- debugPreferencesStore.getShowDemoCtaOverride(),
- );
- // The river guides draw the channel centreline and its water edges (#268).
- // Given their own switch so they can be turned off without losing the rest of
- // the debug window (#270).
- const [showRiverGuides, setShowRiverGuides] = useState(true);
- // Replaces the ?glb= query parameter (#270): switching the scenery kit should
- // be a control, not a URL edit. Seeded from the same default the scene uses,
- // so opening the panel changes nothing by itself.
- const [sceneryEnabled, setSceneryEnabled] = useState(() => isGlbSceneryEnabled());
- // Lake outlines ship off (#493 NFR7); this switch tries them on main and
- // survives a reload, like the demo-row override above.
- const [lakeOutlines, setLakeOutlines] = useState(() => debugPreferencesStore.getLakeOutlines());
- // Only polled while the panel is open (#232).
- const renderStats = useRenderStats(debugMode);
- // The telemetry log is read the same way: only while someone is looking.
- const [telemetryTick, setTelemetryTick] = useState(0);
- const telemetryEvents = useMemo(
- () => (debugMode ? readTelemetry() : []),
- // renderStats ticks about once a second while the panel is open, which is
- // a good enough clock to keep this fresh without a second timer.
- // eslint-disable-next-line react-hooks/exhaustive-deps
- [debugMode, telemetryTick, renderStats],
- );
- const telemetryCount = telemetryEvents.length;
- const recentTelemetry = useMemo(
- () =>
- telemetryEvents
- .slice(-12)
- .map((e) => {
- const seconds = (e.at / 1000).toFixed(1).padStart(7, ' ');
- return `${seconds}s ${e.kind}${e.detail ? ` ${JSON.stringify(e.detail)}` : ''}`;
- })
- .join(String.fromCharCode(10)),
- [telemetryEvents],
- );
-
- // The rower's own call on graphics quality, overruling hardware detection
- // when they know better than the heuristic does (#224).
- const graphics = useGraphicsQuality();
- // Device-performance probe (#454 Phase 2): runs once at app boot, before the
- // Row screen mounts a canvas, and gives `graphics.quality === 'auto'` a
- // measured answer rather than the renderer-string heuristic alone. User
- // choices (`low`/`high`) still win; `useDevicePerformance` is the seam the
- // five-tier widening in #455 will slot its new tiers into.
- const devicePerformance = useDevicePerformance();
- const crew = useCrewPreference();
- const rowerAppearance = useRowerAppearance();
- const appearanceColors = useMemo(
- () => resolveAppearanceColors(rowerAppearance.appearance),
- [rowerAppearance.appearance],
- );
-
- // Demo mode: a visitor with no hardware is rowing on simulated device data.
- const [isDemoMode, setIsDemoMode] = useState(false);
- // Session state for the overlay UI
- const [sessionState, setSessionState] = useState('idle');
- // Holds a completed unauthenticated session until the summary modal is dismissed
- const [guestCompletedSession, setGuestCompletedSession] = useState(null);
- // The same, for a signed-in athlete — who gets the save controls too (issue #221, R4).
- const [completedSession, setCompletedSession] = useState(null);
- // Demo mode is cleared when the session ends, so remember it for the summary.
- const [completedSessionWasDemo, setCompletedSessionWasDemo] = useState(false);
- const [routeEnrichments, setRouteEnrichments] = useState>({});
- const [routeEnrichmentLoading, setRouteEnrichmentLoading] = useState>({});
- // Route import panel state
- const [isImportOpen, setIsImportOpen] = useState(false);
- const [importRouteName, setImportRouteName] = useState('');
- const [importError, setImportError] = useState(null);
-
- // The athlete's default route, if they have set one (issue #219, R6).
- const [defaultRouteId, setDefaultRouteId] = useState(null);
- // #505 Phase 2 (D8-c): the modal that opens when a map pin is clicked.
- const [routeDetailOpen, setRouteDetailOpen] = useState(null);
- // #505 Phase 2e (D6-c): the Routes screen is map-primary and the catalogue
- // slides in from the side. Default open on ≥900px (laptop and up), closed
- // on narrower screens where the drawer would cover most of the map.
- const [routesDrawerOpen, setRoutesDrawerOpen] = useState(() => {
- if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
- return window.matchMedia('(min-width: 900px)').matches;
- });
-
- /**
- * Decide which route the app opens on, and which one the star is lit for.
- *
- * Precedence is deep link → stored default → the bundled demo route. The
- * selection is guarded to happen once: this effect depends on `routes`, so
- * without the guard every import re-ran it and snapped the selection back,
- * discarding the route the user had just loaded. `handleRouteImported` and
- * `handleRouteSelect` set the same guard, which is what lets a rownative deep
- * link beat a stored default (AC6.10).
- *
- * The star state is *not* guarded — it has to follow a later sign-in or
- * sign-out. Both jobs read `resolveDefaultRouteId` from this one place so a
- * stale id it clears (AC6.4) can never leave the star lit for a route that no
- * longer exists.
- */
- const hasPreselectedRef = useRef(false);
- useEffect(() => {
- if (routes.length === 0) return;
-
- const resolved = defaultRoutePreferenceStore.resolveDefaultRouteId(
- user?.id ?? null,
- routes.map((r) => r.id),
- );
- setDefaultRouteId(resolved);
-
- if (hasPreselectedRef.current || isLoading) return;
- setSelectedRoute(
- (resolved ? routes.find((r) => r.id === resolved) : undefined)
- ?? routes.find((r) => r.id === DEMO_ROUTE_ID)
- ?? routes[0],
- );
- hasPreselectedRef.current = true;
- }, [defaultRoutePreferenceStore, isLoading, routes, user]);
-
- /** Star / un-star a route as this athlete's default (AC6.5, AC6.6). */
- const handleToggleDefaultRoute = useCallback((routeId: string) => {
- if (!user) return;
- if (defaultRouteId === routeId) {
- defaultRoutePreferenceStore.clearDefaultRouteId(user.id);
- setDefaultRouteId(null);
- return;
- }
- defaultRoutePreferenceStore.setDefaultRouteId(user.id, routeId);
- setDefaultRouteId(routeId);
- }, [defaultRoutePreferenceStore, defaultRouteId, user]);
-
- // Auto-start/stop the HR simulator for unauthenticated users
- // Skip in Playwright test mode so tests can control HR connection state explicitly.
- useEffect(() => {
- if (isGuestSession) {
- heartRateSimulator.start(130);
- } else {
- heartRateSimulator.stop();
- }
- return () => {
- heartRateSimulator.stop();
- };
- }, [isGuestSession]);
-
- // Start/stop activity timer when workout state changes
- useEffect(() => {
- if (isWorkoutActive) {
- const startTime = Date.now();
- activityTimerRef.current = window.setInterval(() => {
- setActivityElapsedMs(Date.now() - startTime);
- }, 250);
- } else {
- if (activityTimerRef.current !== null) {
- clearInterval(activityTimerRef.current);
- activityTimerRef.current = null;
- }
- setActivityElapsedMs(0);
- }
- return () => {
- if (activityTimerRef.current !== null) {
- clearInterval(activityTimerRef.current);
- activityTimerRef.current = null;
- }
- };
- }, [isWorkoutActive]);
-
- useEffect(() => {
- setRoutes(routeService.getAllRoutes());
- }, []);
-
- useEffect(() => {
- if (!selectedRoute) return;
-
- let cancelled = false;
- const cached = routeEnrichmentService.readCached(selectedRoute.id);
- const cachedData = cached.data;
- if (cachedData) {
- setRouteEnrichments((current) => ({
- ...current,
- [selectedRoute.id]: cachedData,
- }));
- }
-
- if (cachedData && !cached.stale) {
- setRouteEnrichmentLoading((current) => ({
- ...current,
- [selectedRoute.id]: false,
- }));
- return;
- }
-
- setRouteEnrichmentLoading((current) => ({
- ...current,
- [selectedRoute.id]: true,
- }));
-
- void routeEnrichmentService
- .enrichRoute(selectedRoute)
- .then((enrichment) => {
- if (cancelled) return;
- setRouteEnrichments((current) => ({
- ...current,
- [selectedRoute.id]: enrichment,
- }));
- })
- .finally(() => {
- if (cancelled) return;
- setRouteEnrichmentLoading((current) => ({
- ...current,
- [selectedRoute.id]: false,
- }));
- });
-
- return () => {
- cancelled = true;
- };
- }, [selectedRoute, routeEnrichmentService]);
-
- const activeRowerLabel = useMemo(() => (
- activeRowerType === 'pm5' ? 'PM5' : 'FTMS'
- ), [activeRowerType]);
- const selectedRowerConnected = useMemo(() => (
- activeRowerType === 'pm5' ? pm5Connected : ftmsConnected
- ), [activeRowerType, ftmsConnected, pm5Connected]);
-
- // The structured workout, if one is loaded (issue #445). With none, every
- // flow below is exactly as it was — a free row (#67 §2). The hook's
- // callbacks are stable; the object around them is not, so pull out the two
- // the memoised handlers below close over.
- const currentWorkout = useCurrentWorkout(selectedRowerConnected);
- const { stop: stopStructuredWorkout, tick: tickStructuredWorkout } = currentWorkout;
- // The rower-target port matches the current source. Simulator by default,
- // switched to the stubbed FTMS / PM5 implementations when a real erg is
- // connected (#445 D3(a)).
- const rowerTargets = useMemo(() => {
- if (activeRowerType === 'ftms' && ftmsConnected) return pickRowerTargets('ftms');
- if (activeRowerType === 'pm5' && pm5Connected) return pickRowerTargets('pm5');
- return pickRowerTargets('simulator');
- }, [activeRowerType, ftmsConnected, pm5Connected]);
- useWorkoutTargetController({
- segment: currentWorkout.progress?.currentSegment,
- currentPaceSecondsPer500: pm5Data?.pace,
- rowerTargets,
- active: isWorkoutActive && currentView === 'workout' && currentWorkout.isRunning,
- });
-
- // Listen to programmatic session events from the workoutService to update UI state
- useEffect(() => {
- const onStartup = (e: Event) => {
- if (!(e instanceof CustomEvent)) return;
- const session = e.detail as WorkoutSession;
- setCurrentSession(session);
- setIsWorkoutActive(true);
- setCurrentView('workout');
- if (session && session.routeId) {
- const r = routeService.getRouteById(session.routeId);
- if (r) setSelectedRoute(r);
- }
- };
- const onEnd = () => {
- finishingRef.current = false;
- setFinish(null);
- strokeSeenRef.current = false;
- setStrokeAt(null);
- setIsWorkoutActive(false);
- setCurrentSession(null);
- setCurrentView('routes');
- };
- if (typeof window === 'undefined') return;
- window.addEventListener('virtualrow:sessionStarted', onStartup as EventListener);
- window.addEventListener('virtualrow:sessionEnded', onEnd as EventListener);
- return () => {
- window.removeEventListener('virtualrow:sessionStarted', onStartup as EventListener);
- window.removeEventListener('virtualrow:sessionEnded', onEnd as EventListener);
- };
- }, []);
-
- const handleRouteSelect = useCallback((route: WaterRoute) => {
- setSelectedRoute(route);
- // An explicit choice outranks the stored default for the rest of the visit.
- hasPreselectedRef.current = true;
- setCurrentView('routes');
- }, []);
-
- const selectedRouteEnrichment = selectedRoute ? routeEnrichments[selectedRoute.id] ?? null : null;
- const selectedRouteEnrichmentLoading = selectedRoute ? !!routeEnrichmentLoading[selectedRoute.id] : false;
-
- // What the rower is waiting for between choosing a route and rowing it
- // (#318). Watched off the performance timeline, so nothing here reaches into
- // the scene's Suspense boundary - see useRouteLoadProgress for why that
- // matters. CREW_URL comes from crewModel.ts, which is free of three and drei
- // precisely so App can name the file without pulling the 3D bundle in.
- const routeLoadProgress = useRouteLoadProgress(
- currentView === 'workout' && isWorkoutActive,
- CREW_URL[resolveCrew(user?.gender, effectiveCrewPreference(user?.gender, crew.preference))],
- );
-
- const handleStartWorkout = () => {
- // Guard against double-start (rapid clicks, re-entrant calls, or already-active session)
- if (isStartingSessionRef.current || isWorkoutActive || workoutService.getCurrentSession()) return;
- isStartingSessionRef.current = true;
- try {
- if (!selectedRoute || !selectedRowerConnected || !hrConnected) {
- alert(`Please connect your ${activeRowerLabel} and Heart Rate Monitor, and select a route`);
- return;
- }
-
- const session = workoutService.startSession(
- selectedRoute.id,
- selectedRoute.name,
- undefined,
- activeRowerType,
- hrConnected,
- isGuestSession,
- selectedRoute.coordinates,
- );
- // A loaded structured workout runs over the top of the session. If it
- // cannot start, the row still goes ahead as a free row (#67 §2, F.3).
- currentWorkout.start();
-
- setCurrentSession(session);
- setIsWorkoutActive(true);
- setSessionState('active');
- setCurrentView('workout');
- } finally {
- isStartingSessionRef.current = false;
- }
- };
-
- /**
- * Start a demo row on simulated devices.
- *
- * Aimed at anyone with no rowing machine to hand: one control connects
- * nothing by hand, starts the rower and heart-rate simulators, and drops
- * straight into the session so the engine can be judged without buying
- * hardware. Offered signed-in as well as signed-out (issue #219, AC7.2) — the
- * session is still flagged as a demo and still not recorded as a real workout.
- */
- // The demo CTA is offered to guests (issue #219 AC7.2), and to Playwright
- // regardless of sign-in, so existing demo specs stay green (#453 D4, FR7).
- // Signed-in athletes only see it when the debug-panel override is on
- // (FR1, FR3). `handleStartDemo` guards the same gate so a future deep link
- // or shortcut cannot bypass it (FR5).
- const demoCtaVisible =
- !isAuthenticated ||
- !user ||
- !!window.__PLAYWRIGHT_TESTING ||
- showDemoCtaOverride;
-
- const handleStartDemo = useCallback(() => {
- if (isStartingSessionRef.current || isWorkoutActive || workoutService.getCurrentSession()) return;
- if (!selectedRoute) return;
- if (!demoCtaVisible) return;
- isStartingSessionRef.current = true;
- try {
- setIsDemoMode(true);
- pm5Simulator.updateSettings({ pace: 120, cadence: 24, heartRate: 130, power: 150, isRowing: true });
- pm5Simulator.start();
- if (!heartRateSimulator.isRunning()) heartRateSimulator.start(130);
- setPM5Connected(true);
- setActiveRowerType('pm5');
-
- const session = workoutService.startSession(
- selectedRoute.id,
- selectedRoute.name,
- undefined,
- 'pm5',
- true,
- isGuestSession,
- selectedRoute.coordinates,
- );
- setCurrentSession(session);
- setIsWorkoutActive(true);
- setSessionState('active');
- setCurrentView('workout');
- } finally {
- isStartingSessionRef.current = false;
- }
- }, [demoCtaVisible, isGuestSession, isWorkoutActive, selectedRoute]);
-
- const stopDemoDevices = useCallback(() => {
- pm5Simulator.stop();
- setIsDemoMode(false);
- setPM5Connected(false);
- setPM5Data(null);
- }, []);
-
- const handleEndWorkout = useCallback(() => {
- finishingRef.current = false;
- setFinish(null);
- strokeSeenRef.current = false;
- setStrokeAt(null);
- const completed = workoutService.endSession();
- stopStructuredWorkout();
- setIsWorkoutActive(false);
- setCurrentSession(null);
- setSessionState('idle');
- setCompletedSessionWasDemo(isDemoMode);
- if (isDemoMode) stopDemoDevices();
-
- if (!completed) {
- setCurrentView('routes');
- return;
- }
-
- if (isGuestSession) {
- // Show summary modal for unauthenticated sessions
- setGuestCompletedSession(completed);
- return;
- }
-
- // The row is kept locally before any upload is attempted, so a row the
- // athlete declines to upload — or that fails to — still survives a reload
- // (issue #221, AC6.1).
- if (user) saveCompletedSession(user.id, completed, { isDemo: isDemoMode });
- setCompletedSession(completed);
- }, [isGuestSession, isDemoMode, stopDemoDevices, stopStructuredWorkout, user]);
-
- /**
- * What this row is being rowed against (#338), chosen before it starts.
- *
- * Rowing alone is the default, which is what the app did before there was
- * anything to race.
- */
- const [ghostChoice, setGhostChoice] = useState({ kind: 'none' });
-
- /** The quickest row this browser kept on the selected route, if any. */
- const bestRowHere = useMemo(() => {
- if (!user || !selectedRoute) return null;
- return bestRowOnRoute(loadSessions(user.id), selectedRoute.id);
- }, [user, selectedRoute]);
-
- /**
- * The ghost itself: a recorded row, a constant pace, or nothing.
- *
- * Falls back to rowing alone rather than to an empty boat if the best it was
- * told to race has gone - a route changed under the picker, say.
- */
- const ghostSource = useMemo(() => {
- if (ghostChoice.kind === 'pace') return paceGhost(ghostChoice.paceSPer500);
- if (ghostChoice.kind === 'best' && bestRowHere) return recordedGhost(bestRowHere.samples);
- return null;
- }, [ghostChoice, bestRowHere]);
-
- /** What is being chased, in the words the HUD and the summary both use. */
- const ghostLabel = useMemo(
- () =>
- ghostChoice.kind === 'pace'
- ? `a ${formatSplit(ghostChoice.paceSPer500)} pace`
- : 'your best',
- [ghostChoice],
- );
-
- /**
- * The row's clock, for the scene.
- *
- * A ref: the ghost is placed every frame, and the erg reports elapsed time
- * about once a second. Kept current during render because it is read by a
- * frame loop rather than by React.
- */
- const elapsedSecondsRef = useRef(0);
-
- // This athlete's best on the route before this row, from the rows this
- // browser kept for them (#337). None for a demo row, which is not theirs to
- // compare, or without an athlete to ask about.
- const completedSessionBest = useMemo(() => {
- if (!completedSession || !user || completedSessionWasDemo) return undefined;
- return bestPaceOnRoute(loadSessions(user.id), completedSession);
- }, [completedSession, completedSessionWasDemo, user]);
-
- /**
- * How the race came out (#338).
- *
- * The ghost's time to the distance the row actually covered, so a row cut
- * short is compared at the point it stopped rather than at a finish neither
- * boat reached.
- */
- const completedGhost = useMemo(() => {
- if (!completedSession || !ghostSource) return null;
- return {
- seconds: ghostFinishSeconds(ghostSource, completedSession.distance),
- label: ghostLabel,
- };
- }, [completedSession, ghostSource, ghostLabel]);
-
- const handleSessionSaved = useCallback((activityId: string) => {
- if (user && completedSession) markSessionUploaded(user.id, completedSession.id, activityId);
- }, [completedSession, user]);
-
- const handleSessionDone = useCallback(() => {
- setCompletedSession(null);
- setCurrentView('routes');
- }, []);
-
- const handleGuestRowAgain = useCallback(() => {
- setGuestCompletedSession(null);
- setCurrentView('routes');
- }, []);
-
- const handleGuestExit = useCallback(() => {
- setGuestCompletedSession(null);
- setCurrentView('routes');
- }, []);
-
- const handlePauseWorkout = useCallback(() => {
- setSessionState('paused');
- workoutService.pauseSession();
- }, []);
-
- const handleResumeWorkout = useCallback(() => {
- setSessionState('active');
- workoutService.resumeSession();
- }, []);
-
- const handleResetWorkout = useCallback(() => {
- // Reset metrics but keep session
- setActivityElapsedMs(0);
- // Note: Full reset logic would need to clear workoutService data
- }, []);
-
- // Get filtered routes based on current filter settings
- const filteredRoutes = useMemo(() => {
- let filtered = routes;
- if (difficultyFilter !== 'all') {
- filtered = filtered.filter(r => r.difficulty === difficultyFilter);
- }
- return filtered.filter(r => r.distance >= distanceMin && r.distance <= distanceMax);
- }, [routes, difficultyFilter, distanceMin, distanceMax]);
-
- // #505 Phase 2 (D7-c): the overview map shows every pin and dims the ones
- // filtered out, so the user can see what's hidden without clearing the
- // filter. The list below is still reduced to `filteredRoutes`.
- const dimmedRouteIds = useMemo(() => {
- const kept = new Set(filteredRoutes.map((r) => r.id));
- const dimmed = new Set();
- for (const r of routes) {
- if (!kept.has(r.id)) dimmed.add(r.id);
- }
- return dimmed;
- }, [routes, filteredRoutes]);
-
- const handlePM5Data = useCallback((data: PM5Data) => {
- // Always update the service synchronously — no React render triggered here.
- workoutService.updateSessionWithPM5Data(data);
-
- // The first stroke starts the countdown (#336) from a timer rather than
- // from the frame below: a software renderer draws about a frame a second,
- // and a count that waits for one starts a second late. A timer still runs
- // it from a clean call stack, which is what the frame is for.
- if (isWorkoutActive && !strokeSeenRef.current && isStrokeReading(data)) {
- strokeSeenRef.current = true;
- // Stamped now, so the count runs from the drive however late the page
- // renders it. Unless the row ended in between, which cleared the ref.
- const at = Date.now();
- window.setTimeout(() => {
- if (strokeSeenRef.current) setStrokeAt(at);
- }, 0);
- }
-
- // Defer React state updates to a requestAnimationFrame so they run from a
- // clean call-stack instead of deep inside the WS→CDP notification chain.
- // This prevents "Maximum call stack size exceeded" overflows during testing.
- pm5DataPendingRef.current = data;
- if (!pm5RafScheduledRef.current) {
- pm5RafScheduledRef.current = true;
- requestAnimationFrame(() => {
- pm5RafScheduledRef.current = false;
- const latest = pm5DataPendingRef.current;
- if (!latest) return;
-
- setPM5Data(latest);
-
- // Advance the structured workout, if one is running. No-op otherwise,
- // so a free row is untouched (#67 §4, §5).
- tickStructuredWorkout(latest);
-
- if (isWorkoutActive) {
- if (latest.heartRate) {
- const updated = workoutService.getCurrentSession();
- setHeartRateSamples(updated?.heartRateSamples ? [...updated.heartRateSamples] : []);
- }
- // Read latest session data directly from the service so mutations (distance,
- // duration, calories) are always reflected — spreading a stale React state
- // copy would freeze distance at whatever value it had on the first spread.
- const latestSession = workoutService.getCurrentSession();
- setCurrentSession(latestSession ? { ...latestSession } : null);
-
- // Finish when distance reaches route length: the banner, then the
- // summary (#336). Skipped in the Playwright harness, whose rows would
- // otherwise end under specs that are not about the finish, unless a
- // spec asks for it.
- if (
- selectedRoute &&
- typeof window !== 'undefined' &&
- (!window.__PLAYWRIGHT_TESTING || window.__VIRTUALROW_AUTO_FINISH)
- ) {
- const routeDistanceMeters = selectedRoute.distance * 1000;
- const completionThreshold = routeDistanceMeters * 0.995;
- if (latest.distance >= completionThreshold && routeDistanceMeters > 0 && !finishingRef.current) {
- finishingRef.current = true;
- setFinish({
- distanceMeters: routeDistanceMeters,
- elapsedMs: latest.elapsedTime
- ? latest.elapsedTime * 1000
- : (latestSession?.duration ?? 0) * 1000,
- });
- }
- }
- }
- });
- }
- }, [isWorkoutActive, selectedRoute, tickStructuredWorkout]);
-
- // The finish banner stays up for FINISH_BANNER_MS, then the summary opens.
- // Read through a ref so a re-created handler does not restart the wait.
- const handleEndWorkoutRef = useRef(handleEndWorkout);
- useEffect(() => {
- handleEndWorkoutRef.current = handleEndWorkout;
- }, [handleEndWorkout]);
- useEffect(() => {
- if (!finish) return undefined;
- const id = window.setTimeout(() => handleEndWorkoutRef.current(), FINISH_BANNER_MS);
- return () => window.clearTimeout(id);
- }, [finish]);
-
- /**
- * The light this row is rowed in (#346), remembered between rows. `auto`
- * matches the rower's own clock, which is the default.
- */
- const conditions = useConditions();
-
- const startSequence = useStartSequence({
- active: isWorkoutActive,
- strokeAt,
- autoStart: isDemoMode,
- });
-
- /*
- * The count, "Row!", and the line (#339). Here rather than in the scene:
- * these come from the start sequence and the finish, both of which live on
- * this side of the canvas.
- */
- useRaceCues(audioService, startSequence.phase, startSequence.countdown, finish !== null);
-
- // While the demo is running, simulated rower data flows through exactly the
- // same pipeline as a real PM5, so nothing downstream needs to know it is fake.
- useEffect(() => {
- if (!isDemoMode) return;
- pm5Simulator.addListener(handlePM5Data);
- return () => pm5Simulator.removeListener(handlePM5Data);
- }, [isDemoMode, handlePM5Data]);
-
- // Expose PM5 data on window for E2E tests to inspect cadence / pace
- useEffect(() => {
- if (typeof window !== 'undefined') {
- window.__PM5_DATA = pm5Data ?? undefined;
- }
- }, [pm5Data]);
-
- const handleHeartRateSample = useCallback((_bpm: number) => {
- if (isWorkoutActive) {
- requestAnimationFrame(() => {
- const session = workoutService.getCurrentSession();
- setHeartRateSamples(session?.heartRateSamples ? [...session.heartRateSamples] : []);
- setCurrentSession(session ? { ...session } : null);
- });
- }
- }, [isWorkoutActive]);
-
- // Persistent HR listener — HeartRateMonitor is only mounted on the 'routes' view, so
- // its listener is cleaned up when the workout starts and the view switches. This effect
- // stays alive for the lifetime of the App and ensures HR samples are written to the
- // workout session regardless of which view is active.
- useEffect(() => {
- const onHR = ({ bpm }: { bpm: number }) => {
- // Always update the service synchronously; defer state updates to RAF.
- if (isProcessingHrUpdateRef.current) return;
- isProcessingHrUpdateRef.current = true;
- try {
- workoutService.updateSessionHeartRate(bpm);
- } finally {
- isProcessingHrUpdateRef.current = false;
- }
- requestAnimationFrame(() => {
- const session = workoutService.getCurrentSession();
- setHeartRateSamples(session?.heartRateSamples ? [...session.heartRateSamples] : []);
- });
- };
- heartRateBluetoothService.on('heartRate', onHR);
- return () => heartRateBluetoothService.off('heartRate', onHR);
- }, []);
-
- // Track HR monitor connectivity for the lifetime of the app
- useEffect(() => {
- const onConnected = () => requestAnimationFrame(() => setHrConnected(true));
- const onDisconnected = () => requestAnimationFrame(() => setHrConnected(false));
- heartRateBluetoothService.on('connected', onConnected);
- heartRateBluetoothService.on('disconnected', onDisconnected);
- return () => {
- heartRateBluetoothService.off('connected', onConnected);
- heartRateBluetoothService.off('disconnected', onDisconnected);
- };
- }, []);
-
- const handleHrConnected = useCallback(() => setHrConnected(true), []);
- const handleHrDisconnected = useCallback(() => setHrConnected(false), []);
-
- // Connection state updates are deferred to RAF for the same reason as the PM5 data
- // above: these now run directly on the BLE notification stack.
- const handlePM5Connected = useCallback(() => {
- requestAnimationFrame(() => setPM5Connected(true));
- }, []);
-
- const handlePM5Disconnected = useCallback(() => {
- requestAnimationFrame(() => setPM5Connected(false));
- }, []);
-
- const handleFtmsConnected = useCallback(() => {
- requestAnimationFrame(() => setFtmsConnected(true));
- }, []);
-
- const handleFtmsDisconnected = useCallback(() => {
- requestAnimationFrame(() => setFtmsConnected(false));
- }, []);
-
- // FTMS data arrives in the same PM5Data shape; merge into shared rower data state
- const handleFtmsData = useCallback((data: PM5Data) => {
- // Re-use the PM5 data pipeline so all workout tracking works regardless of device type
- handlePM5Data(data);
- }, [handlePM5Data]);
-
- // Only the selected rower drives the session. Both services stay subscribed for the
- // lifetime of the app, so without this a PM5 left connected while FTMS is selected
- // (or vice versa) would interleave a second device's frames into the same session.
- const activeRowerTypeRef = useRef(activeRowerType);
- useEffect(() => {
- activeRowerTypeRef.current = activeRowerType;
- }, [activeRowerType]);
-
- const handleSelectedPM5Data = useCallback((data: PM5Data) => {
- if (activeRowerTypeRef.current !== 'pm5') return;
- handlePM5Data(data);
- }, [handlePM5Data]);
-
- const handleSelectedFtmsData = useCallback((data: PM5Data) => {
- if (activeRowerTypeRef.current !== 'ftms') return;
- handleFtmsData(data);
- }, [handleFtmsData]);
-
- // Persistent rower listeners. BluetoothDevice / FTMSDevice render only on the routes
- // view, so subscribing from there stopped every frame the instant a workout started
- // and the view switched to 'workout' — the session then recorded 0 m for the whole
- // row. These subscriptions outlive the view switch.
- useRowerServiceEvents(bluetoothService, {
- onData: handleSelectedPM5Data,
- onConnected: handlePM5Connected,
- onDisconnected: handlePM5Disconnected,
- });
-
- useRowerServiceEvents(ftmsBluetoothService, {
- onData: handleSelectedFtmsData,
- onConnected: handleFtmsConnected,
- onDisconnected: handleFtmsDisconnected,
- });
-
- // Playwright reads this to compare the card's distance with the engine's own
- // total; it is never set in a normal session.
- useEffect(() => {
- if (!window.__PLAYWRIGHT_TESTING) return;
- window.__SELECTED_ROUTE = selectedRoute
- ? {
- id: selectedRoute.id,
- name: selectedRoute.name,
- distanceKm: selectedRoute.distance,
- geometrySource: selectedRoute.geometrySource,
- externalDistanceMeters: selectedRoute.externalDistanceMeters,
- }
- : undefined;
- }, [selectedRoute]);
-
- const handleRouteImported = useCallback((route: WaterRoute) => {
- setRoutes(routeService.getAllRoutes());
- setSelectedRoute(route);
- // A deep-linked or freshly imported course outranks the stored default
- // for this page load (issue #219, AC6.10).
- hasPreselectedRef.current = true;
- setCurrentView('routes');
- }, []);
-
- // Deep link: if the app was opened with ?rownativeCourseId=, load that
- // course and select it. Held until auth resolves so a shared link survives a
- // sign-in round trip.
- const { status: handoffStatus, dismiss: dismissHandoff } = useRownativeDeepLink({
- onRouteLoaded: handleRouteImported,
- isReady: !isLoading,
- });
-
- /**
- * Import a route file, dispatching on its type.
- *
- * This used to parse whatever was chosen as JSON, so a .gpx or .kml drop —
- * both offered by the file picker — silently did nothing (issue #194 F7).
- */
- const handleRouteFileImport = useCallback((e: React.ChangeEvent) => {
- const file = e.target.files?.[0];
- if (!file) return;
- setImportError(null);
-
- const reader = new FileReader();
- reader.onload = (ev) => {
- const text = ev.target?.result as string;
- const fallbackName = importRouteName.trim() || file.name.replace(/\.[^.]+$/, '');
- const format = detectTrackFormat(file.name);
-
- try {
- if (!format) {
- throw new TrackParseError(
- `${file.name} is not a route file VirtualRow can read. Use a .gpx, .kml or .geojson file.`,
- );
- }
-
- let imported: WaterRoute | undefined;
- if (format === 'gpx') {
- imported = routeService.importRouteFromGPX(text, {
- name: fallbackName,
- difficulty: 'moderate',
- location: 'Imported',
- tags: ['imported', 'gpx'],
- });
- } else if (format === 'kml') {
- const result = routeService.importRouteFromKML(text, {
- name: importRouteName.trim() || undefined,
- difficulty: 'moderate',
- tags: ['imported', 'kml'],
- });
- if (result.status === 'error') throw new TrackParseError(result.error);
- if (result.status === 'selectionRequired') {
- imported = routeService.finalizeKMLImport(result.candidates[0], {
- name: importRouteName.trim() || undefined,
- difficulty: 'moderate',
- tags: ['imported', 'kml'],
- });
- } else {
- imported = result.route;
- }
- } else {
- const parsed = JSON.parse(text) as { properties?: { name?: string; country?: string } };
- imported = routeService.importRouteFromGeoJSON(text, {
- name: importRouteName.trim() || parsed?.properties?.name || fallbackName,
- difficulty: 'moderate',
- location: parsed?.properties?.country ?? 'Imported',
- tags: ['imported', 'geojson'],
- });
- }
-
- if (!imported) {
- throw new TrackParseError(`${file.name} has no route line with at least 2 points.`);
- }
-
- handleRouteImported(imported);
- setIsImportOpen(false);
- setImportRouteName('');
- e.target.value = '';
- } catch (error) {
- setImportError(
- error instanceof Error ? error.message : `${file.name} could not be imported.`,
- );
- }
- };
- reader.readAsText(file);
- }, [importRouteName, handleRouteImported]);
- const latestHeartRate = useMemo(() => (
- heartRateSamples.length > 0
- ? heartRateSamples[heartRateSamples.length - 1].bpm
- : (pm5Data?.heartRate ?? null)
- ), [heartRateSamples, pm5Data]);
- /**
- * Sound, off until a rower asks for it (#339).
- *
- * A browser will not start an AudioContext without a gesture, so this is not
- * a preference the app can honour quietly at load - the switch is the
- * gesture. The volume is kept here so it survives switching the sound off
- * and on again.
- */
- const [soundEnabled, setSoundEnabled] = useState(false);
- const [soundVolume, setSoundVolume] = useState(() => audioService.getVolume());
-
- const handleSoundToggle = useCallback(
- (enabled: boolean) => {
- setSoundEnabled(enabled);
- void (enabled ? audioService.enable() : audioService.disable());
- },
- [audioService],
- );
-
- const handleSoundVolume = useCallback(
- (volume: number) => {
- setSoundVolume(volume);
- audioService.setVolume(volume);
- },
- [audioService],
- );
-
- /**
- * The water bed runs with the row, not with the page.
- *
- * A loop that started when the sound was switched on would play under the
- * route list and the settings panel, which are not on the water.
- */
- useEffect(() => {
- if (!soundEnabled || !isWorkoutActive) {
- audioService.stopWater();
- return;
- }
- audioService.startWater();
- return () => audioService.stopWater();
- }, [audioService, soundEnabled, isWorkoutActive]);
-
- const workoutElapsedTimeMs = useMemo(() => (
- pm5Data?.elapsedTime ? pm5Data.elapsedTime * 1000 : activityElapsedMs
- ), [activityElapsedMs, pm5Data]);
- elapsedSecondsRef.current = workoutElapsedTimeMs / 1000;
-
- /**
- * How hard the strokes are landing, for how loud they sound (#339).
- *
- * From the power, because that is what the stroke actually cost: a light
- * paddle and a racing catch are the same sound at different volumes, and a
- * constant would make every stroke sound like the same stroke. Floored, so a
- * quiet row is quiet rather than silent.
- */
- const strokeIntensity = useMemo(() => {
- const watts = pm5Data?.power ?? 0;
- return Math.min(1, Math.max(0.2, watts / 300));
- }, [pm5Data?.power]);
-
- /**
- * The gap to the ghost, for the HUD (#338).
- *
- * Null until the row has actually started: two boats on the start line have
- * no gap worth reading, and "level" before the first stroke is noise.
- */
- const ghostGap = useMemo(() => {
- if (!ghostSource) return null;
- const rowerMeters = pm5Data?.distance ?? 0;
- const started = workoutElapsedTimeMs > 0 && rowerMeters > 0;
- return {
- gapMeters: started
- ? gapMeters(rowerMeters, ghostDistanceAt(ghostSource, workoutElapsedTimeMs / 1000))
- : null,
- label: ghostLabel,
- };
- }, [ghostSource, ghostLabel, pm5Data?.distance, workoutElapsedTimeMs]);
- const activityProgressPercent = useMemo(() => (
- pm5Data && selectedRoute
- ? Math.min(100, (pm5Data.distance / 1000) / selectedRoute.distance * 100)
- : 0
- ), [pm5Data, selectedRoute]);
-
- return (
-
- You are rowing as a guest — sessions are not saved. Sign in with intervals.icu to keep them.
-
- )}
-
-
-
-
-
-
- {/* -- Row screen ---------------------------------------------
- One route, its map, and the two ways to start it. Everything to do
- with finding a different route lives on the Routes screen
- (issue #219, R2). */}
- {currentView === 'routes' && selectedRoute && (
-
- )}
-
- {/* Decided before the row, not during it (#338). */}
-
-
- {/* Signed-in athletes can load today's planned rowing
- session from intervals.icu, or search a nearby day
- (issue #445 FR2/FR3/FR7). Guests get neither control. */}
- {user && authService.getAccessToken() && (
- {
- currentWorkout.set(workout);
- }}
- />
- )}
-
- {currentWorkout.current && (
-
- Loaded: {currentWorkout.current.name}
-
- )}
-
-
- {selectedRowerConnected && hrConnected
- ? '▶ Start Workout'
- : !selectedRowerConnected
- ? `⚠ Connect ${activeRowerLabel} First`
- : '⚠ Connect HR Monitor First'}
-
-
- {/* The single door to route discovery (issue #219, AC2.2). */}
- setCurrentView('route-search')}
- >
- Change route
-
-
- {/* Offered to guests always (issue #219 AC7.2) and to
- signed-in athletes only via the debug-panel override
- (#453 FR1, FR3, FR7). */}
- {demoCtaVisible && (
-
-
- ▶ Try a demo row — no rowing machine needed
-
-
- Rows this route on simulated rower and heart-rate data, so you can see how it
- feels before connecting anything.
-
-
- )}
-
- {/* The Graphics picker moved to the debug panel (#454
- Phase 1). #346's ConditionsPicker below is still on the
- Row screen — it is a per-row mood control, not a
- once-and-done device tuning. */}
-
-
-
-
- {/* Signed-in athletes whose intervals.icu profile carries a
- `sex` read the rower from that profile alone — the picker
- is only for guests, demo rows and profiles without the
- field (issue #443, keeps #232's fix for the last case). */}
- {!user?.gender ? (
-
- ) : (
-
- )}
- {/* Clothing / hair colour picker (#474). intervals.icu does
- not carry clothing, so this is visible to everyone,
- including athletes whose gender comes from their profile. */}
-
-
-
-
- )}
-
- {/* -- Routes screen ------------------------------------------------
- Course ID first, then search by name, then the local catalogue,
- then file import behind a disclosure (issue #219, R3). */}
- {currentView === 'route-search' && (
-
-
- {/* #505: a MapLibre overview sits above the catalogue, with
- the modal detail overlay (D8), clustering (D4) and
- tile-backdrop cards (D3) coming through the drawer. */}
-
-
- {/* How much of the wait is done, and gone when it really
- is (#318). The Suspense fallback below covers only the
- code chunk, which arrives 0.7s into a 3.2s load; the bar
- sits over the stage for the whole of it. */}
-
-
- {/* #454 Phase 2 (NFR2): withhold the canvas until the
- device probe resolves, so the tier is picked before the
- scene mounts for the selected route. The
- {@link RouteLoadingBar} above still covers the wait;
- cold-cache boot is capped at 200 ms (BENCHMARK_WALL_
- CLOCK_BUDGET_MS) and a cache hit returns in O(1). */}
-
- {devicePerformance.ready && (
-
- )}
-
-
- {currentWorkout.current && currentWorkout.progress && (
-
- )}
-
-
-
{selectedRoute?.name}
-
{selectedRoute?.location}
- {isDemoMode && (
-
- Demo row — simulated data, not a recorded workout
-
- )}
-
-
-
-
-
-
- {finish ? (
-
- ) : (
-
- )}
-
- {/* On the stage, not under it (#335). Last inside the stage
- so it layers over the canvas and the two overlays without
- needing a z-index taller than either. */}
-
-
-
-
-
- )}
-
-
-
-
- {/* Debug Panel - Global, appears on all views */}
-
-
- {/* Debug Info Panel - only visible when debugMode is on */}
- {debugMode && (
-
-
-
🔧 Debug Mode
- setDebugMode(false)}>✕
-
-
- {/* Session-flow overrides (#453 FR6). Sits above PM5 Simulator so
- a tester finds it before scrolling. */}
-
-
Session
-
-
-
- {/* Graphics tier picker (#454 Phase 1, FR5).
- Moved off the Row screen so it does not compete with the
- Start-Workout controls. `useGraphicsQuality()` still owns the
- underlying `virtualrow:graphics-quality` key (#454 D9), so the
- picker's storage contract is unchanged. */}
-
-
Graphics
-
-
-
- {/* PM5 Simulator Controls */}
-
-
PM5 Simulator
-
-
-
- {/* Heart Rate Simulator Controls */}
-
-
Heart Rate Simulator
-
-
-
-
-
Overlays
-
-
-
-
-
-
-
Scene
-
-
- {renderStats.stats ? (
- <>
- {/* Against the budget CI holds, not on its own: a draw
- call count means nothing without the line it is near
- (#342). The breached axes are named in red so the panel
- answers the same question the gate does. */}
-
-
Draw calls:
-
{renderStats.stats.drawCalls} / {RENDER_BUDGET[renderStats.stats.performanceMode as PerformanceMode]?.drawCalls ?? '—'}
-
-
-
Triangles:
-
{renderStats.stats.triangles.toLocaleString()} / {RENDER_BUDGET[renderStats.stats.performanceMode as PerformanceMode]?.triangles.toLocaleString() ?? '—'}
-
-
FPS:
{renderStats.stats.fps?.toFixed(1) ?? 'N/A'}
-
-
Frame p95 (ms):
-
{renderStats.stats.p95Ms?.toFixed(1) ?? 'N/A'} / {RENDER_BUDGET[renderStats.stats.performanceMode as PerformanceMode]?.p95Ms ?? '—'}
- {/* What the scene has been doing, kept in session storage so it
- survives the reload a killed renderer produces - which is the
- one case where every other reading has already gone. */}
-
- {telemetryCount} event{telemetryCount === 1 ? '' : 's'} recorded this tab.
- {' '}Kept in session storage; cleared when the tab closes.
-
- );
- }
-
- render(
-
-
-
- );
-
- expect(screen.getByTestId('auth').textContent).toBe('true');
-
- await act(async () => {
- screen.getByRole('button', { name: 'logout' }).click();
- });
-
- expect(logoutFn).toHaveBeenCalled();
- expect(screen.getByTestId('auth').textContent).toBe('false');
- expect(screen.getByTestId('name').textContent).toBe('null');
- });
-
- it('does not trigger callback when URL has no code/state', () => {
- const service = makeServiceStub({ handleCallback: vi.fn() });
- window.history.replaceState({}, '', '/');
-
- render(
-
-
-
- );
-
- expect(service.handleCallback).not.toHaveBeenCalled();
- });
-
- it('handles React 18 Strict Mode double mount without losing authentication', async () => {
- const callbackUser: AuthUser = { id: 'i5', name: 'Strict Mode User', email: '' };
- let resolveCallback: (u: AuthUser | null) => void;
- const promise = new Promise((resolve) => {
- resolveCallback = resolve;
- });
-
- const handleCallbackMock = vi.fn(() => promise);
- const service = makeServiceStub({
- handleCallback: handleCallbackMock,
- });
-
- window.history.replaceState({}, '', '/?code=strict-code&state=strict-state');
-
- const { unmount } = render(
-
-
-
- );
-
- unmount();
-
- render(
-
-
-
- );
-
- await act(async () => {
- resolveCallback!(callbackUser);
- await promise;
- });
-
- expect(screen.getByTestId('authenticated').textContent).toBe('true');
- expect(screen.getByTestId('user-name').textContent).toBe('Strict Mode User');
- expect(handleCallbackMock).toHaveBeenCalledTimes(2);
- });
-
- // Intentionally not testing that we remembered originalSearch to stop TS warning
- void originalSearch;
-});
diff --git a/src/__tests__/authGateModal.test.tsx b/src/__tests__/authGateModal.test.tsx
deleted file mode 100644
index c8724423..00000000
--- a/src/__tests__/authGateModal.test.tsx
+++ /dev/null
@@ -1,96 +0,0 @@
-import { describe, it, expect, vi } from 'vitest';
-import { render, screen } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { AuthGateModal } from '../components/AuthGateModal';
-import { AuthProvider } from '../context/AuthContext';
-
-// Suppress console noise
-vi.spyOn(console, 'error').mockImplementation(() => {});
-
-function makeServiceStub() {
- return {
- getUser: vi.fn(() => null),
- startLogin: vi.fn(() => Promise.resolve()),
- handleCallback: vi.fn(() => Promise.resolve(null)),
- refreshAccessToken: vi.fn(() => Promise.resolve(false)),
- logout: vi.fn(),
- getAccessToken: vi.fn(() => null),
- scheduleRefresh: vi.fn(),
- get isAuthenticated() { return false; },
- } as unknown as import('../lib/services/authService').AuthService;
-}
-
-function renderModal(props: {
- isOpen?: boolean;
- actionDescription?: string;
- onLogin?: () => void;
- onDismiss?: () => void;
-}) {
- const onLogin = props.onLogin ?? vi.fn();
- const onDismiss = props.onDismiss ?? vi.fn();
- const service = makeServiceStub();
-
- render(
-
-
-
- );
-
- return { onLogin, onDismiss };
-}
-
-describe('AuthGateModal', () => {
- it('renders nothing when isOpen is false', () => {
- renderModal({ isOpen: false });
- expect(screen.queryByRole('dialog')).toBeNull();
- });
-
- it('renders the modal when isOpen is true', () => {
- renderModal({ isOpen: true });
- expect(screen.getByRole('dialog')).toBeTruthy();
- });
-
- it('displays the sign-in title', () => {
- renderModal({ isOpen: true });
- expect(screen.getByText(/sign in to continue/i)).toBeTruthy();
- });
-
- it('shows the custom action description', () => {
- renderModal({ isOpen: true, actionDescription: 'save your workout history' });
- expect(screen.getByText(/save your workout history/i)).toBeTruthy();
- });
-
- it('shows generic message when no actionDescription provided', () => {
- renderModal({ isOpen: true });
- expect(screen.getByText(/requires a free intervals\.icu account/i)).toBeTruthy();
- });
-
- it('calls onLogin when sign-in button is clicked', async () => {
- const user = userEvent.setup();
- const { onLogin } = renderModal({ isOpen: true });
-
- await user.click(screen.getByRole('button', { name: /sign in with intervals/i }));
- expect(onLogin).toHaveBeenCalledOnce();
- });
-
- it('calls onDismiss when "Continue as Guest" is clicked', async () => {
- const user = userEvent.setup();
- const { onDismiss } = renderModal({ isOpen: true });
-
- await user.click(screen.getByRole('button', { name: /continue as guest/i }));
- expect(onDismiss).toHaveBeenCalledOnce();
- });
-
- it('calls onDismiss when the close (✕) button is clicked', async () => {
- const user = userEvent.setup();
- const { onDismiss } = renderModal({ isOpen: true });
-
- await user.click(screen.getByRole('button', { name: /close/i }));
- expect(onDismiss).toHaveBeenCalledOnce();
- });
-});
diff --git a/src/__tests__/bankLand.test.ts b/src/__tests__/bankLand.test.ts
deleted file mode 100644
index aeab7e9b..00000000
--- a/src/__tests__/bankLand.test.ts
+++ /dev/null
@@ -1,185 +0,0 @@
-import * as THREE from 'three';
-import { describe, expect, it } from 'vitest';
-import { layoutFoliage } from '../components/rower3d/foliagePlan';
-import { layoutLandscape } from '../components/rower3d/landscapeLayout';
-import { SCENE_CONFIG } from '../components/rower3d/themeConfig';
-import { getSegmentSceneryProfile } from '../components/rower3d/segmentScenery';
-import {
- applyMeasuredShores,
- SCENERY_PROFILE_CONFIG,
- type OverpassElement,
- type RouteEnrichmentData,
- type RouteSegmentEnrichment,
-} from '../lib/services/routeEnrichmentService';
-import type { Coordinate } from '../lib/types/index';
-import { buildLandAreas, landAt } from '../lib/utils/bankLand';
-import { runRouteDetection } from '../lib/utils/routeDetection';
-import type { DetectedSample } from '../lib/utils/shoreDetection';
-
-/**
- * #493 FR18 / AC19: each bank's land is read from the OSM area just beyond
- * that bank's shore, not from the bbox-nearest feature shared by both (G6).
- */
-
-// Local metres about (0, 0): x east, y north.
-const M = 1 / 111_320;
-const at = (x: number, y: number): Coordinate => ({ lat: y * M, lng: x * M });
-const box = (x0: number, y0: number, x1: number, y1: number) => [at(x0, y0), at(x1, y0), at(x1, y1), at(x0, y1), at(x0, y0)];
-const way = (id: number, tags: Record, ring: Coordinate[]): OverpassElement => ({
- type: 'way',
- id,
- tags,
- geometry: ring.map(({ lat, lng }) => ({ lat, lon: lng })),
-});
-
-// A 60 m channel running north, the route 15 m from its west bank. Heading
-// north, west is the rower's left.
-const channel = way(1, { natural: 'water', water: 'river' }, box(-15, -100, 45, 1100));
-const town = way(2, { landuse: 'residential' }, box(-400, -100, -15, 1100));
-const park = way(3, { leisure: 'park' }, box(45, -100, 400, 1100));
-const route = [at(0, 0), at(0, 1000)];
-const bounds = { minLat: -200 * M, minLng: -700 * M, maxLat: 1200 * M, maxLng: 700 * M };
-
-describe('#493 FR18 land on each bank', () => {
- it('finds the land area a point is in, the smallest where they overlap', () => {
- const playground = way(4, { leisure: 'park' }, box(-200, 400, -100, 500));
- const areas = buildLandAreas([channel, town, playground, way(5, { landuse: 'forest' }, [at(0, 0), at(1, 1)])]);
-
- expect(landAt(areas, at(-50, 100))).toBe('residential');
- expect(landAt(areas, at(-150, 450))).toBe('farmland');
- expect(landAt(areas, at(1000, 0))).toBeNull();
- // An open way encloses nothing; water is not land.
- expect(areas).toHaveLength(2);
- });
-
- it('gives each on-water sample the land beyond each shore', () => {
- const { samples } = runRouteDetection({ coordinates: route, elements: [channel, town, park], coastlineBounds: bounds });
- const water = samples.filter((sample) => sample.kind === 'water');
-
- expect(water.length).toBeGreaterThan(10);
- for (const sample of water) {
- expect(sample.leftLand).toBe('residential');
- expect(sample.rightLand).toBe('farmland');
- }
- });
-
- it('leaves a side unknown when its shore is open or nothing is mapped there', () => {
- const { samples } = runRouteDetection({ coordinates: route, elements: [channel, town], coastlineBounds: bounds });
- const lake = way(1, { natural: 'water' }, box(-15, -100, 2000, 1100));
- const open = runRouteDetection({ coordinates: route, elements: [lake, town], coastlineBounds: bounds }).samples;
-
- expect(samples.every((sample) => sample.kind !== 'water' || sample.rightLand === null)).toBe(true);
- expect(open.filter((sample) => sample.kind === 'water').every((sample) => sample.rightShoreMeters === null && sample.rightLand === null)).toBe(true);
- });
-});
-
-const segment = (index: number, overrides: Partial = {}): RouteSegmentEnrichment => ({
- index,
- startMeters: index * 50,
- endMeters: (index + 1) * 50,
- sceneryProfile: 'forest',
- ...SCENERY_PROFILE_CONFIG.forest,
- waterWidthMeters: 55,
- dragMultiplier: 1,
- bearing: 0,
- bearingDelta: 0,
- ...overrides,
-});
-
-const sample = (distanceMeters: number, overrides: Partial = {}): DetectedSample => ({
- distanceMeters,
- coordinate: { lat: 0, lng: 0 },
- kind: 'water',
- featureId: 'way/1',
- leftShoreMeters: 15,
- rightShoreMeters: 45,
- leftLand: 'residential',
- rightLand: 'farmland',
- ...overrides,
-});
-
-describe('#493 FR18 per-bank profile on each segment', () => {
- it('takes the most common land on each side', () => {
- const [profile] = applyMeasuredShores(
- [segment(0)],
- [sample(0), sample(10, { leftLand: 'commercial' }), sample(20), sample(30, { rightLand: null })],
- );
- expect(profile.leftSceneryProfile).toBe('residential');
- expect(profile.rightSceneryProfile).toBe('farmland');
- });
-
- it('keeps the shared profile on a side where no sample found land', () => {
- const [profile] = applyMeasuredShores([segment(0)], [sample(0, { rightLand: null }), sample(25, { rightLand: undefined })]);
- expect(profile.leftSceneryProfile).toBe('residential');
- expect(profile.rightSceneryProfile).toBeUndefined();
- });
-
- it('breaks a tie the same way whatever the sample order', () => {
- const a = [sample(0, { leftLand: 'forest' }), sample(10, { leftLand: 'beach' })];
- expect(applyMeasuredShores([segment(0)], a)[0].leftSceneryProfile).toBe(
- applyMeasuredShores([segment(0)], [...a].reverse())[0].leftSceneryProfile,
- );
- });
-
- it('is read per side, the shared profile standing in for a side with none, and a track over both', () => {
- const enrichment = {
- segmentProfiles: [segment(0, { leftSceneryProfile: 'residential' })],
- } as RouteEnrichmentData;
-
- expect(getSegmentSceneryProfile(enrichment, 0, null, 'left')).toBe('residential');
- expect(getSegmentSceneryProfile(enrichment, 0, null, 'right')).toBe('forest');
- expect(getSegmentSceneryProfile(enrichment, 0)).toBe('forest');
- expect(
- getSegmentSceneryProfile(enrichment, 0, { bands: [{ untilProgress: 1, profile: 'beach', water: 'river', label: 'Beach' }] }, 'left'),
- ).toBe('beach');
- });
-});
-
-describe('#493 AC19 landscape layout per bank', () => {
- const straight = new THREE.CatmullRomCurve3([new THREE.Vector3(0, 0, -600), new THREE.Vector3(0, 0, 600)]);
- const banks = (left: RouteSegmentEnrichment['leftSceneryProfile'], right: RouteSegmentEnrichment['rightSceneryProfile']) =>
- ({
- segmentProfiles: [0, 1, 2, 3].map((index) =>
- segment(index, {
- sceneryProfile: 'farmland',
- ...SCENERY_PROFILE_CONFIG.farmland,
- leftSceneryProfile: left,
- rightSceneryProfile: right,
- }),
- ),
- elevations: [],
- }) as unknown as RouteEnrichmentData;
-
- it('builds a town on the residential bank and leaves the park bank open', () => {
- const { leftElements, rightElements } = layoutLandscape({ curve: straight, enrichment: banks('residential', 'farmland') });
- const buildings = (elements: typeof leftElements) => elements.filter((element) => element.type === 'building');
-
- expect(buildings(leftElements).length).toBeGreaterThan(buildings(rightElements).length * 3);
- expect(leftElements.every((element) => element.sceneryProfile === 'residential')).toBe(true);
- expect(rightElements.every((element) => element.sceneryProfile === 'farmland')).toBe(true);
- });
-
- it('matches the shared-profile layout where neither bank has its own', () => {
- const shared = layoutLandscape({ curve: straight, enrichment: banks(undefined, undefined) });
- const both = layoutLandscape({ curve: straight, enrichment: banks('farmland', 'farmland') });
- expect(both).toEqual(shared);
- });
-});
-
-describe('#493 FR18 foliage per bank', () => {
- it('plants each bank by its own land', () => {
- const curve = new THREE.CatmullRomCurve3([new THREE.Vector3(0, 0, -1000), new THREE.Vector3(0, 0, 1000)]);
- const enrichment = {
- segmentProfiles: [0, 1, 2, 3].map((index) =>
- segment(index, { sceneryProfile: 'farmland', leftSceneryProfile: 'forest', rightSceneryProfile: 'beach' }),
- ),
- elevations: [],
- } as unknown as RouteEnrichmentData;
- const trees = layoutFoliage({ curve, enrichment, species: SCENE_CONFIG.trees.species });
- // Heading +z the rower's right is -x (perp = tangent × up).
- const left = trees.filter((tree) => tree.position[0] > 0).length;
- const right = trees.filter((tree) => tree.position[0] < 0).length;
-
- expect(left).toBeGreaterThan(right * 2);
- });
-});
diff --git a/src/__tests__/bankMaterial.test.ts b/src/__tests__/bankMaterial.test.ts
deleted file mode 100644
index 173f2299..00000000
--- a/src/__tests__/bankMaterial.test.ts
+++ /dev/null
@@ -1,205 +0,0 @@
-import { afterAll, beforeAll, describe, it, expect } from 'vitest';
-import * as THREE from 'three';
-import { installCanvasMock } from './canvasMock';
-import {
- bankMaterial,
- hasBankGrassDensity,
- isBankTriplanarMaterial,
-} from '../components/rower3d/bankMaterial';
-import { resetDetailTextureCacheForTesting } from '../components/rower3d/detailTexture';
-
-let uninstallCanvas: () => void;
-beforeAll(() => {
- uninstallCanvas = installCanvasMock();
-});
-afterAll(() => uninstallCanvas());
-
-const theme = { color: '#7a8a3c', roughness: 0.9, metalness: 0 };
-
-describe('bankMaterial (#430)', () => {
- it('carries the triplanar shader at auto', () => {
- resetDetailTextureCacheForTesting();
- const mat = bankMaterial(theme, 'medium');
- expect(isBankTriplanarMaterial(mat)).toBe(true);
- expect(mat.onBeforeCompile).toBeDefined();
- });
-
- it('carries the triplanar shader at high', () => {
- resetDetailTextureCacheForTesting();
- expect(isBankTriplanarMaterial(bankMaterial(theme, 'extra-high'))).toBe(true);
- });
-
- it('drops the triplanar shader at low, no shader edit', () => {
- resetDetailTextureCacheForTesting();
- const mat = bankMaterial(theme, 'basic');
- expect(isBankTriplanarMaterial(mat)).toBe(false);
- // A plain onBeforeCompile default on THREE.Material is a no-op function
- // that returns void; identity through cloning is not required here.
- expect(mat.userData?.virtualrowBank).toBeUndefined();
- });
-
- it('installs uGrass, uEarth and the shore varying at auto', () => {
- resetDetailTextureCacheForTesting();
- const mat = bankMaterial(theme, 'medium');
-
- const shader = {
- uniforms: {} as Record,
- vertexShader:
- 'placeholder-vertex\n#include \nplaceholder-vertex-end',
- fragmentShader:
- 'placeholder-fragment\n#include \n' +
- '#include \nplaceholder-fragment-end',
- };
- mat.onBeforeCompile!(
- shader as unknown as THREE.WebGLProgramParametersWithUniforms,
- {} as THREE.WebGLRenderer,
- );
-
- expect(shader.uniforms.uGrass).toBeDefined();
- expect(shader.uniforms.uEarth).toBeDefined();
- expect(shader.vertexShader).toContain('attribute float shoreDist');
- expect(shader.vertexShader).toContain('vBankWorldPos');
- expect(shader.fragmentShader).toContain('bankTriplanar');
- expect(shader.fragmentShader).toContain('vBankShoreDist');
- // FR2: the slope blend at smoothstep(0.3, 0.7).
- expect(shader.fragmentShader).toMatch(/smoothstep\(\s*0\.3\s*,\s*0\.7/);
- // FR3: the shore blend at smoothstep(0, 2.5) with a wet-earth 0.65.
- expect(shader.fragmentShader).toMatch(/smoothstep\(\s*0\.0\s*,\s*2\.5/);
- expect(shader.fragmentShader).toContain('0.65');
- // FR4: macro noise at 64 m.
- expect(shader.fragmentShader).toContain('/ 64.0');
- });
-
- it('installs the normal maps and samples them triplanar at auto (#437)', () => {
- resetDetailTextureCacheForTesting();
- const mat = bankMaterial(theme, 'medium');
-
- const shader = {
- uniforms: {} as Record,
- vertexShader:
- 'placeholder-vertex\n#include \nplaceholder-vertex-end',
- fragmentShader:
- 'placeholder-fragment\n#include \n' +
- '#include \nplaceholder-fragment-end',
- };
- mat.onBeforeCompile!(
- shader as unknown as THREE.WebGLProgramParametersWithUniforms,
- {} as THREE.WebGLRenderer,
- );
-
- // FR1: the two normal uniforms are installed.
- expect(shader.uniforms.uGrassN).toBeDefined();
- expect(shader.uniforms.uEarthN).toBeDefined();
- // FR2: each is sampled triplanar (same helper as the albedo) and the two
- // are blended by the same slope weight before ``.
- expect(shader.fragmentShader).toContain('uGrassN');
- expect(shader.fragmentShader).toContain('uEarthN');
- expect(shader.fragmentShader).toMatch(/bankTriplanar\(\s*uGrassN/);
- expect(shader.fragmentShader).toMatch(/bankTriplanar\(\s*uEarthN/);
- });
-
- it('leaves the shader alone at high past what auto installs (#437)', () => {
- resetDetailTextureCacheForTesting();
- const mat = bankMaterial(theme, 'extra-high');
-
- const shader = {
- uniforms: {} as Record,
- vertexShader:
- 'placeholder-vertex\n#include \nplaceholder-vertex-end',
- fragmentShader:
- 'placeholder-fragment\n#include \n' +
- '#include \nplaceholder-fragment-end',
- };
- mat.onBeforeCompile!(
- shader as unknown as THREE.WebGLProgramParametersWithUniforms,
- {} as THREE.WebGLRenderer,
- );
-
- expect(shader.uniforms.uGrassN).toBeDefined();
- expect(shader.uniforms.uEarthN).toBeDefined();
- });
-
- it('installs no normal maps at low (#437)', () => {
- resetDetailTextureCacheForTesting();
- const mat = bankMaterial(theme, 'basic');
-
- // Low returns a plain MeshStandardMaterial with no shader edit, so nothing
- // installs uGrassN/uEarthN. onBeforeCompile stays the base no-op.
- expect(mat.userData?.virtualrowBank).toBeUndefined();
- expect(mat.normalMap).toBeNull();
- });
-
- it('names a custom program cache key so a swap does not miss', () => {
- resetDetailTextureCacheForTesting();
- const mat = bankMaterial(theme, 'medium');
- expect(mat.customProgramCacheKey!()).toBe('virtualrow-bank-triplanar');
- });
-
- it('carries the authored colour as albedo tint', () => {
- resetDetailTextureCacheForTesting();
- const mat = bankMaterial(theme, 'medium');
- expect(mat.color.getHexString()).toBe('7a8a3c');
- });
-
- it('at low, keeps the grass albedo as `map` and no shader', () => {
- resetDetailTextureCacheForTesting();
- const mat = bankMaterial(theme, 'basic');
- expect(mat.map).not.toBeNull();
- });
-});
-
-/**
- * #496 Phase 2 — the triplanar bank material carries a density-field grass
- * contribution when a biome tile is handed in. The sampler, strength uniform
- * and modulation chunk only compile into the fragment shader on that path;
- * basic/low and medium+ without a tile keep the Phase 1 shape exactly.
- */
-describe('bankMaterial density-field grass (#496 Phase 2)', () => {
- const grassTexture = (): THREE.Texture => new THREE.DataTexture(
- new Uint8Array([128, 128, 128, 255]),
- 1,
- 1,
- );
-
- it('without a density tile, the material compiles exactly as Phase 1', () => {
- resetDetailTextureCacheForTesting();
- const mat = bankMaterial(theme, 'medium');
- expect(hasBankGrassDensity(mat)).toBe(false);
- expect(mat.customProgramCacheKey!()).toBe('virtualrow-bank-triplanar');
- });
-
- it('with a density tile, carries the density flag and the strength uniform', () => {
- resetDetailTextureCacheForTesting();
- const texture = grassTexture();
- const mat = bankMaterial(theme, 'medium', { texture, strength: 0.7 });
- expect(hasBankGrassDensity(mat)).toBe(true);
- expect(mat.customProgramCacheKey!()).toBe('virtualrow-bank-triplanar-density');
-
- const shader = {
- uniforms: {} as Record,
- vertexShader:
- 'placeholder-vertex\n#include \nplaceholder-vertex-end',
- fragmentShader:
- 'placeholder-fragment\n#include \n' +
- '#include \nplaceholder-fragment-end',
- };
- mat.onBeforeCompile!(
- shader as unknown as THREE.WebGLProgramParametersWithUniforms,
- {} as THREE.WebGLRenderer,
- );
- expect(shader.uniforms.uBankGrassDensity?.value).toBe(texture);
- expect(shader.uniforms.uBankGrassDensityStrength?.value).toBe(0.7);
- expect(shader.fragmentShader).toContain('uBankGrassDensity');
- expect(shader.fragmentShader).toContain('bankGrassDensity');
- });
-
- it('is a no-op compile at basic — basic grass stays off (AC3)', () => {
- resetDetailTextureCacheForTesting();
- const texture = grassTexture();
- const mat = bankMaterial(theme, 'basic', { texture, strength: 0.7 });
- // Basic returns the single-albedo material above the triplanar branch; it
- // ignores the density config so no sampler compiles into its shader.
- expect(isBankTriplanarMaterial(mat)).toBe(false);
- expect(hasBankGrassDensity(mat)).toBe(false);
- });
-});
diff --git a/src/__tests__/bankShoreDist.test.ts b/src/__tests__/bankShoreDist.test.ts
deleted file mode 100644
index 98077274..00000000
--- a/src/__tests__/bankShoreDist.test.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import * as THREE from 'three';
-import { createBankGeometry } from '../components/rower3d/bankGeometry';
-
-const straightCurve = () =>
- new THREE.CatmullRomCurve3([
- new THREE.Vector3(0, 0, 0),
- new THREE.Vector3(0, 0, -50),
- new THREE.Vector3(0, 0, -100),
- new THREE.Vector3(0, 0, -150),
- ]);
-
-describe('bank shoreDist attribute (#353)', () => {
- it('the attribute is present with the right cardinality', () => {
- const geo = createBankGeometry(straightCurve(), 'left');
- const attr = geo.getAttribute('shoreDist') as THREE.BufferAttribute;
- expect(attr).toBeDefined();
- expect(attr.itemSize).toBe(1);
- const positions = geo.getAttribute('position') as THREE.BufferAttribute;
- expect(attr.count).toBe(positions.count);
- });
-
- it('the inner-edge vertices sit at shoreDist 0', () => {
- // The strip is built as pairs (inner, outer) per sample: even indices are
- // the inner edge — the waterline — and must all be 0.
- const geo = createBankGeometry(straightCurve(), 'left');
- const attr = geo.getAttribute('shoreDist') as THREE.BufferAttribute;
- for (let i = 0; i < attr.count; i += 2) {
- expect(attr.getX(i)).toBe(0);
- }
- });
-
- it('the outer-edge vertices sit further from the waterline than the inner ones', () => {
- // Monotonic outward: odd indices (outer edge) carry positive distances.
- const geo = createBankGeometry(straightCurve(), 'left');
- const attr = geo.getAttribute('shoreDist') as THREE.BufferAttribute;
- for (let i = 1; i < attr.count; i += 2) {
- expect(attr.getX(i)).toBeGreaterThan(0);
- }
- });
-});
diff --git a/src/__tests__/bankTerrain.test.ts b/src/__tests__/bankTerrain.test.ts
deleted file mode 100644
index 79ef9539..00000000
--- a/src/__tests__/bankTerrain.test.ts
+++ /dev/null
@@ -1,294 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import * as THREE from 'three';
-import { BANK_WATERLINE_Y, createBankGeometry } from '../components/rower3d/bankGeometry';
-import { stripSegmentCount } from '../components/rower3d/routeStripGeometry';
-import { facingAway } from './faceNormals';
-import { createRouteCurve } from '../components/rower3d/curve';
-import type { Coordinate } from '../lib/types/index';
-import {
- MAX_TERRAIN_RELIEF_SCENE_UNITS,
- type RouteEnrichmentData,
-} from '../lib/services/routeEnrichmentService';
-
-/**
- * Proves the OpenTopoData elevations reach the geometry the rower actually
- * sees (#202). Before this, `elevations` was fetched on every cache miss and
- * read by nothing, so a flat canal and an alpine course drew identical banks.
- */
-
-const straightCurve = () =>
- new THREE.CatmullRomCurve3([
- new THREE.Vector3(0, 0, 0),
- new THREE.Vector3(0, 0, -50),
- new THREE.Vector3(0, 0, -100),
- new THREE.Vector3(0, 0, -150),
- ]);
-
-const enrichmentWith = (elevations: number[]): RouteEnrichmentData => ({
- routeId: 'terrain-fixture',
- elevations,
- segmentProfiles: [],
- waterBodyType: 'river',
- waterWidthMeters: 20,
- waterColor: '#3a6b7d',
- waveIntensity: 1,
- fetchedAt: Date.now(),
- source: 'network',
-});
-
-/** Outer-edge vertices are the odd ones; each segment pushes inner then outer. */
-const outerHeights = (geometry: THREE.BufferGeometry): number[] => {
- const position = geometry.getAttribute('position');
- const heights: number[] = [];
- for (let i = 1; i < position.count; i += 2) {
- heights.push(position.getY(i));
- }
- return heights;
-};
-
-const innerHeights = (geometry: THREE.BufferGeometry): number[] => {
- const position = geometry.getAttribute('position');
- const heights: number[] = [];
- for (let i = 0; i < position.count; i += 2) {
- heights.push(position.getY(i));
- }
- return heights;
-};
-
-describe('riverbank terrain relief (#202)', () => {
- it('draws a flat bank when there is no enrichment at all', () => {
- const geometry = createBankGeometry(straightCurve(), 'left');
-
- // Compared with a tolerance, not `===`. The heights come back out of a
- // Float32BufferAttribute, and strict equality only held while the waterline
- // happened to be -0.5 - a value float32 stores exactly (#334).
- for (const y of outerHeights(geometry)) expect(y).toBeCloseTo(BANK_WATERLINE_Y, 5);
- geometry.dispose();
- });
-
- it('draws a flat bank for the all-zero elevations the fallback produces', () => {
- // createFallbackRouteEnrichment fills elevations with zeros. A route that
- // never reached OpenTopoData must render exactly as it did before.
- const geometry = createBankGeometry(straightCurve(), 'left', { enrichment: enrichmentWith([0, 0, 0, 0]) });
-
- for (const y of outerHeights(geometry)) expect(y).toBeCloseTo(BANK_WATERLINE_Y, 5);
- geometry.dispose();
- });
-
- it('raises the bank where the real terrain climbs', () => {
- const geometry = createBankGeometry(
- straightCurve(),
- 'left',
- { enrichment: enrichmentWith([0, 50, 100, 150]) },
- );
- const heights = outerHeights(geometry);
-
- expect(heights[0]).toBeCloseTo(BANK_WATERLINE_Y, 6);
- expect(heights[heights.length - 1]).toBeGreaterThan(BANK_WATERLINE_Y);
- // A monotonic climb upstream should produce a monotonic bank.
- for (let i = 1; i < heights.length; i++) {
- expect(heights[i]).toBeGreaterThanOrEqual(heights[i - 1] - 1e-6);
- }
- geometry.dispose();
- });
-
- it('keeps the inner edge on the waterline however steep the terrain', () => {
- // Anything else opens a gap between the bank and the water plane.
- const geometry = createBankGeometry(
- straightCurve(),
- 'right',
- { enrichment: enrichmentWith([0, 400, 900, 1500]) },
- );
-
- for (const y of innerHeights(geometry)) expect(y).toBeCloseTo(BANK_WATERLINE_Y, 5);
- geometry.dispose();
- });
-
- it('clamps an extreme course so the bank cannot wall the rower in', () => {
- const geometry = createBankGeometry(
- straightCurve(),
- 'left',
- { enrichment: enrichmentWith([0, 100000, 200000, 300000]) },
- );
- const ceiling = BANK_WATERLINE_Y + MAX_TERRAIN_RELIEF_SCENE_UNITS;
-
- expect(Math.max(...outerHeights(geometry))).toBeLessThanOrEqual(ceiling + 1e-6);
- geometry.dispose();
- });
-
- it('emits finite positions and normals for every vertex', () => {
- // A NaN anywhere in the buffer silently blanks the mesh at runtime.
- const geometry = createBankGeometry(
- straightCurve(),
- 'left',
- { enrichment: enrichmentWith([12, Number.NaN, 80, 45]) },
- );
-
- const position = geometry.getAttribute('position');
- const normal = geometry.getAttribute('normal');
-
- expect(position.count).toBe((stripSegmentCount(straightCurve()) + 1) * 2);
- expect(normal).toBeDefined();
- for (let i = 0; i < position.count; i++) {
- expect(Number.isFinite(position.getX(i))).toBe(true);
- expect(Number.isFinite(position.getY(i))).toBe(true);
- expect(Number.isFinite(position.getZ(i))).toBe(true);
- expect(Number.isFinite(normal.getX(i))).toBe(true);
- expect(Number.isFinite(normal.getY(i))).toBe(true);
- expect(Number.isFinite(normal.getZ(i))).toBe(true);
- }
- geometry.dispose();
- });
-
- it('computes normals that tilt with the slope instead of pointing straight up', () => {
- const flat = createBankGeometry(straightCurve(), 'left', { enrichment: enrichmentWith([0, 0, 0, 0]) });
- const sloped = createBankGeometry(straightCurve(), 'left', { enrichment: enrichmentWith([0, 60, 120, 180]) });
-
- const flatNormal = flat.getAttribute('normal');
- const slopedNormal = sloped.getAttribute('normal');
-
- // Sample a mid-strip vertex: flat stays vertical, sloped does not.
- const sample = 100;
- expect(Math.abs(flatNormal.getY(sample))).toBeCloseTo(1, 5);
- expect(Math.abs(slopedNormal.getY(sample))).toBeLessThan(1);
-
- flat.dispose();
- sloped.dispose();
- });
-});
-
-describe('both banks face the sky (#269)', () => {
- /**
- * The right bank was invisible, and the published hero showed it: water blue,
- * the left bank green, and the whole right side of the river the same near
- * white as the sky.
- *
- * The two banks are mirror images - `outward` is -1 on the left and +1 on the
- * right - but both were wound the same way round. Mirroring a triangle
- * reverses which way it faces, so the right bank pointed away from the world
- * and MeshPhysicalMaterial, which culls back faces by default, drew nothing.
- * computeVertexNormals would have lit it from underneath in any case.
- *
- * Asserted on the winding rather than on a rendered frame: this is a property
- * of the index buffer and needs no WebGL context to check.
- */
- const faceNormals = (geometry: THREE.BufferGeometry): THREE.Vector3[] => {
- const position = geometry.getAttribute('position');
- const index = geometry.getIndex();
- if (!index) throw new Error('the bank geometry is expected to be indexed');
-
- const normals: THREE.Vector3[] = [];
- const a = new THREE.Vector3();
- const b = new THREE.Vector3();
- const c = new THREE.Vector3();
- for (let i = 0; i < index.count; i += 3) {
- a.fromBufferAttribute(position, index.getX(i));
- b.fromBufferAttribute(position, index.getX(i + 1));
- c.fromBufferAttribute(position, index.getX(i + 2));
- // Three winds counter-clockwise, so (b-a) x (c-a) points out of the face.
- normals.push(
- new THREE.Vector3()
- .subVectors(b, a)
- .cross(new THREE.Vector3().subVectors(c, a))
- .normalize(),
- );
- }
- return normals;
- };
-
- for (const side of ['left', 'right'] as const) {
- it(`points the ${side} bank upwards, so it is not culled away`, () => {
- const normals = faceNormals(createBankGeometry(straightCurve(), side));
-
- expect(normals.length, 'no triangles were built').toBeGreaterThan(0);
- const downward = normals.filter((n) => n.y <= 0).length;
- expect(
- downward,
- `${downward} of ${normals.length} ${side}-bank triangles face away from the sky`,
- ).toBe(0);
- });
- }
-
- it('winds the two banks in opposite directions, because they are mirrored', () => {
- const left = createBankGeometry(straightCurve(), 'left').getIndex();
- const right = createBankGeometry(straightCurve(), 'right').getIndex();
-
- expect(left, 'the left bank is expected to be indexed').not.toBeNull();
- expect(right, 'the right bank is expected to be indexed').not.toBeNull();
- // Same vertices in the same places, opposite order: that is what mirroring
- // a surface means, and it is the half that was missing.
- expect(
- [right!.getX(0), right!.getX(1), right!.getX(2)],
- 'the banks are wound the same way, so one of them faces backwards',
- ).not.toEqual([left!.getX(0), left!.getX(1), left!.getX(2)]);
- });
-});
-
-describe('banks survive a bend (#285)', () => {
- /**
- * The winding fix in #269 was proved on a straight, which is the one case
- * that cannot fail. An offset curve inside a turn shrinks, and at an offset
- * equal to the radius of curvature it turns inside out - so on a bend the
- * inside bank was being culled away: 32 of 500 triangles over three bends in
- * 3 km, and far more as the bends tighten.
- *
- * The builder now holds the bank short of that fold and smooths the reach
- * along the route, because clamping each sample on its own made the outer
- * edge jump and a quad with a step in it is folded whatever its winding says.
- *
- * What this does not claim is a guarantee at any curvature. A strip two
- * vertices wide cannot be offset further than the radius without folding, and
- * tightening the clamp makes it worse rather than better. Beyond a point the
- * centreline itself is the limit - at twelve bends in 3 km the water channel
- * folds too, which is a deeper question than the banks. The material is
- * double-sided so the residue costs overdraw rather than showing sky through
- * the ground; see the material in bankComponents.tsx.
- */
- const windingRiver = (meters: number, bends: number) => {
- const points: Coordinate[] = [];
- const samples = 60;
- for (let i = 0; i <= samples; i += 1) {
- const t = i / samples;
- points.push({
- lat: 51.45 + (t * meters) / 111_195,
- lng: (Math.sin(t * bends * 2 * Math.PI) * 45) / 70_000,
- });
- }
- return createRouteCurve(points, 0.1)!;
- };
-
- it('keeps every triangle facing the sky on a river-shaped bend', () => {
- // Three bends in 3 km with a 45 m wander: a meander, not a slalom.
- const curve = windingRiver(3000, 3);
-
- for (const side of ['left', 'right'] as const) {
- const away = facingAway(createBankGeometry(curve, side));
- expect(away, `${away} ${side}-bank triangles face away from the sky`).toBe(0);
- }
- });
-
- it('narrows the bank as the bend tightens, rather than reaching through it', () => {
- // What the clamp does, stated as a measurement: the same river wound
- // tighter must be given a narrower bank, because the fold is closer.
- const widthOn = (bends: number) => {
- const position = createBankGeometry(windingRiver(3000, bends), 'left').getAttribute(
- 'position',
- );
- let total = 0;
- let samples = 0;
- for (let i = 0; i < position.count; i += 2) {
- total += Math.hypot(
- position.getX(i + 1) - position.getX(i),
- position.getZ(i + 1) - position.getZ(i),
- );
- samples += 1;
- }
- return total / samples;
- };
-
- const gentle = widthOn(3);
- const tight = widthOn(12);
-
- expect(tight, 'a tighter bend was given the same bank as a gentle one').toBeLessThan(gentle);
- });
-});
diff --git a/src/__tests__/bankTexture.test.ts b/src/__tests__/bankTexture.test.ts
deleted file mode 100644
index f5ba57df..00000000
--- a/src/__tests__/bankTexture.test.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import * as THREE from 'three';
-import { BANK_TEXTURE_SIZE, createBankTexture } from '../components/rower3d/bankTexture';
-
-/**
- * Issue #269 asked for ground "visibly distinct from the water in colour and
- * texture". #280 made the banks visible and correctly coloured and left the
- * texture half untouched: a MeshPhysicalMaterial with no map of any kind, over a
- * strip two vertices wide, is a flat wash of one colour.
- *
- * It showed up in a measurement rather than an opinion. The published hero is
- * checked by heroScreenshot.test.ts for horizontal structure — the same
- * `describeImageContent` machinery the issue pointed at — and a correctly
- * rendered river scored 0.795 against a floor of 0.8, because two thirds of the
- * frame was an unbroken field of green.
- */
-
-describe('the riverbank has a surface, not just a colour (#292)', () => {
- it('varies, so it reads as ground rather than a wash', () => {
- const texture = createBankTexture();
- const data = (texture.image as { data: Uint8Array }).data;
-
- const values = Array.from(data).filter((_, i) => i % 4 === 0);
- const lowest = Math.min(...values);
- const highest = Math.max(...values);
-
- expect(highest - lowest, 'the bank texture is flat').toBeGreaterThan(20);
- });
-
- it('stays close to white, so it shades the theme colour rather than replacing it', () => {
- // `map` multiplies the material colour. A texture that wanders far from
- // white would repaint every theme's bank rather than giving it a surface.
- const texture = createBankTexture();
- const data = (texture.image as { data: Uint8Array }).data;
-
- const values = Array.from(data).filter((_, i) => i % 4 === 0);
- const mean = values.reduce((sum, v) => sum + v, 0) / values.length;
-
- expect(mean).toBeGreaterThan(200);
- expect(Math.min(...values)).toBeGreaterThan(150);
- });
-
- it('tiles, because one bank is hundreds of metres long', () => {
- const texture = createBankTexture();
-
- expect(texture.wrapS).toBe(THREE.RepeatWrapping);
- expect(texture.wrapT).toBe(THREE.RepeatWrapping);
- });
-
- it('is the same every time, so a scene does not shimmer between runs', () => {
- const first = (createBankTexture().image as { data: Uint8Array }).data;
- const second = (createBankTexture().image as { data: Uint8Array }).data;
-
- expect(Array.from(first)).toEqual(Array.from(second));
- });
-
- it('is small enough to be free', () => {
- // It is mottling, not detail. A large one would cost upload and memory for
- // something the rower sees at a glancing angle from a moving boat.
- expect(BANK_TEXTURE_SIZE).toBeLessThanOrEqual(64);
- });
-});
diff --git a/src/__tests__/bankVillage.test.tsx b/src/__tests__/bankVillage.test.tsx
deleted file mode 100644
index f599bf69..00000000
--- a/src/__tests__/bankVillage.test.tsx
+++ /dev/null
@@ -1,77 +0,0 @@
-import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
-import * as THREE from 'three';
-import ReactThreeTestRenderer from '@react-three/test-renderer';
-import { installCanvasMock } from './canvasMock';
-import { SCENE_CONFIG } from '../components/rower3d/themeConfig';
-
-/**
- * Issue #364 — the village houses are dressed from the scene config.
- *
- * The theme that used to be threaded to the bank is gone, and the art
- * direction is read directly. That must not have turned into colours inlined
- * where the house is built: the wall and the roof are still the config's.
- */
-vi.mock('@react-three/drei', async () => {
- const actual = await vi.importActual>('@react-three/drei');
- return { ...actual, Cloud: () => null };
-});
-
-const { CurvedLandscapeElements } = await import('../components/rower3d/bankComponents');
-const { AnimationProvider } = await import('../components/rower3d/AnimationContext');
-
-const straightRoute = () =>
- new THREE.CatmullRomCurve3([
- new THREE.Vector3(0, 0, 0),
- new THREE.Vector3(0, 0, -1000),
- new THREE.Vector3(0, 0, -2000),
- ]);
-
-/**
- * Duck-typed rather than `instanceof`: the renderer's objects do not come from
- * the same `three` module instance the test imports.
- */
-const isMesh = (object: THREE.Object3D): object is THREE.Mesh => (object as THREE.Mesh).isMesh === true;
-
-const hex = (mesh: THREE.Mesh) =>
- `#${(mesh.material as THREE.MeshPhysicalMaterial).color.getHexString()}`;
-
-describe('a village house on the bank', () => {
- let uninstallCanvasMock: () => void;
- beforeAll(() => {
- uninstallCanvasMock = installCanvasMock();
- });
- afterAll(() => uninstallCanvasMock());
-
- it('takes its wall and roof colours from the config', async () => {
- const renderer = await ReactThreeTestRenderer.create(
-
-
- ,
- );
-
- const { wallMaterial, roofColor } = SCENE_CONFIG.architecture;
- const houses: THREE.Object3D[] = [];
- (renderer.scene.instance as unknown as THREE.Scene).traverse((object) => {
- const meshes = object.children.filter(isMesh);
- if (meshes.some((m) => m.geometry.type === 'BoxGeometry' && hex(m) === wallMaterial.color)) {
- houses.push(object);
- }
- });
-
- expect(houses.length, 'the bank built no house with the configured wall').toBeGreaterThan(0);
- for (const house of houses) {
- const roof = house.children
- .filter(isMesh)
- .find((m) => m.geometry.type === 'ConeGeometry');
- expect(roof, 'a house has no roof').toBeDefined();
- expect(hex(roof!)).toBe(roofColor);
- }
-
- await renderer.unmount();
- });
-});
diff --git a/src/__tests__/biomeDensityTile.test.ts b/src/__tests__/biomeDensityTile.test.ts
deleted file mode 100644
index b603e414..00000000
--- a/src/__tests__/biomeDensityTile.test.ts
+++ /dev/null
@@ -1,82 +0,0 @@
-import { describe, it, expect, beforeAll, afterAll } from 'vitest';
-import { installCanvasMock } from './canvasMock';
-import {
- biomeDensityStrengthFor,
- createBiomeDensityTile,
- createBiomeDensityTexture,
-} from '../components/rower3d/foliageTexture';
-import { SCENERY_PROFILES } from '../components/rower3d/sceneryConfig';
-
-/**
- * Issue #496 — Phase 1: the per-biome grass density tile the shader will sample.
- *
- * The tile is a greyscale 2D field whose mean matches the biome's
- * `groundCover.density`, so the shader multiplies it against grass colour and
- * paints bank slopes in proportion to how planted the biome is in the OSM
- * profile. Phase 2 wires it into the bank material; this phase ships the
- * generator and the per-biome mean so a baseline fingerprint can be checked
- * without a GPU.
- */
-
-const SIZE = 64;
-
-/** Mean of the red channel over the tile, 0..1. */
-const meanDensity = (pixels: Uint8ClampedArray, size: number): number => {
- let total = 0;
- for (let i = 0; i < size * size; i += 1) total += pixels[i * 4];
- return total / (size * size * 255);
-};
-
-describe('createBiomeDensityTile (#496 Phase 1)', () => {
- it.each(Object.keys(SCENERY_PROFILES) as Array)(
- 'returns a size²·4 RGBA buffer for the %s biome',
- (profile) => {
- const pixels = createBiomeDensityTile(profile, SIZE);
- expect(pixels.length).toBe(SIZE * SIZE * 4);
- },
- );
-
- it('is deterministic — the same biome twice gives identical pixels', () => {
- const a = createBiomeDensityTile('forest', SIZE);
- const b = createBiomeDensityTile('forest', SIZE);
- expect(Array.from(a)).toEqual(Array.from(b));
- });
-
- it("tracks the biome's groundCover density on average", () => {
- // Forest at 0.85 should be clearly denser than commercial at 0.2.
- const forest = meanDensity(createBiomeDensityTile('forest', SIZE), SIZE);
- const commercial = meanDensity(createBiomeDensityTile('commercial', SIZE), SIZE);
- expect(forest).toBeGreaterThan(commercial);
- // The tile means the biome's density within ±0.15 — noise jitter is bounded.
- expect(Math.abs(forest - SCENERY_PROFILES.forest.groundCover.density)).toBeLessThan(0.15);
- expect(Math.abs(commercial - SCENERY_PROFILES.commercial.groundCover.density)).toBeLessThan(0.15);
- });
-
- it('writes alpha = 255 everywhere (density lives in R; RGBA for canvas compatibility)', () => {
- const pixels = createBiomeDensityTile('fallback', SIZE);
- for (let i = 0; i < SIZE * SIZE; i += 1) {
- expect(pixels[i * 4 + 3]).toBe(255);
- }
- });
-});
-
-describe('biomeDensityStrengthFor (#496 Phase 1)', () => {
- it('returns each biome\'s groundCover density', () => {
- for (const profile of Object.keys(SCENERY_PROFILES) as Array) {
- expect(biomeDensityStrengthFor(profile)).toBe(SCENERY_PROFILES[profile].groundCover.density);
- }
- });
-});
-
-describe('createBiomeDensityTexture (#496 Phase 1)', () => {
- let uninstall: () => void;
- beforeAll(() => { uninstall = installCanvasMock(); });
- afterAll(() => uninstall());
-
- it('wraps the tile in a CanvasTexture of the asked-for size', () => {
- const tex = createBiomeDensityTexture('forest', 32);
- expect(tex.image.width).toBe(32);
- expect(tex.image.height).toBe(32);
- tex.dispose();
- });
-});
diff --git a/src/__tests__/boatComponents.test.tsx b/src/__tests__/boatComponents.test.tsx
deleted file mode 100644
index 497ea7aa..00000000
--- a/src/__tests__/boatComponents.test.tsx
+++ /dev/null
@@ -1,103 +0,0 @@
-import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
-import * as THREE from 'three';
-import ReactThreeTestRenderer from '@react-three/test-renderer';
-import { installCanvasMock } from './canvasMock';
-
-/**
- * Issue #322 — the boat is carried by a group, not by a physics engine.
- *
- * `BoatKinematicController` used to write the boat's position and heading onto
- * a Rapier rigid body that had no colliders and that nothing ever collided
- * with. The engine read those two values back and wrote them onto the group
- * underneath — two and a quarter megabytes of WASM to copy a vector.
- *
- * This is that behaviour, tested where it now lives. It is also the test the
- * TDD guard asked for the moment `boatComponents.tsx` came off the coverage
- * exclusion list in #343: before that, this file could be changed without
- * anything noticing.
- */
-vi.hoisted(() => {
- // The procedural scull, which loads nothing. The GLB path is the same group.
- window.__PLAYWRIGHT_TESTING = true;
-});
-
-const { BoatKinematicController } = await import('../components/rower3d/boatComponents');
-const { BOAT_GROUP_NAME } = await import('../components/rower3d/constants');
-
-const mountBoat = async (position: THREE.Vector3, rotation: number) => {
- const positionRef = { current: position };
- const rotationRef = { current: rotation };
- const strokeCycleTRef = { current: 0 };
-
- const renderer = await ReactThreeTestRenderer.create(
- ,
- );
-
- const tick = async () => {
- await ReactThreeTestRenderer.act(async () => {
- await renderer.advanceFrames(1, 1 / 60);
- });
- };
-
- const boat = (): THREE.Object3D | undefined => {
- let found: THREE.Object3D | undefined;
- renderer.scene.instance.traverse((o) => {
- if (o.name === BOAT_GROUP_NAME) found = o;
- });
- return found;
- };
-
- return { renderer, tick, boat, positionRef, rotationRef };
-};
-
-describe('BoatKinematicController', () => {
- let uninstallCanvasMock: () => void;
- beforeAll(() => {
- uninstallCanvasMock = installCanvasMock();
- });
- afterAll(() => uninstallCanvasMock());
-
- it('puts the boat where the route says it is', async () => {
- const scene = await mountBoat(new THREE.Vector3(12, 0, -34), 0);
- await scene.tick();
-
- const boat = scene.boat();
- expect(boat, 'no group named for the boat').toBeDefined();
- expect(boat!.position.x).toBeCloseTo(12, 5);
- expect(boat!.position.z).toBeCloseTo(-34, 5);
-
- await scene.renderer.unmount();
- });
-
- // The heading is a rotation about Y. It used to be built into a quaternion
- // to hand to Rapier, which decomposed it again.
- it('points the boat the way the route is heading', async () => {
- const scene = await mountBoat(new THREE.Vector3(), Math.PI / 3);
- await scene.tick();
-
- expect(scene.boat()!.rotation.y).toBeCloseTo(Math.PI / 3, 5);
-
- await scene.renderer.unmount();
- });
-
- it('follows the refs as they move, frame to frame', async () => {
- const scene = await mountBoat(new THREE.Vector3(0, 0, 0), 0);
- await scene.tick();
-
- scene.positionRef.current.set(5, 0, 7);
- scene.rotationRef.current = 1.25;
- await scene.tick();
-
- const boat = scene.boat()!;
- expect(boat.position.x).toBeCloseTo(5, 5);
- expect(boat.position.z).toBeCloseTo(7, 5);
- expect(boat.rotation.y).toBeCloseTo(1.25, 5);
-
- await scene.renderer.unmount();
- });
-});
diff --git a/src/__tests__/boatMotion.test.ts b/src/__tests__/boatMotion.test.ts
deleted file mode 100644
index a4d34e85..00000000
--- a/src/__tests__/boatMotion.test.ts
+++ /dev/null
@@ -1,147 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import * as THREE from 'three';
-import {
- CATCH_PITCH_DEGREES,
- FINISH_PITCH_DEGREES,
- SURGE_AMPLITUDE,
- boatMotion,
- surgedSpeed,
-} from '../components/rower3d/boatMotion';
-import { STROKE_DRIVE_FRACTION } from '../components/rower3d/strokePose';
-
-/**
- * Issue #329 — the boat had no stroke.
- *
- * `dispatchTick` low-passes `500 / pace` into a constant speed, so the hull
- * glided down the route at a fixed velocity: no surge on the drive, no check
- * at the catch, no pitch, no heave. On water a scull does all four, and it is
- * the largest single gap in how the thing feels.
- */
-
-const SAMPLES = 2000;
-
-/** The mean of `surge` over one whole stroke, sampled evenly. */
-const meanSurge = () => {
- let total = 0;
- for (let i = 0; i < SAMPLES; i += 1) total += boatMotion(i / SAMPLES).surge;
- return total / SAMPLES;
-};
-
-describe('the surge', () => {
- it('pushes the boat through the drive', () => {
- expect(boatMotion(STROKE_DRIVE_FRACTION / 2).surge).toBeGreaterThan(0.9);
- });
-
- it('checks the boat at the catch and through the recovery', () => {
- expect(boatMotion(STROKE_DRIVE_FRACTION + (1 - STROKE_DRIVE_FRACTION) / 2).surge)
- .toBeLessThan(0);
- });
-
- /**
- * The property this module exists to protect.
- *
- * The erg's distance is the source of truth: a rower who pulls 2,000 m on
- * the handle has rowed 2,000 m whatever the picture does. The surge
- * redistributes distance within a stroke and must not add any.
- */
- it('adds no distance over a whole stroke', () => {
- expect(Math.abs(meanSurge()), 'the surge is leaking into the distance').toBeLessThan(
- 0.02,
- );
- });
-
- // The issue proposes a fixed −0.6 for the recovery. At this project's 0.4
- // drive fraction the two half-sines then have areas of 0.4 and 0.27, so the
- // boat gains about a third of a stroke's distance every stroke. Deriving the
- // scale from the drive fraction cancels at any split — which matters,
- // because #330 is about to make the split a variable.
- it('would still add no distance if the drive were a different length', () => {
- // Sampled through the module rather than recomputed, so this fails if the
- // derivation is ever replaced by a hard-coded number.
- const drive = boatMotion(STROKE_DRIVE_FRACTION / 2).surge;
- const recovery = boatMotion(STROKE_DRIVE_FRACTION + (1 - STROKE_DRIVE_FRACTION) / 2)
- .surge;
-
- expect(drive * STROKE_DRIVE_FRACTION).toBeCloseTo(
- -recovery * (1 - STROKE_DRIVE_FRACTION),
- 6,
- );
- });
-
- it('is continuous across the stroke boundary', () => {
- // A step here would be a visible jolt once a stroke.
- expect(boatMotion(0).surge).toBeCloseTo(boatMotion(0.9999).surge, 2);
- expect(Math.abs(boatMotion(STROKE_DRIVE_FRACTION - 0.0001).surge)).toBeLessThan(0.02);
- expect(Math.abs(boatMotion(STROKE_DRIVE_FRACTION + 0.0001).surge)).toBeLessThan(0.02);
- });
-
- it('reads a phase outside 0-1 as the same point in the stroke', () => {
- expect(boatMotion(1.25).surge).toBeCloseTo(boatMotion(0.25).surge, 10);
- expect(boatMotion(-0.75).surge).toBeCloseTo(boatMotion(0.25).surge, 10);
- });
-
- it('treats a phase that is not a number as the catch', () => {
- expect(boatMotion(Number.NaN)).toEqual(boatMotion(0));
- });
-});
-
-describe('the pitch and heave', () => {
- it('drops the bow at the catch and lifts it at the finish', () => {
- expect(boatMotion(0).pitchRad).toBeCloseTo(
- THREE.MathUtils.degToRad(CATCH_PITCH_DEGREES),
- 6,
- );
- expect(boatMotion(STROKE_DRIVE_FRACTION - 0.0001).pitchRad).toBeCloseTo(
- THREE.MathUtils.degToRad(FINISH_PITCH_DEGREES),
- 3,
- );
- });
-
- it('never pitches further than the bounds the issue set', () => {
- for (let i = 0; i < SAMPLES; i += 1) {
- const degrees = THREE.MathUtils.radToDeg(boatMotion(i / SAMPLES).pitchRad);
- expect(degrees).toBeGreaterThanOrEqual(CATCH_PITCH_DEGREES - 0.001);
- expect(degrees).toBeLessThanOrEqual(FINISH_PITCH_DEGREES + 0.001);
- }
- });
-
- it('heaves in phase with the pitch, and within a couple of centimetres', () => {
- for (let i = 0; i < SAMPLES; i += 1) {
- const { pitchRad, heaveM } = boatMotion(i / SAMPLES);
- // `Math.sign(-0)` is -0, which is not `+0` to `Object.is` — compare the
- // product instead, which says the same thing without the signed zero.
- expect(pitchRad * heaveM).toBeLessThanOrEqual(0);
- expect(Math.abs(heaveM)).toBeLessThanOrEqual(0.025);
- }
- });
-});
-
-describe('surgedSpeed', () => {
- it('speeds the boat up through the drive and slows it on the recovery', () => {
- const mean = 4.17;
- expect(surgedSpeed(mean, STROKE_DRIVE_FRACTION / 2)).toBeGreaterThan(mean);
- expect(
- surgedSpeed(mean, STROKE_DRIVE_FRACTION + (1 - STROKE_DRIVE_FRACTION) / 2),
- ).toBeLessThan(mean);
- });
-
- it('never sends the boat backwards, however hard the check', () => {
- for (let i = 0; i < SAMPLES; i += 1) {
- expect(surgedSpeed(4.17, i / SAMPLES)).toBeGreaterThan(0);
- }
- });
-
- it('leaves a stationary boat stationary', () => {
- expect(surgedSpeed(0, STROKE_DRIVE_FRACTION / 2)).toBe(0);
- });
-
- it('averages out to the speed it was given', () => {
- let total = 0;
- for (let i = 0; i < SAMPLES; i += 1) total += surgedSpeed(4.17, i / SAMPLES);
- expect(total / SAMPLES).toBeCloseTo(4.17, 2);
- });
-
- it('surges by the amplitude the issue asked for', () => {
- expect(SURGE_AMPLITUDE).toBeCloseTo(0.28, 5);
- });
-});
diff --git a/src/__tests__/cameraRig.test.ts b/src/__tests__/cameraRig.test.ts
deleted file mode 100644
index 2bfc5f62..00000000
--- a/src/__tests__/cameraRig.test.ts
+++ /dev/null
@@ -1,339 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import * as THREE from 'three';
-import {
- BASE_FOV,
- CAMERA_VIEWS,
- FOV_FULL_SPEED,
- FOV_SPEED_RANGE,
- LOOK_AT_TAU,
- POSITION_TAU,
- cameraTarget,
- createRigTarget,
- damp,
- dampedFollow,
- isCameraView,
- nextView,
- portraitFov,
- type CameraView,
-} from '../components/rower3d/cameraRig';
-
-/**
- * Issue #328 — the camera was a hard assignment, every frame.
- *
- * boat − tangent × 6, two and a half metres up, `lookAt(boat + 0.3)`, with no
- * lag and no damping, so every wobble in the tangent reached the camera — and
- * the tangent comes from dense Hermite samples, which wobble on bends by
- * construction (#224). A fixed 60° field of view on top, so a boat at racing
- * pace was framed exactly like a boat at rest.
- */
-
-/** A boat at the origin heading down −Z, which is the fallback route heading. */
-const straight = (view: CameraView, velocityMps = 0, bendSign = 0) =>
- cameraTarget(
- {
- boat: new THREE.Vector3(0, 0, 0),
- tangent: new THREE.Vector3(0, 0, -1),
- velocityMps,
- view,
- bendSign,
- },
- createRigTarget(),
- );
-
-describe('cameraTarget', () => {
- it('puts the chase camera seven metres behind the boat', () => {
- const { position } = straight('chase');
-
- // Behind means against the tangent: the boat is heading −Z, so behind is +Z.
- expect(position.z).toBeCloseTo(7, 5);
- expect(position.y).toBeCloseTo(2.6, 5);
- expect(position.x).toBeCloseTo(0, 5);
- });
-
- it('puts the high camera further back and higher than the chase', () => {
- expect(straight('high').position.z).toBeGreaterThan(straight('chase').position.z);
- expect(straight('high').position.y).toBeGreaterThan(straight('chase').position.y);
- });
-
- // The issue's code sample offsets the bow view 0.8 m from the boat's origin,
- // which is the middle of an eight-metre hull — the camera would be inside
- // the boat. FR1 says 0.8 m ahead of the *bow*.
- it('puts the bow camera ahead of the hull, not inside it', () => {
- const { position, lookAt } = straight('bow');
-
- expect(position.z, 'the bow camera is behind the boat').toBeLessThan(0);
- expect(Math.abs(position.z), 'the bow camera is inside the hull').toBeGreaterThan(4.4);
- expect(position.y).toBeCloseTo(1.1, 5);
- expect(lookAt.z, 'the bow camera is not looking down the route').toBeLessThan(-20);
- });
-
- it('puts the side camera out to one side, level with the boat', () => {
- const { position } = straight('side', 0, 1);
-
- expect(Math.abs(position.x), 'the side camera is not to the side').toBeCloseTo(12, 5);
- expect(position.z, 'the side camera dropped back down the route').toBeCloseTo(0, 5);
- expect(position.y).toBeCloseTo(3, 5);
- });
-
- it('puts the side camera on the outside of the bend, whichever way it turns', () => {
- const left = straight('side', 0, -1).position.x;
- const right = straight('side', 0, 1).position.x;
-
- expect(Math.sign(left)).toBe(-Math.sign(right));
- });
-
- // A straight has no outside. Taking the sign as it comes would swap banks
- // every time the curvature crossed zero, which on a nominally straight route
- // is several times a minute.
- it('keeps the side camera on one bank down a straight', () => {
- expect(straight('side', 0, 0).position.x).toBeCloseTo(
- straight('side', 0, 1).position.x,
- 5,
- );
- });
-
- it('holds the camera level rather than banking it into the bend', () => {
- const bend = cameraTarget(
- {
- boat: new THREE.Vector3(0, 0, 0),
- tangent: new THREE.Vector3(0.6, 0, -0.8),
- velocityMps: 4,
- view: 'side',
- bendSign: 1,
- },
- createRigTarget(),
- );
-
- expect(bend.position.y, 'the camera banked with the route').toBeCloseTo(3, 5);
- });
-
- it('looks at the boat rather than at the water it sits in', () => {
- expect(straight('chase').lookAt.y).toBeCloseTo(0.3, 5);
- });
-
- describe('the field of view', () => {
- it('is the resting angle at a standstill', () => {
- expect(straight('chase', 0).fov).toBeCloseTo(BASE_FOV, 5);
- });
-
- it('opens with speed', () => {
- expect(straight('chase', 2).fov).toBeGreaterThan(straight('chase', 1).fov);
- });
-
- it('stops opening at the top of its range', () => {
- const top = BASE_FOV + FOV_SPEED_RANGE;
- expect(straight('chase', FOV_FULL_SPEED).fov).toBeCloseTo(top, 5);
- expect(straight('chase', 50).fov).toBeCloseTo(top, 5);
- });
-
- it('does not narrow below the resting angle when the boat runs backwards', () => {
- expect(straight('chase', -3).fov).toBeCloseTo(BASE_FOV, 5);
- });
-
- // Motion the viewer did not ask for and cannot stop is the kind the
- // setting exists for.
- it('does not move at all under reduced motion', () => {
- const still = cameraTarget(
- {
- boat: new THREE.Vector3(),
- tangent: new THREE.Vector3(0, 0, -1),
- velocityMps: 5,
- view: 'chase',
- bendSign: 0,
- reducedMotion: true,
- },
- createRigTarget(),
- );
-
- expect(still.fov).toBeCloseTo(BASE_FOV, 5);
- });
- });
-
- it('writes into the target it was given rather than allocating one a frame', () => {
- const out = createRigTarget();
- expect(
- cameraTarget(
- {
- boat: new THREE.Vector3(),
- tangent: new THREE.Vector3(0, 0, -1),
- velocityMps: 0,
- view: 'chase',
- bendSign: 0,
- },
- out,
- ),
- ).toBe(out);
- });
-});
-
-describe('damp', () => {
- it('closes most of the gap in one time constant', () => {
- const current = new THREE.Vector3(0, 0, 0);
- damp(current, new THREE.Vector3(10, 0, 0), 0.35, 0.35);
-
- // 1 − 1/e.
- expect(current.x).toBeCloseTo(10 * 0.6321, 3);
- });
-
- /**
- * The reason this is exponential rather than a fixed lerp.
- *
- * `lerp(target, 0.1)` per frame converges twice as fast at 120 fps as at 60,
- * so the camera feels different on different machines and cannot be tuned
- * for either.
- */
- it('converges to the same place at 30 fps as at 120 fps', () => {
- const run = (fps: number) => {
- const current = new THREE.Vector3(0, 0, 0);
- const target = new THREE.Vector3(10, 0, 0);
- for (let i = 0; i < fps; i += 1) damp(current, target, 0.35, 1 / fps);
- return current.x;
- };
-
- expect(run(30)).toBeCloseTo(run(120), 3);
- });
-
- it('never overshoots, however long the frame was', () => {
- const current = new THREE.Vector3(0, 0, 0);
- damp(current, new THREE.Vector3(10, 0, 0), 0.35, 60);
-
- expect(current.x).toBeLessThanOrEqual(10);
- expect(current.x).toBeGreaterThan(9.9);
- });
-
- it('snaps when asked for no smoothing at all', () => {
- const current = new THREE.Vector3(0, 0, 0);
- damp(current, new THREE.Vector3(1, 2, 3), 0, 1 / 60);
-
- expect(current.toArray()).toEqual([1, 2, 3]);
- });
-
- // A camera that arrives before it has finished turning reads as a swing;
- // the other way round reads as a drift.
- it('turns faster than it travels', () => {
- expect(LOOK_AT_TAU).toBeLessThan(POSITION_TAU);
- });
-});
-
-describe('dampedFollow', () => {
- /**
- * The reason this exists rather than damping the world position.
- *
- * An exponential follower trails a moving target by velocity times the time
- * constant, in steady state. Measured in a running scene, the chase camera
- * sat 8.04 m behind a boat it is supposed to sit 7 m behind — and the faster
- * the rower went, the further away the boat got.
- */
- it('holds its distance from a boat that is moving', () => {
- const offset = new THREE.Vector3();
- const out = new THREE.Vector3();
- const scratch = new THREE.Vector3();
- const boat = new THREE.Vector3();
- const target = new THREE.Vector3();
- const speed = 4.17;
- const dt = 1 / 60;
-
- // Ten seconds of rowing down −Z, with the target always 7 m behind.
- for (let i = 0; i < 600; i += 1) {
- boat.z -= speed * dt;
- target.set(boat.x, boat.y + 2.6, boat.z + 7);
- dampedFollow(offset, target, boat, POSITION_TAU, dt, out, scratch);
- }
-
- expect(out.z - boat.z, 'the camera trails a moving boat').toBeCloseTo(7, 2);
- });
-
- it('still smooths a target that jumps about', () => {
- const offset = new THREE.Vector3();
- const out = new THREE.Vector3();
- const scratch = new THREE.Vector3();
- const boat = new THREE.Vector3();
-
- dampedFollow(offset, new THREE.Vector3(0, 0, 7), boat, POSITION_TAU, 1 / 60, out, scratch);
- const first = out.z;
- // The target swings 5 m sideways in one frame, as a tangent can on a bend.
- dampedFollow(offset, new THREE.Vector3(5, 0, 7), boat, POSITION_TAU, 1 / 60, out, scratch);
-
- expect(first).toBeLessThan(7);
- expect(out.x, 'the camera snapped to the new offset instead of easing').toBeLessThan(1);
- });
-
- it('writes into the vector it was given', () => {
- const out = new THREE.Vector3();
- expect(
- dampedFollow(
- new THREE.Vector3(),
- new THREE.Vector3(0, 0, 7),
- new THREE.Vector3(),
- POSITION_TAU,
- 1 / 60,
- out,
- new THREE.Vector3(),
- ),
- ).toBe(out);
- });
-});
-
-describe('portraitFov', () => {
- it('leaves a landscape canvas alone', () => {
- expect(portraitFov(58, 16 / 9)).toBe(58);
- expect(portraitFov(58, 1)).toBe(58);
- });
-
- it('widens as the canvas gets narrower', () => {
- expect(portraitFov(58, 0.75)).toBeGreaterThan(58);
- expect(portraitFov(58, 0.5)).toBeGreaterThan(portraitFov(58, 0.75));
- });
-
- it('stops widening before the view turns inside out', () => {
- expect(portraitFov(58, 0.01)).toBeLessThanOrEqual(100);
- });
-
- it('holds its nerve on a canvas with no height', () => {
- for (const aspect of [0, Number.NaN, Number.POSITIVE_INFINITY, -1]) {
- expect(portraitFov(58, aspect)).toBe(58);
- }
- });
-
- // The rig writes the field of view every frame and the widening used to be
- // written from a resize effect, so the rig would have won on the very next
- // frame. Composed, the speed coupling still reaches a portrait phone.
- it('widens whatever the speed coupling asked for', () => {
- const fast = straight('chase', 5).fov;
- expect(portraitFov(fast, 0.5)).toBeGreaterThan(fast);
- });
-});
-
-describe('nextView', () => {
- it('cycles through every view and returns to the start', () => {
- let view: CameraView = 'chase';
- const seen: CameraView[] = [view];
- for (let i = 0; i < CAMERA_VIEWS.length - 1; i += 1) {
- view = nextView(view);
- seen.push(view);
- }
-
- expect(new Set(seen).size, 'a view is missing from the cycle').toBe(
- CAMERA_VIEWS.length,
- );
- expect(nextView(view)).toBe('chase');
- });
-
- it('starts from the beginning when handed something it does not know', () => {
- expect(nextView('orbit' as CameraView)).toBe(CAMERA_VIEWS[0]);
- });
-});
-
-describe('isCameraView', () => {
- // What comes out of localStorage is whatever was last put there, by any
- // version of this app — including one that had a view this build does not.
- it('accepts the views this build has', () => {
- for (const view of CAMERA_VIEWS) expect(isCameraView(view)).toBe(true);
- });
-
- it('rejects anything else', () => {
- for (const value of ['orbit', '', null, undefined, 3, {}]) {
- expect(isCameraView(value), `${String(value)} was accepted`).toBe(false);
- }
- });
-});
diff --git a/src/__tests__/canvas3DErrorBoundary.test.tsx b/src/__tests__/canvas3DErrorBoundary.test.tsx
deleted file mode 100644
index 91557c4a..00000000
--- a/src/__tests__/canvas3DErrorBoundary.test.tsx
+++ /dev/null
@@ -1,105 +0,0 @@
-import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-import { render, screen } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { Canvas3DErrorBoundary } from '../components/Canvas3DErrorBoundary';
-import { isWebGLAvailable } from '../lib/utils/gpuUtils';
-
-// Mocked at the module, not spied on the namespace: the component binds the
-// import at load, so a namespace spy never reaches it - and the "no context"
-// case would then pass on jsdom having no WebGL rather than on the code.
-vi.mock('../lib/utils/gpuUtils', () => ({ isWebGLAvailable: vi.fn(() => true) }));
-const webglAvailable = vi.mocked(isWebGLAvailable);
-
-/**
- * Issue #345 — retrying checks the renderer the scene will actually use.
- *
- * `handleRetry` used to await a WebGPU probe and, when it resolved true, clear
- * the error and declare the GPU available *without looking for a WebGL
- * context* — on a canvas R3F was always going to build in WebGL. A rower on a
- * machine with a WebGPU adapter and no usable WebGL context could press Retry
- * and be handed the same blank box, reported as working.
- *
- * The probe is gone, and with it the asynchrony: there is one question to ask
- * and it is answered synchronously.
- *
- * The TDD guard asked for this file the moment the component was touched —
- * nothing named it before, which is how the retry path came to behave that way
- * unobserved.
- */
-
-const Boom: React.FC = () => {
- throw new Error('the canvas fell over');
-};
-
-describe('Canvas3DErrorBoundary', () => {
- let consoleError: { mockRestore: () => void };
-
- beforeEach(() => {
- // React logs the caught error; the test is about the boundary, not the noise.
- consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
- webglAvailable.mockReturnValue(true);
- });
-
- afterEach(() => {
- consoleError.mockRestore();
- });
-
- it('shows the scene while nothing has gone wrong', () => {
- render(
-
-
the river
- ,
- );
-
- expect(screen.getByText('the river')).toBeInTheDocument();
- });
-
- it('explains itself when the canvas throws', () => {
- render(
-
-
- ,
- );
-
- expect(screen.getByRole('heading', { name: '3D View Unavailable' })).toBeInTheDocument();
- expect(screen.getByRole('button', { name: 'Retry 3D View' })).toBeInTheDocument();
- });
-
- it('brings the scene back when there is a context to draw into', async () => {
- const { rerender } = render(
-
-
- ,
- );
-
- // The children are swapped before the retry, not after: retrying re-renders
- // whatever is there, and a child that still throws simply throws again.
- rerender(
-
-
the river
- ,
- );
- await userEvent.click(screen.getByRole('button', { name: 'Retry 3D View' }));
-
- expect(screen.getByText('the river')).toBeInTheDocument();
- });
-
- // The regression the WebGPU probe allowed: retry reported success on a
- // machine that had nothing to draw with, and handed back the same blank box.
- it('keeps saying so when there is still no context', async () => {
- webglAvailable.mockReturnValue(false);
- render(
-
-
- ,
- );
-
- await userEvent.click(screen.getByRole('button', { name: 'Retry 3D View' }));
-
- expect(screen.getByText(/WebGL is not available/)).toBeInTheDocument();
- expect(
- screen.queryByRole('button', { name: 'Retry 3D View' }),
- 'still offering a retry that cannot succeed',
- ).not.toBeInTheDocument();
- });
-});
diff --git a/src/__tests__/canvasSurface.test.ts b/src/__tests__/canvasSurface.test.ts
deleted file mode 100644
index d1322ff2..00000000
--- a/src/__tests__/canvasSurface.test.ts
+++ /dev/null
@@ -1,321 +0,0 @@
-import { describe, it, expect, afterEach } from 'vitest';
-import { readFileSync } from 'node:fs';
-import { fileURLToPath } from 'node:url';
-import { dirname, resolve } from 'node:path';
-import {
- QUALITY_TIERS,
- canvasSurfaceFor,
- drawsCaustics,
- drawsGroundCover,
- drawsPostStack,
- drawsScenery,
- drawsShaderGrass,
- drawsShadows,
- drawsSunMesh,
- drawsWaterReflection,
- isCsmEnabled,
- isShaderGrassEnabled,
- maxDpr,
- type CanvasSurface,
-} from '../components/rower3d/canvasSurface';
-import { budgetFor } from '../components/rower3d/sceneryPlacement';
-import type { PerformanceMode } from '../components/rower3d/constants';
-
-/**
- * The whole point of this file: every quality tier is checked against every
- * surface setting, so a combination that cannot work — multisampling on the
- * tier that exists because the GPU is weak, a discrete-adapter request from a
- * scene that has already given up shadows — fails here rather than on a
- * rower's laptop.
- */
-
-const ORDER: PerformanceMode[] = ['basic', 'low', 'medium', 'high', 'extra-high'];
-
-/** Ranks a surface setting so "never richer than the tier above" is checkable. */
-const rank = (surface: CanvasSurface) => ({
- antialias: surface.antialias ? 1 : 0,
- shadows: surface.shadows ? 1 : 0,
- shadowMapSize: surface.shadowMapSize,
- dpr: maxDpr(surface.dpr),
- adapter: { 'low-power': 0, default: 1, 'high-performance': 2 }[surface.powerPreference],
-});
-
-describe('QUALITY_TIERS', () => {
- it('names every tier the scene can run at, in order', () => {
- expect(QUALITY_TIERS).toEqual(ORDER);
- });
-
- it('has a surface for each, so no tier falls through to a default', () => {
- for (const tier of QUALITY_TIERS) {
- expect(canvasSurfaceFor(tier)).toBeDefined();
- }
- });
-});
-
-describe('the low tier asks for nothing it cannot afford', () => {
- const low = canvasSurfaceFor('basic');
-
- it.each([
- ['multisampling', () => low.antialias, false],
- ['shadow maps', () => low.shadows, false],
- ])('does not request %s', (_label, read, expected) => {
- expect(read()).toBe(expected);
- });
-
- it('does not ask for the discrete adapter', () => {
- // This is the combination that cost a rower their scene: a low-quality
- // scene on a surface built for a GPU the browser was not even using.
- expect(low.powerPreference).not.toBe('high-performance');
- });
-
- it('renders one device pixel per CSS pixel', () => {
- expect(maxDpr(low.dpr)).toBe(1);
- });
-});
-
-describe('the high tier gets what it pays for', () => {
- const high = canvasSurfaceFor('extra-high');
-
- it('takes multisampling, shadows and the discrete adapter', () => {
- expect(high.antialias).toBe(true);
- expect(high.shadows).toBe(true);
- expect(high.powerPreference).toBe('high-performance');
- });
-
- it('allows a retina drawing buffer', () => {
- expect(maxDpr(high.dpr)).toBeGreaterThan(1);
- });
-});
-
-describe('the middle tier sits between the two, not alongside high', () => {
- const low = canvasSurfaceFor('basic');
- const medium = canvasSurfaceFor('medium');
- const high = canvasSurfaceFor('extra-high');
-
- it('keeps shadows but not the largest shadow map', () => {
- expect(medium.shadows).toBe(true);
- expect(medium.shadowMapSize).toBeLessThan(high.shadowMapSize);
- expect(medium.shadowMapSize).toBeGreaterThan(low.shadowMapSize);
- });
-
- it('does not claim the discrete adapter', () => {
- // `auto` means "decide for me". Demanding the discrete GPU is a decision,
- // and on a hybrid laptop it is the one that refuses to open a context.
- expect(medium.powerPreference).toBe('default');
- });
-
- it('caps the drawing buffer below the high tier', () => {
- expect(maxDpr(medium.dpr)).toBeGreaterThanOrEqual(maxDpr(low.dpr));
- expect(maxDpr(medium.dpr)).toBeLessThan(maxDpr(high.dpr));
- });
-});
-
-describe('no setting ever goes backwards as quality rises', () => {
- const ranked = ORDER.map((tier) => ({ tier, ...rank(canvasSurfaceFor(tier)) }));
-
- it.each(['antialias', 'shadows', 'shadowMapSize', 'dpr', 'adapter'] as const)(
- '%s is monotonic across low → auto → high',
- (setting) => {
- const values = ranked.map((r) => r[setting]);
-
- expect(values).toEqual([...values].sort((a, b) => a - b));
- },
- );
-
- it('scenery density rises with the tier too', () => {
- const budgets = ORDER.map(budgetFor);
-
- expect(budgets).toEqual([...budgets].sort((a, b) => a - b));
- expect(new Set(budgets).size).toBe(ORDER.length);
- });
-});
-
-
-/**
- * Issue #352 — shadows with a soft edge.
- *
- * three's default shadow map is `PCFShadowMap`, which is a hard edge at the
- * resolutions this scene can afford: a scull's shadow on open water reads as a
- * cut-out. `PCFSoftShadowMap` costs a wider tap pattern and nothing else, so
- * every tier that draws shadows at all draws them soft.
- */
-describe('how hard the shadow edges are', () => {
- it('asks for soft shadows wherever shadows are drawn', () => {
- for (const tier of ['medium', 'extra-high'] as const) {
- const surface = canvasSurfaceFor(tier);
-
- expect(surface.shadows, tier).toBe(true);
- expect(surface.softShadows, tier).toBe(true);
- }
- });
-
- // Nothing to soften: the low tier draws no shadow map at all.
- it('asks for nothing on a tier that draws no shadows', () => {
- const surface = canvasSurfaceFor('basic');
-
- expect(surface.shadows).toBe(false);
- expect(surface.softShadows).toBe(false);
- });
-});
-
-/**
- * #455 FR4 / AC3 — Rower3D.tsx's tier gates read named predicates.
- *
- * Inline string equalities against tier names leave each gate independently
- * authored, so a new tier (or a renamed one) has to be chased through every
- * site. The predicates in canvasSurface.ts carry the boundary; a change there
- * changes every gate it names. The grep below is the contract: no literal
- * tier comparison SHALL survive in Rower3D.tsx.
- */
-describe('tier gates in Rower3D.tsx', () => {
- const here = dirname(fileURLToPath(import.meta.url));
- const rowerSrc = readFileSync(
- resolve(here, '../components/Rower3D.tsx'),
- 'utf8',
- );
-
- it.each([
- "performanceMode === 'basic'",
- "performanceMode !== 'basic'",
- "performanceMode === 'low'",
- "performanceMode !== 'low'",
- "performanceMode === 'medium'",
- "performanceMode !== 'medium'",
- "performanceMode === 'high'",
- "performanceMode !== 'high'",
- "performanceMode === 'extra-high'",
- "performanceMode !== 'extra-high'",
- ])('does not spell "%s" inline', (fragment) => {
- expect(rowerSrc).not.toContain(fragment);
- });
-});
-
-describe('named predicates for the Rower3D gates', () => {
- it('turns scenery/post/water/caustics/ground-cover on from `low` upwards', () => {
- for (const predicate of [
- drawsScenery,
- drawsPostStack,
- drawsWaterReflection,
- drawsCaustics,
- drawsGroundCover,
- ]) {
- expect(predicate('basic'), predicate.name).toBe(false);
- for (const tier of ['low', 'medium', 'high', 'extra-high'] as const) {
- expect(predicate(tier), `${predicate.name}(${tier})`).toBe(true);
- }
- }
- });
-
- it('turns shadows on wherever the surface asks for them', () => {
- for (const tier of QUALITY_TIERS) {
- expect(drawsShadows(tier), tier).toBe(canvasSurfaceFor(tier).shadows);
- }
- });
-
- it('turns the godRays sun sphere on only at extra-high', () => {
- for (const tier of ['basic', 'low', 'medium', 'high'] as const) {
- expect(drawsSunMesh(tier), tier).toBe(false);
- }
- expect(drawsSunMesh('extra-high')).toBe(true);
- });
-});
-
-/**
- * #496 — the bank's grass tufts become a density field the fragment shader
- * samples (D3-b: off at basic + low, on at medium+). Phase 2 flips the
- * `__VIRTUALROW_SHADER_GRASS` default on; only an explicit `false` reverts
- * to the retired single-tuft `InstancedMesh` branch.
- */
-describe('density-field grass tier gate', () => {
- afterEach(() => {
- delete window.__VIRTUALROW_SHADER_GRASS;
- });
-
- it('is off at basic and low (today\'s instanced-tuft behaviour)', () => {
- expect(drawsShaderGrass('basic')).toBe(false);
- expect(drawsShaderGrass('low')).toBe(false);
- });
-
- it('is on at medium / high / extra-high (D3-b)', () => {
- for (const tier of ['medium', 'high', 'extra-high'] as const) {
- expect(drawsShaderGrass(tier), tier).toBe(true);
- }
- });
-
- it('Phase 2 defaults the flag on: medium+ runs the shader without opt-in', () => {
- for (const tier of ['medium', 'high', 'extra-high'] as const) {
- expect(isShaderGrassEnabled(tier), `undefined flag on ${tier}`).toBe(true);
- }
- window.__VIRTUALROW_SHADER_GRASS = true;
- for (const tier of ['medium', 'high', 'extra-high'] as const) {
- expect(isShaderGrassEnabled(tier), `true flag on ${tier}`).toBe(true);
- }
- });
-
- it('the flag never turns shader grass on at basic or low', () => {
- window.__VIRTUALROW_SHADER_GRASS = true;
- expect(isShaderGrassEnabled('basic')).toBe(false);
- expect(isShaderGrassEnabled('low')).toBe(false);
- });
-
- it('setting the flag to `false` explicitly disables shader grass (kill switch)', () => {
- window.__VIRTUALROW_SHADER_GRASS = false;
- for (const tier of QUALITY_TIERS) {
- expect(isShaderGrassEnabled(tier), tier).toBe(false);
- }
- });
-});
-
-/**
- * #495 — the sun's shadow runs through Cascaded Shadow Maps on tiers that
- * ask for ≥ 2 cascades. Phase 2 flips `__VIRTUALROW_CSM` default-on: an
- * undefined flag reads as on, and only an explicit `false` keeps the
- * single-shadow-map path (the D2-a kill switch).
- */
-describe('CSM tier config', () => {
- afterEach(() => {
- delete window.__VIRTUALROW_CSM;
- });
-
- it('zero cascades on `basic` (no shadows at all)', () => {
- expect(canvasSurfaceFor('basic').cascadeCount).toBe(0);
- });
-
- it('one cascade on `low` — today\'s single shadow-map behaviour', () => {
- expect(canvasSurfaceFor('low').cascadeCount).toBe(1);
- });
-
- it('2 / 3 / 4 cascades on medium / high / extra-high (D1-a)', () => {
- expect(canvasSurfaceFor('medium').cascadeCount).toBe(2);
- expect(canvasSurfaceFor('high').cascadeCount).toBe(3);
- expect(canvasSurfaceFor('extra-high').cascadeCount).toBe(4);
- });
-
- it('CSM is only enabled on cascadeCount ≥ 2 — basic and low never run it', () => {
- window.__VIRTUALROW_CSM = true;
- expect(isCsmEnabled('basic')).toBe(false);
- expect(isCsmEnabled('low')).toBe(false);
- });
-
- it('Phase 2 defaults the flag on: medium+ runs CSM without an explicit opt-in', () => {
- for (const tier of ['medium', 'high', 'extra-high'] as const) {
- expect(isCsmEnabled(tier), `undefined flag on ${tier}`).toBe(true);
- }
- });
-
- it('setting the flag to `false` explicitly disables CSM (D2-a kill switch)', () => {
- window.__VIRTUALROW_CSM = false;
- for (const tier of QUALITY_TIERS) {
- expect(isCsmEnabled(tier), tier).toBe(false);
- }
- });
-
- it('explicit `true` still turns CSM on where cascadeCount ≥ 2', () => {
- window.__VIRTUALROW_CSM = true;
- expect(isCsmEnabled('basic')).toBe(false);
- expect(isCsmEnabled('low')).toBe(false);
- for (const tier of ['medium', 'high', 'extra-high'] as const) {
- expect(isCsmEnabled(tier), tier).toBe(true);
- }
- });
-});
diff --git a/src/__tests__/cloudPlan.test.ts b/src/__tests__/cloudPlan.test.ts
deleted file mode 100644
index 1e307dce..00000000
--- a/src/__tests__/cloudPlan.test.ts
+++ /dev/null
@@ -1,74 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import { cloudsFor } from '../components/rower3d/cloudPlan';
-import { SCENE_CONFIG } from '../components/rower3d/themeConfig';
-
-/**
- * Issue #326 — what the sky costs, per tier.
- *
- * Every cloud is a drei `Cloud`, and each is rebuilt per instance from the
- * config's `segments` — up to forty sprites apiece. Eight of those is three
- * hundred and twenty transparent quads over the whole sky, sorted and blended
- * every frame, on hardware the low tier exists because it could not manage
- * shadows.
- *
- * The tier decides, not the authored. A theme says what the sky looks like where
- * there is room for it.
- */
-const authored = SCENE_CONFIG.clouds;
-
-describe('cloudsFor', () => {
- it('draws no clouds at all on the low tier', () => {
- expect(cloudsFor('basic').count).toBe(0);
- });
-
- it('keeps auto to a handful', () => {
- expect(cloudsFor('medium').count).toBeLessThanOrEqual(6);
- });
-
- it('never asks for more than the config authored', () => {
- for (const mode of ['basic', 'medium', 'extra-high'] as const) {
- expect(cloudsFor(mode).count).toBeLessThanOrEqual(authored.count);
- }
- });
-
- // The segment count is the per-cloud cost, and it is the one that multiplies.
- it('spends the authored detail only where there is room for it', () => {
- expect(cloudsFor('extra-high').segments).toBe(authored.segments);
- expect(cloudsFor('medium').segments).toBeLessThan(authored.segments);
- });
-
- it('gives back the rest of the config untouched', () => {
- const plan = cloudsFor('medium');
-
- expect(plan.color).toBe(authored.color);
- expect(plan.opacity).toBe(authored.opacity);
- expect(plan.speed).toBe(authored.speed);
- });
-});
-
-/**
- * Issue #346 — the tier caps whatever the conditions asked for.
- *
- * `cloudsFor` takes the config now, because an overcast sky is sixteen clouds
- * rather than eight. The tier ceiling still has the last word: overcast on a
- * machine that cannot draw eight clouds is not a reason to draw sixteen.
- */
-describe('clouds under the conditions presets', () => {
- const overcast = {
- ...SCENE_CONFIG,
- clouds: { ...SCENE_CONFIG.clouds, count: 16, opacity: 0.9 },
- };
-
- it('reads the config it is handed, not the authored one', () => {
- expect(cloudsFor('extra-high', overcast).opacity).toBe(0.9);
- });
-
- it('still caps the count at what the tier can draw', () => {
- expect(cloudsFor('extra-high', overcast).count).toBeLessThanOrEqual(12);
- expect(cloudsFor('basic', overcast).count).toBe(0);
- });
-
- it('falls back to the authored config when handed none', () => {
- expect(cloudsFor('extra-high').opacity).toBe(SCENE_CONFIG.clouds.opacity);
- });
-});
diff --git a/src/__tests__/composerState.test.ts b/src/__tests__/composerState.test.ts
deleted file mode 100644
index 319507dd..00000000
--- a/src/__tests__/composerState.test.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import { describe, it, expect, afterEach } from 'vitest';
-import { readComposerMounted, recordComposerMounted } from '../components/rower3d/composerState';
-
-/**
- * Issue #327 — which of the two stages actually graded the frame.
- *
- * The tier decides whether to try mounting a composer, but `postprocessing` can
- * refuse: where the context reports no attributes the stack is dropped on
- * purpose (#257), and ACES on the renderer is then the right answer rather than
- * a bug. A spec asserting "exactly one stage grades the frame" has to be able
- * to tell those apart, and the tier alone cannot — which is the mistake this
- * module exists to stop being made again.
- */
-describe('composerState', () => {
- afterEach(() => recordComposerMounted(false));
-
- it('reports nothing mounted before anything mounts', () => {
- expect(readComposerMounted()).toBe(false);
- });
-
- it('remembers that a composer went up', () => {
- recordComposerMounted(true);
- expect(readComposerMounted()).toBe(true);
- });
-
- it('forgets it again when the composer comes down', () => {
- recordComposerMounted(true);
- recordComposerMounted(false);
-
- expect(
- readComposerMounted(),
- 'a torn-down composer still counted, so the renderer would look wrong',
- ).toBe(false);
- });
-});
diff --git a/src/__tests__/conditions.test.ts b/src/__tests__/conditions.test.ts
deleted file mode 100644
index a9938d84..00000000
--- a/src/__tests__/conditions.test.ts
+++ /dev/null
@@ -1,186 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import {
- CONDITIONS,
- conditionsForHour,
- applyConditions,
- sunPositionFrom,
- conditionsLabel,
- type Conditions,
-} from '../components/rower3d/conditions';
-import { SCENE_CONFIG } from '../components/rower3d/themeConfig';
-import { skySunPosition } from '../components/rower3d/sunDirection';
-
-/**
- * Issue #346 — the same route, at a different time of day.
- *
- * Sun position, turbidity, cloud cover and fog were fixed, so every row on a
- * route looked identical to the last one. A preset is a set of overrides on
- * `SCENE_CONFIG`, applied per row rather than per route: pure, so the scene
- * only has to read the result.
- */
-
-describe('the hour of the day', () => {
- it('is dawn early on', () => {
- expect(conditionsForHour(5)).toBe('dawn');
- expect(conditionsForHour(6)).toBe('dawn');
- });
-
- it('is the middle of the day through the middle of the day', () => {
- expect(conditionsForHour(7)).toBe('midday');
- expect(conditionsForHour(12)).toBe('midday');
- expect(conditionsForHour(15)).toBe('midday');
- });
-
- it('is golden hour as the afternoon goes', () => {
- expect(conditionsForHour(16)).toBe('golden');
- expect(conditionsForHour(18)).toBe('golden');
- });
-
- it('is dusk in the evening', () => {
- expect(conditionsForHour(19)).toBe('dusk');
- expect(conditionsForHour(23)).toBe('dusk');
- });
-
- // Rowers row at 05:30, and an hour of 0 is midnight rather than a missing
- // value: it has to land somewhere sensible rather than on the default.
- it('puts the small hours with the dawn rather than nowhere', () => {
- expect(conditionsForHour(0)).toBe('dawn');
- expect(conditionsForHour(3)).toBe('dawn');
- });
-});
-
-describe('the sun’s place in the sky', () => {
- /**
- * One sun, not two.
- *
- * `sky.sunPosition` and `lighting.sunElevation/sunAzimuth` were separate
- * fields, and they disagreed: the sky drew its sun at [90, 55, 35] while the
- * light shone from 45° elevation at 135°. Shadows fell one way and the glare
- * came from another. Both are derived from the same pair now.
- */
- it('is derived from one elevation and one azimuth', () => {
- const [x, y, z] = sunPositionFrom(45, 135, 200);
-
- expect(y).toBeCloseTo(Math.sin(Math.PI / 4) * 200, 5);
- expect(Math.hypot(x, y, z)).toBeCloseTo(200, 5);
- });
-
- it('puts a high sun overhead and a low one near the horizon', () => {
- const high = sunPositionFrom(80, 135, 200);
- const low = sunPositionFrom(5, 135, 200);
-
- expect(high[1]).toBeGreaterThan(low[1]);
- });
-
- /**
- * #352 removed `sky.sunPosition` outright, so there is no longer a second
- * value that *could* disagree — `skySunPosition` is the only way to get one.
- * What is left to hold is that it comes from the preset's own angles.
- */
- it('agrees with the scene’s own lighting, whatever the preset', () => {
- for (const condition of CONDITIONS) {
- const scene = applyConditions(SCENE_CONFIG, condition);
- const sky = skySunPosition(scene.lighting);
- const derived = sunPositionFrom(
- scene.lighting.sunElevation,
- scene.lighting.sunAzimuth,
- Math.hypot(...sky),
- );
-
- sky.forEach((value: number, axis: number) => {
- expect(value, `${condition} axis ${axis}`).toBeCloseTo(derived[axis], 4);
- });
- }
- });
-});
-
-describe('applying a preset', () => {
- it('leaves the config it was given untouched', () => {
- const before = JSON.stringify(SCENE_CONFIG);
-
- applyConditions(SCENE_CONFIG, 'dusk');
-
- expect(JSON.stringify(SCENE_CONFIG)).toBe(before);
- });
-
- it('puts the sun low at dawn and high at midday', () => {
- const dawn = applyConditions(SCENE_CONFIG, 'dawn');
- const midday = applyConditions(SCENE_CONFIG, 'midday');
-
- expect(dawn.lighting.sunElevation).toBeLessThan(midday.lighting.sunElevation);
- });
-
- // Overcast is the one that changes the weather rather than the hour: a
- // flatter, dimmer, closer world.
- it('dims the sun and closes the fog in when it is overcast', () => {
- const overcast = applyConditions(SCENE_CONFIG, 'overcast');
-
- expect(overcast.lighting.sunIntensity).toBeLessThan(SCENE_CONFIG.lighting.sunIntensity);
- expect(overcast.atmosphere.fogFar).toBeLessThan(SCENE_CONFIG.atmosphere.fogFar);
- expect(overcast.clouds.count).toBeGreaterThan(SCENE_CONFIG.clouds.count);
- expect(overcast.clouds.opacity).toBeGreaterThan(SCENE_CONFIG.clouds.opacity);
- });
-
- it('warms the light at dawn, golden hour and dusk', () => {
- for (const condition of ['dawn', 'golden', 'dusk'] as Conditions[]) {
- const scene = applyConditions(SCENE_CONFIG, condition);
- const [r, , b] = [1, 3, 5].map((i) => parseInt(scene.lighting.sunColor.slice(i, i + 2), 16));
-
- expect(r, `${condition} is not a warm light`).toBeGreaterThan(b);
- }
- });
-
- it('keeps the fog near the camera nearer than the fog far from it', () => {
- for (const condition of CONDITIONS) {
- const scene = applyConditions(SCENE_CONFIG, condition);
-
- expect(scene.atmosphere.fogNear, condition).toBeLessThan(scene.atmosphere.fogFar);
- }
- });
-
- /**
- * Every preset has to hand back a config the scene can read, which is what
- * `themeConfig.test.ts` asserts about `SCENE_CONFIG` itself. A preset that
- * put the sun at 95° or gave a negative intensity would render, and look
- * wrong, and say nothing.
- */
- it('hands back a config that still satisfies the scene’s invariants', () => {
- for (const condition of CONDITIONS) {
- const scene = applyConditions(SCENE_CONFIG, condition);
- const { lighting, clouds, sky, atmosphere } = scene;
-
- expect(lighting.sunElevation, condition).toBeGreaterThanOrEqual(0);
- expect(lighting.sunElevation, condition).toBeLessThanOrEqual(90);
- expect(lighting.sunAzimuth, condition).toBeGreaterThanOrEqual(0);
- expect(lighting.sunAzimuth, condition).toBeLessThan(360);
- expect(lighting.sunIntensity, condition).toBeGreaterThanOrEqual(0);
- expect(lighting.ambientIntensity, condition).toBeGreaterThanOrEqual(0);
- expect(clouds.opacity, condition).toBeGreaterThanOrEqual(0);
- expect(clouds.opacity, condition).toBeLessThanOrEqual(1);
- expect(clouds.count, condition).toBeGreaterThanOrEqual(0);
- expect(Number.isInteger(clouds.count), `${condition} has a fraction of a cloud`).toBe(true);
- expect(sky.turbidity, condition).toBeGreaterThan(0);
- expect(atmosphere.fogNear, condition).toBeGreaterThanOrEqual(0);
- }
- });
-
- it('changes something for every preset it offers', () => {
- const rendered = CONDITIONS.map((condition) =>
- JSON.stringify(applyConditions(SCENE_CONFIG, condition)),
- );
-
- expect(new Set(rendered).size, 'two presets render the same scene').toBe(CONDITIONS.length);
- });
-});
-
-describe('naming the presets', () => {
- it('names every one of them in words a rower would use', () => {
- for (const condition of CONDITIONS) {
- expect(conditionsLabel(condition), condition).toMatch(/[a-z]/i);
- }
- });
-
- it('calls golden hour what everybody calls it', () => {
- expect(conditionsLabel('golden')).toMatch(/golden/i);
- });
-});
diff --git a/src/__tests__/conditionsPicker.test.tsx b/src/__tests__/conditionsPicker.test.tsx
deleted file mode 100644
index 289c8b0f..00000000
--- a/src/__tests__/conditionsPicker.test.tsx
+++ /dev/null
@@ -1,76 +0,0 @@
-import { describe, it, expect, vi } from 'vitest';
-import { render, screen } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { ConditionsPicker } from '../components/ConditionsPicker';
-
-/**
- * Issue #346 — picking the light a row is rowed in.
- *
- * On the route panel beside the graphics tier, because it is the same kind of
- * decision: set once, changing how every row looks, and nothing a rower wants
- * to reach for with a handle in each hand.
- */
-
-describe('the conditions picker', () => {
- it('offers the clock and every preset', () => {
- render();
-
- expect(screen.getByRole('radio', { name: /my clock/i })).toBeInTheDocument();
- // Exact names: the clock button carries what it resolved to, so
- // "My clock (Midday)" matches a loose /midday/ as well as "Midday" does.
- for (const name of ['Dawn', 'Midday', 'Golden hour', 'Overcast', 'Dusk']) {
- expect(screen.getByRole('radio', { name })).toBeInTheDocument();
- }
- });
-
- it('shows which one is in force', () => {
- render();
-
- expect(screen.getByRole('radio', { name: 'Dusk' })).toHaveAttribute('aria-checked', 'true');
- expect(screen.getByRole('radio', { name: /my clock/i })).toHaveAttribute(
- 'aria-checked',
- 'false',
- );
- });
-
- it('hands back the preset the rower picked', async () => {
- const onChange = vi.fn();
- render();
-
- await userEvent.click(screen.getByRole('radio', { name: 'Golden hour' }));
-
- expect(onChange).toHaveBeenCalledWith('golden');
- });
-
- it('goes back to the clock', async () => {
- const onChange = vi.fn();
- render();
-
- await userEvent.click(screen.getByRole('radio', { name: /my clock/i }));
-
- expect(onChange).toHaveBeenCalledWith('auto');
- });
-
- /**
- * "Match my clock" is the default, so most rowers never touch this — which
- * makes *what it resolved to* the thing worth saying. Without it the panel
- * says the setting is on automatic and never says what automatic decided.
- */
- it('says what the clock resolved to', () => {
- render();
-
- expect(screen.getByRole('radio', { name: /my clock/i })).toHaveAccessibleName(/golden hour/i);
- });
-
- it('says nothing about the clock once a preset overrules it', () => {
- render();
-
- expect(screen.getByRole('radio', { name: /my clock/i })).not.toHaveAccessibleName(/dusk.*dusk/i);
- });
-
- it('is a radio group a screen reader can read as one', () => {
- render();
-
- expect(screen.getByRole('radiogroup', { name: /conditions/i })).toBeInTheDocument();
- });
-});
diff --git a/src/__tests__/contactShadowTexture.test.ts b/src/__tests__/contactShadowTexture.test.ts
deleted file mode 100644
index 3714eb47..00000000
--- a/src/__tests__/contactShadowTexture.test.ts
+++ /dev/null
@@ -1,79 +0,0 @@
-import { describe, it, expect, beforeAll, afterAll } from 'vitest';
-import { installCanvasMock } from './canvasMock';
-import {
- createContactShadowTexture,
- CONTACT_SHADOW_RADIUS_M,
- CONTACT_SHADOW_OPACITY,
-} from '../components/rower3d/contactShadowTexture';
-
-/**
- * Issue #352 — the shadow where the hull meets the water.
- *
- * Without one the boat sits *on* the river rather than *in* it, however good
- * the directional shadow is — and on the `low` tier, which casts none at all,
- * it is the only thing joining the two.
- *
- * Drawn as one textured plane rather than with drei's `ContactShadows`. That
- * component renders the whole scene into a depth target to capture the blob,
- * and its "render once" guard is a plain `let count` in the component body, so
- * it resets on every React render and the capture happens again and again. It
- * measured as a full extra scene pass at every tier: draw calls went 171 → 298
- * at `low`, which is the tier a contact shadow exists to help. A radial
- * gradient on a quad costs one draw call and no pass at all.
- */
-
-let uninstall: () => void;
-
-beforeAll(() => {
- uninstall = installCanvasMock();
-});
-
-afterAll(() => {
- uninstall();
-});
-
-describe('the contact shadow texture', () => {
- it('is a texture the scene can use', () => {
- const texture = createContactShadowTexture();
-
- expect(texture.image).toBeTruthy();
- // `needsUpdate` is a set-only accessor on a three texture; `version` is
- // what it increments, and what the renderer actually reads.
- expect(texture.version).toBeGreaterThan(0);
- texture.dispose();
- });
-
- // A power-of-two square: it is sampled with a mipmap chain like any other.
- it('is square, and a size the GPU likes', () => {
- const texture = createContactShadowTexture();
- const { width, height } = texture.image as { width: number; height: number };
-
- expect(width).toBe(height);
- expect(Math.log2(width) % 1).toBe(0);
- texture.dispose();
- });
-
- /**
- * Not clamped, and that matters: the default wrap is repeat, so the edge of
- * a fading gradient meets its own opposite edge and draws a seam across the
- * water where the quad ends.
- */
- it('stops at its edges rather than tiling', () => {
- const texture = createContactShadowTexture();
-
- expect(texture.wrapS).toBe(1001); // THREE.ClampToEdgeWrapping
- expect(texture.wrapT).toBe(1001);
- texture.dispose();
- });
-
- it('is a disc a boat’s length across, not a postage stamp', () => {
- // The hull is about 8 m; the blob has to reach under all of it.
- expect(CONTACT_SHADOW_RADIUS_M * 2).toBeGreaterThanOrEqual(8);
- });
-
- // A contact shadow is a suggestion of darkness, not a hole in the river.
- it('is faint enough to read as a shadow rather than as paint', () => {
- expect(CONTACT_SHADOW_OPACITY).toBeGreaterThan(0);
- expect(CONTACT_SHADOW_OPACITY).toBeLessThanOrEqual(0.5);
- });
-});
diff --git a/src/__tests__/courseFurniture.test.ts b/src/__tests__/courseFurniture.test.ts
deleted file mode 100644
index 0bfa70ae..00000000
--- a/src/__tests__/courseFurniture.test.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import { courseFurniture, MAX_DISTANCE_POSTS } from '../components/rower3d/courseFurniture';
-
-/**
- * Issue #336 — a course you can see: buoys across the start, a finish tower
- * and a buoy line at the finish, and a post every 500 m.
- */
-
-const posts = (routeMeters: number) =>
- courseFurniture(routeMeters).filter((f) => f.id === 'a07-distance-marker-post');
-
-describe('courseFurniture', () => {
- it('gives a 2 000 m course four posts, at every 500 m', () => {
- expect(posts(2000).map((p) => p.progress)).toEqual([0.25, 0.5, 0.75, 1]);
- });
-
- it('puts the finish tower on the line', () => {
- const towers = courseFurniture(2000).filter((f) => f.id === 'a06-finish-tower');
- expect(towers).toEqual([{ id: 'a06-finish-tower', progress: 1, side: 'right', kind: 'furniture' }]);
- });
-
- it('lays a buoy either side of the lane at the start and at the finish', () => {
- const buoys = courseFurniture(2000).filter((f) => f.id === 'a01-buoy-lane-sphere');
- expect(buoys.map((b) => [b.progress, b.side])).toEqual([
- [0, 'left'],
- [0, 'right'],
- [1, 'left'],
- [1, 'right'],
- ]);
- // In the water, not on the bank the posts stand on.
- expect(buoys.every((b) => b.kind === 'buoy')).toBe(true);
- });
-
- it('stands the posts on the bank', () => {
- expect(posts(2000).every((p) => p.kind === 'furniture' && p.side === 'left')).toBe(true);
- });
-
- it('gives a course shorter than 500 m no posts', () => {
- expect(posts(400)).toEqual([]);
- });
-
- it('spaces the posts out on a long course rather than planting hundreds', () => {
- // A marathon is 84 posts at 500 m, every one a GLB clone on the GPU.
- const marathon = posts(42_195);
- expect(marathon.length).toBeLessThanOrEqual(MAX_DISTANCE_POSTS);
- // Still at whole multiples of 500 m, so a post means what it says.
- const metres = marathon.map((p) => Math.round(p.progress * 42_195));
- expect(metres.every((m) => m % 500 === 0)).toBe(true);
- expect(metres[0]).toBe(1500);
- });
-
- it('places nothing on a course with no length', () => {
- expect(courseFurniture(0)).toEqual([]);
- expect(courseFurniture(Number.NaN)).toEqual([]);
- });
-});
diff --git a/src/__tests__/crewPicker.test.tsx b/src/__tests__/crewPicker.test.tsx
deleted file mode 100644
index c0ed5b3c..00000000
--- a/src/__tests__/crewPicker.test.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import { describe, it, expect, vi } from 'vitest';
-import { render, screen } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { CrewPicker } from '../components/CrewPicker';
-
-describe('CrewPicker', () => {
- it('shows every choice and marks the current one', () => {
- render( {}} />);
-
- expect(screen.getByRole('radio', { name: 'Auto' })).toHaveAttribute('aria-checked', 'false');
- expect(screen.getByRole('radio', { name: 'Female' })).toHaveAttribute('aria-checked', 'true');
- expect(screen.getByRole('radio', { name: 'Male' })).toHaveAttribute('aria-checked', 'false');
- });
-
- it('reports the rower’s choice', async () => {
- const onChange = vi.fn();
- render();
-
- await userEvent.click(screen.getByRole('radio', { name: 'Female' }));
-
- expect(onChange).toHaveBeenCalledWith('female');
- });
-
- it('is reachable as a labelled group', () => {
- render( {}} />);
-
- expect(screen.getByRole('radiogroup', { name: 'Rower appearance' })).toBeInTheDocument();
- });
-});
diff --git a/src/__tests__/crewPreference.test.ts b/src/__tests__/crewPreference.test.ts
deleted file mode 100644
index a83a6e37..00000000
--- a/src/__tests__/crewPreference.test.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-import { describe, it, expect, beforeEach } from 'vitest';
-import { renderHook, act } from '@testing-library/react';
-import {
- CREW_PREFERENCE_STORAGE_KEY,
- CREW_PREFERENCE_OPTIONS,
- useCrewPreference,
-} from '../hooks/useCrewPreference';
-import { resolveCrew } from '../components/rower3d/crewModel';
-
-describe('resolveCrew with a stated preference', () => {
- it('shows what the rower asked for, whatever the profile says', () => {
- expect(resolveCrew('male', 'female')).toBe('female');
- expect(resolveCrew('female', 'male')).toBe('male');
- });
-
- it('follows the profile when the preference is auto', () => {
- expect(resolveCrew('female', 'auto')).toBe('female');
- expect(resolveCrew('male', 'auto')).toBe('male');
- });
-
- it('still has to choose something for a guest with no profile and no preference', () => {
- expect(resolveCrew(undefined, 'auto')).toBe('male');
- });
-
- it('lets that guest choose, which is the point', () => {
- expect(resolveCrew(undefined, 'female')).toBe('female');
- });
-
- it('keeps the old single-argument behaviour', () => {
- expect(resolveCrew('female')).toBe('female');
- expect(resolveCrew(undefined)).toBe('male');
- });
-});
-
-describe('useCrewPreference', () => {
- beforeEach(() => {
- localStorage.clear();
- });
-
- it('offers auto plus a choice of rower', () => {
- expect(CREW_PREFERENCE_OPTIONS.map((o) => o.value)).toEqual(['auto', 'female', 'male']);
- });
-
- it('starts on auto', () => {
- const { result } = renderHook(() => useCrewPreference());
-
- expect(result.current.preference).toBe('auto');
- });
-
- it('remembers a choice across sessions', () => {
- const { result } = renderHook(() => useCrewPreference());
-
- act(() => result.current.setPreference('female'));
-
- expect(localStorage.getItem(CREW_PREFERENCE_STORAGE_KEY)).toBe('female');
- expect(renderHook(() => useCrewPreference()).result.current.preference).toBe('female');
- });
-
- it('ignores a stored value it does not recognise', () => {
- localStorage.setItem(CREW_PREFERENCE_STORAGE_KEY, 'coxed-eight');
-
- expect(renderHook(() => useCrewPreference()).result.current.preference).toBe('auto');
- });
-});
diff --git a/src/__tests__/crewPreload.test.ts b/src/__tests__/crewPreload.test.ts
deleted file mode 100644
index e46713d5..00000000
--- a/src/__tests__/crewPreload.test.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { preloadCrew } from '../components/rower3d/crewPreload';
-import { CREW_URL } from '../components/rower3d/crewModel';
-
-const preload = vi.hoisted(() => vi.fn());
-
-vi.mock('@react-three/drei', () => ({ useGLTF: { preload } }));
-
-describe('preloadCrew', () => {
- beforeEach(() => preload.mockClear());
-
- it('warms only the crew that is in the boat', () => {
- preloadCrew('female');
-
- expect(preload).toHaveBeenCalledOnce();
- expect(preload).toHaveBeenCalledWith(CREW_URL.female);
- });
-
- it('never fetches the crew that is not', () => {
- preloadCrew('male');
-
- expect(preload).not.toHaveBeenCalledWith(CREW_URL.female);
- });
-});
diff --git a/src/__tests__/crewRig.test.ts b/src/__tests__/crewRig.test.ts
deleted file mode 100644
index 964c2d8a..00000000
--- a/src/__tests__/crewRig.test.ts
+++ /dev/null
@@ -1,237 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import fs from 'fs';
-import path from 'path';
-import {
- GLB_ROWER_NODES,
- GLB_OAR_NODES,
- dressScull,
- litBySky,
- SCENERY_ENV_MAP_INTENSITY,
-} from '../components/rower3d/crewRig';
-import * as THREE from 'three';
-
-/**
- * Every node the scene animates has to exist in the shipped model.
- *
- * The rower's arms were never animated at all in the GLB scull, so nothing
- * would have noticed a rename: the failure mode is a rower sitting rigid while
- * the oars sweep, which looks like a styling choice rather than a fault (#273).
- */
-/** The crewed models. scull.glb is the boat alone and carries no rower. */
-const CREW_MODELS = [
- 'public/assets/boat/scull-male.glb',
- 'public/assets/boat/scull-female.glb',
-];
-
-/** Every boat model, crewed or not, sweeps oars. */
-const ALL_BOAT_MODELS = ['public/assets/boat/scull.glb', ...CREW_MODELS];
-
-/** Node names from a GLB's JSON chunk. */
-const nodeNames = (file: string): string[] => {
- const data = fs.readFileSync(path.join(process.cwd(), file));
- const jsonLength = data.readUInt32LE(12);
- const json = JSON.parse(data.subarray(20, 20 + jsonLength).toString('utf8'));
- return (json.nodes ?? []).map((n: { name?: string }) => n.name ?? '');
-};
-
-describe('the crew rig exposes what the scene animates', () => {
- for (const model of CREW_MODELS) {
- it(`${model.split('/').pop()} carries every rower node the stroke moves`, () => {
- const names = new Set(nodeNames(model));
-
- const missing = GLB_ROWER_NODES.filter((n) => !names.has(n));
-
- expect(
- missing,
- `${model} is missing nodes the scene animates — the rower would sit still`,
- ).toEqual([]);
- });
- }
-
- for (const model of ALL_BOAT_MODELS) {
- it(`${model.split('/').pop()} carries both oars`, () => {
- const names = new Set(nodeNames(model));
-
- expect(GLB_OAR_NODES.filter((n) => !names.has(n))).toEqual([]);
- });
- }
-
- it('does not expect a rower in the uncrewed scull', () => {
- // scull.glb is the boat on its own. Asserting rower nodes there would fail
- // for a model that is doing exactly what it should.
- const names = new Set(nodeNames('public/assets/boat/scull.glb'));
-
- expect([...names].some((n) => n.includes('Rower'))).toBe(false);
- });
-
- // The slide. The rig has always authored it and nothing ever moved it, so
- // the rower's legs compressed while their seat stayed where it was — they
- // shrank and grew rather than sliding (#330).
- it('names the seat, so the rower can slide', () => {
- expect(GLB_ROWER_NODES).toContain('Seat');
- });
-
- it('names the arms and the oars, so a stroke can move both', () => {
- expect(GLB_ROWER_NODES).toContain('Rower_LeftArm');
- expect(GLB_ROWER_NODES).toContain('Rower_RightArm');
- expect(GLB_OAR_NODES).toContain('LeftOar');
- expect(GLB_OAR_NODES).toContain('RightOar');
- });
-});
-
-/**
- * Issue #349 — the scull reflects the sky.
- *
- * Every GLB in the kit ships a flat `baseColorFactor` and nothing else, and
- * until this issue the scene had no usable environment map to reflect, so the
- * hull read as matte plastic and the metal rigger as dark grey. `dressScull`
- * swaps in materials that answer to an environment: a clearcoat on the hull, a
- * metal on the rigger and the gates.
- *
- * The exporter names a group and hangs the geometry on a `_part` child — the
- * meshes are `Hull_part`, `LeftGate_part`, `LeftOar_Blade_part` — so matching
- * on the group name alone would dress nothing.
- */
-describe('dressing the scull', () => {
- const scull = () => {
- const root = new THREE.Group();
- for (const name of [
- 'Hull_part',
- 'Hull_Underside_part',
- 'LeftRigger_part',
- 'LeftRig_Stay1_part',
- 'LeftGate_part',
- 'LeftOar_Blade_part',
- 'Seat_Pan_part',
- 'Rower_Torso_part',
- ]) {
- const mesh = new THREE.Mesh(
- new THREE.BoxGeometry(1, 1, 1),
- new THREE.MeshStandardMaterial({ color: '#c43b2f' }),
- );
- mesh.name = name;
- root.add(mesh);
- }
- return root;
- };
-
- const meshNamed = (root: THREE.Object3D, name: string) =>
- root.getObjectByName(name) as THREE.Mesh;
-
- it('gives the hull a clearcoat, the way a painted boat has one', () => {
- const root = scull();
-
- dressScull(root);
-
- for (const name of ['Hull_part', 'Hull_Underside_part']) {
- const material = meshNamed(root, name).material as THREE.MeshPhysicalMaterial;
- expect(material, name).toBeInstanceOf(THREE.MeshPhysicalMaterial);
- expect(material.clearcoat, name).toBeGreaterThanOrEqual(0.8);
- expect(material.roughness, name).toBeLessThan(0.5);
- }
- });
-
- it('makes the rigger and the gates read as metal', () => {
- const root = scull();
-
- dressScull(root);
-
- for (const name of ['LeftRigger_part', 'LeftRig_Stay1_part', 'LeftGate_part']) {
- const material = meshNamed(root, name).material as THREE.MeshStandardMaterial;
- expect(material.metalness, name).toBeGreaterThanOrEqual(0.8);
- expect(material.roughness, name).toBeLessThanOrEqual(0.3);
- }
- });
-
- // A blade is painted, not chromed, and it is the part a rower sees most.
- it('leaves the blade matte rather than metal', () => {
- const root = scull();
-
- dressScull(root);
-
- const material = meshNamed(root, 'LeftOar_Blade_part').material as THREE.MeshStandardMaterial;
- expect(material.metalness).toBeLessThan(0.5);
- expect(material.roughness).toBeGreaterThan(0.3);
- });
-
- /**
- * The club's colours survive.
- *
- * The GLB's `baseColorFactor` is the only thing distinguishing one boat from
- * another, and a material pass that dropped it would paint every scull the
- * same. Every dressed mesh keeps the colour it arrived with.
- */
- it('keeps every part’s own colour', () => {
- const root = scull();
-
- dressScull(root);
-
- root.traverse((object) => {
- const mesh = object as THREE.Mesh;
- if (!mesh.isMesh) return;
- const material = mesh.material as THREE.MeshStandardMaterial;
- expect(`#${material.color.getHexString()}`, mesh.name).toBe('#c43b2f');
- });
- });
-
- it('leaves the parts it has no opinion about alone', () => {
- const root = scull();
- const before = meshNamed(root, 'Rower_Torso_part').material;
-
- dressScull(root);
-
- expect(meshNamed(root, 'Rower_Torso_part').material).toBe(before);
- });
-
- // Called once after the GLB loads, and the scene may re-render many times.
- it('can be applied twice without building a third material', () => {
- const root = scull();
-
- dressScull(root);
- const first = meshNamed(root, 'Hull_part').material;
- dressScull(root);
-
- expect(meshNamed(root, 'Hull_part').material).toBe(first);
- });
-});
-
-/**
- * Issue #349 — the flat-colour kit takes the sky's tint.
- *
- * The scenery GLBs are `baseColorFactor` and nothing else, so without a little
- * of the environment on them a white clubhouse reads as paper against a blue
- * sky. Half strength: the kit should take the sky's colour, not its shine.
- */
-describe('lighting the scenery kit by the sky', () => {
- it('turns the environment up on every standard material it finds', () => {
- const root = new THREE.Group();
- const mesh = new THREE.Mesh(
- new THREE.BoxGeometry(1, 1, 1),
- new THREE.MeshStandardMaterial({ color: '#ffffff' }),
- );
- root.add(mesh);
-
- litBySky(root);
-
- expect((mesh.material as THREE.MeshStandardMaterial).envMapIntensity).toBe(
- SCENERY_ENV_MAP_INTENSITY,
- );
- });
-
- // Half, not full: a boathouse is painted timber, not a mirror.
- it('does not make the kit shiny', () => {
- expect(SCENERY_ENV_MAP_INTENSITY).toBeGreaterThan(0);
- expect(SCENERY_ENV_MAP_INTENSITY).toBeLessThan(1);
- });
-
- // A basic material has no environment to take, and assigning one would put a
- // property on it that three never reads.
- it('leaves a material with no environment alone', () => {
- const root = new THREE.Group();
- const mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshBasicMaterial());
- root.add(mesh);
-
- expect(() => litBySky(root)).not.toThrow();
- expect('envMapIntensity' in mesh.material).toBe(false);
- });
-});
diff --git a/src/__tests__/debugPanel.test.tsx b/src/__tests__/debugPanel.test.tsx
deleted file mode 100644
index 01431ba1..00000000
--- a/src/__tests__/debugPanel.test.tsx
+++ /dev/null
@@ -1,225 +0,0 @@
-import { afterAll, afterEach, beforeAll, beforeEach, describe, it, expect, vi } from 'vitest';
-import { render, screen } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import App from '../App';
-import { installCanvasMock } from './canvasMock';
-import { workoutService } from '../lib/services/workoutService';
-import { pm5Simulator } from '../lib/services/pm5SimulatorService';
-import { heartRateSimulator } from '../lib/services/heartRateSimulatorService';
-import { RENDER_BUDGET } from '../components/rower3d/renderBudget';
-import { recordRenderStats, clearRenderStats } from '../components/rower3d/sceneStats';
-
-/**
- * Debug has to be switchable from inside the debug window, not only from the
- * floating button that opens it (#270).
- *
- * The close control existed and nothing asserted it: responsive.spec.ts clicks
- * it as a step on the way to something else and never checks the panel
- * actually closed. A control that silently stopped working would have been
- * invisible.
- */
-let uninstallCanvas: () => void;
-beforeAll(() => {
- uninstallCanvas = installCanvasMock();
-});
-afterAll(() => uninstallCanvas());
-
-beforeEach(() => {
- localStorage.clear();
-});
-
-afterEach(() => {
- pm5Simulator.stop();
- heartRateSimulator.stop();
- if (workoutService.getCurrentSession()) workoutService.endSession();
- vi.restoreAllMocks();
-});
-
-describe('the debug window', () => {
- it('is closed until asked for', () => {
- render();
-
- expect(document.querySelector('.debug-info-panel')).toBeNull();
- });
-
- it('opens from the floating control and closes from inside itself', async () => {
- const user = userEvent.setup();
- render();
-
- await user.click(screen.getByRole('button', { name: /debug/i }));
- expect(document.querySelector('.debug-info-panel')).not.toBeNull();
-
- // The control inside the window, not the one that opened it.
- const panel = document.querySelector('.debug-info-panel') as HTMLElement;
- const closeButton = panel.querySelector('.debug-close-btn') as HTMLButtonElement;
- expect(closeButton, 'the debug window has no control of its own').not.toBeNull();
-
- await user.click(closeButton);
-
- expect(document.querySelector('.debug-info-panel')).toBeNull();
- });
-
- it('can be reopened after being closed from inside', async () => {
- const user = userEvent.setup();
- render();
-
- const toggle = screen.getByRole('button', { name: /debug/i });
- await user.click(toggle);
- const closeButton = document.querySelector('.debug-close-btn') as HTMLButtonElement;
- await user.click(closeButton);
- await user.click(toggle);
-
- expect(document.querySelector('.debug-info-panel')).not.toBeNull();
- });
-});
-
-describe('the river guides toggle', () => {
- it('offers a control for the guides inside the debug window', async () => {
- // The guides were gated on debugMode alone, so they arrived with every
- // other debug overlay and could not be turned off on their own (#270).
- const user = userEvent.setup();
- render();
-
- await user.click(screen.getByRole('button', { name: /debug/i }));
-
- const guides = screen.getByRole('checkbox', { name: /river guides/i });
- expect(guides).toBeTruthy();
- expect((guides as HTMLInputElement).checked).toBe(true);
- });
-
- it('remembers being switched off while the window stays open', async () => {
- const user = userEvent.setup();
- render();
-
- await user.click(screen.getByRole('button', { name: /debug/i }));
- const guides = screen.getByRole('checkbox', { name: /river guides/i });
-
- await user.click(guides);
-
- expect((screen.getByRole('checkbox', { name: /river guides/i }) as HTMLInputElement).checked)
- .toBe(false);
- });
-});
-
-/**
- * #493: lake outlines ship behind a flag, off by default. The switch lets a
- * tester turn them on from the debug window on main, and a reload keeps it.
- */
-describe('the lake outlines toggle', () => {
- it('starts off and remembers being switched on across a reload', async () => {
- const user = userEvent.setup();
- const first = render();
-
- await user.click(screen.getByRole('button', { name: /debug/i }));
- const lakes = () => screen.getByRole('checkbox', { name: /lake outlines/i }) as HTMLInputElement;
- expect(lakes().checked).toBe(false);
-
- await user.click(lakes());
- expect(lakes().checked).toBe(true);
- first.unmount();
-
- render();
- await user.click(screen.getByRole('button', { name: /debug/i }));
- expect(lakes().checked).toBe(true);
- });
-});
-
-describe('the scenery kit toggle', () => {
- it('offers a control for the GLB scenery kit inside the debug window', async () => {
- // It replaced ?glb=, which was the only way to switch the kit and required
- // editing the URL to use (#270).
- const user = userEvent.setup();
- render();
-
- await user.click(screen.getByRole('button', { name: /debug/i }));
-
- expect(screen.getByRole('checkbox', { name: /scenery/i })).toBeTruthy();
- });
-
- it('switches the kit off and on again', async () => {
- const user = userEvent.setup();
- render();
-
- await user.click(screen.getByRole('button', { name: /debug/i }));
- const kit = () => screen.getByRole('checkbox', { name: /scenery/i }) as HTMLInputElement;
- const started = kit().checked;
-
- await user.click(kit());
- expect(kit().checked).toBe(!started);
-
- await user.click(kit());
- expect(kit().checked).toBe(started);
- });
-});
-
-/**
- * Issue #342 — the panel shows the same ceiling CI holds.
- *
- * A draw-call count on its own tells a reader nothing: 346 is fine and 1,146
- * is not, and there was no way to tell which from the panel. The budget table
- * is one table, read by the gate and by the panel, so a rower who opens it
- * sees the number a PR would be failed on.
- */
-describe('the render budget in the debug window', () => {
- /**
- * Put one frame's cost where the panel reads it.
- *
- * Through `recordRenderStats` rather than by writing the module's state, so
- * the test goes the way the scene goes: jsdom draws no frames, and a panel
- * with nothing to show is not what is being checked here.
- */
- const seedFrame = (render: { drawCalls: number; triangles: number }) => {
- recordRenderStats(
- {
- isWebGLRenderer: true,
- info: {
- render: { calls: render.drawCalls, triangles: render.triangles },
- reset: () => undefined,
- },
- },
- { backend: 'webgl', performanceMode: 'low', fps: 60, p95Ms: 9 },
- );
- };
-
- const openPanel = async () => {
- const user = userEvent.setup();
- render();
- await user.click(screen.getByRole('button', { name: /debug/i }));
- return document.querySelector('.debug-info-panel') as HTMLElement;
- };
-
- it('shows the draw-call and triangle counts against their budget', async () => {
- // jsdom draws no frames, so the scene reports nothing and the panel says
- // so. What is asserted is that the budget reaches the panel at all: the
- // row is there either way, and the numbers arrive with it.
- seedFrame({ drawCalls: 120, triangles: 90_000 });
-
- const panel = await openPanel();
- const text = panel.textContent ?? '';
-
- expect(text, 'the panel shows no draw calls').toContain('Draw calls:');
- expect(text, 'the draw-call budget is not shown beside the count').toContain(
- String(RENDER_BUDGET.low.drawCalls),
- );
- expect(text, 'the triangle budget is not shown beside the count').toContain(
- RENDER_BUDGET.low.triangles.toLocaleString(),
- );
-
- clearRenderStats();
- });
-
- it('marks a frame that is over its budget', async () => {
- seedFrame({ drawCalls: RENDER_BUDGET.low.drawCalls + 1, triangles: 90_000 });
-
- const panel = await openPanel();
- const flagged = panel.querySelectorAll('tr.debug-over-budget');
-
- expect(
- flagged.length,
- 'a frame over the ceiling looks the same as one inside it',
- ).toBeGreaterThan(0);
- expect(flagged[0].textContent).toContain('Draw calls:');
-
- clearRenderStats();
- });
-});
diff --git a/src/__tests__/demoCtaVisibility.test.tsx b/src/__tests__/demoCtaVisibility.test.tsx
deleted file mode 100644
index c692041a..00000000
--- a/src/__tests__/demoCtaVisibility.test.tsx
+++ /dev/null
@@ -1,143 +0,0 @@
-import { afterAll, afterEach, beforeAll, beforeEach, describe, it, expect, vi } from 'vitest';
-import { render, screen } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import App from '../App';
-import * as UseAuth from '../context/useAuth';
-import type { AuthContextValue } from '../context/useAuth';
-import { installCanvasMock } from './canvasMock';
-import {
- debugPreferencesStore,
- SHOW_DEMO_CTA_KEY,
-} from '../lib/services/debugPreferencesStore';
-import { workoutService } from '../lib/services/workoutService';
-import { pm5Simulator } from '../lib/services/pm5SimulatorService';
-import { heartRateSimulator } from '../lib/services/heartRateSimulatorService';
-
-let uninstallCanvas: () => void;
-beforeAll(() => {
- uninstallCanvas = installCanvasMock();
-});
-afterAll(() => uninstallCanvas());
-
-function signedIn(): AuthContextValue {
- return {
- user: { id: 'i12345', name: 'Test User', email: 'test@example.com' },
- isAuthenticated: true,
- isLoading: false,
- authError: null,
- authErrorClass: null,
- login: vi.fn(),
- logout: vi.fn(),
- clearAuthError: vi.fn(),
- pendingAction: null,
- setPendingAction: vi.fn(),
- };
-}
-
-function mockSignedIn() {
- vi.spyOn(UseAuth, 'useAuth').mockReturnValue(signedIn());
-}
-
-beforeEach(() => {
- localStorage.clear();
- // Playwright is off by default in Vitest — that is the whole point of #453:
- // a real signed-in athlete does NOT see the CTA on a fresh browser.
- delete (window as unknown as { __PLAYWRIGHT_TESTING?: boolean }).__PLAYWRIGHT_TESTING;
-});
-
-afterEach(() => {
- // Same cleanup as appNavigation.test.tsx (see its comment): the demo-mode
- // simulators are module singletons that survive unmount.
- pm5Simulator.stop();
- heartRateSimulator.stop();
- if (workoutService.getCurrentSession()) workoutService.endSession();
- delete (window as unknown as { __PLAYWRIGHT_TESTING?: boolean }).__PLAYWRIGHT_TESTING;
- vi.restoreAllMocks();
-});
-
-/**
- * Issue #453 — the demo-row CTA is noise for signed-in athletes and a
- * foot-gun for the ones who tap it by mistake, so it is now hidden on the
- * main flow for them and toggled back on from the debug panel.
- */
-describe('Demo-row CTA visibility (#453)', () => {
- it('AC1 (FR1): hides the CTA for a signed-in athlete on a fresh browser', () => {
- mockSignedIn();
- const { container } = render();
- expect(container.querySelector('.demo-row-cta')).toBeNull();
- expect(
- screen.queryByRole('button', { name: /Try a demo row/i }),
- ).not.toBeInTheDocument();
- });
-
- it('AC1 (FR2): still shows the CTA for a guest / signed-out visitor', () => {
- const { container } = render();
- expect(container.querySelector('.demo-row-cta')).not.toBeNull();
- expect(
- screen.getByRole('button', { name: /Try a demo row/i }),
- ).toBeInTheDocument();
- });
-
- it('AC6 (FR7): shows the CTA under Playwright even when signed in and the override is off', () => {
- (window as unknown as { __PLAYWRIGHT_TESTING?: boolean }).__PLAYWRIGHT_TESTING = true;
- mockSignedIn();
- const { container } = render();
- expect(container.querySelector('.demo-row-cta')).not.toBeNull();
- });
-
- it('AC1 (FR4): honours the persisted override on first render (signed in)', () => {
- debugPreferencesStore.setShowDemoCtaOverride(true);
- mockSignedIn();
- const { container } = render();
- expect(container.querySelector('.demo-row-cta')).not.toBeNull();
- });
-
- it('AC2 (FR3, FR4): toggling the debug-panel checkbox flips the CTA and localStorage', async () => {
- mockSignedIn();
- const user = userEvent.setup();
- const { container } = render();
-
- // Starts hidden.
- expect(container.querySelector('.demo-row-cta')).toBeNull();
-
- // Open debug panel.
- await user.click(screen.getByRole('button', { name: /Debug/i }));
-
- const toggle = screen.getByRole('checkbox', {
- name: /Show demo-row control on the main screen \(signed in\)/i,
- });
- expect(toggle).not.toBeChecked();
-
- // Check → CTA renders and the persisted key flips true.
- await user.click(toggle);
- expect(container.querySelector('.demo-row-cta')).not.toBeNull();
- expect(localStorage.getItem(SHOW_DEMO_CTA_KEY)).toBe('true');
-
- // Uncheck → CTA disappears and the persisted key flips false.
- await user.click(toggle);
- expect(container.querySelector('.demo-row-cta')).toBeNull();
- expect(localStorage.getItem(SHOW_DEMO_CTA_KEY)).toBe('false');
- });
-
- it('AC5 (FR6): the debug panel has a "Session" section above "PM5 Simulator"', async () => {
- mockSignedIn();
- const user = userEvent.setup();
- render();
-
- await user.click(screen.getByRole('button', { name: /Debug/i }));
-
- const sessionHeading = screen.getByRole('heading', { name: /^Session$/, level: 5 });
- const pm5Heading = screen.getByRole('heading', { name: /^PM5 Simulator$/, level: 5 });
- // Compare document order — the Session section must land above PM5.
- expect(
- sessionHeading.compareDocumentPosition(pm5Heading) &
- Node.DOCUMENT_POSITION_FOLLOWING,
- ).toBeTruthy();
-
- // The Session section carries the checkbox and it lives in a
- // `.debug-section` container using the shared `.debug-toggle-row` shape.
- const sectionContainer = sessionHeading.closest('.debug-section');
- expect(sectionContainer).not.toBeNull();
- expect(sectionContainer?.querySelector('.debug-toggle-row')).not.toBeNull();
- });
-});
diff --git a/src/__tests__/demoRouteScenery.test.ts b/src/__tests__/demoRouteScenery.test.ts
deleted file mode 100644
index 8f60d4c4..00000000
--- a/src/__tests__/demoRouteScenery.test.ts
+++ /dev/null
@@ -1,182 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import * as THREE from 'three';
-import { routeService } from '../lib/services/routeService';
-import {
- createFallbackRouteEnrichment,
- getWaterWidthSceneUnitsForProgress,
- type RouteEnrichmentData,
- type SceneryProfile,
-} from '../lib/services/routeEnrichmentService';
-import { createRouteCurve } from '../components/rower3d/curve';
-import { SCENE_SCALE } from '../components/rower3d/constants';
-import { SCENERY_PROFILES } from '../components/rower3d/sceneryConfig';
-import { resolveSceneryModels, type ResolvedScenery } from '../components/rower3d/sceneryAssets';
-import { resolveRegion } from '../components/rower3d/sceneryRegion';
-import {
- WILLOWBROOK_ROUTE_ID,
- getRouteSceneryTrack,
- trackWaterForProfile,
-} from '../components/rower3d/sceneryTrack';
-import { computePlacements, distinctProfiles } from '../components/rower3d/sceneryPlacement';
-import { computeStructurePlacements, routeStructures } from '../components/rower3d/sceneryStructures';
-import { layoutLandscape } from '../components/rower3d/landscapeLayout';
-import { layoutFoliage } from '../components/rower3d/foliagePlan';
-import { SCENE_CONFIG } from '../components/rower3d/themeConfig';
-import { SCENERY_WATER_MARGIN_METRES } from '../components/rower3d/sceneryClearance';
-
-/**
- * Issue #379, last acceptance scenario: the demo route has nothing standing in
- * its river.
- *
- * Every placement path the scene has - the GLB scatter on both banks, the
- * pinned course structures and the procedural landscape - laid out on the
- * bundled Willowbrook River exactly as Rower3D lays it out, and each placement
- * measured across the route against the water's half-width at its own
- * progress. The width is read here straight from
- * `getWaterWidthSceneUnitsForProgress`, not through the scenery's helper, so
- * the check does not share the code it is checking.
- */
-
-const demo = routeService.getAllRoutes().find((r) => r.id === WILLOWBROOK_ROUTE_ID)!;
-const curve = createRouteCurve(demo.coordinates, SCENE_SCALE)!;
-const track = getRouteSceneryTrack(demo.id);
-const region = resolveRegion(demo.coordinates);
-
-/** The enrichment the demo gets offline: its tags make it a 45 m lake. */
-const offline = createFallbackRouteEnrichment(demo);
-
-/** The same route read as the 55 m river its name says it is. */
-const asRiver: RouteEnrichmentData = {
- ...offline,
- waterBodyType: 'river',
- waterWidthMeters: 55,
- segmentProfiles: offline.segmentProfiles.map((s) => ({ ...s, waterWidthMeters: 55 })),
-};
-
-/** The same, with the width changing along the route, as a surveyed one does. */
-const surveyed: RouteEnrichmentData = {
- ...offline,
- segmentProfiles: offline.segmentProfiles.map((s, i) => ({
- ...s,
- waterWidthMeters: [20, 55, 120, 80, 35][i % 5],
- })),
-};
-
-interface Measured {
- path: string;
- x: number;
- z: number;
- progress: number;
-}
-
-const everythingOnTheBank = (enrichment: RouteEnrichmentData): Measured[] => {
- // As SceneryModels resolves it.
- const resolvedByProfile = new Map();
- for (const p of distinctProfiles(enrichment, track)) {
- const water = track ? trackWaterForProfile(track, p) : enrichment.waterBodyType;
- resolvedByProfile.set(
- p,
- resolveSceneryModels(p, water, SCENERY_PROFILES[p]?.trees.species ?? [], region),
- );
- }
-
- const scatter = (['left', 'right'] as const).flatMap((side) =>
- computePlacements({ curve, enrichment, resolvedByProfile, budget: 1, side, track })
- .filter((p) => p.footing === 'bank')
- .map((p) => ({ path: `scenery-${side}`, x: p.position[0], z: p.position[2], progress: p.progress })),
- );
-
- const structures = computeStructurePlacements(
- curve,
- routeStructures({
- side: 'left',
- hasCurve: true,
- coordinates: demo.coordinates,
- crossings: enrichment.crossings,
- }),
- null,
- enrichment,
- )
- .filter((p) => p.footing === 'bank')
- .map((p) => ({ path: 'structures', x: p.position[0], z: p.position[2], progress: p.progress }));
-
- const { leftElements, rightElements } = layoutLandscape({ curve, enrichment, track });
- const landscape = [...leftElements, ...rightElements].map((e) => ({
- path: 'landscape',
- x: e.position.x,
- z: e.position.z,
- progress: e.progress,
- }));
-
- // The billboard trees, planted round the houses as CurvedLandscapeElements
- // plants them (#333).
- const avoid = [...leftElements, ...rightElements]
- .filter((e) => e.type === 'building')
- .map((e) => ({ x: e.position.x, z: e.position.z }));
- const foliage = layoutFoliage({ curve, enrichment, track, species: SCENE_CONFIG.trees.species, avoid }).map(
- (t) => ({ path: 'foliage', x: t.position[0], z: t.position[2], progress: t.progress }),
- );
-
- return [...scatter, ...structures, ...landscape, ...foliage];
-};
-
-/** Metres from the water's edge, across the route; negative is in the river. */
-const clearanceOf = (enrichment: RouteEnrichmentData, { x, z, progress }: Measured) => {
- const t = Math.max(0, Math.min(1, progress));
- const centre = curve.getPointAt(t);
- const perp = new THREE.Vector3()
- .crossVectors(curve.getTangentAt(t).normalize(), new THREE.Vector3(0, 1, 0))
- .normalize();
- const across = Math.abs((x - centre.x) * perp.x + (z - centre.z) * perp.z);
- const halfWidth =
- getWaterWidthSceneUnitsForProgress(
- enrichment.segmentProfiles,
- enrichment.waterWidthMeters,
- progress,
- ) / 2;
- return across - halfWidth;
-};
-
-describe('the demo route has nothing standing in its river (#379)', () => {
- it.each([
- ['offline, as a 45 m lake', offline],
- ['as a 55 m river', asRiver],
- ['with a width that varies from 20 m to 120 m', surveyed],
- ])('%s', (_label, enrichment) => {
- const placed = everythingOnTheBank(enrichment);
-
- // Every path contributed, or this proved nothing about the one that did not.
- const paths = new Set(placed.map((p) => p.path));
- expect([...paths].sort()).toEqual([
- 'foliage',
- 'landscape',
- 'scenery-left',
- 'scenery-right',
- 'structures',
- ]);
- // A tree-lined route, not the forty cone trees it had (#333).
- expect(placed.filter((p) => p.path === 'foliage').length).toBeGreaterThanOrEqual(400);
-
- const inTheRiver = placed
- .map((p) => ({ ...p, clearance: clearanceOf(enrichment, p) }))
- // The margin is held to within a centimetre: a placement on a bend is
- // offset at its own sample, and the curve is re-sampled here.
- .filter((p) => p.clearance < SCENERY_WATER_MARGIN_METRES - 0.01)
- .map((p) => `${p.path} at ${p.progress.toFixed(3)}: ${p.clearance.toFixed(2)} m`);
-
- expect(inTheRiver).toEqual([]);
- });
-
- it('was not already true before #379, so the check can fail', () => {
- // The landmark offset that stood a building 20.5 m into a 55 m river.
- const sevenMetresOut = { path: 'structures', x: 0, z: 0, progress: 0.5 };
- const centre = curve.getPointAt(0.5);
- const perp = new THREE.Vector3()
- .crossVectors(curve.getTangentAt(0.5).normalize(), new THREE.Vector3(0, 1, 0))
- .normalize();
- sevenMetresOut.x = centre.x + perp.x * 7;
- sevenMetresOut.z = centre.z + perp.z * 7;
-
- expect(clearanceOf(asRiver, sevenMetresOut)).toBeCloseTo(7 - 27.5, 6);
- });
-});
diff --git a/src/__tests__/detailTexture.test.ts b/src/__tests__/detailTexture.test.ts
deleted file mode 100644
index fe758f06..00000000
--- a/src/__tests__/detailTexture.test.ts
+++ /dev/null
@@ -1,80 +0,0 @@
-import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
-import * as THREE from 'three';
-import { installCanvasMock } from './canvasMock';
-import {
- createDetailTexture,
- normalFromHeight,
- resetDetailTextureCacheForTesting,
-} from '../components/rower3d/detailTexture';
-
-let teardown: (() => void) | null = null;
-beforeAll(() => { teardown = installCanvasMock(); });
-afterAll(() => { teardown?.(); });
-beforeEach(() => resetDetailTextureCacheForTesting());
-
-describe('createDetailTexture (#353)', () => {
- it('grass returns an albedo and normal with sRGB and repeat wrapping', () => {
- const { albedo, normal } = createDetailTexture('grass', 32);
- expect(albedo).toBeInstanceOf(THREE.CanvasTexture);
- expect(albedo.wrapS).toBe(THREE.RepeatWrapping);
- expect(albedo.wrapT).toBe(THREE.RepeatWrapping);
- expect(albedo.colorSpace).toBe(THREE.SRGBColorSpace);
- expect(normal.wrapS).toBe(THREE.RepeatWrapping);
- expect(normal.wrapT).toBe(THREE.RepeatWrapping);
- });
-
- it('earth returns a different albedo than grass', () => {
- // The two kinds share the generator; the palette is what changes.
- const grass = createDetailTexture('grass', 16);
- const earth = createDetailTexture('earth', 16);
- expect(grass.albedo).not.toBe(earth.albedo);
- });
-
- it('the same kind is cached and returns the same textures twice', () => {
- // Each detail texture is roughly a megabyte; a fresh instance per bank
- // chunk would allocate one per chunk, and there are hundreds.
- const first = createDetailTexture('grass', 16);
- const second = createDetailTexture('grass', 16);
- expect(first.albedo).toBe(second.albedo);
- expect(first.normal).toBe(second.normal);
- });
-});
-
-describe('normalFromHeight (#353)', () => {
- it('a flat height field encodes as neutral blue at every pixel', () => {
- // Central differences on a constant give zero, and (0,0,1) tangent-space
- // normals encode to (128, 128, 255) — "surface faces straight up".
- const size = 4;
- const heights = new Float32Array(size * size).fill(0.5);
- const texture = normalFromHeight(heights, size, 2.0);
- expect(texture).toBeInstanceOf(THREE.DataTexture);
- // The DataTexture buffer is RGBA in row-major order.
- const data = (texture.image as { data: Uint8Array }).data;
- for (let i = 0; i < size * size; i += 1) {
- expect(data[i * 4]).toBe(128);
- expect(data[i * 4 + 1]).toBe(128);
- expect(data[i * 4 + 2]).toBe(255);
- expect(data[i * 4 + 3]).toBe(255);
- }
- });
-
- it('a height field with a bump encodes a tilted normal', () => {
- // A single-cell bump has a slope on the ring around it, so at least one
- // pixel encodes a red or green channel away from 128.
- const size = 8;
- const heights = new Float32Array(size * size).fill(0.5);
- heights[3 * size + 3] = 1;
- const texture = normalFromHeight(heights, size, 2.0);
- const data = (texture.image as { data: Uint8Array }).data;
- let anyTilt = false;
- for (let i = 0; i < size * size; i += 1) {
- const r = data[i * 4];
- const g = data[i * 4 + 1];
- if (r !== 128 || g !== 128) {
- anyTilt = true;
- break;
- }
- }
- expect(anyTilt).toBe(true);
- });
-});
diff --git a/src/__tests__/detailTextures.test.ts b/src/__tests__/detailTextures.test.ts
deleted file mode 100644
index d2456da1..00000000
--- a/src/__tests__/detailTextures.test.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
-import * as THREE from 'three';
-import { installCanvasMock } from './canvasMock';
-import {
- brickNormalMap,
- carbonWeaveNormalMap,
- clapboardNormalMap,
- lycraKnitNormalMap,
- plasterNormalMap,
- resetDetailTexturesForTesting,
-} from '../components/rower3d/detailTextures';
-
-let teardownCanvas: (() => void) | null = null;
-beforeAll(() => { teardownCanvas = installCanvasMock(); });
-afterAll(() => { teardownCanvas?.(); });
-beforeEach(() => resetDetailTexturesForTesting());
-
-describe('detail textures (#354)', () => {
- it('a carbon weave normal map returns a CanvasTexture set to repeat', () => {
- const t = carbonWeaveNormalMap();
- expect(t).toBeInstanceOf(THREE.CanvasTexture);
- expect(t!.wrapS).toBe(THREE.RepeatWrapping);
- expect(t!.wrapT).toBe(THREE.RepeatWrapping);
- t!.dispose();
- });
-
- it('a lycra knit normal map returns a CanvasTexture set to repeat', () => {
- const t = lycraKnitNormalMap();
- expect(t).toBeInstanceOf(THREE.CanvasTexture);
- expect(t!.wrapS).toBe(THREE.RepeatWrapping);
- expect(t!.wrapT).toBe(THREE.RepeatWrapping);
- t!.dispose();
- });
-
- it('the same texture kind is cached and returns the same object twice', () => {
- // Fresh canvas each call would burn megabytes on every scull mount.
- // Callers get a shared instance and share its lifetime.
- const a = carbonWeaveNormalMap();
- const b = carbonWeaveNormalMap();
- expect(a).toBe(b);
- });
-});
-
-describe('facade detail textures (#432)', () => {
- for (const [name, build] of [
- ['brick', brickNormalMap],
- ['plaster', plasterNormalMap],
- ['clapboard', clapboardNormalMap],
- ] as const) {
- it(`${name} is a CanvasTexture set to repeat, cached across calls`, () => {
- const a = build();
- const b = build();
- expect(a).toBeInstanceOf(THREE.CanvasTexture);
- expect(a!.wrapS).toBe(THREE.RepeatWrapping);
- expect(a!.wrapT).toBe(THREE.RepeatWrapping);
- expect(a).toBe(b);
- });
- }
-
- it('the three facade kinds are three distinct textures', () => {
- const b = brickNormalMap();
- const p = plasterNormalMap();
- const c = clapboardNormalMap();
- expect(b).not.toBe(p);
- expect(p).not.toBe(c);
- expect(b).not.toBe(c);
- });
-});
diff --git a/src/__tests__/effectPlan.test.ts b/src/__tests__/effectPlan.test.ts
deleted file mode 100644
index 7d6e3ede..00000000
--- a/src/__tests__/effectPlan.test.ts
+++ /dev/null
@@ -1,266 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import { effectPlanFor, godRaysSun, effectCost, EFFECT_NAMES } from '../components/rower3d/effectPlan';
-import { QUALITY_TIERS } from '../components/rower3d/canvasSurface';
-import type { PerformanceMode } from '../components/rower3d/constants';
-
-const ORDER: PerformanceMode[] = ['basic', 'medium', 'extra-high'];
-const withSun = { hasSun: true };
-
-describe('the low tier runs no effects at all', () => {
- it('has an empty plan, whether or not a sun is available', () => {
- expect(effectPlanFor('basic', withSun).effects).toEqual([]);
- expect(effectPlanFor('basic', { hasSun: false }).effects).toEqual([]);
- });
-
- it('needs no composer, so none is mounted', () => {
- expect(effectPlanFor('basic', withSun).composer).toBe(false);
- });
-});
-
-describe('the middle tier is genuinely lighter than the top', () => {
- const auto = effectPlanFor('medium', withSun);
-
- it('does not run ambient occlusion', () => {
- // SSAO is the expensive one: a full-resolution pass that also forces the
- // composer's normal pass. On integrated hardware it is what turned a
- // working scene into a GPU reset a second later (#232).
- expect(auto.effects).not.toContain('ssao');
- });
-
- it('does not force a normal pass', () => {
- expect(auto.normalPass).toBe(false);
- });
-
- it('runs neither depth of field nor god rays', () => {
- expect(auto.effects).not.toContain('depthOfField');
- expect(auto.effects).not.toContain('godRays');
- });
-
- it('still colours and tones the scene', () => {
- expect(auto.effects).toEqual(
- expect.arrayContaining(['bloom', 'hueSaturation', 'brightnessContrast', 'vignette', 'toneMapping']),
- );
- });
-});
-
-describe('the high tier keeps everything it had', () => {
- const high = effectPlanFor('extra-high', withSun);
-
- it('runs ambient occlusion with the normal pass it needs', () => {
- expect(high.effects).toContain('ssao');
- expect(high.normalPass).toBe(true);
- });
-
- it('runs god rays only when there is a sun to cast them', () => {
- expect(high.effects).toContain('godRays');
- expect(effectPlanFor('extra-high', { hasSun: false }).effects).not.toContain('godRays');
- });
-});
-
-describe('no tier runs an effect the tier above it skips', () => {
- const plans = ORDER.map((tier) => effectPlanFor(tier, withSun));
-
- it.each(EFFECT_NAMES)('%s is only ever added as quality rises', (effect) => {
- const present = plans.map((plan) => (plan.effects.includes(effect) ? 1 : 0));
-
- expect(present).toEqual([...present].sort((a, b) => a - b));
- });
-
- it('gets strictly more expensive with each tier', () => {
- const costs = plans.map(effectCost);
-
- expect(costs).toEqual([...costs].sort((a, b) => a - b));
- expect(new Set(costs).size).toBe(ORDER.length);
- });
-
- it('never enables the normal pass without the effect that needs it', () => {
- for (const plan of plans) {
- if (plan.normalPass) expect(plan.effects).toContain('ssao');
- }
- });
-});
-
-describe('every tier has a plan of its own', () => {
- it('covers each quality tier, so none falls through to another tier’s stack', () => {
- // The old code chose by if/else and ended on an unguarded `return` holding
- // the second-heaviest stack — reachable by any mode that was not 'medium'
- // and had no sun.
- for (const tier of QUALITY_TIERS) {
- expect(() => effectPlanFor(tier, withSun)).not.toThrow();
- }
- });
-
- it('treats an unknown mode as the lightest, not the heaviest', () => {
- const unknown = effectPlanFor('nonsense' as PerformanceMode, withSun);
-
- expect(effectCost(unknown)).toBeLessThanOrEqual(effectCost(effectPlanFor('medium', withSun)));
- });
-});
-
-describe('godRaysSun — the light source the pass is given (#233)', () => {
- // GodRaysEffect.update dereferences this.lightSource.parent every frame, so
- // the pass must never be constructed without a live mesh.
- //
- // The old caller computed `performanceMode === 'extra-high' ? sunMeshRef : undefined`
- // and the composer guarded on `!!sunMeshRef`. Both test the *ref object*,
- // which useRef makes truthy from the first render — the mesh is on `.current`,
- // and it is null until the mesh mounts, or forever in test mode where the mesh
- // is never rendered at all. So high mode threw a TypeError on every frame.
- const mesh = { isMesh: true } as unknown as import('three').Mesh;
-
- it('gives the pass a real mesh only in high mode', () => {
- expect(godRaysSun('extra-high', mesh)).toBe(mesh);
- });
-
- it('gives nothing when the mesh has not mounted yet', () => {
- // The case the ref hid: high mode, but no light source in the scene.
- expect(godRaysSun('extra-high', null)).toBeNull();
- });
-
- it('gives nothing below high mode, mesh or not', () => {
- expect(godRaysSun('medium', mesh)).toBeNull();
- expect(godRaysSun('basic', mesh)).toBeNull();
- });
-
- it('keeps the plan and the light source in step', () => {
- // hasSun must be derived from the same answer the composer renders with,
- // or the plan can ask for a pass the composer has no sun for.
- const sun = godRaysSun('extra-high', null);
- expect(effectPlanFor('extra-high', { hasSun: !!sun }).effects).not.toContain('godRays');
-
- const live = godRaysSun('extra-high', mesh);
- expect(effectPlanFor('extra-high', { hasSun: !!live }).effects).toContain('godRays');
- });
-});
-
-/**
- * Issue #327 — the frame is tone-mapped once.
- *
- * The renderer is built with `ACESFilmicToneMapping` and the composer ends on a
- * `ToneMapping` pass set to ACES as well, so wherever a composer was mounted
- * the frame was graded twice. That crushes the mid-tones, and it is part of why
- * `sceneExposure` had to be dragged down to 0.55 to stop the sky blowing out
- * (#269) - the exposure was compensating for a second grade nobody had noticed.
- *
- * Which of the two runs is now a property of the plan, so it is decided in one
- * place and can be asserted without a GPU.
- */
-describe('exactly one tone-mapping stage', () => {
- it('leaves it on the renderer when there is no composer', () => {
- const plan = effectPlanFor('basic', { hasSun: false });
-
- expect(plan.composer, 'the low tier grew a composer').toBe(false);
- expect(plan.toneMapOnRenderer, 'nothing would tone-map the frame at all').toBe(true);
- });
-
- it.each(['medium', 'extra-high'] as const)('hands it to the composer at %s', (mode) => {
- const plan = effectPlanFor(mode, { hasSun: true });
-
- expect(plan.composer).toBe(true);
- expect(plan.effects, 'the composer has no tone-mapping pass to hand it to').toContain(
- 'toneMapping',
- );
- expect(plan.toneMapOnRenderer, 'the frame is tone-mapped twice').toBe(false);
- });
-
- it('never tone-maps in both places, whatever the tier', () => {
- for (const mode of ['basic', 'medium', 'extra-high'] as const) {
- const plan = effectPlanFor(mode, { hasSun: true });
- const inComposer = plan.composer && plan.effects.includes('toneMapping');
-
- expect(
- Number(plan.toneMapOnRenderer) + Number(inComposer),
- `${mode} tone-maps ${Number(plan.toneMapOnRenderer) + Number(inComposer)} times`,
- ).toBe(1);
- }
- });
-});
-
-/**
- * Issue #327 — depth of field blurred the whole world.
- *
- * `worldFocusDistance 10` with `worldFocusRange 25`, from a camera six metres
- * behind the boat, put everything past about thirty-five metres into bokeh.
- * Since #321 made a unit a metre that is the entire far bank, permanently.
- */
-describe('depth of field', () => {
- it('is not in the auto stack at all', () => {
- expect(effectPlanFor('medium', { hasSun: true }).effects).not.toContain('depthOfField');
- });
-
- it('is kept for high, where it is focused on the boat each frame', () => {
- expect(effectPlanFor('extra-high', { hasSun: true }).effects).toContain('depthOfField');
- });
-
- it('keeps the tiers in cost order with it gone from auto', () => {
- const low = effectCost(effectPlanFor('basic', { hasSun: true }));
- const auto = effectCost(effectPlanFor('medium', { hasSun: true }));
- const high = effectCost(effectPlanFor('extra-high', { hasSun: true }));
-
- expect(low).toBeLessThan(auto);
- expect(auto).toBeLessThan(high);
- });
-});
-
-/**
- * Issue #344 — a rower who asked for less motion does not get the effect whose
- * job is to feel like movement.
- *
- * Chromatic aberration splits the frame into fringes that shift with the
- * camera. At arm's length while rowing hard it reads as the picture swimming.
- */
-describe('reduced motion', () => {
- const TIERS = ['basic', 'medium', 'extra-high'] as const;
-
- it('drops chromatic aberration at every tier that has it', () => {
- for (const mode of TIERS) {
- expect(
- effectPlanFor(mode, { hasSun: true, reducedMotion: true }).effects,
- `${mode} still splits the frame into fringes`,
- ).not.toContain('chromaticAberration');
- }
- });
-
- // Stated against the default rather than on its own: "no chromatic
- // aberration" also describes a plan that failed to build.
- it('is the only thing that changes', () => {
- for (const mode of TIERS) {
- const asAuthored = effectPlanFor(mode, { hasSun: true });
- const still = effectPlanFor(mode, { hasSun: true, reducedMotion: true });
-
- expect(still.effects).toEqual(
- asAuthored.effects.filter((effect) => effect !== 'chromaticAberration'),
- );
- expect(still.composer).toBe(asAuthored.composer);
- expect(still.normalPass).toBe(asAuthored.normalPass);
- expect(still.ssaoSamples).toBe(asAuthored.ssaoSamples);
- expect(still.toneMapOnRenderer).toBe(asAuthored.toneMapOnRenderer);
- }
- });
-
- // The grade, the bloom and the vignette look the same in a frozen frame as in
- // a moving one, and depth is not motion. None of them is a reason to make the
- // scene plainer for someone who asked for stillness.
- it('keeps the static grade, the depth and the sun', () => {
- const still = effectPlanFor('extra-high', { hasSun: true, reducedMotion: true });
-
- for (const kept of ['bloom', 'hueSaturation', 'brightnessContrast', 'vignette', 'toneMapping', 'ssao', 'depthOfField', 'godRays'] as const) {
- expect(still.effects, `${kept} was dropped for the wrong reason`).toContain(kept);
- }
- });
-
- it('never makes a tier cost more than the one above it', () => {
- const cost = (mode: (typeof TIERS)[number]) =>
- effectCost(effectPlanFor(mode, { hasSun: true, reducedMotion: true }));
-
- expect(cost('basic')).toBeLessThan(cost('medium'));
- expect(cost('medium')).toBeLessThan(cost('extra-high'));
- });
-
- it('leaves the scene as authored when nothing was asked for', () => {
- expect(effectPlanFor('medium', { hasSun: true, reducedMotion: false }).effects).toContain(
- 'chromaticAberration',
- );
- expect(effectPlanFor('medium', { hasSun: true }).effects).toContain('chromaticAberration');
- });
-});
diff --git a/src/__tests__/facadeMaterial.test.ts b/src/__tests__/facadeMaterial.test.ts
deleted file mode 100644
index 7e340232..00000000
--- a/src/__tests__/facadeMaterial.test.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-import { afterAll, afterEach, beforeAll, describe, it, expect } from 'vitest';
-import * as THREE from 'three';
-import { facadeMaterial, FACADE_TILE_METRES } from '../components/rower3d/facadeMaterial';
-import { resetDetailTexturesForTesting } from '../components/rower3d/detailTextures';
-import { installCanvasMock } from './canvasMock';
-
-let uninstallCanvas: () => void;
-beforeAll(() => {
- uninstallCanvas = installCanvasMock();
-});
-afterAll(() => uninstallCanvas());
-
-/**
- * Issue #432 — a facade picks its normal map from what colour the exporter
- * gave it. The wall's tint stays on the material as albedo; brick, plaster
- * and clapboard get their own tiling normals, and low tier drops the map.
- */
-
-const source = (hex: string) =>
- new THREE.MeshStandardMaterial({ color: new THREE.Color(hex) });
-
-afterEach(() => resetDetailTexturesForTesting());
-
-describe('facadeMaterial (#432)', () => {
- it('carries the part colour as its albedo', () => {
- const src = source('#a24c2e');
- const mat = facadeMaterial(src, { tier: 'medium' });
- expect(mat.color.getHexString()).toBe('a24c2e');
- });
-
- it('picks the brick normal for a warm saturated colour', () => {
- const mat = facadeMaterial(source('#a24c2e'), { tier: 'medium' });
- expect(mat.normalMap).not.toBeNull();
- // The brick map is cached, so two calls on brick fixtures share the same
- // texture — a cheap way to name the kind without exposing its identity.
- const twice = facadeMaterial(source('#8a3a20'), { tier: 'medium' });
- expect(twice.normalMap).toBe(mat.normalMap);
- });
-
- it('picks the plaster normal for a bright neutral colour', () => {
- const mat = facadeMaterial(source('#f0e8d0'), { tier: 'medium' });
- const twice = facadeMaterial(source('#eee6d8'), { tier: 'medium' });
- expect(mat.normalMap).toBe(twice.normalMap);
- });
-
- it('picks the clapboard normal for a muted cool colour', () => {
- const mat = facadeMaterial(source('#6b7a70'), { tier: 'medium' });
- const twice = facadeMaterial(source('#4a6870'), { tier: 'medium' });
- expect(mat.normalMap).toBe(twice.normalMap);
- });
-
- it('gives the three kinds three different textures', () => {
- const b = facadeMaterial(source('#a24c2e'), { tier: 'medium' }).normalMap;
- const p = facadeMaterial(source('#f0e8d0'), { tier: 'medium' }).normalMap;
- const c = facadeMaterial(source('#6b7a70'), { tier: 'medium' }).normalMap;
- expect(b).not.toBe(p);
- expect(p).not.toBe(c);
- expect(b).not.toBe(c);
- });
-
- it('drops the normal map at low tier', () => {
- const mat = facadeMaterial(source('#a24c2e'), { tier: 'basic' });
- expect(mat.normalMap).toBeNull();
- });
-
- it('holds a plain roughness so the wall reads as its material, not lacquer', () => {
- expect(facadeMaterial(source('#a24c2e'), { tier: 'medium' }).roughness).toBeGreaterThan(0.5);
- expect(facadeMaterial(source('#a24c2e'), { tier: 'medium' }).metalness).toBe(0);
- });
-
- it('names its tile size, so a caller can wire UVs', () => {
- // A constant, not the material's — the caller sizes the mesh's UVs from it.
- expect(FACADE_TILE_METRES).toBeGreaterThan(0);
- });
-});
diff --git a/src/__tests__/fogPlan.test.ts b/src/__tests__/fogPlan.test.ts
deleted file mode 100644
index 21c09520..00000000
--- a/src/__tests__/fogPlan.test.ts
+++ /dev/null
@@ -1,82 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import { chunkViewDistanceFor, fogFor } from '../components/rower3d/fogPlan';
-import { SCENE_CONFIG } from '../components/rower3d/themeConfig';
-
-/**
- * Issue #325 — the distance fades instead of ending.
- *
- * The scene config has carried `fogColor`, `fogNear` and `fogFar` since the theme
- * table was written, and nothing has ever read them. Without fog the water
- * strip simply stops: `geometryChunks.ts` records that the cut used to be
- * hidden by `fogExp2` and has been bare ever since, and #321 had to hold the
- * cull out at seven kilometres rather than expose an edge nothing softened.
- *
- * Fog is what lets that come back in. The two are one decision, so they are
- * computed in one place: nothing past the fog's far plane is worth drawing,
- * because nothing past it can be seen.
- */
-
-describe('fogFor', () => {
- it('gives a fog it can actually see through', () => {
- const fog = fogFor();
-
- expect(Number.isFinite(fog.near), 'fogNear is not a number').toBe(true);
- expect(Number.isFinite(fog.far), 'fogFar is not a number').toBe(true);
- expect(fog.near, 'fog starts at or before the camera').toBeGreaterThan(0);
- expect(fog.far, 'fog is fully opaque before it begins').toBeGreaterThan(fog.near);
- });
-
- it('takes the colour the config authored', () => {
- expect(fogFor().color).toBe(SCENE_CONFIG.atmosphere.fogColor);
- });
-
- // #364 names these numbers as the thing the refactor must not move.
- it('fades from 80 m to 550 m', () => {
- expect(fogFor()).toMatchObject({ near: 80, far: 550 });
- });
-
- // Scenery is placed out to 240 m from the centreline (#321), and a fog that
- // closes before the far bank turns the world into a corridor.
- it('reaches past the furthest scenery', () => {
- expect(fogFor().far).toBeGreaterThan(300);
- });
-});
-
-describe('chunkViewDistanceFor', () => {
- it('stops drawing past the point it can be seen', () => {
- expect(chunkViewDistanceFor()).toBeGreaterThan(fogFor().far);
- });
-
- // Not much past it. The whole point is that the cut is hidden, and every
- // metre beyond the fog is geometry built, uploaded and drawn into opacity.
- it('does not draw far past it either', () => {
- expect(chunkViewDistanceFor()).toBeLessThan(fogFor().far * 1.5);
- });
-});
-
-/**
- * Issue #346 — fog is part of the weather now.
- *
- * Overcast pulls the far plane in to half, which is what actually reads as
- * overcast: the far bank going soft. `fogFor` takes the config so the preset
- * reaches the scene, and the chunk view distance follows it rather than
- * staying pinned to the authored figure and drawing chunks nobody can see.
- */
-describe('fog under the conditions presets', () => {
- const overcast = {
- ...SCENE_CONFIG,
- atmosphere: { ...SCENE_CONFIG.atmosphere, fogFar: SCENE_CONFIG.atmosphere.fogFar * 0.5 },
- };
-
- it('reads the config it is handed, not the authored one', () => {
- expect(fogFor(overcast).far).toBeCloseTo(SCENE_CONFIG.atmosphere.fogFar * 0.5, 6);
- });
-
- it('brings the chunks in with the fog', () => {
- expect(chunkViewDistanceFor(overcast)).toBeLessThan(chunkViewDistanceFor());
- });
-
- it('falls back to the authored config when handed none', () => {
- expect(fogFor().far).toBe(SCENE_CONFIG.atmosphere.fogFar);
- });
-});
diff --git a/src/__tests__/foliageComponents.test.tsx b/src/__tests__/foliageComponents.test.tsx
deleted file mode 100644
index 2c1644ea..00000000
--- a/src/__tests__/foliageComponents.test.tsx
+++ /dev/null
@@ -1,305 +0,0 @@
-import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest';
-import * as THREE from 'three';
-import ReactThreeTestRenderer from '@react-three/test-renderer';
-import { installCanvasMock } from './canvasMock';
-import { SCENE_CONFIG } from '../components/rower3d/themeConfig';
-
-/**
- * Issue #333 — a forest is a handful of draw calls.
- *
- * The bank trees were eleven meshes each, which is why there could only be
- * forty of them on the demo route. Each species is one `InstancedMesh` now, so
- * what is asserted is the thing that makes a planted bank affordable: the
- * number of meshes does not grow with the number of trees.
- *
- * The browser-side half of this - the renderer's own draw-call count with the
- * foliage on and off - is in `scenery-kit-budgets.spec.ts`.
- */
-vi.mock('@react-three/drei', async () => {
- const actual = await vi.importActual>('@react-three/drei');
- return { ...actual, Cloud: () => null };
-});
-
-const { BankFoliage, BANK_FOLIAGE_NAME, FOLIAGE_CULL_FRAME_INTERVAL } = await import(
- '../components/rower3d/foliageComponents'
-);
-const { CurvedLandscapeElements, LANDSCAPE_ELEMENT_NAME } = await import(
- '../components/rower3d/bankComponents'
-);
-const { AnimationProvider } = await import('../components/rower3d/AnimationContext');
-
-const straightRoute = (length: number) =>
- new THREE.CatmullRomCurve3([
- new THREE.Vector3(0, 0, 0),
- new THREE.Vector3(0, 0, -length / 2),
- new THREE.Vector3(0, 0, -length),
- ]);
-
-/** Duck-typed: the renderer's objects come from its own `three` instance. */
-const isInstanced = (o: THREE.Object3D): o is THREE.InstancedMesh =>
- (o as THREE.InstancedMesh).isInstancedMesh === true;
-
-const mount = async (element: React.ReactElement) => {
- const renderer = await ReactThreeTestRenderer.create({element});
- const all = () => {
- const found: THREE.Object3D[] = [];
- (renderer.scene.instance as unknown as THREE.Scene).traverse((o) => found.push(o));
- return found;
- };
- const foliage = () => all().filter(isInstanced).filter((m) => m.name.startsWith(`${BANK_FOLIAGE_NAME}:`));
- const tick = async (frames: number) => {
- await ReactThreeTestRenderer.act(async () => {
- await renderer.advanceFrames(frames, 1 / 60);
- });
- };
- return { renderer, all, foliage, tick };
-};
-
-describe('BankFoliage', () => {
- let uninstallCanvasMock: () => void;
- beforeAll(() => {
- uninstallCanvasMock = installCanvasMock();
- });
- afterAll(() => uninstallCanvasMock());
- afterEach(() => {
- delete window.__ROWER3D_SCENERY_CLEARANCE;
- delete window.__ROWER3D_FOLIAGE;
- });
-
- it('draws 400 trees and 4,000 with the same handful of meshes', async () => {
- const counts = [];
- for (const length of [4_000, 40_000]) {
- const scene = await mount();
- const meshes = scene.foliage();
- const trees = meshes.reduce((sum, m) => sum + m.instanceMatrix.count, 0);
- counts.push({ meshes: meshes.length, trees });
- await scene.renderer.unmount();
- }
-
- expect(counts[0].trees).toBeGreaterThanOrEqual(400);
- expect(counts[1].trees).toBeGreaterThan(counts[0].trees * 8);
- for (const { meshes } of counts) {
- expect(meshes).toBeGreaterThan(0);
- expect(meshes).toBeLessThanOrEqual(SCENE_CONFIG.trees.species.length);
- }
- });
-
- it('draws each species in its configured colour, cut out by a leaf texture', async () => {
- const scene = await mount();
-
- for (const mesh of scene.foliage()) {
- const material = mesh.material as THREE.MeshLambertMaterial;
- // Name is `BankFoliage::` since #497.
- const type = mesh.name.split(':')[1];
- const entry = SCENE_CONFIG.trees.species.find((s) => s.type === type)!;
- expect(`#${material.color.getHexString()}`).toBe(entry.color);
- expect(material.map).toBeTruthy();
- expect(material.alphaTest).toBeGreaterThanOrEqual(0.5);
- }
- await scene.renderer.unmount();
- });
-
- /**
- * A billboard casts no shadow (#352).
- *
- * Its facing and its sway are computed in a patched vertex shader, and three
- * casts shadows through its own `MeshDepthMaterial`, which does not carry
- * `onBeforeCompile` — so the shadow would be of an unrotated, unswaying quad
- * rather than of the tree that is drawn. It was set before #352 and did
- * nothing, because the shadow camera sat at the origin while the boat rowed
- * away; once the frustum followed the boat it drew 400 trees a second time
- * and doubled what the forest costs.
- */
- it('does not cast a shadow it cannot cast correctly', async () => {
- const scene = await mount();
-
- for (const mesh of scene.foliage()) {
- expect(mesh.castShadow, mesh.name).toBe(false);
- }
- await scene.renderer.unmount();
- });
-
- it('draws only the trees within sight of the boat, from the frame loop', async () => {
- const boat = { current: new THREE.Vector3(0, 0, -2000) };
- const scene = await mount(
- ,
- );
- const planted = scene.foliage().reduce((sum, m) => sum + m.count, 0);
-
- await scene.tick(FOLIAGE_CULL_FRAME_INTERVAL);
- const near = scene.foliage().reduce((sum, m) => sum + m.count, 0);
- expect(near).toBeGreaterThan(0);
- expect(near).toBeLessThan(planted / 4);
-
- // Rowed off the end of the route: nothing left in sight.
- boat.current.set(0, 0, -9000);
- await scene.tick(FOLIAGE_CULL_FRAME_INTERVAL);
- expect(scene.foliage().reduce((sum, m) => sum + m.count, 0)).toBe(0);
- await scene.renderer.unmount();
- });
-
- it('publishes how many trees it planted and how near the water the nearest stands', async () => {
- window.__PLAYWRIGHT_TESTING = true;
- try {
- const boat = { current: new THREE.Vector3(0, 0, -1000) };
- const scene = await mount(
- ,
- );
- await scene.tick(FOLIAGE_CULL_FRAME_INTERVAL);
-
- const published = window.__ROWER3D_FOLIAGE!;
- expect(published.trees).toBe(scene.foliage().reduce((sum, m) => sum + m.instanceMatrix.count, 0));
- expect(published.meshes).toBe(scene.foliage().length);
- expect(published.drawn).toBe(scene.foliage().reduce((sum, m) => sum + m.count, 0));
- expect(published.drawn).toBeLessThan(published.trees);
- const clearance = window.__ROWER3D_SCENERY_CLEARANCE?.foliage;
- expect(clearance?.count).toBe(published.trees);
- expect(clearance!.nearestM).toBeGreaterThanOrEqual(clearance!.marginM - 1e-6);
- await scene.renderer.unmount();
- } finally {
- delete window.__PLAYWRIGHT_TESTING;
- }
- });
-
- it('draws nothing without a route', async () => {
- const scene = await mount();
- expect(scene.foliage()).toEqual([]);
- await scene.renderer.unmount();
- });
-
- /**
- * #497 — chunked foliage with its own bounding spheres.
- *
- * The old mesh covered the whole route, so three could never skip it.
- * Chunked by progress, each `InstancedMesh` carries a sphere that encloses
- * just its own trees and three's own frustum test can short-circuit a
- * chunk when the camera is looking the other way.
- */
- describe('#497 chunked foliage', () => {
- it('names each mesh as BankFoliage::', async () => {
- const scene = await mount(
- ,
- );
- for (const mesh of scene.foliage()) {
- expect(mesh.name).toMatch(/^BankFoliage:[a-z0-9-]+:\d+$/);
- }
- await scene.renderer.unmount();
- });
-
- it('splits a long route into more meshes than there are species', async () => {
- const scene = await mount(
- ,
- );
- const meshes = scene.foliage();
- // 8 km route / 400 m chunks = 20 chunks of route; each one that holds
- // any trees becomes a mesh, so chunking means strictly more meshes than
- // the species count (five tree species in SCENE_CONFIG).
- expect(meshes.length).toBeGreaterThan(SCENE_CONFIG.trees.species.length);
- await scene.renderer.unmount();
- });
-
- it('sets a bounding sphere on each chunk that encloses only its own instances', async () => {
- const scene = await mount(
- ,
- );
- const meshes = scene.foliage();
- expect(meshes.length).toBeGreaterThan(0);
-
- for (const mesh of meshes) {
- expect(mesh.frustumCulled).toBe(true);
- expect(mesh.boundingSphere).toBeTruthy();
- const sphere = mesh.boundingSphere as THREE.Sphere;
- // Every instance's world-space origin sits inside the sphere.
- const matrix = new THREE.Matrix4();
- const origin = new THREE.Vector3();
- for (let i = 0; i < mesh.count; i += 1) {
- mesh.getMatrixAt(i, matrix);
- origin.setFromMatrixPosition(matrix);
- expect(sphere.containsPoint(origin), `${mesh.name} instance ${i}`).toBe(true);
- }
- }
- await scene.renderer.unmount();
- });
-
- it('keeps the total planted count the same with or without chunking', async () => {
- const unchunked = await mount(
- ,
- );
- const chunked = await mount(
- ,
- );
- const totalUnchunked = unchunked
- .foliage()
- .reduce((sum, m) => sum + m.instanceMatrix.count, 0);
- const totalChunked = chunked.foliage().reduce((sum, m) => sum + m.instanceMatrix.count, 0);
- // Same seed, same layout — chunking is a redistribution, not a reroll.
- expect(totalChunked).toBe(totalUnchunked);
- await unchunked.renderer.unmount();
- await chunked.renderer.unmount();
- });
- });
-});
-
-describe('the procedural landscape', () => {
- let uninstallCanvasMock: () => void;
- beforeAll(() => {
- uninstallCanvasMock = installCanvasMock();
- });
- afterAll(() => uninstallCanvasMock());
- afterEach(() => {
- delete window.__VIRTUALROW_FOLIAGE;
- });
-
- const landscape = (length = 6_000) => (
-
- );
-
- it('plants its trees as billboards, not as stacks of cones', async () => {
- const scene = await mount(landscape());
-
- expect(scene.foliage().length).toBeGreaterThan(0);
- const cones = scene
- .all()
- .filter((o) => (o as THREE.Mesh).isMesh && (o as THREE.Mesh).geometry.type === 'ConeGeometry');
- // Roofs and mountains are cones; a cone tree came with a cylinder trunk
- // and flared roots, and there are no cylinders left on the bank at all.
- const cylinders = scene
- .all()
- .filter((o) => (o as THREE.Mesh).isMesh && (o as THREE.Mesh).geometry.type === 'CylinderGeometry');
- expect(cylinders).toEqual([]);
- expect(cones.length).toBeGreaterThan(0);
- await scene.renderer.unmount();
- });
-
- it('mounts a house or mountain by where it stands, not by its index in the list', async () => {
- // A 6 km route mounted around its middle: with progress read from the
- // index (`index * 0.02 / 0.6`), the elements mounted were the fifteenth to
- // the thirtieth of each side's list - wherever those happened to be.
- const scene = await mount(landscape());
- const curve = straightRoute(6_000);
- const mountedZ = scene
- .all()
- .filter((o) => o.name === LANDSCAPE_ELEMENT_NAME)
- .map((o) => o.position.z);
-
- expect(mountedZ.length).toBeGreaterThan(0);
- const centre = curve.getPointAt(0.5).z;
- for (const z of mountedZ) {
- // Within the mount window, 0.15 of the route either side of 0.5.
- expect(Math.abs(z - centre)).toBeLessThanOrEqual(0.15 * 6_000 + 1);
- }
- await scene.renderer.unmount();
- });
-
- it('leaves the foliage out when a spec measures what it costs', async () => {
- window.__VIRTUALROW_FOLIAGE = false;
- const scene = await mount(landscape());
- expect(scene.foliage()).toEqual([]);
- await scene.renderer.unmount();
- });
-});
diff --git a/src/__tests__/foliageMaterial.test.ts b/src/__tests__/foliageMaterial.test.ts
deleted file mode 100644
index c53a4144..00000000
--- a/src/__tests__/foliageMaterial.test.ts
+++ /dev/null
@@ -1,101 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import * as THREE from 'three';
-import {
- FOLIAGE_SWAY_GLSL,
- makeFoliageBillboardMaterial,
- makeSwayFoliageMaterial,
-} from '../components/rower3d/foliageMaterial';
-
-/**
- * Issue #333 — the billboard foliage moves in the wind.
- *
- * What three compiles is the material's shader after `onBeforeCompile` has
- * edited it, so that is what is checked: the chunks three ships for a standard
- * material, handed through the hook exactly as the renderer hands them.
- */
-
-type CompiledShader = Parameters[0];
-
-const compile = (material: THREE.Material): CompiledShader => {
- const shader = {
- uniforms: THREE.UniformsUtils.clone(THREE.ShaderLib.standard.uniforms),
- vertexShader: THREE.ShaderLib.standard.vertexShader,
- fragmentShader: THREE.ShaderLib.standard.fragmentShader,
- } as CompiledShader;
- material.onBeforeCompile(shader, {} as THREE.WebGLRenderer);
- return shader;
-};
-
-describe('makeFoliageBillboardMaterial', () => {
- const uTime = { value: 0 };
- const map = new THREE.Texture();
- const material = makeFoliageBillboardMaterial({ map, color: '#3a6840' }, uTime);
-
- it('is a Lambert material cut out by its texture', () => {
- // Lambert, not PBR. A billboard's shading is painted into its texture, so
- // the physical model buys nothing visible - and it is what the forest
- // cost. Measured on the demo row at the low tier (#333, #391): windows
- // p50 frame 345 ms without foliage, 458 ms with standard foliage, 381 ms
- // with Lambert; ubuntu 228 / 288 / 241.
- expect(material).toBeInstanceOf(THREE.MeshLambertMaterial);
- expect(material).not.toBeInstanceOf(THREE.MeshStandardMaterial);
- expect(material.map).toBe(map);
- expect(material.alphaTest).toBeGreaterThanOrEqual(0.5);
- // Front faces only: the geometry is wound both ways, and a double-sided
- // material flips the normal of a face seen from behind (see
- // createFoliageGeometry), which blacked out every tree seen from its back.
- expect(material.side).toBe(THREE.FrontSide);
- // Cut out, not blended: a blended forest has to be sorted back to front,
- // and an instanced one cannot be.
- expect(material.transparent).toBe(false);
- expect(`#${material.color.getHexString()}`).toBe('#3a6840');
- });
-
- it('sways with uTime, more at the crown than at the root', () => {
- const shader = compile(material);
-
- expect(shader.vertexShader).toContain('uniform float uTime;');
- expect(shader.vertexShader).toContain(FOLIAGE_SWAY_GLSL);
- expect(FOLIAGE_SWAY_GLSL).toContain('uTime');
- // Scaled by uv.y, which is 0 at the ground: the trunk stays planted.
- expect(FOLIAGE_SWAY_GLSL).toMatch(/\* uv\.y/);
- // Phased by where the tree stands, so a bank does not sway in step.
- expect(FOLIAGE_SWAY_GLSL).toContain('instanceMatrix[3].x');
- // After three has set up `transformed`, and before it is projected.
- const sway = shader.vertexShader.indexOf(FOLIAGE_SWAY_GLSL);
- expect(sway).toBeGreaterThan(shader.vertexShader.indexOf('#include '));
- expect(sway).toBeLessThan(shader.vertexShader.indexOf('#include '));
- });
-
- it('shares the one clock it was given', () => {
- const shader = compile(material);
- expect(shader.uniforms.uTime).toBe(uTime);
- });
-
- it('tints each tree a little differently, from where it stands', () => {
- const shader = compile(material);
-
- expect(shader.vertexShader).toContain('vFoliageTint');
- expect(shader.fragmentShader).toContain('varying float vFoliageTint;');
- expect(shader.fragmentShader.indexOf('diffuseColor.rgb *= vFoliageTint;')).toBeGreaterThan(
- shader.fragmentShader.indexOf('#include '),
- );
- });
-
- it('still compiles when a mesh is not instanced', () => {
- // instanceMatrix only exists under USE_INSTANCING; outside it the sway and
- // the tint must fall back rather than reference an undeclared attribute.
- const shader = compile(material);
- expect(shader.vertexShader).toMatch(/#ifdef USE_INSTANCING[\s\S]*instanceMatrix\[3\][\s\S]*#else[\s\S]*#endif/);
- });
-});
-
-describe('makeSwayFoliageMaterial', () => {
- it('keeps swaying the cone pines of the flat scene', () => {
- const uTime = { value: 0 };
- const shader = compile(makeSwayFoliageMaterial({ color: '#224422' }, uTime));
-
- expect(shader.uniforms.uTime).toBe(uTime);
- expect(shader.vertexShader).toContain('swayAmt');
- });
-});
diff --git a/src/__tests__/foliagePlan.test.ts b/src/__tests__/foliagePlan.test.ts
deleted file mode 100644
index cc97c3f3..00000000
--- a/src/__tests__/foliagePlan.test.ts
+++ /dev/null
@@ -1,465 +0,0 @@
-import { describe, it, expect, afterEach } from 'vitest';
-import * as THREE from 'three';
-import {
- FOLIAGE_BAND_DEPTH_METRES,
- FOLIAGE_BUILDING_CLEARANCE_METRES,
- FOLIAGE_SHAPE_SIZE,
- FOLIAGE_SLOT_METRES,
- batchFoliage,
- batchFoliageChunked,
- compactVisible,
- createFoliageGeometry,
- foliageStanding,
- grassDensityStrengthForSegment,
- isFoliageEnabled,
- layoutFoliage,
- type FoliageTree,
-} from '../components/rower3d/foliagePlan';
-import { SCENERY_PROFILES } from '../components/rower3d/sceneryConfig';
-import { foliageShapeFor } from '../components/rower3d/foliageTexture';
-import { SCENE_CONFIG, type TreeSpeciesEntry } from '../components/rower3d/themeConfig';
-import {
- SCENERY_WATER_MARGIN_METRES,
- landClearance,
- nearestBankOffset,
-} from '../components/rower3d/sceneryClearance';
-import type { SceneryTrack } from '../components/rower3d/sceneryTrack';
-import type { RouteEnrichmentData, SceneryProfile } from '../lib/services/routeEnrichmentService';
-
-/**
- * Issue #333 — where the billboard trees stand.
- *
- * The cone trees were placed every 2 % of route *progress*, so how many a bank
- * got had nothing to do with how long it was: one slot per 135 m on the 6.7 km
- * demo, one tree per ~340 m of bank, and a hero shot of empty green. These are
- * placed in metres.
- */
-
-const SPECIES = SCENE_CONFIG.trees.species;
-
-const straight = (length: number) =>
- new THREE.CatmullRomCurve3([
- new THREE.Vector3(0, 0, -length / 2),
- new THREE.Vector3(0, 0, 0),
- new THREE.Vector3(0, 0, length / 2),
- ]);
-
-const water = (widthMeters: number, profile: SceneryProfile = 'fallback'): RouteEnrichmentData =>
- ({
- routeId: 'foliage',
- elevations: [],
- segmentProfiles: [0, 1, 2, 3].map((index) => ({
- index,
- startMeters: index * 50,
- endMeters: (index + 1) * 50,
- sceneryProfile: profile,
- treeDensity: 0.7,
- vegetationDensity: 0.4,
- buildingDensity: 0.3,
- objectScale: 1,
- waterWidthMeters: widthMeters,
- dragMultiplier: 1,
- bearing: 0,
- bearingDelta: 0,
- })),
- waterBodyType: 'river',
- waterWidthMeters: widthMeters,
- waterColor: '#000',
- waveIntensity: 0.5,
- fetchedAt: 0,
- source: 'fallback',
- }) as RouteEnrichmentData;
-
-const onlyProfile = (profile: SceneryProfile): SceneryTrack => ({
- bands: [{ untilProgress: 1, profile, water: 'river', label: profile }],
-});
-
-const lay = (curve: THREE.Curve, enrichment: RouteEnrichmentData | null, extra = {}) =>
- layoutFoliage({ curve, enrichment, species: SPECIES, ...extra });
-
-/** Mean gap along the route between consecutive trees on one bank, in metres. */
-const meanSpacing = (trees: FoliageTree[], length: number, side: 'left' | 'right') => {
- const bank = trees.filter((t) => (side === 'left' ? t.position[0] < 0 : t.position[0] > 0));
- return length / bank.length;
-};
-
-describe('layoutFoliage', () => {
- it('lines both banks with a tree every 12 to 30 m', () => {
- const length = 2000;
- const trees = lay(straight(length), water(40));
-
- for (const side of ['left', 'right'] as const) {
- const spacing = meanSpacing(trees, length, side);
- expect(spacing, `${side} bank`).toBeGreaterThanOrEqual(12);
- expect(spacing, `${side} bank`).toBeLessThanOrEqual(30);
- }
- });
-
- it('places by the metre, so a route twice as long has twice the trees', () => {
- const short = lay(straight(1500), water(40)).length;
- const long = lay(straight(3000), water(40)).length;
-
- expect(long / short).toBeGreaterThan(1.8);
- expect(long / short).toBeLessThan(2.2);
- });
-
- it('samples a slot every FOLIAGE_SLOT_METRES and runs the whole route', () => {
- const trees = lay(straight(1000), water(40));
- const progress = trees.map((t) => t.progress);
-
- expect(Math.min(...progress)).toBeLessThan(FOLIAGE_SLOT_METRES * 3 / 1000);
- expect(Math.max(...progress)).toBeGreaterThan(1 - (FOLIAGE_SLOT_METRES * 3) / 1000);
- expect(trees.length).toBeLessThanOrEqual((2 * 1000) / FOLIAGE_SLOT_METRES);
- });
-
- it('stands every trunk clear of the water, however wide it is', () => {
- for (const width of [20, 55, 120]) {
- const trees = lay(straight(1200), water(width));
- const reading = landClearance(foliageStanding(trees), straight(1200), water(width));
-
- expect(reading.count).toBe(trees.length);
- expect(reading.nearestM).toBeGreaterThanOrEqual(SCENERY_WATER_MARGIN_METRES - 1e-6);
- }
- });
-
- it('keeps clear of the water on a bend as well', () => {
- const bend = new THREE.CatmullRomCurve3([
- new THREE.Vector3(0, 0, -600),
- new THREE.Vector3(200, 0, 0),
- new THREE.Vector3(0, 0, 600),
- ]);
- const reading = landClearance(foliageStanding(lay(bend, water(120))), bend, water(120));
-
- expect(reading.nearestM).toBeGreaterThanOrEqual(SCENERY_WATER_MARGIN_METRES - 1e-6);
- });
-
- it('crowds the bank rather than spreading evenly across the band behind it', () => {
- const trees = lay(straight(3000), water(40));
- const floor = nearestBankOffset(20);
- const behind = trees.map((t) => Math.abs(t.position[0]) - floor);
-
- expect(Math.min(...behind)).toBeGreaterThanOrEqual(0);
- expect(Math.max(...behind)).toBeLessThanOrEqual(FOLIAGE_BAND_DEPTH_METRES + 1);
- // Most of the planting is in the front half of the band: a tree-lined bank.
- const front = behind.filter((d) => d < FOLIAGE_BAND_DEPTH_METRES / 2).length;
- expect(front / behind.length).toBeGreaterThan(0.6);
- });
-
- it('plants a forest denser than a town centre', () => {
- const forest = lay(straight(2000), water(40), { track: onlyProfile('forest') }).length;
- const town = lay(straight(2000), water(40), { track: onlyProfile('commercial') }).length;
-
- expect(forest).toBeGreaterThan(town * 1.5);
- });
-
- it('plants what the profile grows, in a colour the scene config gives it', () => {
- const species = (profile: SceneryProfile) =>
- new Set(lay(straight(2000), water(40), { track: onlyProfile(profile) }).map((t) => SPECIES[t.species].type));
-
- // Forest allows pine, oak and willow, all three of which the config colours.
- expect([...species('forest')].sort()).toEqual(['oak', 'pine', 'willow']);
- // Farmland grows no pines.
- expect(species('farmland').has('pine')).toBe(false);
- // A town centre grows only ornamentals, which the config has no colour
- // for: they are drawn as the configured species of the same shape.
- expect([...species('commercial')]).toEqual(['oak']);
- });
-
- it('falls back to every configured species when nothing matches the profile', () => {
- const palms: TreeSpeciesEntry[] = [{ type: 'palm', color: '#123', trunkColor: '#456' }];
- const trees = layoutFoliage({
- curve: straight(800),
- enrichment: water(40),
- species: palms,
- track: onlyProfile('forest'),
- });
- expect(trees.length).toBeGreaterThan(0);
- expect(new Set(trees.map((t) => t.species))).toEqual(new Set([0]));
- });
-
- it('grows each shape to a believable height', () => {
- for (const tree of lay(straight(2000), water(40), { track: onlyProfile('forest') })) {
- const shape = foliageShapeFor(SPECIES[tree.species].type);
- expect(tree.height).toBeGreaterThanOrEqual(FOLIAGE_SHAPE_SIZE[shape].height * 0.6);
- expect(tree.height).toBeLessThanOrEqual(FOLIAGE_SHAPE_SIZE[shape].height * 1.45);
- }
- });
-
- it('keeps out of the houses', () => {
- const avoid = [{ x: -30, z: 0 }, { x: 35, z: 200 }];
- const trees = lay(straight(1200), water(40), { avoid });
-
- for (const house of avoid) {
- const nearest = Math.min(
- ...trees.map((t) => Math.hypot(t.position[0] - house.x, t.position[2] - house.z)),
- );
- expect(nearest).toBeGreaterThanOrEqual(FOLIAGE_BUILDING_CLEARANCE_METRES);
- }
- });
-
- it('is deterministic, so a route looks the same on every visit', () => {
- expect(lay(straight(1200), water(40))).toEqual(lay(straight(1200), water(40)));
- });
-
- it('plants the bundled channel width when there is no enrichment', () => {
- expect(lay(straight(1200), null).length).toBeGreaterThan(50);
- });
-
- it('places nothing without a curve', () => {
- expect(layoutFoliage({ curve: null, species: SPECIES })).toEqual([]);
- });
-});
-
-describe('batchFoliage', () => {
- const trees: FoliageTree[] = [
- { position: [1, 2, 3], progress: 0.1, species: 0, height: 10, yaw: 0 },
- { position: [4, 5, 6], progress: 0.2, species: 1, height: 12, yaw: Math.PI / 2 },
- { position: [7, 8, 9], progress: 0.3, species: 0, height: 14, yaw: 0 },
- ];
-
- it('gives each species one batch of matrices, one per tree', () => {
- const batches = batchFoliage(trees, 3);
-
- expect(batches.map((b) => b.count)).toEqual([2, 1, 0]);
- expect(batches[0].matrices.length).toBe(2 * 16);
- expect(batches[0].centres).toEqual(new Float32Array([1, 3, 7, 9]));
- });
-
- it('stands each tree where it was placed, as tall as it was grown', () => {
- const [first] = batchFoliage(trees, 2);
- const matrix = new THREE.Matrix4().fromArray(first.matrices, 16);
- const position = new THREE.Vector3();
- const scale = new THREE.Vector3();
- matrix.decompose(position, new THREE.Quaternion(), scale);
-
- expect(position.toArray()).toEqual([7, 8, 9]);
- expect(scale.x).toBeCloseTo(14, 5);
- expect(scale.y).toBeCloseTo(14, 5);
- });
-});
-
-describe('compactVisible', () => {
- const trees: FoliageTree[] = [0, 100, 200, 300].map((z, i) => ({
- position: [0, 0, z],
- progress: i / 4,
- species: 0,
- height: 10 + i,
- yaw: 0,
- }));
- const [batch] = batchFoliage(trees, 1);
-
- it('packs the trees within range to the front, in order, and counts them', () => {
- const target = new Float32Array(batch.matrices.length);
- const count = compactVisible(batch, new THREE.Vector3(0, 0, 250), 160, target);
-
- expect(count).toBe(3);
- expect(Array.from(target.subarray(0, 48))).toEqual(Array.from(batch.matrices.subarray(16, 64)));
- });
-
- it('draws nothing when the boat is far from every tree', () => {
- const target = new Float32Array(batch.matrices.length);
- expect(compactVisible(batch, new THREE.Vector3(5000, 0, 0), 100, target)).toBe(0);
- });
-});
-
-describe('createFoliageGeometry', () => {
- const geometry = createFoliageGeometry(0.8);
- const positions = geometry.getAttribute('position');
-
- it('is two quads crossed at right angles: eight corners, four triangles, each wound both ways', () => {
- expect(positions.count).toBe(8);
- const index = Array.from(geometry.getIndex()!.array);
- expect(index.length).toBe(24);
- // Every triangle has its mirror, so the quad is seen from both sides by a
- // front-face material, and its normal is never flipped into the ground.
- const key = (tri: number[]) => [...tri].sort().join(',');
- const winding = (tri: number[]) => {
- const start = tri.indexOf(Math.min(...tri));
- return [0, 1, 2].map((k) => tri[(start + k) % 3]).join(',');
- };
- const triangles = Array.from({ length: 8 }, (_, i) => index.slice(i * 3, i * 3 + 3));
- for (const tri of triangles.slice(0, 4)) {
- const mirror = triangles.slice(4).find((other) => key(other) === key(tri));
- expect(mirror, `triangle ${tri} has no back face`).toBeDefined();
- expect(winding(mirror!)).not.toBe(winding(tri));
- }
- const xs = Array.from({ length: 8 }, (_, i) => positions.getX(i));
- const zs = Array.from({ length: 8 }, (_, i) => positions.getZ(i));
- // One quad in x, one in z, each `aspect` wide about the trunk.
- expect(Math.max(...xs) - Math.min(...xs)).toBeCloseTo(0.8, 6);
- expect(Math.max(...zs) - Math.min(...zs)).toBeCloseTo(0.8, 6);
- });
-
- it('stands on the ground and is one unit tall, so the instance scale is its height', () => {
- const ys = Array.from({ length: 8 }, (_, i) => positions.getY(i));
- expect(Math.min(...ys)).toBe(0);
- expect(Math.max(...ys)).toBe(1);
- });
-
- it('maps the bottom of the texture to the ground, which is where the sway is zero', () => {
- const uv = geometry.getAttribute('uv');
- for (let i = 0; i < 8; i += 1) expect(uv.getY(i)).toBe(positions.getY(i));
- });
-
- it('lights the crown as a rounded mass rather than as two flat cards', () => {
- const normals = geometry.getAttribute('normal');
- for (let i = 0; i < 8; i += 1) {
- const n = new THREE.Vector3(normals.getX(i), normals.getY(i), normals.getZ(i));
- expect(n.length()).toBeCloseTo(1, 5);
- // Up and out from the trunk: never facing the ground.
- expect(n.y).toBeGreaterThan(0);
- }
- });
-});
-
-describe('isFoliageEnabled', () => {
- afterEach(() => {
- delete window.__VIRTUALROW_FOLIAGE;
- });
-
- it('is on unless a spec turns it off to measure what it costs', () => {
- expect(isFoliageEnabled()).toBe(true);
- window.__VIRTUALROW_FOLIAGE = false;
- expect(isFoliageEnabled()).toBe(false);
- });
-});
-
-/**
- * #497 — one `InstancedMesh` per (species, chunk), each with a bounding
- * sphere that encloses its own trees. Three's own frustum test can skip a
- * chunk when the camera is looking the other way, and the per-frame CPU
- * cull packs instances within a chunk the same way as before.
- */
-describe('batchFoliageChunked', () => {
- const speciesCount = 2;
- const treesSpread = (): FoliageTree[] => {
- // Ten trees, species 0 and 1, spread along progress 0..1.
- const trees: FoliageTree[] = [];
- for (let i = 0; i < 10; i += 1) {
- const progress = i / 9;
- trees.push({
- position: [progress * 1_000, 0, (i % 2 === 0 ? -1 : 1) * 50],
- progress,
- species: i % speciesCount,
- height: 12,
- yaw: 0,
- });
- }
- return trees;
- };
-
- it('one chunk per species when the chunk is as long as the route', () => {
- const trees = treesSpread();
- const chunks = batchFoliageChunked(trees, speciesCount, 1_000, 1_000);
- expect(chunks).toHaveLength(speciesCount);
- chunks.forEach((chunk, i) => {
- expect(chunk.species).toBe(i);
- expect(chunk.chunkIndex).toBe(0);
- });
- });
-
- it('splits a long route into chunks, dropping empty (species, chunk) pairs', () => {
- const trees = treesSpread();
- const chunks = batchFoliageChunked(trees, speciesCount, 1_000, 200);
- expect(chunks.length).toBeGreaterThan(speciesCount);
- for (const chunk of chunks) {
- expect(chunk.count).toBeGreaterThan(0);
- // Every matrix slot is filled.
- expect(chunk.matrices.length).toBe(chunk.count * 16);
- expect(chunk.centres.length).toBe(chunk.count * 2);
- }
- // Total trees across chunks matches input.
- const total = chunks.reduce((s, c) => s + c.count, 0);
- expect(total).toBe(trees.length);
- });
-
- it('each chunk only holds trees from one (species, chunkIndex) pair', () => {
- const trees = treesSpread();
- const chunkCount = 5; // 1000 m / 200 m
- const chunks = batchFoliageChunked(trees, speciesCount, 1_000, 200);
- for (const chunk of chunks) {
- // Every tree in this chunk originally has the right species and the
- // right progress band.
- const own = trees.filter(
- (t) =>
- t.species === chunk.species &&
- Math.min(chunkCount - 1, Math.floor(t.progress * chunkCount)) === chunk.chunkIndex,
- );
- expect(chunk.count).toBe(own.length);
- }
- });
-
- it('each chunk bounding sphere encloses every one of its tree positions', () => {
- const trees = treesSpread();
- const chunks = batchFoliageChunked(trees, speciesCount, 1_000, 200);
- for (const chunk of chunks) {
- for (let i = 0; i < chunk.count; i += 1) {
- const x = chunk.centres[i * 2];
- const z = chunk.centres[i * 2 + 1];
- const dx = x - chunk.boundingSphere.x;
- const dz = z - chunk.boundingSphere.z;
- const distance = Math.hypot(dx, dz);
- expect(distance).toBeLessThanOrEqual(chunk.boundingSphere.radius + 1e-6);
- }
- }
- });
-
- it('is a redistribution: the total matches the unchunked batch', () => {
- const trees = treesSpread();
- const chunks = batchFoliageChunked(trees, speciesCount, 1_000, 200);
- const unchunked = batchFoliage(trees, speciesCount);
- const totalChunked = chunks.reduce((s, c) => s + c.count, 0);
- const totalUnchunked = unchunked.reduce((s, b) => s + b.count, 0);
- expect(totalChunked).toBe(totalUnchunked);
- expect(totalUnchunked).toBe(trees.length);
- });
-
- it('is empty for an empty tree list', () => {
- expect(batchFoliageChunked([], speciesCount, 1_000, 200)).toEqual([]);
- });
-});
-
-/**
- * #496 Phase 2 AC5 — the bank's shader-grass density per segment is the
- * biome's own `groundCover.density`, so the shader-painted cover and the
- * trees above it agree on how lush the segment is.
- */
-describe('grassDensityStrengthForSegment', () => {
- const biomeAt = (profile: SceneryProfile): RouteEnrichmentData =>
- ({
- segmentProfiles: [
- {
- sceneryProfile: profile,
- startDistance: 0,
- endDistance: 1000,
- },
- ],
- }) as unknown as RouteEnrichmentData;
-
- it('is the biome\'s `groundCover.density` for the nearest segment', () => {
- for (const profile of ['forest', 'wetland', 'commercial', 'beach'] as const) {
- expect(grassDensityStrengthForSegment(biomeAt(profile), 0.5)).toBe(
- SCENERY_PROFILES[profile].groundCover.density,
- );
- }
- });
-
- it('falls back to the fallback biome\'s density when enrichment is missing', () => {
- expect(grassDensityStrengthForSegment(null, 0.5)).toBe(
- SCENERY_PROFILES.fallback.groundCover.density,
- );
- expect(grassDensityStrengthForSegment(undefined, 0.5)).toBe(
- SCENERY_PROFILES.fallback.groundCover.density,
- );
- });
-
- it('clamps out-of-range progress to the nearest end rather than throwing', () => {
- const enrichment = biomeAt('wetland');
- expect(grassDensityStrengthForSegment(enrichment, -2)).toBe(
- SCENERY_PROFILES.wetland.groundCover.density,
- );
- expect(grassDensityStrengthForSegment(enrichment, 2)).toBe(
- SCENERY_PROFILES.wetland.groundCover.density,
- );
- });
-});
diff --git a/src/__tests__/foliageTexture.test.ts b/src/__tests__/foliageTexture.test.ts
deleted file mode 100644
index 0c6fbd75..00000000
--- a/src/__tests__/foliageTexture.test.ts
+++ /dev/null
@@ -1,140 +0,0 @@
-import { describe, it, expect, beforeAll, afterAll } from 'vitest';
-import * as THREE from 'three';
-import { installCanvasMock } from './canvasMock';
-import {
- FOLIAGE_SHAPES,
- createFoliageTexture,
- foliageShapeFor,
- paintFoliage,
- type FoliageShape,
-} from '../components/rower3d/foliageTexture';
-
-/**
- * Issue #333 — the leaf mass on a foliage billboard.
- *
- * The trees were stacked cones, and the kit's were spheres on sticks. A crossed
- * billboard is only as good as the silhouette painted on it, so what is
- * checked is the silhouette: how much of the square it fills, where, and that
- * it is the same every visit.
- */
-
-const SIZE = 128;
-
-/** Share of a set of rows whose alpha would survive the material's alphaTest. */
-const coverage = (pixels: Uint8ClampedArray, size: number, fromRow = 0, toRow = size): number => {
- let solid = 0;
- for (let y = fromRow; y < toRow; y += 1) {
- for (let x = 0; x < size; x += 1) {
- if (pixels[(y * size + x) * 4 + 3] >= 128) solid += 1;
- }
- }
- return solid / ((toRow - fromRow) * size);
-};
-
-/** Mean absolute luma step between horizontal neighbours inside the canopy. */
-const leafDetail = (pixels: Uint8ClampedArray, size: number): number => {
- let total = 0;
- let pairs = 0;
- for (let y = 0; y < size; y += 1) {
- for (let x = 0; x + 1 < size; x += 1) {
- const i = (y * size + x) * 4;
- if (pixels[i + 3] < 128 || pixels[i + 7] < 128) continue;
- total += Math.abs(pixels[i + 1] - pixels[i + 5]);
- pairs += 1;
- }
- }
- return pairs ? total / pairs : 0;
-};
-
-describe('paintFoliage', () => {
- it.each(FOLIAGE_SHAPES)('paints a %s that fills between a fifth and three fifths of its square', (shape) => {
- const pixels = paintFoliage(shape, SIZE);
-
- expect(pixels.length).toBe(SIZE * SIZE * 4);
- const covered = coverage(pixels, SIZE);
- expect(covered).toBeGreaterThan(0.2);
- expect(covered).toBeLessThan(0.6);
- });
-
- it('narrows a conifer toward its top', () => {
- const pixels = paintFoliage('conifer', SIZE);
- const quarter = SIZE / 4;
-
- const top = coverage(pixels, SIZE, 0, quarter);
- const lowerCrown = coverage(pixels, SIZE, quarter * 2, quarter * 3);
-
- expect(top).toBeLessThan(lowerCrown * 0.6);
- });
-
- it('keeps a trunk under every crown, where it meets the ground', () => {
- for (const shape of FOLIAGE_SHAPES) {
- const pixels = paintFoliage(shape, SIZE);
- const bottomCentre = ((SIZE - 1) * SIZE + SIZE / 2) * 4;
- expect(pixels[bottomCentre + 3], `${shape} floats off the ground`).toBe(255);
- // And nothing at the bottom corners: a tree, not a hedge.
- expect(pixels[(SIZE - 1) * SIZE * 4 + 3]).toBe(0);
- }
- });
-
- it('shades the leaf mass rather than filling it flat', () => {
- // Flat white under a species colour is a cut-out. The dappling is what the
- // eye reads as leaves, and what the hero's horizontal-structure gate sees.
- for (const shape of FOLIAGE_SHAPES) {
- expect(leafDetail(paintFoliage(shape, SIZE), SIZE), shape).toBeGreaterThan(4);
- }
- });
-
- it('draws each shape its own way', () => {
- const broadleaf = paintFoliage('broadleaf', SIZE);
- const willow = paintFoliage('willow', SIZE);
- expect(Array.from(broadleaf)).not.toEqual(Array.from(willow));
- });
-
- it('is deterministic, so a tree looks the same on every visit', () => {
- for (const shape of FOLIAGE_SHAPES) {
- expect(Array.from(paintFoliage(shape, 64))).toEqual(Array.from(paintFoliage(shape, 64)));
- }
- });
-});
-
-describe('createFoliageTexture', () => {
- let uninstall: () => void;
- beforeAll(() => {
- uninstall = installCanvasMock();
- });
- afterAll(() => uninstall());
-
- it.each(FOLIAGE_SHAPES)('builds a size-by-size sRGB texture for a %s', (shape: FoliageShape) => {
- const texture = createFoliageTexture(shape, SIZE);
-
- expect(texture).toBeInstanceOf(THREE.CanvasTexture);
- const canvas = texture.image as HTMLCanvasElement;
- expect(canvas.width).toBe(SIZE);
- expect(canvas.height).toBe(SIZE);
- expect(texture.colorSpace).toBe(THREE.SRGBColorSpace);
- texture.dispose();
- });
-
- it('defaults to 256 pixels square', () => {
- const texture = createFoliageTexture('broadleaf');
- expect((texture.image as HTMLCanvasElement).width).toBe(256);
- texture.dispose();
- });
-});
-
-describe('foliageShapeFor', () => {
- it('draws pines and cypresses as conifers', () => {
- expect(foliageShapeFor('pine')).toBe('conifer');
- expect(foliageShapeFor('cypress')).toBe('conifer');
- });
-
- it('draws a willow as a willow', () => {
- expect(foliageShapeFor('willow')).toBe('willow');
- });
-
- it('draws every other species as a broadleaf', () => {
- for (const type of ['oak', 'ornamental', 'palm', 'bare'] as const) {
- expect(foliageShapeFor(type)).toBe('broadleaf');
- }
- });
-});
diff --git a/src/__tests__/followBoat.test.tsx b/src/__tests__/followBoat.test.tsx
deleted file mode 100644
index b2df4cb4..00000000
--- a/src/__tests__/followBoat.test.tsx
+++ /dev/null
@@ -1,163 +0,0 @@
-import { describe, it, expect, vi } from 'vitest';
-import { useRef } from 'react';
-import * as THREE from 'three';
-import ReactThreeTestRenderer from '@react-three/test-renderer';
-
-/**
- * Issue #331 — the tiled bands ride the boat without a render.
- *
- * The terrain, the pines, the ground cover and the village are laid out
- * relative to the boat, so each is a group whose position is the boat's. That
- * position used to arrive as a `boatZ` prop pushed from `setSceneryState` ten
- * times a second, and every push re-rendered hundreds of instances to move one
- * group. A group's position is exactly what a frame loop should be writing.
- *
- * The cases that matter are the ones a scene actually hits: a follower mounted
- * before the first frame has produced a boat, and a surface that travels along
- * the route without taking the route's height with it.
- */
-vi.mock('@react-three/drei', async () => {
- const actual = await vi.importActual>('@react-three/drei');
- return { ...actual, Cloud: () => null };
-});
-
-const { useFollowPoint, useFollowZ, useVector3Ref } = await import(
- '../components/rower3d/followBoat'
-);
-const { AnimationProvider } = await import('../components/rower3d/AnimationContext');
-
-const FOLLOWER = 'Follower';
-
-/** A group that follows `followRef`, mounted at `authored`. */
-const Follower: React.FC<{
- followRef?: React.RefObject;
- authored: [number, number, number];
- zOnly?: boolean;
-}> = ({ followRef, authored, zOnly = false }) => {
- const groupRef = useRef(null);
- // Both hooks are called so the rules of hooks hold; only one is given a
- // target, which is how a component picks the follow it wants.
- useFollowPoint(groupRef, zOnly ? undefined : followRef);
- useFollowZ(groupRef, zOnly ? followRef : undefined);
- return ;
-};
-
-const mount = async (element: React.ReactElement) => {
- const renderer = await ReactThreeTestRenderer.create(
- {element},
- );
- const follower = () => {
- let found: THREE.Object3D | undefined;
- (renderer.scene.instance as unknown as THREE.Scene).traverse((object) => {
- if (object.name === FOLLOWER) found = object;
- });
- return found!;
- };
- const tick = async () => {
- await ReactThreeTestRenderer.act(async () => {
- await renderer.advanceFrames(2, 1 / 60);
- });
- };
- return { renderer, follower, tick };
-};
-
-describe('useFollowPoint', () => {
- it('puts the group where the boat is', async () => {
- const boat = { current: new THREE.Vector3(120, 3, -900) };
- const scene = await mount();
-
- await scene.tick();
-
- expect(scene.follower().position.toArray()).toEqual([120, 3, -900]);
- await scene.renderer.unmount();
- });
-
- it('keeps up as the boat moves, without a re-render', async () => {
- const boat = { current: new THREE.Vector3(0, 0, 0) };
- const scene = await mount();
-
- await scene.tick();
- boat.current.set(0, 1.5, -400);
- await scene.tick();
-
- expect(scene.follower().position.z).toBe(-400);
- expect(scene.follower().position.y).toBe(1.5);
- await scene.renderer.unmount();
- });
-
- // The scene mounts before the first frame has run, so a follower can be
- // asked where to go before there is anywhere to go. The origin is not an
- // answer — it is a real place on the route, and snapping to it would park
- // the scenery on the start line for as long as it lasted.
- it('stays where it was authored while there is no boat to follow', async () => {
- const scene = await mount(
- ,
- );
-
- await scene.tick();
-
- expect(scene.follower().position.toArray()).toEqual([7, 8, 9]);
- await scene.renderer.unmount();
- });
-
- it('stays put when it was given nothing to follow at all', async () => {
- const scene = await mount();
-
- await scene.tick();
-
- expect(scene.follower().position.toArray()).toEqual([7, 8, 9]);
- await scene.renderer.unmount();
- });
-});
-
-describe('useFollowZ', () => {
- // The flat water and the reflection plane travel along the route at a height
- // of their own. Copying the whole vector would drop them to the follow
- // point's Y, which is the relief under the boat and not where water lives.
- it('travels along the route without taking the route height with it', async () => {
- const boat = { current: new THREE.Vector3(120, 3, -900) };
- const scene = await mount(
- ,
- );
-
- await scene.tick();
-
- const { x, y, z } = scene.follower().position;
- expect(z).toBe(-900);
- expect(y, 'the water sank to the height of the ground under the boat').toBe(-0.1);
- expect(x, 'the water slid sideways with the boat').toBe(0);
- await scene.renderer.unmount();
- });
-
- it('stays where it was authored while there is no boat to follow', async () => {
- const scene = await mount(
- ,
- );
-
- await scene.tick();
-
- expect(scene.follower().position.z).toBe(5);
- await scene.renderer.unmount();
- });
-});
-
-describe('useVector3Ref', () => {
- it('hands back the same vector across renders rather than a new one each time', async () => {
- const seen: Array = [];
- const Probe: React.FC<{ tag: number }> = () => {
- seen.push(useVector3Ref().current);
- return ;
- };
-
- const renderer = await ReactThreeTestRenderer.create();
- await renderer.update();
-
- expect(seen.length, 'the probe rendered only once').toBeGreaterThan(1);
- expect(seen[0], 'no vector was allocated').toBeInstanceOf(THREE.Vector3);
- expect(seen[1], 'a fresh vector every render is the allocation this avoids').toBe(
- seen[0],
- );
-
- await renderer.unmount();
- });
-});
diff --git a/src/__tests__/frameHotPath.test.ts b/src/__tests__/frameHotPath.test.ts
deleted file mode 100644
index 2a2deb54..00000000
--- a/src/__tests__/frameHotPath.test.ts
+++ /dev/null
@@ -1,138 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import * as THREE from 'three';
-import type { Coordinate } from '../lib/types/index';
-import {
- createRouteCurve,
- distanceToProgress,
- getCurveDistances,
- getRoutePositionAtProgress,
-} from '../components/rower3d/curve';
-import { SCENE_SCALE } from '../components/rower3d/constants';
-import { isChunkWithinViewDistance } from '../components/rower3d/geometryChunks';
-import { createWaterChannelGeometry } from '../components/rower3d/waterGeometry';
-import { chunkProgressRanges } from '../components/rower3d/geometryChunks';
-
-/**
- * The per-frame cost #224 set out to bound.
- *
- * The sustained-frame-rate criterion cannot be honestly measured in Playwright:
- * the scene draws through SwiftShader there and, as `virtualrow.spec.ts`
- * already notes, the animation does not reliably advance under headless
- * automation at all — a 5,000-point route renders about two frames a minute,
- * so any fps number would describe the rasteriser. What *is* deterministic is
- * the CPU work the scene does every frame, which is what route simplification
- * and the distance table were optimised for. That is measured here.
- */
-
-const METERS_PER_DEGREE_LAT = 111_195;
-const ORIGIN_LAT = 51.45;
-const LNG_SCALE = METERS_PER_DEGREE_LAT * Math.cos((ORIGIN_LAT * Math.PI) / 180);
-
-const windingRoute = (points: number, lengthMeters: number, bends: number): Coordinate[] =>
- Array.from({ length: points }, (_, i) => {
- const t = i / (points - 1);
- return {
- lat: ORIGIN_LAT + (t * lengthMeters) / METERS_PER_DEGREE_LAT,
- lng: (Math.sin(t * bends * 2 * Math.PI) * 60) / LNG_SCALE,
- };
- });
-
-/** One frame of the scene's route work: where is the boat, and what is visible. */
-const runFrames = (frames: number) => {
- const track = windingRoute(5000, 8000, 12);
- const curve = createRouteCurve(track, SCENE_SCALE)!;
- const distances = getCurveDistances(curve);
- const curveLength = distances[distances.length - 1];
- const totalDistance = 8000;
-
- const chunkBounds = chunkProgressRanges(6).map((range) => {
- const geometry = createWaterChannelGeometry(curve, { range });
- return geometry.boundingSphere;
- });
-
- const position = new THREE.Vector3();
- const tangent = new THREE.Vector3();
- const camera = new THREE.Vector3();
-
- const started = performance.now();
- for (let frame = 0; frame < frames; frame++) {
- const metres = (frame / frames) * totalDistance;
- const progress = distanceToProgress(metres, totalDistance, distances, curveLength);
- getRoutePositionAtProgress(curve, progress, position, tangent);
- camera.copy(position).addScaledVector(tangent, -6);
- for (const bounds of chunkBounds) isChunkWithinViewDistance(bounds, camera);
- }
- return (performance.now() - started) / frames;
-};
-
-describe('per-frame route work on a 5,000-point winding route (#224)', () => {
- /**
- * A 60 fps frame is 16.7 ms and the scene must also physics-tick, animate and
- * draw in it. A millisecond is a generous ceiling for positioning the boat
- * and culling chunks, and roomy enough not to flake on a loaded machine.
- */
- const PER_FRAME_BUDGET_MS = 1;
-
- it('positions the boat and culls the chunks inside its budget', () => {
- runFrames(200); // warm the JIT and the curve's arc-length table
- expect(runFrames(600)).toBeLessThan(PER_FRAME_BUDGET_MS);
- });
-
- it('costs the same at the end of a route as at the start', () => {
- // A linear scan of the distance table would make late frames dearer than
- // early ones; the binary search in `distanceToProgress` must not.
- const track = windingRoute(5000, 8000, 12);
- const curve = createRouteCurve(track, SCENE_SCALE)!;
- const distances = getCurveDistances(curve);
- const curveLength = distances[distances.length - 1];
- const position = new THREE.Vector3();
- const tangent = new THREE.Vector3();
-
- const sampleAt = (progressBase: number) => {
- for (let i = 0; i < 2000; i++) {
- distanceToProgress(progressBase * 8000, 8000, distances, curveLength);
- }
- const started = performance.now();
- for (let i = 0; i < 20_000; i++) {
- const p = distanceToProgress(progressBase * 8000, 8000, distances, curveLength);
- getRoutePositionAtProgress(curve, p, position, tangent);
- }
- return performance.now() - started;
- };
-
- // Interleaved, and compared on the median of several rounds.
- //
- // The property is algorithmic — a binary search does not care where in the
- // table it lands — but the measurement is a wall clock on a shared CI
- // runner, where one garbage collection inside the second sample is enough
- // to make a constant-time lookup look linear. Taking early and late
- // alternately puts any such pause in front of both, and the median throws
- // out the round it lands in. Measured on macOS as 18 ms against 91 ms from
- // a single pair, which is the shape of a GC pause and not of a linear scan.
- const rounds = 5;
- const earlies: number[] = [];
- const lates: number[] = [];
- for (let round = 0; round < rounds; round += 1) {
- earlies.push(sampleAt(0.02));
- lates.push(sampleAt(0.98));
- }
-
- const median = (times: number[]) =>
- [...times].sort((a, b) => a - b)[Math.floor(times.length / 2)];
-
- expect(median(lates)).toBeLessThan(median(earlies) * 3 + 5);
- });
-
- it('allocates no vectors per frame', () => {
- const curve = createRouteCurve(windingRoute(1000, 3000, 6), SCENE_SCALE)!;
- const position = new THREE.Vector3();
- const tangent = new THREE.Vector3();
-
- const first = getRoutePositionAtProgress(curve, 0.1, position, tangent);
- const second = getRoutePositionAtProgress(curve, 0.9, position, tangent);
-
- expect(first.position).toBe(position);
- expect(second.position).toBe(position);
- expect(first.tangent).toBe(tangent);
- });
-});
diff --git a/src/__tests__/frameStats.test.ts b/src/__tests__/frameStats.test.ts
deleted file mode 100644
index 8a9681cd..00000000
--- a/src/__tests__/frameStats.test.ts
+++ /dev/null
@@ -1,116 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import {
- createFrameStatsRecorder,
- percentileMs,
- FRAME_STATS_WINDOW_SECONDS,
-} from '../components/rower3d/frameStats';
-
-/** Feed `count` frames of `deltaSeconds` each into a recorder. */
-const feed = (
- recorder: ReturnType,
- count: number,
- deltaSeconds: number,
-) => {
- for (let i = 0; i < count; i++) recorder.record(deltaSeconds);
-};
-
-describe('percentileMs', () => {
- it('returns 0 for an empty sample', () => {
- expect(percentileMs([], 95)).toBe(0);
- });
-
- it('reads the worst frames at the top of the distribution', () => {
- const samples = [10, 10, 10, 10, 10, 10, 10, 10, 10, 40];
- expect(percentileMs(samples, 50)).toBe(10);
- expect(percentileMs(samples, 95)).toBe(40);
- });
-
- it('does not mutate the caller\u2019s array', () => {
- const samples = [30, 10, 20];
- percentileMs(samples, 50);
- expect(samples).toEqual([30, 10, 20]);
- });
-
- it('clamps a percentile outside 0..100', () => {
- const samples = [5, 15, 25];
- expect(percentileMs(samples, -10)).toBe(5);
- expect(percentileMs(samples, 200)).toBe(25);
- });
-});
-
-describe('frame stats recorder', () => {
- it('reports nothing until a frame has been seen', () => {
- expect(createFrameStatsRecorder().read()).toBeNull();
- });
-
- it('turns frame deltas into a frame rate', () => {
- const recorder = createFrameStatsRecorder();
- feed(recorder, 120, 1 / 60);
-
- const stats = recorder.read()!;
- expect(stats.frames).toBe(120);
- expect(stats.fps).toBeCloseTo(60, 0);
- expect(stats.p50Ms).toBeCloseTo(16.67, 1);
- expect(stats.p95Ms).toBeCloseTo(16.67, 1);
- });
-
- it('surfaces the slow frames a mean would hide', () => {
- const recorder = createFrameStatsRecorder();
- feed(recorder, 90, 1 / 120);
- feed(recorder, 10, 0.05); // a tenth of the frames stall for 50 ms
-
- const stats = recorder.read()!;
- expect(stats.p50Ms).toBeCloseTo(8.33, 1);
- expect(stats.p95Ms).toBeGreaterThan(40);
- });
-
- it('keeps only the trailing window, so an early stall stops counting', () => {
- const recorder = createFrameStatsRecorder();
- feed(recorder, 10, 0.2); // 2 s of very slow frames
- feed(recorder, 700, 1 / 60); // then ~11.7 s of good ones, past the window
-
- const stats = recorder.read()!;
- expect(stats.windowSeconds).toBeLessThanOrEqual(FRAME_STATS_WINDOW_SECONDS + 0.5);
- expect(stats.p95Ms).toBeCloseTo(16.67, 1);
- });
-
- it('ignores a delta that is not a usable frame time', () => {
- const recorder = createFrameStatsRecorder();
- recorder.record(0);
- recorder.record(-1);
- recorder.record(Number.NaN);
- expect(recorder.read()).toBeNull();
-
- recorder.record(1 / 60);
- expect(recorder.read()!.frames).toBe(1);
- });
-
- it('counts a genuinely slow frame rather than discarding it', () => {
- // A software rasteriser can take over a second on a frame. That is the
- // worst case the metric exists to surface, so it must not be filtered out.
- const recorder = createFrameStatsRecorder();
- feed(recorder, 20, 1.5);
-
- const stats = recorder.read()!;
- expect(stats.frames).toBeGreaterThan(0);
- expect(stats.maxMs).toBeCloseTo(1500, 0);
- expect(stats.fps).toBeLessThan(1);
- });
-
- it('ignores a delta no frame could plausibly have taken', () => {
- const recorder = createFrameStatsRecorder();
- feed(recorder, 60, 1 / 60);
- recorder.record(120);
-
- const stats = recorder.read()!;
- expect(stats.frames).toBe(60);
- expect(stats.p95Ms).toBeCloseTo(16.67, 1);
- });
-
- it('forgets everything when the route changes', () => {
- const recorder = createFrameStatsRecorder();
- feed(recorder, 30, 0.05);
- recorder.reset();
- expect(recorder.read()).toBeNull();
- });
-});
diff --git a/src/__tests__/geometryChunks.test.ts b/src/__tests__/geometryChunks.test.ts
deleted file mode 100644
index 6a62c4d8..00000000
--- a/src/__tests__/geometryChunks.test.ts
+++ /dev/null
@@ -1,89 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import * as THREE from 'three';
-import {
- CHUNK_VIEW_DISTANCE_SCENE_UNITS,
- MAX_GEOMETRY_CHUNKS,
- chunkCountForRoute,
- chunkProgressRanges,
- chunkSegmentCount,
- isChunkWithinViewDistance,
-} from '../components/rower3d/geometryChunks';
-
-describe('chunkCountForRoute', () => {
- it('leaves a short course as a single chunk', () => {
- expect(chunkCountForRoute(500)).toBe(1);
- expect(chunkCountForRoute(1500)).toBe(1);
- });
-
- it('splits a long course so distant water can be skipped', () => {
- expect(chunkCountForRoute(9000)).toBe(6);
- });
-
- it('caps the count so a marathon does not become a draw-call storm', () => {
- expect(chunkCountForRoute(100_000)).toBe(MAX_GEOMETRY_CHUNKS);
- expect(MAX_GEOMETRY_CHUNKS).toBe(16);
- });
-
- it('treats a degenerate length as one chunk', () => {
- expect(chunkCountForRoute(0)).toBe(1);
- expect(chunkCountForRoute(Number.NaN)).toBe(1);
- });
-});
-
-describe('chunkProgressRanges', () => {
- it('covers the whole curve exactly once', () => {
- const ranges = chunkProgressRanges(4);
- expect(ranges).toHaveLength(4);
- expect(ranges[0].from).toBe(0);
- expect(ranges[3].to).toBe(1);
- });
-
- it('hands adjacent chunks the identical boundary, so strips meet without a seam', () => {
- const ranges = chunkProgressRanges(7);
- ranges.slice(1).forEach((range, i) => {
- expect(range.from).toBe(ranges[i].to);
- });
- });
-
- it('returns the whole curve for a single chunk', () => {
- expect(chunkProgressRanges(1)).toEqual([{ from: 0, to: 1 }]);
- expect(chunkProgressRanges(0)).toEqual([{ from: 0, to: 1 }]);
- });
-});
-
-describe('chunkSegmentCount', () => {
- it('keeps the resolution a whole-route build would have used', () => {
- expect(chunkSegmentCount(800, { from: 0, to: 0.25 })).toBe(200);
- });
-
- it('never drops below a drawable single segment', () => {
- expect(chunkSegmentCount(50, { from: 0, to: 0.001 })).toBe(1);
- });
-});
-
-describe('isChunkWithinViewDistance', () => {
- const bounds = (x: number, radius: number) =>
- new THREE.Sphere(new THREE.Vector3(x, 0, 0), radius);
-
- it('keeps the chunk the boat is sitting in', () => {
- expect(isChunkWithinViewDistance(bounds(0, 50), new THREE.Vector3(0, 0, 0))).toBe(true);
- });
-
- it('drops a chunk whose nearest edge is past the fog', () => {
- const farAway = CHUNK_VIEW_DISTANCE_SCENE_UNITS + 200;
- expect(
- isChunkWithinViewDistance(bounds(farAway, 50), new THREE.Vector3(0, 0, 0)),
- ).toBe(false);
- });
-
- it('measures from the chunk edge, not its centre', () => {
- const centre = CHUNK_VIEW_DISTANCE_SCENE_UNITS + 100;
- expect(
- isChunkWithinViewDistance(bounds(centre, 300), new THREE.Vector3(0, 0, 0)),
- ).toBe(true);
- });
-
- it('keeps a chunk whose bounds were never computed', () => {
- expect(isChunkWithinViewDistance(null, new THREE.Vector3(0, 0, 0))).toBe(true);
- });
-});
diff --git a/src/__tests__/gerstnerNormals.test.ts b/src/__tests__/gerstnerNormals.test.ts
deleted file mode 100644
index 84997af6..00000000
--- a/src/__tests__/gerstnerNormals.test.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import * as THREE from 'three';
-import { attachGerstnerShader } from '../components/rower3d/helpers';
-
-/**
- * The wave normal has to point along the axis the wave displaces (#298).
- *
- * The shader supports two geometries: a rotated PlaneGeometry, where height is
- * local Z, and the curved channel, where height is local Y. It built the same
- * Z-up normal for both — `vec3(-wGrad.x, -wGrad.y, 1.0)` — so on the curved
- * river, which is the one a route actually uses, the wave normals pointed
- * downstream instead of up and the water was lit as though its waves lay on
- * their side.
- *
- * Never seen by a spec, because the wave shader is skipped under automation.
- */
-const compiledVertexShader = (axis: 'y' | 'z'): string => {
- const material = new THREE.MeshStandardMaterial();
- attachGerstnerShader(material, { value: 0 }, axis, `test-${axis}`);
-
- const shader = {
- uniforms: {} as Record,
- vertexShader: '#include \n#include \n',
- fragmentShader: '',
- };
- // Asserted rather than optional-chained: whether the hook is installed at
- // all used to be its own test in rower3d.helpers.test.ts, which this
- // subsumes - calling it and reading what it produced covers both.
- expect(typeof material.onBeforeCompile, 'no shader hook was installed').toBe('function');
- material.onBeforeCompile!(
- shader as unknown as THREE.WebGLProgramParametersWithUniforms,
- {} as THREE.WebGLRenderer,
- );
- return shader.vertexShader;
-};
-
-describe('Gerstner wave normals follow the height axis (#298)', () => {
- it('points the normal up the Y axis on the curved channel', () => {
- const source = compiledVertexShader('y');
-
- // Height is local Y, so the gradient belongs in X and Z and the 1.0 in Y.
- expect(source).toContain('vec3(-wGrad.x, 1.0, -wGrad.y)');
- });
-
- it('points the normal up the Z axis on the rotated plane', () => {
- const source = compiledVertexShader('z');
-
- expect(source).toContain('vec3(-wGrad.x, -wGrad.y, 1.0)');
- });
-
- it('displaces along the same axis it points the normal', () => {
- // The two have to agree, which is the mistake being fixed: they did not.
- expect(compiledVertexShader('y')).toContain('position.y + wH');
- expect(compiledVertexShader('z')).toContain('position.z + wH');
- });
-});
diff --git a/src/__tests__/ghostBoat.test.tsx b/src/__tests__/ghostBoat.test.tsx
deleted file mode 100644
index f8cbb49f..00000000
--- a/src/__tests__/ghostBoat.test.tsx
+++ /dev/null
@@ -1,117 +0,0 @@
-import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
-import * as THREE from 'three';
-import ReactThreeTestRenderer from '@react-three/test-renderer';
-import { installCanvasMock } from './canvasMock';
-
-vi.hoisted(() => {
- // The procedural scull, which loads nothing. The GLB path is the same group,
- // and the placement under test is the group's (`boatComponents.test.tsx`).
- window.__PLAYWRIGHT_TESTING = true;
-});
-
-const { GhostBoat, GHOST_GROUP_NAME } = await import('../components/rower3d/GhostBoat');
-const { paceGhost, GHOST_LANE_OFFSET_M } = await import('../components/rower3d/ghost');
-
-/**
- * Issue #338 — the ghost, on the water.
- *
- * `ghost.test.ts` covers where the ghost should be at a given second. This is
- * that arriving in the scene: the frame loop that turns a distance into a
- * point on the route, a lane beside the centreline, and a heading.
- */
-
-/** A straight 1 km route along +Z, so a distance is easy to reason about. */
-const straightRoute = () =>
- new THREE.CatmullRomCurve3([
- new THREE.Vector3(0, 0, 0),
- new THREE.Vector3(0, 0, 500),
- new THREE.Vector3(0, 0, 1000),
- ]);
-
-let uninstallCanvas: () => void;
-
-beforeAll(() => {
- uninstallCanvas = installCanvasMock();
-});
-
-afterAll(() => {
- uninstallCanvas();
-});
-
-const mountGhost = async (elapsedSeconds: number, playing = true) => {
- const curve = straightRoute();
- const elapsedSecondsRef = { current: elapsedSeconds };
- const length = curve.getLength();
-
- const renderer = await ReactThreeTestRenderer.create(
- ,
- );
-
- await renderer.advanceFrames(2, 1 / 60);
- const group = renderer.scene.findByProps({ name: GHOST_GROUP_NAME });
- return { renderer, group, elapsedSecondsRef };
-};
-
-describe('the ghost boat in the scene', () => {
- it('is placed down the route by the time that has passed', async () => {
- const early = await mountGhost(12); // ~50 m
- const late = await mountGhost(120); // ~500 m
-
- expect(late.group.instance.position.z).toBeGreaterThan(
- early.group.instance.position.z + 100,
- );
- });
-
- // Two boats on one line is one boat with z-fighting.
- it('rows its own lane, beside the centreline', async () => {
- const { group } = await mountGhost(60);
-
- expect(Math.abs(group.instance.position.x)).toBeCloseTo(GHOST_LANE_OFFSET_M, 1);
- });
-
- /**
- * On the line at the gun, give or take a frame.
- *
- * Not exactly zero: a playing ghost fills the gap between the erg's packets
- * on the frame clock, so two frames in it has rowed two frames' worth. What
- * matters is that it has not been placed somewhere down the course.
- */
- it('starts on the line, like everything else does', async () => {
- const { group } = await mountGhost(0);
-
- expect(group.instance.position.z).toBeLessThan(1);
- expect(group.instance.position.z).toBeGreaterThanOrEqual(0);
- });
-
- // The ghost runs on the row's clock, so a paused row is a paused ghost -
- // otherwise a rower who stopped for a drink would come back to a race lost.
- it('does not row on while the row is stopped', async () => {
- const { renderer, group } = await mountGhost(30, false);
- const before = group.instance.position.z;
-
- await renderer.advanceFrames(60, 1 / 60);
-
- expect(group.instance.position.z).toBeCloseTo(before, 3);
- });
-
- /**
- * Between the erg's packets the ghost runs on the frame clock, so it does
- * not step down the course a length at a time (`followClock`).
- */
- it('keeps moving between the erg’s packets', async () => {
- const { renderer, group } = await mountGhost(30, true);
- const before = group.instance.position.z;
-
- await renderer.advanceFrames(30, 1 / 60);
-
- expect(group.instance.position.z).toBeGreaterThan(before);
- });
-});
diff --git a/src/__tests__/ghostPicker.test.tsx b/src/__tests__/ghostPicker.test.tsx
deleted file mode 100644
index 48bb6838..00000000
--- a/src/__tests__/ghostPicker.test.tsx
+++ /dev/null
@@ -1,141 +0,0 @@
-import { describe, it, expect, vi } from 'vitest';
-import { render, screen } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { GhostPicker, type GhostChoice } from '../components/GhostPicker';
-import { parsePaceInput } from '../lib/utils/paceInput';
-
-/**
- * Issue #338 — deciding what to race, before the row starts.
- *
- * The choice belongs on the route panel rather than mid-row: a rower with a
- * handle in each hand cannot set a target pace, and a ghost that appeared after
- * the start would be racing from behind through no fault of anyone's.
- */
-
-const alone: GhostChoice = { kind: 'none' };
-
-const picker = (over: Partial> = {}) => {
- const onChange = vi.fn();
- render();
- return onChange;
-};
-
-describe('the ghost picker', () => {
- it('offers rowing alone, racing your best, and a pace boat', () => {
- picker();
-
- expect(screen.getByRole('radio', { name: /row alone/i })).toBeInTheDocument();
- expect(screen.getByRole('radio', { name: /my best/i })).toBeInTheDocument();
- expect(screen.getByRole('radio', { name: /pace boat/i })).toBeInTheDocument();
- });
-
- it('starts on rowing alone, which is what the app did before', () => {
- picker();
-
- expect(screen.getByRole('radio', { name: /row alone/i })).toBeChecked();
- });
-
- it('says what pace the best it would race was rowed at', () => {
- picker();
-
- expect(screen.getByRole('radio', { name: /my best/i })).toHaveAccessibleName(/2:05/);
- });
-
- /**
- * A first row on a route has nothing to race.
- *
- * Disabled with the reason on it rather than hidden: an option that vanishes
- * looks like a feature that is not there, and the rower has done nothing
- * wrong - they simply have not rowed here before.
- */
- it('cannot race a best that does not exist, and says why', () => {
- picker({ hasBest: false, bestPace: null });
-
- const best = screen.getByRole('radio', { name: /my best/i });
- expect(best).toBeDisabled();
- expect(screen.getByText(/first row on this route/i)).toBeInTheDocument();
- });
-
- it('hands back the choice the rower made', async () => {
- const onChange = picker();
-
- await userEvent.click(screen.getByRole('radio', { name: /my best/i }));
-
- expect(onChange).toHaveBeenCalledWith({ kind: 'best' });
- });
-
- it('asks for a pace only once a pace boat is what is wanted', async () => {
- const onChange = picker();
-
- expect(screen.queryByLabelText(/target pace/i)).toBeNull();
-
- await userEvent.click(screen.getByRole('radio', { name: /pace boat/i }));
-
- expect(onChange).toHaveBeenCalledWith({ kind: 'pace', paceSPer500: expect.any(Number) });
- });
-
- it('takes a target pace as a rower says it', async () => {
- const onChange = vi.fn();
- render(
- ,
- );
-
- const field = screen.getByLabelText(/target pace/i);
- await userEvent.clear(field);
- await userEvent.type(field, '2:05');
-
- expect(onChange).toHaveBeenLastCalledWith({ kind: 'pace', paceSPer500: 125 });
- });
-
- // A half-typed "2:" is not a pace, and a boat that jumped to 2 s/500 m the
- // moment the colon was typed would be gone over the horizon.
- it('leaves the boat where it is while a pace is half typed', async () => {
- const onChange = vi.fn();
- render(
- ,
- );
-
- const field = screen.getByLabelText(/target pace/i);
- await userEvent.clear(field);
- await userEvent.type(field, '2:');
-
- expect(onChange).not.toHaveBeenCalledWith({ kind: 'pace', paceSPer500: 2 });
- });
-});
-
-describe('reading a pace a rower typed', () => {
- it('reads mm:ss', () => {
- expect(parsePaceInput('2:05')).toBe(125);
- expect(parsePaceInput('1:45')).toBe(105);
- });
-
- it('tolerates the spaces and the stray unit a rower types', () => {
- expect(parsePaceInput(' 2:05 ')).toBe(125);
- });
-
- it('refuses what is not a pace', () => {
- expect(parsePaceInput('')).toBeNull();
- expect(parsePaceInput('2:')).toBeNull();
- expect(parsePaceInput('abc')).toBeNull();
- expect(parsePaceInput('2:75')).toBeNull();
- });
-
- /**
- * A pace boat at 0:30/500 m is 16 m/s — a speedboat. At 9:59 it is slower
- * than the current. Neither is a rower, and both come from a typo.
- */
- it('refuses a pace no boat rows', () => {
- expect(parsePaceInput('0:30')).toBeNull();
- expect(parsePaceInput('9:30')).toBeNull();
- });
-});
diff --git a/src/__tests__/ghostVerdict.test.tsx b/src/__tests__/ghostVerdict.test.tsx
deleted file mode 100644
index fc586be2..00000000
--- a/src/__tests__/ghostVerdict.test.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import { render, screen } from '@testing-library/react';
-import { GhostVerdict } from '../components/GhostVerdict';
-
-/**
- * Issue #338 — how the race came out, on the summary.
- *
- * The rower watched a gap all the way down the course; the summary is where
- * it is settled. The pace comparison #337 puts here answers "was this a good
- * row"; this answers "did I beat the thing I was chasing", which is the
- * question the row was actually rowed to.
- */
-
-describe('the ghost verdict', () => {
- it('says the race was won, and by how much', () => {
- render();
-
- expect(screen.getByRole('status')).toHaveTextContent(/beat your best by 0:04/i);
- });
-
- it('says it was lost, without dressing it up', () => {
- render();
-
- expect(screen.getByRole('status')).toHaveTextContent(/your best was 0:06 quicker/i);
- });
-
- // Colour is the encouragement, never the message (#344).
- it('does not leave the result to a colour', () => {
- const { container } = render(
- ,
- );
-
- expect(container.querySelector('[data-result="won"]')).not.toBeNull();
- expect(screen.getByText(/beat/i)).toBeInTheDocument();
- });
-
- it('calls a second either way a dead heat', () => {
- render();
-
- expect(screen.getByRole('status')).toHaveTextContent(/dead heat/i);
- });
-
- it('names what was being chased, whatever it was', () => {
- render();
-
- expect(screen.getByRole('status')).toHaveTextContent(/a 2:00 pace/i);
- });
-
- /**
- * A best over 2 km held up against a 5 km route never reached the line, so
- * there is no time at which it got there and nothing to compare. Saying so
- * beats inventing a finish by extrapolating a pace nobody rowed.
- */
- it('says the ghost never got there rather than inventing a time', () => {
- render();
-
- expect(screen.getByRole('status')).toHaveTextContent(/did not reach/i);
- });
-
- it('says nothing at all about a row that raced nothing', () => {
- const { container } = render(
- ,
- );
-
- expect(container).toBeEmptyDOMElement();
- });
-});
diff --git a/src/__tests__/glContext.test.ts b/src/__tests__/glContext.test.ts
deleted file mode 100644
index 277b37be..00000000
--- a/src/__tests__/glContext.test.ts
+++ /dev/null
@@ -1,396 +0,0 @@
-import { describe, it, expect, beforeEach, vi } from 'vitest';
-import {
- probeRenderCapabilities,
- selectBrowserGlOptions,
- resetProbeCacheForTests,
- selectGlOptions,
- readContextState,
- recordContextCreated,
- recordContextLost,
- recordContextRestored,
- clearContextState,
- scheduleContextRestore,
- RESTORE_DELAYS_MS,
- type ContextAttempt,
-} from '../components/rower3d/glContext';
-
-// Both caches here outlive a test: the probe answers and the configuration the
-// driver granted. Cleared before every test in the file rather than per
-// describe, because a test answered from another's cache opens no context and
-// then reports a count of zero for a reason that is not visible in the failure.
-beforeEach(() => {
- resetProbeCacheForTests();
- clearContextState();
-});
-
-/** The preferences the high and low tiers bring to the probe. */
-const HIGH = { preferred: { powerPreference: 'high-performance' as const, antialias: true } };
-const LOW = { preferred: { powerPreference: 'low-power' as const, antialias: false } };
-
-/** An attempt that succeeds only for the configurations named. */
-const attemptAllowing = (
- allowed: Array<{ powerPreference?: string; antialias?: boolean }>,
- reason = 'context creation failed',
-): ContextAttempt => {
- const tried: WebGLContextAttributes[] = [];
- const attempt: ContextAttempt = (options) => {
- tried.push(options);
- const ok = allowed.some(
- (a) =>
- (a.powerPreference === undefined || a.powerPreference === options.powerPreference) &&
- (a.antialias === undefined || a.antialias === options.antialias),
- );
- return ok ? { ok: true } : { ok: false, reason };
- };
- return Object.assign(attempt, { tried });
-};
-
-describe('selectGlOptions', () => {
- it('keeps the discrete GPU and antialiasing when the driver takes them', () => {
- const selection = selectGlOptions(attemptAllowing([{}]), HIGH);
-
- expect(selection).toMatchObject({ powerPreference: 'high-performance', antialias: true });
- expect(selection.fallbackReason).toBeUndefined();
- });
-
- it('falls back to the default adapter when the preferred one will not open', () => {
- // The hybrid-laptop case: asking for high-performance routes to a discrete
- // GPU whose driver refuses the context.
- const attempt = attemptAllowing([{ powerPreference: 'default' }], 'eglCreateContext failed');
-
- const selection = selectGlOptions(attempt, HIGH);
-
- expect(selection).toMatchObject({ powerPreference: 'default', antialias: true });
- expect(selection.fallbackReason).toContain('eglCreateContext failed');
- });
-
- it('gives up multisampling before it gives up rendering', () => {
- const attempt = attemptAllowing([{ antialias: false }], 'out of memory');
-
- const selection = selectGlOptions(attempt, HIGH);
-
- expect(selection.antialias).toBe(false);
- expect(selection.fallbackReason).toContain('out of memory');
- });
-
- it('never trades away resolution to get a context', () => {
- const selection = selectGlOptions(attemptAllowing([{ antialias: false }]), HIGH);
-
- // dpr is the caller's business and nothing here should propose changing it.
- expect(selection).not.toHaveProperty('dpr');
- });
-
- it('reports that nothing worked rather than pretending', () => {
- const selection = selectGlOptions(attemptAllowing([], 'no adapters'), HIGH);
-
- expect(selection.usable).toBe(false);
- expect(selection.fallbackReason).toContain('no adapters');
- });
-
- it('asks for the low-power adapter when the scene is in low quality', () => {
- const attempt = attemptAllowing([{}]);
-
- const selection = selectGlOptions(attempt, LOW);
-
- expect(selection.powerPreference).toBe('low-power');
- expect(selection.antialias).toBe(false);
- });
-
- it('tries the preferred configuration first', () => {
- const attempt = attemptAllowing([{ powerPreference: 'default' }]);
-
- selectGlOptions(attempt, HIGH);
-
- expect((attempt as unknown as { tried: WebGLContextAttributes[] }).tried[0]).toMatchObject({
- powerPreference: 'high-performance',
- antialias: true,
- });
- });
-});
-
-describe('context state', () => {
- beforeEach(() => clearContextState());
-
- it('knows nothing before a canvas has been created', () => {
- expect(readContextState()).toBeNull();
- });
-
- it('records how the context was obtained', () => {
- recordContextCreated({
- powerPreference: 'default',
- antialias: true,
- maxDpr: 1.5,
- fallbackReason: 'refused',
- });
-
- expect(readContextState()).toMatchObject({
- powerPreference: 'default',
- lost: false,
- maxDpr: 1.5,
- fallbackReason: 'refused',
- });
- });
-
- it('remembers a loss, which is what a blank canvas needs to explain itself', () => {
- recordContextCreated({ powerPreference: 'high-performance', antialias: true });
-
- recordContextLost('GPU process exited');
-
- expect(readContextState()).toMatchObject({ lost: true, lostReason: 'GPU process exited' });
- });
-
- it('counts restore attempts, so a loop of them is visible', () => {
- recordContextCreated({ powerPreference: 'default', antialias: false });
- recordContextLost();
- recordContextLost();
-
- expect(readContextState()?.losses).toBe(2);
- });
-
- it('clears the loss when the context comes back', () => {
- recordContextCreated({ powerPreference: 'default', antialias: false });
- recordContextLost();
-
- recordContextRestored();
-
- expect(readContextState()).toMatchObject({ lost: false, losses: 1 });
- });
-});
-
-describe('scheduleContextRestore', () => {
- /** Collects the callbacks a scheduler was handed, so they can be run by hand. */
- const fakeScheduler = () => {
- const queued: Array<{ fn: () => void; delay: number }> = [];
- const schedule = (fn: () => void, delay: number) => {
- queued.push({ fn, delay });
- return 0 as unknown as ReturnType;
- };
- return { queued, schedule };
- };
-
- it('does not ask during the lost event, which the spec ignores', () => {
- const { queued, schedule } = fakeScheduler();
- let restores = 0;
-
- scheduleContextRestore(() => (restores += 1), () => true, schedule);
-
- expect(restores).toBe(0);
- expect(queued[0].delay).toBe(RESTORE_DELAYS_MS[0]);
- });
-
- it('keeps asking, with more patience each time', () => {
- const { queued, schedule } = fakeScheduler();
-
- scheduleContextRestore(() => {}, () => true, schedule);
- queued[0].fn();
- queued[1].fn();
-
- expect(queued.map((q) => q.delay)).toEqual(RESTORE_DELAYS_MS.slice(0, 3));
- });
-
- it('stops once the context is back', () => {
- const { queued, schedule } = fakeScheduler();
- let lost = true;
- let restores = 0;
-
- scheduleContextRestore(
- () => {
- restores += 1;
- lost = false;
- },
- () => lost,
- schedule,
- );
- queued[0].fn();
-
- expect(restores).toBe(1);
- // Nothing further is queued: the scene is drawing again.
- expect(queued).toHaveLength(1);
- });
-
- it('gives up rather than retrying for ever', () => {
- const { queued, schedule } = fakeScheduler();
-
- scheduleContextRestore(() => {}, () => true, schedule);
- for (let i = 0; i < 10 && queued[i]; i += 1) queued[i].fn();
-
- expect(queued).toHaveLength(RESTORE_DELAYS_MS.length);
- });
-
- /**
- * Giving up quietly is what the rower saw (#309).
- *
- * Probed on the demo row: the context was lost, the three attempts ran out
- * at about 5.8 s, and the stage went on saying "restoring..." for the next
- * sixteen seconds and would have said it for ever. The schedule knows when
- * it has finished and was the only thing that did.
- */
- it('says when it has stopped asking', () => {
- const { queued, schedule } = fakeScheduler();
- let exhausted = 0;
-
- scheduleContextRestore(() => {}, () => true, schedule, () => (exhausted += 1));
- for (let i = 0; i < 10 && queued[i]; i += 1) queued[i].fn();
-
- expect(exhausted, 'the scene was never told the asking had stopped').toBe(1);
- });
-
- it('does not say so while it is still asking', () => {
- const { queued, schedule } = fakeScheduler();
- let exhausted = 0;
-
- scheduleContextRestore(() => {}, () => true, schedule, () => (exhausted += 1));
- queued[0].fn();
-
- expect(exhausted).toBe(0);
- });
-
- it('does not say so when the context came back', () => {
- const { queued, schedule } = fakeScheduler();
- let lost = true;
- let exhausted = 0;
-
- scheduleContextRestore(
- () => { lost = false; },
- () => lost,
- schedule,
- () => (exhausted += 1),
- );
- for (let i = 0; i < 10 && queued[i]; i += 1) queued[i].fn();
-
- expect(exhausted, 'a recovered scene was told the asking had failed').toBe(0);
- });
-
- it('works without being given anywhere to report it', () => {
- // The callback is optional, and every existing call-site omits it.
- const { queued, schedule } = fakeScheduler();
-
- expect(() => {
- scheduleContextRestore(() => {}, () => true, schedule);
- for (let i = 0; i < 10 && queued[i]; i += 1) queued[i].fn();
- }).not.toThrow();
- });
-});
-
-describe('device probes are asked once (#261)', () => {
- /**
- * Probing is a property of the machine, not of a component instance, and
- * every probe opens a throwaway WebGL context. Measured on the demo row,
- * probeRenderCapabilities ran four times and selectGlOptions four more —
- * eleven contexts for one scene, against a browser that keeps only a handful
- * alive. Caching the answer is what stops the churn.
- */
- beforeEach(() => {
- resetProbeCacheForTests();
- });
-
- it('probes the device once however often it is asked', () => {
- let contexts = 0;
- const realCreate = document.createElement.bind(document);
- vi.spyOn(document, 'createElement').mockImplementation(((tag: string) => {
- if (tag !== 'canvas') return realCreate(tag);
- contexts += 1;
- return {
- getContext: () => ({
- getParameter: () => 4096,
- getExtension: () => ({ loseContext: () => {} }),
- }),
- } as unknown as HTMLCanvasElement;
- }) as typeof document.createElement);
-
- try {
- const first = probeRenderCapabilities();
- const second = probeRenderCapabilities();
- const third = probeRenderCapabilities();
-
- expect(contexts, 'the device was probed more than once').toBe(1);
- expect(second).toBe(first);
- expect(third).toBe(first);
- } finally {
- vi.restoreAllMocks();
- }
- });
-
-});
-
-describe('a live scene context is never risked on another probe (#context-loss)', () => {
- /**
- * The selection cache is keyed on what was asked for, so a settings change
- * mid-session asked the driver a *new* question — and every question opens a
- * throwaway context. A browser keeps only a handful alive and evicts the
- * oldest, which by then is the scene's own: measured on the demo row, the
- * scene's context was created first at ~1.4s, a probe opened a new one at
- * ~3.7s, and the scene lost its context at ~5.2s and showed 'The 3D view
- * lost the graphics context - restoring...' over the river.
- *
- * Once a context is live the question is already answered for this page, so
- * the granted selection is reused rather than re-probed.
- */
-
- const countingCanvas = () => {
- let contexts = 0;
- const realCreate = document.createElement.bind(document);
- vi.spyOn(document, 'createElement').mockImplementation(((tag: string) => {
- if (tag !== 'canvas') return realCreate(tag);
- contexts += 1;
- return {
- addEventListener: () => {},
- removeEventListener: () => {},
- getContext: () => ({
- getParameter: () => 4096,
- getExtension: () => ({ loseContext: () => {} }),
- }),
- } as unknown as HTMLCanvasElement;
- }) as typeof document.createElement);
- return () => contexts;
- };
-
- it('opens no new context for a different request while the scene is drawing', () => {
- const contexts = countingCanvas();
- try {
- const granted = selectBrowserGlOptions({ powerPreference: 'high-performance', antialias: true });
- expect(granted.usable).toBe(true);
- const afterFirst = contexts();
- expect(afterFirst).toBeGreaterThan(0);
-
- // The scene is now drawing into a real context.
- recordContextCreated({ powerPreference: granted.powerPreference, antialias: granted.antialias });
-
- // A settings change asks a different question. It must not cost a context.
- const later = selectBrowserGlOptions({ powerPreference: 'default', antialias: false });
-
- expect(contexts(), 'a probe context was opened while the scene was drawing').toBe(afterFirst);
- expect(later.usable, 'the reused selection must still be usable').toBe(true);
-
- // And it must still be the surface that was asked for.
- //
- // Reuse used to hand back whatever the driver granted last, for whatever
- // tier asked - so a low-power, no-antialias request received the
- // high-performance adapter and MSAA that a previous request had been
- // given. canvasSurface.ts exists precisely so a tier cannot acquire a
- // setting it has no business asking for, and Rower3D feeds these
- // straight into