- 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
} bare={
procedural
}>
-
physics
- ,
- );
-
- expect(screen.getByText('physics')).toBeTruthy();
- });
-
- it('shows the rich fallback when only the guarded subtree fails', () => {
- // Physics failing is no reason to lose the GLB boat.
- render(
- rich} bare={
procedural
}>
-
- ,
- );
-
- expect(screen.getByText('rich')).toBeTruthy();
- });
-
- it('degrades to the bare fallback when the fallback throws the same error', () => {
- // The real case: the rich fallback renders the same GLB component as the
- // guarded subtree. A rejected crew GLB throws in the subtree, the boundary
- // renders the fallback, and the fallback throws the identical error. With
- // one level of fallback that escapes the boundary and unmounts the scene.
- render(
- } bare={
procedural
}>
-
- ,
- );
-
- expect(screen.getByText('procedural')).toBeTruthy();
- });
-
- it('does not rethrow past itself when every level fails', () => {
- // Nothing renders, but the Canvas above stays mounted rather than the whole
- // scene disappearing.
- expect(() =>
- render(
- } bare={}>
-
- ,
- ),
- ).not.toThrow();
- });
-});
diff --git a/src/__tests__/sceneExposure.test.ts b/src/__tests__/sceneExposure.test.ts
deleted file mode 100644
index 01c22b6a..00000000
--- a/src/__tests__/sceneExposure.test.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import { sceneExposure } from '../components/rower3d/sceneExposure';
-import { SCENE_CONFIG } from '../components/rower3d/themeConfig';
-
-/**
- * The config authors a sky exposure and nothing read it: the canvas hardcoded
- * toneMappingExposure to 1.0. drei's Sky outputs high dynamic range, so at 1.0
- * the sky renders white — and the reflective water with it, which is why 79% of
- * a rendered frame came back near-white and the channel could not be told from
- * the bank (#269).
- */
-describe('sceneExposure', () => {
- it('uses the exposure the config authored', () => {
- expect(sceneExposure(0)).toBeCloseTo(SCENE_CONFIG.sky.exposure, 5);
- });
-
- it('never returns the hardcoded 1.0 when the config asked for less', () => {
- // The specific regression: a sky authored at 0.55 rendered at 1.0.
- expect(SCENE_CONFIG.sky.exposure).toBeLessThan(1);
- expect(sceneExposure(0)).toBeLessThan(1);
- });
-
- it('darkens a little at speed, relative to the config rather than to 1.0', () => {
- const still = sceneExposure(0);
- const fast = sceneExposure(6);
-
- expect(fast).toBeLessThan(still);
- expect(fast).toBeGreaterThan(still * 0.5);
- });
-
- it('is a usable exposure', () => {
- const value = sceneExposure(0);
- expect(Number.isFinite(value)).toBe(true);
- expect(value).toBeGreaterThan(0.05);
- expect(value).toBeLessThanOrEqual(1.5);
- });
-
- it('survives a velocity that is not a number', () => {
- expect(Number.isFinite(sceneExposure(Number.NaN))).toBe(true);
- });
-});
diff --git a/src/__tests__/sceneFreeze.test.ts b/src/__tests__/sceneFreeze.test.ts
deleted file mode 100644
index aed78664..00000000
--- a/src/__tests__/sceneFreeze.test.ts
+++ /dev/null
@@ -1,92 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import {
- frozenClock,
- frozenProgress,
- frozenStrokeCycle,
- readSceneFreeze,
- frozenVelocity,
-} from '../components/rower3d/sceneFreeze';
-
-/**
- * Issue #340 — the scene has to be able to hold still.
- *
- * A visual baseline is worth nothing if the picture moves between the run that
- * recorded it and the run that checks it. `__ROWER3D_FREEZE` is what a
- * screenshot spec sets to stop the clock, pin the boat to a point on the route
- * and park the oars mid-stroke; these are the pure reads the scene makes of it.
- */
-describe('readSceneFreeze', () => {
- it('reads nothing when the host has no freeze set', () => {
- expect(readSceneFreeze({})).toBeNull();
- });
-
- it('reads the time and progress a test asked for', () => {
- expect(readSceneFreeze({ __ROWER3D_FREEZE: { time: 12.5, progress: 0.31 } })).toEqual({
- time: 12.5,
- progress: 0.31,
- });
- });
-
- // The hook comes in over an init script, so it is an edge: a half-written
- // object there should leave the scene running rather than pin it to NaN.
- it.each([
- ['a string payload', 'yes'],
- ['a missing progress', { time: 4 }],
- ['a missing time', { progress: 0.5 }],
- ['a non-finite time', { time: Number.NaN, progress: 0.5 }],
- ['a non-finite progress', { time: 4, progress: Number.POSITIVE_INFINITY }],
- ])('ignores %s', (_label, payload) => {
- expect(readSceneFreeze({ __ROWER3D_FREEZE: payload })).toBeNull();
- });
-
- it('holds progress inside the route and time at or after its start', () => {
- expect(readSceneFreeze({ __ROWER3D_FREEZE: { time: -3, progress: 1.4 } })).toEqual({
- time: 0,
- progress: 1,
- });
- expect(readSceneFreeze({ __ROWER3D_FREEZE: { time: 2, progress: -0.2 } })).toEqual({
- time: 2,
- progress: 0,
- });
- });
-});
-
-describe('the frozen readings', () => {
- const freeze = { time: 12.5, progress: 0.31 };
-
- it('give the live value when nothing is frozen', () => {
- expect(frozenClock(null, 7.25)).toBe(7.25);
- expect(frozenProgress(null, 0.8)).toBe(0.8);
- expect(frozenStrokeCycle(null, 0.42)).toBe(0.42);
- });
-
- it('give the frozen value when the scene is held', () => {
- expect(frozenClock(freeze, 7.25)).toBe(12.5);
- expect(frozenProgress(freeze, 0.8)).toBe(0.31);
- });
-
- // The oars have to land in the same place as the clock, or the boat is the
- // one thing in a frozen frame that still moves.
- it('parks the stroke at the fraction of the second the clock stopped on', () => {
- expect(frozenStrokeCycle(freeze, 0.42)).toBe(0.5);
- expect(frozenStrokeCycle({ time: 12, progress: 0 }, 0.42)).toBe(0);
- });
-});
-
-describe('frozenVelocity', () => {
- /**
- * Issue #328 put the camera's field of view on a velocity curve, and the
- * freeze pins the clock and the boat's progress but not the physics. A
- * frozen scene would still have been photographed through a slightly
- * different lens each run, and the baselines would have drifted for a
- * reason that has nothing to do with the scene.
- */
- it('reports no speed at all while the scene is frozen', () => {
- expect(frozenVelocity({ time: 12.5, progress: 0.31 }, 4.17)).toBe(0);
- });
-
- it('reports the real speed while it is not', () => {
- expect(frozenVelocity(null, 4.17)).toBe(4.17);
- expect(frozenVelocity(null, 0)).toBe(0);
- });
-});
diff --git a/src/__tests__/sceneMemory.test.ts b/src/__tests__/sceneMemory.test.ts
deleted file mode 100644
index 89aa7758..00000000
--- a/src/__tests__/sceneMemory.test.ts
+++ /dev/null
@@ -1,91 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import * as THREE from 'three';
-import { measureSceneMemory, MEGABYTE } from '../components/rower3d/sceneMemory';
-
-const strip = (vertices: number): THREE.Mesh => {
- const geometry = new THREE.BufferGeometry();
- geometry.setAttribute('position', new THREE.Float32BufferAttribute(new Array(vertices * 3).fill(0), 3));
- geometry.setAttribute('normal', new THREE.Float32BufferAttribute(new Array(vertices * 3).fill(0), 3));
- geometry.setAttribute('uv', new THREE.Float32BufferAttribute(new Array(vertices * 2).fill(0), 2));
- return new THREE.Mesh(geometry, new THREE.MeshBasicMaterial());
-};
-
-const rendererStub = (geometries: number, textures: number) =>
- ({ info: { memory: { geometries, textures }, programs: [] } });
-
-describe('measureSceneMemory', () => {
- it('reports nothing useful for an empty scene', () => {
- const usage = measureSceneMemory(new THREE.Scene(), rendererStub(0, 0));
- expect(usage.geometryBytes).toBe(0);
- expect(usage.geometries).toBe(0);
- expect(usage.geometryMb).toBe(0);
- });
-
- it('sums the attribute buffers actually uploaded', () => {
- const scene = new THREE.Scene();
- scene.add(strip(1000));
- // 1000 vertices: position 3, normal 3, uv 2 floats = 8 * 4 bytes each.
- expect(measureSceneMemory(scene, rendererStub(1, 0)).geometryBytes).toBe(1000 * 8 * 4);
- });
-
- it('counts the index buffer too', () => {
- const withIndex = strip(100);
- withIndex.geometry.setIndex(new Array(300).fill(0));
- const scene = new THREE.Scene();
- scene.add(withIndex);
-
- const bare = new THREE.Scene();
- bare.add(strip(100));
-
- expect(measureSceneMemory(scene, rendererStub(1, 0)).geometryBytes).toBeGreaterThan(
- measureSceneMemory(bare, rendererStub(1, 0)).geometryBytes,
- );
- });
-
- it('counts a geometry shared by two meshes once', () => {
- const shared = strip(500).geometry;
- const scene = new THREE.Scene();
- scene.add(new THREE.Mesh(shared, new THREE.MeshBasicMaterial()));
- scene.add(new THREE.Mesh(shared, new THREE.MeshBasicMaterial()));
-
- expect(measureSceneMemory(scene, rendererStub(1, 0)).geometryBytes).toBe(500 * 8 * 4);
- });
-
- it('walks nested groups', () => {
- const scene = new THREE.Scene();
- const group = new THREE.Group();
- group.add(strip(200));
- const inner = new THREE.Group();
- inner.add(strip(300));
- group.add(inner);
- scene.add(group);
-
- expect(measureSceneMemory(scene, rendererStub(2, 0)).geometryBytes).toBe(500 * 8 * 4);
- });
-
- it('counts geometry that is switched off but still resident', () => {
- // Chunk culling sets `visible = false`; the buffers stay on the GPU.
- const hidden = strip(400);
- hidden.visible = false;
- const scene = new THREE.Scene();
- scene.add(hidden);
-
- expect(measureSceneMemory(scene, rendererStub(1, 0)).geometryBytes).toBe(400 * 8 * 4);
- });
-
- it('passes the renderer counters through, and reports megabytes', () => {
- const scene = new THREE.Scene();
- scene.add(strip(MEGABYTE / 32)); // 8 floats * 4 bytes = 32 bytes a vertex
- const usage = measureSceneMemory(scene, rendererStub(7, 12));
-
- expect(usage.geometries).toBe(7);
- expect(usage.textures).toBe(12);
- expect(usage.geometryMb).toBeCloseTo(1, 3);
- });
-
- it('survives a renderer that exposes no info', () => {
- const usage = measureSceneMemory(new THREE.Scene(), {});
- expect(usage.geometries).toBe(0);
- expect(usage.textures).toBe(0);
- });
-});
diff --git a/src/__tests__/sceneStats.test.ts b/src/__tests__/sceneStats.test.ts
deleted file mode 100644
index 7b55f616..00000000
--- a/src/__tests__/sceneStats.test.ts
+++ /dev/null
@@ -1,115 +0,0 @@
-import { describe, it, expect, beforeEach } from 'vitest';
-import {
- recordRenderStats,
- readRenderStats,
- clearRenderStats,
- type RenderStatsSource,
-} from '../components/rower3d/sceneStats';
-
-/** A renderer stub shaped like the part of THREE.WebGLRenderer that is read. */
-interface StubRenderer {
- info: {
- autoReset: boolean;
- render: { calls: number; triangles: number };
- reset: () => void;
- };
-}
-
-const renderer = (calls: number, triangles: number): StubRenderer => {
- const info = {
- autoReset: true,
- render: { calls, triangles },
- reset: () => {
- info.render.calls = 0;
- info.render.triangles = 0;
- },
- };
- return { info };
-};
-
-describe('readRenderStats', () => {
- beforeEach(() => clearRenderStats());
-
- it('has nothing to report before a frame has been measured', () => {
- expect(readRenderStats()).toBeNull();
- });
-});
-
-describe('recordRenderStats', () => {
- beforeEach(() => clearRenderStats());
-
- it('reports the draw calls and triangles of the frame just drawn', () => {
- recordRenderStats(renderer(412, 250_000), { backend: 'webgl', performanceMode: 'high' });
-
- expect(readRenderStats()).toMatchObject({
- drawCalls: 412,
- triangles: 250_000,
- backend: 'webgl',
- performanceMode: 'high',
- });
- });
-
- it('zeroes the counters so the next frame is counted on its own', () => {
- const gl = renderer(412, 250_000);
-
- recordRenderStats(gl, { backend: 'webgl', performanceMode: 'high' });
-
- expect(gl.info.render.calls).toBe(0);
- expect(gl.info.render.triangles).toBe(0);
- });
-
- it('takes the counters off automatic reset, so a frame is not lost mid-count', () => {
- const gl = renderer(1, 1);
-
- recordRenderStats(gl, { backend: 'webgl', performanceMode: 'high' });
-
- expect(gl.info.autoReset).toBe(false);
- });
-
- it('keeps the most recent frame', () => {
- recordRenderStats(renderer(100, 1), { backend: 'webgl', performanceMode: 'low' });
- recordRenderStats(renderer(200, 2), { backend: 'webgl', performanceMode: 'low' });
-
- expect(readRenderStats()?.drawCalls).toBe(200);
- });
-
- it('carries frame timing when the scene has measured some', () => {
- recordRenderStats(renderer(10, 20), {
- backend: 'webgl',
- performanceMode: 'low',
- fps: 58.6,
- p95Ms: 21.4,
- });
-
- expect(readRenderStats()).toMatchObject({ fps: 58.6, p95Ms: 21.4, backend: 'webgl' });
- });
-
- it('survives a renderer that reports no counters at all', () => {
- expect(() =>
- recordRenderStats({} as RenderStatsSource, { backend: 'webgl', performanceMode: 'high' }),
- ).not.toThrow();
- expect(readRenderStats()).toBeNull();
- });
-});
-
-describe('the renderer actually in use', () => {
- beforeEach(() => clearRenderStats());
-
- it('reports the renderer it was handed, not the one detection found', () => {
- // `backend` says what the browser can do; `drawing` says what three is
- // actually using. They agreed once the WebGPU label went (#345), but they
- // are still different questions - and the panel answering the wrong one is
- // what sent a reader looking in the wrong place in the first place (#232).
- const gl = { ...renderer(10, 20), isWebGLRenderer: true };
-
- recordRenderStats(gl, { backend: 'webgl', performanceMode: 'low' });
-
- expect(readRenderStats()).toMatchObject({ backend: 'webgl', drawing: 'webgl' });
- });
-
- it('says so when it cannot tell', () => {
- recordRenderStats(renderer(10, 20), { backend: 'webgl', performanceMode: 'low' });
-
- expect(readRenderStats()?.drawing).toBe('unknown');
- });
-});
diff --git a/src/__tests__/sceneTestRenderer.tsx b/src/__tests__/sceneTestRenderer.tsx
deleted file mode 100644
index 828527e6..00000000
--- a/src/__tests__/sceneTestRenderer.tsx
+++ /dev/null
@@ -1,70 +0,0 @@
-import { Profiler, Suspense, type ProfilerOnRenderCallback } from 'react';
-import ReactThreeTestRenderer from '@react-three/test-renderer';
-import type * as THREE from 'three';
-import { RowerScene } from '../components/Rower3D';
-import { routeService } from '../lib/services/routeService';
-import type { Rower3DProps } from '../components/Rower3D';
-
-/**
- * Mount the R3F scene in Vitest, with no WebGL and no downloads (#343).
- *
- * `vitest.config.ts` excludes `Rower3D.tsx` and every `rower3d/*.tsx` from
- * coverage, which means the TDD guard has never required a test for any of
- * them. The wrong-axis normal (#298), the rower who never moved (#273) and the
- * three-scales-at-once world (#321) all lived in that gap: they are scene-graph
- * facts, and nothing but a person looking at the screen was checking them.
- *
- * A spec using this must stub the scene's two downloads first — see
- * `rowerScene.smoke.test.tsx`, which explains what each one is and why it
- * cannot simply be left to fail. Everything else is the real thing: the real
- * curve, the real lights, the real per-frame physics.
- */
-
-/** The built-in demo route, which every scene test rows. */
-export const demoRoute = routeService.getAllRoutes()[0];
-
-export const renderScene = async (
- props: Partial = {},
- /**
- * Called on every commit of the scene subtree (#331).
- *
- * The scenery used to re-render ten times a second, and the only way to say
- * that it no longer does is to count commits — a scene graph that looks
- * right says nothing about how many times React rebuilt it to get there.
- */
- onRender?: ProfilerOnRenderCallback,
-) => {
- const tree = (p: Partial) => (
- }>
- undefined)}>
-
-
-
- );
- const renderer = await ReactThreeTestRenderer.create(tree(props));
-
- /** Every object in the graph, so a test can ask what the scene contains. */
- const objects = (): THREE.Object3D[] => {
- const found: THREE.Object3D[] = [];
- renderer.scene.instance.traverse((o) => found.push(o));
- return found;
- };
-
- return {
- renderer,
- objects,
- /** Advance the scene clock, running every `useFrame` callback. */
- tick: async (frames = 1, delta = 1 / 60) => {
- await ReactThreeTestRenderer.act(async () => {
- await renderer.advanceFrames(frames, delta);
- });
- },
- /** Render the same scene with new props, as a parent re-rendering would. */
- rerender: async (next: Partial) => {
- await renderer.update(tree(next));
- },
- unmount: () => renderer.unmount(),
- };
-};
-
-export type SceneUnderTest = Awaited>;
diff --git a/src/__tests__/sceneTiming.test.ts b/src/__tests__/sceneTiming.test.ts
deleted file mode 100644
index 0fa05ab1..00000000
--- a/src/__tests__/sceneTiming.test.ts
+++ /dev/null
@@ -1,118 +0,0 @@
-import { describe, it, expect, beforeEach, vi } from 'vitest';
-
-/**
- * The module carries a one-shot latch, so each test gets a fresh copy rather
- * than inheriting whether the previous one had already fired.
- */
-const loadTiming = async () => {
- vi.resetModules();
- return import('../components/rower3d/sceneTiming');
-};
-
-describe('scene timing marks', () => {
- beforeEach(() => {
- performance.clearMarks();
- performance.clearMeasures();
- });
-
- it('measures the gap between asking for a route and the first tick', async () => {
- const { markRouteLoadStart, markFirstFrame, FIRST_FRAME_MEASURE } = await loadTiming();
-
- markRouteLoadStart();
- markFirstFrame();
-
- const [measure] = performance.getEntriesByName(FIRST_FRAME_MEASURE);
- expect(measure).toBeDefined();
- expect(measure.duration).toBeGreaterThanOrEqual(0);
- });
-
- it('records nothing for a frame that follows no route load', async () => {
- const { markFirstFrame, FIRST_FRAME_MEASURE, FIRST_FRAME_MARK } = await loadTiming();
-
- markFirstFrame();
-
- expect(performance.getEntriesByName(FIRST_FRAME_MEASURE)).toHaveLength(0);
- expect(performance.getEntriesByName(FIRST_FRAME_MARK)).toHaveLength(0);
- });
-
- it('measures the first tick only, not every frame after it', async () => {
- const { markRouteLoadStart, markFirstFrame, FIRST_FRAME_MARK } = await loadTiming();
-
- markRouteLoadStart();
- markFirstFrame();
- markFirstFrame();
- markFirstFrame();
-
- expect(performance.getEntriesByName(FIRST_FRAME_MARK)).toHaveLength(1);
- });
-
- it('starts a clean clock for the next route the rower picks', async () => {
- const { markRouteLoadStart, markFirstFrame, FIRST_FRAME_MEASURE } = await loadTiming();
-
- markRouteLoadStart();
- markFirstFrame();
- markRouteLoadStart();
-
- expect(performance.getEntriesByName(FIRST_FRAME_MEASURE)).toHaveLength(0);
-
- markFirstFrame();
- expect(performance.getEntriesByName(FIRST_FRAME_MEASURE)).toHaveLength(1);
- });
-
- it('stays quiet where the browser exposes no performance timeline', async () => {
- vi.stubGlobal('performance', undefined);
- const { markRouteLoadStart, markFirstFrame } = await loadTiming();
-
- expect(() => {
- markRouteLoadStart();
- markFirstFrame();
- }).not.toThrow();
-
- vi.unstubAllGlobals();
- });
-});
-
-describe('route geometry timing', () => {
- beforeEach(() => {
- performance.clearMarks();
- performance.clearMeasures();
- });
-
- it('measures the part of the wait that is the route geometry', async () => {
- const { markRouteLoadStart, markRouteGeometryReady, GEOMETRY_READY_MEASURE } =
- await loadTiming();
-
- markRouteLoadStart();
- markRouteGeometryReady();
-
- const [measure] = performance.getEntriesByName(GEOMETRY_READY_MEASURE);
- expect(measure).toBeDefined();
- expect(measure.duration).toBeGreaterThanOrEqual(0);
- });
-
- it('records nothing when no route load was marked', async () => {
- const { markRouteGeometryReady, GEOMETRY_READY_MEASURE } = await loadTiming();
- markRouteGeometryReady();
- expect(performance.getEntriesByName(GEOMETRY_READY_MEASURE)).toHaveLength(0);
- });
-
- it('measures the first build only, not every re-render', async () => {
- const { markRouteLoadStart, markRouteGeometryReady, GEOMETRY_READY_MARK } = await loadTiming();
- markRouteLoadStart();
- markRouteGeometryReady();
- markRouteGeometryReady();
- expect(performance.getEntriesByName(GEOMETRY_READY_MARK)).toHaveLength(1);
- });
-
- it('starts a clean clock for the next route', async () => {
- const { markRouteLoadStart, markRouteGeometryReady, GEOMETRY_READY_MEASURE } =
- await loadTiming();
- markRouteLoadStart();
- markRouteGeometryReady();
- markRouteLoadStart();
- expect(performance.getEntriesByName(GEOMETRY_READY_MEASURE)).toHaveLength(0);
-
- markRouteGeometryReady();
- expect(performance.getEntriesByName(GEOMETRY_READY_MEASURE)).toHaveLength(1);
- });
-});
diff --git a/src/__tests__/sceneryAssets.test.ts b/src/__tests__/sceneryAssets.test.ts
index f69f6dcf..1a830bde 100644
--- a/src/__tests__/sceneryAssets.test.ts
+++ b/src/__tests__/sceneryAssets.test.ts
@@ -1,5 +1,3 @@
-import fs from 'fs';
-import path from 'path';
import { describe, it, expect } from 'vitest';
import { afterEach } from 'vitest';
import {
@@ -179,19 +177,8 @@ describe('collectSceneryPaths', () => {
});
});
-describe('scenery models are fetched for the route, not on import', () => {
- it('does not preload a fixed model set when the module loads', () => {
- // A module-scope useGLTF.preload of the fallback/unknown set was harmless
- // while the kit was opt-in and the branch was dead. Turning the kit on by
- // default made it fetch ~18 GLBs on every session, most of which the
- // Willowbrook track's own profiles never use. The component's
- // useGLTF(paths) already loads exactly the set the route needs, so there
- // is nothing left for a preload to warm (review of #232).
- const source = fs.readFileSync(
- path.join(process.cwd(), 'src/components/rower3d/sceneryModels.tsx'),
- 'utf-8',
- );
-
- expect(source).not.toContain('useGLTF.preload');
- });
-});
+// #542 AC1 — the "no module-scope `useGLTF.preload`" rule the next block used
+// to pin was a React/three.js contract on the deleted `sceneryModels.tsx`.
+// The WebGPU engine builds its scenery pools lazily per route (see
+// `src/lib/renderer/engine/rideScene.ts`), so there is no module-load fetch
+// to catch any more. Spec removed with the React tree.
diff --git a/src/__tests__/sceneryClearance.test.ts b/src/__tests__/sceneryClearance.test.ts
deleted file mode 100644
index b193cded..00000000
--- a/src/__tests__/sceneryClearance.test.ts
+++ /dev/null
@@ -1,165 +0,0 @@
-import { describe, it, expect, afterEach } from 'vitest';
-import * as THREE from 'three';
-import {
- SCENERY_WATER_MARGIN_METRES,
- landClearance,
- nearestBankOffset,
- publishSceneryClearance,
- waterHalfWidthAt,
-} from '../components/rower3d/sceneryClearance';
-import { SHORELINE_WIDTH_METRES } from '../components/rower3d/bankGeometry';
-import { SCENE_SCALE, WATER_CHANNEL_WIDTH } from '../components/rower3d/constants';
-import { MIN_NAVIGABLE_WIDTH_METERS } from '../components/rower3d/navigableWidth';
-import {
- getWaterWidthSceneUnitsForProgress,
- type RouteEnrichmentData,
-} from '../lib/services/routeEnrichmentService';
-
-/**
- * Where the water ends, and how far past it scenery has to stand (#379).
- *
- * Every lateral offset in the scenery system was an absolute distance from the
- * centreline, authored against a narrow channel. On a 55 m river the half-width
- * is 27.5 m and a landmark at 7 m stood 20.5 m inside the water.
- */
-
-const withWidths = (widths: number[], fallback = widths[0]): RouteEnrichmentData =>
- ({
- routeId: 'clearance',
- elevations: [],
- segmentProfiles: widths.map((waterWidthMeters, index) => ({
- index,
- startMeters: index * 50,
- endMeters: (index + 1) * 50,
- sceneryProfile: 'fallback',
- treeDensity: 0.5,
- vegetationDensity: 0.5,
- buildingDensity: 0.2,
- objectScale: 1,
- waterWidthMeters,
- dragMultiplier: 1,
- bearing: 0,
- bearingDelta: 0,
- })),
- waterBodyType: 'river',
- waterWidthMeters: fallback,
- waterColor: '#000',
- waveIntensity: 0.5,
- fetchedAt: 0,
- source: 'fallback',
- }) as RouteEnrichmentData;
-
-describe('the scenery margin (#379 R2)', () => {
- it('is at least the shoreline strip, so nothing stands in the foam', () => {
- expect(SCENERY_WATER_MARGIN_METRES).toBeGreaterThanOrEqual(SHORELINE_WIDTH_METRES);
- });
-
- it('puts the nearest a placement may stand at the waterline plus the margin', () => {
- expect(nearestBankOffset(27.5)).toBeCloseTo(27.5 + SCENERY_WATER_MARGIN_METRES, 9);
- });
-});
-
-describe('waterHalfWidthAt — the width the water is built from', () => {
- it('reads a 55 m river as a 27.5 m half-width', () => {
- expect(waterHalfWidthAt(withWidths([55, 55, 55]), 0.5, 'left')).toBeCloseTo(27.5, 9);
- });
-
- it('agrees with the water channel at every progress, including between segments', () => {
- const enrichment = withWidths([20, 55, 120, 4], 30);
-
- for (let i = 0; i <= 20; i += 1) {
- const t = i / 20;
- expect(waterHalfWidthAt(enrichment, t, 'right')).toBeCloseTo(
- getWaterWidthSceneUnitsForProgress(enrichment.segmentProfiles, 30, t) / 2,
- 9,
- );
- }
- });
-
- it('gives a route with no enrichment the default channel the water draws', () => {
- for (const none of [null, undefined]) {
- expect(waterHalfWidthAt(none, 0.3, 'left')).toBeCloseTo(WATER_CHANNEL_WIDTH / SCENE_SCALE / 2, 9);
- }
- });
-
- it('applies the navigable floor, as the water does', () => {
- expect(waterHalfWidthAt(withWidths([2, 2]), 0.5, 'right')).toBeCloseTo(
- MIN_NAVIGABLE_WIDTH_METERS / 2,
- 9,
- );
- });
-});
-
-describe('landClearance — how far the nearest thing on the bank is from the water', () => {
- // A straight route along +z: the centreline is x = 0 and the perpendicular is x.
- const straight = new THREE.CatmullRomCurve3([
- new THREE.Vector3(0, 0, 0),
- new THREE.Vector3(0, 0, 100),
- new THREE.Vector3(0, 0, 200),
- ]);
- const river = withWidths([55, 55, 55]);
-
- it('measures the lateral distance past the waterline at each placement', () => {
- const reading = landClearance(
- [
- { position: [40, 0, 100], progress: 0.5 },
- { position: [-30, 0, 50], progress: 0.25 },
- ],
- straight,
- river,
- );
-
- expect(reading.count).toBe(2);
- expect(reading.nearestM).toBeCloseTo(2.5, 6);
- expect(reading.progress).toBeCloseTo(0.25, 9);
- });
-
- it('goes negative for something standing in the river', () => {
- const reading = landClearance([{ position: [7, 0, 100], progress: 0.5 }], straight, river);
-
- expect(reading.nearestM).toBeCloseTo(7 - 27.5, 6);
- });
-
- it('ignores how far along the route a placement is, only how far across', () => {
- // The GLB scatter is sampled at 0.999 when its progress says 1, so its
- // point is metres short of the one its progress names. That must not read
- // as clearance.
- const reading = landClearance([{ position: [30, 0, 199.8], progress: 1 }], straight, river);
-
- expect(reading.nearestM).toBeCloseTo(2.5, 3);
- });
-
- it('reads nothing measured as NaN rather than as a clearance', () => {
- const reading = landClearance([], straight, river);
-
- expect(reading.count).toBe(0);
- expect(reading.nearestM).toBeNaN();
- });
-});
-
-describe('publishSceneryClearance — what an E2E can read from the running scene', () => {
- afterEach(() => {
- delete window.__PLAYWRIGHT_TESTING;
- delete window.__ROWER3D_SCENERY_CLEARANCE;
- });
-
- const reading = { nearestM: 3, count: 4, progress: 0.2 };
-
- it('publishes nothing outside automation', () => {
- publishSceneryClearance('landscape', reading);
-
- expect(window.__ROWER3D_SCENERY_CLEARANCE).toBeUndefined();
- });
-
- it('files each placement path under its own name, with the margin it was held to', () => {
- window.__PLAYWRIGHT_TESTING = true;
-
- publishSceneryClearance('landscape', reading);
- publishSceneryClearance('structures', { ...reading, nearestM: 5 });
-
- expect(window.__ROWER3D_SCENERY_CLEARANCE).toEqual({
- landscape: { ...reading, marginM: SCENERY_WATER_MARGIN_METRES },
- structures: { ...reading, nearestM: 5, marginM: SCENERY_WATER_MARGIN_METRES },
- });
- });
-});
diff --git a/src/__tests__/sceneryManifest.test.ts b/src/__tests__/sceneryManifest.test.ts
deleted file mode 100644
index 6ab8dcb6..00000000
--- a/src/__tests__/sceneryManifest.test.ts
+++ /dev/null
@@ -1,151 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import fs from 'node:fs';
-import path from 'node:path';
-import {
- SCENERY_PROFILE_MODELS,
- WATER_BODY_MODELS,
- TREE_SPECIES_MODELS,
- UNIVERSAL_FURNITURE,
- REGIONAL_MODELS,
-} from '../components/rower3d/sceneryAssets';
-import { COURSE_FURNITURE, LIVERIED_LANDMARKS } from '../components/rower3d/sceneryStructures';
-
-/**
- * Issue #332 — what the scenery kit costs to download.
- *
- * The kit was 130 uncompressed GLBs fetched one by one, and nothing in the app
- * or in CI knew what any of them weighed. The manifest is generated from the
- * files on disk by `scripts/generate-scenery-manifest.mjs`; these are the
- * budgets it is held to, so a model that doubles in size fails here rather
- * than on a rower's phone.
- */
-
-interface Manifest {
- totalBytes: number;
- models: Record;
-}
-
-const ROOT = path.resolve(__dirname, '..', '..');
-const manifest: Manifest = JSON.parse(
- fs.readFileSync(path.join(ROOT, 'public/assets/scenery/manifest.json'), 'utf8'),
-);
-
-const KB = 1024;
-const MB = 1024 * KB;
-
-/** Every id the selection matrix can ask for, from every entry point. */
-const referencedIds = (): string[] => {
- const ids = new Set();
- const add = (list: string[] | undefined) => list?.forEach((id) => ids.add(id));
-
- for (const set of Object.values(SCENERY_PROFILE_MODELS)) {
- add(set.bankEdge);
- add(set.landform);
- add(set.scatter);
- add(set.backdrop);
- }
- for (const set of Object.values(WATER_BODY_MODELS)) {
- Object.values(set).forEach((value) => add(value as string[]));
- }
- for (const set of Object.values(REGIONAL_MODELS)) {
- Object.values(set).forEach((value) => add(value as string[]));
- }
- Object.values(TREE_SPECIES_MODELS).forEach(add);
- add(UNIVERSAL_FURNITURE);
- add(COURSE_FURNITURE.start);
- add(COURSE_FURNITURE.finish);
- add(LIVERIED_LANDMARKS.map((landmark) => landmark.id));
-
- return Array.from(ids);
-};
-
-describe('the scenery manifest', () => {
- // A model the matrix names but the kit does not ship is a 404 mid-row, and
- // the selection matrix is data - nothing type-checks it against the disk.
- it('lists every model the scene can ask for', () => {
- const missing = referencedIds().filter((id) => !(id in manifest.models));
-
- expect(missing, `not in the manifest: ${missing.join(', ')}`).toEqual([]);
- });
-
- it('describes files that are actually on disk, at the size it claims', () => {
- for (const [id, model] of Object.entries(manifest.models)) {
- const file = path.join(ROOT, 'public', model.path);
- expect(fs.existsSync(file), `${id} is in the manifest but not on disk`).toBe(true);
- expect(fs.statSync(file).size, `${id} has changed since the manifest was written`).toBe(
- model.bytes,
- );
- }
- });
-
- it('ships every model compressed', () => {
- const raw = Object.entries(manifest.models)
- .filter(([, model]) => !model.compressed)
- .map(([id]) => id);
-
- expect(raw, `uncompressed: ${raw.join(', ')}`).toEqual([]);
- });
-});
-
-describe('the scenery kit’s byte budget', () => {
- const sculls = Object.entries(manifest.models).filter(([id]) => id.startsWith('scull'));
- const scenery = Object.entries(manifest.models).filter(([id]) => !id.startsWith('scull'));
-
- it('keeps the whole kit under 14 MB', () => {
- expect(manifest.totalBytes).toBeLessThanOrEqual(14 * MB);
- });
-
- it('keeps any one scenery model under 400 kB', () => {
- const heavy = scenery.filter(([, model]) => model.bytes > 400 * KB).map(([id]) => id);
-
- expect(heavy, `over 400 kB: ${heavy.join(', ')}`).toEqual([]);
- });
-
- it('keeps the crewed scull under 700 kB', () => {
- for (const [id, model] of sculls) {
- expect(model.bytes, `${id} is over the scull budget`).toBeLessThanOrEqual(700 * KB);
- }
- });
-
- /**
- * The ratchet, as the coverage thresholds are ratcheted.
- *
- * The budgets above are the issue's, and the kit came in at 6.12 MB against
- * a 14 MB ceiling - which would not notice the kit doubling. This is the
- * measurement with headroom, so the gate answers "did this regress" rather
- * than "is it still within the number somebody wrote down in 2026".
- */
- it('has not drifted back up from what it measured', () => {
- expect(manifest.totalBytes).toBeLessThanOrEqual(7 * MB);
- });
-});
-
-/**
- * Vite copies `public/` into `dist/` verbatim, so anything left in there is
- * shipped - and `build_scenery.py` wrote its multi-view renders beside the
- * models it rendered. That was 133 PNGs and 39 MB of CAD provenance served to
- * every browser that loads one 90 kB tree, referenced by nothing in the app,
- * the specs or the docs. #232 moved the STEP files out for the same reason;
- * this keeps both of them out.
- */
-describe('what the served tree carries', () => {
- const SERVED = path.join(ROOT, 'public/assets/scenery');
-
- const servedFiles = (): string[] =>
- fs
- .readdirSync(SERVED, { recursive: true })
- .map((entry) => entry.toString().split(path.sep).join('/'))
- .filter((entry) => fs.statSync(path.join(SERVED, entry)).isFile());
-
- it('ships models and a manifest, not the pictures they were checked against', () => {
- const provenance = servedFiles().filter((file) => /[.](png|jpe?g|step|stp|blend)$/i.test(file));
-
- expect(provenance, `provenance in the served tree: ${provenance.join(', ')}`).toEqual([]);
- });
-
- it('is the kit and nothing else', () => {
- const strays = servedFiles().filter((file) => !/[.](glb|json|md)$/i.test(file));
-
- expect(strays, `unexpected in the served tree: ${strays.join(', ')}`).toEqual([]);
- });
-});
diff --git a/src/__tests__/sceneryPlacement.test.ts b/src/__tests__/sceneryPlacement.test.ts
deleted file mode 100644
index e5d315fc..00000000
--- a/src/__tests__/sceneryPlacement.test.ts
+++ /dev/null
@@ -1,520 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import * as THREE from 'three';
-import {
- MAX_INSTANCES_PER_SIDE,
- thinToCeiling,
- budgetFor,
- keepsKitTrees,
- distinctProfiles,
- pick,
- computePlacements,
- CATEGORY_OFFSET,
- ASSET_SCALE,
- LAND_BANDS,
- bandsClearOf,
- type Category,
- type Placement,
-} from '../components/rower3d/sceneryPlacement';
-import {
- SCENERY_WATER_MARGIN_METRES,
- landClearance,
-} from '../components/rower3d/sceneryClearance';
-import { resolveSceneryModels, type ResolvedScenery } from '../components/rower3d/sceneryAssets';
-import {
- WILLOWBROOK_SCENERY_TRACK,
- trackProfiles,
- trackWaterForProfile,
-} from '../components/rower3d/sceneryTrack';
-import type { RouteEnrichmentData, SceneryProfile } from '../lib/services/routeEnrichmentService';
-
-const fallbackResolved = resolveSceneryModels('fallback', 'unknown', ['pine', 'oak', 'willow']);
-const byProfile = (r: ResolvedScenery = fallbackResolved) => {
- const m = new Map();
- m.set('fallback', r);
- return m;
-};
-
-const enrichment = (profiles: SceneryProfile[]): RouteEnrichmentData =>
- ({
- routeId: 'test',
- elevations: [0, 1, 2, 3, 2, 1, 0],
- segmentProfiles: profiles.map((sceneryProfile, i) => ({
- index: i, startMeters: i * 50, endMeters: (i + 1) * 50,
- sceneryProfile, treeDensity: 0.5, vegetationDensity: 0.5, buildingDensity: 0.2,
- objectScale: 1, waterWidthMeters: 20, dragMultiplier: 1, bearing: 0, bearingDelta: 0,
- })),
- waterBodyType: 'river', waterWidthMeters: 20, waterColor: '#000',
- waveIntensity: 0.5, fetchedAt: 0, source: 'fallback',
- }) as RouteEnrichmentData;
-
-const validPlacement = (p: Placement) => {
- expect(p.position).toHaveLength(3);
- expect(p.position.every(Number.isFinite)).toBe(true);
- expect(Number.isFinite(p.rotationY)).toBe(true);
- expect(p.scale).toBeGreaterThan(0);
- expect(p.progress).toBeGreaterThanOrEqual(0);
- expect(p.progress).toBeLessThanOrEqual(1);
- expect(typeof p.id).toBe('string');
-};
-
-describe('budgetFor', () => {
- it('scales instance count by performance mode', () => {
- expect(budgetFor('extra-high')).toBe(1);
- expect(budgetFor('basic')).toBe(0.4);
- expect(budgetFor('medium')).toBe(0.7);
- });
-});
-
-describe('distinctProfiles', () => {
- it('returns [fallback] when there is no enrichment', () => {
- expect(distinctProfiles(null)).toEqual(['fallback']);
- expect(distinctProfiles(undefined)).toEqual(['fallback']);
- });
- it('de-duplicates the segment profiles', () => {
- const p = distinctProfiles(enrichment(['forest', 'forest', 'residential']));
- expect(p).toContain('forest');
- expect(p).toContain('residential');
- expect(p).toHaveLength(2);
- });
-});
-
-describe('pick', () => {
- it('returns null for an empty list', () => {
- expect(pick([], 1)).toBeNull();
- });
- it('is deterministic and in range', () => {
- const ids = ['a', 'b', 'c'];
- expect(pick(ids, 42)).toBe(pick(ids, 42));
- expect(ids).toContain(pick(ids, 7));
- });
-});
-
-describe('computePlacements — flat mode', () => {
- it('lays bands per bank with valid placements from the resolved set', () => {
- const allIds = new Set(Object.values(fallbackResolved).flat());
- const out = computePlacements({ enrichment: null, resolvedByProfile: byProfile(), budget: 1, side: 'left' });
- expect(out.length).toBeGreaterThan(0);
- for (const p of out) {
- validPlacement(p);
- expect(allIds.has(p.id)).toBe(true);
- }
- });
-
- it('places fewer instances at a lower budget', () => {
- const hi = computePlacements({ resolvedByProfile: byProfile(), budget: 1, side: 'left' });
- const lo = computePlacements({ resolvedByProfile: byProfile(), budget: 0.4, side: 'left' });
- expect(lo.length).toBeLessThan(hi.length);
- });
-
- it('mirrors left/right banks to opposite sides of the centreline', () => {
- const left = computePlacements({ resolvedByProfile: byProfile(), budget: 1, side: 'left' });
- const right = computePlacements({ resolvedByProfile: byProfile(), budget: 1, side: 'right' });
- // flat mode offsets along +x (perp), signed by side; water at x=0.
- expect(left.some((p) => p.position[0] < 0)).toBe(true);
- expect(right.some((p) => p.position[0] > 0)).toBe(true);
- });
-
- it('returns nothing when no profile resolves', () => {
- const empty = new Map();
- expect(computePlacements({ resolvedByProfile: empty, budget: 1, side: 'left' })).toEqual([]);
- });
-});
-
-describe('computePlacements — curve mode', () => {
- const curve = new THREE.CatmullRomCurve3([
- new THREE.Vector3(0, 0, -200),
- new THREE.Vector3(20, 0, -100),
- new THREE.Vector3(-20, 0, 0),
- new THREE.Vector3(20, 0, 100),
- new THREE.Vector3(0, 0, 200),
- ]);
-
- it('samples the curve and offsets instances off the rowed line', () => {
- const out = computePlacements({
- curve,
- enrichment: enrichment(['fallback', 'forest', 'residential']),
- resolvedByProfile: byProfile(),
- budget: 1,
- side: 'left',
- });
- expect(out.length).toBeGreaterThan(0);
- out.forEach(validPlacement);
- // instances span the length of the route
- expect(Math.min(...out.map((p) => p.progress))).toBeLessThan(0.2);
- expect(Math.max(...out.map((p) => p.progress))).toBeGreaterThan(0.8);
- });
-
- it('respects surface height (y ≈ 0.15) for water dressing', () => {
- const out = computePlacements({ curve, enrichment: enrichment(['fallback']), resolvedByProfile: byProfile(), budget: 1, side: 'left' });
- const surfaceIds = new Set(fallbackResolved.surface);
- const surfaces = out.filter((p) => surfaceIds.has(p.id));
- // surface models exist for the fallback set and sit just above the water
- if (surfaces.length > 0) {
- expect(surfaces.every((p) => Math.abs(p.position[1] - 0.15) < 1e-6)).toBe(true);
- }
- });
-});
-
-describe('the kit trees beside the billboard foliage (#333)', () => {
- const curve = new THREE.CatmullRomCurve3([
- new THREE.Vector3(0, 0, -600),
- new THREE.Vector3(0, 0, 0),
- new THREE.Vector3(0, 0, 600),
- ]);
- const treeIds = new Set(fallbackResolved.trees);
- const place = (kitTrees?: boolean) =>
- computePlacements({
- curve,
- enrichment: enrichment(['fallback']),
- resolvedByProfile: byProfile(),
- budget: 1,
- side: 'right',
- kitTrees,
- });
-
- it('keeps the GLB trees at high only, as the close-up hero trees', () => {
- expect(keepsKitTrees('extra-high')).toBe(true);
- expect(keepsKitTrees('medium')).toBe(false);
- expect(keepsKitTrees('basic')).toBe(false);
- });
-
- it('places the kit trees unless told not to', () => {
- expect(treeIds.size).toBeGreaterThan(0);
- expect(place().some((p) => treeIds.has(p.id))).toBe(true);
- expect(place(false).some((p) => treeIds.has(p.id))).toBe(false);
- });
-
- it('leaves everything else where it was when the trees go', () => {
- const withTrees = place(true).filter((p) => !treeIds.has(p.id));
- expect(place(false)).toEqual(withTrees);
- });
-});
-
-describe('placement constants', () => {
- it('defines an offset band and a positive scale for every category', () => {
- for (const band of Object.values(CATEGORY_OFFSET)) {
- expect(band[0]).toBeLessThanOrEqual(band[1]);
- }
- expect(ASSET_SCALE).toBeGreaterThan(0);
- });
-});
-
-describe('an authored track dresses the route (#232)', () => {
- const resolvedForTrack = () => {
- const map = new Map();
- for (const profile of trackProfiles(WILLOWBROOK_SCENERY_TRACK)) {
- map.set(
- profile,
- resolveSceneryModels(profile, trackWaterForProfile(WILLOWBROOK_SCENERY_TRACK, profile), [
- 'pine',
- 'oak',
- ]),
- );
- }
- return map;
- };
-
- it('changes the models along the route instead of dressing all 5 km alike', () => {
- const placements = computePlacements({
- curve: new THREE.CatmullRomCurve3([
- new THREE.Vector3(0, 0, -200),
- new THREE.Vector3(20, 0, -100),
- new THREE.Vector3(-20, 0, 0),
- new THREE.Vector3(20, 0, 100),
- new THREE.Vector3(0, 0, 200),
- ]),
- // Enrichment that would flatten the whole route to one profile; the track
- // is what the scene should follow.
- enrichment: enrichment(['commercial', 'commercial', 'commercial']),
- resolvedByProfile: resolvedForTrack(),
- budget: 1,
- side: 'left',
- track: WILLOWBROOK_SCENERY_TRACK,
- });
-
- const early = new Set(placements.filter((p) => p.progress < 0.2).map((p) => p.id));
- const late = new Set(placements.filter((p) => p.progress > 0.8).map((p) => p.id));
-
- expect(early.size).toBeGreaterThan(0);
- expect(late.size).toBeGreaterThan(0);
- // The headwaters and the delta are different places.
- expect([...late].some((id) => !early.has(id))).toBe(true);
- });
-});
-
-describe('instance ceiling (#232 phase 3)', () => {
- const placementsOf = (count: number): Placement[] =>
- Array.from({ length: count }, (_, i) => ({
- id: `f0${i % 9}-model`,
- position: [0, 0, i] as [number, number, number],
- rotationY: 0,
- scale: 1,
- progress: i / (count - 1),
- footing: 'bank' as const,
- }));
-
- it('leaves a route inside the ceiling untouched', () => {
- const few = placementsOf(10);
-
- expect(thinToCeiling(few)).toEqual(few);
- });
-
- it('caps a long route, which is where the frame budget goes', () => {
- const many = placementsOf(MAX_INSTANCES_PER_SIDE * 3);
-
- expect(thinToCeiling(many).length).toBeLessThanOrEqual(MAX_INSTANCES_PER_SIDE);
- });
-
- it('thins evenly rather than truncating, so the far end is still dressed', () => {
- const thinned = thinToCeiling(placementsOf(MAX_INSTANCES_PER_SIDE * 3));
-
- expect(thinned[0].progress).toBeLessThan(0.05);
- expect(thinned[thinned.length - 1].progress).toBeGreaterThan(0.9);
- });
-
- it('is deterministic', () => {
- const many = placementsOf(MAX_INSTANCES_PER_SIDE * 2);
-
- expect(thinToCeiling(many)).toEqual(thinToCeiling(many));
- });
-});
-
-describe('each bank is placed once (review of #232)', () => {
- const curve = new THREE.CatmullRomCurve3([
- new THREE.Vector3(0, 0, -200),
- new THREE.Vector3(10, 0, -100),
- new THREE.Vector3(-10, 0, 0),
- new THREE.Vector3(10, 0, 100),
- new THREE.Vector3(0, 0, 200),
- ]);
-
- const forSide = (side: 'left' | 'right') =>
- computePlacements({ curve, resolvedByProfile: byProfile(), budget: 1, side });
-
- it('gives the two banks different instances', () => {
- // In curve mode the loop ran `for (const sign of [-1, 1])` and ignored the
- // `side` it was given, so both components computed
- // the identical array. Every object was drawn twice at the same transform:
- // double the draw calls, and z-fighting between the copies.
- const left = forSide('left');
- const right = forSide('right');
-
- expect(left.length).toBeGreaterThan(0);
- expect(JSON.stringify(left)).not.toEqual(JSON.stringify(right));
- });
-
- it('puts each side on its own bank', () => {
- // The offset is applied along the curve's perpendicular, so "which bank"
- // is the sign of the offset, not of the world x.
- const centreAt = (t: number) => curve.getPointAt(Math.min(0.999, t));
- const sideOf = (p: { position: [number, number, number]; progress: number }) => {
- const c = centreAt(p.progress);
- const tangent = curve.getTangentAt(Math.min(0.999, p.progress)).normalize();
- const perp = new THREE.Vector3().crossVectors(tangent, new THREE.Vector3(0, 1, 0)).normalize();
- return Math.sign((p.position[0] - c.x) * perp.x + (p.position[2] - c.z) * perp.z);
- };
-
- expect(new Set(forSide('left').map(sideOf))).toEqual(new Set([-1]));
- expect(new Set(forSide('right').map(sideOf))).toEqual(new Set([1]));
- });
-
- it('dresses both banks between them, as densely as before', () => {
- // The fix must not halve the scene: the two calls together should still
- // place what one call used to place for both banks.
- const total = forSide('left').length + forSide('right').length;
-
- expect(total).toBeGreaterThan(60);
- });
-});
-
-describe('what actually bounds the cost of a bank (review of #232)', () => {
- const longCurve = new THREE.CatmullRomCurve3(
- Array.from({ length: 40 }, (_, i) => new THREE.Vector3((i % 2 ? 30 : -30), 0, -8000 + i * 400)),
- );
- const shortCurve = new THREE.CatmullRomCurve3([
- new THREE.Vector3(0, 0, -200),
- new THREE.Vector3(10, 0, 0),
- new THREE.Vector3(0, 0, 200),
- ]);
-
- it('samples a long route no more densely than a short one', () => {
- // MAX_INSTANCES_PER_SIDE was documented as the protection against cost
- // scaling with route length. It is not: the band count does not depend on
- // length, so the ceiling is never approached and never was.
- const long = computePlacements({ curve: longCurve, resolvedByProfile: byProfile(), budget: 1, side: 'left' });
- const short = computePlacements({ curve: shortCurve, resolvedByProfile: byProfile(), budget: 1, side: 'left' });
-
- expect(long.length).toBe(short.length);
- expect(long.length).toBeLessThan(MAX_INSTANCES_PER_SIDE);
- });
-
- it('keeps the ceiling working as a backstop if the band count is ever raised', () => {
- const many: Placement[] = Array.from({ length: MAX_INSTANCES_PER_SIDE * 3 }, (_, i) => ({
- id: 'e01-london-plane',
- position: [0, 0, i],
- rotationY: 0,
- scale: 1,
- progress: i / (MAX_INSTANCES_PER_SIDE * 3),
- })) as Placement[];
-
- const thinned = thinToCeiling(many);
-
- expect(thinned).toHaveLength(MAX_INSTANCES_PER_SIDE);
- // Thinned by stride, so the end of the route is still dressed.
- expect(thinned[thinned.length - 1].progress).toBeGreaterThan(0.6);
- });
-});
-
-describe('scenery stands clear of the water it is placed beside (#379)', () => {
- /** A route whose water is one width the whole way along. */
- const water = (widthMeters: number): RouteEnrichmentData => {
- const e = enrichment(['fallback', 'fallback', 'fallback']);
- return {
- ...e,
- waterWidthMeters: widthMeters,
- segmentProfiles: e.segmentProfiles.map((s) => ({ ...s, waterWidthMeters: widthMeters })),
- };
- };
-
- const straight = new THREE.CatmullRomCurve3([
- new THREE.Vector3(0, 0, -300),
- new THREE.Vector3(0, 0, 0),
- new THREE.Vector3(0, 0, 300),
- ]);
-
- const bothBanks = (e: RouteEnrichmentData, curve: THREE.Curve | null = straight) =>
- (['left', 'right'] as const).flatMap((side) =>
- computePlacements({ curve, enrichment: e, resolvedByProfile: byProfile(), budget: 1, side }),
- );
-
- const shift = (bands: Record, cat: Category) =>
- bands[cat][0] - CATEGORY_OFFSET[cat][0];
-
- describe('bandsClearOf', () => {
- it('keeps what belongs on the water where it was, however wide the water', () => {
- for (const halfWidth of [3.53, 10, 27.5, 60]) {
- const bands = bandsClearOf(halfWidth);
- expect(bands.surface).toEqual(CATEGORY_OFFSET.surface);
- expect(bands.inWater).toEqual(CATEGORY_OFFSET.inWater);
- }
- });
-
- it('lists every category that stands on the bank, innermost first', () => {
- const land = (Object.keys(CATEGORY_OFFSET) as Category[]).filter(
- (c) => c !== 'surface' && c !== 'inWater',
- );
- expect([...LAND_BANDS].sort()).toEqual(land.sort());
- const inner = LAND_BANDS.map((c) => CATEGORY_OFFSET[c][0]);
- expect(inner).toEqual([...inner].sort((a, b) => a - b));
- });
-
- it('moves nothing on a stream narrow enough that every band is already on land', () => {
- // 7 m, the narrowest water a real route reports, floored to 7.06 m.
- expect(bandsClearOf(7.06 / 2)).toEqual(CATEGORY_OFFSET);
- });
-
- it('puts the bank dressing of a 55 m river on the bank', () => {
- const bands = bandsClearOf(27.5);
- for (const cat of ['bankEdge', 'furniture', 'scatter'] as const) {
- expect(bands[cat][0], cat).toBeGreaterThanOrEqual(27.5 + SCENERY_WATER_MARGIN_METRES);
- }
- });
-
- it('moves a band out whole rather than squashing it against the bank', () => {
- for (const halfWidth of [10, 27.5, 60]) {
- const bands = bandsClearOf(halfWidth);
- for (const cat of LAND_BANDS) {
- const depth = CATEGORY_OFFSET[cat][1] - CATEGORY_OFFSET[cat][0];
- expect(bands[cat][1] - bands[cat][0], `${cat} at ${halfWidth}`).toBeCloseTo(depth, 9);
- expect(shift(bands, cat), `${cat} moved inwards`).toBeGreaterThanOrEqual(0);
- }
- }
- });
-
- it('pushes the whole arrangement out, in order, on a 120 m segment', () => {
- const bands = bandsClearOf(60);
- const inner = LAND_BANDS.map((c) => bands[c][0]);
-
- for (let i = 1; i < inner.length; i += 1) {
- expect(inner[i], `${LAND_BANDS[i]} is not beyond ${LAND_BANDS[i - 1]}`).toBeGreaterThan(
- inner[i - 1],
- );
- }
- expect(Math.min(...inner)).toBeGreaterThanOrEqual(60 + SCENERY_WATER_MARGIN_METRES);
- });
-
- it('moves the outer bands of a 20 m channel by less than a metre', () => {
- // The authored bands were drawn for this channel, and everything from the
- // scatter out was already on land. The bank edge and the furniture were
- // not - they started 4 m and 2 m inside its 10 m half-width - so those two
- // move exactly as far as the bank needs and no further.
- const bands = bandsClearOf(10);
-
- for (const cat of LAND_BANDS.slice(2)) {
- expect(Math.abs(shift(bands, cat)), cat).toBeLessThan(1);
- }
- expect(bands.bankEdge[0]).toBeCloseTo(10 + SCENERY_WATER_MARGIN_METRES, 9);
- });
- });
-
- describe('computePlacements', () => {
- it('keeps every bank placement on a 55 m river beyond 27.5 m and the margin', () => {
- const placed = bothBanks(water(55)).filter((p) => p.footing === 'bank');
- const reading = landClearance(placed, straight, water(55));
-
- expect(reading.count).toBeGreaterThan(20);
- expect(reading.nearestM).toBeGreaterThanOrEqual(SCENERY_WATER_MARGIN_METRES - 1e-6);
- });
-
- it('puts the bank dressing further out than 27.5 m on a 55 m river', () => {
- const ids = new Set([
- ...fallbackResolved.bankEdge,
- ...fallbackResolved.furniture,
- ...fallbackResolved.scatter,
- ]);
- const dressing = bothBanks(water(55)).filter((p) => ids.has(p.id));
-
- expect(dressing.length).toBeGreaterThan(0);
- for (const p of dressing) expect(Math.abs(p.position[0])).toBeGreaterThan(27.5);
- });
-
- it('clears the water on a 120 m segment, and on a curve', () => {
- const bend = new THREE.CatmullRomCurve3([
- new THREE.Vector3(0, 0, -400),
- new THREE.Vector3(150, 0, 0),
- new THREE.Vector3(0, 0, 400),
- ]);
- const placed = bothBanks(water(120), bend).filter((p) => p.footing === 'bank');
-
- expect(placed.length).toBeGreaterThan(20);
- expect(landClearance(placed, bend, water(120)).nearestM).toBeGreaterThanOrEqual(
- SCENERY_WATER_MARGIN_METRES - 1e-6,
- );
- });
-
- it('leaves what belongs on the water inside its authored band', () => {
- const afloat = bothBanks(water(120)).filter((p) => p.footing === 'water');
- const surfaceIds = new Set(fallbackResolved.surface);
-
- expect(afloat.length).toBeGreaterThan(0);
- for (const p of afloat) {
- expect(surfaceIds.has(p.id)).toBe(true);
- expect(Math.abs(p.position[0])).toBeLessThanOrEqual(CATEGORY_OFFSET.surface[1]);
- }
- });
-
- it('clears the water in flat mode too', () => {
- const e = water(55);
- const placed = (['left', 'right'] as const)
- .flatMap((side) =>
- computePlacements({ enrichment: e, resolvedByProfile: byProfile(), budget: 1, side }),
- )
- .filter((p) => p.footing === 'bank');
-
- expect(placed.length).toBeGreaterThan(0);
- for (const p of placed) {
- expect(Math.abs(p.position[0])).toBeGreaterThanOrEqual(27.5 + SCENERY_WATER_MARGIN_METRES);
- }
- });
- });
-});
diff --git a/src/__tests__/sceneryRerender.test.tsx b/src/__tests__/sceneryRerender.test.tsx
deleted file mode 100644
index a88728c2..00000000
--- a/src/__tests__/sceneryRerender.test.tsx
+++ /dev/null
@@ -1,155 +0,0 @@
-import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
-import { Profiler } from 'react';
-import * as THREE from 'three';
-import ReactThreeTestRenderer from '@react-three/test-renderer';
-import { installCanvasMock } from './canvasMock';
-
-/**
- * Issue #331 — steady rowing does not re-render the scenery.
- *
- * `RowerScene` called `setSceneryState` every 0.1 s, and the landscape read it
- * as a `boatProgress` prop. So moving the boat *necessarily* re-rendered the
- * bank: the component answered by re-running its placement filters and
- * rebuilding JSX for hundreds of trees and houses, ten times a second, on a
- * boat doing nothing but going straight. That is what the periodic 100–300 ms
- * frames in `frameStats.p95Ms` were, and why they showed up independently of
- * GPU load.
- *
- * Counting commits is the only way to state it: a scene graph that looks right
- * says nothing about how many times React rebuilt it to get there, which is
- * exactly why this went unnoticed while every other scene test passed.
- *
- * Measured on the landscape itself rather than on the whole scene. The scene
- * re-renders for reasons that have nothing to do with scenery — the stroke
- * phase changes several times a stroke — and a count that swept those in would
- * answer a different question from the one #331 asked.
- */
-vi.mock('@react-three/drei', async () => {
- const actual = await vi.importActual>('@react-three/drei');
- return { ...actual, Cloud: () => null };
-});
-
-const { CurvedLandscapeElements, LANDSCAPE_ELEMENT_NAME } = await import(
- '../components/rower3d/bankComponents'
-);
-const { AnimationProvider } = await import('../components/rower3d/AnimationContext');
-
-/** A straight 2 km route, so a boat's distance from a tree is easy to reason about. */
-const straightRoute = () =>
- new THREE.CatmullRomCurve3([
- new THREE.Vector3(0, 0, 0),
- new THREE.Vector3(0, 0, -1000),
- new THREE.Vector3(0, 0, -2000),
- ]);
-
-describe('steady rowing', () => {
- let uninstallCanvasMock: () => void;
- beforeAll(() => {
- uninstallCanvasMock = installCanvasMock();
- });
- afterAll(() => uninstallCanvasMock());
-
- /**
- * Mount the bank with a boat the test can move, and count its commits.
- *
- * `mountProgress` 0.15 either side of 0.1 covers the first 500 m of the 2 km
- * route, so the boat can row inside the mounted window and the cull is what
- * decides what is drawn — which is the thing under test. Mounting is a
- * separate, far slower decision (#331).
- */
- const mountBank = async (viewDistance = 200) => {
- const boat = { current: new THREE.Vector3(0, 0, 0) };
- let commits = 0;
-
- const renderer = await ReactThreeTestRenderer.create(
-
- {
- commits += 1;
- }}
- >
-
-
- ,
- );
-
- /**
- * The per-element groups the cull writes `visible` on. By name, since the
- * trees beside them are instanced and culled per instance (#333).
- */
- const elements = (): THREE.Object3D[] => {
- const scene = renderer.scene.instance as unknown as THREE.Scene;
- const found: THREE.Object3D[] = [];
- scene.traverse((object) => {
- if (object.name === LANDSCAPE_ELEMENT_NAME) found.push(object);
- });
- return found;
- };
-
- const rowTo = async (z: number) => {
- boat.current.set(0, 0, z);
- await ReactThreeTestRenderer.act(async () => {
- await renderer.advanceFrames(12, 1 / 60);
- });
- };
-
- return { renderer, rowTo, elements, commitsSoFar: () => commits };
- };
-
- it('draws what is near the boat and not what is far from it', async () => {
- const bank = await mountBank(200);
-
- await bank.rowTo(0);
- const drawnAtStart = bank.elements().filter((o) => o.visible);
- expect(drawnAtStart.length, 'nothing was drawn at the start').toBeGreaterThan(0);
-
- // Four hundred metres down the route with a 200 m view distance: nothing
- // that stood beside the boat at the start is still in range.
- await bank.rowTo(-400);
- const nearStartStillDrawn = bank
- .elements()
- .filter((object) => object.visible && object.position.z > -180);
-
- expect(
- nearStartStillDrawn.length,
- 'the bank by the start line is still drawn four hundred metres later',
- ).toBe(0);
-
- await bank.renderer.unmount();
- });
-
- it('changes what is drawn without re-rendering to do it', async () => {
- const bank = await mountBank(200);
-
- await bank.rowTo(0);
- const afterMount = bank.commitsSoFar();
- const atStart = bank
- .elements()
- .filter((object) => object.visible)
- .map((object) => object.position.z);
-
- await bank.rowTo(-400);
- const later = bank
- .elements()
- .filter((object) => object.visible)
- .map((object) => object.position.z);
-
- // The premise: something really did change. Without it the commit count
- // below would be satisfied by a cull that never ran at all.
- expect(later, 'the cull did not react to the boat moving').not.toEqual(atStart);
-
- expect(
- bank.commitsSoFar() - afterMount,
- 'moving the boat still rebuilds the bank',
- ).toBe(0);
-
- await bank.renderer.unmount();
- });
-});
diff --git a/src/__tests__/scenerySkyPass.test.ts b/src/__tests__/scenerySkyPass.test.ts
deleted file mode 100644
index f4f7111e..00000000
--- a/src/__tests__/scenerySkyPass.test.ts
+++ /dev/null
@@ -1,111 +0,0 @@
-import { afterAll, beforeAll, describe, it, expect } from 'vitest';
-import * as THREE from 'three';
-import { installCanvasMock } from './canvasMock';
-import {
- applyScenerySkyPass,
- isBuildingPath,
- isWindowColor,
- tierFromPath,
-} from '../components/rower3d/scenerySkyPass';
-
-let uninstallCanvas: () => void;
-beforeAll(() => {
- uninstallCanvas = installCanvasMock();
-});
-afterAll(() => uninstallCanvas());
-
-const buildingWithParts = (parts: { name: string; hex: string }[]) => {
- const group = new THREE.Group();
- for (const part of parts) {
- const mat = new THREE.MeshStandardMaterial({ color: new THREE.Color(part.hex) });
- const mesh = new THREE.Mesh(new THREE.BoxGeometry(), mat);
- mesh.name = part.name;
- group.add(mesh);
- }
- return group;
-};
-
-describe('tierFromPath / isBuildingPath (#432)', () => {
- it('reads the tier off a scenery GLB path', () => {
- expect(tierFromPath('/scenery/tier-b/b03-boathouse.glb')).toBe('tier-b');
- expect(tierFromPath('/scenery/tier-d/d10-brick-terrace.glb')).toBe('tier-d');
- expect(tierFromPath('/scenery/tier-e/e01-oak.glb')).toBe('tier-e');
- });
-
- it('names tier-b, tier-d and tier-g as building tiers, and only them', () => {
- expect(isBuildingPath('/x/tier-b/foo.glb')).toBe(true);
- expect(isBuildingPath('/x/tier-d/foo.glb')).toBe(true);
- expect(isBuildingPath('/x/tier-g/foo.glb')).toBe(true);
- expect(isBuildingPath('/x/tier-e/foo.glb')).toBe(false);
- expect(isBuildingPath('/x/tier-f/foo.glb')).toBe(false);
- });
-});
-
-describe('isWindowColor (#432)', () => {
- it('recognises the exporter’s glaze palette', () => {
- expect(isWindowColor(new THREE.Color('#2a3a44'))).toBe(true);
- expect(isWindowColor(new THREE.Color('#3a4e5a'))).toBe(true);
- });
-
- it('does not confuse a facade colour for glass', () => {
- expect(isWindowColor(new THREE.Color('#a24c2e'))).toBe(false);
- expect(isWindowColor(new THREE.Color('#f0e8d0'))).toBe(false);
- expect(isWindowColor(new THREE.Color('#6b7a70'))).toBe(false);
- });
-});
-
-describe('applyScenerySkyPass (#432)', () => {
- it('swaps a building’s facade parts for a facade material', () => {
- const scene = buildingWithParts([
- { name: 'wall', hex: '#a24c2e' },
- { name: 'wall2', hex: '#f0e8d0' },
- ]);
-
- applyScenerySkyPass(scene, 'medium');
-
- const meshes = scene.children as THREE.Mesh[];
- expect(meshes[0].material).toBeInstanceOf(THREE.MeshStandardMaterial);
- // A facade at auto carries a normal map.
- expect((meshes[0].material as THREE.MeshStandardMaterial).normalMap).not.toBeNull();
- });
-
- it('swaps a glass part for a physical window material', () => {
- const scene = buildingWithParts([
- { name: 'window', hex: '#2a3a44' },
- ]);
-
- applyScenerySkyPass(scene, 'medium');
-
- const mesh = scene.children[0] as THREE.Mesh;
- expect(mesh.material).toBeInstanceOf(THREE.MeshPhysicalMaterial);
- });
-
- it('is idempotent — a second pass leaves the swapped materials in place', () => {
- const scene = buildingWithParts([{ name: 'wall', hex: '#a24c2e' }]);
-
- applyScenerySkyPass(scene, 'medium');
- const first = (scene.children[0] as THREE.Mesh).material;
- applyScenerySkyPass(scene, 'medium');
- const second = (scene.children[0] as THREE.Mesh).material;
-
- expect(second).toBe(first);
- });
-
- it('lifts every replacement with the scenery env-map intensity', () => {
- const scene = buildingWithParts([{ name: 'wall', hex: '#a24c2e' }]);
-
- applyScenerySkyPass(scene, 'medium');
-
- const mat = (scene.children[0] as THREE.Mesh).material as THREE.MeshStandardMaterial;
- expect(mat.envMapIntensity).toBeGreaterThan(0);
- });
-
- it('drops the normal map on low tier facades', () => {
- const scene = buildingWithParts([{ name: 'wall', hex: '#a24c2e' }]);
-
- applyScenerySkyPass(scene, 'basic');
-
- const mat = (scene.children[0] as THREE.Mesh).material as THREE.MeshStandardMaterial;
- expect(mat.normalMap).toBeNull();
- });
-});
diff --git a/src/__tests__/sceneryStructures.test.ts b/src/__tests__/sceneryStructures.test.ts
deleted file mode 100644
index ec919138..00000000
--- a/src/__tests__/sceneryStructures.test.ts
+++ /dev/null
@@ -1,432 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import fs from 'fs';
-import path from 'path';
-import * as THREE from 'three';
-import {
- routeStructures,
- COURSE_FURNITURE,
- LIVERIED_LANDMARKS,
- courseStructures,
- landmarkStructures,
- crossingStructures,
- computeStructurePlacements,
-} from '../components/rower3d/sceneryStructures';
-import type { Crossing } from '../lib/utils/bridgeCrossings';
-import { buildTerrainProfile, type RouteEnrichmentData } from '../lib/services/routeEnrichmentService';
-import {
- SCENERY_WATER_MARGIN_METRES,
- waterHalfWidthAt,
-} from '../components/rower3d/sceneryClearance';
-
-const curve = new THREE.CatmullRomCurve3([
- new THREE.Vector3(0, 0, -200),
- new THREE.Vector3(10, 0, -100),
- new THREE.Vector3(-10, 0, 0),
- new THREE.Vector3(10, 0, 100),
- new THREE.Vector3(0, 0, 200),
-]);
-
-const assetExists = (id: string) =>
- ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'l'].some((tier) =>
- fs.existsSync(path.join(process.cwd(), 'public/assets/scenery', `tier-${tier}`, `${id}.glb`)),
- );
-
-describe('course furniture', () => {
- it('names models that exist', () => {
- const ids = [...COURSE_FURNITURE.start, ...COURSE_FURNITURE.finish];
-
- expect(ids.length).toBeGreaterThan(0);
- expect(ids.filter((id) => !assetExists(id))).toEqual([]);
- });
-
- it('draws on the Tier A and Tier B kits that nothing could reach before', () => {
- const ids = [...COURSE_FURNITURE.start, ...COURSE_FURNITURE.finish];
-
- expect(ids.some((id) => id.startsWith('b'))).toBe(true);
- expect(ids.some((id) => id.startsWith('a'))).toBe(true);
- });
-
- it('puts the start furniture at the start and the finish tower at the finish', () => {
- const structures = courseStructures(2000);
-
- const start = structures.filter((s) => s.progress < 0.1);
- const finish = structures.filter((s) => s.progress > 0.9);
-
- expect(start.length).toBeGreaterThan(0);
- expect(finish.length).toBeGreaterThan(0);
- expect(finish.map((s) => s.id)).toContain('a06-finish-tower');
- });
-});
-
-describe('liveried landmarks', () => {
- it('names models that exist', () => {
- const missing = LIVERIED_LANDMARKS.map((l) => l.id).filter((id) => !assetExists(id));
-
- expect(missing).toEqual([]);
- });
-
- it('pins Barnes Railway Bridge to the Thames at Barnes', () => {
- const thames = [
- { lat: 51.4712, lng: -0.2501 },
- { lat: 51.4735, lng: -0.2456 },
- { lat: 51.4756, lng: -0.2411 },
- ];
-
- const found = landmarkStructures(thames);
-
- expect(found.map((s) => s.id)).toContain('l-barnes-railway-bridge');
- });
-
- it('places nothing on water the landmarks do not belong to', () => {
- const elsewhere = [
- { lat: -33.86, lng: 151.21 },
- { lat: -33.87, lng: 151.22 },
- ];
-
- expect(landmarkStructures(elsewhere)).toEqual([]);
- });
-});
-
-describe('crossingStructures', () => {
- it('turns a crossing into the Tier C model for its kind', () => {
- const crossings: Crossing[] = [{ progress: 0.4, kind: 'rail' }];
-
- expect(crossingStructures(crossings)).toEqual([
- { id: 'c04-bridge-rail-girder', progress: 0.4, kind: 'bridge' },
- ]);
- });
-
- it('handles a route with no bridges', () => {
- expect(crossingStructures([])).toEqual([]);
- expect(crossingStructures(undefined)).toEqual([]);
- });
-});
-
-describe('computeStructurePlacements', () => {
- it('puts a bridge on the rowed line and furniture off to the side', () => {
- const [bridge] = computeStructurePlacements(curve, [
- { id: 'c02-bridge-road-concrete', progress: 0.5, kind: 'bridge' },
- ]);
- const [furniture] = computeStructurePlacements(curve, [
- { id: 'a06-finish-tower', progress: 0.5, kind: 'furniture' },
- ]);
-
- const offCentre = (p: typeof bridge) => Math.hypot(p.position[0], p.position[2]);
-
- expect(bridge.id).toBe('c02-bridge-road-concrete');
- expect(Math.abs(offCentre(bridge) - offCentre(furniture))).toBeGreaterThan(0);
- });
-
- it('is deterministic, so a route dresses the same way twice', () => {
- const request = [{ id: 'a06-finish-tower', progress: 0.9, kind: 'furniture' as const }];
-
- expect(computeStructurePlacements(curve, request)).toEqual(
- computeStructurePlacements(curve, request),
- );
- });
-
- it('produces finite transforms', () => {
- const placements = computeStructurePlacements(curve, [
- { id: 'c01-bridge-arch-masonry', progress: 0, kind: 'bridge' },
- { id: 'l-fremont-bridge', progress: 1, kind: 'landmark' },
- ]);
-
- expect(placements).toHaveLength(2);
- for (const p of placements) {
- expect(p.position.every(Number.isFinite)).toBe(true);
- expect(Number.isFinite(p.rotationY)).toBe(true);
- expect(p.scale).toBeGreaterThan(0);
- }
- });
-
- it('places nothing without a curve', () => {
- expect(computeStructurePlacements(null, [{ id: 'a06-finish-tower', progress: 0, kind: 'furniture' }])).toEqual([]);
- });
-});
-
-
-describe('structures that span the water (review of #232)', () => {
- // Every Tier L landmark is a bridge, and each is authored the way the Tier C
- // bridges are: the span runs along the model's X. They were given the
- // "everything else faces the water" quarter turn, which laid Ponte Isabella
- // along the rowed line instead of across it.
- it('orients a liveried landmark across the water, like the bridge it is', () => {
- // Routed through landmarkStructures, because that is what decides the kind:
- // Ponte Isabella is authored like the Tier C arch (span along X), so it has
- // to be laid across the rowed line, not turned a quarter along it.
- const route = [
- { lat: 45.0447, lng: 7.6858 },
- { lat: 45.0457, lng: 7.6868 },
- ];
- const requests = landmarkStructures(route);
- const isabella = requests.find((r) => r.id === 'l-ponte-isabella');
- expect(isabella).toBeDefined();
-
- const [landmark] = computeStructurePlacements(curve, [{ ...isabella!, progress: 0.5 }]);
- const [bridge] = computeStructurePlacements(curve, [
- { id: 'c01-bridge-arch-masonry', progress: 0.5, kind: 'bridge' },
- ]);
-
- expect(landmark.rotationY).toBeCloseTo(bridge.rotationY, 6);
- });
-
- it('stands a non-spanning hero on the bank, facing the water', () => {
- // The 'landmark' kind now means exactly this, so a lighthouse added to the
- // table later does not inherit a bridge's place on the rowed line.
- const [hero] = computeStructurePlacements(curve, [
- { id: 'l-ponte-isabella', progress: 0.5, kind: 'landmark' },
- ]);
- const [bridge] = computeStructurePlacements(curve, [
- { id: 'c01-bridge-arch-masonry', progress: 0.5, kind: 'bridge' },
- ]);
-
- expect(Math.abs(hero.rotationY - bridge.rotationY)).toBeCloseTo(Math.PI / 2, 6);
- expect(Math.hypot(hero.position[0] - bridge.position[0], hero.position[2] - bridge.position[2]))
- .toBeGreaterThan(1);
- });
-
- it('still turns bank furniture to face the water', () => {
- const at = 0.5;
- const [bridge] = computeStructurePlacements(curve, [
- { id: 'c01-bridge-arch-masonry', progress: at, kind: 'bridge' },
- ]);
- const [furniture] = computeStructurePlacements(curve, [
- { id: 'a06-finish-tower', progress: at, kind: 'furniture' },
- ]);
-
- expect(Math.abs(furniture.rotationY - bridge.rotationY)).toBeCloseTo(Math.PI / 2, 6);
- });
-
- it('asks for a spanning landmark as the bridge it is', () => {
- // Whether a hero spans the water is a property of the structure, not
- // something the renderer should infer from the tier it came from.
- const route = [
- { lat: 51.4739, lng: -0.2463 },
- { lat: 51.4749, lng: -0.2453 },
- ];
-
- const [barnes] = landmarkStructures(route);
-
- expect(LIVERIED_LANDMARKS.every((l) => typeof l.spansWater === 'boolean')).toBe(true);
- expect(barnes.kind).toBe('bridge');
- });
-});
-
-describe('structures sit on the ground (review of #232)', () => {
- // Scatter in the same [0,0,0] group is lifted by getTerrainReliefForProgress;
- // structures were pinned to y = 0, so bank furniture was buried or floating
- // on any route with relief.
- const hilly = buildTerrainProfile([0, 12, 30, 18, 4]);
-
- it('lifts bank furniture onto the terrain', () => {
- const [flat] = computeStructurePlacements(curve, [
- { id: 'a06-finish-tower', progress: 0.5, kind: 'furniture' },
- ]);
- const [lifted] = computeStructurePlacements(
- curve,
- [{ id: 'a06-finish-tower', progress: 0.5, kind: 'furniture' }],
- hilly,
- );
-
- expect(flat.position[1]).toBe(0);
- expect(lifted.position[1]).toBeGreaterThan(0);
- });
-
- it('leaves structures on flat water at zero', () => {
- const [p] = computeStructurePlacements(
- curve,
- [{ id: 'a06-finish-tower', progress: 0.5, kind: 'furniture' }],
- buildTerrainProfile([]),
- );
-
- expect(p.position[1]).toBe(0);
- });
-
- it('keeps a bridge on the water line, whatever the banks do', () => {
- // A bridge deck is placed relative to the water it spans, not the hillside.
- const [p] = computeStructurePlacements(
- curve,
- [{ id: 'c01-bridge-arch-masonry', progress: 0.5, kind: 'bridge' }],
- hilly,
- );
-
- expect(p.position[1]).toBe(0);
- });
-});
-
-
-describe('routeStructures — what a route actually asks for (review of #232)', () => {
- const coordinates = [
- { lat: 51.4739, lng: -0.2463 },
- { lat: 51.4749, lng: -0.2453 },
- ];
-
- it('places structures once per route, on one bank only', () => {
- const left = routeStructures({ side: 'left', hasCurve: true, coordinates, routeMeters: 2000 });
- const right = routeStructures({ side: 'right', hasCurve: true, coordinates, routeMeters: 2000 });
-
- expect(left.length).toBeGreaterThan(0);
- expect(right).toEqual([]);
- });
-
- it('asks for nothing when there is no curve to place them on', () => {
- // computeStructurePlacements returns [] without a curve, but the models
- // were still added to the useGLTF path list, so a straight-mode route
- // downloaded and suspended on 8+ GLBs — two of them Tier B — to render
- // none of them.
- expect(routeStructures({ side: 'left', hasCurve: false, coordinates })).toEqual([]);
- });
-
- it('still carries the course furniture and any landmark the route earns', () => {
- const ids = routeStructures({ side: 'left', hasCurve: true, coordinates, routeMeters: 2000 }).map((s) => s.id);
-
- expect(ids).toContain('a06-finish-tower');
- expect(ids).toContain('l-barnes-railway-bridge');
- });
-});
-
-describe('structures stand clear of the water (#379)', () => {
- // A straight route along +z, so the distance across the water is |x|.
- const straight = new THREE.CatmullRomCurve3([
- new THREE.Vector3(0, 0, -300),
- new THREE.Vector3(0, 0, 0),
- new THREE.Vector3(0, 0, 300),
- ]);
-
- const river = (widthMeters: number): RouteEnrichmentData =>
- ({
- routeId: 'river',
- elevations: [],
- segmentProfiles: [0, 1, 2].map((index) => ({
- index,
- startMeters: index * 50,
- endMeters: (index + 1) * 50,
- sceneryProfile: 'fallback',
- treeDensity: 0.5,
- vegetationDensity: 0.5,
- buildingDensity: 0.2,
- 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 across = (kind: 'bridge' | 'furniture' | 'landmark', e?: RouteEnrichmentData | null) => {
- const [p] = computeStructurePlacements(
- straight,
- [{ id: 'a06-finish-tower', progress: 0.5, kind }],
- null,
- e,
- );
- return { distance: Math.abs(p.position[0]), footing: p.footing };
- };
-
- it('stands a landmark on the bank of a 55 m river, past the shoreline margin', () => {
- const landmark = across('landmark', river(55));
-
- expect(landmark.distance).toBeGreaterThanOrEqual(27.5);
- expect(landmark.distance).toBeGreaterThanOrEqual(27.5 + SCENERY_WATER_MARGIN_METRES);
- expect(landmark.footing).toBe('bank');
- });
-
- it('stands course furniture on the bank of a 55 m river', () => {
- expect(across('furniture', river(55)).distance).toBeGreaterThanOrEqual(
- 27.5 + SCENERY_WATER_MARGIN_METRES,
- );
- });
-
- it('keeps a bridge on the centreline, however wide the water', () => {
- for (const width of [7, 55, 120]) {
- const bridge = across('bridge', river(width));
- expect(bridge.distance).toBeCloseTo(0, 9);
- expect(bridge.footing).toBe('water');
- }
- });
-
- it('leaves furniture where it was on a stream narrow enough to allow it', () => {
- // 7 m of water: half of the 7.06 m floor plus the margin is still inside
- // the 7 m the furniture was authored at.
- expect(across('furniture', river(7)).distance).toBeCloseTo(7, 9);
- });
-
- it('reads the default channel for a route with no enrichment, as the water does', () => {
- expect(across('furniture', null).distance).toBeCloseTo(
- waterHalfWidthAt(null, 0.5, 'right') + SCENERY_WATER_MARGIN_METRES,
- 9,
- );
- });
-});
-
-describe('the course marked on the water (#336)', () => {
- const straight = new THREE.CatmullRomCurve3([
- new THREE.Vector3(0, 0, -300),
- new THREE.Vector3(0, 0, 0),
- new THREE.Vector3(0, 0, 300),
- ]);
-
- it('stands exactly one finish tower, on the finish line', () => {
- const towers = courseStructures(2000).filter((s) => s.id === 'a06-finish-tower');
- expect(towers).toHaveLength(1);
- expect(towers[0].progress).toBe(1);
- });
-
- it('carries a post every 500 m of the route', () => {
- const posts = courseStructures(2000).filter((s) => s.id === 'a07-distance-marker-post');
- expect(posts.map((p) => p.progress)).toEqual([0.25, 0.5, 0.75, 1]);
- });
-
- it('keeps the ends dressed on a route whose length is not known', () => {
- const ids = courseStructures().map((s) => s.id);
- expect(ids).toContain('a05-stakeboat-platform');
- expect(ids).not.toContain('a07-distance-marker-post');
- });
-
- it('floats the buoys in the water either side of the boat, clear of its blades', () => {
- const placed = computeStructurePlacements(straight, [
- { id: 'a01-buoy-lane-sphere', progress: 0.5, kind: 'buoy', side: 'left' },
- { id: 'a01-buoy-lane-sphere', progress: 0.5, kind: 'buoy', side: 'right' },
- ]);
- const [left, right] = placed.map((p) => p.position[0]);
-
- expect(Math.sign(left)).toBe(-Math.sign(right));
- for (const p of placed) {
- expect(p.footing).toBe('water');
- expect(p.position[1]).toBe(0);
- // A scull's blades reach about 2.9 m either side of the hull.
- expect(Math.abs(p.position[0])).toBeGreaterThan(3);
- // And inside the default channel's water, not on its bank.
- expect(Math.abs(p.position[0])).toBeLessThan(waterHalfWidthAt(null, 0.5, 'right'));
- }
- });
-
- it('stands a post on the other bank from the tower, facing the water', () => {
- const [post, tower] = computeStructurePlacements(straight, [
- { id: 'a07-distance-marker-post', progress: 0.5, kind: 'furniture', side: 'left' },
- { id: 'a06-finish-tower', progress: 0.5, kind: 'furniture', side: 'right' },
- ]);
-
- expect(Math.sign(post.position[0])).toBe(-Math.sign(tower.position[0]));
- expect(Math.abs(post.position[0])).toBeCloseTo(Math.abs(tower.position[0]), 9);
- expect(post.footing).toBe('bank');
- // Each turned a quarter from the line, towards the water: opposite ways.
- expect(post.rotationY).toBeCloseTo(-tower.rotationY, 9);
- });
-
- it('places a request with no side where furniture always stood', () => {
- const [sided] = computeStructurePlacements(straight, [
- { id: 'a06-finish-tower', progress: 0.5, kind: 'furniture', side: 'right' },
- ]);
- const [unsided] = computeStructurePlacements(straight, [
- { id: 'a06-finish-tower', progress: 0.5, kind: 'furniture' },
- ]);
- expect(sided).toEqual(unsided);
- });
-});
diff --git a/src/__tests__/servicesContext.test.tsx b/src/__tests__/servicesContext.test.tsx
deleted file mode 100644
index 96906267..00000000
--- a/src/__tests__/servicesContext.test.tsx
+++ /dev/null
@@ -1,88 +0,0 @@
-import { describe, it, expect, vi } from 'vitest';
-import { render } from '@testing-library/react';
-import { ServicesProvider } from '../context/ServicesContext';
-import { useServices, defaultServices } from '../context/useServices';
-import type { Services } from '../lib/ports';
-
-function Probe({ onResolve }: { onResolve: (s: Services) => void }) {
- const services = useServices();
- onResolve(services);
- return null;
-}
-
-describe('ServicesProvider / useServices', () => {
- it('returns the production default services when no override is provided', () => {
- let resolved: Services | null = null;
- render(
-
- { resolved = s; }} />
- ,
- );
- expect(resolved).not.toBeNull();
- expect(resolved!.workoutService).toBe(defaultServices.workoutService);
- expect(resolved!.routeService).toBe(defaultServices.routeService);
- expect(resolved!.pm5BluetoothService).toBe(defaultServices.pm5BluetoothService);
- expect(resolved!.rownativeService).toBe(defaultServices.rownativeService);
- });
-
- it('falls back to defaultServices when no provider is mounted (so legacy tests still work)', () => {
- let resolved: Services | null = null;
- render( { resolved = s; }} />);
- expect(resolved).not.toBeNull();
- expect(resolved!.workoutService).toBe(defaultServices.workoutService);
- });
-
- it('lets callers override individual ports while inheriting the rest', () => {
- // A distinct object that still satisfies the port. This test is about the
- // provider's merge behaviour, so all it needs is a different identity.
- const stubWorkout: Services['workoutService'] = Object.create(
- defaultServices.workoutService,
- );
- let resolved: Services | null = null;
- render(
-
- { resolved = s; }} />
- ,
- );
- expect(resolved!.workoutService).toBe(stubWorkout);
- // Non-overridden ports keep production defaults.
- expect(resolved!.routeService).toBe(defaultServices.routeService);
- expect(resolved!.heartRateBluetoothService).toBe(defaultServices.heartRateBluetoothService);
- });
-
- it('accepts a plain-object stub for the route enrichment port', () => {
- // The port is a method surface, not a class alias, so this literal must
- // type-check without a cast — that is what lets app tests stub enrichment
- // without module mocking.
- const routeEnrichmentService: Services['routeEnrichmentService'] = {
- enrichRoute: vi.fn(),
- readCached: vi.fn(() => ({ data: null, stale: false })),
- clearCache: vi.fn(),
- clearAllCache: vi.fn(),
- };
- let resolved: Services | null = null;
- render(
-
- { resolved = s; }} />
- ,
- );
- expect(resolved!.routeEnrichmentService).toBe(routeEnrichmentService);
- expect(resolved!.routeService).toBe(defaultServices.routeService);
- });
-
- it('exposes all expected ports on the Services bundle', () => {
- const expected: Array = [
- 'workoutService',
- 'routeService',
- 'pm5BluetoothService',
- 'ftmsBluetoothService',
- 'heartRateBluetoothService',
- 'authService',
- 'rownativeService',
- 'routeEnrichmentService',
- ];
- for (const key of expected) {
- expect(defaultServices[key]).toBeDefined();
- }
- });
-});
diff --git a/src/__tests__/sessionSummary.test.tsx b/src/__tests__/sessionSummary.test.tsx
deleted file mode 100644
index 70f5dfda..00000000
--- a/src/__tests__/sessionSummary.test.tsx
+++ /dev/null
@@ -1,424 +0,0 @@
-import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-import { render, screen, waitFor, within } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-vi.mock('react-chartjs-2', () => ({
- Line: () => ,
-}));
-
-import * as exporters from '../lib/utils/exporters';
-import { SessionSummary } from '../components/SessionSummary';
-import { ServicesProvider } from '../context/ServicesContext';
-import { AuthContext, type AuthContextValue } from '../context/useAuth';
-import type { ActivityUploadAuth, ActivityUploadResult } from '../lib/services/intervalsIcuActivityService';
-import type { ActivitySample, WorkoutSession } from '../lib/types/index';
-
-/**
- * The signed-in end-of-session summary (issue #221, R4) and the rule that a
- * guest or demo row never reaches the upload (R5).
- */
-
-function makeSamples(count: number): ActivitySample[] {
- return Array.from({ length: count }, (_, t) => ({
- t,
- distance: t * 4,
- pace: 127,
- power: 149,
- cadence: 24,
- heartRate: 132,
- lat: 51.5,
- lng: -0.9 + t * 0.0001,
- }));
-}
-
-function makeSession(overrides: Partial = {}): WorkoutSession {
- return {
- id: 's-1',
- routeId: 'r1',
- routeName: 'Willowbrook River',
- startTime: new Date('2026-03-14T08:00:00Z'),
- endTime: new Date('2026-03-14T08:21:14Z'),
- duration: 1274,
- distance: 5000,
- averagePace: 127,
- calories: 240,
- splits: [],
- isActive: false,
- heartRateAvg: 132,
- heartRateMax: 147,
- samples: makeSamples(30),
- ...overrides,
- };
-}
-
-const AUTHENTICATED: AuthContextValue = {
- user: { id: 'i123', name: 'Alex', email: 'alex@example.com' },
- isAuthenticated: true,
- isLoading: false,
- authError: null,
- authErrorClass: null,
- login: async () => {},
- logout: () => {},
- clearAuthError: () => {},
- pendingAction: null,
- setPendingAction: () => {},
-};
-
-type UploadArgs = [WorkoutSession, Uint8Array, ActivityUploadAuth];
-type UploadMock = ReturnType;
-
-/** A stubbed `uploadActivity`, typed to the port so a drifted stub fails the build. */
-function uploadMock(
- impl: (...args: UploadArgs) => Promise = async () => ({
- status: 'uploaded',
- activityId: 'i9090',
- activityUrl: 'https://intervals.icu/activities/i9090',
- }),
-) {
- return vi.fn<(...args: UploadArgs) => Promise>(impl);
-}
-
-function renderSummary(
- props: Partial> = {},
- options: { uploadActivity?: UploadMock; auth?: AuthContextValue } = {},
-) {
- const uploadActivity = options.uploadActivity ?? uploadMock();
-
- render(
-
-
-
-
- ,
- );
- return { uploadActivity };
-}
-
-describe('SessionSummary (issue #221, R4)', () => {
- beforeEach(() => {
- vi.stubGlobal('URL', Object.assign(URL, {
- createObjectURL: vi.fn(() => 'blob:stub'),
- revokeObjectURL: vi.fn(),
- }));
- });
-
- afterEach(() => {
- vi.unstubAllGlobals();
- vi.restoreAllMocks();
- });
-
- it('shows the row a signed-in athlete just finished (AC4.1)', () => {
- renderSummary();
-
- expect(screen.getByRole('heading', { name: /workout complete/i })).toBeInTheDocument();
- expect(screen.getByText('Willowbrook River')).toBeInTheDocument();
- // The totals, which the splits below them repeat per 500 m (#337).
- const totals = within(document.querySelector('.session-summary-stats') as HTMLElement);
- expect(totals.getByText('5.00 km')).toBeInTheDocument();
- expect(totals.getByText('21:14')).toBeInTheDocument();
- expect(totals.getByText(/2:07/)).toBeInTheDocument();
- expect(totals.getByText('132')).toBeInTheDocument(); // avg HR
- expect(totals.getByText('147')).toBeInTheDocument(); // max HR
- expect(totals.getByText('149')).toBeInTheDocument(); // avg W
- });
-
- it('offers Save to intervals.icu when signed in with samples (AC4.2)', () => {
- renderSummary();
- expect(screen.getByRole('button', { name: /save to intervals\.icu/i })).toBeEnabled();
- });
-
- it('disables Save for a row that recorded nothing (AC4.2, AC1.7)', () => {
- renderSummary({ session: makeSession({ samples: [] }) });
- expect(screen.getByRole('button', { name: /save to intervals\.icu/i })).toBeDisabled();
- });
-
- it('shows no Save control when signed out (AC4.2)', () => {
- renderSummary({}, { auth: { ...AUTHENTICATED, isAuthenticated: false, user: null } });
- expect(screen.queryByRole('button', { name: /save to intervals\.icu/i })).not.toBeInTheDocument();
- });
-
- it('cannot be double-submitted, and becomes a link on success (AC4.3)', async () => {
- const user = userEvent.setup();
- let release: (result: ActivityUploadResult) => void = () => {};
- const uploadActivity = uploadMock(() => new Promise((resolve) => {
- release = resolve;
- }));
- renderSummary({}, { uploadActivity });
-
- const save = screen.getByRole('button', { name: /save to intervals\.icu/i });
- await user.click(save);
-
- // The encoder is dynamically imported, so wait for the upload to be in
- // flight rather than for the label alone — otherwise the second click can
- // land before the first attempt has reached the service at all.
- await waitFor(() => expect(uploadActivity).toHaveBeenCalledTimes(1));
-
- const saving = screen.getByRole('button', { name: /saving/i });
- expect(saving).toBeDisabled();
- await user.click(saving);
- expect(uploadActivity).toHaveBeenCalledTimes(1);
-
- release({ status: 'uploaded', activityId: 'i9090', activityUrl: 'https://intervals.icu/activities/i9090' });
-
- const link = await screen.findByRole('link', { name: /view on intervals\.icu/i });
- expect(link).toHaveAttribute('href', 'https://intervals.icu/activities/i9090');
- });
-
- it('uploads the encoded FIT bytes for the session (AC4.4)', async () => {
- const user = userEvent.setup();
- const { uploadActivity } = renderSummary();
-
- await user.click(screen.getByRole('button', { name: /save to intervals\.icu/i }));
-
- await waitFor(() => expect(uploadActivity).toHaveBeenCalledTimes(1));
- const [session, bytes] = uploadActivity.mock.calls[0];
- expect(session).toMatchObject({ id: 's-1' });
- expect(bytes).toBeInstanceOf(Uint8Array);
- expect(String.fromCharCode(...(bytes as Uint8Array).slice(8, 12))).toBe('.FIT');
- });
-
- it('downloads the same bytes under a route-and-date name (AC4.4)', async () => {
- const user = userEvent.setup();
- const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
- renderSummary();
-
- await user.click(screen.getByRole('button', { name: /download \.fit/i }));
-
- await waitFor(() => expect(click).toHaveBeenCalled());
- const anchor = click.mock.instances[0] as unknown as HTMLAnchorElement;
- expect(anchor.download).toBe('willowbrook-river-2026-03-14.fit');
- });
-
- it('dismisses with Done whether or not the row was saved (AC4.5)', async () => {
- const user = userEvent.setup();
- const onDone = vi.fn();
- renderSummary({ onDone });
-
- await user.click(screen.getByRole('button', { name: /^done$/i }));
- expect(onDone).toHaveBeenCalledTimes(1);
- });
-
- it('says the row is lost before it is dismissed unsaved (AC4.6)', () => {
- renderSummary();
- expect(screen.getByText(/not been saved to intervals\.icu/i)).toBeInTheDocument();
- });
-
- it('stops warning once the row is saved (AC4.6)', async () => {
- const user = userEvent.setup();
- renderSummary();
-
- await user.click(screen.getByRole('button', { name: /save to intervals\.icu/i }));
-
- await screen.findByRole('link', { name: /view on intervals\.icu/i });
- expect(screen.queryByText(/not been saved to intervals\.icu/i)).not.toBeInTheDocument();
- });
-
- it('surfaces an upload failure and stays retryable (AC3.5, AC3.6)', async () => {
- const user = userEvent.setup();
- const uploadActivity = uploadMock(async () => ({
- status: 'failed',
- message: 'intervals.icu rejected the upload (502): Bad Gateway',
- }));
- renderSummary({}, { uploadActivity });
-
- await user.click(screen.getByRole('button', { name: /save to intervals\.icu/i }));
-
- expect(await screen.findByRole('alert')).toHaveTextContent('502');
- expect(screen.getByRole('button', { name: /save to intervals\.icu/i })).toBeEnabled();
- });
-
- it('reports a session that was already saved (AC3.3)', async () => {
- const user = userEvent.setup();
- const uploadActivity = uploadMock(async () => ({
- status: 'already-uploaded',
- activityId: 'i5',
- activityUrl: 'https://intervals.icu/activities/i5',
- }));
- renderSummary({}, { uploadActivity });
-
- await user.click(screen.getByRole('button', { name: /save to intervals\.icu/i }));
-
- expect(await screen.findByText(/already saved/i)).toBeInTheDocument();
- expect(screen.getByRole('link', { name: /view on intervals\.icu/i })).toBeInTheDocument();
- });
-
- it('tells the athlete to sign in again when the session expired (AC3.4)', async () => {
- const user = userEvent.setup();
- const uploadActivity = uploadMock(async () => ({
- status: 'auth-expired',
- message: 'Your intervals.icu session expired — sign in again.',
- }));
- renderSummary({}, { uploadActivity });
-
- await user.click(screen.getByRole('button', { name: /save to intervals\.icu/i }));
-
- expect(await screen.findByRole('alert')).toHaveTextContent(/sign in again/i);
- });
-
- it('hands the activity id back so the row can be recorded as saved (AC6.2)', async () => {
- const user = userEvent.setup();
- const onSaved = vi.fn();
- renderSummary({ onSaved });
-
- await user.click(screen.getByRole('button', { name: /save to intervals\.icu/i }));
- await waitFor(() => expect(onSaved).toHaveBeenCalledWith('i9090'));
- });
-
- it('shows the gate-chain GPS chip only for gate-chain routes (issue #466, AC6)', () => {
- const PATTERN = /schematic line between start and finish/i;
-
- const { unmount } = render(
-
-
-
-
- ,
- );
- expect(screen.getByText(PATTERN)).toBeInTheDocument();
- unmount();
-
- for (const source of ['track', 'polygon-path', 'osm-derived', undefined] as const) {
- const { unmount: close } = render(
-
-
-
-
- ,
- );
- expect(screen.queryByText(PATTERN)).not.toBeInTheDocument();
- close();
- }
- });
-
- it('uploads the same bytes it offers for download (issue #466, AC7)', async () => {
- const user = userEvent.setup();
- // Spy on triggerBlobDownload so we capture the exact BlobPart handed to it,
- // bypassing jsdom's missing Blob.arrayBuffer. Both buttons funnel through
- // the component-local encode() helper, so byte-identical output here is the
- // only guarantee the FIT the athlete downloads matches what intervals.icu
- // received.
- const trigger = vi.spyOn(exporters, 'triggerBlobDownload').mockImplementation(() => {});
-
- const { uploadActivity } = renderSummary();
-
- await user.click(screen.getByRole('button', { name: /save to intervals\.icu/i }));
- await waitFor(() => expect(uploadActivity).toHaveBeenCalledTimes(1));
- const uploadedBytes = uploadActivity.mock.calls[0][1] as Uint8Array;
-
- await user.click(screen.getByRole('button', { name: /download \.fit/i }));
- await waitFor(() => expect(trigger).toHaveBeenCalledTimes(1));
- const downloadedBytes = trigger.mock.calls[0][0] as Uint8Array;
-
- expect(downloadedBytes).toBeInstanceOf(Uint8Array);
- expect(downloadedBytes.byteLength).toBe(uploadedBytes.byteLength);
- expect(Array.from(downloadedBytes)).toEqual(Array.from(uploadedBytes));
- });
-});
-
-describe('SessionSummary — guest and demo rows are never uploaded (issue #221, R5)', () => {
- beforeEach(() => {
- vi.stubGlobal('URL', Object.assign(URL, {
- createObjectURL: vi.fn(() => 'blob:stub'),
- revokeObjectURL: vi.fn(),
- }));
- });
-
- afterEach(() => {
- vi.unstubAllGlobals();
- });
-
- it('offers no upload control for a guest session (AC5.1)', () => {
- renderSummary({ session: makeSession({ isGuest: true }) });
- expect(screen.queryByRole('button', { name: /save to intervals\.icu/i })).not.toBeInTheDocument();
- });
-
- it('offers no upload control for a demo row, even signed in (AC5.2)', () => {
- renderSummary({ isDemo: true });
- expect(screen.queryByRole('button', { name: /save to intervals\.icu/i })).not.toBeInTheDocument();
- });
-
- it('still shows a signed-in demo row with its badge and a download (AC5.3)', () => {
- renderSummary({ isDemo: true });
- expect(screen.getByText(/demo row/i)).toBeInTheDocument();
- expect(screen.getByRole('button', { name: /download \.fit/i })).toBeInTheDocument();
- expect(screen.getByText('5.00 km')).toBeInTheDocument();
- });
-
- it('never calls uploadActivity for a guest or a demo row (AC5.4)', async () => {
- const user = userEvent.setup();
- const uploadActivity = uploadMock();
-
- const { unmount } = render(
-
-
-
-
- ,
- );
- await user.click(screen.getByRole('button', { name: /^done$/i }));
- unmount();
-
- render(
-
-
-
-
- ,
- );
- await user.click(screen.getByRole('button', { name: /^done$/i }));
-
- expect(uploadActivity).not.toHaveBeenCalled();
- });
-});
-
-describe('SessionSummary — the row itself (#337)', () => {
- const twoKm: ActivitySample[] = Array.from({ length: 501 }, (_, t) => ({
- t,
- distance: t * 4,
- pace: 125,
- power: 170,
- cadence: 26,
- heartRate: 141,
- }));
-
- it('lists a split per 500 m and draws the row', () => {
- renderSummary({ session: makeSession({ distance: 2000, samples: twoKm }) });
-
- const rows = within(screen.getByRole('table', { name: 'Splits' })).getAllByRole('row').slice(1);
- expect(rows).toHaveLength(4);
- expect(screen.getByTestId('row-chart')).toBeInTheDocument();
- });
-
- it('compares the row with the best on this route', () => {
- renderSummary({ session: makeSession({ averagePace: 127 }), personalBest: 125 });
- expect(screen.getByTestId('row-pb')).toHaveTextContent('Personal best 2:05/500m · +0:02 today');
- });
-
- it('calls a first row on the route a first row', () => {
- renderSummary({ personalBest: null });
- expect(screen.getByTestId('row-pb')).toHaveTextContent('First row on this route');
- });
-});
-
-/**
- * Issue #338 — the race, settled on the summary.
- *
- * `ghostVerdict.test.tsx` covers what the verdict says. These are about it
- * reaching the screen at all, and staying off it when nothing was raced.
- */
-describe('SessionSummary, after a race (#338)', () => {
- it('says how the race came out', () => {
- renderSummary({ ghost: { seconds: 1_290, label: 'your best' } });
-
- expect(screen.getByRole('status')).toHaveTextContent(/beat your best/i);
- });
-
- it('says nothing about a race for a row that raced nothing', () => {
- renderSummary();
-
- expect(screen.queryByText(/beat your best/i)).toBeNull();
- });
-});
diff --git a/src/__tests__/settingsPanel.test.tsx b/src/__tests__/settingsPanel.test.tsx
deleted file mode 100644
index 02fe7138..00000000
--- a/src/__tests__/settingsPanel.test.tsx
+++ /dev/null
@@ -1,124 +0,0 @@
-import { describe, it, expect, beforeEach, vi } from 'vitest';
-import { render, screen, fireEvent, act } from '@testing-library/react';
-import { SettingsPanel } from '../components/SettingsPanel';
-import {
- CrashReportsPreferenceStore,
- CRASH_REPORTS_ENABLED_KEY,
-} from '../lib/services/crashReportsPreferenceStore';
-
-/**
- * SettingsPanel renders the Diagnostics opt-out (#473, FR8 / AC8 / AC9).
- */
-describe('SettingsPanel (#473)', () => {
- beforeEach(() => {
- localStorage.clear();
- });
-
- it('does not render when closed', () => {
- const prefs = new CrashReportsPreferenceStore();
- render(
- {}}
- crashReportsPreferenceStore={prefs}
- intervalsIcuAthleteId={null}
- />,
- );
- expect(screen.queryByRole('dialog')).toBeNull();
- });
-
- it('renders Diagnostics with the opt-out defaulting to on (D3=b)', () => {
- const prefs = new CrashReportsPreferenceStore();
- render(
- {}}
- crashReportsPreferenceStore={prefs}
- intervalsIcuAthleteId={null}
- />,
- );
- const toggle = screen.getByLabelText(/send crash reports/i) as HTMLInputElement;
- expect(toggle.checked).toBe(true);
- });
-
- it('toggles the preference immediately (AC8)', () => {
- const prefs = new CrashReportsPreferenceStore();
- render(
- {}}
- crashReportsPreferenceStore={prefs}
- intervalsIcuAthleteId={null}
- />,
- );
- const toggle = screen.getByLabelText(/send crash reports/i) as HTMLInputElement;
- fireEvent.click(toggle);
- expect(prefs.isEnabled()).toBe(false);
- expect(localStorage.getItem(CRASH_REPORTS_ENABLED_KEY)).toBe('false');
- });
-
- it('updates when the store changes externally (AC8 live toggle)', () => {
- const prefs = new CrashReportsPreferenceStore();
- render(
- {}}
- crashReportsPreferenceStore={prefs}
- intervalsIcuAthleteId={null}
- />,
- );
- act(() => {
- prefs.setEnabled(false);
- });
- const toggle = screen.getByLabelText(/send crash reports/i) as HTMLInputElement;
- expect(toggle.checked).toBe(false);
- });
-
- it('expands "What\'s in a report?" and lists the FR4 fields', () => {
- const prefs = new CrashReportsPreferenceStore();
- render(
- {}}
- crashReportsPreferenceStore={prefs}
- intervalsIcuAthleteId="12345"
- />,
- );
- fireEvent.click(screen.getByRole('button', { name: /what's in a report/i }));
- expect(screen.getByText(/opaque browser-profile id/i)).toBeTruthy();
- expect(screen.getByText(/build SHA/i)).toBeTruthy();
- expect(screen.getByText(/intervals\.icu athlete id/i)).toBeTruthy();
- expect(screen.getByText(/tokens, email/i)).toBeTruthy();
- });
-
- it('says "No account identifier" when signed out (D2=b when applicable)', () => {
- const prefs = new CrashReportsPreferenceStore();
- render(
- {}}
- crashReportsPreferenceStore={prefs}
- intervalsIcuAthleteId={null}
- />,
- );
- fireEvent.click(screen.getByRole('button', { name: /what's in a report/i }));
- expect(screen.getByText(/no account identifier/i)).toBeTruthy();
- expect(screen.queryByText(/intervals\.icu athlete id/i)).toBeNull();
- });
-
- it('calls onClose when the backdrop is clicked', () => {
- const prefs = new CrashReportsPreferenceStore();
- const onClose = vi.fn();
- const { container } = render(
- ,
- );
- const backdrop = container.querySelector('.settings-panel-backdrop') as HTMLElement;
- fireEvent.click(backdrop);
- expect(onClose).toHaveBeenCalled();
- });
-});
diff --git a/src/__tests__/shaders.test.ts b/src/__tests__/shaders.test.ts
deleted file mode 100644
index 7c79e6b8..00000000
--- a/src/__tests__/shaders.test.ts
+++ /dev/null
@@ -1,307 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import { parser } from '@shaderfrog/glsl-parser';
-import {
- splitChunks,
- fillPlaceholders,
- gerstnerChunks,
- waterSurfaceChunks,
- foliageBillboardChunks,
- foliageSwayChunks,
- bankTriplanarChunks,
-} from '../components/rower3d/shaderChunks';
-import { WATER_FRESNEL_F0, WATER_FRESNEL_STRENGTH } from '../components/rower3d/helpers';
-import { countShaderErrors } from '../components/rower3d/shaderErrors';
-
-/**
- * Issue #341 — the GLSL parses in CI instead of on a rower's GPU.
- *
- * Every chunk here is spliced into one of three's own shaders at
- * `onBeforeCompile`. Nothing checked them: a typo compiled fine as TypeScript,
- * shipped, and surfaced as a WebGL compile error on whatever hardware happened
- * to load it. #298 is what that costs - a wave normal built on the wrong axis
- * lit the river as though the swell lay on its side, and no spec saw it.
- *
- * What these cases can and cannot do. A parser is not a compiler: it will not
- * tell you a normal points the wrong way, and it does not know three's
- * built-ins. What it will not let past is a shader that is not GLSL - an
- * unbalanced brace, a missing semicolon, a `vec3` given two arguments - which
- * is the class of fault that used to reach a GPU.
- */
-
-/**
- * The declarations three's own shader would have provided.
- *
- * The chunks are fragments: they read `position`, write `transformed`, and
- * call functions three declares above them. To parse one on its own it has to
- * be given that context, and this is the smallest version of it that is
- * honest - every name here is one three really does declare, at the point the
- * chunk is injected.
- */
-const THREE_VERTEX_PRELUDE = `
-precision highp float;
-attribute vec3 position;
-attribute vec3 normal;
-attribute vec2 uv;
-attribute vec4 tangent;
-uniform mat4 instanceMatrix;
-`;
-
-const THREE_FRAGMENT_PRELUDE = `
-precision highp float;
-uniform sampler2D normalMap;
-uniform vec2 normalScale;
-varying vec2 vNormalMapUv;
-varying vec3 vViewPosition;
-mat3 tbn = mat3(1.0);
-vec3 normal = vec3(0.0, 1.0, 0.0);
-vec4 diffuseColor = vec4(1.0);
-`;
-
-/**
- * Wrap a chunk in a complete shader and parse it.
- *
- * Preprocessor directives are stripped first: `#ifdef USE_TANGENT` and
- * `#include ` are three's business, resolved before a
- * GPU ever sees them, and a GLSL grammar has no opinion on either. Removing
- * them keeps both branches of an `#ifdef` in the text, which is what we want -
- * each is code that has to be valid.
- */
-const withoutPreprocessor = (glsl: string) =>
- glsl
- .split('\n')
- .filter((line) => !/^\s*#/.test(line))
- .join('\n');
-
-const parseInMain = (prelude: string, body: string) => {
- const source = `${prelude}\nvoid main() {\n${withoutPreprocessor(body)}\n}\n`;
- return () => parser.parse(source);
-};
-
-/** The values the water is really built with, so the chunks are the real ones. */
-const gerstnerForAxis = (heightAxis: 'y' | 'z') =>
- gerstnerChunks({
- waveXY:
- heightAxis === 'z' ? 'vec2(position.x, position.y)' : 'vec2(position.x, position.z)',
- normalFromGradient:
- heightAxis === 'z' ? 'vec3(-wGrad.x, -wGrad.y, 1.0)' : 'vec3(-wGrad.x, 1.0, -wGrad.y)',
- heightDisplace:
- heightAxis === 'z'
- ? 'vec3(position.x, position.y, position.z + wH)'
- : 'vec3(position.x, position.y + wH, position.z)',
- amplitudes: ['0.0450', '0.0300', '0.0220', '0.0150'],
- frequencies: ['1.0472', '1.5708', '2.0944', '4.1888'],
- });
-
-describe('the scene’s GLSL parses', () => {
- describe('the Gerstner swell', () => {
- // Both axes, because they are different shaders: the curved channel
- // displaces local Y and the rotated plane local Z, and #298 was one of
- // them being written as the other.
- for (const axis of ['y', 'z'] as const) {
- it(`builds a vertex shader for the ${axis}-up mesh`, () => {
- const chunks = gerstnerForAxis(axis);
- const body = `${chunks.normal}\n${chunks.position}`;
-
- expect(parseInMain(`${THREE_VERTEX_PRELUDE}\n${chunks.functions}`, body)).not.toThrow();
- });
- }
-
- it('leaves no placeholder unfilled', () => {
- const chunks = gerstnerForAxis('y');
- const all = `${chunks.functions}${chunks.normal}${chunks.position}`;
-
- for (const token of ['WAVE_XY', 'NORMAL_FROM_GRADIENT', 'HEIGHT_DISPLACE', 'AMP0', 'FREQ3']) {
- expect(all, `${token} was never substituted`).not.toContain(token);
- }
- });
-
- // The numbers are the point of the chunk; a substitution that silently
- // dropped them would still parse.
- it('carries the amplitudes and frequencies it was given', () => {
- const chunks = gerstnerForAxis('y');
-
- expect(chunks.position).toContain('0.0450');
- expect(chunks.position).toContain('4.1888');
- expect(chunks.normal).toContain('0.0150');
- });
- });
-
- it('builds the water surface fragment shader', () => {
- const chunks = waterSurfaceChunks({
- fresnelF0: WATER_FRESNEL_F0,
- fresnelStrength: WATER_FRESNEL_STRENGTH,
- });
-
- expect(
- parseInMain(`${THREE_FRAGMENT_PRELUDE}\n${chunks.uniforms}`, chunks.surface),
- ).not.toThrow();
- });
-
- // `FRESNEL_F0` is a prefix of `FRESNEL_F0_INV`, so a substitution that did
- // not respect word boundaries would leave `0.0200_INV` in the shader.
- it('fills the fresnel constants without corrupting each other', () => {
- const { surface } = waterSurfaceChunks({ fresnelF0: 0.02, fresnelStrength: 0.6 });
-
- expect(surface).toContain('0.0200 + 0.9800');
- expect(surface).not.toContain('_INV');
- expect(surface).toContain('0.6000');
- });
-
- it('builds the billboard foliage shaders', () => {
- const chunks = foliageBillboardChunks();
-
- expect(
- parseInMain(
- `${THREE_VERTEX_PRELUDE}\n${chunks.vertexDeclarations}\nvec3 transformed = position;`,
- `${chunks.sway}${chunks.tint}`,
- ),
- ).not.toThrow();
- expect(
- parseInMain(`${THREE_FRAGMENT_PRELUDE}\n${chunks.fragmentDeclarations}`, chunks.tintFragment),
- ).not.toThrow();
- });
-
- it('builds the kit foliage sway', () => {
- const chunks = foliageSwayChunks();
-
- expect(
- parseInMain(
- `${THREE_VERTEX_PRELUDE}\n${chunks.declarations}\nvec3 transformed = position;`,
- chunks.sway,
- ),
- ).not.toThrow();
- });
-
- describe('the bank triplanar chunks (#430)', () => {
- it('builds a valid vertex shader', () => {
- const chunks = bankTriplanarChunks();
- const prelude = `${THREE_VERTEX_PRELUDE}\nuniform mat4 modelMatrix;\n${chunks.vertexDeclarations}`;
- const body = `vec3 transformed = position;\n${chunks.vertexAssign}`;
-
- expect(parseInMain(prelude, body)).not.toThrow();
- });
-
- it('builds a valid fragment shader', () => {
- const chunks = bankTriplanarChunks();
- const prelude = `${THREE_FRAGMENT_PRELUDE}\n${chunks.fragmentDeclarations}`;
-
- // The map and normal replacements land in the same main() one after the
- // other; the map replacement declares `bankSlope` and `bankShore` that
- // the normal replacement reads, so both parse together as one body.
- expect(
- parseInMain(prelude, `${chunks.mapReplacement}\n${chunks.normalReplacement}`),
- ).not.toThrow();
- });
-
- it('carries the numbers the FR asked for', () => {
- const chunks = bankTriplanarChunks();
- // FR2 slope
- expect(chunks.mapReplacement).toMatch(/smoothstep\(\s*0\.3\s*,\s*0\.7/);
- // FR3 shore + wet-earth ×0.65
- expect(chunks.mapReplacement).toMatch(/smoothstep\(\s*0\.0\s*,\s*2\.5/);
- expect(chunks.mapReplacement).toContain('0.65');
- // FR4 macro noise at 64 m
- expect(chunks.mapReplacement).toContain('/ 64.0');
- });
-
- // #437: the normal replacement samples uGrassN/uEarthN triplanar, blends
- // them by the same slope/shore weights as the albedo, and perturbs
- // `normal` before lighting reads it.
- it('samples the two normals triplanar and blends them (#437)', () => {
- const chunks = bankTriplanarChunks();
- expect(chunks.normalReplacement).toMatch(/bankTriplanar\(\s*uGrassN/);
- expect(chunks.normalReplacement).toMatch(/bankTriplanar\(\s*uEarthN/);
- expect(chunks.normalReplacement).toContain('bankSlope');
- expect(chunks.normalReplacement).toContain('bankShore');
- expect(chunks.normalReplacement).toContain('normal');
- });
- });
-
- /**
- * The gate, gated.
- *
- * A parse test that cannot fail is worse than none, because it reads as
- * cover. This is the fault the whole file exists to catch, written down.
- */
- it('rejects GLSL that is not GLSL', () => {
- const broken = 'float swayAmt = sin(uTime * 1.2 + position.x * 0.5;';
-
- expect(parseInMain(THREE_VERTEX_PRELUDE, broken)).toThrow();
- });
-});
-
-describe('splitting a shader file into chunks', () => {
- const source = [
- '// what this shader is for',
- '// @chunk:first',
- 'float a = 1.0;',
- '',
- '// @chunk:second',
- 'float b = 2.0;',
- ].join('\n');
-
- it('keys each chunk by the name on its marker', () => {
- expect(Object.keys(splitChunks(source))).toEqual(['first', 'second']);
- });
-
- it('drops the header above the first marker', () => {
- expect(splitChunks(source).first).not.toContain('what this shader is for');
- expect(splitChunks(source).first).toBe('float a = 1.0;\n');
- });
-
- it('substitutes whole words only', () => {
- expect(fillPlaceholders('A + A_LONGER', { A: '1.0', A_LONGER: '2.0' })).toBe('1.0 + 2.0');
- });
-});
-
-/**
- * Issue #341 — what the parser cannot know, the driver still can.
- *
- * A parse test proves the chunks are GLSL. It cannot prove a driver will
- * compile them: a `varying` the two halves of a program disagree about, or a
- * built-in that exists in one GLSL version and not the next, is valid GLSL and
- * still a black scene. `countShaderErrors` is how a spec finds out.
- */
-describe('counting shaders the driver refused', () => {
- // A plain object, because the thing under test takes a `Pick` of the context
- // rather than the context itself - which is the whole reason it can be tested
- // without a GPU.
- const COMPILE_STATUS = 0x8b81;
- type Compiling = Parameters[0];
- const fakeGl = (compiles: boolean): Compiling =>
- ({
- COMPILE_STATUS,
- getShaderParameter: (_shader: WebGLShader, pname: number) =>
- pname === COMPILE_STATUS ? compiles : 1,
- getShaderInfoLog: () => 'ERROR: 0:12: undeclared identifier',
- }) as unknown as Compiling;
- const anyShader = {} as WebGLShader;
-
- it('says nothing while shaders compile', () => {
- const logs: string[] = [];
- const gl = countShaderErrors(fakeGl(true), (log) => logs.push(log));
-
- gl.getShaderParameter(anyShader, COMPILE_STATUS);
-
- expect(logs).toEqual([]);
- });
-
- it('reports the driver’s own log when one does not', () => {
- const logs: string[] = [];
- const gl = countShaderErrors(fakeGl(false), (log) => logs.push(log));
-
- gl.getShaderParameter(anyShader, COMPILE_STATUS);
-
- expect(logs).toEqual(['ERROR: 0:12: undeclared identifier']);
- });
-
- // Compiling is asynchronous: the failure is knowable when someone asks for
- // COMPILE_STATUS, and only then. Every other query has to pass through.
- it('leaves every other query alone, and its answer intact', () => {
- const logs: string[] = [];
- const gl = countShaderErrors(fakeGl(false), (log) => logs.push(log));
-
- expect(gl.getShaderParameter(anyShader, 0x8b88)).toBe(1);
- expect(logs).toEqual([]);
- });
-});
diff --git a/src/__tests__/shoreline.test.ts b/src/__tests__/shoreline.test.ts
deleted file mode 100644
index e54ff721..00000000
--- a/src/__tests__/shoreline.test.ts
+++ /dev/null
@@ -1,103 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import * as THREE from 'three';
-import {
- SHORELINE_WIDTH_METRES,
- createBankGeometry,
- createShorelineGeometry,
-} from '../components/rower3d/bankGeometry';
-import { WATER_SURFACE_Y } from '../components/rower3d/waterGeometry';
-
-/**
- * Issue #334 — the water meets the land, rather than stepping down to it.
- *
- * The bank's inner edge sat at `BANK_WATERLINE_Y` (-0.5) while the water
- * surface sat at -0.1, so the shore was a four-centimetre cliff all the way
- * along the route, seen edge-on from a camera two and a half metres up. Both
- * numbers were authored separately and neither knew about the other.
- *
- * They are one number now: the waterline is where the water is.
- */
-
-const straightCurve = () =>
- new THREE.CatmullRomCurve3([
- new THREE.Vector3(0, 0, 0),
- new THREE.Vector3(0, 0, -200),
- new THREE.Vector3(0, 0, -400),
- ]);
-
-/** Every vertex of a bank strip, as {x, y, z}. */
-const verticesOf = (geometry: THREE.BufferGeometry) => {
- const position = geometry.getAttribute('position') as THREE.BufferAttribute;
- return Array.from({ length: position.count }, (_, i) => ({
- x: position.getX(i),
- y: position.getY(i),
- z: position.getZ(i),
- }));
-};
-
-describe('the shore meets the water', () => {
- it.each(['left', 'right'] as const)('puts the %s bank edge at water level', (side) => {
- const vertices = verticesOf(createBankGeometry(straightCurve(), side));
-
- // The strip is two vertices wide, inner then outer, per sample.
- const inner = vertices.filter((_, i) => i % 2 === 0);
- expect(inner.length, 'no bank was built').toBeGreaterThan(0);
-
- for (const vertex of inner) {
- expect(
- vertex.y,
- 'the shore steps down to the water instead of meeting it',
- ).toBeCloseTo(WATER_SURFACE_Y - 0.02, 5);
- }
- });
-
- // The outer edge still climbs with the terrain; only the waterline moved.
- it('leaves the far edge free to rise', () => {
- const vertices = verticesOf(createBankGeometry(straightCurve(), 'left'));
- const outer = vertices.filter((_, i) => i % 2 === 1);
-
- expect(outer.length).toBeGreaterThan(0);
- for (const vertex of outer) {
- expect(vertex.y).toBeGreaterThanOrEqual(WATER_SURFACE_Y - 0.02);
- }
- });
-});
-
-describe('the shoreline strip', () => {
- it.each(['left', 'right'] as const)('is a constant width along the %s bank', (side) => {
- const vertices = verticesOf(createShorelineGeometry(straightCurve(), side));
- expect(vertices.length, 'no shoreline was built').toBeGreaterThan(0);
-
- for (let i = 0; i + 1 < vertices.length; i += 2) {
- const wet = vertices[i];
- const dry = vertices[i + 1];
- const width = Math.hypot(dry.x - wet.x, dry.z - wet.z);
- expect(width, 'the foam line changes width along the bank').toBeCloseTo(
- SHORELINE_WIDTH_METRES,
- 2,
- );
- }
- });
-
- it('lies on the water, not in it or above it', () => {
- const vertices = verticesOf(createShorelineGeometry(straightCurve(), 'left'));
-
- for (const vertex of vertices) {
- expect(vertex.y).toBeCloseTo(WATER_SURFACE_Y + 0.01, 5);
- }
- });
-
- // The gradient runs across the strip, so the material can fade from foam at
- // the waterline to nothing on the dry side.
- it('runs its texture across the strip rather than along it', () => {
- const geometry = createShorelineGeometry(straightCurve(), 'left');
- const uv = geometry.getAttribute('uv') as THREE.BufferAttribute;
-
- expect(uv.getX(0), 'the wet edge is not the start of the gradient').toBe(0);
- expect(uv.getX(1), 'the dry edge is not the end of it').toBe(1);
- });
-
- it('carries a bounding sphere so the frame loop can cull it', () => {
- expect(createShorelineGeometry(straightCurve(), 'left').boundingSphere).not.toBeNull();
- });
-});
diff --git a/src/__tests__/shorelineTexture.test.ts b/src/__tests__/shorelineTexture.test.ts
deleted file mode 100644
index 8cf7c987..00000000
--- a/src/__tests__/shorelineTexture.test.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import { describe, it, expect, beforeAll, afterAll } from 'vitest';
-import { installCanvasMock } from './canvasMock';
-import {
- SHORELINE_TEXTURE_SIZE,
- createShorelineTexture,
-} from '../components/rower3d/shorelineTexture';
-
-/**
- * Issue #334 — the foam line is decoration, and fails like decoration.
- *
- * jsdom has no 2D context unless one is stubbed in, which is also the state a
- * browser lands in when canvas is blocked or the context limit is reached. A
- * scene that throws there has turned a missing gradient into a missing river.
- */
-describe('createShorelineTexture', () => {
- it('returns nothing rather than throwing where there is no context to draw in', () => {
- expect(() => createShorelineTexture()).not.toThrow();
- expect(createShorelineTexture()).toBeNull();
- });
-
- describe('with a canvas to draw into', () => {
- let uninstall: () => void;
- beforeAll(() => {
- uninstall = installCanvasMock();
- });
- afterAll(() => uninstall());
-
- it('draws the gradient across the strip', () => {
- const texture = createShorelineTexture();
-
- expect(texture, 'no texture was produced').not.toBeNull();
- expect(texture!.image.width).toBe(SHORELINE_TEXTURE_SIZE);
- // One row: the gradient varies across the strip and repeats along it.
- expect(texture!.image.height).toBe(1);
- });
- });
-});
diff --git a/src/__tests__/skyComponents.test.tsx b/src/__tests__/skyComponents.test.tsx
deleted file mode 100644
index 44c49ea6..00000000
--- a/src/__tests__/skyComponents.test.tsx
+++ /dev/null
@@ -1,335 +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 { AnimationProvider } from '../components/rower3d/AnimationContext';
-
-/** A boat position ref, the shape the followers read (#331). */
-const boatAt = (x: number, z: number): React.RefObject => ({
- current: new THREE.Vector3(x, 0, z),
-});
-
-/**
- * Issue #325 — the sky is the one thing the fog must not touch.
- *
- * Fog fades distance into a flat colour, which is the point. The skydome and
- * the horizon silhouette both sit far beyond the fog's far plane, so fogging
- * them paints them out completely: the scene loses its sun, its gradient and
- * the skyline the fade is supposed to be revealing. three fogs every material
- * by default, so both have to say otherwise.
- */
-vi.mock('@react-three/drei', async () => {
- const actual = await vi.importActual>('@react-three/drei');
- // A group rather than null, so a test can count how many clouds a tier asked
- // for. It carries no material, so the fog cases below are unaffected.
- // Cloud and Billboard are stubbed as named groups so a test can count the
- // clouds a tier asked for and see that the silhouette is inside a billboard.
- // Neither carries a material, so the fog cases below are unaffected.
- return {
- ...actual,
- Cloud: () => ,
- Billboard: ({ children, ...props }: { children?: React.ReactNode }) => (
- {children}
- ),
- };
-});
-
-const { PhotorealisticSkydome, HorizonSilhouette, CLOUD_LAYER_NAME } = await import(
- '../components/rower3d/skyComponents'
-);
-
-/** Every material in a mounted tree, paired with whether it takes fog. */
-const materialsOf = async (element: React.ReactElement) => {
- const renderer = await ReactThreeTestRenderer.create(element);
- const found: Array<{ type: string; fog: boolean }> = [];
- (renderer.scene.instance as unknown as THREE.Scene).traverse((object) => {
- const mesh = object as THREE.Mesh;
- const materials = Array.isArray(mesh.material)
- ? mesh.material
- : mesh.material
- ? [mesh.material]
- : [];
- for (const material of materials) {
- found.push({
- type: material.type,
- fog: (material as THREE.MeshBasicMaterial).fog === true,
- });
- }
- });
- return { renderer, found };
-};
-
-describe('the sky stands outside the fog', () => {
- let uninstallCanvasMock: () => void;
- beforeAll(() => {
- uninstallCanvasMock = installCanvasMock();
- });
- afterAll(() => uninstallCanvasMock());
-
- it('keeps the skydome out of it', async () => {
- const { renderer, found } = await materialsOf(
- ,
- );
-
- expect(found.length, 'the skydome drew nothing').toBeGreaterThan(0);
- expect(
- found.filter((m) => m.fog),
- 'a skydome material would be painted out by the fog',
- ).toEqual([]);
-
- await renderer.unmount();
- });
-
- it('keeps the horizon silhouette out of it', async () => {
- const { renderer, found } = await materialsOf(
- ,
- );
-
- expect(found.length, 'the silhouette drew nothing').toBeGreaterThan(0);
- expect(
- found.filter((m) => m.fog),
- 'the skyline the fade is meant to reveal would be faded out with it',
- ).toEqual([]);
-
- await renderer.unmount();
- });
-});
-
-/**
- * Issue #326 — the sky comes round the bend with the boat.
- *
- * The cloud layer, the horizon silhouette and the caustics all sat at
- * `[0, y, boatZ]`. The route curve moves the boat in X as well, so on any bend
- * the sky slid sideways relative to the camera, and on a looped course it ended
- * up behind the boat entirely.
- */
-describe('the sky follows the boat', () => {
- let uninstall: () => void;
- beforeAll(() => {
- uninstall = installCanvasMock();
- });
- afterAll(() => uninstall());
-
- /**
- * The sky follows the boat in the frame loop now (#331), so the test has to
- * run frames. `AnimationProvider` owns the single `useFrame` the followers
- * subscribe to, which is the real path rather than a stand-in for it.
- */
- const skyAt = async (x: number, z: number, mode: 'basic' | 'medium' | 'extra-high' = 'extra-high') => {
- const renderer = await ReactThreeTestRenderer.create(
-
-
- ,
- );
- await renderer.advanceFrames(2, 1 / 60);
- let layer: THREE.Object3D | undefined;
- (renderer.scene.instance as unknown as THREE.Scene).traverse((o) => {
- if (o.name === CLOUD_LAYER_NAME) layer = o;
- });
- return { renderer, layer };
- };
-
- it('puts the clouds over the boat, not over the origin', async () => {
- const { renderer, layer } = await skyAt(420, -1800);
-
- expect(layer, 'no cloud layer to find').toBeDefined();
- // The drift is a few metres of sideways wander; the boat is 420 m out.
- expect(
- layer!.position.x,
- 'the sky stayed at x=0 while the boat rounded a bend',
- ).toBeGreaterThan(400);
- expect(layer!.position.z).toBeCloseTo(-1800, 3);
-
- await 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 the sky to it would
- // put the clouds over the start line for as long as it lasted.
- it('leaves the sky alone when there is no boat to follow yet', async () => {
- const renderer = await ReactThreeTestRenderer.create(
-
-
- ,
- );
- await renderer.advanceFrames(2, 1 / 60);
-
- let layer: THREE.Object3D | undefined;
- (renderer.scene.instance as unknown as THREE.Scene).traverse((o) => {
- if (o.name === CLOUD_LAYER_NAME) layer = o;
- });
-
- expect(layer, 'no cloud layer to find').toBeDefined();
- expect(layer!.position.x, 'the sky moved to somewhere no boat has been').toBe(0);
- expect(layer!.position.z).toBe(0);
-
- await renderer.unmount();
- });
-
- // Stated against the high tier rather than on its own: "no clouds" also
- // describes a sky that failed to build, and that is not what is being
- // claimed.
- it('draws clouds for high and none for low', async () => {
- const high = await skyAt(0, 0);
- const low = await skyAt(0, 0, 'basic');
-
- expect(high.layer?.children.length, 'the sky built no clouds at all').toBeGreaterThan(0);
- expect(low.layer?.children ?? [], 'the low tier is paying for clouds').toHaveLength(0);
-
- await high.renderer.unmount();
- await low.renderer.unmount();
- });
-});
-
-/**
- * Issue #326 — the silhouette faces the rower.
- *
- * It is a flat shape standing kilometres down the route, and a rower reaches
- * it from whatever heading the bend left them on; unturned, it shows its edge.
- * `lockX` and `lockZ` keep it upright so it yaws about Y like a horizon does
- * rather than tipping to face a camera that is looking slightly down.
- */
-describe('the horizon silhouette', () => {
- let uninstall: () => void;
- beforeAll(() => {
- uninstall = installCanvasMock();
- });
- afterAll(() => uninstall());
-
- it('is billboarded to the camera, and only about Y', async () => {
- const renderer = await ReactThreeTestRenderer.create(
- ,
- );
-
- const billboards: string[] = [];
- (renderer.scene.instance as unknown as THREE.Scene).traverse((o) => {
- if (o.name.startsWith('Billboard:')) billboards.push(o.name);
- });
-
- expect(billboards, 'the silhouette is not billboarded').toHaveLength(1);
- expect(billboards[0], 'the silhouette can tip out of upright').toContain('"lockX":true');
- expect(billboards[0]).toContain('"lockZ":true');
-
- await renderer.unmount();
- });
-
- it('keeps the shape inside the billboard, not beside it', async () => {
- const renderer = await ReactThreeTestRenderer.create(
- ,
- );
-
- let billboard: THREE.Object3D | undefined;
- (renderer.scene.instance as unknown as THREE.Scene).traverse((o) => {
- if (o.name.startsWith('Billboard:')) billboard = o;
- });
-
- expect(billboard!.children.length, 'the billboard turns nothing').toBeGreaterThan(0);
-
- await renderer.unmount();
- });
-});
-
-/**
- * Issue #349 — the sky lights the scene, not a black capture of it.
- *
- * `PMREMEnvironment` built `scene.environment` from the live scene on mount,
- * before `Sky` had drawn, so the map was near-black and every physical
- * material had nothing to reflect. This installs the sky-only map that #324
- * already builds for the water, so it cannot depend on load order.
- */
-describe('the sky as the scene’s environment', () => {
- const texture = () => Object.assign(new THREE.Texture(), { dispose: vi.fn() });
-
- const mountEnvironment = async (built: THREE.Texture | null, intensity = 0.7) => {
- const made = built;
- vi.doMock('../components/rower3d/skyEnvironment', async (importOriginal) => ({
- ...(await importOriginal>()),
- buildSkyEnvironment: () => made,
- }));
- vi.resetModules();
- const { SkyEnvironment } = await import('../components/rower3d/skyComponents');
- const { SCENE_CONFIG } = await import('../components/rower3d/themeConfig');
-
- const renderer = await ReactThreeTestRenderer.create(
- ,
- );
- return renderer;
- };
-
- it('installs the map it was given, at the tier’s strength', async () => {
- const built = texture();
- const renderer = await mountEnvironment(built, 0.7);
-
- const scene = renderer.scene.instance as unknown as THREE.Scene;
- expect(scene.environment).toBe(built);
- expect(scene.environmentIntensity).toBe(0.7);
-
- await renderer.unmount();
- });
-
- // A texture left behind is one the endurance traverse counts and one the GPU
- // holds; the map is rebuilt whenever the sun moves, so this runs often.
- it('takes the map down again, and disposes it', async () => {
- const built = texture();
- const renderer = await mountEnvironment(built);
-
- await renderer.unmount();
-
- expect(built.dispose).toHaveBeenCalled();
- });
-
- /**
- * A browser with no GL to convolve with returns nothing rather than throwing
- * (#324), and a river without a reflection is still a river.
- */
- it('leaves the scene alone when the sky could not be built', async () => {
- const renderer = await mountEnvironment(null);
-
- expect((renderer.scene.instance as unknown as THREE.Scene).environment).toBeNull();
-
- await renderer.unmount();
- });
-});
-
-/**
- * Issue #418 — the water and the scene light from the same texture.
- *
- * The cache is exercised in `skyEnvironmentCache.test.ts`; this proves it is
- * the seam `SkyEnvironment` uses.
- */
-describe('the sky and the water share one environment map (#418)', () => {
- it('acquires through the cache, and releases on unmount rather than disposing itself', async () => {
- const built = Object.assign(new THREE.Texture(), { dispose: vi.fn() });
- vi.doMock('../components/rower3d/skyEnvironment', async (importOriginal) => ({
- ...(await importOriginal>()),
- buildSkyEnvironment: vi.fn(() => built),
- }));
- vi.resetModules();
- const { SkyEnvironment } = await import('../components/rower3d/skyComponents');
- const { SCENE_CONFIG } = await import('../components/rower3d/themeConfig');
- const cache = await import('../components/rower3d/skyEnvironmentCache');
- cache.resetSkyEnvironmentCacheForTesting();
-
- // A separate consumer takes the map first, the way `waterComponents`
- // does when a scene mounts both.
- const water = cache.acquireSkyEnvironment({} as THREE.WebGLRenderer, SCENE_CONFIG.sky, [0, 110, 0]);
-
- const renderer = await ReactThreeTestRenderer.create(
- ,
- );
- // Same texture as the water holds — one build for two consumers.
- expect((renderer.scene.instance as unknown as THREE.Scene).environment).toBe(water.texture);
- expect(cache.buildCountForTesting()).toBe(1);
-
- await renderer.unmount();
- // The water still holds it; unmounting `SkyEnvironment` must not dispose.
- expect(built.dispose).not.toHaveBeenCalled();
-
- water.release();
- expect(built.dispose).toHaveBeenCalledTimes(1);
- });
-});
diff --git a/src/__tests__/skyEnvironmentCache.test.ts b/src/__tests__/skyEnvironmentCache.test.ts
deleted file mode 100644
index 16d3cd56..00000000
--- a/src/__tests__/skyEnvironmentCache.test.ts
+++ /dev/null
@@ -1,119 +0,0 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import * as THREE from 'three';
-import { SCENE_CONFIG } from '../components/rower3d/themeConfig';
-import {
- acquireSkyEnvironment,
- buildCountForTesting,
- resetSkyEnvironmentCacheForTesting,
-} from '../components/rower3d/skyEnvironmentCache';
-import * as skyEnvironment from '../components/rower3d/skyEnvironment';
-
-/**
- * Issue #418 — the sky PMREM is built once per (sky, sun) and shared between
- * the water and the scene environment.
- */
-
-const rendererStub = () => ({} as unknown as THREE.WebGLRenderer);
-
-const fakeTexture = (name: string): THREE.Texture => {
- const t = new THREE.Texture();
- t.name = name;
- return t;
-};
-
-const SUN: [number, number, number] = [90, 55, 35];
-
-beforeEach(() => resetSkyEnvironmentCacheForTesting());
-
-describe('acquireSkyEnvironment', () => {
- it('builds once for two consumers asking with the same sky (AC1.1, AC1.2)', () => {
- const build = vi.spyOn(skyEnvironment, 'buildSkyEnvironment')
- .mockImplementation(() => fakeTexture('midday'));
-
- const water = acquireSkyEnvironment(rendererStub(), SCENE_CONFIG.sky, SUN);
- const scene = acquireSkyEnvironment(rendererStub(), SCENE_CONFIG.sky, SUN);
-
- expect(build).toHaveBeenCalledTimes(1);
- expect(water.texture).toBe(scene.texture);
- expect(buildCountForTesting()).toBe(1);
- });
-
- it('builds again for a different sky (AC2.1)', () => {
- const build = vi.spyOn(skyEnvironment, 'buildSkyEnvironment')
- .mockImplementation((_, __, sun: readonly [number, number, number]) => fakeTexture(sun.join(',')));
-
- const midday = acquireSkyEnvironment(rendererStub(), SCENE_CONFIG.sky, SUN);
- const dusk = acquireSkyEnvironment(rendererStub(), SCENE_CONFIG.sky, [10, 5, 30]);
-
- expect(build).toHaveBeenCalledTimes(2);
- expect(midday.texture).not.toBe(dusk.texture);
- });
-
- it('disposes only after the last consumer releases (AC1.4)', () => {
- const texture = fakeTexture('shared');
- const dispose = vi.spyOn(texture, 'dispose');
- vi.spyOn(skyEnvironment, 'buildSkyEnvironment').mockReturnValueOnce(texture);
-
- const water = acquireSkyEnvironment(rendererStub(), SCENE_CONFIG.sky, SUN);
- const scene = acquireSkyEnvironment(rendererStub(), SCENE_CONFIG.sky, SUN);
-
- water.release();
- expect(dispose).not.toHaveBeenCalled();
-
- scene.release();
- expect(dispose).toHaveBeenCalledTimes(1);
- });
-
- it('a rebuild after the last release hands back a fresh texture (AC1.3)', () => {
- const first = fakeTexture('first');
- const second = fakeTexture('second');
- const dispose = vi.spyOn(first, 'dispose');
- vi.spyOn(skyEnvironment, 'buildSkyEnvironment')
- .mockReturnValueOnce(first)
- .mockReturnValueOnce(second);
-
- acquireSkyEnvironment(rendererStub(), SCENE_CONFIG.sky, SUN).release();
- expect(dispose).toHaveBeenCalledTimes(1);
-
- const again = acquireSkyEnvironment(rendererStub(), SCENE_CONFIG.sky, SUN);
- expect(again.texture).toBe(second);
- expect(buildCountForTesting()).toBe(2);
- });
-
- it('releasing twice from the same handle is a no-op (AC1.4)', () => {
- const texture = fakeTexture('once');
- const dispose = vi.spyOn(texture, 'dispose');
- vi.spyOn(skyEnvironment, 'buildSkyEnvironment').mockReturnValueOnce(texture);
-
- const water = acquireSkyEnvironment(rendererStub(), SCENE_CONFIG.sky, SUN);
- const scene = acquireSkyEnvironment(rendererStub(), SCENE_CONFIG.sky, SUN);
-
- water.release();
- water.release();
- expect(dispose).not.toHaveBeenCalled();
-
- scene.release();
- expect(dispose).toHaveBeenCalledTimes(1);
- });
-
- it('returns a null-texture handle where the renderer cannot convolve one', () => {
- vi.spyOn(skyEnvironment, 'buildSkyEnvironment').mockReturnValueOnce(null);
- const handle = acquireSkyEnvironment(rendererStub(), SCENE_CONFIG.sky, SUN);
- expect(handle.texture).toBeNull();
- // Releasing a null handle does not throw and does not count.
- expect(() => handle.release()).not.toThrow();
- });
-
- it('stops growing across a session (AC2.2)', () => {
- vi.spyOn(skyEnvironment, 'buildSkyEnvironment').mockImplementation(() => fakeTexture(''));
-
- const handles = [];
- for (let i = 0; i < 5; i++) {
- handles.push(acquireSkyEnvironment(rendererStub(), SCENE_CONFIG.sky, [i, 0, 0]));
- }
- handles.forEach((h) => h.release());
-
- // Every unique sky was built once; nothing lingers keyed on identity.
- expect(buildCountForTesting()).toBe(5);
- });
-});
diff --git a/src/__tests__/soundPicker.test.tsx b/src/__tests__/soundPicker.test.tsx
deleted file mode 100644
index 57c30b24..00000000
--- a/src/__tests__/soundPicker.test.tsx
+++ /dev/null
@@ -1,80 +0,0 @@
-import { describe, it, expect, vi } from 'vitest';
-import { render, screen, fireEvent } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { SoundPicker } from '../components/SoundPicker';
-
-/**
- * Issue #339 — switching the sound on, and setting how loud it is.
- *
- * Off until asked for. A browser will not start an AudioContext without a
- * gesture, so the switch is not a preference the app can honour silently at
- * load — it is the gesture.
- */
-
-describe('the sound picker', () => {
- it('is off to begin with, and says so', () => {
- render();
-
- expect(screen.getByRole('switch', { name: /sound/i })).not.toBeChecked();
- });
-
- it('asks for sound when the rower switches it on', async () => {
- const onToggle = vi.fn();
- render();
-
- await userEvent.click(screen.getByRole('switch', { name: /sound/i }));
-
- expect(onToggle).toHaveBeenCalledWith(true);
- });
-
- it('switches back off again', async () => {
- const onToggle = vi.fn();
- render();
-
- await userEvent.click(screen.getByRole('switch', { name: /sound/i }));
-
- expect(onToggle).toHaveBeenCalledWith(false);
- });
-
- // A volume slider for sound that is off is a control with nothing to do.
- it('offers a volume only once there is something to hear', () => {
- const { rerender } = render(
- ,
- );
- expect(screen.queryByRole('slider', { name: /volume/i })).toBeNull();
-
- rerender();
- expect(screen.getByRole('slider', { name: /volume/i })).toBeInTheDocument();
- });
-
- it('reports the volume the rower set', () => {
- const onVolume = vi.fn();
- render();
-
- // `fireEvent` rather than `userEvent`: a range input is dragged, not
- // typed into, and `userEvent.clear` refuses it as not editable.
- fireEvent.change(screen.getByRole('slider', { name: /volume/i }), {
- target: { value: '0.75' },
- });
-
- expect(onVolume).toHaveBeenCalledWith(0.75);
- });
-
- // The number as well as the slider: a slider's position is not a reading,
- // and a rower setting a level over an erg wants to know what they set.
- it('says how loud it is, not only where the handle sits', () => {
- render();
-
- expect(screen.getByText('35%')).toBeInTheDocument();
- });
-
- it('is a switch a screen reader can read as one', () => {
- render();
-
- expect(screen.getByRole('switch', { name: /sound/i })).toBeChecked();
- expect(screen.getByRole('slider', { name: /volume/i })).toHaveAttribute(
- 'aria-valuetext',
- '50%',
- );
- });
-});
diff --git a/src/__tests__/strokePose.test.ts b/src/__tests__/strokePose.test.ts
deleted file mode 100644
index 587db94c..00000000
--- a/src/__tests__/strokePose.test.ts
+++ /dev/null
@@ -1,241 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import {
- BLADE_CLEARANCE_M,
- BLADE_DEPTH_M,
- BLADE_TRANSITION_FRACTION,
- CATCH_SWEEP_RAD,
- FEATHER_RAD,
- FINISH_SWEEP_RAD,
- SLIDE_TRAVEL_M,
- STROKE_DRIVE_FRACTION,
- strokePose,
-} from '../components/rower3d/strokePose';
-
-/**
- * One stroke model, used by both sculls.
- *
- * The procedural scull already animated the rower's arms from the stroke
- * phase; the GLB scull — which is what every real session renders — swept the
- * oars and left the rower rigid, so the boat appeared to row itself. Both read
- * from here now, so they cannot drift apart again (#273).
- */
-describe('strokePose', () => {
- /**
- * The sweep was `sin(phase * 2pi) * 0.5` — an even 28.6 degrees either side
- * of square, with the oar square at the catch. A sculler's stroke is neither
- * of those things: the blade goes in at about 55 degrees towards the bow and
- * comes out at about 35 the other way (#329).
- */
- it('sweeps the oars from the catch angle to the finish angle', () => {
- expect(strokePose(0).oarSweep).toBeCloseTo(CATCH_SWEEP_RAD, 5);
- expect(strokePose(STROKE_DRIVE_FRACTION - 0.0001).oarSweep).toBeCloseTo(
- FINISH_SWEEP_RAD,
- 3,
- );
- });
-
- it('sweeps back the other way through the recovery', () => {
- expect(strokePose(0.25).oarSweep).toBeGreaterThan(0);
- expect(strokePose(0.75).oarSweep).toBeLessThan(0);
- expect(strokePose(0.9999).oarSweep).toBeCloseTo(CATCH_SWEEP_RAD, 2);
- });
-
- it('reaches further towards the bow than past the rigger', () => {
- // A stroke is asymmetric, and this is the direction it is asymmetric in.
- expect(Math.abs(CATCH_SWEEP_RAD)).toBeGreaterThan(Math.abs(FINISH_SWEEP_RAD));
- });
-
- describe('the blades', () => {
- it('are squared and in the water through the drive', () => {
- const mid = strokePose(STROKE_DRIVE_FRACTION / 2);
-
- expect(mid.bladeFeatherRad, 'the blade is feathered during the drive').toBeCloseTo(
- 0,
- 6,
- );
- expect(mid.bladeHeightM, 'the blade is not in the water').toBeCloseTo(
- -BLADE_DEPTH_M,
- 6,
- );
- });
-
- it('are feathered and clear of the water through the recovery', () => {
- const mid = strokePose(STROKE_DRIVE_FRACTION + (1 - STROKE_DRIVE_FRACTION) / 2);
-
- expect(mid.bladeFeatherRad).toBeCloseTo(FEATHER_RAD, 6);
- expect(mid.bladeHeightM, 'the blade drags through the recovery').toBeCloseTo(
- BLADE_CLEARANCE_M,
- 6,
- );
- });
-
- // A blade that squares instantly is a blade that teleports. The
- // transition is the most recognisable thing a sculler does with their
- // hands, and it is the reason this is a ramp rather than a branch.
- it('take time to square and to feather', () => {
- const justAfterFinish = strokePose(
- STROKE_DRIVE_FRACTION + BLADE_TRANSITION_FRACTION / 2,
- );
-
- expect(Math.abs(justAfterFinish.bladeFeatherRad)).toBeGreaterThan(0);
- expect(Math.abs(justAfterFinish.bladeFeatherRad)).toBeLessThan(
- Math.abs(FEATHER_RAD),
- );
- });
-
- it('are squared again by the time they reach the catch', () => {
- // Within half a degree of square, and already under the surface: a blade
- // still feathering at the catch is a crab.
- const atCatch = strokePose(0.9999);
- expect(Math.abs((atCatch.bladeFeatherRad * 180) / Math.PI)).toBeLessThan(0.5);
- expect(atCatch.bladeHeightM).toBeLessThan(0);
- });
-
- it('never sit between the two heights for long', () => {
- // Most of a stroke has the blade either buried or clear; only the two
- // short transitions are in between.
- let between = 0;
- for (let i = 0; i < 1000; i += 1) {
- const { bladeHeightM } = strokePose(i / 1000);
- if (bladeHeightM > -BLADE_DEPTH_M + 0.01 && bladeHeightM < BLADE_CLEARANCE_M - 0.01) {
- between += 1;
- }
- }
-
- expect(between / 1000).toBeLessThan(BLADE_TRANSITION_FRACTION * 3);
- });
- });
-
- /**
- * Issue #330 — a drive is sequenced, not a shrug.
- *
- * `armPull`, `legCompression` and `bodyLean` all came off the same eased
- * `t`, so the legs, the back and the arms moved in lockstep. A real drive is
- * legs first, back through the middle, arms to finish; the recovery reverses
- * it — arms away, body over, then the slide.
- */
- describe('the sequence of the drive', () => {
- /** A phase a given fraction of the way through the drive. */
- const throughDrive = (fraction: number) => strokePose(STROKE_DRIVE_FRACTION * fraction);
-
- it('sends the legs down before the arms come in', () => {
- const early = throughDrive(0.2);
-
- expect(early.legCompression, 'the legs have not started').toBeLessThan(0.7);
- expect(early.armPull, 'the arms came in with the legs').toBeLessThan(0.05);
- });
-
- it('swings the back through the middle', () => {
- const early = throughDrive(0.15);
- const middle = throughDrive(0.5);
-
- expect(middle.bodyLean, 'the back never swung').toBeGreaterThan(early.bodyLean);
- });
-
- it('draws the arms in to finish', () => {
- expect(throughDrive(0.6).armPull, 'the arms have not begun').toBeGreaterThan(0.05);
- expect(throughDrive(0.999).armPull).toBeGreaterThan(0.9);
- });
-
- it('has everything at its end value by the finish', () => {
- const finish = throughDrive(0.999);
-
- expect(finish.legCompression).toBeLessThan(0.02);
- expect(finish.armPull).toBeGreaterThan(0.9);
- expect(finish.seatPosition).toBeGreaterThan(-0.02);
- });
-
- it('reverses the order on the recovery: arms away, body over, then slide', () => {
- const recovery = (fraction: number) =>
- strokePose(STROKE_DRIVE_FRACTION + (1 - STROKE_DRIVE_FRACTION) * fraction);
-
- // A fifth of the way back, the hands have gone and the slide has not.
- expect(recovery(0.2).armPull).toBeLessThan(0.6);
- expect(recovery(0.2).legCompression, 'the slide started before the hands left')
- .toBeLessThan(0.05);
- // Three quarters back, the slide is under way.
- expect(recovery(0.75).legCompression).toBeGreaterThan(0.2);
- });
- });
-
- describe('the slide', () => {
- it('follows the legs exactly, so the seat and the knees agree', () => {
- for (let i = 0; i < 200; i += 1) {
- const pose = strokePose(i / 200);
- expect(pose.seatPosition).toBeCloseTo(-SLIDE_TRAVEL_M * pose.legCompression, 6);
- }
- });
-
- it('travels the length of the slide over a stroke', () => {
- let lowest = Infinity;
- let highest = -Infinity;
- for (let i = 0; i < 400; i += 1) {
- const { seatPosition } = strokePose(i / 400);
- lowest = Math.min(lowest, seatPosition);
- highest = Math.max(highest, seatPosition);
- }
-
- expect(highest - lowest, 'the seat barely moves').toBeGreaterThan(0.4);
- });
- });
-
- // Nothing may jump at the seam, or the rower twitches once a stroke.
- it('is continuous across the end of the stroke', () => {
- const before = strokePose(0.9999);
- const after = strokePose(0);
-
- for (const key of ['legCompression', 'armPull', 'bodyLean', 'seatPosition'] as const) {
- expect(Math.abs(before[key] - after[key]), `${key} jumps at the catch`).toBeLessThan(
- 0.05,
- );
- }
- });
-
- it('draws the arms in through the drive and extends them on the recovery', () => {
- // The drive is the first part of the cycle: legs down, body back, arms in.
- const early = strokePose(0.05);
- const endOfDrive = strokePose(STROKE_DRIVE_FRACTION);
- const recovery = strokePose(0.9);
-
- expect(endOfDrive.armPull).toBeGreaterThan(early.armPull);
- expect(recovery.armPull).toBeLessThan(endOfDrive.armPull);
- });
-
- it('moves the arms whenever the oars move', () => {
- // The fault this exists to prevent: oars sweeping while the rower holds
- // still. Sampling the cycle, neither may be frozen while the other moves.
- const samples = Array.from({ length: 24 }, (_, i) => strokePose(i / 24));
- const oarRange = Math.max(...samples.map((s) => s.oarSweep)) - Math.min(...samples.map((s) => s.oarSweep));
- const armRange = Math.max(...samples.map((s) => s.upperArmAngle)) - Math.min(...samples.map((s) => s.upperArmAngle));
-
- expect(oarRange).toBeGreaterThan(0.5);
- expect(armRange).toBeGreaterThan(0.5);
- });
-
- it('keeps the forearm and upper arm moving together', () => {
- // An elbow that straightens while the shoulder draws in reads as broken.
- const a = strokePose(0.1);
- const b = strokePose(0.3);
-
- expect(Math.sign(b.upperArmAngle - a.upperArmAngle)).toBe(
- Math.sign(b.forearmAngle - a.forearmAngle),
- );
- });
-
- it('is continuous across the wrap from one stroke to the next', () => {
- const endOfCycle = strokePose(0.999);
- const startOfNext = strokePose(0);
-
- expect(Math.abs(endOfCycle.oarSweep - startOfNext.oarSweep)).toBeLessThan(0.05);
- expect(Math.abs(endOfCycle.armPull - startOfNext.armPull)).toBeLessThan(0.05);
- });
-
- it('handles a phase outside 0..1 rather than producing nonsense', () => {
- for (const phase of [-0.5, 1.5, 2.25, Number.NaN]) {
- const pose = strokePose(phase);
- expect(Number.isFinite(pose.oarSweep)).toBe(true);
- expect(Number.isFinite(pose.upperArmAngle)).toBe(true);
- expect(Number.isFinite(pose.armPull)).toBe(true);
- }
- });
-});
diff --git a/src/__tests__/sunDirection.test.ts b/src/__tests__/sunDirection.test.ts
deleted file mode 100644
index 34e6e479..00000000
--- a/src/__tests__/sunDirection.test.ts
+++ /dev/null
@@ -1,152 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import {
- sunDirection,
- followerLightPosition,
- shadowFrustumCoversBoat,
- skySunPosition,
- SHADOW_HALF_EXTENT_M,
- SUN_LIGHT_DISTANCE_M,
-} from '../components/rower3d/sunDirection';
-import { SCENE_CONFIG } from '../components/rower3d/themeConfig';
-import { CONDITIONS, applyConditions, sunPositionFrom } from '../components/rower3d/conditions';
-
-/**
- * Issue #352 — one sun, and a shadow that goes with the boat.
- *
- * The directional light sat at a fixed position with a ±60 m shadow camera
- * around the origin and no `target`, so it pointed at the origin whatever the
- * boat did. A boat rows away from the origin: past about 60 m everything left
- * the frustum and the row finished with no shadows at all. Nothing caught it
- * because every published screenshot is taken in the first few seconds, while
- * the boat is still standing on the start.
- */
-
-const lightingAt = (sunElevation: number, sunAzimuth: number) => ({
- ...SCENE_CONFIG.lighting,
- sunElevation,
- sunAzimuth,
-});
-
-describe('the sun’s direction', () => {
- it('points straight up at noon overhead', () => {
- const [x, y, z] = sunDirection(lightingAt(90, 0));
-
- expect(x).toBeCloseTo(0, 6);
- expect(y).toBeCloseTo(1, 6);
- expect(z).toBeCloseTo(0, 6);
- });
-
- // Azimuth is degrees clockwise from north, and the scene's north is +Z.
- it('points north on the horizon at an azimuth of nothing', () => {
- const [x, y, z] = sunDirection(lightingAt(0, 0));
-
- expect(x).toBeCloseTo(0, 6);
- expect(y).toBeCloseTo(0, 6);
- expect(z).toBeCloseTo(1, 6);
- });
-
- it('points east at an azimuth of ninety', () => {
- const [x, , z] = sunDirection(lightingAt(0, 90));
-
- expect(x).toBeCloseTo(1, 6);
- expect(z).toBeCloseTo(0, 6);
- });
-
- // A direction, not a position: everything downstream scales it itself.
- it('is a unit vector at every elevation and azimuth', () => {
- for (let elevation = 0; elevation <= 90; elevation += 15) {
- for (let azimuth = 0; azimuth < 360; azimuth += 45) {
- const length = Math.hypot(...sunDirection(lightingAt(elevation, azimuth)));
- expect(length, `${elevation}/${azimuth}`).toBeCloseTo(1, 6);
- }
- }
- });
-
- /**
- * The sun in the sky and the sun that lights the scene are the same sun.
- *
- * They were two authored fields that disagreed until #346 derived both from
- * the same pair of angles. This holds that line: for every preset, the sky's
- * disc and the light's direction are parallel, so a shadow points away from
- * the bright spot rather than off at its own angle.
- */
- it('is parallel to where the sky draws its sun, under every preset', () => {
- for (const condition of CONDITIONS) {
- const config = applyConditions(SCENE_CONFIG, condition);
- const direction = sunDirection(config.lighting);
- const sky = skySunPosition(config.lighting);
- const skyLength = Math.hypot(...sky);
-
- sky.forEach((value: number, axis: number) => {
- expect(value / skyLength, `${condition} axis ${axis}`).toBeCloseTo(direction[axis], 5);
- });
- }
- });
-
- it('is the same math the sky position is built from', () => {
- const direction = sunDirection(lightingAt(37, 212));
- const position = sunPositionFrom(37, 212, 1);
-
- position.forEach((value, axis) => expect(value).toBeCloseTo(direction[axis], 9));
- });
-});
-
-describe('the light that follows the boat', () => {
- it('sits up-sun of wherever the boat is', () => {
- const direction = sunDirection(lightingAt(90, 0));
-
- const position = followerLightPosition([100, 0, -250], direction);
-
- expect(position[0]).toBeCloseTo(100, 6);
- expect(position[1]).toBeCloseTo(SUN_LIGHT_DISTANCE_M, 6);
- expect(position[2]).toBeCloseTo(-250, 6);
- });
-
- it('keeps the same distance from the boat however far it has rowed', () => {
- const direction = sunDirection(lightingAt(35, 200));
-
- for (const boat of [[0, 0, 0], [500, 0, -2_000], [-3_000, 0, 4_000]] as const) {
- const position = followerLightPosition([...boat], direction);
- const away = Math.hypot(
- position[0] - boat[0],
- position[1] - boat[1],
- position[2] - boat[2],
- );
- expect(away).toBeCloseTo(SUN_LIGHT_DISTANCE_M, 5);
- }
- });
-});
-
-/**
- * The frustum test the Playwright spec publishes.
- *
- * Trivially true once the light is a follower — which is the point. It failed
- * for every boat past 60 m from the origin before this issue, and it is the
- * assertion that says so.
- */
-describe('whether the boat is inside the shadow camera', () => {
- it('covers a boat the light is targeting', () => {
- expect(shadowFrustumCoversBoat([0, 0, 0], [0, 0, 0])).toBe(true);
- });
-
- it('covers a boat within half an extent of the target', () => {
- expect(shadowFrustumCoversBoat([SHADOW_HALF_EXTENT_M - 1, 0, 0], [0, 0, 0])).toBe(true);
- });
-
- // The old rig: light fixed on the origin, boat a kilometre down the course.
- it('does not cover a boat the light left behind', () => {
- expect(shadowFrustumCoversBoat([0, 0, -1_000], [0, 0, 0])).toBe(false);
- });
-
- it('measures across the ground, not through the air', () => {
- // Directly above the target by more than an extent is still covered: the
- // shadow camera is orthographic and its depth range is the `far` plane.
- expect(shadowFrustumCoversBoat([0, SHADOW_HALF_EXTENT_M + 50, 0], [0, 0, 0])).toBe(true);
- });
-
- it('is the extent the shadow camera is actually built with', () => {
- expect(SHADOW_HALF_EXTENT_M).toBeGreaterThan(0);
- // Tighter than the ±60 it replaces: texels spent where the camera looks.
- expect(SHADOW_HALF_EXTENT_M).toBeLessThan(60);
- });
-});
diff --git a/src/__tests__/themeConfig.test.ts b/src/__tests__/themeConfig.test.ts
deleted file mode 100644
index 3506c07c..00000000
--- a/src/__tests__/themeConfig.test.ts
+++ /dev/null
@@ -1,229 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import { readdirSync, readFileSync } from 'node:fs';
-import { join } from 'node:path';
-import { SCENE_CONFIG } from '../components/rower3d/themeConfig';
-import {
- BLOOM_SAFE_LUMINANCE,
- bloomSafeFoamColor,
- foamLuminance,
-} from '../components/rower3d/wakeTexture';
-
-describe('SCENE_CONFIG', () => {
- it('has every section the scene reads', () => {
- expect(Object.keys(SCENE_CONFIG).sort()).toEqual([
- 'architecture',
- 'atmosphere',
- 'bank',
- 'clouds',
- 'colorGrading',
- 'groundCover',
- 'horizon',
- 'landscapeColors',
- 'lighting',
- 'sky',
- 'trees',
- 'water',
- ]);
- });
-
- it('water has all required fields', () => {
- const w = SCENE_CONFIG.water;
- expect(typeof w.color).toBe('string');
- expect(typeof w.transmission).toBe('number');
- expect(typeof w.roughness).toBe('number');
- expect(typeof w.thickness).toBe('number');
- expect(typeof w.emissive).toBe('string');
- expect(typeof w.emissiveIntensity).toBe('number');
- expect(typeof w.attenuationColor).toBe('string');
- expect(typeof w.attenuationDistance).toBe('number');
- expect(typeof w.specularIntensity).toBe('number');
- expect(typeof w.sheenColor).toBe('string');
- // Phase 6 water character fields (#127)
- expect(w.waveAmplitude).toBeGreaterThan(0);
- expect(w.waveFrequency).toBeGreaterThan(0);
- expect(w.foamIntensity).toBeGreaterThanOrEqual(0);
- expect(w.foamIntensity).toBeLessThanOrEqual(1);
- // #323 — the foam colour reaches a material only after `bloomSafeFoamColor`
- // caps it, so what it is capped *to* is what has to stay under the bloom
- // pass's threshold.
- expect(w.foamColor).toMatch(/^#[0-9a-f]{6}$/i);
- expect(foamLuminance(bloomSafeFoamColor(w.foamColor))).toBeLessThanOrEqual(
- BLOOM_SAFE_LUMINANCE,
- );
- });
-
- it('bank has all required fields', () => {
- const b = SCENE_CONFIG.bank;
- expect(typeof b.color).toBe('string');
- expect(typeof b.roughness).toBe('number');
- expect(typeof b.metalness).toBe('number');
- expect(typeof b.emissive).toBe('string');
- expect(typeof b.emissiveIntensity).toBe('number');
- expect(typeof b.sheen).toBe('number');
- expect(typeof b.sheenColor).toBe('string');
- expect(typeof b.flatColor).toBe('string');
- });
-
- it('landscapeColors has all required fields', () => {
- const lc = SCENE_CONFIG.landscapeColors;
- expect(typeof lc.mountain).toBe('string');
- expect(typeof lc.mountainSnow).toBe('string');
- expect(typeof lc.buildingAccent).toBe('string');
- expect(typeof lc.windowGlow).toBe('string');
- });
-
- it('atmosphere has all required fields', () => {
- const a = SCENE_CONFIG.atmosphere;
- expect(typeof a.fogColor).toBe('string');
- expect(typeof a.fogNear).toBe('number');
- expect(typeof a.fogFar).toBe('number');
- });
-
- it('sky has all required fields with valid ranges', () => {
- const sky = SCENE_CONFIG.sky;
- // No `sunPosition` here (#352): a second place to author the sun is a
- // second answer, and the two disagreed for as long as both existed.
- expect(sky).not.toHaveProperty('sunPosition');
- expect(sky.turbidity).toBeGreaterThanOrEqual(0);
- expect(typeof sky.rayleigh).toBe('number');
- expect(typeof sky.mieCoefficient).toBe('number');
- expect(sky.mieDirectionalG).toBeGreaterThanOrEqual(0);
- expect(sky.mieDirectionalG).toBeLessThanOrEqual(1);
- expect(typeof sky.exposure).toBe('number');
- });
-
- it('clouds have all required fields', () => {
- const clouds = SCENE_CONFIG.clouds;
- expect(typeof clouds.enabled).toBe('boolean');
- expect(clouds.count).toBeGreaterThan(0);
- expect(clouds.opacity).toBeGreaterThanOrEqual(0);
- expect(clouds.opacity).toBeLessThanOrEqual(1);
- expect(typeof clouds.speed).toBe('number');
- expect(typeof clouds.color).toBe('string');
- expect(typeof clouds.segments).toBe('number');
- expect(typeof clouds.scale).toBe('number');
- expect(typeof clouds.depth).toBe('number');
- });
-
- it('lighting has all required fields with valid ranges (#108)', () => {
- const l = SCENE_CONFIG.lighting;
- expect(typeof l.ambientColor).toBe('string');
- expect(l.ambientIntensity).toBeGreaterThanOrEqual(0);
- expect(typeof l.sunColor).toBe('string');
- expect(l.sunIntensity).toBeGreaterThanOrEqual(0);
- expect(typeof l.fillColor).toBe('string');
- expect(l.fillIntensity).toBeGreaterThanOrEqual(0);
- // Phase 6 lighting profile (#126)
- expect(l.sunElevation).toBeGreaterThanOrEqual(0);
- expect(l.sunElevation).toBeLessThanOrEqual(90);
- expect(l.sunAzimuth).toBeGreaterThanOrEqual(0);
- expect(l.sunAzimuth).toBeLessThan(360);
- });
-
- it('colorGrading has all required fields with valid ranges (#124)', () => {
- const cg = SCENE_CONFIG.colorGrading;
- expect(cg.hue).toBeGreaterThanOrEqual(-0.5);
- expect(cg.hue).toBeLessThanOrEqual(0.5);
- expect(cg.saturation).toBeGreaterThanOrEqual(-1);
- expect(cg.saturation).toBeLessThanOrEqual(1);
- expect(cg.brightness).toBeGreaterThanOrEqual(-1);
- expect(cg.brightness).toBeLessThanOrEqual(1);
- expect(cg.contrast).toBeGreaterThanOrEqual(-1);
- expect(cg.contrast).toBeLessThanOrEqual(1);
- });
-});
-
-describe('trees config (#128)', () => {
- it('has at least one tree species', () => {
- expect(SCENE_CONFIG.trees.species.length).toBeGreaterThan(0);
- });
-
- it('gives every species its colours', () => {
- for (const s of SCENE_CONFIG.trees.species) {
- expect(typeof s.type).toBe('string');
- expect(typeof s.color).toBe('string');
- expect(typeof s.trunkColor).toBe('string');
- }
- });
-
- it('has willow trees', () => {
- expect(SCENE_CONFIG.trees.species.map((s) => s.type)).toContain('willow');
- });
-});
-
-describe('architecture config (#129)', () => {
- it('describes the village houses the bank builds', () => {
- const a = SCENE_CONFIG.architecture;
- expect(typeof a.wallMaterial.color).toBe('string');
- expect(a.wallMaterial.roughness).toBeGreaterThanOrEqual(0);
- expect(a.wallMaterial.roughness).toBeLessThanOrEqual(1);
- expect(typeof a.roofColor).toBe('string');
- });
-});
-
-describe('groundCover config (#130)', () => {
- const VALID_TYPES = ['reed', 'rock', 'grass', 'flower', 'debris'] as const;
-
- it('has at least one ground cover type', () => {
- expect(SCENE_CONFIG.groundCover.types.length).toBeGreaterThan(0);
- });
-
- it('gives every ground cover entry its fields', () => {
- for (const t of SCENE_CONFIG.groundCover.types) {
- expect(VALID_TYPES).toContain(t.type);
- expect(typeof t.color).toBe('string');
- expect(t.scale).toBeGreaterThan(0);
- }
- });
-
- it('has reeds and flowers', () => {
- const types = SCENE_CONFIG.groundCover.types.map((t) => t.type);
- expect(types).toContain('reed');
- expect(types).toContain('flower');
- });
-});
-
-describe('horizon config (#131)', () => {
- it('has a valid horizon', () => {
- const h = SCENE_CONFIG.horizon;
- expect(typeof h.color).toBe('string');
- expect(h.distance).toBeGreaterThan(0);
- expect(h.height).toBeGreaterThan(0);
- });
-});
-
-/**
- * Issue #364 — the machinery that chose between six themes, and the fields
- * only those themes read, are gone from the whole source tree. Read from the
- * files rather than the object, because a type or an interface field leaves
- * nothing at runtime.
- *
- * Each name is assembled from parts so this file does not contain it: the
- * issue's own check is a grep of `src/`, tests included.
- */
-describe('what the retired themes left behind (#364)', () => {
- const sourceFiles = (dir: string): string[] =>
- readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
- const path = join(dir, entry.name);
- if (entry.isDirectory()) return sourceFiles(path);
- return /\.(ts|tsx|js|mjs)$/.test(entry.name) ? [path] : [];
- });
- const files = sourceFiles('src').map((path) => ({ path, text: readFileSync(path, 'utf8') }));
-
- it.each([
- ['Route', 'Theme'],
- ['detect', 'Route', 'Theme'],
- ['get', 'ThemeConfig'],
- ['THEME', '_CONFIG'],
- ['themeUses', 'GlbScenery'],
- ['Landscape', 'Source'],
- ['landscape', 'Source'],
- ['building', 'Style'],
- ['bridge', 'Style'],
- ['has', 'Bridges'],
- ['roof', 'Style'],
- ].map((parts) => parts.join('')))('names %s nowhere in src/', (name) => {
- const pattern = new RegExp(`\\b${name}\\b`);
- expect(files.filter(({ text }) => pattern.test(text)).map(({ path }) => path)).toEqual([]);
- });
-});
diff --git a/src/__tests__/useCameraView.test.tsx b/src/__tests__/useCameraView.test.tsx
deleted file mode 100644
index fa0801c7..00000000
--- a/src/__tests__/useCameraView.test.tsx
+++ /dev/null
@@ -1,125 +0,0 @@
-import { describe, it, expect, beforeEach, vi } from 'vitest';
-import { act, renderHook } from '@testing-library/react';
-import {
- CAMERA_VIEW_STORAGE_KEY,
- useCameraView,
-} from '../hooks/useCameraView';
-
-/**
- * Issue #328 — the camera view is chosen, and remembered.
- *
- * A cycle that forgets on reload is worse than no cycle: the other three views
- * are only reachable through it, so a rower who prefers the bow view has to
- * find it again every session.
- */
-
-const pressKey = (key: string, target: EventTarget = window) => {
- act(() => {
- target.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
- });
-};
-
-beforeEach(() => {
- localStorage.clear();
-});
-
-describe('useCameraView', () => {
- it('starts on the chase camera', () => {
- const { result } = renderHook(() => useCameraView());
- expect(result.current.view).toBe('chase');
- });
-
- it('cycles on V', () => {
- const { result } = renderHook(() => useCameraView());
-
- pressKey('v');
- expect(result.current.view).toBe('side');
- pressKey('v');
- expect(result.current.view).toBe('bow');
- pressKey('v');
- expect(result.current.view).toBe('high');
- });
-
- it('does not care about shift', () => {
- const { result } = renderHook(() => useCameraView());
-
- pressKey('V');
- expect(result.current.view).toBe('side');
- });
-
- it('remembers the choice', () => {
- const { result, unmount } = renderHook(() => useCameraView());
- pressKey('v');
- pressKey('v');
- expect(result.current.view).toBe('bow');
- unmount();
-
- const again = renderHook(() => useCameraView());
- expect(again.result.current.view, 'the view was forgotten on reload').toBe('bow');
- });
-
- it('starts from the chase camera when the stored value is from another build', () => {
- localStorage.setItem(CAMERA_VIEW_STORAGE_KEY, 'orbit');
- const { result } = renderHook(() => useCameraView());
-
- expect(result.current.view).toBe('chase');
- });
-
- it('cycles from the button as well as the key', () => {
- const { result } = renderHook(() => useCameraView());
-
- act(() => result.current.cycle());
- expect(result.current.view).toBe('side');
- });
-
- // A route named "Vesper Reach" is typed into a text field, and every V in it
- // would otherwise move the camera.
- it.each(['INPUT', 'TEXTAREA', 'SELECT'])('ignores V typed into a %s', (tag) => {
- const { result } = renderHook(() => useCameraView());
- const field = document.createElement(tag);
- document.body.appendChild(field);
-
- pressKey('v', field);
-
- expect(result.current.view).toBe('chase');
- field.remove();
- });
-
- it('ignores V with a modifier held, which belongs to the browser', () => {
- const { result } = renderHook(() => useCameraView());
-
- act(() => {
- window.dispatchEvent(new KeyboardEvent('keydown', { key: 'v', ctrlKey: true }));
- });
-
- expect(result.current.view).toBe('chase');
- });
-
- it('stops listening once the scene is gone', () => {
- const { result, unmount } = renderHook(() => useCameraView());
- unmount();
-
- pressKey('v');
-
- expect(result.current.view).toBe('chase');
- });
-
- // Private windows and blocked site data throw from `localStorage` rather
- // than returning null, and a camera is not worth failing a row over.
- it('still cycles where storage is unavailable', () => {
- const getItem = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
- throw new Error('blocked');
- });
- const setItem = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
- throw new Error('blocked');
- });
-
- const { result } = renderHook(() => useCameraView());
- expect(result.current.view).toBe('chase');
- pressKey('v');
- expect(result.current.view).toBe('side');
-
- getItem.mockRestore();
- setItem.mockRestore();
- });
-});
diff --git a/src/__tests__/useConditions.test.ts b/src/__tests__/useConditions.test.ts
deleted file mode 100644
index c659bc1d..00000000
--- a/src/__tests__/useConditions.test.ts
+++ /dev/null
@@ -1,166 +0,0 @@
-import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-import { renderHook, act } from '@testing-library/react';
-import { useConditions, CONDITIONS_STORAGE_KEY } from '../hooks/useConditions';
-
-/**
- * Issue #346 — the rower's choice of conditions, remembered.
- *
- * Set once, not every row, exactly as the graphics tier is. The default is
- * "match my clock": someone rowing at six in the morning gets a dawn, which is
- * the whole point of having presets and is a better first impression than a
- * menu nobody has opened.
- */
-
-beforeEach(() => {
- localStorage.clear();
- vi.useRealTimers();
-});
-
-afterEach(() => {
- vi.useRealTimers();
- // Cleared *after* as well as before. This suite stores presets, and on a
- // jsdom that outlives the file — which is CI's, though not every developer's
- // — the last one written leaks into whatever runs next. It did: `app.test.tsx`
- // read a stored `dusk` and failed an assertion about the default (#346).
- localStorage.clear();
-});
-
-/** Freeze the wall clock at a given local hour. */
-const atHour = (hour: number) => {
- const now = new Date();
- now.setHours(hour, 0, 0, 0);
- vi.useFakeTimers();
- vi.setSystemTime(now);
-};
-
-describe('the conditions a row is rowed in', () => {
- it('matches the rower’s own clock until they say otherwise', () => {
- atHour(6);
-
- const { result } = renderHook(() => useConditions());
-
- expect(result.current.choice).toBe('auto');
- expect(result.current.conditions).toBe('dawn');
- });
-
- it('follows the clock into the evening', () => {
- atHour(20);
-
- const { result } = renderHook(() => useConditions());
-
- expect(result.current.conditions).toBe('dusk');
- });
-
- it('takes the preset the rower picked over the hour', () => {
- atHour(12);
- const { result } = renderHook(() => useConditions());
-
- act(() => result.current.setChoice('overcast'));
-
- expect(result.current.choice).toBe('overcast');
- expect(result.current.conditions).toBe('overcast');
- });
-
- it('goes back to the clock when the rower asks it to', () => {
- atHour(12);
- const { result } = renderHook(() => useConditions());
-
- act(() => result.current.setChoice('dusk'));
- act(() => result.current.setChoice('auto'));
-
- expect(result.current.conditions).toBe('midday');
- });
-
- // Set once, not every row.
- it('is remembered for next time', () => {
- const first = renderHook(() => useConditions());
- act(() => first.result.current.setChoice('golden'));
-
- const second = renderHook(() => useConditions());
-
- expect(second.result.current.choice).toBe('golden');
- });
-
- it('ignores a stored value that is not a preset', () => {
- atHour(12);
- localStorage.setItem(CONDITIONS_STORAGE_KEY, 'thunderstorm');
-
- const { result } = renderHook(() => useConditions());
-
- expect(result.current.choice).toBe('auto');
- expect(result.current.conditions).toBe('midday');
- });
-
- // Private browsing, or storage switched off. The default is no worse for it.
- it('still works when there is nowhere to remember anything', () => {
- atHour(12);
- const getItem = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
- throw new Error('denied');
- });
- const setItem = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
- throw new Error('denied');
- });
-
- const { result } = renderHook(() => useConditions());
- act(() => result.current.setChoice('dawn'));
-
- expect(result.current.conditions).toBe('dawn');
-
- getItem.mockRestore();
- setItem.mockRestore();
- });
-
- /**
- * The scene reads a config, not a preset.
- *
- * `applyConditions` is covered in `conditions.test.ts`; what matters here is
- * that the hook hands over the applied config rather than leaving every
- * call-site to apply it and to disagree about how.
- */
- it('hands the scene a config with the preset already applied', () => {
- atHour(12);
- const { result } = renderHook(() => useConditions());
-
- act(() => result.current.setChoice('overcast'));
-
- expect(result.current.sceneConfig.clouds.opacity).toBeGreaterThan(0.8);
- expect(result.current.sceneConfig.lighting.sunElevation).toBe(40);
- });
-});
-
-/**
- * Under automation, "match my clock" means midday.
- *
- * Every visual baseline and every contrast check would otherwise be a function
- * of the hour CI happened to start: a scene recorded at midday and compared at
- * dusk is a failure nobody caused, and a dusk scene would quietly fail the
- * contrast floors #291 exists to hold.
- */
-describe('the clock under automation', () => {
- it('is midday, whatever hour the runner thinks it is', async () => {
- atHour(21);
- window.__PLAYWRIGHT_TESTING = true;
- vi.resetModules();
- const { useConditions: underTest } = await import('../hooks/useConditions');
-
- const { result } = renderHook(() => underTest());
-
- expect(result.current.conditions).toBe('midday');
- delete window.__PLAYWRIGHT_TESTING;
- });
-
- // A spec that wants another preset stores one, which is the path a rower
- // takes: the pin is on "auto", not on the feature.
- it('still honours a preset a spec asked for', async () => {
- atHour(21);
- window.__PLAYWRIGHT_TESTING = true;
- localStorage.setItem(CONDITIONS_STORAGE_KEY, 'dusk');
- vi.resetModules();
- const { useConditions: underTest } = await import('../hooks/useConditions');
-
- const { result } = renderHook(() => underTest());
-
- expect(result.current.conditions).toBe('dusk');
- delete window.__PLAYWRIGHT_TESTING;
- });
-});
diff --git a/src/__tests__/useCurrentWorkout.test.tsx b/src/__tests__/useCurrentWorkout.test.tsx
deleted file mode 100644
index 0534c90b..00000000
--- a/src/__tests__/useCurrentWorkout.test.tsx
+++ /dev/null
@@ -1,112 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import { renderHook, act } from '@testing-library/react';
-import { useCurrentWorkout } from '../hooks/useCurrentWorkout';
-import type { PM5Data, StructuredWorkout, WorkoutSegment } from '../lib/types/index';
-
-const segment = (over: Partial = {}): WorkoutSegment => ({
- id: `s${over.order ?? 0}`,
- order: over.order ?? 0,
- type: 'work',
- duration: 60,
- ...over,
-});
-
-const workout = (segments: WorkoutSegment[]): StructuredWorkout => ({
- id: 'w1',
- name: 'Test',
- description: '',
- type: 'custom',
- segments,
- totalDuration: segments.reduce((t, s) => t + (s.duration ?? 0), 0),
- targetMetric: 'pace',
- createdAt: new Date(),
-});
-
-const reading = (seconds: number, meters: number): PM5Data => ({
- elapsedTime: seconds * 1000,
- distance: meters,
-});
-
-describe('useCurrentWorkout (#445)', () => {
- it('is idle before a workout is set', () => {
- const { result } = renderHook(() => useCurrentWorkout());
- expect(result.current.current).toBeNull();
- expect(result.current.isRunning).toBe(false);
- expect(result.current.progress).toBeNull();
- expect(result.current.segments).toEqual([]);
- });
-
- it('accepts a workout imperatively and lets it start', () => {
- const { result } = renderHook(() => useCurrentWorkout());
- act(() => { result.current.set(workout([segment({ duration: 60 })])); });
- expect(result.current.current?.id).toBe('w1');
- act(() => { result.current.start(); });
- expect(result.current.isRunning).toBe(true);
- expect(result.current.progress?.currentSegmentIndex).toBe(0);
- });
-
- it('refuses to start a workout with an unbounded segment (#67 F.3)', () => {
- const { result } = renderHook(() => useCurrentWorkout());
- act(() => {
- result.current.set(workout([segment({ duration: undefined, distance: undefined })]));
- });
- let started = true;
- act(() => { started = result.current.start(); });
- expect(started).toBe(false);
- expect(result.current.validationErrors.length).toBeGreaterThan(0);
- expect(result.current.isRunning).toBe(false);
- });
-
- it('advances progress on ticks and marks complete', () => {
- const { result } = renderHook(() => useCurrentWorkout());
- act(() => {
- result.current.set(workout([segment({ duration: 10 }), segment({ order: 1, duration: 10 })]));
- });
- act(() => { result.current.start(); });
- act(() => { result.current.tick(reading(5, 20)); });
- expect(result.current.progress?.currentSegmentIndex).toBe(0);
- act(() => { result.current.tick(reading(10, 40)); });
- expect(result.current.progress?.currentSegmentIndex).toBe(1);
- act(() => { result.current.tick(reading(20, 80)); });
- expect(result.current.isComplete).toBe(true);
- expect(result.current.isRunning).toBe(false);
- });
-
- it('clears progress when the workout is cleared', () => {
- const { result } = renderHook(() => useCurrentWorkout());
- act(() => {
- result.current.set(workout([segment()]));
- });
- act(() => { result.current.start(); });
- act(() => { result.current.set(null); });
- expect(result.current.current).toBeNull();
- expect(result.current.isRunning).toBe(false);
- expect(result.current.progress).toBeNull();
- });
-
- it('exposes a speed factor while running that depends on segment intensity', () => {
- const { result } = renderHook(() => useCurrentWorkout());
- act(() => {
- result.current.set(workout([segment({ duration: 60, intensity: 'zone2' })]));
- });
- act(() => { result.current.start(); });
- expect(result.current.speedFactor).toBeCloseTo(0.8);
- });
-
- it('does not count the reading-gap of a reconnect against the rower (#67 §8)', () => {
- const { result, rerender } = renderHook(
- ({ connected }: { connected: boolean }) => useCurrentWorkout(connected),
- { initialProps: { connected: true } },
- );
- act(() => {
- result.current.set(workout([segment({ duration: 60 }), segment({ order: 1, duration: 60 })]));
- });
- act(() => { result.current.start(); });
- act(() => { result.current.tick(reading(30, 100)); });
- rerender({ connected: false });
- rerender({ connected: true });
- act(() => { result.current.tick(reading(210, 105)); });
- // Still on segment 0 — the 180s gap did not consume the workout.
- expect(result.current.progress?.currentSegmentIndex).toBe(0);
- });
-});
diff --git a/src/__tests__/useDevicePerformance.test.tsx b/src/__tests__/useDevicePerformance.test.tsx
deleted file mode 100644
index fb908941..00000000
--- a/src/__tests__/useDevicePerformance.test.tsx
+++ /dev/null
@@ -1,119 +0,0 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { render, waitFor } from '@testing-library/react';
-import React from 'react';
-import {
- resolvePerformanceModeFromProbe,
- useDevicePerformance,
-} from '../hooks/useDevicePerformance';
-
-/**
- * #454 Phase 2 — hook-level gates on {@link useDevicePerformance}.
- *
- * The hook is a thin React wrapper over the pure probe module. Tests here
- * assert the two things only the hook does: synchronous vs post-mount
- * resolution, and the user-choice-wins seam exposed by
- * {@link resolvePerformanceModeFromProbe}.
- */
-
-interface Harness {
- readyAfterCommit: boolean;
- tierAfterReady: string | null;
-}
-
-const captureProbe = (
- options: Parameters[0],
- onCapture: (snapshot: Harness) => void,
-): React.FC => {
- const Component: React.FC = () => {
- const state = useDevicePerformance(options);
- React.useEffect(() => {
- if (state.ready) {
- onCapture({
- readyAfterCommit: true,
- tierAfterReady: state.result?.tier ?? null,
- });
- }
- }, [state]);
- return
{state.ready ? 'ready' : 'pending'}
;
- };
- return Component;
-};
-
-describe('useDevicePerformance (#454 Phase 2)', () => {
- beforeEach(() => {
- localStorage.clear();
- delete (window as unknown as { __PLAYWRIGHT_TESTING?: boolean }).__PLAYWRIGHT_TESTING;
- delete (window as unknown as { __VIRTUALROW_PERFORMANCE_MODE?: unknown })
- .__VIRTUALROW_PERFORMANCE_MODE;
- delete (window as unknown as { __VIRTUALROW_TELEMETRY?: boolean }).__VIRTUALROW_TELEMETRY;
- delete (window as unknown as { __VIRTUALROW_DEVICE_PROBE?: unknown })
- .__VIRTUALROW_DEVICE_PROBE;
- });
-
- afterEach(() => {
- vi.restoreAllMocks();
- });
-
- it('AC4 (FR2, NFR2): resolves after mount and surfaces the probed tier', async () => {
- const captured: Harness[] = [];
- const Harness = captureProbe(
- { openContext: () => null },
- (snap) => captured.push(snap),
- );
- const { getByTestId } = render();
- await waitFor(() => {
- expect(getByTestId('probe-ready').textContent).toBe('ready');
- });
- expect(captured.length).toBeGreaterThan(0);
- // No WebGL2, so the hook lands on the static fallback with tier=medium
- // (#455 D6: silence resolves to medium, never basic).
- expect(captured[captured.length - 1].tierAfterReady).toBe('medium');
- });
-
- it('synchronous: true returns a ready state inside the first render for App tests', () => {
- const Harness = captureProbe(
- { openContext: () => null, synchronous: true },
- () => undefined,
- );
- const { getByTestId } = render();
- // In synchronous mode the hook's initial state is already ready.
- expect(getByTestId('probe-ready').textContent).toBe('ready');
- });
-
- describe('resolvePerformanceModeFromProbe', () => {
- it('user-set basic always wins over the probe', () => {
- expect(resolvePerformanceModeFromProbe('basic', 'medium')).toBe('basic');
- });
-
- it('user-set extra-high always wins over the probe', () => {
- expect(resolvePerformanceModeFromProbe('extra-high', 'basic')).toBe('extra-high');
- });
-
- it('user-set auto defers to the probed tier', () => {
- expect(resolvePerformanceModeFromProbe('auto', 'basic')).toBe('basic');
- });
-
- it('user-set auto with a medium probe returns medium', () => {
- expect(resolvePerformanceModeFromProbe('auto', 'medium')).toBe('medium');
- });
-
- it('undefined user choice (undefined → auto) defers to the probed tier', () => {
- expect(resolvePerformanceModeFromProbe(undefined, 'basic')).toBe('basic');
- });
-
- it('a high/extra-high probed tier is treated as no-answer and falls through', () => {
- // The probe never returns high/extra-high today (FR7), but the resolver
- // defends against a future codomain by falling through to the global
- // default rather than silently promoting the scene.
- const highValue = resolvePerformanceModeFromProbe('auto', 'high');
- expect(['basic', 'low', 'medium']).toContain(highValue);
- const extraHighValue = resolvePerformanceModeFromProbe('auto', 'extra-high');
- expect(['basic', 'low', 'medium']).toContain(extraHighValue);
- });
-
- it('falls through to resolvePerformanceMode() when neither the user nor the probe decides', () => {
- const value = resolvePerformanceModeFromProbe('auto', null);
- expect(['basic', 'low', 'medium', 'high', 'extra-high']).toContain(value);
- });
- });
-});
diff --git a/src/__tests__/useFullscreen.test.tsx b/src/__tests__/useFullscreen.test.tsx
deleted file mode 100644
index 3209322b..00000000
--- a/src/__tests__/useFullscreen.test.tsx
+++ /dev/null
@@ -1,166 +0,0 @@
-import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
-import { act, renderHook } from '@testing-library/react';
-import { useFullscreen } from '../hooks/useFullscreen';
-
-/**
- * Issue #335 — the stage can fill the screen, and the page knows when it does.
- *
- * jsdom implements neither `requestFullscreen` nor `fullscreenElement`, which
- * is convenient: it is exactly the shape iOS Safari presents, so the fallback
- * path is the default here and the supported path is the one that has to be
- * built up.
- */
-
-/** A ref to a real element, because the hook refuses to act without one. */
-const stageRef = () => {
- const element = document.createElement('div');
- document.body.appendChild(element);
- return { current: element } as React.RefObject;
-};
-
-/**
- * Give the document the Fullscreen API it does not have, backed by a variable
- * the test can read — so "did it ask?" and "what did it ask for?" are both
- * answerable without stubbing the hook's own state.
- */
-const installFullscreenApi = () => {
- let current: Element | null = null;
- // `mock.instances` is what records which element asked, so the body only has
- // to note that something did.
- const request = vi.fn(function (this: Element) {
- current = request.mock.instances[request.mock.instances.length - 1] as unknown as Element;
- document.dispatchEvent(new Event('fullscreenchange'));
- return Promise.resolve();
- });
- const exit = vi.fn(() => {
- current = null;
- document.dispatchEvent(new Event('fullscreenchange'));
- return Promise.resolve();
- });
-
- Object.defineProperty(Element.prototype, 'requestFullscreen', {
- configurable: true,
- writable: true,
- value: request,
- });
- Object.defineProperty(document, 'exitFullscreen', {
- configurable: true,
- writable: true,
- value: exit,
- });
- Object.defineProperty(document, 'fullscreenElement', {
- configurable: true,
- get: () => current,
- });
-
- return { request, exit };
-};
-
-const uninstallFullscreenApi = () => {
- Reflect.deleteProperty(Element.prototype, 'requestFullscreen');
- Reflect.deleteProperty(document, 'exitFullscreen');
- Reflect.deleteProperty(document, 'fullscreenElement');
-};
-
-describe('the stage goes fullscreen', () => {
- afterEach(() => {
- uninstallFullscreenApi();
- document.body.innerHTML = '';
- });
-
- describe('where the browser has the API', () => {
- let api: ReturnType;
- beforeEach(() => {
- api = installFullscreenApi();
- });
-
- it('asks for the element it was given, and reports it filled', () => {
- const ref = stageRef();
- const { result } = renderHook(() => useFullscreen(ref));
-
- expect(result.current.supported).toBe(true);
- expect(result.current.active).toBe(false);
-
- act(() => result.current.toggle());
-
- expect(api.request).toHaveBeenCalledTimes(1);
- expect(api.request.mock.instances[0] as unknown as Element).toBe(ref.current);
- expect(result.current.active).toBe(true);
- });
-
- it('gives the screen back', () => {
- const ref = stageRef();
- const { result } = renderHook(() => useFullscreen(ref));
-
- act(() => result.current.toggle());
- act(() => result.current.toggle());
-
- expect(api.exit).toHaveBeenCalledTimes(1);
- expect(result.current.active).toBe(false);
- });
-
- // Escape is the way most rowers will leave it, and it is a route the
- // toggle never hears about. Reading the state back from the event rather
- // than flipping it on click is what makes the button truthful afterwards.
- it('notices the screen being given back by something else', () => {
- const ref = stageRef();
- const { result } = renderHook(() => useFullscreen(ref));
- act(() => result.current.toggle());
- expect(result.current.active).toBe(true);
-
- act(() => {
- Object.defineProperty(document, 'fullscreenElement', {
- configurable: true,
- get: () => null,
- });
- document.dispatchEvent(new Event('fullscreenchange'));
- });
-
- expect(result.current.active).toBe(false);
- });
-
- // A request outside a user gesture rejects. It must not surface as an
- // unhandled rejection over a row in progress.
- it('survives the browser refusing', async () => {
- Object.defineProperty(Element.prototype, 'requestFullscreen', {
- configurable: true,
- writable: true,
- value: () => Promise.reject(new Error('not allowed')),
- });
- const ref = stageRef();
- const { result } = renderHook(() => useFullscreen(ref));
-
- act(() => result.current.toggle());
- await act(async () => { await Promise.resolve(); });
-
- expect(result.current.active).toBe(false);
- });
- });
-
- describe('where it does not (iOS Safari)', () => {
- it('falls back to a mode the page can style itself', () => {
- const ref = stageRef();
- const { result } = renderHook(() => useFullscreen(ref));
-
- expect(result.current.supported).toBe(false);
-
- act(() => result.current.toggle());
- expect(result.current.active).toBe(true);
-
- act(() => result.current.toggle());
- expect(result.current.active).toBe(false);
- });
- });
-
- it('does nothing at all before the stage has mounted', () => {
- const api = installFullscreenApi();
- const { result } = renderHook(() =>
- useFullscreen({ current: null } as React.RefObject),
- );
-
- act(() => result.current.toggle());
-
- expect(api.request).not.toHaveBeenCalled();
- expect(result.current.active).toBe(false);
- });
-});
diff --git a/src/__tests__/useGraphicsQuality.test.tsx b/src/__tests__/useGraphicsQuality.test.tsx
deleted file mode 100644
index e9c2d24f..00000000
--- a/src/__tests__/useGraphicsQuality.test.tsx
+++ /dev/null
@@ -1,112 +0,0 @@
-import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
-import { renderHook, act } from '@testing-library/react';
-import {
- useGraphicsQuality,
- GRAPHICS_QUALITY_STORAGE_KEY,
- GRAPHICS_QUALITY_OPTIONS,
- migrateLegacyQuality,
-} from '../hooks/useGraphicsQuality';
-
-describe('useGraphicsQuality', () => {
- beforeEach(() => {
- localStorage.clear();
- });
- afterEach(() => {
- vi.unstubAllGlobals();
- });
-
- it('starts on auto, and asks the scene to decide for itself', () => {
- const { result } = renderHook(() => useGraphicsQuality());
- expect(result.current.quality).toBe('auto');
- expect(result.current.performanceMode).toBeUndefined();
- });
-
- it('pins the scene to the tier the rower picked', () => {
- const { result } = renderHook(() => useGraphicsQuality());
- act(() => result.current.setQuality('basic'));
-
- expect(result.current.quality).toBe('basic');
- expect(result.current.performanceMode).toBe('basic');
- });
-
- it('remembers the choice for the next session', () => {
- const { result } = renderHook(() => useGraphicsQuality());
- act(() => result.current.setQuality('extra-high'));
- expect(localStorage.getItem(GRAPHICS_QUALITY_STORAGE_KEY)).toBe('extra-high');
-
- const { result: reopened } = renderHook(() => useGraphicsQuality());
- expect(reopened.current.quality).toBe('extra-high');
- });
-
- it('falls back to auto when storage holds something else', () => {
- localStorage.setItem(GRAPHICS_QUALITY_STORAGE_KEY, 'ultra');
- const { result } = renderHook(() => useGraphicsQuality());
- expect(result.current.quality).toBe('auto');
- });
-
- it('ignores a value that is not a quality', () => {
- const { result } = renderHook(() => useGraphicsQuality());
- act(() => result.current.setQuality('ludicrous' as never));
- expect(result.current.quality).toBe('auto');
- });
-
- it('works where localStorage throws', () => {
- vi.stubGlobal('localStorage', {
- getItem: () => {
- throw new Error('denied');
- },
- setItem: () => {
- throw new Error('denied');
- },
- });
-
- const { result } = renderHook(() => useGraphicsQuality());
- expect(result.current.quality).toBe('auto');
- expect(() => act(() => result.current.setQuality('basic'))).not.toThrow();
- expect(result.current.quality).toBe('basic');
- });
-
- it('offers all six tiers in the shipped order (#455 FR6)', () => {
- expect(GRAPHICS_QUALITY_OPTIONS.map((o) => o.value)).toEqual([
- 'auto',
- 'basic',
- 'low',
- 'medium',
- 'high',
- 'extra-high',
- ]);
- });
-
- describe('#455 D3 / FR5 — legacy storage migration', () => {
- it('maps a stored "low" (today\'s basic) to "basic"', () => {
- expect(migrateLegacyQuality('low')).toBe('basic');
- });
-
- it('maps a stored "high" (today\'s extra-high) to "extra-high"', () => {
- expect(migrateLegacyQuality('high')).toBe('extra-high');
- });
-
- it('leaves a stored "auto" alone (still the picker default)', () => {
- expect(migrateLegacyQuality('auto')).toBe('auto');
- });
-
- it('passes new-scheme values through', () => {
- expect(migrateLegacyQuality('basic')).toBe('basic');
- expect(migrateLegacyQuality('medium')).toBe('medium');
- expect(migrateLegacyQuality('extra-high')).toBe('extra-high');
- });
-
- it('maps anything else (nonsense, empty, missing) to "auto"', () => {
- expect(migrateLegacyQuality('nonsense')).toBe('auto');
- expect(migrateLegacyQuality('')).toBe('auto');
- expect(migrateLegacyQuality(null)).toBe('auto');
- });
-
- it('rewrites the stored key when the hook reads back a migrated value', () => {
- localStorage.setItem(GRAPHICS_QUALITY_STORAGE_KEY, 'low');
- const { result } = renderHook(() => useGraphicsQuality());
- expect(result.current.quality).toBe('basic');
- expect(localStorage.getItem(GRAPHICS_QUALITY_STORAGE_KEY)).toBe('basic');
- });
- });
-});
diff --git a/src/__tests__/usePhysicsEngine.test.tsx b/src/__tests__/usePhysicsEngine.test.tsx
deleted file mode 100644
index 11a2bb1f..00000000
--- a/src/__tests__/usePhysicsEngine.test.tsx
+++ /dev/null
@@ -1,256 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import { act, renderHook } from '@testing-library/react';
-import { usePhysicsEngine } from '../hooks/usePhysicsEngine';
-
-describe('usePhysicsEngine', () => {
- it('updates boat velocity and position from pace data', () => {
- const { result } = renderHook(() => usePhysicsEngine());
-
- act(() => {
- result.current.dispatchTick(2, {
- pace: 250,
- power: undefined,
- cadence: 24,
- distance: 0,
- elapsedTime: 0,
- });
- });
-
- expect(result.current.boatStateRef.current.velocityMps).toBe(2);
- expect(result.current.boatStateRef.current.positionM).toBe(4);
- expect(result.current.boatStateRef.current.acceleration).toBe(1);
- });
-
- // Issue #340 — the oars are the one thing in a frozen frame that would still
- // move. Parking the cycle rather than the pose settles the phase with it, so
- // the blade spray stops respawning and two shots of the same scene match.
- it('parks the stroke where the clock stopped while a visual spec holds the scene', () => {
- const { result } = renderHook(() => usePhysicsEngine());
-
- window.__ROWER3D_FREEZE = { time: 12.5, progress: 0.31 };
- try {
- act(() => {
- result.current.dispatchTick(0.5, {
- pace: 120,
- power: undefined,
- cadence: 30,
- distance: 0,
- elapsedTime: 0,
- });
- result.current.dispatchTick(0.5, {
- pace: 120,
- power: undefined,
- cadence: 30,
- distance: 0,
- elapsedTime: 0,
- });
- });
- } finally {
- delete window.__ROWER3D_FREEZE;
- }
-
- expect(result.current.boatStateRef.current.strokeCycleT).toBe(0.5);
- expect(result.current.boatStateRef.current.strokePhase).toBe('finish');
- });
-
- it('resets boat state and position accumulator', () => {
- const { result } = renderHook(() => usePhysicsEngine());
-
- act(() => {
- result.current.dispatchTick(1, {
- pace: 250,
- power: undefined,
- cadence: 24,
- distance: 0,
- elapsedTime: 0,
- });
- result.current.resetEngine();
- });
-
- expect(result.current.boatStateRef.current).toEqual({
- velocityMps: 0,
- smoothedVelocityMps: 0,
- positionM: 0,
- strokePhase: 'recovery',
- strokeCycleT: 0,
- acceleration: 0,
- });
- });
-
- it('applies low-pass filter to smooth velocity', () => {
- const { result } = renderHook(() => usePhysicsEngine());
-
- // After one tick, smoothedVelocityMps should be between 0 and raw speed
- act(() => {
- result.current.dispatchTick(0.016, { pace: 250, power: undefined, cadence: 24, distance: 0, elapsedTime: 0 });
- });
-
- const raw = result.current.boatStateRef.current.velocityMps;
- const smoothed = result.current.boatStateRef.current.smoothedVelocityMps;
- expect(raw).toBe(2);
- expect(smoothed).toBeGreaterThan(0);
- expect(smoothed).toBeLessThan(raw);
- });
-
- it('smoothed velocity converges to raw after many ticks', () => {
- const { result } = renderHook(() => usePhysicsEngine());
-
- // 500 ticks × 16ms = 8s ≈ 16 time constants (tau=0.5s): exp(-16) < 1e-6
- act(() => {
- for (let i = 0; i < 500; i++) {
- result.current.dispatchTick(0.016, { pace: 250, power: undefined, cadence: 24, distance: 0, elapsedTime: 0 });
- }
- });
-
- const raw = result.current.boatStateRef.current.velocityMps;
- const smoothed = result.current.boatStateRef.current.smoothedVelocityMps;
- expect(raw).toBe(2);
- expect(smoothed).toBeCloseTo(2, 3);
- });
-
- it('updateStrokePhase triggers re-render only on phase change', () => {
- const { result } = renderHook(() => usePhysicsEngine());
-
- act(() => {
- result.current.updateStrokePhase('catch');
- });
- expect(result.current.strokePhase).toBe('catch');
- expect(result.current.boatStateRef.current.strokePhase).toBe('catch');
-
- // Calling with same phase again should not change anything
- act(() => {
- result.current.updateStrokePhase('catch');
- });
- expect(result.current.strokePhase).toBe('catch');
- });
-
- describe('strokeCycleT advancement', () => {
- it('advances strokeCycleT proportionally to cadence and dt', () => {
- const { result } = renderHook(() => usePhysicsEngine());
-
- // 20 spm → 1/3 cycle per second → after 1s should be ≈0.333
- act(() => {
- result.current.dispatchTick(1, {
- pace: 250,
- power: undefined,
- cadence: 20,
- distance: 0,
- elapsedTime: 0,
- });
- });
-
- expect(result.current.boatStateRef.current.strokeCycleT).toBeCloseTo(20 / 60, 5);
- });
-
- it('wraps strokeCycleT back to 0 after a full cycle', () => {
- const { result } = renderHook(() => usePhysicsEngine());
-
- // 60 spm → 1 cycle per second → after 1s should wrap to 0
- act(() => {
- result.current.dispatchTick(1, {
- pace: 250,
- power: undefined,
- cadence: 60,
- distance: 0,
- elapsedTime: 0,
- });
- });
-
- expect(result.current.boatStateRef.current.strokeCycleT).toBeCloseTo(0, 5);
- });
-
- it('accumulates strokeCycleT across multiple ticks', () => {
- const { result } = renderHook(() => usePhysicsEngine());
-
- const pm5 = { pace: 250, power: undefined as undefined, cadence: 20, distance: 0, elapsedTime: 0 };
- // 20 spm → cycleFreq = 1/3 Hz
- // After 10 ticks × 0.1s: newT = (10 * 0.1 * 20/60) % 1 ≈ 0.333
- act(() => {
- for (let i = 0; i < 10; i++) {
- result.current.dispatchTick(0.1, pm5);
- }
- });
-
- const expected = (10 * 0.1 * (20 / 60)) % 1.0;
- expect(result.current.boatStateRef.current.strokeCycleT).toBeCloseTo(expected, 3);
- });
- });
-
- describe('strokePhase derivation from strokeCycleT', () => {
- it('t < 0.15 → catch', () => {
- const { result } = renderHook(() => usePhysicsEngine());
- // 20 spm, dt = 0.1s → newT = 20/60 * 0.1 ≈ 0.033 (catch)
- act(() => {
- result.current.dispatchTick(0.1, {
- pace: 250, power: undefined, cadence: 20, distance: 0, elapsedTime: 0,
- });
- });
- expect(result.current.boatStateRef.current.strokeCycleT).toBeLessThan(0.15);
- expect(result.current.boatStateRef.current.strokePhase).toBe('catch');
- });
-
- it('t in 0.15–0.45 → drive', () => {
- const { result } = renderHook(() => usePhysicsEngine());
- // Use a large cadence/dt to land in drive band: e.g. 180 spm × 0.1s = 0.3
- act(() => {
- result.current.dispatchTick(0.1, {
- pace: 250, power: undefined, cadence: 180, distance: 0, elapsedTime: 0,
- });
- });
- const t = result.current.boatStateRef.current.strokeCycleT;
- expect(t).toBeGreaterThanOrEqual(0.15);
- expect(t).toBeLessThan(0.45);
- expect(result.current.boatStateRef.current.strokePhase).toBe('drive');
- });
-
- it('t in 0.45–0.55 → finish', () => {
- const { result } = renderHook(() => usePhysicsEngine());
- // 300 spm × 0.1s = 0.5 → finish
- act(() => {
- result.current.dispatchTick(0.1, {
- pace: 250, power: undefined, cadence: 300, distance: 0, elapsedTime: 0,
- });
- });
- const t = result.current.boatStateRef.current.strokeCycleT;
- expect(t).toBeGreaterThanOrEqual(0.45);
- expect(t).toBeLessThan(0.55);
- expect(result.current.boatStateRef.current.strokePhase).toBe('finish');
- });
-
- it('t >= 0.55 → recovery', () => {
- const { result } = renderHook(() => usePhysicsEngine());
- // 480 spm × 0.1s = 0.8 → recovery
- act(() => {
- result.current.dispatchTick(0.1, {
- pace: 250, power: undefined, cadence: 480, distance: 0, elapsedTime: 0,
- });
- });
- const t = result.current.boatStateRef.current.strokeCycleT;
- expect(t).toBeGreaterThanOrEqual(0.55);
- expect(result.current.boatStateRef.current.strokePhase).toBe('recovery');
- });
-
- it('uses fallback cadence of 20 spm when cadence is absent', () => {
- const { result } = renderHook(() => usePhysicsEngine());
- act(() => {
- result.current.dispatchTick(1, {
- pace: 250, power: undefined, cadence: undefined, distance: 0, elapsedTime: 0,
- });
- });
- // 20 spm default → newT = 20/60 ≈ 0.333 → drive
- expect(result.current.boatStateRef.current.strokeCycleT).toBeCloseTo(20 / 60, 4);
- expect(result.current.boatStateRef.current.strokePhase).toBe('drive');
- });
-
- it('strokePhase React state updates on phase transition', () => {
- const { result } = renderHook(() => usePhysicsEngine());
- // Drive phase transition
- act(() => {
- result.current.dispatchTick(0.1, {
- pace: 250, power: undefined, cadence: 180, distance: 0, elapsedTime: 0,
- });
- });
- expect(result.current.strokePhase).toBe('drive');
- });
- });
-});
diff --git a/src/__tests__/useProgressiveChunks.test.tsx b/src/__tests__/useProgressiveChunks.test.tsx
deleted file mode 100644
index 9a5726c9..00000000
--- a/src/__tests__/useProgressiveChunks.test.tsx
+++ /dev/null
@@ -1,104 +0,0 @@
-import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-import { renderHook, waitFor } from '@testing-library/react';
-import * as THREE from 'three';
-import { useProgressiveChunks } from '../hooks/useProgressiveChunks';
-
-/**
- * jsdom has no `requestIdleCallback`, which is also the Safari story — so the
- * default run here exercises the macrotask fallback. The idle path gets its own
- * test below.
- */
-describe('useProgressiveChunks', () => {
- let built: number[];
- let buildChunk: (index: number) => THREE.BufferGeometry;
-
- beforeEach(() => {
- built = [];
- buildChunk = (index: number) => {
- built.push(index);
- return new THREE.BufferGeometry();
- };
- });
-
- afterEach(() => {
- vi.unstubAllGlobals();
- });
-
- it('builds the opening chunk before the first render returns', () => {
- const { result } = renderHook(() => useProgressiveChunks(6, buildChunk));
-
- expect(built).toEqual([0]);
- expect(result.current[0]).toBeInstanceOf(THREE.BufferGeometry);
- expect(result.current.slice(1)).toEqual([null, null, null, null, null]);
- });
-
- it('fills in the rest of the route in the background', async () => {
- const { result } = renderHook(() => useProgressiveChunks(4, buildChunk));
-
- await waitFor(() => {
- expect(result.current.every((chunk) => chunk !== null)).toBe(true);
- });
- expect(built).toEqual([0, 1, 2, 3]);
- });
-
- it('builds every chunk eagerly when asked to', () => {
- renderHook(() => useProgressiveChunks(3, buildChunk, 3));
- expect(built).toEqual([0, 1, 2]);
- });
-
- it('uses requestIdleCallback when the browser offers one', async () => {
- const idle = vi.fn((run: IdleRequestCallback) => {
- setTimeout(() => run({ didTimeout: false, timeRemaining: () => 5 }), 0);
- return 1;
- });
- vi.stubGlobal('requestIdleCallback', idle);
- vi.stubGlobal('cancelIdleCallback', vi.fn());
-
- const { result } = renderHook(() => useProgressiveChunks(3, buildChunk));
- await waitFor(() => {
- expect(result.current.every((chunk) => chunk !== null)).toBe(true);
- });
- expect(idle).toHaveBeenCalled();
- });
-
- it('disposes every built chunk on unmount', async () => {
- const disposed: THREE.BufferGeometry[] = [];
- const trackingBuild = (index: number) => {
- const geometry = new THREE.BufferGeometry();
- geometry.dispose = () => disposed.push(geometry);
- built.push(index);
- return geometry;
- };
-
- const { result, unmount } = renderHook(() => useProgressiveChunks(3, trackingBuild));
- await waitFor(() => {
- expect(result.current.every((chunk) => chunk !== null)).toBe(true);
- });
-
- unmount();
- expect(disposed).toHaveLength(3);
- });
-
- it('stops building for a route the rower has already left', async () => {
- const { unmount } = renderHook(() => useProgressiveChunks(8, buildChunk));
- unmount();
-
- await new Promise((resolve) => setTimeout(resolve, 20));
- expect(built).toEqual([0]);
- });
-
- it('rebuilds from scratch when the route changes', async () => {
- const { rerender, result } = renderHook(
- ({ count }: { count: number }) => useProgressiveChunks(count, buildChunk),
- { initialProps: { count: 2 } },
- );
- await waitFor(() => expect(built).toEqual([0, 1]));
-
- rerender({ count: 3 });
- await waitFor(() => {
- expect(result.current).toHaveLength(3);
- expect(result.current.every((chunk) => chunk !== null)).toBe(true);
- });
- expect(built).toEqual([0, 1, 0, 1, 2]);
- });
-});
diff --git a/src/__tests__/useRaceCues.test.tsx b/src/__tests__/useRaceCues.test.tsx
deleted file mode 100644
index c2a89d29..00000000
--- a/src/__tests__/useRaceCues.test.tsx
+++ /dev/null
@@ -1,141 +0,0 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { renderHook } from '@testing-library/react';
-import { useRaceCues } from '../hooks/useRaceCues';
-import type { AudioPort } from '../lib/ports';
-import type { StartPhase } from '../hooks/useStartSequence';
-
-/**
- * Issue #339 — the cues a rower listens for rather than looks for.
- *
- * The countdown and the finish are the two moments the screen asks a rower to
- * notice while they are looking at the handle. A sine chirp is the whole point
- * of having sound at all: it reaches them without their having to read.
- *
- * Unlike the stroke sounds, these are *not* suppressed under reduced motion.
- * A rower who asked for less movement asked for less movement, and a countdown
- * they cannot hear is a countdown they have to watch for.
- */
-
-const port = () => {
- const audio = {
- enable: vi.fn(async () => {}),
- disable: vi.fn(async () => {}),
- isEnabled: vi.fn(() => true),
- setVolume: vi.fn(),
- getVolume: vi.fn(() => 0.5),
- splash: vi.fn(),
- cue: vi.fn(),
- startWater: vi.fn(),
- stopWater: vi.fn(),
- };
- return audio as unknown as AudioPort & typeof audio;
-};
-
-let audio: ReturnType;
-
-beforeEach(() => {
- audio = port();
-});
-
-interface Step {
- phase: StartPhase;
- countdown: number | null;
- finished?: boolean;
-}
-
-const run = (steps: Step[]) => {
- const { rerender } = renderHook(
- (step: Step) => useRaceCues(audio, step.phase, step.countdown, step.finished ?? false),
- { initialProps: steps[0] },
- );
- for (const step of steps.slice(1)) rerender(step);
-};
-
-describe('the countdown', () => {
- it('chirps once for each number', () => {
- run([
- { phase: 'armed', countdown: null },
- { phase: 'counting', countdown: 3 },
- { phase: 'counting', countdown: 2 },
- { phase: 'counting', countdown: 1 },
- ]);
-
- expect(audio.cue).toHaveBeenCalledTimes(3);
- });
-
- // A re-render is not a second three.
- it('does not chirp again for a number it is still on', () => {
- run([
- { phase: 'counting', countdown: 3 },
- { phase: 'counting', countdown: 3 },
- { phase: 'counting', countdown: 3 },
- ]);
-
- expect(audio.cue).toHaveBeenCalledTimes(1);
- });
-
- // "Row!" is the one that matters, so it is not the same note as the count.
- it('marks the start with a different note from the count', () => {
- run([
- { phase: 'counting', countdown: 1 },
- { phase: 'go', countdown: null },
- ]);
-
- const [counted, go] = audio.cue.mock.calls.map((call) => call[0] as number);
- expect(go).toBeGreaterThan(counted);
- });
-
- it('marks the start once, not on every render of it', () => {
- run([
- { phase: 'counting', countdown: 1 },
- { phase: 'go', countdown: null },
- { phase: 'go', countdown: null },
- { phase: 'rowing', countdown: null },
- ]);
-
- expect(audio.cue).toHaveBeenCalledTimes(2);
- });
-});
-
-describe('the finish', () => {
- it('sounds when the line is crossed', () => {
- run([
- { phase: 'rowing', countdown: null },
- { phase: 'rowing', countdown: null, finished: true },
- ]);
-
- expect(audio.cue).toHaveBeenCalledTimes(1);
- });
-
- it('sounds once, however long the banner stays up', () => {
- run([
- { phase: 'rowing', countdown: null },
- { phase: 'rowing', countdown: null, finished: true },
- { phase: 'rowing', countdown: null, finished: true },
- ]);
-
- expect(audio.cue).toHaveBeenCalledTimes(1);
- });
-});
-
-describe('with the sound off', () => {
- it('says nothing', () => {
- audio.isEnabled = vi.fn(() => false);
-
- run([
- { phase: 'counting', countdown: 3 },
- { phase: 'go', countdown: null, finished: true },
- ]);
-
- expect(audio.cue).not.toHaveBeenCalled();
- });
-
- it('does not fall over with no sound wired at all', () => {
- const { rerender } = renderHook(
- (step: Step) => useRaceCues(null, step.phase, step.countdown, step.finished ?? false),
- { initialProps: { phase: 'counting', countdown: 3 } as Step },
- );
-
- expect(() => rerender({ phase: 'go', countdown: null, finished: true })).not.toThrow();
- });
-});
diff --git a/src/__tests__/useReducedMotion.test.tsx b/src/__tests__/useReducedMotion.test.tsx
deleted file mode 100644
index 67e92cce..00000000
--- a/src/__tests__/useReducedMotion.test.tsx
+++ /dev/null
@@ -1,113 +0,0 @@
-import { describe, it, expect, afterEach, vi } from 'vitest';
-import { act, renderHook } from '@testing-library/react';
-import { useReducedMotion, useReducedMotionRef } from '../hooks/useReducedMotion';
-
-/**
- * Issue #344 — the scene answers to the system's motion setting.
- *
- * jsdom has no `matchMedia` at all, which is the case that matters most here:
- * an absent API is not a preference either way, and a hook that read it as
- * "reduce" would hold the whole scene still for every rower in a webview.
- */
-
-/** A `matchMedia` whose answer the test controls, and can change. */
-const installMatchMedia = (initial: boolean) => {
- let matches = initial;
- const listeners = new Set<(event: MediaQueryListEvent) => void>();
- const query = vi.fn((queryString: string) => ({
- matches,
- media: queryString,
- addEventListener: (_: string, handler: (event: MediaQueryListEvent) => void) =>
- listeners.add(handler),
- removeEventListener: (_: string, handler: (event: MediaQueryListEvent) => void) =>
- listeners.delete(handler),
- }));
- Object.defineProperty(window, 'matchMedia', { configurable: true, writable: true, value: query });
-
- return {
- query,
- change(next: boolean) {
- matches = next;
- for (const handler of listeners) handler({ matches: next } as MediaQueryListEvent);
- },
- get listenerCount() {
- return listeners.size;
- },
- };
-};
-
-afterEach(() => {
- Reflect.deleteProperty(window, 'matchMedia');
- vi.restoreAllMocks();
-});
-
-describe('the reduced-motion preference', () => {
- it('asks for the preference by name', () => {
- const media = installMatchMedia(false);
- renderHook(() => useReducedMotion());
-
- expect(media.query).toHaveBeenCalledWith('(prefers-reduced-motion: reduce)');
- });
-
- it('reports what the system says', () => {
- installMatchMedia(true);
- const { result } = renderHook(() => useReducedMotion());
-
- expect(result.current).toBe(true);
- });
-
- // The setting can be turned on part-way through a row, and the motion it is
- // meant to stop is happening at that moment.
- it('notices it being turned on mid-row', () => {
- const media = installMatchMedia(false);
- const { result } = renderHook(() => useReducedMotion());
- expect(result.current).toBe(false);
-
- act(() => media.change(true));
-
- expect(result.current).toBe(true);
- });
-
- it('stops listening when the scene goes away', () => {
- const media = installMatchMedia(false);
- const { unmount } = renderHook(() => useReducedMotion());
- expect(media.listenerCount).toBe(1);
-
- unmount();
-
- expect(media.listenerCount).toBe(0);
- });
-
- // No `matchMedia` is no answer, not an answer of "reduce". A webview without
- // it would otherwise get a scene with the motion taken out of it.
- it('leaves the scene as authored where there is nothing to ask', () => {
- const { result } = renderHook(() => useReducedMotion());
-
- expect(result.current).toBe(false);
- });
-
- describe('as a ref, for the frame loop', () => {
- it('carries the preference without a re-render', () => {
- const media = installMatchMedia(true);
- let renders = 0;
- const { result } = renderHook(() => {
- renders += 1;
- return useReducedMotionRef();
- });
-
- expect(result.current.current).toBe(true);
- const rendersAfterMount = renders;
-
- act(() => media.change(false));
-
- expect(result.current.current).toBe(false);
- expect(renders, 'the scene re-rendered to learn a frame-loop value').toBe(rendersAfterMount);
- });
-
- it('defaults to the authored motion where there is nothing to ask', () => {
- const { result } = renderHook(() => useReducedMotionRef());
-
- expect(result.current.current).toBe(false);
- });
- });
-});
diff --git a/src/__tests__/useRenderStats.test.ts b/src/__tests__/useRenderStats.test.ts
deleted file mode 100644
index b156a377..00000000
--- a/src/__tests__/useRenderStats.test.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
-import { renderHook, act } from '@testing-library/react';
-import { useRenderStats, STALL_THRESHOLD_MS } from '../hooks/useRenderStats';
-import { recordRenderStats, clearRenderStats } from '../components/rower3d/sceneStats';
-
-const renderer = (calls: number) => {
- const info = {
- autoReset: true,
- render: { calls, triangles: calls * 100 },
- reset: () => {
- info.render.calls = 0;
- info.render.triangles = 0;
- },
- };
- return { info };
-};
-
-const record = (calls: number) =>
- recordRenderStats(renderer(calls), { backend: 'webgl', performanceMode: 'high' });
-
-describe('useRenderStats', () => {
- beforeEach(() => {
- clearRenderStats();
- vi.useFakeTimers();
- });
-
- afterEach(() => {
- vi.useRealTimers();
- });
-
- it('reads nothing while the panel is closed, so a hidden panel costs nothing', () => {
- record(300);
-
- const { result } = renderHook(() => useRenderStats(false));
-
- expect(result.current.stats).toBeNull();
- });
-
- it('reports the scene cost once it is showing', () => {
- record(412);
-
- const { result } = renderHook(() => useRenderStats(true));
-
- expect(result.current.stats?.drawCalls).toBe(412);
- expect(result.current.stats?.triangles).toBe(41_200);
- });
-
- it('keeps up as the scene changes', () => {
- record(100);
- const { result } = renderHook(() => useRenderStats(true, 500));
-
- record(250);
- act(() => void vi.advanceTimersByTime(600));
-
- expect(result.current.stats?.drawCalls).toBe(250);
- });
-
- it('calls out a render loop that has stopped drawing', () => {
- record(120);
- const { result } = renderHook(() => useRenderStats(true, 500));
-
- expect(result.current.stalled).toBe(false);
-
- vi.setSystemTime(Date.now() + STALL_THRESHOLD_MS + 1000);
- act(() => void vi.advanceTimersByTime(600));
-
- expect(result.current.stalled).toBe(true);
- });
-});
diff --git a/src/__tests__/useRowerAppearance.test.tsx b/src/__tests__/useRowerAppearance.test.tsx
deleted file mode 100644
index 9a64afd9..00000000
--- a/src/__tests__/useRowerAppearance.test.tsx
+++ /dev/null
@@ -1,48 +0,0 @@
-import { act, renderHook } from '@testing-library/react';
-import { describe, expect, it } from 'vitest';
-import { useRowerAppearance } from '../hooks/useRowerAppearance';
-import {
- DEFAULT_ROWER_APPEARANCE,
- type RowerAppearancePreference,
- type RowerAppearanceService,
-} from '../lib/services/rowerAppearanceService';
-
-const stubService = (
- initial: RowerAppearancePreference = { ...DEFAULT_ROWER_APPEARANCE },
-): RowerAppearanceService & { writes: RowerAppearancePreference[] } => {
- const writes: RowerAppearancePreference[] = [];
- let current = { ...initial };
- return {
- read: () => ({ ...current }),
- write: (next) => {
- current = { ...next };
- writes.push({ ...next });
- },
- writes,
- };
-};
-
-describe('useRowerAppearance', () => {
- it('exposes the service default on first render', () => {
- const service = stubService();
- const { result } = renderHook(() => useRowerAppearance(service));
- expect(result.current.appearance).toEqual(DEFAULT_ROWER_APPEARANCE);
- });
-
- it('setSlot writes through the service and updates state', () => {
- const service = stubService();
- const { result } = renderHook(() => useRowerAppearance(service));
- act(() => result.current.setSlot('top', 'red'));
- expect(result.current.appearance.top).toBe('red');
- expect(service.writes.at(-1)?.top).toBe('red');
- });
-
- it('reset returns to the default and writes it through', () => {
- const service = stubService({ ...DEFAULT_ROWER_APPEARANCE, top: 'red' });
- const { result } = renderHook(() => useRowerAppearance(service));
- expect(result.current.appearance.top).toBe('red');
- act(() => result.current.reset());
- expect(result.current.appearance).toEqual(DEFAULT_ROWER_APPEARANCE);
- expect(service.writes.at(-1)).toEqual(DEFAULT_ROWER_APPEARANCE);
- });
-});
diff --git a/src/__tests__/useRowerServiceEvents.test.ts b/src/__tests__/useRowerServiceEvents.test.ts
deleted file mode 100644
index b5517a84..00000000
--- a/src/__tests__/useRowerServiceEvents.test.ts
+++ /dev/null
@@ -1,186 +0,0 @@
-import { describe, expect, it, vi } from 'vitest';
-import { act, renderHook } from '@testing-library/react';
-import {
- useRowerServiceEvents,
- type RowerEventSource,
- type RowerEventHandlers,
-} from '../hooks/useRowerServiceEvents';
-import type { PM5Data } from '../lib/types/index';
-
-function createMockService(): RowerEventSource & {
- emit: (event: string, data?: unknown) => void;
- listenerCount: (event: string) => number;
-} {
- const listeners = new Map void>>();
-
- return {
- on(event: string, listener: (data: unknown) => void) {
- if (!listeners.has(event)) listeners.set(event, new Set());
- listeners.get(event)!.add(listener);
- },
- off(event: string, listener: (data: unknown) => void) {
- listeners.get(event)?.delete(listener);
- },
- emit(event: string, data?: unknown) {
- listeners.get(event)?.forEach((fn) => fn(data));
- },
- listenerCount(event: string) {
- return listeners.get(event)?.size ?? 0;
- },
- };
-}
-
-function makePM5Frame(overrides: Partial = {}): PM5Data {
- return { distance: 100, elapsedTime: 30_000, pace: 120, power: 200, cadence: 28, ...overrides };
-}
-
-describe('useRowerServiceEvents', () => {
- // #210 AC-1′ — before this hook existed, BluetoothDevice/FTMSDevice subscribed
- // from the routes view. When the view switched to 'workout', the components
- // unmounted, tearing down the listeners, and the session recorded 0 m for the
- // whole row. This test proves the App-level subscription delivers data regardless
- // of which view is active.
- it('delivers data events to the handler continuously', () => {
- const service = createMockService();
- const onData = vi.fn();
-
- renderHook(() => useRowerServiceEvents(service, { onData }));
-
- const frame1 = makePM5Frame({ distance: 100 });
- const frame2 = makePM5Frame({ distance: 200 });
- const frame3 = makePM5Frame({ distance: 300 });
-
- act(() => service.emit('data', frame1));
- act(() => service.emit('data', frame2));
- act(() => service.emit('data', frame3));
-
- expect(onData).toHaveBeenCalledTimes(3);
- expect(onData).toHaveBeenNthCalledWith(1, frame1);
- expect(onData).toHaveBeenNthCalledWith(2, frame2);
- expect(onData).toHaveBeenNthCalledWith(3, frame3);
- });
-
- // #210 AC-3 — the subscription is registered once (keyed on the service
- // reference) and never torn down by a re-render or by an unrelated component
- // unmounting. Only unmounting the host component (App) cleans up.
- it('keeps the subscription alive across handler identity changes', () => {
- const service = createMockService();
- const onData1 = vi.fn();
- const onData2 = vi.fn();
-
- const { rerender } = renderHook(
- ({ handlers }: { handlers: RowerEventHandlers }) =>
- useRowerServiceEvents(service, handlers),
- { initialProps: { handlers: { onData: onData1 } } },
- );
-
- expect(service.listenerCount('data')).toBe(1);
-
- // Simulate a re-render with a new handler identity — must not re-subscribe
- rerender({ handlers: { onData: onData2 } });
- expect(service.listenerCount('data')).toBe(1);
-
- act(() => service.emit('data', makePM5Frame()));
-
- expect(onData1).not.toHaveBeenCalled();
- expect(onData2).toHaveBeenCalledTimes(1);
- });
-
- it('unsubscribes only when the host unmounts, not on re-render', () => {
- const service = createMockService();
- const onData = vi.fn();
-
- const { unmount, rerender } = renderHook(
- ({ handlers }: { handlers: RowerEventHandlers }) =>
- useRowerServiceEvents(service, handlers),
- { initialProps: { handlers: { onData } } },
- );
-
- rerender({ handlers: { onData } });
- expect(service.listenerCount('data')).toBe(1);
-
- unmount();
- expect(service.listenerCount('data')).toBe(0);
-
- act(() => service.emit('data', makePM5Frame()));
- expect(onData).not.toHaveBeenCalled();
- });
-
- it('forwards connected events with the device name', () => {
- const service = createMockService();
- const onConnected = vi.fn();
-
- renderHook(() => useRowerServiceEvents(service, { onConnected }));
-
- act(() => service.emit('connected', { deviceName: 'Concept2 PM5' }));
-
- expect(onConnected).toHaveBeenCalledWith('Concept2 PM5');
- });
-
- it('forwards disconnected events', () => {
- const service = createMockService();
- const onDisconnected = vi.fn();
-
- renderHook(() => useRowerServiceEvents(service, { onDisconnected }));
-
- act(() => service.emit('disconnected'));
-
- expect(onDisconnected).toHaveBeenCalledTimes(1);
- });
-
- it('forwards error events with the message', () => {
- const service = createMockService();
- const onError = vi.fn();
-
- renderHook(() => useRowerServiceEvents(service, { onError }));
-
- act(() => service.emit('error', { message: 'GATT connection lost' }));
-
- expect(onError).toHaveBeenCalledWith('GATT connection lost');
- });
-
- it('defaults device name to empty string when payload is missing', () => {
- const service = createMockService();
- const onConnected = vi.fn();
-
- renderHook(() => useRowerServiceEvents(service, { onConnected }));
-
- act(() => service.emit('connected', undefined));
-
- expect(onConnected).toHaveBeenCalledWith('');
- });
-
- it('defaults error message when payload is missing', () => {
- const service = createMockService();
- const onError = vi.fn();
-
- renderHook(() => useRowerServiceEvents(service, { onError }));
-
- act(() => service.emit('error', undefined));
-
- expect(onError).toHaveBeenCalledWith('Unknown error occurred');
- });
-
- // #210 AC-4′ — FTMS and HR use the same subscription mechanism. This test
- // proves a second independent service instance has its own subscription
- // lifecycle, confirming the pattern generalises to all rower types.
- it('supports two independent services with separate subscriptions', () => {
- const pm5Service = createMockService();
- const ftmsService = createMockService();
- const onPM5Data = vi.fn();
- const onFTMSData = vi.fn();
-
- renderHook(() => {
- useRowerServiceEvents(pm5Service, { onData: onPM5Data });
- useRowerServiceEvents(ftmsService, { onData: onFTMSData });
- });
-
- act(() => pm5Service.emit('data', makePM5Frame({ distance: 100 })));
- act(() => ftmsService.emit('data', makePM5Frame({ distance: 200 })));
-
- expect(onPM5Data).toHaveBeenCalledTimes(1);
- expect(onPM5Data).toHaveBeenCalledWith(expect.objectContaining({ distance: 100 }));
- expect(onFTMSData).toHaveBeenCalledTimes(1);
- expect(onFTMSData).toHaveBeenCalledWith(expect.objectContaining({ distance: 200 }));
- });
-});
diff --git a/src/__tests__/useRownativeDeepLink.test.tsx b/src/__tests__/useRownativeDeepLink.test.tsx
deleted file mode 100644
index 7ba7bf5a..00000000
--- a/src/__tests__/useRownativeDeepLink.test.tsx
+++ /dev/null
@@ -1,249 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-import { StrictMode } from 'react';
-import { render, screen, waitFor } from '@testing-library/react';
-import { ServicesProvider } from '../context/ServicesContext';
-import { defaultServices } from '../context/useServices';
-import { resetDeepLinkForTests, useRownativeDeepLink } from '../hooks/useRownativeDeepLink';
-import type { Services } from '../lib/ports';
-import type { WaterRoute } from '../lib/types/index';
-
-function makeRoute(name = 'Deep Linked Course'): WaterRoute {
- return {
- id: 'r1', name, description: '', distance: 5.35, difficulty: 'moderate',
- location: 'United States', coordinates: [{ lat: 1, lng: 2 }, { lat: 2, lng: 3 }],
- elevationGain: 0, estimatedTime: 92, tags: ['rownative'], createdAt: new Date(),
- source: 'rownative', externalId: '5',
- };
-}
-
-function Harness({ onRouteLoaded, isReady }: { onRouteLoaded: (r: WaterRoute) => void; isReady?: boolean }) {
- const { status } = useRownativeDeepLink({ onRouteLoaded, isReady });
- return
;
-}
-
-function renderHook(search: string, overrides?: Partial, isReady = true) {
- window.history.replaceState({}, '', `/${search}`);
- const onRouteLoaded = vi.fn();
- const utils = render(
-
-
- ,
- );
- return { onRouteLoaded, ...utils };
-}
-
-const noExistingRoute = {
- ...defaultServices.routeService,
- findRouteByRownativeId: () => undefined,
-} satisfies Services['routeService'];
-
-describe('useRownativeDeepLink', () => {
- beforeEach(() => {
- vi.restoreAllMocks();
- window.history.replaceState({}, '', '/');
- resetDeepLinkForTests();
- });
-
- it('does nothing when the URL carries no course id', () => {
- const importCourseById = vi.fn();
- const { onRouteLoaded } = renderHook('', {
- rownativeService: { ...defaultServices.rownativeService, importCourseById } satisfies Services['rownativeService'],
- });
-
- expect(screen.getByTestId('status').textContent).toBe('idle');
- expect(importCourseById).not.toHaveBeenCalled();
- expect(onRouteLoaded).not.toHaveBeenCalled();
- });
-
- it('imports the course named in the URL — no state nonce required (AC-2)', async () => {
- const route = makeRoute();
- const importCourseById = vi.fn().mockResolvedValue(route);
- const { onRouteLoaded } = renderHook('?rownativeCourseId=5', {
- rownativeService: { ...defaultServices.rownativeService, importCourseById } satisfies Services['rownativeService'],
- routeService: noExistingRoute,
- });
-
- await waitFor(() => expect(onRouteLoaded).toHaveBeenCalledWith(route));
- expect(importCourseById).toHaveBeenCalledWith('5');
- });
-
- it('strips the param exactly once so a refresh does not re-import (AC-2)', async () => {
- const importCourseById = vi.fn().mockResolvedValue(makeRoute());
- const { unmount } = renderHook('?rownativeCourseId=5', {
- rownativeService: { ...defaultServices.rownativeService, importCourseById } satisfies Services['rownativeService'],
- routeService: noExistingRoute,
- });
-
- await waitFor(() => expect(new URLSearchParams(window.location.search).get('rownativeCourseId')).toBeNull());
- unmount();
-
- // Simulate an actual refresh: fresh module state, reading the cleaned URL.
- resetDeepLinkForTests();
- render(
-
-
- ,
- );
- expect(importCourseById).toHaveBeenCalledTimes(1);
- });
-
- it('preserves unrelated query params and the hash while stripping ours', async () => {
- window.history.replaceState({}, '', '/?keep=1&rownativeCourseId=5&state=legacy#section');
- render(
-
-
- ,
- );
-
- await waitFor(() => {
- const params = new URLSearchParams(window.location.search);
- expect(params.get('rownativeCourseId')).toBeNull();
- // Legacy nonce from the abandoned redirect design is tolerated and stripped.
- expect(params.get('state')).toBeNull();
- expect(params.get('keep')).toBe('1');
- expect(window.location.hash).toBe('#section');
- });
- });
-
- it('holds the import until the app is ready, then runs it (RS-5)', async () => {
- const route = makeRoute();
- const importCourseById = vi.fn().mockResolvedValue(route);
- const services = {
- ...defaultServices,
- rownativeService: { ...defaultServices.rownativeService, importCourseById } satisfies Services['rownativeService'],
- routeService: noExistingRoute,
- };
- window.history.replaceState({}, '', '/?rownativeCourseId=5');
- const onRouteLoaded = vi.fn();
-
- const { rerender } = render(
-
-
- ,
- );
- expect(importCourseById).not.toHaveBeenCalled();
-
- rerender(
-
-
- ,
- );
- await waitFor(() => expect(importCourseById).toHaveBeenCalledWith('5'));
- });
-
- it('selects an already imported course instead of importing twice (AC-5)', async () => {
- const existing = makeRoute('Already Imported');
- const importCourseById = vi.fn();
- const { onRouteLoaded } = renderHook('?rownativeCourseId=5', {
- rownativeService: { ...defaultServices.rownativeService, importCourseById } satisfies Services['rownativeService'],
- routeService: { ...defaultServices.routeService, findRouteByRownativeId: () => existing } satisfies Services['routeService'],
- });
-
- await waitFor(() => expect(onRouteLoaded).toHaveBeenCalledWith(existing));
- expect(importCourseById).not.toHaveBeenCalled();
- });
-
- it('surfaces a load failure and creates no route (AC-3)', async () => {
- const { onRouteLoaded } = renderHook('?rownativeCourseId=2', {
- rownativeService: {
- ...defaultServices.rownativeService,
- importCourseById: vi.fn().mockRejectedValue(new Error("Course 2 isn't in the public course data yet.")),
- } satisfies Services['rownativeService'],
- routeService: noExistingRoute,
- });
-
- await waitFor(() => expect(screen.getByTestId('status').textContent).toMatch(/isn't in the public course data yet/i));
- expect(onRouteLoaded).not.toHaveBeenCalled();
- });
-
- it('rejects a malformed id via the service, making no route', async () => {
- const importCourseById = vi.fn().mockRejectedValue(new Error('Enter a rownative course ID or a rownative.icu course link.'));
- const { onRouteLoaded } = renderHook(`?rownativeCourseId=${encodeURIComponent('../../etc/passwd')}`, {
- rownativeService: { ...defaultServices.rownativeService, importCourseById } satisfies Services['rownativeService'],
- routeService: noExistingRoute,
- });
-
- await waitFor(() => expect(screen.getByTestId('status').textContent).toMatch(/course ID or a rownative\.icu course link/i));
- expect(onRouteLoaded).not.toHaveBeenCalled();
- });
-});
-
-describe('under StrictMode, which is how the app actually runs (#288)', () => {
- /**
- * main.tsx wraps the app in , and React double-invokes effects
- * there in a development build. This hook is written for that - its own
- * comments say "StrictMode's double-invoked initialiser, or any remount, sees
- * the same answer" and "cancelling on a StrictMode cleanup would abandon the
- * only import".
- *
- * Nothing tested it. The E2E suite runs the production build, where
- * StrictMode is inert, so the double-invoke path had no coverage at all and a
- * regression would have appeared only to whoever next ran `npm run dev`.
- */
- beforeEach(() => {
- vi.restoreAllMocks();
- window.history.replaceState({}, '', '/');
- resetDeepLinkForTests();
- });
-
- it('imports the course once, not twice, when effects are double-invoked', async () => {
- const importCourseById = vi.fn().mockResolvedValue(makeRoute());
- window.history.replaceState({}, '', '/?rownativeCourseId=5');
- const onRouteLoaded = vi.fn();
-
- render(
-
-
-
-
- ,
- );
-
- await waitFor(() => expect(onRouteLoaded).toHaveBeenCalled());
-
- expect(
- importCourseById.mock.calls.length,
- 'the double-invoked effect imported the course more than once',
- ).toBe(1);
- });
-
- it('does not abandon the import when the first effect is torn down', async () => {
- // The cleanup of the discarded first pass must not cancel the only import
- // in flight, which is the failure the hook's own comment warns about.
- const importCourseById = vi.fn().mockResolvedValue(makeRoute());
- window.history.replaceState({}, '', '/?rownativeCourseId=5');
- const onRouteLoaded = vi.fn();
-
- render(
-
-
-
-
- ,
- );
-
- await waitFor(() =>
- expect(onRouteLoaded, 'the import was abandoned by a StrictMode cleanup').toHaveBeenCalled(),
- );
- });
-});
diff --git a/src/__tests__/useStartSequence.test.tsx b/src/__tests__/useStartSequence.test.tsx
deleted file mode 100644
index c089b058..00000000
--- a/src/__tests__/useStartSequence.test.tsx
+++ /dev/null
@@ -1,159 +0,0 @@
-import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-import { renderHook, act } from '@testing-library/react';
-import { isStrokeReading, useStartSequence, type StartSequenceInput } from '../hooks/useStartSequence';
-
-/**
- * Issue #336 — a row starts on the first stroke, after a 3-2-1.
- *
- * `idle → armed → counting(3..1) → go → rowing`. The boat is held at the start
- * while armed and counting, and let go on "Row!".
- */
-
-const input = (overrides: Partial = {}): StartSequenceInput => ({
- active: true,
- strokeAt: null,
- autoStart: false,
- ...overrides,
-});
-
-beforeEach(() => {
- vi.useFakeTimers();
-});
-
-afterEach(() => {
- vi.useRealTimers();
-});
-
-const advance = (ms: number) =>
- act(() => {
- vi.advanceTimersByTime(ms);
- });
-
-describe('useStartSequence', () => {
- it('is idle, and holds nothing, without a session', () => {
- const { result } = renderHook(() => useStartSequence(input({ active: false })));
- expect(result.current).toEqual({ phase: 'idle', countdown: null, holdBoat: false });
- });
-
- it('arms on a new session and holds the boat until a stroke', () => {
- const { result } = renderHook(() => useStartSequence(input()));
- expect(result.current).toEqual({ phase: 'armed', countdown: null, holdBoat: true });
-
- advance(60_000);
- expect(result.current.phase).toBe('armed');
- expect(result.current.holdBoat).toBe(true);
- });
-
- it('counts 3, 2, 1 a second apart from the first stroke, then says row', () => {
- const { result, rerender } = renderHook((p: StartSequenceInput) => useStartSequence(p), {
- initialProps: input(),
- });
-
- rerender(input({ strokeAt: Date.now() }));
- expect(result.current).toEqual({ phase: 'counting', countdown: 3, holdBoat: true });
-
- advance(1000);
- expect(result.current.countdown).toBe(2);
- advance(1000);
- expect(result.current.countdown).toBe(1);
- expect(result.current.holdBoat).toBe(true);
-
- advance(1000);
- expect(result.current).toEqual({ phase: 'go', countdown: null, holdBoat: false });
- });
-
- it('clears "Row!" after a second and leaves the boat free', () => {
- const { result } = renderHook(() => useStartSequence(input({ strokeAt: Date.now() })));
- advance(3000);
- expect(result.current.phase).toBe('go');
-
- advance(1000);
- expect(result.current).toEqual({ phase: 'rowing', countdown: null, holdBoat: false });
- });
-
- it('does not restart the countdown on later strokes', () => {
- const { result, rerender } = renderHook((p: StartSequenceInput) => useStartSequence(p), {
- initialProps: input({ strokeAt: Date.now() }),
- });
- advance(1500);
- rerender(input({ strokeAt: null }));
- rerender(input({ strokeAt: Date.now() }));
- expect(result.current.countdown).toBe(2);
- });
-
- it('starts the countdown by itself in demo mode', () => {
- const { result } = renderHook(() => useStartSequence(input({ autoStart: true })));
- expect(result.current.phase).toBe('counting');
- expect(result.current.countdown).toBe(3);
-
- advance(4000);
- expect(result.current.phase).toBe('rowing');
- });
-
- it('goes back to idle when the session ends, and arms afresh for the next', () => {
- const { result, rerender } = renderHook((p: StartSequenceInput) => useStartSequence(p), {
- initialProps: input({ strokeAt: Date.now() }),
- });
- advance(5000);
- expect(result.current.phase).toBe('rowing');
-
- rerender(input({ active: false, strokeAt: Date.now() }));
- expect(result.current.phase).toBe('idle');
-
- // The erg still reports strokes from the last row; the next row waits for a
- // fresh one rather than counting down on a stale reading.
- rerender(input({ active: true, strokeAt: null }));
- expect(result.current.phase).toBe('armed');
- });
-
- it('stops counting when the session ends mid-countdown', () => {
- const { result, rerender } = renderHook((p: StartSequenceInput) => useStartSequence(p), {
- initialProps: input({ strokeAt: Date.now() }),
- });
- advance(1000);
- rerender(input({ active: false }));
- advance(10_000);
- expect(result.current).toEqual({ phase: 'idle', countdown: null, holdBoat: false });
- });
-
- it('does not replay an ended countdown into the next session', () => {
- const { result, rerender } = renderHook((p: StartSequenceInput) => useStartSequence(p), {
- initialProps: input({ strokeAt: Date.now() }),
- });
- advance(1000);
- rerender(input({ active: false }));
- rerender(input({ active: true }));
- advance(10_000);
- expect(result.current.phase).toBe('armed');
- expect(result.current.holdBoat).toBe(true);
- });
-});
-
-describe('useStartSequence timing', () => {
- it('counts from the stroke, not from when the page got round to it', () => {
- // The page was busy for 1.5 s after the drive: the count is already at 2,
- // and "Row!" is 1.5 s away rather than 3.
- const { result } = renderHook(() => useStartSequence(input({ strokeAt: Date.now() - 1500 })));
- expect(result.current.phase).toBe('counting');
-
- advance(0);
- expect(result.current.countdown).toBe(2);
- advance(500);
- expect(result.current.countdown).toBe(1);
- advance(1000);
- expect(result.current.phase).toBe('go');
- });
-});
-
-describe('isStrokeReading', () => {
- it('is a stroke when the erg reports a rate or power', () => {
- expect(isStrokeReading({ cadence: 22 })).toBe(true);
- expect(isStrokeReading({ power: 140 })).toBe(true);
- });
-
- it('is not a stroke at rest, or with nothing connected', () => {
- expect(isStrokeReading({ cadence: 0, power: 0 })).toBe(false);
- expect(isStrokeReading({})).toBe(false);
- expect(isStrokeReading(null)).toBe(false);
- });
-});
diff --git a/src/__tests__/useStrokeAudio.test.tsx b/src/__tests__/useStrokeAudio.test.tsx
deleted file mode 100644
index 8ba6e90d..00000000
--- a/src/__tests__/useStrokeAudio.test.tsx
+++ /dev/null
@@ -1,128 +0,0 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { renderHook } from '@testing-library/react';
-import { useStrokeAudio } from '../hooks/useStrokeAudio';
-import type { AudioPort } from '../lib/ports';
-
-/**
- * Issue #339 — the stroke you hear.
- *
- * The catch is what makes a stroke feel connected to the screen, so it is
- * played on the transition into it rather than on every render that happens to
- * find the phase there: at 60 fps a drive lasts some forty renders, and forty
- * splashes is white noise.
- */
-
-const port = () => {
- const audio = {
- enable: vi.fn(async () => {}),
- disable: vi.fn(async () => {}),
- isEnabled: vi.fn(() => true),
- setVolume: vi.fn(),
- getVolume: vi.fn(() => 0.5),
- splash: vi.fn(),
- cue: vi.fn(),
- startWater: vi.fn(),
- stopWater: vi.fn(),
- };
- return audio as unknown as AudioPort & typeof audio;
-};
-
-let audio: ReturnType;
-
-beforeEach(() => {
- audio = port();
-});
-
-const strokes = (phases: string[], options: { intensity?: number; reduced?: boolean } = {}) => {
- const { rerender } = renderHook(
- ({ phase }: { phase: string }) =>
- useStrokeAudio(audio, phase, options.intensity ?? 1, options.reduced ?? false),
- { initialProps: { phase: phases[0] } },
- );
- for (const phase of phases.slice(1)) rerender({ phase });
-};
-
-describe('the stroke’s sound', () => {
- it('plays once as the blade goes in, not once a frame', () => {
- strokes(['recovery', 'catch', 'catch', 'catch', 'drive']);
-
- expect(audio.splash).toHaveBeenCalledTimes(1);
- });
-
- it('plays again on the next stroke', () => {
- strokes(['recovery', 'catch', 'drive', 'finish', 'recovery', 'catch']);
-
- // One per catch; the finish is quieter but it is still a sound.
- const catches = audio.splash.mock.calls.filter((call) => (call[0] as number) > 0.5);
- expect(catches).toHaveLength(2);
- });
-
- // The finish is the blade coming out, which is a smaller sound than the
- // blade going in. Same splash, less of it.
- it('plays the finish more quietly than the catch', () => {
- strokes(['recovery', 'catch', 'drive', 'finish']);
-
- const [atCatch, atFinish] = audio.splash.mock.calls.map((call) => call[0] as number);
- expect(atFinish).toBeLessThan(atCatch);
- });
-
- /**
- * The stroke's own loudness.
- *
- * A light paddle and a racing catch are the same sound at different volumes,
- * so the intensity the physics reports is passed through rather than a
- * constant that makes every stroke sound like the same stroke.
- */
- it('is as loud as the stroke that made it', () => {
- strokes(['recovery', 'catch'], { intensity: 0.3 });
- const gentle = audio.splash.mock.calls[0][0] as number;
-
- audio = port();
- strokes(['recovery', 'catch'], { intensity: 1 });
- const hard = audio.splash.mock.calls[0][0] as number;
-
- expect(hard).toBeGreaterThan(gentle);
- });
-
- /**
- * `prefers-reduced-motion` is a request not to be startled (#344).
- *
- * A rower who has asked for less movement has not asked for a noise on every
- * stroke either, so the stroke sounds are not added automatically — the water
- * bed and the cues the rower asked for still play.
- */
- it('adds nothing on a stroke for a rower who asked for less', () => {
- strokes(['recovery', 'catch', 'drive', 'finish'], { reduced: true });
-
- expect(audio.splash).not.toHaveBeenCalled();
- });
-
- it('says nothing while the sound is switched off', () => {
- audio.isEnabled = vi.fn(() => false);
-
- strokes(['recovery', 'catch']);
-
- expect(audio.splash).not.toHaveBeenCalled();
- });
-
- // The first phase the hook sees is where the stroke already was, not a
- // transition into it: a page that loads mid-drive should not splash.
- it('does not play for the phase it started on', () => {
- strokes(['catch']);
-
- expect(audio.splash).not.toHaveBeenCalled();
- });
-});
-
-// A scene mounted with no sound wired to it at all — the default everywhere
-// the audio port has not been passed down.
-describe('a scene with no sound at all', () => {
- it('plays nothing, and does not fall over trying', () => {
- const { rerender } = renderHook(
- ({ phase }: { phase: string }) => useStrokeAudio(null, phase, 1, false),
- { initialProps: { phase: 'recovery' } },
- );
-
- expect(() => rerender({ phase: 'catch' })).not.toThrow();
- });
-});
diff --git a/src/__tests__/useWorkoutTargetController.test.tsx b/src/__tests__/useWorkoutTargetController.test.tsx
deleted file mode 100644
index 1435501e..00000000
--- a/src/__tests__/useWorkoutTargetController.test.tsx
+++ /dev/null
@@ -1,145 +0,0 @@
-import { describe, it, expect, vi } from 'vitest';
-import { renderHook } from '@testing-library/react';
-import {
- useWorkoutTargetController,
- segmentHasControllableTarget,
- computeTargetPayload,
-} from '../hooks/useWorkoutTargetController';
-import type { WorkoutSegment } from '../lib/types';
-import type { RowerTargetPort } from '../lib/ports';
-
-const makeSegment = (partial: Partial): WorkoutSegment => ({
- id: 'seg-1',
- order: 0,
- type: 'work',
- duration: 60,
- ...partial,
-});
-
-const stubPort = () => {
- const setTargets = vi.fn<(...args: Parameters) => void>();
- const port: RowerTargetPort = { setTargets };
- return { port, setTargets };
-};
-
-describe('computeTargetPayload (#445, FR5 / D8(a) / D9(a) / AC6 / AC7)', () => {
- it('returns null when the block has no controllable target (AC7)', () => {
- expect(computeTargetPayload(makeSegment({ type: 'warmup' }), 120)).toBeNull();
- expect(segmentHasControllableTarget(makeSegment({ type: 'warmup' }))).toBe(false);
- });
-
- it('sets pace to the midpoint of the target band (AC6, unclamped case)', () => {
- const payload = computeTargetPayload(
- makeSegment({ targetPaceMin: 110, targetPaceMax: 112 }),
- 111, // current pace already within ±10 %, so midpoint 111 survives
- );
- expect(payload).toEqual({ paceSecondsPer500: 111 });
- });
-
- it('clamps the setpoint to ±10 % of the athlete\'s current pace (AC6)', () => {
- const fast = computeTargetPayload(
- makeSegment({ targetPaceMin: 60, targetPaceMax: 62 }),
- 120,
- );
- // Lower bound = 120 * 0.9 = 108, midpoint 61 is below that → clamped up to 108
- expect(fast?.paceSecondsPer500).toBe(108);
- const slow = computeTargetPayload(
- makeSegment({ targetPaceMin: 300, targetPaceMax: 320 }),
- 120,
- );
- // Upper bound = 120 * 1.1 = 132, midpoint 310 above → clamped down to 132
- expect(slow?.paceSecondsPer500).toBe(132);
- });
-
- it('passes power through as-is', () => {
- const payload = computeTargetPayload(
- makeSegment({ targetPower: 250, targetPaceMin: 110, targetPaceMax: 112 }),
- 111,
- );
- expect(payload).toEqual({ paceSecondsPer500: 111, powerWatts: 250 });
- });
-
- it('leaves the pace unbounded (midpoint) when the athlete has not started rowing yet', () => {
- const payload = computeTargetPayload(
- makeSegment({ targetPaceMin: 110, targetPaceMax: 112 }),
- undefined,
- );
- expect(payload).toEqual({ paceSecondsPer500: 111 });
- });
-});
-
-describe('useWorkoutTargetController (#445, FR5)', () => {
- it('does not write when active is false (row stage unmounted)', () => {
- const { port, setTargets } = stubPort();
- renderHook(() =>
- useWorkoutTargetController({
- segment: makeSegment({ targetPaceMin: 110, targetPaceMax: 112 }),
- currentPaceSecondsPer500: 111,
- rowerTargets: port,
- active: false,
- }),
- );
- expect(setTargets).not.toHaveBeenCalled();
- });
-
- it('does not call setTargets when the block has no targets (AC7)', () => {
- const { port, setTargets } = stubPort();
- renderHook(() =>
- useWorkoutTargetController({
- segment: makeSegment({ type: 'warmup' }),
- currentPaceSecondsPer500: 111,
- rowerTargets: port,
- active: true,
- }),
- );
- expect(setTargets).not.toHaveBeenCalled();
- });
-
- it('writes on mount and again after 200 ms (throttled to 5 Hz, AC6)', () => {
- let now = 0;
- const { port, setTargets } = stubPort();
- const { rerender } = renderHook(
- ({ pace }: { pace: number }) =>
- useWorkoutTargetController({
- segment: makeSegment({ targetPaceMin: 110, targetPaceMax: 112 }),
- currentPaceSecondsPer500: pace,
- rowerTargets: port,
- active: true,
- now: () => now,
- }),
- { initialProps: { pace: 111 } },
- );
- expect(setTargets).toHaveBeenCalledTimes(1);
- now = 100; // 100 ms — within throttle window
- rerender({ pace: 112 });
- expect(setTargets).toHaveBeenCalledTimes(1);
- now = 220; // > 200 ms — next write allowed
- rerender({ pace: 113 });
- expect(setTargets).toHaveBeenCalledTimes(2);
- });
-
- it('resets the throttle on a segment change so the new block writes immediately', () => {
- let now = 0;
- const { port, setTargets } = stubPort();
- const { rerender } = renderHook(
- ({ seg }: { seg: WorkoutSegment }) =>
- useWorkoutTargetController({
- segment: seg,
- currentPaceSecondsPer500: 120,
- rowerTargets: port,
- active: true,
- now: () => now,
- }),
- {
- initialProps: {
- seg: makeSegment({ id: 'a', targetPaceMin: 110, targetPaceMax: 112 }),
- },
- },
- );
- expect(setTargets).toHaveBeenCalledTimes(1);
- now = 50; // way inside throttle window
- rerender({ seg: makeSegment({ id: 'b', targetPaceMin: 100, targetPaceMax: 102 }) });
- // New segment id → throttle reset → immediate write
- expect(setTargets).toHaveBeenCalledTimes(2);
- });
-});
diff --git a/src/__tests__/vegetationComponents.test.tsx b/src/__tests__/vegetationComponents.test.tsx
deleted file mode 100644
index 6b6a87de..00000000
--- a/src/__tests__/vegetationComponents.test.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-import { describe, it, expect, beforeAll, afterAll } from 'vitest';
-import * as THREE from 'three';
-import ReactThreeTestRenderer from '@react-three/test-renderer';
-import { installCanvasMock } from './canvasMock';
-
-/**
- * #496 Phase 2 (AC4) — `vegetationComponents.tsx` has no `grassMeshRef`
- * after the change. The bank's grass is now a density-field contribution in
- * `bankMaterial.ts`, so the single-tuft `InstancedMesh` branch (GRASS_MAX = 80
- * crossed planes) is gone. Reeds and rocks remain as `InstancedMesh` because
- * they are 3-D props the shader-painted cover cannot replace (D4-a).
- */
-
-const { GroundCover } = await import('../components/rower3d/vegetationComponents');
-
-const isInstanced = (o: THREE.Object3D): o is THREE.InstancedMesh =>
- (o as THREE.InstancedMesh).isInstancedMesh === true;
-
-/** The geometry type an instancedMesh wraps — reeds use CylinderGeometry,
- * rocks SphereGeometry, and the retired grass used PlaneGeometry. */
-const geometryKind = (mesh: THREE.InstancedMesh): string => mesh.geometry.type;
-
-describe('GroundCover (#496 Phase 2)', () => {
- let uninstall: () => void;
- beforeAll(() => {
- uninstall = installCanvasMock();
- });
- afterAll(() => uninstall());
-
- const mount = async () => {
- const renderer = await ReactThreeTestRenderer.create();
- const scene = renderer.scene.instance as unknown as THREE.Scene;
- const found: THREE.Object3D[] = [];
- scene.traverse((o) => found.push(o));
- return { renderer, instanced: found.filter(isInstanced) };
- };
-
- it('renders no PlaneGeometry InstancedMesh — the GRASS_MAX branch is gone', async () => {
- const { renderer, instanced } = await mount();
- const planes = instanced.filter((m) => geometryKind(m) === 'PlaneGeometry');
- expect(planes).toHaveLength(0);
- await renderer.unmount();
- });
-
- it('still renders reeds (CylinderGeometry) — D4-a keeps the 3-D props', async () => {
- const { renderer, instanced } = await mount();
- const kinds = new Set(instanced.map(geometryKind));
- expect(kinds.has('CylinderGeometry')).toBe(true);
- await renderer.unmount();
- });
-});
diff --git a/src/__tests__/visibilityCull.test.ts b/src/__tests__/visibilityCull.test.ts
deleted file mode 100644
index ac1c92e2..00000000
--- a/src/__tests__/visibilityCull.test.ts
+++ /dev/null
@@ -1,152 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import * as THREE from 'three';
-import {
- SCENERY_MOUNT_RANGE_PROGRESS,
- SCENERY_REMOUNT_PROGRESS,
- SCENERY_STATE_MIN_INTERVAL_SECONDS,
- chunkIndexFor,
- cullByDistance,
- shouldRebuildScenery,
- withinMountRange,
-} from '../components/rower3d/visibilityCull';
-
-/**
- * Issue #331 — the scenery was culled by re-rendering.
- *
- * `setSceneryState` ran ten times a second and `SceneryModels` answered by
- * rebuilding its JSX, mounting and unmounting `` groups as the boat
- * moved. This is the arithmetic that replaces it, in a form that can be
- * checked without a canvas.
- */
-
-describe('cullByDistance', () => {
- /** Four points at 0, 10, 100 and 1000 metres out along +X. */
- const centres = new Float32Array([0, 0, 10, 0, 100, 0, 1000, 0]);
- const boat = new THREE.Vector3(0, 0, 0);
-
- it('marks the near ones and not the far ones', () => {
- const out = cullByDistance(centres, boat, 50, new Uint8Array(4));
- expect(Array.from(out)).toEqual([1, 1, 0, 0]);
- });
-
- it('measures from the boat, not from the origin', () => {
- const out = cullByDistance(centres, new THREE.Vector3(1000, 0, 0), 50, new Uint8Array(4));
- expect(Array.from(out)).toEqual([0, 0, 0, 1]);
- });
-
- it('ignores height, because a bank is not a reason to stop drawing', () => {
- const high = cullByDistance(centres, new THREE.Vector3(0, 900, 0), 50, new Uint8Array(4));
- expect(Array.from(high)).toEqual([1, 1, 0, 0]);
- });
-
- it('includes a point exactly at the range', () => {
- const out = cullByDistance(new Float32Array([30, 40]), boat, 50, new Uint8Array(1));
- expect(out[0], 'the boundary case falls outside').toBe(1);
- });
-
- it('measures across both axes rather than down one', () => {
- const diagonal = new Float32Array([40, 40]);
- expect(cullByDistance(diagonal, boat, 50, new Uint8Array(1))[0]).toBe(0);
- expect(cullByDistance(diagonal, boat, 60, new Uint8Array(1))[0]).toBe(1);
- });
-
- it('writes into the array it is given rather than allocating one a frame', () => {
- const out = new Uint8Array(4);
- expect(cullByDistance(centres, boat, 50, out)).toBe(out);
- });
-
- it('clears a mark that has gone out of range, rather than only setting them', () => {
- const out = new Uint8Array([1, 1, 1, 1]);
- cullByDistance(centres, boat, 5, out);
- expect(Array.from(out)).toEqual([1, 0, 0, 0]);
- });
-});
-
-describe('chunkIndexFor', () => {
- it('splits the route evenly', () => {
- expect(chunkIndexFor(0, 4)).toBe(0);
- expect(chunkIndexFor(0.24, 4)).toBe(0);
- expect(chunkIndexFor(0.25, 4)).toBe(1);
- expect(chunkIndexFor(0.5, 4)).toBe(2);
- });
-
- it('gives the last chunk its own far end', () => {
- // Without the clamp the floor returns `chunks` at progress 1, and finishing
- // a route would unmount the world.
- expect(chunkIndexFor(1, 4)).toBe(3);
- expect(chunkIndexFor(1, 1)).toBe(0);
- });
-
- it('holds at the ends when progress runs past them', () => {
- expect(chunkIndexFor(-0.5, 4)).toBe(0);
- expect(chunkIndexFor(2, 4)).toBe(3);
- });
-
- it('has one chunk when there is one chunk', () => {
- expect(chunkIndexFor(0.7, 1)).toBe(0);
- expect(chunkIndexFor(0.7, 0)).toBe(0);
- });
-});
-
-describe('shouldRebuildScenery', () => {
- it('rebuilds when the boat crosses into a new chunk', () => {
- expect(shouldRebuildScenery(2, 1, 0.5, 0.5, 100, 0)).toBe(true);
- });
-
- // A chunk boundary alone is not enough. #331's plan assumed the scenery was
- // instanced and all of it could stay mounted; it is not — a tree is a
- // `coneGeometry` — and mounting the whole route took the #272 traverse from
- // 412 uploaded geometries to 766, against a ceiling of 627.
- it('rebuilds when the boat rows out of the window it was mounted for', () => {
- expect(
- shouldRebuildScenery(1, 1, 0.5 + SCENERY_REMOUNT_PROGRESS * 1.01, 0.5, 100, 0),
- 'the boat left its mounted window and nothing was rebuilt',
- ).toBe(true);
- });
-
- it('does not rebuild while the boat stays inside its window and its chunk', () => {
- expect(shouldRebuildScenery(1, 1, 0.51, 0.5, 100, 0)).toBe(false);
- });
-
- // Stated a hair past the threshold rather than on it: `0.5 - 0.05` is
- // 0.44999999999999996 in binary floating point, so an exact-boundary case
- // here would be testing the arithmetic of doubles, not the rule.
- it('rebuilds whichever way the boat is going', () => {
- const backwards = 0.5 - SCENERY_REMOUNT_PROGRESS * 1.01;
- expect(shouldRebuildScenery(1, 1, backwards, 0.5, 100, 0)).toBe(true);
- });
-
- it('refuses to rebuild twice inside the interval, however far the boat moved', () => {
- const justUnder = SCENERY_STATE_MIN_INTERVAL_SECONDS - 0.01;
- expect(shouldRebuildScenery(2, 1, 0.9, 0.1, justUnder, 0)).toBe(false);
- expect(
- shouldRebuildScenery(2, 1, 0.9, 0.1, SCENERY_STATE_MIN_INTERVAL_SECONDS, 0),
- ).toBe(true);
- });
-
- it('caps the rebuild rate at once a second', () => {
- expect(SCENERY_STATE_MIN_INTERVAL_SECONDS).toBe(1);
- });
-
- // The window has to be wider than the distance the boat can travel between
- // rebuilds, or it rows past the end of its own scenery.
- it('moves the window well before the boat reaches its edge', () => {
- expect(SCENERY_REMOUNT_PROGRESS).toBeLessThan(SCENERY_MOUNT_RANGE_PROGRESS);
- });
-});
-
-describe('withinMountRange', () => {
- it('mounts what is near the centre of the window', () => {
- expect(withinMountRange(0.5, 0.5)).toBe(true);
- expect(withinMountRange(0.5 + SCENERY_MOUNT_RANGE_PROGRESS / 2, 0.5)).toBe(true);
- });
-
- it('leaves out what is beyond it, on either side', () => {
- expect(withinMountRange(0.5 + SCENERY_MOUNT_RANGE_PROGRESS + 0.001, 0.5)).toBe(false);
- expect(withinMountRange(0.5 - SCENERY_MOUNT_RANGE_PROGRESS - 0.001, 0.5)).toBe(false);
- });
-
- it('keeps what sits exactly on the edge', () => {
- expect(withinMountRange(SCENERY_MOUNT_RANGE_PROGRESS, 0)).toBe(true);
- });
-});
diff --git a/src/__tests__/wakeTexture.test.ts b/src/__tests__/wakeTexture.test.ts
deleted file mode 100644
index c7904ea7..00000000
--- a/src/__tests__/wakeTexture.test.ts
+++ /dev/null
@@ -1,254 +0,0 @@
-import { describe, it, expect, beforeAll, afterAll } from 'vitest';
-import * as THREE from 'three';
-import { installCanvasMock } from './canvasMock';
-import {
- foamIntensityFor,
- WAKE_TEXTURE_SIZE,
- FOAM_RING_TEXTURE_SIZE,
- FOAM_RING_LIFETIME_SECONDS,
- WAKE_RENDER_ORDER,
- STERN_Z_METRES,
- BLOOM_SAFE_LUMINANCE,
- WAKE_ALPHA_STOPS,
- WAKE_EDGE_KEEP_STOPS,
- wakeFor,
- foamRingFor,
- foamLuminance,
- bloomSafeFoamColor,
- createWakeTexture,
- createFoamRingTexture,
- createWakeGeometry,
-} from '../components/rower3d/wakeTexture';
-
-/**
- * Issue #323 — the wake and the blade foam were white slabs.
- *
- * Three untextured quads at `color="white"`, bright enough for the bloom pass
- * to find them, with hard edges, no fade behind the stern, and — the part that
- * made it a slab rather than a wake — drawn from the boat's centre backwards,
- * so the whole thing sat under the hull instead of behind it.
- */
-
-/** The hull is an 8 m box centred on the origin; its stern cone ends at z = -4.4. */
-const HULL_STERN_Z = -4.4;
-
-describe('wakeFor', () => {
- it('draws nothing at rest', () => {
- expect(wakeFor(0)).toEqual({ length: 0, opacity: 0 });
- });
-
- it('is a soft trail at racing pace, not a lamp', () => {
- const wake = wakeFor(4.17);
- expect(wake.length).toBeCloseTo(9.17, 2);
- expect(wake.opacity).toBeCloseTo(0.51, 2);
- // The Gherkin's ceiling: the wake never reaches an opacity that reads as white.
- expect(wake.opacity).toBeLessThan(0.6);
- });
-
- it('grows with speed', () => {
- expect(wakeFor(3).length).toBeGreaterThan(wakeFor(2).length);
- expect(wakeFor(3).opacity).toBeGreaterThan(wakeFor(2).opacity);
- });
-
- it('stops growing rather than running off down the river', () => {
- expect(wakeFor(50).length).toBe(14);
- expect(wakeFor(50).opacity).toBeCloseTo(0.55, 5);
- });
-
- it('treats a boat going backwards as a boat at rest', () => {
- expect(wakeFor(-3)).toEqual({ length: 0, opacity: 0 });
- });
-});
-
-describe('foamRingFor', () => {
- it('starts small and bright at the catch', () => {
- const ring = foamRingFor(1);
- expect(ring.scale).toBeCloseTo(0.4, 5);
- expect(ring.opacity).toBeCloseTo(0.7, 5);
- });
-
- it('ends wide and gone', () => {
- const ring = foamRingFor(0);
- expect(ring.scale).toBeCloseTo(1.4, 5);
- expect(ring.opacity).toBe(0);
- });
-
- it('expands as it fades', () => {
- const half = foamRingFor(0.5);
- expect(half.scale).toBeCloseTo(0.9, 5);
- expect(half.opacity).toBeCloseTo(0.35, 5);
- });
-
- it('holds its bounds when the life runs past either end', () => {
- expect(foamRingFor(2)).toEqual(foamRingFor(1));
- expect(foamRingFor(-1)).toEqual(foamRingFor(0));
- });
-
- it('takes six tenths of a second to do it', () => {
- expect(FOAM_RING_LIFETIME_SECONDS).toBe(0.6);
- });
-});
-
-describe('the foam colour', () => {
- it('leaves a colour the bloom pass would already ignore alone', () => {
- const dim = '#8090a0';
- expect(foamLuminance(dim)).toBeLessThan(BLOOM_SAFE_LUMINANCE);
- expect(bloomSafeFoamColor(dim)).toBe(dim);
- });
-
- it('dims white to the ceiling rather than letting the bloom find it', () => {
- expect(foamLuminance('#ffffff')).toBeCloseTo(1, 5);
- expect(foamLuminance(bloomSafeFoamColor('#ffffff'))).toBeLessThanOrEqual(
- BLOOM_SAFE_LUMINANCE,
- );
- });
-
- it('keeps the hue while it dims', () => {
- const dimmed = bloomSafeFoamColor('#dfe9ee');
- const [r, g, b] = [1, 3, 5].map((i) => parseInt(dimmed.slice(i, i + 2), 16));
- expect(g).toBeGreaterThan(r);
- expect(b).toBeGreaterThan(g);
- });
-
- it('reads an unparseable colour as black rather than as white', () => {
- // The wrong direction to fail in matters here: a colour the parser cannot
- // read must not come back bright enough for the bloom pass to find.
- expect(foamLuminance('rebeccapurple')).toBe(0);
- expect(bloomSafeFoamColor('rebeccapurple')).toBe('rebeccapurple');
- });
-
- it('sits below the bloom threshold, which is where a wake belongs', () => {
- // #327 raised the bloom's luminanceThreshold to 0.9; the ceiling is under it.
- expect(BLOOM_SAFE_LUMINANCE).toBeLessThan(0.9);
- });
-});
-
-describe('the wake texture', () => {
- it('fades along the wake, opaque at the stern and gone at the tail', () => {
- expect(WAKE_ALPHA_STOPS[0].offset).toBe(0);
- expect(WAKE_ALPHA_STOPS[0].alpha).toBeGreaterThan(0.5);
- const last = WAKE_ALPHA_STOPS[WAKE_ALPHA_STOPS.length - 1];
- expect(last.offset).toBe(1);
- expect(last.alpha).toBe(0);
-
- for (let i = 1; i < WAKE_ALPHA_STOPS.length; i += 1) {
- expect(WAKE_ALPHA_STOPS[i].offset).toBeGreaterThan(WAKE_ALPHA_STOPS[i - 1].offset);
- expect(WAKE_ALPHA_STOPS[i].alpha).toBeLessThan(WAKE_ALPHA_STOPS[i - 1].alpha);
- }
- });
-
- it('softens both sides so the wake has no cut edge', () => {
- const at = (offset: number) =>
- WAKE_EDGE_KEEP_STOPS.find((stop) => stop.offset === offset)?.alpha;
-
- expect(at(0), 'the left edge is a hard line').toBe(0);
- expect(at(1), 'the right edge is a hard line').toBe(0);
- expect(at(0.5)).toBe(1);
- });
-
- it('returns nothing rather than throwing where there is no context to draw in', () => {
- expect(() => createWakeTexture()).not.toThrow();
- expect(createWakeTexture()).toBeNull();
- expect(createFoamRingTexture()).toBeNull();
- });
-
- describe('with a canvas to draw into', () => {
- let uninstall: () => void;
- beforeAll(() => {
- uninstall = installCanvasMock();
- });
- afterAll(() => uninstall());
-
- it('is a square of the documented size, in the colour space the scene renders in', () => {
- const texture = createWakeTexture();
- expect(texture, 'no texture was produced').not.toBeNull();
- expect(texture!.image.width).toBe(WAKE_TEXTURE_SIZE);
- expect(texture!.image.height).toBe(WAKE_TEXTURE_SIZE);
- expect(texture!.colorSpace).toBe(THREE.SRGBColorSpace);
- });
-
- it('is not flipped, so the opaque end of the gradient is the stern end', () => {
- // uv.y runs 0 at the stern to 1 at the tail, and the gradient is drawn
- // the same way round. three flips canvas textures by default, which
- // would put the fade on backwards: a wake brightest where it ends.
- expect(createWakeTexture()!.flipY).toBe(false);
- });
-
- it('gives the foam ring a texture of its own', () => {
- const texture = createFoamRingTexture();
- expect(texture!.image.width).toBe(FOAM_RING_TEXTURE_SIZE);
- expect(texture!.image.height).toBe(FOAM_RING_TEXTURE_SIZE);
- });
- });
-});
-
-describe('the wake geometry', () => {
- const verticesOf = (geometry: THREE.BufferGeometry) => {
- const position = geometry.getAttribute('position') as THREE.BufferAttribute;
- return Array.from({ length: position.count }, (_, i) => ({
- x: position.getX(i),
- y: position.getY(i),
- z: position.getZ(i),
- }));
- };
-
- it('trails backwards over a unit length, so speed can scale it', () => {
- const vertices = verticesOf(createWakeGeometry());
- expect(vertices.length, 'no wake was built').toBeGreaterThan(0);
-
- for (const vertex of vertices) {
- expect(vertex.z).toBeLessThanOrEqual(0);
- expect(vertex.z).toBeGreaterThanOrEqual(-1);
- }
- expect(Math.min(...vertices.map((v) => v.z))).toBeCloseTo(-1, 5);
- });
-
- it('is one mesh, carrying both arms of the V and the stern patch', () => {
- const vertices = verticesOf(createWakeGeometry());
- expect(Math.min(...vertices.map((v) => v.x)), 'no left arm').toBeLessThan(-0.1);
- expect(Math.max(...vertices.map((v) => v.x)), 'no right arm').toBeGreaterThan(0.1);
- });
-
- it('never reaches under the hull, at any speed', () => {
- const vertices = verticesOf(createWakeGeometry());
-
- for (const velocity of [0.5, 2, 4.17, 50]) {
- const { length } = wakeFor(velocity);
- const nearest = Math.max(...vertices.map((v) => STERN_Z_METRES + v.z * length));
- expect(
- nearest,
- 'the wake is drawn under the boat instead of behind it at ' + velocity + ' m/s',
- ).toBeLessThanOrEqual(HULL_STERN_Z);
- }
- });
-
- it('runs its texture along the wake and across it', () => {
- const uv = createWakeGeometry().getAttribute('uv') as THREE.BufferAttribute;
- const vs = Array.from({ length: uv.count }, (_, i) => uv.getY(i));
- const us = Array.from({ length: uv.count }, (_, i) => uv.getX(i));
-
- expect(Math.min(...vs)).toBe(0);
- expect(Math.max(...vs)).toBe(1);
- expect(Math.min(...us)).toBe(0);
- expect(Math.max(...us)).toBe(1);
- });
-
- it('carries a bounding sphere so the frame loop can cull it', () => {
- expect(createWakeGeometry().boundingSphere).not.toBeNull();
- });
-
- it('draws over the water rather than fighting it for the depth buffer', () => {
- expect(WAKE_RENDER_ORDER).toBe(2);
- });
-});
-
-describe('foamIntensityFor (#336)', () => {
- it('leaves the everyday foam alone', () => {
- expect(foamIntensityFor(0.35, false)).toBe(0.35);
- });
-
- it('bursts at the finish, and never past fully opaque', () => {
- expect(foamIntensityFor(0.35, true)).toBeCloseTo(0.875, 9);
- expect(foamIntensityFor(0.65, true)).toBe(1);
- });
-});
diff --git a/src/__tests__/waterMaterial.test.ts b/src/__tests__/waterMaterial.test.ts
deleted file mode 100644
index 8442baad..00000000
--- a/src/__tests__/waterMaterial.test.ts
+++ /dev/null
@@ -1,157 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import {
- CHOP_WAVELENGTH_METRES,
- RIPPLE_TILE_METRES,
- SWELL_WAVELENGTHS_METRES,
- rippleRepeat,
- rippleScroll,
- waterMaterialPlan,
-} from '../components/rower3d/waterMaterial';
-import { TARGET_METERS_BETWEEN_SEGMENTS } from '../components/rower3d/curve';
-import type { PerformanceMode } from '../components/rower3d/constants';
-
-/**
- * Issue #324 — the route water was a flat emissive sheet.
- *
- * `MeshStandardMaterial { roughness 0.55, metalness 0, emissive = colour }`,
- * no normal map, nothing to reflect, and a Gerstner injection whose shortest
- * wave was 126 m long. At a boat's eye height that is not a surface; it is a
- * blue plane with a light behind it.
- */
-
-const TIERS: PerformanceMode[] = ['basic', 'medium', 'extra-high'];
-
-describe('waterMaterialPlan', () => {
- it('has a plan for every tier', () => {
- for (const tier of TIERS) {
- expect(waterMaterialPlan(tier), `no water plan for ${tier}`).toBeDefined();
- }
- });
-
- it('gets smoother as the tier rises, because a mirror needs a smooth surface', () => {
- expect(waterMaterialPlan('medium').roughness).toBeLessThan(
- waterMaterialPlan('basic').roughness,
- );
- expect(waterMaterialPlan('extra-high').roughness).toBeLessThan(
- waterMaterialPlan('medium').roughness,
- );
- });
-
- it('reflects more of the sky as the tier rises', () => {
- expect(waterMaterialPlan('medium').envMapIntensity).toBeGreaterThan(
- waterMaterialPlan('basic').envMapIntensity,
- );
- expect(waterMaterialPlan('extra-high').envMapIntensity).toBeGreaterThan(
- waterMaterialPlan('medium').envMapIntensity,
- );
- });
-
- it('puts the planar mirror on the high tier alone', () => {
- expect(waterMaterialPlan('basic').useMirror).toBe(false);
- expect(waterMaterialPlan('medium').useMirror, 'auto is paying for a mirror').toBe(false);
- expect(waterMaterialPlan('extra-high').useMirror).toBe(true);
- });
-
- it('never leaves the water metallic, which renders it as a hole', () => {
- // #269: with nothing to reflect, metalness rendered the surface near-black.
- for (const tier of TIERS) {
- expect(waterMaterialPlan(tier).metalness).toBe(0);
- }
- });
-
- it('keeps every normal scale on the surface rather than exaggerating it', () => {
- for (const tier of TIERS) {
- const { normalScale } = waterMaterialPlan(tier);
- expect(normalScale).toBeGreaterThan(0);
- expect(normalScale).toBeLessThanOrEqual(0.5);
- }
- });
-});
-
-describe('the wave scales', () => {
- /**
- * The mesh cannot carry the chop the issue asks for, and this is where that
- * is written down.
- *
- * VR-04 asks for Gerstner wavelengths of 1.5–6 m. The water channel is a
- * two-vertex-wide ribbon sampled every 15 m along the route — there is not a
- * single vertex between the two banks. A 6 m wave needs samples every 3 m to
- * exist at all, so displacing vertices at that scale produces aliasing, not
- * chop. Metre-scale detail belongs in the normal map, which is per-pixel and
- * has no sampling limit; the vertices carry the swell they can represent.
- */
- it('keeps every displaced wave long enough for the mesh to sample it', () => {
- const shortest = Math.min(...SWELL_WAVELENGTHS_METRES);
- expect(
- shortest,
- 'a displaced wave shorter than two segments aliases instead of undulating',
- ).toBeGreaterThanOrEqual(TARGET_METERS_BETWEEN_SEGMENTS * 2);
- });
-
- it('puts the chop the issue asks for in the ripple texture instead', () => {
- expect(Math.min(...CHOP_WAVELENGTH_METRES)).toBeGreaterThanOrEqual(1.5);
- expect(Math.max(...CHOP_WAVELENGTH_METRES)).toBeLessThanOrEqual(6);
- });
-
- it('is chop on one scale and swell on another, not the same thing twice', () => {
- expect(Math.max(...CHOP_WAVELENGTH_METRES)).toBeLessThan(
- Math.min(...SWELL_WAVELENGTHS_METRES),
- );
- });
-});
-
-describe('rippleRepeat', () => {
- // The channel's uv runs 0–1 across the water and 0–1 along the whole route,
- // so a texture left at repeat 1 stretches one tile over kilometres. The
- // repeat has to be the route's size in tiles, or the ripples are not ripples.
- it('tiles the texture once per tile-width across the channel', () => {
- const repeat = rippleRepeat({ widthMetres: RIPPLE_TILE_METRES * 4, lengthMetres: 100 });
- expect(repeat.x).toBeCloseTo(4, 5);
- });
-
- it('tiles it once per tile-length along the route', () => {
- const repeat = rippleRepeat({ widthMetres: 20, lengthMetres: RIPPLE_TILE_METRES * 250 });
- expect(repeat.y).toBeCloseTo(250, 5);
- });
-
- it('never collapses to a single stretched tile on a degenerate route', () => {
- const repeat = rippleRepeat({ widthMetres: 0, lengthMetres: 0 });
- expect(repeat.x).toBeGreaterThanOrEqual(1);
- expect(repeat.y).toBeGreaterThanOrEqual(1);
- });
-
- it('holds the tile square, so ripples are not stretched down the river', () => {
- const repeat = rippleRepeat({ widthMetres: 30, lengthMetres: 3_000 });
- expect(repeat.x / repeat.y).toBeCloseTo(30 / 3_000, 5);
- });
-});
-
-describe('rippleScroll', () => {
- it('moves both layers as time passes', () => {
- const start = rippleScroll(0);
- const later = rippleScroll(10);
- expect(later.first.y).not.toBe(start.first.y);
- expect(later.second.x).not.toBe(start.second.x);
- });
-
- it('scrolls the two layers at different rates, so they never lock together', () => {
- const at = rippleScroll(10);
- expect(Math.abs(at.first.y)).not.toBeCloseTo(Math.abs(at.second.y), 3);
- });
-
- it('sends them in different directions, so the surface does not slide one way', () => {
- const at = rippleScroll(10);
- expect(Math.sign(at.first.y)).not.toBe(Math.sign(at.second.y));
- });
-
- it('wraps rather than growing without bound', () => {
- // A texture offset that keeps climbing loses float precision after an hour
- // of rowing, and the ripples start to judder.
- for (const t of [0, 61, 3_600, 86_400]) {
- const { first, second } = rippleScroll(t);
- for (const value of [first.x, first.y, second.x, second.y]) {
- expect(Math.abs(value)).toBeLessThanOrEqual(1);
- }
- }
- });
-});
diff --git a/src/__tests__/waterSurfaceShader.test.ts b/src/__tests__/waterSurfaceShader.test.ts
deleted file mode 100644
index c9b1df99..00000000
--- a/src/__tests__/waterSurfaceShader.test.ts
+++ /dev/null
@@ -1,175 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import * as THREE from 'three';
-import {
- WATER_FRESNEL_F0,
- WATER_FRESNEL_STRENGTH,
- attachGerstnerShader,
- attachWaterSurface,
-} from '../components/rower3d/helpers';
-import {
- SKY_ENVIRONMENT_SCALE,
- buildSkyEnvironment,
- buildSkyScene,
-} from '../components/rower3d/skyEnvironment';
-import { SCENE_CONFIG } from '../components/rower3d/themeConfig';
-import { skySunPosition } from '../components/rower3d/sunDirection';
-
-/**
- * Issue #324 — the water's fragment half.
- *
- * The channel had one normal map slot and no fresnel, so it was the same
- * brightness looking down at it as looking along it. Water is the opposite of
- * that: dark underfoot, a mirror at the far bank. This is the injection that
- * does both, and the thing most worth testing about it is that it *composes* —
- * `attachGerstnerShader` owns the same `onBeforeCompile`, and a hook that
- * replaced it would silently delete the waves.
- */
-
-const ripple2 = () => ({
- uRipple2Offset: { value: new THREE.Vector2() },
- uRipple2Scale: { value: 1.7 },
-});
-
-/** Run a material's compile hook over a stand-in shader and hand back both stages. */
-const compiled = (prepare: (material: THREE.MeshStandardMaterial) => void) => {
- const material = new THREE.MeshStandardMaterial();
- prepare(material);
-
- const shader = {
- uniforms: {} as Record,
- vertexShader: '#include \n#include \n',
- fragmentShader: 'void main() {\n#include \n}\n',
- };
-
- expect(typeof material.onBeforeCompile, 'no shader hook was installed').toBe('function');
- material.onBeforeCompile!(
- shader as unknown as THREE.WebGLProgramParametersWithUniforms,
- {} as THREE.WebGLRenderer,
- );
- return shader;
-};
-
-const SUN = skySunPosition(SCENE_CONFIG.lighting);
-
-describe('attachWaterSurface', () => {
- it('blends towards the reflection at a grazing angle', () => {
- const { fragmentShader } = compiled((m) => attachWaterSurface(m, ripple2()));
-
- expect(fragmentShader).toContain('wFresnel');
- expect(fragmentShader, "Schlick's F0 for water is not in the shader").toContain(
- WATER_FRESNEL_F0.toFixed(4),
- );
- expect(fragmentShader).toContain(WATER_FRESNEL_STRENGTH.toFixed(4));
- });
-
- it('samples the ripple tile a second time, at its own offset and scale', () => {
- const { fragmentShader } = compiled((m) => attachWaterSurface(m, ripple2()));
-
- expect(fragmentShader, 'there is only one ripple layer').toContain('uRipple2Offset');
- expect(fragmentShader).toContain('uRipple2Scale');
- expect(fragmentShader, 'the second layer is declared but never sampled').toContain(
- 'texture2D(',
- );
- });
-
- it('declares its uniforms before using them', () => {
- const { fragmentShader } = compiled((m) => attachWaterSurface(m, ripple2()));
-
- const declaration = fragmentShader.indexOf('uniform vec2 uRipple2Offset;');
- const use = fragmentShader.indexOf('uRipple2Offset', declaration + 1);
-
- expect(declaration, 'the uniform is used but never declared').toBeGreaterThanOrEqual(0);
- expect(use, 'the uniform is declared but never used').toBeGreaterThan(declaration);
- });
-
- it('hands three the same uniform objects it was given, so a frame can write them', () => {
- const uniforms = ripple2();
- const { uniforms: bound } = compiled((m) => attachWaterSurface(m, uniforms));
-
- expect(bound.uRipple2Offset).toBe(uniforms.uRipple2Offset);
- expect(bound.uRipple2Scale).toBe(uniforms.uRipple2Scale);
- });
-
- it('keeps the chunk it replaced, so the rest of three still compiles', () => {
- const { fragmentShader } = compiled((m) => attachWaterSurface(m, ripple2()));
-
- expect(fragmentShader).toContain('#include ');
- });
-
- // The reason this test exists. Both halves set `onBeforeCompile` on the same
- // material; whichever ran second used to be the only one that mattered.
- describe('alongside the Gerstner waves', () => {
- it('leaves the vertex displacement in place', () => {
- const { vertexShader } = compiled((m) => {
- attachGerstnerShader(m, { value: 0 }, 'y', 'test-compose');
- attachWaterSurface(m, ripple2());
- });
-
- expect(vertexShader, 'the waves were overwritten by the surface hook').toContain(
- 'gWave(',
- );
- expect(vertexShader).toContain('vec3(-wGrad.x, 1.0, -wGrad.y)');
- });
-
- it('adds the fresnel to the same material', () => {
- const { fragmentShader } = compiled((m) => {
- attachGerstnerShader(m, { value: 0 }, 'y', 'test-compose');
- attachWaterSurface(m, ripple2());
- });
-
- expect(fragmentShader).toContain('wFresnel');
- });
-
- it("keeps the waves' time uniform bound", () => {
- const time = { value: 4 };
- const { uniforms } = compiled((m) => {
- attachGerstnerShader(m, time, 'y', 'test-compose');
- attachWaterSurface(m, ripple2());
- });
-
- expect(uniforms.uTime, 'the wave clock was dropped').toBe(time);
- });
- });
-});
-
-describe('buildSkyEnvironment', () => {
- // A river without a reflection is still a river. A scene that throws on
- // mount because `PMREMGenerator` could not convolve a cube map is not.
- it('returns nothing rather than throwing where there is no GL to convolve with', () => {
- const renderer = {} as THREE.WebGLRenderer;
- const sky = SCENE_CONFIG.sky;
-
- expect(() => buildSkyEnvironment(renderer, sky, SUN)).not.toThrow();
- expect(buildSkyEnvironment(renderer, sky, SUN)).toBeNull();
- });
-
- it('captures a scene holding the sky and nothing else', () => {
- // The fault it replaces: `PMREMEnvironment` calls `fromScene(scene)` on
- // mount, before `Sky` has drawn, so the map it captures is near-black —
- // and the water material's "no environment map to reflect" was describing
- // a state that code had created. This one cannot depend on load order,
- // because the only thing in the scene it captures is the sky it just made.
- const { scene, mesh } = buildSkyScene(SCENE_CONFIG.sky, SUN);
-
- expect(scene.children, 'the captured scene is not just the sky').toHaveLength(1);
- expect(scene.children[0]).toBe(mesh);
- });
-
- it('lights the sky the way the theme asks', () => {
- const sky = SCENE_CONFIG.sky;
- const { mesh } = buildSkyScene(sky, SUN);
- const uniforms = mesh.material.uniforms;
-
- expect(uniforms.turbidity.value).toBe(sky.turbidity);
- expect(uniforms.rayleigh.value).toBe(sky.rayleigh);
- expect(uniforms.mieCoefficient.value).toBe(sky.mieCoefficient);
- expect(uniforms.mieDirectionalG.value).toBe(sky.mieDirectionalG);
- // Derived from the lighting angles now, not authored on the sky (#352).
- expect(uniforms.sunPosition.value.toArray()).toEqual([...SUN]);
- });
-
- it('builds a dome big enough to be a sky rather than a ball over the boat', () => {
- const { mesh } = buildSkyScene(SCENE_CONFIG.sky, SUN);
- expect(mesh.scale.x).toBe(SKY_ENVIRONMENT_SCALE);
- });
-});
diff --git a/src/__tests__/windowMaterial.test.ts b/src/__tests__/windowMaterial.test.ts
deleted file mode 100644
index a8c2afb1..00000000
--- a/src/__tests__/windowMaterial.test.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import * as THREE from 'three';
-import {
- nightLevelFor,
- sharedNightUniform,
- windowMaterial,
-} from '../components/rower3d/windowMaterial';
-
-/**
- * Issue #432 — a window is a physical material whose emission scales with a
- * shared `uNight` uniform, so one write during a Conditions change lights
- * every cloned window across the kit.
- */
-
-describe('windowMaterial (#432)', () => {
- it('is physical, low-roughness, low-metalness, with room for a reflection', () => {
- const mat = windowMaterial(new THREE.Color('#a0c0d0'));
- expect(mat).toBeInstanceOf(THREE.MeshPhysicalMaterial);
- expect(mat.metalness).toBeLessThan(0.2);
- expect(mat.roughness).toBeLessThan(0.1);
- expect(mat.envMapIntensity).toBeGreaterThanOrEqual(1);
- });
-
- it('carries a warm emissive colour for its interior light', () => {
- const mat = windowMaterial(new THREE.Color('#a0c0d0'));
- // Warm: red is highest. A window lit at dusk reads sodium-lamp, not sky.
- expect(mat.emissive.r).toBeGreaterThan(mat.emissive.b);
- });
-
- it('installs a uNight uniform through onBeforeCompile', () => {
- const mat = windowMaterial(new THREE.Color('#a0c0d0'));
- // Fake shader that captures what onBeforeCompile does to it.
- const shader = {
- uniforms: {} as Record,
- fragmentShader:
- 'placeholder\n#include \nplaceholder\n#include \nend',
- vertexShader: '',
- };
- mat.onBeforeCompile!(shader as unknown as THREE.WebGLProgramParametersWithUniforms, {} as THREE.WebGLRenderer);
-
- expect(shader.uniforms.uNight).toBe(sharedNightUniform);
- expect(shader.fragmentShader).toContain('uniform float uNight');
- expect(shader.fragmentShader).toContain('totalEmissiveRadiance *= uNight');
- });
-
- it('names its custom program cache key so a swap does not miss', () => {
- const mat = windowMaterial(new THREE.Color('#a0c0d0'));
- expect(mat.customProgramCacheKey!()).toBe('virtualrow-window');
- });
-});
-
-describe('nightLevelFor (#432)', () => {
- it('is 0 at midday, 1 at dusk', () => {
- expect(nightLevelFor('midday')).toBe(0);
- expect(nightLevelFor('dusk')).toBe(1);
- });
-
- it('is 0 under overcast — a bright directionless daytime sky', () => {
- expect(nightLevelFor('overcast')).toBe(0);
- });
-
- it('is interpolated at dawn and golden — a low sun, not full night', () => {
- expect(nightLevelFor('dawn')).toBeGreaterThan(0);
- expect(nightLevelFor('dawn')).toBeLessThan(1);
- expect(nightLevelFor('golden')).toBeGreaterThan(nightLevelFor('dawn'));
- expect(nightLevelFor('golden')).toBeLessThan(1);
- });
-});
diff --git a/src/__tests__/workoutBlockPanel.test.tsx b/src/__tests__/workoutBlockPanel.test.tsx
deleted file mode 100644
index 545555cd..00000000
--- a/src/__tests__/workoutBlockPanel.test.tsx
+++ /dev/null
@@ -1,200 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import { render, screen, within } from '@testing-library/react';
-import { WorkoutBlockPanel } from '../components/WorkoutBlockPanel';
-import type {
- StructuredWorkout,
- WorkoutProgress,
- WorkoutSegment,
-} from '../lib/types';
-
-const workout: StructuredWorkout = {
- id: 'wo-1',
- name: 'Threshold 4x4',
- description: 'Test',
- type: 'intervals',
- segments: [],
- totalDuration: 1800,
- targetMetric: 'pace',
- createdAt: new Date(),
- source: 'intervals.icu',
-};
-
-const seg = (
- id: string,
- overrides: Partial = {},
-): WorkoutSegment => ({
- id,
- order: 0,
- type: 'work',
- duration: 240,
- targetPaceMin: 110,
- targetPaceMax: 112,
- targetPower: 250,
- intensity: 'zone4',
- description: `Block ${id}`,
- ...overrides,
-});
-
-const segments: WorkoutSegment[] = [
- seg('a'),
- seg('b'),
- seg('c', { type: 'rest', intensity: 'recovery', targetPaceMin: undefined, targetPaceMax: undefined, targetPower: undefined }),
-];
-
-const makeProgress = (idx: number, elapsed = 0): WorkoutProgress => ({
- workoutId: workout.id,
- currentSegmentIndex: idx,
- currentSegment: segments[idx],
- segmentElapsedTime: elapsed,
- segmentProgress: 0,
- totalElapsedTime: elapsed,
- totalProgress: 0,
- isOnTarget: true,
- deviationPercent: 0,
-});
-
-describe('WorkoutBlockPanel (#445, FR4 / AC5 / NFR6)', () => {
- it('mounts with a region role naming the workout (NFR6 / AC5)', () => {
- render(
- ,
- );
- expect(
- screen.getByRole('region', { name: /Workout block panel/i }),
- ).toBeInTheDocument();
- });
-
- it('highlights the current block on the timeline', () => {
- render(
- ,
- );
- const timeline = screen.getByRole('list', { name: /Workout timeline/i });
- const steps = within(timeline).getAllByRole('listitem');
- expect(steps[1]).toHaveAttribute('aria-current', 'step');
- expect(steps[0]).not.toHaveAttribute('aria-current');
- });
-
- it('renders prescribed vs actual pace and power tiles (AC5)', () => {
- render(
- ,
- );
- // Prescribed pace band 1:50–1:52 (110–112 sec/500m)
- expect(screen.getByText(/1:50–1:52/)).toBeInTheDocument();
- // Prescribed power 250 W
- expect(screen.getByText(/^250 W$/)).toBeInTheDocument();
- // Actual power 230 W
- expect(screen.getByText(/^230 W$/)).toBeInTheDocument();
- });
-
- it('shows "—" for a metric with no prescribed target (D9(a) / AC7)', () => {
- render(
- ,
- );
- // Prescribed row for both tiles reads "—"
- const dashes = screen.getAllByText('—');
- expect(dashes.length).toBeGreaterThanOrEqual(2);
- });
-
- it('uses aria-live=polite on actual values only (NFR6)', () => {
- render(
- ,
- );
- const region = screen.getByRole('region', { name: /Workout block panel/i });
- const politeNodes = region.querySelectorAll('[aria-live="polite"]');
- expect(politeNodes.length).toBe(2);
- // Prescribed values must NOT be live regions (NFR6: only actuals update
- // audibly).
- for (const node of politeNodes) {
- expect(node.classList.contains('workout-block-tile-actual')).toBe(true);
- }
- });
-
- it('hides the compliance badge while the device is disconnected', () => {
- const { rerender } = render(
- ,
- );
- // Connected: compliance status shown.
- expect(screen.queryByRole('status')).toBeInTheDocument();
- rerender(
- ,
- );
- expect(screen.queryByRole('status')).not.toBeInTheDocument();
- });
-
- it('shows the elapsed / remaining seconds for the current block', () => {
- render(
- ,
- );
- expect(screen.getByText(/Elapsed 1:05/)).toBeInTheDocument();
- expect(screen.getByText(/Remaining 2:55/)).toBeInTheDocument();
- });
-
- it('reads "Workout complete" when the final block is done', () => {
- render(
- ,
- );
- expect(screen.getByText(/Workout complete/i)).toBeInTheDocument();
- });
-});
diff --git a/src/__tests__/worldScale.test.ts b/src/__tests__/worldScale.test.ts
deleted file mode 100644
index 30ea72db..00000000
--- a/src/__tests__/worldScale.test.ts
+++ /dev/null
@@ -1,110 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import { SCENE_SCALE } from '../lib/utils/worldScale';
-import { WATER_CHANNEL_WIDTH, RIVERBANK_WIDTH, LANDSCAPE_OFFSET } from '../components/rower3d/constants';
-import { ASSET_SCALE } from '../components/rower3d/sceneryPlacement';
-import {
- MIN_NAVIGABLE_WIDTH_METERS,
- OAR_REACH_METERS,
- bladeClearanceMeters,
-} from '../components/rower3d/navigableWidth';
-import { createRouteCurve, getCurveDistances } from '../components/rower3d/curve';
-import type { Coordinate } from '../lib/types';
-
-/**
- * Issue #321 — one scene unit is one metre.
- *
- * The scene used to be built at three scales at once. The route curve, the
- * water and the banks ran at 1 unit = 10 m; the boat hull, its oars and the
- * camera at 1 unit = 1 m; the scenery kit at roughly 1 unit = 2.5 m. Every
- * visual complaint in #347 is downstream of that:
- *
- * - at 2:00/500m the boat advanced 0.417 units/s along an 8-unit hull, so it
- * took about 19 seconds to travel its own length. On water it takes two. The
- * game felt ten times slower than the erg;
- * - a 22 m tree came out about 9 units tall beside an 8-unit boat, which is
- * why the trees looked boat-sized and hung in the sky;
- * - `bladeClearanceMeters` divided by `SCENE_SCALE` to compare a channel
- * measured in tens of metres against an oar measured in metres.
- *
- * These are the arithmetic facts the rest of the scene is tuned against. They
- * are asserted as physical quantities — metres of water, metres of tree, metres
- * of boat per second — rather than as the numbers the constants happen to hold,
- * so they keep meaning the same thing if the constants move again.
- */
-
-/** A straight kilometre, laid out along a line of longitude. */
-const straightKilometre = (): Coordinate[] => {
- // 1 degree of latitude is ~111.32 km, so 1000 m is ~0.008983 degrees.
- const metresPerDegreeLat = 111_320;
- return Array.from({ length: 11 }, (_, i) => ({
- lat: 51 + (i * 100) / metresPerDegreeLat,
- lng: -1,
- }));
-};
-
-describe('the world is built in metres', () => {
- it('measures a route curve in metres', () => {
- const curve = createRouteCurve(straightKilometre(), SCENE_SCALE);
- expect(curve, 'no curve was built').not.toBeNull();
-
- const distances = getCurveDistances(curve!);
- const lengthInUnits = distances[distances.length - 1];
-
- expect(
- lengthInUnits,
- 'a kilometre of route should be a thousand scene units long',
- ).toBeGreaterThan(990);
- expect(lengthInUnits).toBeLessThan(1010);
- });
-
- // A 22 m tree is the case from the issue, and the one visible in
- // docs/screenshot-activity.png: at 0.0004 it came out about 8.8 units, which
- // beside an 8-unit boat is a tree the size of a boat.
- it('builds a 22 m tree 22 units tall', () => {
- const treeHeightMillimetres = 22_000;
- expect(ASSET_SCALE * treeHeightMillimetres).toBeGreaterThan(21);
- expect(ASSET_SCALE * treeHeightMillimetres).toBeLessThan(23);
- });
-
- // The floor exists so the blades are over water. It can only do that if it is
- // wider than the blades, which at 0.7 units against a 5.56 unit oar span it
- // was not.
- it('never draws a channel narrower than the oars', () => {
- expect(MIN_NAVIGABLE_WIDTH_METERS).toBeGreaterThan(2 * OAR_REACH_METERS);
- });
-
- it('reads a channel width in the units the scene builds it in', () => {
- // The narrowest channel the scene will draw, handed straight back to the
- // clearance function the way the frame loop hands it over.
- expect(bladeClearanceMeters(MIN_NAVIGABLE_WIDTH_METERS)).toBeCloseTo(0.75, 5);
- });
-
- // Nothing measured is the absence of a reading, not a clearance of zero — a
- // distinction #271 turned on.
- it('reports nothing measured as nothing measured', () => {
- expect(bladeClearanceMeters(Number.NaN)).toBeNaN();
- });
-
- /**
- * The scene's own distances, as metres.
- *
- * These were authored against the old ten-metre unit, so each was a tenth of
- * what its comment claimed: the water channel said "20 metres" and drew 200,
- * the landscape stood 500 m back from the water rather than 50.
- */
- it('lays the waterway out at the widths its comments claim', () => {
- /** What a distance authored in scene units really measures, in metres. */
- const metres = (sceneUnits: number) => sceneUnits / SCENE_SCALE;
-
- expect(metres(WATER_CHANNEL_WIDTH), 'the channel is 20 m of water').toBeCloseTo(20, 5);
- expect(metres(RIVERBANK_WIDTH), 'each bank is 60 m wide').toBeCloseTo(60, 5);
- expect(
- metres(LANDSCAPE_OFFSET),
- 'the landscape starts 50 m from the centreline',
- ).toBeCloseTo(50, 5);
- expect(
- metres(WATER_CHANNEL_WIDTH),
- 'the channel has to be wide enough for the oars it carries',
- ).toBeGreaterThan(MIN_NAVIGABLE_WIDTH_METERS);
- });
-});
diff --git a/src/components/AuthButton.css b/src/components/AuthButton.css
deleted file mode 100644
index 00dd2d97..00000000
--- a/src/components/AuthButton.css
+++ /dev/null
@@ -1,273 +0,0 @@
-/* ============================================================================
- AUTH BUTTON — Sign in / User menu
-
- Lives in the brand header, so the trigger is styled for the dark #1a3a4a
- background while the dropdown it opens is a light panel over page content
- (issue #219, R1).
- ============================================================================ */
-
-/* ── Sign-in button ── */
-.auth-button {
- display: inline-flex;
- align-items: center;
- gap: var(--space-sm);
- /* 44px touch-target floor (issue #195). */
- min-height: 44px;
- padding: var(--space-sm) var(--space-md);
- border-radius: var(--radius-control);
- border: 1px solid transparent;
- cursor: pointer;
- font-family: inherit;
- font-size: 0.85rem;
- font-weight: 500;
- white-space: nowrap;
-}
-
-.auth-button--signin {
- background: transparent;
- color: var(--color-brand-contrast);
- border-color: rgba(255, 255, 255, 0.5);
-}
-
-@media (hover: hover) {
- .auth-button--signin:hover {
- background: rgba(255, 255, 255, 0.12);
- border-color: rgba(255, 255, 255, 0.7);
- }
-}
-
-.auth-button--signin:focus-visible {
- outline: 2px solid var(--color-brand-contrast);
- outline-offset: 2px;
-}
-
-.auth-button--loading {
- background: transparent;
- color: rgba(255, 255, 255, 0.75);
- cursor: default;
- border-color: rgba(255, 255, 255, 0.3);
-}
-
-.auth-button-text--short {
- display: none;
-}
-
-@media (max-width: 600px) {
- .auth-button-text--long {
- display: none;
- }
-
- .auth-button-text--short {
- display: inline;
- }
-}
-
-.auth-signin-wrapper {
- display: flex;
- flex-direction: column;
- align-items: flex-end;
- gap: var(--space-2xs);
-}
-
-.auth-login-error {
- font-size: 0.75rem;
- color: var(--color-status-provisional-bg);
- max-width: 280px;
- text-align: left;
- white-space: normal;
- word-break: normal;
- overflow-wrap: anywhere;
- display: flex;
- flex-direction: column;
- gap: var(--space-2xs);
- align-items: flex-start;
-}
-
-.auth-retry-button {
- margin-top: var(--space-2xs);
- min-height: 32px;
- padding: var(--space-2xs) var(--space-sm);
- font-size: 0.75rem;
- color: var(--color-text-primary);
- background: var(--color-bg-secondary);
- border: 1px solid var(--color-border-divider);
- border-radius: 4px;
- cursor: pointer;
- font-family: inherit;
-}
-
-@media (hover: hover) {
- .auth-retry-button:hover {
- background: var(--color-bg-tertiary, var(--color-bg-secondary));
- }
-}
-
-.auth-spinner {
- width: 14px;
- height: 14px;
- border: 2px solid currentColor;
- border-top-color: transparent;
- border-radius: 50%;
- animation: auth-spin 0.7s linear infinite;
- flex-shrink: 0;
-}
-
-@keyframes auth-spin {
- to { transform: rotate(360deg); }
-}
-
-@media (prefers-reduced-motion: reduce) {
- .auth-spinner {
- animation: none;
- }
-}
-
-/* ── User menu trigger ── */
-.auth-user-menu {
- position: relative;
- display: inline-block;
- z-index: 1001; /* establish stacking context above the fixed backdrop */
-}
-
-.auth-user-trigger {
- display: inline-flex;
- align-items: center;
- gap: var(--space-sm);
- min-height: 44px;
- padding: var(--space-2xs) var(--space-md) var(--space-2xs) var(--space-2xs);
- border-radius: var(--radius-control);
- border: 1px solid rgba(255, 255, 255, 0.5);
- background: transparent;
- color: var(--color-brand-contrast);
- cursor: pointer;
- font-family: inherit;
- font-size: 0.85rem;
- font-weight: 500;
-}
-
-@media (hover: hover) {
- .auth-user-trigger:hover {
- background: rgba(255, 255, 255, 0.12);
- border-color: rgba(255, 255, 255, 0.7);
- }
-}
-
-.auth-user-trigger:focus-visible {
- outline: 2px solid var(--color-brand-contrast);
- outline-offset: 2px;
-}
-
-.auth-avatar {
- border-radius: 50%;
- object-fit: cover;
- width: 28px;
- height: 28px;
- flex-shrink: 0;
-}
-
-.auth-avatar-placeholder {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 28px;
- height: 28px;
- border-radius: 50%;
- background: rgba(255, 255, 255, 0.2);
- color: var(--color-brand-contrast);
- font-size: 0.75rem;
- font-weight: 700;
- flex-shrink: 0;
-}
-
-.auth-display-name {
- max-width: 120px;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.auth-caret {
- font-size: 0.65rem;
- opacity: 0.75;
- margin-left: 2px;
-}
-
-/* ── Dropdown ── */
-.auth-dropdown-backdrop {
- position: fixed;
- inset: 0;
- z-index: 999;
- /* Ensure pointer events pass through to dropdown items above */
- pointer-events: auto;
- background: transparent;
- border: none;
- border-radius: 0;
-}
-
-.auth-dropdown {
- position: absolute;
- right: 0;
- top: calc(100% + 6px);
- min-width: 200px;
- background: var(--color-bg-card);
- border: 1px solid var(--color-border);
- border-radius: var(--radius-panel);
- box-shadow: var(--shadow-modal);
- z-index: 1000;
- overflow: hidden;
-}
-
-.auth-dropdown-header {
- padding: var(--space-md) var(--space-lg);
- display: flex;
- flex-direction: column;
- gap: 2px;
-}
-
-.auth-dropdown-name {
- font-weight: 600;
- font-size: 0.9rem;
- color: var(--color-text-primary);
-}
-
-.auth-dropdown-email {
- font-size: 0.75rem;
- color: var(--color-text-muted);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.auth-dropdown-divider {
- margin: 0;
- border: none;
- border-top: 1px solid var(--color-border-divider);
-}
-
-.auth-dropdown-item {
- display: flex;
- align-items: center;
- gap: var(--space-sm);
- width: 100%;
- min-height: 44px;
- padding: var(--space-sm) var(--space-lg);
- background: none;
- border: none;
- border-radius: 0;
- color: var(--color-text-primary);
- font-family: inherit;
- font-size: 0.85rem;
- cursor: pointer;
- text-align: left;
-}
-
-@media (hover: hover) {
- .auth-dropdown-item:hover {
- background: var(--color-bg-secondary);
- border-color: transparent;
- }
-}
-
-.auth-dropdown-item--signout {
- color: var(--color-error);
-}
diff --git a/src/components/AuthButton.tsx b/src/components/AuthButton.tsx
deleted file mode 100644
index 374afd3d..00000000
--- a/src/components/AuthButton.tsx
+++ /dev/null
@@ -1,138 +0,0 @@
-import { useRef, useState } from 'react';
-import { useAuth } from '../context/useAuth';
-import { RETRYABLE_AUTH_ERROR_CLASSES } from '../lib/services/authService';
-import './AuthButton.css';
-
-export function AuthButton() {
- const { user, isAuthenticated, isLoading, authError, authErrorClass, login, logout, clearAuthError } = useAuth();
- const [dropdownOpen, setDropdownOpen] = useState(false);
- const [loginError, setLoginError] = useState(null);
- const dropdownRef = useRef(null);
- const visibleError = loginError ?? authError;
- // `config` is the only class that is NOT recoverable from a retry; every
- // other class (including the fallback `unknown` and the `null` from a
- // locally-thrown login error) offers the button (#486 Phase 2 FR3, D4 = a).
- const showRetryButton =
- visibleError !== null &&
- (authErrorClass === null || RETRYABLE_AUTH_ERROR_CLASSES.has(authErrorClass));
-
- const startLogin = () => {
- setLoginError(null);
- clearAuthError();
- login().catch((err: unknown) => {
- const msg = err instanceof Error ? err.message : 'Login failed';
- setLoginError(msg);
- });
- };
-
- const handleDropdownToggle = () => setDropdownOpen((o) => !o);
- const handleDropdownClose = () => setDropdownOpen(false);
-
- if (isLoading) {
- return (
-
-
- 🔑
- {/* The full label does not fit beside the title and the hamburger on a
- 320px header. The accessible name comes from aria-label above, so
- it stays the same at every width (issue #219, R8). */}
- Sign in with intervals.icu
- Sign in
-
- {visibleError && (
-
- ⚠️ {visibleError.includes('VITE_INTERVALS_CLIENT_ID')
- ? 'OAuth client ID not configured — check DEVELOPMENT.md'
- : visibleError}
- {showRetryButton && (
-
- Retry sign in
-
- )}
-
- Make sure your Concept2 PM5 is powered on and nearby
-
-
- );
-};
-
-function formatTime(ms: number): string {
- const seconds = Math.floor(ms / 1000);
- const minutes = Math.floor(seconds / 60);
- const hours = Math.floor(minutes / 60);
-
- if (hours > 0) {
- return `${hours}:${String(minutes % 60).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
- }
- return `${minutes}:${String(seconds % 60).padStart(2, '0')}`;
-}
-
-export default BluetoothDevice;
diff --git a/src/components/Canvas3DErrorBoundary.tsx b/src/components/Canvas3DErrorBoundary.tsx
deleted file mode 100644
index c9630eea..00000000
--- a/src/components/Canvas3DErrorBoundary.tsx
+++ /dev/null
@@ -1,134 +0,0 @@
-import React, { Component, type ReactNode } from 'react';
-import { isWebGLAvailable } from '../lib/utils/gpuUtils';
-import { crashReporterService } from '../lib/services/crashReporterService';
-import { reportFromErrorBoundary } from '../lib/utils/crashBoot';
-
-interface Props {
- children: ReactNode;
- fallback?: ReactNode;
- onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
-}
-
-interface State {
- hasError: boolean;
- error: Error | null;
- gpuAvailable: boolean;
- gpuBackend: 'webgl' | 'none';
-}
-
-/** Whether there is anything to draw with. */
-function checkGPUAvailability(): { available: boolean; backend: 'webgl' | 'none' } {
- return isWebGLAvailable()
- ? { available: true, backend: 'webgl' }
- : { available: false, backend: 'none' };
-}
-
-/**
- * Error boundary for Three.js Canvas components.
- * Catches WebGL context loss and other 3D rendering errors, displaying a
- * graceful fallback UI instead of crashing the app.
- */
-export class Canvas3DErrorBoundary extends Component {
- constructor(props: Props) {
- super(props);
- this.state = { hasError: false, error: null, gpuAvailable: true, gpuBackend: 'webgl' };
- }
-
- static getDerivedStateFromError(error: Error): Partial {
- return { hasError: true, error };
- }
-
- componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
- // Log error for debugging
- console.error('Canvas3D Error:', error);
- console.error('Error Info:', errorInfo);
-
- // Notify parent if callback provided
- if (this.props.onError) {
- this.props.onError(error, errorInfo);
- }
-
- // #473 FR2: ship a crash report with the error summary and recovered log,
- // deferred so the render path never has `reportCrash` on its call stack.
- reportFromErrorBoundary(crashReporterService, error, errorInfo, {
- boundary: 'Canvas3D',
- buildSha: (import.meta.env?.VITE_BUILD_SHA as string | undefined) ?? 'dev',
- });
-
- // Expose error flag for tests
- try {
- (window as unknown as Record).__CANVAS3D_ERROR = true;
- } catch {
- // Ignore window access errors
- }
- }
-
- // Synchronous. Retrying used to await a WebGPU probe and, if it resolved
- // true, clear the error without checking there was a WebGL context to draw
- // into - on a renderer R3F would then build in WebGL anyway (#345).
- handleRetry = (): void => {
- const { available, backend } = checkGPUAvailability();
- this.setState({ hasError: !available, error: null, gpuAvailable: available, gpuBackend: backend });
- };
-
- render(): ReactNode {
- if (this.state.hasError) {
- // Render fallback UI
- if (this.props.fallback) {
- return this.props.fallback;
- }
-
- // Default fallback
- return (
-
-
🚣
-
- 3D View Unavailable
-
-
- {this.state.gpuAvailable
- ? 'GPU context lost. Your workout data is still being tracked.'
- : 'WebGL is not available. Your workout data is still being tracked.'}
-
- );
- }
-
- return this.props.children;
- }
-}
-
-export default Canvas3DErrorBoundary;
diff --git a/src/components/ConditionsPicker.tsx b/src/components/ConditionsPicker.tsx
deleted file mode 100644
index a39433bc..00000000
--- a/src/components/ConditionsPicker.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-import React from 'react';
-import { CONDITIONS, conditionsLabel, type Conditions } from './rower3d/conditions';
-import type { ConditionsChoice } from '../hooks/useConditions';
-
-/**
- * The light a row is rowed in (issue #346).
- *
- * 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.
- *
- * Buttons with `role="radio"` rather than real radio inputs, matching
- * `GraphicsQualityPicker` — the two sit together and should not be two
- * different kinds of control.
- */
-
-export interface ConditionsPickerProps {
- choice: ConditionsChoice;
- /** What `auto` resolved to, so the panel can say what the clock decided. */
- resolved: Conditions;
- onChange: (choice: ConditionsChoice) => void;
-}
-
-export const ConditionsPicker: React.FC = ({
- choice,
- resolved,
- onChange,
-}) => (
-
-);
-
-export default ConditionsPicker;
diff --git a/src/components/CrashReportedBanner.tsx b/src/components/CrashReportedBanner.tsx
deleted file mode 100644
index 3778b324..00000000
--- a/src/components/CrashReportedBanner.tsx
+++ /dev/null
@@ -1,122 +0,0 @@
-/**
- * The "a report was sent last time" banner (issue #473, FR9).
- *
- * Non-modal, dismissible, one-line, top-right. Shown on the FIRST load after
- * a crash was reported successfully, with a link to the Settings opt-out.
- * Dismissal persists for this browser profile until the next distinct crash
- * signature is seen (see `crashBannerStore`).
- *
- * Hidden entirely when the opt-out is off, so a rower who turned reporting
- * off never sees a disclosure about a report that would not have been sent.
- */
-import { useEffect, useState } from 'react';
-import { crashBannerStore } from '../lib/services/crashBannerStore';
-import { crashReportsPreferenceStore } from '../lib/services/crashReportsPreferenceStore';
-
-interface Props {
- /** Fires when the viewer taps the opt-out link. The App opens Settings. */
- onOpenSettings?: () => void;
-}
-
-export const CrashReportedBanner: React.FC = ({ onOpenSettings }) => {
- const [visible, setVisible] = useState(() => {
- try {
- if (!crashReportsPreferenceStore.isEnabled()) return false;
- return crashBannerStore.current() !== null;
- } catch {
- return false;
- }
- });
-
- // Opt-out may be toggled while the banner is up; mirror that live so a
- // rower who turns reporting off also loses the "a report was sent"
- // disclosure for the signature they turned it off against.
- useEffect(() => {
- try {
- const unsubscribe = crashReportsPreferenceStore.subscribe((enabled) => {
- if (!enabled) setVisible(false);
- });
- return unsubscribe;
- } catch {
- return undefined;
- }
- }, []);
-
- if (!visible) return null;
-
- const handleDismiss = (): void => {
- try {
- crashBannerStore.dismiss();
- } catch {
- // ignored
- }
- setVisible(false);
- };
-
- return (
-
-
- The previous session crashed. A diagnostic report was sent to help us
- fix it.{' '}
- {onOpenSettings ? (
-
- Opt out
-
- ) : null}
-
-
- ×
-
-
- );
-};
diff --git a/src/components/CrewPicker.tsx b/src/components/CrewPicker.tsx
deleted file mode 100644
index b75fbf22..00000000
--- a/src/components/CrewPicker.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-import React from 'react';
-import {
- CREW_PREFERENCE_OPTIONS,
- type CrewPreference,
-} from '../hooks/useCrewPreference';
-
-export interface CrewPickerProps {
- preference: CrewPreference;
- onChange: (preference: CrewPreference) => void;
-}
-
-/**
- * Lets a rower choose who is in the boat (#232).
- *
- * Since #443 this only renders for guests, demo rows and signed-in athletes
- * whose intervals.icu profile has no `sex` field — an athlete whose profile
- * carries a `sex` reads the rower from that profile alone and gets a short
- * hint pointing at their intervals.icu settings instead. The picker itself is
- * otherwise unchanged and still writes `virtualrow:crew` for the guest case.
- */
-export const CrewPicker: React.FC = ({ preference, onChange }) => (
-
-);
diff --git a/src/components/FTMSDevice.tsx b/src/components/FTMSDevice.tsx
deleted file mode 100644
index 57130b41..00000000
--- a/src/components/FTMSDevice.tsx
+++ /dev/null
@@ -1,196 +0,0 @@
-import React, { useState, useEffect, useRef } from 'react';
-import type { BluetoothDeviceState, PM5Data } from '../lib/types/index';
-import { ftmsBluetoothService } from '../lib/services/ftmsBluetoothService';
-import './BluetoothDevice.css';
-
-/**
- * BLE connection panel for FTMS-compatible rowing machines (WaterRower, Concept2 RowErg,
- * Sunny Rowing, NordicTrack RW900, etc.).
- *
- * Mirrors BluetoothDevice.tsx but uses ftmsBluetoothService instead of the PM5 CSAFE service.
- * The emitted PM5Data shape is identical, so all downstream workout tracking is unchanged.
- *
- * Like BluetoothDevice this panel owns only its own display — App subscribes to
- * `ftmsBluetoothService` for the workout pipeline so data survives the view switch.
- */
-export const FTMSDevice: React.FC = () => {
- const [deviceState, setDeviceState] = useState({
- isConnected: false,
- });
- const [isConnecting, setIsConnecting] = useState(false);
- const [rowerData, setRowerData] = useState(null);
-
- // RAF-based throttle for local FTMS display — avoids renders from inside the WS/CDP stack.
- const latestRowerDataRef = useRef(null);
- const ftmsRafScheduledRef = useRef(false);
-
- useEffect(() => {
- const handleConnected = (data: unknown) => {
- const d = data as { deviceName?: string };
- const deviceName = d.deviceName ?? 'FTMS Rower';
- requestAnimationFrame(() => {
- setDeviceState((prev) => ({ ...prev, isConnected: true, deviceName }));
- setIsConnecting(false);
- });
- };
-
- const handleDisconnectedEvent = () => {
- requestAnimationFrame(() => {
- setDeviceState({ isConnected: false });
- setRowerData(null);
- });
- };
-
- const handleData = (data: PM5Data) => {
- latestRowerDataRef.current = data;
- if (!ftmsRafScheduledRef.current) {
- ftmsRafScheduledRef.current = true;
- requestAnimationFrame(() => {
- ftmsRafScheduledRef.current = false;
- if (latestRowerDataRef.current) setRowerData(latestRowerDataRef.current);
- });
- }
- };
-
- const handleError = (error: unknown) => {
- const e = error as { message?: string };
- const msg = e.message ?? 'Unknown error';
- requestAnimationFrame(() => {
- setDeviceState((prev) => ({ ...prev, error: msg }));
- setIsConnecting(false);
- });
- };
-
- ftmsBluetoothService.on('connected', handleConnected);
- ftmsBluetoothService.on('disconnected', handleDisconnectedEvent);
- ftmsBluetoothService.on('data', handleData);
- ftmsBluetoothService.on('error', handleError);
-
- return () => {
- ftmsBluetoothService.off('connected', handleConnected);
- ftmsBluetoothService.off('disconnected', handleDisconnectedEvent);
- ftmsBluetoothService.off('data', handleData);
- ftmsBluetoothService.off('error', handleError);
- };
- }, []);
-
- const handleConnect = async () => {
- setIsConnecting(true);
- const success = await ftmsBluetoothService.connect();
- if (!success) setIsConnecting(false);
- };
-
- const handleDisconnect = async () => {
- await ftmsBluetoothService.disconnect();
- };
-
- return (
-
- {isDemo
- ? 'This was a demo on simulated data, and nothing has been saved.'
- : 'This session has not been saved.'}
- {' '}
- Signing in with intervals.icu would have kept your {preservedList} for this row.
-
-
-
-
-
- Distance
- {distanceKm} km
-
-
- Time
- {formatDuration(session.duration)}
-
-
- Avg Pace
- {formatPace(session.averagePace)}/500m
-
-
- Calories
- {session.calories} kcal
-
- {avgHR !== null && (
-
- Avg HR
- {avgHR} bpm
-
- )}
- {maxHR !== null && (
-
- Max HR
- {maxHR} bpm
-
- )}
-
-
- {/* No comparison with a best: a guest has no history to keep one in. */}
-
-
-
- {onSignIn && (
-
- 🔑 Sign in to save future rows
-
- )}
-
- ▶ Row Again
-
-
- Done
-
-
- );
-}
diff --git a/src/components/RouteLoadingBar.css b/src/components/RouteLoadingBar.css
deleted file mode 100644
index 2607d2c3..00000000
--- a/src/components/RouteLoadingBar.css
+++ /dev/null
@@ -1,54 +0,0 @@
-/* ------------------------------------------------------- route loading ---
- How much of the route load is done (#318). Sits over the stage while the
- scene assembles, and goes when the scene is ready to row - not when its code
- chunk arrives, which is what the static message it replaces used to do. */
-
-.route-loading {
- position: absolute;
- inset: 0;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- gap: 0.75rem;
- /* Above the canvas, below anything the rower can act on. */
- z-index: 2;
- pointer-events: none;
- padding: 0 16px;
- text-align: center;
-}
-
-.route-loading-bar {
- width: min(320px, 70%);
- height: 6px;
- border-radius: 999px;
- background: rgba(255, 255, 255, 0.14);
- overflow: hidden;
-}
-
-.route-loading-fill {
- height: 100%;
- border-radius: inherit;
- background: var(--accent-color, #4da3ff);
- /* Eased so a phase landing reads as movement rather than a jump. The width
- itself is never animated towards a guess - it only changes when a phase
- has actually finished. */
- transition: width 200ms ease-out;
-}
-
-.route-loading-phase {
- margin: 0;
- font-size: 13px;
- color: var(--text-secondary, #9fb0c7);
-}
-
-.route-loading-percent {
- color: var(--text-primary, #e8eef7);
- font-variant-numeric: tabular-nums;
-}
-
-.route-loading-warning {
- margin: 0;
- font-size: 13px;
- color: var(--warning-color, #e0a33e);
-}
diff --git a/src/components/RouteLoadingBar.tsx b/src/components/RouteLoadingBar.tsx
deleted file mode 100644
index aeba8dff..00000000
--- a/src/components/RouteLoadingBar.tsx
+++ /dev/null
@@ -1,91 +0,0 @@
-import { useEffect, useState } from 'react';
-import type { LoadProgress } from '../lib/utils/loadingProgress';
-import { BOAT_UNAVAILABLE_MESSAGE } from '../lib/utils/loadingProgress';
-import './RouteLoadingBar.css';
-
-/**
- * How much of the route load is done (#318).
- *
- * Replaces a line of static text that was dismissed when the scene's code
- * chunk resolved — 0.7 s into a 3.2 s wait, leaving the rower looking at an
- * empty stage while the largest download of the lot was still in flight.
- *
- * A real `role="progressbar"` rather than a styled div: a rower using a screen
- * reader gets the same number everyone else does, and a spec can read it
- * without knowing how it is drawn.
- *
- * It owns its own visibility, because the two things it shows go at different
- * times. The bar goes when the wait is over; a boat that could not be fetched
- * is still true afterwards, and the rower is about to notice a river with no
- * boat in it.
- */
-
-/**
- * How long the filled bar stays up once the wait is over.
- *
- * Without it the bar is removed in the same render that completes it, so it
- * jumps from its last phase straight to nothing and never shows the finish —
- * measured going 45% -> gone. This delays the *dismissal* only; the value
- * itself still moves only when a phase settles, which is the property that
- * keeps this from being a timer.
- */
-const FINISHED_HOLD_MS = 400;
-
-interface RouteLoadingBarProps {
- progress: LoadProgress;
-}
-
-export function RouteLoadingBar({ progress }: RouteLoadingBarProps) {
- const boatMissing = progress.failed.includes('boat');
-
- // The hold is released by the timer below, never synchronously in the
- // effect body — React's compiler rules reject that, and cascading renders
- // are a real cost on a page that is already assembling a 3D scene.
- const [held, setHeld] = useState(false);
- const [wasComplete, setWasComplete] = useState(progress.complete);
- if (wasComplete !== progress.complete) {
- setWasComplete(progress.complete);
- setHeld(progress.complete);
- }
-
- useEffect(() => {
- if (!progress.complete) return;
- const timer = setTimeout(() => setHeld(false), FINISHED_HOLD_MS);
- return () => clearTimeout(timer);
- }, [progress.complete]);
-
- const showBar = !progress.complete || held;
-
- if (!showBar && !boatMissing) return null;
-
- return (
-
- );
-}
-
-/** Pace reads upward when it improves, as on an erg monitor's graph. */
-const CHART_OPTIONS: ChartOptions<'line'> = {
- responsive: true,
- maintainAspectRatio: false,
- animation: false,
- interaction: { mode: 'index', intersect: false },
- plugins: { legend: { position: 'bottom' } },
- scales: {
- x: { title: { display: true, text: 'km' }, ticks: { maxTicksLimit: 8 } },
- pace: {
- type: 'linear',
- position: 'left',
- reverse: true,
- ticks: { callback: (value) => clock(Number(value)) },
- },
- hr: { type: 'linear', position: 'right', grid: { drawOnChartArea: false } },
- },
-};
diff --git a/src/components/RowHud.css b/src/components/RowHud.css
deleted file mode 100644
index d66b7763..00000000
--- a/src/components/RowHud.css
+++ /dev/null
@@ -1,344 +0,0 @@
-/* ============================================================================
- THE ROW HUD (#335)
-
- Everything a rower needs while rowing, on the stage rather than under it.
-
- The rules the layout has to keep, all of them already enforced somewhere:
- - every control is at least 44x44 (responsive.spec.ts)
- - no text below 11px (responsive.spec.ts)
- - the stage is the majority of the main panel (responsive.spec.ts) - which
- this helps rather than hinders, since the panel below the stage is gone
- - the numbers read at a glance from an erg, which is what the 2rem value
- and `tabular-nums` are for: a digit that changes width makes the whole
- split jitter every stroke.
- ============================================================================ */
-
-/* How much of the stage's bottom edge the strip owns.
- Declared on the stage rather than on the HUD because the camera rig's button
- is the HUD's sibling, not its child, and has to clear the same band. The
- strip takes it as a `min-height`, so the two cannot disagree by construction
- - the only way to move one is to move the other. */
-.activity-route-stage {
- --row-hud-strip-height: 11rem;
-}
-
-.row-hud {
- position: absolute;
- inset: 0;
- z-index: 25;
- display: flex;
- flex-direction: column;
- justify-content: flex-end;
- /* The stage underneath still has to receive drags for the camera, so the
- layer itself is transparent to the pointer and only the controls in it are
- not. Without this the HUD would swallow the whole canvas. */
- pointer-events: none;
-}
-
-.row-hud > * {
- pointer-events: auto;
-}
-
-/* The strip's top is a transparent fade into the river, and a transparent
- thing that eats clicks is a dead zone over the water. The tiles and the
- controls take the pointer; the band they sit in does not. */
-.row-hud-strip {
- pointer-events: none;
-}
-
-.row-hud-strip > * {
- pointer-events: auto;
-}
-
-/* --- the corner actions ---------------------------------------------------
- Left of the minimap, which keeps its own top-right corner. */
-.row-hud-actions {
- position: absolute;
- top: var(--space-lg);
- right: calc(var(--space-lg) + clamp(120px, 22%, 280px) + var(--space-sm));
- display: flex;
- gap: var(--space-xs);
-}
-
-.btn-hud-action {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- min-width: 44px;
- min-height: 44px;
- padding: 0;
- border: 1px solid rgba(255, 255, 255, 0.25);
- border-radius: 999px;
- background: rgba(10, 14, 20, 0.7);
- backdrop-filter: blur(6px);
- color: #fff;
- font-size: 1.1rem;
- line-height: 1;
- cursor: pointer;
-}
-
-.btn-hud-action:hover,
-.btn-hud-action:focus-visible {
- background: rgba(10, 14, 20, 0.78);
- border-color: rgba(255, 255, 255, 0.5);
-}
-
-/* --- the metric strip and the controls under it --------------------------- */
-.row-hud-strip {
- display: flex;
- flex-direction: column;
- justify-content: flex-end;
- min-height: var(--row-hud-strip-height);
- gap: var(--space-sm);
- /* `env()` with a zero fallback: on a notched phone in landscape the home
- indicator sits exactly where the End button would be. */
- padding: var(--space-sm) calc(var(--space-md) + env(safe-area-inset-right, 0px))
- calc(var(--space-md) + env(safe-area-inset-bottom, 0px))
- calc(var(--space-md) + env(safe-area-inset-left, 0px));
- background: linear-gradient(180deg, rgba(10, 14, 20, 0) 0%, rgba(10, 14, 20, 0.72) 38%);
-}
-
-.row-hud .activity-stats-grid {
- width: 100%;
- display: grid;
- grid-auto-flow: column;
- grid-auto-columns: 1fr;
- gap: var(--space-2xs);
-}
-
-.row-hud .activity-stat-card {
- padding: var(--space-2xs) var(--space-xs);
- border-radius: var(--radius-control);
- /* The backdrop, at 0.7 rather than the 0.55 the design sketched (#344).
- Measured against the brightest water the strip sits over - rgb(226,225,223)
- on the demo route - 0.55 put the labels at 4.69:1, which clears 4.5 and
- would stop clearing it the first time the sun came out. 0.7 puts them at
- about 7.6:1, which is a margin rather than a coin toss. */
- background: rgba(10, 14, 20, 0.7);
- backdrop-filter: blur(6px);
- border: 1px solid rgba(255, 255, 255, 0.14);
- text-align: center;
- min-width: 0;
-}
-
-.row-hud .activity-stat-label {
- display: block;
- /* 16px, not the 11px legibility floor (#344). The floor is what no text may
- go below; a label a rower reads at arm's length while rowing hard is not a
- place to sit on it. Wraps rather than truncating - "Heart Rate (bpm)" is
- sixteen characters and a phone tile is not that wide, and half a word is
- worse than two lines. */
- font-size: 1rem;
- font-weight: 600;
- letter-spacing: 0.04em;
- text-transform: uppercase;
- /* 0.92 white on rgba(10,14,20,0.55) over the river clears 4.5:1 against
- every part of the scene the strip sits on; 0.82 did not clear it against
- the brightest water. */
- color: rgba(255, 255, 255, 0.92);
- line-height: 1.15;
- overflow-wrap: anywhere;
-}
-
-.row-hud .activity-stat-value {
- display: block;
- /* 32px, flat rather than clamped (#344). It used to scale down to 20px on a
- phone, which is the screen most likely to be propped against an erg and
- the one that could least afford it. It fits at that size because the unit
- moved to the label - the value is `2:00`, not `2:00/500m`.
-
- The same width every stroke, too: proportional digits made the split
- twitch sideways once a second, which is the one number a rower stares at. */
- font-size: 2rem;
- font-variant-numeric: tabular-nums;
- font-weight: 700;
- line-height: 1.1;
- color: #fff;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.row-hud .activity-controls {
- display: flex;
- justify-content: space-between;
- align-items: center;
- gap: var(--space-sm);
- width: 100%;
-}
-
-/* Over a river rather than over the page, so the panel's card colours would
- vanish into it; these are the stage's own. */
-.row-hud .btn-activity-control {
- flex: 0 1 auto;
- min-height: 44px;
- padding: var(--space-sm) var(--space-md);
- backdrop-filter: blur(6px);
-}
-
-.row-hud .btn-activity-control--subtle {
- background: rgba(10, 14, 20, 0.7);
- color: #fff;
- border-color: rgba(255, 255, 255, 0.25);
-}
-
-.row-hud .btn-activity-control--subtle:hover {
- background: rgba(10, 14, 20, 0.78);
- border-color: rgba(255, 255, 255, 0.5);
-}
-
-/* --- phones ---------------------------------------------------------------
- Four tiles, and they wrap to 2x2 rather than shrinking to four unreadable
- columns. `metricTiles` has already dropped power and time by this width. */
-@media (max-width: 599px) {
- /* Two rows of tiles and two of controls, so the band is taller. */
- .activity-route-stage {
- --row-hud-strip-height: 18rem;
- }
-
- .row-hud .activity-stats-grid {
- grid-auto-flow: row;
- grid-template-columns: repeat(2, 1fr);
- }
-
- .row-hud .activity-controls {
- flex-wrap: wrap;
- }
-
- .row-hud .btn-activity-control {
- flex: 1 1 40%;
- }
-
- /* The minimap is not there to be shared with at this width, so the button
- takes the corner. */
- .row-hud-actions {
- right: var(--space-sm);
- top: calc(var(--space-sm) + clamp(120px, 22%, 280px) * 17 / 25 + var(--space-sm));
- }
-}
-
-/* --- short landscape -------------------------------------------------------
- A phone on its side gives the stage about 180px of height, and a strip laid
- out in two rows takes 152 of them - it covered the river, the route name and
- the minimap, and pushed the camera button off the top of the stage entirely.
-
- The old panel handled this width by moving the stats into a column beside the
- stage. There is no beside any more, so the strip lies down instead: tiles and
- controls in one row, along the bottom, at the smallest type the 11px floor
- allows. */
-@media (max-height: 560px) and (orientation: landscape) {
- .activity-route-stage {
- --row-hud-strip-height: 5.5rem;
- }
-
- .row-hud-strip {
- flex-direction: row;
- align-items: flex-end;
- gap: var(--space-xs);
- padding: var(--space-2xs) calc(var(--space-sm) + env(safe-area-inset-right, 0px))
- calc(var(--space-2xs) + env(safe-area-inset-bottom, 0px))
- calc(var(--space-sm) + env(safe-area-inset-left, 0px));
- }
-
- .row-hud .activity-stats-grid {
- flex: 1 1 auto;
- min-width: 0;
- }
-
- /* The one place the 32px floor gives way: 180px of stage cannot hold a
- 32px number, a 16px label and a 44px control at once, and the numbers
- being on screen at all is what #335 was for. */
- .row-hud .activity-stat-value {
- font-size: 1.5rem;
- }
-
- .row-hud .activity-stat-label {
- font-size: 0.8125rem;
- }
-
- .row-hud .activity-controls {
- flex: 0 0 auto;
- width: auto;
- }
-
- .row-hud .btn-activity-control {
- padding: var(--space-2xs) var(--space-sm);
- font-size: 0.8rem;
- }
-
- /* 180px of stage is not enough for a route panel, a minimap, a camera
- control and a strip to each have their own space, so two of them give
- way. The minimap shrinks to clear the strip, and the camera button - which
- carries its own dark pill and is the only way to reach the other three
- views - is lifted over the route panel rather than left underneath it. */
- /* Scoped to the stage so it outranks the base rule in `App.css`, which the
- bundler happens to emit after this file. */
- .activity-route-stage .activity-map-overlay {
- width: clamp(90px, 14%, 126px);
- }
-
- .activity-route-stage .btn-camera-view {
- z-index: 26;
- }
-}
-
-/* The camera rig's own control sits bottom-left, where the strip now is
- (#328). Lifted clear of it rather than moved, because bottom-left beside the
- other stage controls is still where it belongs. */
-.activity-route-stage .btn-camera-view {
- bottom: calc(var(--space-sm) + var(--row-hud-strip-height, 9.5rem));
-}
-
-/* --- fullscreen -----------------------------------------------------------
- Real fullscreen makes the stage the whole screen by itself; this is the CSS
- fallback for iOS Safari, which has no Fullscreen API outside