running-tools

A collection of tools for runners and their coaches
git clone https://git.ashermorgan.net/running-tools/
Log | Files | Refs | README

commit 7a9ffcd827c2e0603c8486d49e190c0e5e222513
parent fc80d58fec429f7c6d9765e97f9e0f3a6358cf76
Author: Asher Morgan <59518073+ashermorgan@users.noreply.github.com>
Date:   Sun, 12 May 2024 16:19:10 -0700

Merge pull request #7 from ashermorgan/e2e-tests

Add end-to-end tests
Diffstat:
M.github/workflows/node.js.yml | 2++
M.gitignore | 4++++
MREADME.md | 3++-
Mpackage-lock.json | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mpackage.json | 5++++-
Aplaywright.config.js | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/e2e/cross-calculator.spec.js | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/e2e/pace-calculator.spec.js | 163+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/e2e/race-calculator.spec.js | 189+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/e2e/split-calculator.spec.js | 168+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/e2e/unit-calculator.spec.js | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mvite.config.js | 1+
12 files changed, 846 insertions(+), 6 deletions(-)

diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml @@ -23,5 +23,7 @@ jobs: cache: 'npm' - run: npm ci + - run: npx playwright install --with-deps - run: npm run build --if-present - run: npm run test:unit + - run: npm run test:e2e diff --git a/.gitignore b/.gitignore @@ -21,3 +21,7 @@ pnpm-debug.log* *.njsproj *.sln *.sw? +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/README.md b/README.md @@ -23,10 +23,11 @@ Run development server npm run dev ``` -Run linter and unit tests +Run linter and tests ``` npm run lint npm run test:unit +npm run test:e2e ``` Build for production diff --git a/package-lock.json b/package-lock.json @@ -14,6 +14,8 @@ "vue-router": "^4.2.2" }, "devDependencies": { + "@playwright/test": "^1.44.0", + "@types/node": "^20.12.11", "@vitejs/plugin-vue": "^4.2.3", "@vue/test-utils": "^2.4.0", "eslint": "^8.39.0", @@ -2485,6 +2487,21 @@ "node": ">= 8" } }, + "node_modules/@playwright/test": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.44.0.tgz", + "integrity": "sha512-rNX5lbNidamSUorBhB4XZ9SQTjAqfe5M+p37Z8ic0jPFBMo5iCtQz1kRWkEMg+rYOKSlVycpQmpqjSFq7LXOfg==", + "dev": true, + "dependencies": { + "playwright": "1.44.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -2549,10 +2566,13 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.4.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.0.tgz", - "integrity": "sha512-jfT7iTf/4kOQ9S7CHV9BIyRaQqHu67mOjsIQBC3BKZvzvUB6zLxEwJ6sBE3ozcvP8kF6Uk5PXN0Q+c0dfhGX0g==", - "dev": true + "version": "20.12.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.11.tgz", + "integrity": "sha512-vDg9PZ/zi+Nqp6boSOT7plNuthRugEKixDv5sFTIpkE89MmNtEArAShI4mxuX2+UrLEe9pxC1vm2cjm9YlWbJw==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } }, "node_modules/@types/normalize-package-data": { "version": "2.4.4", @@ -6265,6 +6285,36 @@ "pathe": "^1.1.0" } }, + "node_modules/playwright": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.44.0.tgz", + "integrity": "sha512-F9b3GUCLQ3Nffrfb6dunPOkE5Mh68tR7zN32L4jCk4FjQamgesGay7/dAAe1WaMEGV04DkdJfcJzjoCKygUaRQ==", + "dev": true, + "dependencies": { + "playwright-core": "1.44.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.44.0.tgz", + "integrity": "sha512-ZTbkNpFfYcGWohvTTl+xewITm7EOuqIqex0c7dNZ+aXsbrLj0qI8XlGKfPpipjm0Wny/4Lt4CJsWJk1stVS5qQ==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/postcss": { "version": "8.4.32", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.32.tgz", @@ -7594,6 +7644,12 @@ "through": "^2.3.8" } }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz", diff --git a/package.json b/package.json @@ -8,7 +8,8 @@ "build": "vite build", "preview": "vite preview", "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore", - "test:unit": "vitest" + "test:unit": "vitest", + "test:e2e": "npx playwright test" }, "dependencies": { "feather-icons": "^4.29.0", @@ -17,6 +18,8 @@ "vue-router": "^4.2.2" }, "devDependencies": { + "@playwright/test": "^1.44.0", + "@types/node": "^20.12.11", "@vitejs/plugin-vue": "^4.2.3", "@vue/test-utils": "^2.4.0", "eslint": "^8.39.0", diff --git a/playwright.config.js b/playwright.config.js @@ -0,0 +1,54 @@ +// @ts-check +const { defineConfig, devices } = require('@playwright/test'); + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config(); + +/** + * @see https://playwright.dev/docs/test-configuration + */ +module.exports = defineConfig({ + testDir: './tests/e2e', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: 'http://localhost:5173', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: 'npm run dev', + url: 'http://localhost:5173', + reuseExistingServer: !process.env.CI, + }, +}); + diff --git a/tests/e2e/cross-calculator.spec.js b/tests/e2e/cross-calculator.spec.js @@ -0,0 +1,108 @@ +const { test, expect } = require('@playwright/test'); + +test('Save and update state when navigating between calculators', async ({ page }) => { + // Go to pace calculator + await page.goto('/'); + await page.getByRole('button', { name: 'Pace Calculator' }).click(); + + // Enter input pace (2 mi in 15:30) + await page.getByLabel('Input distance value').fill('2'); + await page.getByLabel('Input distance unit').selectOption('Miles'); + await page.getByLabel('Input duration hours').fill('0'); + await page.getByLabel('Input duration minutes').fill('15'); + await page.getByLabel('Input duration seconds').fill('30'); + + // Change default units (should update on other calculators too) + await page.getByText('Advanced Options').click(); + await page.getByLabel('Default units').selectOption('Kilometers'); + + // Switch target set + await page.getByLabel('Selected target set').selectOption('5K Mile Splits'); + + // Go to race calculator + await page.getByRole('link', { name: 'Back' }).click(); + await page.getByRole('button', { name: 'Race Calculator' }).click(); + + // Enter input race (2 mi in 10:30) + await page.getByLabel('Input distance value').fill('2'); + await page.getByLabel('Input distance unit').selectOption('Miles'); + await page.getByLabel('Input race duration hours').fill('0'); + await page.getByLabel('Input race duration minutes').fill('10'); + await page.getByLabel('Input race duration seconds').fill('30'); + + // Change prediction model + await page.getByText('Advanced Options').click(); + await page.getByLabel('Prediction model').selectOption('Riegel\'s Model'); + + // Go to split calculator + await page.getByRole('link', { name: 'Back' }).click(); + await page.getByRole('button', { name: 'Split Calculator' }).click(); + + // Edit target set + await page.getByRole('button', { name: 'Edit target set' }).click(); + await page.getByLabel('Target set label').fill('5K 1600m Splits'); + await page.getByLabel('Target distance value').nth(0).fill('1.6'); + await page.getByLabel('Target distance unit').nth(0).selectOption('Kilometers'); + await page.getByLabel('Target distance value').nth(1).fill('3.2'); + await page.getByLabel('Target distance unit').nth(1).selectOption('Kilometers'); + await page.getByRole('button', { name: 'Add time target' }).click(); + await page.getByRole('button', { name: 'Close' }).click(); + + // Enter input 5K splits (7:00, 6:30, 6:30) + await page.getByLabel('Split duration minutes').nth(0).fill('7'); + await page.getByLabel('Split duration seconds').nth(0).fill('0'); + await page.getByLabel('Split duration minutes').nth(1).fill('6'); + await page.getByLabel('Split duration seconds').nth(1).fill('30'); + await page.getByLabel('Split duration minutes').nth(2).fill('6'); + await page.getByLabel('Split duration seconds').nth(2).fill('30'); + + // Go to unit calculator + await page.getByRole('link', { name: 'Back' }).click(); + await page.getByRole('button', { name: 'Unit Calculator' }).click(); + + // Convert speed and pace units (10 kph to time per mile) + await page.getByLabel('Selected unit category').selectOption('Speed & Pace'); + await page.getByLabel('Input units').selectOption('Kilometers per Hour'); + await page.getByLabel('Input value').fill('10'); + await page.getByLabel('Output units').selectOption('Time per Mile'); + + // Return to pace calculator + await page.getByRole('link', { name: 'Back' }).click(); + await page.getByRole('button', { name: 'Pace Calculator' }).click(); + + // Assert paces are correct (input pace not reset) + await expect(page.getByRole('row').nth(1)).toHaveText('1.6 km' + '7:42.30'); + await expect(page.getByRole('row').nth(2)).toHaveText('2.08 km' + '10:00'); + await expect(page.getByRole('row').nth(3)).toHaveText('3.2 km' + '15:24.60'); + await expect(page.getByRole('row').nth(4)).toHaveText('5 km' + '24:04.68'); + await expect(page.getByRole('row')).toHaveCount(5); + + // Return to race calculator + await page.getByRole('link', { name: 'Back' }).click(); + await page.getByRole('button', { name: 'Race Calculator' }).click(); + + // Assert race predictions are correct (input race and prediction model not reset) + await expect(page.getByRole('row').nth(5)).toHaveText('1 mi' + '5:02.17' + '3:08 / km'); + await expect(page.getByRole('row').nth(10)).toHaveText('5 km' + '16:44.86' + '3:21 / km'); + await expect(page.getByRole('row')).toHaveCount(17); + + // Return to split calculator + await page.getByRole('link', { name: 'Back' }).click(); + await page.getByRole('button', { name: 'Split Calculator' }).click(); + + // Assert times and paces are correct (split times not reset) + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(1)).toHaveText('7:00.00'); + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(3)).toHaveText('4:23 / km'); + await expect(page.getByRole('row').nth(2).getByRole('cell').nth(1)).toHaveText('13:30.00'); + await expect(page.getByRole('row').nth(2).getByRole('cell').nth(3)).toHaveText('4:04 / km'); + await expect(page.getByRole('row').nth(3).getByRole('cell').nth(1)).toHaveText('20:00.00'); + await expect(page.getByRole('row').nth(3).getByRole('cell').nth(3)).toHaveText('3:37 / km'); + await expect(page.getByRole('row')).toHaveCount(4); + + // Return to unit calculator + await page.getByRole('link', { name: 'Back' }).click(); + await page.getByRole('button', { name: 'Unit Calculator' }).click(); + + // Assert result is correct (state not reset) + await expect(page.getByLabel('Output value')).toHaveText('00:09:39.366'); +}); diff --git a/tests/e2e/pace-calculator.spec.js b/tests/e2e/pace-calculator.spec.js @@ -0,0 +1,163 @@ +const { test, expect } = require('@playwright/test'); + +test('Basic usage', async ({ page }) => { + await page.goto('/'); + + // Go to pace calculator + await page.getByRole('button', { name: 'Pace Calculator' }).click(); + await expect(page).toHaveTitle('Pace Calculator - Running Tools'); + + // Enter input pace (2 mi in 15:30) + await page.getByLabel('Input distance value').fill('2'); + await page.getByLabel('Input distance unit').selectOption('Miles'); + await page.getByLabel('Input duration hours').fill('0'); + await page.getByLabel('Input duration minutes').fill('15'); + await page.getByLabel('Input duration seconds').fill('30'); + + // Assert paces are correct + await expect(page.getByRole('row').nth(11)).toHaveText('1 mi' + '7:45.00'); + await expect(page.getByRole('row').nth(13)).toHaveText('1.29 mi' + '10:00'); + await expect(page.getByRole('row')).toHaveCount(31); + + // Change default units + await page.getByText('Advanced Options').click(); + await page.getByLabel('Default units').selectOption('Kilometers'); + + // Assert paces are correct + await expect(page.getByRole('row').nth(11)).toHaveText('1 mi' + '7:45.00'); + await expect(page.getByRole('row').nth(13)).toHaveText('2.08 km' + '10:00'); + await expect(page.getByRole('row')).toHaveCount(31); +}); + +test('Customize target sets', async ({ page }) => { + // Go to pace calculator + await page.goto('/'); + await page.getByRole('button', { name: 'Pace Calculator' }).click(); + + // Enter input pace (2 mi in 15:30) + await page.getByLabel('Input distance value').fill('2'); + await page.getByLabel('Input distance unit').selectOption('Miles'); + await page.getByLabel('Input duration hours').fill('0'); + await page.getByLabel('Input duration minutes').fill('15'); + await page.getByLabel('Input duration seconds').fill('30'); + + // Edit default target set + await page.getByText('Advanced Options').click(); + await page.getByRole('button', { name: 'Edit target set' }).click(); + await page.getByLabel('Target set label').fill('Less-common Pace Targets'); + await page.getByLabel('Target distance value').nth(10).fill('1.01'); + await page.getByLabel('Target distance unit').nth(10).selectOption('Miles'); + await page.getByLabel('Target duration second').nth(0).fill('1'); + await page.getByRole('button', { name: 'Add distance target' }).click(); + await page.getByLabel('Target distance value').last().fill('1.5'); + await page.getByLabel('Target distance unit').last().selectOption('Miles'); + await page.getByRole('button', { name: 'Add time target' }).click(); + await page.getByLabel('Target duration minutes').last().fill('19'); + await page.getByLabel('Target duration seconds').last().fill('0'); + await page.getByRole('button', { name: 'Close' }).click(); + + // Assert paces are correct + await expect(page.getByRole('row').nth(11)).toHaveText('1.01 mi' + '7:49.65'); + await expect(page.getByRole('row').nth(13)).toHaveText('1.29 mi' + '10:01'); + await expect(page.getByRole('row').nth(14)).toHaveText('1.5 mi' + '11:37.50'); + await expect(page.getByRole('row').nth(18)).toHaveText('2.45 mi' + '19:00'); + await expect(page.getByRole('row')).toHaveCount(33); + + // Switch target set + await page.getByLabel('Selected target set').selectOption('5K Mile Splits'); + + // Assert paces are correct + await expect(page.getByRole('row').nth(1)).toHaveText('1 mi' + '7:45.00'); + await expect(page.getByRole('row').nth(2)).toHaveText('2 mi' + '15:30.00'); + await expect(page.getByRole('row').nth(3)).toHaveText('5 km' + '24:04.68'); + await expect(page.getByRole('row')).toHaveCount(4); + + // Create custom target set + await page.getByLabel('Selected target set').selectOption('[ Create New Target Set ]'); + await expect(page.getByRole('row').nth(1)).toHaveText('There aren\'t any targets in this set yet.'); + await expect(page.getByRole('row')).toHaveCount(2); + + // Edit new target set + await page.getByRole('button', { name: 'Edit target set' }).click(); + await expect(page.getByLabel('Target set label')).toHaveValue('New target set'); + await page.getByLabel('Target set label').fill('800m Splits'); + await page.getByRole('button', { name: 'Add distance target' }).click(); + await page.getByLabel('Target distance value').nth(0).fill('0.4'); + await page.getByLabel('Target distance unit').nth(0).selectOption('Kilometers'); + await page.getByRole('button', { name: 'Add distance target' }).click(); + await page.getByLabel('Target distance value').nth(1).fill('800'); + await page.getByLabel('Target distance unit').nth(1).selectOption('Meters'); + await page.getByRole('button', { name: 'Close' }).click(); + + // Assert paces are correct + await expect(page.getByRole('row').nth(1)).toHaveText('0.4 km' + '1:55.57'); + await expect(page.getByRole('row').nth(2)).toHaveText('800 m' + '3:51.15'); + await expect(page.getByRole('row')).toHaveCount(3); + + // Delete custom target set + await page.getByRole('button', { name: 'Edit target set' }).click(); + await expect(page.getByLabel('Target set label')).toHaveValue('800m Splits'); + await page.getByRole('button', { name: 'Delete target set' }).click(); + + // Assert paces are correct (back to default target set) + await expect(page.getByRole('row').nth(11)).toHaveText('1.01 mi' + '7:49.65'); + await expect(page.getByRole('row').nth(13)).toHaveText('1.29 mi' + '10:01'); + await expect(page.getByRole('row').nth(14)).toHaveText('1.5 mi' + '11:37.50'); + await expect(page.getByRole('row').nth(18)).toHaveText('2.45 mi' + '19:00'); + await expect(page.getByRole('row')).toHaveCount(33); + + // Revert target set + await page.getByRole('button', { name: 'Edit target set' }).click(); + await expect(page.getByLabel('Target set label')).toHaveValue('Less-common Pace Targets'); + await page.getByRole('button', { name: 'Revert target set' }).click(); + await page.getByRole('button', { name: 'Close' }).click(); + + // Assert paces are correct + await expect(page.getByRole('row').nth(11)).toHaveText('1 mi' + '7:45.00'); + await expect(page.getByRole('row').nth(13)).toHaveText('1.29 mi' + '10:00'); + await expect(page.getByRole('row')).toHaveCount(31); + + // Assert title was reset + await page.getByRole('button', { name: 'Edit target set' }).click(); + await expect(page.getByLabel('Target set label')).toHaveValue('Common Pace Targets'); +}); + +test('Save settings across page reloads', async ({ page }) => { + // Go to pace calculator + await page.goto('/'); + await page.getByRole('button', { name: 'Pace Calculator' }).click(); + + // Enter input pace (2 mi in 15:30) + await page.getByLabel('Input distance value').fill('2'); + await page.getByLabel('Input distance unit').selectOption('Miles'); + await page.getByLabel('Input duration hours').fill('0'); + await page.getByLabel('Input duration minutes').fill('15'); + await page.getByLabel('Input duration seconds').fill('30'); + + // Create custom target set + await page.getByText('Advanced Options').click(); + await page.getByLabel('Selected target set').selectOption('[ Create New Target Set ]'); + + // Edit new target set + await page.getByRole('button', { name: 'Edit target set' }).click(); + await expect(page.getByLabel('Target set label')).toHaveValue('New target set'); + await page.getByLabel('Target set label').fill('Less-common Pace Targets'); + await page.getByRole('button', { name: 'Add distance target' }).click(); + await page.getByLabel('Target distance value').last().fill('1.01'); + await page.getByLabel('Target distance unit').last().selectOption('Miles'); + await page.getByRole('button', { name: 'Add time target' }).click(); + await page.getByLabel('Target duration minutes').last().fill('19'); + await page.getByLabel('Target duration seconds').last().fill('0'); + await page.getByRole('button', { name: 'Close' }).click(); + + // Change default units + await page.getByLabel('Default units').selectOption('Kilometers'); + + // Reload page + await page.reload(); + + // Assert paces are correct (custom targets and default units not reset) + await expect(page.getByRole('row').nth(1)).toHaveText('1.01 mi' + '7:49.65'); + await expect(page.getByRole('row').nth(2)).toHaveText('3.95 km' + '19:00'); + await expect(page.getByRole('row')).toHaveCount(3); +}); diff --git a/tests/e2e/race-calculator.spec.js b/tests/e2e/race-calculator.spec.js @@ -0,0 +1,189 @@ +const { test, expect } = require('@playwright/test'); + +test('Basic usage', async ({ page }) => { + // Go to race calculator + await page.goto('/'); + await page.getByRole('button', { name: 'Race Calculator' }).click(); + await expect(page).toHaveTitle('Race Calculator - Running Tools'); + + // Enter input race (2 mi in 10:30) + await page.getByLabel('Input distance value').fill('2'); + await page.getByLabel('Input distance unit').selectOption('Miles'); + await page.getByLabel('Input race duration hours').fill('0'); + await page.getByLabel('Input race duration minutes').fill('10'); + await page.getByLabel('Input race duration seconds').fill('30'); + + // Assert race predictions are correct + await expect(page.getByRole('row').nth(5)).toHaveText('1 mi' + '4:55.53' + '4:56 / mi'); + await expect(page.getByRole('row').nth(10)).toHaveText('5 km' + '16:47.58' + '5:24 / mi'); + await expect(page.getByRole('row')).toHaveCount(17); + + // Assert race statistics are correct + await page.getByText('Race Statistics').click(); + await expect(page.getByText('Purdy Points:')).toContainText(': 680.1'); + await expect(page.getByText('V̇O₂:')).toContainText(': 61.0 ml/kg/min (100.5% of max)'); + await expect(page.getByText('V̇O₂ Max:')).toContainText(': 60.7 ml/kg/min'); + + // Change default units + await page.getByText('Advanced Options').click(); + await page.getByLabel('Default units').selectOption('Kilometers'); + + // Assert race predictions are correct + await expect(page.getByRole('row').nth(5)).toHaveText('1 mi' + '4:55.53' + '3:04 / km'); + await expect(page.getByRole('row').nth(10)).toHaveText('5 km' + '16:47.58' + '3:22 / km'); + await expect(page.getByRole('row')).toHaveCount(17); + + // Change prediction model + await page.getByLabel('Prediction model').selectOption('Riegel\'s Model'); + + // Assert race predictions are correct + await expect(page.getByRole('row').nth(5)).toHaveText('1 mi' + '5:02.17' + '3:08 / km'); + await expect(page.getByRole('row').nth(10)).toHaveText('5 km' + '16:44.86' + '3:21 / km'); + await expect(page.getByRole('row')).toHaveCount(17); + + // Change Riegel exponent + await page.getByLabel('Riegel Exponent').fill('1.12'); + + // Assert race predictions are correct + await expect(page.getByRole('row').nth(5)).toHaveText('1 mi' + '4:49.86' + '3:00 / km'); + await expect(page.getByRole('row').nth(10)).toHaveText('5 km' + '17:11.77' + '3:26 / km'); + await expect(page.getByRole('row')).toHaveCount(17); +}); + +test('Customize target sets', async ({ page }) => { + // Go to race calculator + await page.goto('/'); + await page.getByRole('button', { name: 'Race Calculator' }).click(); + + // Enter input race (2 mi in 10:30) + await page.getByLabel('Input distance value').fill('2'); + await page.getByLabel('Input distance unit').selectOption('Miles'); + await page.getByLabel('Input race duration hours').fill('0'); + await page.getByLabel('Input race duration minutes').fill('10'); + await page.getByLabel('Input race duration seconds').fill('30'); + + // Edit default target set + await page.getByText('Advanced Options').click(); + await page.getByRole('button', { name: 'Edit target set' }).click(); + await page.getByLabel('Target set label').fill('Less-common Race Targets'); + await page.getByLabel('Target distance value').nth(4).fill('1.01'); + await page.getByRole('button', { name: 'Add distance target' }).click(); + await page.getByLabel('Target distance value').last().fill('1.5'); + await page.getByLabel('Target distance unit').last().selectOption('Miles'); + await page.getByRole('button', { name: 'Add time target' }).click(); + await page.getByLabel('Target duration minutes').last().fill('19'); + await page.getByLabel('Target duration seconds').last().fill('0'); + await page.getByRole('button', { name: 'Close' }).click(); + + // Assert race predictions are correct + await expect(page.getByRole('row').nth(5)).toHaveText('1.01 mi' + '4:58.81' + '4:56 / mi'); + await expect(page.getByRole('row').nth(6)).toHaveText('1.5 mi' + '7:41.60' + '5:08 / mi'); + await expect(page.getByRole('row').nth(12)).toHaveText('3.49 mi' + '19:00' + '5:27 / mi'); + await expect(page.getByRole('row')).toHaveCount(19); + + // Switch target set + await page.getByLabel('Selected target set').selectOption('5K Mile Splits'); + + // Assert race predictions are correct + await expect(page.getByRole('row').nth(1)).toHaveText('1 mi' + '4:55.53' + '4:56 / mi'); + await expect(page.getByRole('row').nth(2)).toHaveText('2 mi' + '10:30.00' + '5:15 / mi'); + await expect(page.getByRole('row').nth(3)).toHaveText('5 km' + '16:47.58' + '5:24 / mi'); + await expect(page.getByRole('row')).toHaveCount(4); + + // Create custom target set + await page.getByLabel('Selected target set').selectOption('[ Create New Target Set ]'); + await expect(page.getByRole('row').nth(1)).toHaveText('There aren\'t any targets in this set yet.'); + await expect(page.getByRole('row')).toHaveCount(2); + + // Edit new target set + await page.getByRole('button', { name: 'Edit target set' }).click(); + await expect(page.getByLabel('Target set label')).toHaveValue('New target set'); + await page.getByLabel('Target set label').fill('XC Race Targets'); + await page.getByRole('button', { name: 'Add distance target' }).click(); + await page.getByLabel('Target distance value').nth(0).fill('5'); + await page.getByLabel('Target distance unit').nth(0).selectOption('Kilometers'); + await page.getByRole('button', { name: 'Add distance target' }).click(); + await page.getByLabel('Target distance value').nth(1).fill('10'); + await page.getByLabel('Target distance unit').nth(1).selectOption('Kilometers'); + await page.getByRole('button', { name: 'Close' }).click(); + + // Assert race predictions are correct + await expect(page.getByRole('row').nth(1)).toHaveText('5 km' + '16:47.58' + '5:24 / mi'); + await expect(page.getByRole('row').nth(2)).toHaveText('10 km' + '34:53.84' + '5:37 / mi'); + await expect(page.getByRole('row')).toHaveCount(3); + + // Delete custom target set + await page.getByRole('button', { name: 'Edit target set' }).click(); + await expect(page.getByLabel('Target set label')).toHaveValue('XC Race Targets'); + await page.getByRole('button', { name: 'Delete target set' }).click(); + + // Switch to default target set + await page.getByLabel('Selected target set').selectOption('Less-common Race Targets'); + + // Assert race predictions are correct (back to default target set) + await expect(page.getByRole('row').nth(5)).toHaveText('1.01 mi' + '4:58.81' + '4:56 / mi'); + await expect(page.getByRole('row').nth(6)).toHaveText('1.5 mi' + '7:41.60' + '5:08 / mi'); + await expect(page.getByRole('row').nth(12)).toHaveText('3.49 mi' + '19:00' + '5:27 / mi'); + await expect(page.getByRole('row')).toHaveCount(19); + + // Revert target set + await page.getByRole('button', { name: 'Edit target set' }).click(); + await expect(page.getByLabel('Target set label')).toHaveValue('Less-common Race Targets'); + await page.getByRole('button', { name: 'Revert target set' }).click(); + await page.getByRole('button', { name: 'Close' }).click(); + + // Assert paces are correct + await expect(page.getByRole('row').nth(5)).toHaveText('1 mi' + '4:55.53' + '4:56 / mi'); + await expect(page.getByRole('row').nth(10)).toHaveText('5 km' + '16:47.58' + '5:24 / mi'); + await expect(page.getByRole('row')).toHaveCount(17); + + // Assert title was reset + await page.getByRole('button', { name: 'Edit target set' }).click(); + await expect(page.getByLabel('Target set label')).toHaveValue('Common Race Targets'); +}); + +test('Save settings across page reloads', async ({ page }) => { + // Go to race calculator + await page.goto('/'); + await page.getByRole('button', { name: 'Race Calculator' }).click(); + + // Enter input race (2 mi in 10:30) + await page.getByLabel('Input distance value').fill('2'); + await page.getByLabel('Input distance unit').selectOption('Miles'); + await page.getByLabel('Input race duration hours').fill('0'); + await page.getByLabel('Input race duration minutes').fill('10'); + await page.getByLabel('Input race duration seconds').fill('30'); + + // Create custom target set + await page.getByText('Advanced Options').click(); + await page.getByLabel('Selected target set').selectOption('[ Create New Target Set ]'); + + // Edit new target set + await page.getByRole('button', { name: 'Edit target set' }).click(); + await expect(page.getByLabel('Target set label')).toHaveValue('New target set'); + await page.getByLabel('Target set label').fill('Less-common Race Targets'); + await page.getByRole('button', { name: 'Add distance target' }).click(); + await page.getByLabel('Target distance value').last().fill('1.01'); + await page.getByLabel('Target distance unit').last().selectOption('Miles'); + await page.getByRole('button', { name: 'Add time target' }).click(); + await page.getByLabel('Target duration minutes').last().fill('19'); + await page.getByLabel('Target duration seconds').last().fill('0'); + await page.getByRole('button', { name: 'Close' }).click(); + + // Change default units + await page.getByLabel('Default units').selectOption('Kilometers'); + + // Change prediction model + await page.getByLabel('Prediction model').selectOption('Riegel\'s Model'); + + // Change Riegel exponent + await page.getByLabel('Riegel Exponent').fill('1.12'); + + // Reload page + await page.reload(); + + // Assert race predictions are correct (custom targets, default units, and model settings not reset) + await expect(page.getByRole('row').nth(1)).toHaveText('1.01 mi' + '4:53.11' + '3:00 / km'); + await expect(page.getByRole('row').nth(2)).toHaveText('5.47 km' + '19:00' + '3:29 / km'); + await expect(page.getByRole('row')).toHaveCount(3); +}); diff --git a/tests/e2e/split-calculator.spec.js b/tests/e2e/split-calculator.spec.js @@ -0,0 +1,168 @@ +const { test, expect } = require('@playwright/test'); + +test('Basic usage', async ({ page }) => { + // Go to split calculator + await page.goto('/'); + await page.getByRole('button', { name: 'Split Calculator' }).click(); + await expect(page).toHaveTitle('Split Calculator - Running Tools'); + + // Enter input 5K splits (7:00, 6:30, 6:30) + await page.getByLabel('Split duration minutes').nth(0).fill('7'); + await page.getByLabel('Split duration seconds').nth(0).fill('0'); + await page.getByLabel('Split duration minutes').nth(1).fill('6'); + await page.getByLabel('Split duration seconds').nth(1).fill('30'); + await page.getByLabel('Split duration minutes').nth(2).fill('6'); + await page.getByLabel('Split duration seconds').nth(2).fill('30'); + + // Assert times and paces are correct + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(1)).toHaveText('7:00.00'); + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(3)).toHaveText('7:00 / mi'); + await expect(page.getByRole('row').nth(2).getByRole('cell').nth(1)).toHaveText('13:30.00'); + await expect(page.getByRole('row').nth(2).getByRole('cell').nth(3)).toHaveText('6:30 / mi'); + await expect(page.getByRole('row').nth(3).getByRole('cell').nth(1)).toHaveText('20:00.00'); + await expect(page.getByRole('row').nth(3).getByRole('cell').nth(3)).toHaveText('5:52 / mi'); + await expect(page.getByRole('row')).toHaveCount(4); + + // Change default units + await page.getByLabel('Default units').selectOption('Kilometers'); + + // Assert paces are correct + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(3)).toHaveText('4:21 / km'); + await expect(page.getByRole('row').nth(2).getByRole('cell').nth(3)).toHaveText('4:02 / km'); + await expect(page.getByRole('row').nth(3).getByRole('cell').nth(3)).toHaveText('3:39 / km'); +}); + +test('Customize target sets', async ({ page }) => { + // Go to split calculator + await page.goto('/'); + await page.getByRole('button', { name: 'Split Calculator' }).click(); + + // Enter input 5K splits (7:00, 6:30, 6:30) + await page.getByLabel('Split duration minutes').nth(0).fill('7'); + await page.getByLabel('Split duration seconds').nth(0).fill('0'); + await page.getByLabel('Split duration minutes').nth(1).fill('6'); + await page.getByLabel('Split duration seconds').nth(1).fill('30'); + await page.getByLabel('Split duration minutes').nth(2).fill('6'); + await page.getByLabel('Split duration seconds').nth(2).fill('30'); + + // Edit target set + await page.getByRole('button', { name: 'Edit target set' }).click(); + await page.getByLabel('Target set label').fill('5K 1600m Splits'); + await page.getByLabel('Target distance value').nth(0).fill('1.6'); + await page.getByLabel('Target distance unit').nth(0).selectOption('Kilometers'); + await page.getByLabel('Target distance value').nth(1).fill('3.2'); + await page.getByLabel('Target distance unit').nth(1).selectOption('Kilometers'); + await page.getByRole('button', { name: 'Add distance target' }).click(); + await page.getByLabel('Target distance value').nth(3).fill('4.8'); + await page.getByLabel('Target distance unit').nth(3).selectOption('Kilometers'); + await page.getByRole('button', { name: 'Add time target' }).click(); + await page.getByRole('button', { name: 'Close' }).click(); + + // Assert times and paces are correct (new distances are processed) + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(1)).toHaveText('7:00.00'); + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(3)).toHaveText('7:02 / mi'); + await expect(page.getByRole('row').nth(2).getByRole('cell').nth(1)).toHaveText('13:30.00'); + await expect(page.getByRole('row').nth(2).getByRole('cell').nth(3)).toHaveText('6:32 / mi'); + await expect(page.getByRole('row').nth(3).getByRole('cell').nth(1)).toHaveText('13:30.00'); + await expect(page.getByRole('row').nth(3).getByRole('cell').nth(3)).toHaveText('0:00 / mi'); + await expect(page.getByRole('row').nth(4).getByRole('cell').nth(1)).toHaveText('20:00.00'); + await expect(page.getByRole('row').nth(4).getByRole('cell').nth(3)).toHaveText('52:18 / mi'); + await expect(page.getByRole('row')).toHaveCount(5); + + // Update third and fourth splits + await page.getByLabel('Split duration minutes').nth(2).fill('6'); + await page.getByLabel('Split duration seconds').nth(2).fill('0'); + await page.getByLabel('Split duration minutes').nth(3).fill('0'); + await page.getByLabel('Split duration seconds').nth(3).fill('30'); + await page.getByLabel('Split duration seconds').nth(3).blur(); + + // Assert times and paces are correct (new input splits are processed) + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(1)).toHaveText('7:00.00'); + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(3)).toHaveText('7:02 / mi'); + await expect(page.getByRole('row').nth(2).getByRole('cell').nth(1)).toHaveText('13:30.00'); + await expect(page.getByRole('row').nth(2).getByRole('cell').nth(3)).toHaveText('6:32 / mi'); + await expect(page.getByRole('row').nth(3).getByRole('cell').nth(1)).toHaveText('19:30.00'); + await expect(page.getByRole('row').nth(3).getByRole('cell').nth(3)).toHaveText('6:02 / mi'); + await expect(page.getByRole('row').nth(4).getByRole('cell').nth(1)).toHaveText('20:00.00'); + await expect(page.getByRole('row').nth(4).getByRole('cell').nth(3)).toHaveText('4:01 / mi'); + await expect(page.getByRole('row')).toHaveCount(5); + + // Create custom target set + await page.getByLabel('Selected target set').selectOption('[ Create New Target Set ]'); + await expect(page.getByRole('row').nth(1)).toHaveText('There aren\'t any targets in this set yet.'); + await expect(page.getByRole('row')).toHaveCount(2); + + // Edit custom target set + await page.getByRole('button', { name: 'Edit target set' }).click(); + await expect(page.getByLabel('Target set label')).toHaveValue('New target set'); + await page.getByLabel('Target set label').fill('800m Splits'); + await page.getByRole('button', { name: 'Add distance target' }).click(); + await page.getByLabel('Target distance value').nth(0).fill('0.4'); + await page.getByLabel('Target distance unit').nth(0).selectOption('Kilometers'); + await page.getByRole('button', { name: 'Add distance target' }).click(); + await page.getByLabel('Target distance value').nth(1).fill('800'); + await page.getByLabel('Target distance unit').nth(1).selectOption('Meters'); + await page.getByRole('button', { name: 'Add time target' }).click(); + await page.getByRole('button', { name: 'Close' }).click(); + + // Assert times and paces are correct (input splits initialized to zero) + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(1)).toHaveText('0:00.00'); + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(3)).toHaveText('0:00 / mi'); + await expect(page.getByRole('row').nth(2).getByRole('cell').nth(1)).toHaveText('0:00.00'); + await expect(page.getByRole('row').nth(2).getByRole('cell').nth(3)).toHaveText('0:00 / mi'); + await expect(page.getByRole('row')).toHaveCount(3); + + // Switch target set + await page.getByLabel('Selected target set').selectOption('5K 1600m Splits'); + + // Assert times and paces are correct (input splits are not reset) + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(1)).toHaveText('7:00.00'); + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(3)).toHaveText('7:02 / mi'); + await expect(page.getByRole('row').nth(2).getByRole('cell').nth(1)).toHaveText('13:30.00'); + await expect(page.getByRole('row').nth(2).getByRole('cell').nth(3)).toHaveText('6:32 / mi'); + await expect(page.getByRole('row').nth(3).getByRole('cell').nth(1)).toHaveText('19:30.00'); + await expect(page.getByRole('row').nth(3).getByRole('cell').nth(3)).toHaveText('6:02 / mi'); + await expect(page.getByRole('row').nth(4).getByRole('cell').nth(1)).toHaveText('20:00.00'); + await expect(page.getByRole('row').nth(4).getByRole('cell').nth(3)).toHaveText('4:01 / mi'); + await expect(page.getByRole('row')).toHaveCount(5); +}); + +test('Save settings and state across page reloads', async ({ page }) => { + // Go to split calculator + await page.goto('/'); + await page.getByRole('button', { name: 'Split Calculator' }).click(); + + // Create custom target set + await page.getByLabel('Selected target set').selectOption('[ Create New Target Set ]'); + + // Edit new target set + await page.getByRole('button', { name: 'Edit target set' }).click(); + await expect(page.getByLabel('Target set label')).toHaveValue('New target set'); + await page.getByLabel('Target set label').fill('800m Splits'); + await page.getByRole('button', { name: 'Add distance target' }).click(); + await page.getByLabel('Target distance value').nth(0).fill('0.4'); + await page.getByLabel('Target distance unit').nth(0).selectOption('Kilometers'); + await page.getByRole('button', { name: 'Add distance target' }).click(); + await page.getByLabel('Target distance value').nth(1).fill('800'); + await page.getByLabel('Target distance unit').nth(1).selectOption('Meters'); + await page.getByRole('button', { name: 'Close' }).click(); + + // Enter 800m (0:55, 1:05) + await page.getByLabel('Split duration minutes').nth(0).fill('0'); + await page.getByLabel('Split duration seconds').nth(0).fill('55'); + await page.getByLabel('Split duration minutes').nth(1).fill('1'); + await page.getByLabel('Split duration seconds').nth(1).fill('5'); + + // Change default units + await page.getByLabel('Default units').selectOption('Kilometers'); + + // Reload page + await page.reload(); + + // Assert paces are correct (custom targets, split times, and default units not reset) + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(1)).toHaveText('0:55.00'); + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(3)).toHaveText('2:18 / km'); + await expect(page.getByRole('row').nth(2).getByRole('cell').nth(1)).toHaveText('2:00.00'); + await expect(page.getByRole('row').nth(2).getByRole('cell').nth(3)).toHaveText('2:43 / km'); + await expect(page.getByRole('row')).toHaveCount(3); +}); diff --git a/tests/e2e/unit-calculator.spec.js b/tests/e2e/unit-calculator.spec.js @@ -0,0 +1,91 @@ +const { test, expect } = require('@playwright/test'); + +test('Basic usage', async ({ page }) => { + // Go to unit calculator + await page.goto('/'); + await page.getByRole('button', { name: 'Unit Calculator' }).click(); + await expect(page).toHaveTitle('Unit Calculator - Running Tools'); + + // Convert distance units (5000m to mi) + await page.getByLabel('Input units').selectOption('Meters'); + await page.getByLabel('Input value').fill('5000'); + await page.getByLabel('Output units').selectOption('Miles'); + await expect(page.getByLabel('Output value')).toHaveText('3.107'); + + // Convert speed and pace units (0:04:32/km to mph) + await page.getByLabel('Selected unit category').selectOption('Speed & Pace'); + await page.getByLabel('Input units').selectOption('Time per Kilometer'); + await page.getByLabel('Input time hours').fill('0'); + await page.getByLabel('Input time minutes').fill('4'); + await page.getByLabel('Input time seconds').fill('32'); + await page.getByLabel('Output units').selectOption('Miles per Hour'); + await expect(page.getByLabel('Output value')).toHaveText('8.224'); + + // Convert speed and pace units (10 kph to time per mile) + await page.getByLabel('Input units').selectOption('Kilometers per Hour'); + await page.getByLabel('Input value').fill('10'); + await page.getByLabel('Output units').selectOption('Time per Mile'); + await expect(page.getByLabel('Output value')).toHaveText('00:09:39.366'); + + // Convert time units (83.76 min to hh:mm:ss) + await page.getByLabel('Selected unit category').selectOption('Time'); + await page.getByLabel('Input units').selectOption('Minutes'); + await page.getByLabel('Input value').fill('83.76'); + await page.getByLabel('Output units').selectOption('hh:mm:ss'); + await expect(page.getByLabel('Output value')).toHaveText('01:23:45.600'); + + // Convert time units (6:54:32.100 to seconds) + await page.getByLabel('Selected unit category').selectOption('Time'); + await page.getByLabel('Input units').selectOption('hh:mm:ss'); + await page.getByLabel('Input time hours').fill('6'); + await page.getByLabel('Input time minutes').fill('54'); + await page.getByLabel('Input time seconds').fill('32.1'); + await page.getByLabel('Output units').selectOption('seconds'); + await expect(page.getByLabel('Output value')).toHaveText('24872.100'); + + // Return to speed and pace category + await page.getByLabel('Selected unit category').selectOption('Speed & Pace'); + await expect(page.getByLabel('Output value')).toHaveText('00:09:39.366'); +}); + +test('Save state across page reloads', async ({ page }) => { + // Go to unit calculator + await page.goto('/'); + await page.getByRole('button', { name: 'Unit Calculator' }).click(); + + // Convert distance units (5000m to mi) + await page.getByLabel('Input units').selectOption('Meters'); + await page.getByLabel('Input value').fill('5000'); + await page.getByLabel('Output units').selectOption('Miles'); + await expect(page.getByLabel('Output value')).toHaveText('3.107'); + + // Convert time units (6:54:32.100 to seconds) + await page.getByLabel('Selected unit category').selectOption('Time'); + await page.getByLabel('Input units').selectOption('hh:mm:ss'); + await page.getByLabel('Input time hours').fill('6'); + await page.getByLabel('Input time minutes').fill('54'); + await page.getByLabel('Input time seconds').fill('32.1'); + await page.getByLabel('Output units').selectOption('seconds'); + await expect(page.getByLabel('Output value')).toHaveText('24872.100'); + + // Convert speed and pace units (10 kph to time per mile) + await page.getByLabel('Selected unit category').selectOption('Speed & Pace'); + await page.getByLabel('Input units').selectOption('Kilometers per Hour'); + await page.getByLabel('Input value').fill('10'); + await page.getByLabel('Output units').selectOption('Time per Mile'); + await expect(page.getByLabel('Output value')).toHaveText('00:09:39.366'); + + // Reload page + await page.reload(); + + // Assert distance result is correct (state not reset) + await expect(page.getByLabel('Output value')).toHaveText('3.107'); + + // Assert time result is correct (state not reset) + await page.getByLabel('Selected unit category').selectOption('Time'); + await expect(page.getByLabel('Output value')).toHaveText('24872.100'); + + // Assert speed & pace result is correct (state not reset) + await page.getByLabel('Selected unit category').selectOption('Speed & Pace'); + await expect(page.getByLabel('Output value')).toHaveText('00:09:39.366'); +}); diff --git a/vite.config.js b/vite.config.js @@ -59,5 +59,6 @@ export default defineConfig({ base: process.env.BASE_URL ? process.env.BASE_URL : '/', test: { environment: 'jsdom', + include: ['tests/unit/**/*.{test,spec}.?(c|m)[jt]s?(x)'], }, });