From 2b50d3ffc8a6479bcc0b7575dbf5c96d957b2b8c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 15:50:31 +0000 Subject: [PATCH 1/2] #542 AC1: remove the React tree from next/svelte-webgpu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Svelte + WebGPU port is at parity (PRs #581–#586). Everything the React/three.js shell owned is gone: `src/App.tsx`, `src/main.tsx`, `src/components/` (bar `routeMapProjection.ts` and the pure-TS scenery config / helpers that `src/lib/scenery/` and the Svelte UI still read), `src/hooks/`, `src/context/`, all 66 `*.tsx` tests and the handful of `*.ts` tests that only measured a React-era contract. The scenery `__tests__` were parity suites pinned on each React implementation, so they go too. Pure-TS modules under `src/components/rower3d/` that Svelte, `src/lib` or the renderer still import stay: `conditions`, `constants`, `crewModel`, `ghost`, `navigableWidth`, `rowerAppearance`, `sceneQuality`, `sceneryAssets`, `sceneryClearance`, `sceneryConfig`, `sceneryCrossings`, `sceneryRegion`, `sceneryTrack`, `segmentScenery`, `themeConfig`. They move into `src/lib/` in a later slice. Config fallout: * `index.html` — repo-root entry only existed to boot React; SvelteKit owns the HTML host via `src/app.html`. * `tsconfig.app.json` — `jsx: react-jsx` is dead with no `.tsx` left. * `tsconfig.playwright.json` — drops `renderBudget`/`strokePose` includes (and the two spec files that read them, which only ran on the React engine). * `eslint.config.js` — drops the react-hooks + react-refresh plugins. * `setupTests.ts` — drops the `@testing-library/jest-dom` shim; no preserved test uses its matchers. * `playwright.config.next.ts` — removes two deleted specs from the next-branch testIgnore. What stays for the follow-up: * `package.json` dependencies on `react`, `react-dom`, `@react-three/*`, `postprocessing`, `react-chartjs-2`, `@testing-library/react`, `@react-three/test-renderer`, `@types/react`, `@types/react-dom`, `@vitejs/plugin-react`, `eslint-plugin-react-hooks`, `eslint-plugin-react-refresh` — nothing in the surviving tree imports them, but ripping them out touches `package-lock.json` and deserves its own PR. Local gates: `tsc`, `eslint`, `vitest` (213 files · 2081 pass · 8 skip), `tsc -p tsconfig.playwright.json`, `svelte-check` (clean — remaining warnings are pre-existing node_modules + tsconfig composite notes). Co-Authored-By: Claude Opus 4.7 Claude-Session: https://claude.ai/code/session_01Q5D9BTXmu7SeMa3LghJD8b --- eslint.config.js | 12 - index.html | 13 - playwright/playwright.config.next.ts | 5 +- .../tests/route-performance-budgets.spec.ts | 322 --- playwright/tests/rower-stroke.spec.ts | 179 -- src/App.tsx | 2060 ----------------- src/__tests__/AnimationContext.test.tsx | 166 -- src/__tests__/CascadedShadows.test.tsx | 302 --- src/__tests__/CrashReportedBanner.test.tsx | 60 - src/__tests__/RiverGuides.test.tsx | 42 - src/__tests__/RouteDetailOverlay.test.tsx | 69 - src/__tests__/RouteLoadingBar.test.tsx | 114 - src/__tests__/RouteOverviewMap.test.tsx | 95 - src/__tests__/RouteThumbnail.test.tsx | 45 - src/__tests__/Rower3D.curve.test.ts | 345 --- src/__tests__/RownativeRouteImport.test.tsx | 358 --- src/__tests__/app.test.tsx | 282 --- src/__tests__/appNavigation.test.tsx | 416 ---- src/__tests__/assetBasePath.test.ts | 4 +- src/__tests__/authButtonRetry.test.tsx | 101 - src/__tests__/authContext.test.tsx | 313 --- src/__tests__/authGateModal.test.tsx | 96 - src/__tests__/bankLand.test.ts | 185 -- src/__tests__/bankMaterial.test.ts | 205 -- src/__tests__/bankShoreDist.test.ts | 41 - src/__tests__/bankTerrain.test.ts | 294 --- src/__tests__/bankTexture.test.ts | 62 - src/__tests__/bankVillage.test.tsx | 77 - src/__tests__/biomeDensityTile.test.ts | 82 - src/__tests__/boatComponents.test.tsx | 103 - src/__tests__/boatMotion.test.ts | 147 -- src/__tests__/cameraRig.test.ts | 339 --- src/__tests__/canvas3DErrorBoundary.test.tsx | 105 - src/__tests__/canvasSurface.test.ts | 321 --- src/__tests__/cloudPlan.test.ts | 74 - src/__tests__/composerState.test.ts | 35 - src/__tests__/conditions.test.ts | 186 -- src/__tests__/conditionsPicker.test.tsx | 76 - src/__tests__/contactShadowTexture.test.ts | 79 - src/__tests__/courseFurniture.test.ts | 56 - src/__tests__/crewPicker.test.tsx | 29 - src/__tests__/crewPreference.test.ts | 64 - src/__tests__/crewPreload.test.ts | 24 - src/__tests__/crewRig.test.ts | 237 -- src/__tests__/debugPanel.test.tsx | 225 -- src/__tests__/demoCtaVisibility.test.tsx | 143 -- src/__tests__/demoRouteScenery.test.ts | 182 -- src/__tests__/detailTexture.test.ts | 80 - src/__tests__/detailTextures.test.ts | 68 - src/__tests__/effectPlan.test.ts | 266 --- src/__tests__/facadeMaterial.test.ts | 75 - src/__tests__/fogPlan.test.ts | 82 - src/__tests__/foliageComponents.test.tsx | 305 --- src/__tests__/foliageMaterial.test.ts | 101 - src/__tests__/foliagePlan.test.ts | 465 ---- src/__tests__/foliageTexture.test.ts | 140 -- src/__tests__/followBoat.test.tsx | 163 -- src/__tests__/frameHotPath.test.ts | 138 -- src/__tests__/frameStats.test.ts | 116 - src/__tests__/geometryChunks.test.ts | 89 - src/__tests__/gerstnerNormals.test.ts | 56 - src/__tests__/ghostBoat.test.tsx | 117 - src/__tests__/ghostPicker.test.tsx | 141 -- src/__tests__/ghostVerdict.test.tsx | 67 - src/__tests__/glContext.test.ts | 396 ---- src/__tests__/gltfScullRig.test.tsx | 318 --- src/__tests__/graphicsPickerLocation.test.tsx | 111 - src/__tests__/graphicsQualityPicker.test.tsx | 43 - src/__tests__/groundPlane.test.ts | 66 - src/__tests__/groundPlaneRelief.test.ts | 121 - src/__tests__/guestSessionSummary.test.tsx | 117 - src/__tests__/heartRateChart.test.tsx | 24 - src/__tests__/heartRateMonitor.test.tsx | 68 - src/__tests__/lakeGeometry.test.ts | 153 -- src/__tests__/lakeGuides.test.tsx | 90 - src/__tests__/lakePhysicsIsolation.test.tsx | 70 - src/__tests__/lakeRibbon.test.ts | 99 - src/__tests__/lakeRouteOnWater.test.ts | 183 -- src/__tests__/landscapeLayout.test.ts | 136 -- src/__tests__/lightingPlan.test.ts | 136 -- .../loadTodaysWorkoutButton.test.tsx | 128 - src/__tests__/materialPass.test.ts | 122 - src/__tests__/measuredShores.test.ts | 203 -- src/__tests__/paletteKind.test.ts | 42 - src/__tests__/postProcessingGuard.test.ts | 57 - src/__tests__/progressEasing.test.ts | 85 - src/__tests__/raceCallouts.test.tsx | 45 - src/__tests__/renderBudget.test.ts | 105 - src/__tests__/renderedRouteFidelity.test.ts | 139 -- src/__tests__/rippleTexture.test.ts | 78 - src/__tests__/riverGuideGeometry.test.ts | 89 - src/__tests__/routeCurvatureFidelity.test.ts | 187 -- src/__tests__/routeGeometryResolution.test.ts | 153 -- src/__tests__/routeStripGeometry.test.ts | 237 -- src/__tests__/routesDrawerToggle.test.tsx | 47 - src/__tests__/rowBreakdown.test.tsx | 131 -- src/__tests__/rowHud.test.tsx | 256 -- src/__tests__/rower3D.test.tsx | 415 ---- src/__tests__/rower3d.helpers.test.ts | 161 -- src/__tests__/rowerAppearancePicker.test.tsx | 113 - src/__tests__/rowerScene.smoke.test.tsx | 430 ---- src/__tests__/rownativeGeometry.test.ts | 344 --- src/__tests__/sceneErrorBoundary.test.tsx | 66 - src/__tests__/sceneExposure.test.ts | 41 - src/__tests__/sceneFreeze.test.ts | 92 - src/__tests__/sceneMemory.test.ts | 91 - src/__tests__/sceneStats.test.ts | 115 - src/__tests__/sceneTestRenderer.tsx | 70 - src/__tests__/sceneTiming.test.ts | 118 - src/__tests__/sceneryAssets.test.ts | 23 +- src/__tests__/sceneryClearance.test.ts | 165 -- src/__tests__/sceneryManifest.test.ts | 151 -- src/__tests__/sceneryPlacement.test.ts | 520 ----- src/__tests__/sceneryRerender.test.tsx | 155 -- src/__tests__/scenerySkyPass.test.ts | 111 - src/__tests__/sceneryStructures.test.ts | 432 ---- src/__tests__/servicesContext.test.tsx | 88 - src/__tests__/sessionSummary.test.tsx | 424 ---- src/__tests__/settingsPanel.test.tsx | 124 - src/__tests__/shaders.test.ts | 307 --- src/__tests__/shoreline.test.ts | 103 - src/__tests__/shorelineTexture.test.ts | 37 - src/__tests__/skyComponents.test.tsx | 335 --- src/__tests__/skyEnvironmentCache.test.ts | 119 - src/__tests__/soundPicker.test.tsx | 80 - src/__tests__/strokePose.test.ts | 241 -- src/__tests__/sunDirection.test.ts | 152 -- src/__tests__/themeConfig.test.ts | 229 -- src/__tests__/useCameraView.test.tsx | 125 - src/__tests__/useConditions.test.ts | 166 -- src/__tests__/useCurrentWorkout.test.tsx | 112 - src/__tests__/useDevicePerformance.test.tsx | 119 - src/__tests__/useFullscreen.test.tsx | 166 -- src/__tests__/useGraphicsQuality.test.tsx | 112 - src/__tests__/usePhysicsEngine.test.tsx | 256 -- src/__tests__/useProgressiveChunks.test.tsx | 104 - src/__tests__/useRaceCues.test.tsx | 141 -- src/__tests__/useReducedMotion.test.tsx | 113 - src/__tests__/useRenderStats.test.ts | 69 - src/__tests__/useRowerAppearance.test.tsx | 48 - src/__tests__/useRowerServiceEvents.test.ts | 186 -- src/__tests__/useRownativeDeepLink.test.tsx | 249 -- src/__tests__/useStartSequence.test.tsx | 159 -- src/__tests__/useStrokeAudio.test.tsx | 128 - .../useWorkoutTargetController.test.tsx | 145 -- src/__tests__/vegetationComponents.test.tsx | 51 - src/__tests__/visibilityCull.test.ts | 152 -- src/__tests__/wakeTexture.test.ts | 254 -- src/__tests__/waterMaterial.test.ts | 157 -- src/__tests__/waterSurfaceShader.test.ts | 175 -- src/__tests__/windowMaterial.test.ts | 68 - src/__tests__/workoutBlockPanel.test.tsx | 200 -- src/__tests__/worldScale.test.ts | 110 - src/components/AuthButton.css | 273 --- src/components/AuthButton.tsx | 138 -- src/components/AuthGateModal.css | 142 -- src/components/AuthGateModal.tsx | 83 - src/components/BluetoothDevice.css | 201 -- src/components/BluetoothDevice.tsx | 205 -- src/components/Canvas3DErrorBoundary.tsx | 134 -- src/components/ConditionsPicker.tsx | 67 - src/components/CrashReportedBanner.tsx | 122 - src/components/CrewPicker.tsx | 40 - src/components/FTMSDevice.tsx | 196 -- src/components/GhostPicker.css | 61 - src/components/GhostPicker.tsx | 108 - src/components/GhostVerdict.css | 20 - src/components/GhostVerdict.tsx | 58 - src/components/GraphicsQualityPicker.tsx | 41 - src/components/GuestSessionSummary.css | 181 -- src/components/GuestSessionSummary.tsx | 116 - src/components/HeartRateChart.tsx | 116 - src/components/HeartRateMonitor.tsx | 153 -- src/components/HeartRateSimulator.tsx | 114 - src/components/LoadTodaysWorkoutButton.tsx | 212 -- src/components/PM5Simulator.css | 373 --- src/components/PM5Simulator.tsx | 191 -- src/components/RaceCallouts.css | 45 - src/components/RaceCallouts.tsx | 34 - src/components/RouteDetailOverlay.css | 92 - src/components/RouteDetailOverlay.tsx | 129 -- src/components/RouteLoadingBar.css | 54 - src/components/RouteLoadingBar.tsx | 91 - src/components/RouteMap.css | 40 - src/components/RouteMap.tsx | 311 --- src/components/RouteOverviewMap.css | 89 - src/components/RouteOverviewMap.tsx | 331 --- src/components/RouteThumbnail.css | 50 - src/components/RouteThumbnail.tsx | 141 -- src/components/RowBreakdown.css | 60 - src/components/RowBreakdown.tsx | 165 -- src/components/RowHud.css | 344 --- src/components/RowHud.tsx | 199 -- src/components/Rower3D.css | 118 - src/components/Rower3D.tsx | 1576 ------------- src/components/RowerAppearancePicker.css | 119 - src/components/RowerAppearancePicker.tsx | 152 -- src/components/RownativeRouteImport.css | 191 -- src/components/RownativeRouteImport.tsx | 323 --- src/components/SessionSummary.css | 225 -- src/components/SessionSummary.tsx | 216 -- src/components/SettingsPanel.css | 127 - src/components/SettingsPanel.tsx | 127 - src/components/SoundPicker.css | 98 - src/components/SoundPicker.tsx | 66 - src/components/WorkoutBlockPanel.css | 170 -- src/components/WorkoutBlockPanel.tsx | 191 -- src/components/rowHudPlan.ts | 65 - src/components/rower3d/AnimationContext.tsx | 44 - src/components/rower3d/CascadedShadows.tsx | 89 - src/components/rower3d/GhostBoat.tsx | 110 - src/components/rower3d/RiverGuides.tsx | 48 - src/components/rower3d/SceneErrorBoundary.tsx | 84 - src/components/rower3d/animationFrame.ts | 40 - src/components/rower3d/bankComponents.tsx | 642 ----- src/components/rower3d/bankGeometry.ts | 295 --- src/components/rower3d/bankMaterial.ts | 139 -- src/components/rower3d/bankTexture.ts | 96 - src/components/rower3d/boatComponents.tsx | 516 ----- src/components/rower3d/boatMotion.ts | 93 - src/components/rower3d/cameraRig.ts | 208 -- src/components/rower3d/canvasSurface.ts | 219 -- src/components/rower3d/cascadedShadowsCore.ts | 57 - src/components/rower3d/cloudPlan.ts | 41 - src/components/rower3d/composerState.ts | 19 - .../rower3d/contactShadowTexture.ts | 60 - src/components/rower3d/courseFurniture.ts | 58 - src/components/rower3d/crewPreload.ts | 16 - src/components/rower3d/crewRig.ts | 125 - src/components/rower3d/curve.ts | 361 --- src/components/rower3d/detailTexture.ts | 185 -- src/components/rower3d/detailTextures.ts | 239 -- src/components/rower3d/effectComponents.tsx | 597 ----- src/components/rower3d/effectPlan.ts | 176 -- src/components/rower3d/facadeMaterial.ts | 62 - src/components/rower3d/fogPlan.ts | 46 - src/components/rower3d/foliageComponents.tsx | 244 -- src/components/rower3d/foliageMaterial.ts | 94 - src/components/rower3d/foliagePlan.ts | 421 ---- src/components/rower3d/foliageTexture.ts | 339 --- src/components/rower3d/followBoat.ts | 67 - src/components/rower3d/frameStats.ts | 95 - src/components/rower3d/geometryChunks.ts | 95 - src/components/rower3d/glContext.ts | 329 --- src/components/rower3d/groundPlane.ts | 74 - src/components/rower3d/groundPlaneRelief.ts | 167 -- src/components/rower3d/helpers.ts | 213 -- src/components/rower3d/lakeGeometry.ts | 303 --- src/components/rower3d/landscapeLayout.ts | 195 -- src/components/rower3d/lightingPlan.ts | 122 - src/components/rower3d/materialPass.ts | 154 -- src/components/rower3d/paletteKind.ts | 35 - src/components/rower3d/postProcessingGuard.ts | 47 - src/components/rower3d/progressEasing.ts | 42 - src/components/rower3d/renderBudget.ts | 110 - src/components/rower3d/rippleTexture.ts | 111 - src/components/rower3d/riverGuideGeometry.ts | 127 - src/components/rower3d/routeStripChunks.tsx | 104 - src/components/rower3d/routeStripGeometry.ts | 232 -- src/components/rower3d/sceneExposure.ts | 34 - src/components/rower3d/sceneFreeze.ts | 88 - src/components/rower3d/sceneMemory.ts | 61 - src/components/rower3d/sceneStats.ts | 99 - src/components/rower3d/sceneTiming.ts | 80 - src/components/rower3d/sceneryModels.tsx | 420 ---- src/components/rower3d/sceneryPlacement.ts | 320 --- src/components/rower3d/scenerySkyPass.ts | 94 - src/components/rower3d/sceneryStructures.ts | 270 --- src/components/rower3d/shaderChunks.ts | 165 -- src/components/rower3d/shaderErrors.ts | 53 - .../rower3d/shaders/bankTriplanar.glsl | 111 - .../rower3d/shaders/foliageBillboard.glsl | 35 - .../rower3d/shaders/foliageSway.vert.glsl | 12 - .../rower3d/shaders/gerstner.vert.glsl | 46 - .../rower3d/shaders/waterSurface.frag.glsl | 30 - src/components/rower3d/shadowReceivers.ts | 86 - src/components/rower3d/shorelineTexture.ts | 50 - src/components/rower3d/skyComponents.tsx | 323 --- src/components/rower3d/skyEnvironment.ts | 91 - src/components/rower3d/skyEnvironmentCache.ts | 86 - src/components/rower3d/strokePose.ts | 178 -- src/components/rower3d/sunDirection.ts | 92 - .../rower3d/vegetationComponents.tsx | 308 --- src/components/rower3d/visibilityCull.ts | 120 - src/components/rower3d/wakeTexture.ts | 333 --- src/components/rower3d/waterComponents.tsx | 380 --- src/components/rower3d/waterGeometry.ts | 92 - src/components/rower3d/waterMaterial.ts | 181 -- src/components/rower3d/windowMaterial.ts | 76 - src/context/AuthContext.tsx | 131 -- src/context/ServicesContext.tsx | 38 - src/context/useAuth.ts | 56 - src/context/useServices.ts | 71 - src/hooks/useCameraView.ts | 76 - src/hooks/useConditions.ts | 86 - src/hooks/useCrewPreference.ts | 62 - src/hooks/useCurrentWorkout.ts | 123 - src/hooks/useDevicePerformance.ts | 141 -- src/hooks/useFullscreen.ts | 62 - src/hooks/useGraphicsQuality.ts | 100 - src/hooks/usePhysicsEngine.ts | 118 - src/hooks/useProgressiveChunks.ts | 86 - src/hooks/useRaceCues.ts | 75 - src/hooks/useReducedMotion.ts | 60 - src/hooks/useRenderStats.ts | 49 - src/hooks/useRouteLoadProgress.ts | 159 -- src/hooks/useRowerAppearance.ts | 45 - src/hooks/useRowerServiceEvents.ts | 65 - src/hooks/useRownativeDeepLink.ts | 118 - src/hooks/useStartSequence.ts | 111 - src/hooks/useStrokeAudio.ts | 47 - src/hooks/useWorkoutTargetController.ts | 118 - .../scenery/__tests__/courseFurniture.test.ts | 56 - src/lib/scenery/__tests__/foliagePlan.test.ts | 87 - .../scenery/__tests__/foliageTexture.test.ts | 30 - .../scenery/__tests__/landscapeLayout.test.ts | 151 -- .../__tests__/sceneryPlacement.test.ts | 124 - .../__tests__/sceneryStructures.test.ts | 101 - src/main.tsx | 45 - src/setupTests.ts | 6 +- tsconfig.app.json | 1 - tsconfig.playwright.json | 4 +- 322 files changed, 17 insertions(+), 49459 deletions(-) delete mode 100644 index.html delete mode 100644 playwright/tests/route-performance-budgets.spec.ts delete mode 100644 playwright/tests/rower-stroke.spec.ts delete mode 100644 src/App.tsx delete mode 100644 src/__tests__/AnimationContext.test.tsx delete mode 100644 src/__tests__/CascadedShadows.test.tsx delete mode 100644 src/__tests__/CrashReportedBanner.test.tsx delete mode 100644 src/__tests__/RiverGuides.test.tsx delete mode 100644 src/__tests__/RouteDetailOverlay.test.tsx delete mode 100644 src/__tests__/RouteLoadingBar.test.tsx delete mode 100644 src/__tests__/RouteOverviewMap.test.tsx delete mode 100644 src/__tests__/RouteThumbnail.test.tsx delete mode 100644 src/__tests__/Rower3D.curve.test.ts delete mode 100644 src/__tests__/RownativeRouteImport.test.tsx delete mode 100644 src/__tests__/app.test.tsx delete mode 100644 src/__tests__/appNavigation.test.tsx delete mode 100644 src/__tests__/authButtonRetry.test.tsx delete mode 100644 src/__tests__/authContext.test.tsx delete mode 100644 src/__tests__/authGateModal.test.tsx delete mode 100644 src/__tests__/bankLand.test.ts delete mode 100644 src/__tests__/bankMaterial.test.ts delete mode 100644 src/__tests__/bankShoreDist.test.ts delete mode 100644 src/__tests__/bankTerrain.test.ts delete mode 100644 src/__tests__/bankTexture.test.ts delete mode 100644 src/__tests__/bankVillage.test.tsx delete mode 100644 src/__tests__/biomeDensityTile.test.ts delete mode 100644 src/__tests__/boatComponents.test.tsx delete mode 100644 src/__tests__/boatMotion.test.ts delete mode 100644 src/__tests__/cameraRig.test.ts delete mode 100644 src/__tests__/canvas3DErrorBoundary.test.tsx delete mode 100644 src/__tests__/canvasSurface.test.ts delete mode 100644 src/__tests__/cloudPlan.test.ts delete mode 100644 src/__tests__/composerState.test.ts delete mode 100644 src/__tests__/conditions.test.ts delete mode 100644 src/__tests__/conditionsPicker.test.tsx delete mode 100644 src/__tests__/contactShadowTexture.test.ts delete mode 100644 src/__tests__/courseFurniture.test.ts delete mode 100644 src/__tests__/crewPicker.test.tsx delete mode 100644 src/__tests__/crewPreference.test.ts delete mode 100644 src/__tests__/crewPreload.test.ts delete mode 100644 src/__tests__/crewRig.test.ts delete mode 100644 src/__tests__/debugPanel.test.tsx delete mode 100644 src/__tests__/demoCtaVisibility.test.tsx delete mode 100644 src/__tests__/demoRouteScenery.test.ts delete mode 100644 src/__tests__/detailTexture.test.ts delete mode 100644 src/__tests__/detailTextures.test.ts delete mode 100644 src/__tests__/effectPlan.test.ts delete mode 100644 src/__tests__/facadeMaterial.test.ts delete mode 100644 src/__tests__/fogPlan.test.ts delete mode 100644 src/__tests__/foliageComponents.test.tsx delete mode 100644 src/__tests__/foliageMaterial.test.ts delete mode 100644 src/__tests__/foliagePlan.test.ts delete mode 100644 src/__tests__/foliageTexture.test.ts delete mode 100644 src/__tests__/followBoat.test.tsx delete mode 100644 src/__tests__/frameHotPath.test.ts delete mode 100644 src/__tests__/frameStats.test.ts delete mode 100644 src/__tests__/geometryChunks.test.ts delete mode 100644 src/__tests__/gerstnerNormals.test.ts delete mode 100644 src/__tests__/ghostBoat.test.tsx delete mode 100644 src/__tests__/ghostPicker.test.tsx delete mode 100644 src/__tests__/ghostVerdict.test.tsx delete mode 100644 src/__tests__/glContext.test.ts delete mode 100644 src/__tests__/gltfScullRig.test.tsx delete mode 100644 src/__tests__/graphicsPickerLocation.test.tsx delete mode 100644 src/__tests__/graphicsQualityPicker.test.tsx delete mode 100644 src/__tests__/groundPlane.test.ts delete mode 100644 src/__tests__/groundPlaneRelief.test.ts delete mode 100644 src/__tests__/guestSessionSummary.test.tsx delete mode 100644 src/__tests__/heartRateChart.test.tsx delete mode 100644 src/__tests__/heartRateMonitor.test.tsx delete mode 100644 src/__tests__/lakeGeometry.test.ts delete mode 100644 src/__tests__/lakeGuides.test.tsx delete mode 100644 src/__tests__/lakePhysicsIsolation.test.tsx delete mode 100644 src/__tests__/lakeRibbon.test.ts delete mode 100644 src/__tests__/lakeRouteOnWater.test.ts delete mode 100644 src/__tests__/landscapeLayout.test.ts delete mode 100644 src/__tests__/lightingPlan.test.ts delete mode 100644 src/__tests__/loadTodaysWorkoutButton.test.tsx delete mode 100644 src/__tests__/materialPass.test.ts delete mode 100644 src/__tests__/measuredShores.test.ts delete mode 100644 src/__tests__/paletteKind.test.ts delete mode 100644 src/__tests__/postProcessingGuard.test.ts delete mode 100644 src/__tests__/progressEasing.test.ts delete mode 100644 src/__tests__/raceCallouts.test.tsx delete mode 100644 src/__tests__/renderBudget.test.ts delete mode 100644 src/__tests__/renderedRouteFidelity.test.ts delete mode 100644 src/__tests__/rippleTexture.test.ts delete mode 100644 src/__tests__/riverGuideGeometry.test.ts delete mode 100644 src/__tests__/routeCurvatureFidelity.test.ts delete mode 100644 src/__tests__/routeGeometryResolution.test.ts delete mode 100644 src/__tests__/routeStripGeometry.test.ts delete mode 100644 src/__tests__/routesDrawerToggle.test.tsx delete mode 100644 src/__tests__/rowBreakdown.test.tsx delete mode 100644 src/__tests__/rowHud.test.tsx delete mode 100644 src/__tests__/rower3D.test.tsx delete mode 100644 src/__tests__/rower3d.helpers.test.ts delete mode 100644 src/__tests__/rowerAppearancePicker.test.tsx delete mode 100644 src/__tests__/rowerScene.smoke.test.tsx delete mode 100644 src/__tests__/rownativeGeometry.test.ts delete mode 100644 src/__tests__/sceneErrorBoundary.test.tsx delete mode 100644 src/__tests__/sceneExposure.test.ts delete mode 100644 src/__tests__/sceneFreeze.test.ts delete mode 100644 src/__tests__/sceneMemory.test.ts delete mode 100644 src/__tests__/sceneStats.test.ts delete mode 100644 src/__tests__/sceneTestRenderer.tsx delete mode 100644 src/__tests__/sceneTiming.test.ts delete mode 100644 src/__tests__/sceneryClearance.test.ts delete mode 100644 src/__tests__/sceneryManifest.test.ts delete mode 100644 src/__tests__/sceneryPlacement.test.ts delete mode 100644 src/__tests__/sceneryRerender.test.tsx delete mode 100644 src/__tests__/scenerySkyPass.test.ts delete mode 100644 src/__tests__/sceneryStructures.test.ts delete mode 100644 src/__tests__/servicesContext.test.tsx delete mode 100644 src/__tests__/sessionSummary.test.tsx delete mode 100644 src/__tests__/settingsPanel.test.tsx delete mode 100644 src/__tests__/shaders.test.ts delete mode 100644 src/__tests__/shoreline.test.ts delete mode 100644 src/__tests__/shorelineTexture.test.ts delete mode 100644 src/__tests__/skyComponents.test.tsx delete mode 100644 src/__tests__/skyEnvironmentCache.test.ts delete mode 100644 src/__tests__/soundPicker.test.tsx delete mode 100644 src/__tests__/strokePose.test.ts delete mode 100644 src/__tests__/sunDirection.test.ts delete mode 100644 src/__tests__/themeConfig.test.ts delete mode 100644 src/__tests__/useCameraView.test.tsx delete mode 100644 src/__tests__/useConditions.test.ts delete mode 100644 src/__tests__/useCurrentWorkout.test.tsx delete mode 100644 src/__tests__/useDevicePerformance.test.tsx delete mode 100644 src/__tests__/useFullscreen.test.tsx delete mode 100644 src/__tests__/useGraphicsQuality.test.tsx delete mode 100644 src/__tests__/usePhysicsEngine.test.tsx delete mode 100644 src/__tests__/useProgressiveChunks.test.tsx delete mode 100644 src/__tests__/useRaceCues.test.tsx delete mode 100644 src/__tests__/useReducedMotion.test.tsx delete mode 100644 src/__tests__/useRenderStats.test.ts delete mode 100644 src/__tests__/useRowerAppearance.test.tsx delete mode 100644 src/__tests__/useRowerServiceEvents.test.ts delete mode 100644 src/__tests__/useRownativeDeepLink.test.tsx delete mode 100644 src/__tests__/useStartSequence.test.tsx delete mode 100644 src/__tests__/useStrokeAudio.test.tsx delete mode 100644 src/__tests__/useWorkoutTargetController.test.tsx delete mode 100644 src/__tests__/vegetationComponents.test.tsx delete mode 100644 src/__tests__/visibilityCull.test.ts delete mode 100644 src/__tests__/wakeTexture.test.ts delete mode 100644 src/__tests__/waterMaterial.test.ts delete mode 100644 src/__tests__/waterSurfaceShader.test.ts delete mode 100644 src/__tests__/windowMaterial.test.ts delete mode 100644 src/__tests__/workoutBlockPanel.test.tsx delete mode 100644 src/__tests__/worldScale.test.ts delete mode 100644 src/components/AuthButton.css delete mode 100644 src/components/AuthButton.tsx delete mode 100644 src/components/AuthGateModal.css delete mode 100644 src/components/AuthGateModal.tsx delete mode 100644 src/components/BluetoothDevice.css delete mode 100644 src/components/BluetoothDevice.tsx delete mode 100644 src/components/Canvas3DErrorBoundary.tsx delete mode 100644 src/components/ConditionsPicker.tsx delete mode 100644 src/components/CrashReportedBanner.tsx delete mode 100644 src/components/CrewPicker.tsx delete mode 100644 src/components/FTMSDevice.tsx delete mode 100644 src/components/GhostPicker.css delete mode 100644 src/components/GhostPicker.tsx delete mode 100644 src/components/GhostVerdict.css delete mode 100644 src/components/GhostVerdict.tsx delete mode 100644 src/components/GraphicsQualityPicker.tsx delete mode 100644 src/components/GuestSessionSummary.css delete mode 100644 src/components/GuestSessionSummary.tsx delete mode 100644 src/components/HeartRateChart.tsx delete mode 100644 src/components/HeartRateMonitor.tsx delete mode 100644 src/components/HeartRateSimulator.tsx delete mode 100644 src/components/LoadTodaysWorkoutButton.tsx delete mode 100644 src/components/PM5Simulator.css delete mode 100644 src/components/PM5Simulator.tsx delete mode 100644 src/components/RaceCallouts.css delete mode 100644 src/components/RaceCallouts.tsx delete mode 100644 src/components/RouteDetailOverlay.css delete mode 100644 src/components/RouteDetailOverlay.tsx delete mode 100644 src/components/RouteLoadingBar.css delete mode 100644 src/components/RouteLoadingBar.tsx delete mode 100644 src/components/RouteMap.css delete mode 100644 src/components/RouteMap.tsx delete mode 100644 src/components/RouteOverviewMap.css delete mode 100644 src/components/RouteOverviewMap.tsx delete mode 100644 src/components/RouteThumbnail.css delete mode 100644 src/components/RouteThumbnail.tsx delete mode 100644 src/components/RowBreakdown.css delete mode 100644 src/components/RowBreakdown.tsx delete mode 100644 src/components/RowHud.css delete mode 100644 src/components/RowHud.tsx delete mode 100644 src/components/Rower3D.css delete mode 100644 src/components/Rower3D.tsx delete mode 100644 src/components/RowerAppearancePicker.css delete mode 100644 src/components/RowerAppearancePicker.tsx delete mode 100644 src/components/RownativeRouteImport.css delete mode 100644 src/components/RownativeRouteImport.tsx delete mode 100644 src/components/SessionSummary.css delete mode 100644 src/components/SessionSummary.tsx delete mode 100644 src/components/SettingsPanel.css delete mode 100644 src/components/SettingsPanel.tsx delete mode 100644 src/components/SoundPicker.css delete mode 100644 src/components/SoundPicker.tsx delete mode 100644 src/components/WorkoutBlockPanel.css delete mode 100644 src/components/WorkoutBlockPanel.tsx delete mode 100644 src/components/rowHudPlan.ts delete mode 100644 src/components/rower3d/AnimationContext.tsx delete mode 100644 src/components/rower3d/CascadedShadows.tsx delete mode 100644 src/components/rower3d/GhostBoat.tsx delete mode 100644 src/components/rower3d/RiverGuides.tsx delete mode 100644 src/components/rower3d/SceneErrorBoundary.tsx delete mode 100644 src/components/rower3d/animationFrame.ts delete mode 100644 src/components/rower3d/bankComponents.tsx delete mode 100644 src/components/rower3d/bankGeometry.ts delete mode 100644 src/components/rower3d/bankMaterial.ts delete mode 100644 src/components/rower3d/bankTexture.ts delete mode 100644 src/components/rower3d/boatComponents.tsx delete mode 100644 src/components/rower3d/boatMotion.ts delete mode 100644 src/components/rower3d/cameraRig.ts delete mode 100644 src/components/rower3d/canvasSurface.ts delete mode 100644 src/components/rower3d/cascadedShadowsCore.ts delete mode 100644 src/components/rower3d/cloudPlan.ts delete mode 100644 src/components/rower3d/composerState.ts delete mode 100644 src/components/rower3d/contactShadowTexture.ts delete mode 100644 src/components/rower3d/courseFurniture.ts delete mode 100644 src/components/rower3d/crewPreload.ts delete mode 100644 src/components/rower3d/crewRig.ts delete mode 100644 src/components/rower3d/curve.ts delete mode 100644 src/components/rower3d/detailTexture.ts delete mode 100644 src/components/rower3d/detailTextures.ts delete mode 100644 src/components/rower3d/effectComponents.tsx delete mode 100644 src/components/rower3d/effectPlan.ts delete mode 100644 src/components/rower3d/facadeMaterial.ts delete mode 100644 src/components/rower3d/fogPlan.ts delete mode 100644 src/components/rower3d/foliageComponents.tsx delete mode 100644 src/components/rower3d/foliageMaterial.ts delete mode 100644 src/components/rower3d/foliagePlan.ts delete mode 100644 src/components/rower3d/foliageTexture.ts delete mode 100644 src/components/rower3d/followBoat.ts delete mode 100644 src/components/rower3d/frameStats.ts delete mode 100644 src/components/rower3d/geometryChunks.ts delete mode 100644 src/components/rower3d/glContext.ts delete mode 100644 src/components/rower3d/groundPlane.ts delete mode 100644 src/components/rower3d/groundPlaneRelief.ts delete mode 100644 src/components/rower3d/helpers.ts delete mode 100644 src/components/rower3d/lakeGeometry.ts delete mode 100644 src/components/rower3d/landscapeLayout.ts delete mode 100644 src/components/rower3d/lightingPlan.ts delete mode 100644 src/components/rower3d/materialPass.ts delete mode 100644 src/components/rower3d/paletteKind.ts delete mode 100644 src/components/rower3d/postProcessingGuard.ts delete mode 100644 src/components/rower3d/progressEasing.ts delete mode 100644 src/components/rower3d/renderBudget.ts delete mode 100644 src/components/rower3d/rippleTexture.ts delete mode 100644 src/components/rower3d/riverGuideGeometry.ts delete mode 100644 src/components/rower3d/routeStripChunks.tsx delete mode 100644 src/components/rower3d/routeStripGeometry.ts delete mode 100644 src/components/rower3d/sceneExposure.ts delete mode 100644 src/components/rower3d/sceneFreeze.ts delete mode 100644 src/components/rower3d/sceneMemory.ts delete mode 100644 src/components/rower3d/sceneStats.ts delete mode 100644 src/components/rower3d/sceneTiming.ts delete mode 100644 src/components/rower3d/sceneryModels.tsx delete mode 100644 src/components/rower3d/sceneryPlacement.ts delete mode 100644 src/components/rower3d/scenerySkyPass.ts delete mode 100644 src/components/rower3d/sceneryStructures.ts delete mode 100644 src/components/rower3d/shaderChunks.ts delete mode 100644 src/components/rower3d/shaderErrors.ts delete mode 100644 src/components/rower3d/shaders/bankTriplanar.glsl delete mode 100644 src/components/rower3d/shaders/foliageBillboard.glsl delete mode 100644 src/components/rower3d/shaders/foliageSway.vert.glsl delete mode 100644 src/components/rower3d/shaders/gerstner.vert.glsl delete mode 100644 src/components/rower3d/shaders/waterSurface.frag.glsl delete mode 100644 src/components/rower3d/shadowReceivers.ts delete mode 100644 src/components/rower3d/shorelineTexture.ts delete mode 100644 src/components/rower3d/skyComponents.tsx delete mode 100644 src/components/rower3d/skyEnvironment.ts delete mode 100644 src/components/rower3d/skyEnvironmentCache.ts delete mode 100644 src/components/rower3d/strokePose.ts delete mode 100644 src/components/rower3d/sunDirection.ts delete mode 100644 src/components/rower3d/vegetationComponents.tsx delete mode 100644 src/components/rower3d/visibilityCull.ts delete mode 100644 src/components/rower3d/wakeTexture.ts delete mode 100644 src/components/rower3d/waterComponents.tsx delete mode 100644 src/components/rower3d/waterGeometry.ts delete mode 100644 src/components/rower3d/waterMaterial.ts delete mode 100644 src/components/rower3d/windowMaterial.ts delete mode 100644 src/context/AuthContext.tsx delete mode 100644 src/context/ServicesContext.tsx delete mode 100644 src/context/useAuth.ts delete mode 100644 src/context/useServices.ts delete mode 100644 src/hooks/useCameraView.ts delete mode 100644 src/hooks/useConditions.ts delete mode 100644 src/hooks/useCrewPreference.ts delete mode 100644 src/hooks/useCurrentWorkout.ts delete mode 100644 src/hooks/useDevicePerformance.ts delete mode 100644 src/hooks/useFullscreen.ts delete mode 100644 src/hooks/useGraphicsQuality.ts delete mode 100644 src/hooks/usePhysicsEngine.ts delete mode 100644 src/hooks/useProgressiveChunks.ts delete mode 100644 src/hooks/useRaceCues.ts delete mode 100644 src/hooks/useReducedMotion.ts delete mode 100644 src/hooks/useRenderStats.ts delete mode 100644 src/hooks/useRouteLoadProgress.ts delete mode 100644 src/hooks/useRowerAppearance.ts delete mode 100644 src/hooks/useRowerServiceEvents.ts delete mode 100644 src/hooks/useRownativeDeepLink.ts delete mode 100644 src/hooks/useStartSequence.ts delete mode 100644 src/hooks/useStrokeAudio.ts delete mode 100644 src/hooks/useWorkoutTargetController.ts delete mode 100644 src/lib/scenery/__tests__/courseFurniture.test.ts delete mode 100644 src/lib/scenery/__tests__/foliagePlan.test.ts delete mode 100644 src/lib/scenery/__tests__/foliageTexture.test.ts delete mode 100644 src/lib/scenery/__tests__/landscapeLayout.test.ts delete mode 100644 src/lib/scenery/__tests__/sceneryPlacement.test.ts delete mode 100644 src/lib/scenery/__tests__/sceneryStructures.test.ts delete mode 100644 src/main.tsx diff --git a/eslint.config.js b/eslint.config.js index 65e95662..06caf820 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,7 +1,5 @@ import js from '@eslint/js' import globals from 'globals' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' import tseslint from 'typescript-eslint' import { defineConfig, globalIgnores } from 'eslint/config' @@ -36,16 +34,6 @@ export default defineConfig([ }, }, - // React rules apply to the app only. Playwright specs and build scripts are - // not React, and the hooks plugin cannot tell Playwright's `use` fixture - // callback from React's `use` hook — it reported three rules-of-hooks - // violations in an earlier fixture file for ordinary fixture - // code. - { - files: ['src/**/*.{ts,tsx}'], - extends: [reactHooks.configs.flat.recommended, reactRefresh.configs.vite], - }, - // #550 AC6/NFR: on `next/svelte-webgpu` the WebGPU surface lives in // `src/lib/renderer/` (the hand-written engine). `@webgpu/types` is allowed // there and nowhere else; `three` is forbidden there, which is what makes diff --git a/index.html b/index.html deleted file mode 100644 index 2314bd01..00000000 --- a/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - virtualrow - - -
- - - diff --git a/playwright/playwright.config.next.ts b/playwright/playwright.config.next.ts index 4a3fa7d4..1bbe1cea 100644 --- a/playwright/playwright.config.next.ts +++ b/playwright/playwright.config.next.ts @@ -43,9 +43,10 @@ const EXTRA_IGNORE = [ '**/oar-clearance.spec.ts', '**/quality-tier-endurance.spec.ts', '**/route-performance.spec.ts', - '**/route-performance-budgets.spec.ts', + // #542 AC1: `route-performance-budgets.spec.ts` and `rower-stroke.spec.ts` + // read the deleted React render-budget / stroke-pose modules; they go with + // the React tree. A new WebGPU equivalent re-lands on the GPU runner. '**/rower-slide.spec.ts', - '**/rower-stroke.spec.ts', '**/scene-context-endurance.spec.ts', '**/scene-contrast.spec.ts', '**/scene-telemetry.spec.ts', diff --git a/playwright/tests/route-performance-budgets.spec.ts b/playwright/tests/route-performance-budgets.spec.ts deleted file mode 100644 index 455a963c..00000000 --- a/playwright/tests/route-performance-budgets.spec.ts +++ /dev/null @@ -1,322 +0,0 @@ -import { test, expect, type Page } from '../fixtures/crash-watch'; -import * as fs from 'fs'; -import * as path from 'path'; -import { fileURLToPath } from 'url'; -import { expectSceneAlive } from '../utils/scene-health'; -import { RENDER_BUDGET, overBudget } from '../../src/components/rower3d/renderBudget'; - -/** - * Issue #224 — the runtime budgets: sustained frame rate, GPU memory, and no - * visual regression on the demo route. - * - * These read the app's own telemetry rather than timing from the test process. - * Where a budget cannot be honestly enforced in this environment the spec says - * so at the assertion, and logs the measurement either way. - */ - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const mockBluetoothPath = path.resolve(__dirname, '../mock-bluetooth.js'); - -const METERS_PER_DEGREE_LAT = 111_195; - -function windingCourse(points: number, lengthMeters: number, bends: number, amplitude = 60) { - const originLat = 51.45; - const lngScale = METERS_PER_DEGREE_LAT * Math.cos((originLat * Math.PI) / 180); - return Array.from({ length: points }, (_, i) => { - const t = i / (points - 1); - return [ - (Math.sin(t * bends * 2 * Math.PI) * amplitude) / lngScale, - originLat + (t * lengthMeters) / METERS_PER_DEGREE_LAT, - ]; - }); -} - -function geoJsonFile(name: string, coordinates: number[][]) { - return { - name: `${name}.geojson`, - mimeType: 'application/geo+json', - buffer: Buffer.from( - JSON.stringify({ - type: 'Feature', - properties: { name }, - geometry: { type: 'LineString', coordinates }, - }), - ), - }; -} - -async function waitForDeviceConnected(page: Page, deviceLabel: string) { - await page.waitForFunction( - (label) => { - const containers = Array.from(document.querySelectorAll('.bluetooth-device-container')); - const target = containers.find((c) => - c.querySelector('.device-name')?.textContent?.includes(label), - ); - return target?.querySelector('.device-status')?.textContent?.includes('Connected') ?? false; - }, - deviceLabel, - { timeout: 10_000 }, - ); -} - -async function connectHardwareAndStart(page: Page) { - await page.getByRole('button', { name: 'Connect PM5', exact: true }).click(); - await waitForDeviceConnected(page, 'Concept2 PM5'); - await page.evaluate(() => { - const containers = Array.from(document.querySelectorAll('.bluetooth-device-container')); - const hr = containers.find((c) => - c.querySelector('.device-name')?.textContent?.includes('Heart Rate Monitor'), - ); - (hr?.querySelector('button.btn-connect') as HTMLButtonElement)?.click(); - }); - await waitForDeviceConnected(page, 'Heart Rate Monitor'); - - await expect(page.locator('.btn-start-workout')).toBeEnabled({ timeout: 10_000 }); - await page.evaluate(() => { - (document.querySelector('.btn-start-workout') as HTMLButtonElement)?.click(); - }); - await expect(page.locator('.rower3d-canvas-container')).toBeVisible({ timeout: 30_000 }); - await expectSceneAlive(page, 'the budgeted route scene'); -} - -async function rowGeneratedCourse(page: Page, routeName: string, coordinates: number[][]) { - await page.addInitScript({ content: fs.readFileSync(mockBluetoothPath, 'utf8') }); - await page.goto('./'); - - await page.getByRole('button', { name: 'Routes', exact: true }).click(); - await page.getByRole('button', { name: /import a file/i }).click(); - await page.getByLabel('Route name').fill(routeName); - await page - .locator('.route-import input[type="file"]') - .setInputFiles(geoJsonFile(routeName, coordinates)); - await expect(page.locator('.route-info-overlay h2')).toContainText(routeName, { - timeout: 30_000, - }); - - await connectHardwareAndStart(page); -} - -/** - * True when the page is drawing through a software rasteriser. - * - * SwiftShader is what CI has, and it is one to two orders of magnitude slower - * than the integrated GPU the frame-rate criterion is written against. The - * measurement is still worth taking and logging; it is not worth failing on. - */ -async function isSoftwareRendered(page: Page): Promise { - return page.evaluate(() => { - try { - const gl = document.createElement('canvas').getContext('webgl2') as WebGL2RenderingContext | null; - if (!gl) return true; - const info = gl.getExtension('WEBGL_debug_renderer_info'); - const name = String(info ? gl.getParameter(info.UNMASKED_RENDERER_WEBGL) : ''); - return /swiftshader|llvmpipe|software|angle \(google/i.test(name); - } catch { - return true; - } - }); -} - -test('reports frame telemetry while rowing a 5,000-point winding route', async ({ page }) => { - const problems: string[] = []; - page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`)); - await rowGeneratedCourse(page, 'Frame Budget Course', windingCourse(5000, 8000, 12)); - - // Poll on the frame count, not the window duration: the trailing window is - // trimmed a frame at a time, so its length oscillates by one frame. - const stats = await (async () => { - const read = () => page.evaluate(() => window.__ROWER3D_FRAME_STATS ?? null); - const deadline = Date.now() + 45_000; - let seen = await read(); - while (Date.now() < deadline && (seen?.frames ?? 0) < 40) { - await page.waitForTimeout(1000); - seen = await read(); - } - return seen; - })(); - - const software = await isSoftwareRendered(page); - console.log( - `frames=${stats?.frames} over ${stats?.windowSeconds.toFixed(1)}s ` + - `fps=${stats?.fps.toFixed(1)} p50=${stats?.p50Ms.toFixed(1)}ms ` + - `p95=${stats?.p95Ms.toFixed(1)}ms max=${stats?.maxMs.toFixed(1)}ms software=${software}`, - ); - - // #224 asks for p5 frame time <= 18 ms sustained over ten seconds. That is - // not measurable here: headless Chromium barely advances this scene — about - // two frames a minute on a software rasteriser — which is why the oar-cadence - // check in virtualrow.spec.ts is wrapped in a try/catch with the same - // caveat. Any fps asserted here would describe SwiftShader, not the engine. - // The per-frame CPU budget is enforced instead in `frameHotPath.test.ts`, - // where nothing else is competing. What this spec holds is that the - // telemetry is wired to a scene that is genuinely ticking. - expect(stats, 'the scene reported no frame telemetry').toBeTruthy(); - expect(stats!.frames).toBeGreaterThanOrEqual(1); - expect(stats!.fps).toBeGreaterThan(0); - expect(Number.isFinite(stats!.p95Ms)).toBe(true); - - if (!software) { - expect(stats!.p95Ms).toBeLessThanOrEqual(18); - - // #331 — the shape of the distribution, not just its height. - // - // The scenery used to re-render ten times a second, which does not move - // the median at all: it adds a periodic 100-300 ms frame on top of an - // otherwise healthy one. A budget on p95 alone can be met by a scene that - // stutters twice a second, so what is asserted is the spread. - // - // Guarded like the budget above, and for the same reason: on SwiftShader - // this ratio describes the rasteriser rather than the engine. - expect( - stats!.p95Ms, - 'frame times are spiking periodically, which is what a re-rendering scene looks like', - ).toBeLessThanOrEqual(stats!.p50Ms * 2); - } - - expect(problems).toEqual([]); -}); - -/** - * Issue #342 — the budget is a gate, per tier, on the scene a rower gets. - * - * There was a draw-call ceiling here before, and it had to be written with a - * caveat: it read `window.__ROWER3D_RENDER_STATS`, which the scene published - * only under `IS_TEST_MODE`, and test mode is also what drops the effect - * stack, the wake, the spray and the environment probe. It recorded 186 draw - * calls where the same route outside test mode recorded 1114 — a ceiling over - * a scene nobody runs. - * - * `__VIRTUALROW_TELEMETRY` publishes the numbers without entering test mode, - * the way `__VIRTUALROW_PERFORMANCE_MODE` gave #197 a tier without one. These - * run at each tier against `RENDER_BUDGET`, which is the same table the debug - * panel shows a rower. - */ -for (const tier of ['basic', 'medium', 'extra-high'] as const) { - test(`keeps the demo route inside its ${tier} render budget`, async ({ page }) => { - await page.addInitScript((mode) => { - (window as unknown as { __VIRTUALROW_PERFORMANCE_MODE?: string }) - .__VIRTUALROW_PERFORMANCE_MODE = mode as string; - }, tier); - await page.addInitScript(() => { - (window as unknown as { __VIRTUALROW_TELEMETRY?: boolean }).__VIRTUALROW_TELEMETRY = true; - }); - await page.setViewportSize({ width: 1280, height: 800 }); - await page.goto('./'); - await page.locator('.btn-try-demo').click(); - await expect(page.locator('.activity-view')).toBeVisible({ timeout: 30_000 }); - - await expect - .poll(async () => page.evaluate(() => window.__ROWER3D_RENDER_STATS?.drawCalls ?? 0), { - timeout: 60_000, - intervals: [1000], - }) - .toBeGreaterThan(0); - - // Long enough for the progressive chunk build to have settled: a count - // taken while the route is still tessellating describes a scene mid-load. - await page.waitForTimeout(20_000); - - const stats = await page.evaluate(() => window.__ROWER3D_RENDER_STATS!); - const frames = await page.evaluate(() => window.__ROWER3D_FRAME_STATS ?? null); - const software = await isSoftwareRendered(page); - - console.log( - `${tier}: draw calls ${stats.drawCalls}/${RENDER_BUDGET[tier].drawCalls}, ` + - `triangles ${stats.triangles}/${RENDER_BUDGET[tier].triangles}, ` + - `p50 ${frames?.p50Ms?.toFixed(1) ?? 'n/a'}ms, ` + - `p95 ${frames?.p95Ms?.toFixed(1) ?? 'n/a'}/${RENDER_BUDGET[tier].p95Ms}ms, ` + - `software=${software}`, - ); - - expect(stats.performanceMode, 'the tier override did not take').toBe(tier); - - // Draw calls and triangles are what the scene asks for and do not depend - // on what is drawing it, so they are held on every machine. Frame time - // does: this scene measures a p50 of 750 ms at low and 2,000 ms at high on - // SwiftShader, so asserting it there would describe the rasteriser. - const cost = software - ? { drawCalls: stats.drawCalls, triangles: stats.triangles } - : { drawCalls: stats.drawCalls, triangles: stats.triangles, p95Ms: frames?.p95Ms }; - - expect(overBudget(tier, cost), `the ${tier} scene is over budget`).toEqual([]); - }); -} - -test('keeps a 20 km route inside its geometry memory budget', async ({ page }) => { - await rowGeneratedCourse(page, 'Twenty Kilometre Course', windingCourse(4000, 20_000, 16)); - - // Every chunk builds on idle callbacks; wait for the count to settle. - await page.waitForFunction(() => (window.__ROWER3D_MEMORY?.geometries ?? 0) > 0, undefined, { - timeout: 60_000, - }); - let previous = -1; - await expect - .poll( - async () => { - const current = await page.evaluate(() => window.__ROWER3D_MEMORY?.geometryBytes ?? 0); - const settled = current === previous; - previous = current; - return settled; - }, - { timeout: 60_000, intervals: [1500] }, - ) - .toBe(true); - - const memory = await page.evaluate(() => window.__ROWER3D_MEMORY!); - console.log( - `geometry ${memory.geometryMb.toFixed(2)} MB across ${memory.geometries} geometries, ` + - `${memory.textures} textures`, - ); - - // #224 budgets 80 MB of GPU memory for a 20 km route. Chunk culling switches - // geometry off to save draw calls but leaves the buffers resident, so this is - // the whole route's geometry, not just the visible band. - expect(memory.geometryMb).toBeLessThanOrEqual(80); -}); - -test('draws the demo route unchanged at start, middle and end', async ({ page }) => { - await page.addInitScript({ content: fs.readFileSync(mockBluetoothPath, 'utf8') }); - const errors: string[] = []; - page.on('pageerror', (error) => errors.push(error.message)); - await page.goto('./'); - - // Willowbrook River is the pre-selected demo route (issue #187, TD-1). - await expect(page.locator('.route-info-overlay h2')).toContainText('Willowbrook River'); - await connectHardwareAndStart(page); - await page.waitForFunction(() => window.__ROWER3D_ROUTE?.hasCurve === true, undefined, { - timeout: 30_000, - }); - - const route = await page.evaluate(() => window.__ROWER3D_ROUTE!); - - for (const progress of [0, 0.5, 0.95]) { - // Geometric checks rather than a pixel diff: the boat must sit on the water - // at every checkpoint, which is what "no visual regression" means here and - // does not vary with the platform's font and rasteriser. - const sample = await page.evaluate(async (target) => { - const wait = () => new Promise((r) => requestAnimationFrame(r)); - for (let i = 0; i < 5; i++) await wait(); - return { pos: window.__ROWER3D_POS, camera: window.__ROWER3D_CAMERA, target }; - }, progress); - - expect(sample.pos, `no boat telemetry at ${progress}`).toBeTruthy(); - expect(Number.isFinite(sample.pos!.x)).toBe(true); - expect(Number.isFinite(sample.pos!.z)).toBe(true); - expect(Number.isFinite(sample.pos!.angle)).toBe(true); - expect(sample.camera!.position.every((n) => Number.isFinite(n))).toBe(true); - } - - expect(route.curveLength).toBeGreaterThan(0); - expect(route.totalDistance).toBeGreaterThan(0); - - // The opt-in pixel baseline, matching the pattern already used in - // virtualrow.spec.ts: comparison only where the runner owns the baseline. - if (process.env.UPDATE_SNAPSHOTS === 'true') { - const shot = await page.locator('.rower3d-canvas-container').screenshot(); - expect(shot).toMatchSnapshot('route-checkpoints.png', { maxDiffPixelRatio: 0.01 }); - } - - expect( - errors.filter((text) => /typeerror|referenceerror|cannot read properties/i.test(text)), - ).toEqual([]); -}); diff --git a/playwright/tests/rower-stroke.spec.ts b/playwright/tests/rower-stroke.spec.ts deleted file mode 100644 index 4375c424..00000000 --- a/playwright/tests/rower-stroke.spec.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { test, expect, type Page } from '../fixtures/crash-watch'; -import { expectSceneAlive } from '../utils/scene-health'; -import { - CATCH_SWEEP_RAD, - FINISH_SWEEP_RAD, -} from '../../src/components/rower3d/strokePose'; - -/** - * Issue #273 — the rower's arms move with the oars in the GLB scull. - * - * The GLB scull renders only when `IS_TEST_MODE` is false, so this spec - * deliberately installs no BLE harness: with it, the app renders the - * *procedural* scull instead and the path a real session uses goes untested. - * That asymmetry is how the rower came to sit rigid while the oars swept, with - * every spec passing. - * - * What this can and cannot assert. Both angles come from one `strokePose`, so - * if the arm reports a value in the band that pose produces, the arms are being - * driven by the stroke rather than left at rest. What it cannot show is the - * arms *moving*: the render loop draws once and then idles (#261), so the - * stroke phase does not advance and every sample reads the same pose. Movement - * is covered by strokePose.test.ts until that is fixed. - */ - -/** The range strokePose can produce: -0.5 + armPull * 1.2, armPull in 0..1. */ -const ARM_MIN = -0.5; -const ARM_MAX = 0.7; - -/** - * How far the oar can sweep either way, from the angles the stroke is built - * from rather than from a number written down beside them. - * - * This was a flat 0.5, with a comment saying `oarSweep` is - * `sin(phase * 2pi) * 0.5`. It was, until #329 gave the stroke the catch and - * finish angles a sculler actually rows - 55 degrees towards the bow and 35 - * the other way - and then the spec was asserting the shape of a stroke the - * code had stopped having. Importing the constants means the next person to - * retune them does not have to remember this line exists. - */ -const OAR_LIMIT = Math.max(Math.abs(CATCH_SWEEP_RAD), Math.abs(FINISH_SWEEP_RAD)); - -/** - * Start the demo row with a DOM click rather than a synthesised one. - * - * `locator.click()` is bound by the config's 10s `actionTimeout`, and on a - * Windows runner drawing this scene through SwiftShader the renderer's main - * thread is busy for longer than that at a stretch: the log reads "element is - * visible, enabled and stable" and then "performing click action" until the - * timeout, because the input event is queued behind a frame. The button has - * already been waited for, so what is left is delivering the click, and - * `evaluate` delivers it against the test timeout instead of the action one. - * - * The same workaround, for the same reason, as the End Workout clicks in - * `responsive.spec.ts` and `crew-model-loading.spec.ts`. - */ -async function startDemoRow(page: Page) { - const demo = page.locator('.btn-try-demo'); - await expect(demo).toBeVisible({ timeout: 20_000 }); - await page.evaluate(() => - (document.querySelector('.btn-try-demo') as HTMLButtonElement | null)?.click(), - ); -} - -test('the GLB scull drives the rower from the same stroke as the oars', async ({ page }) => { - test.slow(); - const errors: string[] = []; - page.on('pageerror', (e) => errors.push(e.message)); - - await page.setViewportSize({ width: 1280, height: 800 }); - await page.addInitScript(() => { - (window as unknown as { __VIRTUALROW_PERFORMANCE_MODE?: string }) - .__VIRTUALROW_PERFORMANCE_MODE = 'low'; - }); - await page.goto('./'); - await startDemoRow(page); - await page.locator('.rower3d-canvas-container').waitFor({ state: 'visible', timeout: 30_000 }); - await expectSceneAlive(page, 'the stroke scene'); - - await expect - .poll(() => page.evaluate(() => window.__ROWER3D_ARM_ANGLE ?? null), { - timeout: 30_000, - message: 'the GLB scull never reported an arm angle — the rower is not being animated', - }) - .not.toBeNull(); - - const { arm, oar } = await page.evaluate(() => ({ - arm: window.__ROWER3D_ARM_ANGLE ?? null, - oar: window.__ROWER3D_OAR_ANGLE ?? null, - })); - - expect(Number.isFinite(arm), 'the arm angle is not a number').toBe(true); - expect(Number.isFinite(oar), 'the oar angle is not a number').toBe(true); - - // In the band the shared stroke produces — so the arms are posed by the - // stroke, not parked at whatever the model was authored with. - expect(arm!).toBeGreaterThanOrEqual(ARM_MIN - 0.001); - expect(arm!).toBeLessThanOrEqual(ARM_MAX + 0.001); - expect(Math.abs(oar!)).toBeLessThanOrEqual(OAR_LIMIT + 0.001); - - expect(errors.filter((e) => !/reading 'alpha'/.test(e))).toEqual([]); -}); - -/** - * Issue #328 — the camera is a rig, and the rower can point it somewhere else. - * - * It was `camera.position.set(boat - tangent * 6)` every frame, with no lag, - * no damping and one view. What is checked here is the part a unit test - * cannot reach: that the rig is actually driving the camera in a running - * scene, that it settles where it says it will, and that `V` reaches it. - */ -test('the camera rig follows the boat and answers to V', async ({ page }) => { - test.slow(); - - await page.setViewportSize({ width: 1280, height: 800 }); - await page.addInitScript(() => { - (window as unknown as { __VIRTUALROW_PERFORMANCE_MODE?: string }) - .__VIRTUALROW_PERFORMANCE_MODE = 'low'; - // `__ROWER3D_POS` is automation scaffolding and stays behind the test - // flag; the camera is measured against it, so this spec sets it. - window.__PLAYWRIGHT_TESTING = true; - }); - await page.goto('./'); - await startDemoRow(page); - await page.locator('.rower3d-canvas-container').waitFor({ state: 'visible', timeout: 30_000 }); - await expectSceneAlive(page, 'the camera scene'); - - await expect - .poll(() => page.evaluate(() => window.__ROWER3D_CAMERA?.view ?? null), { - timeout: 30_000, - message: 'the camera never reported which view it was using', - }) - .toBe('chase'); - - // Long enough for the damping to have settled: the position time constant is - // 0.35 s, so three seconds is eight of them. - await page.waitForTimeout(3_000); - - const settled = await page.evaluate(() => { - const camera = window.__ROWER3D_CAMERA; - const boat = window.__ROWER3D_POS; - if (!camera || !boat) return null; - return { - view: camera.view, - fov: camera.fov, - distance: Math.hypot(camera.position[0] - boat.x, camera.position[2] - boat.z), - height: camera.position[1] - boat.y, - }; - }); - - expect(settled, 'no camera or boat position was published').not.toBeNull(); - console.log( - `[camera] view=${settled!.view} distance=${settled!.distance.toFixed(2)}m ` + - `height=${settled!.height.toFixed(2)}m fov=${settled!.fov?.toFixed(1)}`, - ); - - // The chase view sits 7 m back. The window is wide enough for a boat that is - // still accelerating, and narrow enough to fail if the rig stopped driving. - expect(settled!.distance).toBeGreaterThan(6.5); - expect(settled!.distance).toBeLessThan(7.5); - expect(settled!.height).toBeGreaterThan(2.3); - expect(settled!.height).toBeLessThan(2.9); - - // Pressing V reaches the rig, and the button says the same thing. - // A DOM click, for the reason in `startDemoRow`, and on the one element in - // the page that is definitely busy drawing. - await page.evaluate(() => - (document.querySelector('.rower3d-canvas-container canvas') as HTMLElement | null)?.click(), - ); - await page.keyboard.press('v'); - - await expect - .poll(() => page.evaluate(() => window.__ROWER3D_CAMERA?.view ?? null), { - timeout: 15_000, - message: 'V did not change the camera view', - }) - .toBe('side'); - - await expect(page.locator('.btn-camera-view')).toContainText('side'); -}); diff --git a/src/App.tsx b/src/App.tsx deleted file mode 100644 index 782fee90..00000000 --- a/src/App.tsx +++ /dev/null @@ -1,2060 +0,0 @@ -import { useState, useEffect, useCallback, useMemo, useRef, lazy, Suspense } from 'react'; -import { RouteMap } from './components/RouteMap'; -import { BluetoothDevice } from './components/BluetoothDevice'; -import { PM5Simulator } from './components/PM5Simulator'; -import { HeartRateSimulator } from './components/HeartRateSimulator'; -import { RownativeRouteImport } from './components/RownativeRouteImport'; -import { FTMSDevice } from './components/FTMSDevice'; -import { routeService } from './lib/services/routeService'; -import { workoutService } from './lib/services/workoutService'; -import HeartRateMonitor from './components/HeartRateMonitor'; -import { heartRateBluetoothService } from './lib/services/heartRateBluetoothService'; -import { bluetoothService } from './lib/services/bluetoothService'; -import { ftmsBluetoothService } from './lib/services/ftmsBluetoothService'; -// Rower3D pulls in three, @react-three/{fiber,drei,postprocessing,rapier} (~hundreds of kB). -// Code-split it so the routes view doesn't pay the cost — the chunk only -// loads when the user actually starts a workout (currentView === 'workout'). -const Rower3D = lazy(() => import('./components/Rower3D')); -import { RouteThumbnail } from './components/RouteThumbnail'; -import { RouteOverviewMap } from './components/RouteOverviewMap'; -import { RouteDetailOverlay } from './components/RouteDetailOverlay'; -import { GuestSessionSummary } from './components/GuestSessionSummary'; -import { SessionSummary } from './components/SessionSummary'; -import { AuthButton } from './components/AuthButton'; -import { SettingsPanel } from './components/SettingsPanel'; -import { CrashReportedBanner } from './components/CrashReportedBanner'; -import { heartRateSimulator } from './lib/services/heartRateSimulatorService'; -import { pm5Simulator } from './lib/services/pm5SimulatorService'; -import { debugPreferencesStore } from './lib/services/debugPreferencesStore'; -import { useAuth } from './context/useAuth'; -import { resolveCrew, effectiveCrewPreference, CREW_URL } from './components/rower3d/crewModel'; -import { RouteLoadingBar } from './components/RouteLoadingBar'; -import { useRouteLoadProgress } from './hooks/useRouteLoadProgress'; -import { isGlbSceneryEnabled } from './components/rower3d/sceneryAssets'; -import { readTelemetry, telemetryAsText, clearTelemetry } from './lib/utils/sceneTelemetryLog'; -import { useServices } from './context/useServices'; -import { useRownativeDeepLink } from './hooks/useRownativeDeepLink'; -import { useRowerServiceEvents } from './hooks/useRowerServiceEvents'; -import { OUTLINE_ONLY_TAG } from './lib/services/routeService'; -import { externalDistanceNote, formatRouteDistanceKm, geometryProvenanceBadge } from './lib/utils/geometryProvenance'; -import { TrackParseError, detectTrackFormat } from './lib/utils/trackParsers'; -import { useGraphicsQuality } from './hooks/useGraphicsQuality'; -import { - useDevicePerformance, - resolvePerformanceModeFromProbe, -} from './hooks/useDevicePerformance'; -import { GraphicsQualityPicker } from './components/GraphicsQualityPicker'; -import { SoundPicker } from './components/SoundPicker'; -import { useRaceCues } from './hooks/useRaceCues'; -import { ConditionsPicker } from './components/ConditionsPicker'; -import { useConditions } from './hooks/useConditions'; -import { CrewPicker } from './components/CrewPicker'; -import { useCrewPreference } from './hooks/useCrewPreference'; -import { RowerAppearancePicker } from './components/RowerAppearancePicker'; -import { useRowerAppearance } from './hooks/useRowerAppearance'; -import { resolveAppearanceColors } from './components/rower3d/rowerAppearance'; -import { useRenderStats } from './hooks/useRenderStats'; -import { RENDER_BUDGET, overBudget } from './components/rower3d/renderBudget'; -import type { PerformanceMode } from './components/rower3d/constants'; -import { useCurrentWorkout } from './hooks/useCurrentWorkout'; -import { WorkoutBlockPanel } from './components/WorkoutBlockPanel'; -import { LoadTodaysWorkoutButton } from './components/LoadTodaysWorkoutButton'; -import { useWorkoutTargetController } from './hooks/useWorkoutTargetController'; -import { pickRowerTargets } from './context/useServices'; -import { RowHud } from './components/RowHud'; -import { useFullscreen } from './hooks/useFullscreen'; -import { isStrokeReading, useStartSequence } from './hooks/useStartSequence'; -import { FinishBanner, StartCallout } from './components/RaceCallouts'; -import { loadSessions, markSessionUploaded, saveCompletedSession } from './lib/services/localStorageWorkoutStore'; -import { bestPaceOnRoute } from './lib/utils/sessionSummary'; -import { formatSplit } from './lib/utils/formatters'; -import { GhostPicker, type GhostChoice } from './components/GhostPicker'; -import { - bestRowOnRoute, - gapMeters, - ghostDistanceAt, - ghostFinishSeconds, - paceGhost, - recordedGhost, -} from './components/rower3d/ghost'; -import type { WaterRoute, PM5Data, WorkoutSession, HeartRateSample } from './lib/types/index'; -import type { RouteEnrichmentData } from './lib/services/routeEnrichmentService'; -import './App.css'; - -// Session state type for workout controls -type SessionState = 'idle' | 'active' | 'paused'; - -/** - * The three screens (issue #219, R3). - * - * `routes` is the Row screen — one route, the devices and the two ways to - * start. `route-search` is where a different route is found and chosen. - * `workout` is the running session. State, not a router: the app has no - * addressable URLs beyond the rownative deep link, so adding one would buy - * nothing. - */ -type ViewMode = 'routes' | 'route-search' | 'workout'; - -/** The bundled demo route, and the fallback when no default resolves. */ -const DEMO_ROUTE_ID = '1'; - -/** How long the finish banner is up before the summary opens (#336). */ -const FINISH_BANNER_MS = 2000; - -/** Header nav. The workout screen is reached by starting a row, not by a tab. */ -const NAV_ITEMS: ReadonlyArray<{ view: ViewMode; label: string }> = [ - { view: 'routes', label: 'Row' }, - { view: 'route-search', label: 'Routes' }, -]; - -/** Extract the rownative.icu status value from route tags (e.g. "status:provisional" → "provisional"). */ -function extractRouteStatus(tags: string[] | undefined): string | undefined { - return tags?.find((t) => t.startsWith('status:'))?.replace('status:', ''); -} - -/** - * Provenance of a route's geometry, for the badge. - * - * Routes imported before this field existed carry the `outline-only` tag and - * nothing else, so fall back to reading that. - */ -function routeGeometrySource(route: WaterRoute): WaterRoute['geometrySource'] { - if (route.geometrySource) return route.geometrySource; - return route.tags?.includes(OUTLINE_ONLY_TAG) ? 'gate-chain' : undefined; -} - -function App() { - const { isAuthenticated, isLoading, login, user } = useAuth(); - const { - routeEnrichmentService, - defaultRoutePreferenceStore, - audioService, - authService, - intervalsIcuWorkoutService, - crashReportsPreferenceStore, - } = useServices(); - // In Playwright e2e tests, window.__PLAYWRIGHT_TESTING is set to true by mock-bluetooth.js. - // Guard all unauthenticated-guest behaviours on this flag so tests can exercise the full UI. - const isGuestSession = !isAuthenticated && !window.__PLAYWRIGHT_TESTING; - const showAuthFeatures = isAuthenticated || !!window.__PLAYWRIGHT_TESTING; - const [currentView, setCurrentView] = useState('routes'); - // The header nav collapses behind a hamburger below 768px (issue #219, R8). - const [isNavOpen, setIsNavOpen] = useState(false); - const [routes, setRoutes] = useState([]); - const [selectedRoute, setSelectedRoute] = useState(null); - const [isWorkoutActive, setIsWorkoutActive] = useState(false); - const [currentSession, setCurrentSession] = useState(null); - const [pm5Connected, setPM5Connected] = useState(false); - const [pm5Data, setPM5Data] = useState(null); - const [ftmsConnected, setFtmsConnected] = useState(false); - const [hrConnected, setHrConnected] = useState(false); - const [heartRateSamples, setHeartRateSamples] = useState([]); - const [activeRowerType, setActiveRowerType] = useState<'pm5' | 'ftms'>('pm5'); - // Filter state for routes - const [difficultyFilter, setDifficultyFilter] = useState<'all' | 'easy' | 'moderate' | 'hard'>('all'); - const distanceMin = 0; - const distanceMax = 100; - // Local activity timer (ms elapsed since workout started) - const [activityElapsedMs, setActivityElapsedMs] = useState(0); - // The start and the finish of a row (#336). `strokeAt` is when this session - // saw its first stroke, not the last reading the erg left from the one before. - const [strokeAt, setStrokeAt] = useState(null); - const [finish, setFinish] = useState<{ distanceMeters: number; elapsedMs: number } | null>(null); - const finishingRef = useRef(false); - const strokeSeenRef = useRef(false); - const activityTimerRef = useRef(null); - /** - * The stage, and whether it is filling the screen (#335). - * - * Declared here rather than in `RowHud` because the element that goes - * fullscreen is the stage, and on iOS Safari - which has no Fullscreen API - - * the fallback is a class on the view around it. Both are outside the HUD. - */ - const rowStageRef = useRef(null); - const rowFullscreen = useFullscreen(rowStageRef); - // Re-entrancy guards — prevent recursive session start or HR update loops - const isStartingSessionRef = useRef(false); - const isProcessingHrUpdateRef = useRef(false); - // RAF-based throttle for PM5/HR state updates — avoids stack overflow when - // Playwright CDP adds extra frames to the WS→characteristic notification path. - const pm5DataPendingRef = useRef(null); - const pm5RafScheduledRef = useRef(false); - // Debug mode state - const [debugMode, setDebugMode] = useState(false); - // Settings panel visibility (#473, D4=b). - const [settingsOpen, setSettingsOpen] = useState(false); - // Signed-in athletes don't see the demo-row CTA on the main flow (#453); - // this toggle in the debug panel puts it back for testing and dogfooding, - // persisted per browser so a reload doesn't fight a tester (FR3, FR4). - const [showDemoCtaOverride, setShowDemoCtaOverride] = useState(() => - debugPreferencesStore.getShowDemoCtaOverride(), - ); - // The river guides draw the channel centreline and its water edges (#268). - // Given their own switch so they can be turned off without losing the rest of - // the debug window (#270). - const [showRiverGuides, setShowRiverGuides] = useState(true); - // Replaces the ?glb= query parameter (#270): switching the scenery kit should - // be a control, not a URL edit. Seeded from the same default the scene uses, - // so opening the panel changes nothing by itself. - const [sceneryEnabled, setSceneryEnabled] = useState(() => isGlbSceneryEnabled()); - // Lake outlines ship off (#493 NFR7); this switch tries them on main and - // survives a reload, like the demo-row override above. - const [lakeOutlines, setLakeOutlines] = useState(() => debugPreferencesStore.getLakeOutlines()); - // Only polled while the panel is open (#232). - const renderStats = useRenderStats(debugMode); - // The telemetry log is read the same way: only while someone is looking. - const [telemetryTick, setTelemetryTick] = useState(0); - const telemetryEvents = useMemo( - () => (debugMode ? readTelemetry() : []), - // renderStats ticks about once a second while the panel is open, which is - // a good enough clock to keep this fresh without a second timer. - // eslint-disable-next-line react-hooks/exhaustive-deps - [debugMode, telemetryTick, renderStats], - ); - const telemetryCount = telemetryEvents.length; - const recentTelemetry = useMemo( - () => - telemetryEvents - .slice(-12) - .map((e) => { - const seconds = (e.at / 1000).toFixed(1).padStart(7, ' '); - return `${seconds}s ${e.kind}${e.detail ? ` ${JSON.stringify(e.detail)}` : ''}`; - }) - .join(String.fromCharCode(10)), - [telemetryEvents], - ); - - // The rower's own call on graphics quality, overruling hardware detection - // when they know better than the heuristic does (#224). - const graphics = useGraphicsQuality(); - // Device-performance probe (#454 Phase 2): runs once at app boot, before the - // Row screen mounts a canvas, and gives `graphics.quality === 'auto'` a - // measured answer rather than the renderer-string heuristic alone. User - // choices (`low`/`high`) still win; `useDevicePerformance` is the seam the - // five-tier widening in #455 will slot its new tiers into. - const devicePerformance = useDevicePerformance(); - const crew = useCrewPreference(); - const rowerAppearance = useRowerAppearance(); - const appearanceColors = useMemo( - () => resolveAppearanceColors(rowerAppearance.appearance), - [rowerAppearance.appearance], - ); - - // Demo mode: a visitor with no hardware is rowing on simulated device data. - const [isDemoMode, setIsDemoMode] = useState(false); - // Session state for the overlay UI - const [sessionState, setSessionState] = useState('idle'); - // Holds a completed unauthenticated session until the summary modal is dismissed - const [guestCompletedSession, setGuestCompletedSession] = useState(null); - // The same, for a signed-in athlete — who gets the save controls too (issue #221, R4). - const [completedSession, setCompletedSession] = useState(null); - // Demo mode is cleared when the session ends, so remember it for the summary. - const [completedSessionWasDemo, setCompletedSessionWasDemo] = useState(false); - const [routeEnrichments, setRouteEnrichments] = useState>({}); - const [routeEnrichmentLoading, setRouteEnrichmentLoading] = useState>({}); - // Route import panel state - const [isImportOpen, setIsImportOpen] = useState(false); - const [importRouteName, setImportRouteName] = useState(''); - const [importError, setImportError] = useState(null); - - // The athlete's default route, if they have set one (issue #219, R6). - const [defaultRouteId, setDefaultRouteId] = useState(null); - // #505 Phase 2 (D8-c): the modal that opens when a map pin is clicked. - const [routeDetailOpen, setRouteDetailOpen] = useState(null); - // #505 Phase 2e (D6-c): the Routes screen is map-primary and the catalogue - // slides in from the side. Default open on ≥900px (laptop and up), closed - // on narrower screens where the drawer would cover most of the map. - const [routesDrawerOpen, setRoutesDrawerOpen] = useState(() => { - if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false; - return window.matchMedia('(min-width: 900px)').matches; - }); - - /** - * Decide which route the app opens on, and which one the star is lit for. - * - * Precedence is deep link → stored default → the bundled demo route. The - * selection is guarded to happen once: this effect depends on `routes`, so - * without the guard every import re-ran it and snapped the selection back, - * discarding the route the user had just loaded. `handleRouteImported` and - * `handleRouteSelect` set the same guard, which is what lets a rownative deep - * link beat a stored default (AC6.10). - * - * The star state is *not* guarded — it has to follow a later sign-in or - * sign-out. Both jobs read `resolveDefaultRouteId` from this one place so a - * stale id it clears (AC6.4) can never leave the star lit for a route that no - * longer exists. - */ - const hasPreselectedRef = useRef(false); - useEffect(() => { - if (routes.length === 0) return; - - const resolved = defaultRoutePreferenceStore.resolveDefaultRouteId( - user?.id ?? null, - routes.map((r) => r.id), - ); - setDefaultRouteId(resolved); - - if (hasPreselectedRef.current || isLoading) return; - setSelectedRoute( - (resolved ? routes.find((r) => r.id === resolved) : undefined) - ?? routes.find((r) => r.id === DEMO_ROUTE_ID) - ?? routes[0], - ); - hasPreselectedRef.current = true; - }, [defaultRoutePreferenceStore, isLoading, routes, user]); - - /** Star / un-star a route as this athlete's default (AC6.5, AC6.6). */ - const handleToggleDefaultRoute = useCallback((routeId: string) => { - if (!user) return; - if (defaultRouteId === routeId) { - defaultRoutePreferenceStore.clearDefaultRouteId(user.id); - setDefaultRouteId(null); - return; - } - defaultRoutePreferenceStore.setDefaultRouteId(user.id, routeId); - setDefaultRouteId(routeId); - }, [defaultRoutePreferenceStore, defaultRouteId, user]); - - // Auto-start/stop the HR simulator for unauthenticated users - // Skip in Playwright test mode so tests can control HR connection state explicitly. - useEffect(() => { - if (isGuestSession) { - heartRateSimulator.start(130); - } else { - heartRateSimulator.stop(); - } - return () => { - heartRateSimulator.stop(); - }; - }, [isGuestSession]); - - // Start/stop activity timer when workout state changes - useEffect(() => { - if (isWorkoutActive) { - const startTime = Date.now(); - activityTimerRef.current = window.setInterval(() => { - setActivityElapsedMs(Date.now() - startTime); - }, 250); - } else { - if (activityTimerRef.current !== null) { - clearInterval(activityTimerRef.current); - activityTimerRef.current = null; - } - setActivityElapsedMs(0); - } - return () => { - if (activityTimerRef.current !== null) { - clearInterval(activityTimerRef.current); - activityTimerRef.current = null; - } - }; - }, [isWorkoutActive]); - - useEffect(() => { - setRoutes(routeService.getAllRoutes()); - }, []); - - useEffect(() => { - if (!selectedRoute) return; - - let cancelled = false; - const cached = routeEnrichmentService.readCached(selectedRoute.id); - const cachedData = cached.data; - if (cachedData) { - setRouteEnrichments((current) => ({ - ...current, - [selectedRoute.id]: cachedData, - })); - } - - if (cachedData && !cached.stale) { - setRouteEnrichmentLoading((current) => ({ - ...current, - [selectedRoute.id]: false, - })); - return; - } - - setRouteEnrichmentLoading((current) => ({ - ...current, - [selectedRoute.id]: true, - })); - - void routeEnrichmentService - .enrichRoute(selectedRoute) - .then((enrichment) => { - if (cancelled) return; - setRouteEnrichments((current) => ({ - ...current, - [selectedRoute.id]: enrichment, - })); - }) - .finally(() => { - if (cancelled) return; - setRouteEnrichmentLoading((current) => ({ - ...current, - [selectedRoute.id]: false, - })); - }); - - return () => { - cancelled = true; - }; - }, [selectedRoute, routeEnrichmentService]); - - const activeRowerLabel = useMemo(() => ( - activeRowerType === 'pm5' ? 'PM5' : 'FTMS' - ), [activeRowerType]); - const selectedRowerConnected = useMemo(() => ( - activeRowerType === 'pm5' ? pm5Connected : ftmsConnected - ), [activeRowerType, ftmsConnected, pm5Connected]); - - // The structured workout, if one is loaded (issue #445). With none, every - // flow below is exactly as it was — a free row (#67 §2). The hook's - // callbacks are stable; the object around them is not, so pull out the two - // the memoised handlers below close over. - const currentWorkout = useCurrentWorkout(selectedRowerConnected); - const { stop: stopStructuredWorkout, tick: tickStructuredWorkout } = currentWorkout; - // The rower-target port matches the current source. Simulator by default, - // switched to the stubbed FTMS / PM5 implementations when a real erg is - // connected (#445 D3(a)). - const rowerTargets = useMemo(() => { - if (activeRowerType === 'ftms' && ftmsConnected) return pickRowerTargets('ftms'); - if (activeRowerType === 'pm5' && pm5Connected) return pickRowerTargets('pm5'); - return pickRowerTargets('simulator'); - }, [activeRowerType, ftmsConnected, pm5Connected]); - useWorkoutTargetController({ - segment: currentWorkout.progress?.currentSegment, - currentPaceSecondsPer500: pm5Data?.pace, - rowerTargets, - active: isWorkoutActive && currentView === 'workout' && currentWorkout.isRunning, - }); - - // Listen to programmatic session events from the workoutService to update UI state - useEffect(() => { - const onStartup = (e: Event) => { - if (!(e instanceof CustomEvent)) return; - const session = e.detail as WorkoutSession; - setCurrentSession(session); - setIsWorkoutActive(true); - setCurrentView('workout'); - if (session && session.routeId) { - const r = routeService.getRouteById(session.routeId); - if (r) setSelectedRoute(r); - } - }; - const onEnd = () => { - finishingRef.current = false; - setFinish(null); - strokeSeenRef.current = false; - setStrokeAt(null); - setIsWorkoutActive(false); - setCurrentSession(null); - setCurrentView('routes'); - }; - if (typeof window === 'undefined') return; - window.addEventListener('virtualrow:sessionStarted', onStartup as EventListener); - window.addEventListener('virtualrow:sessionEnded', onEnd as EventListener); - return () => { - window.removeEventListener('virtualrow:sessionStarted', onStartup as EventListener); - window.removeEventListener('virtualrow:sessionEnded', onEnd as EventListener); - }; - }, []); - - const handleRouteSelect = useCallback((route: WaterRoute) => { - setSelectedRoute(route); - // An explicit choice outranks the stored default for the rest of the visit. - hasPreselectedRef.current = true; - setCurrentView('routes'); - }, []); - - const selectedRouteEnrichment = selectedRoute ? routeEnrichments[selectedRoute.id] ?? null : null; - const selectedRouteEnrichmentLoading = selectedRoute ? !!routeEnrichmentLoading[selectedRoute.id] : false; - - // What the rower is waiting for between choosing a route and rowing it - // (#318). Watched off the performance timeline, so nothing here reaches into - // the scene's Suspense boundary - see useRouteLoadProgress for why that - // matters. CREW_URL comes from crewModel.ts, which is free of three and drei - // precisely so App can name the file without pulling the 3D bundle in. - const routeLoadProgress = useRouteLoadProgress( - currentView === 'workout' && isWorkoutActive, - CREW_URL[resolveCrew(user?.gender, effectiveCrewPreference(user?.gender, crew.preference))], - ); - - const handleStartWorkout = () => { - // Guard against double-start (rapid clicks, re-entrant calls, or already-active session) - if (isStartingSessionRef.current || isWorkoutActive || workoutService.getCurrentSession()) return; - isStartingSessionRef.current = true; - try { - if (!selectedRoute || !selectedRowerConnected || !hrConnected) { - alert(`Please connect your ${activeRowerLabel} and Heart Rate Monitor, and select a route`); - return; - } - - const session = workoutService.startSession( - selectedRoute.id, - selectedRoute.name, - undefined, - activeRowerType, - hrConnected, - isGuestSession, - selectedRoute.coordinates, - ); - // A loaded structured workout runs over the top of the session. If it - // cannot start, the row still goes ahead as a free row (#67 §2, F.3). - currentWorkout.start(); - - setCurrentSession(session); - setIsWorkoutActive(true); - setSessionState('active'); - setCurrentView('workout'); - } finally { - isStartingSessionRef.current = false; - } - }; - - /** - * Start a demo row on simulated devices. - * - * Aimed at anyone with no rowing machine to hand: one control connects - * nothing by hand, starts the rower and heart-rate simulators, and drops - * straight into the session so the engine can be judged without buying - * hardware. Offered signed-in as well as signed-out (issue #219, AC7.2) — the - * session is still flagged as a demo and still not recorded as a real workout. - */ - // The demo CTA is offered to guests (issue #219 AC7.2), and to Playwright - // regardless of sign-in, so existing demo specs stay green (#453 D4, FR7). - // Signed-in athletes only see it when the debug-panel override is on - // (FR1, FR3). `handleStartDemo` guards the same gate so a future deep link - // or shortcut cannot bypass it (FR5). - const demoCtaVisible = - !isAuthenticated || - !user || - !!window.__PLAYWRIGHT_TESTING || - showDemoCtaOverride; - - const handleStartDemo = useCallback(() => { - if (isStartingSessionRef.current || isWorkoutActive || workoutService.getCurrentSession()) return; - if (!selectedRoute) return; - if (!demoCtaVisible) return; - isStartingSessionRef.current = true; - try { - setIsDemoMode(true); - pm5Simulator.updateSettings({ pace: 120, cadence: 24, heartRate: 130, power: 150, isRowing: true }); - pm5Simulator.start(); - if (!heartRateSimulator.isRunning()) heartRateSimulator.start(130); - setPM5Connected(true); - setActiveRowerType('pm5'); - - const session = workoutService.startSession( - selectedRoute.id, - selectedRoute.name, - undefined, - 'pm5', - true, - isGuestSession, - selectedRoute.coordinates, - ); - setCurrentSession(session); - setIsWorkoutActive(true); - setSessionState('active'); - setCurrentView('workout'); - } finally { - isStartingSessionRef.current = false; - } - }, [demoCtaVisible, isGuestSession, isWorkoutActive, selectedRoute]); - - const stopDemoDevices = useCallback(() => { - pm5Simulator.stop(); - setIsDemoMode(false); - setPM5Connected(false); - setPM5Data(null); - }, []); - - const handleEndWorkout = useCallback(() => { - finishingRef.current = false; - setFinish(null); - strokeSeenRef.current = false; - setStrokeAt(null); - const completed = workoutService.endSession(); - stopStructuredWorkout(); - setIsWorkoutActive(false); - setCurrentSession(null); - setSessionState('idle'); - setCompletedSessionWasDemo(isDemoMode); - if (isDemoMode) stopDemoDevices(); - - if (!completed) { - setCurrentView('routes'); - return; - } - - if (isGuestSession) { - // Show summary modal for unauthenticated sessions - setGuestCompletedSession(completed); - return; - } - - // The row is kept locally before any upload is attempted, so a row the - // athlete declines to upload — or that fails to — still survives a reload - // (issue #221, AC6.1). - if (user) saveCompletedSession(user.id, completed, { isDemo: isDemoMode }); - setCompletedSession(completed); - }, [isGuestSession, isDemoMode, stopDemoDevices, stopStructuredWorkout, user]); - - /** - * What this row is being rowed against (#338), chosen before it starts. - * - * Rowing alone is the default, which is what the app did before there was - * anything to race. - */ - const [ghostChoice, setGhostChoice] = useState({ kind: 'none' }); - - /** The quickest row this browser kept on the selected route, if any. */ - const bestRowHere = useMemo(() => { - if (!user || !selectedRoute) return null; - return bestRowOnRoute(loadSessions(user.id), selectedRoute.id); - }, [user, selectedRoute]); - - /** - * The ghost itself: a recorded row, a constant pace, or nothing. - * - * Falls back to rowing alone rather than to an empty boat if the best it was - * told to race has gone - a route changed under the picker, say. - */ - const ghostSource = useMemo(() => { - if (ghostChoice.kind === 'pace') return paceGhost(ghostChoice.paceSPer500); - if (ghostChoice.kind === 'best' && bestRowHere) return recordedGhost(bestRowHere.samples); - return null; - }, [ghostChoice, bestRowHere]); - - /** What is being chased, in the words the HUD and the summary both use. */ - const ghostLabel = useMemo( - () => - ghostChoice.kind === 'pace' - ? `a ${formatSplit(ghostChoice.paceSPer500)} pace` - : 'your best', - [ghostChoice], - ); - - /** - * The row's clock, for the scene. - * - * A ref: the ghost is placed every frame, and the erg reports elapsed time - * about once a second. Kept current during render because it is read by a - * frame loop rather than by React. - */ - const elapsedSecondsRef = useRef(0); - - // This athlete's best on the route before this row, from the rows this - // browser kept for them (#337). None for a demo row, which is not theirs to - // compare, or without an athlete to ask about. - const completedSessionBest = useMemo(() => { - if (!completedSession || !user || completedSessionWasDemo) return undefined; - return bestPaceOnRoute(loadSessions(user.id), completedSession); - }, [completedSession, completedSessionWasDemo, user]); - - /** - * How the race came out (#338). - * - * The ghost's time to the distance the row actually covered, so a row cut - * short is compared at the point it stopped rather than at a finish neither - * boat reached. - */ - const completedGhost = useMemo(() => { - if (!completedSession || !ghostSource) return null; - return { - seconds: ghostFinishSeconds(ghostSource, completedSession.distance), - label: ghostLabel, - }; - }, [completedSession, ghostSource, ghostLabel]); - - const handleSessionSaved = useCallback((activityId: string) => { - if (user && completedSession) markSessionUploaded(user.id, completedSession.id, activityId); - }, [completedSession, user]); - - const handleSessionDone = useCallback(() => { - setCompletedSession(null); - setCurrentView('routes'); - }, []); - - const handleGuestRowAgain = useCallback(() => { - setGuestCompletedSession(null); - setCurrentView('routes'); - }, []); - - const handleGuestExit = useCallback(() => { - setGuestCompletedSession(null); - setCurrentView('routes'); - }, []); - - const handlePauseWorkout = useCallback(() => { - setSessionState('paused'); - workoutService.pauseSession(); - }, []); - - const handleResumeWorkout = useCallback(() => { - setSessionState('active'); - workoutService.resumeSession(); - }, []); - - const handleResetWorkout = useCallback(() => { - // Reset metrics but keep session - setActivityElapsedMs(0); - // Note: Full reset logic would need to clear workoutService data - }, []); - - // Get filtered routes based on current filter settings - const filteredRoutes = useMemo(() => { - let filtered = routes; - if (difficultyFilter !== 'all') { - filtered = filtered.filter(r => r.difficulty === difficultyFilter); - } - return filtered.filter(r => r.distance >= distanceMin && r.distance <= distanceMax); - }, [routes, difficultyFilter, distanceMin, distanceMax]); - - // #505 Phase 2 (D7-c): the overview map shows every pin and dims the ones - // filtered out, so the user can see what's hidden without clearing the - // filter. The list below is still reduced to `filteredRoutes`. - const dimmedRouteIds = useMemo(() => { - const kept = new Set(filteredRoutes.map((r) => r.id)); - const dimmed = new Set(); - for (const r of routes) { - if (!kept.has(r.id)) dimmed.add(r.id); - } - return dimmed; - }, [routes, filteredRoutes]); - - const handlePM5Data = useCallback((data: PM5Data) => { - // Always update the service synchronously — no React render triggered here. - workoutService.updateSessionWithPM5Data(data); - - // The first stroke starts the countdown (#336) from a timer rather than - // from the frame below: a software renderer draws about a frame a second, - // and a count that waits for one starts a second late. A timer still runs - // it from a clean call stack, which is what the frame is for. - if (isWorkoutActive && !strokeSeenRef.current && isStrokeReading(data)) { - strokeSeenRef.current = true; - // Stamped now, so the count runs from the drive however late the page - // renders it. Unless the row ended in between, which cleared the ref. - const at = Date.now(); - window.setTimeout(() => { - if (strokeSeenRef.current) setStrokeAt(at); - }, 0); - } - - // Defer React state updates to a requestAnimationFrame so they run from a - // clean call-stack instead of deep inside the WS→CDP notification chain. - // This prevents "Maximum call stack size exceeded" overflows during testing. - pm5DataPendingRef.current = data; - if (!pm5RafScheduledRef.current) { - pm5RafScheduledRef.current = true; - requestAnimationFrame(() => { - pm5RafScheduledRef.current = false; - const latest = pm5DataPendingRef.current; - if (!latest) return; - - setPM5Data(latest); - - // Advance the structured workout, if one is running. No-op otherwise, - // so a free row is untouched (#67 §4, §5). - tickStructuredWorkout(latest); - - if (isWorkoutActive) { - if (latest.heartRate) { - const updated = workoutService.getCurrentSession(); - setHeartRateSamples(updated?.heartRateSamples ? [...updated.heartRateSamples] : []); - } - // Read latest session data directly from the service so mutations (distance, - // duration, calories) are always reflected — spreading a stale React state - // copy would freeze distance at whatever value it had on the first spread. - const latestSession = workoutService.getCurrentSession(); - setCurrentSession(latestSession ? { ...latestSession } : null); - - // Finish when distance reaches route length: the banner, then the - // summary (#336). Skipped in the Playwright harness, whose rows would - // otherwise end under specs that are not about the finish, unless a - // spec asks for it. - if ( - selectedRoute && - typeof window !== 'undefined' && - (!window.__PLAYWRIGHT_TESTING || window.__VIRTUALROW_AUTO_FINISH) - ) { - const routeDistanceMeters = selectedRoute.distance * 1000; - const completionThreshold = routeDistanceMeters * 0.995; - if (latest.distance >= completionThreshold && routeDistanceMeters > 0 && !finishingRef.current) { - finishingRef.current = true; - setFinish({ - distanceMeters: routeDistanceMeters, - elapsedMs: latest.elapsedTime - ? latest.elapsedTime * 1000 - : (latestSession?.duration ?? 0) * 1000, - }); - } - } - } - }); - } - }, [isWorkoutActive, selectedRoute, tickStructuredWorkout]); - - // The finish banner stays up for FINISH_BANNER_MS, then the summary opens. - // Read through a ref so a re-created handler does not restart the wait. - const handleEndWorkoutRef = useRef(handleEndWorkout); - useEffect(() => { - handleEndWorkoutRef.current = handleEndWorkout; - }, [handleEndWorkout]); - useEffect(() => { - if (!finish) return undefined; - const id = window.setTimeout(() => handleEndWorkoutRef.current(), FINISH_BANNER_MS); - return () => window.clearTimeout(id); - }, [finish]); - - /** - * The light this row is rowed in (#346), remembered between rows. `auto` - * matches the rower's own clock, which is the default. - */ - const conditions = useConditions(); - - const startSequence = useStartSequence({ - active: isWorkoutActive, - strokeAt, - autoStart: isDemoMode, - }); - - /* - * The count, "Row!", and the line (#339). Here rather than in the scene: - * these come from the start sequence and the finish, both of which live on - * this side of the canvas. - */ - useRaceCues(audioService, startSequence.phase, startSequence.countdown, finish !== null); - - // While the demo is running, simulated rower data flows through exactly the - // same pipeline as a real PM5, so nothing downstream needs to know it is fake. - useEffect(() => { - if (!isDemoMode) return; - pm5Simulator.addListener(handlePM5Data); - return () => pm5Simulator.removeListener(handlePM5Data); - }, [isDemoMode, handlePM5Data]); - - // Expose PM5 data on window for E2E tests to inspect cadence / pace - useEffect(() => { - if (typeof window !== 'undefined') { - window.__PM5_DATA = pm5Data ?? undefined; - } - }, [pm5Data]); - - const handleHeartRateSample = useCallback((_bpm: number) => { - if (isWorkoutActive) { - requestAnimationFrame(() => { - const session = workoutService.getCurrentSession(); - setHeartRateSamples(session?.heartRateSamples ? [...session.heartRateSamples] : []); - setCurrentSession(session ? { ...session } : null); - }); - } - }, [isWorkoutActive]); - - // Persistent HR listener — HeartRateMonitor is only mounted on the 'routes' view, so - // its listener is cleaned up when the workout starts and the view switches. This effect - // stays alive for the lifetime of the App and ensures HR samples are written to the - // workout session regardless of which view is active. - useEffect(() => { - const onHR = ({ bpm }: { bpm: number }) => { - // Always update the service synchronously; defer state updates to RAF. - if (isProcessingHrUpdateRef.current) return; - isProcessingHrUpdateRef.current = true; - try { - workoutService.updateSessionHeartRate(bpm); - } finally { - isProcessingHrUpdateRef.current = false; - } - requestAnimationFrame(() => { - const session = workoutService.getCurrentSession(); - setHeartRateSamples(session?.heartRateSamples ? [...session.heartRateSamples] : []); - }); - }; - heartRateBluetoothService.on('heartRate', onHR); - return () => heartRateBluetoothService.off('heartRate', onHR); - }, []); - - // Track HR monitor connectivity for the lifetime of the app - useEffect(() => { - const onConnected = () => requestAnimationFrame(() => setHrConnected(true)); - const onDisconnected = () => requestAnimationFrame(() => setHrConnected(false)); - heartRateBluetoothService.on('connected', onConnected); - heartRateBluetoothService.on('disconnected', onDisconnected); - return () => { - heartRateBluetoothService.off('connected', onConnected); - heartRateBluetoothService.off('disconnected', onDisconnected); - }; - }, []); - - const handleHrConnected = useCallback(() => setHrConnected(true), []); - const handleHrDisconnected = useCallback(() => setHrConnected(false), []); - - // Connection state updates are deferred to RAF for the same reason as the PM5 data - // above: these now run directly on the BLE notification stack. - const handlePM5Connected = useCallback(() => { - requestAnimationFrame(() => setPM5Connected(true)); - }, []); - - const handlePM5Disconnected = useCallback(() => { - requestAnimationFrame(() => setPM5Connected(false)); - }, []); - - const handleFtmsConnected = useCallback(() => { - requestAnimationFrame(() => setFtmsConnected(true)); - }, []); - - const handleFtmsDisconnected = useCallback(() => { - requestAnimationFrame(() => setFtmsConnected(false)); - }, []); - - // FTMS data arrives in the same PM5Data shape; merge into shared rower data state - const handleFtmsData = useCallback((data: PM5Data) => { - // Re-use the PM5 data pipeline so all workout tracking works regardless of device type - handlePM5Data(data); - }, [handlePM5Data]); - - // Only the selected rower drives the session. Both services stay subscribed for the - // lifetime of the app, so without this a PM5 left connected while FTMS is selected - // (or vice versa) would interleave a second device's frames into the same session. - const activeRowerTypeRef = useRef(activeRowerType); - useEffect(() => { - activeRowerTypeRef.current = activeRowerType; - }, [activeRowerType]); - - const handleSelectedPM5Data = useCallback((data: PM5Data) => { - if (activeRowerTypeRef.current !== 'pm5') return; - handlePM5Data(data); - }, [handlePM5Data]); - - const handleSelectedFtmsData = useCallback((data: PM5Data) => { - if (activeRowerTypeRef.current !== 'ftms') return; - handleFtmsData(data); - }, [handleFtmsData]); - - // Persistent rower listeners. BluetoothDevice / FTMSDevice render only on the routes - // view, so subscribing from there stopped every frame the instant a workout started - // and the view switched to 'workout' — the session then recorded 0 m for the whole - // row. These subscriptions outlive the view switch. - useRowerServiceEvents(bluetoothService, { - onData: handleSelectedPM5Data, - onConnected: handlePM5Connected, - onDisconnected: handlePM5Disconnected, - }); - - useRowerServiceEvents(ftmsBluetoothService, { - onData: handleSelectedFtmsData, - onConnected: handleFtmsConnected, - onDisconnected: handleFtmsDisconnected, - }); - - // Playwright reads this to compare the card's distance with the engine's own - // total; it is never set in a normal session. - useEffect(() => { - if (!window.__PLAYWRIGHT_TESTING) return; - window.__SELECTED_ROUTE = selectedRoute - ? { - id: selectedRoute.id, - name: selectedRoute.name, - distanceKm: selectedRoute.distance, - geometrySource: selectedRoute.geometrySource, - externalDistanceMeters: selectedRoute.externalDistanceMeters, - } - : undefined; - }, [selectedRoute]); - - const handleRouteImported = useCallback((route: WaterRoute) => { - setRoutes(routeService.getAllRoutes()); - setSelectedRoute(route); - // A deep-linked or freshly imported course outranks the stored default - // for this page load (issue #219, AC6.10). - hasPreselectedRef.current = true; - setCurrentView('routes'); - }, []); - - // Deep link: if the app was opened with ?rownativeCourseId=, load that - // course and select it. Held until auth resolves so a shared link survives a - // sign-in round trip. - const { status: handoffStatus, dismiss: dismissHandoff } = useRownativeDeepLink({ - onRouteLoaded: handleRouteImported, - isReady: !isLoading, - }); - - /** - * Import a route file, dispatching on its type. - * - * This used to parse whatever was chosen as JSON, so a .gpx or .kml drop — - * both offered by the file picker — silently did nothing (issue #194 F7). - */ - const handleRouteFileImport = useCallback((e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - setImportError(null); - - const reader = new FileReader(); - reader.onload = (ev) => { - const text = ev.target?.result as string; - const fallbackName = importRouteName.trim() || file.name.replace(/\.[^.]+$/, ''); - const format = detectTrackFormat(file.name); - - try { - if (!format) { - throw new TrackParseError( - `${file.name} is not a route file VirtualRow can read. Use a .gpx, .kml or .geojson file.`, - ); - } - - let imported: WaterRoute | undefined; - if (format === 'gpx') { - imported = routeService.importRouteFromGPX(text, { - name: fallbackName, - difficulty: 'moderate', - location: 'Imported', - tags: ['imported', 'gpx'], - }); - } else if (format === 'kml') { - const result = routeService.importRouteFromKML(text, { - name: importRouteName.trim() || undefined, - difficulty: 'moderate', - tags: ['imported', 'kml'], - }); - if (result.status === 'error') throw new TrackParseError(result.error); - if (result.status === 'selectionRequired') { - imported = routeService.finalizeKMLImport(result.candidates[0], { - name: importRouteName.trim() || undefined, - difficulty: 'moderate', - tags: ['imported', 'kml'], - }); - } else { - imported = result.route; - } - } else { - const parsed = JSON.parse(text) as { properties?: { name?: string; country?: string } }; - imported = routeService.importRouteFromGeoJSON(text, { - name: importRouteName.trim() || parsed?.properties?.name || fallbackName, - difficulty: 'moderate', - location: parsed?.properties?.country ?? 'Imported', - tags: ['imported', 'geojson'], - }); - } - - if (!imported) { - throw new TrackParseError(`${file.name} has no route line with at least 2 points.`); - } - - handleRouteImported(imported); - setIsImportOpen(false); - setImportRouteName(''); - e.target.value = ''; - } catch (error) { - setImportError( - error instanceof Error ? error.message : `${file.name} could not be imported.`, - ); - } - }; - reader.readAsText(file); - }, [importRouteName, handleRouteImported]); - const latestHeartRate = useMemo(() => ( - heartRateSamples.length > 0 - ? heartRateSamples[heartRateSamples.length - 1].bpm - : (pm5Data?.heartRate ?? null) - ), [heartRateSamples, pm5Data]); - /** - * Sound, off until a rower asks for it (#339). - * - * A browser will not start an AudioContext without a gesture, so this is not - * a preference the app can honour quietly at load - the switch is the - * gesture. The volume is kept here so it survives switching the sound off - * and on again. - */ - const [soundEnabled, setSoundEnabled] = useState(false); - const [soundVolume, setSoundVolume] = useState(() => audioService.getVolume()); - - const handleSoundToggle = useCallback( - (enabled: boolean) => { - setSoundEnabled(enabled); - void (enabled ? audioService.enable() : audioService.disable()); - }, - [audioService], - ); - - const handleSoundVolume = useCallback( - (volume: number) => { - setSoundVolume(volume); - audioService.setVolume(volume); - }, - [audioService], - ); - - /** - * The water bed runs with the row, not with the page. - * - * A loop that started when the sound was switched on would play under the - * route list and the settings panel, which are not on the water. - */ - useEffect(() => { - if (!soundEnabled || !isWorkoutActive) { - audioService.stopWater(); - return; - } - audioService.startWater(); - return () => audioService.stopWater(); - }, [audioService, soundEnabled, isWorkoutActive]); - - const workoutElapsedTimeMs = useMemo(() => ( - pm5Data?.elapsedTime ? pm5Data.elapsedTime * 1000 : activityElapsedMs - ), [activityElapsedMs, pm5Data]); - elapsedSecondsRef.current = workoutElapsedTimeMs / 1000; - - /** - * How hard the strokes are landing, for how loud they sound (#339). - * - * From the power, because that is what the stroke actually cost: a light - * paddle and a racing catch are the same sound at different volumes, and a - * constant would make every stroke sound like the same stroke. Floored, so a - * quiet row is quiet rather than silent. - */ - const strokeIntensity = useMemo(() => { - const watts = pm5Data?.power ?? 0; - return Math.min(1, Math.max(0.2, watts / 300)); - }, [pm5Data?.power]); - - /** - * The gap to the ghost, for the HUD (#338). - * - * Null until the row has actually started: two boats on the start line have - * no gap worth reading, and "level" before the first stroke is noise. - */ - const ghostGap = useMemo(() => { - if (!ghostSource) return null; - const rowerMeters = pm5Data?.distance ?? 0; - const started = workoutElapsedTimeMs > 0 && rowerMeters > 0; - return { - gapMeters: started - ? gapMeters(rowerMeters, ghostDistanceAt(ghostSource, workoutElapsedTimeMs / 1000)) - : null, - label: ghostLabel, - }; - }, [ghostSource, ghostLabel, pm5Data?.distance, workoutElapsedTimeMs]); - const activityProgressPercent = useMemo(() => ( - pm5Data && selectedRoute - ? Math.min(100, (pm5Data.distance / 1000) / selectedRoute.distance * 100) - : 0 - ), [pm5Data, selectedRoute]); - - return ( -
- {guestCompletedSession && ( - - )} - - setSettingsOpen(false)} - crashReportsPreferenceStore={crashReportsPreferenceStore} - intervalsIcuAthleteId={user?.id ?? null} - /> - - setSettingsOpen(true)} /> - - {completedSession && ( - - )} - -
-
-

VirtualRow

- - {/* The nav stands down during a workout, as the device bar does - (issue #219, AC8.4). */} - {!isWorkoutActive && ( - <> - - - - )} - -
- {!isWorkoutActive && ( - - )} - -
-
- {!isAuthenticated && ( -

- 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 && ( -
-
- -
-
- {handoffStatus.kind !== 'idle' && handoffStatus.kind !== 'loaded' && ( -
- {handoffStatus.kind === 'loading' - ? `Loading rownative course ${handoffStatus.courseId}...` - : handoffStatus.message} - {handoffStatus.kind === 'error' && ( - - )} -
- )} - -
-
-

{selectedRoute.name}

-

📍 {selectedRoute.location}

-
- -
- - 📏 {formatRouteDistanceKm(selectedRoute.distance)} - - {externalDistanceNote(selectedRoute) && ( - - {externalDistanceNote(selectedRoute)} - - )} - - ⏱️ {selectedRoute.estimatedTime} min - - - {selectedRoute.difficulty} - - {(() => { - const badge = geometryProvenanceBadge(routeGeometrySource(selectedRoute)); - return badge && ( - - {badge.label} - - ); - })()} -
- - {selectedRouteEnrichmentLoading && ( -

Loading route data…

- )} - - {/* 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} -

- )} - - - - {/* The single door to route discovery (issue #219, AC2.2). */} - - - {/* Offered to guests always (issue #219 AC7.2) and to - signed-in athletes only via the debug-panel override - (#453 FR1, FR3, FR7). */} - {demoCtaVisible && ( -
- -

- 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 ? ( - - ) : ( -

- Rower matches your intervals.icu profile ·{' '} - - Update on intervals.icu ↗ - -

- )} - {/* 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' && ( -
-
-
-

Routes

- - -
- - {/* #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. */} -
-
- setRouteDetailOpen(route)} - /> -
-
- - setRouteDetailOpen(null)} - onStartRow={() => { - if (routeDetailOpen) { - handleRouteSelect(routeDetailOpen); - setRouteDetailOpen(null); - } - }} - /> - - -
-
-

Add a rownative course

- {showAuthFeatures ? ( - <> -

- Paste a course ID or a rownative.icu link to add it straight away, or - search the catalogue by name. -

- - - ) : ( -

- Sign in with intervals.icu to import courses from rownative.icu. -

- )} -
- -
-
-

My routes

-
-
- {(['all', 'easy', 'moderate', 'hard'] as const).map((d) => ( - - ))} -
-
-
- {filteredRoutes.map((route) => { - const rownativeStatus = extractRouteStatus(route.tags); - const isDefault = defaultRouteId === route.id; - return ( -
handleRouteSelect(route)} - > -
-

{route.name}

-
- {isDefault && ( - Default - )} - - {route.difficulty} - - {route.source === 'rownative' && ( - rownative.icu - )} - {rownativeStatus && ( - - {rownativeStatus.charAt(0).toUpperCase() + rownativeStatus.slice(1)} - - )} - {(() => { - const badge = geometryProvenanceBadge(routeGeometrySource(route)); - return badge && ( - - {badge.label} - - ); - })()} -
-
-

{route.location}

-
- {formatRouteDistanceKm(route.distance)} - • - {route.estimatedTime} min - {externalDistanceNote(route) && ( - <> - • - {externalDistanceNote(route)} - - )} -
- {route.coordinates && route.coordinates.length >= 2 && ( - - )} - {routeEnrichmentLoading[route.id] && ( -

Loading route data…

- )} - {isAuthenticated && ( - - )} -
- ); - })} -
- - {showAuthFeatures && ( -
- - {isImportOpen && ( -
- - setImportRouteName(e.target.value)} - /> - - {importError && ( -

⚠ {importError}

- )} -
- )} -
- )} -
-
-
- )} - - {currentView === 'workout' && isWorkoutActive && currentSession && ( -
-
-
- {/* 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

- -
- - {/* 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. */} - - - - - - - - - - - - - - - - - - - - - - ) : ( - - )} - {renderStats.context && ( - <> - - - - - - - {renderStats.context.fallbackReason && ( - - )} - - )} - -
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 ?? '—'}
Drawing with:{renderStats.stats.drawing}
Backend detected:{renderStats.stats.backend}
Quality:{renderStats.stats.performanceMode}
Drawing:{renderStats.stalled ? '⚠️ stalled — no recent frame' : '✅ yes'}
Drawing:❌ the 3D scene has not drawn a frame
GPU adapter:{renderStats.context.powerPreference}
Antialias:{renderStats.context.antialias ? 'on' : 'off'}
Context: - {renderStats.context.lost - ? `⚠️ lost${renderStats.context.lostReason ? ` — ${renderStats.context.lostReason}` : ''}` - : `✅ live${renderStats.context.losses > 0 ? ` (recovered ${renderStats.context.losses}×)` : ''}`} -
Fell back because:{renderStats.context.fallbackReason}
-
- -
-
Scene telemetry
- {/* 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. -

-
- - - -
-
{recentTelemetry}
-
- -
-
PM5 Data (Live)
- - - - - - - - - - - - -
Connected:{pm5Connected ? '✅ Yes' : '❌ No'}
Pace (s/500m):{pm5Data?.pace ?? 'N/A'}
Speed (m/s):{pm5Data?.pace ? (500 / pm5Data.pace).toFixed(2) : 'N/A'}
Distance (m):{pm5Data?.distance?.toFixed(1) ?? 'N/A'}
Elapsed (s):{pm5Data?.elapsedTime ?? 'N/A'}
Cadence (spm):{pm5Data?.cadence ?? 'N/A'}
Power (W):{pm5Data?.power ?? 'N/A'}
Heart Rate:{pm5Data?.heartRate ?? 'N/A'}
-
- - {currentView === 'workout' && ( -
-
Route Visualization (3D View)
-

🔴 Red lines = Water edges (left/right bank)

-

🟡 Yellow line = Route centerline

-
- )} - -
-
Route Info
- - - - - - -
Route:{selectedRoute?.name ?? 'None'}
Distance (km):{selectedRoute?.distance ?? 'N/A'}
Progress (%):{pm5Data && selectedRoute ? ((pm5Data.distance / 1000) / selectedRoute.distance * 100).toFixed(1) : '0.0'}
-
- -
-
App State
- - - - - - -
Current View:{currentView}
Workout Active:{isWorkoutActive ? 'Yes' : 'No'}
HR Samples:{heartRateSamples.length}
-
-
- )} -
- ); -} - -export default App; diff --git a/src/__tests__/AnimationContext.test.tsx b/src/__tests__/AnimationContext.test.tsx deleted file mode 100644 index 9e3f1675..00000000 --- a/src/__tests__/AnimationContext.test.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, act } from '@testing-library/react'; -import * as fiber from '@react-three/fiber'; - -// Capture the useFrame callback so we can fire frames manually in tests. -let capturedFrameCallback: ((state: { clock: { elapsedTime: number } }) => void) | null = null; - -vi.mock('@react-three/fiber', () => ({ - useFrame: vi.fn((cb: (state: { clock: { elapsedTime: number } }) => void) => { - capturedFrameCallback = cb; - }), -})); - -import { AnimationProvider } from '../components/rower3d/AnimationContext'; -import { useAnimationFrame } from '../components/rower3d/animationFrame'; - -// Helper: simulate one animation frame at the given elapsed time. -function fireFrame(elapsedTime: number) { - if (capturedFrameCallback) { - capturedFrameCallback({ clock: { elapsedTime } }); - } -} - -// Test consumer component: records every time value it receives. -function Consumer({ onFrame }: { onFrame: (t: number) => void }) { - useAnimationFrame((time) => onFrame(time)); - return null; -} - -describe('AnimationProvider', () => { - beforeEach(() => { - capturedFrameCallback = null; - }); - - it('registers a useFrame callback when mounted', () => { - render( - -
- , - ); - expect(vi.mocked(fiber.useFrame)).toHaveBeenCalled(); - }); - - it('fans the frame time out to a subscribed child', () => { - const received: number[] = []; - render( - - received.push(t)} /> - , - ); - - act(() => fireFrame(1.0)); - act(() => fireFrame(2.5)); - - expect(received).toEqual([1.0, 2.5]); - }); - - // Issue #340 — the shared tick is where a frozen scene either holds still or - // does not. Every animated piece of scenery reads its time through here, so a - // freeze that this provider ignores is a freeze that nothing honours. - it('hands every subscriber the frozen time while a visual spec holds the scene', () => { - const received: number[] = []; - render( - - received.push(t)} /> - , - ); - - window.__ROWER3D_FREEZE = { time: 12.5, progress: 0.31 }; - try { - act(() => fireFrame(1.0)); - act(() => fireFrame(2.5)); - } finally { - delete window.__ROWER3D_FREEZE; - } - act(() => fireFrame(4.0)); - - expect(received).toEqual([12.5, 12.5, 4.0]); - }); - - it('fans out to multiple subscribed children', () => { - const a: number[] = []; - const b: number[] = []; - - render( - - a.push(t)} /> - b.push(t)} /> - , - ); - - act(() => fireFrame(3.0)); - - expect(a).toEqual([3.0]); - expect(b).toEqual([3.0]); - }); -}); - -describe('useAnimationFrame', () => { - beforeEach(() => { - capturedFrameCallback = null; - }); - - it('invokes callback with the elapsed time on each frame', () => { - const times: number[] = []; - render( - - times.push(t)} /> - , - ); - - act(() => fireFrame(0.016)); - act(() => fireFrame(0.032)); - act(() => fireFrame(0.048)); - - expect(times).toEqual([0.016, 0.032, 0.048]); - }); - - it('unsubscribes on unmount — no callbacks after unmount', () => { - const times: number[] = []; - const { unmount } = render( - - times.push(t)} /> - , - ); - - act(() => fireFrame(1.0)); - expect(times).toHaveLength(1); - - unmount(); - - act(() => fireFrame(2.0)); - // After unmount, the callback should no longer fire. - expect(times).toHaveLength(1); - }); - - it('always calls the latest closure without re-subscribing', () => { - // The stable-ref pattern means the callback can update without triggering - // a subscribe/unsubscribe cycle. - const log: string[] = []; - let label = 'A'; - - function DynamicConsumer() { - useAnimationFrame(() => log.push(label)); - return null; - } - - const { rerender } = render( - - - , - ); - - act(() => fireFrame(1)); - label = 'B'; - rerender( - - - , - ); - act(() => fireFrame(2)); - - // Both frames recorded; second used the updated label. - expect(log).toEqual(['A', 'B']); - }); -}); diff --git a/src/__tests__/CascadedShadows.test.tsx b/src/__tests__/CascadedShadows.test.tsx deleted file mode 100644 index b9a310a0..00000000 --- a/src/__tests__/CascadedShadows.test.tsx +++ /dev/null @@ -1,302 +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'; - -/** - * Issue #495 — Cascaded Shadow Maps mount under the main camera and clean up - * on unmount so a tier change doesn't leak directional lights. - * - * Phase 2 wires `csm.setupMaterial` onto every shadow-receiving material, - * preserving each material's own `onBeforeCompile` by chaining the callbacks. - */ - -const { CascadedShadows } = await import('../components/rower3d/CascadedShadows'); -const { wireShadowReceivers, unwireShadowReceivers } = await import( - '../components/rower3d/shadowReceivers' -); -const { createCascadedShadows } = await import('../components/rower3d/cascadedShadowsCore'); - -describe('CascadedShadows', () => { - let uninstallCanvasMock: () => void; - beforeAll(() => { - uninstallCanvasMock = installCanvasMock(); - }); - afterAll(() => uninstallCanvasMock()); - - const defaults = { - cascades: 3, - shadowMapSize: 1024, - lightDirection: new THREE.Vector3(-1, -1, -1).normalize(), - lightColor: '#ffeec0' as const, - lightIntensity: 2.2, - maxFar: 400, - }; - - it('creates one directional light per cascade and parents them to the scene', async () => { - const scene = new THREE.Scene(); - const camera = new THREE.PerspectiveCamera(60, 1, 0.1, 500); - const csm = createCascadedShadows(camera, scene, { ...defaults, cascades: 3 }); - expect(csm.lights).toHaveLength(3); - for (const light of csm.lights) { - expect(light).toBeInstanceOf(THREE.DirectionalLight); - expect(light.castShadow).toBe(true); - expect(light.color.getHex()).toBe(new THREE.Color('#ffeec0').getHex()); - } - csm.remove(); - csm.dispose(); - }); - - it('sets each cascade\'s shadow map size from the surface config', async () => { - const scene = new THREE.Scene(); - const camera = new THREE.PerspectiveCamera(60, 1, 0.1, 500); - const csm = createCascadedShadows(camera, scene, { ...defaults, cascades: 4, shadowMapSize: 2048 }); - for (const light of csm.lights) { - expect(light.shadow.mapSize.width).toBe(2048); - expect(light.shadow.mapSize.height).toBe(2048); - } - csm.remove(); - csm.dispose(); - }); - - it('mounts under R3F and disposes its lights on unmount', async () => { - const renderer = await ReactThreeTestRenderer.create(); - const scene = renderer.scene.instance as unknown as THREE.Scene; - // Give the mount useEffect a tick to run. - await ReactThreeTestRenderer.act(async () => { - await renderer.advanceFrames(1, 1 / 60); - }); - const directionals = (): THREE.DirectionalLight[] => { - const found: THREE.DirectionalLight[] = []; - scene.traverse((o) => { - if ((o as THREE.DirectionalLight).isDirectionalLight) found.push(o as THREE.DirectionalLight); - }); - return found; - }; - expect(directionals().length).toBe(defaults.cascades); - await renderer.unmount(); - expect(directionals().length).toBe(0); - }); -}); - -/** - * #495 Phase 2 — the point of CSM is that every shadow-receiving material - * samples from the cascade stack, not from the single-map `DirectionalLight`. - * three's `csm.setupMaterial` does the shader injection, but it OVERWRITES - * `material.onBeforeCompile`, so a material with its own (bankMaterial.ts - * triplanar chain, water, boat) silently loses its chunks the moment CSM - * touches it. The wiring preserves the original callback and runs both. - */ -describe('wireShadowReceivers', () => { - let uninstallCanvasMock: () => void; - beforeAll(() => { - uninstallCanvasMock = installCanvasMock(); - }); - afterAll(() => uninstallCanvasMock()); - - const defaults = { - cascades: 3, - shadowMapSize: 512, - lightDirection: new THREE.Vector3(-1, -1, -1).normalize(), - lightColor: '#ffeec0' as const, - lightIntensity: 2.2, - maxFar: 400, - }; - - it('registers each `receiveShadow` material exactly once', () => { - const scene = new THREE.Scene(); - const camera = new THREE.PerspectiveCamera(60, 1, 0.1, 500); - const csm = createCascadedShadows(camera, scene, defaults); - - const matA = new THREE.MeshStandardMaterial(); - const matB = new THREE.MeshStandardMaterial(); - const bank = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), matA); - bank.receiveShadow = true; - const water = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), matB); - water.receiveShadow = true; - scene.add(bank, water); - - const seen = new Map(); - expect(wireShadowReceivers(csm, scene, seen)).toBe(2); - // Second walk — no new materials, so nothing is re-registered. - expect(wireShadowReceivers(csm, scene, seen)).toBe(0); - - csm.remove(); - csm.dispose(); - }); - - it('skips meshes that don\'t receive shadows', () => { - const scene = new THREE.Scene(); - const camera = new THREE.PerspectiveCamera(60, 1, 0.1, 500); - const csm = createCascadedShadows(camera, scene, defaults); - - const caster = new THREE.Mesh( - new THREE.PlaneGeometry(1, 1), - new THREE.MeshStandardMaterial(), - ); - caster.castShadow = true; - caster.receiveShadow = false; - scene.add(caster); - - const seen = new Map(); - expect(wireShadowReceivers(csm, scene, seen)).toBe(0); - - csm.remove(); - csm.dispose(); - }); - - it('preserves `this` on the chained callback — endurance regression', () => { - // drei's `MeshReflectorMaterial.onBeforeCompile` reads `this._hasBlur` - // from the material. A plain `fn(shader, renderer)` call drops `this`, - // so the chain has to use `.call(this, …)`. #495 Phase 2 endurance CI - // caught this: shipping-scene-contrast threw on the water material's - // reflector. - const scene = new THREE.Scene(); - const camera = new THREE.PerspectiveCamera(60, 1, 0.1, 500); - const csm = createCascadedShadows(camera, scene, defaults); - - const material = new THREE.MeshStandardMaterial() as THREE.MeshStandardMaterial & { - _probe: boolean; - }; - material._probe = true; - let probedThis: unknown = undefined; - material.onBeforeCompile = function (this: unknown) { - probedThis = (this as { _probe?: boolean })?._probe; - }; - const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), material); - mesh.receiveShadow = true; - scene.add(mesh); - - const seen = new Map(); - wireShadowReceivers(csm, scene, seen); - - const shader = { - vertexShader: '', - fragmentShader: '#include ', - uniforms: {} as Record, - defines: {} as Record, - }; - material.onBeforeCompile?.call( - material, - shader as unknown as THREE.WebGLProgramParametersWithUniforms, - {} as THREE.WebGLRenderer, - ); - expect(probedThis).toBe(true); - - csm.remove(); - csm.dispose(); - }); - - it('preserves the material\'s existing onBeforeCompile — both callbacks run', () => { - const scene = new THREE.Scene(); - const camera = new THREE.PerspectiveCamera(60, 1, 0.1, 500); - const csm = createCascadedShadows(camera, scene, defaults); - - const material = new THREE.MeshStandardMaterial(); - const originalCalls: string[] = []; - material.onBeforeCompile = () => { - originalCalls.push('original'); - }; - const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), material); - mesh.receiveShadow = true; - scene.add(mesh); - - const seen = new Map(); - wireShadowReceivers(csm, scene, seen); - - // Simulate three invoking the chained onBeforeCompile at compile time. - // CSM replaces the `` chunk, so seed it. - const shader = { - vertexShader: '', - fragmentShader: '#include ', - uniforms: {} as Record, - defines: {} as Record, - }; - material.onBeforeCompile?.( - shader as unknown as THREE.WebGLProgramParametersWithUniforms, - {} as THREE.WebGLRenderer, - ); - - expect(originalCalls).toEqual(['original']); - // CSM injects its uniforms through onBeforeCompile. - expect(shader.uniforms).toHaveProperty('CSM_cascades'); - expect(shader.uniforms).toHaveProperty('cameraNear'); - expect(shader.uniforms).toHaveProperty('shadowFar'); - - csm.remove(); - csm.dispose(); - }); - - it('sets USE_CSM on each registered material\'s defines', () => { - const scene = new THREE.Scene(); - const camera = new THREE.PerspectiveCamera(60, 1, 0.1, 500); - const csm = createCascadedShadows(camera, scene, { ...defaults, cascades: 2 }); - - const material = new THREE.MeshStandardMaterial(); - const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), material); - mesh.receiveShadow = true; - scene.add(mesh); - - const seen = new Map(); - wireShadowReceivers(csm, scene, seen); - - const defines = (material as unknown as { defines?: Record }).defines; - expect(defines?.USE_CSM).toBe(1); - expect(defines?.CSM_CASCADES).toBe(2); - - csm.remove(); - csm.dispose(); - }); - - it('unwires every material back to its original onBeforeCompile and defines', () => { - const scene = new THREE.Scene(); - const camera = new THREE.PerspectiveCamera(60, 1, 0.1, 500); - const csm = createCascadedShadows(camera, scene, defaults); - - const material = new THREE.MeshStandardMaterial(); - const originalCalls: string[] = []; - const originalCb: THREE.Material['onBeforeCompile'] = () => { - originalCalls.push('after'); - }; - material.onBeforeCompile = originalCb; - const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), material); - mesh.receiveShadow = true; - scene.add(mesh); - - const seen = new Map(); - wireShadowReceivers(csm, scene, seen); - const defines = () => - (material as unknown as { defines?: Record }).defines; - expect(defines()?.USE_CSM).toBe(1); - - unwireShadowReceivers(seen); - expect(material.onBeforeCompile).toBe(originalCb); - expect(defines()?.USE_CSM).toBeUndefined(); - expect(seen.size).toBe(0); - - csm.remove(); - csm.dispose(); - }); - - it('handles meshes with an array of materials', () => { - const scene = new THREE.Scene(); - const camera = new THREE.PerspectiveCamera(60, 1, 0.1, 500); - const csm = createCascadedShadows(camera, scene, defaults); - - const matA = new THREE.MeshStandardMaterial(); - const matB = new THREE.MeshStandardMaterial(); - const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), [matA, matB]); - mesh.receiveShadow = true; - scene.add(mesh); - - const seen = new Map(); - expect(wireShadowReceivers(csm, scene, seen)).toBe(2); - const defines = (m: THREE.Material) => - (m as unknown as { defines?: Record }).defines; - expect(defines(matA)?.USE_CSM).toBe(1); - expect(defines(matB)?.USE_CSM).toBe(1); - - csm.remove(); - csm.dispose(); - }); -}); diff --git a/src/__tests__/CrashReportedBanner.test.tsx b/src/__tests__/CrashReportedBanner.test.tsx deleted file mode 100644 index e7bc2062..00000000 --- a/src/__tests__/CrashReportedBanner.test.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/react'; -import { CrashReportedBanner } from '../components/CrashReportedBanner'; -import { crashBannerStore, CRASH_BANNER_KEY } from '../lib/services/crashBannerStore'; -import { - crashReportsPreferenceStore, - CRASH_REPORTS_ENABLED_KEY, -} from '../lib/services/crashReportsPreferenceStore'; - -/** - * Component tests for FR9 banner. The store carries persistence; this file - * tests just the view, so it drives through the store rather than - * reaching into localStorage directly. - */ - -describe('CrashReportedBanner (#473 Phase 3, FR9)', () => { - beforeEach(() => { - try { - localStorage.removeItem(CRASH_BANNER_KEY); - localStorage.removeItem(CRASH_REPORTS_ENABLED_KEY); - } catch { - /* ignored */ - } - }); - - it('renders nothing when no crash has been raised', () => { - const { container } = render(); - expect(container.firstChild).toBeNull(); - }); - - it('renders the one-line disclosure when a crash was raised', () => { - crashBannerStore.raise('sigA'); - render(); - expect( - screen.getByText(/previous session crashed/i), - ).toBeTruthy(); - }); - - it('calls onOpenSettings when the Opt out link is clicked', () => { - crashBannerStore.raise('sigA'); - const onOpenSettings = vi.fn(); - render(); - fireEvent.click(screen.getByText(/opt out/i)); - expect(onOpenSettings).toHaveBeenCalledTimes(1); - }); - - it('hides when the viewer dismisses', () => { - crashBannerStore.raise('sigA'); - render(); - fireEvent.click(screen.getByLabelText(/dismiss/i)); - expect(screen.queryByText(/previous session crashed/i)).toBeNull(); - }); - - it('renders nothing when the opt-out is off, whatever the store says', () => { - crashReportsPreferenceStore.setEnabled(false); - crashBannerStore.raise('sigA'); - const { container } = render(); - expect(container.firstChild).toBeNull(); - }); -}); diff --git a/src/__tests__/RiverGuides.test.tsx b/src/__tests__/RiverGuides.test.tsx deleted file mode 100644 index 36149c82..00000000 --- a/src/__tests__/RiverGuides.test.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { render } from '@testing-library/react'; -import * as THREE from 'three'; -import { RiverGuides } from '../components/rower3d/RiverGuides'; - -/** - * The component is thin — the geometry it draws is tested in - * riverGuideGeometry.test.ts — but two things about it matter and are cheap to - * assert: it draws nothing without a route, and it draws exactly three lines - * with one (a centreline and two edges). - * - * R3F intrinsics render as unknown elements under jsdom, which is enough to - * count them without a WebGL context. - */ -const curve = new THREE.CatmullRomCurve3([ - new THREE.Vector3(0, 0, -100), - new THREE.Vector3(0, 0, 0), - new THREE.Vector3(0, 0, 100), -]); - -describe('RiverGuides', () => { - it('draws nothing without a route', () => { - const { container } = render(); - - expect(container.innerHTML).toBe(''); - }); - - it('draws nothing when the route has no curve', () => { - const { container } = render(); - - expect(container.innerHTML).toBe(''); - }); - - it('draws a centreline and two edges', () => { - const { container } = render(); - - const primitives = container.querySelectorAll('primitive'); - expect(primitives.length).toBe(3); - }); - - -}); diff --git a/src/__tests__/RouteDetailOverlay.test.tsx b/src/__tests__/RouteDetailOverlay.test.tsx deleted file mode 100644 index ff8a69d8..00000000 --- a/src/__tests__/RouteDetailOverlay.test.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, it, expect, afterEach, vi } from 'vitest'; -import { render, screen, cleanup, fireEvent } from '@testing-library/react'; -import { RouteDetailOverlay } from '../components/RouteDetailOverlay'; -import type { WaterRoute } from '../lib/types/index'; - -const route = (over: Partial = {}): WaterRoute => ({ - id: 'r1', - name: 'Thames tideway', - description: '', - distance: 10, - difficulty: 'moderate', - location: 'London', - coordinates: [ - { lat: 51.47, lng: -0.21 }, - { lat: 51.49, lng: -0.11 }, - ], - elevationGain: 0, - estimatedTime: 42, - tags: [], - createdAt: new Date(0), - ...over, -}); - -describe('RouteDetailOverlay', () => { - afterEach(() => cleanup()); - - it('renders nothing when no route is open', () => { - const { container } = render( - {}} onStartRow={() => {}} />, - ); - expect(container).toBeEmptyDOMElement(); - }); - - it('shows the route name, location, distance, time and difficulty', () => { - render( {}} onStartRow={() => {}} />); - expect(screen.getByTestId('route-detail-overlay')).toBeInTheDocument(); - expect(screen.getByRole('heading', { name: 'Thames tideway' })).toBeInTheDocument(); - expect(screen.getByText('London')).toBeInTheDocument(); - expect(screen.getByText(/10\.00\s*km/)).toBeInTheDocument(); - expect(screen.getByText(/42\s*min/)).toBeInTheDocument(); - expect(screen.getByText('moderate')).toBeInTheDocument(); - }); - - it('fires onStartRow when "Row this route" is clicked', () => { - const onStartRow = vi.fn(); - render( {}} onStartRow={onStartRow} />); - screen.getByRole('button', { name: /Row this route/i }).click(); - expect(onStartRow).toHaveBeenCalledOnce(); - }); - - it('fires onClose when the close button is clicked', () => { - const onClose = vi.fn(); - render( {}} />); - screen.getByRole('button', { name: /close/i }).click(); - expect(onClose).toHaveBeenCalledOnce(); - }); - - it('closes on Escape', () => { - const onClose = vi.fn(); - render( {}} />); - fireEvent.keyDown(window, { key: 'Escape' }); - expect(onClose).toHaveBeenCalledOnce(); - }); - - it('shows OSM attribution so OSM-sourced polygons stay credited', () => { - render( {}} onStartRow={() => {}} />); - expect(screen.getByTestId('route-detail-attribution').textContent).toMatch(/OpenStreetMap/i); - }); -}); diff --git a/src/__tests__/RouteLoadingBar.test.tsx b/src/__tests__/RouteLoadingBar.test.tsx deleted file mode 100644 index 338117af..00000000 --- a/src/__tests__/RouteLoadingBar.test.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { render, screen, act } from '@testing-library/react'; -import { RouteLoadingBar } from '../components/RouteLoadingBar'; -import { describeLoadProgress, type LoadPhaseKey } from '../lib/utils/loadingProgress'; - -/** - * What the rower sees while the route loads (#318). - * - * `loadingProgress.test.ts` proves the arithmetic. This proves the two things - * the component decides for itself, both of which were wrong first time: - * - * - the bar is removed when the wait is over, and completion and removal - * happened in the same render, so it went 45% -> gone and never showed the - * finish. The E2E caught that; this pins it. - * - a boat that could not be fetched is still true after the bar has gone, - * and the rower is about to notice a river with no boat in it. - */ - -const progress = (finished: LoadPhaseKey[], failed: LoadPhaseKey[] = []) => - describeLoadProgress(new Set(finished), new Set(failed)); - -const ALL: LoadPhaseKey[] = ['view', 'scene', 'frame', 'boat']; - -afterEach(() => { - vi.useRealTimers(); -}); - -describe('RouteLoadingBar', () => { - it('tells everyone the same number, including a screen reader', () => { - render(); - - const bar = screen.getByRole('progressbar'); - expect(bar).toHaveAttribute('aria-valuemin', '0'); - expect(bar).toHaveAttribute('aria-valuemax', '100'); - expect(bar).toHaveAttribute('aria-valuenow', '20'); - expect(bar).toHaveAccessibleName('Loading the 3D view'); - }); - - it('says what it is waiting for, not just how far along it is', () => { - render(); - - expect(screen.getByRole('progressbar')).toHaveAttribute( - 'aria-valuetext', - 'Preparing the scene', - ); - }); - - it('shows the finish before it goes', () => { - vi.useFakeTimers(); - // The real sequence: the bar is up, and then the last phase lands. Mounting - // it already-complete is a different case, covered below - there is nothing - // to show a rower who never waited. - const { rerender } = render( - , - ); - expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '45'); - - rerender(); - - // The fault this exists to prevent: completion and removal in one render, - // so the bar jumped from 45% straight to nothing and never showed the - // finish. The E2E caught that; this pins it. - expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '100'); - - act(() => { - vi.advanceTimersByTime(500); - }); - - expect(screen.queryByRole('progressbar')).toBeNull(); - }); - - it('stays while anything is outstanding, however long that is', () => { - vi.useFakeTimers(); - render(); - - act(() => { - vi.advanceTimersByTime(60_000); - }); - - // 45%: the boat has not arrived, and the boat is most of the wait. - expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '45'); - }); - - it('explains a boat that could not be fetched', () => { - render(); - - expect(screen.getByText(/The boat could not be loaded/i)).toBeInTheDocument(); - }); - - it('keeps explaining it after the bar has gone', () => { - vi.useFakeTimers(); - render(); - - act(() => { - vi.advanceTimersByTime(500); - }); - - // The bar's job is done - the wait really is over - but the river still - // has no boat in it and the rower is owed the reason. - expect(screen.queryByRole('progressbar')).toBeNull(); - expect(screen.getByText(/The boat could not be loaded/i)).toBeInTheDocument(); - }); - - it('shows nothing at all once a clean load has finished', () => { - vi.useFakeTimers(); - const { container } = render(); - - act(() => { - vi.advanceTimersByTime(500); - }); - - expect(container).toBeEmptyDOMElement(); - }); -}); diff --git a/src/__tests__/RouteOverviewMap.test.tsx b/src/__tests__/RouteOverviewMap.test.tsx deleted file mode 100644 index 2b5c2343..00000000 --- a/src/__tests__/RouteOverviewMap.test.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { describe, it, expect, afterEach, vi } from 'vitest'; -import { render, screen, cleanup } from '@testing-library/react'; -import { RouteOverviewMap } from '../components/RouteOverviewMap'; -import type { WaterRoute } from '../lib/types/index'; - -const route = (over: Partial): WaterRoute => ({ - id: 'r1', - name: 'A', - description: '', - distance: 2, - difficulty: 'easy', - location: '', - coordinates: [{ lat: 51, lng: -0.5 }, { lat: 51.1, lng: -0.4 }], - elevationGain: 0, - estimatedTime: 10, - tags: [], - createdAt: new Date(0), - ...over, -}); - -describe('RouteOverviewMap', () => { - afterEach(() => cleanup()); - - // Well-separated so Phase 2b's wide-zoom clustering keeps them as singletons. - const farApartRoutes = [ - route({ id: 'r1', coordinates: [{ lat: 51.5, lng: -0.1 }] }), - route({ id: 'r2', coordinates: [{ lat: 40.7, lng: -74 }] }), - ]; - - it('renders one pin per route with its id as a data attribute', () => { - render( {}} />); - const pins = screen.getAllByTestId('route-pin'); - expect(pins).toHaveLength(2); - expect(pins.map((p) => p.getAttribute('data-route-id')).sort()).toEqual(['r1', 'r2']); - }); - - it('marks the default route pin with data-default="true"', () => { - render( {}} />); - const [, r2] = screen.getAllByTestId('route-pin').sort((a, b) => - (a.getAttribute('data-route-id') ?? '').localeCompare(b.getAttribute('data-route-id') ?? ''), - ); - expect(r2.getAttribute('data-default')).toBe('true'); - }); - - it('dims pins whose route is filtered out, keeping the pin so counts match the catalogue', () => { - render( - {}} - dimmedRouteIds={new Set(['r2'])} - />, - ); - const r1 = screen.getAllByTestId('route-pin').find((p) => p.getAttribute('data-route-id') === 'r1'); - const r2 = screen.getAllByTestId('route-pin').find((p) => p.getAttribute('data-route-id') === 'r2'); - expect(r1?.getAttribute('data-dimmed')).toBe('false'); - expect(r2?.getAttribute('data-dimmed')).toBe('true'); - }); - - it('renders a cluster bubble when routes sit on top of each other at the overview zoom', () => { - const stacked = [ - route({ id: 'a', coordinates: [{ lat: 51.5, lng: -0.1 }] }), - route({ id: 'b', coordinates: [{ lat: 51.5001, lng: -0.1001 }] }), - route({ id: 'c', coordinates: [{ lat: 51.5002, lng: -0.1002 }] }), - ]; - render( {}} />); - const clusters = screen.getAllByTestId('route-cluster'); - expect(clusters).toHaveLength(1); - expect(clusters[0].getAttribute('data-cluster-count')).toBe('3'); - // No route-pin rendered for the clustered members at this zoom. - expect(screen.queryAllByTestId('route-pin')).toHaveLength(0); - }); - - it('calls onSelect with the route when its pin is clicked', () => { - const onSelect = vi.fn(); - const r1 = route({ id: 'r1', coordinates: [{ lat: 51.5, lng: -0.1 }] }); - render(); - screen.getByTestId('route-pin').click(); - expect(onSelect).toHaveBeenCalledWith(r1); - }); - - it('skips a route with no coordinates (no pin to place)', () => { - const routes = [route({ id: 'r1' }), route({ id: 'r2', coordinates: [] })]; - render( {}} />); - const pins = screen.getAllByTestId('route-pin'); - expect(pins).toHaveLength(1); - expect(pins[0].getAttribute('data-route-id')).toBe('r1'); - }); - - it('shows OpenStreetMap attribution when any pin is rendered', () => { - render( {}} />); - expect(screen.getByTestId('route-overview-attribution').textContent).toMatch(/OpenStreetMap/i); - }); -}); diff --git a/src/__tests__/RouteThumbnail.test.tsx b/src/__tests__/RouteThumbnail.test.tsx deleted file mode 100644 index 006cab3e..00000000 --- a/src/__tests__/RouteThumbnail.test.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, it, expect, afterEach } from 'vitest'; -import { render, screen, cleanup } from '@testing-library/react'; -import { RouteThumbnail } from '../components/RouteThumbnail'; - -const coords = [ - { lat: 51.5, lng: -0.1 }, - { lat: 51.51, lng: -0.11 }, - { lat: 51.52, lng: -0.12 }, -]; - -describe('RouteThumbnail', () => { - afterEach(() => cleanup()); - - it('renders a polyline for a route with ≥ 2 coordinates', () => { - const { container } = render(); - const line = container.querySelector('polyline.route-thumbnail-line'); - expect(line).not.toBeNull(); - expect(line?.getAttribute('points')?.split(/\s+/).length ?? 0).toBeGreaterThanOrEqual(2); - }); - - it('renders a placeholder when there are not enough coordinates', () => { - const { container } = render(); - expect(container.querySelector('.route-thumbnail--placeholder')).not.toBeNull(); - expect(container.querySelector('polyline')).toBeNull(); - }); - - it('omits the OSM backdrop by default', () => { - render(); - expect(screen.queryByTestId('route-thumbnail-backdrop')).toBeNull(); - }); - - it('renders an OSM tile img when showBackdrop is true', () => { - render(); - const img = screen.getByTestId('route-thumbnail-backdrop') as HTMLImageElement; - expect(img.src).toMatch(/^https:\/\/tile\.openstreetmap\.org\/\d+\/\d+\/\d+\.png$/); - expect(img.getAttribute('loading')).toBe('lazy'); - // The polyline rides on top in the same wrapper. - expect(screen.getByTestId('route-thumbnail-wrap').querySelector('polyline')).not.toBeNull(); - }); - - it('skips the backdrop for a coord-less route even with showBackdrop set', () => { - render(); - expect(screen.queryByTestId('route-thumbnail-backdrop')).toBeNull(); - }); -}); diff --git a/src/__tests__/Rower3D.curve.test.ts b/src/__tests__/Rower3D.curve.test.ts deleted file mode 100644 index 16e98160..00000000 --- a/src/__tests__/Rower3D.curve.test.ts +++ /dev/null @@ -1,345 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import * as THREE from 'three'; -import type { Coordinate } from '../lib/types/index'; -import { - gpsToScenePoints, - createRouteCurve, - getRoutePositionAtProgress, - getCurveDistances, - distanceToProgress, - upsampleRouteCoordinates, -} from '../components/rower3d/curve'; -import { distanceBetweenMeters } from '../lib/utils/coordinateUtils'; - -describe('Rower3D curve helpers', () => { - describe('gpsToScenePoints', () => { - it('returns an empty array for fewer than two coordinates', () => { - expect(gpsToScenePoints([])).toEqual([]); - expect(gpsToScenePoints([{ lat: 0, lng: 0 }])).toEqual([]); - }); - - it('places the first coordinate at the origin', () => { - const coords: Coordinate[] = [ - { lat: 48.0, lng: 11.0 }, - { lat: 48.001, lng: 11.001 }, - ]; - const points = gpsToScenePoints(coords); - expect(points).toHaveLength(2); - expect(points[0].x).toBeCloseTo(0, 5); - expect(points[0].z).toBeCloseTo(0, 5); - }); - - it('inverts the Z axis (north = -Z)', () => { - const coords: Coordinate[] = [ - { lat: 0, lng: 0 }, - { lat: 0.001, lng: 0 }, // moves north → -Z in scene - ]; - const points = gpsToScenePoints(coords); - expect(points[1].z).toBeLessThan(0); - }); - - it('respects the sceneScale parameter', () => { - const coords: Coordinate[] = [ - { lat: 0, lng: 0 }, - { lat: 0, lng: 0.001 }, - ]; - const small = gpsToScenePoints(coords, 0.1); - const large = gpsToScenePoints(coords, 1.0); - expect(Math.abs(large[1].x)).toBeCloseTo(Math.abs(small[1].x) * 10, 5); - }); - }); - - describe('createRouteCurve', () => { - it('returns null for insufficient coordinates', () => { - expect(createRouteCurve([])).toBeNull(); - expect(createRouteCurve([{ lat: 0, lng: 0 }])).toBeNull(); - }); - - it('returns a CatmullRomCurve3 for valid input', () => { - const coords: Coordinate[] = [ - { lat: 0, lng: 0 }, - { lat: 0.001, lng: 0 }, - { lat: 0.002, lng: 0.001 }, - ]; - const curve = createRouteCurve(coords); - expect(curve).toBeInstanceOf(THREE.CatmullRomCurve3); - }); - - it('upsamples sparse GPS points to the default 10m resolution', () => { - const coords: Coordinate[] = [ - { lat: 0, lng: 0 }, - { lat: 0, lng: 0.001 }, // ~111 m apart - ]; - - const upsampled = upsampleRouteCoordinates(coords); - const curve = createRouteCurve(coords); - - expect(upsampled.length).toBeGreaterThan(2); - expect(curve?.points.length).toBe(upsampled.length); - }); - }); - - describe('getRoutePositionAtProgress', () => { - it('falls back to a straight line when curve is null', () => { - const out = getRoutePositionAtProgress(null, 0.5); - expect(out.position.x).toBe(0); - expect(out.position.z).toBe(-50); - expect(out.tangent.z).toBe(-1); - expect(out.angle).toBe(0); - }); - - it('writes into provided scratch vectors without allocating new ones', () => { - const coords: Coordinate[] = [ - { lat: 0, lng: 0 }, - { lat: 0.001, lng: 0 }, - ]; - const curve = createRouteCurve(coords)!; - const outPos = new THREE.Vector3(); - const outTan = new THREE.Vector3(); - const result = getRoutePositionAtProgress(curve, 0.5, outPos, outTan); - expect(result.position).toBe(outPos); - expect(result.tangent).toBe(outTan); - }); - - it('clamps progress to [0, 1]', () => { - const coords: Coordinate[] = [ - { lat: 0, lng: 0 }, - { lat: 0.001, lng: 0 }, - ]; - const curve = createRouteCurve(coords)!; - const atNeg = getRoutePositionAtProgress(curve, -1); - const atZero = getRoutePositionAtProgress(curve, 0); - const atTwo = getRoutePositionAtProgress(curve, 2); - const atOne = getRoutePositionAtProgress(curve, 1); - expect(atNeg.position.equals(atZero.position)).toBe(true); - expect(atTwo.position.equals(atOne.position)).toBe(true); - }); - - it('produces a heading angle aligned with the tangent', () => { - // A straight north-pointing line should have an angle near 0 because - // the boat's local -Z is aligned with the +Z-ish tangent? Actually we - // build with z negated (north = -Z), so tangent will be -Z and - // atan2(tangent.x=0, tangent.z=-1) = π. - const coords: Coordinate[] = [ - { lat: 0, lng: 0 }, - { lat: 0.001, lng: 0 }, - { lat: 0.002, lng: 0 }, - ]; - const curve = createRouteCurve(coords)!; - const out = getRoutePositionAtProgress(curve, 0.5); - // Tangent is a unit vector - expect(out.tangent.length()).toBeCloseTo(1, 5); - }); - }); - - describe('getCurveDistances', () => { - it('starts at 0 and grows monotonically', () => { - const coords: Coordinate[] = [ - { lat: 0, lng: 0 }, - { lat: 0.001, lng: 0 }, - { lat: 0.002, lng: 0.001 }, - { lat: 0.003, lng: 0.002 }, - ]; - const curve = createRouteCurve(coords)!; - const distances = getCurveDistances(curve, 50); - expect(distances[0]).toBe(0); - expect(distances).toHaveLength(51); - for (let i = 1; i < distances.length; i++) { - expect(distances[i]).toBeGreaterThanOrEqual(distances[i - 1]); - } - }); - - it('total length approximates the straight-line distance for collinear points', () => { - // Two-point Catmull-Rom collapses to a straight line. - const coords: Coordinate[] = [ - { lat: 0, lng: 0 }, - { lat: 0, lng: 0.001 }, // ~111 m east, and a unit is a metre (#321) - ]; - const curve = createRouteCurve(coords)!; - const distances = getCurveDistances(curve, 100); - const len = distances[distances.length - 1]; - // Stated as the metres it is rather than as a wide sanity band: a degree - // of longitude at the equator is 111.32 km, so a thousandth of one is - // 111.32 m, and the curve is that many units long. - expect(len).toBeGreaterThan(105); - expect(len).toBeLessThan(118); - }); - }); - - describe('distanceToProgress', () => { - const curveDistances = [0, 10, 20, 30, 40, 50]; - const curveLength = 50; - - it('returns 0 when totalDistanceMeters or curveLength is non-positive', () => { - expect(distanceToProgress(100, 0, curveDistances, curveLength)).toBe(0); - expect(distanceToProgress(100, 1000, curveDistances, 0)).toBe(0); - }); - - it('returns 0 at the start of the route', () => { - expect(distanceToProgress(0, 1000, curveDistances, curveLength)).toBe(0); - }); - - it('returns ~1 at the end of the route', () => { - const t = distanceToProgress(1000, 1000, curveDistances, curveLength); - expect(t).toBeCloseTo(1, 5); - }); - - it('returns ~0.5 at the midpoint', () => { - const t = distanceToProgress(500, 1000, curveDistances, curveLength); - expect(t).toBeGreaterThan(0.4); - expect(t).toBeLessThan(0.6); - }); - - it('always returns a value in [0, 1]', () => { - const t1 = distanceToProgress(-100, 1000, curveDistances, curveLength); - const t2 = distanceToProgress(2000, 1000, curveDistances, curveLength); - expect(t1).toBeGreaterThanOrEqual(0); - expect(t1).toBeLessThanOrEqual(1); - expect(t2).toBeGreaterThanOrEqual(0); - expect(t2).toBeLessThanOrEqual(1); - }); - }); - - describe('upsampleRouteCoordinates (the single cubic-Hermite path, #224)', () => { - it('returns original coordinates if already close enough', () => { - const coords: Coordinate[] = [ - { lat: 50.0, lng: 0.0 }, - { lat: 50.0001, lng: 0.0 }, // ~11m apart - ]; - const upsampled = upsampleRouteCoordinates(coords, 20); // 20m resolution - expect(upsampled).toHaveLength(2); - }); - - it('inserts interpolated points for large gaps', () => { - const coords: Coordinate[] = [ - { lat: 50.0, lng: 0.0 }, - { lat: 50.001, lng: 0.0 }, // ~111m apart - ]; - const upsampled = upsampleRouteCoordinates(coords, 10); // 10m resolution - // Should have original 2 + interpolated points - expect(upsampled.length).toBeGreaterThan(2); - expect(upsampled.length).toBeLessThanOrEqual(13); // 111m / 10m = 11.1 segments - }); - - it('preserves first coordinate', () => { - const coords: Coordinate[] = [ - { lat: 50.0, lng: 0.0 }, - { lat: 50.01, lng: 0.0 }, - ]; - const upsampled = upsampleRouteCoordinates(coords, 10); - expect(upsampled[0]).toEqual(coords[0]); - }); - - it('preserves last coordinate', () => { - const coords: Coordinate[] = [ - { lat: 50.0, lng: 0.0 }, - { lat: 50.01, lng: 0.0 }, - ]; - const upsampled = upsampleRouteCoordinates(coords, 10); - expect(upsampled[upsampled.length - 1].lat).toBeCloseTo(coords[1].lat, 5); - expect(upsampled[upsampled.length - 1].lng).toBeCloseTo(coords[1].lng, 5); - }); - - it('handles single coordinate', () => { - const coords: Coordinate[] = [{ lat: 50.0, lng: 0.0 }]; - const upsampled = upsampleRouteCoordinates(coords, 10); - expect(upsampled).toHaveLength(1); - expect(upsampled[0]).toEqual(coords[0]); - }); - - it('handles empty coordinates', () => { - const coords: Coordinate[] = []; - const upsampled = upsampleRouteCoordinates(coords, 10); - expect(upsampled).toHaveLength(0); - }); - - it('creates evenly spaced interpolated points', () => { - const coords: Coordinate[] = [ - { lat: 50.0, lng: 0.0 }, - { lat: 50.001, lng: 0.0 }, // ~111m apart - ]; - const upsampled = upsampleRouteCoordinates(coords, 50); // 50m resolution - expect(upsampled.length).toBeGreaterThan(2); - - // Check that distances between consecutive points are reasonably uniform - for (let i = 1; i < upsampled.length; i++) { - const prev = upsampled[i - 1]; - const curr = upsampled[i]; - const distance = distanceBetweenMeters(prev, curr); - expect(distance).toBeLessThanOrEqual(60); // Allow some tolerance - } - }); - }); -}); - -describe('upsampleRouteCoordinates on unevenly spaced input (#224)', () => { - 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 at = (east: number, north: number): Coordinate => ({ - lat: ORIGIN_LAT + north / METERS_PER_DEGREE_LAT, - lng: east / LNG_SCALE, - }); - const eastOf = (c: Coordinate) => c.lng * LNG_SCALE; - const northOf = (c: Coordinate) => (c.lat - ORIGIN_LAT) * METERS_PER_DEGREE_LAT; - - /** - * Simplification leaves a long straight leg next to short steps around a - * bend. Tangents taken as plain centred differences are then dominated by the - * long leg and fling the interpolated points off the route (#224). - */ - const unevenBend: Coordinate[] = [ - at(-40, 600), // 602 m straight leg - at(-40, -2), - at(-36, -17), // then 15 m steps around a hairpin - at(-28, -29), - at(-16, -37), - at(-1, -40), - ]; - - it('keeps every inserted point inside the span of the leg it subdivides', () => { - const upsampled = upsampleRouteCoordinates(unevenBend, 10); - - for (let i = 0; i < upsampled.length; i++) { - const north = northOf(upsampled[i]); - const east = eastOf(upsampled[i]); - // The whole route lies within these bounds; an overshoot leaves them. - expect(north).toBeGreaterThan(-45); - expect(north).toBeLessThan(605); - expect(east).toBeGreaterThan(-45); - expect(east).toBeLessThan(5); - } - }); - - it('does not reverse direction between consecutive points', () => { - const upsampled = upsampleRouteCoordinates(unevenBend, 10); - for (let i = 1; i < upsampled.length - 1; i++) { - const ax = eastOf(upsampled[i]) - eastOf(upsampled[i - 1]); - const ay = northOf(upsampled[i]) - northOf(upsampled[i - 1]); - const bx = eastOf(upsampled[i + 1]) - eastOf(upsampled[i]); - const by = northOf(upsampled[i + 1]) - northOf(upsampled[i]); - const la = Math.hypot(ax, ay); - const lb = Math.hypot(bx, by); - if (la < 1e-6 || lb < 1e-6) continue; - const turn = Math.acos( - Math.max(-1, Math.min(1, (ax * bx + ay * by) / (la * lb))), - ); - expect((turn * 180) / Math.PI).toBeLessThan(90); - } - }); - - it('still reduces to the plain centred difference on evenly spaced input', () => { - // A regular arc: the weighting must not change the shape it already made. - const arc: Coordinate[] = Array.from({ length: 12 }, (_, i) => { - const angle = (i / 11) * Math.PI; - return at(Math.cos(angle) * 100, Math.sin(angle) * 100); - }); - const upsampled = upsampleRouteCoordinates(arc, 10); - - for (const point of upsampled) { - const radius = Math.hypot(eastOf(point), northOf(point)); - expect(radius).toBeGreaterThan(95); - expect(radius).toBeLessThan(105); - } - }); -}); diff --git a/src/__tests__/RownativeRouteImport.test.tsx b/src/__tests__/RownativeRouteImport.test.tsx deleted file mode 100644 index 37426831..00000000 --- a/src/__tests__/RownativeRouteImport.test.tsx +++ /dev/null @@ -1,358 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { ServicesProvider } from '../context/ServicesContext'; -import { defaultServices } from '../context/useServices'; -import { RownativeRouteImport } from '../components/RownativeRouteImport'; -import { RownativeCourseNotFoundError } from '../lib/services/rownativeService'; -import type { Services } from '../lib/ports'; -import type { WaterRoute } from '../lib/types/index'; - -function createRoute(name = 'Quinsig S to N', externalId = '5'): WaterRoute { - return { - id: 'new-route', name, description: 'desc', 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, - }; -} - -const COURSES = [ - { id: '5', name: 'Quinsig S to N', country: 'United States', distanceMeters: 5349, status: 'established' }, - { id: '106', name: 'HOTS Stake Race', country: 'United States', distanceMeters: 4804, status: 'provisional' }, -]; - -/** Real resolveCourseId — input validation is part of what these tests exercise. */ -const realResolve = defaultServices.rownativeService.resolveCourseId.bind(defaultServices.rownativeService); - -describe('RownativeRouteImport', () => { - beforeEach(() => { vi.restoreAllMocks(); }); - - function renderWithServices(overrides?: Partial) { - const onRouteImported = vi.fn(); - render( - - - , - ); - return { onRouteImported }; - } - - function rownative(overrides: Record) { - return { - // Spreading a class instance copies its fields, not its prototype - // methods, so every method a call-site reaches has to be named here. - // `satisfies` does not catch it: the spread's *type* carries the methods - // even though the value does not. - ...defaultServices.rownativeService, - resolveCourseId: realResolve, - attachedTrack: () => null, - ...overrides, - } satisfies Services['rownativeService']; - } - - const noExisting = { ...defaultServices.routeService, findRouteByRownativeId: () => undefined } satisfies Services['routeService']; - - it('offers no account-linking controls at all', () => { - renderWithServices(); - - expect(screen.queryByRole('button', { name: /link rownative account/i })).toBeNull(); - expect(screen.queryByRole('button', { name: /complete linking/i })).toBeNull(); - expect(screen.queryByRole('button', { name: /unlink/i })).toBeNull(); - expect(screen.queryByRole('button', { name: /pull route kml/i })).toBeNull(); - }); - - it('imports a pasted course id (AC-1)', async () => { - const user = userEvent.setup(); - const route = createRoute(); - const importCourseById = vi.fn().mockResolvedValue(route); - const { onRouteImported } = renderWithServices({ - rownativeService: rownative({ importCourseById }), - routeService: noExisting, - }); - - - await user.type(screen.getByLabelText(/rownative course id or link/i), '5'); - await user.click(screen.getByRole('button', { name: /^import$/i })); - - expect(importCourseById).toHaveBeenCalledWith('5'); - expect(onRouteImported).toHaveBeenCalledWith(route); - }); - - it('imports a pasted rownative.icu course link (AC-1)', async () => { - const user = userEvent.setup(); - const importCourseById = vi.fn().mockResolvedValue(createRoute()); - renderWithServices({ rownativeService: rownative({ importCourseById }), routeService: noExisting }); - - - await user.type(screen.getByLabelText(/rownative course id or link/i), 'https://rownative.icu/course/5'); - await user.click(screen.getByRole('button', { name: /^import$/i })); - - expect(importCourseById).toHaveBeenCalledWith('5'); - }); - - it('rejects a foreign host client-side, making no request (AC-4)', async () => { - const user = userEvent.setup(); - const importCourseById = vi.fn(); - renderWithServices({ rownativeService: rownative({ importCourseById }) }); - - - await user.type(screen.getByLabelText(/rownative course id or link/i), 'https://evil.example/course/5'); - await user.click(screen.getByRole('button', { name: /^import$/i })); - - expect(importCourseById).not.toHaveBeenCalled(); - expect(screen.getByRole('alert').textContent).toMatch(/https:\/\/ links on rownative\.icu/i); - }); - - it('rejects an http link client-side, making no request (AC-4)', async () => { - const user = userEvent.setup(); - const importCourseById = vi.fn(); - renderWithServices({ rownativeService: rownative({ importCourseById }) }); - - - await user.type(screen.getByLabelText(/rownative course id or link/i), 'http://rownative.icu/course/5'); - await user.click(screen.getByRole('button', { name: /^import$/i })); - - expect(importCourseById).not.toHaveBeenCalled(); - expect(screen.getByRole('alert')).toBeInTheDocument(); - }); - - it('offers a search-by-name shortcut when the id is missing from the mirror (AC-3)', async () => { - const user = userEvent.setup(); - const importCourseById = vi.fn().mockRejectedValue( - new RownativeCourseNotFoundError("Course 2 isn't in the public course data yet.", '2'), - ); - const searchCourses = vi.fn().mockResolvedValue(COURSES); - const { onRouteImported } = renderWithServices({ - rownativeService: rownative({ - importCourseById, - searchCourses, - getCourseIndex: vi.fn().mockResolvedValue(COURSES), - }), - routeService: noExisting, - }); - - - await user.type(screen.getByLabelText(/rownative course id or link/i), '2'); - await user.click(screen.getByRole('button', { name: /^import$/i })); - - expect(await screen.findByRole('alert')).toHaveTextContent(/isn't in the public course data yet/i); - expect(onRouteImported).not.toHaveBeenCalled(); - - await user.click(screen.getByRole('button', { name: /search by name/i })); - expect(await screen.findByText(/showing 2 of 2 courses/i)).toBeInTheDocument(); - }); - - it('searches the catalogue by name and imports a result', async () => { - const user = userEvent.setup(); - const route = createRoute('HOTS Stake Race', '106'); - const searchCourses = vi.fn().mockResolvedValue([COURSES[1]]); - const importCourseById = vi.fn().mockResolvedValue(route); - const { onRouteImported } = renderWithServices({ - rownativeService: rownative({ - searchCourses, - importCourseById, - getCourseIndex: vi.fn().mockResolvedValue(COURSES), - }), - routeService: noExisting, - }); - - - await user.type(screen.getByLabelText(/search rownative courses by name/i), 'hots'); - await user.click(screen.getByRole('button', { name: /^search$/i })); - - expect(await screen.findByText('HOTS Stake Race')).toBeInTheDocument(); - expect(screen.getByText(/showing 1 of 2 courses/i)).toBeInTheDocument(); - expect(screen.getByText('4.80 km')).toBeInTheDocument(); - - await user.click(screen.getByRole('button', { name: /HOTS Stake Race/i })); - expect(importCourseById).toHaveBeenCalledWith('106'); - expect(onRouteImported).toHaveBeenCalledWith(route); - }); - - it('reports an empty search rather than showing a blank panel', async () => { - const user = userEvent.setup(); - renderWithServices({ - rownativeService: rownative({ - searchCourses: vi.fn().mockResolvedValue([]), - getCourseIndex: vi.fn().mockResolvedValue(COURSES), - }), - }); - - - await user.type(screen.getByLabelText(/search rownative courses by name/i), 'zzzz'); - await user.click(screen.getByRole('button', { name: /^search$/i })); - - expect(await screen.findByText(/no courses match "zzzz"/i)).toBeInTheDocument(); - }); - - it('surfaces a search failure', async () => { - const user = userEvent.setup(); - renderWithServices({ - rownativeService: rownative({ - searchCourses: vi.fn().mockRejectedValue(new Error('Unable to load rownative course data (HTTP 500). Please try again.')), - getCourseIndex: vi.fn().mockRejectedValue(new Error('Unable to load rownative course data (HTTP 500). Please try again.')), - }), - }); - - - await user.click(screen.getByRole('button', { name: /^search$/i })); - - expect(await screen.findByRole('alert')).toHaveTextContent(/HTTP 500/); - }); - - it('selects an already imported course rather than importing twice (AC-5)', async () => { - const user = userEvent.setup(); - const existing = createRoute('Already Here'); - const importCourseById = vi.fn(); - const { onRouteImported } = renderWithServices({ - rownativeService: rownative({ importCourseById }), - routeService: { ...defaultServices.routeService, findRouteByRownativeId: () => existing } satisfies Services['routeService'], - }); - - - await user.type(screen.getByLabelText(/rownative course id or link/i), '5'); - await user.click(screen.getByRole('button', { name: /^import$/i })); - - expect(importCourseById).not.toHaveBeenCalled(); - expect(onRouteImported).toHaveBeenCalledWith(existing); - expect(await screen.findByRole('status')).toHaveTextContent(/already in your routes/i); - }); - - it('disables Import until something is entered', async () => { - const user = userEvent.setup(); - renderWithServices(); - - - expect(screen.getByRole('button', { name: /^import$/i })).toBeDisabled(); - await user.type(screen.getByLabelText(/rownative course id or link/i), '5'); - expect(screen.getByRole('button', { name: /^import$/i })).toBeEnabled(); - }); -}); - -/** - * Attaching a track, through the UI (#313). - * - * The service half of this shipped with #206 and had no entrance: nothing under - * src/components imported trackAttachmentStore, and the E2E that covered it - * wrote the attachment straight into localStorage with a comment saying it was - * "seeded the way a previous session would have left it" — which no session - * could do. These drive the control a rower actually uses. - */ -describe('RownativeRouteImport — attaching a track', () => { - beforeEach(() => { vi.restoreAllMocks(); }); - - function renderWithServices(overrides?: Partial) { - const onRouteImported = vi.fn(); - render( - - - , - ); - return { onRouteImported }; - } - - function rownative(overrides: Record) { - return { - // Spreading a class instance copies its fields, not its prototype - // methods, so every method a call-site reaches has to be named here. - // `satisfies` does not catch it: the spread's *type* carries the methods - // even though the value does not. - ...defaultServices.rownativeService, - resolveCourseId: realResolve, - attachedTrack: () => null, - ...overrides, - } satisfies Services['rownativeService']; - } - - const noExisting = { - ...defaultServices.routeService, - findRouteByRownativeId: () => undefined, - } satisfies Services['routeService']; - - const trackFile = () => - new File( - [JSON.stringify({ - type: 'Feature', - geometry: { type: 'LineString', coordinates: [[-4.56, 55.93], [-4.28, 55.85]] }, - })], - 'clyde.geojson', - { type: 'application/geo+json' }, - ); - - /** Search, so the result list with its attach controls is on screen. */ - async function showResults(overrides: Record) { - const user = userEvent.setup(); - const result = renderWithServices({ - rownativeService: rownative({ - searchCourses: vi.fn().mockResolvedValue([COURSES[0]]), - getCourseIndex: vi.fn().mockResolvedValue(COURSES), - ...overrides, - }), - routeService: noExisting, - }); - await user.click(screen.getByRole('button', { name: /^search$/i })); - await screen.findByText(/showing 1 of 2 courses/i); - return { user, ...result }; - } - - it('offers an attach control on every course', async () => { - await showResults({ attachedTrack: () => null }); - - expect(screen.getByLabelText(/attach a track to Quinsig S to N/i)).toBeInTheDocument(); - }); - - it('attaches a chosen file and hands back the re-imported route', async () => { - const route = createRoute(); - const attachTrack = vi.fn().mockResolvedValue({ - track: { courseId: '5', fileName: 'clyde.geojson', attachedAt: 1, coordinates: [] }, - route, - }); - const { user, onRouteImported } = await showResults({ attachTrack, attachedTrack: () => null }); - - await user.upload(screen.getByLabelText(/attach a track to Quinsig S to N/i), trackFile()); - - expect(attachTrack).toHaveBeenCalledWith('5', 'clyde.geojson', expect.stringContaining('LineString')); - expect(onRouteImported).toHaveBeenCalledWith(route); - expect(await screen.findByText(/clyde\.geojson attached/i)).toBeInTheDocument(); - }); - - it('shows why a track was refused, in the words the check used', async () => { - // The gate check names the gate and the offset. Replacing that with - // "could not attach" would throw away the only useful part. - const attachTrack = vi.fn().mockRejectedValue( - new Error('This track passes 1,350 m from the "WP5" gate, more than the 40 m allowed.'), - ); - const { user, onRouteImported } = await showResults({ attachTrack, attachedTrack: () => null }); - - await user.upload(screen.getByLabelText(/attach a track to Quinsig S to N/i), trackFile()); - - expect(await screen.findByRole('alert')).toHaveTextContent(/1,350 m from the "WP5" gate/); - expect(onRouteImported, 'a refused track still changed the route').not.toHaveBeenCalled(); - }); - - it('shows what is attached, and can remove it', async () => { - const route = createRoute(); - const detachTrack = vi.fn().mockResolvedValue(route); - const { user, onRouteImported } = await showResults({ - detachTrack, - attachedTrack: (id: string) => (id === '5' ? { courseId: '5', fileName: 'clyde.gpx', attachedAt: 1, coordinates: [] } : null), - }); - - expect(screen.getByText('clyde.gpx')).toBeInTheDocument(); - // The control says what it will do to a course that already has one. - expect(screen.getByText(/replace track/i)).toBeInTheDocument(); - - await user.click(screen.getByRole('button', { name: /remove the track attached to Quinsig S to N/i })); - - expect(detachTrack).toHaveBeenCalledWith('5'); - expect(onRouteImported).toHaveBeenCalledWith(route); - }); - - it('offers no remove control for a course with nothing attached', async () => { - await showResults({ attachedTrack: () => null }); - - expect(screen.queryByRole('button', { name: /remove the track/i })).toBeNull(); - expect(screen.getByText(/attach track/i)).toBeInTheDocument(); - }); -}); diff --git a/src/__tests__/app.test.tsx b/src/__tests__/app.test.tsx deleted file mode 100644 index 5c4b1c07..00000000 --- a/src/__tests__/app.test.tsx +++ /dev/null @@ -1,282 +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 { formatPace } from '../lib/utils/formatters'; -import * as UseAuth from '../context/useAuth'; -import type { AuthContextValue } from '../context/useAuth'; -import { installCanvasMock } from './canvasMock'; - -let uninstallCanvas: () => void; - -beforeAll(() => { - uninstallCanvas = installCanvasMock(); -}); - -afterAll(() => { - uninstallCanvas(); -}); - -describe('App component', () => { - it('renders title, the selected route and the heart rate panel', () => { - render(); - expect(screen.getByRole('heading', { name: /VirtualRow/i })).toBeInTheDocument(); - // The Row screen shows one route — the list itself lives on the Routes - // screen now (issue #219, R2). - const matches = screen.getAllByText(/Willowbrook River/i); - expect(matches.length).toBeGreaterThan(0); - // Heart Rate panel title - expect(screen.getByText(/Heart Rate/i)).toBeInTheDocument(); - }); - - it('formats pace values for the activity screen', () => { - expect(formatPace(null)).toBe('--:--'); - expect(formatPace(0)).toBe('--:--'); - expect(formatPace(125)).toBe('2:05/500m'); - expect(formatPace(359)).toBe('5:59/500m'); - }); - - it('does not show Quick Start button', () => { - render(); - expect(screen.queryByRole('button', { name: /Quick Start/i })).not.toBeInTheDocument(); - }); - - it('does not show a History tab', () => { - render(); - - expect(screen.queryByRole('button', { name: /History/i })).not.toBeInTheDocument(); - }); - - it('does not render the route description in the route details panel', () => { - const { container } = render(); - - expect(container.querySelector('.route-description')).toBeNull(); - expect( - screen.queryByRole('button', { name: /(Collapse|Expand) route info/i }), - ).not.toBeInTheDocument(); - }); - - // ── Signed-out test drive (issue #187) ───────────────────────────────── - - it('pre-selects Willowbrook River for a signed-out visitor (TD-1)', () => { - render(); - // The route panel heading carries the pre-selected route. - expect(screen.getAllByText(/Willowbrook River/i).length).toBeGreaterThan(0); - expect(screen.getByText(/Willowbrook Valley/i)).toBeInTheDocument(); - }); - - it('disables the start control and names the missing device (TD-1)', () => { - render(); - const start = screen.getByRole('button', { name: /Connect PM5 First/i }); - expect(start).toBeDisabled(); - }); - - it('names FTMS instead when FTMS is the selected rower type (TD-1)', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByRole('button', { name: /^FTMS$/ })); - expect(screen.getByRole('button', { name: /Connect FTMS First/i })).toBeDisabled(); - }); - - it('offers a plain-language demo row, not hidden behind Debug (TD-2)', () => { - render(); - - const demo = screen.getByRole('button', { name: /try a demo row/i }); - expect(demo).toBeInTheDocument(); - expect(demo).toBeEnabled(); - // It must be reachable without opening the developer debug panel. - expect(demo.closest('.debug-info-panel')).toBeNull(); - expect(screen.getByText(/simulated rower and heart-rate data/i)).toBeInTheDocument(); - }); - - it('states in visible copy that guest sessions are not saved (TD-3)', () => { - const { container } = render(); - - const notice = container.querySelector('.signed-out-notice'); - expect(notice).toBeInTheDocument(); - expect(notice).toHaveTextContent(/sessions are not saved/i); - }); - - it('offers a sign-in control on the routes view (TD-3)', () => { - render(); - expect(screen.getByRole('button', { name: /sign in with intervals\.icu/i })).toBeInTheDocument(); - }); - - it('shows Rower Device and Heart Rate panels for unauthenticated users without guest sidebar class', () => { - const { container } = render(); - - expect(screen.getByText(/Rower Device/i)).toBeInTheDocument(); - expect(screen.getByText(/Heart Rate/i)).toBeInTheDocument(); - // GUEST-2: sidebar must not carry app-sidebar--guest (which previously hid device panels) - const sidebar = container.querySelector('.app-sidebar'); - expect(sidebar?.classList.contains('app-sidebar--guest')).toBe(false); - }); - - it('does not show the file import control for unauthenticated users', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByRole('button', { name: /^Routes$/ })); - expect(screen.queryByRole('button', { name: /import a file/i })).not.toBeInTheDocument(); - }); - - it('does not show rownative import for unauthenticated users', async () => { - const user = userEvent.setup(); - render(); - - // Absent on the Row screen and on the Routes screen alike. - expect(screen.queryByRole('region', { name: /rownative course import/i })).not.toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: /^Routes$/ })); - expect(screen.queryByRole('region', { name: /rownative course import/i })).not.toBeInTheDocument(); - }); - - describe('authenticated user', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('shows the rownative import and file import on the Routes screen when logged in', async () => { - const authedValue: AuthContextValue = { - 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(), - }; - vi.spyOn(UseAuth, 'useAuth').mockReturnValue(authedValue); - const user = userEvent.setup(); - - render(); - await user.click(screen.getByRole('button', { name: /^Routes$/ })); - - expect(screen.getByRole('button', { name: /import a file/i })).toBeInTheDocument(); - expect(screen.getByRole('region', { name: /rownative course import/i })).toBeInTheDocument(); - }); - - it('does not show a History tab when logged in', () => { - const authedValue: AuthContextValue = { - 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(), - }; - vi.spyOn(UseAuth, 'useAuth').mockReturnValue(authedValue); - - render(); - - expect(screen.queryByRole('button', { name: /History/i })).not.toBeInTheDocument(); - }); - }); -}); - -/** - * Issue #338 — the pre-row choice, on the screen it belongs to. - * - * `ghostPicker.test.tsx` covers the control itself. These are about it being - * wired to the route the rower is actually looking at: the best it offers to - * race has to be a row on *this* route, kept for *this* athlete. - */ -describe('choosing what to race', () => { - it('offers the choice on the route panel, before the row starts', () => { - render(); - - expect(screen.getByRole('group', { name: /row against/i })).toBeInTheDocument(); - expect(screen.getByRole('radio', { name: /row alone/i })).toBeChecked(); - }); - - // A visitor with no stored rows has nothing to race, and the option says so - // rather than disappearing: the rower has done nothing wrong. - it('cannot race a best that was never rowed, and says why', () => { - render(); - - expect(screen.getByRole('radio', { name: /my best/i })).toBeDisabled(); - expect(screen.getByText(/first row on this route/i)).toBeInTheDocument(); - }); - - it('asks for a target pace once a pace boat is chosen', async () => { - render(); - - await userEvent.click(screen.getByRole('radio', { name: /pace boat/i })); - - expect(screen.getByLabelText(/target pace/i)).toHaveValue('2:00'); - }); -}); - - -/** - * Issue #339 — the sound switch, on the screen it belongs to. - * - * `soundPicker.test.tsx` covers the control. This is about it being wired: - * the switch is the user gesture a browser requires before any audio exists, - * so if it is not connected to anything there is no other way to start sound. - */ -describe('switching the sound on', () => { - it('offers the switch among the other settings, off to begin with', () => { - render(); - - expect(screen.getByRole('switch', { name: /sound/i })).not.toBeChecked(); - expect(screen.queryByRole('slider', { name: /volume/i })).toBeNull(); - }); - - it('turns on, and offers a volume once there is something to hear', async () => { - render(); - - await userEvent.click(screen.getByRole('switch', { name: /sound/i })); - - expect(screen.getByRole('switch', { name: /sound/i })).toBeChecked(); - expect(screen.getByRole('slider', { name: /volume/i })).toBeInTheDocument(); - }); -}); - -/** - * Issue #346 — the conditions picker, on the screen it belongs to. - * - * `conditionsPicker.test.tsx` covers the control. This is about it being wired - * to the hook that remembers the choice: unwired, it would look identical and - * every row would be lit the same way it always was. - */ -describe('choosing the light a row is rowed in', () => { - // The choice is remembered in localStorage, so a test that asserts the - // default has to start from no stored choice — including one left by the - // test above it, or by another file sharing this jsdom. - beforeEach(() => localStorage.clear()); - - it('offers the conditions beside the other things set once', () => { - render(); - - expect(screen.getByRole('radiogroup', { name: /conditions/i })).toBeInTheDocument(); - }); - - it('matches the rower’s clock until they say otherwise', () => { - render(); - - expect(screen.getByRole('radio', { name: /my clock/i })).toHaveAttribute( - 'aria-checked', - 'true', - ); - }); - - it('takes the preset the rower picked', async () => { - render(); - - await userEvent.click(screen.getByRole('radio', { name: 'Dusk' })); - - expect(screen.getByRole('radio', { name: 'Dusk' })).toHaveAttribute('aria-checked', 'true'); - expect(screen.getByRole('radio', { name: /my clock/i })).toHaveAttribute( - 'aria-checked', - 'false', - ); - }); -}); diff --git a/src/__tests__/appNavigation.test.tsx b/src/__tests__/appNavigation.test.tsx deleted file mode 100644 index 834065e2..00000000 --- a/src/__tests__/appNavigation.test.tsx +++ /dev/null @@ -1,416 +0,0 @@ -import { afterAll, afterEach, beforeAll, beforeEach, describe, it, expect, vi } from 'vitest'; -import { render, screen, within } 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 { - defaultRoutePreferenceStore, - DEFAULT_ROUTE_KEY_PREFIX, -} from '../lib/services/defaultRoutePreferenceStore'; -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()); - -const ATHLETE_ID = 'i12345'; - -function signedIn(id = ATHLETE_ID, gender?: 'male' | 'female'): AuthContextValue { - return { - user: { id, name: 'Test User', email: 'test@example.com', gender }, - isAuthenticated: true, - isLoading: false, - authError: null, - authErrorClass: null, - login: vi.fn(), - logout: vi.fn(), - clearAuthError: vi.fn(), - pendingAction: null, - setPendingAction: vi.fn(), - }; -} - -function mockSignedIn(id = ATHLETE_ID, gender?: 'male' | 'female') { - vi.spyOn(UseAuth, 'useAuth').mockReturnValue(signedIn(id, gender)); -} - -/** Walk to the Routes screen through the header nav, as a user would. */ -async function goToRoutes(user: ReturnType) { - await user.click(screen.getByRole('button', { name: /^Routes$/ })); -} - -beforeEach(() => { - localStorage.clear(); -}); - -afterEach(() => { - // Starting a demo row starts two module-singleton simulators on real - // intervals and opens a workout session. Unmounting the component does not - // stop them, so left alone they leak into every later test — in this file and - // in any other sharing the worker. The shuffled-order gate (#201) is what - // turns that leak from an intermittent annoyance into a hard failure. - pm5Simulator.stop(); - heartRateSimulator.stop(); - if (workoutService.getCurrentSession()) workoutService.endSession(); - vi.restoreAllMocks(); -}); - -/* ========================================================================== - R2 — the Row screen is decluttered around the demo route - ========================================================================== */ -describe('Row screen (issue #219, R2)', () => { - it('AC2.1: carries no route-discovery clutter when signed out', () => { - const { container } = render(); - - expect(container.querySelector('.routes-list')).toBeNull(); - expect(container.querySelector('.route-filters')).toBeNull(); - expect(container.querySelector('.btn-import-route')).toBeNull(); - expect(container.querySelector('.route-import')).toBeNull(); - expect(container.querySelector('.rownative-import')).toBeNull(); - }); - - it('AC2.1: carries no route-discovery clutter when signed in either', () => { - mockSignedIn(); - const { container } = render(); - - expect(container.querySelector('.routes-list')).toBeNull(); - expect(container.querySelector('.route-filters')).toBeNull(); - expect(container.querySelector('.btn-import-route')).toBeNull(); - expect(container.querySelector('.route-import')).toBeNull(); - expect(container.querySelector('.rownative-import')).toBeNull(); - }); - - it('AC2.1: keeps exactly one primary action group — start and demo', () => { - render(); - - expect(screen.getByRole('button', { name: /Connect PM5 First/i })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /try a demo row/i })).toBeInTheDocument(); - }); - - it('AC2.2: a Change route control opens the Routes screen', async () => { - const user = userEvent.setup(); - const { container } = render(); - - await user.click(screen.getByRole('button', { name: /change route/i })); - - expect(container.querySelector('.view-container--search')).toBeInTheDocument(); - expect(container.querySelector('.view-container--routes')).toBeNull(); - }); - - it('AC2.4: still shows the route location and distance in the hero', () => { - render(); - - expect(screen.getByText(/Willowbrook Valley/i)).toBeInTheDocument(); - expect(screen.getAllByText(/Willowbrook River/i).length).toBeGreaterThan(0); - }); -}); - -/* ========================================================================== - R3 — a second screen for route search and selection - ========================================================================== */ -describe('Routes screen (issue #219, R3)', () => { - it('AC3.2: shows the rownative import, the route list and the file disclosure', async () => { - mockSignedIn(); - const user = userEvent.setup(); - const { container } = render(); - - await goToRoutes(user); - - expect(screen.getByRole('region', { name: /rownative course import/i })).toBeInTheDocument(); - expect(container.querySelector('.routes-list')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /import a file/i })).toBeInTheDocument(); - }); - - it('AC3.2: puts the course-ID import ahead of the name search', async () => { - mockSignedIn(); - const user = userEvent.setup(); - render(); - - await goToRoutes(user); - - const region = screen.getByRole('region', { name: /rownative course import/i }); - const idField = within(region).getByLabelText(/course ID or link/i); - const searchField = within(region).getByLabelText(/search rownative courses by name/i); - - // Node.DOCUMENT_POSITION_FOLLOWING === 4 - expect(idField.compareDocumentPosition(searchField) & 4).toBeTruthy(); - }); - - it('AC3.4: choosing a route returns to the Row screen with it selected', async () => { - mockSignedIn(); - const user = userEvent.setup(); - const { container } = render(); - - await goToRoutes(user); - await user.click(screen.getByRole('heading', { name: /Willowbrook River/i, level: 4 })); - - expect(container.querySelector('.view-container--routes')).toBeInTheDocument(); - expect(container.querySelector('.route-info-overlay h2')).toHaveTextContent('Willowbrook River'); - }); - - it('AC3.5: the file input is absent until the disclosure is expanded', async () => { - mockSignedIn(); - const user = userEvent.setup(); - const { container } = render(); - - await goToRoutes(user); - - const disclosure = screen.getByRole('button', { name: /import a file/i }); - expect(disclosure).toHaveAttribute('aria-expanded', 'false'); - expect(container.querySelector('.route-import input[type="file"]')).toBeNull(); - - await user.click(disclosure); - - expect(disclosure).toHaveAttribute('aria-expanded', 'true'); - expect(container.querySelector('.route-import input[type="file"]')).toBeInTheDocument(); - }); - - it('AC3.6: Back to Row returns to the Row screen', async () => { - const user = userEvent.setup(); - const { container } = render(); - - await goToRoutes(user); - await user.click(screen.getByRole('button', { name: /back to row/i })); - - expect(container.querySelector('.view-container--routes')).toBeInTheDocument(); - }); - - it('a signed-out visitor sees the route list but is told to sign in to import', async () => { - const user = userEvent.setup(); - const { container } = render(); - - await goToRoutes(user); - - expect(container.querySelector('.routes-list')).toBeInTheDocument(); - expect(screen.queryByRole('region', { name: /rownative course import/i })).toBeNull(); - expect(screen.getByText(/sign in with intervals\.icu to import courses/i)).toBeInTheDocument(); - }); -}); - -/* ========================================================================== - R6 — default route, selectable once logged in - ========================================================================== */ -describe('default route (issue #219, R6)', () => { - it('AC6.1: the Set as default control is signed-in only', async () => { - const user = userEvent.setup(); - render(); - await goToRoutes(user); - - expect(screen.queryByRole('button', { name: /set as default/i })).toBeNull(); - }); - - it('AC6.2: setting a default writes the athlete-scoped key', async () => { - mockSignedIn(); - const user = userEvent.setup(); - render(); - - await goToRoutes(user); - await user.click(screen.getByRole('button', { name: /set as default/i })); - - expect(localStorage.getItem(`${DEFAULT_ROUTE_KEY_PREFIX}${ATHLETE_ID}`)).toBe('1'); - expect(screen.getByRole('button', { name: /remove as default/i })).toBeInTheDocument(); - }); - - it('AC6.3: a stored default is pre-selected on the next load', () => { - defaultRoutePreferenceStore.setDefaultRouteId(ATHLETE_ID, '1'); - mockSignedIn(); - - const { container } = render(); - - expect(container.querySelector('.route-info-overlay h2')).toHaveTextContent('Willowbrook River'); - }); - - it('AC6.4: a stored id that no longer resolves falls back and is cleared', () => { - defaultRoutePreferenceStore.setDefaultRouteId(ATHLETE_ID, 'no-such-route'); - mockSignedIn(); - - const { container } = render(); - - expect(container.querySelector('.route-info-overlay h2')).toHaveTextContent('Willowbrook River'); - expect(defaultRoutePreferenceStore.getDefaultRouteId(ATHLETE_ID)).toBeNull(); - }); - - it('AC6.6: un-starring clears the stored default', async () => { - defaultRoutePreferenceStore.setDefaultRouteId(ATHLETE_ID, '1'); - mockSignedIn(); - const user = userEvent.setup(); - render(); - - await goToRoutes(user); - await user.click(screen.getByRole('button', { name: /remove as default/i })); - - expect(defaultRoutePreferenceStore.getDefaultRouteId(ATHLETE_ID)).toBeNull(); - expect(screen.getByRole('button', { name: /set as default/i })).toBeInTheDocument(); - }); - - it('AC6.9: one athlete does not inherit another athlete default', () => { - defaultRoutePreferenceStore.setDefaultRouteId('i99999', 'no-such-route'); - mockSignedIn(ATHLETE_ID); - - render(); - - // i99999's stale key is untouched — it was never read for this athlete. - expect(defaultRoutePreferenceStore.getDefaultRouteId('i99999')).toBe('no-such-route'); - }); - - it('AC6.7: a signed-out visitor still lands on Willowbrook', () => { - const { container } = render(); - - expect(container.querySelector('.route-info-overlay h2')).toHaveTextContent('Willowbrook River'); - }); - - // #442: the pre-workout main screen used to render `route.tags` as a strip - // of raw pill chips (`scenic`, `meadow`, `rownative`, `status:established`, - // `outline-only`, …) between the meta-badges and the ghost picker. The - // strings were a mix of curated labels, scenery hints and internal - // provenance flags — noise without the same actionable content the - // meta-badges already carry — so the strip is gone, but the `WaterRoute.tags` - // field, the search filter, the scenery inference and the provenance - // fallback all keep reading it. - it('AC1 (#442): no chip strip of raw route tags appears on the main screen', () => { - const { container } = render(); - - expect(container.querySelector('.route-info-overlay h2')).toHaveTextContent('Willowbrook River'); - expect(container.querySelector('.route-tags')).toBeNull(); - }); -}); - -/* ========================================================================== - #443 — the CrewPicker is for guests / demo rows / signed-in athletes with - no intervals.icu `sex`; a signed-in athlete whose gender is known reads - the rower from their profile without a separate control. - ========================================================================== */ -describe('Rower panel (issue #443)', () => { - it('AC1: hidden for a signed-in athlete whose intervals.icu profile has a sex', () => { - mockSignedIn(ATHLETE_ID, 'female'); - - render(); - - expect(screen.queryByRole('radiogroup', { name: 'Rower appearance' })).toBeNull(); - }); - - it('AC1: hidden for a male-gendered signed-in athlete too', () => { - mockSignedIn(ATHLETE_ID, 'male'); - - render(); - - expect(screen.queryByRole('radiogroup', { name: 'Rower appearance' })).toBeNull(); - }); - - it('AC3: still shown to a signed-in athlete whose profile has no sex', () => { - mockSignedIn(ATHLETE_ID, undefined); - - render(); - - expect(screen.getByRole('radiogroup', { name: 'Rower appearance' })).toBeInTheDocument(); - }); - - it('AC4: still shown to a signed-out visitor', () => { - render(); - - expect(screen.getByRole('radiogroup', { name: 'Rower appearance' })).toBeInTheDocument(); - }); - - it('AC5: shows a "matches your intervals.icu profile" hint with a link to settings', () => { - mockSignedIn(ATHLETE_ID, 'female'); - - render(); - - const hint = screen.getByTestId('crew-profile-hint'); - expect(hint).toHaveTextContent(/intervals\.icu/i); - const link = within(hint).getByRole('link', { name: /intervals\.icu/i }); - expect(link).toHaveAttribute('href', 'https://intervals.icu/settings'); - expect(link).toHaveAttribute('target', '_blank'); - expect(link).toHaveAttribute('rel', expect.stringContaining('noopener')); - expect(link).toHaveAttribute('rel', expect.stringContaining('noreferrer')); - }); - - it('AC5: the hint is absent for a signed-out visitor', () => { - render(); - - expect(screen.queryByTestId('crew-profile-hint')).toBeNull(); - }); - - it('AC5: the hint is absent for a signed-in athlete without a known sex', () => { - mockSignedIn(ATHLETE_ID, undefined); - - render(); - - expect(screen.queryByTestId('crew-profile-hint')).toBeNull(); - }); -}); - -/* ========================================================================== - R7 — the demo row - ========================================================================== */ -describe('demo row (issue #219, R7)', () => { - it('AC7.1: is one click from first paint', () => { - render(); - - const demo = screen.getByRole('button', { name: /try a demo row/i }); - expect(demo).toBeEnabled(); - expect(demo.closest('.debug-info-panel')).toBeNull(); - }); - - it('AC7.2 (superseded by #453): is NOT offered to signed-in users by default', () => { - // #219 AC7.2 originally offered the demo row to signed-in users too; - // #453 narrows that — a signed-in athlete has committed to real hardware - // and a real intervals.icu account, so the demo control is now hidden - // for them unless flipped back on from the debug panel. The full gate is - // covered in `demoCtaVisibility.test.tsx`. - mockSignedIn(); - render(); - - expect( - screen.queryByRole('button', { name: /try a demo row/i }), - ).not.toBeInTheDocument(); - }); -}); - -/* ========================================================================== - R8 — navigation - ========================================================================== */ -describe('header navigation (issue #219, R8)', () => { - it('AC8.1: exposes Row and Routes', () => { - render(); - - expect(screen.getByRole('button', { name: /^Row$/ })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /^Routes$/ })).toBeInTheDocument(); - }); - - it('AC8.2: marks the active screen with aria-current', async () => { - const user = userEvent.setup(); - render(); - - expect(screen.getByRole('button', { name: /^Row$/ })).toHaveAttribute('aria-current', 'page'); - expect(screen.getByRole('button', { name: /^Routes$/ })).not.toHaveAttribute('aria-current'); - - await goToRoutes(user); - - expect(screen.getByRole('button', { name: /^Routes$/ })).toHaveAttribute('aria-current', 'page'); - expect(screen.getByRole('button', { name: /^Row$/ })).not.toHaveAttribute('aria-current'); - }); - - it('AC8.3: the hamburger reports its state and controls the overlay', () => { - render(); - - const toggle = screen.getByRole('button', { name: 'Menu' }); - expect(toggle).toHaveAttribute('aria-expanded', 'false'); - expect(toggle).toHaveAttribute('aria-controls', 'nav-overlay'); - }); - - it('AC8.4: the nav is hidden during an active workout', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByRole('button', { name: /try a demo row/i })); - - expect(screen.queryByRole('button', { name: /^Routes$/ })).toBeNull(); - }); -}); diff --git a/src/__tests__/assetBasePath.test.ts b/src/__tests__/assetBasePath.test.ts index 24bc27c2..aa58273e 100644 --- a/src/__tests__/assetBasePath.test.ts +++ b/src/__tests__/assetBasePath.test.ts @@ -84,7 +84,9 @@ describe('every asset URL the app can build is a file that ships', () => { } // One-off structures are constructible URLs too, and are not in any kit. - const structures = await import('../components/rower3d/sceneryStructures'); + // #542 AC1: the Svelte port lives in `src/lib/scenery/sceneryStructures.ts` + // now; the three.js version it replaced is gone. + const structures = await import('../lib/scenery/sceneryStructures'); [...structures.COURSE_FURNITURE.start, ...structures.COURSE_FURNITURE.finish].forEach((id) => ids.add(id), ); diff --git a/src/__tests__/authButtonRetry.test.tsx b/src/__tests__/authButtonRetry.test.tsx deleted file mode 100644 index d9fb2d92..00000000 --- a/src/__tests__/authButtonRetry.test.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { render, screen, act } from '@testing-library/react'; -import { AuthProvider } from '../context/AuthContext'; -import { AuthButton } from '../components/AuthButton'; -import type { AuthErrorClass } from '../lib/services/authService'; -import type { AuthPort } from '../lib/ports'; - -/** - * AuthButton — Retry behaviour (#486 Phase 2 FR2 / FR3, D2 = a, D4 = a). - * - * The service exposes `getLastErrorClass()`; AuthContext mirrors it as - * `authErrorClass`. AuthButton renders a Retry button for every retryable - * class and omits it for `'config'`, matching ACs 1–5. These tests pin the - * mapping without going through the live Playwright matrix. - */ - -vi.spyOn(console, 'error').mockImplementation(() => {}); - -function makeServiceStub( - klass: AuthErrorClass | null, - message: string, -): AuthPort { - return { - getUser: vi.fn(() => null), - startLogin: vi.fn(() => Promise.resolve()), - handleCallback: vi.fn(() => Promise.resolve(null)), - getLastError: vi.fn(() => message), - getLastErrorClass: vi.fn(() => klass), - refreshAccessToken: vi.fn(() => Promise.resolve(false)), - logout: vi.fn(), - getAccessToken: vi.fn(() => null), - scheduleRefresh: vi.fn(), - get isAuthenticated() { return false; }, - } satisfies AuthPort; -} - -async function renderWithFailedCallback(service: AuthPort): Promise { - window.history.replaceState({}, '', '/?code=any&state=any'); - await act(async () => { - render( - - - , - ); - await Promise.resolve(); - }); -} - -describe('AuthButton — classified error + Retry (#486 Phase 2)', () => { - it('AC1 — hides the Retry button for a config class', async () => { - const service = makeServiceStub('config', "VirtualRow isn't registered"); - await renderWithFailedCallback(service); - expect(screen.queryByRole('button', { name: /retry sign in/i })).toBeNull(); - expect(screen.getByRole('alert').textContent).toContain("isn't registered"); - expect(screen.getByRole('alert').getAttribute('data-auth-error-class')).toBe('config'); - }); - - it.each(['transient', 'expired-code', 'network', 'unknown'])( - 'AC2/AC3/AC4 — shows the Retry button for %s', - async (klass) => { - const service = makeServiceStub(klass, `error class=${klass}`); - await renderWithFailedCallback(service); - const button = screen.getByRole('button', { name: /retry sign in/i }); - expect(button).toBeTruthy(); - expect(screen.getByRole('alert').getAttribute('data-auth-error-class')).toBe(klass); - }, - ); - - it('AC5 — the csrf class omits Retry (it is a client-side guard, not a server failure)', async () => { - const service = makeServiceStub('csrf', 'login session expired'); - await renderWithFailedCallback(service); - expect(screen.queryByRole('button', { name: /retry sign in/i })).toBeNull(); - }); - - it('Retry button calls startLogin on click (D4 = a, D9 = a)', async () => { - const service = makeServiceStub('transient', 'intervals.icu temporarily unavailable'); - await renderWithFailedCallback(service); - - const button = screen.getByRole('button', { name: /retry sign in/i }); - await act(async () => { - button.click(); - }); - - expect(service.startLogin).toHaveBeenCalledTimes(1); - // D9 = a: Retry must NOT call logout — it would wipe an existing valid - // session that happened to be mid-refresh. - expect(service.logout).not.toHaveBeenCalled(); - }); - - it('shows Retry when there is an error but no class (e.g. a locally-thrown startLogin error)', async () => { - // AuthProvider sets authErrorClass='unknown' on a thrown promise, which - // is retryable. This test documents the local-fallback invariant for a - // null class as well (e.g. a loginError raised in-component only). - const service = makeServiceStub(null, 'Login failed'); - await renderWithFailedCallback(service); - // With null class from the service, no state is set from the callback; - // but the loginError path from a startLogin throw also needs retry. - // Here we assert the null-class fallback (showRetryButton defaults true). - expect(screen.queryByRole('alert')).not.toBeNull(); - }); -}); diff --git a/src/__tests__/authContext.test.tsx b/src/__tests__/authContext.test.tsx deleted file mode 100644 index 9138beb6..00000000 --- a/src/__tests__/authContext.test.tsx +++ /dev/null @@ -1,313 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen, act } from '@testing-library/react'; -import { AuthProvider } from '../context/AuthContext'; -import { useAuth } from '../context/useAuth'; -import type { AuthUser } from '../lib/types/index'; - -// Suppress console noise -vi.spyOn(console, 'error').mockImplementation(() => {}); - -// ─── Test helpers ───────────────────────────────────────────────────────────── - -function TestConsumer() { - const { user, isAuthenticated, isLoading, authError, authErrorClass } = useAuth(); - return ( -
- {String(isAuthenticated)} - {String(isLoading)} - {user?.name ?? 'null'} - {user?.id ?? 'null'} - {authError ?? 'null'} - {authErrorClass ?? 'null'} -
- ); -} - -function makeServiceStub(overrides: Partial<{ - getUser: () => AuthUser | null; - handleCallback: (code: string, state: string) => Promise; - getLastError: () => string | null; - getLastErrorClass: () => import('../lib/services/authService').AuthErrorClass | null; - startLogin: () => Promise; - logout: () => void; - isAuthenticated: boolean; -}> = {}) { - return { - getUser: vi.fn(() => null), - startLogin: vi.fn(() => Promise.resolve()), - handleCallback: vi.fn(() => Promise.resolve(null)), - getLastError: vi.fn(() => null), - getLastErrorClass: vi.fn(() => null), - refreshAccessToken: vi.fn(() => Promise.resolve(false)), - logout: vi.fn(), - getAccessToken: vi.fn(() => null), - scheduleRefresh: vi.fn(), - get isAuthenticated() { return false; }, - ...overrides, - } satisfies import('../lib/ports').AuthPort; -} - -// ─── Tests ──────────────────────────────────────────────────────────────────── - -describe('AuthProvider', () => { - let originalSearch = ''; - - beforeEach(() => { - originalSearch = window.location.search; - }); - - afterEach(() => { - // Restore URL - window.history.replaceState({}, '', window.location.pathname); - vi.restoreAllMocks(); - }); - - it('renders children with unauthenticated defaults', () => { - const service = makeServiceStub(); - render( - - - - ); - expect(screen.getByTestId('authenticated').textContent).toBe('false'); - expect(screen.getByTestId('loading').textContent).toBe('false'); - expect(screen.getByTestId('user-name').textContent).toBe('null'); - }); - - it('shows the user from getUser() on initial render', () => { - const user: AuthUser = { id: 'i1', name: 'Alice', email: 'alice@example.com' }; - const service = makeServiceStub({ getUser: () => user }); - render( - - - - ); - expect(screen.getByTestId('authenticated').textContent).toBe('true'); - expect(screen.getByTestId('user-name').textContent).toBe('Alice'); - }); - - it('completes OAuth callback when URL has code and state params', async () => { - const callbackUser: AuthUser = { id: 'i2', name: 'Bob', email: 'bob@example.com' }; - const service = makeServiceStub({ - handleCallback: vi.fn(() => Promise.resolve(callbackUser)), - }); - - // Simulate callback URL - window.history.replaceState({}, '', '/?code=auth-code&state=csrf-state'); - - await act(async () => { - render( - - - - ); - // Let the async handleCallback resolve - await Promise.resolve(); - }); - - expect(service.handleCallback).toHaveBeenCalledWith('auth-code', 'csrf-state'); - expect(screen.getByTestId('user-name').textContent).toBe('Bob'); - expect(screen.getByTestId('authenticated').textContent).toBe('true'); - expect(screen.getByTestId('auth-error').textContent).toBe('null'); - }); - - it('strips code and state from URL after callback', async () => { - const callbackUser: AuthUser = { id: 'i3', name: 'Carol', email: '' }; - const service = makeServiceStub({ - handleCallback: vi.fn(() => Promise.resolve(callbackUser)), - }); - - window.history.replaceState({}, '', '/?code=some-code&state=some-state&other=keep'); - - await act(async () => { - render( - - - - ); - await Promise.resolve(); - }); - - const params = new URLSearchParams(window.location.search); - expect(params.get('code')).toBeNull(); - expect(params.get('state')).toBeNull(); - expect(params.get('other')).toBe('keep'); - }); - - it('stays unauthenticated when handleCallback returns null (bad state)', async () => { - const service = makeServiceStub({ - handleCallback: vi.fn(() => Promise.resolve(null)), - getLastError: vi.fn(() => 'Sign-in failed: Your login session expired or became invalid. Please retry.'), - }); - - window.history.replaceState({}, '', '/?code=bad-code&state=bad-state'); - - await act(async () => { - render( - - - - ); - await Promise.resolve(); - }); - - expect(screen.getByTestId('authenticated').textContent).toBe('false'); - expect(screen.getByTestId('user-name').textContent).toBe('null'); - expect(screen.getByTestId('auth-error').textContent) - .toContain('login session expired or became invalid'); - }); - - it('surfaces a retryable error when callback finalization throws', async () => { - const service = makeServiceStub({ - handleCallback: vi.fn(() => Promise.reject(new Error('network down'))), - }); - - window.history.replaceState({}, '', '/?code=bad-code&state=bad-state'); - - await act(async () => { - render( - - - - ); - await Promise.resolve(); - }); - - expect(screen.getByTestId('authenticated').textContent).toBe('false'); - expect(screen.getByTestId('auth-error').textContent) - .toBe('network down'); - // A thrown promise sets the generic retryable class so the UI still - // offers Retry (#486 Phase 2 FR3). - expect(screen.getByTestId('auth-error-class').textContent).toBe('unknown'); - }); - - it('exposes the classified auth error from the service after a failed callback (#486 Phase 2)', async () => { - const service = makeServiceStub({ - handleCallback: vi.fn(() => Promise.resolve(null)), - getLastError: vi.fn(() => 'Sign-in failed: expired code'), - getLastErrorClass: vi.fn(() => 'expired-code' as const), - }); - - window.history.replaceState({}, '', '/?code=replayed&state=valid'); - - await act(async () => { - render( - - - - ); - await Promise.resolve(); - }); - - expect(screen.getByTestId('auth-error-class').textContent).toBe('expired-code'); - }); - - it('transitions to authenticated state when callback resolves a fallback-profile user', async () => { - const callbackUser: AuthUser = { id: 'i999', name: 'Fallback Rower', email: 'fallback@example.com' }; - const service = makeServiceStub({ - handleCallback: vi.fn(() => Promise.resolve(callbackUser)), - }); - - window.history.replaceState({}, '', '/?code=fallback-code&state=fallback-state'); - - await act(async () => { - render( - - - - ); - await Promise.resolve(); - }); - - expect(screen.getByTestId('authenticated').textContent).toBe('true'); - expect(screen.getByTestId('user-id').textContent).toBe('i999'); - expect(screen.getByTestId('auth-error').textContent).toBe('null'); - }); - - it('clears user on logout', async () => { - const user: AuthUser = { id: 'i4', name: 'Dave', email: '' }; - const logoutFn = vi.fn(); - const service = makeServiceStub({ getUser: () => user, logout: logoutFn }); - - function LogoutConsumer() { - const { logout, user: u, isAuthenticated } = useAuth(); - return ( -
- {String(isAuthenticated)} - - {u?.name ?? 'null'} -
- ); - } - - 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 . This assertion was the one missing - // from the test, which is how the wrong value got tested into place - // (#297). - expect(later.antialias, 'a tier was granted multisampling it did not ask for').toBe(false); - expect( - later.powerPreference, - 'a tier was granted a richer adapter than it asked for', - ).toBe('default'); - } finally { - vi.restoreAllMocks(); - } - }); - - it('still probes a new request when no scene context is live', () => { - const contexts = countingCanvas(); - try { - selectBrowserGlOptions({ powerPreference: 'high-performance', antialias: true }); - const afterFirst = contexts(); - selectBrowserGlOptions({ powerPreference: 'default', antialias: false }); - expect(contexts(), 'with nothing drawing, a new request is worth a probe').toBeGreaterThan(afterFirst); - } finally { - vi.restoreAllMocks(); - } - }); -}); diff --git a/src/__tests__/gltfScullRig.test.tsx b/src/__tests__/gltfScullRig.test.tsx deleted file mode 100644 index 81cc4cfc..00000000 --- a/src/__tests__/gltfScullRig.test.tsx +++ /dev/null @@ -1,318 +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'; - -/** - * Issues #329 and #330 — the parts of the GLB scull the frame loop moves. - * - * The rower sat rigid while the oars swept and nobody noticed, because - * `Rower3D` renders `RowingScull` under `IS_TEST_MODE` and `GltfScull` - * otherwise — so the scull every real session uses was the one path - * automation could not reach (#273). Mounting it directly gets past that, and - * driving it from a stroke phase this test owns makes every pose exact rather - * than whatever the clock happened to be at. - * - * The model matters as much as the mount: an empty `Group` makes every - * `if (oars.left)` in the frame loop a no-op, and a test watching that would - * pass whatever the code did. The mock builds the rig with the names - * `scripts/build_crew.py` gives it. - */ -vi.mock('@react-three/drei', async () => { - const actual = await vi.importActual>('@react-three/drei'); - const three = await vi.importActual('three'); - - const buildRig = () => { - const scene = new three.Group(); - // Which nodes this rig carries, so a test can hand the scene a model that - // is missing them - see "a model with none of the nodes" below. - const names = (globalThis as { __TEST_RIG_NODES?: string[] }).__TEST_RIG_NODES ?? [ - 'LeftOar', - 'RightOar', - 'Seat', - 'Rower_Torso', - 'Rower_LeftArm', - 'Rower_RightArm', - 'LeftArm_Fore', - 'RightArm_Fore', - ]; - // The real GLB wraps the whole scull in two ±90° rotations about X - // (`scull_root` cancels the glTF exporter's Z-up → Y-up, `scull` is the - // CadQuery assembly below it). The oar's local axes are decided by that - // parent chain, so a mock that drops it animates about a different axis - // than production and would have let #462 sit unnoticed. See - // `scripts/build_crew.py:231-236` and the GLB inspection in #462's body. - const sculRoot = new three.Group(); - sculRoot.name = 'scull_root'; - sculRoot.quaternion.set(-Math.SQRT1_2, 0, 0, Math.SQRT1_2); - const scull = new three.Group(); - scull.name = 'scull'; - scull.quaternion.set(Math.SQRT1_2, 0, 0, Math.SQRT1_2); - sculRoot.add(scull); - scene.add(sculRoot); - for (const name of names) { - const node = new three.Group(); - node.name = name; - // Authored away from the origin, as the rig authors them, so the frame - // loop has a real rest position to measure its offsets from. - if (name.endsWith('Oar')) { - node.position.set(0, 0.3, 0); - // Give the oar a blade sub-node along its local X axis (shaft axis), - // as the real GLB does, so a test can watch where the blade ends up - // in world space — not just read back the local rotation. - const sign = name === 'LeftOar' ? -1 : 1; - const blade = new three.Group(); - blade.name = `${name}_Blade`; - blade.position.set(sign * 1.75, 0, 0); - node.add(blade); - } - if (name === 'Seat') node.position.set(0, 0, 0.2); - scull.add(node); - } - return { scene }; - }; - - const useGLTF = Object.assign( - (path: string | string[]) => (Array.isArray(path) ? path.map(buildRig) : buildRig()), - { preload: () => undefined }, - ); - return { ...actual, Cloud: () => null, useGLTF }; -}); - -const { GltfScull } = await import('../components/rower3d/boatComponents'); -const { - BLADE_CLEARANCE_M, - BLADE_DEPTH_M, - FEATHER_RAD, - SLIDE_TRAVEL_M, - STROKE_DRIVE_FRACTION, - OAR_LEVER_RATIO, -} = await import('../components/rower3d/strokePose'); - -/** Mount the scull with a stroke phase this test drives. */ -const mountAt = async (phase: number) => { - const strokeCycleTRef = { current: phase }; - const renderer = await ReactThreeTestRenderer.create( - , - ); - - /** - * Advance to a stroke phase and copy out what the rig looks like there. - * - * Numbers, not nodes. A map of `Object3D`s holds live references, so reading - * two phases and comparing them afterwards compares the second phase with - * itself - which is how the first version of this test claimed the blade - * never left the water while reporting the same figure on both sides. - */ - const at = async (next: number) => { - strokeCycleTRef.current = next; - await ReactThreeTestRenderer.act(async () => { - await renderer.advanceFrames(2, 1 / 60); - }); - - const pose: Record = {}; - const sceneRoot = renderer.scene.instance as unknown as THREE.Scene; - sceneRoot.updateMatrixWorld(true); - const w = new THREE.Vector3(); - sceneRoot.traverse((object) => { - if (!object.name) return; - object.getWorldPosition(w); - pose[object.name] = { - x: object.position.x, - y: object.position.y, - z: object.position.z, - rx: object.rotation.x, - ry: object.rotation.y, - rz: object.rotation.z, - wx: w.x, - wy: w.y, - wz: w.z, - }; - }); - return pose; - }; - - return { renderer, at }; -}; - -/** Mid-drive and mid-recovery, which is where the two halves are unambiguous. */ -const MID_DRIVE = STROKE_DRIVE_FRACTION / 2; -const MID_RECOVERY = STROKE_DRIVE_FRACTION + (1 - STROKE_DRIVE_FRACTION) / 2; - -describe('the GLB scull', () => { - let uninstallCanvasMock: () => void; - beforeAll(() => { - uninstallCanvasMock = installCanvasMock(); - }); - afterAll(() => uninstallCanvasMock()); - - it('carries the rig it is going to animate', async () => { - const scull = await mountAt(0); - const nodes = await scull.at(0); - - for (const name of ['LeftOar', 'RightOar', 'Seat', 'Rower_Torso']) { - expect(nodes[name], `no ${name} in the mounted scull`).toBeDefined(); - } - - await scull.renderer.unmount(); - }); - - // #329. Before it, the blades swept over the water at a constant height and - // squared throughout — the one thing a sculler never does. - // - // The feather is a roll about the oar's own shaft (its local X axis, which - // is the direction `build_crew.py` lays the loom down). Rotating about the - // oar node's Z instead tilted the shaft out of horizontal and left the - // blades swinging above the rower (#462), so this reads back the X-axis - // rotation specifically. - it('squares the blades on the drive and feathers them on the recovery', async () => { - const scull = await mountAt(MID_DRIVE); - - const drive = await scull.at(MID_DRIVE); - expect(drive.LeftOar.rx, 'the blade is squared mid-drive').toBeCloseTo(0, 5); - expect(drive.LeftOar.rz, 'nothing rolls the shaft out of horizontal').toBeCloseTo(0, 5); - - const recovery = await scull.at(MID_RECOVERY); - expect(recovery.LeftOar.rx).toBeCloseTo(FEATHER_RAD, 5); - expect(recovery.LeftOar.rz, 'the shaft stays horizontal on the recovery (#462)').toBeCloseTo(0, 5); - - await scull.renderer.unmount(); - }); - - // #462. The oars appeared to swing vertically above the rower rather than - // sweeping horizontally fore-and-aft. Reading local rotation values alone - // can miss this — the parent chain decides where each local axis lands in - // world space — so this measures the blade's world position relative to - // the gate and asserts the shaft stays in the horizontal plane. - it('keeps the shaft horizontal in world space across the whole stroke (#462)', async () => { - const scull = await mountAt(0); - - const samples = [0, 0.1, MID_DRIVE, STROKE_DRIVE_FRACTION - 0.001, MID_RECOVERY, 0.9]; - for (const phase of samples) { - const nodes = await scull.at(phase); - // The blade sub-node's world position is derived through the full - // scull_root → scull → LeftOar → LeftOar_Blade chain. If the fix at - // #462 regresses, the blade lifts off the horizontal plane here. - const gateY = nodes.LeftOar.wy; - const bladeY = nodes.LeftOar_Blade?.wy ?? gateY; - const shaftRise = Math.abs(bladeY - gateY); - expect( - shaftRise, - `shaft tipped out of horizontal at phase ${phase}: ΔY=${shaftRise}`, - ).toBeLessThan(0.2); - } - - await scull.renderer.unmount(); - }); - - it('puts the blades in the water on the drive and clear of it on the recovery', async () => { - const scull = await mountAt(MID_DRIVE); - - const rest = 0.3; // where the mock rig authors the oars - const drive = await scull.at(MID_DRIVE); - expect(drive.LeftOar.y).toBeCloseTo(rest - BLADE_DEPTH_M * OAR_LEVER_RATIO, 5); - - const recovery = await scull.at(MID_RECOVERY); - expect(recovery.LeftOar.y).toBeCloseTo(rest + BLADE_CLEARANCE_M * OAR_LEVER_RATIO, 5); - expect( - recovery.LeftOar.y, - 'the blade does not come out of the water', - ).toBeGreaterThan(drive.LeftOar.y); - - await scull.renderer.unmount(); - }); - - // #330. The rig has always carried a Seat 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 up and down the boat. - it('slides the seat the length of the slide', async () => { - const scull = await mountAt(0); - - const rest = 0.2; // where the mock rig authors the seat - const atCatch = await scull.at(0); - const atFinish = await scull.at(STROKE_DRIVE_FRACTION - 0.001); - - expect(atCatch.Seat.z, 'the seat is not back at the catch').toBeCloseTo( - rest - SLIDE_TRAVEL_M, - 2, - ); - expect(atFinish.Seat.z, 'the seat did not come up the slide').toBeCloseTo(rest, 2); - }); - - it('keeps the seat and the knees agreeing', async () => { - // `seatPosition` is derived from `legCompression` rather than tracked - // beside it, so a rower cannot end up sitting somewhere their legs do not. - const scull = await mountAt(0); - - // A quarter of the way through the drive, not half: the legs have their - // own window and it is the drive's first half, so by the midpoint they are - // already down and the seat has arrived. - const partWay = await scull.at(STROKE_DRIVE_FRACTION * 0.25); - const rest = 0.2; - expect(partWay.Seat.z).toBeGreaterThan(rest - SLIDE_TRAVEL_M); - expect(partWay.Seat.z).toBeLessThan(rest); - }); - - it('sweeps the oars in opposite directions, as a sculler does', async () => { - const scull = await mountAt(MID_DRIVE); - const nodes = await scull.at(MID_DRIVE); - - expect(nodes.LeftOar.ry).toBeCloseTo(-nodes.RightOar.ry, 6); - - await scull.renderer.unmount(); - }); - - /** - * A crew model that does not carry the rig. - * - * Every node the frame loop touches is behind an `if`, and those guards are - * the difference between a scull that is missing an oar and a scene that - * throws sixty times a second. The GLB is downloaded at runtime and #266 and - * #267 both set the precedent: an asset that disappoints does not take the - * scene down with it. - */ - it('animates nothing, and throws nothing, when the model has no rig', async () => { - (globalThis as { __TEST_RIG_NODES?: string[] }).__TEST_RIG_NODES = []; - try { - const scull = await mountAt(0); - const atCatch = await scull.at(0); - const atFinish = await scull.at(STROKE_DRIVE_FRACTION - 0.001); - - // The wrapper nodes (`scull_root`, `scull`) are always present — the - // real GLB carries them for the glTF Y-up transform. What matters is - // that no rig node (`LeftOar`, `Seat`, `Rower_*`) appeared on a model - // that was told it has none. - const WRAPPERS = new Set(['scull_root', 'scull']); - const rigAt = (p: typeof atCatch) => - Object.keys(p).filter((n) => !WRAPPERS.has(n)); - expect(rigAt(atCatch), 'the bare model grew nodes').toEqual([]); - expect(rigAt(atFinish)).toEqual([]); - - await scull.renderer.unmount(); - } finally { - delete (globalThis as { __TEST_RIG_NODES?: string[] }).__TEST_RIG_NODES; - } - }); - - it('moves the rower rather than leaving them rigid while the oars swing', async () => { - const scull = await mountAt(0); - - const atCatch = await scull.at(0); - const atFinish = await scull.at(STROKE_DRIVE_FRACTION - 0.001); - - expect( - Math.abs(atFinish.Rower_Torso.rx - atCatch.Rower_Torso.rx), - 'the rower sat rigid while the boat rowed itself (#273)', - ).toBeGreaterThan(0.1); - expect( - Math.abs(atFinish.Rower_LeftArm.rx - atCatch.Rower_LeftArm.rx), - 'the arms never drew in', - ).toBeGreaterThan(0.1); - - await scull.renderer.unmount(); - }); -}); diff --git a/src/__tests__/graphicsPickerLocation.test.tsx b/src/__tests__/graphicsPickerLocation.test.tsx deleted file mode 100644 index fa85567b..00000000 --- a/src/__tests__/graphicsPickerLocation.test.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { afterAll, afterEach, beforeAll, beforeEach, describe, it, expect, vi } from 'vitest'; -import { render, screen, within } 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'; - -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(), - }; -} - -beforeEach(() => { - localStorage.clear(); -}); - -afterEach(() => { - vi.restoreAllMocks(); -}); - -/** - * #454 Phase 1 — the Graphics picker is no longer part of the Row screen - * furniture; it moved into the debug panel next to the debug-only session - * and simulator controls, where a rower goes when they want to override - * the probe (Phase 2 will teach the app to pick the tier from a runtime - * measurement; the picker is the escape hatch for that decision). - * - * The picker's own DOM re-uses the shared `.graphics-quality` fieldset - * class (CrewPicker does too), so location assertions target the picker - * by its unique `role="radiogroup"` / `aria-label="Graphics quality"`. - */ -describe('Graphics picker location (#454 Phase 1)', () => { - it('AC7 (FR6): the Row screen no longer renders the Graphics picker', () => { - render(); - // Debug panel is not open on a fresh render, so the picker should be - // nowhere in the DOM — the Row screen no longer carries one. - expect( - screen.queryByRole('radiogroup', { name: /Graphics quality/i }), - ).not.toBeInTheDocument(); - // `.demo-row-cta`, `.conditions-picker` remain — the layout just - // collapses where the picker was. - expect(document.querySelector('.demo-row-cta')).not.toBeNull(); - expect(document.querySelector('.conditions-picker')).not.toBeNull(); - }); - - it('AC7 (FR6): still no Graphics picker on the Row screen for signed-in athletes', () => { - vi.spyOn(UseAuth, 'useAuth').mockReturnValue(signedIn()); - render(); - expect( - screen.queryByRole('radiogroup', { name: /Graphics quality/i }), - ).not.toBeInTheDocument(); - }); - - it('AC5 (FR5): the debug panel carries a Graphics section beneath Session', async () => { - vi.spyOn(UseAuth, 'useAuth').mockReturnValue(signedIn()); - const user = userEvent.setup(); - render(); - - await user.click(screen.getByRole('button', { name: /Debug/i })); - - const graphicsHeading = screen.getByRole('heading', { name: /^Graphics$/, level: 5 }); - const sessionHeading = screen.getByRole('heading', { name: /^Session$/, level: 5 }); - // Session comes before Graphics in the panel. - expect( - sessionHeading.compareDocumentPosition(graphicsHeading) & - Node.DOCUMENT_POSITION_FOLLOWING, - ).toBeTruthy(); - - // The picker itself lives inside the Graphics `.debug-section`. - const graphicsSection = graphicsHeading.closest('.debug-section'); - expect(graphicsSection).not.toBeNull(); - const picker = within(graphicsSection as HTMLElement).getByRole('radiogroup', { - name: /Graphics quality/i, - }); - expect(picker).toBeInTheDocument(); - - // And exactly one Graphics-quality picker is present in the document - // (regression guard against a stray render on the Row screen). - expect(screen.getAllByRole('radiogroup', { name: /Graphics quality/i })).toHaveLength(1); - }); - - it('AC6 (FR5, D9): the picker keeps writing `virtualrow:graphics-quality`', async () => { - vi.spyOn(UseAuth, 'useAuth').mockReturnValue(signedIn()); - const user = userEvent.setup(); - render(); - - await user.click(screen.getByRole('button', { name: /Debug/i })); - - const picker = screen.getByRole('radiogroup', { name: /Graphics quality/i }); - await user.click(within(picker).getByRole('radio', { name: /^High$/ })); - - expect(localStorage.getItem('virtualrow:graphics-quality')).toBe('high'); - }); -}); diff --git a/src/__tests__/graphicsQualityPicker.test.tsx b/src/__tests__/graphicsQualityPicker.test.tsx deleted file mode 100644 index 0f426354..00000000 --- a/src/__tests__/graphicsQualityPicker.test.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { GraphicsQualityPicker } from '../components/GraphicsQualityPicker'; - -describe('GraphicsQualityPicker', () => { - it('offers the tiers as a labelled radio group', () => { - render(); - - const group = screen.getByRole('radiogroup', { name: /graphics quality/i }); - expect(group).toBeInTheDocument(); - // #455 FR6: six options — auto plus the five tiers (basic/low/medium/high/extra-high). - expect(screen.getAllByRole('radio')).toHaveLength(6); - }); - - it('marks the current choice as checked, and only that one', () => { - render(); - - expect(screen.getByRole('radio', { name: 'Basic' })).toBeChecked(); - expect(screen.getByRole('radio', { name: 'Auto' })).not.toBeChecked(); - expect(screen.getByRole('radio', { name: 'Extra High' })).not.toBeChecked(); - }); - - it('reports the tier the rower picked', async () => { - const onChange = vi.fn(); - render(); - - await userEvent.click(screen.getByRole('radio', { name: 'Extra High' })); - expect(onChange).toHaveBeenCalledWith('extra-high'); - }); - - it('explains what each tier does', () => { - render(); - expect(screen.getByRole('radio', { name: 'Auto' })).toHaveAttribute( - 'title', - 'Match the graphics card', - ); - expect(screen.getByRole('radio', { name: 'Basic' })).toHaveAttribute( - 'title', - 'No shadows or effects', - ); - }); -}); diff --git a/src/__tests__/groundPlane.test.ts b/src/__tests__/groundPlane.test.ts deleted file mode 100644 index 0ecd2527..00000000 --- a/src/__tests__/groundPlane.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import * as THREE from 'three'; -import { - GROUND_PLANE_MARGIN_METRES, - GROUND_PLANE_MINIMUM_METRES, - groundPlaneFor, -} from '../components/rower3d/groundPlane'; - -/** - * Issue #334 — there is ground everywhere the rower can see. - * - * The banks are strips whose outer reach is clamped on bends (#285), and past - * that reach there was nothing: the canvas cleared to transparent and the - * page's own CSS gradient showed through, as white tears between the bank and - * the horizon on every bend. - * - * What makes this worth a pure test rather than a look at the screen is that - * the failure is invisible until someone rows to the end of a long route. A - * plane centred on the origin covers a route that starts there and goes one - * way for six kilometres only half the time. - */ - -/** A route running away from the origin, as a real one does. */ -const offsetRoute = (fromX: number, toX: number) => - new THREE.CatmullRomCurve3([ - new THREE.Vector3(fromX, 0, 0), - new THREE.Vector3((fromX + toX) / 2, 0, -100), - new THREE.Vector3(toX, 0, -200), - ]); - -/** Whether a plane of this plan contains a point, in the XZ plane. */ -const covers = (plan: ReturnType, x: number, z: number) => { - const half = plan.size / 2; - return ( - Math.abs(x - plan.centre[0]) <= half && Math.abs(z - plan.centre[1]) <= half - ); -}; - -describe('groundPlaneFor', () => { - it('covers a route that runs away from the origin', () => { - const plan = groundPlaneFor(offsetRoute(3000, 9000)); - - expect(covers(plan, 3000, 0), 'the start of the route is off the world').toBe(true); - expect(covers(plan, 9000, -200), 'the end of the route is off the world').toBe(true); - }); - - it('reaches a margin past the furthest the rower can get', () => { - const plan = groundPlaneFor(offsetRoute(0, 1000)); - - // As far past the end as the fog can see, so its own edge is never in shot. - expect(covers(plan, 1000 + GROUND_PLANE_MARGIN_METRES - 1, 0)).toBe(true); - }); - - it('builds something even before there is a route', () => { - const plan = groundPlaneFor(null); - - expect(plan.centre).toEqual([0, 0]); - expect(plan.size).toBe(GROUND_PLANE_MINIMUM_METRES); - }); - - it('does not shrink below the minimum for a very short route', () => { - expect(groundPlaneFor(offsetRoute(0, 10)).size).toBeGreaterThanOrEqual( - GROUND_PLANE_MINIMUM_METRES, - ); - }); -}); diff --git a/src/__tests__/groundPlaneRelief.test.ts b/src/__tests__/groundPlaneRelief.test.ts deleted file mode 100644 index ddfab6b0..00000000 --- a/src/__tests__/groundPlaneRelief.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import * as THREE from 'three'; -import { - GROUND_PLANE_RELIEF_AMPLITUDE_M, - GROUND_PLANE_RELIEF_TAPER_METRES, - GROUND_PLANE_SEGMENTS, - groundPlaneReliefFor, -} from '../components/rower3d/groundPlaneRelief'; -import { groundPlaneFor } from '../components/rower3d/groundPlane'; - -/** - * Issue #431 — the field beyond the bank is not a mirror-flat sheet. - * - * VR-14's ground plane is one big quad. #202 gives the bank strip real - * elevations, but past the bank strip the world reads as glass, and on a - * long open bend the horizon is a straight line where a rolling field - * should be. This adds low-frequency vertex noise on a 64×64 grid, tapered - * to zero within 12 m of the waterline so it never opens a seam with the - * bank strip. - */ - -const routeAlongX = () => - new THREE.CatmullRomCurve3([ - new THREE.Vector3(-400, 0, 0), - new THREE.Vector3(0, 0, 0), - new THREE.Vector3(400, 0, 0), - ]); - -const distanceToRoute = (x: number, z: number, curve: THREE.CatmullRomCurve3) => { - const points = curve.getPoints(256); - let min = Infinity; - for (const p of points) { - const d = Math.hypot(p.x - x, p.z - z); - if (d < min) min = d; - } - return min; -}; - -describe('groundPlaneReliefFor (#431)', () => { - it('lays out a 64×64 grid at auto', () => { - const curve = routeAlongX(); - const plan = groundPlaneFor(curve); - - const vertices = groundPlaneReliefFor(plan, curve, 'medium'); - - const expected = (GROUND_PLANE_SEGMENTS + 1) ** 2; - expect(vertices.length).toBe(expected); - }); - - it('holds every vertex within the taper distance at y = 0', () => { - const curve = routeAlongX(); - const plan = groundPlaneFor(curve); - - const vertices = groundPlaneReliefFor(plan, curve, 'medium'); - - for (const v of vertices) { - const dist = distanceToRoute(v.x, v.z, curve); - if (dist < GROUND_PLANE_RELIEF_TAPER_METRES) { - expect( - Math.abs(v.y), - `a vertex ${dist.toFixed(1)} m from the water is at y = ${v.y}`, - ).toBeLessThan(0.01); - } - } - }); - - it('keeps every vertex within the amplitude', () => { - const curve = routeAlongX(); - const plan = groundPlaneFor(curve); - - const vertices = groundPlaneReliefFor(plan, curve, 'medium'); - - for (const v of vertices) { - expect(Math.abs(v.y)).toBeLessThanOrEqual(GROUND_PLANE_RELIEF_AMPLITUDE_M + 1e-6); - } - }); - - it('actually displaces the field past the taper', () => { - const curve = routeAlongX(); - const plan = groundPlaneFor(curve); - - const vertices = groundPlaneReliefFor(plan, curve, 'medium'); - - const displaced = vertices.filter((v) => Math.abs(v.y) > 0.1); - // A 64×64 grid over a kilometres-wide plan puts most vertices well past - // the 12 m taper. If almost none are displaced the mask has swallowed - // the whole plane, and the FR is defeated. - expect(displaced.length).toBeGreaterThan(500); - }); - - it('returns a flat plane at low', () => { - const curve = routeAlongX(); - const plan = groundPlaneFor(curve); - - const vertices = groundPlaneReliefFor(plan, curve, 'basic'); - - for (const v of vertices) { - expect(v.y).toBe(0); - } - }); - - it('returns a flat plane when there is no route yet', () => { - const plan = groundPlaneFor(null); - - const vertices = groundPlaneReliefFor(plan, null, 'medium'); - - for (const v of vertices) { - expect(v.y).toBe(0); - } - }); - - it('is deterministic — the same route builds the same vertices', () => { - const curve = routeAlongX(); - const plan = groundPlaneFor(curve); - - const a = groundPlaneReliefFor(plan, curve, 'medium'); - const b = groundPlaneReliefFor(plan, curve, 'medium'); - - expect(a).toEqual(b); - }); -}); diff --git a/src/__tests__/guestSessionSummary.test.tsx b/src/__tests__/guestSessionSummary.test.tsx deleted file mode 100644 index 2281200b..00000000 --- a/src/__tests__/guestSessionSummary.test.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { render, screen, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -vi.mock('react-chartjs-2', () => ({ - Line: () => , -})); - -import { GuestSessionSummary } from '../components/GuestSessionSummary'; -import type { WorkoutSession } from '../lib/types/index'; - -function makeSession(overrides: Partial = {}): WorkoutSession { - return { - id: 's1', - routeId: '1', - routeName: 'Willowbrook River', - startTime: new Date('2026-01-02T10:00:00Z'), - endTime: new Date('2026-01-02T10:20:00Z'), - duration: 1200, - distance: 4200, - averagePace: 124, - calories: 180, - splits: [], - isActive: false, - samples: [], - ...overrides, - }; -} - -describe('GuestSessionSummary', () => { - it('names exactly what signing in would have preserved (TD-3)', () => { - render( - , - ); - - const copy = screen.getByText(/signing in with intervals\.icu/i); - expect(copy).toHaveTextContent(/distance/i); - expect(copy).toHaveTextContent(/time/i); - expect(copy).toHaveTextContent(/average pace/i); - expect(copy).toHaveTextContent(/calories/i); - expect(copy).toHaveTextContent(/heart rate/i); - }); - - it('omits heart rate from the list when the session captured none', () => { - render( - , - ); - - const copy = screen.getByText(/signing in with intervals\.icu/i); - expect(copy).toHaveTextContent(/calories/i); - expect(copy).not.toHaveTextContent(/heart rate/i); - }); - - it('states plainly that the session was not saved', () => { - render(); - expect(screen.getByText(/has not been saved/i)).toBeInTheDocument(); - }); - - it('marks a demo row as simulated so it is never read as a real session (TD-2)', () => { - render(); - - expect(screen.getByText('Demo Row')).toBeInTheDocument(); - expect(screen.getByText(/demo on simulated data/i)).toBeInTheDocument(); - }); - - it('labels a non-demo session as a guest session', () => { - render(); - expect(screen.getByText('Guest Session')).toBeInTheDocument(); - }); - - it('offers sign-in when a handler is supplied, and calls it', async () => { - const user = userEvent.setup(); - const onSignIn = vi.fn(); - render( - , - ); - - await user.click(screen.getByRole('button', { name: /sign in to save future rows/i })); - expect(onSignIn).toHaveBeenCalledTimes(1); - }); - - it('omits the sign-in control when no handler is supplied', () => { - render(); - expect(screen.queryByRole('button', { name: /sign in/i })).toBeNull(); - }); - - it('still offers Row Again and Done', async () => { - const user = userEvent.setup(); - const onRowAgain = vi.fn(); - const onExit = vi.fn(); - render(); - - await user.click(screen.getByRole('button', { name: /row again/i })); - await user.click(screen.getByRole('button', { name: /done/i })); - expect(onRowAgain).toHaveBeenCalledTimes(1); - expect(onExit).toHaveBeenCalledTimes(1); - }); -}); - -describe('GuestSessionSummary — the row itself (#337)', () => { - const samples = Array.from({ length: 501 }, (_, t) => ({ t, distance: t * 4, pace: 125, cadence: 24 })); - - it('shows the splits and the chart a signed-in rower gets', () => { - render(); - const rows = within(screen.getByRole('table', { name: 'Splits' })).getAllByRole('row').slice(1); - expect(rows).toHaveLength(4); - expect(screen.getByTestId('row-chart')).toBeInTheDocument(); - }); - - it('makes no comparison with a best a guest has nowhere to keep', () => { - render(); - expect(screen.queryByTestId('row-pb')).toBeNull(); - }); -}); diff --git a/src/__tests__/heartRateChart.test.tsx b/src/__tests__/heartRateChart.test.tsx deleted file mode 100644 index 287e09f2..00000000 --- a/src/__tests__/heartRateChart.test.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; - -// Mock react-chartjs-2 Line component to avoid relying on Canvas in tests -vi.mock('react-chartjs-2', () => ({ - Line: (_props: unknown) =>
Chart
-})); - -import HeartRateChart from '../components/HeartRateChart'; - -const sampleData = [ - { bpm: 80, timestamp: new Date() }, - { bpm: 90, timestamp: new Date() }, - { bpm: 100, timestamp: new Date() }, -]; - -describe('HeartRateChart', () => { - it('renders chart placeholder and matches snapshot', () => { - const { container } = render(); - const chart = screen.getByTestId('chart'); - expect(chart).toBeInTheDocument(); - expect(container.firstChild).toMatchSnapshot(); - }); -}); diff --git a/src/__tests__/heartRateMonitor.test.tsx b/src/__tests__/heartRateMonitor.test.tsx deleted file mode 100644 index 5470879b..00000000 --- a/src/__tests__/heartRateMonitor.test.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen, act, waitFor } from '@testing-library/react'; -import HeartRateMonitor from '../components/HeartRateMonitor'; -import { heartRateBluetoothService } from '../lib/services/heartRateBluetoothService'; - -type TestableService = { - listeners: Map; - samples: unknown[]; -}; - -describe('HeartRateMonitor component', () => { - beforeEach(() => { - // clear any existing listeners and samples - (heartRateBluetoothService as unknown as TestableService).listeners = new Map(); - (heartRateBluetoothService as unknown as TestableService).samples = []; - }); - - afterEach(() => { - // ensure unmount clears listeners - (heartRateBluetoothService as unknown as TestableService).listeners = new Map(); - (heartRateBluetoothService as unknown as TestableService).samples = []; - }); - - it('shows connection state on connected/disconnected events', async () => { - render(); - - // Initially disconnected - expect(screen.getByText(/Disconnected/i)).toBeInTheDocument(); - - // Emit connected — state update is RAF-deferred to avoid WS/CDP stack overflows - act(() => heartRateBluetoothService.simulateConnected('test')); - - // Wait for the RAF callback to fire and React to re-render - const statusElement = document.querySelector('.device-status'); - await waitFor(() => { - expect(statusElement).toHaveClass('connected'); - }, { timeout: 500 }); - expect(screen.getByText('Connected')).toBeInTheDocument(); - - // Emit disconnected - act(() => heartRateBluetoothService.simulateDisconnected()); - await waitFor(() => { - expect(statusElement).toHaveClass('disconnected'); - }, { timeout: 500 }); - }); - - it('invokes onSample callback and updates metrics on heartRate event', async () => { - const onSample = vi.fn(); - render(); - - // Emit a heart rate sample — onSample is called synchronously; UI updates are RAF-deferred - // Cast to access the private emit method without using `any` - type TestEmitter = { emit: (event: string, data: unknown) => void }; - act(() => (heartRateBluetoothService as unknown as TestEmitter).emit('heartRate', { bpm: 84 })); - - expect(onSample).toHaveBeenCalled(); - expect(onSample.mock.calls[0][0]).toBe(84); - - // Wait for RAF to fire and React to update the metric display - await waitFor(() => { - expect(screen.getByText('84')).toBeInTheDocument(); - }, { timeout: 500 }); - - expect(screen.getByText(/Current/)).toBeInTheDocument(); - const metricUnits = screen.getAllByText('bpm'); - expect(metricUnits.length).toBeGreaterThan(0); - }); -}); diff --git a/src/__tests__/lakeGeometry.test.ts b/src/__tests__/lakeGeometry.test.ts deleted file mode 100644 index 2cc8eb68..00000000 --- a/src/__tests__/lakeGeometry.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; -import * as THREE from 'three'; -import { afterEach, describe, expect, it } from 'vitest'; -import { - createLakeLandGeometry, - createLakeWaterGeometry, - isLakeOutlineEnabled, - LAKE_SHORE_RISE_METERS, - projectRing, -} from '../components/rower3d/lakeGeometry'; -import { BANK_WATERLINE_Y } from '../components/rower3d/bankGeometry'; -import { WATER_SURFACE_Y } from '../components/rower3d/waterGeometry'; -import type { OverpassElement } from '../lib/services/routeEnrichmentService'; -import type { Coordinate } from '../lib/types/index'; -import type { LakePolygon } from '../lib/utils/lakePolygons'; -import type { MaskBounds } from '../lib/utils/osmWaterMask'; -import { runRouteDetection } from '../lib/utils/routeDetection'; - -/** - * #493 Phase 3 (FR4, FR5, FR6, AC14, NFR7): a lake drawn from its outline, - * asserted on the geometry without a WebGL context. - */ - -const M = 1 / 111_320; -const origin: Coordinate = { lat: 0, lng: 0 }; -const at = (x: number, y: number): Coordinate => ({ lat: y * M, lng: x * M }); -const square = (x0: number, y0: number, x1: number, y1: number) => [at(x0, y0), at(x1, y0), at(x1, y1), at(x0, y1), at(x0, y0)]; - -// 400 m square of water around the origin, with a 40 m island at (100, 100) m. -const lake: LakePolygon = { - featureId: 'way/1', - outer: square(-200, -200, 200, 200), - inner: [square(80, 80, 120, 120)], -}; - -const triangles = (geometry: THREE.BufferGeometry) => { - const position = geometry.getAttribute('position'); - const index = geometry.getIndex()!; - const vertex = (i: number) => new THREE.Vector3().fromBufferAttribute(position, index.getX(i)); - return Array.from({ length: index.count / 3 }, (_, t) => [vertex(t * 3), vertex(t * 3 + 1), vertex(t * 3 + 2)] as const); -}; - -const faceNormal = ([a, b, c]: readonly [THREE.Vector3, THREE.Vector3, THREE.Vector3]) => - new THREE.Vector3().subVectors(b, a).cross(new THREE.Vector3().subVectors(c, a)); - -const areaXZ = (geometry: THREE.BufferGeometry) => - triangles(geometry).reduce((sum, [a, b, c]) => sum + Math.abs((b.x - a.x) * (c.z - a.z) - (c.x - a.x) * (b.z - a.z)) / 2, 0); - -const covers = (geometry: THREE.BufferGeometry, x: number, z: number) => - triangles(geometry).some(([a, b, c]) => { - const side = (p: THREE.Vector3, q: THREE.Vector3) => (q.x - p.x) * (z - p.z) - (q.z - p.z) * (x - p.x); - const [d1, d2, d3] = [side(a, b), side(b, c), side(c, a)]; - return !((d1 < 0 || d2 < 0 || d3 < 0) && (d1 > 0 || d2 > 0 || d3 > 0)); - }); - -describe('#493 Phase 3 lake outline flag (NFR7)', () => { - afterEach(() => { - delete window.__VIRTUALROW_LAKE_OUTLINES; - }); - - it('is off unless turned on', () => { - expect(isLakeOutlineEnabled()).toBe(false); - window.__VIRTUALROW_LAKE_OUTLINES = true; - expect(isLakeOutlineEnabled()).toBe(true); - }); -}); - -describe('#493 Phase 3 lake projection', () => { - it('puts the route origin at zero, east on +x and north on -z, without the closing repeat', () => { - const ring = projectRing(square(0, 0, 100, 100), origin); - expect(ring).toHaveLength(4); - expect(ring[0].x).toBeCloseTo(0, 6); - expect(ring[1].x).toBeCloseTo(100, 0); - expect(ring[2].y).toBeCloseTo(-100, 0); - }); -}); - -describe('#493 Phase 3 lake water (FR4, AC14)', () => { - const water = createLakeWaterGeometry([lake], origin); - - it('is flat at the water height and faces the sky', () => { - const position = water.getAttribute('position'); - for (let i = 0; i < position.count; i++) expect(position.getY(i)).toBeCloseTo(WATER_SURFACE_Y, 6); - expect(triangles(water).every((triangle) => faceNormal(triangle).y > 0)).toBe(true); - }); - - it('covers the lake and leaves the island dry', () => { - expect(areaXZ(water)).toBeCloseTo(400 * 400 - 40 * 40, -2); - expect(covers(water, 0, 0)).toBe(true); - expect(covers(water, 100, -100)).toBe(false); - expect(covers(water, 300, 0)).toBe(false); - }); - - it('builds nothing from no lake', () => { - expect(createLakeWaterGeometry([], origin).getAttribute('position').count).toBe(0); - }); -}); - -describe('#493 Phase 3 lake shore and islands (FR5, FR6, D6 a)', () => { - const land = createLakeLandGeometry([lake], origin, { width: 30 }); - const position = land.getAttribute('position'); - const points = Array.from({ length: position.count }, (_, i) => new THREE.Vector3().fromBufferAttribute(position, i)); - - it('climbs from the waterline away from the water, facing the sky', () => { - expect(Math.min(...points.map((point) => point.y))).toBeCloseTo(BANK_WATERLINE_Y, 6); - expect(Math.max(...points.map((point) => point.y))).toBeCloseTo(BANK_WATERLINE_Y + LAKE_SHORE_RISE_METERS, 6); - expect(triangles(land).every((triangle) => faceNormal(triangle).y > 0)).toBe(true); - // The shore band runs out to 30 m beyond the 200 m square, never into the lake. - expect(Math.max(...points.map((point) => point.x))).toBeCloseTo(230, 0); - expect(covers(land, 0, 0)).toBe(false); - expect(covers(land, 215, 0)).toBe(true); - }); - - it('draws the island as land, with a top inside its shore', () => { - expect(covers(land, 100, -100)).toBe(true); - const top = points.filter((point) => Math.abs(point.y - (BANK_WATERLINE_Y + LAKE_SHORE_RISE_METERS)) < 1e-6 && point.x > 70 && point.x < 130); - expect(top.length).toBeGreaterThan(0); - expect(top.every((point) => point.x >= 80 && point.x <= 120 && point.z <= -80 && point.z >= -120)).toBe(true); - }); - - it('carries the bank material\'s attributes', () => { - expect(land.getAttribute('uv')).toBeDefined(); - expect(land.getAttribute('shoreDist')).toBeDefined(); - expect(land.getAttribute('normal')).toBeDefined(); - }); -}); - -describe('#493 Phase 3 lake geometry on Lake Quinsigamond (course 1)', () => { - const fixture = JSON.parse(readFileSync(join(process.cwd(), 'src/__tests__/fixtures/osm/1.json'), 'utf8')) as { - route: Coordinate[]; - bounds: MaskBounds; - elements: OverpassElement[]; - }; - const { lakes } = runRouteDetection({ coordinates: fixture.route, elements: fixture.elements, coastlineBounds: fixture.bounds }); - - it('builds water under every route point and land around it, with no broken vertices', () => { - const water = createLakeWaterGeometry(lakes, fixture.route[0]); - const land = createLakeLandGeometry(lakes, fixture.route[0]); - for (const geometry of [water, land]) { - const position = geometry.getAttribute('position'); - expect(position.count).toBeGreaterThan(0); - expect(Array.from(position.array as Float32Array).every(Number.isFinite)).toBe(true); - } - const onWater = fixture.route.filter((point) => { - const [p] = projectRing([point], fixture.route[0]); - return covers(water, p.x, p.y); - }); - // NFR8 found 69% of course 1's route points on mapped water: the straight - // line between its gates cuts across the bends. - expect(onWater.length / fixture.route.length).toBeGreaterThanOrEqual(0.69); - }); -}); diff --git a/src/__tests__/lakeGuides.test.tsx b/src/__tests__/lakeGuides.test.tsx deleted file mode 100644 index 988465ec..00000000 --- a/src/__tests__/lakeGuides.test.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { render } from '@testing-library/react'; -import * as THREE from 'three'; -import { describe, expect, it } from 'vitest'; -import { RiverGuides } from '../components/rower3d/RiverGuides'; -import { buildLakeGuides } from '../components/rower3d/riverGuideGeometry'; -import { waterDetectionTelemetry, type RouteEnrichmentData } from '../lib/services/routeEnrichmentService'; -import type { Coordinate } from '../lib/types/index'; -import type { LakePolygon } from '../lib/utils/lakePolygons'; - -/** - * #493 FR22 / AC23: the debug guides draw the lake's detected shoreline and - * its islands, and the telemetry log carries what detection found. - */ - -const M = 1 / 111_320; -const origin: Coordinate = { lat: 0, lng: 0 }; -const at = (x: number, y: number): Coordinate => ({ lat: y * M, lng: x * M }); -const square = (x0: number, y0: number, x1: number, y1: number) => [at(x0, y0), at(x1, y0), at(x1, y1), at(x0, y1), at(x0, y0)]; -const lake: LakePolygon = { - featureId: 'way/1', - outer: square(-200, -200, 200, 200), - inner: [square(80, 80, 120, 120), square(-120, -120, -80, -80)], -}; - -const enrichment = (overrides: Partial = {}): RouteEnrichmentData => ({ - routeId: 'lake-guides', - elevations: [], - segmentProfiles: [], - waterBodyType: 'lake', - waterWidthMeters: 120, - waterColor: '#335577', - waveIntensity: 0.3, - fetchedAt: 0, - source: 'network', - ...overrides, -}); - -describe('#493 AC23 lake guides', () => { - it('traces each shoreline and island as a closed loop above the water', () => { - const { shores, islands } = buildLakeGuides([lake], origin); - expect(shores).toHaveLength(1); - expect(islands).toHaveLength(2); - for (const loop of [...shores, ...islands]) { - expect(loop[0].distanceTo(loop[loop.length - 1])).toBeLessThan(1e-6); - expect(loop.every((point) => point.y > 0)).toBe(true); - } - // East on +x, north on -z, like the route curve. - expect(Math.max(...shores[0].map((p) => p.x))).toBeCloseTo(200, 0); - expect(Math.min(...islands[0].map((p) => p.z))).toBeCloseTo(-120, 0); - }); - - it('draws nothing without a lake', () => { - expect(buildLakeGuides(undefined, origin)).toEqual({ shores: [], islands: [] }); - expect(buildLakeGuides([lake], null)).toEqual({ shores: [], islands: [] }); - }); - - it('adds a line per shoreline and island to the channel guides', () => { - const curve = new THREE.CatmullRomCurve3([new THREE.Vector3(0, 0, -100), new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 0, 100)]); - const { container } = render( - , - ); - expect(container.querySelectorAll('primitive').length).toBe(3 + 1 + 2); - }); -}); - -describe('#493 FR22 detection summary in the telemetry log', () => { - it('reports the type, the sample counts, and the lakes and islands drawn', () => { - const detail = waterDetectionTelemetry( - enrichment({ - waterDetection: { shares: { lake: 0.9, river: 0.1 }, water: 40, land: 1, unknown: 2 }, - lakes: { polygons: [lake], elevationMeters: 12 }, - }), - ); - expect(detail).toEqual({ - waterBodyType: 'lake', - source: 'network', - shares: { lake: 0.9, river: 0.1 }, - water: 40, - land: 1, - unknown: 2, - lakes: 1, - islands: 2, - }); - }); - - it('reports nothing for fallback data, which has no detection', () => { - expect(waterDetectionTelemetry(enrichment({ source: 'fallback' }))).toBeNull(); - expect(waterDetectionTelemetry(null)).toBeNull(); - }); -}); diff --git a/src/__tests__/lakePhysicsIsolation.test.tsx b/src/__tests__/lakePhysicsIsolation.test.tsx deleted file mode 100644 index 12ca3b8c..00000000 --- a/src/__tests__/lakePhysicsIsolation.test.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { act, renderHook } from '@testing-library/react'; -import { usePhysicsEngine } from '../hooks/usePhysicsEngine'; -import { RouteEnrichmentService } from '../lib/services/routeEnrichmentService'; -import { runDetectionInline } from '../lib/services/routeDetectionRunner'; -import type { WaterRoute } from '../lib/types/index'; - -/** - * #493 NFR4 / AC11: a stalled Overpass fetch never reaches the frame loop. - * The same ticks give the same boat with and without a fetch hanging for 5 s. - */ -const TICK = 1 / 60; -const FRAMES = 5 * 60; -const pace = { pace: 120, power: undefined, cadence: 28, distance: 0, elapsedTime: 0 }; - -const row = (between: () => void = () => {}) => { - const { result } = renderHook(() => usePhysicsEngine()); - for (let frame = 0; frame < FRAMES; frame++) { - act(() => { - result.current.dispatchTick(TICK, pace); - }); - between(); - } - const { positionM, velocityMps, strokeCycleT } = result.current.boatStateRef.current; - return { positionM, velocityMps, strokeCycleT }; -}; - -describe('#493 physics isolation (NFR4, AC11)', () => { - afterEach(() => { - vi.useRealTimers(); - }); - - it('rows the same 5 s while an Overpass fetch stalls', async () => { - const alone = row(); - - vi.useFakeTimers(); - let settled = false; - const stalled = vi.fn( - () => new Promise((_resolve, reject) => setTimeout(() => reject(new Error('timed out')), 5_000)), - ) as unknown as typeof fetch; - const service = new RouteEnrichmentService(stalled, null, runDetectionInline); - const route = { - id: 'stalled', - name: 'Stalled', - tags: ['lake'], - coordinates: [ - { lat: 47.5, lng: 8.5 }, - { lat: 47.51, lng: 8.5 }, - ], - } as unknown as WaterRoute; - const pending = service.enrichRoute(route).then((enrichment) => { - settled = true; - return enrichment; - }); - - const withFetch = row(() => { - vi.advanceTimersByTime(TICK * 1000); - }); - - expect(stalled).toHaveBeenCalled(); - await vi.advanceTimersByTimeAsync(1_000); - const enrichment = await pending; - expect(settled).toBe(true); - expect(enrichment.source).toBe('fallback'); - - expect(withFetch.positionM).toBeCloseTo(alone.positionM, 3); - expect(withFetch.velocityMps).toBeCloseTo(alone.velocityMps, 3); - expect(withFetch.strokeCycleT).toBeCloseTo(alone.strokeCycleT, 3); - }); -}); diff --git a/src/__tests__/lakeRibbon.test.ts b/src/__tests__/lakeRibbon.test.ts deleted file mode 100644 index 75faeac4..00000000 --- a/src/__tests__/lakeRibbon.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import * as THREE from 'three'; -import { describe, expect, it } from 'vitest'; -import type { Coordinate } from '../lib/types/index'; -import { createRouteCurve } from '../components/rower3d/curve'; -import { SCENE_SCALE } from '../components/rower3d/constants'; -import { drawsStripQuad, type StripGeometryOptions } from '../components/rower3d/routeStripGeometry'; -import { createWaterChannelGeometry } from '../components/rower3d/waterGeometry'; -import { createBankGeometry, createShorelineGeometry } from '../components/rower3d/bankGeometry'; -import { - isOnLakeAtProgress, - type RouteEnrichmentData, - type RouteSegmentEnrichment, -} from '../lib/services/routeEnrichmentService'; - -/** - * #493 Phase 3 (D5 a): on a lake route drawn from its outline, the ribbon, its - * banks and its foam strip leave the lake stretch to the lake's own surface - * and shore, and keep the river stretches either side of it. - */ - -const LENGTH = 2000; -const SEGMENTS = 10; -const route: Coordinate[] = Array.from({ length: 50 }, (_, i) => ({ lat: 50 + (i / 49) * (LENGTH / 111_195), lng: 0 })); -const curve = createRouteCurve(route, SCENE_SCALE)!; - -const segment = (index: number): RouteSegmentEnrichment => ({ - index, - startMeters: index * (LENGTH / SEGMENTS), - endMeters: (index + 1) * (LENGTH / SEGMENTS), - sceneryProfile: 'fallback', - treeDensity: 0.5, - vegetationDensity: 0.5, - buildingDensity: 0.1, - objectScale: 1, - waterWidthMeters: 55, - dragMultiplier: 1, - bearing: 0, - bearingDelta: 0, - // The middle of the route is on the lake. - ...(index >= 3 && index <= 6 && { onLake: true as const }), -}); - -const enrichment: RouteEnrichmentData = { - routeId: 'lake-ribbon', - elevations: [], - segmentProfiles: Array.from({ length: SEGMENTS }, (_, i) => segment(i)), - waterBodyType: 'lake', - waterWidthMeters: 55, - waterColor: '#335577', - waveIntensity: 0.3, - lakes: { polygons: [{ featureId: 'way/1', outer: [], inner: [] }], elevationMeters: 0 }, - fetchedAt: 0, - source: 'network', -}; - -/** How far along the route each triangle's centre is (the route runs north, so -z). */ -const triangleProgress = (geometry: THREE.BufferGeometry) => { - const position = geometry.getAttribute('position'); - const index = geometry.getIndex()!; - return Array.from({ length: index.count / 3 }, (_, t) => { - let z = 0; - for (let k = 0; k < 3; k++) z += position.getZ(index.getX(t * 3 + k)); - return -z / 3 / LENGTH; - }); -}; - -describe('#493 Phase 3 lake stretch of a route (D5 a)', () => { - it('reads the nearest segment\'s lake mark at a progress', () => { - expect(isOnLakeAtProgress(enrichment.segmentProfiles, 0)).toBe(false); - expect(isOnLakeAtProgress(enrichment.segmentProfiles, 0.5)).toBe(true); - expect(isOnLakeAtProgress(enrichment.segmentProfiles, 1)).toBe(false); - expect(isOnLakeAtProgress(undefined, 0.5)).toBe(false); - expect(isOnLakeAtProgress([], 0.5)).toBe(false); - }); - - it('draws every quad unless asked to leave the lake out, and then only those with an end off it', () => { - const omit: StripGeometryOptions = { enrichment, omitLake: true }; - expect(drawsStripQuad({ enrichment }, 0.45, 0.5)).toBe(true); - expect(drawsStripQuad(omit, 0.45, 0.5)).toBe(false); - expect(drawsStripQuad(omit, 0.1, 0.15)).toBe(true); - // A quad crossing onto the lake is kept, so the ribbon meets the lake's edge. - expect(drawsStripQuad(omit, 0.25, 0.4)).toBe(true); - }); - - it.each([ - ['water', (options: StripGeometryOptions) => createWaterChannelGeometry(curve, options)], - ['left bank', (options: StripGeometryOptions) => createBankGeometry(curve, 'left', options)], - ['right bank', (options: StripGeometryOptions) => createBankGeometry(curve, 'right', options)], - ['shoreline', (options: StripGeometryOptions) => createShorelineGeometry(curve, 'left', options)], - ])('leaves the lake stretch out of the %s and keeps the river either side', (_, build) => { - const whole = triangleProgress(build({ enrichment })); - const omitted = triangleProgress(build({ enrichment, omitLake: true })); - const onLake = (p: number) => p > 0.4 && p < 0.6; - expect(whole.some(onLake)).toBe(true); - expect(omitted.some(onLake)).toBe(false); - expect(omitted.filter((p) => p < 0.2)).toHaveLength(whole.filter((p) => p < 0.2).length); - expect(omitted.filter((p) => p > 0.8)).toHaveLength(whole.filter((p) => p > 0.8).length); - }); -}); diff --git a/src/__tests__/lakeRouteOnWater.test.ts b/src/__tests__/lakeRouteOnWater.test.ts deleted file mode 100644 index e245fa86..00000000 --- a/src/__tests__/lakeRouteOnWater.test.ts +++ /dev/null @@ -1,183 +0,0 @@ -import * as THREE from 'three'; -import { describe, expect, it } from 'vitest'; -import { - createLakeLandGeometry, - createLakeWaterGeometry, - ribbonClearings, - type LakeClearing, -} from '../components/rower3d/lakeGeometry'; -import { SCENE_SCALE } from '../components/rower3d/constants'; -import { - applyMeasuredShores, - lakeLandSamples, - waterDetectionTelemetry, - type RouteEnrichmentData, - type RouteSegmentEnrichment, -} from '../lib/services/routeEnrichmentService'; -import type { Coordinate } from '../lib/types/index'; -import type { LakePolygon } from '../lib/utils/lakePolygons'; -import type { DetectedSample } from '../lib/utils/shoreDetection'; - -/** - * #493 Phase 4 (FR8, D21 b + ii, AC6): a route sample on land past the drift - * tolerance never puts the boat on land. That stretch keeps the ribbon, the - * lake's land opens to let it through, and the samples are reported. - */ - -const M = 1 / 111_320; -const origin: Coordinate = { lat: 0, lng: 0 }; -const at = (x: number, y: number): Coordinate => ({ lat: y * M, lng: x * M }); -const square = (x0: number, y0: number, x1: number, y1: number) => [at(x0, y0), at(x1, y0), at(x1, y1), at(x0, y1), at(x0, y0)]; - -// 400 m square of water around the origin, with a 40 m island at (100, 100) m. -const lake: LakePolygon = { featureId: 'way/1', outer: square(-200, -200, 200, 200), inner: [square(80, 80, 120, 120)] }; - -const segment = (index: number, overrides: Partial = {}): RouteSegmentEnrichment => ({ - index, - startMeters: index * 50, - endMeters: (index + 1) * 50, - sceneryProfile: 'fallback', - treeDensity: 0.5, - vegetationDensity: 0.5, - buildingDensity: 0.1, - objectScale: 1, - waterWidthMeters: 55, - dragMultiplier: 1, - bearing: 0, - bearingDelta: 0, - ...overrides, -}); - -const sample = (distanceMeters: number, overrides: Partial = {}): DetectedSample => ({ - distanceMeters, - coordinate: at(distanceMeters, 0), - kind: 'water', - featureId: 'way/1', - leftShoreMeters: 15, - rightShoreMeters: 45, - ...overrides, -}); - -const lakeIds = new Set(['way/1']); - -/** Scene x/z of a point `x` m east and `y` m north of the origin. */ -const scene = (x: number, y: number) => ({ x: x * SCENE_SCALE, z: -y * SCENE_SCALE }); - -const vertices = (geometry: THREE.BufferGeometry) => { - const position = geometry.getAttribute('position'); - return Array.from({ length: position.count }, (_, i) => new THREE.Vector3().fromBufferAttribute(position, i)); -}; - -const triangleCentres = (geometry: THREE.BufferGeometry) => { - const position = geometry.getAttribute('position'); - const index = geometry.getIndex()!; - return Array.from({ length: index.count / 3 }, (_, t) => { - const centre = new THREE.Vector3(); - for (let k = 0; k < 3; k++) centre.add(new THREE.Vector3().fromBufferAttribute(position, index.getX(t * 3 + k))); - return centre.divideScalar(3); - }); -}; - -const areaXZ = (geometry: THREE.BufferGeometry) => { - const position = geometry.getAttribute('position'); - const index = geometry.getIndex()!; - let sum = 0; - for (let t = 0; t < index.count / 3; t++) { - const [a, b, c] = [0, 1, 2].map((k) => new THREE.Vector3().fromBufferAttribute(position, index.getX(t * 3 + k))); - sum += Math.abs((b.x - a.x) * (c.z - a.z) - (c.x - a.x) * (b.z - a.z)) / 2; - } - return sum; -}; - -describe('#493 FR8 a lake segment with a sample on land keeps the ribbon', () => { - it('marks a segment on the lake only when none of its samples is on land', () => { - const profiles = applyMeasuredShores( - [segment(0), segment(1)], - [sample(0), sample(20), sample(40), sample(55), sample(70, { kind: 'land', featureId: undefined }), sample(85)], - lakeIds, - ); - expect(profiles[0].onLake).toBe(true); - expect(profiles[1].onLake).toBeUndefined(); - }); - - it('reports the land samples that kept a lake segment off the lake', () => { - const samples = [sample(0), sample(20), sample(55), sample(70, { kind: 'land', featureId: undefined }), sample(85)]; - expect(lakeLandSamples([segment(0), segment(1)], samples, lakeIds)).toEqual([at(70, 0)]); - }); - - it('reports nothing on a route that never leaves its lake', () => { - expect(lakeLandSamples([segment(0)], [sample(0), sample(20)], lakeIds)).toEqual([]); - }); - - it('logs how many samples were off the water, and where (FR22)', () => { - const enrichment = { - routeId: 'r', - elevations: [], - segmentProfiles: [], - waterBodyType: 'lake', - waterWidthMeters: 100, - waterColor: '#335577', - waveIntensity: 0.3, - lakes: { polygons: [lake], elevationMeters: 0, offWater: [at(70, 0)] }, - fetchedAt: 0, - source: 'network', - } as RouteEnrichmentData; - expect(waterDetectionTelemetry(enrichment)).toMatchObject({ offWater: 1, offWaterSamples: [at(70, 0)] }); - }); -}); - -describe('#493 FR8 D21 ii the lake opens where the ribbon runs', () => { - it('lays no lake land within a clearing', () => { - // The ribbon leaving the lake through its east shore. - const clearing: LakeClearing = { ...scene(200, 0), radius: 20 * SCENE_SCALE }; - const land = createLakeLandGeometry([lake], origin, { clearings: [clearing] }); - for (const centre of triangleCentres(land)) { - expect(Math.hypot(centre.x - clearing.x, centre.z - clearing.z)).toBeGreaterThan(clearing.radius); - } - expect(triangleCentres(land).length).toBeGreaterThan(0); - }); - - it('drops an island the ribbon crosses, so the lake fills its place', () => { - const clearing: LakeClearing = { ...scene(100, 100), radius: 10 * SCENE_SCALE }; - const land = createLakeLandGeometry([lake], origin, { clearings: [clearing] }); - const islandTop = vertices(land).filter((v) => { - const x = v.x / SCENE_SCALE; - const y = -v.z / SCENE_SCALE; - return x > 85 && x < 115 && y > 85 && y < 115; - }); - expect(islandTop).toHaveLength(0); - - const water = createLakeWaterGeometry([lake], origin, undefined, [clearing]); - expect(areaXZ(water)).toBeCloseTo((400 * SCENE_SCALE) ** 2, -1); - }); - - it('keeps an island the ribbon passes well clear of', () => { - const clearing: LakeClearing = { ...scene(-150, -150), radius: 10 * SCENE_SCALE }; - const water = createLakeWaterGeometry([lake], origin, undefined, [clearing]); - expect(areaXZ(water)).toBeCloseTo((400 * SCENE_SCALE) ** 2 - (40 * SCENE_SCALE) ** 2, -1); - }); - - it('clears the lake along every stretch where the ribbon is drawn, and nowhere else', () => { - const curve = new THREE.CatmullRomCurve3( - Array.from({ length: 11 }, (_, i) => new THREE.Vector3(i * 10, 0, 0)), - ); - const enrichment = { - waterWidthMeters: 30, - segmentProfiles: Array.from({ length: 10 }, (_, i) => segment(i, { waterWidthMeters: 30, ...(i < 5 && { onLake: true as const }) })), - } as unknown as RouteEnrichmentData; - const clearings = ribbonClearings(curve, enrichment); - expect(clearings.length).toBeGreaterThan(0); - expect(clearings.every((c) => c.x >= 45)).toBe(true); - expect(clearings.some((c) => c.x > 90)).toBe(true); - expect(clearings.every((c) => c.radius > 0)).toBe(true); - }); - - it('clears nothing on a route that stays on its lake', () => { - const curve = new THREE.CatmullRomCurve3([new THREE.Vector3(0, 0, 0), new THREE.Vector3(100, 0, 0)]); - const enrichment = { - waterWidthMeters: 30, - segmentProfiles: [segment(0, { onLake: true }), segment(1, { onLake: true })], - } as unknown as RouteEnrichmentData; - expect(ribbonClearings(curve, enrichment)).toEqual([]); - }); -}); diff --git a/src/__tests__/landscapeLayout.test.ts b/src/__tests__/landscapeLayout.test.ts deleted file mode 100644 index 9cb3e25f..00000000 --- a/src/__tests__/landscapeLayout.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import * as THREE from 'three'; -import { - landscapeStanding, - layoutLandscape, - type LandscapeElement, -} from '../components/rower3d/landscapeLayout'; -import { LANDSCAPE_OFFSET } from '../components/rower3d/constants'; -import { - SCENERY_WATER_MARGIN_METRES, - landClearance, -} from '../components/rower3d/sceneryClearance'; -import type { RouteEnrichmentData } from '../lib/services/routeEnrichmentService'; - -/** - * The procedural trees, buildings and mountains of `CurvedLandscapeElements` - * (#379). `LANDSCAPE_OFFSET` put them 50 m from the centreline whatever the - * water did, which is clear of a 55 m river and not of a 100 m one. - */ - -const straight = new THREE.CatmullRomCurve3([ - new THREE.Vector3(0, 0, -600), - new THREE.Vector3(0, 0, 0), - new THREE.Vector3(0, 0, 600), -]); - -const water = (widthMeters: number): RouteEnrichmentData => - ({ - routeId: 'landscape', - elevations: [], - segmentProfiles: [0, 1, 2, 3].map((index) => ({ - index, - startMeters: index * 50, - endMeters: (index + 1) * 50, - sceneryProfile: 'forest', - 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 everything = (e: RouteEnrichmentData | null, curve = straight): LandscapeElement[] => { - const { leftElements, rightElements } = layoutLandscape({ curve, enrichment: e }); - return [...leftElements, ...rightElements]; -}; - -const standing = (elements: LandscapeElement[]) => - landscapeStanding({ leftElements: elements, rightElements: [] }); - -const offsets = (elements: LandscapeElement[]) => elements.map((e) => Math.abs(e.position.x)); - -describe('layoutLandscape', () => { - it('dresses both banks along the whole route', () => { - const { leftElements, rightElements } = layoutLandscape({ curve: straight, enrichment: water(55) }); - - expect(leftElements.length).toBeGreaterThan(5); - expect(rightElements.length).toBeGreaterThan(5); - // Opposite banks: every left element on one side of the line, every right - // one on the other. - const leftSide = Math.sign(leftElements[0].position.x); - expect(leftElements.every((e) => Math.sign(e.position.x) === leftSide)).toBe(true); - expect(rightElements.every((e) => Math.sign(e.position.x) === -leftSide)).toBe(true); - const progress = [...leftElements, ...rightElements].map((e) => e.progress); - expect(Math.min(...progress)).toBeLessThan(0.1); - expect(Math.max(...progress)).toBeGreaterThan(0.9); - }); - - it('is deterministic, so a route looks the same on every visit', () => { - expect(layoutLandscape({ curve: straight, enrichment: water(55) })).toEqual( - layoutLandscape({ curve: straight, enrichment: water(55) }), - ); - }); - - it('keeps the 50 m it always stood back where the water allows it', () => { - for (const e of [null, water(20), water(55)]) { - const across = offsets(everything(e)); - expect(Math.min(...across)).toBeGreaterThanOrEqual(LANDSCAPE_OFFSET); - expect(Math.max(...across)).toBeLessThanOrEqual(LANDSCAPE_OFFSET + 50); - } - }); - - it('keeps out of a 120 m river, which is wider than 50 m allows', () => { - const elements = everything(water(120)); - const reading = landClearance(standing(elements), straight, water(120)); - - expect(reading.count).toBeGreaterThan(10); - expect(reading.nearestM).toBeGreaterThanOrEqual(SCENERY_WATER_MARGIN_METRES - 1e-6); - }); - - it('moves out rather than piling up on the bank', () => { - // The spread behind the floor is kept, so wide water thins the landscape - // with distance instead of lining it all up at the waterline. - const across = offsets(everything(water(120))); - - expect(Math.max(...across) - Math.min(...across)).toBeGreaterThan(10); - }); - - it('keeps out 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(standing(everything(water(120), bend)), bend, water(120)); - - expect(reading.nearestM).toBeGreaterThanOrEqual(SCENERY_WATER_MARGIN_METRES - 1e-6); - }); - - it('leaves the trees to the billboard foliage, and the houses and mountains where they were', () => { - // #333: a slot that rolls a tree is left empty rather than re-rolled, so - // the seed stream - and with it every house and mountain - is unchanged. - const elements = everything(water(55)); - - expect(elements.length).toBeGreaterThan(10); - expect(new Set(elements.map((e) => e.type))).toEqual(new Set(['building', 'mountain'])); - }); - - it('places nothing without a curve', () => { - expect(layoutLandscape({ curve: null, enrichment: water(55) })).toEqual({ - leftElements: [], - rightElements: [], - }); - }); -}); diff --git a/src/__tests__/lightingPlan.test.ts b/src/__tests__/lightingPlan.test.ts deleted file mode 100644 index f8216f0a..00000000 --- a/src/__tests__/lightingPlan.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { lightingPlan, QUALITY_TIERS_BY_LIGHT } from '../components/rower3d/lightingPlan'; -import { SCENE_CONFIG } from '../components/rower3d/themeConfig'; -import { CONDITIONS, applyConditions } from '../components/rower3d/conditions'; - -/** - * Issue #349 — the sky lights the scene. - * - * `RowerScene` lit with four lights: a hemisphere, the sun, an ambient and a - * second directional fill. Three of those exist to fake what an environment - * map does properly, and the scene had no usable one — `PMREMEnvironment` - * captured the live scene on mount, before `Sky` had drawn, so the map was - * near-black and every `MeshStandard`/`MeshPhysical` material in the scene had - * nothing to reflect. That is why the hull read as matte plastic and the - * rigger as dark grey. - * - * This is the decision half: which lights exist and how strong, as a pure - * function of the tier and the scene config the conditions presets resolve. - */ - -describe('how strongly the sky lights the scene', () => { - it('rises with the tier', () => { - const strengths = QUALITY_TIERS_BY_LIGHT.map( - (tier) => lightingPlan(tier, SCENE_CONFIG).environmentIntensity, - ); - - expect(strengths).toEqual([...strengths].sort((a, b) => a - b)); - expect(new Set(strengths).size, 'two tiers light the scene identically').toBe( - QUALITY_TIERS_BY_LIGHT.length, - ); - }); - - // Never zero, even at the bottom: the environment is what the flat-colour - // GLB kit picks its sky tint up from, and without any of it the kit is the - // matte plastic this issue is about. - it('is never switched off entirely', () => { - for (const tier of QUALITY_TIERS_BY_LIGHT) { - expect(lightingPlan(tier, SCENE_CONFIG).environmentIntensity, tier).toBeGreaterThan(0); - } - }); -}); - -describe('the lights that remain', () => { - /** - * One hemisphere and one sun. - * - * The ambient light and the second directional fill were flattening the - * scene to make up for an environment map that was black. With a real one - * they are subtracting contrast for nothing. - */ - it('keeps a hemisphere for the ground bounce and nothing else but the sun', () => { - const plan = lightingPlan('extra-high', SCENE_CONFIG); - - expect(plan.hemisphere).toBeTruthy(); - expect(plan.sun).toBeTruthy(); - expect(plan).not.toHaveProperty('ambient'); - expect(plan).not.toHaveProperty('fill'); - }); - - it('takes every colour from the config rather than from a literal', () => { - const plan = lightingPlan('extra-high', SCENE_CONFIG); - - expect(plan.hemisphere.sky).toBe(SCENE_CONFIG.lighting.ambientColor); - expect(plan.hemisphere.ground).toBe(SCENE_CONFIG.bank.flatColor); - expect(plan.sun.color).toBe(SCENE_CONFIG.lighting.sunColor); - }); - - it('carries the sun’s own angles, so one function places it everywhere', () => { - const plan = lightingPlan('extra-high', SCENE_CONFIG); - - expect(plan.sun.elevation).toBe(SCENE_CONFIG.lighting.sunElevation); - expect(plan.sun.azimuth).toBe(SCENE_CONFIG.lighting.sunAzimuth); - expect(plan.sun.intensity).toBe(SCENE_CONFIG.lighting.sunIntensity); - }); - - /** - * The ground bounce is the config's own ambient strength. - * - * Not a flat 0.25: the conditions presets (#346) scale `ambientIntensity` — - * overcast raises it, because an overcast sky is bright and directionless — - * and a hemisphere pinned to a constant would ignore the one preset whose - * whole character is diffuse light. - */ - it('lets the conditions move the ground bounce', () => { - const overcast = applyConditions(SCENE_CONFIG, 'overcast'); - const dusk = applyConditions(SCENE_CONFIG, 'dusk'); - - expect(lightingPlan('extra-high', overcast).hemisphere.intensity).toBeGreaterThan( - lightingPlan('extra-high', dusk).hemisphere.intensity, - ); - }); - - /** - * The ground bounce stays at the strength the scene was built around. - * - * The issue asks for 0.25 here. Measured against `scene-contrast.spec.ts`, - * that is much too dark: the hemisphere is what lights the distant scenery - * and the far bank, and dropping it from 0.9 took the top of the frame from - * 138 to 108 while the environment brightened the water underneath it — two - * errors pulling opposite ways, and the spec failed with no horizon left - * between them. The environment is here to give the hull and the riggers - * something to reflect, not to relight the world. - */ - it('lights the distance as strongly as it always did', () => { - expect(lightingPlan('extra-high', SCENE_CONFIG).hemisphere.intensity).toBeCloseTo(0.9, 6); - }); -}); - -describe('every preset still lights a scene somebody can see', () => { - it('never asks for a negative or absent light', () => { - for (const condition of CONDITIONS) { - const config = applyConditions(SCENE_CONFIG, condition); - - for (const tier of QUALITY_TIERS_BY_LIGHT) { - const plan = lightingPlan(tier, config); - - expect(plan.environmentIntensity, `${condition}/${tier}`).toBeGreaterThan(0); - expect(plan.hemisphere.intensity, `${condition}/${tier}`).toBeGreaterThan(0); - expect(plan.sun.intensity, `${condition}/${tier}`).toBeGreaterThanOrEqual(0); - } - } - }); -}); - -/** - * `performanceMode` is a string that arrives from a URL flag and from stored - * preferences, so a tier the table has never heard of is reachable without - * anybody writing a bug — and the scene still has to be lit. - */ -describe('a tier nobody planned for', () => { - it('is lit like the middle one rather than not at all', () => { - const unknown = lightingPlan('ultra' as never, SCENE_CONFIG); - - expect(unknown.environmentIntensity).toBe(lightingPlan('medium', SCENE_CONFIG).environmentIntensity); - }); -}); diff --git a/src/__tests__/loadTodaysWorkoutButton.test.tsx b/src/__tests__/loadTodaysWorkoutButton.test.tsx deleted file mode 100644 index 29b1d22f..00000000 --- a/src/__tests__/loadTodaysWorkoutButton.test.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { LoadTodaysWorkoutButton } from '../components/LoadTodaysWorkoutButton'; -import { IntervalsWorkoutFetchError } from '../lib/services/intervalsIcuWorkoutService'; -import type { AuthPort, IntervalsIcuWorkoutPort } from '../lib/ports'; -import type { StructuredWorkout, WorkoutPlan } from '../lib/types/index'; - -const fixedNow = () => new Date('2026-09-30T09:00:00Z'); - -const plan = (over: Partial = {}): WorkoutPlan => ({ - id: 'p1', - name: 'Tuesday intervals', - summary: '4x4min', - scheduledDate: '2026-09-30T07:00:00', - source: 'intervals.icu', - blocks: [], - totalDurationSec: 1200, - ...over, -}); - -const asWorkout = (p: WorkoutPlan): StructuredWorkout => ({ - id: `icu-plan-${p.id}`, - name: p.name, - description: '', - type: 'intervals', - segments: [], - totalDuration: p.totalDurationSec, - targetMetric: 'pace', - createdAt: new Date(), -}); - -const services = ( - overrides: { - fetchPlannedRowingWorkouts?: IntervalsIcuWorkoutPort['fetchPlannedRowingWorkouts']; - getAccessToken?: AuthPort['getAccessToken']; - refreshAccessToken?: AuthPort['refreshAccessToken']; - } = {}, -) => { - const authService: AuthPort = { - getAccessToken: overrides.getAccessToken ?? vi.fn(() => 'token'), - refreshAccessToken: overrides.refreshAccessToken ?? vi.fn().mockResolvedValue(true), - // Unused by the component; satisfy the port shape. - startLogin: vi.fn(), - handleCallback: vi.fn(), - logout: vi.fn(), - getUser: vi.fn(() => ({ id: 'i123' })), - getLastError: vi.fn(() => null), - scheduleRefresh: vi.fn(), - isAuthenticated: vi.fn(() => true), - } as unknown as AuthPort; - const intervalsIcuWorkoutService: IntervalsIcuWorkoutPort = { - fetchPlannedRowingWorkouts: - overrides.fetchPlannedRowingWorkouts ?? vi.fn().mockResolvedValue([plan()]), - toStructuredWorkout: vi.fn(asWorkout), - }; - return { authService, intervalsIcuWorkoutService }; -}; - -describe('LoadTodaysWorkoutButton (#445, AC2 / AC3 / AC4)', () => { - it("loads today's first-by-time workout on click (AC2, D1(a))", async () => { - const onLoad = vi.fn(); - const later = plan({ id: 'p2', scheduledDate: '2026-09-30T12:00:00' }); - const earlier = plan({ id: 'p1', scheduledDate: '2026-09-30T07:00:00' }); - const svc = services({ - fetchPlannedRowingWorkouts: vi.fn().mockResolvedValue([later, earlier]), - }); - render(); - await userEvent.click(screen.getByRole('button', { name: /load today's workout/i })); - await waitFor(() => expect(onLoad).toHaveBeenCalled()); - expect(svc.intervalsIcuWorkoutService.toStructuredWorkout).toHaveBeenCalledWith(earlier); - }); - - it('reports no workout when none is scheduled today', async () => { - const svc = services({ fetchPlannedRowingWorkouts: vi.fn().mockResolvedValue([]) }); - const onLoad = vi.fn(); - render(); - await userEvent.click(screen.getByRole('button', { name: /load today's workout/i })); - await waitFor(() => expect(screen.getByRole('alert')).toBeInTheDocument()); - expect(onLoad).not.toHaveBeenCalled(); - }); - - it('refreshes the token once on a 401 and retries (FR2)', async () => { - let call = 0; - const svc = services({ - fetchPlannedRowingWorkouts: vi.fn().mockImplementation(async () => { - call += 1; - if (call === 1) throw new IntervalsWorkoutFetchError('expired', 401); - return [plan()]; - }), - }); - const onLoad = vi.fn(); - render(); - await userEvent.click(screen.getByRole('button', { name: /load today's workout/i })); - await waitFor(() => expect(onLoad).toHaveBeenCalled()); - expect(svc.authService.refreshAccessToken).toHaveBeenCalledTimes(1); - }); - - it('searches by date within ±14 days and offers matches to pick (AC3)', async () => { - const p1 = plan({ id: 'p1', name: 'Wednesday piece', scheduledDate: '2026-10-01T09:00:00' }); - const p2 = plan({ id: 'p2', name: 'Elsewhere', scheduledDate: '2026-09-28T09:00:00' }); - const svc = services({ - fetchPlannedRowingWorkouts: vi.fn().mockResolvedValue([p1, p2]), - }); - const onLoad = vi.fn(); - render(); - const dateInput = screen.getByLabelText(/search another day/i); - await userEvent.type(dateInput, '2026-10-01'); - await waitFor(() => - expect(screen.getByRole('button', { name: /wednesday piece/i })).toBeInTheDocument(), - ); - // The other day's plan is not offered. - expect(screen.queryByRole('button', { name: /elsewhere/i })).toBeNull(); - await userEvent.click(screen.getByRole('button', { name: /wednesday piece/i })); - expect(onLoad).toHaveBeenCalled(); - }); - - it('surfaces a fetch error to the rower', async () => { - const svc = services({ - fetchPlannedRowingWorkouts: vi.fn().mockRejectedValue(new Error('network')), - }); - render( - , - ); - await userEvent.click(screen.getByRole('button', { name: /load today's workout/i })); - await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent(/network/i)); - }); -}); diff --git a/src/__tests__/materialPass.test.ts b/src/__tests__/materialPass.test.ts deleted file mode 100644 index 35c3e115..00000000 --- a/src/__tests__/materialPass.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'; -import * as THREE from 'three'; -import { installCanvasMock } from './canvasMock'; -import { - applyMaterialPass, - resetMaterialPassCacheForTesting, -} from '../components/rower3d/materialPass'; - -let teardownCanvas: (() => void) | null = null; -beforeAll(() => { teardownCanvas = installCanvasMock(); }); -afterAll(() => { teardownCanvas?.(); }); - -const meshWith = (name: string, colorHex = '#ff0000'): THREE.Mesh => { - const geometry = new THREE.PlaneGeometry(1, 1); - const material = new THREE.MeshStandardMaterial({ color: new THREE.Color(colorHex) }); - const mesh = new THREE.Mesh(geometry, material); - mesh.name = name; - return mesh; -}; - -const scullFixture = (): THREE.Group => { - const g = new THREE.Group(); - g.add(meshWith('Hull_part', '#e0e0e0')); - g.add(meshWith('Rower_Torso', '#0055aa')); - g.add(meshWith('Rower_Head', '#f0c8ad')); - g.add(meshWith('LeftOar_Blade_part', '#ffdd44')); - g.add(meshWith('UnknownPart', '#333333')); - return g; -}; - -describe('applyMaterialPass (#354)', () => { - beforeEach(() => resetMaterialPassCacheForTesting()); - afterEach(() => resetMaterialPassCacheForTesting()); - - it('the hull becomes a physical material with a clearcoat and a normal map at auto', () => { - const root = scullFixture(); - applyMaterialPass(root, { tier: 'medium' }); - const hull = root.getObjectByName('Hull_part') as THREE.Mesh; - const mat = hull.material as THREE.MeshPhysicalMaterial; - expect(mat).toBeInstanceOf(THREE.MeshPhysicalMaterial); - expect(mat.clearcoat).toBe(1); - expect(mat.clearcoatRoughness).toBeCloseTo(0.08, 5); - expect(mat.roughness).toBeCloseTo(0.25, 5); - expect(mat.envMapIntensity).toBeCloseTo(1.2, 5); - expect(mat.normalMap).not.toBeNull(); - }); - - it('cloth on the rower gets sheen and a lycra normal map at auto', () => { - const root = scullFixture(); - applyMaterialPass(root, { tier: 'medium' }); - const torso = root.getObjectByName('Rower_Torso') as THREE.Mesh; - const mat = torso.material as THREE.MeshPhysicalMaterial; - expect(mat).toBeInstanceOf(THREE.MeshPhysicalMaterial); - expect(mat.roughness).toBeCloseTo(0.7, 5); - expect(mat.sheen).toBeGreaterThan(0); - expect(mat.normalMap).not.toBeNull(); - }); - - it('skin gets its own sheen colour without a normal map', () => { - const root = scullFixture(); - applyMaterialPass(root, { tier: 'medium' }); - const head = root.getObjectByName('Rower_Head') as THREE.Mesh; - const mat = head.material as THREE.MeshPhysicalMaterial; - expect(mat).toBeInstanceOf(THREE.MeshPhysicalMaterial); - expect(mat.roughness).toBeCloseTo(0.55, 5); - expect(mat.sheen).toBeGreaterThan(0); - expect(mat.normalMap).toBeNull(); - }); - - it('the blade becomes a physical material with a light clearcoat', () => { - const root = scullFixture(); - applyMaterialPass(root, { tier: 'medium' }); - const blade = root.getObjectByName('LeftOar_Blade_part') as THREE.Mesh; - const mat = blade.material as THREE.MeshPhysicalMaterial; - expect(mat).toBeInstanceOf(THREE.MeshPhysicalMaterial); - expect(mat.roughness).toBeCloseTo(0.4, 5); - expect(mat.clearcoat).toBeCloseTo(0.5, 5); - }); - - it('the low tier drops every normal map', () => { - const root = scullFixture(); - applyMaterialPass(root, { tier: 'basic' }); - const hull = root.getObjectByName('Hull_part') as THREE.Mesh; - const torso = root.getObjectByName('Rower_Torso') as THREE.Mesh; - expect((hull.material as THREE.MeshPhysicalMaterial).normalMap).toBeNull(); - expect((torso.material as THREE.MeshPhysicalMaterial).normalMap).toBeNull(); - }); - - it('the source material is disposed once its replacement is installed', () => { - const root = scullFixture(); - const hull = root.getObjectByName('Hull_part') as THREE.Mesh; - const sourceMat = hull.material as THREE.MeshStandardMaterial; - const dispose = vi.spyOn(sourceMat, 'dispose'); - applyMaterialPass(root, { tier: 'medium' }); - expect(dispose).toHaveBeenCalledTimes(1); - }); - - it('an unrecognised node is left with its original material', () => { - const root = scullFixture(); - const other = root.getObjectByName('UnknownPart') as THREE.Mesh; - const originalMat = other.material as THREE.MeshStandardMaterial; - applyMaterialPass(root, { tier: 'medium' }); - expect(other.material).toBe(originalMat); - }); - - it('a second pass on the same node is a no-op', () => { - const root = scullFixture(); - applyMaterialPass(root, { tier: 'medium' }); - const hull = root.getObjectByName('Hull_part') as THREE.Mesh; - const firstReplacement = hull.material; - applyMaterialPass(root, { tier: 'medium' }); - expect(hull.material).toBe(firstReplacement); - }); - - it('the base colour of each part is preserved', () => { - const root = scullFixture(); - applyMaterialPass(root, { tier: 'medium' }); - const hull = root.getObjectByName('Hull_part') as THREE.Mesh; - const hex = (hull.material as THREE.MeshPhysicalMaterial).color.getHexString(); - expect(hex).toBe('e0e0e0'); - }); -}); diff --git a/src/__tests__/measuredShores.test.ts b/src/__tests__/measuredShores.test.ts deleted file mode 100644 index 4d41b9c6..00000000 --- a/src/__tests__/measuredShores.test.ts +++ /dev/null @@ -1,203 +0,0 @@ -import * as THREE from 'three'; -import { describe, expect, it } from 'vitest'; -import { createBankGeometry, createShorelineGeometry } from '../components/rower3d/bankGeometry'; -import { buildRiverGuides } from '../components/rower3d/riverGuideGeometry'; -import { landClearance, waterHalfWidthAt } from '../components/rower3d/sceneryClearance'; -import { createWaterChannelGeometry } from '../components/rower3d/waterGeometry'; -import { - applyMeasuredShores, - getWaterEdgesSceneUnitsForProgress, - getWaterWidthSceneUnitsForProgress, - type RouteEnrichmentData, - type RouteSegmentEnrichment, -} from '../lib/services/routeEnrichmentService'; -import { MIN_NAVIGABLE_WIDTH_METERS } from '../components/rower3d/navigableWidth'; -import { SHORE_RAY_LIMIT_METERS, type DetectedSample } from '../lib/utils/shoreDetection'; - -const segment = (index: number, overrides: Partial = {}): RouteSegmentEnrichment => ({ - index, - startMeters: index * 50, - endMeters: (index + 1) * 50, - sceneryProfile: 'fallback', - treeDensity: 0.5, - vegetationDensity: 0.5, - buildingDensity: 0.1, - objectScale: 1, - 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, - ...overrides, -}); - -describe('#493 FR16 measured shore distances on each segment', () => { - it('records each side and makes the width their sum (AC17)', () => { - const [profile] = applyMeasuredShores([segment(0)], [sample(0), sample(25)]); - expect(profile.leftShoreMeters).toBe(15); - expect(profile.rightShoreMeters).toBe(45); - expect(profile.waterWidthMeters).toBe(60); - }); - - it('takes the median of the samples in the segment', () => { - const samples = [ - sample(0, { leftShoreMeters: 10 }), - sample(10, { leftShoreMeters: 12 }), - sample(20, { leftShoreMeters: 90 }), - ]; - expect(applyMeasuredShores([segment(0)], samples)[0].leftShoreMeters).toBe(12); - }); - - it('reads a side as open when most samples see no shore there', () => { - const samples = [sample(0, { rightShoreMeters: null }), sample(20, { rightShoreMeters: null }), sample(40)]; - const [profile] = applyMeasuredShores([segment(0)], samples); - expect(profile.rightShoreMeters).toBeNull(); - // Nothing to add up, so the guessed width stays. - expect(profile.waterWidthMeters).toBe(55); - }); - - it('leaves a segment with no sample on water as it was', () => { - const [profile] = applyMeasuredShores([segment(0)], [sample(10, { kind: 'land' })]); - expect(profile).toEqual(segment(0)); - expect('leftShoreMeters' in profile).toBe(false); - }); - - it('gives each segment its own samples', () => { - const profiles = applyMeasuredShores( - [segment(0), segment(1)], - [sample(10, { leftShoreMeters: 5 }), sample(60, { leftShoreMeters: 30 }), sample(100, { leftShoreMeters: 30 })], - ); - expect(profiles.map((profile) => profile.leftShoreMeters)).toEqual([5, 30]); - }); -}); - -describe('#493 D19 c water edges either side of the route', () => { - it('places each edge at its own measured distance', () => { - const edges = getWaterEdgesSceneUnitsForProgress( - [segment(0, { leftShoreMeters: 15, rightShoreMeters: 45 })], - 55, - 0.5, - ); - expect(edges).toEqual({ left: 15, right: 45 }); - }); - - it('keeps each side wide enough for the blades', () => { - const edges = getWaterEdgesSceneUnitsForProgress( - [segment(0, { leftShoreMeters: 1, rightShoreMeters: 45 })], - 55, - 0, - ); - expect(edges.left).toBe(MIN_NAVIGABLE_WIDTH_METERS / 2); - expect(edges.right).toBe(45); - }); - - it('runs an open side out to the ray limit', () => { - const edges = getWaterEdgesSceneUnitsForProgress( - [segment(0, { leftShoreMeters: null, rightShoreMeters: 40 })], - 55, - 0, - ); - expect(edges.left).toBe(SHORE_RAY_LIMIT_METERS); - }); - - it('centres an unmeasured segment on the route, as before', () => { - expect(getWaterEdgesSceneUnitsForProgress([segment(0)], 55, 0)).toEqual({ left: 27.5, right: 27.5 }); - expect(getWaterEdgesSceneUnitsForProgress(undefined, 20, 0)).toEqual({ left: 10, right: 10 }); - expect(getWaterEdgesSceneUnitsForProgress(undefined, 2, 0)).toEqual({ - left: MIN_NAVIGABLE_WIDTH_METERS / 2, - right: MIN_NAVIGABLE_WIDTH_METERS / 2, - }); - }); - - it('blends each side between segments', () => { - const profiles = [ - segment(0, { leftShoreMeters: 10, rightShoreMeters: 40 }), - segment(1, { leftShoreMeters: 30, rightShoreMeters: 20 }), - ]; - expect(getWaterEdgesSceneUnitsForProgress(profiles, 55, 0.5)).toEqual({ left: 20, right: 30 }); - }); - - it('keeps the width the sum of the two edges', () => { - const profiles = [segment(0, { leftShoreMeters: 15, rightShoreMeters: 45 })]; - expect(getWaterWidthSceneUnitsForProgress(profiles, 55, 0)).toBe(60); - expect(getWaterWidthSceneUnitsForProgress([segment(0, { waterWidthMeters: 2 })], 55, 0)).toBe( - MIN_NAVIGABLE_WIDTH_METERS, - ); - }); -}); - -// A straight route due north: the left bank is to the west (-x), the right to the east (+x). -const northward = () => - 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), - ]); - -/** A 60 m channel with the route 15 m off its left bank (AC17). */ -const offCentre = (): RouteEnrichmentData => ({ - routeId: 'off-centre', - elevations: [], - segmentProfiles: [0, 1, 2].map((index) => segment(index, { leftShoreMeters: 15, rightShoreMeters: 45 })), - waterBodyType: 'river', - waterWidthMeters: 60, - waterColor: '#000000', - waveIntensity: 1, - fetchedAt: 0, - source: 'network', -}); - -const xs = (geometry: THREE.BufferGeometry) => { - const position = geometry.getAttribute('position'); - return Array.from({ length: position.count }, (_, index) => position.getX(index)); -}; - -describe('#493 AC17 the scene draws each bank at its own distance', () => { - it('builds the water from 15 m left to 45 m right', () => { - const values = xs(createWaterChannelGeometry(northward(), { enrichment: offCentre() })); - expect(Math.min(...values)).toBeCloseTo(-15, 3); - expect(Math.max(...values)).toBeCloseTo(45, 3); - }); - - it('starts each bank at its own waterline', () => { - const left = xs(createBankGeometry(northward(), 'left', { enrichment: offCentre() })); - const right = xs(createBankGeometry(northward(), 'right', { enrichment: offCentre() })); - // Even vertices are the inner edge, the waterline. - expect(left.filter((_, index) => index % 2 === 0).every((x) => Math.abs(x + 15) < 1e-3)).toBe(true); - expect(right.filter((_, index) => index % 2 === 0).every((x) => Math.abs(x - 45) < 1e-3)).toBe(true); - }); - - it('lays the foam along each waterline', () => { - const left = xs(createShorelineGeometry(northward(), 'left', { enrichment: offCentre() })); - const right = xs(createShorelineGeometry(northward(), 'right', { enrichment: offCentre() })); - expect(Math.max(...left)).toBeGreaterThan(-15); - expect(Math.min(...left)).toBeGreaterThan(-18); - expect(Math.min(...right)).toBeLessThan(45); - expect(Math.max(...right)).toBeLessThan(48); - }); - - it('draws the debug edges where the water ends', () => { - const guides = buildRiverGuides(northward(), offCentre()); - expect(guides.left.every((point) => Math.abs(point.x + 15) < 1e-3)).toBe(true); - expect(guides.right.every((point) => Math.abs(point.x - 45) < 1e-3)).toBe(true); - }); - - it('measures clearance from the waterline on the side the placement is on', () => { - expect(waterHalfWidthAt(offCentre(), 0.5, 'left')).toBe(15); - expect(waterHalfWidthAt(offCentre(), 0.5, 'right')).toBe(45); - const reading = (x: number) => - landClearance([{ position: [x, 0, -75], progress: 0.5 }], northward(), offCentre()).nearestM; - expect(reading(-20)).toBeCloseTo(5, 3); - expect(reading(20)).toBeCloseTo(-25, 3); - }); -}); diff --git a/src/__tests__/paletteKind.test.ts b/src/__tests__/paletteKind.test.ts deleted file mode 100644 index 00a0a27e..00000000 --- a/src/__tests__/paletteKind.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import * as THREE from 'three'; -import { paletteKind } from '../components/rower3d/paletteKind'; - -/** - * Issue #432 — classify a scenery part's flat colour so the material pass - * knows which facade texture to sample. Fixtures pick a representative - * colour for each kind rather than probing thresholds: the rules are set - * for the palette they classify, so a value plainly inside each group is - * the honest test. - */ - -describe('paletteKind (#432)', () => { - it('sees brick in a warm saturated red', () => { - expect(paletteKind(new THREE.Color('#a24c2e'))).toBe('brick'); - expect(paletteKind(new THREE.Color('#8a3a20'))).toBe('brick'); - }); - - it('sees plaster in a bright near-white', () => { - expect(paletteKind(new THREE.Color('#f0e8d0'))).toBe('plaster'); - expect(paletteKind(new THREE.Color('#eee6d8'))).toBe('plaster'); - }); - - it('sees clapboard in a muted cool timber', () => { - expect(paletteKind(new THREE.Color('#6b7a70'))).toBe('clapboard'); - expect(paletteKind(new THREE.Color('#4a6870'))).toBe('clapboard'); - }); - - it('sees clapboard in a saturated cool colour', () => { - // A deep blue or green cladding is painted timber, not brick. - expect(paletteKind(new THREE.Color('#2a4a70'))).toBe('clapboard'); - expect(paletteKind(new THREE.Color('#3a6a4a'))).toBe('clapboard'); - }); - - it('reads warm hex in sRGB, not linear', () => { - // A plain `getHSL` reads a linear-space L for `#f0e8d0` of 0.75 — under - // the plaster gate — and the classifier would say clapboard on a colour - // authored to be plaster. - const color = new THREE.Color('#f0e8d0'); - expect(paletteKind(color)).toBe('plaster'); - }); -}); diff --git a/src/__tests__/postProcessingGuard.test.ts b/src/__tests__/postProcessingGuard.test.ts deleted file mode 100644 index 4292e281..00000000 --- a/src/__tests__/postProcessingGuard.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { canInitialisePostProcessing } from '../components/rower3d/postProcessingGuard'; - -/** - * `postprocessing` dereferences `renderer.getContext().getContextAttributes().alpha` - * unguarded in two places — EffectComposer.setRenderer and EffectComposer.addPass. - * Either throws a TypeError when the context reports no attributes, which is - * what a software rasteriser and a post-reset context both do (#197, #257). - */ - -/** A renderer stand-in whose context answers however the test needs. */ -const rendererWith = (attributes: unknown, opts: { throws?: boolean } = {}) => ({ - getContext: () => { - if (opts.throws) throw new Error('context gone'); - return attributes === undefined - ? null - : { getContextAttributes: () => attributes }; - }, -}); - -describe('canInitialisePostProcessing', () => { - it('allows the composer when the context reports attributes', () => { - expect(canInitialisePostProcessing(rendererWith({ alpha: true }))).toBe(true); - }); - - it('refuses when getContextAttributes returns null', () => { - // The exact condition that throws inside addPass. - expect(canInitialisePostProcessing(rendererWith(null))).toBe(false); - }); - - it('refuses when there is no context at all', () => { - expect(canInitialisePostProcessing(rendererWith(undefined))).toBe(false); - }); - - it('refuses when asking for the context throws', () => { - expect(canInitialisePostProcessing(rendererWith(null, { throws: true }))).toBe(false); - }); - - it('refuses a renderer that is missing entirely', () => { - expect(canInitialisePostProcessing(null)).toBe(false); - expect(canInitialisePostProcessing(undefined)).toBe(false); - }); - - it('re-reads the context every time it is asked', () => { - // The old guard memoised on the renderer identity, so it answered once at - // mount and never again. The renderer object does not change when its - // context is lost, so a composer mounted while attributes were available - // kept its verdict after they were gone — and addPass threw on the next - // effect-list change (#257). - let attributes: unknown = { alpha: true }; - const renderer = { getContext: () => ({ getContextAttributes: () => attributes }) }; - - expect(canInitialisePostProcessing(renderer)).toBe(true); - attributes = null; - expect(canInitialisePostProcessing(renderer)).toBe(false); - }); -}); diff --git a/src/__tests__/progressEasing.test.ts b/src/__tests__/progressEasing.test.ts deleted file mode 100644 index c3fd6930..00000000 --- a/src/__tests__/progressEasing.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { easeProgressTowards, PROGRESS_EASE_PER_SECOND } from '../components/rower3d/progressEasing'; - -/** - * Issue #295 — the easing that follows the reported distance while a session is - * paused diverged on a slow frame. - * - * `progress += (target - progress) * delta * 3` is a first-order filter whose - * gain is `3 * delta`. Stable under 1, oscillating past it, and divergent past - * 2 — so on a frame longer than 0.667s each step overshot further than the one - * before. #272 records frame p95 figures of 2784 ms and 6902 ms on the - * rasteriser CI uses, so this is the regime the project already measures. - */ - -/** The long frames #272 measured, in seconds. */ -const SLOW_FRAME = 2.784; -const VERY_SLOW_FRAME = 6.902; - -describe('easing the boat towards the reported distance (#295)', () => { - it('closes on the target without ever passing it', () => { - let progress = 0; - for (let i = 0; i < 20; i += 1) { - progress = easeProgressTowards(progress, 1, 0.1); - expect(progress, 'the boat overshot the distance the rower had covered') - .toBeLessThanOrEqual(1); - expect(progress).toBeGreaterThanOrEqual(0); - } - expect(progress).toBeGreaterThan(0.9); - }); - - it('arrives rather than oscillating when a frame takes seconds', () => { - // The old gain here was 3 * 2.784 = 8.35, which threw the boat a long way - // past the target and further back again on the next frame. - const seen: number[] = []; - let progress = 0; - for (let i = 0; i < 6; i += 1) { - progress = easeProgressTowards(progress, 0.5, SLOW_FRAME); - seen.push(progress); - } - - for (const value of seen) { - expect(value, `progress left the route: ${seen.map((v) => v.toFixed(2)).join(', ')}`) - .toBeGreaterThanOrEqual(0); - expect(value).toBeLessThanOrEqual(1); - } - expect(seen[seen.length - 1]).toBeCloseTo(0.5, 5); - }); - - it('does not diverge on the longest frame the project has measured', () => { - let progress = 0.2; - for (let i = 0; i < 10; i += 1) { - progress = easeProgressTowards(progress, 0.6, VERY_SLOW_FRAME); - } - - expect(progress).toBeCloseTo(0.6, 5); - }); - - it('never moves backwards while the target is ahead', () => { - // The symptom a rower would report: the boat jittering rather than easing. - let progress = 0; - let previous = -1; - for (let i = 0; i < 12; i += 1) { - progress = easeProgressTowards(progress, 0.8, 1.0); - expect(progress, 'the boat went backwards while the target was ahead') - .toBeGreaterThanOrEqual(previous); - previous = progress; - } - }); - - it('holds still when a frame reports no time, or nonsense', () => { - expect(easeProgressTowards(0.3, 0.9, 0)).toBe(0.3); - expect(easeProgressTowards(0.3, 0.9, -1)).toBe(0.3); - expect(easeProgressTowards(0.3, 0.9, Number.NaN)).toBe(0.3); - expect(easeProgressTowards(0.3, Number.NaN, 0.1)).toBe(0.3); - }); - - it('keeps the rate it always had on an ordinary frame', () => { - // Nothing about how this feels at 60 fps should change. - const sixtyFps = 1 / 60; - expect(easeProgressTowards(0, 1, sixtyFps)).toBeCloseTo( - sixtyFps * PROGRESS_EASE_PER_SECOND, - 10, - ); - }); -}); diff --git a/src/__tests__/raceCallouts.test.tsx b/src/__tests__/raceCallouts.test.tsx deleted file mode 100644 index 102fdfa6..00000000 --- a/src/__tests__/raceCallouts.test.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { render, screen } from '@testing-library/react'; -import { StartCallout, FinishBanner } from '../components/RaceCallouts'; -import { formatCourseMetres } from '../lib/utils/formatters'; - -/** - * Issue #336 — what the rower is told at each end of the course. - */ - -describe('StartCallout', () => { - it('asks for the first stroke while armed', () => { - render(); - expect(screen.getByRole('status')).toHaveTextContent('Ready — take your first stroke'); - }); - - it('shows the count', () => { - render(); - expect(screen.getByRole('status')).toHaveTextContent('2'); - }); - - it('says row when the boat is let go', () => { - render(); - expect(screen.getByRole('status')).toHaveTextContent('Row!'); - }); - - it.each(['idle', 'rowing'] as const)('says nothing when %s', (phase) => { - const { container } = render(); - expect(container).toBeEmptyDOMElement(); - }); -}); - -describe('FinishBanner', () => { - it('says how far and how long', () => { - render(); - expect(screen.getByRole('status')).toHaveTextContent('Finished — 2 000 m in 8:12'); - }); -}); - -describe('formatCourseMetres', () => { - it('groups thousands the way a course is written', () => { - expect(formatCourseMetres(500)).toBe('500'); - expect(formatCourseMetres(2000)).toBe('2 000'); - expect(formatCourseMetres(21097.5)).toBe('21 098'); - }); -}); diff --git a/src/__tests__/renderBudget.test.ts b/src/__tests__/renderBudget.test.ts deleted file mode 100644 index 08383bac..00000000 --- a/src/__tests__/renderBudget.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - RENDER_BUDGET, - overBudget, - type RenderBudget, -} from '../components/rower3d/renderBudget'; -import type { PerformanceMode } from '../components/rower3d/constants'; - -/** - * Issue #342 — the budget is a gate, not a note. - * - * `recordRenderStats` has counted draw calls, triangles and frame time since - * #232 and nothing has ever been a ceiling. VR-04, VR-13 and VR-18 all add GPU - * work, and each needs a line it cannot cross without someone noticing. - */ - -const TIERS: PerformanceMode[] = ['basic', 'medium', 'extra-high']; -const AXES: Array = ['drawCalls', 'triangles', 'p95Ms']; - -describe('RENDER_BUDGET', () => { - it('has a budget for every tier', () => { - for (const tier of TIERS) { - expect(RENDER_BUDGET[tier], `no budget for ${tier}`).toBeDefined(); - } - }); - - it.each(AXES)('allows more %s at each step up the tiers', (axis) => { - expect(RENDER_BUDGET.medium[axis]).toBeGreaterThan(RENDER_BUDGET.basic[axis]); - expect(RENDER_BUDGET['extra-high'][axis]).toBeGreaterThan(RENDER_BUDGET.medium[axis]); - }); - - // The measurements the table was set from, on the demo route at twenty - // seconds, outside test mode. A ceiling under the reading is not a ceiling; - // it is a red build on a tree nobody changed, which this project has had - // once already (#340). - it.each([ - ['basic' as const, 346, 215_513], - ['medium' as const, 296, 217_840], - ['extra-high' as const, 187, 281_254], - ])('leaves %s room above what the scene measured', (tier, drawCalls, triangles) => { - expect(RENDER_BUDGET[tier].drawCalls).toBeGreaterThan(drawCalls); - expect(RENDER_BUDGET[tier].triangles).toBeGreaterThan(triangles); - }); -}); - -describe('overBudget', () => { - it('says nothing when a frame is inside its budget', () => { - expect(overBudget('basic', { drawCalls: 100, triangles: 1_000, p95Ms: 8 })).toEqual([]); - }); - - it('names the axis that broke rather than answering yes or no', () => { - expect(overBudget('basic', { drawCalls: RENDER_BUDGET.basic.drawCalls + 1 })).toEqual([ - 'drawCalls', - ]); - expect(overBudget('basic', { triangles: RENDER_BUDGET.basic.triangles + 1 })).toEqual([ - 'triangles', - ]); - expect(overBudget('basic', { p95Ms: RENDER_BUDGET.basic.p95Ms + 1 })).toEqual(['p95Ms']); - }); - - it('names every axis that broke, not just the first', () => { - expect( - overBudget('basic', { - drawCalls: 10_000, - triangles: 10_000_000, - p95Ms: 500, - }), - ).toEqual(['drawCalls', 'triangles', 'p95Ms']); - }); - - it('lets a frame sit exactly on its budget', () => { - expect(overBudget('medium', { ...RENDER_BUDGET.medium })).toEqual([]); - }); - - it('checks only what it was given', () => { - // A caller on a software rasteriser leaves frame time out rather than - // asserting a number that describes the rasteriser. - expect(overBudget('basic', { drawCalls: 10 })).toEqual([]); - expect(overBudget('basic', {})).toEqual([]); - }); - - it('measures each tier against its own budget', () => { - const cost = { triangles: RENDER_BUDGET.basic.triangles + 1 }; - expect(overBudget('basic', cost)).toEqual(['triangles']); - expect(overBudget('extra-high', cost), 'high was held to the low budget').toEqual([]); - }); - - describe('on a slower machine', () => { - it('scales the frame-time ceiling', () => { - const slow = { p95Ms: RENDER_BUDGET.basic.p95Ms * 10 }; - expect(overBudget('basic', slow)).toEqual(['p95Ms']); - expect(overBudget('basic', slow, 12)).toEqual([]); - }); - - // Draw calls and triangles are what the scene asks for. They do not depend - // on what is drawing it, so scaling them would scale away the measurement. - it('leaves draw calls and triangles where they are', () => { - const heavy = { - drawCalls: RENDER_BUDGET.basic.drawCalls + 1, - triangles: RENDER_BUDGET.basic.triangles + 1, - }; - expect(overBudget('basic', heavy, 100)).toEqual(['drawCalls', 'triangles']); - }); - }); -}); diff --git a/src/__tests__/renderedRouteFidelity.test.ts b/src/__tests__/renderedRouteFidelity.test.ts deleted file mode 100644 index 736b7bd9..00000000 --- a/src/__tests__/renderedRouteFidelity.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import * as THREE from 'three'; -import type { Coordinate } from '../lib/types/index'; -import { createRouteCurve, gpsToScenePoints } from '../components/rower3d/curve'; -import { SCENE_SCALE } from '../components/rower3d/constants'; -import { DEFAULT_SIMPLIFY_EPSILON_METERS } from '../lib/utils/coordinateUtils'; - -/** - * The shape guarantee #224 actually asks for. - * - * `routeSimplification.test.ts` checks the simplifier's own contract: no dropped - * point strays further than the epsilon from the *polyline* that replaced it. - * That is not the number the rower sees. The scene draws a Catmull-Rom spline - * through Hermite-upsampled control points, and a spline cuts the corner on the - * inside of a bend — so the drawn water can sit further out than the epsilon - * ever allowed. These tests measure the drawn curve against the imported track. - */ - -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 at = (eastMeters: number, northMeters: number): Coordinate => ({ - lat: ORIGIN_LAT + northMeters / METERS_PER_DEGREE_LAT, - lng: eastMeters / LNG_SCALE, -}); - -/** A U-turn of `radiusMeters` between two straight legs. */ -const hairpin = (radiusMeters: number, legMeters: number, bendPoints = 60): Coordinate[] => [ - ...Array.from({ length: 30 }, (_, i) => at(-radiusMeters, legMeters - (i / 29) * legMeters)), - ...Array.from({ length: bendPoints }, (_, i) => { - const angle = Math.PI + (i / (bendPoints - 1)) * Math.PI; - return at(Math.cos(angle) * radiusMeters, Math.sin(angle) * radiusMeters); - }), - ...Array.from({ length: 30 }, (_, i) => at(radiusMeters, (i / 29) * legMeters)), -]; - -const serpentine = (points: number, lengthMeters: number, bends: number, amplitudeMeters: number) => - Array.from({ length: points }, (_, i) => { - const t = i / (points - 1); - return at(Math.sin(t * bends * 2 * Math.PI) * amplitudeMeters, t * lengthMeters); - }); - -/** Distance from `point` to the segment `a`-`b`, in the horizontal plane. */ -const distanceToSegment = (point: THREE.Vector3, a: THREE.Vector3, b: THREE.Vector3): number => { - const dx = b.x - a.x; - const dz = b.z - a.z; - const lengthSquared = dx * dx + dz * dz; - const along = - lengthSquared > 0 - ? Math.max(0, Math.min(1, ((point.x - a.x) * dx + (point.z - a.z) * dz) / lengthSquared)) - : 0; - return Math.hypot(a.x + along * dx - point.x, a.z + along * dz - point.z); -}; - -/** - * Worst distance in metres from any imported point to the curve as drawn. - * - * Measured against the sampled *segments*, not the samples: nearest-sample - * distance would carry half the sample spacing as spurious error, which on a - * 12 km route is a couple of metres of pure measurement artefact. - */ -const renderedDeviationMeters = ( - track: Coordinate[], - epsilonMeters: number = DEFAULT_SIMPLIFY_EPSILON_METERS, - samples = 4000, -): number => { - const curve = createRouteCurve(track, SCENE_SCALE, 10, epsilonMeters)!; - const drawn = Array.from({ length: samples + 1 }, (_, i) => curve.getPointAt(i / samples)); - const imported = gpsToScenePoints(track, SCENE_SCALE); - - let worst = 0; - for (const point of imported) { - let nearest = Infinity; - for (let i = 0; i < drawn.length - 1; i++) { - nearest = Math.min(nearest, distanceToSegment(point, drawn[i], drawn[i + 1])); - } - worst = Math.max(worst, nearest); - } - return worst / SCENE_SCALE; -}; - -/** The deviation the acceptance criterion allows. */ -const MAX_RENDERED_DEVIATION_METERS = 3; - -describe('the drawn curve stays on the imported water (#224)', () => { - it('holds a 15 m hairpin', () => { - expect(renderedDeviationMeters(hairpin(15, 600))).toBeLessThanOrEqual( - MAX_RENDERED_DEVIATION_METERS, - ); - }); - - it('holds a 40 m hairpin', () => { - expect(renderedDeviationMeters(hairpin(40, 600))).toBeLessThanOrEqual( - MAX_RENDERED_DEVIATION_METERS, - ); - }); - - it('holds a 100 m hairpin, where the spline has most room to cut the corner', () => { - expect(renderedDeviationMeters(hairpin(100, 600))).toBeLessThanOrEqual( - MAX_RENDERED_DEVIATION_METERS, - ); - }); - - it('holds a long serpentine course', () => { - expect(renderedDeviationMeters(serpentine(2000, 3000, 8, 60))).toBeLessThanOrEqual( - MAX_RENDERED_DEVIATION_METERS, - ); - }); - - it('holds a dense 4,000-point import', () => { - expect(renderedDeviationMeters(serpentine(4000, 12_000, 10, 45))).toBeLessThanOrEqual( - MAX_RENDERED_DEVIATION_METERS, - ); - }); - - it('keeps a margin at the default epsilon, across every shape', () => { - // The default leaves roughly a third of the budget spare. Recorded as a - // guard: the tangent weighting in `upsampleRouteCoordinates` is what buys - // it, and without that these same shapes measured 6.8 m. Doubling the - // epsilon does *not* hold — 6 m of chord tolerance draws 3.5 m off — so - // this margin is the headroom, not a licence to raise the epsilon. - const worst = [ - hairpin(15, 600), - hairpin(40, 600), - hairpin(100, 600), - serpentine(2000, 3000, 8, 60), - serpentine(4000, 12_000, 10, 45), - ].reduce((acc, track) => Math.max(acc, renderedDeviationMeters(track)), 0); - - expect(worst).toBeLessThanOrEqual(2.5); - }); - - it('still throws away most of a dense import', () => { - const track = serpentine(4000, 12_000, 10, 45); - const curve = createRouteCurve(track, SCENE_SCALE)!; - expect(curve.points.length).toBeLessThan(track.length / 2); - }); -}); diff --git a/src/__tests__/rippleTexture.test.ts b/src/__tests__/rippleTexture.test.ts deleted file mode 100644 index e107f5b2..00000000 --- a/src/__tests__/rippleTexture.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import * as THREE from 'three'; -import { installCanvasMock } from './canvasMock'; -import { - RIPPLE_TEXTURE_SIZE, - RIPPLE_WAVELENGTH_RANGE_METRES, - createRippleNormalMap, - rippleWavelengthsMetres, -} from '../components/rower3d/rippleTexture'; -import { RIPPLE_TILE_METRES } from '../components/rower3d/waterMaterial'; - -/** - * Issue #324 — the chop the mesh cannot carry. - * - * The channel is a two-vertex-wide ribbon sampled every 15 m, so the 1.5–6 m - * waves VR-04 asks for cannot exist in its vertices. They exist here instead, - * per-pixel, where there is no sampling limit. - */ - -describe('the chop the ripple texture carries', () => { - it('is the range the issue asked for', () => { - const [shortest, longest] = [ - Math.min(...rippleWavelengthsMetres()), - Math.max(...rippleWavelengthsMetres()), - ]; - - expect(shortest).toBeGreaterThanOrEqual(RIPPLE_WAVELENGTH_RANGE_METRES[0]); - expect(longest).toBeLessThanOrEqual(RIPPLE_WAVELENGTH_RANGE_METRES[1]); - }); - - // A tiling texture whose waves do not close at the tile edge shows its seams - // — a grid of them, right across the river. - it('fits a whole number of cycles into the tile, so it has no seams', () => { - for (const wavelength of rippleWavelengthsMetres()) { - const cycles = RIPPLE_TILE_METRES / wavelength; - expect(cycles, `${wavelength} m does not close on the tile`).toBeCloseTo( - Math.round(cycles), - 6, - ); - } - }); - - it('carries more than one wavelength, or it is corduroy rather than water', () => { - expect(new Set(rippleWavelengthsMetres()).size).toBeGreaterThan(1); - }); -}); - -describe('createRippleNormalMap', () => { - it('returns nothing rather than throwing where there is no context to draw in', () => { - expect(() => createRippleNormalMap()).not.toThrow(); - expect(createRippleNormalMap()).toBeNull(); - }); - - describe('with a canvas to draw into', () => { - let uninstall: () => void; - beforeAll(() => { - uninstall = installCanvasMock(); - }); - afterAll(() => uninstall()); - - it('is a square tile that repeats both ways', () => { - const texture = createRippleNormalMap(); - - expect(texture, 'no ripple texture was produced').not.toBeNull(); - expect(texture!.image.width).toBe(RIPPLE_TEXTURE_SIZE); - expect(texture!.image.height).toBe(RIPPLE_TEXTURE_SIZE); - expect(texture!.wrapS).toBe(THREE.RepeatWrapping); - expect(texture!.wrapT).toBe(THREE.RepeatWrapping); - }); - - // A normal map holds directions, not colour. Tagged sRGB it would be - // gamma-decoded on the way in and every slope would come out wrong — the - // water would be lit as though its waves leaned the wrong way. - it('is not treated as colour', () => { - expect(createRippleNormalMap()!.colorSpace).toBe(THREE.NoColorSpace); - }); - }); -}); diff --git a/src/__tests__/riverGuideGeometry.test.ts b/src/__tests__/riverGuideGeometry.test.ts deleted file mode 100644 index b376d7b0..00000000 --- a/src/__tests__/riverGuideGeometry.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import * as THREE from 'three'; -import { buildRiverGuides, guideLine, GUIDE_COLOURS } from '../components/rower3d/riverGuideGeometry'; - -/** - * The boat looked off centre in the channel on every route, and there was no - * way to see whether it actually was: the water renders close to white, so the - * far bank is invisible and only the near one reads. These guides draw the - * centreline and the two water edges so the question can be answered by - * looking, rather than argued from screenshots. - */ -const straight = new THREE.CatmullRomCurve3([ - new THREE.Vector3(0, 0, -100), - new THREE.Vector3(0, 0, 0), - new THREE.Vector3(0, 0, 100), -]); - -describe('buildRiverGuides', () => { - it('puts the centreline on the curve itself', () => { - const { centre } = buildRiverGuides(straight); - - expect(centre.length).toBeGreaterThan(1); - for (const p of centre) { - expect(Math.abs(p.x)).toBeLessThan(0.001); - } - }); - - it('places the two edges symmetrically either side', () => { - const { left, right } = buildRiverGuides(straight); - - expect(left.length).toBe(right.length); - for (let i = 0; i < left.length; i += 1) { - // Equal and opposite about the centreline, which is the whole question - // the guides exist to answer. - expect(left[i].x).toBeCloseTo(-right[i].x, 4); - expect(Math.abs(left[i].x)).toBeGreaterThan(0); - } - }); - - it('follows a bend rather than staying straight', () => { - const bend = new THREE.CatmullRomCurve3([ - new THREE.Vector3(0, 0, -100), - new THREE.Vector3(40, 0, 0), - new THREE.Vector3(0, 0, 100), - ]); - - const { centre } = buildRiverGuides(bend); - const xs = centre.map((p) => p.x); - - expect(Math.max(...xs)).toBeGreaterThan(5); - }); - - it('returns nothing without a curve', () => { - const guides = buildRiverGuides(null); - - expect(guides.centre).toEqual([]); - expect(guides.left).toEqual([]); - expect(guides.right).toEqual([]); - }); - - it('names the colours the guides are drawn in', () => { - // Yellow down the middle, red at the edges — stated here so a change of - // mind has to change a test rather than just a literal in the scene. - expect(GUIDE_COLOURS.centre).toBe('#ffd400'); - expect(GUIDE_COLOURS.edge).toBe('#ff2d2d'); - }); -}); - -describe('guideLine', () => { - it('draws over the water rather than inside it', () => { - // A guide buried in the water surface answers nothing, which is the whole - // reason these exist. - const line = guideLine( - [new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 0, 10)], - GUIDE_COLOURS.centre, - ); - - const material = line.material as THREE.LineBasicMaterial; - expect(material.depthTest).toBe(false); - expect(line.renderOrder).toBeGreaterThan(0); - }); - - it('carries the colour it was asked for', () => { - const line = guideLine([new THREE.Vector3(), new THREE.Vector3(0, 0, 1)], GUIDE_COLOURS.edge); - - const material = line.material as THREE.LineBasicMaterial; - expect(`#${material.color.getHexString()}`).toBe(GUIDE_COLOURS.edge); - }); -}); diff --git a/src/__tests__/routeCurvatureFidelity.test.ts b/src/__tests__/routeCurvatureFidelity.test.ts deleted file mode 100644 index cf9686d4..00000000 --- a/src/__tests__/routeCurvatureFidelity.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import * as THREE from 'three'; -import type { Coordinate } from '../lib/types/index'; -import { createRouteCurve } from '../components/rower3d/curve'; -import { createWaterChannelGeometry } from '../components/rower3d/waterGeometry'; -import { createBankGeometry } from '../components/rower3d/bankGeometry'; -import { SCENE_SCALE } from '../components/rower3d/constants'; -import { - stripProgressSchedule, - stripSegmentCount, -} from '../components/rower3d/routeStripGeometry'; -import type { RouteEnrichmentData } from '../lib/services/routeEnrichmentService'; - -/** - * A channel narrow enough to negotiate a tight bend. - * - * The default width is 200 m, which physically cannot follow a 40 m radius — - * the inner edge folds through itself. Rowable hairpins are narrow water. - */ -const narrowChannel = (waterWidthMeters: number): RouteEnrichmentData => ({ - routeId: 'hairpin-fixture', - elevations: [], - segmentProfiles: [], - waterBodyType: 'river', - waterWidthMeters, - waterColor: '#3a6b7d', - waveIntensity: 1, - fetchedAt: Date.now(), - source: 'network', -}); - -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 at = (eastMeters: number, northMeters: number): Coordinate => ({ - lat: ORIGIN_LAT + northMeters / METERS_PER_DEGREE_LAT, - lng: eastMeters / LNG_SCALE, -}); - -/** - * A U-shaped hairpin: two straight legs joined by a 180-degree bend of - * `radiusMeters`. The shape #224 calls out as the faceting risk — a fixed - * segment count spreads the same geometry over the straights and the bend. - */ -const hairpin = (radiusMeters: number, legMeters: number, bendPoints = 60): Coordinate[] => { - const inbound = Array.from({ length: 30 }, (_, i) => - at(-radiusMeters, legMeters - (i / 29) * legMeters), - ); - const bend = Array.from({ length: bendPoints }, (_, i) => { - const angle = Math.PI + (i / (bendPoints - 1)) * Math.PI; - return at(Math.cos(angle) * radiusMeters, Math.sin(angle) * radiusMeters); - }); - const outbound = Array.from({ length: 30 }, (_, i) => - at(radiusMeters, (i / 29) * legMeters), - ); - return [...inbound, ...bend, ...outbound]; -}; - -/** Largest turn in degrees between consecutive samples of the strip schedule. */ -const worstScheduledTurnDegrees = (curve: THREE.CatmullRomCurve3): number => { - const points = stripProgressSchedule(curve).map((t) => curve.getPointAt(t)); - let worst = 0; - for (let i = 1; i < points.length - 1; i++) { - const a = points[i].clone().sub(points[i - 1]); - const b = points[i + 1].clone().sub(points[i]); - if (a.length() < 1e-6 || b.length() < 1e-6) continue; - worst = Math.max(worst, THREE.MathUtils.radToDeg(a.normalize().angleTo(b.normalize()))); - } - return worst; -}; - -/** Largest turn in degrees along one edge of a built strip. */ -const worstEdgeTurnDegrees = (geometry: THREE.BufferGeometry, edge: 0 | 1): number => { - const position = geometry.getAttribute('position') as THREE.BufferAttribute; - const edgePoints: THREE.Vector3[] = []; - for (let i = edge; i < position.count; i += 2) { - edgePoints.push(new THREE.Vector3().fromBufferAttribute(position, i)); - } - let worst = 0; - for (let i = 1; i < edgePoints.length - 1; i++) { - const a = edgePoints[i].clone().sub(edgePoints[i - 1]); - const b = edgePoints[i + 1].clone().sub(edgePoints[i]); - if (a.length() < 1e-9 || b.length() < 1e-9) continue; - worst = Math.max(worst, THREE.MathUtils.radToDeg(a.normalize().angleTo(b.normalize()))); - } - return worst; -}; - -describe('tight-curve routes render without faceting (#224)', () => { - it('holds a 40 m hairpin under the faceting threshold on the water channel', () => { - const curve = createRouteCurve(hairpin(40, 600), SCENE_SCALE)!; - const water = createWaterChannelGeometry(curve, { enrichment: narrowChannel(30) }); - - // A facet becomes visible when one segment turns more than a few degrees. - // 6 degrees is a 60-sided circle: smooth at any distance the rower sees. - expect(worstEdgeTurnDegrees(water, 0)).toBeLessThan(6); - expect(worstEdgeTurnDegrees(water, 1)).toBeLessThan(6); - }); - - it('holds the hairpin on the waterline edge of both riverbanks', () => { - // Only the inner edge is asserted. The outer edge sits a bank-width out — - // 150 m for this channel — and no 150 m-wide strip can follow a 40 m bend - // without its inside edge passing through itself. That is geometry, not a - // sampling defect, and it is the waterline the rower reads anyway. - const curve = createRouteCurve(hairpin(40, 600), SCENE_SCALE)!; - for (const side of ['left', 'right'] as const) { - const bank = createBankGeometry(curve, side, { enrichment: narrowChannel(30) }); - expect(worstEdgeTurnDegrees(bank, 0)).toBeLessThan(6); - } - }); - - it('keeps a very tight 15 m bend smooth as well', () => { - const curve = createRouteCurve(hairpin(15, 400), SCENE_SCALE)!; - // Measured on the schedule the strip actually samples, which concentrates - // its budget in the bend; uniform sampling at the same count would not. - expect(worstScheduledTurnDegrees(curve)).toBeLessThan(6); - expect( - worstEdgeTurnDegrees(createWaterChannelGeometry(curve, { enrichment: narrowChannel(12) }), 0), - ).toBeLessThan(6); - }); - - it('does not let simplification straighten the bend away', () => { - const track = hairpin(40, 600); - const simplified = createRouteCurve(track, SCENE_SCALE)!; - const unsimplified = createRouteCurve(track, SCENE_SCALE, 10, 0)!; - - // Both curves must still travel the same distance around the bend, to - // within the 3 m simplification epsilon. Said as metres rather than as a - // `toBeCloseTo` precision, because a unit is a metre now (#321) and a - // precision of 0 silently means half of whatever a unit happens to be. - expect( - Math.abs(simplified.getLength() - unsimplified.getLength()), - ).toBeLessThan(3); - }); - - it('spends more segments on a hairpin than the old fixed 200 would have', () => { - // The bend is where a fixed count under-samples; a 20 km winding route now - // earns 800 rather than 200. - const long = hairpin(40, 9_700); - expect(stripSegmentCount(createRouteCurve(long, SCENE_SCALE)!)).toBeGreaterThan(200); - }); -}); - -describe('closed-loop routes join without a seam (#224)', () => { - const loop = (radiusMeters: number, points = 400): Coordinate[] => - Array.from({ length: points }, (_, i) => { - const angle = (i / (points - 1)) * 2 * Math.PI; - return at(Math.sin(angle) * radiusMeters, (Math.cos(angle) - 1) * radiusMeters); - }); - - it('puts the end of the curve exactly where it started', () => { - const curve = createRouteCurve(loop(400), SCENE_SCALE)!; - const start = curve.getPointAt(0); - const end = curve.getPointAt(1); - // Scene units at 0.1 scale: 0.01 is a centimetre of water. - expect(start.distanceTo(end)).toBeLessThan(0.01); - }); - - it('carries the boat across the 0/1 boundary without a jump', () => { - const curve = createRouteCurve(loop(400), SCENE_SCALE)!; - const justBefore = curve.getPointAt(0.9995); - const justAfter = curve.getPointAt(0.0005); - const stepAcross = justBefore.distanceTo(justAfter); - const typicalStep = curve.getPointAt(0.5).distanceTo(curve.getPointAt(0.5005)); - - expect(stepAcross).toBeLessThan(typicalStep * 4); - }); - - it('closes the water channel on itself, first vertex pair to last', () => { - const curve = createRouteCurve(loop(400), SCENE_SCALE)!; - const water = createWaterChannelGeometry(curve, { enrichment: narrowChannel(30) }); - const position = water.getAttribute('position') as THREE.BufferAttribute; - const first = new THREE.Vector3().fromBufferAttribute(position, 0); - const last = new THREE.Vector3().fromBufferAttribute(position, position.count - 2); - - // Half a metre, which is what 0.05 units meant before #321. - expect(first.distanceTo(last)).toBeLessThan(0.5); - }); - - it('keeps a loop smooth through the join', () => { - const curve = createRouteCurve(loop(400), SCENE_SCALE)!; - expect( - worstEdgeTurnDegrees(createWaterChannelGeometry(curve, { enrichment: narrowChannel(30) }), 0), - ).toBeLessThan(6); - }); -}); diff --git a/src/__tests__/routeGeometryResolution.test.ts b/src/__tests__/routeGeometryResolution.test.ts deleted file mode 100644 index 4eb26b9d..00000000 --- a/src/__tests__/routeGeometryResolution.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import * as THREE from 'three'; -import type { Coordinate } from '../lib/types/index'; -import { - adaptiveSegmentCount, - createRouteCurve, - curveLengthMeters, - gpsToScenePoints, - curveSegmentCount, - getCurveDistances, - MAX_ROUTE_SEGMENTS, - MIN_ROUTE_SEGMENTS, - TARGET_METERS_BETWEEN_SEGMENTS, -} from '../components/rower3d/curve'; -import { SCENE_SCALE } from '../components/rower3d/constants'; -import { polylineLengthMeters } from '../lib/utils/coordinateUtils'; - -/** A straight north-running course of the requested real-world length. */ -const straightRoute = (lengthMeters: number, points = 40): Coordinate[] => { - const metersPerDegreeLat = 111_195; - return Array.from({ length: points }, (_, i) => ({ - lat: 50 + (i / (points - 1)) * (lengthMeters / metersPerDegreeLat), - lng: 0, - })); -}; - -describe('adaptiveSegmentCount', () => { - it('samples roughly every 15 m', () => { - expect(adaptiveSegmentCount(3000)).toBe(200); - expect(TARGET_METERS_BETWEEN_SEGMENTS).toBe(15); - }); - - it('does not drop below the floor on a sprint course', () => { - expect(adaptiveSegmentCount(500)).toBe(MIN_ROUTE_SEGMENTS); - expect(MIN_ROUTE_SEGMENTS).toBe(50); - }); - - it('rises with length up to the ceiling', () => { - expect(adaptiveSegmentCount(20_000)).toBe(MAX_ROUTE_SEGMENTS); - expect(adaptiveSegmentCount(50_000)).toBe(MAX_ROUTE_SEGMENTS); - expect(MAX_ROUTE_SEGMENTS).toBe(800); - }); - - it('gives a marathon more geometry than a head race', () => { - expect(adaptiveSegmentCount(6800)).toBeGreaterThan(adaptiveSegmentCount(2000)); - }); - - it('falls back to the floor for a degenerate length', () => { - expect(adaptiveSegmentCount(0)).toBe(MIN_ROUTE_SEGMENTS); - expect(adaptiveSegmentCount(-1)).toBe(MIN_ROUTE_SEGMENTS); - expect(adaptiveSegmentCount(Number.NaN)).toBe(MIN_ROUTE_SEGMENTS); - }); - - it('honours an explicit target spacing', () => { - expect(adaptiveSegmentCount(3000, 30)).toBe(100); - }); -}); - -describe('curve length and resolution', () => { - it('recovers the real-world route length from the scene curve', () => { - const coordinates = straightRoute(4000); - const curve = createRouteCurve(coordinates, SCENE_SCALE)!; - expect(curveLengthMeters(curve)).toBeCloseTo( - polylineLengthMeters(coordinates), - -1, - ); - }); - - it('scales the segment count with the curve it is given', () => { - const sprint = createRouteCurve(straightRoute(500), SCENE_SCALE)!; - const marathon = createRouteCurve(straightRoute(20_000), SCENE_SCALE)!; - - expect(curveSegmentCount(sprint)).toBe(MIN_ROUTE_SEGMENTS); - expect(curveSegmentCount(marathon)).toBe(MAX_ROUTE_SEGMENTS); - }); - - it('gives the distance lookup table one entry per segment plus the origin', () => { - const curve = createRouteCurve(straightRoute(3000), SCENE_SCALE)!; - expect(getCurveDistances(curve)).toHaveLength(curveSegmentCount(curve) + 1); - }); -}); - -describe('createRouteCurve simplification', () => { - it('drops the collinear filler a straight import leaves behind', () => { - const straight = straightRoute(2000, 200); - const simplifiedCurve = createRouteCurve(straight, SCENE_SCALE)!; - const rawCurve = createRouteCurve(straight, SCENE_SCALE, 10, 0)!; - - expect(simplifiedCurve.points.length).toBeLessThan(rawCurve.points.length); - }); - - it('still upsamples to the 10 m grid after simplifying', () => { - const curve = createRouteCurve(straightRoute(2000, 200), SCENE_SCALE)!; - // 2 km at 10 m resolution, give or take the spline's own end handling. - expect(curve.points.length).toBeGreaterThan(150); - }); - - it('keeps the boat on the same water as the unsimplified curve', () => { - const metersPerDegreeLat = 111_195; - const winding: Coordinate[] = Array.from({ length: 600 }, (_, i) => { - const t = i / 599; - return { - lat: 50 + (t * 3000) / metersPerDegreeLat, - lng: (Math.sin(t * 6 * Math.PI) * 40) / (metersPerDegreeLat * Math.cos((50 * Math.PI) / 180)), - }; - }); - - const simplified = createRouteCurve(winding, SCENE_SCALE)!; - const raw = createRouteCurve(winding, SCENE_SCALE, 10, 0)!; - - const worstDrift = Array.from({ length: 101 }, (_, i) => { - const t = i / 100; - return simplified.getPointAt(t).distanceTo(raw.getPointAt(t)); - }).reduce((a, b) => Math.max(a, b), 0); - - // Metres of water, because a unit is a metre (#321). Three of them is the - // simplification epsilon; this read 0.3 when three metres was 0.3 units. - expect(worstDrift).toBeLessThan(3); - }); - - it('keeps a 10,000-point import to a spline the frame loop can afford', () => { - // The frame loop samples this spline twice per frame, and every geometry - // vertex samples it once more. A dense import used to hand it one control - // point per GPS fix; the shape needs a small fraction of them (#224). - const dense: Coordinate[] = Array.from({ length: 10_000 }, (_, i) => { - const t = i / 9999; - return { - lat: 51.45 + (t * 12_000) / 111_195, - lng: (Math.sin(t * 20 * Math.PI) * 45) / (111_195 * Math.cos((51.45 * Math.PI) / 180)), - }; - }); - - const curve = createRouteCurve(dense, SCENE_SCALE)!; - expect(curve.points.length).toBeLessThan(dense.length / 4); - - // ...and the shape still holds: the simplified spline never strays further - // from the imported polyline than the epsilon allows. Measured against - // `gpsToScenePoints`, which is the projection the scene itself draws in. - const imported = gpsToScenePoints(dense, SCENE_SCALE); - const worstDrift = Array.from({ length: 201 }, (_, i) => { - const point = curve.getPointAt(i / 200); - return imported.reduce((nearest, p) => Math.min(nearest, point.distanceTo(p)), Infinity); - }).reduce((a, b) => Math.max(a, b)); - - // Metres: the 3 m simplification epsilon, which read 0.3 units before #321. - expect(worstDrift).toBeLessThan(3); - }); - - it('never simplifies a route below a drawable two points', () => { - const pair: Coordinate[] = [{ lat: 50, lng: 0 }, { lat: 50.001, lng: 0 }]; - expect(createRouteCurve(pair, SCENE_SCALE)).toBeInstanceOf(THREE.CatmullRomCurve3); - }); -}); diff --git a/src/__tests__/routeStripGeometry.test.ts b/src/__tests__/routeStripGeometry.test.ts deleted file mode 100644 index 94092bcf..00000000 --- a/src/__tests__/routeStripGeometry.test.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import * as THREE from 'three'; -import type { Coordinate } from '../lib/types/index'; -import { createRouteCurve } from '../components/rower3d/curve'; -import { stripSegmentCount } from '../components/rower3d/routeStripGeometry'; -import { createWaterChannelGeometry, WATER_SURFACE_Y } from '../components/rower3d/waterGeometry'; -import { createBankGeometry, BANK_WATERLINE_Y } from '../components/rower3d/bankGeometry'; -import { chunkProgressRanges } from '../components/rower3d/geometryChunks'; -import { facingAway, faceNormals } from './faceNormals'; -import { SCENE_SCALE, WATER_CHANNEL_WIDTH } from '../components/rower3d/constants'; -import type { RouteEnrichmentData } from '../lib/services/routeEnrichmentService'; - -const metersPerDegreeLat = 111_195; - -/** A gently winding course of the requested real-world length. */ -const windingRoute = (lengthMeters: number, bends = 4, points = 300): Coordinate[] => - Array.from({ length: points }, (_, i) => { - const t = i / (points - 1); - return { - lat: 50 + (t * lengthMeters) / metersPerDegreeLat, - lng: - (Math.sin(t * bends * Math.PI) * 60) / - (metersPerDegreeLat * Math.cos((50 * Math.PI) / 180)), - }; - }); - -const curveFor = (lengthMeters: number, bends?: number) => - createRouteCurve(windingRoute(lengthMeters, bends), SCENE_SCALE)!; - -const vertexAt = (geometry: THREE.BufferGeometry, index: number): THREE.Vector3 => - new THREE.Vector3().fromBufferAttribute( - geometry.getAttribute('position') as THREE.BufferAttribute, - index, - ); - -const vertexCount = (geometry: THREE.BufferGeometry) => - geometry.getAttribute('position').count; - -const flatEnrichment = (waterWidthMeters: number): RouteEnrichmentData => ({ - routeId: 'strip-fixture', - elevations: [], - segmentProfiles: [], - waterBodyType: 'river', - waterWidthMeters, - waterColor: '#3a6b7d', - waveIntensity: 1, - fetchedAt: Date.now(), - source: 'network', -}); - -describe('createWaterChannelGeometry', () => { - it('lays two vertices per sample along the whole route', () => { - const curve = curveFor(3000); - const geometry = createWaterChannelGeometry(curve); - expect(vertexCount(geometry)).toBe((stripSegmentCount(curve) + 1) * 2); - }); - - it('faces the sky, so a front-facing material draws it (#284)', () => { - /** - * The water strip walks its two vertices in the +perp direction while the - * left bank walks -perp, and both used the same index order. Mirroring a - * triangle reverses which way it faces, so every water triangle pointed - * down - the same mistake #269 found in the banks, in the strip next to - * them. - * - * It was invisible because waterComponents.tsx sets side: DoubleSide. That - * hides the culling and not the shading: under DOUBLE_SIDED three flips the - * normal for back faces, so the explicit (0,1,0) normal attribute was being - * negated at shading time and the river was lit from underneath. - */ - const geometry = createWaterChannelGeometry(curveFor(3000)); - - const away = facingAway(geometry); - expect(away, `${away} water triangles face away from the sky`).toBe(0); - }); - - it('agrees with the banks about which way is up', () => { - // The three strips meet at the waterline and are lit by the same sun. - const curve = curveFor(3000); - const water = faceNormals(createWaterChannelGeometry(curve)); - const left = faceNormals(createBankGeometry(curve, 'left')); - const right = faceNormals(createBankGeometry(curve, 'right')); - - for (const [label, normals] of [['water', water], ['left', left], ['right', right]] as const) { - expect(normals.length, `${label} built no triangles`).toBeGreaterThan(0); - expect( - Math.min(...normals.map((n) => n.y)), - `the ${label} strip has a triangle pointing away from the sky`, - ).toBeGreaterThan(0); - } - }); - it('spends more geometry on a long route than a short one', () => { - expect(vertexCount(createWaterChannelGeometry(curveFor(20_000)))).toBeGreaterThan( - vertexCount(createWaterChannelGeometry(curveFor(1000))), - ); - }); - - it('keeps the surface flat at the waterline', () => { - const geometry = createWaterChannelGeometry(curveFor(2000)); - const heights = Array.from( - { length: vertexCount(geometry) }, - (_, i) => vertexAt(geometry, i).y, - ); - expect(new Set(heights).size).toBe(1); - expect(heights[0]).toBeCloseTo(WATER_SURFACE_Y, 6); - }); - - it('opens the channel to the route\u2019s measured water width', () => { - const curve = curveFor(2000); - const NARROW_METRES = 20; - const WIDE_METRES = 200; - const narrow = createWaterChannelGeometry(curve, { - enrichment: flatEnrichment(NARROW_METRES), - }); - const wide = createWaterChannelGeometry(curve, { enrichment: flatEnrichment(WIDE_METRES) }); - const spanAtStart = (geometry: THREE.BufferGeometry) => - vertexAt(geometry, 0).distanceTo(vertexAt(geometry, 1)); - - expect(spanAtStart(wide)).toBeGreaterThan(spanAtStart(narrow) * 5); - // The span is the width, in metres, because a unit is a metre (#321). This - // used to read `WATER_CHANNEL_WIDTH` and passed on a coincidence: 200 m at - // the old ten-metre unit came out as 20 units, which is the number the - // default channel width happens to hold. - expect(spanAtStart(wide)).toBeCloseTo(WIDE_METRES, 3); - expect(spanAtStart(narrow)).toBeCloseTo(WATER_CHANNEL_WIDTH, 3); - }); - - it('carries a bounding sphere so the frame loop can cull it', () => { - const geometry = createWaterChannelGeometry(curveFor(5000)); - expect(geometry.boundingSphere).not.toBeNull(); - expect(geometry.boundingSphere!.radius).toBeGreaterThan(0); - }); -}); - -describe('chunked strips', () => { - it('meets at the chunk boundary with no seam', () => { - const curve = curveFor(12_000, 8); - const ranges = chunkProgressRanges(8); - - ranges.slice(0, -1).forEach((range, i) => { - const before = createWaterChannelGeometry(curve, { range }); - const after = createWaterChannelGeometry(curve, { range: ranges[i + 1] }); - const lastIndex = vertexCount(before) - 2; - - expect(vertexAt(before, lastIndex).distanceTo(vertexAt(after, 0))).toBe(0); - expect(vertexAt(before, lastIndex + 1).distanceTo(vertexAt(after, 1))).toBe(0); - }); - }); - - it('meets at the chunk boundary on both banks too', () => { - const curve = curveFor(12_000, 8); - const [first, second] = chunkProgressRanges(4); - - (['left', 'right'] as const).forEach((side) => { - const before = createBankGeometry(curve, side, { range: first }); - const after = createBankGeometry(curve, side, { range: second }); - const lastIndex = vertexCount(before) - 2; - - expect(vertexAt(before, lastIndex).distanceTo(vertexAt(after, 0))).toBe(0); - expect(vertexAt(before, lastIndex + 1).distanceTo(vertexAt(after, 1))).toBe(0); - }); - }); - - it('keeps the resolution of an unchunked build', () => { - const curve = curveFor(12_000, 8); - const whole = vertexCount(createWaterChannelGeometry(curve)); - const chunked = chunkProgressRanges(8) - .map((range) => vertexCount(createWaterChannelGeometry(curve, { range }))) - .reduce((a, b) => a + b, 0); - - // Every chunk repeats its predecessor's closing pair of vertices, and each - // chunk rounds its own share of the budget, so the totals agree loosely. - expect(chunked).toBeGreaterThan(whole); - expect(chunked).toBeLessThan(whole * 1.2); - }); - - it('traces the same water as the unchunked build', () => { - const curve = curveFor(9000, 6); - const whole = createWaterChannelGeometry(curve); - const middle = createWaterChannelGeometry(curve, { range: { from: 0.5, to: 0.75 } }); - - // Chunked and unchunked builds place their samples independently, so a - // chunk vertex need not coincide with a whole-route vertex — it must lie on - // the same edge. Measured against the edge's segments: nearest-vertex - // distance would just report the sample spacing. 0.05 scene units is 50 cm. - const startOfChunk = vertexAt(middle, 0); - const edge: THREE.Vector3[] = []; - for (let i = 0; i < vertexCount(whole); i += 2) edge.push(vertexAt(whole, i)); - - const nearest = edge.slice(0, -1).reduce((best, a, i) => { - const b = edge[i + 1]; - const span = b.clone().sub(a); - const lengthSquared = span.lengthSq(); - const along = - lengthSquared > 0 - ? Math.max(0, Math.min(1, startOfChunk.clone().sub(a).dot(span) / lengthSquared)) - : 0; - return Math.min(best, startOfChunk.distanceTo(a.clone().addScaledVector(span, along))); - }, Infinity); - - expect(nearest).toBeLessThan(0.05); - }); - - it('builds a drawable chunk even for a hair-thin range', () => { - const geometry = createWaterChannelGeometry(curveFor(2000), { - range: { from: 0.5, to: 0.5001 }, - }); - expect(vertexCount(geometry)).toBe(4); - expect(geometry.getIndex()!.count).toBe(6); - }); -}); - -describe('createBankGeometry', () => { - it('pins the inner edge to the waterline and pushes the outer edge away', () => { - const curve = curveFor(2000); - const left = createBankGeometry(curve, 'left'); - const right = createBankGeometry(curve, 'right'); - - // A tolerance, not `===`: the height comes back out of a - // Float32BufferAttribute, and strict equality only held while the - // waterline happened to be -0.5, which float32 stores exactly (#334). - expect(vertexAt(left, 0).y).toBeCloseTo(BANK_WATERLINE_Y, 5); - expect(vertexAt(left, 1).distanceTo(vertexAt(left, 0))).toBeGreaterThan(0); - // The two banks straddle the channel: their inner edges sit on opposite sides. - expect(vertexAt(left, 0).distanceTo(vertexAt(right, 0))).toBeCloseTo( - WATER_CHANNEL_WIDTH, - 3, - ); - }); - - it('scales its resolution with the route, like the water it borders', () => { - const curve = curveFor(20_000); - expect(vertexCount(createBankGeometry(curve, 'left'))).toBe( - vertexCount(createWaterChannelGeometry(curve)), - ); - }); -}); diff --git a/src/__tests__/routesDrawerToggle.test.tsx b/src/__tests__/routesDrawerToggle.test.tsx deleted file mode 100644 index 84e8efc0..00000000 --- a/src/__tests__/routesDrawerToggle.test.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { afterAll, beforeAll, afterEach, describe, it, expect } from 'vitest'; -import { render, screen, cleanup } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import App from '../App'; -import { installCanvasMock } from './canvasMock'; - -/** - * #505 Phase 2e (D6-c): the Routes screen is map-primary and the catalogue - * slides in from the right. - */ - -let uninstallCanvas: () => void; - -beforeAll(() => { - uninstallCanvas = installCanvasMock(); -}); - -afterAll(() => { - uninstallCanvas(); -}); - -afterEach(() => cleanup()); - -describe('Routes drawer toggle (D6-c)', () => { - it('renders the drawer toggle on the Routes screen', async () => { - render(); - const user = userEvent.setup(); - // Go to the Routes screen (header nav has a "Routes" entry). - await user.click(screen.getByRole('button', { name: /^routes$/i })); - const toggle = await screen.findByTestId('routes-drawer-toggle'); - expect(toggle.textContent).toMatch(/show list/i); - expect(toggle.getAttribute('aria-expanded')).toBe('false'); - }); - - it('flips `data-drawer-open` and the toggle label when clicked', async () => { - render(); - const user = userEvent.setup(); - await user.click(screen.getByRole('button', { name: /^routes$/i })); - const toggle = await screen.findByTestId('routes-drawer-toggle'); - const screenEl = toggle.closest('.route-search-screen'); - expect(screenEl).not.toBeNull(); - expect(screenEl?.getAttribute('data-drawer-open')).toBe('false'); - await user.click(toggle); - expect(screenEl?.getAttribute('data-drawer-open')).toBe('true'); - expect(toggle.textContent).toMatch(/hide list/i); - }); -}); diff --git a/src/__tests__/rowBreakdown.test.tsx b/src/__tests__/rowBreakdown.test.tsx deleted file mode 100644 index 56dca4fd..00000000 --- a/src/__tests__/rowBreakdown.test.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen, within, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import type { ActivitySample, WorkoutSession } from '../lib/types/index'; - -vi.mock('react-chartjs-2', () => ({ - Line: () => , -})); - -const download = vi.hoisted(() => vi.fn()); -vi.mock('../lib/utils/exporters', async (importOriginal) => ({ - ...(await importOriginal()), - triggerBlobDownload: download, -})); - -import { RowBreakdown } from '../components/RowBreakdown'; - -/** - * Issue #337 — the row itself, under the totals: the splits, a chart, how it - * compares with this athlete's best on the route, and a card to share. - */ - -const samples = (metres: number): ActivitySample[] => - Array.from({ length: Math.ceil(metres / 4) + 1 }, (_, t) => ({ - t, - distance: Math.min(metres, t * 4), - pace: 125, - power: 170, - cadence: 26, - heartRate: 141, - })); - -const session = (overrides: Partial = {}): WorkoutSession => ({ - id: 's-1', - routeId: 'r1', - routeName: 'Willowbrook River', - startTime: new Date('2026-03-14T08:00:00Z'), - duration: 500, - distance: 2000, - averagePace: 123, - calories: 100, - splits: [], - isActive: false, - samples: samples(2000), - ...overrides, -}); - -describe('RowBreakdown', () => { - beforeEach(() => download.mockReset()); - - it('lists one split per 500 m', () => { - render(); - const table = screen.getByRole('table', { name: 'Splits' }); - const rows = within(table).getAllByRole('row').slice(1); - expect(rows.map((r) => within(r).getAllByRole('cell')[0].textContent)).toEqual([ - '500 m', - '1 000 m', - '1 500 m', - '2 000 m', - ]); - expect(within(rows[0]).getByText('2:05')).toBeInTheDocument(); - expect(within(rows[0]).getByText('26')).toBeInTheDocument(); - }); - - it('draws the row as a chart', () => { - render(); - expect(screen.getByTestId('row-chart')).toBeInTheDocument(); - }); - - it('says a first row on the route is one, rather than comparing it with nothing', () => { - render(); - expect(screen.getByTestId('row-pb')).toHaveTextContent('First row on this route'); - }); - - it('compares the average split with the best on this route', () => { - render(); - const pb = screen.getByTestId('row-pb'); - expect(pb).toHaveTextContent('Personal best 2:05/500m'); - expect(pb).toHaveTextContent('−0:02 today'); - expect(pb).toHaveAttribute('data-trend', 'faster'); - }); - - it('marks a slower row as slower', () => { - render(); - expect(screen.getByTestId('row-pb')).toHaveAttribute('data-trend', 'slower'); - }); - - it('leaves the comparison out where there is no history to compare with', () => { - render(); - expect(screen.queryByTestId('row-pb')).toBeNull(); - }); - - it('says so when the row recorded nothing to break down', () => { - render(); - expect(screen.queryByRole('table', { name: 'Splits' })).toBeNull(); - expect(screen.queryByTestId('row-chart')).toBeNull(); - }); - - describe('the share card', () => { - let restore: () => void; - beforeEach(() => { - const getContext = HTMLCanvasElement.prototype.getContext; - const toBlob = HTMLCanvasElement.prototype.toBlob; - HTMLCanvasElement.prototype.getContext = vi.fn(() => ({ - fillRect: vi.fn(), - fillText: vi.fn(), - createLinearGradient: vi.fn(() => ({ addColorStop: vi.fn() })), - })) as unknown as typeof getContext; - HTMLCanvasElement.prototype.toBlob = function (cb: BlobCallback, type?: string) { - cb(new Blob(['png'], { type: type ?? 'image/png' })); - }; - restore = () => { - HTMLCanvasElement.prototype.getContext = getContext; - HTMLCanvasElement.prototype.toBlob = toBlob; - }; - }); - afterEach(() => restore()); - - it('downloads a PNG of the row, made in the browser', async () => { - render(); - await userEvent.click(screen.getByRole('button', { name: /share/i })); - - await waitFor(() => expect(download).toHaveBeenCalledTimes(1)); - const [blob, type, name] = download.mock.calls[0]; - expect(blob).toBeInstanceOf(Blob); - expect((blob as Blob).type).toBe('image/png'); - expect(type).toBe('image/png'); - expect(name).toBe('willowbrook-river-2026-03-14.png'); - }); - }); -}); diff --git a/src/__tests__/rowHud.test.tsx b/src/__tests__/rowHud.test.tsx deleted file mode 100644 index 18804a79..00000000 --- a/src/__tests__/rowHud.test.tsx +++ /dev/null @@ -1,256 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen, act } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { RowHud, type RowHudProps } from '../components/RowHud'; - -/** - * Issue #335 — the metrics are on the stage. - * - * The cases here are the ones a rower would notice: the right tiles for the - * width they are looking at, the numbers reading as numbers, the controls still - * working, and `F` reaching the fullscreen control. Which tiles for which width - * is `rowHudPlan.test.ts`; this is that decision arriving on screen. - */ - -const props = (over: Partial = {}): RowHudProps => ({ - paceSecondsPer500: 118, - strokeRate: 24, - power: 187, - heartRate: 148, - distanceMeters: 1000.4, - elapsedMs: 252_000, - paused: false, - onPause: vi.fn(), - onResume: vi.fn(), - onReset: vi.fn(), - onEnd: vi.fn(), - fullscreen: { active: false, toggle: vi.fn(), supported: true }, - ...over, -}); - -/** jsdom reports whatever `innerWidth` says, so a viewport is one assignment. */ -const atWidth = (width: number) => { - Object.defineProperty(window, 'innerWidth', { configurable: true, value: width }); -}; - -const tiles = () => - Array.from(document.querySelectorAll('.activity-stat-card')).map((card) => ({ - label: card.querySelector('.activity-stat-label')?.textContent ?? '', - value: card.querySelector('.activity-stat-value')?.textContent ?? '', - })); - -describe('the row HUD', () => { - beforeEach(() => atWidth(1280)); - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('puts six tiles on a laptop and four on a phone', () => { - const { unmount } = render(); - expect(tiles()).toHaveLength(6); - unmount(); - - atWidth(390); - render(); - expect(tiles().map((t) => t.label)).toEqual([ - 'Split (/500m)', - 'SPM', - 'Heart Rate (bpm)', - 'Meters', - ]); - }); - - // A phone turned on its side crosses the threshold, and nothing re-renders - // the HUD when it does unless the width is listened to. - it('picks up a rotation', () => { - atWidth(390); - render(); - expect(tiles()).toHaveLength(4); - - act(() => { - atWidth(844); - window.dispatchEvent(new Event('orientationchange')); - }); - // 844 is still under the six-tile threshold; 1280 is over it. - expect(tiles()).toHaveLength(4); - - act(() => { - atWidth(1280); - window.dispatchEvent(new Event('resize')); - }); - expect(tiles()).toHaveLength(6); - }); - - it('reads the numbers out the way the monitor does', () => { - render(); - - // The number alone; its unit is the label above it (#344). - expect(tiles()).toEqual([ - { label: 'Split (/500m)', value: '1:58' }, - { label: 'SPM', value: '24' }, - { label: 'Power (W)', value: '187' }, - { label: 'Heart Rate (bpm)', value: '148' }, - { label: 'Meters', value: '1000' }, - { label: 'Time', value: '4:12' }, - ]); - }); - - // Before the first stroke there is no split and no rate. A dash says so; a - // zero would be a reading. - it('says nothing rather than zero before the first stroke', () => { - render( - , - ); - const values = tiles().map((t) => t.value); - - expect(values).toContain('--:--'); - expect(values.filter((v) => v === '--'), 'rate, power and heart rate all say nothing') - .toHaveLength(3); - }); - - it('holds every digit to the same width', () => { - render(); - // The class the stylesheet hangs `tabular-nums` on. Asserting the computed - // value would assert jsdom's stylesheet handling, not the design. - for (const card of Array.from(document.querySelectorAll('.activity-stat-value'))) { - expect(card).toHaveClass('activity-stat-value'); - } - expect(document.querySelectorAll('.activity-stat-value')).toHaveLength(6); - }); - - describe('the controls', () => { - it('pauses, resumes, resets and ends', async () => { - const user = userEvent.setup(); - const running = props(); - const { rerender } = render(); - - await user.click(screen.getByRole('button', { name: /pause/i })); - expect(running.onPause).toHaveBeenCalledTimes(1); - - await user.click(screen.getByRole('button', { name: /reset/i })); - expect(running.onReset).toHaveBeenCalledTimes(1); - - await user.click(screen.getByRole('button', { name: /end workout/i })); - expect(running.onEnd).toHaveBeenCalledTimes(1); - - rerender(); - await user.click(screen.getByRole('button', { name: /resume/i })); - expect(running.onResume).toHaveBeenCalledTimes(1); - }); - }); - - describe('fullscreen', () => { - it('toggles from the button, and says which state it is in', async () => { - const user = userEvent.setup(); - const toggle = vi.fn(); - const { rerender } = render( - , - ); - - const button = screen.getByRole('button', { name: /fill the screen/i }); - expect(button).toHaveAttribute('aria-pressed', 'false'); - await user.click(button); - expect(toggle).toHaveBeenCalledTimes(1); - - rerender(); - expect(screen.getByRole('button', { name: /leave fullscreen/i })).toHaveAttribute( - 'aria-pressed', - 'true', - ); - }); - - it('answers to F', async () => { - const user = userEvent.setup(); - const toggle = vi.fn(); - render(); - - await user.keyboard('f'); - expect(toggle).toHaveBeenCalledTimes(1); - }); - - // Ctrl+F is the browser's find. Taking it would be a shortcut that breaks - // a shortcut. - it('leaves the modified F alone', async () => { - const user = userEvent.setup(); - const toggle = vi.fn(); - render(); - - await user.keyboard('{Control>}f{/Control}'); - expect(toggle).not.toHaveBeenCalled(); - }); - - it('leaves F alone while the rower is typing', async () => { - const user = userEvent.setup(); - const toggle = vi.fn(); - render( - <> - - - , - ); - - await user.click(screen.getByLabelText(/a field somewhere else/i)); - await user.keyboard('f'); - - expect(toggle).not.toHaveBeenCalled(); - expect(screen.getByLabelText(/a field somewhere else/i)).toHaveValue('f'); - }); - }); -}); - -/** - * Issue #338 — the gap to the boat you are chasing. - * - * The ghost is only worth rowing against if you can see where it is, and on a - * phone against an erg the one thing you can look at is the strip. The gap sits - * above it: it is the reading a racing rower checks between strokes, and it - * belongs with the numbers rather than in the scene, where a boat two lengths - * up is off the edge of the shot. - */ -describe('the row HUD, racing a ghost', () => { - beforeEach(() => atWidth(1280)); - afterEach(() => { - vi.restoreAllMocks(); - }); - - const gap = () => document.querySelector('.row-hud-gap'); - - it('says nothing at all when nothing is being chased', () => { - render(); - - expect(gap()).toBeNull(); - }); - - it('shows the metres in hand, and what they are in hand of', () => { - render(); - - expect(gap()).toHaveTextContent('+12 m'); - expect(gap()).toHaveTextContent(/your best/i); - }); - - // Words as well as the colour (#344): a rower who cannot tell the greens - // from the reds reads the same sign from the "+" and the "−". - it('does not leave the lead to a colour', () => { - const { rerender } = render( - , - ); - expect(gap()).toHaveAttribute('data-lead', 'ahead'); - - rerender(); - expect(gap()).toHaveAttribute('data-lead', 'behind'); - expect(gap()).toHaveTextContent('−12 m'); - - rerender(); - expect(gap()).toHaveAttribute('data-lead', 'level'); - expect(gap()).toHaveTextContent(/level/i); - }); - - // Before the first stroke there is no gap, only two boats on the line. - it('waits for a gap rather than showing a zero', () => { - render(); - - expect(gap()).toBeNull(); - }); -}); diff --git a/src/__tests__/rower3D.test.tsx b/src/__tests__/rower3D.test.tsx deleted file mode 100644 index fc482ea4..00000000 --- a/src/__tests__/rower3D.test.tsx +++ /dev/null @@ -1,415 +0,0 @@ -/** - * Rower3D Component Tests - * - * Tests for the 3D rowing simulation component ensuring: - * 1. Objects between camera and rower never obscure the view - * 2. Screen does not spin around the rower - * 3. Rower moves forward along route when there is power/pace - * 4. Rower is always in the river (blue), river fits within bank definitions - * 5. Trees and objects have perspective scaling based on distance - * 6. Objects are sized relative to rower/boat with real-world proportions - */ - -import { describe, it, expect, vi } from 'vitest'; - -// Mock dependencies that require browser APIs -vi.mock('@react-three/fiber', () => ({ - Canvas: ({ children }: { children: React.ReactNode }) =>
{children}
, - useFrame: vi.fn(), - useThree: () => ({ - camera: { position: { set: vi.fn(), toArray: () => [0, 2.5, 8] }, lookAt: vi.fn(), rotation: { toArray: () => [0, 0, 0] } }, - gl: { setPixelRatio: vi.fn(), domElement: document.createElement('canvas') } - }) -})); - -vi.mock('@react-three/drei', () => ({ - Sky: () => null -})); - -// Test the logic functions extracted from Rower3D - -describe('Rower3D Component Logic', () => { - - describe('1. View Obstruction Prevention', () => { - /** - * Test that the isInViewCorridor function correctly identifies objects - * that would obstruct the view between camera and boat - */ - it('should identify objects in the view corridor between camera and boat', () => { - // Camera is at z=8, boat at z=0 - // View corridor: z > -5 and z < 12, with variable width - - const isInViewCorridor = (sceneX: number, sceneZ: number): boolean => { - if (sceneZ > -5 && sceneZ < 12) { - let viewHalfWidth: number; - if (sceneZ < 3) { - viewHalfWidth = 4; - } else { - viewHalfWidth = 4 + (sceneZ - 3) * 0.8; - } - return Math.abs(sceneX) < viewHalfWidth; - } - return false; - }; - - // Objects directly between camera and boat should be in corridor - expect(isInViewCorridor(0, 5)).toBe(true); // Centered between camera and boat - expect(isInViewCorridor(0, 0)).toBe(true); // At boat position (now included) - expect(isInViewCorridor(0, 10)).toBe(true); // Close to camera - - // Objects to the side should NOT be in corridor (outside view width) - expect(isInViewCorridor(20, 5)).toBe(false); // Far to the right - expect(isInViewCorridor(-20, 5)).toBe(false); // Far to the left - - // Objects ahead of boat (more negative than -5) should NOT be in corridor - expect(isInViewCorridor(0, -10)).toBe(false); // Well ahead of boat - - // Objects behind camera should NOT be in corridor - expect(isInViewCorridor(0, 15)).toBe(false); // Behind camera - }); - - it('should make objects transparent when in view corridor', () => { - const isInViewCorridor = (sceneX: number, sceneZ: number): boolean => { - if (sceneZ > -5 && sceneZ < 12) { - let viewHalfWidth: number; - if (sceneZ < 3) { - viewHalfWidth = 4; - } else { - viewHalfWidth = 4 + (sceneZ - 3) * 0.8; - } - return Math.abs(sceneX) < viewHalfWidth; - } - return false; - }; - - const getViewCorridorOpacity = (sceneX: number, sceneZ: number): number => { - if (!isInViewCorridor(sceneX, sceneZ)) { - return 1.0; // Fully opaque when not in corridor - } - // In view corridor - calculate transparency based on proximity to center - const distFromCenter = Math.sqrt(sceneX * sceneX + sceneZ * sceneZ); - const opacity = Math.min(0.3, 0.1 + distFromCenter * 0.02); - return opacity; - }; - - // Objects in corridor should be transparent (opacity < 1) - expect(getViewCorridorOpacity(0, 5)).toBeLessThan(1.0); - expect(getViewCorridorOpacity(0, 0)).toBeLessThan(1.0); - - // Objects outside corridor should be fully opaque - expect(getViewCorridorOpacity(20, 5)).toBe(1.0); - expect(getViewCorridorOpacity(0, -10)).toBe(1.0); - expect(getViewCorridorOpacity(0, 15)).toBe(1.0); - }); - - it('should allow objects far to the sides even at same Z as corridor', () => { - const isInViewCorridor = (sceneX: number, sceneZ: number): boolean => { - if (sceneZ > -5 && sceneZ < 12) { - let viewHalfWidth: number; - if (sceneZ < 3) { - viewHalfWidth = 4; - } else { - viewHalfWidth = 4 + (sceneZ - 3) * 0.8; - } - return Math.abs(sceneX) < viewHalfWidth; - } - return false; - }; - - // At z=5, view half-width is 4 + (5-3)*0.8 = 5.6 - // So objects with |x| >= 5.6 should NOT be in corridor - expect(isInViewCorridor(3, 5)).toBe(true); // x=3 < 5.6, in corridor - expect(isInViewCorridor(6, 5)).toBe(false); // x=6 > 5.6, outside corridor - expect(isInViewCorridor(-7, 5)).toBe(false); // x=-7 outside corridor - }); - }); - - describe('2. Screen Rotation Stability', () => { - /** - * Test that world rotation changes slowly to prevent disorienting spinning - */ - it('should limit rotation speed to prevent spinning', () => { - const maxRotationSpeed = 0.3; // radians per second - const deltaTime = 0.016; // 60fps frame - - // Simulate large rotation target change - const currentRotation = 0; - const targetRotation = Math.PI; // 180 degree turn request - - let rotationDiff = targetRotation - currentRotation; - // Handle angle wrapping - if (rotationDiff > Math.PI) rotationDiff -= Math.PI * 2; - if (rotationDiff < -Math.PI) rotationDiff += Math.PI * 2; - - // Calculate limited rotation step - const rotationStep = Math.sign(rotationDiff) * Math.min(Math.abs(rotationDiff), maxRotationSpeed * deltaTime); - - // Rotation step should be limited by maxRotationSpeed - expect(Math.abs(rotationStep)).toBeLessThanOrEqual(maxRotationSpeed * deltaTime); - - // Even with large target change, actual step is small - expect(Math.abs(rotationStep)).toBeLessThan(0.01); // Small step per frame - }); - - it('should handle angle wrapping correctly', () => { - // Test crossing from +π to -π boundary - const currentRotation = Math.PI * 0.9; - const targetRotation = -Math.PI * 0.9; - - let rotationDiff = targetRotation - currentRotation; - if (rotationDiff > Math.PI) rotationDiff -= Math.PI * 2; - if (rotationDiff < -Math.PI) rotationDiff += Math.PI * 2; - - // Should take the short way around (0.2π instead of 1.8π) - expect(Math.abs(rotationDiff)).toBeLessThan(Math.PI); - }); - }); - - describe('3. Rower Forward Movement', () => { - /** - * Test that rower progress increases when there is pace/power - */ - it('should calculate forward progress from pace', () => { - const paceSPer500 = 120; // 2:00 per 500m = 4.17 m/s - const totalDistance = 5000; // 5km route - const deltaTime = 1.0; // 1 second - - // Speed in m/s - const speedMps = 500 / paceSPer500; - expect(speedMps).toBeCloseTo(4.17, 1); - - // Progress per second - const progressPerSecond = speedMps / totalDistance; - expect(progressPerSecond).toBeGreaterThan(0); - - // After 1 second at this pace, progress should increase - const newProgress = 0 + progressPerSecond * deltaTime; - expect(newProgress).toBeGreaterThan(0); - expect(newProgress).toBeLessThan(1); - }); - - it('should not move when pace is zero or undefined', () => { - const paceSPer500 = 0; - const speedMps = paceSPer500 > 0 ? 500 / paceSPer500 : 0; - - expect(speedMps).toBe(0); - - // Progress should not change - const totalDistance = 5000; - const progressPerSecond = totalDistance > 0 ? (speedMps / totalDistance) : 0; - expect(progressPerSecond).toBe(0); - }); - - it('should apply intensity factor to speed', () => { - const basePace = 120; // 2:00 per 500m - const intensityFactor = 1.2; // 120% intensity - - let speedMps = 500 / basePace; - speedMps *= intensityFactor; - - expect(speedMps).toBeCloseTo(5.0, 1); // Faster with intensity - }); - }); - - describe('4. Rower Position in River', () => { - /** - * Test that boat position is at water level, not on banks - */ - it('should position boat at water level (y=0.05)', () => { - // Boat Y position should be slightly above water surface (y=0) - const boatYPosition = 0.05; - const waterYPosition = 0; - - expect(boatYPosition).toBeGreaterThan(waterYPosition); - expect(boatYPosition).toBeLessThan(0.1); // Not too high - }); - - it('should have water geometry at y=0', () => { - // Water surface is at y=0 - const waterYOffset = 0; - expect(waterYOffset).toBe(0); - }); - - it('should have banks elevated above water', () => { - // Banks should be higher than water to create riverbed illusion - const bankYOffset = 0.3; - const edgeYOffset = 0.15; - const waterYOffset = 0; - - expect(bankYOffset).toBeGreaterThan(waterYOffset); - expect(edgeYOffset).toBeGreaterThan(waterYOffset); - expect(bankYOffset).toBeGreaterThan(edgeYOffset); // Main bank higher than edge - }); - - it('should have river width always greater than boat width', () => { - // Boat + oars width is ~2.4 units - // Minimum river half-width is 3 units (6 units total) - const boatHalfWidth = 1.5; // Conservative estimate including oars - const minRiverHalfWidth = 3; - - // River should always be wider than boat - expect(minRiverHalfWidth).toBeGreaterThan(boatHalfWidth); - - // Test variable width function across route - const getRiverHalfWidth = (progress: number): number => { - const naturalVariation = Math.sin(progress * Math.PI * 8) * 0.5; - let baseWidth: number; - if (progress < 0.2) baseWidth = 4.5 + progress * 5; - else if (progress < 0.4) baseWidth = 6 + (progress - 0.2) * 5; - else if (progress < 0.6) { - const gorgeProgress = (progress - 0.4) / 0.2; - baseWidth = 7 - 3.5 * (1 - 4 * Math.pow(gorgeProgress - 0.5, 2)); - } - else if (progress < 0.8) baseWidth = 5.5 + (progress - 0.6) * 5; - else baseWidth = 6.5 + (progress - 0.8) * 7.5; - - return Math.max(3, Math.min(8, baseWidth + naturalVariation)); - }; - - // Test at multiple points along route - for (let p = 0; p <= 1; p += 0.1) { - const width = getRiverHalfWidth(p); - expect(width).toBeGreaterThanOrEqual(3); // Min width - expect(width).toBeLessThanOrEqual(8); // Max width - expect(width).toBeGreaterThan(boatHalfWidth); // Always wider than boat - } - }); - }); - - describe('5. Perspective Scaling', () => { - /** - * Test that objects scale based on distance from camera - */ - it('should scale objects smaller when further from camera', () => { - const calculatePerspectiveScale = (z: number, baseScale: number): number => { - const cameraZ = 8; - const distanceFromCamera = Math.abs(z - cameraZ); - const perspectiveFactor = Math.max(0.15, Math.min(1.5, 8 / (distanceFromCamera * 0.15 + 8))); - return baseScale * perspectiveFactor; - }; - - const baseScale = 1.0; - - // Objects at camera position should have factor ~1.0 - const scaleAtCamera = calculatePerspectiveScale(8, baseScale); - expect(scaleAtCamera).toBeCloseTo(1.0, 1); - - // Objects ahead (negative Z) should be smaller - const scaleAhead = calculatePerspectiveScale(-50, baseScale); - expect(scaleAhead).toBeLessThan(scaleAtCamera); - - // Objects very far ahead should be even smaller - const scaleFarAhead = calculatePerspectiveScale(-200, baseScale); - expect(scaleFarAhead).toBeLessThan(scaleAhead); - - // Scale should have a minimum (0.15) - const scaleVeryFar = calculatePerspectiveScale(-1000, baseScale); - expect(scaleVeryFar).toBeGreaterThanOrEqual(0.15); - }); - - it('should have maximum perspective factor to prevent giant objects', () => { - const calculatePerspectiveScale = (z: number, baseScale: number): number => { - const cameraZ = 8; - const distanceFromCamera = Math.abs(z - cameraZ); - const perspectiveFactor = Math.max(0.15, Math.min(1.5, 8 / (distanceFromCamera * 0.15 + 8))); - return baseScale * perspectiveFactor; - }; - - // Even objects very close should not be overly large - const scaleVeryClose = calculatePerspectiveScale(7.9, 1.0); - expect(scaleVeryClose).toBeLessThanOrEqual(1.5); - }); - }); - - describe('6. Real-World Proportions', () => { - /** - * Test that objects have realistic sizes relative to rower/boat - * Scale: 1 unit ≈ 2 meters - */ - it('should have boat at realistic size (7.4m racing single)', () => { - // Racing single scull: 7.4m long - // At scale 1 unit = 2m, boat should be 3.7 units - const boatLengthUnits = 3.7; - const boatLengthMeters = boatLengthUnits * 2; - - expect(boatLengthMeters).toBeCloseTo(7.4, 1); - }); - - it('should have rower at realistic size (1.8m tall)', () => { - // Average rower height: ~1.8m - // At scale 1 unit = 2m, rower should be 0.9 units - const rowerHeightUnits = 0.9; - const rowerHeightMeters = rowerHeightUnits * 2; - - expect(rowerHeightMeters).toBeCloseTo(1.8, 1); - }); - - it('should have pine trees at realistic size (10-20m)', () => { - // Pine trees: 10-20m tall - // At scale 1 unit = 2m, trees should be 5-10 units at base scale - // Tree geometry with scale 1.0-2.0 gives 10-20m - const minTreeScale = 1.0; - const maxTreeScale = 2.0; - const treeGeometryHeight = 10; // Base tree height in meters - - const minTreeHeight = minTreeScale * treeGeometryHeight; - const maxTreeHeight = maxTreeScale * treeGeometryHeight; - - expect(minTreeHeight).toBeGreaterThanOrEqual(10); - expect(maxTreeHeight).toBeLessThanOrEqual(20); - }); - - it('should have buildings at realistic size (5-11m)', () => { - // Buildings: 5-11m tall - // At scale 1 unit = 2m, buildings should be 2.5-5.5 units - const minBuildingHeight = 2.5; - const maxBuildingHeight = 5.5; - - const minHeightMeters = minBuildingHeight * 2; - const maxHeightMeters = maxBuildingHeight * 2; - - expect(minHeightMeters).toBeGreaterThanOrEqual(5); - expect(maxHeightMeters).toBeLessThanOrEqual(11); - }); - - it('should have tree size proportional to rower', () => { - // A 15m tree should be about 8x rower height (1.8m) - const rowerHeight = 1.8; - const typicalTreeHeight = 15; - const ratio = typicalTreeHeight / rowerHeight; - - expect(ratio).toBeGreaterThan(5); // Trees much taller than rower - expect(ratio).toBeLessThan(15); // But not absurdly tall - }); - }); -}); - -describe('World Transform Calculations', () => { - it('should correctly transform world coordinates to scene coordinates', () => { - const transformToScene = (worldX: number, worldZ: number, rotation: number, tx: number, tz: number) => { - const cosR = Math.cos(rotation); - const sinR = Math.sin(rotation); - const rotatedX = worldX * cosR - worldZ * sinR; - const rotatedZ = worldX * sinR + worldZ * cosR; - return { - x: rotatedX + tx, - z: rotatedZ + tz - }; - }; - - // No rotation, no translation - coordinates unchanged - let result = transformToScene(5, 10, 0, 0, 0); - expect(result.x).toBeCloseTo(5); - expect(result.z).toBeCloseTo(10); - - // 90 degree rotation (π/2) - result = transformToScene(1, 0, Math.PI / 2, 0, 0); - expect(result.x).toBeCloseTo(0, 5); - expect(result.z).toBeCloseTo(1, 5); - - // Translation only - result = transformToScene(5, 10, 0, -5, -10); - expect(result.x).toBeCloseTo(0); - expect(result.z).toBeCloseTo(0); - }); -}); diff --git a/src/__tests__/rower3d.helpers.test.ts b/src/__tests__/rower3d.helpers.test.ts deleted file mode 100644 index 6202a384..00000000 --- a/src/__tests__/rower3d.helpers.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import * as THREE from 'three'; -import { - seededRandom, - createBoatNormalMap, - createWaterNormalMap, - createCausticsTexture, - attachGerstnerShader, -} from '../components/rower3d/helpers'; - -// --------------------------------------------------------------------------- -// Minimal canvas-context stub — enough for the texture-generator functions. -// jsdom does not implement a full 2-D canvas API so we provide the subset -// that helpers.ts actually calls. -// --------------------------------------------------------------------------- -function makeCtxStub() { - const imageData = { data: new Uint8ClampedArray(128 * 128 * 4) }; - return { - fillStyle: '', - strokeStyle: '', - fillRect: vi.fn(), - strokeStyle_: '', - beginPath: vi.fn(), - moveTo: vi.fn(), - lineTo: vi.fn(), - stroke: vi.fn(), - createImageData: vi.fn(() => imageData), - putImageData: vi.fn(), - createRadialGradient: vi.fn(() => ({ - addColorStop: vi.fn(), - })), - }; -} - -describe('seededRandom', () => { - it('returns a value in [0, 1)', () => { - for (let i = 0; i < 100; i++) { - const val = seededRandom(i); - expect(val).toBeGreaterThanOrEqual(0); - expect(val).toBeLessThan(1); - } - }); - - it('is deterministic — same seed always yields same value', () => { - expect(seededRandom(42)).toBe(seededRandom(42)); - expect(seededRandom(0)).toBe(seededRandom(0)); - expect(seededRandom(9999)).toBe(seededRandom(9999)); - }); - - it('produces different values for different seeds', () => { - const vals = new Set([seededRandom(1), seededRandom(2), seededRandom(3), seededRandom(100)]); - expect(vals.size).toBe(4); - }); - - it('handles negative seeds', () => { - const val = seededRandom(-5); - expect(val).toBeGreaterThanOrEqual(0); - expect(val).toBeLessThan(1); - }); -}); - -describe('createBoatNormalMap', () => { - beforeEach(() => { - vi.spyOn(document, 'createElement').mockReturnValue({ - width: 0, - height: 0, - getContext: vi.fn(() => makeCtxStub()), - } as unknown as HTMLCanvasElement); - }); - - it('returns a THREE.Texture with RepeatWrapping and correct repeat', () => { - const tex = createBoatNormalMap(); - expect(tex).toBeInstanceOf(THREE.Texture); - expect(tex.wrapS).toBe(THREE.RepeatWrapping); - expect(tex.wrapT).toBe(THREE.RepeatWrapping); - expect(tex.repeat.x).toBe(4); - expect(tex.repeat.y).toBe(2); - }); -}); - -describe('createWaterNormalMap', () => { - beforeEach(() => { - vi.spyOn(document, 'createElement').mockReturnValue({ - width: 0, - height: 0, - getContext: vi.fn(() => makeCtxStub()), - } as unknown as HTMLCanvasElement); - }); - - it('returns a THREE.CanvasTexture with RepeatWrapping and correct repeat', () => { - const tex = createWaterNormalMap(1.0); - expect(tex).toBeInstanceOf(THREE.Texture); - expect(tex.wrapS).toBe(THREE.RepeatWrapping); - expect(tex.wrapT).toBe(THREE.RepeatWrapping); - expect(tex.repeat.x).toBe(8); - expect(tex.repeat.y).toBe(8); - }); - - it('accepts different frequency values', () => { - const tex1 = createWaterNormalMap(0.5); - const tex2 = createWaterNormalMap(2.0); - expect(tex1).toBeInstanceOf(THREE.Texture); - expect(tex2).toBeInstanceOf(THREE.Texture); - }); -}); - -describe('createCausticsTexture', () => { - beforeEach(() => { - vi.spyOn(document, 'createElement').mockReturnValue({ - width: 0, - height: 0, - getContext: vi.fn(() => makeCtxStub()), - } as unknown as HTMLCanvasElement); - }); - - it('returns a THREE.CanvasTexture', () => { - const tex = createCausticsTexture(); - expect(tex).toBeInstanceOf(THREE.Texture); - }); -}); - -describe('attachGerstnerShader', () => { - - it('sets customProgramCacheKey that includes the cache key', () => { - const mat = new THREE.MeshPhysicalMaterial(); - attachGerstnerShader(mat, { value: 0 }, 'y', 'my-theme'); - expect(mat.customProgramCacheKey()).toContain('my-theme'); - }); - - it('injects uTime uniform via onBeforeCompile', () => { - const mat = new THREE.MeshPhysicalMaterial(); - const timeUniform = { value: 42 }; - attachGerstnerShader(mat, timeUniform, 'z', 'key'); - - const shader = { - uniforms: {} as Record, - vertexShader: '#include \n#include ', - }; - mat.onBeforeCompile(shader as unknown as THREE.WebGLProgramParametersWithUniforms, {} as THREE.WebGLRenderer); - expect(shader.uniforms.uTime).toBe(timeUniform); - expect(shader.vertexShader).not.toContain('#include '); - expect(shader.vertexShader).not.toContain('#include '); - }); - - it('handles heightAxis "y" without error', () => { - const mat = new THREE.MeshPhysicalMaterial(); - expect(() => - attachGerstnerShader(mat, { value: 0 }, 'y', 'key-y', 1.5, 2.0) - ).not.toThrow(); - expect(mat.customProgramCacheKey()).toContain('gerstner-key-y'); - }); - - it('uses default waveAmplitude and waveFrequency of 1.0', () => { - const mat1 = new THREE.MeshPhysicalMaterial(); - const mat2 = new THREE.MeshPhysicalMaterial(); - attachGerstnerShader(mat1, { value: 0 }, 'z', 'k'); - attachGerstnerShader(mat2, { value: 0 }, 'z', 'k', 1.0, 1.0); - // Both should produce the same shader code — verify by checking cache key - expect(mat1.customProgramCacheKey()).toBe(mat2.customProgramCacheKey()); - }); -}); diff --git a/src/__tests__/rowerAppearancePicker.test.tsx b/src/__tests__/rowerAppearancePicker.test.tsx deleted file mode 100644 index c5d7082a..00000000 --- a/src/__tests__/rowerAppearancePicker.test.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; -import { RowerAppearancePicker } from '../components/RowerAppearancePicker'; -import { DEFAULT_ROWER_APPEARANCE } from '../lib/services/rowerAppearanceService'; -import { TOP_PALETTE, HAIR_PALETTE } from '../components/rower3d/rowerAppearance'; - -describe('RowerAppearancePicker', () => { - it('renders a chip row for every colourable slot', () => { - const onChange = vi.fn(); - render( - , - ); - expect(screen.getByRole('radiogroup', { name: 'Top' })).toBeDefined(); - expect(screen.getByRole('radiogroup', { name: 'Shorts' })).toBeDefined(); - expect(screen.getByRole('radiogroup', { name: 'Shoes' })).toBeDefined(); - expect(screen.getByRole('radiogroup', { name: 'Hair' })).toBeDefined(); - expect(screen.getByRole('radiogroup', { name: 'Hairstyle' })).toBeDefined(); - expect(screen.getByRole('radiogroup', { name: 'Hat' })).toBeDefined(); - }); - - it('hides the hat colour row when the hat is none and shows it when a hat is picked', () => { - const onChange = vi.fn(); - const { rerender } = render( - , - ); - expect(screen.queryByRole('radiogroup', { name: 'Hat colour' })).toBeNull(); - - rerender( - , - ); - expect(screen.getByRole('radiogroup', { name: 'Hat colour' })).toBeDefined(); - }); - - it('calls onChange when a hairstyle option is picked', () => { - const onChange = vi.fn(); - render( - , - ); - const hairstyleGroup = screen.getByRole('radiogroup', { name: 'Hairstyle' }); - const longChip = hairstyleGroup.querySelector('[aria-label="Long"]')!; - fireEvent.click(longChip); - expect(onChange).toHaveBeenCalledWith('hairstyle', 'long'); - }); - - it('calls onChange when a hat option is picked', () => { - const onChange = vi.fn(); - render( - , - ); - const hatGroup = screen.getByRole('radiogroup', { name: 'Hat' }); - const beanieChip = hatGroup.querySelector('[aria-label="Beanie"]')!; - fireEvent.click(beanieChip); - expect(onChange).toHaveBeenCalledWith('hat', 'beanie'); - }); - - it('calls onChange with the slot id when a swatch is clicked', () => { - const onChange = vi.fn(); - render( - , - ); - const topGroup = screen.getByRole('radiogroup', { name: 'Top' }); - const red = TOP_PALETTE.find((option) => option.id === 'red')!; - const redSwatch = topGroup.querySelector(`[aria-label="${red.label}"]`)!; - fireEvent.click(redSwatch); - expect(onChange).toHaveBeenCalledWith('top', 'red'); - }); - - it('marks the selected swatch with aria-checked', () => { - const { rerender } = render( - {}} - />, - ); - const hairGroup = screen.getByRole('radiogroup', { name: 'Hair' }); - const blond = HAIR_PALETTE.find((option) => option.id === 'blond')!; - const black = HAIR_PALETTE.find((option) => option.id === 'black')!; - expect( - hairGroup.querySelector(`[aria-label="${blond.label}"]`)?.getAttribute('aria-checked'), - ).toBe('true'); - expect( - hairGroup.querySelector(`[aria-label="${black.label}"]`)?.getAttribute('aria-checked'), - ).toBe('false'); - - rerender( - {}} - />, - ); - expect( - hairGroup.querySelector(`[aria-label="${black.label}"]`)?.getAttribute('aria-checked'), - ).toBe('true'); - }); -}); diff --git a/src/__tests__/rowerScene.smoke.test.tsx b/src/__tests__/rowerScene.smoke.test.tsx deleted file mode 100644 index 9601777e..00000000 --- a/src/__tests__/rowerScene.smoke.test.tsx +++ /dev/null @@ -1,430 +0,0 @@ -import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest'; -import type { RouteEnrichmentData } from '../lib/services/routeEnrichmentService'; -import { clearTelemetry, readTelemetry } from '../lib/utils/sceneTelemetryLog'; -import type * as THREE from 'three'; -import { installCanvasMock } from './canvasMock'; -import { LAKE_VERTEX_CAP, SCENE_SCALE } from '../components/rower3d/constants'; - -/** - * Issue #343 — the scene, mounted in a unit test. - * - * `vitest.config.ts` excluded `Rower3D.tsx` and every `rower3d/*.tsx` from - * coverage, so the TDD guard never asked for a test for any of them. The - * wrong-axis normal (#298) and the rower who never moved (#273) both lived in - * that gap: they are facts about the scene graph, and nothing except a person - * looking at the screen was checking them. - * - * Three things stand between Vitest and a mounted scene, and none of them is - * the scene's own logic: - * - * - `IS_TEST_MODE` is read once at module scope, so the automation flag has to - * be set before the scene module is imported. Without it `PMREMEnvironment` - * runs `PMREMGenerator.fromScene` against a renderer with no GL, three fails - * with "Invalid value used as weak map key", and `SceneErrorBoundary` - * swallows the entire scene. - * - drei's `Cloud` loads a sprite texture. It sits inside the skydome with no - * boundary of its own, so an unstubbed `Cloud` suspends everything and the - * renderer hands back an empty graph — which reads exactly like a broken - * scene. - * - `useGLTF` cannot even fail cleanly here: three's FileLoader hands jsdom's - * `AbortSignal` to Node's `fetch`, which rejects it as not an `AbortSignal`. - * - * Everything past that is the real thing: the real route curve, the real - * lights, the real per-frame physics. - */ -vi.hoisted(() => { - window.__PLAYWRIGHT_TESTING = true; -}); - -vi.mock('@react-three/drei', async () => { - const actual = await vi.importActual>('@react-three/drei'); - const three = await vi.importActual('three'); - // `useGLTF` answers a list with a list: sceneryModels asks for every model a - // route needs in one call, and a stub that always answers with one object - // hands `undefined` to a . - const useGLTF = Object.assign( - (path: string | string[]) => - Array.isArray(path) - ? path.map(() => ({ scene: new three.Group() })) - : { scene: new three.Group() }, - { preload: () => undefined }, - ); - return { ...actual, Cloud: () => null, useGLTF }; -}); - -const { renderScene, demoRoute } = await import('./sceneTestRenderer'); -const { BOAT_GROUP_NAME } = await import('../components/rower3d/constants'); -const { fogFor } = await import('../components/rower3d/fogPlan'); -const { GROUND_PLANE_NAME, LAKE_LAND_NAME } = await import('../components/rower3d/bankComponents'); -const { LAKE_WATER_NAME } = await import('../components/rower3d/waterComponents'); -const { SCENERY_MODELS_NAME } = await import('../components/rower3d/sceneryModels'); - -/** 2:00/500m — 4.17 m/s, and a pace a test can do arithmetic on. */ -const PACE_S_PER_500 = 120; - -describe('RowerScene', () => { - let uninstallCanvasMock: () => void; - beforeAll(() => { - uninstallCanvasMock = installCanvasMock(); - // These smoke tests count a single sun — the scene-graph identity. CSM - // (#495 Phase 2) mounts its own `DirectionalLight` per cascade alongside - // that sun, so turn it off here with the public kill switch and let - // `CascadedShadows.test.tsx` own CSM's assertions. - window.__VIRTUALROW_CSM = false; - }); - afterAll(() => { - uninstallCanvasMock(); - delete window.__VIRTUALROW_CSM; - }); - - // Every tier, because they do not build the same graph: `low` drops the - // effect stack and the shadow-casting sun, and a tier that throws on mount is - // a tier nobody discovers until a rower with that hardware opens the page. - it.each(['basic', 'medium', 'extra-high'] as const)('builds a scene graph at %s', async (tier) => { - const scene = await renderScene({ performanceMode: tier }); - - const names = scene.objects().map((o) => o.name); - expect(names, 'the scene never came out of Suspense').not.toContain('SceneSuspended'); - expect(scene.objects().length).toBeGreaterThan(20); - expect(names).toContain(BOAT_GROUP_NAME); - - await scene.unmount(); - }); - - // One art direction dresses every scene, whatever the route is called: - // #361 retired the other five themes and #364 the choosing between them, - // so a route's name no longer decides what it looks like. - it('dresses the scene whatever the route is called', async () => { - const scene = await renderScene({ route: { ...demoRoute, name: 'Leviathan Reach' } }); - - const names = scene.objects().map((o) => o.name); - expect(names, 'the scene never came out of Suspense').not.toContain('SceneSuspended'); - expect(names).toContain(BOAT_GROUP_NAME); - - await scene.unmount(); - }); - - // Issue #325 — the fog the config authored has to reach the renderer, not - // just be returned by a helper. This is the seam where it used to stop: the - // numbers existed in the config for the whole life of the project and - // nothing ever mounted them. - it('fogs the scene with the distance the config authored', async () => { - const scene = await renderScene({ performanceMode: 'basic' }); - // `instance` is typed as the base Object3D; the root of an R3F tree is a Scene. - const { fog } = scene.renderer.scene.instance as unknown as THREE.Scene; - - expect(fog, 'the scene has no fog').toBeTruthy(); - const linear = fog as THREE.Fog; - expect(linear.near).toBe(fogFor().near); - expect(linear.far).toBe(fogFor().far); - // #364: the numbers the refactor must not move. - expect([linear.near, linear.far]).toEqual([80, 550]); - - await scene.unmount(); - }); - - // Issue #364 — the GLB scenery used to be gated on the route's theme as - // well, a condition that could no longer be false. What is left of the gate - // is the tier and the switch, and both still have to close it. - describe('GLB scenery', () => { - const mountedScenery = (scene: Awaited>) => - scene.objects().filter((o) => o.name === SCENERY_MODELS_NAME); - - it('is not mounted on the low tier, even when switched on', async () => { - const scene = await renderScene({ performanceMode: 'basic', sceneryEnabled: true }); - expect(mountedScenery(scene)).toHaveLength(0); - await scene.unmount(); - }); - - it('is mounted on the auto tier when switched on', async () => { - const scene = await renderScene({ performanceMode: 'medium', sceneryEnabled: true }); - expect(mountedScenery(scene).length).toBeGreaterThan(0); - await scene.unmount(); - }); - - it('is not mounted on the auto tier when switched off', async () => { - const scene = await renderScene({ performanceMode: 'medium', sceneryEnabled: false }); - expect(mountedScenery(scene)).toHaveLength(0); - await scene.unmount(); - }); - - // Issue #379 — the mounted scene reports every placement path standing - // clear of the water, which is what oar-clearance.spec.ts reads in a - // browser. - it('reports every placement path standing clear of the water', async () => { - delete window.__ROWER3D_SCENERY_CLEARANCE; - const scene = await renderScene({ performanceMode: 'medium', sceneryEnabled: true }); - const readings: NonNullable = - window.__ROWER3D_SCENERY_CLEARANCE ?? {}; - - expect(Object.keys(readings).sort()).toEqual(['foliage', 'landscape', 'scenery-left', 'structures']); - for (const [path, reading] of Object.entries(readings)) { - expect(reading.count, path).toBeGreaterThan(0); - expect(reading.nearestM, path).toBeGreaterThanOrEqual(reading.marginM - 0.01); - } - await scene.unmount(); - }); - }); - - // Issue #334 — there is ground under the world. - // - // The banks are strips whose outer reach is clamped on bends (#285), and past - // that reach there was nothing: the canvas cleared to transparent and the - // page's own gradient showed through, as a tear of background between the - // bank and the horizon on every bend. - it('puts opaque ground under the whole world', async () => { - const scene = await renderScene({ performanceMode: 'basic' }); - - const ground = scene.objects().find((o) => o.name === GROUND_PLANE_NAME); - expect(ground, 'nothing is behind the banks').toBeDefined(); - - await scene.unmount(); - }); - - it('covers the route it was built for', async () => { - const scene = await renderScene({ performanceMode: 'basic' }); - - const ground = scene.objects().find((o) => o.name === GROUND_PLANE_NAME) as THREE.Mesh; - const boat = scene.objects().find((o) => o.name === BOAT_GROUP_NAME)!; - - // Measured off the plane the scene actually built, not off the minimum: - // it is sized from the route's own extent (groundPlane.test.ts covers that - // arithmetic), and the demo route runs a good way from the origin. - // - // The geometry is a world-oriented BufferGeometry since #431, so the plane's - // extent lives on its bounding box rather than on a PlaneGeometry parameter. - ground.geometry.computeBoundingBox(); - const box = ground.geometry.boundingBox!; - expect(boat.position.x).toBeGreaterThan(box.min.x); - expect(boat.position.x).toBeLessThan(box.max.x); - expect(boat.position.z).toBeGreaterThan(box.min.z); - expect(boat.position.z).toBeLessThan(box.max.z); - - await scene.unmount(); - }); - - // The rower who never moved (#273) was exactly this: a scene that mounted, - // rendered and sat still, with every Playwright spec passing because none of - // them asked where the boat was a second later. - it('rows the boat along the route as the frames go by', async () => { - const scene = await renderScene({ - performanceMode: 'basic', - paceSPer500: PACE_S_PER_500, - cadence: 30, - isPlaying: true, - }); - - const boat = scene.objects().find((o) => o.name === BOAT_GROUP_NAME); - expect(boat, 'no boat in the scene').toBeDefined(); - const start = boat!.position.clone(); - - await scene.tick(120); - - // Stated in metres rather than in scene units, using the same constant the - // scene builds its curve from. Two seconds at 2:00/500m is 8.3 m, and - // saying so survives #321 rescaling the world: what changes there is how - // many units a metre is, not how far a boat goes in two seconds. - const metresTravelled = boat!.position.distanceTo(start) / SCENE_SCALE; - - expect( - metresTravelled, - 'the boat did not cover the ground its pace says it should have', - ).toBeGreaterThan(7); - - await scene.unmount(); - }); - - // Issue #336: the boat waits at the start for the countdown, however hard - // the rower is pulling, and goes once it is let go. - it('holds the boat at the start while the countdown runs', async () => { - const props = { - performanceMode: 'basic' as const, - paceSPer500: PACE_S_PER_500, - cadence: 30, - distanceMeters: 20, - isPlaying: true, - holdBoat: true, - }; - const scene = await renderScene(props); - - await scene.tick(120); - expect(window.__ROWER3D_POS?.progress, 'the boat left before "Row!"').toBe(0); - // Stamped with the frame's own time, which is what a spec times it by. - expect(Number.isFinite(window.__ROWER3D_POS?.at)).toBe(true); - - await scene.rerender({ ...props, holdBoat: false }); - await scene.tick(60); - expect(window.__ROWER3D_POS?.progress ?? 0).toBeGreaterThan(0); - - await scene.unmount(); - }); - - // The low tier exists to cost less, and a shadow map is the most expensive - // single thing the sun does. - it.each([ - ['basic', 0], - ['medium', 1], - ['extra-high', 1], - ] as const)('has %s shadow-casting sun(s) at that tier: %i', async (tier, casting) => { - const scene = await renderScene({ performanceMode: tier }); - - const suns = scene - .objects() - .filter((o): o is THREE.DirectionalLight => (o as THREE.DirectionalLight).isDirectionalLight); - - expect(suns.length, 'the scene lost its sun').toBe(1); - expect(suns.filter((s) => s.castShadow)).toHaveLength(casting); - - await scene.unmount(); - }); - - /** - * Issue #349 — one sun, one hemisphere, and the sky. - * - * The scene lit with four lights: a hemisphere, the sun, an ambient and a - * second directional fill. Three of those existed to fake what an - * environment map does, and the scene had no usable one — `PMREMEnvironment` - * captured the live scene before `Sky` had drawn, so the map was near-black. - * With a real sky environment the ambient and the fill only subtract - * contrast, so counting them is the test. - */ - it('lights with the sun, a ground bounce and nothing else', async () => { - const scene = await renderScene({ performanceMode: 'extra-high' }); - const objects = scene.objects(); - - const count = (is: (o: THREE.Object3D) => boolean) => objects.filter(is).length; - - expect( - count((o) => (o as THREE.DirectionalLight).isDirectionalLight === true), - 'more than one directional light', - ).toBe(1); - expect( - count((o) => (o as THREE.HemisphereLight).isHemisphereLight === true), - 'the ground bounce is missing', - ).toBe(1); - expect( - count((o) => (o as THREE.AmbientLight).isAmbientLight === true), - 'an ambient light is flattening the scene', - ).toBe(0); - - await scene.unmount(); - }); - - /** - * Issue #329 — the hull pitches about its own lateral axis. - * - * three applies Euler rotations in `XYZ` order by default, which puts the - * pitch on world X. A boat heading east would then roll instead of pitching, - * and one heading north-east would do a bit of each. Yaw has to come first. - */ - it('pitches the boat about its own beam, not about world X', async () => { - const scene = await renderScene({ - performanceMode: 'basic', - paceSPer500: PACE_S_PER_500, - cadence: 30, - isPlaying: true, - }); - - await scene.tick(10); - const boat = scene.objects().find((o) => o.name === BOAT_GROUP_NAME); - - expect(boat, 'no boat in the scene').toBeDefined(); - expect( - boat!.rotation.order, - 'the boat rolls instead of pitching on any heading but due north', - ).toBe('YXZ'); - - await scene.unmount(); - }); - - /** - * #493 Phase 3 (FR4-FR6, NFR7): a lake route's water and shore drawn from - * the lake's outline, only once the flag is on. - */ - describe('lake outlines', () => { - const start = demoRoute.coordinates[0]; - const d = 300 / 111_320; - const ring = [ - { lat: start.lat - d, lng: start.lng - d }, - { lat: start.lat - d, lng: start.lng + d }, - { lat: start.lat + d, lng: start.lng + d }, - { lat: start.lat + d, lng: start.lng - d }, - { lat: start.lat - d, lng: start.lng - d }, - ]; - const enrichment: RouteEnrichmentData = { - routeId: demoRoute.id, - elevations: [], - segmentProfiles: [], - waterBodyType: 'lake', - waterWidthMeters: 120, - waterColor: '#335577', - waveIntensity: 0.3, - lakes: { polygons: [{ featureId: 'way/1', outer: ring, inner: [] }], elevationMeters: 0 }, - fetchedAt: 0, - source: 'network', - }; - - afterEach(() => { - delete window.__VIRTUALROW_LAKE_OUTLINES; - }); - - it('draws the lake and its shore when the flag is on', async () => { - window.__VIRTUALROW_LAKE_OUTLINES = true; - const scene = await renderScene({ performanceMode: 'basic', enrichment }); - const names = scene.objects().map((o) => o.name); - expect(names).toContain(LAKE_WATER_NAME); - expect(names).toContain(LAKE_LAND_NAME); - await scene.unmount(); - }); - - it('logs what detection found once the route is loaded (FR22)', async () => { - clearTelemetry(); - const scene = await renderScene({ performanceMode: 'basic', enrichment }); - const logged = readTelemetry().filter((event) => event.kind === 'water-detection'); - expect(logged).toHaveLength(1); - expect(logged[0].detail).toMatchObject({ waterBodyType: 'lake', lakes: 1, islands: 0 }); - await scene.unmount(); - }); - - it('draws the lake from the debug panel switch with the window flag unset', async () => { - const scene = await renderScene({ performanceMode: 'basic', enrichment, lakeOutlines: true }); - const names = scene.objects().map((o) => o.name); - expect(names).toContain(LAKE_WATER_NAME); - expect(names).toContain(LAKE_LAND_NAME); - await scene.unmount(); - }); - - it('slims a detailed lake to the tier cap before drawing it (FR9)', async () => { - const centre = demoRoute.coordinates[0]; - const detailed = Array.from({ length: 3000 }, (_, index) => { - const angle = (index / 3000) * Math.PI * 2; - const r = (1500 + 60 * Math.sin(index * 7.3)) / 111_320; - return { lat: centre.lat + Math.sin(angle) * r, lng: centre.lng + Math.cos(angle) * r }; - }); - const big: RouteEnrichmentData = { - ...enrichment, - lakes: { polygons: [{ featureId: 'way/2', outer: [...detailed, detailed[0]], inner: [] }], elevationMeters: 0 }, - }; - const vertices = async (performanceMode: 'basic' | 'extra-high') => { - const scene = await renderScene({ performanceMode, enrichment: big, lakeOutlines: true }); - const water = scene.objects().find((o) => o.name === LAKE_WATER_NAME) as THREE.Mesh; - const count = water.geometry.getAttribute('position').count; - await scene.unmount(); - return count; - }; - const basic = await vertices('basic'); - expect(basic).toBeLessThanOrEqual(LAKE_VERTEX_CAP.basic); - const extraHigh = await vertices('extra-high'); - expect(extraHigh).toBeLessThanOrEqual(LAKE_VERTEX_CAP['extra-high']); - expect(extraHigh).toBeGreaterThan(LAKE_VERTEX_CAP.basic); - }); - - it('keeps the ribbon alone while the flag is off', async () => { - const scene = await renderScene({ performanceMode: 'basic', enrichment }); - const names = scene.objects().map((o) => o.name); - expect(names).not.toContain(LAKE_WATER_NAME); - expect(names).not.toContain(LAKE_LAND_NAME); - await scene.unmount(); - }); - }); -}); diff --git a/src/__tests__/rownativeGeometry.test.ts b/src/__tests__/rownativeGeometry.test.ts deleted file mode 100644 index 25cf2e2e..00000000 --- a/src/__tests__/rownativeGeometry.test.ts +++ /dev/null @@ -1,344 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { describe, expect, it, vi } from 'vitest'; -import type { Coordinate } from '../lib/types/index'; -import { - GATE_TOLERANCE_M, - RownativeGeometryUnavailableError, - classifyPolygons, - fitCandidateToGates, - resolveCourseGeometry, - type RownativeCourseGeometryInput, -} from '../lib/services/rownativeGeometry'; -import { OsmWaterwayPathProvider } from '../lib/services/osmWaterwayPathProvider'; -import { RouteService } from '../lib/services/routeService'; -import { RownativeService } from '../lib/services/rownativeService'; -import { TrackAttachmentStore } from '../lib/services/trackAttachmentStore'; -import { createRouteCurve, distanceToProgress, getCurveDistances } from '../components/rower3d/curve'; -import { distanceBetweenMeters, polylineLengthMeters } from '../lib/utils/coordinateUtils'; -import { routeTotalDistanceMeters } from '../lib/utils/geoUtils'; -import { projectPointOntoPolyline } from '../lib/utils/polylineGeometry'; -import { parseTrackFile } from '../lib/utils/trackParsers'; - -/** - * Offline fixtures copied verbatim from the rownative/courses mirror - * (issue #194 R-15). Every number asserted below was measured against these - * exact files, so a drift upstream cannot silently change what we claim. - */ -function fixture(name: string): string { - return readFileSync(join(process.cwd(), 'src/__tests__/fixtures/rownative', name), 'utf8'); -} - -function course(id: number): RownativeCourseGeometryInput & { distance_m?: number; country?: string; status?: string } { - return JSON.parse(fixture(`${id}.json`)); -} - -/** Every fixture id, so the whole-set criteria (AC-5, AC-6) really cover the set. */ -const ALL_FIXTURES = [1, 61, 153, 175, 179, 237, 257, 277]; - -/** - * The gate-chain algorithm exactly as it stood at 1125b39: sort polygons by - * `order`, replace each with its vertex mean. AC-3 pins the fallback against - * this so the fix cannot regress the courses it does not improve. - */ -function legacyGateChain(input: RownativeCourseGeometryInput): Coordinate[] { - return [...(input.polygons ?? [])] - .sort((a, b) => (a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER)) - .map((polygon) => { - const points = (polygon.points ?? []).filter((p) => Number.isFinite(p.lat) && Number.isFinite(p.lon)); - return points.length === 0 ? null : { - lat: points.reduce((sum, p) => sum + p.lat, 0) / points.length, - lng: points.reduce((sum, p) => sum + p.lon, 0) / points.length, - }; - }) - .filter((point): point is Coordinate => point !== null); -} - -function gateOffsets(line: Coordinate[], gates: Coordinate[]): number[] { - return gates.map((gate) => projectPointOntoPolyline(gate, line).offsetMeters); -} - -/** The 43-point Clyde track shipped as the R-8 fixture for course 179. */ -function castleToCraneTrack(): Coordinate[] { - return parseTrackFile('179.gpx', fixture('179.gpx')).coordinates; -} - -/** Import a course through the real service with a controlled fetch and store. */ -async function importCourse( - id: number, - options: { track?: { fileName: string; coordinates: Coordinate[] }; osmProvider?: ConstructorParameters[3] } = {}, -) { - const routes = new RouteService(); - const tracks = new TrackAttachmentStore(); - tracks.clear(); - if (options.track) tracks.set(String(id), options.track.fileName, options.track.coordinates); - - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - json: async () => JSON.parse(fixture(`${id}.json`)), - text: async () => fixture(`${id}.json`), - } as Response); - - const service = new RownativeService( - fetchMock as unknown as typeof fetch, - (data) => routes.importRouteFromRownative(data), - tracks, - options.osmProvider ?? null, - ); - return service.importCourseById(String(id)); -} - -describe('rownative geometry resolution (issue #194)', () => { - describe('AC-1 / AC-2 — provenance is set, and precedence is honoured', () => { - it('reports a track when one is attached and a gate chain when none is', async () => { - const withTrack = await resolveCourseGeometry(course(179), { attachedTrack: castleToCraneTrack() }); - expect(withTrack.source).toBe('track'); - - const withoutTrack = await resolveCourseGeometry(course(179)); - expect(withoutTrack.source).toBe('gate-chain'); - }); - - it('tags the route with its geometry source', async () => { - const tracked = await importCourse(179, { track: { fileName: '179.gpx', coordinates: castleToCraneTrack() } }); - expect(tracked.geometrySource).toBe('track'); - expect(tracked.tags).toContain('geometry:track'); - - const gated = await importCourse(179); - expect(gated.geometrySource).toBe('gate-chain'); - expect(gated.tags).toContain('geometry:gate-chain'); - }); - - it('prefers an attached track over the traced polygons course 257 already has', async () => { - // Nudged ~4 m off the mirror's own trace: still inside gate tolerance, but - // distinguishable from the polygon vertices. - const { candidates } = classifyPolygons(course(257)); - const island = candidates.find((candidate) => candidate.name.includes('Fighting Island'))!; - const attached = island.vertices.map((vertex) => ({ lat: vertex.lat + 0.00004, lng: vertex.lng })); - - const resolved = await resolveCourseGeometry(course(257), { attachedTrack: attached }); - - expect(resolved.source).toBe('track'); - for (const point of resolved.coordinates) { - expect(island.vertices.some((vertex) => vertex.lat === point.lat && vertex.lng === point.lng)).toBe(false); - } - }); - - it('treats an upstream path field as a track (R-13 / AC-14)', async () => { - // A future mirror schema addition must need no VirtualRow change. - const base = course(1); - const chain = legacyGateChain(base); - const path = Array.from({ length: 120 }, (_, i) => { - const t = i / 119; - return { - lat: chain[0].lat + (chain[1].lat - chain[0].lat) * t, - lon: chain[0].lng + (chain[1].lng - chain[0].lng) * t, - }; - }); - - const resolved = await resolveCourseGeometry({ ...base, path }); - - expect(resolved.source).toBe('track'); - expect(resolved.coordinates.length).toBeGreaterThan(2); - }); - }); - - describe('AC-3 — the fallback is regression-free', () => { - it.each([1, 61, 153, 237])('course %i still resolves to the 1125b39 gate chain', async (id) => { - const resolved = await resolveCourseGeometry(course(id)); - expect(resolved.source).toBe('gate-chain'); - expect(resolved.coordinates).toEqual(legacyGateChain(course(id))); - }); - }); - - describe('AC-4 — path-shaped polygons are used as paths', () => { - it('trims course 277 to the gate-to-gate portion of its Route polygon', async () => { - const resolved = await resolveCourseGeometry(course(277)); - const { gates } = classifyPolygons(course(277)); - - expect(resolved.source).toBe('polygon-path'); - // The 37-vertex Start and Finish polygons are gates, not paths. - expect(gates.map((gate) => gate.name)).toEqual(['Start', 'Finish']); - - const line = resolved.coordinates; - expect(distanceBetweenMeters(line[0], gates[0].center)).toBeLessThanOrEqual(GATE_TOLERANCE_M); - expect(distanceBetweenMeters(line[line.length - 1], gates[1].center)).toBeLessThanOrEqual(GATE_TOLERANCE_M); - // 10.19 km between the gates, not the 13.27 km the whole trace runs to. - expect(polylineLengthMeters(line) / 1000).toBeCloseTo(10.2, 1); - }); - - it('rows course 257 as the full Fighting Island loop and drops the other trace', async () => { - const resolved = await resolveCourseGeometry(course(257)); - const { gates, candidates } = classifyPolygons(course(257)); - - expect(resolved.source).toBe('polygon-path'); - expect(polylineLengthMeters(resolved.coordinates) / 1000).toBeCloseTo(20.7, 0); - - // Starts at the Start gate… - const start = gates.find((gate) => gate.name === 'Start')!; - expect(distanceBetweenMeters(resolved.coordinates[0], start.center)).toBeLessThanOrEqual(GATE_TOLERANCE_M); - // …and every waypoint is on the line. - for (const offset of gateOffsets(resolved.coordinates, gates.map((gate) => gate.center))) { - expect(offset).toBeLessThanOrEqual(GATE_TOLERANCE_M); - } - - // "Around Grassy" misses WP4/WP5 by hundreds of metres and is discarded, - // not folded back in as a gate. - const grassy = candidates.find((candidate) => candidate.name.includes('Grassy'))!; - for (const vertex of grassy.vertices) { - expect(resolved.coordinates).not.toContainEqual(vertex); - } - expect(resolved.warnings.join(' ')).toContain('Around Grassy'); - }); - - it('sends course 237 back to gates when its long waypoints are not a path', async () => { - const resolved = await resolveCourseGeometry(course(237)); - - expect(resolved.source).toBe('gate-chain'); - expect(resolved.coordinates).toEqual(legacyGateChain(course(237))); - expect(resolved.warnings.join(' ')).toContain('gate outline'); - }); - - it('keeps course 61 "Gate 1" a gate despite its 13 vertices', () => { - const { gates, candidates } = classifyPolygons(course(61)); - - expect(candidates).toHaveLength(0); - expect(gates.map((gate) => gate.name)).toContain('Gate 1'); - }); - }); - - describe('AC-5 — gates are honoured', () => { - it.each(ALL_FIXTURES.filter((id) => id !== 175))('every gate of course %i lies on the final polyline', async (id) => { - const resolved = await resolveCourseGeometry(course(id)); - for (const offset of gateOffsets(resolved.coordinates, resolved.gates)) { - expect(offset).toBeLessThanOrEqual(GATE_TOLERANCE_M); - } - }); - - it('rejects a track that bypasses the finish gate, naming it, and falls back', async () => { - const track = castleToCraneTrack(); - // Drag the tail ~300 m north so the line misses the finish gate entirely. - const bypass = track.map((point, index) => ( - index >= track.length - 3 ? { lat: point.lat + 0.0027, lng: point.lng } : point - )); - const { gates } = classifyPolygons(course(179)); - - const fit = fitCandidateToGates(bypass, gates); - expect(fit.ok).toBe(false); - if (!fit.ok) { - expect(fit.error.gateName).toBe('Finish'); - expect(fit.error.message).toContain('Finish'); - expect(fit.error.offsetMeters).toBeGreaterThan(150); - } - - const resolved = await resolveCourseGeometry(course(179), { attachedTrack: bypass }); - expect(resolved.source).toBe('gate-chain'); - expect(resolved.warnings.join(' ')).toContain('Finish'); - }); - }); - - describe('AC-6 — one distance, taken from the geometry', () => { - it.each(ALL_FIXTURES.filter((id) => id !== 175))('course %i reports the length it actually rows', async (id) => { - const route = await importCourse(id); - expect(Math.abs(route.distance * 1000 - polylineLengthMeters(route.coordinates))).toBeLessThan(1); - }); - - it('gives Castle to Crane its real length once a track is attached', async () => { - const route = await importCourse(179, { track: { fileName: '179.gpx', coordinates: castleToCraneTrack() } }); - - // ~13 miles of river, not the 19.6 km straight line the mirror declares. - expect(route.distance).toBeGreaterThan(20); - expect(route.distance).toBeLessThan(23); - expect(route.externalDistanceMeters).toBe(19599); - }); - }); - - describe('AC-7 — the card and the boat agree', () => { - it('rowing the distance on the card puts the boat at the end of the course', async () => { - const route = await importCourse(179, { track: { fileName: '179.gpx', coordinates: castleToCraneTrack() } }); - - // What the engine measures the route as, from the same coordinates. - const engineTotal = routeTotalDistanceMeters(route.coordinates); - expect(Math.abs(engineTotal - route.distance * 1000)).toBeLessThan(1); - - // …and what it does with a rower's distance readings along the way. - const curve = createRouteCurve(route.coordinates, 0.1)!; - const distances = getCurveDistances(curve); - const curveLength = distances[distances.length - 1]; - - expect(distanceToProgress(engineTotal, engineTotal, distances, curveLength)).toBeCloseTo(1, 2); - expect(distanceToProgress(engineTotal / 2, engineTotal, distances, curveLength)).toBeCloseTo(0.5, 1); - expect(distanceToProgress(0, engineTotal, distances, curveLength)).toBeCloseTo(0, 2); - }); - }); - - describe('AC-8 — out-and-back courses are refused, not stubbed', () => { - it('refuses course 175 and says how to fix it', async () => { - await expect(resolveCourseGeometry(course(175))).rejects.toBeInstanceOf(RownativeGeometryUnavailableError); - await expect(resolveCourseGeometry(course(175))).rejects.toThrow(/attach a track/i); - await expect(importCourse(175)).rejects.toBeInstanceOf(RownativeGeometryUnavailableError); - }); - - it('imports course 175 once a 5 km out-and-back track is attached', async () => { - const { gates } = classifyPolygons(course(175)); - const [start, finish] = gates.map((gate) => gate.center); - // Out 2.5 km due east and back, starting on Start and ending on Finish. - const out = Array.from({ length: 26 }, (_, i) => ({ - lat: start.lat, - lng: start.lng + (i * 2500) / 26 / (111_320 * Math.cos((start.lat * Math.PI) / 180)), - })); - const track = [...out, ...out.slice(0, -1).reverse().map((p) => ({ ...p })), finish]; - - const route = await importCourse(175, { track: { fileName: '175.gpx', coordinates: track } }); - - expect(route.geometrySource).toBe('track'); - expect(route.distance).toBeCloseTo(5, 0); - }); - }); - - describe('AC-9 — the badge follows provenance, not point count', () => { - it('tags course 153 (35 gates) as gates-only', async () => { - const route = await importCourse(153); - expect(route.geometrySource).toBe('gate-chain'); - expect(route.tags).toContain('outline-only'); - }); - - it('does not tag course 179 with a 9-point track as gates-only', async () => { - const full = castleToCraneTrack(); - const sparse = [full[0], ...full.filter((_, i) => i % 6 === 0 && i > 0).slice(0, 7), full[full.length - 1]]; - expect(sparse).toHaveLength(9); - - const route = await importCourse(179, { track: { fileName: 'sparse.gpx', coordinates: sparse } }); - - expect(route.geometrySource).toBe('track'); - expect(route.tags).not.toContain('outline-only'); - }); - }); - - describe('AC-13 — a failing OSM provider is invisible', () => { - const failures: [string, () => Promise][] = [ - ['a 5xx response', async () => ({ ok: false, status: 502, json: async () => ({}) } as Response)], - ['a timeout', () => new Promise((_, reject) => reject(new DOMException('Aborted', 'AbortError')))], - ['malformed JSON', async () => ({ ok: true, status: 200, json: async () => { throw new SyntaxError('bad json'); } } as unknown as Response)], - ]; - - it.each(failures)('imports through %s without stalling or throwing', async (_label, respond) => { - const provider = new OsmWaterwayPathProvider(respond as unknown as typeof fetch); - const started = performance.now(); - - const resolved = await resolveCourseGeometry(course(61), { osmProvider: provider }); - - expect(resolved.source).toBe('gate-chain'); - expect(performance.now() - started).toBeLessThan(100); - }); - - it('uses an OSM path when the provider does return one', async () => { - const { gates } = classifyPolygons(course(61)); - const provider = { findPath: async () => gates.map((gate) => gate.center) }; - - const resolved = await resolveCourseGeometry(course(61), { osmProvider: provider }); - - expect(resolved.source).toBe('osm-derived'); - }); - }); -}); diff --git a/src/__tests__/sceneErrorBoundary.test.tsx b/src/__tests__/sceneErrorBoundary.test.tsx deleted file mode 100644 index 08bb3f87..00000000 --- a/src/__tests__/sceneErrorBoundary.test.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen } from '@testing-library/react'; -import React from 'react'; -import { SceneErrorBoundary } from '../components/rower3d/SceneErrorBoundary'; - -/** Throws on every render, the way a rejected useGLTF suspense resource does. */ -const AlwaysThrows: React.FC<{ label: string }> = ({ label }) => { - throw new Error(`boom: ${label}`); -}; - -describe('SceneErrorBoundary', () => { - beforeEach(() => { - // The boundary warns on every rung it drops; the console noise is expected. - vi.spyOn(console, 'warn').mockImplementation(() => {}); - }); - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('renders its children when nothing fails', () => { - render( - rich

} 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
{status.kind === 'error' ? status.message : status.kind}
; -} - -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 ( -
-
- ); - } - - if (!isAuthenticated || !user) { - return ( -
- - {visibleError && ( -
- ⚠️ {visibleError.includes('VITE_INTERVALS_CLIENT_ID') - ? 'OAuth client ID not configured — check DEVELOPMENT.md' - : visibleError} - {showRetryButton && ( - - )} -
- )} -
- ); - } - - return ( -
- - - {dropdownOpen && ( - <> - {/* Backdrop to close the dropdown when clicking outside */} - - ); -} diff --git a/src/components/AuthGateModal.css b/src/components/AuthGateModal.css deleted file mode 100644 index a4480291..00000000 --- a/src/components/AuthGateModal.css +++ /dev/null @@ -1,142 +0,0 @@ -/* ============================================================================ - AUTH GATE MODAL — Sign-in prompt for protected actions - ============================================================================ */ - -.auth-gate-overlay { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.4); - display: flex; - align-items: center; - justify-content: center; - z-index: 2000; - padding: var(--space-lg); -} - -.auth-gate-modal { - position: relative; - background: var(--color-bg-card); - border: 1px solid var(--color-border); - border-radius: var(--radius-panel); - padding: var(--space-xl) var(--space-xl) var(--space-lg); - max-width: 420px; - width: 100%; - max-height: calc(100dvh - 2rem); - overflow-y: auto; - text-align: center; - box-shadow: var(--shadow-modal); -} - -.auth-gate-close { - position: absolute; - top: var(--space-2xs); - right: var(--space-2xs); - background: none; - border: none; - color: var(--color-text-muted); - font-size: 1.25rem; - cursor: pointer; - line-height: 1; - /* 44px touch floor (issue #195, Phase 4). */ - min-width: 44px; - min-height: 44px; - display: inline-flex; - align-items: center; - justify-content: center; - border-radius: var(--radius-control); -} - -@media (hover: hover) { - .auth-gate-close:hover { - background: var(--color-bg-secondary); - border-color: transparent; - color: var(--color-text-primary); - } -} - -.auth-gate-icon { - font-size: 2.5rem; - margin-bottom: var(--space-md); -} - -.auth-gate-title { - font-size: 1.1rem; - font-weight: 700; - color: var(--color-text-primary); - margin-bottom: var(--space-sm); -} - -.auth-gate-body { - font-size: 0.9rem; - color: var(--color-text-secondary); - line-height: 1.5; - margin-bottom: var(--space-xl); -} - -.auth-gate-actions { - display: flex; - flex-direction: column; - gap: var(--space-sm); - margin-bottom: var(--space-lg); -} - -.auth-gate-btn--signin { - min-height: 44px; - background: var(--color-brand); - color: var(--color-brand-contrast); - border-color: transparent; - font-weight: 600; - padding: var(--space-md) var(--space-lg); - border-radius: var(--radius-control); - display: inline-flex; - align-items: center; - justify-content: center; - gap: var(--space-sm); - font-size: 0.9rem; -} - -@media (hover: hover) { - .auth-gate-btn--signin:hover { - background: var(--color-brand-hover); - border-color: transparent; - } -} - -.auth-gate-btn--guest { - min-height: 44px; - background: var(--color-bg-card); - border: 1px solid var(--color-border); - color: var(--color-text-secondary); - font-size: 0.875rem; - padding: var(--space-sm) var(--space-lg); - border-radius: var(--radius-control); -} - -@media (hover: hover) { - .auth-gate-btn--guest:hover { - background: var(--color-bg-secondary); - border-color: var(--color-border-hover); - color: var(--color-text-primary); - } -} - -.auth-gate-note { - font-size: 0.75rem; - color: var(--color-text-muted); -} - -.auth-gate-link { - color: var(--color-primary); - text-decoration: none; -} - -.auth-gate-link:hover { - text-decoration: underline; -} - -/* Short viewports: trade padding for content before scrolling (issue #195). */ -@media (max-height: 560px) { - .auth-gate-modal { - padding: var(--space-lg) var(--space-md) var(--space-md); - } -} diff --git a/src/components/AuthGateModal.tsx b/src/components/AuthGateModal.tsx deleted file mode 100644 index 7407f45c..00000000 --- a/src/components/AuthGateModal.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { useAuth } from '../context/useAuth'; -import './AuthGateModal.css'; - -export interface AuthGateModalProps { - /** Whether the modal is visible. */ - isOpen: boolean; - /** Human-readable description of the action requiring auth (e.g. "save your workout"). */ - actionDescription?: string; - /** Called when the user clicks "Sign in". */ - onLogin: () => void; - /** Called when the user dismisses the modal without signing in. */ - onDismiss: () => void; -} - -/** - * Non-blocking sign-in prompt shown when a guest attempts a protected action. - * The user can either sign in (triggering the OAuth flow) or continue without - * an account (allowing the action to degrade gracefully — e.g. guest export). - */ -export function AuthGateModal({ isOpen, actionDescription, onLogin, onDismiss }: AuthGateModalProps) { - const { login } = useAuth(); - - if (!isOpen) return null; - - const handleSignIn = () => { - onLogin(); - void login(); - }; - - return ( -
-
- - - - -

Sign in to continue

- -

- {actionDescription - ? `To ${actionDescription}, you need a free intervals.icu account.` - : 'This feature requires a free intervals.icu account.'} -

- -
- - -
- -

- No account? Create one free at{' '} - - intervals.icu - -

-
-
- ); -} diff --git a/src/components/BluetoothDevice.css b/src/components/BluetoothDevice.css deleted file mode 100644 index da3a9c0c..00000000 --- a/src/components/BluetoothDevice.css +++ /dev/null @@ -1,201 +0,0 @@ -/* ============================================================================ - BLUETOOTH DEVICE PANEL - - Shared by BluetoothDevice (PM5), FTMSDevice and HeartRateMonitor. The base - rules below are the standalone card; the horizontal-strip layout the device - bar uses lives in App.css under `.routes-devices-row` (issue #219, R5), so - this file stays the single owner of the component's own look. - ============================================================================ */ - -.bluetooth-device-container { - background: var(--color-bg-card); - border-radius: var(--radius-panel); - padding: var(--space-md); - border: 1px solid var(--color-border); -} - -.device-header { - display: flex; - align-items: flex-start; - gap: var(--space-md); - margin-bottom: var(--space-md); -} - -.device-icon-container { - flex-shrink: 0; - width: 36px; - height: 36px; - border-radius: var(--radius-control); - background: var(--color-bg-secondary); - border: 1px solid var(--color-border-card); - display: flex; - align-items: center; - justify-content: center; -} - -.device-icon { - width: 24px; - height: 24px; - color: var(--color-text-muted); - stroke-width: 2; -} - -.device-icon.connected { - color: var(--color-accent-green); -} - -.device-info { - flex: 1; - min-width: 0; -} - -.device-name { - margin: 0; - font-size: 0.9rem; - font-weight: 600; - color: var(--color-text-primary); -} - -.device-status { - margin: var(--space-2xs) 0 0; - font-size: 0.8rem; - color: var(--color-text-secondary); - display: flex; - align-items: center; - gap: var(--space-2xs); -} - -.device-status.connected { - color: var(--color-status-established-text); - font-weight: 600; -} - -.device-status.disconnected { - color: var(--color-text-muted); -} - -.metrics-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(80px, 1fr)); - gap: var(--space-sm); - margin-bottom: var(--space-md); - padding-bottom: var(--space-sm); - border-bottom: 1px solid var(--color-border-divider); -} - -.metric { - display: flex; - flex-direction: column; - align-items: center; - text-align: center; - padding: var(--space-sm); - background: var(--color-bg-secondary); - border-radius: var(--radius-control); - border: 1px solid var(--color-border-card); -} - -.metric-label { - font-size: 0.6875rem; /* 11px legibility floor (issue #195) */ - color: var(--color-text-secondary); - text-transform: uppercase; - font-weight: 600; - letter-spacing: 0.04em; - margin-bottom: var(--space-2xs); -} - -.metric-value { - font-size: 1.1rem; - font-weight: 700; - color: var(--color-text-primary); - line-height: 1; -} - -.metric-unit { - font-size: 0.6875rem; /* 11px legibility floor (issue #195) */ - color: var(--color-text-muted); - margin-top: 0.1rem; -} - -.btn-connect, -.btn-scan { - flex: 1; - /* 44px touch floor (issue #195, Phase 4). */ - min-height: 44px; - padding: var(--space-sm) var(--space-md); - background: var(--color-brand); - border: 1px solid transparent; - border-radius: var(--radius-control); - color: var(--color-brand-contrast); - font-size: 0.8rem; - font-weight: 600; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - gap: var(--space-sm); -} - -@media (hover: hover) { - .btn-connect:hover, - .btn-scan:hover { - background: var(--color-brand-hover); - border-color: transparent; - } -} - -.btn-connect:disabled, -.btn-scan:disabled { - background: var(--color-bg-secondary); - color: var(--color-text-muted); - border-color: var(--color-border); - cursor: not-allowed; -} - -.btn-disconnect { - flex: 1; - /* 44px touch floor (issue #195, Phase 4). */ - min-height: 44px; - padding: var(--space-sm) var(--space-md); - background: var(--color-bg-card); - border: 1px solid var(--color-border); - border-radius: var(--radius-control); - color: var(--color-text-secondary); - font-size: 0.8rem; - font-weight: 500; - cursor: pointer; -} - -@media (hover: hover) { - .btn-disconnect:hover { - background: var(--color-bg-secondary); - border-color: var(--color-border-hover); - color: var(--color-error); - } -} - -/* Error message */ -.error-message { - margin-top: var(--space-md); - padding: var(--space-sm) var(--space-md); - background: var(--color-error-banner-bg); - border: 1px solid transparent; - border-radius: var(--radius-control); - color: var(--color-brand-contrast); - font-size: 0.8rem; - display: flex; - align-items: center; - gap: var(--space-sm); -} - -.device-note { - margin-top: var(--space-md); - color: var(--color-text-muted); - font-size: 0.8rem; - line-height: 1.5; -} - -.device-actions { - display: flex; - gap: var(--space-sm); - align-items: center; -} diff --git a/src/components/BluetoothDevice.tsx b/src/components/BluetoothDevice.tsx deleted file mode 100644 index 48dab0d2..00000000 --- a/src/components/BluetoothDevice.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import React, { useState, useEffect, useRef } from 'react'; -import type { BluetoothDeviceState, PM5Data } from '../lib/types/index'; -import { bluetoothService } from '../lib/services/bluetoothService'; -import './BluetoothDevice.css'; - -/** - * Connect/disconnect panel for a Concept2 PM5, with a small live readout. - * - * This panel renders on the routes view only, so it deliberately owns nothing but its - * own display: the workout pipeline subscribes to `bluetoothService` from App (see - * `useRowerServiceEvents`) where the subscription survives the switch to the workout - * view. Do not reintroduce data/connection callbacks here — App is already listening, - * and a second path would feed every frame into the session twice. - */ -export const BluetoothDevice: React.FC = () => { - const [deviceState, setDeviceState] = useState({ - isConnected: false, - }); - const [isConnecting, setIsConnecting] = useState(false); - const [pm5Data, setPM5Data] = useState(null); - - // RAF-based throttle for local PM5 display — avoids renders from inside the WS/CDP stack. - const latestPM5DataRef = useRef(null); - const pm5RafScheduledRef = useRef(false); - - useEffect(() => { - const handleConnected = (data: unknown) => { - const connectionData = data as { deviceName?: string }; - const deviceName = connectionData.deviceName || 'Concept2 PM5'; - // Defer state updates to RAF so React renders from a clean call stack, - // preventing "Maximum call stack size exceeded" under Playwright CDP. - requestAnimationFrame(() => { - setDeviceState((prev) => ({ ...prev, isConnected: true, deviceName })); - setIsConnecting(false); - }); - }; - - const handleDisconnectedEvent = () => { - requestAnimationFrame(() => { - setDeviceState({ isConnected: false }); - setPM5Data(null); - }); - }; - - const handleData = (data: unknown) => { - // Throttle the local display update to prevent renders inside the WS stack. - latestPM5DataRef.current = data as PM5Data; - if (!pm5RafScheduledRef.current) { - pm5RafScheduledRef.current = true; - requestAnimationFrame(() => { - pm5RafScheduledRef.current = false; - if (latestPM5DataRef.current) setPM5Data(latestPM5DataRef.current); - }); - } - }; - - const handleError = (error: unknown) => { - const errorData = error as { message?: string }; - const errorMsg = errorData.message || 'Unknown error occurred'; - requestAnimationFrame(() => { - setDeviceState((prev) => ({ ...prev, error: errorMsg })); - setIsConnecting(false); - }); - }; - - bluetoothService.on('connected', handleConnected); - bluetoothService.on('disconnected', handleDisconnectedEvent); - bluetoothService.on('data', handleData); - bluetoothService.on('error', handleError); - - // Cleanup: remove event listeners when component unmounts or callbacks change - return () => { - bluetoothService.off('connected', handleConnected); - bluetoothService.off('disconnected', handleDisconnectedEvent); - bluetoothService.off('data', handleData); - bluetoothService.off('error', handleError); - // We intentionally do NOT disconnect the device here to maintain connection - }; - }, []); - - const handleConnect = async () => { - setIsConnecting(true); - const success = await bluetoothService.connect(); - if (!success) { - setIsConnecting(false); - } - }; - - const handleDisconnect = async () => { - await bluetoothService.disconnect(); - }; - - return ( -
-
-
- - - - -
-
-

- {deviceState.deviceName || 'Concept2 PM5'} -

-

- {deviceState.isConnected ? '● Connected' : '● Disconnected'} -

-
-
- - {pm5Data && ( -
-
- Pace - - {pm5Data.pace ? pm5Data.pace.toFixed(1) : '--'} - - s/500m -
-
- Distance - - {(pm5Data.distance / 1000).toFixed(2)} - - km -
-
- Time - - {formatTime(pm5Data.elapsedTime)} - - — -
- {pm5Data.power !== undefined && ( -
- Power - {pm5Data.power} - W -
- )} - {pm5Data.cadence !== undefined && ( -
- Cadence - {pm5Data.cadence} - spm -
- )} - {pm5Data.heartRate !== undefined && pm5Data.heartRate > 0 && ( -
- Heart Rate - {pm5Data.heartRate} - bpm -
- )} -
- )} - - {deviceState.error && ( -
- ⚠ - {deviceState.error} -
- )} - -
- {!deviceState.isConnected ? ( - - ) : ( - - )} -
- -

- 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.'} -

- {this.state.gpuAvailable && ( - - )} -
- ); - } - - 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, -}) => ( -
- Conditions -
- - - {CONDITIONS.map((condition) => ( - - ))} -
-
-); - -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 ? ( - - ) : 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 }) => ( -
- Rower -
- {CREW_PREFERENCE_OPTIONS.map((option) => ( - - ))} -
-
-); 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 ( -
-
-
- - - - -
-
-

- {deviceState.deviceName ?? 'FTMS Rower'} -

-

- {deviceState.isConnected ? '● Connected' : '● Disconnected'} -

-
-
- - {rowerData && ( -
-
- Pace - - {rowerData.pace ? rowerData.pace.toFixed(1) : '--'} - - s/500m -
-
- Distance - - {(rowerData.distance / 1000).toFixed(2)} - - km -
-
- Time - {formatTime(rowerData.elapsedTime)} - — -
- {rowerData.power !== undefined && ( -
- Power - {rowerData.power} - W -
- )} - {rowerData.cadence !== undefined && rowerData.cadence > 0 && ( -
- Rate - {rowerData.cadence} - spm -
- )} - {rowerData.heartRate !== undefined && rowerData.heartRate > 0 && ( -
- HR - {rowerData.heartRate} - bpm -
- )} -
- )} - - {deviceState.error && ( -
- ⚠ - {deviceState.error} -
- )} - -
- {!deviceState.isConnected ? ( - - ) : ( - - )} -
- -

- Connect any FTMS-compatible rowing machine (WaterRower, Concept2 RowErg, etc.) -

-
- ); -}; - -function formatTime(seconds: number): string { - const s = Math.floor(seconds); - const m = Math.floor(s / 60); - const h = Math.floor(m / 60); - if (h > 0) { - return `${h}:${String(m % 60).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`; - } - return `${m}:${String(s % 60).padStart(2, '0')}`; -} - -export default FTMSDevice; diff --git a/src/components/GhostPicker.css b/src/components/GhostPicker.css deleted file mode 100644 index f6ba1de7..00000000 --- a/src/components/GhostPicker.css +++ /dev/null @@ -1,61 +0,0 @@ -/* - * On the route panel, with the other pre-row decisions (#338). - */ -.ghost-picker { - margin: 0 0 1rem; - padding: 0.75rem 1rem; - border: 1px solid var(--border-color); - border-radius: var(--radius-control); -} - -.ghost-picker-legend { - padding: 0 0.4rem; - font-size: 0.8rem; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--text-secondary); -} - -.ghost-picker-option { - display: flex; - align-items: center; - gap: 0.5rem; - /* 44px touch floor (issue #195, Phase 4). */ - min-height: 44px; - cursor: pointer; -} - -.ghost-picker-option input:disabled + span { - opacity: 0.55; - cursor: not-allowed; -} - -.ghost-picker-detail { - color: var(--text-secondary); - font-variant-numeric: tabular-nums; -} - -.ghost-picker-hint { - margin: 0 0 0.25rem 1.6rem; - font-size: 0.8rem; - color: var(--text-secondary); -} - -.ghost-picker-pace { - display: flex; - align-items: center; - gap: 0.5rem; - min-height: 44px; - margin-left: 1.6rem; -} - -.ghost-picker-pace-field { - width: 5rem; - min-height: 36px; - padding: 0.25rem 0.5rem; - border: 1px solid var(--border-color); - border-radius: var(--radius-control); - background: var(--color-bg-card); - color: var(--text-primary); - font-variant-numeric: tabular-nums; -} diff --git a/src/components/GhostPicker.tsx b/src/components/GhostPicker.tsx deleted file mode 100644 index 6cb674af..00000000 --- a/src/components/GhostPicker.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import React, { useState } from 'react'; -import { formatSplit } from '../lib/utils/formatters'; -import { parsePaceInput, DEFAULT_TARGET_PACE } from '../lib/utils/paceInput'; -import './GhostPicker.css'; - -/** - * What to race, decided before the row starts (#338). - * - * 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. - */ - -export type GhostChoice = - | { kind: 'none' } - | { kind: 'best' } - | { kind: 'pace'; paceSPer500: number }; - -export interface GhostPickerProps { - value: GhostChoice; - onChange: (choice: GhostChoice) => void; - /** Whether this browser kept a row on this route that can be raced. */ - hasBest: boolean; - /** That row's average split, for the label. */ - bestPace: number | null; -} - -export const GhostPicker: React.FC = ({ - value, - onChange, - hasBest, - bestPace, -}) => { - // What the rower has typed, which is not the same as the pace being raced: - // the field holds `2:` for a keystroke, and the boat does not. - const [paceText, setPaceText] = useState(() => - formatSplit(value.kind === 'pace' ? value.paceSPer500 : DEFAULT_TARGET_PACE), - ); - - const option = (kind: GhostChoice['kind']) => ({ - type: 'radio' as const, - name: 'ghost', - checked: value.kind === kind, - onChange: () => - onChange( - kind === 'pace' - ? { kind: 'pace', paceSPer500: parsePaceInput(paceText) ?? DEFAULT_TARGET_PACE } - : { kind }, - ), - }); - - return ( -
- Row against - - - - - {/* - Disabled with the reason beside it rather than hidden: an option that - vanishes looks like a feature that is not there, and the rower has done - nothing wrong - they have simply not rowed here before. - */} - {!hasBest && ( -

- Your first row on this route — there is nothing to race yet. -

- )} - - - - {value.kind === 'pace' && ( - - )} -
- ); -}; - -export default GhostPicker; diff --git a/src/components/GhostVerdict.css b/src/components/GhostVerdict.css deleted file mode 100644 index c7c3bdc0..00000000 --- a/src/components/GhostVerdict.css +++ /dev/null @@ -1,20 +0,0 @@ -/* - * The result of the race, above the row's own numbers (#338). - * Words carry the result; the colour only agrees with them (#344). - */ -.ghost-verdict { - margin: 0 0 0.75rem; - padding: 0.5rem 0.9rem; - border-left: 3px solid var(--border-color); - border-radius: var(--radius-control); - background: var(--color-bg-card); - font-size: 0.95rem; -} - -.ghost-verdict[data-result='won'] { - border-left-color: #4ade80; -} - -.ghost-verdict[data-result='lost'] { - border-left-color: #fca5a5; -} diff --git a/src/components/GhostVerdict.tsx b/src/components/GhostVerdict.tsx deleted file mode 100644 index 285a3d4a..00000000 --- a/src/components/GhostVerdict.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import React from 'react'; -import { formatSplit } from '../lib/utils/formatters'; -import './GhostVerdict.css'; - -/** - * How the race came out (#338). - * - * The rower watched a gap all the way down the course, and the summary is - * where it is settled. The pace comparison #337 puts on this screen 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. - */ - -export interface GhostVerdictProps { - /** Seconds the row took. */ - rowSeconds: number; - /** Seconds the ghost took to the same distance, or null if it never got there. */ - ghostSeconds: number | null; - /** What was being chased — "your best", "a 2:00 pace". Null when nothing was. */ - label: string | null; -} - -/** Inside a second is a dead heat; a tenth either way is not a story. */ -const DEAD_HEAT_SECONDS = 1; - -export const GhostVerdict: React.FC = ({ rowSeconds, ghostSeconds, label }) => { - if (label === null) return null; - - if (ghostSeconds === null) { - return ( -

- {label} did not reach this distance, so there was nothing to race to the line. -

- ); - } - - const margin = ghostSeconds - rowSeconds; - const result = Math.abs(margin) < DEAD_HEAT_SECONDS ? 'level' : margin > 0 ? 'won' : 'lost'; - - return ( -

- {result === 'level' && <>A dead heat with {label}.} - {result === 'won' && ( - <> - You beat {label} by {formatSplit(margin)}. - - )} - {result === 'lost' && ( - <> - {label.charAt(0).toUpperCase()} - {label.slice(1)} was {formatSplit(-margin)} quicker. - - )} -

- ); -}; - -export default GhostVerdict; diff --git a/src/components/GraphicsQualityPicker.tsx b/src/components/GraphicsQualityPicker.tsx deleted file mode 100644 index 7c272931..00000000 --- a/src/components/GraphicsQualityPicker.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React from 'react'; -import { - GRAPHICS_QUALITY_OPTIONS, - type GraphicsQuality, -} from '../hooks/useGraphicsQuality'; - -export interface GraphicsQualityPickerProps { - quality: GraphicsQuality; - onChange: (quality: GraphicsQuality) => void; -} - -/** - * Lets a rower overrule the scene's guess about their graphics card (#224 4G). - * - * The scene picks a tier from what the GPU reports, which is right most of the - * time and wrong for anyone whose browser withholds the renderer string, or who - * would simply rather have frames than shadows. - */ -export const GraphicsQualityPicker: React.FC = ({ - quality, - onChange, -}) => ( -
- Graphics -
- {GRAPHICS_QUALITY_OPTIONS.map((option) => ( - - ))} -
-
-); diff --git a/src/components/GuestSessionSummary.css b/src/components/GuestSessionSummary.css deleted file mode 100644 index 3e31ac01..00000000 --- a/src/components/GuestSessionSummary.css +++ /dev/null @@ -1,181 +0,0 @@ -.guest-summary-backdrop { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.4); - display: flex; - align-items: center; - justify-content: center; - z-index: 1000; - padding: var(--space-lg); -} - -.guest-summary-modal { - background: var(--surface-primary); - border: 1px solid var(--border-color); - border-radius: var(--radius-panel); - padding: var(--space-xl); - max-width: 480px; - width: 100%; - /* Scroll rather than pushing its own actions off a short screen — on a - landscape phone Row Again sat at y=425 in a 375px viewport (issue #195). */ - max-height: calc(100dvh - 2rem); - overflow-y: auto; - box-shadow: var(--shadow-modal); - display: flex; - flex-direction: column; - gap: var(--space-lg); -} - -/* Reclaim vertical space on short viewports before resorting to scrolling. */ -@media (max-height: 560px) { - .guest-summary-modal { - padding: var(--space-md); - gap: var(--space-md); - } - - .guest-summary-backdrop { - padding: var(--space-sm); - align-items: flex-start; - } -} - -.guest-summary-header { - text-align: center; - display: flex; - flex-direction: column; - gap: var(--space-xs); -} - -.guest-badge { - display: inline-block; - background: var(--color-status-provisional-bg); - color: var(--color-status-provisional-text); - border: 1px solid transparent; - border-radius: var(--radius-control); - padding: var(--space-2xs) var(--space-md); - font-size: 0.75rem; - font-weight: 600; - letter-spacing: 0.05em; - text-transform: uppercase; - align-self: center; -} - -.guest-summary-header h2 { - margin: 0; - font-size: 1.25rem; - font-weight: 700; - color: var(--text-primary); -} - -.guest-summary-route { - margin: 0; - font-size: 1rem; - color: var(--text-secondary); -} - -.guest-summary-unsaved { - margin: 0; - font-size: 0.8rem; - color: var(--text-tertiary); -} - -.guest-summary-stats { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: var(--space-md); -} - -.guest-stat { - background: var(--surface-secondary); - border: 1px solid var(--color-border-card); - border-radius: var(--radius-inset); - padding: var(--space-md); - display: flex; - flex-direction: column; - gap: var(--space-2xs); -} - -.guest-stat-label { - font-size: 0.6875rem; /* 11px legibility floor (issue #195) */ - font-weight: 600; - letter-spacing: 0.06em; - text-transform: uppercase; - color: var(--text-tertiary); -} - -.guest-stat-value { - font-size: 1.25rem; - font-weight: 700; - color: var(--text-primary); - line-height: 1.2; -} - -.guest-stat-unit { - font-size: 0.8rem; - font-weight: 400; - color: var(--text-secondary); - margin-left: 0.15em; -} - -.guest-summary-actions { - display: flex; - flex-direction: column; - gap: var(--space-md); -} - -.btn-guest-row-again { - /* 44px touch floor (issue #195, Phase 4). */ - min-height: 44px; - background: var(--color-brand); - color: var(--color-brand-contrast); - border: 1px solid transparent; - border-radius: var(--radius-control); - padding: var(--space-md) var(--space-xl); - font-size: 0.9rem; - font-weight: 600; - cursor: pointer; -} - -@media (hover: hover) { - .btn-guest-row-again:hover { - background: var(--color-brand-hover); - border-color: transparent; - } -} - -.btn-guest-exit { - /* 44px touch floor (issue #195, Phase 4). */ - min-height: 44px; - background: var(--color-bg-card); - color: var(--text-secondary); - border: 1px solid var(--border-color); - border-radius: var(--radius-control); - padding: var(--space-sm) var(--space-xl); - font-size: 0.875rem; - font-weight: 500; - cursor: pointer; -} - -@media (hover: hover) { - .btn-guest-exit:hover { - background: var(--color-bg-secondary); - color: var(--text-primary); - border-color: var(--color-border-hover); - } -} - -.btn-guest-sign-in { - /* 44px touch floor (issue #195, Phase 4). */ - min-height: 44px; - background: var(--color-bg-card); - color: var(--color-brand); - border: 1px solid var(--color-brand); - font-weight: 600; -} - -@media (hover: hover) { - .btn-guest-sign-in:hover { - background: var(--color-bg-secondary); - border-color: var(--color-brand-hover); - } -} diff --git a/src/components/GuestSessionSummary.tsx b/src/components/GuestSessionSummary.tsx deleted file mode 100644 index 8fd2ed9f..00000000 --- a/src/components/GuestSessionSummary.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import type { WorkoutSession } from '../lib/types/index'; -import { formatPace } from '../lib/utils/formatters'; -import { RowBreakdown } from './RowBreakdown'; -import './GuestSessionSummary.css'; - -interface GuestSessionSummaryProps { - session: WorkoutSession; - onRowAgain: () => void; - onExit: () => void; - /** Sign in from the summary. Omitted when there is nothing to sign in to. */ - onSignIn?: () => void; - /** True when the session ran on simulated devices, so it is never read as a real row. */ - isDemo?: boolean; -} - -function formatDuration(seconds: number): string { - const h = Math.floor(seconds / 3600); - const m = Math.floor((seconds % 3600) / 60); - const s = seconds % 60; - if (h > 0) { - return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; - } - return `${m}:${String(s).padStart(2, '0')}`; -} - -export function GuestSessionSummary({ session, onRowAgain, onExit, onSignIn, isDemo }: GuestSessionSummaryProps) { - const distanceKm = (session.distance / 1000).toFixed(2); - const avgHR = session.heartRateAvg ?? ( - session.heartRateSamples && session.heartRateSamples.length > 0 - ? Math.round(session.heartRateSamples.reduce((s, h) => s + h.bpm, 0) / session.heartRateSamples.length) - : null - ); - const maxHR = session.heartRateMax ?? ( - session.heartRateSamples && session.heartRateSamples.length > 0 - ? session.heartRateSamples.reduce((m, h) => Math.max(m, h.bpm), 0) - : null - ); - - // Name exactly the stats this summary is showing, so the offer matches what - // the visitor can actually see they are about to lose. - const preserved = [ - 'distance', - 'time', - 'average pace', - 'calories', - ...(avgHR !== null || maxHR !== null ? ['heart rate'] : []), - ]; - const preservedList = `${preserved.slice(0, -1).join(', ')} and ${preserved[preserved.length - 1]}`; - - return ( -
-
-
- {isDemo ? 'Demo Row' : 'Guest Session'} -

Session Complete

-

{session.routeName}

-

- {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 && ( - - )} - - -
-
-
- ); -} diff --git a/src/components/HeartRateChart.tsx b/src/components/HeartRateChart.tsx deleted file mode 100644 index 744cdd0a..00000000 --- a/src/components/HeartRateChart.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import React, { useRef } from 'react'; -import { Line } from 'react-chartjs-2'; -import { - Chart as ChartJS, - LineElement, - PointElement, - LinearScale, - TimeScale, - Tooltip, - Legend, - Filler, - CategoryScale, -} from 'chart.js'; -import type { ChartOptions, ScriptableLineSegmentContext, Chart } from 'chart.js'; -import type { HeartRateSample } from '../lib/types/index'; - -ChartJS.register(LineElement, PointElement, LinearScale, TimeScale, Tooltip, Legend, Filler, CategoryScale); - -interface HeartRateChartProps { - samples: HeartRateSample[]; - height?: number; - maxPoints?: number; -} - -export const HeartRateChart: React.FC = ({ samples, height = 140, maxPoints = 120 }) => { - const trimmed = samples.slice(-maxPoints); - const smoothData = trimmed.map((_, idx) => { - const start = Math.max(0, idx - Math.floor(5 / 2)); - const end = Math.min(trimmed.length, idx + Math.ceil(5 / 2)); - const slice = trimmed.slice(start, end); - return Math.round(slice.reduce((s, v) => s + v.bpm, 0) / slice.length); - }); - - // Zone color function defined before data so segment can use it - const zoneColor = (bpm: number) => { - if (bpm < 90) return '#22c55e'; // recovery - if (bpm < 120) return '#3b82f6'; // easy - if (bpm < 150) return '#f59e0b'; // moderate - if (bpm < 170) return '#ef4444'; // hard - return '#b91c1c'; // intense - }; - - const data = { - labels: trimmed.map((s) => s.timestamp.toLocaleTimeString()), - datasets: [ - { - label: 'Heart Rate (bpm)', - data: trimmed.map((s) => s.bpm), - borderColor: '#ef4444', - backgroundColor: 'rgba(239,68,68,0.25)', - tension: 0.35, - pointRadius: 0, - fill: true, - segment: { - borderColor: (ctx: ScriptableLineSegmentContext) => zoneColor(ctx.p0.parsed.y ?? 0), - }, - }, - { - label: 'Smoothed', - data: smoothData, - borderColor: '#f87171', - backgroundColor: 'transparent', - tension: 0.2, - pointRadius: 0, - borderWidth: 2, - }, - ], - }; - - const options: ChartOptions<'line'> = { - responsive: true, - maintainAspectRatio: false, - plugins: { - legend: { display: false }, - tooltip: { mode: 'index', intersect: false }, - }, - scales: { - x: { display: false }, - y: { display: true, ticks: { color: '#ef4444' } }, - }, - }; - - const ref = useRef | null>(null); - - const zoneBackground = { - id: 'hrZonesBackground', - beforeDraw: (chart: Chart) => { - const { ctx, chartArea: { left, right }, scales: { y } } = chart; - const zones = [ - { max: 90, color: 'rgba(34,197,94,0.10)' }, - { max: 120, color: 'rgba(59,130,246,0.08)' }, - { max: 150, color: 'rgba(245,158,11,0.08)' }, - { max: 170, color: 'rgba(239,68,68,0.08)' }, - { max: y.max, color: 'rgba(185,28,28,0.08)' }, - ]; - let prevMin = y.min; - zones.forEach(z => { - const yStart = y.getPixelForValue(prevMin); - const yEnd = y.getPixelForValue(z.max); - ctx.save(); - ctx.fillStyle = z.color; - ctx.fillRect(left, yEnd, right - left, yStart - yEnd); - ctx.restore(); - prevMin = z.max; - }); - } - }; - - return ( -
- -
- ); -}; - -export default HeartRateChart; diff --git a/src/components/HeartRateMonitor.tsx b/src/components/HeartRateMonitor.tsx deleted file mode 100644 index 5a5dc053..00000000 --- a/src/components/HeartRateMonitor.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import React, { useEffect, useState, useRef } from 'react'; -import { heartRateBluetoothService } from '../lib/services/heartRateBluetoothService'; -import './BluetoothDevice.css'; - -interface HeartRateMonitorProps { - onSample?: (bpm: number) => void; - onConnected?: () => void; - onDisconnected?: () => void; -} - -export const HeartRateMonitor: React.FC = ({ onSample, onConnected, onDisconnected }) => { - const [connected, setConnected] = useState(false); - const [isConnecting, setIsConnecting] = useState(false); - const [current, setCurrent] = useState(null); - const [avg, setAvg] = useState(null); - const [max, setMax] = useState(null); - const [error, setError] = useState(null); - - // RAF-based throttle for HR metric display — avoids renders from inside the WS/CDP stack. - const latestBpmRef = useRef(null); - const hrRafScheduledRef = useRef(false); - - useEffect(() => { - const handleHR = (sample: { bpm: number }) => { - // Notify parent synchronously so App-level service updates are immediate. - onSample?.(sample.bpm); - // Defer UI state updates to RAF: runs with a clean stack, not inside - // the WS→characteristic notification chain that causes stack overflows. - latestBpmRef.current = sample.bpm; - if (!hrRafScheduledRef.current) { - hrRafScheduledRef.current = true; - requestAnimationFrame(() => { - hrRafScheduledRef.current = false; - const bpm = latestBpmRef.current; - if (bpm == null) return; - setCurrent(bpm); - const samples = heartRateBluetoothService.getSamples(); - const values = samples.map(s => s.bpm); - if (values.length) { - setAvg(Math.round(values.reduce((sum, v) => sum + v, 0) / values.length)); - setMax(values.reduce((m, v) => v > m ? v : m, values[0])); - } - }); - } - }; - const handleConnected = () => { - requestAnimationFrame(() => { - setConnected(true); - onConnected?.(); - }); - }; - const handleDisconnected = () => { - requestAnimationFrame(() => { - setConnected(false); - onDisconnected?.(); - }); - }; - const handleError = (e: unknown) => { - requestAnimationFrame(() => { - setError((e instanceof Error ? e.message : String(e)) || 'HR error'); - }); - }; - - heartRateBluetoothService.on('heartRate', handleHR); - heartRateBluetoothService.on('connected', handleConnected); - heartRateBluetoothService.on('disconnected', handleDisconnected); - heartRateBluetoothService.on('error', handleError); - return () => { - heartRateBluetoothService.off('heartRate', handleHR); - heartRateBluetoothService.off('connected', handleConnected); - heartRateBluetoothService.off('disconnected', handleDisconnected); - heartRateBluetoothService.off('error', handleError); - }; - }, [onSample, onConnected, onDisconnected]); - - const connect = async () => { - if (isConnecting) return; - setIsConnecting(true); - setError(null); - try { - await heartRateBluetoothService.connect(); - } finally { - setIsConnecting(false); - } - }; - - const disconnect = async () => { - await heartRateBluetoothService.disconnect(); - }; - - return ( -
-
-
- - - -
-
-

Heart Rate Monitor

-

- {connected ? 'Connected' : 'Disconnected'} -

-
-
- - {error && ( -
- ⚠ - {error} -
- )} - -
-
-
Current
-
{current ?? '--'}
-
bpm
-
-
-
Average
-
{avg ?? '--'}
-
bpm
-
-
-
Max
-
{max ?? '--'}
-
bpm
-
-
- -
- {!connected ? ( - - ) : ( - - )} -
-
- ); -}; - -export default HeartRateMonitor; diff --git a/src/components/HeartRateSimulator.tsx b/src/components/HeartRateSimulator.tsx deleted file mode 100644 index 0bfeb227..00000000 --- a/src/components/HeartRateSimulator.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import React, { useState, useEffect, useCallback } from 'react'; -import { heartRateSimulator } from '../lib/services/heartRateSimulatorService'; -import { heartRateBluetoothService } from '../lib/services/heartRateBluetoothService'; -import './PM5Simulator.css'; - -interface HeartRateSimulatorProps { - /** Called when the simulator is started */ - onStarted?: () => void; - /** Called when the simulator is stopped */ - onStopped?: () => void; -} - -export const HeartRateSimulator: React.FC = ({ - onStarted, - onStopped, -}) => { - const [isActive, setIsActive] = useState(heartRateSimulator.isRunning()); - const [isExpanded, setIsExpanded] = useState(false); - const [bpm, setBpm] = useState(heartRateSimulator.getBpm()); - const [currentBpm, setCurrentBpm] = useState(null); - - // Track live BPM from the shared HR service - const handleSample = useCallback((sample: { bpm: number }) => { - if (heartRateSimulator.isRunning()) setCurrentBpm(sample.bpm); - }, []); - - useEffect(() => { - heartRateBluetoothService.on('heartRate', handleSample); - return () => { - heartRateBluetoothService.off('heartRate', handleSample); - }; - }, [handleSample]); - - const handleStart = () => { - heartRateSimulator.start(bpm); - setIsActive(true); - onStarted?.(); - }; - - const handleStop = () => { - heartRateSimulator.stop(); - setIsActive(false); - setCurrentBpm(null); - onStopped?.(); - }; - - const handleBpmChange = (value: number) => { - setBpm(value); - heartRateSimulator.setBpm(value); - }; - - return ( -
-
setIsExpanded(!isExpanded)} - > -
- ❤️ - HR Simulator - {isActive && ACTIVE} -
- -
- - {isExpanded && ( -
- {isActive && currentBpm !== null && ( -
- Live HR - {currentBpm} bpm -
- )} - -
-
- -
- handleBpmChange(Number(e.target.value))} - /> - {bpm} bpm -
-
-
- -
- {!isActive ? ( - - ) : ( - - )} -
- -

- 💡 Simulates a Bluetooth heart rate monitor without real hardware -

-
- )} -
- ); -}; - -export default HeartRateSimulator; diff --git a/src/components/LoadTodaysWorkoutButton.tsx b/src/components/LoadTodaysWorkoutButton.tsx deleted file mode 100644 index d93b0fcc..00000000 --- a/src/components/LoadTodaysWorkoutButton.tsx +++ /dev/null @@ -1,212 +0,0 @@ -/** - * Load today's planned workout from intervals.icu (issue #445, FR2 / FR3). - * - * Renders two controls for a signed-in athlete: - * - a "Load today's workout" button that fetches today's first rowing - * event, converts it, and hands it back through {@link onLoad}; - * - a date input (min = today − 14 days, max = today + 14 days) that lets - * the athlete pick a session on another day within the window, showing - * the matches in a small list from which one is loaded (FR3 / D2(a)). - * - * Guests (`accessToken` null) see nothing (FR7 / D7(a)). - */ -import { useCallback, useMemo, useState } from 'react'; -import type { StructuredWorkout, WorkoutPlan } from '../lib/types/index'; -import type { AuthPort, IntervalsIcuWorkoutPort } from '../lib/ports'; -import { IntervalsWorkoutFetchError } from '../lib/services/intervalsIcuWorkoutService'; - -export interface LoadTodaysWorkoutButtonProps { - authService: AuthPort; - intervalsIcuWorkoutService: IntervalsIcuWorkoutPort; - athleteId: string; - /** Called once a workout is loaded and ready to start. */ - onLoad: (workout: StructuredWorkout) => void; - /** - * Optional clock override so tests can pin "today". Defaults to - * `new Date()`. - */ - now?: () => Date; -} - -/** ±14 days per the issue's search window (D2(a)). */ -const SEARCH_WINDOW_DAYS = 14; - -const formatDate = (date: Date): string => { - // Local-calendar YYYY-MM-DD, not UTC: plan.scheduledDate strings are - // naive-local (e.g. "2026-09-30T07:00:00"), so "today" must also come - // from the local calendar or a +1/−1 timezone offset flips the day. - const y = date.getFullYear(); - const m = String(date.getMonth() + 1).padStart(2, '0'); - const d = String(date.getDate()).padStart(2, '0'); - return `${y}-${m}-${d}`; -}; - -const startOfDay = (date: Date): Date => { - const d = new Date(date); - d.setHours(0, 0, 0, 0); - return d; -}; - -const addDays = (date: Date, days: number): Date => { - const d = new Date(date); - d.setDate(d.getDate() + days); - return d; -}; - -/** ISO-date-only comparison against a plan's scheduledDate. */ -const planIsOnDate = (plan: WorkoutPlan, isoDate: string): boolean => { - if (!plan.scheduledDate) return false; - return plan.scheduledDate.slice(0, 10) === isoDate; -}; - -/** Order plans by their scheduled time, ties broken by fetch order (D1(a)). */ -const byScheduledTime = (a: WorkoutPlan, b: WorkoutPlan): number => { - if (!a.scheduledDate && !b.scheduledDate) return 0; - if (!a.scheduledDate) return 1; - if (!b.scheduledDate) return -1; - return a.scheduledDate.localeCompare(b.scheduledDate); -}; - -export function LoadTodaysWorkoutButton({ - authService, - intervalsIcuWorkoutService, - athleteId, - onLoad, - now = () => new Date(), -}: LoadTodaysWorkoutButtonProps) { - const [status, setStatus] = useState<'idle' | 'loading' | 'error' | 'searching'>('idle'); - const [error, setError] = useState(null); - const [searchDate, setSearchDate] = useState(''); - const [searchResults, setSearchResults] = useState([]); - - const dateBounds = useMemo(() => { - const today = startOfDay(now()); - return { - min: formatDate(addDays(today, -SEARCH_WINDOW_DAYS)), - max: formatDate(addDays(today, SEARCH_WINDOW_DAYS)), - today: formatDate(today), - }; - }, [now]); - - /** - * Fetch planned rowing workouts within `daysAhead` days, refreshing the - * access token once on a 401 (FR2). - */ - const fetchWithRefresh = useCallback( - async (daysAhead: number): Promise => { - const attempt = async (): Promise => { - const token = authService.getAccessToken(); - if (!token) throw new IntervalsWorkoutFetchError('Signed out.', 401); - return intervalsIcuWorkoutService.fetchPlannedRowingWorkouts(token, athleteId, daysAhead); - }; - try { - return await attempt(); - } catch (err) { - if (err instanceof IntervalsWorkoutFetchError && err.status === 401) { - const refreshed = await authService.refreshAccessToken(); - if (!refreshed) throw err; - return await attempt(); - } - throw err; - } - }, - [authService, intervalsIcuWorkoutService, athleteId], - ); - - const handleLoadToday = useCallback(async () => { - setStatus('loading'); - setError(null); - try { - const plans = await fetchWithRefresh(1); - const todaysPlans = plans.filter((p) => planIsOnDate(p, dateBounds.today)); - const sorted = [...todaysPlans].sort(byScheduledTime); - const first = sorted[0]; - if (!first) { - setStatus('error'); - setError('No rowing workout scheduled for today.'); - return; - } - onLoad(intervalsIcuWorkoutService.toStructuredWorkout(first)); - setStatus('idle'); - } catch (err) { - setStatus('error'); - setError(err instanceof Error ? err.message : 'Could not load today\'s workout.'); - } - }, [fetchWithRefresh, dateBounds.today, intervalsIcuWorkoutService, onLoad]); - - const handleSearchDate = useCallback(async (isoDate: string) => { - setSearchDate(isoDate); - setSearchResults([]); - setError(null); - if (!isoDate) return; - setStatus('searching'); - try { - const plans = await fetchWithRefresh(SEARCH_WINDOW_DAYS); - const onDay = plans.filter((p) => planIsOnDate(p, isoDate)).sort(byScheduledTime); - setSearchResults(onDay); - setStatus('idle'); - if (onDay.length === 0) { - setError('No rowing workout scheduled for that day.'); - } - } catch (err) { - setStatus('error'); - setError(err instanceof Error ? err.message : 'Could not search planned workouts.'); - } - }, [fetchWithRefresh]); - - const handlePickResult = useCallback((plan: WorkoutPlan) => { - onLoad(intervalsIcuWorkoutService.toStructuredWorkout(plan)); - setSearchResults([]); - setSearchDate(''); - setError(null); - setStatus('idle'); - }, [intervalsIcuWorkoutService, onLoad]); - - return ( -
- - - - - {searchResults.length > 0 && ( -
    - {searchResults.map((plan) => ( -
  • - -
  • - ))} -
- )} - - {error && ( -

- {error} -

- )} -
- ); -} diff --git a/src/components/PM5Simulator.css b/src/components/PM5Simulator.css deleted file mode 100644 index f74f7964..00000000 --- a/src/components/PM5Simulator.css +++ /dev/null @@ -1,373 +0,0 @@ -/* =========================================== - PM5Simulator.css - Every rule below the root is scoped to .pm5-simulator-container. These are - global class names — .btn, .metric-value, .status-badge, .expand-btn — and - unscoped they restyled unrelated parts of the app, which is what made - responsive rules in App.css fragile (issue #195, Phase 5). Both PM5Simulator - and HeartRateSimulator render this container as their root. - =========================================== */ -.pm5-simulator-container { - background: rgba(15, 23, 42, 0.9); - border-radius: 12px; - overflow: hidden; - border: 1px solid rgba(255, 255, 255, 0.1); - margin-top: 8px; -} - -.pm5-simulator-container .simulator-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 12px 16px; - cursor: pointer; - transition: background 0.2s ease; -} - -.pm5-simulator-container .simulator-header:hover { - background: rgba(255, 255, 255, 0.05); -} - -.pm5-simulator-container .simulator-title { - display: flex; - align-items: center; - gap: 8px; - color: var(--color-text-primary); - font-weight: 600; - font-size: 13px; -} - -.pm5-simulator-container .simulator-icon { - font-size: 16px; -} - -.pm5-simulator-container .status-badge { - font-size: 11px; /* 11px legibility floor (issue #195) */ - padding: 3px 10px; - border-radius: 10px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.pm5-simulator-container .status-badge.active { - background: rgba(0, 245, 212, 0.2); - color: var(--color-status-established-text); - border: 1px solid rgba(0, 245, 212, 0.3); - animation: pulse-glow 2s infinite; -} - -.pm5-simulator-container .status-badge.inactive { - background: rgba(255, 255, 255, 0.1); - color: rgba(255, 255, 255, 0.5); -} - -@keyframes pulse-glow { - 0%, 100% { - opacity: 1; - box-shadow: 0 0 8px rgba(0, 245, 212, 0.3); - } - 50% { - opacity: 0.8; - box-shadow: 0 0 16px rgba(0, 245, 212, 0.5); - } -} - -.pm5-simulator-container .expand-btn { - background: none; - border: none; - color: rgba(255, 255, 255, 0.5); - cursor: pointer; - /* 44px touch floor (issue #195, Phase 4). */ - min-width: 44px; - min-height: 44px; - display: inline-flex; - align-items: center; - justify-content: center; - padding: 4px 8px; - font-size: 12px; - transition: color 0.2s; -} - -.pm5-simulator-container .expand-btn:hover { - color: var(--color-text-primary); -} - -.pm5-simulator-container .simulator-content { - padding: 16px; - border-top: 1px solid rgba(255, 255, 255, 0.1); -} - -.pm5-simulator-container .btn { - padding: 10px 20px; - border: none; - border-radius: 8px; - font-weight: 700; - cursor: pointer; - transition: all 0.2s ease; - font-size: 12px; - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.pm5-simulator-container .btn-start { - width: 100%; - background: var(--color-brand); - color: var(--color-bg-primary); -} - -/* Hover-only: on a touch device a tap would otherwise leave the lifted state - stuck until the next tap elsewhere (issue #195, Phase 4). */ -@media (hover: hover) { - .pm5-simulator-container .btn-start:hover { - transform: translateY(-1px); - box-shadow: 0 4px 16px rgba(var(--color-primary-rgb), 0.4); - } -} - -.pm5-simulator-container .rowing-toggle { - margin-bottom: 16px; -} - -.pm5-simulator-container .btn-rowing { - width: 100%; - padding: 14px 20px; - font-size: 14px; -} - -.pm5-simulator-container .btn-rowing.stopped { - background: var(--color-brand); - color: var(--color-bg-primary); -} - -.pm5-simulator-container .btn-rowing.rowing { - background: var(--color-error); - color: white; - animation: rowing-pulse 1s infinite; -} - -@keyframes rowing-pulse { - 0%, 100% { transform: scale(1); } - 50% { transform: scale(1.02); } -} - -/* Hover-only: on a touch device a tap would otherwise leave the lifted state - stuck until the next tap elsewhere (issue #195, Phase 4). */ -@media (hover: hover) { - .pm5-simulator-container .btn-rowing:hover { - transform: translateY(-1px); - box-shadow: 0 4px 16px rgba(0, 245, 212, 0.4); - } -} - -.pm5-simulator-container .btn-rowing.rowing:hover { - box-shadow: 0 4px 16px rgba(255, 0, 110, 0.4); -} - -.pm5-simulator-container .settings-grid { - display: flex; - flex-direction: column; - gap: 14px; - margin-bottom: 16px; -} - -.pm5-simulator-container .setting-item { - display: flex; - flex-direction: column; - gap: 6px; -} - -.pm5-simulator-container .setting-item label { - font-size: 11px; - color: rgba(255, 255, 255, 0.5); - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.pm5-simulator-container .setting-control { - display: flex; - align-items: center; - gap: 12px; -} - -.pm5-simulator-container .setting-control input[type="range"] { - flex: 1; - /* A 6px-tall track is not a draggable target on a touchscreen; the visible - rail stays 6px via the background gradient stops (issue #195, Phase 4). */ - height: 28px; - -webkit-appearance: none; - appearance: none; - background: linear-gradient( - rgba(255, 255, 255, 0.15) 0 0 - ) no-repeat center / 100% 6px; - border-radius: 3px; - cursor: pointer; -} - -.pm5-simulator-container .setting-control input[type="range"]::-webkit-slider-thumb { - -webkit-appearance: none; - width: 24px; - height: 24px; - border-radius: 50%; - background: linear-gradient(135deg, var(--color-status-established-text) 0%, #00d4aa 100%); - cursor: pointer; - box-shadow: 0 2px 8px rgba(0, 245, 212, 0.4); -} - -.pm5-simulator-container .setting-control input[type="range"]::-moz-range-thumb { - width: 24px; - height: 24px; - border-radius: 50%; - background: linear-gradient(135deg, var(--color-status-established-text) 0%, #00d4aa 100%); - cursor: pointer; - border: none; - box-shadow: 0 2px 8px rgba(0, 245, 212, 0.4); -} - -.pm5-simulator-container .setting-value { - min-width: 60px; - text-align: right; - font-size: 14px; - font-weight: 700; - color: var(--color-status-established-text); -} - -.pm5-simulator-container .metric-value { - font-size: 14px; - font-weight: 700; - color: var(--color-primary); -} - -/* Metrics Display */ -.pm5-simulator-container .metrics-display { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 10px; - margin-bottom: 16px; - padding: 12px; - background: var(--color-bg-glass); - border-radius: 8px; - border: 1px solid var(--color-border); -} - -.pm5-simulator-container .metric-item { - text-align: center; -} - -.pm5-simulator-container .metric-item .label { - font-size: 11px; /* 11px legibility floor (issue #195) */ - color: var(--color-text-secondary); - text-transform: uppercase; - letter-spacing: 0.5px; - margin-bottom: 4px; -} - -.pm5-simulator-container .metric-item .value { - font-size: 18px; - font-weight: 700; - color: var(--color-primary); -} - -/* Connection status */ -.pm5-simulator-container .connection-status { - display: flex; - align-items: center; - gap: 8px; - padding: 10px 12px; - background: rgba(0, 0, 0, 0.2); - border-radius: 8px; - margin-bottom: 12px; -} - -.pm5-simulator-container .connection-status.connected { - background: rgba(0, 245, 212, 0.1); - border: 1px solid rgba(0, 245, 212, 0.3); -} - -.pm5-simulator-container .connection-status .indicator { - width: 8px; - height: 8px; - border-radius: 50%; - background: rgba(255, 255, 255, 0.3); -} - -.pm5-simulator-container .connection-status.connected .indicator { - background: var(--color-status-established-text); - box-shadow: 0 0 8px rgba(0, 245, 212, 0.5); -} - -.pm5-simulator-container .connection-status .text { - font-size: 12px; - color: rgba(255, 255, 255, 0.5); -} - -.pm5-simulator-container .connection-status.connected .text { - color: var(--color-status-established-text); -} - -.pm5-simulator-container .simulator-actions { - display: flex; - gap: 8px; - margin-top: 4px; -} - -.pm5-simulator-container .simulator-actions .btn { - flex: 1; -} - -.pm5-simulator-container .btn-stop { - background: linear-gradient(135deg, #ff4444 0%, #cc2222 100%); - color: white; -} - -/* Hover-only: on a touch device a tap would otherwise leave the lifted state - stuck until the next tap elsewhere (issue #195, Phase 4). */ -@media (hover: hover) { - .pm5-simulator-container .btn-stop:hover { - transform: translateY(-1px); - box-shadow: 0 4px 16px rgba(255, 68, 68, 0.4); - } -} - -.pm5-simulator-container .btn-reset { - background: rgba(255, 255, 255, 0.1); - color: rgba(255, 255, 255, 0.7); -} - -.pm5-simulator-container .btn-reset:hover { - background: rgba(255, 255, 255, 0.18); - color: var(--color-text-primary); -} - -.pm5-simulator-container .simulator-hint { - margin-top: 12px; - font-size: 11px; - color: rgba(255, 255, 255, 0.35); - line-height: 1.5; -} - -/* Live stat display (used by HR simulator) */ -.pm5-simulator-container .simulator-live-stat { - display: flex; - align-items: center; - justify-content: space-between; - padding: 8px 12px; - background: rgba(0, 245, 212, 0.08); - border: 1px solid rgba(0, 245, 212, 0.2); - border-radius: 8px; - margin-bottom: 12px; -} - -.pm5-simulator-container .simulator-live-label { - font-size: 11px; - font-weight: 600; - color: rgba(255, 255, 255, 0.5); - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.pm5-simulator-container .simulator-live-value { - font-size: 18px; - font-weight: 700; - color: #ff6b8a; -} diff --git a/src/components/PM5Simulator.tsx b/src/components/PM5Simulator.tsx deleted file mode 100644 index 3ed8b3e3..00000000 --- a/src/components/PM5Simulator.tsx +++ /dev/null @@ -1,191 +0,0 @@ -import React, { useState, useEffect, useCallback } from 'react'; -import { pm5Simulator } from '../lib/services/pm5SimulatorService'; -import type { SimulatorSettings } from '../lib/services/pm5SimulatorService'; -import type { PM5Data } from '../lib/types/index'; -import './PM5Simulator.css'; - -interface PM5SimulatorProps { - onConnected?: (deviceName: string) => void; - onDisconnected?: () => void; - onDataReceived?: (data: PM5Data) => void; -} - -export const PM5Simulator: React.FC = ({ - onConnected, - onDisconnected, - onDataReceived, -}) => { - const [isActive, setIsActive] = useState(false); - const [isExpanded, setIsExpanded] = useState(false); - const [settings, setSettings] = useState({ - pace: 120, - cadence: 24, - heartRate: 130, - power: 150, - isRowing: false, - }); - - // Handle data from simulator - const handleData = useCallback((data: PM5Data) => { - onDataReceived?.(data); - }, [onDataReceived]); - - useEffect(() => { - pm5Simulator.addListener(handleData); - return () => { - pm5Simulator.removeListener(handleData); - }; - }, [handleData]); - - const handleStartSimulator = () => { - // When starting the simulator, also start rowing automatically - const startSettings = { ...settings, isRowing: true }; - pm5Simulator.updateSettings(startSettings); - pm5Simulator.start(); - setSettings(startSettings); - setIsActive(true); - onConnected?.('PM5 Simulator'); - }; - - const handleStopSimulator = () => { - pm5Simulator.stop(); - setIsActive(false); - onDisconnected?.(); - }; - - const handleToggleRowing = () => { - const newIsRowing = !settings.isRowing; - setSettings(prev => ({ ...prev, isRowing: newIsRowing })); - pm5Simulator.updateSettings({ isRowing: newIsRowing }); - }; - - const handleSettingChange = (key: keyof SimulatorSettings, value: number) => { - setSettings(prev => ({ ...prev, [key]: value })); - if (isActive) { - pm5Simulator.updateSettings({ [key]: value }); - } - }; - - const handleReset = () => { - pm5Simulator.reset(); - }; - - // Format pace as MM:SS - const formatPace = (paceSeconds: number): string => { - const mins = Math.floor(paceSeconds / 60); - const secs = paceSeconds % 60; - return `${mins}:${secs.toString().padStart(2, '0')}`; - }; - - return ( -
-
setIsExpanded(!isExpanded)} - > -
- 🎮 - PM5 Simulator - {isActive && ACTIVE} -
- -
- - {isExpanded && ( -
- {!isActive ? ( - - ) : ( - <> -
- -
- -
-
- -
- handleSettingChange('pace', Number(e.target.value))} - /> - {formatPace(settings.pace)}/500m -
-
- -
- -
- handleSettingChange('cadence', Number(e.target.value))} - /> - {settings.cadence} spm -
-
- -
- -
- handleSettingChange('heartRate', Number(e.target.value))} - /> - {settings.heartRate} bpm -
-
- -
- -
- handleSettingChange('power', Number(e.target.value))} - /> - {settings.power} W -
-
-
- -
- - -
- - )} - -

- 💡 Use this simulator to test the app without a real PM5 rowing machine -

-
- )} -
- ); -}; - -export default PM5Simulator; diff --git a/src/components/RaceCallouts.css b/src/components/RaceCallouts.css deleted file mode 100644 index 03ce83c6..00000000 --- a/src/components/RaceCallouts.css +++ /dev/null @@ -1,45 +0,0 @@ -/* ============================================================================ - RACE CALLOUTS (#336) - - The start prompt, the 3-2-1, "Row!" and the finish banner. Centred on the - stage, above the HUD strip, and transparent to the pointer so the camera - still takes drags through them. - ============================================================================ */ - -.race-callout { - position: absolute; - inset: 0 0 var(--row-hud-strip-height, 11rem); - z-index: 26; - display: flex; - align-items: center; - justify-content: center; - pointer-events: none; - text-align: center; - padding: 0 16px; -} - -.race-callout-prompt, -.race-callout-count { - color: #fff; - background: rgba(8, 20, 32, 0.72); - border-radius: 12px; - text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6); - font-variant-numeric: tabular-nums; -} - -.race-callout-prompt { - font-size: clamp(1.1rem, 3.5vw, 1.75rem); - font-weight: 600; - padding: 0.6em 1em; -} - -.race-callout-count { - font-size: clamp(3rem, 14vw, 7rem); - font-weight: 800; - line-height: 1; - padding: 0.15em 0.45em; -} - -.race-callout--finish .race-callout-prompt { - background: rgba(10, 70, 40, 0.8); -} diff --git a/src/components/RaceCallouts.tsx b/src/components/RaceCallouts.tsx deleted file mode 100644 index 27cb71e4..00000000 --- a/src/components/RaceCallouts.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import type { StartPhase } from '../hooks/useStartSequence'; -import { formatCourseMetres, formatTime } from '../lib/utils/formatters'; -import './RaceCallouts.css'; - -/** - * What the rower is told at each end of the course (#336): "Ready", the 3-2-1 - * and "Row!" at the start, and the distance and time at the finish. - * - * Both are `role="status"`, so a screen reader hears the count without the - * rower having to look away from the erg. - */ - -export function StartCallout({ phase, countdown }: { phase: StartPhase; countdown: number | null }) { - if (phase === 'idle' || phase === 'rowing') return null; - - const text = - phase === 'armed' ? 'Ready — take your first stroke' : phase === 'go' ? 'Row!' : String(countdown); - - return ( -
- {text} -
- ); -} - -export function FinishBanner({ distanceMeters, elapsedMs }: { distanceMeters: number; elapsedMs: number }) { - return ( -
- - Finished — {formatCourseMetres(distanceMeters)} m in {formatTime(elapsedMs)} - -
- ); -} diff --git a/src/components/RouteDetailOverlay.css b/src/components/RouteDetailOverlay.css deleted file mode 100644 index 005020f4..00000000 --- a/src/components/RouteDetailOverlay.css +++ /dev/null @@ -1,92 +0,0 @@ -/* #505 Phase 2 — route detail modal (D8c). */ - -.route-detail-overlay__backdrop { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.55); - display: flex; - align-items: center; - justify-content: center; - z-index: 100; - padding: 16px; -} - -.route-detail-overlay__dialog { - background: var(--color-bg-card, #141618); - color: var(--color-text-primary, #e8e8e8); - border: 1px solid var(--color-border, #22262a); - border-radius: 10px; - width: min(640px, 100%); - max-height: 90vh; - overflow-y: auto; - padding: 20px; - position: relative; -} - -.route-detail-overlay__close { - position: absolute; - top: 10px; - right: 10px; - background: transparent; - border: 0; - color: inherit; - font-size: 1.2rem; - cursor: pointer; - width: 32px; - height: 32px; - line-height: 32px; -} - -.route-detail-overlay__title { - margin: 0 0 4px; -} - -.route-detail-overlay__location { - color: var(--color-text-secondary, #a7adb3); - margin: 0 0 12px; -} - -.route-detail-overlay__meta { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin: 0 0 16px; -} - -.route-detail-overlay__outline { - display: block; - width: 100%; - height: 220px; - background: var(--surface-2, #0f1114); - border-radius: 6px; - margin-bottom: 16px; -} - -.route-detail-overlay__row { - display: block; - width: 100%; - padding: 10px 16px; - background: var(--color-accent, #0066ff); - color: #fff; - border: 0; - border-radius: 6px; - cursor: pointer; - font-size: 1rem; - font-weight: 600; -} - -.route-detail-overlay__row:hover { - filter: brightness(1.1); -} - -.route-detail-overlay__attribution { - margin-top: 10px; - font-size: 11px; - color: var(--color-text-secondary, #a7adb3); - text-align: right; -} - -.route-detail-overlay__attribution a { - color: inherit; - text-decoration: underline; -} diff --git a/src/components/RouteDetailOverlay.tsx b/src/components/RouteDetailOverlay.tsx deleted file mode 100644 index 1059ff2b..00000000 --- a/src/components/RouteDetailOverlay.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { useEffect } from 'react'; -import type { WaterRoute } from '../lib/types/index'; -import { - externalDistanceNote, - formatRouteDistanceKm, - geometryProvenanceBadge, -} from '../lib/utils/geometryProvenance'; -import { OUTLINE_ONLY_TAG } from '../lib/services/routeService'; -import { RouteThumbnail } from './RouteThumbnail'; - -function routeGeometrySource(route: WaterRoute): WaterRoute['geometrySource'] { - if (route.geometrySource) return route.geometrySource; - return route.tags?.includes(OUTLINE_ONLY_TAG) ? 'gate-chain' : undefined; -} -import './RouteDetailOverlay.css'; - -interface RouteDetailOverlayProps { - route: WaterRoute | null; - onClose: () => void; - onStartRow: () => void; -} - -/** - * #505 Phase 2 (D8c): modal that opens when a map pin or drawer row is - * clicked. Shows name, location, distance, time, difficulty and the geometry- - * provenance badge over the route's outline; "Row this route" dispatches to - * the Row screen through `onStartRow`. - * - * Phase 2 draws the outline with the existing `RouteThumbnail`. Phase 2b - * swaps in the OSM water/land polygons from #493 enrichment (D9-b) once - * the overlay is receiving the enrichment prop. - */ -export function RouteDetailOverlay({ route, onClose, onStartRow }: RouteDetailOverlayProps) { - useEffect(() => { - if (!route) return; - const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); - }; - window.addEventListener('keydown', onKey); - return () => window.removeEventListener('keydown', onKey); - }, [route, onClose]); - - if (!route) return null; - - const provenance = geometryProvenanceBadge(routeGeometrySource(route)); - const extNote = externalDistanceNote(route); - - return ( -
{ - if (e.target === e.currentTarget) onClose(); - }} - > -
- - -

- {route.name} -

-

{route.location}

- -
- 📏 {formatRouteDistanceKm(route.distance)} - {extNote && ( - {extNote} - )} - ⏱️ {route.estimatedTime} min - {route.difficulty} - {provenance && ( - - {provenance.label} - - )} -
- - {route.coordinates.length >= 2 ? ( - - ) : ( - -
- ); -} 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 ( -
- {showBar && ( - <> -
-
-
- -

- {progress.phase} - {progress.percent}% -

- - )} - - {boatMissing && ( -

- {BOAT_UNAVAILABLE_MESSAGE} -

- )} -
- ); -} diff --git a/src/components/RouteMap.css b/src/components/RouteMap.css deleted file mode 100644 index 7b6350f5..00000000 --- a/src/components/RouteMap.css +++ /dev/null @@ -1,40 +0,0 @@ -/* =========================================== - RouteMap.css - =========================================== */ - -.route-map-container { - position: relative; - width: 100%; - height: 100%; - border-radius: var(--radius-panel); - overflow: hidden; - background: var(--color-bg-secondary); - border: 1px solid var(--color-border); -} - -.route-map { - width: 100%; - height: 100%; - z-index: 1; -} - -/* Canvas fills container */ -.route-map canvas { - width: 100% !important; - height: 100% !important; - display: block; -} - -.route-map-container.highlight-mode { - opacity: 0.8; -} - -/* Everything that used to follow — .route-info-overlay, .route-info-card, - .route-stats, .stat, .badge, .description, .btn/.btn-primary/.btn-secondary - and the loading spinner — styled markup this component does not render. Those - are global class names, so the only thing they did was reach into App.tsx and - restyle unrelated elements, which made every responsive rule fragile - (issue #195, Phase 5). Deleted; RouteMap owns only what it renders. - - The route info card in the routes view is styled by .route-info-overlay in - App.css, where the markup lives. */ diff --git a/src/components/RouteMap.tsx b/src/components/RouteMap.tsx deleted file mode 100644 index 6f60ede8..00000000 --- a/src/components/RouteMap.tsx +++ /dev/null @@ -1,311 +0,0 @@ -import React, { useMemo, useRef, useEffect, useState } from 'react'; -import { latLngToMeters } from '../lib/utils/geoUtils'; -import { createRouteMapTransform } from './routeMapProjection'; -import type { WaterRoute } from '../lib/types/index'; -import './RouteMap.css'; - -interface RouteMapProps { - route: WaterRoute; - highlightMode?: boolean; - progressPercent?: number; // 0-100, percentage of route completed -} - -export const RouteMap: React.FC = ({ - route, - highlightMode, - progressPercent, -}) => { - const canvasRef = useRef(null); - - // Convert lat/lng coordinates to pixel positions - const { points, bounds } = useMemo(() => { - if (!route || !route.coordinates || route.coordinates.length === 0) { - return { points: [], bounds: { minX: 0, maxX: 0, minY: 0, maxY: 0, width: 0, height: 0 } }; - } - - const originLat = route.coordinates[0].lat; - const originLng = route.coordinates[0].lng; - - // Convert to meters - const meterPoints = route.coordinates.map((c) => { - const p = latLngToMeters(c.lat, c.lng, originLat, originLng); - return { x: p.x, y: p.y }; - }); - - // Calculate bounds - let minX = Infinity, maxX = -Infinity; - let minY = Infinity, maxY = -Infinity; - - for (const point of meterPoints) { - minX = Math.min(minX, point.x); - maxX = Math.max(maxX, point.x); - minY = Math.min(minY, point.y); - maxY = Math.max(maxY, point.y); - } - - const width = maxX - minX; - const height = maxY - minY; - - return { - points: meterPoints, - bounds: { minX, maxX, minY, maxY, width, height } - }; - }, [route]); - - // Rendered canvas size, kept in state so the draw effect re-runs on resize. - const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 }); - - // Draw the route on canvas - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas || points.length < 2) return; - - const ctx = canvas.getContext('2d'); - if (!ctx) return; - - // Get actual canvas dimensions - const rect = canvas.getBoundingClientRect(); - const dpr = window.devicePixelRatio || 1; - - // Set canvas size with device pixel ratio for crisp rendering - canvas.width = rect.width * dpr; - canvas.height = rect.height * dpr; - ctx.scale(dpr, dpr); - - const canvasWidth = rect.width; - const canvasHeight = rect.height; - - const { toCanvas, scale, padding } = createRouteMapTransform(bounds, { - width: canvasWidth, - height: canvasHeight, - }); - - // Clear canvas - ctx.clearRect(0, 0, canvasWidth, canvasHeight); - - // Draw earth background (green/gray gradient) - const earthGradient = ctx.createRadialGradient( - canvasWidth / 2, canvasHeight / 2, 0, - canvasWidth / 2, canvasHeight / 2, Math.max(canvasWidth, canvasHeight) - ); - earthGradient.addColorStop(0, '#6b8e6b'); // Green-gray center - earthGradient.addColorStop(0.5, '#5a7d5a'); // Slightly darker - earthGradient.addColorStop(1, '#4a6a4a'); // Darker edges - ctx.fillStyle = earthGradient; - ctx.fillRect(0, 0, canvasWidth, canvasHeight); - - // Add subtle texture pattern - ctx.globalAlpha = 0.1; - for (let i = 0; i < 200; i++) { - const x = Math.random() * canvasWidth; - const y = Math.random() * canvasHeight; - const size = Math.random() * 3 + 1; - ctx.fillStyle = Math.random() > 0.5 ? '#3d5c3d' : '#7a9e7a'; - ctx.beginPath(); - ctx.arc(x, y, size, 0, Math.PI * 2); - ctx.fill(); - } - ctx.globalAlpha = 1; - - // Draw water area around route (lighter blue area) - const waterPadding = 30; - ctx.fillStyle = 'rgba(59, 130, 246, 0.15)'; - ctx.beginPath(); - - // Create water path following route with offset - if (points.length > 0) { - const firstPoint = toCanvas(points[0].x, points[0].y); - ctx.moveTo(firstPoint.x - waterPadding, firstPoint.y - waterPadding); - - for (const point of points) { - const p = toCanvas(point.x, point.y); - ctx.lineTo(p.x, p.y); - } - } - ctx.strokeStyle = 'rgba(59, 130, 246, 0.2)'; - ctx.lineWidth = 60; - ctx.lineCap = 'round'; - ctx.lineJoin = 'round'; - ctx.stroke(); - - // Calculate split point for progress - const splitIndex = progressPercent !== undefined && progressPercent > 0 - ? Math.min(Math.floor((progressPercent / 100) * points.length), points.length - 1) - : 0; - - // Draw route shadow for depth - ctx.beginPath(); - const shadowStart = toCanvas(points[0].x, points[0].y); - ctx.moveTo(shadowStart.x + 2, shadowStart.y + 2); - for (let i = 1; i < points.length; i++) { - const p = toCanvas(points[i].x, points[i].y); - ctx.lineTo(p.x + 2, p.y + 2); - } - ctx.strokeStyle = 'rgba(0, 0, 0, 0.2)'; - ctx.lineWidth = 6; - ctx.lineCap = 'round'; - ctx.lineJoin = 'round'; - ctx.stroke(); - - // Draw completed portion (red) if there's progress - if (progressPercent !== undefined && progressPercent > 0 && splitIndex > 0) { - ctx.beginPath(); - const completedStart = toCanvas(points[0].x, points[0].y); - ctx.moveTo(completedStart.x, completedStart.y); - for (let i = 1; i <= splitIndex; i++) { - const p = toCanvas(points[i].x, points[i].y); - ctx.lineTo(p.x, p.y); - } - ctx.strokeStyle = '#ef4444'; // Red - ctx.lineWidth = 4; - ctx.lineCap = 'round'; - ctx.lineJoin = 'round'; - ctx.stroke(); - - // Draw remaining portion (green) - if (splitIndex < points.length - 1) { - ctx.beginPath(); - const remainingStart = toCanvas(points[splitIndex].x, points[splitIndex].y); - ctx.moveTo(remainingStart.x, remainingStart.y); - for (let i = splitIndex + 1; i < points.length; i++) { - const p = toCanvas(points[i].x, points[i].y); - ctx.lineTo(p.x, p.y); - } - ctx.strokeStyle = '#22c55e'; // Green - ctx.lineWidth = 4; - ctx.lineCap = 'round'; - ctx.lineJoin = 'round'; - ctx.stroke(); - } - } else { - // Draw full route (blue) - ctx.beginPath(); - const start = toCanvas(points[0].x, points[0].y); - ctx.moveTo(start.x, start.y); - for (let i = 1; i < points.length; i++) { - const p = toCanvas(points[i].x, points[i].y); - ctx.lineTo(p.x, p.y); - } - ctx.strokeStyle = '#3b82f6'; // Blue - ctx.lineWidth = 4; - ctx.lineCap = 'round'; - ctx.lineJoin = 'round'; - ctx.stroke(); - } - - // Draw start marker (green circle) - const startPoint = toCanvas(points[0].x, points[0].y); - ctx.beginPath(); - ctx.arc(startPoint.x, startPoint.y, 8, 0, Math.PI * 2); - ctx.fillStyle = '#22c55e'; - ctx.fill(); - ctx.strokeStyle = '#ffffff'; - ctx.lineWidth = 2; - ctx.stroke(); - - // Draw end marker (red circle with flag) - const endPoint = toCanvas(points[points.length - 1].x, points[points.length - 1].y); - ctx.beginPath(); - ctx.arc(endPoint.x, endPoint.y, 8, 0, Math.PI * 2); - ctx.fillStyle = '#ef4444'; - ctx.fill(); - ctx.strokeStyle = '#ffffff'; - ctx.lineWidth = 2; - ctx.stroke(); - - // Draw current position marker (yellow) if there's progress - if (progressPercent !== undefined && progressPercent > 0 && splitIndex < points.length) { - const currentPoint = toCanvas(points[splitIndex].x, points[splitIndex].y); - ctx.beginPath(); - ctx.arc(currentPoint.x, currentPoint.y, 10, 0, Math.PI * 2); - ctx.fillStyle = '#fbbf24'; - ctx.fill(); - ctx.strokeStyle = '#ffffff'; - ctx.lineWidth = 2; - ctx.stroke(); - - // Inner dot - ctx.beginPath(); - ctx.arc(currentPoint.x, currentPoint.y, 4, 0, Math.PI * 2); - ctx.fillStyle = '#f59e0b'; - ctx.fill(); - } - - // Draw distance scale in corner (if not in highlight mode) - if (!highlightMode) { - const scaleBarWidth = 100; - const scaleDistance = scaleBarWidth / scale; // meters - const scaleX = canvasWidth - padding - scaleBarWidth; - const scaleY = canvasHeight - 20; - - ctx.fillStyle = 'rgba(255, 255, 255, 0.9)'; - ctx.fillRect(scaleX - 10, scaleY - 20, scaleBarWidth + 20, 30); - - ctx.strokeStyle = '#374151'; - ctx.lineWidth = 2; - ctx.beginPath(); - ctx.moveTo(scaleX, scaleY); - ctx.lineTo(scaleX + scaleBarWidth, scaleY); - ctx.stroke(); - - // Scale ticks - ctx.beginPath(); - ctx.moveTo(scaleX, scaleY - 5); - ctx.lineTo(scaleX, scaleY + 5); - ctx.moveTo(scaleX + scaleBarWidth, scaleY - 5); - ctx.lineTo(scaleX + scaleBarWidth, scaleY + 5); - ctx.stroke(); - - // Scale label - ctx.fillStyle = '#374151'; - ctx.font = '11px sans-serif'; - ctx.textAlign = 'center'; - const distanceLabel = scaleDistance >= 1000 - ? `${(scaleDistance / 1000).toFixed(1)} km` - : `${Math.round(scaleDistance)} m`; - ctx.fillText(distanceLabel, scaleX + scaleBarWidth / 2, scaleY - 8); - } - - }, [points, bounds, progressPercent, highlightMode, canvasSize]); - - /** - * Track the canvas's rendered size. - * - * This previously listened for window `resize` and *dispatched* a `resize` - * event from the handler, which re-entered itself until the stack blew - * ("Maximum call stack size exceeded" on every viewport change — issue #195). - * A ResizeObserver reports the real element size and feeds the draw effect - * through its dependencies, so it also catches container-only changes that a - * window listener never sees. - */ - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas || typeof ResizeObserver === 'undefined') return; - - const observer = new ResizeObserver((entries) => { - const entry = entries[0]; - if (!entry) return; - const { width, height } = entry.contentRect; - setCanvasSize((prev) => - Math.abs(prev.width - width) < 1 && Math.abs(prev.height - height) < 1 - ? prev - : { width, height }, - ); - }); - observer.observe(canvas); - return () => observer.disconnect(); - }, []); - - return ( -
-
- -
-
- ); -}; - -export default RouteMap; diff --git a/src/components/RouteOverviewMap.css b/src/components/RouteOverviewMap.css deleted file mode 100644 index 924f07c1..00000000 --- a/src/components/RouteOverviewMap.css +++ /dev/null @@ -1,89 +0,0 @@ -/* #505 — map-primary Routes overview. */ - -.route-overview-map { - position: relative; - width: 100%; - height: 100%; - min-height: 300px; - background: var(--surface-2, #0f1114); - overflow: hidden; -} - -.route-overview-map__canvas { - position: absolute; - inset: 0; -} - -.route-overview-map__fallback { - position: absolute; - inset: 0; - display: block; -} - -.route-overview-map__pin-layer { - position: absolute; - inset: 0; - pointer-events: none; -} - -.route-overview-map__pin { - position: absolute; - transform: translate(-50%, -100%); - background: none; - border: 0; - padding: 0; - cursor: pointer; - pointer-events: auto; - color: var(--text-primary, #e8e8e8); - font: inherit; - line-height: 1; -} - -.route-overview-map__pin[data-dimmed='true'] { - opacity: 0.3; -} - -.route-overview-map__pin[data-selected='true'] svg { - filter: drop-shadow(0 0 4px rgba(0, 180, 255, 0.9)); -} - -.route-overview-map__pin[data-default='true'] svg { - filter: drop-shadow(0 0 4px rgba(255, 196, 0, 0.9)); -} - -.route-overview-map__cluster { - position: absolute; - transform: translate(-50%, -50%); - width: 32px; - height: 32px; - border-radius: 50%; - background: rgba(0, 180, 255, 0.85); - color: #0b1620; - border: 2px solid #fff; - font-weight: 700; - font-size: 13px; - cursor: pointer; - pointer-events: auto; - box-shadow: 0 0 6px rgba(0, 0, 0, 0.5); -} - -.route-overview-map__cluster:hover { - filter: brightness(1.1); -} - -.route-overview-map__attribution { - position: absolute; - right: 4px; - bottom: 4px; - padding: 2px 6px; - background: rgba(0, 0, 0, 0.55); - color: #fff; - font-size: 10px; - border-radius: 3px; - z-index: 2; -} - -.route-overview-map__attribution a { - color: inherit; - text-decoration: underline; -} diff --git a/src/components/RouteOverviewMap.tsx b/src/components/RouteOverviewMap.tsx deleted file mode 100644 index c8b39d21..00000000 --- a/src/components/RouteOverviewMap.tsx +++ /dev/null @@ -1,331 +0,0 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; -import type { WaterRoute, Coordinate } from '../lib/types/index'; -import { routeCentroid } from '../lib/utils/routeCentroid'; -import { clusterRoutePins, type ClusteredEntry } from '../lib/utils/routePinClusters'; -import './RouteOverviewMap.css'; - -const FALLBACK_ZOOM = 2; - -interface RouteOverviewMapProps { - routes: readonly WaterRoute[]; - defaultRouteId: string | null; - selectedRouteId: string | null; - dimmedRouteIds?: ReadonlySet; - onSelect: (route: WaterRoute) => void; -} - -interface PlacedPin { - route: WaterRoute; - centroid: Coordinate; -} - -/** - * #505 — map-primary Routes-screen overview. - * - * Lazy-loads MapLibre; when the module or its init fails (jsdom, no WebGL, - * blocked tile host), falls back to an SVG world sketch of the pins so the - * surface stays usable (NFR1 of #505). - * - * Clustering (D4), the modal detail overlay (D8) and the OSM-tile card - * backdrop (D3) land in Phase 2 of the issue; this phase renders pins and - * handles selection the same way the drawer rows do. - */ -export function RouteOverviewMap({ - routes, - defaultRouteId, - selectedRouteId, - dimmedRouteIds, - onSelect, -}: RouteOverviewMapProps) { - const canvasRef = useRef(null); - const mapInstanceRef = useRef<{ - flyTo: (opts: { center: [number, number]; zoom: number; animate?: boolean }) => void; - } | null>(null); - const [mapReady, setMapReady] = useState(false); - const [projection, setProjection] = useState<((c: Coordinate) => { x: number; y: number }) | null>( - null, - ); - const [containerSize, setContainerSize] = useState<{ w: number; h: number }>({ w: 0, h: 0 }); - // #505 Phase 2c (D4-b): the live viewport, read from MapLibre's `moveend` - // event. In the SVG fallback this stays at the FALLBACK_ZOOM / world bbox, - // which is what Phase 2b used throughout. - const [viewport, setViewport] = useState<{ - zoom: number; - bbox: [number, number, number, number]; - }>({ zoom: FALLBACK_ZOOM, bbox: [-180, -85, 180, 85] }); - - const pins = useMemo(() => { - const out: PlacedPin[] = []; - for (const r of routes) { - const c = routeCentroid(r.coordinates); - if (c) out.push({ route: r, centroid: c }); - } - return out; - }, [routes]); - - // #505 Phase 2c (D4-b): cluster against the live viewport, so a user zooming - // in on a cluster watches it break apart as its members pass the cluster - // radius. In the SVG fallback the viewport stays at the overview defaults. - const entries = useMemo( - () => clusterRoutePins(routes, viewport.zoom, viewport.bbox), - [routes, viewport], - ); - - // Track rendered size so the fallback SVG and the pin layer agree on pixels. - useEffect(() => { - const el = canvasRef.current; - if (!el) return; - const measure = () => { - const rect = el.getBoundingClientRect(); - setContainerSize({ w: rect.width, h: rect.height }); - }; - measure(); - const ro = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(measure); - ro?.observe(el); - return () => ro?.disconnect(); - }, []); - - // Try to initialise MapLibre; on any error (jsdom, no WebGL, module missing) - // fall through to the SVG fallback. We intentionally do not import the - // module statically so the Routes screen bundle stays light when the flag - // is off (NFR3 of #505). - useEffect(() => { - let cancelled = false; - let mapInstance: { remove: () => void } | null = null; - const el = canvasRef.current; - if (!el || pins.length === 0) { - setMapReady(false); - return; - } - - (async () => { - try { - const maplibre = (await import('maplibre-gl')) as unknown as { - Map: new (opts: Record) => { - remove: () => void; - project: (lnglat: [number, number]) => { x: number; y: number }; - fitBounds: ( - bounds: [[number, number], [number, number]], - opts?: Record, - ) => void; - flyTo: (opts: { center: [number, number]; zoom: number; animate?: boolean }) => void; - on: (ev: string, cb: () => void) => void; - off: (ev: string, cb: () => void) => void; - getZoom: () => number; - getBounds: () => { - getWest: () => number; - getSouth: () => number; - getEast: () => number; - getNorth: () => number; - }; - }; - }; - - const style = { - version: 8, - sources: { - osm: { - type: 'raster', - tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'], - tileSize: 256, - maxzoom: 19, - attribution: '© OpenStreetMap', - }, - }, - layers: [{ id: 'osm', type: 'raster', source: 'osm' }], - }; - - const map = new maplibre.Map({ - container: el, - style, - attributionControl: false, - center: [pins[0].centroid.lng, pins[0].centroid.lat], - zoom: 2, - }); - if (cancelled) { - map.remove(); - return; - } - mapInstance = map; - mapInstanceRef.current = map; - - const defaultPin = pins.find((p) => p.route.id === defaultRouteId); - if (defaultPin) { - map.fitBounds( - [ - [defaultPin.centroid.lng - 0.2, defaultPin.centroid.lat - 0.2], - [defaultPin.centroid.lng + 0.2, defaultPin.centroid.lat + 0.2], - ], - { padding: 32, animate: false }, - ); - } else { - let minLng = Infinity; - let maxLng = -Infinity; - let minLat = Infinity; - let maxLat = -Infinity; - for (const p of pins) { - if (p.centroid.lng < minLng) minLng = p.centroid.lng; - if (p.centroid.lng > maxLng) maxLng = p.centroid.lng; - if (p.centroid.lat < minLat) minLat = p.centroid.lat; - if (p.centroid.lat > maxLat) maxLat = p.centroid.lat; - } - if (Number.isFinite(minLng)) { - map.fitBounds( - [ - [minLng, minLat], - [maxLng, maxLat], - ], - { padding: 48, animate: false }, - ); - } - } - - setMapReady(true); - setProjection(() => (c: Coordinate) => map.project([c.lng, c.lat])); - - const readViewport = () => { - const b = map.getBounds(); - setViewport({ - zoom: map.getZoom(), - bbox: [b.getWest(), b.getSouth(), b.getEast(), b.getNorth()], - }); - }; - readViewport(); - map.on('moveend', readViewport); - } catch { - setMapReady(false); - } - })(); - - return () => { - cancelled = true; - mapInstance?.remove(); - mapInstance = null; - mapInstanceRef.current = null; - setMapReady(false); - setProjection(null); - }; - }, [pins, defaultRouteId]); - - // Equirectangular projection for the fallback: a world sketch bounded by - // the pin set (so a UK-only catalogue doesn't centre on the Atlantic). - const fallbackProjection = useMemo(() => { - if (pins.length === 0 || containerSize.w === 0 || containerSize.h === 0) return null; - let minLng = Infinity; - let maxLng = -Infinity; - let minLat = Infinity; - let maxLat = -Infinity; - for (const p of pins) { - if (p.centroid.lng < minLng) minLng = p.centroid.lng; - if (p.centroid.lng > maxLng) maxLng = p.centroid.lng; - if (p.centroid.lat < minLat) minLat = p.centroid.lat; - if (p.centroid.lat > maxLat) maxLat = p.centroid.lat; - } - const padLng = Math.max(0.5, (maxLng - minLng) * 0.2); - const padLat = Math.max(0.5, (maxLat - minLat) * 0.2); - minLng -= padLng; - maxLng += padLng; - minLat -= padLat; - maxLat += padLat; - const lngRange = maxLng - minLng || 1; - const latRange = maxLat - minLat || 1; - const w = containerSize.w; - const h = containerSize.h; - return (c: Coordinate) => ({ - x: ((c.lng - minLng) / lngRange) * w, - y: ((maxLat - c.lat) / latRange) * h, - }); - }, [pins, containerSize]); - - const activeProjection = mapReady && projection ? projection : fallbackProjection; - - return ( -
- {!mapReady && ( - - )} -
- {entries.map((entry) => { - if (entry.kind === 'cluster') { - const pos = activeProjection - ? activeProjection({ lat: entry.lat, lng: entry.lng }) - : null; - return ( - - ); - } - const { route, centroid } = { route: entry.route, centroid: { lat: entry.lat, lng: entry.lng } }; - const pos = activeProjection ? activeProjection(centroid) : null; - const isDefault = route.id === defaultRouteId; - const isSelected = route.id === selectedRouteId; - const isDimmed = dimmedRouteIds?.has(route.id) ?? false; - return ( - - ); - })} -
- {pins.length > 0 && ( -
- ©{' '} - - OpenStreetMap - {' '} - contributors -
- )} -
- ); -} diff --git a/src/components/RouteThumbnail.css b/src/components/RouteThumbnail.css deleted file mode 100644 index 5a897b72..00000000 --- a/src/components/RouteThumbnail.css +++ /dev/null @@ -1,50 +0,0 @@ -.route-thumbnail { - display: block; - border-radius: var(--radius-control); - background: var(--color-bg-secondary); - border: 1px solid var(--color-border-card); - flex-shrink: 0; -} - -.route-thumbnail--placeholder { - background: var(--color-bg-secondary); - border-radius: var(--radius-control); -} - -.route-thumbnail-line { - stroke: var(--color-brand); - stroke-width: 2.5; - filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.75)); -} - -.route-item.active .route-thumbnail-line { - stroke: var(--color-brand); -} - -/* #505 Phase 2d (D3-b): per-card OSM tile backdrop. */ -.route-thumbnail-wrap { - position: relative; - display: inline-block; - border-radius: var(--radius-control); - overflow: hidden; - border: 1px solid var(--color-border-card); - background: var(--color-bg-secondary); - flex-shrink: 0; -} - -.route-thumbnail-wrap .route-thumbnail { - position: relative; - background: transparent; - border: 0; - border-radius: 0; - z-index: 1; -} - -.route-thumbnail__backdrop { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - object-fit: cover; - z-index: 0; -} diff --git a/src/components/RouteThumbnail.tsx b/src/components/RouteThumbnail.tsx deleted file mode 100644 index a8f8151b..00000000 --- a/src/components/RouteThumbnail.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import { useMemo, useState } from 'react'; -import type { Coordinate } from '../lib/types/index'; -import { osmTileFor, pickBackdropZoom, projectInsideTile } from '../lib/utils/osmTileBackdrop'; -import { routeCentroid } from '../lib/utils/routeCentroid'; -import './RouteThumbnail.css'; - -interface RouteThumbnailProps { - coordinates: Coordinate[]; - /** Width of the SVG viewBox (default 120). */ - width?: number; - /** Height of the SVG viewBox (default 80). */ - height?: number; - className?: string; - /** - * #505 Phase 2d (D3-b): draw an OSM raster tile backdrop behind the polyline. - * When true the polyline is projected in Web Mercator relative to the chosen - * tile, so the route lines up with the geographic feature on the tile. If - * the tile fails to load (network, offline, blocked host) the backdrop is - * hidden and the polyline falls back to the thumbnail-local linear projection. - */ - showBackdrop?: boolean; -} - -/** - * RouteThumbnail renders a lightweight SVG polyline preview of a route's - * geographic coordinates. By default the polyline is normalised to the SVG - * viewBox with a small padding. With `showBackdrop`, a single OSM raster - * tile is drawn behind the polyline and the polyline is projected in Web - * Mercator relative to that tile. - */ -export function RouteThumbnail({ - coordinates, - width = 120, - height = 80, - className = '', - showBackdrop = false, -}: RouteThumbnailProps) { - const [failedUrl, setFailedUrl] = useState(null); - - const backdrop = useMemo(() => { - if (!showBackdrop || !coordinates || coordinates.length < 2) return null; - const centre = routeCentroid(coordinates); - if (!centre) return null; - const z = pickBackdropZoom(coordinates); - const tile = osmTileFor(centre, z); - return tile; - }, [showBackdrop, coordinates]); - - if (!coordinates || coordinates.length < 2) { - return ( -